package controlplane

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"

	"git.toki-labs.com/toki/gito/services/core/internal/config"
	"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/storage"
	"git.toki-labs.com/toki/gito/services/core/internal/worker"
)

// fakeStore is a shared in-memory implementation of storage.Store for tests.
type fakeStore struct {
	watches           *fakeBranchWatchStore
	cursors           *fakeRevisionCursorStore
	deliveries        *fakeProviderDeliveryStore
	webhookDeliveries *fakeWebhookDeliveryStore
	providerConfigs   storage.ProviderConfigStore
}

func newFakeStore() *fakeStore {
	return &fakeStore{
		watches:    &fakeBranchWatchStore{},
		cursors:    &fakeRevisionCursorStore{},
		deliveries: &fakeProviderDeliveryStore{records: make(map[string]core.ProviderDelivery)},
	}
}

func (f *fakeStore) Ping(_ context.Context) error                      { return nil }
func (f *fakeStore) Repos() storage.RepoStore                          { return nil }
func (f *fakeStore) WorkspaceLeases() storage.WorkspaceLeaseStore      { return nil }
func (f *fakeStore) Operations() storage.OperationStore                { return nil }
func (f *fakeStore) OperationEvents() storage.OperationEventStore      { return nil }
func (f *fakeStore) BranchWatches() storage.BranchWatchStore           { return f.watches }
func (f *fakeStore) RevisionCursors() storage.RevisionCursorStore      { return f.cursors }
func (f *fakeStore) ProviderDeliveries() storage.ProviderDeliveryStore { return f.deliveries }
func (f *fakeStore) AgentRunInputs() storage.AgentRunInputStore        { return nil }
func (f *fakeStore) WebhookDeliveries() storage.WebhookDeliveryStore {
	if f.webhookDeliveries == nil {
		return nil
	}
	return f.webhookDeliveries
}

func (f *fakeStore) ProviderConfigs() storage.ProviderConfigStore {
	return f.providerConfigs
}

var _ storage.Store = (*fakeStore)(nil)

// fakeBranchWatchStore

type fakeBranchWatchStore struct {
	mu      sync.Mutex
	watches []core.BranchWatch
}

func (f *fakeBranchWatchStore) UpsertBranchWatch(_ context.Context, watch core.BranchWatch) (core.BranchWatch, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	for _, w := range f.watches {
		if w.Provider == watch.Provider && w.RepoID == watch.RepoID && w.Branch == watch.Branch {
			return w, nil
		}
	}
	f.watches = append(f.watches, watch)
	return watch, nil
}

func (f *fakeBranchWatchStore) FindBranchWatch(_ context.Context, provider, repoID, branch string) (core.BranchWatch, bool, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	for _, w := range f.watches {
		if w.Provider == provider && w.RepoID == repoID && w.Branch == branch {
			return w, true, nil
		}
	}
	return core.BranchWatch{}, false, nil
}

func (f *fakeBranchWatchStore) ListBranchWatches(_ context.Context) ([]core.BranchWatch, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([]core.BranchWatch(nil), f.watches...), nil
}

// fakeRevisionCursorStore

type fakeRevisionCursorStore struct {
	mu      sync.Mutex
	cursors map[string]core.RevisionCursor
}

func (f *fakeRevisionCursorStore) UpsertRevisionCursor(_ context.Context, cursor core.RevisionCursor) error {
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.cursors == nil {
		f.cursors = make(map[string]core.RevisionCursor)
	}
	f.cursors[cursor.RepoID+":"+cursor.Branch] = cursor
	return nil
}

func (f *fakeRevisionCursorStore) GetRevisionCursor(_ context.Context, repoID, branch string) (core.RevisionCursor, bool, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	c, ok := f.cursors[repoID+":"+branch]
	return c, ok, nil
}

// fakeProviderDeliveryStore

type fakeProviderDeliveryStore struct {
	mu      sync.Mutex
	records map[string]core.ProviderDelivery
}

func (f *fakeProviderDeliveryStore) RecordOnce(_ context.Context, delivery core.ProviderDelivery) (storage.DeliveryResult, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	if existing, ok := f.records[delivery.DedupeKey]; ok {
		return storage.DeliveryResult{
			First:             false,
			ExistingEventID:   existing.EventID,
			ExistingCreatedAt: existing.CreatedAt.Truncate(time.Microsecond),
		}, nil
	}
	f.records[delivery.DedupeKey] = delivery
	return storage.DeliveryResult{First: true}, nil
}

func (f *fakeProviderDeliveryStore) DeleteDelivery(_ context.Context, provider, dedupeKey string) error {
	f.mu.Lock()
	defer f.mu.Unlock()
	delete(f.records, dedupeKey)
	return nil
}

// fakeWebhookDeliveryStore

type fakeWebhookDeliveryStore struct {
	mu         sync.Mutex
	records    map[string]core.WebhookDelivery
	outcomeErr error
}

func (f *fakeWebhookDeliveryStore) SetOutcomeErr(err error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.outcomeErr = err
}

func (f *fakeWebhookDeliveryStore) EnqueueDelivery(_ context.Context, delivery core.WebhookDelivery) (storage.WebhookDeliveryCreateResult, error) {
	f.mu.Lock()
	defer f.mu.Unlock()

	if delivery.ID == "" {
		delivery.ID = "fake-delivery-id-" + fmt.Sprint(len(f.records))
	}
	if delivery.Status == "" {
		delivery.Status = core.WebhookDeliveryPending
	}

	key := delivery.EventID + ":" + delivery.SubscriptionID
	if existing, ok := f.records[key]; ok {
		return storage.WebhookDeliveryCreateResult{Delivery: existing, Created: false}, nil
	}

	now := time.Now().UTC()
	if delivery.CreatedAt.IsZero() {
		delivery.CreatedAt = now
	}
	if delivery.UpdatedAt.IsZero() {
		delivery.UpdatedAt = now
	}
	if delivery.NextAttemptAt.IsZero() {
		delivery.NextAttemptAt = now
	}
	f.records[key] = delivery
	return storage.WebhookDeliveryCreateResult{Delivery: delivery, Created: true}, nil
}

func (f *fakeWebhookDeliveryStore) GetDelivery(_ context.Context, id string) (core.WebhookDelivery, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	for _, r := range f.records {
		if r.ID == id {
			return r, nil
		}
	}
	return core.WebhookDelivery{}, fmt.Errorf("not found")
}

func (f *fakeWebhookDeliveryStore) PickPendingDeliveries(_ context.Context, limit int, now time.Time) ([]core.WebhookDelivery, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	var list []core.WebhookDelivery
	for key, d := range f.records {
		if (d.Status == core.WebhookDeliveryPending || d.Status == core.WebhookDeliveryRetryable) && (d.NextAttemptAt.Before(now) || d.NextAttemptAt.Equal(now)) {
			d.Status = core.WebhookDeliverySending
			d.UpdatedAt = now
			f.records[key] = d
			list = append(list, d)
			if len(list) >= limit {
				break
			}
		}
	}
	return list, nil
}

func (f *fakeWebhookDeliveryStore) StartDelivery(_ context.Context, id string, now time.Time) (core.WebhookDelivery, bool, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	for key, d := range f.records {
		if d.ID == id && (d.Status == core.WebhookDeliveryPending || d.Status == core.WebhookDeliveryRetryable) {
			d.Status = core.WebhookDeliverySending
			d.UpdatedAt = now
			f.records[key] = d
			return d, true, nil
		}
	}
	return core.WebhookDelivery{}, false, nil
}

func (f *fakeWebhookDeliveryStore) RecordOutcome(_ context.Context, id string, status core.WebhookDeliveryStatus, statusCode int, lastErr string, nextAttemptAt time.Time, now time.Time) (core.WebhookDelivery, error) {
	f.mu.Lock()
	if f.outcomeErr != nil {
		err := f.outcomeErr
		f.mu.Unlock()
		return core.WebhookDelivery{}, err
	}
	f.mu.Unlock()
	for key, d := range f.records {
		if d.ID == id {
			d.Status = status
			d.LastStatusCode = statusCode
			d.LastError = lastErr
			d.NextAttemptAt = nextAttemptAt
			d.AttemptCount++
			d.UpdatedAt = now
			f.records[key] = d
			return d, nil
		}
	}
	return core.WebhookDelivery{}, fmt.Errorf("not found")
}

// Tests

func TestRuntimeRestartPersistsWatchAndCursor(t *testing.T) {
	store := newFakeStore()

	// Runtime A: register a watch
	runtimeA := NewRuntimeWithStore(nil, store)
	_, err := runtimeA.RegisterBranchWatch("nomadcode", "develop", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	// Runtime B: same store, simulates process restart
	runtimeB := NewRuntimeWithStore(nil, store)

	revision := core.RevisionEvent{
		RepoID:     "nomadcode",
		Branch:     "develop",
		Before:     "aaa",
		After:      "bbb",
		ObservedAt: time.Now().UTC(),
	}
	record, matched, err := runtimeB.HandleRevision(context.Background(), "forgejo", "delivery-restart-1", revision)
	if err != nil {
		t.Fatalf("handle revision: %v", err)
	}
	if !matched {
		t.Fatalf("expected matched=true after restart, got false; record=%+v", record)
	}
	if record.Duplicate {
		t.Fatalf("expected first delivery not to be duplicate")
	}

	// cursor must be persisted
	cursor, ok, err := store.cursors.GetRevisionCursor(context.Background(), "nomadcode", "develop")
	if err != nil {
		t.Fatalf("get cursor: %v", err)
	}
	if !ok {
		t.Fatal("expected revision cursor to be stored after first delivery")
	}
	if cursor.Revision != "bbb" {
		t.Fatalf("cursor revision: got %q want %q", cursor.Revision, "bbb")
	}
}

func TestRuntimeDuplicateDeliveryIsIdempotent(t *testing.T) {
	store := newFakeStore()
	runtime := NewRuntimeWithStore(nil, store)

	_, err := runtime.RegisterBranchWatch("nomadcode", "develop", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	revision := core.RevisionEvent{
		RepoID:     "nomadcode",
		Branch:     "develop",
		Before:     "aaa",
		After:      "bbb",
		ObservedAt: time.Now().UTC(),
	}

	record1, matched1, err := runtime.HandleRevision(context.Background(), "forgejo", "delivery-dup-1", revision)
	if err != nil {
		t.Fatalf("first handle: %v", err)
	}
	if !matched1 || record1.Duplicate {
		t.Fatalf("first: matched=%v duplicate=%v", matched1, record1.Duplicate)
	}

	record2, matched2, err := runtime.HandleRevision(context.Background(), "forgejo", "delivery-dup-1", revision)
	if err != nil {
		t.Fatalf("second handle: %v", err)
	}
	if !matched2 {
		t.Fatalf("duplicate delivery should still return matched=true")
	}
	if !record2.Duplicate {
		t.Fatalf("expected second delivery to be marked as duplicate")
	}

	// duplicate response must return same event id and created_at as the first
	if record2.ID != record1.ID {
		t.Fatalf("duplicate event id: got %q want %q", record2.ID, record1.ID)
	}
	if !record2.CreatedAt.Equal(record1.CreatedAt) {
		t.Fatalf("duplicate created_at: got %v want %v", record2.CreatedAt, record1.CreatedAt)
	}

	// in-memory event log must have exactly one record
	evList := runtime.ListEvents()
	if len(evList) != 1 {
		t.Fatalf("event list count: got %d want 1", len(evList))
	}
	if evList[0].Type != events.BranchUpdated {
		t.Fatalf("event type: got %q", evList[0].Type)
	}
}

func TestForgejoPushDuplicateDeliveryIsIdempotent(t *testing.T) {
	store := newFakeStore()
	router := newRouterWithStore(config.Config{AppEnv: "test", ProtoSocketPath: "/proto-socket"}, nil, store)

	registerBranchListener(t, router, `{"repo_id":"nomadcode","branch":"develop","provider":"forgejo"}`)

	pushBody := `{
		"ref": "refs/heads/develop",
		"before": "111",
		"after": "222",
		"repository": {"name": "nomadcode", "full_name": "toki/nomadcode"},
		"commits": [{"id": "222", "modified": ["README.md"]}]
	}`

	sendPush := func(deliveryID string) map[string]any {
		req := httptest.NewRequest(http.MethodPost, "/callbacks/forgejo/push?repo_id=nomadcode", bytes.NewBufferString(pushBody))
		req.Header.Set("X-Forgejo-Event", "push")
		req.Header.Set("X-Forgejo-Delivery", deliveryID)
		rec := httptest.NewRecorder()
		router.ServeHTTP(rec, req)
		if rec.Code != http.StatusAccepted {
			t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String())
		}
		var body map[string]any
		if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
			t.Fatalf("decode: %v", err)
		}
		return body
	}

	first := sendPush("dup-delivery-1")
	if first["matched"] != true || first["duplicate"] == true {
		t.Fatalf("first push: matched=%v duplicate=%v", first["matched"], first["duplicate"])
	}

	second := sendPush("dup-delivery-1")
	if second["matched"] != true {
		t.Fatalf("second push: expected matched=true")
	}
	if second["duplicate"] != true {
		t.Fatalf("second push: expected duplicate=true, got %v", second["duplicate"])
	}

	// duplicate response event id and created_at must match the first delivery
	firstEvent, _ := first["event"].(map[string]any)
	secondEvent, _ := second["event"].(map[string]any)
	if firstEvent == nil || secondEvent == nil {
		t.Fatalf("event missing: first=%v second=%v", first["event"], second["event"])
	}
	if firstEvent["id"] != secondEvent["id"] {
		t.Fatalf("duplicate event id mismatch: first=%v second=%v", firstEvent["id"], secondEvent["id"])
	}
	if firstEvent["created_at"] != secondEvent["created_at"] {
		t.Fatalf("duplicate created_at mismatch: first=%v second=%v", firstEvent["created_at"], secondEvent["created_at"])
	}

	// /api/events count must not increase on duplicate
	evReq := httptest.NewRequest(http.MethodGet, "/api/events", nil)
	evRec := httptest.NewRecorder()
	router.ServeHTTP(evRec, evReq)
	var evBody map[string][]map[string]any
	if err := json.NewDecoder(evRec.Body).Decode(&evBody); err != nil {
		t.Fatalf("decode events: %v", err)
	}
	if len(evBody["events"]) != 1 {
		t.Fatalf("events count: got %d want 1", len(evBody["events"]))
	}
}

func setupGitRepo(t *testing.T) (workdir string, initialRev string) {
	tmpDir := t.TempDir()
	workdir = filepath.Join(tmpDir, "repo")
	if err := os.MkdirAll(workdir, 0755); err != nil {
		t.Fatalf("failed to create repo dir: %v", err)
	}
	runGit(t, workdir, "init")
	runGit(t, workdir, "config", "user.name", "Test User")
	runGit(t, workdir, "config", "user.email", "test@example.com")
	runGit(t, workdir, "config", "commit.gpgsign", "false")
	runGit(t, workdir, "branch", "-M", "main")

	writeFile(t, filepath.Join(workdir, "README.md"), "# Seed Repository\n")
	runGit(t, workdir, "add", "README.md")
	runGit(t, workdir, "commit", "-m", "initial commit")

	cmd := exec.Command("git", "rev-parse", "HEAD")
	cmd.Dir = workdir
	out, err := cmd.Output()
	if err != nil {
		t.Fatalf("failed to get head rev: %v", err)
	}
	initialRev = strings.TrimSpace(string(out))
	return workdir, initialRev
}

func runGit(t *testing.T, dir string, args ...string) {
	cmd := exec.Command("git", args...)
	cmd.Dir = dir
	if out, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("git %s failed: %v, output: %s", strings.Join(args, " "), err, string(out))
	}
}

func writeFile(t *testing.T, path, content string) {
	if err := os.WriteFile(path, []byte(content), 0644); err != nil {
		t.Fatalf("WriteFile %s failed: %v", path, err)
	}
}

func TestRuntimeScanBranchRevisionCursorStates(t *testing.T) {
	store := newFakeStore()
	r := NewRuntimeWithStore(nil, store)
	workdir, initialRev := setupGitRepo(t)

	opts := ScanRevisionOptions{
		RepoID:  "myrepo",
		Branch:  "main",
		Workdir: workdir,
		Runner:  gitengine.CLI{},
	}

	// 1. First observation: stores cursor, returns no event, matched=false
	record, matched, err := r.ScanBranchRevision(context.Background(), opts)
	if err != nil {
		t.Fatalf("first scan: %v", err)
	}
	if matched {
		t.Fatal("first scan: expected matched=false")
	}
	if record.ID != "" {
		t.Fatalf("first scan: expected empty record, got %+v", record)
	}

	cursor, ok, err := store.cursors.GetRevisionCursor(context.Background(), "myrepo", "main")
	if err != nil {
		t.Fatalf("get cursor: %v", err)
	}
	if !ok {
		t.Fatal("first scan: cursor not stored")
	}
	if cursor.Revision != initialRev {
		t.Fatalf("first scan cursor revision: got %q want %q", cursor.Revision, initialRev)
	}

	// 2. Unchanged revision: no event, updates observed time (or stays unchanged), matched=false
	record2, matched2, err := r.ScanBranchRevision(context.Background(), opts)
	if err != nil {
		t.Fatalf("second scan: %v", err)
	}
	if matched2 {
		t.Fatal("second scan: expected matched=false")
	}
	if record2.ID != "" {
		t.Fatalf("second scan: expected empty record, got %+v", record2)
	}

	cursor2, ok2, err := store.cursors.GetRevisionCursor(context.Background(), "myrepo", "main")
	if err != nil {
		t.Fatalf("get cursor2: %v", err)
	}
	if !ok2 {
		t.Fatal("second scan: cursor not found")
	}
	if cursor2.Revision != initialRev {
		t.Fatalf("second scan cursor revision: got %q want %q", cursor2.Revision, initialRev)
	}

	// 3. Changed revision with watch: stores cursor with new revision, returns event, matched=true
	_, err = r.RegisterBranchWatch("myrepo", "main", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	writeFile(t, filepath.Join(workdir, "README.md"), "# Seed Repository\n\nupdated\n")
	runGit(t, workdir, "add", "README.md")
	runGit(t, workdir, "commit", "-m", "update readme")

	cmd := exec.Command("git", "rev-parse", "HEAD")
	cmd.Dir = workdir
	out, err := cmd.Output()
	if err != nil {
		t.Fatalf("get head: %v", err)
	}
	afterRev := strings.TrimSpace(string(out))

	record3, matched3, err := r.ScanBranchRevision(context.Background(), opts)
	if err != nil {
		t.Fatalf("third scan: %v", err)
	}
	if !matched3 {
		t.Fatal("third scan: expected matched=true")
	}
	if record3.Revision.Before != initialRev {
		t.Fatalf("third scan record before: got %q want %q", record3.Revision.Before, initialRev)
	}
	if record3.Revision.After != afterRev {
		t.Fatalf("third scan record after: got %q want %q", record3.Revision.After, afterRev)
	}

	cursor3, ok3, err := store.cursors.GetRevisionCursor(context.Background(), "myrepo", "main")
	if err != nil {
		t.Fatalf("get cursor3: %v", err)
	}
	if !ok3 {
		t.Fatal("third scan: cursor not found")
	}
	if cursor3.Revision != afterRev {
		t.Fatalf("third scan cursor revision: got %q want %q", cursor3.Revision, afterRev)
	}
}

func TestRuntimeScanBranchRevisionBuildsRevisionEvent(t *testing.T) {
	store := newFakeStore()
	r := NewRuntimeWithStore(nil, store)
	workdir, initialRev := setupGitRepo(t)

	// Register watch
	_, err := r.RegisterBranchWatch("myrepo", "main", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	opts := ScanRevisionOptions{
		RepoID:  "myrepo",
		Branch:  "main",
		Workdir: workdir,
		Runner:  gitengine.CLI{},
	}

	// First scan (observation)
	_, _, err = r.ScanBranchRevision(context.Background(), opts)
	if err != nil {
		t.Fatalf("first scan: %v", err)
	}

	// Make changes
	writeFile(t, filepath.Join(workdir, "newfile.txt"), "new file")
	writeFile(t, filepath.Join(workdir, "README.md"), "# Seed Repository\n\nupdated\n")
	runGit(t, workdir, "add", "newfile.txt", "README.md")
	runGit(t, workdir, "commit", "-m", "add newfile and update readme")

	cmd := exec.Command("git", "rev-parse", "HEAD")
	cmd.Dir = workdir
	out, err := cmd.Output()
	if err != nil {
		t.Fatalf("get head: %v", err)
	}
	afterRev := strings.TrimSpace(string(out))

	// Second scan (changes)
	record, matched, err := r.ScanBranchRevision(context.Background(), opts)
	if err != nil {
		t.Fatalf("second scan: %v", err)
	}
	if !matched {
		t.Fatal("expected matched=true")
	}
	if record.Revision.Before != initialRev {
		t.Fatalf("before: got %q want %q", record.Revision.Before, initialRev)
	}
	if record.Revision.After != afterRev {
		t.Fatalf("after: got %q want %q", record.Revision.After, afterRev)
	}

	// Verify changed files
	if len(record.Revision.ChangedFiles) != 2 {
		t.Fatalf("expected 2 changed files, got %d", len(record.Revision.ChangedFiles))
	}
	filesMap := make(map[string]string)
	for _, f := range record.Revision.ChangedFiles {
		filesMap[f.Path] = f.ChangeType
	}
	if filesMap["README.md"] != "modified" {
		t.Fatalf("README.md change type: got %q want 'modified'", filesMap["README.md"])
	}
	if filesMap["newfile.txt"] != "added" {
		t.Fatalf("newfile.txt change type: got %q want 'added'", filesMap["newfile.txt"])
	}

	// Check cursor updated
	cursor, ok, err := store.cursors.GetRevisionCursor(context.Background(), "myrepo", "main")
	if err != nil || !ok {
		t.Fatalf("get cursor: err=%v ok=%v", err, ok)
	}
	if cursor.Revision != afterRev {
		t.Fatalf("cursor: got %q want %q", cursor.Revision, afterRev)
	}

	// Test no watch event emit
	optsNoWatch := ScanRevisionOptions{
		RepoID:  "nowatch",
		Branch:  "main",
		Workdir: workdir,
		Runner:  gitengine.CLI{},
	}
	// Initial scan for nowatch (stores cursor)
	_, _, err = r.ScanBranchRevision(context.Background(), optsNoWatch)
	if err != nil {
		t.Fatalf("first scan nowatch: %v", err)
	}

	// Add another commit
	writeFile(t, filepath.Join(workdir, "another.txt"), "another")
	runGit(t, workdir, "add", "another.txt")
	runGit(t, workdir, "commit", "-m", "add another")

	// Second scan for nowatch (no watch registered)
	beforeEvents := len(r.ListEvents())
	_, matchedNoWatch, err := r.ScanBranchRevision(context.Background(), optsNoWatch)
	if err != nil {
		t.Fatalf("second scan nowatch: %v", err)
	}
	if matchedNoWatch {
		t.Fatal("expected matched=false for unwatched repo")
	}
	afterEvents := len(r.ListEvents())
	if afterEvents != beforeEvents {
		t.Fatalf("expected events count to remain %d, got %d", beforeEvents, afterEvents)
	}

	// Verify unwatched cursor advanced to latest head revision
	latestHead, err := gitengine.HeadRevision(gitengine.CLI{}, workdir)
	if err != nil {
		t.Fatalf("failed to get head rev for verify: %v", err)
	}
	cursorNoWatch, okNoWatch, err := store.cursors.GetRevisionCursor(context.Background(), "nowatch", "main")
	if err != nil || !okNoWatch {
		t.Fatalf("get nowatch cursor: err=%v ok=%v", err, okNoWatch)
	}
	if cursorNoWatch.Revision != latestHead {
		t.Fatalf("unwatched cursor did not advance: got %q want %q", cursorNoWatch.Revision, latestHead)
	}
}

func TestRuntimeHandleProviderPollRevisionUsesRevisionIdentity(t *testing.T) {
	store := newFakeStore()
	broadcaster := &fakeBroadcaster{}
	r := NewRuntimeWithStore(broadcaster, store)

	_, err := r.RegisterBranchWatch("nomadcode", "develop", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	observedAt := time.Date(2026, 6, 18, 9, 30, 0, 0, time.UTC)
	poll := ProviderPollRevision{
		Provider: "forgejo",
		RepoID:   "nomadcode",
		Branch:   "develop",
		Before:   "rev-before",
		After:    "rev-after",
		ChangedFiles: []core.ChangedFile{
			{Path: "README.md", ChangeType: "modified"},
		},
		ObservedAt: observedAt,
	}

	record, matched, err := r.HandleProviderPollRevision(context.Background(), poll)
	if err != nil {
		t.Fatalf("handle provider poll revision: %v", err)
	}
	if !matched {
		t.Fatal("expected provider poll revision to match branch watch")
	}
	if record.Type != events.BranchUpdated {
		t.Fatalf("event type: got %q want %q", record.Type, events.BranchUpdated)
	}
	if record.Provider != "forgejo" {
		t.Fatalf("provider: got %q want forgejo", record.Provider)
	}
	if record.DeliveryID != "" {
		t.Fatalf("provider poll source must not require provider delivery id, got %q", record.DeliveryID)
	}
	if record.Revision.RepoID != "nomadcode" ||
		record.Revision.Branch != "develop" ||
		record.Revision.Before != "rev-before" ||
		record.Revision.After != "rev-after" {
		t.Fatalf("revision identity mismatch: %+v", record.Revision)
	}
	if !record.Revision.ObservedAt.Equal(observedAt) {
		t.Fatalf("observed_at: got %s want %s", record.Revision.ObservedAt, observedAt)
	}

	dedupeKey := "revision:nomadcode:develop:rev-before:rev-after"
	if got := store.deliveries.records[dedupeKey]; got.EventID != record.ID {
		t.Fatalf("poll source dedupe key mismatch: got %+v want event_id %q", got, record.ID)
	}
	if len(broadcaster.envelopes) != 1 {
		t.Fatalf("expected one branch.updated envelope, got %d", len(broadcaster.envelopes))
	}
	payload := broadcaster.envelopes[0].Payload
	if payload["repo_id"] != "nomadcode" || payload["branch"] != "develop" ||
		payload["before"] != "rev-before" || payload["after"] != "rev-after" {
		t.Fatalf("poll source payload identity mismatch: %#v", payload)
	}

	duplicate, matched, err := r.HandleProviderPollRevision(context.Background(), poll)
	if err != nil {
		t.Fatalf("handle duplicate provider poll revision: %v", err)
	}
	if !matched {
		t.Fatal("expected duplicate provider poll revision to still match branch watch")
	}
	if !duplicate.Duplicate {
		t.Fatal("expected duplicate provider poll revision to be marked duplicate")
	}
	if duplicate.ID != record.ID {
		t.Fatalf("duplicate event id: got %q want %q", duplicate.ID, record.ID)
	}
	if len(r.ListEvents()) != 1 {
		t.Fatalf("duplicate poll source must not append another event, got %d", len(r.ListEvents()))
	}
}

type fakeBroadcaster struct {
	envelopes []protosocket.Envelope
	err       error
}

func (f *fakeBroadcaster) BroadcastEnvelope(ctx context.Context, env protosocket.Envelope) error {
	f.envelopes = append(f.envelopes, env)
	return f.err
}

func TestRuntimeScanAgentRunRevision(t *testing.T) {
	store := newFakeStore()

	// Register watch
	rWatch := NewRuntimeWithStore(nil, store)
	_, err := rWatch.RegisterBranchWatch("myrepo", "main", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	// 1. Broadcaster is nil -> ScanAgentRunRevision fails on match (not published)
	rNilBroadcaster := NewRuntimeWithStore(nil, store)

	// Create mock git workspace
	workdir := t.TempDir()
	runGit(t, workdir, "init")
	runGit(t, workdir, "config", "user.name", "test")
	runGit(t, workdir, "config", "user.email", "test@example.com")

	// First commit to set HEAD
	writeFile(t, filepath.Join(workdir, "README.md"), "hello")
	runGit(t, workdir, "add", "README.md")
	runGit(t, workdir, "commit", "-m", "first commit")

	firstRev, _ := gitengine.HeadRevision(gitengine.CLI{}, workdir)

	// Setup initial cursor
	err = store.cursors.UpsertRevisionCursor(context.Background(), core.RevisionCursor{
		RepoID:   "myrepo",
		Branch:   "main",
		Revision: firstRev,
	})
	if err != nil {
		t.Fatalf("upsert cursor: %v", err)
	}

	// Add second commit
	writeFile(t, filepath.Join(workdir, "README.md"), "hello world")
	runGit(t, workdir, "add", "README.md")
	runGit(t, workdir, "commit", "-m", "second commit")

	req := worker.RevisionScanRequest{
		Provider:   "forgejo",
		RepoID:     "myrepo",
		Branch:     "main",
		WorkDir:    workdir,
		Runner:     gitengine.CLI{},
		ObservedAt: time.Now(),
	}

	// Scanner with nil broadcaster should return error when matched because event isn't published
	_, err = rNilBroadcaster.ScanAgentRunRevision(context.Background(), req)
	if err == nil {
		t.Fatal("expected error on matched scan with nil broadcaster")
	}
	if !strings.Contains(err.Error(), "broadcaster is nil") {
		t.Fatalf("expected broadcaster is nil error, got %v", err)
	}

	// 2. Broadcaster is non-nil and succeeds -> ScanAgentRunRevision succeeds
	broadcaster := &fakeBroadcaster{}
	store2 := newFakeStore()
	rWithBroadcaster := NewRuntimeWithStore(broadcaster, store2)
	_, err = rWithBroadcaster.RegisterBranchWatch("myrepo", "main", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	// We need to reset the cursor back to firstRev to detect changes again
	err = store2.cursors.UpsertRevisionCursor(context.Background(), core.RevisionCursor{
		RepoID:   "myrepo",
		Branch:   "main",
		Revision: firstRev,
	})
	if err != nil {
		t.Fatalf("upsert cursor: %v", err)
	}

	res, err := rWithBroadcaster.ScanAgentRunRevision(context.Background(), req)
	if err != nil {
		t.Fatalf("unexpected ScanAgentRunRevision error: %v", err)
	}
	if !res.Matched {
		t.Fatal("expected matched=true")
	}
	if res.EventID == "" {
		t.Fatal("expected non-empty EventID")
	}
	if len(broadcaster.envelopes) != 1 {
		t.Fatalf("expected 1 broadcasted envelope, got %d", len(broadcaster.envelopes))
	}
}

type fakeOpEvents struct {
	events []storage.OperationEvent
	err    error
}

func (f *fakeOpEvents) AppendEvent(ctx context.Context, ev storage.OperationEvent) error {
	f.events = append(f.events, ev)
	return f.err
}
func (f *fakeOpEvents) ListEvents(ctx context.Context, operationID string) ([]storage.OperationEvent, error) {
	return nil, nil
}
func (f *fakeOpEvents) ListPendingEvents(ctx context.Context, limit int) ([]storage.OperationEvent, error) {
	return nil, nil
}
func (f *fakeOpEvents) MarkPublished(ctx context.Context, eventID string, now time.Time) (storage.OperationEvent, error) {
	return storage.OperationEvent{}, nil
}

func TestOutboxBroadcaster(t *testing.T) {
	eventsStore := &fakeOpEvents{}
	broadcaster := NewOutboxBroadcaster(eventsStore)

	// envelope with different channel/action should be skipped
	err := broadcaster.BroadcastEnvelope(context.Background(), protosocket.Envelope{
		Channel: "repo",
		Action:  "register",
		Payload: map[string]any{},
	})
	if err != nil {
		t.Fatalf("unexpected error on non-matched envelope: %v", err)
	}
	if len(eventsStore.events) != 0 {
		t.Fatalf("expected 0 events appended, got %d", len(eventsStore.events))
	}

	// envelope with branch.updated channel/action should append to outbox
	payload := map[string]any{
		"id":      "evt-123",
		"repo_id": "myrepo",
		"branch":  "main",
		"before":  "rev-before",
		"after":   "rev-after",
	}
	err = broadcaster.BroadcastEnvelope(context.Background(), protosocket.Envelope{
		Channel: "event",
		Action:  "branch.updated",
		Payload: payload,
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(eventsStore.events) != 1 {
		t.Fatalf("expected 1 event appended, got %d", len(eventsStore.events))
	}

	ev := eventsStore.events[0]
	if ev.OperationID != "" {
		t.Errorf("expected empty OperationID for generic event, got %q", ev.OperationID)
	}
	if ev.Event.ID != "evt-123" {
		t.Errorf("expected Event ID 'evt-123', got %q", ev.Event.ID)
	}
	if ev.Event.Type != events.BranchUpdated {
		t.Errorf("expected Event Type %q, got %q", events.BranchUpdated, ev.Event.Type)
	}
	if ev.Event.Subject != "repo:myrepo" {
		t.Errorf("expected Subject 'repo:myrepo', got %q", ev.Event.Subject)
	}
}

func TestRuntimeScanAgentRunRevisionPublishFailureDoesNotAdvanceDurableState(t *testing.T) {
	store := newFakeStore()

	// Register watch
	rWatch := NewRuntimeWithStore(nil, store)
	_, err := rWatch.RegisterBranchWatch("myrepo", "main", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	// Create mock git workspace
	workdir := t.TempDir()
	runGit(t, workdir, "init")
	runGit(t, workdir, "config", "user.name", "test")
	runGit(t, workdir, "config", "user.email", "test@example.com")

	// First commit to set HEAD
	writeFile(t, filepath.Join(workdir, "README.md"), "hello")
	runGit(t, workdir, "add", "README.md")
	runGit(t, workdir, "commit", "-m", "first commit")

	firstRev, _ := gitengine.HeadRevision(gitengine.CLI{}, workdir)

	// Setup initial cursor
	err = store.cursors.UpsertRevisionCursor(context.Background(), core.RevisionCursor{
		RepoID:   "myrepo",
		Branch:   "main",
		Revision: firstRev,
	})
	if err != nil {
		t.Fatalf("upsert cursor: %v", err)
	}

	// Add second commit
	writeFile(t, filepath.Join(workdir, "README.md"), "hello world")
	runGit(t, workdir, "add", "README.md")
	runGit(t, workdir, "commit", "-m", "second commit")

	secondRev, _ := gitengine.HeadRevision(gitengine.CLI{}, workdir)

	req := worker.RevisionScanRequest{
		Provider:   "forgejo",
		RepoID:     "myrepo",
		Branch:     "main",
		WorkDir:    workdir,
		Runner:     gitengine.CLI{},
		ObservedAt: time.Now(),
	}

	// 1. Failing broadcaster
	failingBroadcaster := &fakeBroadcaster{err: fmt.Errorf("broadcast failed")}
	rFail := NewRuntimeWithStore(failingBroadcaster, store)

	_, err = rFail.ScanAgentRunRevision(context.Background(), req)
	if err == nil {
		t.Fatal("expected error on matched scan with failing broadcaster")
	}

	// Check that cursor was NOT advanced (remains firstRev)
	cur, found, err := store.cursors.GetRevisionCursor(context.Background(), "myrepo", "main")
	if err != nil || !found {
		t.Fatalf("failed to get cursor: %v, found: %t", err, found)
	}
	if cur.Revision != firstRev {
		t.Errorf("expected cursor to remain at %s, got %s", firstRev, cur.Revision)
	}

	// Check that dedupe record was deleted/not present so retry is allowed
	// dedupeKeyFor(deliveryID, revision) -> revision:myrepo:main:firstRev:secondRev
	dedupeKey := fmt.Sprintf("revision:myrepo:main:%s:%s", firstRev, secondRev)
	if _, ok := store.deliveries.records[dedupeKey]; ok {
		t.Error("expected dedupe delivery record to be deleted after failure")
	}

	// 2. Succeeding broadcaster (Retry)
	successBroadcaster := &fakeBroadcaster{}
	rSuccess := NewRuntimeWithStore(successBroadcaster, store)

	res, err := rSuccess.ScanAgentRunRevision(context.Background(), req)
	if err != nil {
		t.Fatalf("unexpected retry error: %v", err)
	}
	if !res.Matched {
		t.Fatal("expected matched=true on retry")
	}

	// Check that cursor advanced to secondRev
	curAfter, found, err := store.cursors.GetRevisionCursor(context.Background(), "myrepo", "main")
	if err != nil || !found {
		t.Fatalf("failed to get cursor after success: %v, found: %t", err, found)
	}
	if curAfter.Revision != secondRev {
		t.Errorf("expected cursor to advance to %s, got %s", secondRev, curAfter.Revision)
	}

	// Check that one event was successfully broadcasted
	if len(successBroadcaster.envelopes) != 1 {
		t.Errorf("expected 1 broadcasted envelope, got %d", len(successBroadcaster.envelopes))
	}
}

// TestRuntimeScanAgentRunRevisionEmitsWithoutExistingCursor verifies that when
// an explicit before/after pair is provided, a branch.updated event is emitted
// even if no cursor exists yet for the branch. changed_files must be populated
// from the real git diff between before and after.
func TestRuntimeScanAgentRunRevisionEmitsWithoutExistingCursor(t *testing.T) {
	store := newFakeStore()
	broadcaster := &fakeBroadcaster{}
	r := NewRuntimeWithStore(broadcaster, store)

	_, err := r.RegisterBranchWatch("myrepo", "main", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	// Set up a real git repo with two commits so changed_files can be computed.
	workdir, beforeRev := setupGitRepo(t)
	writeFile(t, filepath.Join(workdir, "agent.txt"), "agent output\n")
	runGit(t, workdir, "add", "agent.txt")
	runGit(t, workdir, "config", "commit.gpgsign", "false")
	runGit(t, workdir, "commit", "-m", "agent run result")
	cmd := exec.Command("git", "rev-parse", "HEAD")
	cmd.Dir = workdir
	out, err := cmd.Output()
	if err != nil {
		t.Fatalf("get after rev: %v", err)
	}
	afterRev := strings.TrimSpace(string(out))

	// No cursor seeded — branch has never been observed.
	req := worker.RevisionScanRequest{
		Provider:       "forgejo",
		RepoID:         "myrepo",
		Branch:         "main",
		WorkDir:        workdir,
		Runner:         gitengine.CLI{},
		BeforeRevision: beforeRev,
		AfterRevision:  afterRev,
		ObservedAt:     time.Now().UTC(),
	}

	res, err := r.ScanAgentRunRevision(context.Background(), req)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !res.Attempted {
		t.Fatal("expected attempted=true")
	}
	if !res.Matched {
		t.Fatal("expected matched=true when watch exists")
	}
	if res.EventID == "" {
		t.Fatal("expected non-empty event_id")
	}

	// Cursor must be advanced to after-revision.
	cursor, ok, err := store.cursors.GetRevisionCursor(context.Background(), "myrepo", "main")
	if err != nil || !ok {
		t.Fatalf("cursor not stored after explicit scan: err=%v ok=%v", err, ok)
	}
	if cursor.Revision != afterRev {
		t.Fatalf("cursor revision: got %q want %q", cursor.Revision, afterRev)
	}

	// Broadcaster must have received the event with changed_files.
	if len(broadcaster.envelopes) != 1 {
		t.Fatalf("expected 1 broadcasted envelope, got %d", len(broadcaster.envelopes))
	}
	payload := broadcaster.envelopes[0].Payload
	files, _ := payload["changed_files"].([]any)
	if len(files) == 0 {
		t.Errorf("changed_files must be non-empty in branch.updated event; payload=%v", payload)
	}
}

// TestRuntimeScanAgentRunRevisionPublishFailureNoCursorAdvance verifies that
// when the broadcaster fails, the durable cursor is not advanced — even with
// an explicit before/after pair.
func TestRuntimeScanAgentRunRevisionPublishFailureNoCursorAdvance(t *testing.T) {
	store := newFakeStore()
	failBroadcaster := &fakeBroadcaster{err: fmt.Errorf("publish failed")}
	r := NewRuntimeWithStore(failBroadcaster, store)

	_, err := r.RegisterBranchWatch("myrepo", "feature", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	// Set up a real git repo with two commits.
	workdir, beforeRev := setupGitRepo(t)
	writeFile(t, filepath.Join(workdir, "feature.txt"), "feature work\n")
	runGit(t, workdir, "add", "feature.txt")
	runGit(t, workdir, "config", "commit.gpgsign", "false")
	runGit(t, workdir, "commit", "-m", "feature commit")
	cmd := exec.Command("git", "rev-parse", "HEAD")
	cmd.Dir = workdir
	out, err := cmd.Output()
	if err != nil {
		t.Fatalf("get after rev: %v", err)
	}
	afterRev := strings.TrimSpace(string(out))

	req := worker.RevisionScanRequest{
		Provider:       "forgejo",
		RepoID:         "myrepo",
		Branch:         "feature",
		WorkDir:        workdir,
		Runner:         gitengine.CLI{},
		BeforeRevision: beforeRev,
		AfterRevision:  afterRev,
		ObservedAt:     time.Now().UTC(),
	}

	_, scanErr := r.ScanAgentRunRevision(context.Background(), req)
	if scanErr == nil {
		t.Fatal("expected error when broadcaster fails, got nil")
	}

	// Cursor must NOT be advanced because publish failed.
	_, found, err := store.cursors.GetRevisionCursor(context.Background(), "myrepo", "feature")
	if err != nil {
		t.Fatalf("get cursor: %v", err)
	}
	if found {
		t.Fatal("cursor must not be stored when publish fails")
	}
}

// --- Webhook Delivery Tests ---

// fakeWebhookConsumer captures received HTTP requests for testing.
type fakeWebhookConsumer struct {
	mu       sync.Mutex
	received []webhookReception
}

type webhookReception struct {
	url        string
	headers    map[string]string
	body       map[string]any
	bodyRaw    []byte
	eventType  string
	deliveryID string
	signature  string
}

func (c *fakeWebhookConsumer) Record(req *http.Request, body []byte) error {
	rec := webhookReception{
		url:     req.URL.String(),
		bodyRaw: body,
	}

	// Extract headers we care about
	rec.eventType = req.Header.Get("X-Gito-Event")
	rec.deliveryID = req.Header.Get("X-Gito-Delivery")
	rec.signature = req.Header.Get("X-Gito-Signature")

	// Parse body
	if len(body) > 0 {
		var m map[string]any
		if err := json.Unmarshal(body, &m); err == nil {
			rec.body = m
		}
	}
	c.mu.Lock()
	c.received = append(c.received, rec)
	c.mu.Unlock()
	return nil
}

// fakeWebhookServer creates an httptest.Server that records incoming webhook deliveries.
func fakeWebhookServer(t *testing.T, consumer *fakeWebhookConsumer) *httptest.Server {
	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(r.Body)
		if err != nil {
			t.Fatalf("read body: %v", err)
		}
		w.WriteHeader(http.StatusOK)
		_ = consumer.Record(r, body)
	}))
}

// TestRuntime_WebhookDeliveryBranchUpdated verifies that when a branch.updated
// event matches a webhook subscription, an HTTP POST is sent to the target URL
// with correct headers and payload (S02).
func TestRuntime_WebhookDeliveryBranchUpdated(t *testing.T) {
	consumer := &fakeWebhookConsumer{}
	server := fakeWebhookServer(t, consumer)
	defer server.Close()

	store := newFakeStore()
	broadcaster := &fakeBroadcaster{}
	runtime := NewRuntimeWithStore(broadcaster, store)
	runtime.WithWebhookClient(server.Client())

	// Register a webhook subscription that watches branch.updated events
	_, err := runtime.RegisterWebhookSubscription(
		"test-webhook", server.URL,
		[]string{"branch.updated"},
		"nomadcode", "", // empty repo_id/branch = match all
		"", // no secret
	)
	if err != nil {
		t.Fatalf("register webhook: %v", err)
	}

	// Register a branch watch
	_, err = runtime.RegisterBranchWatch("nomadcode", "develop", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	// Handle a revision event
	revision := core.RevisionEvent{
		RepoID:     "nomadcode",
		Branch:     "develop",
		Before:     "aaa111",
		After:      "bbb222",
		ObservedAt: time.Now().UTC(),
		ChangedFiles: []core.ChangedFile{
			{Path: "README.md", ChangeType: "modified"},
		},
	}
	_, matched, err := runtime.HandleRevision(context.Background(), "forgejo", "test-delivery-1", revision)
	if err != nil {
		t.Fatalf("handle revision: %v", err)
	}
	if !matched {
		t.Fatal("expected matched=true")
	}

	// Verify the fake consumer received exactly one POST
	consumer.mu.Lock()
	received := consumer.received
	consumer.mu.Unlock()

	if len(received) != 1 {
		t.Fatalf("expected 1 webhook reception, got %d", len(received))
	}

	rec := received[0]

	// Check headers
	if rec.eventType != "branch.updated" {
		t.Errorf("X-Gito-Event: got %q want %q", rec.eventType, "branch.updated")
	}
	if rec.deliveryID != "test-delivery-1" {
		t.Errorf("X-Gito-Delivery: got %q want %q", rec.deliveryID, "test-delivery-1")
	}
	if rec.signature != "" {
		t.Error("expected no X-Gito-Signature when no secret is set")
	}

	// Check body fields
	body := rec.body
	if body == nil {
		t.Fatal("expected non-nil body")
	}
	expectedFields := []string{"id", "type", "provider", "delivery_id", "repo_id", "branch", "before", "after", "changed_files", "observed_at", "created_at"}
	for _, field := range expectedFields {
		if _, ok := body[field]; !ok {
			t.Errorf("expected field %q in webhook payload", field)
		}
	}

	// Check specific values
	if body["type"] != "branch.updated" {
		t.Errorf("type: got %v want %q", body["type"], "branch.updated")
	}
	if body["provider"] != "forgejo" {
		t.Errorf("provider: got %v want %q", body["provider"], "forgejo")
	}
	if body["repo_id"] != "nomadcode" {
		t.Errorf("repo_id: got %v want %q", body["repo_id"], "nomadcode")
	}
	if body["branch"] != "develop" {
		t.Errorf("branch: got %v want %q", body["branch"], "develop")
	}
	if body["before"] != "aaa111" {
		t.Errorf("before: got %v want %q", body["before"], "aaa111")
	}
	if body["after"] != "bbb222" {
		t.Errorf("after: got %v want %q", body["after"], "bbb222")
	}

	// Check that X-Gito-Delivery is non-empty (already checked above but explicit)
	if rec.deliveryID == "" {
		t.Error("X-Gito-Delivery must be non-empty")
	}

	// Verify no nomadcode-specific or plane-specific fields in payload
	for key := range body {
		if strings.HasPrefix(key, "nomadcode_") || strings.HasPrefix(key, "plane_") {
			t.Errorf("unexpected consumer-specific field %q in payload", key)
		}
	}

	// Verify broadcast still happened
	if len(broadcaster.envelopes) != 1 {
		t.Errorf("expected 1 broadcasted envelope, got %d", len(broadcaster.envelopes))
	}
}

// TestRuntime_WebhookDeliveryWithSecret verifies that when a subscription has a
// secret_ref, the X-Gito-Signature header is set with HMAC-SHA256 signature.
func TestRuntime_WebhookDeliveryWithSecret(t *testing.T) {
	consumer := &fakeWebhookConsumer{}
	server := fakeWebhookServer(t, consumer)
	defer server.Close()

	store := newFakeStore()
	runtime := NewRuntimeWithStore(nil, store)
	runtime.WithWebhookClient(server.Client())

	testSecret := "my-secret-key"
	runtime.WithSecretResolver(&fakeSecretResolver{secret: testSecret, resolved: true})

	_, err := runtime.RegisterWebhookSubscription(
		"secured-webhook", server.URL,
		[]string{"branch.updated"},
		"myrepo", "",
		"secret-ref-1",
	)
	if err != nil {
		t.Fatalf("register webhook: %v", err)
	}

	_, err = runtime.RegisterBranchWatch("myrepo", "main", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	revision := core.RevisionEvent{
		RepoID:     "myrepo",
		Branch:     "main",
		Before:     "abc",
		After:      "def",
		ObservedAt: time.Now().UTC(),
	}
	_, matched, err := runtime.HandleRevision(context.Background(), "forgejo", "sec-delivery-1", revision)
	if err != nil {
		t.Fatalf("handle revision: %v", err)
	}
	if !matched {
		t.Fatal("expected matched=true")
	}

	consumer.mu.Lock()
	received := consumer.received
	consumer.mu.Unlock()

	if len(received) != 1 {
		t.Fatalf("expected 1 webhook reception, got %d", len(received))
	}

	rec := received[0]
	if rec.signature == "" {
		t.Fatal("expected X-Gito-Signature when secret is set")
	}

	// Verify signature: sha256=HMAC-SHA256(secret, body)
	mac := hmac.New(sha256.New, []byte(testSecret))
	mac.Write(rec.bodyRaw)
	expectedSig := "sha256=" + hex.EncodeToString(mac.Sum(nil))
	if rec.signature != expectedSig {
		t.Errorf("X-Gito-Signature: got %q want %q", rec.signature, expectedSig)
	}
}

// TestRuntime_WebhookDeliveryNoSecretExposure verifies that the secret value is
// never exposed in the response body or logs.
func TestRuntime_WebhookDeliveryNoSecretExposure(t *testing.T) {
	consumer := &fakeWebhookConsumer{}
	server := fakeWebhookServer(t, consumer)
	defer server.Close()

	store := newFakeStore()
	runtime := NewRuntimeWithStore(nil, store)
	runtime.WithWebhookClient(server.Client())

	runtime.WithSecretResolver(&fakeSecretResolver{secret: "super-secret", resolved: true})

	_, err := runtime.RegisterWebhookSubscription(
		"no-exposure-webhook", server.URL,
		[]string{"branch.updated"},
		"secure-repo", "",
		"secret-abc",
	)
	if err != nil {
		t.Fatalf("register webhook: %v", err)
	}

	_, err = runtime.RegisterBranchWatch("secure-repo", "main", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	revision := core.RevisionEvent{
		RepoID:     "secure-repo",
		Branch:     "main",
		Before:     "111",
		After:      "222",
		ObservedAt: time.Now().UTC(),
	}
	_, matched, err := runtime.HandleRevision(context.Background(), "forgejo", "expose-test", revision)
	if err != nil {
		t.Fatalf("handle revision: %v", err)
	}
	if !matched {
		t.Fatal("expected matched=true")
	}

	consumer.mu.Lock()
	received := consumer.received
	consumer.mu.Unlock()

	if len(received) != 1 {
		t.Fatalf("expected 1 webhook reception, got %d", len(received))
	}

	body := received[0].body
	if body == nil {
		t.Fatal("expected non-nil body")
	}

	// Check that the secret value does not appear in the payload body
	bodyStr, err := json.Marshal(body)
	if err != nil {
		t.Fatalf("marshal body: %v", err)
	}
	if strings.Contains(string(bodyStr), "super-secret") {
		t.Error("secret value must not appear in webhook payload body")
	}

	// Check that secret_ref is not exposed
	if strings.Contains(string(bodyStr), "secret-abc") {
		t.Error("secret_ref must not appear in webhook payload body")
	}
}

// TestRuntime_WebhookDeliveryFiltering verifies that webhooks are only sent
// to subscriptions that match the event type, repo_id, and branch.
func TestRuntime_WebhookDeliveryFiltering(t *testing.T) {
	consumer := &fakeWebhookConsumer{}
	server := fakeWebhookServer(t, consumer)
	defer server.Close()

	store := newFakeStore()
	runtime := NewRuntimeWithStore(nil, store)
	runtime.WithWebhookClient(server.Client())

	// Register multiple subscriptions with different filters
	_, err := runtime.RegisterWebhookSubscription(
		"all-branch-updated", server.URL,
		[]string{"branch.updated", "push"},
		"", "", "", // match all
	)
	if err != nil {
		t.Fatalf("register webhook all: %v", err)
	}

	_, err = runtime.RegisterWebhookSubscription(
		"nomadcode-only", server.URL,
		[]string{"branch.updated"},
		"nomadcode", "", "", // only nomadcode repo
	)
	if err != nil {
		t.Fatalf("register webhook nomadcode: %v", err)
	}

	_, err = runtime.RegisterWebhookSubscription(
		"develop-branch-only", server.URL,
		[]string{"branch.updated"},
		"", "main", "", // only main branch filter
	)
	if err != nil {
		t.Fatalf("register webhook develop: %v", err)
	}

	_, err = runtime.RegisterWebhookSubscription(
		"push-only", server.URL,
		[]string{"push"},
		"", "", "", // only push events
	)
	if err != nil {
		t.Fatalf("register webhook push: %v", err)
	}

	// Register branch watch
	_, err = runtime.RegisterBranchWatch("nomadcode", "develop", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	// Handle a branch.updated event for nomadcode/develop
	revision := core.RevisionEvent{
		RepoID:     "nomadcode",
		Branch:     "develop",
		Before:     "aaa",
		After:      "bbb",
		ObservedAt: time.Now().UTC(),
	}
	_, matched, err := runtime.HandleRevision(context.Background(), "forgejo", "filter-test", revision)
	if err != nil {
		t.Fatalf("handle revision: %v", err)
	}
	if !matched {
		t.Fatal("expected matched=true")
	}

	consumer.mu.Lock()
	received := consumer.received
	consumer.mu.Unlock()

	// Expected: all-branch-updated (matches event type, no repo/branch filter)
	//           nomadcode-only (matches event type, repo_id=nomadcode matches)
	// NOT: develop-branch-only (branch filter is "main" but event is "develop")
	// NOT: push-only (event type is "branch.updated" not "push")
	if len(received) != 2 {
		t.Fatalf("expected 2 webhook receptions, got %d", len(received))
	}
}

// fakeSecretResolver implements WebhookSecretResolver for testing.
type fakeSecretResolver struct {
	secret   string
	resolved bool
	err      error
}

func (f *fakeSecretResolver) ResolveWebhookSecret(_ context.Context, secretRef string) (string, bool, error) {
	if f.err != nil {
		return "", false, f.err
	}
	if f.resolved {
		return f.secret, true, nil
	}
	return "", false, nil
}

var _ WebhookSecretResolver = (*fakeSecretResolver)(nil)

// TestRuntime_WebhookDeliveryUnsignedSecretRefFails verifies that when a subscription
// has secret_ref but the resolver is nil or returns unresolved, the delivery fails
// with an error (no unsigned delivery is sent).
func TestRuntime_WebhookDeliveryUnsignedSecretRefFails(t *testing.T) {
	consumer := &fakeWebhookConsumer{}
	server := fakeWebhookServer(t, consumer)
	defer server.Close()

	store := newFakeStore()
	runtime := NewRuntimeWithStore(nil, store)
	runtime.WithWebhookClient(server.Client())
	// No secret resolver set — resolver is nil

	_, err := runtime.RegisterWebhookSubscription(
		"secured-webhook", server.URL,
		[]string{"branch.updated"},
		"myrepo", "",
		"secret-ref-1",
	)
	if err != nil {
		t.Fatalf("register webhook: %v", err)
	}

	_, err = runtime.RegisterBranchWatch("myrepo", "main", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	revision := core.RevisionEvent{
		RepoID:     "myrepo",
		Branch:     "main",
		Before:     "abc",
		After:      "def",
		ObservedAt: time.Now().UTC(),
	}
	_, matched, err := runtime.HandleRevision(context.Background(), "forgejo", "unsigned-test-1", revision)
	if err == nil {
		t.Fatal("expected error when secret_ref is set but resolver is nil")
	}
	if !matched {
		t.Fatal("expected matched=true")
	}
	if !strings.Contains(err.Error(), "secret_ref") {
		t.Errorf("expected error to mention secret_ref, got: %v", err)
	}

	// Verify no request was sent to the consumer
	consumer.mu.Lock()
	received := consumer.received
	consumer.mu.Unlock()
	if len(received) != 0 {
		t.Fatalf("expected 0 receptions (unsigned delivery blocked), got %d", len(received))
	}
}

// TestRuntime_WebhookDeliveryUnsignedSecretRefFails_Unresolved verifies that when
// the resolver returns resolved=false, the delivery fails with an error.
func TestRuntime_WebhookDeliveryUnsignedSecretRefFails_Unresolved(t *testing.T) {
	consumer := &fakeWebhookConsumer{}
	server := fakeWebhookServer(t, consumer)
	defer server.Close()

	store := newFakeStore()
	runtime := NewRuntimeWithStore(nil, store)
	runtime.WithWebhookClient(server.Client())
	runtime.WithSecretResolver(&fakeSecretResolver{secret: "", resolved: false})

	_, err := runtime.RegisterWebhookSubscription(
		"secured-webhook", server.URL,
		[]string{"branch.updated"},
		"myrepo", "",
		"secret-ref-1",
	)
	if err != nil {
		t.Fatalf("register webhook: %v", err)
	}

	_, err = runtime.RegisterBranchWatch("myrepo", "main", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	revision := core.RevisionEvent{
		RepoID:     "myrepo",
		Branch:     "main",
		Before:     "abc",
		After:      "def",
		ObservedAt: time.Now().UTC(),
	}
	_, matched, err := runtime.HandleRevision(context.Background(), "forgejo", "unresolved-test-1", revision)
	if err == nil {
		t.Fatal("expected error when secret_ref is unresolved")
	}
	if !matched {
		t.Fatal("expected matched=true")
	}
	if !strings.Contains(err.Error(), "unresolved") {
		t.Errorf("expected error to mention unresolved, got: %v", err)
	}

	// Verify no request was sent
	consumer.mu.Lock()
	received := consumer.received
	consumer.mu.Unlock()
	if len(received) != 0 {
		t.Fatalf("expected 0 receptions (unresolved delivery blocked), got %d", len(received))
	}
}

// TestRuntime_WebhookDeliveryFallbackDeliveryID verifies that when deliveryID is empty,
// the consumer still receives a stable non-empty X-Gito-Delivery header and delivery_id.
func TestRuntime_WebhookDeliveryFallbackDeliveryID(t *testing.T) {
	consumer := &fakeWebhookConsumer{}
	server := fakeWebhookServer(t, consumer)
	defer server.Close()

	store := newFakeStore()
	broadcaster := &fakeBroadcaster{}
	runtime := NewRuntimeWithStore(broadcaster, store)
	runtime.WithWebhookClient(server.Client())

	_, err := runtime.RegisterWebhookSubscription(
		"test-webhook", server.URL,
		[]string{"branch.updated"},
		"nomadcode", "",
		"", // no secret
	)
	if err != nil {
		t.Fatalf("register webhook: %v", err)
	}

	_, err = runtime.RegisterBranchWatch("nomadcode", "develop", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	// Handle with empty deliveryID (simulates scan path)
	revision := core.RevisionEvent{
		RepoID:     "nomadcode",
		Branch:     "develop",
		Before:     "aaa111",
		After:      "bbb222",
		ObservedAt: time.Now().UTC(),
		ChangedFiles: []core.ChangedFile{
			{Path: "README.md", ChangeType: "modified"},
		},
	}
	_, matched, err := runtime.HandleRevision(context.Background(), "forgejo", "", revision)
	if err != nil {
		t.Fatalf("handle revision: %v", err)
	}
	if !matched {
		t.Fatal("expected matched=true")
	}

	consumer.mu.Lock()
	received := consumer.received
	consumer.mu.Unlock()

	if len(received) != 1 {
		t.Fatalf("expected 1 webhook reception, got %d", len(received))
	}

	rec := received[0]
	if rec.deliveryID == "" {
		t.Fatal("X-Gito-Delivery must be non-empty even when provider deliveryID is empty")
	}
	if rec.body == nil {
		t.Fatal("expected non-nil body")
	}
	bodyDeliveryID, ok := rec.body["delivery_id"].(string)
	if !ok || bodyDeliveryID == "" {
		t.Errorf("body delivery_id must be non-empty string, got: %v (ok=%v)", rec.body["delivery_id"], ok)
	}
}

// TestRuntime_WebhookDeliveryFailureAllowsRetry verifies that when webhook delivery fails,
// the provider delivery dedupe record is rolled back so that the same delivery can be
// retried successfully, and the same stable consumer delivery ID is preserved across
// retries even when provider deliveryID is empty.
func TestRuntime_WebhookDeliveryFailureAllowsRetry(t *testing.T) {
	store := newFakeStore()
	broadcaster := &fakeBroadcaster{}
	runtime := NewRuntimeWithStore(broadcaster, store)

	// Use a simpler approach: track receptions in order with a custom server
	mu := sync.Mutex{}
	callNum := 0
	var receptions []webhookReception
	serverWithRetry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		mu.Lock()
		callNum++
		n := callNum
		mu.Unlock()

		body, _ := io.ReadAll(r.Body)
		rec := webhookReception{
			url:        r.URL.String(),
			eventType:  r.Header.Get("X-Gito-Event"),
			deliveryID: r.Header.Get("X-Gito-Delivery"),
			signature:  r.Header.Get("X-Gito-Signature"),
		}
		if len(body) > 0 {
			var m map[string]any
			if json.Unmarshal(body, &m) == nil {
				rec.body = m
			}
		}
		receptions = append(receptions, rec)

		if n == 1 {
			// First call: fail
			w.WriteHeader(http.StatusInternalServerError)
			return
		}
		// Second call: succeed
		w.WriteHeader(http.StatusOK)
	}))
	defer serverWithRetry.Close()

	runtime.WithWebhookClient(serverWithRetry.Client())

	_, err := runtime.RegisterWebhookSubscription(
		"test-webhook", serverWithRetry.URL,
		[]string{"branch.updated"},
		"nomadcode", "",
		"", // no secret
	)
	if err != nil {
		t.Fatalf("register webhook: %v", err)
	}

	_, err = runtime.RegisterBranchWatch("nomadcode", "develop", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	revision := core.RevisionEvent{
		RepoID:     "nomadcode",
		Branch:     "develop",
		Before:     "aaa111",
		After:      "bbb222",
		ObservedAt: time.Now().UTC(),
	}

	// First call: should fail (call 1 in server)
	_, matched, err := runtime.HandleRevision(context.Background(), "forgejo", "", revision)
	if err == nil {
		t.Fatal("expected error on first delivery failure")
	}
	if !matched {
		t.Fatal("expected matched=true on first call")
	}

	mu.Lock()
	firstCallReceptions := len(receptions)
	mu.Unlock()
	if firstCallReceptions != 1 {
		t.Fatalf("expected 1 reception on first call, got %d", firstCallReceptions)
	}

	// Second call with same revision: should succeed (call 2 in server)
	_, matched, err = runtime.HandleRevision(context.Background(), "forgejo", "", revision)
	if err != nil {
		t.Fatalf("expected success on retry, got: %v", err)
	}
	if !matched {
		t.Fatal("expected matched=true on retry")
	}

	mu.Lock()
	finalReceptions := len(receptions)
	mu.Unlock()
	if finalReceptions != 2 {
		t.Fatalf("expected 2 receptions total, got %d", finalReceptions)
	}

	// Verify both receptions have the same X-Gito-Delivery header
	mu.Lock()
	firstDeliveryID := receptions[0].deliveryID
	secondDeliveryID := receptions[1].deliveryID
	mu.Unlock()

	if firstDeliveryID == "" {
		t.Fatal("first reception X-Gito-Delivery must be non-empty")
	}
	if secondDeliveryID == "" {
		t.Fatal("second reception X-Gito-Delivery must be non-empty")
	}
	if firstDeliveryID != secondDeliveryID {
		t.Errorf("X-Gito-Delivery mismatch across retry: first=%q second=%q (expected same stable ID)", firstDeliveryID, secondDeliveryID)
	}

	// Verify body delivery_id also matches
	mu.Lock()
	firstBodyDeliveryID, _ := receptions[0].body["delivery_id"].(string)
	secondBodyDeliveryID, _ := receptions[1].body["delivery_id"].(string)
	mu.Unlock()

	if firstBodyDeliveryID == "" {
		t.Fatal("first reception body delivery_id must be non-empty")
	}
	if secondBodyDeliveryID == "" {
		t.Fatal("second reception body delivery_id must be non-empty")
	}
	if firstBodyDeliveryID != secondBodyDeliveryID {
		t.Errorf("body delivery_id mismatch across retry: first=%q second=%q (expected same stable ID)", firstBodyDeliveryID, secondBodyDeliveryID)
	}

	// Verify the delivery ID is the deterministic fallback format
	expectedFallback := fmt.Sprintf("fallback:%s:%s:%s:%s", revision.RepoID, revision.Branch, revision.Before, revision.After)
	if firstDeliveryID != expectedFallback {
		t.Errorf("X-Gito-Delivery: got %q want %q", firstDeliveryID, expectedFallback)
	}
	if firstBodyDeliveryID != expectedFallback {
		t.Errorf("body delivery_id: got %q want %q", firstBodyDeliveryID, expectedFallback)
	}
}

// TestWebhookDeliveryBranchUpdated is an alias for TestRuntime_WebhookDeliveryBranchUpdated
// to match the SDD test evidence command: go test -run TestWebhookDeliveryBranchUpdated
// --- Reconcile Tests ---

func TestRuntimeReconcileExpectedRevisionConflict(t *testing.T) {
	store := newFakeStore()
	broadcaster := &fakeBroadcaster{}
	runtime := NewRuntimeWithStore(broadcaster, store)

	// Register branch watch so events could match if emitted
	_, err := runtime.RegisterBranchWatch("nomadcode", "develop", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	// Dry-run with expected/current mismatch must return conflict
	req := ReconcileRequest{
		Provider:         "forgejo",
		RepoID:           "nomadcode",
		Branch:           "develop",
		ExpectedRevision: "rev-expected",
		CurrentRevision:  "rev-current",
		TargetRevision:   "rev-target",
	}
	result := runtime.DryRunReconcile(context.Background(), req)

	if !result.Conflict {
		t.Fatal("expected conflict for expected/current revision mismatch")
	}
	if result.Status != ReconcileStatusConflict {
		t.Fatalf("status: got %q want %q", result.Status, ReconcileStatusConflict)
	}
	if result.Applied {
		t.Fatal("expected applied=false for conflict")
	}
	if result.Reason == "" {
		t.Fatal("expected reason to be set")
	}

	// Events and broadcaster must not be affected by dry-run
	if len(runtime.ListEvents()) != 0 {
		t.Fatalf("expected 0 events after dry-run, got %d", len(runtime.ListEvents()))
	}
	if len(broadcaster.envelopes) != 0 {
		t.Fatalf("expected 0 envelopes after dry-run, got %d", len(broadcaster.envelopes))
	}

	// Apply with expected/current mismatch must also return conflict without side effects
	applyReq := req
	applyReq.IdempotencyKey = "conflict-apply-key"
	applyResult := runtime.ApplyReconcile(context.Background(), applyReq)
	if !applyResult.Conflict {
		t.Fatal("expected conflict for apply with expected/current mismatch")
	}
	if applyResult.Applied {
		t.Fatal("expected applied=false for conflict apply")
	}
	if len(runtime.ListEvents()) != 0 {
		t.Fatal("apply conflict must not create events")
	}
	if len(broadcaster.envelopes) != 0 {
		t.Fatal("apply conflict must not broadcast")
	}
}

func TestRuntimeDryRunReconcileReportsDriftConflictProposal(t *testing.T) {
	store := newFakeStore()
	broadcaster := &fakeBroadcaster{}
	runtime := NewRuntimeWithStore(broadcaster, store)

	// Register watch
	_, err := runtime.RegisterBranchWatch("nomadcode", "develop", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	// Conflict case: expected != current
	conflictReq := ReconcileRequest{
		Provider:         "forgejo",
		RepoID:           "nomadcode",
		Branch:           "develop",
		ExpectedRevision: "rev-a",
		CurrentRevision:  "rev-b",
		TargetRevision:   "rev-c",
	}
	conflictResult := runtime.DryRunReconcile(context.Background(), conflictReq)
	if !conflictResult.Conflict {
		t.Fatal("expected conflict status")
	}
	if conflictResult.Status != ReconcileStatusConflict {
		t.Fatalf("conflict status: got %q", conflictResult.Status)
	}

	// Noop case: current == target
	noopReq := ReconcileRequest{
		Provider:        "forgejo",
		RepoID:          "nomadcode",
		Branch:          "develop",
		CurrentRevision: "rev-same",
		TargetRevision:  "rev-same",
	}
	noopResult := runtime.DryRunReconcile(context.Background(), noopReq)
	if noopResult.Status != ReconcileStatusNoop {
		t.Fatalf("noop status: got %q want %q", noopResult.Status, ReconcileStatusNoop)
	}
	if noopResult.Drift {
		t.Fatal("expected drift=false for noop")
	}
	if noopResult.Applied {
		t.Fatal("expected applied=false for dry-run noop")
	}

	// Proposal/drift case: current != target, no expected mismatch
	proposalReq := ReconcileRequest{
		Provider:        "forgejo",
		RepoID:          "nomadcode",
		Branch:          "develop",
		CurrentRevision: "rev-current",
		TargetRevision:  "rev-target",
		ChangedFiles: []core.ChangedFile{
			{Path: "README.md", ChangeType: "modified"},
		},
	}
	proposalResult := runtime.DryRunReconcile(context.Background(), proposalReq)
	if proposalResult.Status != ReconcileStatusProposal {
		t.Fatalf("proposal status: got %q want %q", proposalResult.Status, ReconcileStatusProposal)
	}
	if !proposalResult.Drift {
		t.Fatal("expected drift=true for proposal")
	}
	if proposalResult.Applied {
		t.Fatal("expected applied=false for dry-run proposal")
	}
	if proposalResult.Proposal == nil {
		t.Fatal("expected proposal to be set")
	} else {
		if proposalResult.Proposal.Before != "rev-current" {
			t.Errorf("proposal before: got %q want %q", proposalResult.Proposal.Before, "rev-current")
		}
		if proposalResult.Proposal.After != "rev-target" {
			t.Errorf("proposal after: got %q want %q", proposalResult.Proposal.After, "rev-target")
		}
		if len(proposalResult.Proposal.Changed) != 1 {
			t.Fatalf("expected 1 changed file in proposal, got %d", len(proposalResult.Proposal.Changed))
		}
	}

	// Dry-run must never create events or broadcasts
	if len(runtime.ListEvents()) != 0 {
		t.Fatalf("expected 0 events after dry-run, got %d", len(runtime.ListEvents()))
	}
	if len(broadcaster.envelopes) != 0 {
		t.Fatalf("expected 0 envelopes after dry-run, got %d", len(broadcaster.envelopes))
	}
}

func TestRuntimeApplyReconcileIsIdempotent(t *testing.T) {
	store := newFakeStore()
	broadcaster := &fakeBroadcaster{}
	runtime := NewRuntimeWithStore(broadcaster, store)

	// Register watch
	_, err := runtime.RegisterBranchWatch("nomadcode", "develop", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	req := ReconcileRequest{
		Provider:        "forgejo",
		RepoID:          "nomadcode",
		Branch:          "develop",
		CurrentRevision: "rev-before",
		TargetRevision:  "rev-after",
		IdempotencyKey:  "idempotent-key-1",
		ChangedFiles: []core.ChangedFile{
			{Path: "README.md", ChangeType: "modified"},
		},
		ObservedAt: time.Now().UTC(),
	}

	// First apply: should succeed and create 1 event + 1 envelope
	firstResult := runtime.ApplyReconcile(context.Background(), req)
	if firstResult.Status != ReconcileStatusApplied {
		t.Fatalf("first apply status: got %q want %q", firstResult.Status, ReconcileStatusApplied)
	}
	if !firstResult.Applied {
		t.Fatal("expected applied=true on first apply")
	}
	if firstResult.Duplicate {
		t.Fatal("expected duplicate=false on first apply")
	}
	if len(runtime.ListEvents()) != 1 {
		t.Fatalf("expected 1 event after first apply, got %d", len(runtime.ListEvents()))
	}
	if len(broadcaster.envelopes) != 1 {
		t.Fatalf("expected 1 envelope after first apply, got %d", len(broadcaster.envelopes))
	}

	// Second apply with same idempotency key: should return duplicate without side effects
	secondResult := runtime.ApplyReconcile(context.Background(), req)
	if secondResult.Status != ReconcileStatusDuplicate {
		t.Fatalf("second apply status: got %q want %q", secondResult.Status, ReconcileStatusDuplicate)
	}
	if secondResult.Applied {
		t.Fatal("expected applied=false on duplicate apply")
	}
	if !secondResult.Duplicate {
		t.Fatal("expected duplicate=true on second apply")
	}

	// Side effects must not increase on duplicate
	if len(runtime.ListEvents()) != 1 {
		t.Fatalf("expected 1 event after duplicate apply, got %d", len(runtime.ListEvents()))
	}
	if len(broadcaster.envelopes) != 1 {
		t.Fatalf("expected 1 envelope after duplicate apply, got %d", len(broadcaster.envelopes))
	}

	// Expected/current mismatch apply must return conflict without side effects
	conflictReq := req
	conflictReq.ExpectedRevision = "rev-mismatch"
	conflictResult := runtime.ApplyReconcile(context.Background(), conflictReq)
	if !conflictResult.Conflict {
		t.Fatal("expected conflict for apply with expected mismatch")
	}
	if conflictResult.Applied {
		t.Fatal("expected applied=false for conflict apply")
	}
	if len(runtime.ListEvents()) != 1 {
		t.Fatal("conflict apply must not create additional events")
	}
	if len(broadcaster.envelopes) != 1 {
		t.Fatal("conflict apply must not broadcast additional envelopes")
	}
}

func TestWebhookDeliveryBranchUpdated(t *testing.T) {
	TestRuntime_WebhookDeliveryBranchUpdated(t)
}

func TestWebhookDeliveryRetryBackoffRecordsState(t *testing.T) {
	store := newFakeStore()
	store.webhookDeliveries = &fakeWebhookDeliveryStore{records: make(map[string]core.WebhookDelivery)}
	runtime := NewRuntimeWithStore(nil, store)

	var statusCode int
	var responseError error
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if responseError != nil {
			hj, ok := w.(http.Hijacker)
			if ok {
				conn, _, _ := hj.Hijack()
				conn.Close()
				return
			}
		}
		w.WriteHeader(statusCode)
	}))
	defer server.Close()

	runtime.WithWebhookClient(server.Client())

	sub, err := runtime.RegisterWebhookSubscription(
		"test-webhook", server.URL,
		[]string{"branch.updated"},
		"nomadcode", "",
		"",
	)
	if err != nil {
		t.Fatalf("register webhook: %v", err)
	}

	_, err = runtime.RegisterBranchWatch("nomadcode", "develop", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	revision := core.RevisionEvent{
		RepoID:     "nomadcode",
		Branch:     "develop",
		Before:     "aaa111",
		After:      "bbb222",
		ObservedAt: time.Now().UTC(),
	}

	// 1. Test Retryable Outcome (HTTP 500)
	statusCode = http.StatusInternalServerError
	record, matched, err := runtime.HandleRevision(context.Background(), "forgejo", "del-123", revision)
	if err != nil {
		t.Fatalf("HandleRevision: %v", err)
	}
	if !matched {
		t.Fatal("expected matched to be true")
	}

	dStore := store.WebhookDeliveries().(*fakeWebhookDeliveryStore)
	key := record.ID + ":" + sub.ID
	dStore.mu.Lock()
	del, ok := dStore.records[key]
	dStore.mu.Unlock()
	if !ok {
		t.Fatalf("expected delivery record to be created in store")
	}

	if del.Status != core.WebhookDeliveryRetryable {
		t.Errorf("expected status retryable, got %s", del.Status)
	}
	if del.AttemptCount != 1 {
		t.Errorf("expected attempt count 1, got %d", del.AttemptCount)
	}
	if del.LastStatusCode != 500 {
		t.Errorf("expected last status code 500, got %d", del.LastStatusCode)
	}
	if del.NextAttemptAt.Before(time.Now()) {
		t.Errorf("expected next attempt to be in the future")
	}

	// 2. Test Success Outcome (HTTP 200)
	dStore.mu.Lock()
	del.Status = core.WebhookDeliveryPending
	del.NextAttemptAt = time.Now().Add(-time.Hour)
	dStore.records[key] = del
	dStore.mu.Unlock()

	statusCode = http.StatusOK
	err = runtime.ProcessPendingWebhooks(context.Background())
	if err != nil {
		t.Fatalf("ProcessPendingWebhooks: %v", err)
	}

	dStore.mu.Lock()
	del = dStore.records[key]
	dStore.mu.Unlock()

	if del.Status != core.WebhookDeliverySucceeded {
		t.Errorf("expected status succeeded, got %s", del.Status)
	}
	if del.AttemptCount != 2 {
		t.Errorf("expected attempt count 2, got %d", del.AttemptCount)
	}
	if del.LastStatusCode != 200 {
		t.Errorf("expected last status code 200, got %d", del.LastStatusCode)
	}

	// 3. Test Network Transport Error (should be retryable)
	dStore.mu.Lock()
	del.Status = core.WebhookDeliveryPending
	del.NextAttemptAt = time.Now().Add(-time.Hour)
	dStore.records[key] = del
	dStore.mu.Unlock()

	responseError = fmt.Errorf("network error")
	err = runtime.ProcessPendingWebhooks(context.Background())
	if err != nil {
		t.Fatalf("ProcessPendingWebhooks: %v", err)
	}

	dStore.mu.Lock()
	del = dStore.records[key]
	dStore.mu.Unlock()

	if del.Status != core.WebhookDeliveryRetryable {
		t.Errorf("expected status retryable after transport error, got %s", del.Status)
	}
	if del.AttemptCount != 3 {
		t.Errorf("expected attempt count 3, got %d", del.AttemptCount)
	}
}

// TestWebhookDeliveryDoesNotDispatchUnrelatedPendingDelivery verifies that the immediate
// dispatch path only dispatches the delivery just enqueued for the current event/subscription,
// not an unrelated pending/retryable delivery that happened to be earlier in the global queue.
func TestWebhookDeliveryDoesNotDispatchUnrelatedPendingDelivery(t *testing.T) {
	store := newFakeStore()
	store.webhookDeliveries = &fakeWebhookDeliveryStore{records: make(map[string]core.WebhookDelivery)}
	broadcaster := &fakeBroadcaster{}
	runtime := NewRuntimeWithStore(broadcaster, store)

	mu := sync.Mutex{}
	var receptions []webhookReception
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		body, _ := io.ReadAll(r.Body)
		mu.Lock()
		receptions = append(receptions, webhookReception{
			url:        r.URL.String(),
			eventType:  r.Header.Get("X-Gito-Event"),
			deliveryID: r.Header.Get("X-Gito-Delivery"),
		})
		if len(body) > 0 {
			var m map[string]any
			if json.Unmarshal(body, &m) == nil {
				receptions[len(receptions)-1].body = m
			}
		}
		mu.Unlock()
		w.WriteHeader(http.StatusOK)
	}))
	defer server.Close()

	runtime.WithWebhookClient(server.Client())

	// 1. First subscription for event type "branch.updated" on repo "repo-a".
	subA, err := runtime.RegisterWebhookSubscription("sub-a", server.URL, []string{"branch.updated"}, "repo-a", "", "")
	if err != nil {
		t.Fatalf("register sub-a: %v", err)
	}

	// 2. Second subscription for same event on repo "repo-b" that will receive the new event.
	_, err = runtime.RegisterWebhookSubscription("sub-b", server.URL, []string{"branch.updated"}, "repo-b", "", "")
	if err != nil {
		t.Fatalf("register sub-b: %v", err)
	}

	// 3. Seed an unrelated pending delivery for subA (repo-a) that has an earlier next_attempt_at.
	// This simulates an existing pending/retryable delivery that would normally be picked first
	// by a global PickPendingDeliveries(limit=1) call.
	earlyDelivery := core.WebhookDelivery{
		ID:             "early-delivery-id",
		EventID:        "old-event-id",
		SubscriptionID: subA.ID,
		Status:         core.WebhookDeliveryPending,
		NextAttemptAt:  time.Now().Add(-time.Hour),
	}
	store.webhookDeliveries.records["old-event-id:"+subA.ID] = earlyDelivery

	// Register branch watches so HandleRevision uses the store path.
	_, err = runtime.RegisterBranchWatch("repo-a", "main", "forgejo")
	if err != nil {
		t.Fatalf("register watch repo-a: %v", err)
	}
	_, err = runtime.RegisterBranchWatch("repo-b", "main", "forgejo")
	if err != nil {
		t.Fatalf("register watch repo-b: %v", err)
	}

	// 4. Handle a new revision event for repo-b. This will enqueue a new delivery for subB.
	revision := core.RevisionEvent{
		RepoID:     "repo-b",
		Branch:     "main",
		Before:     "aaa",
		After:      "bbb",
		ObservedAt: time.Now().UTC(),
	}
	_, matched, err := runtime.HandleRevision(context.Background(), "forgejo", "new-delivery-id", revision)
	if err != nil {
		t.Fatalf("HandleRevision: %v", err)
	}
	if !matched {
		t.Fatal("expected matched=true")
	}

	// 5. Verify that only the new delivery (for subB/repo-b) was dispatched.
	// The early delivery for subA should remain untouched.
	mu.Lock()
	defer mu.Unlock()

	if len(receptions) != 1 {
		t.Fatalf("expected 1 webhook reception, got %d", len(receptions))
	}

	rec := receptions[0]
	if rec.body == nil {
		t.Fatal("expected non-nil body")
	}
	repoID, ok := rec.body["repo_id"].(string)
	if !ok || repoID != "repo-b" {
		t.Errorf("expected repo_id=%q, got %v", "repo-b", rec.body["repo_id"])
	}

	// The early delivery should still be pending (not dispatched by the new event).
	store.webhookDeliveries.mu.Lock()
	early, ok := store.webhookDeliveries.records["old-event-id:"+subA.ID]
	store.webhookDeliveries.mu.Unlock()
	if !ok {
		t.Fatal("early delivery record disappeared")
	}
	if early.Status != core.WebhookDeliveryPending {
		t.Errorf("early delivery should still be pending, got %s", early.Status)
	}
}

// TestRuntimeApplyReconcileRetryAfterPublishFailure verifies that when the first
// ApplyReconcile fails during webhook/broadcast side effects, the apply idempotency
// key is NOT consumed, and a retry with the same key succeeds.
func TestRuntimeApplyReconcileRetryAfterPublishFailure(t *testing.T) {
	store := newFakeStore()

	// Register branch watch so the reconcile event matches
	_, err := store.watches.UpsertBranchWatch(context.Background(), core.BranchWatch{
		ID:       "watch-nomadcode-develop",
		Provider: "forgejo",
		RepoID:   "nomadcode",
		Branch:   "develop",
	})
	if err != nil {
		t.Fatalf("upsert branch watch: %v", err)
	}

	// 1. First apply with failing broadcaster -> conflict
	failingBroadcaster := &fakeBroadcaster{err: fmt.Errorf("broadcast failed")}
	rFail := NewRuntimeWithStore(failingBroadcaster, store)

	applyReq := ReconcileRequest{
		Provider:        "forgejo",
		RepoID:          "nomadcode",
		Branch:          "develop",
		CurrentRevision: "rev-before",
		TargetRevision:  "rev-after",
		IdempotencyKey:  "retry-key-1",
		ChangedFiles: []core.ChangedFile{
			{Path: "README.md", ChangeType: "modified"},
		},
		ObservedAt: time.Now().UTC(),
	}

	firstResult := rFail.ApplyReconcile(context.Background(), applyReq)
	if firstResult.Status != ReconcileStatusConflict {
		t.Fatalf("first apply status: got %q want %q", firstResult.Status, ReconcileStatusConflict)
	}
	if !firstResult.Conflict {
		t.Fatal("first apply: expected conflict=true")
	}

	// 2. Second apply with succeeding broadcaster -> applied
	successBroadcaster := &fakeBroadcaster{}
	rSuccess := NewRuntimeWithStore(successBroadcaster, store)

	secondResult := rSuccess.ApplyReconcile(context.Background(), applyReq)
	if secondResult.Status != ReconcileStatusApplied {
		t.Fatalf("second apply status: got %q want %q", secondResult.Status, ReconcileStatusApplied)
	}
	if !secondResult.Applied {
		t.Fatal("second apply: expected applied=true")
	}
	if secondResult.Duplicate {
		t.Fatal("second apply: expected duplicate=false")
	}

	// 3. Third apply with same key -> duplicate (side effects not repeated)
	thirdResult := rSuccess.ApplyReconcile(context.Background(), applyReq)
	if thirdResult.Status != ReconcileStatusDuplicate {
		t.Fatalf("third apply status: got %q want %q", thirdResult.Status, ReconcileStatusDuplicate)
	}
	if thirdResult.Applied {
		t.Fatal("third apply: expected applied=false")
	}
	if !thirdResult.Duplicate {
		t.Fatal("third apply: expected duplicate=true")
	}

	// 4. Events/envelopes count: successful retry creates 1 event + 1 envelope; duplicate creates nothing.
	if len(rSuccess.ListEvents()) != 1 {
		t.Fatalf("expected 1 event total, got %d", len(rSuccess.ListEvents()))
	}
	if len(successBroadcaster.envelopes) != 1 {
		t.Fatalf("expected 1 envelope total, got %d", len(successBroadcaster.envelopes))
	}
}

func TestWebhookDeliveryRecordOutcomeError(t *testing.T) {
	// Create a runtime with a store that has a webhook delivery store which returns an error on RecordOutcome.
	store := newFakeStore()
	store.webhookDeliveries = &fakeWebhookDeliveryStore{
		records:    make(map[string]core.WebhookDelivery),
		outcomeErr: fmt.Errorf("database connection lost"),
	}
	runtime := NewRuntimeWithStore(nil, store)

	// The fake server that succeeds on the HTTP side — so outcome failure is the only failure.
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	}))
	defer server.Close()
	runtime.WithWebhookClient(server.Client())

	// Register subscription and watch.
	_, err := runtime.RegisterWebhookSubscription("test-sub", server.URL, []string{"branch.updated"}, "repo", "", "")
	if err != nil {
		t.Fatalf("register webhook: %v", err)
	}
	_, err = runtime.RegisterBranchWatch("repo", "main", "forgejo")
	if err != nil {
		t.Fatalf("register watch: %v", err)
	}

	// Handle revision — enqueue + dispatch + RecordOutcome.
	revision := core.RevisionEvent{
		RepoID:     "repo",
		Branch:     "main",
		Before:     "aaa",
		After:      "bbb",
		ObservedAt: time.Now().UTC(),
	}
	_, matched, err := runtime.HandleRevision(context.Background(), "forgejo", "test-del-1", revision)
	if !matched {
		t.Fatal("expected matched=true")
	}

	// Because RecordOutcome returns an error, HandleRevision should propagate it.
	// deliverWebhooks calls dispatchAndRecordOutcome which now returns error.
	if err == nil {
		t.Fatal("expected error when RecordOutcome fails, got nil")
	}
	if !strings.Contains(err.Error(), "record webhook delivery outcome") {
		t.Errorf("expected error to contain 'record webhook delivery outcome', got: %v", err)
	}
}
