package service_test

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"sync/atomic"
	"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"
	"github.com/pingcap/agent-git-service/internal/testharness"
	"gorm.io/gorm"
)

const testMergeQueueRules = `[{"type":"merge_queue","parameters":{"check_response_timeout_minutes":60,"grouping_strategy":"ALLGREEN","merge_method":"SQUASH","max_entries_to_build":5,"max_entries_to_merge":5,"min_entries_to_merge":1,"min_entries_to_merge_wait_minutes":5}}]`

func TestSubmitAsyncMergeAlreadyQueuedDoesNotCreateRequestAndReturnsExistingRequest(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()

	pr, authCtx, _ := setupProtectedPR(t, svc, tenantHandle, "async-queued-owner", "async-queued-repo", false)
	if err := svc.CreateRuleset(authCtx, pr.Repository.FullName, &db.Ruleset{
		Name: "merge queue", Target: "branch", Enforcement: "active",
		ConditionsJSON: `{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}}`,
		RulesJSON:      testMergeQueueRules,
	}); err != nil {
		t.Fatalf("CreateRuleset: %v", err)
	}
	queue := db.MergeQueue{RepositoryID: pr.RepositoryID, BaseRef: pr.BaseRef}
	if err := tenantHandle.DB().Create(&queue).Error; err != nil {
		t.Fatalf("create queue: %v", err)
	}
	entry := db.MergeQueueEntry{
		QueueID: queue.ID, PullRequestID: pr.ID, Position: 1, State: "queued",
		EnqueuedByID: pr.AuthorID, HeadSHA: pr.HeadSHA, BaseSHA: pr.BaseSHA,
	}
	if err := tenantHandle.DB().Create(&entry).Error; err != nil {
		t.Fatalf("create queue entry: %v", err)
	}

	first, err := svc.SubmitAsyncMerge(authCtx, pr.Repository.FullName, pr.Number, service.SubmitAsyncMergeInput{})
	if err != nil {
		t.Fatalf("SubmitAsyncMerge for queued PR: %v", err)
	}
	if !first.AlreadyQueued || first.Request.Status != service.AsyncMergeEnqueued || first.Request.UUID != "" {
		t.Fatalf("queued result = %+v", first)
	}
	var count int64
	if err := tenantHandle.DB().Model(&db.AsyncMergeRequest{}).Where("pull_request_id = ?", pr.ID).Count(&count).Error; err != nil {
		t.Fatal(err)
	}
	if count != 0 {
		t.Fatalf("async request count = %d, want 0 for already-queued response", count)
	}
	now := time.Now().UTC()
	existing := db.AsyncMergeRequest{
		UUID: "existing-request", RepositoryID: pr.RepositoryID, PullRequestID: pr.ID, ActorID: pr.AuthorID,
		Status: service.AsyncMergePending, MergeMethod: "squash", MergeAction: service.AsyncMergeActionDefault,
		EffectiveMergeAction: service.AsyncMergeActionDirect, ExpectedHeadSHA: pr.HeadSHA,
		CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now.Add(-2 * time.Hour), ExpiresAt: now.Add(-time.Hour),
	}
	if err := tenantHandle.DB().Create(&existing).Error; err != nil {
		t.Fatal(err)
	}
	if err := tenantHandle.DB().First(&existing, existing.ID).Error; err != nil {
		t.Fatal(err)
	}
	updatedAtBeforeRetry := existing.UpdatedAt

	second, err := svc.SubmitAsyncMerge(authCtx, pr.Repository.FullName, pr.Number, service.SubmitAsyncMergeInput{})
	if err != nil {
		t.Fatalf("SubmitAsyncMerge existing request: %v", err)
	}
	if !second.ExistingActive || second.Request.UUID != existing.UUID {
		t.Fatalf("existing result = %+v, want UUID %q", second, existing.UUID)
	}
	if !second.Request.ExpiresAt.After(now) {
		t.Fatalf("existing request retention was not refreshed: %v", second.Request.ExpiresAt)
	}
	if !second.Request.UpdatedAt.Equal(updatedAtBeforeRetry) {
		t.Fatalf("duplicate submission changed execution progress time: %v -> %v", updatedAtBeforeRetry, second.Request.UpdatedAt)
	}
	retained, err := svc.GetAsyncMerge(authCtx, pr.Repository.FullName, pr.Number, existing.UUID)
	if err != nil || retained.ID != existing.ID {
		t.Fatalf("refreshed existing request = %+v, %v", retained, err)
	}
	if !retained.UpdatedAt.Equal(updatedAtBeforeRetry) {
		t.Fatalf("duplicate submission persisted a new execution progress time: %v -> %v", updatedAtBeforeRetry, retained.UpdatedAt)
	}
	if err := tenantHandle.DB().Model(&db.AsyncMergeRequest{}).Where("pull_request_id = ?", pr.ID).Count(&count).Error; err != nil {
		t.Fatal(err)
	}
	if count != 1 {
		t.Fatalf("async request count = %d, want one refreshed active request", count)
	}
}

func TestRecoverPullRequestMergeWorkResumesPendingDirectMerge(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()

	pr, authCtx, owner := setupProtectedPR(t, svc, tenantHandle, "recover-async-owner", "recover-async-repo", false)
	now := time.Now().UTC()
	request := db.AsyncMergeRequest{
		UUID: "recover-request", RepositoryID: pr.RepositoryID, PullRequestID: pr.ID, ActorID: owner.ID,
		Status: service.AsyncMergePending, MergeMethod: "squash", MergeAction: service.AsyncMergeActionDirect,
		EffectiveMergeAction: service.AsyncMergeActionDirect, ExpectedHeadSHA: pr.HeadSHA,
		CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now.Add(-2 * time.Hour), ExpiresAt: now.Add(-time.Hour),
	}
	if err := tenantHandle.DB().Create(&request).Error; err != nil {
		t.Fatal(err)
	}
	if _, err := svc.GetAsyncMerge(authCtx, pr.Repository.FullName, pr.Number, request.UUID); !errors.Is(err, service.ErrNotFound) {
		t.Fatalf("expired request should be hidden before recovery, got %v", err)
	}
	if err := svc.RecoverPullRequestMergeWork(authCtx); err != nil {
		t.Fatalf("RecoverPullRequestMergeWork: %v", err)
	}
	deadline := time.Now().Add(10 * time.Second)
	for time.Now().Before(deadline) {
		if err := tenantHandle.DB().First(&request, request.ID).Error; err != nil {
			t.Fatal(err)
		}
		if request.Status != service.AsyncMergePending {
			break
		}
		time.Sleep(20 * time.Millisecond)
	}
	if request.Status != service.AsyncMergeMerged || request.ResultSHA == "" {
		t.Fatalf("recovered request = status %q, sha %q, failure %q", request.Status, request.ResultSHA, request.FailureMessage)
	}
	if !request.ExpiresAt.After(time.Now().UTC()) {
		t.Fatalf("recovered request retention was not renewed: %v", request.ExpiresAt)
	}
	if _, err := svc.GetAsyncMerge(authCtx, pr.Repository.FullName, pr.Number, request.UUID); err != nil {
		t.Fatalf("recovered request is not publicly visible: %v", err)
	}
}

func TestRecoverPullRequestMergeWorkResumesQueuedBuild(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()

	pr, authCtx, owner := setupProtectedPR(t, svc, tenantHandle, "recover-queue-owner", "recover-queue-repo", false)
	protectBranch(t, svc, authCtx, pr.RepositoryID, pr.BaseRef, `{"contexts":["CI"]}`, "", true)
	if err := svc.CreateRuleset(authCtx, pr.Repository.FullName, &db.Ruleset{
		Name: "merge queue", Target: "branch", Enforcement: "active",
		ConditionsJSON: `{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}}`,
		RulesJSON:      testMergeQueueRules,
	}); err != nil {
		t.Fatalf("CreateRuleset: %v", err)
	}
	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)
	}
	if err := svc.RecoverPullRequestMergeWork(authCtx); err != nil {
		t.Fatalf("RecoverPullRequestMergeWork: %v", err)
	}
	deadline := time.Now().Add(10 * time.Second)
	for time.Now().Before(deadline) {
		if err := tenantHandle.DB().First(&entry, entry.ID).Error; err != nil {
			t.Fatal(err)
		}
		if entry.State == "awaiting_checks" {
			break
		}
		time.Sleep(20 * time.Millisecond)
	}
	if entry.State != "awaiting_checks" {
		t.Fatalf("recovered queue entry state = %q, want awaiting_checks", entry.State)
	}
}

func TestRecoverPullRequestMergeWorkRetriesCrashBeforeGitPushAndCleansRef(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()

	pr, authCtx, owner := setupProtectedPR(t, svc, tenantHandle, "recover-before-push", "recover-before-push-repo", false)
	if err := svc.CreateRuleset(authCtx, pr.Repository.FullName, &db.Ruleset{
		Name: "merge queue", Target: "branch", Enforcement: "active",
		ConditionsJSON: `{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}}`, RulesJSON: testMergeQueueRules,
	}); err != nil {
		t.Fatal(err)
	}
	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: "locked",
		EnqueuedByID: owner.ID, HeadSHA: pr.HeadSHA, BaseSHA: pr.BaseSHA,
	}
	if err := tenantHandle.DB().Create(&entry).Error; err != nil {
		t.Fatal(err)
	}
	ref := "refs/heads/gh-readonly-queue/main/pr-1-recovery-before-push"
	group := db.MergeGroup{
		QueueID: queue.ID, HeadEntryID: entry.ID, EntryCount: 1, HeadSHA: pr.HeadSHA,
		HeadRef: ref, BaseSHA: pr.BaseSHA, BaseRef: pr.BaseRef, Status: "merging",
	}
	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, ref, pr.HeadSHA); err != nil {
		t.Fatal(err)
	}
	if err := svc.RecoverPullRequestMergeWork(authCtx); err != nil {
		t.Fatal(err)
	}
	stored, err := svc.GetPRByID(authCtx, pr.ID)
	if err != nil || !stored.Merged {
		t.Fatalf("recovered pull request = %+v, %v", stored, err)
	}
	if _, err := tenantHandle.Git().LookupRef(authCtx, pr.Repository.FullName, ref); !errors.Is(err, gitstore.ErrRefNotFound) {
		t.Fatalf("terminal merge-group ref still exists: %v", err)
	}
}

func TestRecoverPullRequestMergeWorkFinalizesCrashAfterGitPush(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()

	pr, authCtx, owner := setupProtectedPR(t, svc, tenantHandle, "recover-after-push", "recover-after-push-repo", false)
	if err := tenantHandle.Git().CreateBranch(authCtx, pr.Repository.FullName, "recover-after-push-two", pr.BaseRef); err != nil {
		t.Fatal(err)
	}
	if _, err := tenantHandle.Git().WriteFile(authCtx, pr.Repository.FullName, "recover-after-push-two", "second.txt", "second", []byte("second\n")); err != nil {
		t.Fatal(err)
	}
	secondPR, err := svc.CreatePR(authCtx, service.CreatePRInput{
		RepoFullName: pr.Repository.FullName, Title: "second", HeadRef: "recover-after-push-two",
		BaseRef: pr.BaseRef, AuthorLogin: owner.Login,
	})
	if err != nil {
		t.Fatal(err)
	}
	rules := `[{
		"type":"merge_queue",
		"parameters":{"check_response_timeout_minutes":60,"grouping_strategy":"ALLGREEN","merge_method":"SQUASH","max_entries_to_build":5,"max_entries_to_merge":5,"min_entries_to_merge":1,"min_entries_to_merge_wait_minutes":0}
	},{"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"CI"}]}}]`
	if err := svc.CreateRuleset(authCtx, pr.Repository.FullName, &db.Ruleset{
		Name: "merge queue", Target: "branch", Enforcement: "active",
		ConditionsJSON: `{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}}`, RulesJSON: rules,
	}); err != nil {
		t.Fatal(err)
	}
	queue := db.MergeQueue{RepositoryID: pr.RepositoryID, BaseRef: pr.BaseRef}
	if err := tenantHandle.DB().Create(&queue).Error; err != nil {
		t.Fatal(err)
	}
	entries := []db.MergeQueueEntry{
		{QueueID: queue.ID, PullRequestID: pr.ID, Position: 1, State: "queued",
			EnqueuedByID: owner.ID, HeadSHA: pr.HeadSHA, BaseSHA: pr.BaseSHA},
		{QueueID: queue.ID, PullRequestID: secondPR.ID, Position: 2, State: "queued",
			EnqueuedByID: owner.ID, HeadSHA: secondPR.HeadSHA, BaseSHA: secondPR.BaseSHA},
	}
	if err := tenantHandle.DB().Create(&entries).Error; err != nil {
		t.Fatal(err)
	}
	if _, err := svc.BuildMergeGroupForQueue(authCtx, queue.ID, owner); err != nil {
		t.Fatal(err)
	}
	var group db.MergeGroup
	if err := tenantHandle.DB().Where("head_entry_id = ?", entries[1].ID).Order("created_at DESC").First(&group).Error; err != nil {
		t.Fatal(err)
	}
	mergeResult, err := tenantHandle.Git().MergeStackAtomicallyDetailed(authCtx, gitstore.StackMergeOptions{
		FullName: pr.Repository.FullName, BaseBranch: pr.BaseRef, MergeMethod: "squash",
		Committer: owner.Login, Email: owner.Email,
		Members: []gitstore.StackMergeMember{
			{Number: pr.Number, BaseBranch: pr.BaseRef, HeadBranch: pr.HeadRef,
				Message: "Merge first pull request", ExpectedHeadSHA: pr.HeadSHA},
			{Number: secondPR.Number, BaseBranch: secondPR.BaseRef, HeadBranch: secondPR.HeadRef,
				Message: "Merge second pull request", ExpectedHeadSHA: secondPR.HeadSHA},
		},
	})
	if err != nil || mergeResult.FinalSHA == "" {
		t.Fatalf("simulate Git push = %+v, %v", mergeResult, err)
	}
	if err := tenantHandle.DB().Transaction(func(tx *gorm.DB) error {
		if err := tx.Model(&db.MergeGroup{}).Where("id = ?", group.ID).Update("status", "merging").Error; err != nil {
			return err
		}
		return tx.Model(&db.MergeQueueEntry{}).Where("id IN ?", []uint{entries[0].ID, entries[1].ID}).Update("state", "locked").Error
	}); err != nil {
		t.Fatal(err)
	}
	if err := svc.RecoverPullRequestMergeWork(authCtx); err != nil {
		t.Fatal(err)
	}
	storedFirst, err := svc.GetPRByID(authCtx, pr.ID)
	if err != nil || !storedFirst.Merged || storedFirst.MergeCommitSHA != mergeResult.MemberSHAs[0] {
		t.Fatalf("finalized first pull request = %+v, %v; want sha %s", storedFirst, err, mergeResult.MemberSHAs[0])
	}
	storedSecond, err := svc.GetPRByID(authCtx, secondPR.ID)
	if err != nil || !storedSecond.Merged || storedSecond.MergeCommitSHA != mergeResult.MemberSHAs[1] {
		t.Fatalf("finalized second pull request = %+v, %v; want sha %s", storedSecond, err, mergeResult.MemberSHAs[1])
	}
	if _, err := tenantHandle.Git().LookupRef(authCtx, pr.Repository.FullName, group.HeadRef); !errors.Is(err, gitstore.ErrRefNotFound) {
		t.Fatalf("terminal merge-group ref still exists: %v", err)
	}
}

func TestRecoverMergeQueueMappingFailureKeepsDurableMergingState(t *testing.T) {
	for _, test := range []struct {
		name   string
		mutate func(testing.TB, *service.Service, mergeQueueCrashFixture)
	}{
		{
			name: "missing prefix group",
			mutate: func(t testing.TB, svc *service.Service, fixture mergeQueueCrashFixture) {
				var prefix db.MergeGroup
				if err := svc.DBForCtx(fixture.authCtx).Where("queue_id = ? AND head_entry_id = ?", fixture.queue.ID, fixture.entries[0].ID).First(&prefix).Error; err != nil {
					t.Fatal(err)
				}
				if err := svc.DBForCtx(fixture.authCtx).Model(&db.MergeQueueEntry{}).Where("id = ?", fixture.entries[0].ID).Update("merge_group_id", nil).Error; err != nil {
					t.Fatal(err)
				}
				if err := svc.DBForCtx(fixture.authCtx).Where("merge_group_id = ?", prefix.ID).Delete(&db.MergeGroupLifecycleEvent{}).Error; err != nil {
					t.Fatal(err)
				}
				if err := svc.DBForCtx(fixture.authCtx).Delete(&prefix).Error; err != nil {
					t.Fatal(err)
				}
			},
		},
		{
			name: "first parent tree mismatch",
			mutate: func(t testing.TB, svc *service.Service, fixture mergeQueueCrashFixture) {
				if err := svc.DBForCtx(fixture.authCtx).Model(&db.MergeGroup{}).
					Where("queue_id = ? AND head_entry_id = ?", fixture.queue.ID, fixture.entries[0].ID).
					Update("head_sha", fixture.group.HeadSHA).Error; err != nil {
					t.Fatal(err)
				}
			},
		},
		{
			name: "prefix commit read failure",
			mutate: func(t testing.TB, svc *service.Service, fixture mergeQueueCrashFixture) {
				if err := svc.DBForCtx(fixture.authCtx).Model(&db.MergeGroup{}).
					Where("queue_id = ? AND head_entry_id = ?", fixture.queue.ID, fixture.entries[0].ID).
					Update("head_sha", "0000000000000000000000000000000000000001").Error; err != nil {
					t.Fatal(err)
				}
			},
		},
	} {
		t.Run(test.name, func(t *testing.T) {
			svc, tenantHandle, cleanup := setupTestService(t)
			defer cleanup()
			fixture := setupMergeQueueCrashAfterGitPush(t, svc, tenantHandle, strings.ReplaceAll(test.name, " ", "-"))
			test.mutate(t, svc, fixture)

			if err := svc.RecoverPullRequestMergeWork(fixture.authCtx); err != nil {
				t.Fatal(err)
			}
			var group db.MergeGroup
			if err := tenantHandle.DB().First(&group, fixture.group.ID).Error; err != nil {
				t.Fatal(err)
			}
			if group.Status != "merging" {
				t.Fatalf("group status = %q, want durable merging", group.Status)
			}
			for _, original := range fixture.prs {
				stored, err := svc.GetPRByID(fixture.authCtx, original.ID)
				if err != nil {
					t.Fatal(err)
				}
				if stored.Merged || stored.MergeCommitSHA != "" {
					t.Fatalf("mapping failure finalized pull request #%d: %+v", stored.Number, stored)
				}
			}
		})
	}
}

type mergeQueueCrashFixture struct {
	authCtx context.Context
	queue   db.MergeQueue
	entries []db.MergeQueueEntry
	group   db.MergeGroup
	prs     []db.PullRequest
}

func setupMergeQueueCrashAfterGitPush(t testing.TB, svc *service.Service, tenantHandle *testharness.TenantHandle, name string) mergeQueueCrashFixture {
	t.Helper()
	pr, authCtx, owner := setupProtectedPR(t, svc, tenantHandle, name+"-owner", name+"-repo", false)
	if err := tenantHandle.Git().CreateBranch(authCtx, pr.Repository.FullName, "second", pr.HeadRef); err != nil {
		t.Fatal(err)
	}
	if _, err := tenantHandle.Git().WriteFile(authCtx, pr.Repository.FullName, "second", "second.txt", "second", []byte("second\n")); err != nil {
		t.Fatal(err)
	}
	secondPR, err := svc.CreatePR(authCtx, service.CreatePRInput{
		RepoFullName: pr.Repository.FullName, Title: "second", HeadRef: "second",
		BaseRef: pr.HeadRef, AuthorLogin: owner.Login,
	})
	if err != nil {
		t.Fatal(err)
	}
	rules := `[{"type":"merge_queue","parameters":{"check_response_timeout_minutes":60,"grouping_strategy":"ALLGREEN","merge_method":"SQUASH","max_entries_to_build":5,"max_entries_to_merge":5,"min_entries_to_merge":1,"min_entries_to_merge_wait_minutes":0}},{"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"CI"}]}}]`
	if err := svc.CreateRuleset(authCtx, pr.Repository.FullName, &db.Ruleset{
		Name: "merge queue", Target: "branch", Enforcement: "active",
		ConditionsJSON: `{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}}`, RulesJSON: rules,
	}); err != nil {
		t.Fatal(err)
	}
	queue := db.MergeQueue{RepositoryID: pr.RepositoryID, BaseRef: pr.BaseRef}
	if err := tenantHandle.DB().Create(&queue).Error; err != nil {
		t.Fatal(err)
	}
	entries := []db.MergeQueueEntry{
		{QueueID: queue.ID, PullRequestID: pr.ID, Position: 1, State: "queued", EnqueuedByID: owner.ID, HeadSHA: pr.HeadSHA, BaseSHA: pr.BaseSHA},
		{QueueID: queue.ID, PullRequestID: secondPR.ID, Position: 2, State: "queued", EnqueuedByID: owner.ID, HeadSHA: secondPR.HeadSHA, BaseSHA: secondPR.BaseSHA},
	}
	if err := tenantHandle.DB().Create(&entries).Error; err != nil {
		t.Fatal(err)
	}
	if _, err := svc.BuildMergeGroupForQueue(authCtx, queue.ID, owner); err != nil {
		t.Fatal(err)
	}
	var group db.MergeGroup
	if err := tenantHandle.DB().Where("head_entry_id = ?", entries[1].ID).Order("created_at DESC").First(&group).Error; err != nil {
		t.Fatal(err)
	}
	if _, err := tenantHandle.Git().MergeStackAtomicallyDetailed(authCtx, gitstore.StackMergeOptions{
		FullName: pr.Repository.FullName, BaseBranch: pr.BaseRef, MergeMethod: "squash",
		Committer: owner.Login, Email: owner.Email,
		Members: []gitstore.StackMergeMember{
			{Number: pr.Number, BaseBranch: pr.BaseRef, HeadBranch: pr.HeadRef, Message: "first", ExpectedHeadSHA: pr.HeadSHA},
			{Number: secondPR.Number, BaseBranch: secondPR.BaseRef, HeadBranch: secondPR.HeadRef, Message: "second", ExpectedHeadSHA: secondPR.HeadSHA},
		},
	}); err != nil {
		t.Fatal(err)
	}
	if err := tenantHandle.DB().Transaction(func(tx *gorm.DB) error {
		if err := tx.Model(&db.MergeGroup{}).Where("id = ?", group.ID).Update("status", "merging").Error; err != nil {
			return err
		}
		return tx.Model(&db.MergeQueueEntry{}).Where("id IN ?", []uint{entries[0].ID, entries[1].ID}).Update("state", "locked").Error
	}); err != nil {
		t.Fatal(err)
	}
	return mergeQueueCrashFixture{authCtx: authCtx, queue: queue, entries: entries, group: group, prs: []db.PullRequest{pr, secondPR}}
}

func TestMergeQueueBuildChecksAndMerge(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()

	pr, authCtx, owner := setupProtectedPR(t, svc, tenantHandle, "queue-owner", "queue-repo", false)
	protectBranch(t, svc, authCtx, pr.RepositoryID, pr.BaseRef, `{"contexts":["CI"]}`, "", true)
	if err := svc.CreateRuleset(authCtx, pr.Repository.FullName, &db.Ruleset{
		Name: "merge queue", Target: "branch", Enforcement: "active",
		ConditionsJSON: `{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}}`,
		RulesJSON:      testMergeQueueRules,
	}); err != nil {
		t.Fatalf("CreateRuleset: %v", err)
	}
	queue := db.MergeQueue{RepositoryID: pr.RepositoryID, BaseRef: pr.BaseRef}
	if err := tenantHandle.DB().Create(&queue).Error; err != nil {
		t.Fatalf("create queue: %v", 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.Fatalf("create queue entry: %v", err)
	}

	group, err := svc.BuildMergeGroupForQueue(authCtx, queue.ID, owner)
	if err != nil {
		t.Fatalf("BuildMergeGroupForQueue: %v", err)
	}
	if group == nil || group.HeadSHA == "" || group.HeadRef == "" {
		t.Fatalf("merge group = %+v", group)
	}
	var awaiting db.MergeQueueEntry
	if err := tenantHandle.DB().First(&awaiting, entry.ID).Error; err != nil {
		t.Fatal(err)
	}
	if awaiting.State != "awaiting_checks" {
		t.Fatalf("entry state = %q, want awaiting_checks", awaiting.State)
	}

	if err := svc.CreateCommitStatus(authCtx, &db.CommitStatus{
		RepositoryID: pr.RepositoryID, CommitSHA: group.HeadSHA, State: "success", Context: "CI", CreatorID: owner.ID,
	}); err != nil {
		t.Fatalf("CreateCommitStatus: %v", err)
	}
	deadline := time.Now().Add(10 * time.Second)
	var merged db.PullRequest
	for time.Now().Before(deadline) {
		if err := tenantHandle.DB().First(&merged, pr.ID).Error; err != nil {
			t.Fatal(err)
		}
		if merged.Merged {
			break
		}
		time.Sleep(20 * time.Millisecond)
	}
	if !merged.Merged || merged.MergeCommitSHA == "" {
		t.Fatalf("pull request did not merge: %+v", merged)
	}
	var completed db.MergeQueueEntry
	if err := tenantHandle.DB().First(&completed, entry.ID).Error; err != nil {
		t.Fatal(err)
	}
	if completed.State != "merged" {
		t.Fatalf("entry state = %q, want merged", completed.State)
	}
}

func TestMergeQueueEnforcesRulesetRequiredStatusChecksWithoutLegacyProtection(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()

	pr, authCtx, owner := setupProtectedPR(t, svc, tenantHandle, "queue-ruleset-owner", "queue-ruleset-repo", false)
	rules := `[{
		"type":"merge_queue",
		"parameters":{"check_response_timeout_minutes":60,"grouping_strategy":"ALLGREEN","merge_method":"SQUASH","max_entries_to_build":5,"max_entries_to_merge":5,"min_entries_to_merge":1,"min_entries_to_merge_wait_minutes":0}
	},{
		"type":"required_status_checks",
		"parameters":{"required_status_checks":[{"context":"CI","integration_id":42}],"strict_required_status_checks_policy":false,"do_not_enforce_on_create":false}
	}]`
	if err := svc.CreateRuleset(authCtx, pr.Repository.FullName, &db.Ruleset{
		Name: "queue and checks", Target: "branch", Enforcement: "active",
		ConditionsJSON: `{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}}`, RulesJSON: rules,
	}); err != nil {
		t.Fatalf("CreateRuleset: %v", err)
	}
	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)
	}
	group, err := svc.BuildMergeGroupForQueue(authCtx, queue.ID, owner)
	if err != nil || group == nil {
		t.Fatalf("BuildMergeGroupForQueue = %+v, %v", group, err)
	}
	if err := svc.CreateCommitStatus(authCtx, &db.CommitStatus{
		RepositoryID: pr.RepositoryID, CommitSHA: group.HeadSHA, State: "success", Context: "unrelated", CreatorID: owner.ID,
	}); err != nil {
		t.Fatal(err)
	}
	time.Sleep(100 * time.Millisecond)
	stored, err := svc.GetPRByID(authCtx, pr.ID)
	if err != nil {
		t.Fatal(err)
	}
	if stored.Merged {
		t.Fatal("merge queue bypassed ruleset required status check")
	}
	wrongIntegration := int64(7)
	if err := svc.CreateCommitStatus(authCtx, &db.CommitStatus{
		RepositoryID: pr.RepositoryID, CommitSHA: group.HeadSHA, State: "success", Context: "CI", IntegrationID: &wrongIntegration, CreatorID: owner.ID,
	}); err != nil {
		t.Fatal(err)
	}
	time.Sleep(100 * time.Millisecond)
	stored, err = svc.GetPRByID(authCtx, pr.ID)
	if err != nil || stored.Merged {
		t.Fatalf("same-name status from wrong integration satisfied ruleset check: %+v, %v", stored, err)
	}
	requiredIntegration := int64(42)
	if err := svc.CreateCommitStatus(authCtx, &db.CommitStatus{
		RepositoryID: pr.RepositoryID, CommitSHA: group.HeadSHA, State: "success", Context: "CI", IntegrationID: &requiredIntegration, CreatorID: owner.ID,
	}); err != nil {
		t.Fatal(err)
	}
	deadline := time.Now().Add(10 * time.Second)
	for time.Now().Before(deadline) {
		stored, err = svc.GetPRByID(authCtx, pr.ID)
		if err != nil || stored.Merged {
			break
		}
		time.Sleep(20 * time.Millisecond)
	}
	if err != nil || !stored.Merged {
		t.Fatalf("pull request was not merged after ruleset check passed: %+v, %v", stored, err)
	}
}

func TestMergeQueueNotifiesGitHubPullRequestWebhookActions(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()

	pr, authCtx, _ := setupProtectedPR(t, svc, tenantHandle, "queue-hook-owner", "queue-hook-repo", false)
	rules := `[{"type":"merge_queue","parameters":{"check_response_timeout_minutes":60,"grouping_strategy":"ALLGREEN","merge_method":"SQUASH","max_entries_to_build":0,"max_entries_to_merge":1,"min_entries_to_merge":1,"min_entries_to_merge_wait_minutes":0}}]`
	if err := svc.CreateRuleset(authCtx, pr.Repository.FullName, &db.Ruleset{
		Name: "merge queue", Target: "branch", Enforcement: "active",
		ConditionsJSON: `{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}}`,
		RulesJSON:      rules,
	}); err != nil {
		t.Fatalf("CreateRuleset: %v", err)
	}
	type notification struct {
		action string
		ids    []uint
	}
	var notifications []notification
	svc.SetPullRequestQueueEventHook(func(_ context.Context, ids []uint, action string) {
		notifications = append(notifications, notification{action: action, ids: append([]uint(nil), ids...)})
	})
	defer svc.SetPullRequestQueueEventHook(nil)

	if _, err := svc.EnqueuePullRequest(authCtx, pr.ID, pr.HeadSHA, false); err != nil {
		t.Fatalf("EnqueuePullRequest: %v", err)
	}
	if len(notifications) != 1 || notifications[0].action != "enqueued" || len(notifications[0].ids) != 1 || notifications[0].ids[0] != pr.ID {
		t.Fatalf("enqueue notifications = %+v", notifications)
	}
	if _, err := svc.DequeuePullRequest(authCtx, pr.ID); err != nil {
		t.Fatalf("DequeuePullRequest: %v", err)
	}
	if len(notifications) != 2 || notifications[1].action != "dequeued" || len(notifications[1].ids) != 1 || notifications[1].ids[0] != pr.ID {
		t.Fatalf("queue notifications = %+v", notifications)
	}
}

func TestSubmitAsyncMergeMergesStackPrefixAtomically(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()
	ctx := tenantHandle.Context()

	owner := db.User{Login: "async-stack-owner", Name: "Async Stack", Type: db.TypeUser}
	if err := tenantHandle.DB().Create(&owner).Error; err != nil {
		t.Fatal(err)
	}
	repo, err := svc.CreateRepo(ctx, service.CreateRepoInput{
		OwnerLogin: owner.Login, Name: "async-stack-repo", DefaultBranch: "main", AutoInit: true,
	})
	if err != nil {
		t.Fatalf("CreateRepo: %v", err)
	}
	if err := tenantHandle.Git().CreateBranch(ctx, repo.FullName, "stack-one", "main"); err != nil {
		t.Fatal(err)
	}
	if _, err := tenantHandle.Git().WriteFile(ctx, repo.FullName, "stack-one", "one.txt", "one", []byte("one\n")); err != nil {
		t.Fatal(err)
	}
	if err := tenantHandle.Git().CreateBranch(ctx, repo.FullName, "stack-two", "stack-one"); err != nil {
		t.Fatal(err)
	}
	if _, err := tenantHandle.Git().WriteFile(ctx, repo.FullName, "stack-two", "two.txt", "two", []byte("two\n")); err != nil {
		t.Fatal(err)
	}
	authCtx := testharness.ContextWithUser(t, ctx, owner)
	firstPR, err := svc.CreatePR(authCtx, service.CreatePRInput{
		RepoFullName: repo.FullName, Title: "one", HeadRef: "stack-one", BaseRef: "main", AuthorLogin: owner.Login,
	})
	if err != nil {
		t.Fatalf("CreatePR one: %v", err)
	}
	secondPR, err := svc.CreatePR(authCtx, service.CreatePRInput{
		RepoFullName: repo.FullName, Title: "two", HeadRef: "stack-two", BaseRef: "stack-one", AuthorLogin: owner.Login,
	})
	if err != nil {
		t.Fatalf("CreatePR two: %v", err)
	}
	if _, err := svc.CreatePullRequestStack(authCtx, repo.FullName, []int{firstPR.Number, secondPR.Number}); err != nil {
		t.Fatalf("CreatePullRequestStack: %v", err)
	}

	result, err := svc.SubmitAsyncMerge(authCtx, repo.FullName, secondPR.Number, service.SubmitAsyncMergeInput{
		MergeMethod: "squash", MergeAction: service.AsyncMergeActionDirect,
	})
	if err != nil {
		t.Fatalf("SubmitAsyncMerge: %v", err)
	}
	deadline := time.Now().Add(10 * time.Second)
	for (result.Request.Status == service.AsyncMergePending ||
		result.Request.Status == service.AsyncMergeMerging ||
		result.Request.Status == service.AsyncMergeCascading) && time.Now().Before(deadline) {
		time.Sleep(20 * time.Millisecond)
		result.Request, err = svc.GetAsyncMerge(authCtx, repo.FullName, secondPR.Number, result.Request.UUID)
		if err != nil {
			t.Fatalf("GetAsyncMerge: %v", err)
		}
	}
	if result.Request.Status != service.AsyncMergeMerged {
		t.Fatalf("async status = %q, failure = %q", result.Request.Status, result.Request.FailureMessage)
	}
	mergedOne, err := svc.GetPRByID(authCtx, firstPR.ID)
	if err != nil {
		t.Fatal(err)
	}
	mergedTwo, err := svc.GetPRByID(authCtx, secondPR.ID)
	if err != nil {
		t.Fatal(err)
	}
	if !mergedOne.Merged || !mergedTwo.Merged {
		t.Fatalf("merged flags = %v, %v", mergedOne.Merged, mergedTwo.Merged)
	}
	if mergedOne.MergeCommitSHA == "" || mergedOne.MergeCommitSHA == mergedTwo.MergeCommitSHA {
		t.Fatalf("per-PR merge SHAs = %q, %q", mergedOne.MergeCommitSHA, mergedTwo.MergeCommitSHA)
	}
	if result.Request.ResultSHA != mergedTwo.MergeCommitSHA {
		t.Fatalf("result SHA = %q, top PR SHA = %q", result.Request.ResultSHA, mergedTwo.MergeCommitSHA)
	}
}

func TestRecoverDirectStackMergeAfterGitPushBeforeDatabaseFinalize(t *testing.T) {
	for _, method := range []string{"merge", "squash", "rebase"} {
		t.Run(method, func(t *testing.T) {
			svc, tenantHandle, cleanup := setupTestService(t)
			defer cleanup()
			repo, authCtx, _, prs := setupAsyncMergeStack(t, svc, tenantHandle, "recover-direct-"+method, 2)
			oldBaseSHA, err := tenantHandle.Git().HeadSHA(authCtx, repo.FullName, repo.DefaultBranch)
			if err != nil {
				t.Fatal(err)
			}

			callbackName := "fail_direct_stack_finalize_" + method
			var injected atomic.Bool
			if err := tenantHandle.DB().Callback().Update().Before("gorm:update").Register(callbackName, func(tx *gorm.DB) {
				if tx.Statement.Table == "pull_requests" && injected.CompareAndSwap(false, true) {
					tx.AddError(errors.New("forced direct Stack finalize failure"))
				}
			}); err != nil {
				t.Fatal(err)
			}

			result, err := svc.SubmitAsyncMerge(authCtx, repo.FullName, prs[1].Number, service.SubmitAsyncMergeInput{
				MergeMethod: method, MergeAction: service.AsyncMergeActionDirect,
			})
			if err != nil {
				t.Fatal(err)
			}
			deadline := time.Now().Add(10 * time.Second)
			for !injected.Load() && time.Now().Before(deadline) {
				time.Sleep(20 * time.Millisecond)
			}
			if !injected.Load() {
				t.Fatal("direct Stack merge did not reach the injected post-push DB failure")
			}
			if err := tenantHandle.DB().Callback().Update().Remove(callbackName); err != nil {
				t.Fatal(err)
			}

			pushedBaseSHA, err := tenantHandle.Git().HeadSHA(authCtx, repo.FullName, repo.DefaultBranch)
			if err != nil {
				t.Fatal(err)
			}
			if pushedBaseSHA == oldBaseSHA {
				t.Fatal("injected failure occurred before the Git base was advanced")
			}
			expiredAt := time.Now().UTC().Add(-time.Hour)
			if err := tenantHandle.DB().Model(&db.AsyncMergeRequest{}).Where("id = ?", result.Request.ID).
				UpdateColumns(map[string]any{"updated_at": expiredAt, "expires_at": expiredAt}).Error; err != nil {
				t.Fatal(err)
			}
			var storedRequest db.AsyncMergeRequest
			if err := tenantHandle.DB().First(&storedRequest, result.Request.ID).Error; err != nil || storedRequest.Status != service.AsyncMergeMerging {
				t.Fatalf("request before recovery = %+v, %v", storedRequest, err)
			}
			if _, err := svc.GetAsyncMerge(authCtx, repo.FullName, prs[1].Number, result.Request.UUID); !errors.Is(err, service.ErrNotFound) {
				t.Fatalf("expired merging request should be hidden before recovery, got %v", err)
			}

			if err := svc.RecoverPullRequestMergeWork(authCtx); err != nil {
				t.Fatal(err)
			}
			storedRequest, err = svc.GetAsyncMerge(authCtx, repo.FullName, prs[1].Number, result.Request.UUID)
			if err != nil || storedRequest.Status != service.AsyncMergeMerged || storedRequest.ResultSHA != pushedBaseSHA {
				t.Fatalf("recovered request = %+v, %v; want merged at %s", storedRequest, err, pushedBaseSHA)
			}
			first, err := svc.GetPRByID(authCtx, prs[0].ID)
			if err != nil {
				t.Fatal(err)
			}
			second, err := svc.GetPRByID(authCtx, prs[1].ID)
			if err != nil {
				t.Fatal(err)
			}
			if !first.Merged || !second.Merged || first.MergeCommitSHA == "" || second.MergeCommitSHA == "" || first.MergeCommitSHA == second.MergeCommitSHA {
				t.Fatalf("recovered per-member merge SHAs = %q, %q", first.MergeCommitSHA, second.MergeCommitSHA)
			}
			if second.MergeCommitSHA != pushedBaseSHA {
				t.Fatalf("top member SHA = %q, want pushed base %q", second.MergeCommitSHA, pushedBaseSHA)
			}
		})
	}
}

func TestRecoverDirectStackCascadeAfterGitPushBeforeDatabaseFinalize(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()
	repo, authCtx, _, prs := setupAsyncMergeStack(t, svc, tenantHandle, "recover-cascade", 3)
	originalRemainingSHA := prs[2].HeadSHA

	callbackName := "fail_direct_stack_cascade_finalize"
	var injected atomic.Bool
	if err := tenantHandle.DB().Callback().Update().Before("gorm:update").Register(callbackName, func(tx *gorm.DB) {
		if tx.Statement.Table == "pull_request_stacks" && injected.CompareAndSwap(false, true) {
			tx.AddError(errors.New("forced Stack cascade DB failure"))
		}
	}); err != nil {
		t.Fatal(err)
	}

	result, err := svc.SubmitAsyncMerge(authCtx, repo.FullName, prs[1].Number, service.SubmitAsyncMergeInput{
		MergeMethod: "squash", MergeAction: service.AsyncMergeActionDirect,
	})
	if err != nil {
		t.Fatal(err)
	}
	deadline := time.Now().Add(10 * time.Second)
	for !injected.Load() && time.Now().Before(deadline) {
		time.Sleep(20 * time.Millisecond)
	}
	if !injected.Load() {
		t.Fatal("Stack cascade did not reach the injected post-push DB failure")
	}
	if err := tenantHandle.DB().Callback().Update().Remove(callbackName); err != nil {
		t.Fatal(err)
	}

	expiredAt := time.Now().UTC().Add(-time.Hour)
	if err := tenantHandle.DB().Model(&db.AsyncMergeRequest{}).Where("id = ?", result.Request.ID).
		UpdateColumns(map[string]any{"updated_at": expiredAt, "expires_at": expiredAt}).Error; err != nil {
		t.Fatal(err)
	}
	var storedRequest db.AsyncMergeRequest
	if err := tenantHandle.DB().First(&storedRequest, result.Request.ID).Error; err != nil || storedRequest.Status != service.AsyncMergeCascading || storedRequest.ResultSHA == "" {
		t.Fatalf("request before cascade recovery = %+v, %v", storedRequest, err)
	}
	if _, err := svc.GetAsyncMerge(authCtx, repo.FullName, prs[1].Number, result.Request.UUID); !errors.Is(err, service.ErrNotFound) {
		t.Fatalf("expired cascading request should be hidden before recovery, got %v", err)
	}
	remainingRefSHA, err := tenantHandle.Git().HeadSHA(authCtx, repo.FullName, prs[2].HeadRef)
	if err != nil {
		t.Fatal(err)
	}
	if remainingRefSHA == originalRemainingSHA {
		t.Fatal("injected failure occurred before the remaining Stack ref was published")
	}

	if err := svc.RecoverPullRequestMergeWork(authCtx); err != nil {
		t.Fatal(err)
	}
	storedRequest, err = svc.GetAsyncMerge(authCtx, repo.FullName, prs[1].Number, result.Request.UUID)
	if err != nil || storedRequest.Status != service.AsyncMergeMerged {
		t.Fatalf("request after cascade recovery = %+v, %v", storedRequest, err)
	}
	remaining, err := svc.GetPRByID(authCtx, prs[2].ID)
	if err != nil {
		t.Fatal(err)
	}
	if remaining.Merged || remaining.BaseRef != repo.DefaultBranch || remaining.BaseSHA != storedRequest.ResultSHA || remaining.HeadSHA != remainingRefSHA {
		t.Fatalf("remaining Stack member after recovery = %+v", remaining)
	}
}

func TestRecoverDirectStackAfterCascadeDatabaseCommitDoesNotReplayRebase(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()
	repo, authCtx, _, prs := setupAsyncMergeStack(t, svc, tenantHandle, "recover-complete-cascade", 3)

	callbackName := "fail_after_stack_cascade_database_commit"
	var asyncUpdates atomic.Int32
	var injected atomic.Bool
	if err := tenantHandle.DB().Callback().Update().Before("gorm:update").Register(callbackName, func(tx *gorm.DB) {
		if tx.Statement.Table == "async_merge_requests" && asyncUpdates.Add(1) == 3 {
			injected.Store(true)
			tx.AddError(errors.New("forced async completion failure after Stack cascade commit"))
		}
	}); err != nil {
		t.Fatal(err)
	}

	result, err := svc.SubmitAsyncMerge(authCtx, repo.FullName, prs[1].Number, service.SubmitAsyncMergeInput{
		MergeMethod: "squash", MergeAction: service.AsyncMergeActionDirect,
	})
	if err != nil {
		t.Fatal(err)
	}
	deadline := time.Now().Add(10 * time.Second)
	for !injected.Load() && time.Now().Before(deadline) {
		time.Sleep(20 * time.Millisecond)
	}
	if !injected.Load() {
		t.Fatal("merge did not reach the injected post-cascade completion update")
	}
	if err := tenantHandle.DB().Callback().Update().Remove(callbackName); err != nil {
		t.Fatal(err)
	}

	request, err := svc.GetAsyncMerge(authCtx, repo.FullName, prs[1].Number, result.Request.UUID)
	if err != nil || request.Status != service.AsyncMergeCascading {
		t.Fatalf("request before recovery = %+v, %v", request, err)
	}
	remainingBefore, err := svc.GetPRByID(authCtx, prs[2].ID)
	if err != nil {
		t.Fatal(err)
	}
	refBefore, err := tenantHandle.Git().HeadSHA(authCtx, repo.FullName, remainingBefore.HeadRef)
	if err != nil {
		t.Fatal(err)
	}
	if remainingBefore.HeadSHA != refBefore || remainingBefore.BaseRef != repo.DefaultBranch || remainingBefore.BaseSHA != request.ResultSHA {
		t.Fatalf("cascade DB topology was not committed before injected failure: %+v", remainingBefore)
	}

	if err := svc.RecoverPullRequestMergeWork(authCtx); err != nil {
		t.Fatal(err)
	}
	request, err = svc.GetAsyncMerge(authCtx, repo.FullName, prs[1].Number, result.Request.UUID)
	if err != nil || request.Status != service.AsyncMergeMerged {
		t.Fatalf("request after recovery = %+v, %v", request, err)
	}
	refAfter, err := tenantHandle.Git().HeadSHA(authCtx, repo.FullName, remainingBefore.HeadRef)
	if err != nil {
		t.Fatal(err)
	}
	if refAfter != refBefore {
		t.Fatalf("recovery replayed an already committed cascade: ref changed %s -> %s", refBefore, refAfter)
	}
}

func TestSubmitAsyncMergeQueuesStackOnUltimateBase(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()

	repo, authCtx, _, prs := setupAsyncMergeStack(t, svc, tenantHandle, "queue-ultimate-base", 3)
	createMergeQueueRuleset(t, svc, authCtx, repo.FullName)

	result, err := svc.SubmitAsyncMerge(authCtx, repo.FullName, prs[2].Number, service.SubmitAsyncMergeInput{})
	if err != nil {
		t.Fatalf("SubmitAsyncMerge: %v", err)
	}
	if !result.Created || result.Request.Status != service.AsyncMergeEnqueued || result.Request.EffectiveMergeAction != service.AsyncMergeActionQueue {
		t.Fatalf("async merge result = %+v, want a newly enqueued Stack request", result)
	}

	var queue db.MergeQueue
	if err := tenantHandle.DB().Where("repository_id = ? AND base_ref = ?", repo.ID, repo.DefaultBranch).First(&queue).Error; err != nil {
		t.Fatalf("Stack queue for final base %q: %v", repo.DefaultBranch, err)
	}
	var entries []db.MergeQueueEntry
	if err := tenantHandle.DB().Where("queue_id = ?", queue.ID).Order("position ASC").Find(&entries).Error; err != nil {
		t.Fatal(err)
	}
	if len(entries) != len(prs) {
		t.Fatalf("queue entries = %d, want %d", len(entries), len(prs))
	}
	for i, entry := range entries {
		if entry.PullRequestID != prs[i].ID {
			t.Fatalf("queue entry %d pull request = %d, want %d", i, entry.PullRequestID, prs[i].ID)
		}
	}
}

func setupAsyncMergeStack(t testing.TB, svc *service.Service, tenantHandle *testharness.TenantHandle, name string, size int) (db.Repository, context.Context, db.User, []db.PullRequest) {
	t.Helper()
	ctx := tenantHandle.Context()
	owner := db.User{Login: name + "-owner", Name: name, Type: db.TypeUser}
	if err := tenantHandle.DB().Create(&owner).Error; err != nil {
		t.Fatal(err)
	}
	repo, err := svc.CreateRepo(ctx, service.CreateRepoInput{
		OwnerLogin: owner.Login, Name: name + "-repo", DefaultBranch: "main", AutoInit: true,
	})
	if err != nil {
		t.Fatal(err)
	}
	authCtx := testharness.ContextWithUser(t, ctx, owner)
	prs := make([]db.PullRequest, 0, size)
	baseRef := repo.DefaultBranch
	for i := 1; i <= size; i++ {
		headRef := fmt.Sprintf("stack-%d", i)
		if err := tenantHandle.Git().CreateBranch(authCtx, repo.FullName, headRef, baseRef); err != nil {
			t.Fatal(err)
		}
		if _, err := tenantHandle.Git().WriteFile(authCtx, repo.FullName, headRef, fmt.Sprintf("member-%d.txt", i), fmt.Sprintf("member %d", i), []byte(fmt.Sprintf("member %d\n", i))); err != nil {
			t.Fatal(err)
		}
		pr, err := svc.CreatePR(authCtx, service.CreatePRInput{
			RepoFullName: repo.FullName, Title: fmt.Sprintf("member %d", i),
			HeadRef: headRef, BaseRef: baseRef, AuthorLogin: owner.Login,
		})
		if err != nil {
			t.Fatal(err)
		}
		prs = append(prs, pr)
		baseRef = headRef
	}
	numbers := make([]int, len(prs))
	for i := range prs {
		numbers[i] = prs[i].Number
	}
	if _, err := svc.CreatePullRequestStack(authCtx, repo.FullName, numbers); err != nil {
		t.Fatal(err)
	}
	return repo, authCtx, owner, prs
}
