package service_test

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"sync"
	"testing"
	"time"

	"github.com/pingcap/agent-git-service/internal/db"
	"github.com/pingcap/agent-git-service/internal/gitstore"
	"github.com/pingcap/agent-git-service/internal/service"
	"gorm.io/gorm"
)

func TestMergeGroupBuildDatabaseRollbackCannotPublishOrphanRef(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()
	pr, authCtx, owner := setupProtectedPR(t, svc, tenantHandle, "group-rollback-owner", "group-rollback-repo", false)
	createMergeQueueRuleset(t, svc, authCtx, pr.Repository.FullName)
	queue := db.MergeQueue{RepositoryID: pr.RepositoryID, BaseRef: pr.BaseRef}
	if err := tenantHandle.DB().Create(&queue).Error; err != nil {
		t.Fatal(err)
	}
	entry := db.MergeQueueEntry{
		QueueID: queue.ID, PullRequestID: pr.ID, Position: 1, State: "queued",
		EnqueuedByID: owner.ID, HeadSHA: pr.HeadSHA, BaseSHA: pr.BaseSHA,
	}
	if err := tenantHandle.DB().Create(&entry).Error; err != nil {
		t.Fatal(err)
	}
	baseSHA, err := tenantHandle.Git().HeadSHA(authCtx, pr.Repository.FullName, pr.BaseRef)
	if err != nil {
		t.Fatal(err)
	}
	headSHA, err := tenantHandle.Git().SimulateMerge(authCtx, pr.Repository.FullName, baseSHA, pr.HeadSHA)
	if err != nil {
		t.Fatal(err)
	}
	shortSHA := headSHA
	if len(shortSHA) > 12 {
		shortSHA = shortSHA[:12]
	}
	expectedRef := fmt.Sprintf("refs/heads/gh-readonly-queue/%s/pr-%d-%s", pr.BaseRef, pr.Number, shortSHA)

	callbackName := "fail_merge_group_create_before_ref_publication"
	if err := tenantHandle.DB().Callback().Create().Before("gorm:create").Register(callbackName, func(tx *gorm.DB) {
		if tx.Statement.Table == "merge_groups" {
			tx.AddError(errors.New("forced merge group transaction rollback"))
		}
	}); err != nil {
		t.Fatal(err)
	}
	if _, err := svc.BuildMergeGroupForQueue(authCtx, queue.ID, owner); err == nil {
		t.Fatal("BuildMergeGroupForQueue succeeded despite injected DB rollback")
	}
	if err := tenantHandle.DB().Callback().Create().Remove(callbackName); err != nil {
		t.Fatal(err)
	}

	if _, err := tenantHandle.Git().LookupRef(authCtx, pr.Repository.FullName, expectedRef); !errors.Is(err, gitstore.ErrRefNotFound) {
		t.Fatalf("DB rollback left orphan merge-group ref %s: %v", expectedRef, err)
	}
	var groupCount int64
	if err := tenantHandle.DB().Model(&db.MergeGroup{}).Where("queue_id = ?", queue.ID).Count(&groupCount).Error; err != nil {
		t.Fatal(err)
	}
	if groupCount != 0 {
		t.Fatalf("merge group count = %d, want 0 after rollback", groupCount)
	}
	if err := tenantHandle.DB().First(&entry, entry.ID).Error; err != nil {
		t.Fatal(err)
	}
	if entry.State != "queued" || entry.MergeGroupID != nil {
		t.Fatalf("queue entry escaped rollback: %+v", entry)
	}
}

func TestMergeGroupPublicationSerializesWithDequeueRefCleanup(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()
	pr, authCtx, owner := setupProtectedPR(t, svc, tenantHandle, "group-race-owner", "group-race-repo", false)
	createMergeQueueRuleset(t, svc, authCtx, pr.Repository.FullName)
	protectBranch(t, svc, authCtx, pr.RepositoryID, pr.BaseRef, `{"checks":[{"context":"CI","app_id":999}]}`, "", true)
	queue := db.MergeQueue{RepositoryID: pr.RepositoryID, BaseRef: pr.BaseRef}
	if err := tenantHandle.DB().Create(&queue).Error; err != nil {
		t.Fatal(err)
	}
	entry := db.MergeQueueEntry{
		QueueID: queue.ID, PullRequestID: pr.ID, Position: 1, State: "queued",
		EnqueuedByID: owner.ID, HeadSHA: pr.HeadSHA, BaseSHA: pr.BaseSHA,
	}
	if err := tenantHandle.DB().Create(&entry).Error; err != nil {
		t.Fatal(err)
	}

	publicationLocked := make(chan struct{})
	allowPublication := make(chan struct{})
	var hookOnce sync.Once
	svc.SetMergeGroupBeforeRefPublishHookForTest(func(uint) {
		hookOnce.Do(func() {
			close(publicationLocked)
			<-allowPublication
		})
	})
	buildDone := make(chan error, 1)
	go func() {
		_, err := svc.BuildMergeGroupForQueue(authCtx, queue.ID, owner)
		buildDone <- err
	}()
	select {
	case <-publicationLocked:
	case <-time.After(5 * time.Second):
		t.Fatal("publication did not acquire the merge-group row lock")
	}

	dequeueDone := make(chan error, 1)
	go func() {
		_, err := svc.DequeuePullRequest(authCtx, pr.ID)
		dequeueDone <- err
	}()
	select {
	case err := <-dequeueDone:
		t.Fatalf("dequeue bypassed the publication lock: %v", err)
	case <-time.After(100 * time.Millisecond):
	}
	close(allowPublication)
	if err := <-buildDone; err != nil {
		t.Fatal(err)
	}
	if err := <-dequeueDone; err != nil {
		t.Fatal(err)
	}

	var group db.MergeGroup
	if err := tenantHandle.DB().Where("queue_id = ?", queue.ID).Order("id DESC").First(&group).Error; err != nil {
		t.Fatal(err)
	}
	if group.TerminalReason != "dequeued" || group.RefPublishedAt == nil || group.RefDeletedAt == nil {
		t.Fatalf("serialized terminal group = %+v", group)
	}
	if _, err := tenantHandle.Git().LookupRef(authCtx, pr.Repository.FullName, group.HeadRef); !errors.Is(err, gitstore.ErrRefNotFound) {
		t.Fatalf("published merge-group ref was orphaned: %v", err)
	}
}

func TestRecoverMergeGroupChecksRequestedOutboxExactlyOnce(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()
	pr, authCtx, owner := setupProtectedPR(t, svc, tenantHandle, "group-checks-owner", "group-checks-repo", false)
	workflowSHA, err := tenantHandle.Git().WriteFile(authCtx, pr.Repository.FullName, pr.HeadRef, ".github/workflows/ci.yml", "add CI", []byte("name: CI\non:\n  pull_request: &main-target\n    branches: [main]\n  merge_group: *main-target\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps: []\n"))
	if err != nil {
		t.Fatal(err)
	}
	workflowSHA, err = tenantHandle.Git().WriteFile(authCtx, pr.Repository.FullName, pr.HeadRef, ".github/workflows/release-only.yml", "add release CI", []byte("name: Release CI\non:\n  merge_group:\n    branches: [release]\njobs: {}\n"))
	if err != nil {
		t.Fatal(err)
	}
	pr.HeadSHA = workflowSHA
	if err := tenantHandle.DB().Model(&db.PullRequest{}).Where("id = ?", pr.ID).Update("head_sha", workflowSHA).Error; err != nil {
		t.Fatal(err)
	}
	createMergeQueueRuleset(t, svc, authCtx, pr.Repository.FullName)
	protectBranch(t, svc, authCtx, pr.RepositoryID, pr.BaseRef, `{"checks":[{"context":"CI","app_id":999}]}`, "", true)

	queue := db.MergeQueue{RepositoryID: pr.RepositoryID, BaseRef: pr.BaseRef}
	if err := tenantHandle.DB().Create(&queue).Error; err != nil {
		t.Fatal(err)
	}
	entry := db.MergeQueueEntry{
		QueueID: queue.ID, PullRequestID: pr.ID, Position: 1, State: "awaiting_checks",
		EnqueuedByID: owner.ID, HeadSHA: pr.HeadSHA, BaseSHA: pr.BaseSHA,
	}
	if err := tenantHandle.DB().Create(&entry).Error; err != nil {
		t.Fatal(err)
	}
	baseSHA, err := tenantHandle.Git().HeadSHA(authCtx, pr.Repository.FullName, pr.BaseRef)
	if err != nil {
		t.Fatal(err)
	}
	headSHA, err := tenantHandle.Git().SimulateMerge(authCtx, pr.Repository.FullName, baseSHA, pr.HeadSHA)
	if err != nil {
		t.Fatal(err)
	}
	now := time.Now().UTC()
	group := db.MergeGroup{
		QueueID: queue.ID, HeadEntryID: entry.ID, EntryCount: 1, ActorID: owner.ID,
		HeadSHA: headSHA, HeadRef: "refs/heads/gh-readonly-queue/main/pr-1-checks-recovery",
		BaseSHA: baseSHA, BaseRef: "refs/heads/main", Status: "checks_requested", RefPublishedAt: &now,
	}
	if err := tenantHandle.DB().Create(&group).Error; err != nil {
		t.Fatal(err)
	}
	if err := tenantHandle.DB().Model(&entry).Update("merge_group_id", group.ID).Error; err != nil {
		t.Fatal(err)
	}
	if err := tenantHandle.Git().UpdateRef(authCtx, pr.Repository.FullName, group.HeadRef, group.HeadSHA); err != nil {
		t.Fatal(err)
	}
	event := db.MergeGroupLifecycleEvent{
		MergeGroupID: group.ID, Kind: "checks_requested", ActorID: &owner.ID,
	}
	if err := tenantHandle.DB().Create(&event).Error; err != nil {
		t.Fatal(err)
	}
	hook, server := createMergeGroupTestWebhook(t, svc, authCtx, pr.RepositoryID)
	defer server.Close()

	repoPath, err := tenantHandle.Git().GetRepoPath(authCtx, pr.Repository.FullName)
	if err != nil {
		t.Fatal(err)
	}
	offlinePath := repoPath + ".offline"
	if err := os.Rename(repoPath, offlinePath); err != nil {
		t.Fatal(err)
	}
	if err := svc.RecoverPullRequestMergeWork(authCtx); err != nil {
		t.Fatal(err)
	}
	if err := os.Rename(offlinePath, repoPath); err != nil {
		t.Fatal(err)
	}
	if err := tenantHandle.DB().First(&event, event.ID).Error; err != nil {
		t.Fatal(err)
	}
	if event.ActionsDispatchedAt != nil {
		t.Fatal("transient event-tree read was acknowledged instead of left for retry")
	}

	if err := svc.RecoverPullRequestMergeWork(authCtx); err != nil {
		t.Fatal(err)
	}
	assertChecksRequestedRecovered(t, svc, authCtx, pr.RepositoryID, hook.ID, group.ID)
	var discovered db.Workflow
	if err := tenantHandle.DB().Where("repository_id = ? AND path = ?", pr.RepositoryID, ".github/workflows/ci.yml").First(&discovered).Error; err != nil {
		t.Fatalf("workflow present only in merge-group SHA was not discovered: %v", err)
	}
	var releaseOnlyCount int64
	if err := tenantHandle.DB().Model(&db.Workflow{}).Where("repository_id = ? AND path = ?", pr.RepositoryID, ".github/workflows/release-only.yml").Count(&releaseOnlyCount).Error; err != nil {
		t.Fatal(err)
	}
	if releaseOnlyCount != 0 {
		t.Fatalf("release-only workflow was discovered for main merge group")
	}
	if err := tenantHandle.DB().Delete(&discovered).Error; err != nil {
		t.Fatal(err)
	}
	if err := tenantHandle.DB().Model(&db.MergeGroupLifecycleEvent{}).Where("id = ?", event.ID).Update("actions_dispatched_at", nil).Error; err != nil {
		t.Fatal(err)
	}

	if err := svc.RecoverPullRequestMergeWork(authCtx); err != nil {
		t.Fatal(err)
	}
	assertChecksRequestedRecovered(t, svc, authCtx, pr.RepositoryID, hook.ID, group.ID)
	var rebuilt db.Workflow
	if err := tenantHandle.DB().Where("repository_id = ? AND path = ?", pr.RepositoryID, discovered.Path).First(&rebuilt).Error; err != nil {
		t.Fatalf("event workflow catalog identity was not rebuilt: %v", err)
	}
	if rebuilt.ID == discovered.ID {
		t.Fatal("test did not rebuild the mutable workflow catalog identity")
	}
}

func TestPullRequestRecoveryLoopReclaimsDurableMergeGroupWorkflowRuns(t *testing.T) {
	svc, tenantHandle, cleanup := setupExecutableWorkflowTestService(t, 2*time.Second)
	defer cleanup()
	pr, authCtx, owner := setupProtectedPR(t, svc, tenantHandle, "workflow-recovery-owner", "workflow-recovery-repo", false)
	svc.PullRequestMergeRecoveryInterval = 20 * time.Millisecond
	svc.PullRequestMergeRecoveryGracePeriod = 20 * time.Millisecond

	for _, path := range []string{".github/workflows/queued.yml", ".github/workflows/abandoned.yml"} {
		if _, err := tenantHandle.Git().WriteFile(authCtx, pr.Repository.FullName, pr.BaseRef, path, "add workflow", []byte("name: durable\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo recovered\n")); err != nil {
			t.Fatal(err)
		}
	}
	definitionSHA, err := tenantHandle.Git().HeadSHA(authCtx, pr.Repository.FullName, pr.BaseRef)
	if err != nil {
		t.Fatal(err)
	}
	if err := svc.SyncWorkflowsFromRepo(authCtx, pr.Repository.FullName); err != nil {
		t.Fatal(err)
	}
	var workflows []db.Workflow
	if err := tenantHandle.DB().Where("repository_id = ?", pr.RepositoryID).Order("id ASC").Find(&workflows).Error; err != nil || len(workflows) != 2 {
		t.Fatalf("workflows = %+v, err=%v", workflows, err)
	}

	queue := db.MergeQueue{RepositoryID: pr.RepositoryID, BaseRef: pr.BaseRef}
	if err := tenantHandle.DB().Create(&queue).Error; err != nil {
		t.Fatal(err)
	}
	group := db.MergeGroup{
		QueueID: queue.ID, HeadEntryID: 1, EntryCount: 1, ActorID: owner.ID,
		HeadSHA: definitionSHA, HeadRef: "refs/heads/gh-readonly-queue/main/pr-1-workflow-recovery",
		BaseSHA: pr.BaseSHA, BaseRef: "refs/heads/main", Status: "stale", TerminalReason: "invalidated",
	}
	if err := tenantHandle.DB().Create(&group).Error; err != nil {
		t.Fatal(err)
	}
	queuedRun := db.WorkflowRun{
		RepositoryID: pr.RepositoryID, WorkflowID: workflows[0].ID, ActorID: &owner.ID,
		IntegrationID: githubActionsAppIDForTest(), MergeGroupID: &group.ID,
		Name: workflows[0].Name, WorkflowPath: workflows[0].Path, HeadBranch: group.HeadRef, HeadSHA: group.HeadSHA,
		Status: db.RunQueued, Event: "merge_group", RunNumber: 1, RunAttempt: 1,
	}
	if err := tenantHandle.DB().Create(&queuedRun).Error; err != nil {
		t.Fatal(err)
	}
	leaseExpiresAt := time.Now().UTC().Add(150 * time.Millisecond)
	abandonedRun := db.WorkflowRun{
		RepositoryID: pr.RepositoryID, WorkflowID: workflows[1].ID, ActorID: &owner.ID,
		IntegrationID: githubActionsAppIDForTest(), MergeGroupID: &group.ID,
		Name: workflows[1].Name, WorkflowPath: workflows[1].Path, HeadBranch: group.HeadRef, HeadSHA: group.HeadSHA,
		Status: db.RunInProgress, Event: "merge_group", RunNumber: 1, RunAttempt: 1,
		ExecutionLeaseID: "dead-process", ExecutionLeaseExpiresAt: &leaseExpiresAt,
	}
	if err := tenantHandle.DB().Create(&abandonedRun).Error; err != nil {
		t.Fatal(err)
	}

	// Fail the first live recovery scan. The periodic worker must retry this
	// transient error without a restart or a direct recovery call from the test.
	callbackName := "test:fail-first-workflow-recovery-query"
	injected := make(chan struct{})
	var injectOnce sync.Once
	if err := tenantHandle.DB().Callback().Query().Before("gorm:query").Register(callbackName, func(tx *gorm.DB) {
		if tx.Statement.Schema == nil || tx.Statement.Schema.Table != "workflow_runs" {
			return
		}
		injectOnce.Do(func() {
			tx.AddError(errors.New("forced transient workflow recovery failure"))
			close(injected)
		})
	}); err != nil {
		t.Fatal(err)
	}
	if err := svc.StartPullRequestMergeRecovery(authCtx); err != nil {
		t.Fatal(err)
	}
	select {
	case <-injected:
	case <-time.After(5 * time.Second):
		t.Fatal("periodic recovery did not reach the injected transient failure")
	}
	if err := tenantHandle.DB().Callback().Query().Remove(callbackName); err != nil {
		t.Fatal(err)
	}
	deadline := time.Now().Add(5 * time.Second)
	for time.Now().Before(deadline) {
		var runs []db.WorkflowRun
		if err := tenantHandle.DB().Where("id IN ?", []uint{queuedRun.ID, abandonedRun.ID}).Order("id ASC").Find(&runs).Error; err != nil {
			t.Fatal(err)
		}
		if len(runs) == 2 && runs[0].Status == db.RunCompleted && runs[1].Status == db.RunCompleted {
			if runs[0].RunAttempt != 1 {
				t.Fatalf("queued run attempt = %d, want 1", runs[0].RunAttempt)
			}
			if runs[1].RunAttempt != 2 {
				t.Fatalf("abandoned run attempt = %d, want 2", runs[1].RunAttempt)
			}
			return
		}
		time.Sleep(20 * time.Millisecond)
	}
	t.Fatal("durable workflow runs were not recovered by the live periodic worker")
}

func githubActionsAppIDForTest() *int64 {
	id := int64(15368)
	return &id
}

func TestRecoverMergeGroupDestroyedOutboxReasonsAndRefCleanup(t *testing.T) {
	for _, test := range []struct {
		status             string
		reason             string
		legacyWithoutActor bool
	}{
		{status: "merged", reason: "merged"},
		{status: "failed", reason: "dequeued"},
		{status: "stale", reason: "invalidated", legacyWithoutActor: true},
	} {
		t.Run(test.reason, func(t *testing.T) {
			svc, tenantHandle, cleanup := setupTestService(t)
			defer cleanup()
			pr, authCtx, owner := setupProtectedPR(t, svc, tenantHandle, "group-"+test.reason+"-owner", "group-"+test.reason+"-repo", false)
			queue := db.MergeQueue{RepositoryID: pr.RepositoryID, BaseRef: pr.BaseRef}
			if err := tenantHandle.DB().Create(&queue).Error; err != nil {
				t.Fatal(err)
			}
			entry := db.MergeQueueEntry{
				QueueID: queue.ID, PullRequestID: pr.ID, Position: 1, State: test.status,
				EnqueuedByID: owner.ID, HeadSHA: pr.HeadSHA, BaseSHA: pr.BaseSHA,
			}
			if err := tenantHandle.DB().Create(&entry).Error; err != nil {
				t.Fatal(err)
			}
			now := time.Now().UTC()
			group := db.MergeGroup{
				QueueID: queue.ID, HeadEntryID: entry.ID, EntryCount: 1,
				HeadSHA: pr.HeadSHA, HeadRef: fmt.Sprintf("refs/heads/gh-readonly-queue/main/pr-1-%s", test.reason),
				BaseSHA: pr.BaseSHA, BaseRef: "refs/heads/main", Status: test.status,
				TerminalReason: test.reason, RefPublishedAt: &now,
			}
			if !test.legacyWithoutActor {
				group.ActorID = owner.ID
			}
			if err := tenantHandle.DB().Create(&group).Error; err != nil {
				t.Fatal(err)
			}
			if err := tenantHandle.Git().UpdateRef(authCtx, pr.Repository.FullName, group.HeadRef, group.HeadSHA); err != nil {
				t.Fatal(err)
			}
			if !test.legacyWithoutActor {
				event := db.MergeGroupLifecycleEvent{
					MergeGroupID: group.ID, Kind: "destroyed", Reason: test.reason, ActorID: &owner.ID,
				}
				if err := tenantHandle.DB().Create(&event).Error; err != nil {
					t.Fatal(err)
				}
			}
			hook, server := createMergeGroupTestWebhook(t, svc, authCtx, pr.RepositoryID)
			defer server.Close()

			for recoveryAttempt := 0; recoveryAttempt < 2; recoveryAttempt++ {
				if err := svc.RecoverPullRequestMergeWork(authCtx); err != nil {
					t.Fatal(err)
				}
				assertDestroyedRecovered(t, svc, authCtx, pr, hook.ID, group, test.reason)
			}
		})
	}
}

func TestTerminalMergeGroupWithoutPublishedRefIsNotMarkedDeleted(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()
	pr, authCtx, owner := setupProtectedPR(t, svc, tenantHandle, "never-published-owner", "never-published-repo", false)
	queue := db.MergeQueue{RepositoryID: pr.RepositoryID, BaseRef: pr.BaseRef}
	if err := tenantHandle.DB().Create(&queue).Error; err != nil {
		t.Fatal(err)
	}
	group := db.MergeGroup{
		QueueID: queue.ID, HeadEntryID: 1, EntryCount: 1, ActorID: owner.ID,
		HeadSHA: pr.HeadSHA, HeadRef: "refs/heads/gh-readonly-queue/main/pr-1-never-published",
		BaseSHA: pr.BaseSHA, BaseRef: "refs/heads/main", Status: "stale", TerminalReason: "dequeued",
	}
	if err := tenantHandle.DB().Create(&group).Error; err != nil {
		t.Fatal(err)
	}
	event := db.MergeGroupLifecycleEvent{
		MergeGroupID: group.ID, Kind: "destroyed", Reason: "dequeued", ActorID: &owner.ID,
	}
	if err := tenantHandle.DB().Create(&event).Error; err != nil {
		t.Fatal(err)
	}

	if err := svc.RecoverPullRequestMergeWork(authCtx); err != nil {
		t.Fatal(err)
	}
	if err := tenantHandle.DB().First(&group, group.ID).Error; err != nil {
		t.Fatal(err)
	}
	if group.RefDeletedAt != nil {
		t.Fatalf("never-published ref was recorded as deleted at %v", group.RefDeletedAt)
	}
}

func TestDequeueStackMarksTargetDequeuedAndDescendantInvalidated(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()
	repo, authCtx, _, prs := setupAsyncMergeStack(t, svc, tenantHandle, "dequeue-reasons", 3)
	createMergeQueueRuleset(t, svc, authCtx, repo.FullName)
	protectBranch(t, svc, authCtx, repo.ID, repo.DefaultBranch, `{"checks":[{"context":"CI","app_id":999}]}`, "", true)
	entries, err := svc.EnqueuePullRequest(authCtx, prs[2].ID, prs[2].HeadSHA, false)
	if err != nil || len(entries) != 3 {
		t.Fatalf("EnqueuePullRequest = %+v, %v", entries, err)
	}
	deadline := time.Now().Add(10 * time.Second)
	for time.Now().Before(deadline) {
		var groupCount int64
		if err := tenantHandle.DB().Model(&db.MergeGroup{}).Where("queue_id = ? AND status = ?", entries[0].QueueID, "checks_requested").Count(&groupCount).Error; err != nil {
			t.Fatal(err)
		}
		if groupCount == 3 {
			break
		}
		time.Sleep(20 * time.Millisecond)
	}
	if _, err := svc.DequeuePullRequest(authCtx, prs[1].ID); err != nil {
		t.Fatal(err)
	}

	for _, expected := range []struct {
		entryID uint
		reason  string
	}{
		{entryID: entries[1].ID, reason: "dequeued"},
		{entryID: entries[2].ID, reason: "invalidated"},
	} {
		var group db.MergeGroup
		if err := tenantHandle.DB().Where("queue_id = ? AND head_entry_id = ?", entries[0].QueueID, expected.entryID).
			Order("created_at DESC").First(&group).Error; err != nil {
			t.Fatal(err)
		}
		if group.Status != "stale" || group.TerminalReason != expected.reason || group.RefDeletedAt == nil {
			t.Fatalf("terminal group for entry %d = %+v, want reason %s and deleted ref", expected.entryID, group, expected.reason)
		}
		var event db.MergeGroupLifecycleEvent
		if err := tenantHandle.DB().Where("merge_group_id = ? AND kind = ?", group.ID, "destroyed").First(&event).Error; err != nil {
			t.Fatal(err)
		}
		if event.Reason != expected.reason {
			t.Fatalf("destroyed event = %+v, want reason %s", event, expected.reason)
		}
	}
}

func createMergeQueueRuleset(t testing.TB, svc *service.Service, ctx context.Context, repoFullName string) {
	t.Helper()
	if err := svc.CreateRuleset(ctx, repoFullName, &db.Ruleset{
		Name: "merge queue", Target: "branch", Enforcement: "active",
		ConditionsJSON: `{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}}`,
		RulesJSON:      testMergeQueueRules,
	}); err != nil {
		t.Fatal(err)
	}
}

func createMergeGroupTestWebhook(t testing.TB, svc *service.Service, ctx context.Context, repositoryID uint) (*db.Webhook, *httptest.Server) {
	t.Helper()
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusNoContent)
	}))
	hook := &db.Webhook{
		RepositoryID: repositoryID, Name: "web", Active: true,
		EventsJSON: `["merge_group"]`, ConfigJSON: `{"url":"` + server.URL + `","content_type":"json"}`,
	}
	if err := svc.CreateWebhook(ctx, hook); err != nil {
		server.Close()
		t.Fatal(err)
	}
	return hook, server
}

func assertChecksRequestedRecovered(t testing.TB, svc *service.Service, ctx context.Context, repositoryID, hookID, groupID uint) {
	t.Helper()
	var event db.MergeGroupLifecycleEvent
	database := svc.DBForCtx(ctx)
	if err := database.Where("merge_group_id = ? AND kind = ?", groupID, "checks_requested").First(&event).Error; err != nil {
		t.Fatal(err)
	}
	if event.ActionsDispatchedAt == nil {
		t.Fatalf("checks_requested Actions work was not dispatched: %+v", event)
	}
	var eventCount, runCount int64
	if err := database.Model(&db.MergeGroupLifecycleEvent{}).Where("merge_group_id = ? AND kind = ?", groupID, "checks_requested").Count(&eventCount).Error; err != nil {
		t.Fatal(err)
	}
	if err := database.Model(&db.WorkflowRun{}).Where("merge_group_id = ? AND event = ?", groupID, "merge_group").Count(&runCount).Error; err != nil {
		t.Fatal(err)
	}
	if eventCount != 1 || runCount != 1 {
		t.Fatalf("recovered logical checks events = %d, workflow runs = %d; want 1, 1", eventCount, runCount)
	}
	deliveries, err := svc.ListHookDeliveries(ctx, repositoryID, hookID)
	if err != nil {
		t.Fatal(err)
	}
	if len(deliveries) != 0 {
		t.Fatalf("App-only merge_group event leaked into repository-hook deliveries: %+v", deliveries)
	}
}

func assertDestroyedRecovered(t testing.TB, svc *service.Service, ctx context.Context, pr db.PullRequest, hookID uint, group db.MergeGroup, reason string) {
	t.Helper()
	var stored db.MergeGroup
	database := svc.DBForCtx(ctx)
	if err := database.First(&stored, group.ID).Error; err != nil {
		t.Fatal(err)
	}
	if stored.RefDeletedAt == nil {
		t.Fatal("terminal merge-group ref deletion was not persisted")
	}
	git, err := svc.GitForCtx(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := git.LookupRef(ctx, pr.Repository.FullName, group.HeadRef); !errors.Is(err, gitstore.ErrRefNotFound) {
		t.Fatalf("terminal merge-group ref still exists: %v", err)
	}
	var events []db.MergeGroupLifecycleEvent
	if err := database.Where("merge_group_id = ? AND kind = ?", group.ID, "destroyed").Find(&events).Error; err != nil {
		t.Fatal(err)
	}
	if len(events) != 1 || events[0].Reason != reason {
		t.Fatalf("destroyed logical events = %+v, want one event with reason %q", events, reason)
	}
	deliveries, err := svc.ListHookDeliveries(ctx, pr.RepositoryID, hookID)
	if err != nil {
		t.Fatal(err)
	}
	if len(deliveries) != 0 {
		t.Fatalf("App-only merge_group event leaked into repository-hook deliveries: %+v", deliveries)
	}
}
