package controlplane

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"os"
	"sort"
	"strings"
	"sync"
	"time"

	"git.toki-labs.com/toki/gito/services/core/internal/core"
	"git.toki-labs.com/toki/gito/services/core/internal/events"
	"git.toki-labs.com/toki/gito/services/core/internal/gitengine"
	"git.toki-labs.com/toki/gito/services/core/internal/protosocket"
	"git.toki-labs.com/toki/gito/services/core/internal/provider"
	"git.toki-labs.com/toki/gito/services/core/internal/storage"
	"git.toki-labs.com/toki/gito/services/core/internal/worker"
)

type EventBroadcaster interface {
	BroadcastEnvelope(ctx context.Context, env protosocket.Envelope) error
}

// WebhookSecretResolver is used to resolve a secret reference to its actual value.
type WebhookSecretResolver interface {
	ResolveWebhookSecret(ctx context.Context, secretRef string) (string, bool, error)
}

// EnvWebhookSecretResolver resolves subscription secret refs of the form
// `env:VAR_NAME` from the process environment.
type EnvWebhookSecretResolver struct{}

func (EnvWebhookSecretResolver) ResolveWebhookSecret(_ context.Context, secretRef string) (string, bool, error) {
	secretRef = strings.TrimSpace(secretRef)
	if !strings.HasPrefix(secretRef, "env:") {
		return "", false, nil
	}
	name := strings.TrimSpace(strings.TrimPrefix(secretRef, "env:"))
	if name == "" {
		return "", false, fmt.Errorf("env secret ref missing variable name")
	}
	if strings.ContainsAny(name, " \t\r\n=") {
		return "", false, fmt.Errorf("invalid env secret ref %q", secretRef)
	}
	secret, ok := os.LookupEnv(name)
	if !ok || secret == "" {
		return "", false, nil
	}
	return secret, true, nil
}

type Runtime struct {
	mu             sync.Mutex
	watches        map[string]BranchWatch
	subscriptions  map[string]WebhookSubscription
	records        []EventRecord
	broadcaster    EventBroadcaster
	store          storage.Store
	webhookClient  *http.Client
	secretResolver WebhookSecretResolver
}

type BranchWatch struct {
	ID        string    `json:"id"`
	RepoID    string    `json:"repo_id"`
	Branch    string    `json:"branch"`
	Provider  string    `json:"provider"`
	CreatedAt time.Time `json:"created_at"`
}

type EventRecord struct {
	ID         string                 `json:"id"`
	Type       string                 `json:"type"`
	Provider   string                 `json:"provider"`
	DeliveryID string                 `json:"delivery_id,omitempty"`
	Duplicate  bool                   `json:"duplicate,omitempty"`
	Revision   core.RevisionEvent     `json:"revision"`
	Webhook    *ProviderWebhookRecord `json:"webhook,omitempty"`
	CreatedAt  time.Time              `json:"created_at"`
}

type ProviderWebhookRecord struct {
	EventType   string
	ExternalID  string
	PayloadSize int
	ReceivedAt  time.Time
}

type ProviderPollRevision struct {
	Provider     string
	RepoID       string
	Branch       string
	Before       string
	After        string
	ChangedFiles []core.ChangedFile
	ObservedAt   time.Time
}

type ReconcileStatus string

const (
	ReconcileStatusNoop      ReconcileStatus = "noop"
	ReconcileStatusConflict  ReconcileStatus = "conflict"
	ReconcileStatusProposal  ReconcileStatus = "proposal"
	ReconcileStatusApplied   ReconcileStatus = "applied"
	ReconcileStatusDuplicate ReconcileStatus = "duplicate"
)

type ReconcileRequest struct {
	Provider         string
	RepoID           string
	Branch           string
	ExpectedRevision string
	CurrentRevision  string
	TargetRevision   string
	ChangedFiles     []core.ChangedFile
	IdempotencyKey   string
	ObservedAt       time.Time
}

type ReconcileProposal struct {
	Before  string             `json:"before"`
	After   string             `json:"after"`
	Changed []core.ChangedFile `json:"changed,omitempty"`
}

type ReconcileResult struct {
	Status         ReconcileStatus    `json:"status"`
	Conflict       bool               `json:"conflict,omitempty"`
	Applied        bool               `json:"applied,omitempty"`
	Duplicate      bool               `json:"duplicate,omitempty"`
	Drift          bool               `json:"drift,omitempty"`
	Reason         string             `json:"reason,omitempty"`
	Proposal       *ReconcileProposal `json:"proposal,omitempty"`
	IdempotencyKey string             `json:"idempotency_key,omitempty"`
}

type WebhookSubscription struct {
	ID        string
	Name      string
	TargetURL string
	Events    []string
	RepoID    string
	Branch    string
	SecretRef string
	CreatedAt time.Time
}

func NewRuntime(broadcaster EventBroadcaster) *Runtime {
	return &Runtime{
		watches:       make(map[string]BranchWatch),
		subscriptions: make(map[string]WebhookSubscription),
		broadcaster:   broadcaster,
		webhookClient: &http.Client{
			Timeout: 10 * time.Second,
		},
	}
}

// NewRuntimeWithStore constructs a Runtime backed by a durable store.
// When store is nil the behavior is identical to NewRuntime.
func NewRuntimeWithStore(broadcaster EventBroadcaster, store storage.Store) *Runtime {
	return &Runtime{
		watches:       make(map[string]BranchWatch),
		subscriptions: make(map[string]WebhookSubscription),
		broadcaster:   broadcaster,
		store:         store,
		webhookClient: &http.Client{
			Timeout: 10 * time.Second,
		},
	}
}

// WithWebhookClient sets a custom HTTP client for webhook delivery.
func (r *Runtime) WithWebhookClient(client *http.Client) {
	r.webhookClient = client
}

// WithSecretResolver sets a custom secret resolver for webhook signatures.
func (r *Runtime) WithSecretResolver(resolver WebhookSecretResolver) {
	r.secretResolver = resolver
}

// matchingWebhookSubscriptions returns subscriptions that match the given event record.
func (r *Runtime) matchingWebhookSubscriptions(record EventRecord) []WebhookSubscription {
	r.mu.Lock()
	defer r.mu.Unlock()

	var matched []WebhookSubscription
	for _, sub := range r.subscriptions {
		// Check event type match
		eventMatched := false
		for _, event := range sub.Events {
			if event == record.Type {
				eventMatched = true
				break
			}
		}
		if !eventMatched {
			continue
		}

		// Check repo_id match: empty means match all
		if sub.RepoID != "" && sub.RepoID != record.Revision.RepoID {
			continue
		}

		// Check branch match: empty means match all
		if sub.Branch != "" && sub.Branch != record.Revision.Branch {
			continue
		}

		matched = append(matched, sub)
	}
	return matched
}

// buildWebhookPayload builds the HTTP webhook payload from an EventRecord.
// The payload contains only base fields required by any consumer (including NomadCode).
func (r *Runtime) buildWebhookPayload(record EventRecord) (map[string]any, error) {
	changedFiles := make([]any, 0, len(record.Revision.ChangedFiles))
	for _, file := range record.Revision.ChangedFiles {
		changedFiles = append(changedFiles, map[string]any{
			"path":        file.Path,
			"change_type": file.ChangeType,
		})
	}

	payload := map[string]any{
		"id":            record.ID,
		"type":          record.Type,
		"provider":      record.Provider,
		"delivery_id":   consumerDeliveryID(record),
		"repo_id":       record.Revision.RepoID,
		"branch":        record.Revision.Branch,
		"before":        record.Revision.Before,
		"after":         record.Revision.After,
		"changed_files": changedFiles,
		"observed_at":   record.Revision.ObservedAt.UTC().Format(time.RFC3339Nano),
		"created_at":    record.CreatedAt.UTC().Format(time.RFC3339Nano),
	}
	return payload, nil
}

// signPayload computes HMAC-SHA256 signature over the payload JSON body.
// It returns the hex-encoded HMAC digest.
func signPayload(secret string, body []byte) string {
	hmacSig := hmac.New(sha256.New, []byte(secret))
	hmacSig.Write(body)
	return hex.EncodeToString(hmacSig.Sum(nil))
}

// deliverWebhooks sends HTTP POST to matching webhook subscriptions.
func (r *Runtime) deliverWebhooks(ctx context.Context, record EventRecord) error {
	subscriptions := r.matchingWebhookSubscriptions(record)
	if len(subscriptions) == 0 {
		return nil
	}

	for _, sub := range subscriptions {
		if r.store != nil && r.store.WebhookDeliveries() != nil {
			delivery := core.WebhookDelivery{
				ID:             newID(),
				EventID:        record.ID,
				SubscriptionID: sub.ID,
				Status:         core.WebhookDeliveryPending,
				NextAttemptAt:  time.Now().UTC(),
			}
			res, err := r.store.WebhookDeliveries().EnqueueDelivery(ctx, delivery)
			if err != nil {
				return fmt.Errorf("enqueue delivery: %w", err)
			}
			if res.Created {
				started, ok, err := r.store.WebhookDeliveries().StartDelivery(ctx, res.Delivery.ID, time.Now().UTC())
				if err != nil {
					return fmt.Errorf("start delivery: %w", err)
				}
				if ok {
					if err := r.dispatchAndRecordOutcome(ctx, started, sub, record); err != nil {
						return err
					}
				}
			}
		} else {
			if err := r.postWebhook(ctx, sub, record); err != nil {
				return fmt.Errorf("webhook delivery to %q: %w", sub.Name, err)
			}
		}
	}
	return nil
}

// postWebhook sends a single HTTP POST to the subscription target URL.
func (r *Runtime) postWebhook(ctx context.Context, sub WebhookSubscription, record EventRecord) error {
	resp, err := r.executeWebhookRequest(ctx, sub, record)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
	}

	return nil
}

func (r *Runtime) executeWebhookRequest(ctx context.Context, sub WebhookSubscription, record EventRecord) (*http.Response, error) {
	payload, err := r.buildWebhookPayload(record)
	if err != nil {
		return nil, fmt.Errorf("build payload: %w", err)
	}

	bodyBytes, err := json.Marshal(payload)
	if err != nil {
		return nil, fmt.Errorf("marshal payload: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, sub.TargetURL, bytes.NewReader(bodyBytes))
	if err != nil {
		return nil, fmt.Errorf("create request: %w", err)
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Gito-Event", record.Type)
	req.Header.Set("X-Gito-Delivery", consumerDeliveryID(record))

	if sub.SecretRef != "" {
		if r.secretResolver == nil {
			return nil, fmt.Errorf("secret_ref %q is configured but no secret resolver is available", sub.SecretRef)
		}
		secret, resolved, err := r.secretResolver.ResolveWebhookSecret(ctx, sub.SecretRef)
		if err != nil {
			return nil, fmt.Errorf("resolve secret for %q: %w", sub.SecretRef, err)
		}
		if !resolved {
			return nil, fmt.Errorf("secret_ref %q is unresolved", sub.SecretRef)
		}
		signature := signPayload(secret, bodyBytes)
		req.Header.Set("X-Gito-Signature", "sha256="+signature)
	}

	return r.webhookClient.Do(req)
}

func (r *Runtime) dispatchAndRecordOutcome(ctx context.Context, d core.WebhookDelivery, sub WebhookSubscription, record EventRecord) error {
	resp, err := r.executeWebhookRequest(ctx, sub, record)
	if resp != nil {
		defer resp.Body.Close()
	}

	outcome := classifyWebhookDelivery(resp, err, d.AttemptCount, time.Now().UTC())

	updated, recErr := r.store.WebhookDeliveries().RecordOutcome(
		ctx,
		d.ID,
		outcome.status,
		outcome.statusCode,
		outcome.lastErr,
		outcome.nextAttemptAt,
		time.Now().UTC(),
	)
	if recErr != nil {
		return fmt.Errorf("record webhook delivery outcome %s: %w", d.ID, recErr)
	}
	_ = updated
	return nil
}

func (r *Runtime) ProcessPendingWebhooks(ctx context.Context) error {
	if r.store == nil || r.store.WebhookDeliveries() == nil {
		return nil
	}

	picked, err := r.store.WebhookDeliveries().PickPendingDeliveries(ctx, 10, time.Now().UTC())
	if err != nil {
		return fmt.Errorf("pick pending: %w", err)
	}

	for _, d := range picked {
		var targetSub WebhookSubscription
		r.mu.Lock()
		sub, ok := r.subscriptions[d.SubscriptionID]
		r.mu.Unlock()
		if !ok {
			_, recErr := r.store.WebhookDeliveries().RecordOutcome(ctx, d.ID, core.WebhookDeliveryFailed, 0, "subscription not found", time.Time{}, time.Now().UTC())
			if recErr != nil {
				return fmt.Errorf("record outcome for missing subscription %s: %w", d.ID, recErr)
			}
			continue
		}
		targetSub = sub

		var targetRecord EventRecord
		found := false
		r.mu.Lock()
		for _, rec := range r.records {
			if rec.ID == d.EventID {
				targetRecord = rec
				found = true
				break
			}
		}
		r.mu.Unlock()

		if !found {
			_, recErr := r.store.WebhookDeliveries().RecordOutcome(ctx, d.ID, core.WebhookDeliveryFailed, 0, "event record not found", time.Time{}, time.Now().UTC())
			if recErr != nil {
				return fmt.Errorf("record outcome for missing event %s: %w", d.ID, recErr)
			}
			continue
		}

		if err := r.dispatchAndRecordOutcome(ctx, d, targetSub, targetRecord); err != nil {
			return err
		}
	}
	return nil
}

type webhookDeliveryOutcome struct {
	status        core.WebhookDeliveryStatus
	lastErr       string
	statusCode    int
	nextAttemptAt time.Time
}

func classifyWebhookDelivery(resp *http.Response, err error, attempt int, now time.Time) webhookDeliveryOutcome {
	if err != nil {
		return retryableOutcome(now, attempt, 0, err.Error())
	}

	sc := resp.StatusCode
	if sc >= 200 && sc < 300 {
		return succeededOutcome(sc)
	}

	if sc == http.StatusTooManyRequests || sc >= 500 {
		return retryableOutcome(now, attempt, sc, fmt.Sprintf("unexpected status code: %d", sc))
	}

	return failedOutcome(sc, fmt.Sprintf("terminal status code: %d", sc))
}

func succeededOutcome(sc int) webhookDeliveryOutcome {
	return webhookDeliveryOutcome{
		status:     core.WebhookDeliverySucceeded,
		statusCode: sc,
	}
}

func failedOutcome(sc int, errStr string) webhookDeliveryOutcome {
	return webhookDeliveryOutcome{
		status:     core.WebhookDeliveryFailed,
		statusCode: sc,
		lastErr:    errStr,
	}
}

func retryableOutcome(now time.Time, attempt int, sc int, errStr string) webhookDeliveryOutcome {
	backoff := time.Duration(1<<attempt) * time.Second
	if backoff > 60*time.Second {
		backoff = 60 * time.Second
	}

	if attempt >= 5 {
		return failedOutcome(sc, fmt.Sprintf("retry budget exceeded: %s", errStr))
	}

	return webhookDeliveryOutcome{
		status:        core.WebhookDeliveryRetryable,
		statusCode:    sc,
		lastErr:       errStr,
		nextAttemptAt: now.Add(backoff),
	}
}

func (r *Runtime) RegisterBranchWatch(repoID, branch, provider string) (BranchWatch, error) {
	repoID = strings.TrimSpace(repoID)
	branch = strings.TrimSpace(branch)
	provider = strings.TrimSpace(provider)
	if repoID == "" || branch == "" {
		return BranchWatch{}, fmt.Errorf("repo_id and branch are required")
	}
	if provider == "" {
		provider = "forgejo"
	}

	if r.store != nil && r.store.BranchWatches() != nil {
		cw := core.BranchWatch{
			ID:        "watch-" + stableWatchKey(repoID, branch, provider),
			Provider:  provider,
			RepoID:    repoID,
			Branch:    branch,
			CreatedAt: time.Now().UTC(),
		}
		stored, err := r.store.BranchWatches().UpsertBranchWatch(context.Background(), cw)
		if err != nil {
			return BranchWatch{}, fmt.Errorf("register branch watch: %w", err)
		}
		return branchWatchFromCore(stored), nil
	}

	watch := BranchWatch{
		ID:        "watch-" + stableWatchKey(repoID, branch, provider),
		RepoID:    repoID,
		Branch:    branch,
		Provider:  provider,
		CreatedAt: time.Now().UTC(),
	}
	r.mu.Lock()
	defer r.mu.Unlock()
	if existing, ok := r.watches[watch.ID]; ok {
		return existing, nil
	}
	r.watches[watch.ID] = watch
	return watch, nil
}

func (r *Runtime) ListBranchWatches() []BranchWatch {
	if r.store != nil && r.store.BranchWatches() != nil {
		stored, err := r.store.BranchWatches().ListBranchWatches(context.Background())
		if err != nil {
			// fall through to in-memory on transient error
		} else {
			watches := make([]BranchWatch, 0, len(stored))
			for _, w := range stored {
				watches = append(watches, branchWatchFromCore(w))
			}
			sort.Slice(watches, func(i, j int) bool {
				return watches[i].ID < watches[j].ID
			})
			return watches
		}
	}

	r.mu.Lock()
	defer r.mu.Unlock()
	watches := make([]BranchWatch, 0, len(r.watches))
	for _, watch := range r.watches {
		watches = append(watches, watch)
	}
	sort.Slice(watches, func(i, j int) bool {
		return watches[i].ID < watches[j].ID
	})
	return watches
}

func (r *Runtime) RegisterWebhookSubscription(name, targetURL string, events []string, repoID, branch, secretRef string) (WebhookSubscription, error) {
	name = strings.TrimSpace(name)
	targetURL = strings.TrimSpace(targetURL)
	repoID = strings.TrimSpace(repoID)
	branch = strings.TrimSpace(branch)
	secretRef = strings.TrimSpace(secretRef)
	events = normalizeEventNames(events)
	if targetURL == "" {
		return WebhookSubscription{}, fmt.Errorf("target_url is required")
	}
	parsedTarget, err := url.Parse(targetURL)
	if err != nil || parsedTarget.Host == "" || (parsedTarget.Scheme != "http" && parsedTarget.Scheme != "https") {
		return WebhookSubscription{}, fmt.Errorf("target_url must be an http or https URL")
	}
	if len(events) == 0 {
		return WebhookSubscription{}, fmt.Errorf("events are required")
	}
	if name == "" {
		name = "webhook-subscription"
	}

	subscription := WebhookSubscription{
		ID:        "webhook-sub-" + newID(),
		Name:      name,
		TargetURL: targetURL,
		Events:    events,
		RepoID:    repoID,
		Branch:    branch,
		SecretRef: secretRef,
		CreatedAt: time.Now().UTC(),
	}
	r.mu.Lock()
	defer r.mu.Unlock()
	r.subscriptions[subscription.ID] = subscription
	return subscription, nil
}

func (r *Runtime) ListWebhookSubscriptions() []WebhookSubscription {
	r.mu.Lock()
	defer r.mu.Unlock()
	subscriptions := make([]WebhookSubscription, 0, len(r.subscriptions))
	for _, subscription := range r.subscriptions {
		subscriptions = append(subscriptions, subscription)
	}
	sort.Slice(subscriptions, func(i, j int) bool {
		return subscriptions[i].ID < subscriptions[j].ID
	})
	return subscriptions
}

func normalizeEventNames(events []string) []string {
	seen := make(map[string]struct{}, len(events))
	result := make([]string, 0, len(events))
	for _, event := range events {
		event = strings.TrimSpace(event)
		if event == "" {
			continue
		}
		if _, ok := seen[event]; ok {
			continue
		}
		seen[event] = struct{}{}
		result = append(result, event)
	}
	return result
}

func (r *Runtime) ListEvents() []EventRecord {
	r.mu.Lock()
	defer r.mu.Unlock()
	records := append([]EventRecord(nil), r.records...)
	sort.Slice(records, func(i, j int) bool {
		return records[i].CreatedAt.Before(records[j].CreatedAt)
	})
	return records
}

func (r *Runtime) HandleRevision(ctx context.Context, provider, deliveryID string, revision core.RevisionEvent) (EventRecord, bool, error) {
	provider = strings.TrimSpace(provider)
	if provider == "" {
		provider = "forgejo"
	}
	deliveryID = strings.TrimSpace(deliveryID)

	record := EventRecord{
		ID:         "event-" + newID(),
		Type:       events.BranchUpdated,
		Provider:   provider,
		DeliveryID: deliveryID,
		Revision:   revision,
		CreatedAt:  time.Now().UTC().Truncate(time.Microsecond),
	}

	if r.store != nil && r.store.BranchWatches() != nil {
		return r.handleRevisionWithStore(ctx, provider, deliveryID, revision, record)
	}

	r.mu.Lock()
	matched := r.matchesLocked(provider, revision)
	if matched {
		r.records = append(r.records, record)
	}
	r.mu.Unlock()

	if !matched {
		return record, false, nil
	}

	// Deliver webhooks for matched event
	if err := r.deliverWebhooks(ctx, record); err != nil {
		return record, true, err
	}

	if r.broadcaster != nil {
		if err := r.broadcaster.BroadcastEnvelope(ctx, branchUpdatedEnvelope(record)); err != nil {
			return record, true, err
		}
	}
	return record, true, nil
}

func (r *Runtime) HandleProviderWebhook(ctx context.Context, input provider.WebhookRequest) (EventRecord, error) {
	providerName := strings.TrimSpace(string(input.Provider))
	if providerName == "" {
		return EventRecord{}, fmt.Errorf("provider is required")
	}
	eventType := strings.TrimSpace(input.EventType)
	if eventType == "" {
		eventType = "unknown"
	}
	externalID := strings.TrimSpace(input.ExternalID)
	receivedAt := input.ReceivedAt
	if receivedAt.IsZero() {
		receivedAt = time.Now().UTC().Truncate(time.Microsecond)
	}
	record := EventRecord{
		ID:         "event-" + newID(),
		Type:       events.ProviderWebhook,
		Provider:   providerName,
		DeliveryID: externalID,
		Webhook: &ProviderWebhookRecord{
			EventType:   eventType,
			ExternalID:  externalID,
			PayloadSize: len(input.Payload),
			ReceivedAt:  receivedAt,
		},
		CreatedAt: receivedAt,
	}

	r.mu.Lock()
	r.records = append(r.records, record)
	r.mu.Unlock()

	if r.broadcaster != nil {
		if err := r.broadcaster.BroadcastEnvelope(ctx, providerWebhookEnvelope(record)); err != nil {
			return record, err
		}
	}
	return record, nil
}

func (r *Runtime) HandleProviderPollRevision(ctx context.Context, poll ProviderPollRevision) (EventRecord, bool, error) {
	provider := strings.TrimSpace(poll.Provider)
	if provider == "" {
		provider = "forgejo"
	}
	repoID := strings.TrimSpace(poll.RepoID)
	branch := strings.TrimSpace(poll.Branch)
	before := strings.TrimSpace(poll.Before)
	after := strings.TrimSpace(poll.After)
	if repoID == "" || branch == "" || before == "" || after == "" {
		return EventRecord{}, false, fmt.Errorf("repo_id, branch, before, and after are required")
	}
	observedAt := poll.ObservedAt
	if observedAt.IsZero() {
		observedAt = time.Now().UTC()
	}
	revision := core.RevisionEvent{
		RepoID:       repoID,
		Branch:       branch,
		Before:       before,
		After:        after,
		ChangedFiles: append([]core.ChangedFile(nil), poll.ChangedFiles...),
		ObservedAt:   observedAt,
	}
	return r.HandleRevision(ctx, provider, "", revision)
}

func (r *Runtime) handleRevisionWithStore(ctx context.Context, provider, deliveryID string, revision core.RevisionEvent, record EventRecord) (EventRecord, bool, error) {
	_, matched, err := r.store.BranchWatches().FindBranchWatch(ctx, provider, revision.RepoID, revision.Branch)
	if err != nil {
		return record, false, fmt.Errorf("find branch watch: %w", err)
	}
	if !matched {
		return record, false, nil
	}

	var dedupeKey string
	if r.store.ProviderDeliveries() != nil {
		dedupeKey = dedupeKeyFor(deliveryID, revision)
		delivery := core.ProviderDelivery{
			ID:         newID(),
			Provider:   provider,
			DeliveryID: deliveryID,
			DedupeKey:  dedupeKey,
			EventID:    record.ID,
			RepoID:     revision.RepoID,
			Branch:     revision.Branch,
			Revision:   revision.After,
			CreatedAt:  record.CreatedAt,
		}
		result, err := r.store.ProviderDeliveries().RecordOnce(ctx, delivery)
		if err != nil {
			return record, true, fmt.Errorf("record delivery: %w", err)
		}
		if !result.First {
			// Return idempotent response using original delivery's event id and timestamp.
			record.ID = result.ExistingEventID
			record.CreatedAt = result.ExistingCreatedAt
			record.Duplicate = true
			return record, true, nil
		}
	}

	r.mu.Lock()
	r.records = append(r.records, record)
	r.mu.Unlock()

	// Deliver webhooks for matched event
	if err := r.deliverWebhooks(ctx, record); err != nil {
		// Rollback delivery record on webhook delivery failure so retry is allowed.
		if r.store.ProviderDeliveries() != nil && dedupeKey != "" {
			_ = r.store.ProviderDeliveries().DeleteDelivery(ctx, provider, dedupeKey)
		}
		return record, true, err
	}

	if r.broadcaster != nil {
		if err := r.broadcaster.BroadcastEnvelope(ctx, branchUpdatedEnvelope(record)); err != nil {
			// Rollback delivery record on broadcast failure
			if r.store.ProviderDeliveries() != nil && dedupeKey != "" {
				_ = r.store.ProviderDeliveries().DeleteDelivery(ctx, provider, dedupeKey)
			}
			return record, true, err
		}
	}

	if r.store.RevisionCursors() != nil {
		cursor := core.RevisionCursor{
			RepoID:     revision.RepoID,
			Branch:     revision.Branch,
			Revision:   revision.After,
			ObservedAt: revision.ObservedAt,
		}
		if err := r.store.RevisionCursors().UpsertRevisionCursor(ctx, cursor); err != nil {
			return record, true, fmt.Errorf("upsert revision cursor: %w", err)
		}
	}

	return record, true, nil
}

func (r *Runtime) matchesLocked(provider string, revision core.RevisionEvent) bool {
	for _, watch := range r.watches {
		if watch.Provider == provider &&
			watch.RepoID == revision.RepoID &&
			watch.Branch == revision.Branch {
			return true
		}
	}
	return false
}

func dedupeKeyFor(deliveryID string, revision core.RevisionEvent) string {
	if deliveryID != "" {
		return "delivery:" + deliveryID
	}
	return fmt.Sprintf("revision:%s:%s:%s:%s", revision.RepoID, revision.Branch, revision.Before, revision.After)
}

// consumerDeliveryID returns a stable non-empty delivery ID for the consumer.
// Priority: record.DeliveryID > deterministic revision-based fallback > "unknown".
// When record.DeliveryID (provider delivery id) is empty, the fallback ID is
// derived deterministically from revision identity so that the same retry
// produces the same consumer delivery id.
func consumerDeliveryID(record EventRecord) string {
	if record.DeliveryID != "" {
		return record.DeliveryID
	}
	// Deterministic fallback from revision identity so that
	// the same revision retry always produces the same
	// consumer-facing delivery id.
	if revFallback := consumerDeliveryIDFromRevision(record); revFallback != "" {
		return revFallback
	}
	return "unknown"
}

// consumerDeliveryIDFromRevision derives a stable non-empty delivery ID from
// the revision identity fields. It returns an empty string when repo/branch
// information is unavailable so that callers can still fall back to "unknown".
func consumerDeliveryIDFromRevision(record EventRecord) string {
	rev := record.Revision
	if rev.RepoID == "" && rev.Branch == "" {
		return ""
	}
	return fmt.Sprintf("fallback:%s:%s:%s:%s", rev.RepoID, rev.Branch, rev.Before, rev.After)
}

func branchWatchFromCore(w core.BranchWatch) BranchWatch {
	return BranchWatch{
		ID:        w.ID,
		RepoID:    w.RepoID,
		Branch:    w.Branch,
		Provider:  w.Provider,
		CreatedAt: w.CreatedAt,
	}
}

func branchUpdatedEnvelope(record EventRecord) protosocket.Envelope {
	changedFiles := make([]any, 0, len(record.Revision.ChangedFiles))
	for _, file := range record.Revision.ChangedFiles {
		changedFiles = append(changedFiles, map[string]any{
			"path":        file.Path,
			"change_type": file.ChangeType,
		})
	}
	return protosocket.NewEventEnvelope(events.BranchUpdated, map[string]any{
		"id":            record.ID,
		"type":          record.Type,
		"provider":      record.Provider,
		"delivery_id":   record.DeliveryID,
		"repo_id":       record.Revision.RepoID,
		"branch":        record.Revision.Branch,
		"before":        record.Revision.Before,
		"after":         record.Revision.After,
		"changed_files": changedFiles,
		"observed_at":   record.Revision.ObservedAt.UTC().Format(time.RFC3339Nano),
		"created_at":    record.CreatedAt.UTC().Format(time.RFC3339Nano),
	})
}

func providerWebhookEnvelope(record EventRecord) protosocket.Envelope {
	payload := map[string]any{
		"id":          record.ID,
		"type":        record.Type,
		"provider":    record.Provider,
		"delivery_id": record.DeliveryID,
		"created_at":  record.CreatedAt.UTC().Format(time.RFC3339Nano),
	}
	if record.Webhook != nil {
		payload["event_type"] = record.Webhook.EventType
		payload["payload_size"] = record.Webhook.PayloadSize
		payload["received_at"] = record.Webhook.ReceivedAt.UTC().Format(time.RFC3339Nano)
	}
	return protosocket.NewEventEnvelope(events.ProviderWebhook, payload)
}

func stableWatchKey(repoID, branch, provider string) string {
	replacer := strings.NewReplacer("/", "-", " ", "-", "_", "-", ".", "-")
	value := strings.ToLower(provider + "-" + repoID + "-" + branch)
	value = replacer.Replace(value)
	value = strings.Trim(value, "-")
	if value == "" {
		return newID()
	}
	return value
}

func newID() string {
	b := make([]byte, 8)
	if _, err := rand.Read(b); err != nil {
		return fmt.Sprintf("%d", time.Now().UnixNano())
	}
	return hex.EncodeToString(b)
}

type ScanRevisionOptions struct {
	Provider   string
	RepoID     string
	Branch     string
	Workdir    string
	Runner     gitengine.CommandRunner
	ObservedAt time.Time
}

func (r *Runtime) ScanBranchRevision(ctx context.Context, opts ScanRevisionOptions) (EventRecord, bool, error) {
	if r.store == nil || r.store.RevisionCursors() == nil {
		return EventRecord{}, false, fmt.Errorf("revision cursor store not supported")
	}

	repoID := strings.TrimSpace(opts.RepoID)
	branch := strings.TrimSpace(opts.Branch)
	workdir := strings.TrimSpace(opts.Workdir)
	if repoID == "" || branch == "" || workdir == "" {
		return EventRecord{}, false, fmt.Errorf("repo_id, branch, and workdir are required")
	}

	provider := strings.TrimSpace(opts.Provider)
	if provider == "" {
		provider = "forgejo"
	}

	runner := opts.Runner
	if runner == nil {
		runner = gitengine.CLI{}
	}

	after, err := gitengine.HeadRevision(runner, workdir)
	if err != nil {
		return EventRecord{}, false, fmt.Errorf("head revision: %w", err)
	}

	observedAt := opts.ObservedAt
	if observedAt.IsZero() {
		observedAt = time.Now().UTC()
	}

	cursor, found, err := r.store.RevisionCursors().GetRevisionCursor(ctx, repoID, branch)
	if err != nil {
		return EventRecord{}, false, fmt.Errorf("get revision cursor: %w", err)
	}

	if !found {
		newCursor := core.RevisionCursor{
			RepoID:     repoID,
			Branch:     branch,
			Revision:   after,
			ObservedAt: observedAt,
		}
		if err := r.store.RevisionCursors().UpsertRevisionCursor(ctx, newCursor); err != nil {
			return EventRecord{}, false, fmt.Errorf("upsert revision cursor: %w", err)
		}
		return EventRecord{}, false, nil
	}

	if cursor.Revision == after {
		cursor.ObservedAt = observedAt
		if err := r.store.RevisionCursors().UpsertRevisionCursor(ctx, cursor); err != nil {
			return EventRecord{}, false, fmt.Errorf("upsert revision cursor: %w", err)
		}
		return EventRecord{}, false, nil
	}

	before := cursor.Revision
	files, err := gitengine.ChangedFilesWithStatus(runner, workdir, before, after)
	if err != nil {
		return EventRecord{}, false, fmt.Errorf("changed files with status: %w", err)
	}

	revEvent := revisionEventFromScan(repoID, branch, before, after, files, observedAt)
	record, matched, err := r.HandleRevision(ctx, provider, "", revEvent)
	if err != nil {
		return record, matched, err
	}
	if !matched {
		cursor := core.RevisionCursor{
			RepoID:     repoID,
			Branch:     branch,
			Revision:   after,
			ObservedAt: observedAt,
		}
		if err := r.store.RevisionCursors().UpsertRevisionCursor(ctx, cursor); err != nil {
			return record, false, fmt.Errorf("upsert revision cursor: %w", err)
		}
	}
	return record, matched, nil
}

func revisionEventFromScan(repoID, branch, before, after string, files []gitengine.ChangedFile, observedAt time.Time) core.RevisionEvent {
	changedFiles := make([]core.ChangedFile, 0, len(files))
	for _, f := range files {
		changedFiles = append(changedFiles, core.ChangedFile{
			Path:       f.Path,
			ChangeType: f.ChangeType,
		})
	}
	return core.RevisionEvent{
		RepoID:       repoID,
		Branch:       branch,
		Before:       before,
		After:        after,
		ChangedFiles: changedFiles,
		ObservedAt:   observedAt,
	}
}

func (r *Runtime) ScanAgentRunRevision(ctx context.Context, req worker.RevisionScanRequest) (worker.RevisionScanResult, error) {
	before := strings.TrimSpace(req.BeforeRevision)
	after := strings.TrimSpace(req.AfterRevision)

	if before != "" && after != "" {
		// Explicit pair: bypass cursor look-up and emit immediately if watched.
		// This covers cursor-free first push of a watched branch after agent_run.
		record, matched, err := r.scanExplicitRevisionPair(ctx, req.Provider, req.RepoID, req.Branch, before, after, req.WorkDir, req.Runner, req.ObservedAt)
		if err != nil {
			return worker.RevisionScanResult{}, err
		}
		if matched && record.ID != "" && r.broadcaster == nil {
			return worker.RevisionScanResult{}, fmt.Errorf("event matched but broadcaster is nil (not published)")
		}
		return worker.RevisionScanResult{
			Attempted: true,
			Matched:   matched,
			EventID:   record.ID,
		}, nil
	}

	opts := ScanRevisionOptions{
		Provider:   req.Provider,
		RepoID:     req.RepoID,
		Branch:     req.Branch,
		Workdir:    req.WorkDir,
		Runner:     req.Runner,
		ObservedAt: req.ObservedAt,
	}
	record, matched, err := r.ScanBranchRevision(ctx, opts)
	if err != nil {
		return worker.RevisionScanResult{}, err
	}

	if matched && record.ID != "" && r.broadcaster == nil {
		return worker.RevisionScanResult{}, fmt.Errorf("event matched but broadcaster is nil (not published)")
	}

	return worker.RevisionScanResult{
		Attempted: true,
		Matched:   matched,
		EventID:   record.ID,
	}, nil
}

// scanExplicitRevisionPair emits a branch.updated event for a known before/after
// pair, regardless of cursor state. changed_files are computed from workdir using
// runner. On publish success the cursor advances to after; on failure the cursor
// is not advanced, preserving retry semantics.
func (r *Runtime) scanExplicitRevisionPair(ctx context.Context, provider, repoID, branch, before, after, workdir string, runner gitengine.CommandRunner, observedAt time.Time) (EventRecord, bool, error) {
	provider = strings.TrimSpace(provider)
	if provider == "" {
		provider = "forgejo"
	}
	if strings.TrimSpace(workdir) == "" {
		return EventRecord{}, false, fmt.Errorf("workdir is required for explicit revision pair scan")
	}
	if runner == nil {
		runner = gitengine.CLI{}
	}
	if observedAt.IsZero() {
		observedAt = time.Now().UTC()
	}

	changedFiles, err := gitengine.ChangedFilesWithStatus(runner, workdir, before, after)
	if err != nil {
		return EventRecord{}, false, fmt.Errorf("changed files for explicit revision pair: %w", err)
	}

	revEvent := revisionEventFromScan(repoID, branch, before, after, changedFiles, observedAt)

	record, matched, err := r.HandleRevision(ctx, provider, "", revEvent)
	if err != nil {
		return record, matched, err
	}

	return record, matched, nil
}

type outboxBroadcaster struct {
	store storage.OperationEventStore
}

func NewOutboxBroadcaster(store storage.OperationEventStore) EventBroadcaster {
	return &outboxBroadcaster{store: store}
}

// DryRunReconcile validates the reconcile request and returns a proposal without
// applying any changes. It does not append events, create deliveries, or broadcast.
func (r *Runtime) DryRunReconcile(ctx context.Context, req ReconcileRequest) ReconcileResult {
	req.Provider = strings.TrimSpace(req.Provider)
	if req.Provider == "" {
		req.Provider = "forgejo"
	}
	req.RepoID = strings.TrimSpace(req.RepoID)
	req.Branch = strings.TrimSpace(req.Branch)
	if req.RepoID == "" || req.Branch == "" {
		return ReconcileResult{
			Status:   ReconcileStatusConflict,
			Conflict: true,
			Reason:   "repo_id and branch are required",
		}
	}

	// Check expected revision mismatch first
	if req.ExpectedRevision != "" && req.CurrentRevision != "" && req.ExpectedRevision != req.CurrentRevision {
		return ReconcileResult{
			Status:   ReconcileStatusConflict,
			Conflict: true,
			Applied:  false,
			Reason:   fmt.Sprintf("expected revision %q does not match current revision %q", req.ExpectedRevision, req.CurrentRevision),
		}
	}

	// No drift when current matches target
	if req.CurrentRevision == req.TargetRevision {
		return ReconcileResult{
			Status:  ReconcileStatusNoop,
			Drift:   false,
			Applied: false,
			Reason:  "current revision already matches target",
		}
	}

	// Drift proposal: current differs from target
	return ReconcileResult{
		Status:  ReconcileStatusProposal,
		Drift:   true,
		Applied: false,
		Proposal: &ReconcileProposal{
			Before:  req.CurrentRevision,
			After:   req.TargetRevision,
			Changed: req.ChangedFiles,
		},
		Reason: "drift detected: current differs from target",
	}
}

// ApplyReconcile validates the reconcile request, runs dry-run, and applies the
// change using HandleRevision with an idempotency key. Side effects (webhook
// delivery, broadcast) happen only on first apply; duplicate keys return
// ReconcileStatusDuplicate without additional side effects.
// Apply idempotency is delegated to HandleRevision's delivery dedupe path, so a
// failure during HandleRevision rolls back the dedupe record and allows retry.
// IdempotencyKey and durable ProviderDeliveries store are required; missing
// either returns conflict without side effects.
func (r *Runtime) ApplyReconcile(ctx context.Context, req ReconcileRequest) ReconcileResult {
	req.Provider = strings.TrimSpace(req.Provider)
	if req.Provider == "" {
		req.Provider = "forgejo"
	}
	req.RepoID = strings.TrimSpace(req.RepoID)
	req.Branch = strings.TrimSpace(req.Branch)
	if req.RepoID == "" || req.Branch == "" {
		return ReconcileResult{
			Status:   ReconcileStatusConflict,
			Conflict: true,
			Reason:   "repo_id and branch are required",
		}
	}

	// Dry-run first: conflict blocks apply
	dryResult := r.DryRunReconcile(ctx, req)
	if dryResult.Conflict {
		return dryResult
	}

	// Idempotency key is required for apply
	if req.IdempotencyKey == "" {
		return ReconcileResult{
			Status:   ReconcileStatusConflict,
			Conflict: true,
			Reason:   "idempotency_key is required for apply",
		}
	}

	// Durable dedupe store is required for apply
	if r.store == nil || r.store.ProviderDeliveries() == nil {
		return ReconcileResult{
			Status:   ReconcileStatusConflict,
			Conflict: true,
			Reason:   "durable ProviderDeliveries store is required for apply",
		}
	}

	// Apply via HandleRevision. The existing delivery dedupe path inside
	// HandleRevision acts as both the delivery dedupe and the apply idempotency
	// key. When HandleRevision fails it rolls back its delivery dedupe record,
	// so the same idempotency key can be retried successfully.
	revision := core.RevisionEvent{
		RepoID:       req.RepoID,
		Branch:       req.Branch,
		Before:       req.CurrentRevision,
		After:        req.TargetRevision,
		ChangedFiles: req.ChangedFiles,
		ObservedAt:   req.ObservedAt,
	}
	record, matched, err := r.HandleRevision(ctx, req.Provider, "reconcile:"+req.IdempotencyKey, revision)
	if err != nil {
		return ReconcileResult{
			Status:   ReconcileStatusConflict,
			Conflict: true,
			Reason:   fmt.Sprintf("handle revision: %v", err),
		}
	}
	if record.Duplicate {
		// HandleRevision returned an idempotent duplicate result. This means
		// the previous invocation with the same delivery id already completed
		// side effects (webhook delivery, broadcast, cursor upsert).
		return ReconcileResult{
			Status:         ReconcileStatusDuplicate,
			Duplicate:      true,
			Applied:        false,
			IdempotencyKey: req.IdempotencyKey,
			Reason:         "duplicate reconcile apply by idempotency key",
		}
	}
	if !matched {
		return ReconcileResult{
			Status:         ReconcileStatusApplied,
			Applied:        true,
			IdempotencyKey: req.IdempotencyKey,
			Reason:         "applied but event did not match any watch",
		}
	}
	return ReconcileResult{
		Status:         ReconcileStatusApplied,
		Applied:        true,
		Duplicate:      false,
		IdempotencyKey: req.IdempotencyKey,
		Reason:         "reconcile apply succeeded",
	}
}

func (o *outboxBroadcaster) BroadcastEnvelope(ctx context.Context, env protosocket.Envelope) error {
	if env.Channel != "event" || env.Action != "branch.updated" {
		return nil
	}

	payloadBytes, err := json.Marshal(env.Payload)
	if err != nil {
		return fmt.Errorf("marshal envelope payload: %w", err)
	}

	var m map[string]any
	if err := json.Unmarshal(payloadBytes, &m); err != nil {
		return fmt.Errorf("unmarshal envelope payload map: %w", err)
	}

	eventID, _ := m["id"].(string)
	if eventID == "" {
		eventID = fmt.Sprintf("branch-updated-outbox:%d", time.Now().UnixNano())
	}

	repoID, _ := m["repo_id"].(string)
	subject := "repo:" + repoID

	opEvent := storage.OperationEvent{
		OperationID: "",
		Event: events.Event{
			ID:        eventID,
			Type:      events.BranchUpdated,
			Subject:   subject,
			Payload:   payloadBytes,
			CreatedAt: time.Now().UTC(),
		},
	}

	if err := o.store.AppendEvent(ctx, opEvent); err != nil {
		return fmt.Errorf("append branch.updated outbox event: %w", err)
	}
	return nil
}
