package daemon

// Callback delivery. Each matching event is one POST to the callback URL,
// signed with the callback's secret. A failed POST is tried again after one
// minute and then after five; after 20 failures in a row the callback is
// paused until its owner resumes it.

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"crypto/tls"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strings"
	"sync"
	"time"

	"github.com/FelineStateMachine/tinyrelay/internal/event"
	"github.com/FelineStateMachine/tinyrelay/internal/relay"
	"github.com/FelineStateMachine/tinyrelay/internal/replication"
	"github.com/FelineStateMachine/tinyrelay/internal/work"
)

// callbackPayload carries an event and its delivery attempt. Stored events
// are reloaded before delivery; ephemeral events use this signed payload.
type callbackPayload struct {
	Event   event.Event `json:"event"`
	Attempt int         `json:"attempt"`
}

// callbackSignature is the X-Tiny-Signature value for a body: sha256= and
// the hex HMAC-SHA256 of the body under the callback secret.
func callbackSignature(secret string, body []byte) string {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(body)
	return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}

// callbackHTTPClient refuses private addresses and redirects unless the
// relay itself runs on a loopback address.
func (s *callbackService) callbackHTTPClient() *http.Client {
	if s.callbackClient != nil {
		return s.callbackClient
	}
	if loopbackPublicURL(s.publicURL) {
		return &http.Client{Timeout: callbackTimeout, Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
	}
	return replication.NewPinnedClient(callbackTimeout)
}

// callbackLock bounds delivery to one POST per callback at a time.
func (s *callbackService) callbackLock(id string) *sync.Mutex {
	s.callbackMu.Lock()
	defer s.callbackMu.Unlock()
	if s.callbackLocks == nil {
		s.callbackLocks = map[string]*sync.Mutex{}
	}
	lock := s.callbackLocks[id]
	if lock == nil {
		lock = &sync.Mutex{}
		s.callbackLocks[id] = lock
	}
	return lock
}

// handleCallbackDelivery serves one callback-delivery intent. It rechecks
// the callback and the key's access right before the POST, so a callback
// removed or paused after the event arrived delivers nothing.
func (s *callbackService) handleCallbackDelivery(ctx context.Context, intent work.Intent) (err error) {
	ctx, finish := s.telemetry.Start(ctx, "callback")
	outcome := "error"
	defer func() { finish(outcome) }()
	var payload callbackPayload
	if err := json.Unmarshal([]byte(intent.Payload), &payload); err != nil || payload.Event.ID == "" {
		outcome = "invalid"
		return errors.New("callback-delivery: invalid payload")
	}
	if payload.Attempt < 1 {
		payload.Attempt = 1
	}
	if err := event.Validate(payload.Event); err != nil {
		outcome = "invalid"
		return errors.New("callback-delivery: invalid event")
	}
	lock := s.callbackLock(intent.Target)
	lock.Lock()
	defer lock.Unlock()
	record, err := s.callback(ctx, intent.Target)
	if err != nil {
		if strings.HasPrefix(err.Error(), "not found:") {
			outcome = "paused"
			return nil
		}
		return err
	}
	if record.Paused {
		outcome = "paused"
		return nil
	}
	source := payload.Event
	if !event.IsEphemeral(payload.Event.Kind) {
		var raw string
		err := s.store.DB().QueryRowContext(ctx, `SELECT raw FROM events WHERE id=? AND (expires=0 OR expires>?)`, payload.Event.ID, time.Now().Unix()).Scan(&raw)
		if errors.Is(err, sql.ErrNoRows) {
			outcome = "unauthorized"
			return nil
		}
		if err != nil {
			return err
		}
		parsed, err := event.Parse([]byte(raw))
		if err != nil {
			return err
		}
		source = parsed
	}
	role, err := s.community.Role(ctx, record.Owner)
	if err != nil {
		return err
	}
	if role == "" && record.Owner != s.policy().Owner {
		// The key lost its standing since it registered; stop until a
		// person resumes the callback.
		outcome = "unauthorized"
		return s.pauseCallback(ctx, record.ID, record.Failures, "paused: the key is no longer a member")
	}
	if role == "agent" {
		grant, ok, err := s.community.AgentGrant(ctx, record.Owner)
		if err != nil {
			return err
		}
		if !ok || !grant.Active(time.Now().Unix()) {
			outcome = "unauthorized"
			return s.pauseCallback(ctx, record.ID, record.Failures, "paused: the agent grant is no longer active")
		}
		if err := s.checkCallbackScope(ctx, record.Owner, record.Filter); err != nil {
			outcome = "unauthorized"
			return s.pauseCallback(ctx, record.ID, record.Failures, "paused: the agent grant no longer covers this callback")
		}
	}
	if !s.gate.CanSee(ctx, source, relay.Session{PubKeys: []string{record.Owner}, RelayURL: s.relayURL}, nil) {
		outcome = "unauthorized"
		return nil
	}
	status, postErr := s.postCallback(ctx, record, source)
	now := time.Now().Unix()
	if postErr == nil {
		outcome = "ok"
		_, err = s.store.DB().ExecContext(ctx, `UPDATE callbacks SET last_delivery_at=?, last_status='ok', failures=0 WHERE id=?`, now, record.ID)
		s.telemetry.Logger().Info("callback delivered", "tenant", s.tenant.Name, "attempt", payload.Attempt, "status", status)
		return err
	}
	// Counts only: the reason names the HTTP status or the failure class,
	// never the URL.
	reason := postErr.Error()
	failures := record.Failures + 1
	paused := failures >= callbackPauseFailures
	if paused {
		outcome = "paused"
		if err := s.pauseCallback(ctx, record.ID, failures, fmt.Sprintf("paused after %d failures: %s", failures, reason)); err != nil {
			return err
		}
	} else if _, err := s.store.DB().ExecContext(ctx, `UPDATE callbacks SET failures=?, last_status=? WHERE id=?`, failures, reason, record.ID); err != nil {
		return err
	}
	s.telemetry.Logger().Info("callback delivery failed", "tenant", s.tenant.Name, "attempt", payload.Attempt, "status", status, "failures", failures, "paused", paused)
	if paused || payload.Attempt >= callbackMaxAttempts {
		return nil
	}
	return s.retryCallback(ctx, intent, payload)
}

// pauseCallback stops deliveries and records the count and reason, then
// drops the index so no new intents are queued for the callback.
func (s *callbackService) pauseCallback(ctx context.Context, id string, failures int, status string) error {
	if _, err := s.store.DB().ExecContext(ctx, `UPDATE callbacks SET paused=1, failures=?, last_status=? WHERE id=?`, failures, status, id); err != nil {
		return err
	}
	s.invalidateCallbacks()
	return nil
}

// retryCallback queues the next attempt after its backoff. Each attempt is
// its own intent so the durable queue's ordering and fencing still apply.
func (s *callbackService) retryCallback(ctx context.Context, intent work.Intent, payload callbackPayload) error {
	delay := callbackBackoff[len(callbackBackoff)-1]
	if payload.Attempt-1 < len(callbackBackoff) {
		delay = callbackBackoff[payload.Attempt-1]
	}
	payload.Attempt++
	encoded, err := json.Marshal(payload)
	if err != nil {
		return err
	}
	now := time.Now().Unix()
	key := sha256.Sum256([]byte(callbackDelivery + "\x00" + payload.Event.ID + "\x00" + intent.Target + "\x00" + fmt.Sprint(payload.Attempt)))
	return s.store.WithTx(ctx, func(tx *sql.Tx) error {
		_, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, hex.EncodeToString(key[:]), callbackDelivery, payload.Event.ID+"#"+fmt.Sprint(payload.Attempt), intent.Target, string(encoded), now+int64(delay/time.Second), now, now)
		return err
	})
}

// postCallback sends the event and returns the HTTP status with a bounded
// reason on failure.
func (s *callbackService) postCallback(ctx context.Context, record callbackRecord, e event.Event) (int, error) {
	body, err := json.Marshal(e)
	if err != nil {
		return 0, errors.New("encode event")
	}
	ctx, cancel := context.WithTimeout(ctx, callbackTimeout)
	defer cancel()
	request, err := http.NewRequestWithContext(ctx, http.MethodPost, record.URL, strings.NewReader(string(body)))
	if err != nil {
		return 0, errors.New("bad url")
	}
	request.Header.Set("Content-Type", "application/json")
	request.Header.Set("X-Tiny-Callback", record.ID)
	request.Header.Set("X-Tiny-Signature", callbackSignature(record.secret, body))
	request.Header.Set("X-Tiny-Relay", s.publicURL)
	request.Header.Set("User-Agent", "tinyrelay")
	response, err := s.callbackHTTPClient().Do(request)
	if err != nil {
		if errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil {
			return 0, errors.New("timeout")
		}
		return 0, errors.New("connection failed")
	}
	defer response.Body.Close()
	_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
	if response.StatusCode < 200 || response.StatusCode >= 300 {
		return response.StatusCode, fmt.Errorf("HTTP %d", response.StatusCode)
	}
	return response.StatusCode, nil
}

func (t *Tenant) handleCallbackDelivery(ctx context.Context, intent work.Intent) error {
	return t.callbacks.handleCallbackDelivery(ctx, intent)
}
