package service_test

import (
	"context"
	"errors"
	"fmt"
	"net/url"
	"strings"
	"testing"
	"time"

	"github.com/pingcap/agent-git-service/internal/db"
	"github.com/pingcap/agent-git-service/internal/service"
	reposync "github.com/pingcap/agent-git-service/internal/sync"
	"github.com/pingcap/agent-git-service/internal/testharness"
)

type allowRepositorySyncConnection struct{}

func (allowRepositorySyncConnection) ValidateRepository(context.Context, string, string, string) error {
	return nil
}

type onboardingRepositorySyncConnection struct{}

type onboardingRepositorySyncConnectionWithoutInstallations struct {
	onboardingRepositorySyncConnection
}

func (onboardingRepositorySyncConnectionWithoutInstallations) VerifyGitHubAuthorizations(context.Context, string) (reposync.GitHubAuthorization, error) {
	return reposync.GitHubAuthorization{User: reposync.GitHubUserIdentity{ID: 9002, Login: "repo-less-user"}}, nil
}

func (onboardingRepositorySyncConnection) ValidateRepository(context.Context, string, string, string) error {
	return nil
}

func (onboardingRepositorySyncConnection) ListGitHubInstallations(context.Context) ([]reposync.GitHubInstallation, error) {
	return []reposync.GitHubInstallation{{
		ConnectionID: "github-app-installation:42", InstallationID: 42,
		AccountLogin: "pingcap", AccountType: "Organization", RepositorySelection: "selected",
		Permissions: map[string]string{"contents": "write"}, PermissionsValid: true,
	}}, nil
}

func (onboardingRepositorySyncConnection) ListGitHubRepositories(_ context.Context, connectionID string) ([]reposync.GitHubRepositoryOption, error) {
	return []reposync.GitHubRepositoryOption{{ID: "R_42", FullName: "pingcap/agent-git-service", Visibility: "public", ConnectionID: connectionID}}, nil
}

func (onboardingRepositorySyncConnection) GitHubInstallURL(state string) (string, error) {
	return "https://github.example/apps/ags/installations/new?state=" + url.QueryEscape(state), nil
}

func (onboardingRepositorySyncConnection) GitHubAuthorizeURL(state string) (string, error) {
	return "https://github.example/login/oauth/authorize?client_id=ags&state=" + url.QueryEscape(state), nil
}

func (onboardingRepositorySyncConnection) VerifyGitHubInstallation(context.Context, string, int64) (reposync.GitHubAuthorization, error) {
	return onboardingRepositorySyncConnection{}.VerifyGitHubAuthorizations(context.Background(), "")
}

func (onboardingRepositorySyncConnection) VerifyGitHubInstallationChange(context.Context, string, int64, []int64) (reposync.GitHubAuthorization, error) {
	return onboardingRepositorySyncConnection{}.VerifyGitHubAuthorizations(context.Background(), "")
}

func (onboardingRepositorySyncConnection) VerifyGitHubAuthorizations(context.Context, string) (reposync.GitHubAuthorization, error) {
	return reposync.GitHubAuthorization{
		User: reposync.GitHubUserIdentity{ID: 9001, Login: "octocat", AvatarURL: "https://avatars.example/octocat"},
		Installations: []reposync.GitHubInstallation{{
			ConnectionID: "github-app-installation:42", InstallationID: 42,
			AccountLogin: "pingcap", AccountType: "Organization", RepositorySelection: "selected",
			Permissions: map[string]string{"contents": "write"}, PermissionsValid: true,
		}},
	}, nil
}

type additionalInstallationOnboarding struct {
	onboardingRepositorySyncConnection
	t *testing.T
}

func (f *additionalInstallationOnboarding) ListGitHubInstallations(context.Context) ([]reposync.GitHubInstallation, error) {
	return []reposync.GitHubInstallation{{
		ConnectionID: "github-app-installation:41", InstallationID: 41,
		AccountLogin: "octocat", AccountType: "User", RepositorySelection: "selected",
		Permissions: map[string]string{"contents": "write"}, PermissionsValid: true,
	}}, nil
}

func (f *additionalInstallationOnboarding) VerifyGitHubAuthorizations(context.Context, string) (reposync.GitHubAuthorization, error) {
	return reposync.GitHubAuthorization{
		User: reposync.GitHubUserIdentity{ID: 9001, Login: "octocat"},
		Installations: []reposync.GitHubInstallation{{
			ConnectionID: "github-app-installation:41", InstallationID: 41,
			AccountLogin: "octocat", AccountType: "User", RepositorySelection: "selected",
			Permissions: map[string]string{"contents": "write"}, PermissionsValid: true,
		}},
	}, nil
}

func (f *additionalInstallationOnboarding) VerifyGitHubInstallationChange(_ context.Context, _ string, installationID int64, baseline []int64) (reposync.GitHubAuthorization, error) {
	if installationID != 0 {
		f.t.Fatalf("installation ID = %d, want omitted callback ID", installationID)
	}
	if len(baseline) != 1 || baseline[0] != 41 {
		f.t.Fatalf("installation baseline = %#v, want [41]", baseline)
	}
	return reposync.GitHubAuthorization{
		User: reposync.GitHubUserIdentity{ID: 9001, Login: "octocat"},
		Installations: []reposync.GitHubInstallation{{
			ConnectionID: "github-app-installation:42", InstallationID: 42,
			AccountLogin: "clawmem-ai", AccountType: "Organization", RepositorySelection: "all",
			Permissions: map[string]string{"contents": "write"}, PermissionsValid: true,
		}},
	}, nil
}

func TestRepositorySyncGitHubConnectionOnboarding(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestServiceWithConfig(t, testharness.ServiceConfig{
		CanonicalBaseURL: "https://git.example.test",
		ConsoleBaseURL:   "https://console.example.test",
	})
	defer cleanup()
	ctx := tenantHandle.Context()
	svc.SetRepositorySyncConnectionValidator(tenantHandle.Info().ID, onboardingRepositorySyncConnection{})
	owner := db.User{Login: "sync-connect-owner", Name: "sync-connect-owner", Type: db.TypeUser, UserKind: db.UserKindHuman, Status: db.UserStatusActive}
	if err := svc.DBForCtx(ctx).Create(&owner).Error; err != nil {
		t.Fatalf("create owner: %v", err)
	}
	ownerCtx := testContextWithUser(ctx, owner)
	before, err := svc.ListRepositorySyncGitHubInstallations(ownerCtx)
	if err != nil || len(before) != 0 {
		t.Fatalf("installations before authorization = %#v, %v", before, err)
	}
	started, err := svc.StartRepositorySyncGitHubInstall(ownerCtx, "/sync?repository=owner/repo")
	if err != nil {
		t.Fatalf("StartRepositorySyncGitHubInstall: %v", err)
	}
	installURL, err := url.Parse(started.InstallURL)
	if err != nil || installURL.Query().Get("state") == "" {
		t.Fatalf("install URL = %q, %v", started.InstallURL, err)
	}
	completed, err := svc.CompleteRepositorySyncGitHubInstall(ctx, service.CompleteRepositorySyncGitHubInstallInput{
		State: installURL.Query().Get("state"), Code: "github-code", InstallationID: 42, SetupAction: "install",
	})
	if err != nil {
		t.Fatalf("CompleteRepositorySyncGitHubInstall: %v", err)
	}
	if completed.Status != "connected" || !strings.Contains(completed.RedirectURL, "github=connected") {
		t.Fatalf("completed = %#v", completed)
	}
	if !strings.HasPrefix(completed.RedirectURL, "https://console.example.test/sync?") {
		t.Fatalf("callback redirect = %q, want browser console origin", completed.RedirectURL)
	}
	after, err := svc.ListRepositorySyncGitHubInstallations(ownerCtx)
	if err != nil || len(after) != 1 || after[0].AccountLogin != "pingcap" {
		t.Fatalf("installations after authorization = %#v, %v", after, err)
	}
	account, err := svc.GetRepositorySyncGitHubAccount(ownerCtx)
	if err != nil || account == nil || account.Login != "octocat" {
		t.Fatalf("connected account = %#v, %v", account, err)
	}
	repositories, err := svc.ListRepositorySyncGitHubRepositories(ownerCtx, "")
	if err != nil || len(repositories) != 1 || repositories[0].ConnectionID != "github-app-installation:42" {
		t.Fatalf("connected repositories = %#v, %v", repositories, err)
	}

	other := db.User{Login: "sync-connect-other", Name: "sync-connect-other", Type: db.TypeUser, UserKind: db.UserKindHuman, Status: db.UserStatusActive}
	if err := svc.DBForCtx(ctx).Create(&other).Error; err != nil {
		t.Fatalf("create other user: %v", err)
	}
	otherCtx := testContextWithUser(ctx, other)
	otherInstallations, err := svc.ListRepositorySyncGitHubInstallations(otherCtx)
	if err != nil || len(otherInstallations) != 0 {
		t.Fatalf("other user inherited installations = %#v, %v", otherInstallations, err)
	}
	if _, err := svc.ListRepositorySyncGitHubRepositories(otherCtx, "github-app-installation:42"); !errors.Is(err, service.ErrForbidden) {
		t.Fatalf("other user repository discovery error = %v, want ErrForbidden", err)
	}
	if _, err := svc.CreateRepositorySyncBinding(otherCtx, service.CreateRepositorySyncBindingInput{
		AGSRepositoryID: 1, GitHubRepositoryID: "R_42", GitHubRepositoryFullName: "pingcap/agent-git-service",
		GitHubConnectionID: "github-app-installation:42", InitialAuthority: service.SyncProviderGitHub,
	}); !errors.Is(err, service.ErrForbidden) {
		t.Fatalf("other user binding error = %v, want ErrForbidden", err)
	}

	if err := svc.DisconnectRepositorySyncGitHubAccount(ownerCtx); err != nil {
		t.Fatalf("DisconnectRepositorySyncGitHubAccount: %v", err)
	}
	account, err = svc.GetRepositorySyncGitHubAccount(ownerCtx)
	if err != nil || account != nil {
		t.Fatalf("account after disconnect = %#v, %v", account, err)
	}
	afterDisconnect, err := svc.ListRepositorySyncGitHubInstallations(ownerCtx)
	if err != nil || len(afterDisconnect) != 0 {
		t.Fatalf("installations after disconnect = %#v, %v", afterDisconnect, err)
	}
}

func TestRepositorySyncGitHubExistingInstallationReauthorization(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestServiceWithConfig(t, testharness.ServiceConfig{
		CanonicalBaseURL: "https://git.example.test",
		ConsoleBaseURL:   "https://console.example.test",
	})
	defer cleanup()
	ctx := tenantHandle.Context()
	svc.SetRepositorySyncConnectionValidator(tenantHandle.Info().ID, onboardingRepositorySyncConnection{})
	owner := db.User{Login: "sync-reconnect-owner", Name: "sync-reconnect-owner", Type: db.TypeUser, UserKind: db.UserKindHuman, Status: db.UserStatusActive}
	if err := svc.DBForCtx(ctx).Create(&owner).Error; err != nil {
		t.Fatalf("create owner: %v", err)
	}
	started, err := svc.StartRepositorySyncGitHubAuthorize(
		testContextWithUser(ctx, owner),
		"/sync/new?step=repositories&kind=bring_to_ags&ags_repository_id=42&github_repository_id=R_kgDOExample",
	)
	if err != nil {
		t.Fatalf("StartRepositorySyncGitHubAuthorize: %v", err)
	}
	authorizeURL, err := url.Parse(started.InstallURL)
	if err != nil || authorizeURL.Path != "/login/oauth/authorize" || authorizeURL.Query().Get("state") == "" {
		t.Fatalf("authorize URL = %q, %v", started.InstallURL, err)
	}
	completed, err := svc.CompleteRepositorySyncGitHubInstall(ctx, service.CompleteRepositorySyncGitHubInstallInput{
		State: authorizeURL.Query().Get("state"), Code: "github-code",
	})
	if err != nil {
		t.Fatalf("CompleteRepositorySyncGitHubInstall: %v", err)
	}
	if completed.Status != "connected" || !strings.HasPrefix(completed.RedirectURL, "https://console.example.test/sync/new?") {
		t.Fatalf("completed = %#v", completed)
	}
	callbackURL, err := url.Parse(completed.RedirectURL)
	if err != nil {
		t.Fatalf("parse callback redirect: %v", err)
	}
	callbackQuery := callbackURL.Query()
	if callbackQuery.Get("step") != "repositories" ||
		callbackQuery.Get("kind") != "bring_to_ags" ||
		callbackQuery.Get("ags_repository_id") != "42" ||
		callbackQuery.Get("github_repository_id") != "R_kgDOExample" ||
		callbackQuery.Get("github") != "connected" {
		t.Fatalf("callback redirect query = %#v", callbackQuery)
	}
	installations, err := svc.ListRepositorySyncGitHubInstallations(testContextWithUser(ctx, owner))
	if err != nil || len(installations) != 1 || installations[0].InstallationID != 42 {
		t.Fatalf("installations = %#v, %v", installations, err)
	}
}

func TestRepositorySyncGitHubAdditionalInstallationMergesExistingGrant(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestServiceWithConfig(t, testharness.ServiceConfig{
		CanonicalBaseURL: "https://git.example.test",
		ConsoleBaseURL:   "https://console.example.test",
	})
	defer cleanup()
	ctx := tenantHandle.Context()
	provider := &additionalInstallationOnboarding{t: t}
	svc.SetRepositorySyncConnectionValidator(tenantHandle.Info().ID, provider)
	owner := db.User{Login: "sync-add-installation-owner", Name: "sync-add-installation-owner", Type: db.TypeUser, UserKind: db.UserKindHuman, Status: db.UserStatusActive}
	if err := svc.DBForCtx(ctx).Create(&owner).Error; err != nil {
		t.Fatalf("create owner: %v", err)
	}
	ownerCtx := testContextWithUser(ctx, owner)

	authorize, err := svc.StartRepositorySyncGitHubAuthorize(ownerCtx, "/sync/new")
	if err != nil {
		t.Fatalf("start initial authorization: %v", err)
	}
	authorizeURL, _ := url.Parse(authorize.InstallURL)
	if _, err := svc.CompleteRepositorySyncGitHubInstall(ctx, service.CompleteRepositorySyncGitHubInstallInput{
		State: authorizeURL.Query().Get("state"), Code: "initial-code",
	}); err != nil {
		t.Fatalf("complete initial authorization: %v", err)
	}

	install, err := svc.StartRepositorySyncGitHubInstall(ownerCtx, "/sync/new")
	if err != nil {
		t.Fatalf("start additional installation: %v", err)
	}
	installURL, _ := url.Parse(install.InstallURL)
	result, err := svc.CompleteRepositorySyncGitHubInstall(ctx, service.CompleteRepositorySyncGitHubInstallInput{
		State: installURL.Query().Get("state"), Code: "additional-code",
	})
	if err != nil || result.Status != "connected" {
		t.Fatalf("complete additional installation = %#v, %v", result, err)
	}

	var userConnection db.SyncGitHubUserConnection
	if err := svc.DBForCtx(ctx).First(&userConnection, "user_id = ?", owner.ID).Error; err != nil {
		t.Fatalf("load user connection: %v", err)
	}
	var grants []db.SyncGitHubUserInstallation
	if err := svc.DBForCtx(ctx).Where("user_connection_id = ?", userConnection.ID).Order("installation_id").Find(&grants).Error; err != nil {
		t.Fatalf("load installation grants: %v", err)
	}
	if len(grants) != 2 || grants[0].InstallationID != 41 || grants[1].InstallationID != 42 {
		t.Fatalf("installation grants = %#v, want 41 and 42", grants)
	}
}

func TestRepositorySyncGitHubAccountCanConnectBeforeRepositoryAccessIsGranted(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestServiceWithConfig(t, testharness.ServiceConfig{
		CanonicalBaseURL: "https://git.example.test",
		ConsoleBaseURL:   "https://console.example.test",
	})
	defer cleanup()
	ctx := tenantHandle.Context()
	svc.SetRepositorySyncConnectionValidator(tenantHandle.Info().ID, onboardingRepositorySyncConnectionWithoutInstallations{})
	owner := db.User{Login: "sync-repo-less-owner", Name: "sync-repo-less-owner", Type: db.TypeUser, UserKind: db.UserKindHuman, Status: db.UserStatusActive}
	if err := svc.DBForCtx(ctx).Create(&owner).Error; err != nil {
		t.Fatalf("create owner: %v", err)
	}
	ownerCtx := testContextWithUser(ctx, owner)
	started, err := svc.StartRepositorySyncGitHubAuthorize(ownerCtx, "/sync/new")
	if err != nil {
		t.Fatalf("StartRepositorySyncGitHubAuthorize: %v", err)
	}
	authorizeURL, err := url.Parse(started.InstallURL)
	if err != nil {
		t.Fatalf("parse authorization URL: %v", err)
	}
	completed, err := svc.CompleteRepositorySyncGitHubInstall(ctx, service.CompleteRepositorySyncGitHubInstallInput{
		State: authorizeURL.Query().Get("state"), Code: "github-code",
	})
	if err != nil || completed.Status != "connected" {
		t.Fatalf("complete authorization = %#v, %v", completed, err)
	}
	account, err := svc.GetRepositorySyncGitHubAccount(ownerCtx)
	if err != nil || account == nil || account.Login != "repo-less-user" {
		t.Fatalf("connected account = %#v, %v", account, err)
	}
	installations, err := svc.ListRepositorySyncGitHubInstallations(ownerCtx)
	if err != nil || len(installations) != 0 {
		t.Fatalf("installations = %#v, %v", installations, err)
	}
	repositories, err := svc.ListRepositorySyncGitHubRepositories(ownerCtx, "")
	if err != nil || len(repositories) != 0 {
		t.Fatalf("repositories = %#v, %v", repositories, err)
	}
}

func TestRepositorySyncBindingRunAndHandoffControlPlane(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()
	ctx := tenantHandle.Context()
	svc.SetRepositorySyncConnectionValidator(tenantHandle.Info().ID, allowRepositorySyncConnection{})

	owner := db.User{Login: "sync-owner", Name: "sync-owner", Type: db.TypeUser, UserKind: db.UserKindHuman, Status: db.UserStatusActive}
	if err := svc.DBForCtx(ctx).Create(&owner).Error; err != nil {
		t.Fatalf("create owner: %v", err)
	}
	ownerCtx := testContextWithUser(ctx, owner)
	repository, err := svc.CreateRepo(ownerCtx, service.CreateRepoInput{OwnerLogin: owner.Login, Name: "source"})
	if err != nil {
		t.Fatalf("create repository: %v", err)
	}

	binding, err := svc.CreateRepositorySyncBinding(ownerCtx, service.CreateRepositorySyncBindingInput{
		AGSRepositoryID: repository.ID, GitHubRepositoryID: "R_repo", GitHubRepositoryFullName: "example/source",
		GitHubConnectionID: "github-app-installation:1", InitialAuthority: service.SyncProviderGitHub,
		ScheduleMode: service.SyncScheduleScheduled, ScheduleCron: "0 2 * * *",
	})
	if err != nil {
		t.Fatalf("CreateRepositorySyncBinding: %v", err)
	}
	if binding.AuthoritySide != service.SyncSideB || binding.AuthorityEpoch != 1 || len(binding.Endpoints) != 2 {
		t.Fatalf("binding = %#v", binding)
	}
	if binding.SyncEnabled || binding.NextRunAt != nil {
		t.Fatalf("new binding scheduled before its first full apply: %#v", binding)
	}
	enable := true
	if _, err := svc.UpdateRepositorySyncBinding(ownerCtx, binding.ID, service.UpdateRepositorySyncBindingInput{SyncEnabled: &enable}); !errors.Is(err, service.ErrInvalidState) {
		t.Fatalf("enable before full apply error = %v, want ErrInvalidState", err)
	}
	if _, err := svc.HasRepoAccess(service.ContextWithRepositoryWriteOperation(ownerCtx), repository.ID, owner.ID); !errors.Is(err, service.ErrConflict) {
		t.Fatalf("GitHub-authority standby write access error = %v, want ErrConflict", err)
	}

	if _, err := svc.CreateRepositorySyncBinding(ownerCtx, service.CreateRepositorySyncBindingInput{
		AGSRepositoryID: repository.ID, GitHubRepositoryID: "R_other", GitHubRepositoryFullName: "example/other",
		GitHubConnectionID: "github-app-installation:1", InitialAuthority: service.SyncProviderGitHub,
	}); !errors.Is(err, service.ErrConflict) {
		t.Fatalf("duplicate endpoint error = %v, want ErrConflict", err)
	}

	run, err := svc.CreateRepositorySyncRun(ownerCtx, binding.ID, service.CreateRepositorySyncRunInput{Mode: service.SyncRunModeApply})
	if err != nil {
		t.Fatalf("CreateRepositorySyncRun: %v", err)
	}
	if run.SourceSide != service.SyncSideB || run.TargetSide != service.SyncSideA || run.Status != service.SyncRunStatusQueued || run.Stage != service.SyncRunStageQueued || run.AuditMode != service.SyncAuditModeFull {
		t.Fatalf("run = %#v", run)
	}
	if _, err := svc.CreateRepositorySyncRun(ownerCtx, binding.ID, service.CreateRepositorySyncRunInput{}); !errors.Is(err, service.ErrConflict) {
		t.Fatalf("concurrent run error = %v, want ErrConflict", err)
	}
	if _, err := svc.CancelRepositorySyncRun(ownerCtx, run.ID); err != nil {
		t.Fatalf("CancelRepositorySyncRun: %v", err)
	}
	if _, err := svc.CreateRepositorySyncHandoff(ownerCtx, binding.ID, service.CreateRepositorySyncHandoffInput{
		ToProvider: service.SyncProviderAGS, ExpectedAuthorityEpoch: binding.AuthorityEpoch,
	}); !errors.Is(err, service.ErrValidation) {
		t.Fatalf("unconfirmed handoff error = %v, want ErrValidation", err)
	}
	handoff, err := svc.CreateRepositorySyncHandoff(ownerCtx, binding.ID, service.CreateRepositorySyncHandoffInput{
		ToProvider: service.SyncProviderAGS, ExpectedAuthorityEpoch: binding.AuthorityEpoch, AuthorityWritesStopped: true,
	})
	if err != nil {
		t.Fatalf("CreateRepositorySyncHandoff: %v", err)
	}
	if handoff.Kind != service.SyncRunKindHandoff || handoff.HandoffToSide != service.SyncSideA || handoff.Phase != service.SyncRunPhaseHandoffFinal {
		t.Fatalf("handoff = %#v", handoff)
	}
	claim, ok, err := svc.ClaimNextRepositorySyncRun(ctx, "handoff-test-worker", time.Minute)
	if err != nil || !ok || claim.Run.ID != handoff.ID {
		t.Fatalf("claim handoff: claim=%#v ok=%t err=%v", claim, ok, err)
	}
	if _, err := svc.PersistRepositorySyncPlan(ctx, handoff.ID, service.PersistRepositorySyncPlanInput{TargetConfirmedEmpty: true, LeaseOwner: claim.LeaseOwner, LeaseGeneration: claim.LeaseGeneration}); err != nil {
		t.Fatalf("plan handoff final sync: %v", err)
	}
	handoff, err = svc.CompleteRepositorySyncHandoffPhase(ctx, handoff.ID, service.CompleteRepositorySyncHandoffPhaseInput{
		ExpectedPhase: service.SyncRunPhaseHandoffFinal, AuthorityStable: true, LeaseOwner: claim.LeaseOwner, LeaseGeneration: claim.LeaseGeneration,
	})
	if err != nil {
		t.Fatalf("complete handoff final sync: %v", err)
	}
	if handoff.Status != service.SyncRunStatusQueued || handoff.Phase != service.SyncRunPhaseHandoffReverseDry {
		t.Fatalf("handoff after final sync = %#v", handoff)
	}
	if err := svc.ReleaseRepositorySyncLease(ctx, binding.ID, claim.LeaseOwner, claim.LeaseGeneration); err != nil {
		t.Fatalf("release handoff final lease: %v", err)
	}
	claim, ok, err = svc.ClaimNextRepositorySyncRun(ctx, "handoff-test-worker", time.Minute)
	if err != nil || !ok || claim.Run.ID != handoff.ID || claim.Run.Phase != service.SyncRunPhaseHandoffReverseDry {
		t.Fatalf("claim reverse dry-run: claim=%#v ok=%t err=%v", claim, ok, err)
	}
	reversePlan, err := svc.PersistRepositorySyncPlan(ctx, handoff.ID, service.PersistRepositorySyncPlanInput{TargetConfirmedEmpty: true, LeaseOwner: claim.LeaseOwner, LeaseGeneration: claim.LeaseGeneration})
	if err != nil {
		t.Fatalf("plan handoff reverse dry-run: %v", err)
	}
	if !reversePlan.DryRun {
		t.Fatal("handoff reverse phase was not forced to dry-run")
	}
	if _, err := svc.CompleteRepositorySyncHandoffPhase(ctx, handoff.ID, service.CompleteRepositorySyncHandoffPhaseInput{
		ExpectedPhase: service.SyncRunPhaseHandoffReverseDry, AuthorityStable: true, LeaseOwner: claim.LeaseOwner, LeaseGeneration: claim.LeaseGeneration,
	}); err != nil {
		t.Fatalf("complete handoff reverse dry-run: %v", err)
	}
	switched, err := svc.GetRepositorySyncBinding(ownerCtx, binding.ID)
	if err != nil {
		t.Fatalf("get switched binding: %v", err)
	}
	if switched.AuthoritySide != service.SyncSideA || switched.AuthorityEpoch != 2 || switched.SyncEnabled {
		t.Fatalf("switched binding = %#v", switched)
	}
	if permission, err := svc.HasRepoAccess(service.ContextWithRepositoryWriteOperation(ownerCtx), repository.ID, owner.ID); err != nil || !permission.AtLeast(service.RepoPermissionAdmin) {
		t.Fatalf("new AGS authority remained write locked: permission=%v err=%v", permission, err)
	}
}

func TestRepositorySyncFailedInitialPreflightReleasesSetupButApplyFailureKeepsLock(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()
	ctx := tenantHandle.Context()
	svc.SetRepositorySyncConnectionValidator(tenantHandle.Info().ID, allowRepositorySyncConnection{})

	owner := db.User{Login: "sync-preflight-owner", Name: "sync-preflight-owner", Type: db.TypeUser, UserKind: db.UserKindHuman, Status: db.UserStatusActive}
	if err := svc.DBForCtx(ctx).Create(&owner).Error; err != nil {
		t.Fatalf("create owner: %v", err)
	}
	ownerCtx := testContextWithUser(ctx, owner)
	repository, err := svc.CreateRepo(ownerCtx, service.CreateRepoInput{OwnerLogin: owner.Login, Name: "preflight-source"})
	if err != nil {
		t.Fatalf("create repository: %v", err)
	}

	firstBinding, err := svc.CreateRepositorySyncBinding(ownerCtx, service.CreateRepositorySyncBindingInput{
		AGSRepositoryID: repository.ID, GitHubRepositoryID: "R_preflight", GitHubRepositoryFullName: "example/preflight",
		GitHubConnectionID: "github-app-installation:preflight", InitialAuthority: service.SyncProviderGitHub,
	})
	if err != nil {
		t.Fatalf("create first binding: %v", err)
	}
	preflight, err := svc.CreateRepositorySyncRun(ownerCtx, firstBinding.ID, service.CreateRepositorySyncRunInput{Mode: service.SyncRunModeDryRun, AuditMode: service.SyncAuditModeFull})
	if err != nil {
		t.Fatalf("create preflight: %v", err)
	}
	claim, ok, err := svc.ClaimNextRepositorySyncRun(ctx, "preflight-test-worker", time.Minute)
	if err != nil || !ok || claim.Run.ID != preflight.ID {
		t.Fatalf("claim preflight: claim=%#v ok=%t err=%v", claim, ok, err)
	}
	completed, err := svc.CompleteRepositorySyncRun(ctx, preflight.ID, service.CompleteRepositorySyncRunInput{
		Status: service.SyncRunStatusRequiresAttention, AuthorityStable: true,
		LeaseOwner: claim.LeaseOwner, LeaseGeneration: claim.LeaseGeneration,
	})
	if err != nil || completed.Status != service.SyncRunStatusRequiresAttention {
		t.Fatalf("complete preflight: run=%#v err=%v", completed, err)
	}
	if err := svc.ReleaseRepositorySyncLease(ctx, firstBinding.ID, claim.LeaseOwner, claim.LeaseGeneration); err != nil {
		t.Fatalf("release preflight lease: %v", err)
	}
	closed, err := svc.GetRepositorySyncBinding(ownerCtx, firstBinding.ID)
	if err != nil || closed.Status != service.SyncBindingStatusClosed {
		t.Fatalf("failed preflight binding = %#v, %v", closed, err)
	}
	var claims int64
	if err := svc.DBForCtx(ctx).Model(&db.SyncBindingEndpoint{}).Where("binding_id = ? AND active_claim_key IS NOT NULL", firstBinding.ID).Count(&claims).Error; err != nil {
		t.Fatalf("count active endpoint claims: %v", err)
	}
	if claims != 0 {
		t.Fatalf("failed preflight retained %d endpoint claims", claims)
	}
	if permission, err := svc.HasRepoAccess(service.ContextWithRepositoryWriteOperation(ownerCtx), repository.ID, owner.ID); err != nil || !permission.AtLeast(service.RepoPermissionAdmin) {
		t.Fatalf("failed preflight kept AGS write lock: permission=%v err=%v", permission, err)
	}

	secondBinding, err := svc.CreateRepositorySyncBinding(ownerCtx, service.CreateRepositorySyncBindingInput{
		AGSRepositoryID: repository.ID, GitHubRepositoryID: "R_preflight", GitHubRepositoryFullName: "example/preflight",
		GitHubConnectionID: "github-app-installation:preflight", InitialAuthority: service.SyncProviderGitHub,
	})
	if err != nil {
		t.Fatalf("create replacement binding after preflight failure: %v", err)
	}
	apply, err := svc.CreateRepositorySyncRun(ownerCtx, secondBinding.ID, service.CreateRepositorySyncRunInput{Mode: service.SyncRunModeApply, AuditMode: service.SyncAuditModeFull})
	if err != nil {
		t.Fatalf("create apply run: %v", err)
	}
	claim, ok, err = svc.ClaimNextRepositorySyncRun(ctx, "apply-test-worker", time.Minute)
	if err != nil || !ok || claim.Run.ID != apply.ID {
		t.Fatalf("claim apply: claim=%#v ok=%t err=%v", claim, ok, err)
	}
	if _, err := svc.TerminateRepositorySyncRun(ctx, claim, service.SyncRunStatusFailed, "apply_failed", "target write failed after apply began"); err != nil {
		t.Fatalf("terminate apply run: %v", err)
	}
	if err := svc.ReleaseRepositorySyncLease(ctx, secondBinding.ID, claim.LeaseOwner, claim.LeaseGeneration); err != nil {
		t.Fatalf("release apply lease: %v", err)
	}
	active, err := svc.GetRepositorySyncBinding(ownerCtx, secondBinding.ID)
	if err != nil || active.Status != service.SyncBindingStatusActive {
		t.Fatalf("failed apply binding = %#v, %v", active, err)
	}
	if _, err := svc.HasRepoAccess(service.ContextWithRepositoryWriteOperation(ownerCtx), repository.ID, owner.ID); !errors.Is(err, service.ErrConflict) {
		t.Fatalf("failed apply did not retain AGS write lock: %v", err)
	}
}

func TestRepositorySyncTargetClaimsAndOrphanRecovery(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()
	ctx := tenantHandle.Context()
	svc.SetRepositorySyncConnectionValidator(tenantHandle.Info().ID, allowRepositorySyncConnection{})

	owner := db.User{Login: "sync-claim-owner", Name: "sync-claim-owner", Type: db.TypeUser, UserKind: db.UserKindHuman, Status: db.UserStatusActive}
	if err := svc.DBForCtx(ctx).Create(&owner).Error; err != nil {
		t.Fatalf("create owner: %v", err)
	}
	ownerCtx := testContextWithUser(ctx, owner)
	source, err := svc.CreateRepo(ownerCtx, service.CreateRepoInput{OwnerLogin: owner.Login, Name: "source"})
	if err != nil {
		t.Fatalf("create source: %v", err)
	}

	first, err := svc.CreateRepositorySyncBinding(ownerCtx, service.CreateRepositorySyncBindingInput{
		AGSRepositoryID: source.ID, GitHubRepositoryID: "R_target_one", GitHubRepositoryFullName: "example/target-one",
		GitHubConnectionID: "github-app-installation:claims", InitialAuthority: service.SyncProviderAGS,
	})
	if err != nil {
		t.Fatalf("create first binding: %v", err)
	}
	// Repeating the exact pair keeps its history rather than creating a second
	// team binding just because another setup request was made.
	reused, err := svc.CreateRepositorySyncBinding(ownerCtx, service.CreateRepositorySyncBindingInput{
		AGSRepositoryID: source.ID, GitHubRepositoryID: "R_target_one", GitHubRepositoryFullName: "example/target-one",
		GitHubConnectionID: "github-app-installation:claims", InitialAuthority: service.SyncProviderAGS,
	})
	if err != nil || reused.ID != first.ID {
		t.Fatalf("exact pair reuse = %#v, %v; want binding %d", reused, err, first.ID)
	}
	// A source is deliberately reusable: one AGS source can maintain two
	// independent GitHub standby repositories.
	if _, err := svc.CreateRepositorySyncBinding(ownerCtx, service.CreateRepositorySyncBindingInput{
		AGSRepositoryID: source.ID, GitHubRepositoryID: "R_target_two", GitHubRepositoryFullName: "example/target-two",
		GitHubConnectionID: "github-app-installation:claims", InitialAuthority: service.SyncProviderAGS,
	}); err != nil {
		t.Fatalf("source fan-out binding: %v", err)
	}
	otherSource, err := svc.CreateRepo(ownerCtx, service.CreateRepoInput{OwnerLogin: owner.Login, Name: "other-source"})
	if err != nil {
		t.Fatalf("create other source: %v", err)
	}
	if _, err := svc.CreateRepositorySyncBinding(ownerCtx, service.CreateRepositorySyncBindingInput{
		AGSRepositoryID: otherSource.ID, GitHubRepositoryID: "R_target_one", GitHubRepositoryFullName: "example/target-one",
		GitHubConnectionID: "github-app-installation:claims", InitialAuthority: service.SyncProviderAGS,
	}); !errors.Is(err, service.ErrConflict) {
		t.Fatalf("duplicate writable target error = %v, want ErrConflict", err)
	}

	run, err := svc.CreateRepositorySyncRun(ownerCtx, first.ID, service.CreateRepositorySyncRunInput{Mode: service.SyncRunModeApply})
	if err != nil {
		t.Fatalf("create run: %v", err)
	}
	firstClaim, ok, err := svc.ClaimNextRepositorySyncRun(ctx, "first-worker", time.Minute)
	if err != nil || !ok || firstClaim.Run.ID != run.ID {
		t.Fatalf("first claim = %#v, %t, %v", firstClaim, ok, err)
	}
	// This is the #41 failure shape from the old Worker shutdown path: a Run is
	// still running but has no lease. A later Worker must safely fence and take it.
	if err := svc.DBForCtx(ctx).Model(&db.SyncBinding{}).Where("id = ?", first.ID).
		Updates(map[string]any{"lease_owner": "", "lease_expires_at": nil}).Error; err != nil {
		t.Fatalf("clear abandoned lease: %v", err)
	}
	secondClaim, ok, err := svc.ClaimNextRepositorySyncRun(ctx, "recovery-worker", time.Minute)
	if err != nil || !ok || secondClaim.Run.ID != run.ID || secondClaim.LeaseGeneration <= firstClaim.LeaseGeneration {
		t.Fatalf("reclaimed claim = %#v, %t, %v; first=%#v", secondClaim, ok, err, firstClaim)
	}
	if secondClaim.Run.LastProgressAt == nil {
		t.Fatal("reclaimed run did not record last progress")
	}
	if err := svc.ReleaseRepositorySyncLease(ctx, first.ID, secondClaim.LeaseOwner, secondClaim.LeaseGeneration); err != nil {
		t.Fatalf("release reclaimed lease: %v", err)
	}
}

func TestRepositorySyncRateLimitPauseAndSafePreApplyCancel(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()
	ctx := tenantHandle.Context()
	svc.SetRepositorySyncConnectionValidator(tenantHandle.Info().ID, allowRepositorySyncConnection{})

	owner := db.User{Login: "sync-pause-owner", Name: "sync-pause-owner", Type: db.TypeUser, UserKind: db.UserKindHuman, Status: db.UserStatusActive}
	if err := svc.DBForCtx(ctx).Create(&owner).Error; err != nil {
		t.Fatalf("create owner: %v", err)
	}
	ownerCtx := testContextWithUser(ctx, owner)
	repository, err := svc.CreateRepo(ownerCtx, service.CreateRepoInput{OwnerLogin: owner.Login, Name: "pause-target"})
	if err != nil {
		t.Fatalf("create repository: %v", err)
	}
	binding, err := svc.CreateRepositorySyncBinding(ownerCtx, service.CreateRepositorySyncBindingInput{
		AGSRepositoryID: repository.ID, GitHubRepositoryID: "R_pause", GitHubRepositoryFullName: "example/pause",
		GitHubConnectionID: "github-app-installation:pause", InitialAuthority: service.SyncProviderGitHub,
	})
	if err != nil {
		t.Fatalf("create binding: %v", err)
	}
	run, err := svc.CreateRepositorySyncRun(ownerCtx, binding.ID, service.CreateRepositorySyncRunInput{Mode: service.SyncRunModeApply})
	if err != nil {
		t.Fatalf("create run: %v", err)
	}
	claim, ok, err := svc.ClaimNextRepositorySyncRun(ctx, "rate-worker", time.Minute)
	if err != nil || !ok || claim.Run.ID != run.ID {
		t.Fatalf("claim = %#v, %t, %v", claim, ok, err)
	}
	retryAt := time.Now().UTC().Add(time.Hour)
	paused, err := svc.DeferRepositorySyncRun(ctx, claim, retryAt, "rate_limited", "GitHub rate limit exceeded")
	if err != nil || paused.Status != service.SyncRunStatusQueued || paused.RetryAfterAt == nil || paused.RetryAfterAt.Before(retryAt.Add(-time.Second)) {
		t.Fatalf("paused run = %#v, %v", paused, err)
	}
	if err := svc.ReleaseRepositorySyncLease(ctx, binding.ID, claim.LeaseOwner, claim.LeaseGeneration); err != nil {
		t.Fatalf("release rate-limit lease: %v", err)
	}
	if _, ok, err := svc.ClaimNextRepositorySyncRun(ctx, "early-worker", time.Minute); err != nil || ok {
		t.Fatalf("rate-limited run claimed before retry time: ok=%t err=%v", ok, err)
	}
	cancelled, err := svc.CancelRepositorySyncRun(ownerCtx, run.ID)
	if err != nil || cancelled.Status != service.SyncRunStatusCancelled {
		t.Fatalf("cancel queued rate-limited run = %#v, %v", cancelled, err)
	}
	retried, err := svc.CreateRepositorySyncRun(ownerCtx, binding.ID, service.CreateRepositorySyncRunInput{Mode: service.SyncRunModeApply})
	if err != nil {
		t.Fatalf("create run after safe cancellation: %v", err)
	}
	activeClaim, ok, err := svc.ClaimNextRepositorySyncRun(ctx, "planning-worker", time.Minute)
	if err != nil || !ok || activeClaim.Run.ID != retried.ID {
		t.Fatalf("claim replacement run = %#v, %t, %v", activeClaim, ok, err)
	}
	if err := svc.UpdateRepositorySyncRunStage(ctx, activeClaim, service.SyncRunStagePlanning); err != nil {
		t.Fatalf("mark planning: %v", err)
	}
	cancelled, err = svc.CancelRepositorySyncRun(ownerCtx, retried.ID)
	if err != nil || cancelled.Status != service.SyncRunStatusCancelled {
		t.Fatalf("cancel running pre-apply run = %#v, %v", cancelled, err)
	}
	var refreshed db.SyncBinding
	if err := svc.DBForCtx(ctx).First(&refreshed, binding.ID).Error; err != nil || refreshed.LeaseOwner != "" || refreshed.LeaseExpiresAt != nil {
		t.Fatalf("pre-apply cancellation retained lease: %#v, %v", refreshed, err)
	}
}

func TestPersistRepositorySyncPlanUsesBulkBootstrapAndAllocatesLinks(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()
	ctx := tenantHandle.Context()
	svc.SetRepositorySyncConnectionValidator(tenantHandle.Info().ID, allowRepositorySyncConnection{})
	owner := db.User{Login: "sync-plan-owner", Name: "sync-plan-owner", Type: db.TypeUser, UserKind: db.UserKindHuman, Status: db.UserStatusActive}
	if err := svc.DBForCtx(ctx).Create(&owner).Error; err != nil {
		t.Fatalf("create owner: %v", err)
	}
	ownerCtx := testContextWithUser(ctx, owner)
	repository, err := svc.CreateRepo(ownerCtx, service.CreateRepoInput{OwnerLogin: owner.Login, Name: "source"})
	if err != nil {
		t.Fatalf("create repository: %v", err)
	}
	binding, err := svc.CreateRepositorySyncBinding(ownerCtx, service.CreateRepositorySyncBindingInput{
		AGSRepositoryID: repository.ID, GitHubRepositoryID: "R_plan", GitHubRepositoryFullName: "example/plan",
		GitHubConnectionID: "github-app-installation:2", InitialAuthority: service.SyncProviderGitHub,
	})
	if err != nil {
		t.Fatalf("create binding: %v", err)
	}
	run, err := svc.CreateRepositorySyncRun(ownerCtx, binding.ID, service.CreateRepositorySyncRunInput{})
	if err != nil {
		t.Fatalf("create run: %v", err)
	}
	claim, ok, err := svc.ClaimNextRepositorySyncRun(ctx, "plan-test-worker", time.Minute)
	if err != nil || !ok || claim.Run.ID != run.ID {
		t.Fatalf("claim run: claim=%#v ok=%t err=%v", claim, ok, err)
	}

	source := make([]reposync.InventoryObject, 0, 17) // crosses the plan persistence transaction boundary.
	for index := 0; index < 17; index++ {
		source = append(source, reposync.InventoryObject{
			ObjectType: "issues", Identity: fmt.Sprintf("%d", 100+index), ObjectKey: fmt.Sprintf("issue:%d", 100+index),
			ContentHash: fmt.Sprintf("hash-%d", index), PayloadBytes: 10,
		})
	}
	planInput := service.PersistRepositorySyncPlanInput{
		TargetConfirmedEmpty: true,
		Source:               source,
		BatchLimits:          reposync.BatchLimits{MaxItems: 1, MaxBytes: 1024},
		LeaseOwner:           claim.LeaseOwner, LeaseGeneration: claim.LeaseGeneration,
	}
	result, err := svc.PersistRepositorySyncPlan(ctx, run.ID, planInput)
	if err != nil {
		t.Fatalf("PersistRepositorySyncPlan: %v", err)
	}
	if result.PlannerMode != reposync.PlannerModeBootstrap || len(result.Batches) != len(source) {
		t.Fatalf("result = %#v", result)
	}
	for _, item := range result.Items {
		if item.ObjectLinkID == nil || item.Action != reposync.ActionCreate {
			t.Fatalf("bootstrap item = %#v", item)
		}
	}
	// Simulate a reclaimed Worker replaying planning after the first set of
	// short transactions has committed but before execution begins. It must not
	// create duplicate RunItems or ObjectLinks.
	replayed, err := svc.PersistRepositorySyncPlan(ctx, run.ID, planInput)
	if err != nil {
		t.Fatalf("replay PersistRepositorySyncPlan: %v", err)
	}
	if len(replayed.Items) != len(source) || replayed.PlannerMode != reposync.PlannerModeBootstrap {
		t.Fatalf("replayed plan = %#v", replayed)
	}
	var runItemCount, linkCount int64
	if err := svc.DBForCtx(ctx).Model(&db.SyncRunItem{}).Where("run_id = ?", run.ID).Count(&runItemCount).Error; err != nil {
		t.Fatalf("count run items: %v", err)
	}
	if err := svc.DBForCtx(ctx).Model(&db.SyncObjectLink{}).Where("binding_id = ?", binding.ID).Count(&linkCount).Error; err != nil {
		t.Fatalf("count object links: %v", err)
	}
	if runItemCount != int64(len(source)) || linkCount != int64(len(source)) {
		t.Fatalf("run items=%d links=%d, want %d/%d", runItemCount, linkCount, len(source), len(source))
	}
	storedRun, err := svc.GetRepositorySyncRun(ownerCtx, run.ID)
	if err != nil {
		t.Fatalf("get planned run: %v", err)
	}
	if storedRun.Status != service.SyncRunStatusRunning || storedRun.PlannerMode != reposync.PlannerModeBootstrap {
		t.Fatalf("stored run = %#v", storedRun)
	}
	progress, err := svc.GetRepositorySyncRunProgress(ownerCtx, run.ID)
	if err != nil {
		t.Fatalf("get planned run progress: %v", err)
	}
	want := int64(len(source))
	if progress.Total != want || progress.Processed != 0 || progress.Planned != want || progress.Remaining != want {
		t.Fatalf("planned progress = %#v", progress)
	}
	issuesProgress := progress.ByObjectType["issues"]
	if issuesProgress.Total != want || issuesProgress.Planned != want || issuesProgress.Remaining != want {
		t.Fatalf("planned issues progress = %#v", issuesProgress)
	}
	var storedItems []db.SyncRunItem
	if err := svc.DBForCtx(ctx).Where("run_id = ?", run.ID).Order("id").Find(&storedItems).Error; err != nil {
		t.Fatalf("load run items: %v", err)
	}
	for index, item := range storedItems {
		if _, err := svc.CompleteRepositorySyncRunItem(ctx, item.ID, service.CompleteRepositorySyncRunItemInput{
			TargetIdentity: "target-" + item.ObjectKey, TargetRevision: "r", TargetHash: result.Items[index].DesiredHash,
			TargetNumber: 100 + index, TargetURL: "https://ags.test/items/" + item.ObjectKey,
			LeaseOwner: claim.LeaseOwner, LeaseGeneration: claim.LeaseGeneration,
		}); err != nil {
			t.Fatalf("complete run item %d: %v", item.ID, err)
		}
	}
	completed, err := svc.CompleteRepositorySyncRun(ctx, run.ID, service.CompleteRepositorySyncRunInput{
		Status: service.SyncRunStatusCompleted, AuthorityStable: true, AllManagedObjectsVerified: true,
		LeaseOwner: claim.LeaseOwner, LeaseGeneration: claim.LeaseGeneration,
	})
	if err != nil {
		t.Fatalf("CompleteRepositorySyncRun: %v", err)
	}
	if completed.Status != service.SyncRunStatusCompleted {
		t.Fatalf("completed run = %#v", completed)
	}
	progress, err = svc.GetRepositorySyncRunProgress(ownerCtx, run.ID)
	if err != nil {
		t.Fatalf("get completed run progress: %v", err)
	}
	if progress.Total != want || progress.Processed != want || progress.Completed != want || progress.Remaining != 0 {
		t.Fatalf("completed progress = %#v", progress)
	}
	issuesProgress = progress.ByObjectType["issues"]
	if issuesProgress.Processed != want || issuesProgress.Completed != want || issuesProgress.Remaining != 0 {
		t.Fatalf("completed issues progress = %#v", issuesProgress)
	}
	var links []db.SyncObjectLink
	if err := svc.DBForCtx(ctx).Where("binding_id = ?", binding.ID).Order("id").Find(&links).Error; err != nil {
		t.Fatal(err)
	}
	for index, link := range links {
		if link.SideANumber == nil || *link.SideANumber != 100+index || link.SideAURL == "" || link.SideARevision != "r" || link.VerifiedMappingVersion == "" || link.LastVerifiedAt == nil {
			t.Fatalf("target ObjectLink audit fields were not persisted: %#v", link)
		}
	}
	mode, cron, enable := service.SyncScheduleScheduled, "0 3 * * *", true
	enabled, err := svc.UpdateRepositorySyncBinding(ownerCtx, binding.ID, service.UpdateRepositorySyncBindingInput{
		ScheduleMode: &mode, ScheduleCron: &cron, SyncEnabled: &enable,
	})
	if err != nil {
		t.Fatalf("enable schedule after full apply: %v", err)
	}
	if !enabled.SyncEnabled || enabled.NextRunAt == nil {
		t.Fatalf("schedule was not enabled after full apply: %#v", enabled)
	}
}

func TestPersistRepositorySyncPlanAdoptsMatchingUnlinkedLabel(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()
	ctx := tenantHandle.Context()
	svc.SetRepositorySyncConnectionValidator(tenantHandle.Info().ID, allowRepositorySyncConnection{})
	owner := db.User{Login: "sync-label-owner", Name: "sync-label-owner", Type: db.TypeUser, UserKind: db.UserKindHuman, Status: db.UserStatusActive}
	if err := svc.DBForCtx(ctx).Create(&owner).Error; err != nil {
		t.Fatalf("create owner: %v", err)
	}
	ownerCtx := testContextWithUser(ctx, owner)
	repository, err := svc.CreateRepo(ownerCtx, service.CreateRepoInput{OwnerLogin: owner.Login, Name: "label-target"})
	if err != nil {
		t.Fatalf("create repository: %v", err)
	}
	binding, err := svc.CreateRepositorySyncBinding(ownerCtx, service.CreateRepositorySyncBindingInput{
		AGSRepositoryID: repository.ID, GitHubRepositoryID: "R_labels", GitHubRepositoryFullName: "example/labels",
		GitHubConnectionID: "github-app-installation:labels", InitialAuthority: service.SyncProviderGitHub,
	})
	if err != nil {
		t.Fatalf("create binding: %v", err)
	}
	run, err := svc.CreateRepositorySyncRun(ownerCtx, binding.ID, service.CreateRepositorySyncRunInput{Mode: service.SyncRunModeApply, AuditMode: service.SyncAuditModeFull})
	if err != nil {
		t.Fatalf("create run: %v", err)
	}
	claim, ok, err := svc.ClaimNextRepositorySyncRun(ctx, "label-plan-worker", time.Minute)
	if err != nil || !ok || claim.Run.ID != run.ID {
		t.Fatalf("claim run: claim=%#v ok=%t err=%v", claim, ok, err)
	}
	result, err := svc.PersistRepositorySyncPlan(ctx, run.ID, service.PersistRepositorySyncPlanInput{
		Source: []reposync.InventoryObject{{
			ObjectType: "labels", Identity: "github-label-7", ObjectKey: "label:bug", UniqueKey: "bug",
			Revision: "github-revision", ContentHash: "github-hash", Payload: []byte(`{"schema":1,"type":"labels","identity":"github-label-7","key":"label:bug","fields":{"name":"bug","color":"ff0000"}}`),
		}},
		Target: []reposync.InventoryObject{{
			ObjectType: "labels", Identity: "42", ObjectKey: "label:bug", UniqueKey: "bug",
			Revision: "ags-revision", ContentHash: "ags-hash",
		}},
		LeaseOwner: claim.LeaseOwner, LeaseGeneration: claim.LeaseGeneration,
	})
	if err != nil {
		t.Fatalf("persist label plan: %v", err)
	}
	if len(result.Items) != 1 || result.Items[0].Action != reposync.ActionUpdate || result.Items[0].ObjectLinkID == nil {
		t.Fatalf("label plan = %#v, want one linked update", result.Items)
	}
	var link db.SyncObjectLink
	if err := svc.DBForCtx(ctx).First(&link, "id = ?", *result.Items[0].ObjectLinkID).Error; err != nil {
		t.Fatalf("load label link: %v", err)
	}
	if link.SideAID == nil || *link.SideAID != "42" || link.SideBID == nil || *link.SideBID != "github-label-7" {
		t.Fatalf("label link = %#v, want AGS target 42 and GitHub source github-label-7", link)
	}
}

func TestEnqueueDueRepositorySyncRunsChoosesCheckpointAndFullAudit(t *testing.T) {
	svc, tenantHandle, cleanup := setupTestService(t)
	defer cleanup()
	ctx := tenantHandle.Context()
	svc.SetRepositorySyncConnectionValidator(tenantHandle.Info().ID, allowRepositorySyncConnection{})
	svc.SetRepositorySyncFullAuditInterval(tenantHandle.Info().ID, 7*24*time.Hour)

	owner := db.User{Login: "sync-schedule-owner", Name: "sync-schedule-owner", Type: db.TypeUser, UserKind: db.UserKindHuman, Status: db.UserStatusActive}
	if err := svc.DBForCtx(ctx).Create(&owner).Error; err != nil {
		t.Fatalf("create owner: %v", err)
	}
	ownerCtx := testContextWithUser(ctx, owner)
	repository, err := svc.CreateRepo(ownerCtx, service.CreateRepoInput{OwnerLogin: owner.Login, Name: "scheduled-source"})
	if err != nil {
		t.Fatalf("create repository: %v", err)
	}
	binding, err := svc.CreateRepositorySyncBinding(ownerCtx, service.CreateRepositorySyncBindingInput{
		AGSRepositoryID: repository.ID, GitHubRepositoryID: "R_schedule", GitHubRepositoryFullName: "example/schedule",
		GitHubConnectionID: "github-app-installation:3", InitialAuthority: service.SyncProviderAGS,
		ScheduleMode: service.SyncScheduleScheduled, ScheduleCron: "0 2 * * *",
	})
	if err != nil {
		t.Fatalf("create binding: %v", err)
	}

	now := time.Now().UTC().Truncate(time.Second)
	due := now.Add(-time.Minute)
	recentFull := now.Add(-time.Hour)
	if err := svc.DBForCtx(ctx).Model(&db.SyncBinding{}).Where("id = ?", binding.ID).Updates(map[string]any{
		"sync_enabled": true, "next_run_at": due, "last_full_audit_at": recentFull,
	}).Error; err != nil {
		t.Fatalf("prepare checkpoint schedule: %v", err)
	}
	enqueued, err := svc.EnqueueDueRepositorySyncRuns(ctx, now, 10)
	if err != nil || enqueued != 1 {
		t.Fatalf("enqueue checkpoint: count=%d err=%v", enqueued, err)
	}
	var checkpoint db.SyncRun
	if err := svc.DBForCtx(ctx).Where("binding_id = ?", binding.ID).Order("id DESC").First(&checkpoint).Error; err != nil {
		t.Fatal(err)
	}
	if checkpoint.AuditMode != service.SyncAuditModeCheckpoint {
		t.Fatalf("recent full audit scheduled %q, want checkpoint", checkpoint.AuditMode)
	}

	coalescedDue := now.Add(-30 * time.Second)
	if err := svc.DBForCtx(ctx).Model(&db.SyncBinding{}).Where("id = ?", binding.ID).Update("next_run_at", coalescedDue).Error; err != nil {
		t.Fatal(err)
	}
	if enqueued, err = svc.EnqueueDueRepositorySyncRuns(ctx, now, 10); err != nil || enqueued != 0 {
		t.Fatalf("coalesce active run: count=%d err=%v", enqueued, err)
	}
	var afterCoalesce db.SyncBinding
	if err := svc.DBForCtx(ctx).First(&afterCoalesce, binding.ID).Error; err != nil {
		t.Fatal(err)
	}
	if afterCoalesce.NextRunAt == nil || !afterCoalesce.NextRunAt.Equal(coalescedDue) {
		t.Fatalf("coalesced due instant changed: %#v", afterCoalesce.NextRunAt)
	}

	oldFull := now.Add(-8 * 24 * time.Hour)
	if err := svc.DBForCtx(ctx).Model(&db.SyncRun{}).Where("id = ?", checkpoint.ID).Update("status", service.SyncRunStatusCompleted).Error; err != nil {
		t.Fatal(err)
	}
	if err := svc.DBForCtx(ctx).Model(&db.SyncBinding{}).Where("id = ?", binding.ID).Updates(map[string]any{
		"next_run_at": due, "last_full_audit_at": oldFull,
	}).Error; err != nil {
		t.Fatalf("prepare full audit schedule: %v", err)
	}
	if enqueued, err = svc.EnqueueDueRepositorySyncRuns(ctx, now, 10); err != nil || enqueued != 1 {
		t.Fatalf("enqueue full audit: count=%d err=%v", enqueued, err)
	}
	var full db.SyncRun
	if err := svc.DBForCtx(ctx).Where("binding_id = ?", binding.ID).Order("id DESC").First(&full).Error; err != nil {
		t.Fatal(err)
	}
	if full.AuditMode != service.SyncAuditModeFull {
		t.Fatalf("overdue full audit scheduled %q, want full", full.AuditMode)
	}
}
