package service

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/pingcap/agent-git-service/internal/db"
	"github.com/pingcap/agent-git-service/internal/randutil"
	reposync "github.com/pingcap/agent-git-service/internal/sync"
	"github.com/pingcap/agent-git-service/internal/sync/canonical"
	"github.com/pingcap/agent-git-service/internal/tenant"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

const (
	SyncSideA = "a"
	SyncSideB = "b"

	SyncProviderAGS    = "ags"
	SyncProviderGitHub = "github"

	SyncBindingStatusActive = "active"
	SyncBindingStatusPaused = "paused"
	SyncBindingStatusClosed = "closed"

	SyncScheduleManual    = "manual"
	SyncScheduleScheduled = "scheduled"

	SyncRefConflictPolicyPreserve  = "preserve"
	SyncRefConflictPolicyUseSource = "use_source_if_unchanged"

	SyncRunKindSync    = "sync"
	SyncRunKindHandoff = "handoff"

	SyncRunModeApply  = "apply"
	SyncRunModeDryRun = "dry_run"

	SyncRunStatusQueued                      = "queued"
	SyncRunStatusRunning                     = "running"
	SyncRunStatusCompleted                   = "completed"
	SyncRunStatusCompletedWithPendingChanges = "completed_with_pending_changes"
	SyncRunStatusRequiresAttention           = "requires_attention"
	SyncRunStatusBlocked                     = "blocked"
	SyncRunStatusFailed                      = "failed"
	SyncRunStatusCancelled                   = "cancelled"
	SyncRunStatusSuperseded                  = "superseded"

	SyncRunPhaseSync              = "sync"
	SyncRunPhaseHandoffFinal      = "handoff_final"
	SyncRunPhaseHandoffReverseDry = "handoff_reverse_dry_run"
	SyncRunPhaseHandoffSwitch     = "handoff_switch"

	SyncRunStageQueued          = "queued"
	SyncRunStageCheckingAccess  = "checking_access"
	SyncRunStageInventorySource = "inventory_source"
	SyncRunStageInventoryTarget = "inventory_target"
	SyncRunStagePlanning        = "planning"
	SyncRunStageApplying        = "applying"
	SyncRunStageVerifying       = "verifying"
	SyncRunStageFinalizing      = "finalizing"
	SyncRunStageCompleted       = "completed"

	SyncPlannerModeAuto = "auto"

	SyncAuditModeCheckpoint = "checkpoint"
	SyncAuditModeFull       = "full"

	SyncRunItemErrorApplyResultUnknown = "apply_result_unknown"
	SyncRunItemErrorMapperChanged      = "mapping_version_changed_with_unfinished_intent"

	SyncGitHubConnectionStatusActive       = "active"
	SyncGitHubRequestStatusPending         = "pending"
	SyncGitHubRequestStatusPendingApproval = "pending_approval"
	SyncGitHubRequestStatusCompleted       = "completed"
	SyncGitHubRequestStatusFailed          = "failed"
	SyncGitHubRequestStatusExpired         = "expired"

	SyncGitHubRequestFlowAuthorize = "authorize"
	SyncGitHubRequestFlowInstall   = "install"
)

// CreateRepositorySyncBindingInput defines one AGS/GitHub repository pair.
// The AGS endpoint is side a and GitHub is side b; direction is derived only
// from InitialAuthority.
type CreateRepositorySyncBindingInput struct {
	AGSRepositoryID           uint
	GitHubRepositoryID        string
	GitHubRepositoryFullName  string
	GitHubConnectionID        string
	InitialAuthority          string
	ScheduleMode              string
	ScheduleCron              string
	ConfirmVisibilityExposure bool
}

// UpdateRepositorySyncBindingInput changes lifecycle or schedule fields. It
// deliberately cannot change authority; only a successful handoff may do so.
type UpdateRepositorySyncBindingInput struct {
	Status                    *string
	ScheduleMode              *string
	ScheduleCron              *string
	SyncEnabled               *bool
	ConfirmVisibilityExposure *bool
	RefConflictPolicy         *string
}

type CreateRepositorySyncRunInput struct {
	Mode      string
	AuditMode string
}

type CreateRepositorySyncHandoffInput struct {
	ToProvider                string
	ExpectedAuthorityEpoch    uint64
	AuthorityWritesStopped    bool
	ConfirmVisibilityExposure bool
}

type RepositorySyncConnectionValidator interface {
	ValidateRepository(context.Context, string, string, string) error
}

type RepositorySyncConnectionResolver interface {
	ResolveRepository(context.Context, string, string, string) (string, error)
}

type RepositorySyncConnectionVisibilityResolver interface {
	ResolveRepositoryWithVisibility(context.Context, string, string, string) (string, string, error)
}

type StartRepositorySyncGitHubInstallResult struct {
	InstallURL string
	ExpiresAt  time.Time
}

type CompleteRepositorySyncGitHubInstallInput struct {
	State          string
	Code           string
	InstallationID int64
	SetupAction    string
	OAuthError     string
}

type CompleteRepositorySyncGitHubInstallResult struct {
	RedirectURL string
	Status      string
	Detail      string
}

type RepositorySyncGitHubAccount struct {
	ID        uint
	Login     string
	AvatarURL string
	Status    string
}

func (s *Service) SetRepositorySyncConnectionValidator(id tenant.ID, validator RepositorySyncConnectionValidator) {
	if id == "" || validator == nil {
		return
	}
	s.repositorySyncValidators.Store(id, validator)
}

func (s *Service) repositorySyncGitHubAuthorizer(ctx context.Context) (reposync.GitHubConnectionAuthorizer, error) {
	info, err := tenant.Require(ctx)
	if err != nil {
		return nil, err
	}
	value, configured := s.repositorySyncValidators.Load(info.ID)
	if !configured {
		return nil, fmt.Errorf("%w: repository sync is not configured for this tenant", ErrInvalidState)
	}
	authorizer, ok := value.(reposync.GitHubConnectionAuthorizer)
	if !ok {
		return nil, fmt.Errorf("%w: GitHub App installation onboarding is unavailable", ErrInvalidState)
	}
	return authorizer, nil
}

func (s *Service) repositorySyncGitHubReauthorizer(ctx context.Context) (reposync.GitHubConnectionReauthorizer, error) {
	info, err := tenant.Require(ctx)
	if err != nil {
		return nil, err
	}
	value, configured := s.repositorySyncValidators.Load(info.ID)
	if !configured {
		return nil, fmt.Errorf("%w: repository sync is not configured for this tenant", ErrInvalidState)
	}
	authorizer, ok := value.(reposync.GitHubConnectionReauthorizer)
	if !ok {
		return nil, fmt.Errorf("%w: GitHub App reauthorization is unavailable", ErrInvalidState)
	}
	return authorizer, nil
}

// StartRepositorySyncGitHubInstall creates a short-lived, one-time state and
// returns GitHub's own App installation URL. No GitHub token is returned to the
// browser or persisted by AGS.
func (s *Service) StartRepositorySyncGitHubInstall(ctx context.Context, returnTo string) (StartRepositorySyncGitHubInstallResult, error) {
	authorizer, err := s.repositorySyncGitHubAuthorizer(ctx)
	if err != nil {
		return StartRepositorySyncGitHubInstallResult{}, err
	}
	discoverer, err := s.repositorySyncGitHubDiscoverer(ctx)
	if err != nil {
		return StartRepositorySyncGitHubInstallResult{}, err
	}
	installations, err := discoverer.ListGitHubInstallations(ctx)
	if err != nil {
		return StartRepositorySyncGitHubInstallResult{}, wrapErr(err)
	}
	baseline := make([]int64, 0, len(installations))
	for _, installation := range installations {
		if installation.InstallationID > 0 {
			baseline = append(baseline, installation.InstallationID)
		}
	}
	return s.startRepositorySyncGitHubAuthorization(ctx, returnTo, SyncGitHubRequestFlowInstall, baseline, authorizer.GitHubInstallURL)
}

// StartRepositorySyncGitHubAuthorize connects the current AGS user to a
// verified GitHub identity and refreshes that user's installation grants.
func (s *Service) StartRepositorySyncGitHubAuthorize(ctx context.Context, returnTo string) (StartRepositorySyncGitHubInstallResult, error) {
	authorizer, err := s.repositorySyncGitHubReauthorizer(ctx)
	if err != nil {
		return StartRepositorySyncGitHubInstallResult{}, err
	}
	return s.startRepositorySyncGitHubAuthorization(ctx, returnTo, SyncGitHubRequestFlowAuthorize, nil, authorizer.GitHubAuthorizeURL)
}

func (s *Service) startRepositorySyncGitHubAuthorization(ctx context.Context, returnTo, flow string, installationBaseline []int64, authorizationURL func(string) (string, error)) (StartRepositorySyncGitHubInstallResult, error) {
	viewer, ok := UserFromContext(ctx)
	if !ok || viewer.ID == 0 {
		return StartRepositorySyncGitHubInstallResult{}, ErrUnauthorized
	}
	returnTo = normalizeRepositorySyncReturnTo(returnTo)
	state := randutil.Hex(32)
	digest := sha256.Sum256([]byte(state))
	expiresAt := time.Now().UTC().Add(10 * time.Minute)
	baselineJSON, err := json.Marshal(installationBaseline)
	if err != nil {
		return StartRepositorySyncGitHubInstallResult{}, err
	}
	request := db.SyncGitHubConnectionRequest{
		StateHash: hex.EncodeToString(digest[:]), RequestedByID: viewer.ID,
		ReturnTo: returnTo, Flow: flow, InstallationBaselineJSON: db.LargeText(baselineJSON),
		Status: SyncGitHubRequestStatusPending, ExpiresAt: expiresAt,
	}
	if err := s.DBForCtx(ctx).Create(&request).Error; err != nil {
		return StartRepositorySyncGitHubInstallResult{}, wrapErr(err)
	}
	installURL, err := authorizationURL(state)
	if err != nil {
		_ = s.DBForCtx(ctx).Delete(&request).Error
		return StartRepositorySyncGitHubInstallResult{}, fmt.Errorf("%w: %v", ErrInvalidState, err)
	}
	return StartRepositorySyncGitHubInstallResult{InstallURL: installURL, ExpiresAt: expiresAt}, nil
}

// CompleteRepositorySyncGitHubInstall consumes GitHub's public callback. The
// GitHub user access token used for verification lives only inside the
// provider call; the durable connection stores non-secret installation data.
func (s *Service) CompleteRepositorySyncGitHubInstall(ctx context.Context, in CompleteRepositorySyncGitHubInstallInput) (CompleteRepositorySyncGitHubInstallResult, error) {
	state := strings.TrimSpace(in.State)
	if state == "" {
		return CompleteRepositorySyncGitHubInstallResult{}, fmt.Errorf("%w: GitHub App callback state is required", ErrValidation)
	}
	digest := sha256.Sum256([]byte(state))
	var request db.SyncGitHubConnectionRequest
	if err := s.DBForCtx(ctx).First(&request, "state_hash = ?", hex.EncodeToString(digest[:])).Error; err != nil {
		return CompleteRepositorySyncGitHubInstallResult{}, wrapErr(err)
	}
	if request.Status != SyncGitHubRequestStatusPending && request.Status != SyncGitHubRequestStatusPendingApproval {
		return CompleteRepositorySyncGitHubInstallResult{}, fmt.Errorf("%w: GitHub App callback state was already used", ErrConflict)
	}
	now := time.Now().UTC()
	if !request.ExpiresAt.After(now) {
		_ = s.DBForCtx(ctx).Model(&request).Updates(map[string]any{"status": SyncGitHubRequestStatusExpired, "completed_at": now}).Error
		return s.repositorySyncGitHubInstallRedirect(ctx, request.ReturnTo, "failed", "authorization_expired")
	}
	if strings.TrimSpace(in.OAuthError) != "" {
		_ = s.DBForCtx(ctx).Model(&request).Updates(map[string]any{"status": SyncGitHubRequestStatusFailed, "completed_at": now}).Error
		return s.repositorySyncGitHubInstallRedirect(ctx, request.ReturnTo, "failed", "authorization_denied")
	}
	if strings.EqualFold(strings.TrimSpace(in.SetupAction), "request") {
		if err := s.DBForCtx(ctx).Model(&request).Update("status", SyncGitHubRequestStatusPendingApproval).Error; err != nil {
			return CompleteRepositorySyncGitHubInstallResult{}, wrapErr(err)
		}
		return s.repositorySyncGitHubInstallRedirect(ctx, request.ReturnTo, "pending", "administrator_approval")
	}
	if strings.TrimSpace(in.Code) == "" {
		_ = s.DBForCtx(ctx).Model(&request).Updates(map[string]any{"status": SyncGitHubRequestStatusFailed, "completed_at": now}).Error
		return s.repositorySyncGitHubInstallRedirect(ctx, request.ReturnTo, "failed", "verification_failed")
	}
	flow := strings.TrimSpace(request.Flow)
	if flow == "" {
		if in.InstallationID > 0 || strings.TrimSpace(in.SetupAction) != "" {
			flow = SyncGitHubRequestFlowInstall
		} else {
			flow = SyncGitHubRequestFlowAuthorize
		}
	}
	var authorization reposync.GitHubAuthorization
	var err error
	if flow == SyncGitHubRequestFlowInstall {
		var baseline []int64
		if strings.TrimSpace(string(request.InstallationBaselineJSON)) != "" {
			if decodeErr := json.Unmarshal([]byte(request.InstallationBaselineJSON), &baseline); decodeErr != nil {
				_ = s.DBForCtx(ctx).Model(&request).Updates(map[string]any{"status": SyncGitHubRequestStatusFailed, "completed_at": now}).Error
				return s.repositorySyncGitHubInstallRedirect(ctx, request.ReturnTo, "failed", "verification_failed")
			}
		}
		authorizer, authorizerErr := s.repositorySyncGitHubAuthorizer(ctx)
		if authorizerErr != nil {
			return CompleteRepositorySyncGitHubInstallResult{}, authorizerErr
		}
		authorization, err = authorizer.VerifyGitHubInstallationChange(ctx, in.Code, in.InstallationID, baseline)
	} else {
		authorizer, authorizerErr := s.repositorySyncGitHubReauthorizer(ctx)
		if authorizerErr != nil {
			return CompleteRepositorySyncGitHubInstallResult{}, authorizerErr
		}
		authorization, err = authorizer.VerifyGitHubAuthorizations(ctx, in.Code)
	}
	if err != nil {
		_ = s.DBForCtx(ctx).Model(&request).Updates(map[string]any{"status": SyncGitHubRequestStatusFailed, "completed_at": now}).Error
		detail := "verification_failed"
		if flow == SyncGitHubRequestFlowInstall {
			detail = "repository_access_not_visible"
		}
		return s.repositorySyncGitHubInstallRedirect(ctx, request.ReturnTo, "failed", detail)
	}
	if authorization.User.ID <= 0 || strings.TrimSpace(authorization.User.Login) == "" {
		_ = s.DBForCtx(ctx).Model(&request).Updates(map[string]any{"status": SyncGitHubRequestStatusFailed, "completed_at": now}).Error
		return s.repositorySyncGitHubInstallRedirect(ctx, request.ReturnTo, "failed", "identity_verification_failed")
	}
	var existingUserConnection db.SyncGitHubUserConnection
	existingLookup := s.DBForCtx(ctx).First(&existingUserConnection, "user_id = ? AND status = ?", request.RequestedByID, SyncGitHubConnectionStatusActive)
	if existingLookup.Error != nil && existingLookup.Error != gorm.ErrRecordNotFound {
		return CompleteRepositorySyncGitHubInstallResult{}, wrapErr(existingLookup.Error)
	}
	if existingLookup.Error == nil && existingUserConnection.GitHubUserID != authorization.User.ID {
		_ = s.DBForCtx(ctx).Model(&request).Updates(map[string]any{"status": SyncGitHubRequestStatusFailed, "completed_at": now}).Error
		return s.repositorySyncGitHubInstallRedirect(ctx, request.ReturnTo, "failed", "identity_mismatch")
	}
	installations := authorization.Installations
	connections := make([]db.SyncGitHubConnection, 0, len(installations))
	for _, installation := range installations {
		permissions, marshalErr := json.Marshal(installation.Permissions)
		if marshalErr != nil {
			return CompleteRepositorySyncGitHubInstallResult{}, marshalErr
		}
		connections = append(connections, db.SyncGitHubConnection{
			ConnectionID: installation.ConnectionID, InstallationID: installation.InstallationID,
			AccountLogin: installation.AccountLogin, AccountType: installation.AccountType,
			AccountAvatarURL: installation.AccountAvatarURL, ManagementURL: installation.ManagementURL,
			RepositorySelection: installation.RepositorySelection, PermissionsJSON: db.LargeText(permissions),
			Status: SyncGitHubConnectionStatusActive, ConnectedByID: request.RequestedByID, LastValidatedAt: &now,
		})
	}
	err = s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		for i := range connections {
			if err := tx.Clauses(clause.OnConflict{
				Columns:   []clause.Column{{Name: "installation_id"}},
				DoUpdates: clause.AssignmentColumns([]string{"connection_id", "account_login", "account_type", "account_avatar_url", "management_url", "repository_selection", "permissions_json", "status", "connected_by_id", "last_validated_at", "updated_at"}),
			}).Create(&connections[i]).Error; err != nil {
				return err
			}
		}
		var userConnection db.SyncGitHubUserConnection
		lookup := tx.First(&userConnection, "user_id = ?", request.RequestedByID)
		if lookup.Error != nil && lookup.Error != gorm.ErrRecordNotFound {
			return lookup.Error
		}
		if lookup.Error == gorm.ErrRecordNotFound {
			userConnection = db.SyncGitHubUserConnection{
				UserID: request.RequestedByID, GitHubUserID: authorization.User.ID,
				GitHubLogin: authorization.User.Login, GitHubAvatarURL: authorization.User.AvatarURL,
				Status: SyncGitHubConnectionStatusActive, LastValidatedAt: &now,
			}
			if err := tx.Create(&userConnection).Error; err != nil {
				return err
			}
		} else if err := tx.Model(&userConnection).Updates(map[string]any{
			"git_hub_user_id": authorization.User.ID, "git_hub_login": authorization.User.Login,
			"git_hub_avatar_url": authorization.User.AvatarURL, "status": SyncGitHubConnectionStatusActive,
			"last_validated_at": now,
		}).Error; err != nil {
			return err
		}
		if flow == SyncGitHubRequestFlowAuthorize {
			if err := tx.Where("user_connection_id = ?", userConnection.ID).Delete(&db.SyncGitHubUserInstallation{}).Error; err != nil {
				return err
			}
		}
		for _, installation := range installations {
			grant := db.SyncGitHubUserInstallation{
				UserConnectionID: userConnection.ID, ConnectionID: installation.ConnectionID,
				InstallationID: installation.InstallationID,
			}
			if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&grant).Error; err != nil {
				return err
			}
		}
		return tx.Model(&request).Updates(map[string]any{"status": SyncGitHubRequestStatusCompleted, "completed_at": now}).Error
	})
	if err != nil {
		return CompleteRepositorySyncGitHubInstallResult{}, wrapErr(err)
	}
	return s.repositorySyncGitHubInstallRedirect(ctx, request.ReturnTo, "connected", authorization.User.Login)
}

func normalizeRepositorySyncReturnTo(value string) string {
	value = strings.TrimSpace(value)
	parsed, err := url.Parse(value)
	if err != nil || parsed.IsAbs() || parsed.Host != "" || (parsed.Path != "/sync" && !strings.HasPrefix(parsed.Path, "/sync/")) {
		return "/sync"
	}
	return parsed.RequestURI()
}

func (s *Service) repositorySyncGitHubInstallRedirect(ctx context.Context, returnTo, status, detail string) (CompleteRepositorySyncGitHubInstallResult, error) {
	scope, err := s.Scope(ctx)
	if err != nil {
		return CompleteRepositorySyncGitHubInstallResult{}, err
	}
	base := strings.TrimRight(strings.TrimSpace(scope.Info.ConsoleBaseURL), "/")
	if base == "" {
		base = scope.Info.HTMLBaseURL()
	}
	target, err := url.Parse(strings.TrimRight(base, "/") + normalizeRepositorySyncReturnTo(returnTo))
	if err != nil {
		return CompleteRepositorySyncGitHubInstallResult{}, err
	}
	query := target.Query()
	query.Set("github", status)
	if strings.TrimSpace(detail) != "" {
		query.Set("github_detail", detail)
	}
	target.RawQuery = query.Encode()
	return CompleteRepositorySyncGitHubInstallResult{RedirectURL: target.String(), Status: status, Detail: detail}, nil
}

func (s *Service) repositorySyncGitHubDiscoverer(ctx context.Context) (reposync.GitHubConnectionDiscoverer, error) {
	viewer, ok := UserFromContext(ctx)
	if !ok || viewer.ID == 0 {
		return nil, ErrUnauthorized
	}
	info, err := tenant.Require(ctx)
	if err != nil {
		return nil, err
	}
	value, configured := s.repositorySyncValidators.Load(info.ID)
	if !configured {
		return nil, fmt.Errorf("%w: repository sync is not configured for this tenant", ErrInvalidState)
	}
	discoverer, ok := value.(reposync.GitHubConnectionDiscoverer)
	if !ok {
		return nil, fmt.Errorf("%w: GitHub App connection discovery is unavailable", ErrInvalidState)
	}
	return discoverer, nil
}

func (s *Service) GetRepositorySyncGitHubAccount(ctx context.Context) (*RepositorySyncGitHubAccount, error) {
	viewer, ok := UserFromContext(ctx)
	if !ok || viewer.ID == 0 {
		return nil, ErrUnauthorized
	}
	var connection db.SyncGitHubUserConnection
	result := s.DBForCtx(ctx).Where("user_id = ? AND status = ?", viewer.ID, SyncGitHubConnectionStatusActive).Limit(1).Find(&connection)
	if result.Error != nil {
		return nil, wrapErr(result.Error)
	}
	if result.RowsAffected == 0 {
		return nil, nil
	}
	return &RepositorySyncGitHubAccount{ID: connection.ID, Login: connection.GitHubLogin, AvatarURL: connection.GitHubAvatarURL, Status: connection.Status}, nil
}

func (s *Service) repositorySyncGitHubUserConnection(ctx context.Context) (*db.SyncGitHubUserConnection, error) {
	viewer, ok := UserFromContext(ctx)
	if !ok || viewer.ID == 0 {
		return nil, ErrUnauthorized
	}
	var connection db.SyncGitHubUserConnection
	result := s.DBForCtx(ctx).Where("user_id = ? AND status = ?", viewer.ID, SyncGitHubConnectionStatusActive).Limit(1).Find(&connection)
	if result.Error != nil {
		return nil, wrapErr(result.Error)
	}
	if result.RowsAffected == 0 {
		return nil, nil
	}
	return &connection, nil
}

func (s *Service) repositorySyncGitHubUserGrants(ctx context.Context) (*db.SyncGitHubUserConnection, []db.SyncGitHubUserInstallation, error) {
	connection, err := s.repositorySyncGitHubUserConnection(ctx)
	if err != nil || connection == nil {
		return connection, nil, err
	}
	var grants []db.SyncGitHubUserInstallation
	if err := s.DBForCtx(ctx).Where("user_connection_id = ?", connection.ID).Order("id").Find(&grants).Error; err != nil {
		return nil, nil, wrapErr(err)
	}
	return connection, grants, nil
}

func (s *Service) repositorySyncGitHubUserHasGrant(ctx context.Context, connectionID string) (bool, error) {
	connection, grants, err := s.repositorySyncGitHubUserGrants(ctx)
	if err != nil || connection == nil {
		return false, err
	}
	for _, grant := range grants {
		if grant.ConnectionID == connectionID {
			return true, nil
		}
	}
	return false, nil
}

func (s *Service) ListRepositorySyncGitHubInstallations(ctx context.Context) ([]reposync.GitHubInstallation, error) {
	discoverer, err := s.repositorySyncGitHubDiscoverer(ctx)
	if err != nil {
		return nil, err
	}
	allowed := make(map[string]bool)
	connectionByID := make(map[string]db.SyncGitHubConnection)
	userConnection, grants, err := s.repositorySyncGitHubUserGrants(ctx)
	if err != nil {
		return nil, err
	}
	if userConnection == nil {
		return []reposync.GitHubInstallation{}, nil
	}
	for _, grant := range grants {
		allowed[grant.ConnectionID] = true
	}
	if len(allowed) == 0 {
		return []reposync.GitHubInstallation{}, nil
	}
	var connections []db.SyncGitHubConnection
	if err := s.DBForCtx(ctx).Where("status = ? AND connection_id IN ?", SyncGitHubConnectionStatusActive, mapKeys(allowed)).Find(&connections).Error; err != nil {
		return nil, wrapErr(err)
	}
	for _, connection := range connections {
		connectionByID[connection.ConnectionID] = connection
	}
	items, err := discoverer.ListGitHubInstallations(ctx)
	if err != nil {
		return nil, wrapErr(err)
	}
	visible := make([]reposync.GitHubInstallation, 0, len(items))
	for _, item := range items {
		if allowed[item.ConnectionID] {
			visible = append(visible, item)
			delete(allowed, item.ConnectionID)
		}
	}
	for connectionID := range allowed {
		connection, ok := connectionByID[connectionID]
		if !ok {
			continue
		}
		permissions := make(map[string]string)
		_ = json.Unmarshal([]byte(connection.PermissionsJSON), &permissions)
		visible = append(visible, reposync.GitHubInstallation{
			ConnectionID: connection.ConnectionID, InstallationID: connection.InstallationID,
			AccountLogin: connection.AccountLogin, AccountType: connection.AccountType,
			AccountAvatarURL: connection.AccountAvatarURL, ManagementURL: connection.ManagementURL,
			RepositorySelection: connection.RepositorySelection, Permissions: permissions,
			PermissionsValid: false, MissingPermissions: []string{"the GitHub App installation is no longer accessible"},
		})
	}
	sort.Slice(visible, func(i, j int) bool {
		return strings.ToLower(visible[i].AccountLogin) < strings.ToLower(visible[j].AccountLogin)
	})
	return visible, nil
}

func (s *Service) ListRepositorySyncGitHubRepositories(ctx context.Context, connectionID string) ([]reposync.GitHubRepositoryOption, error) {
	connectionID = strings.TrimSpace(connectionID)
	userConnection, grants, err := s.repositorySyncGitHubUserGrants(ctx)
	if err != nil {
		return nil, err
	}
	if userConnection == nil {
		return nil, fmt.Errorf("%w: connect your GitHub account first", ErrForbidden)
	}
	allowed := make(map[string]bool, len(grants))
	for _, grant := range grants {
		allowed[grant.ConnectionID] = true
	}
	if connectionID != "" && !allowed[connectionID] {
		return nil, fmt.Errorf("%w: GitHub connection is not authorized by the current user", ErrForbidden)
	}
	connectionIDs := mapKeys(allowed)
	if connectionID != "" {
		connectionIDs = []string{connectionID}
	}
	if len(connectionIDs) == 0 {
		return []reposync.GitHubRepositoryOption{}, nil
	}
	discoverer, err := s.repositorySyncGitHubDiscoverer(ctx)
	if err != nil {
		return nil, err
	}
	var connections []db.SyncGitHubConnection
	if err := s.DBForCtx(ctx).Where("connection_id IN ?", connectionIDs).Find(&connections).Error; err != nil {
		return nil, wrapErr(err)
	}
	connectionByID := make(map[string]db.SyncGitHubConnection, len(connections))
	for _, connection := range connections {
		connectionByID[connection.ConnectionID] = connection
	}
	result := make([]reposync.GitHubRepositoryOption, 0)
	seen := make(map[string]bool)
	for _, currentConnectionID := range connectionIDs {
		items, listErr := discoverer.ListGitHubRepositories(ctx, currentConnectionID)
		if listErr != nil {
			return nil, wrapErr(listErr)
		}
		connection := connectionByID[currentConnectionID]
		for _, item := range items {
			if seen[item.ID] {
				continue
			}
			seen[item.ID] = true
			item.ConnectionID = currentConnectionID
			item.AccountLogin = connection.AccountLogin
			item.AccountType = connection.AccountType
			result = append(result, item)
		}
	}
	sort.Slice(result, func(i, j int) bool { return strings.ToLower(result[i].FullName) < strings.ToLower(result[j].FullName) })
	return result, nil
}

func mapKeys(values map[string]bool) []string {
	result := make([]string, 0, len(values))
	for key := range values {
		result = append(result, key)
	}
	sort.Strings(result)
	return result
}

// DisconnectRepositorySyncGitHubAccount removes only the authenticated AGS
// user's setup/discovery authorization. Process-owned installation metadata
// and existing bindings remain intact so background syncs are not broken.
func (s *Service) DisconnectRepositorySyncGitHubAccount(ctx context.Context) error {
	connection, err := s.repositorySyncGitHubUserConnection(ctx)
	if err != nil || connection == nil {
		return err
	}
	if err := s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		if err := tx.Where("user_connection_id = ?", connection.ID).Delete(&db.SyncGitHubUserInstallation{}).Error; err != nil {
			return err
		}
		return tx.Delete(connection).Error
	}); err != nil {
		return wrapErr(err)
	}
	return nil
}

func (s *Service) SetRepositorySyncFullAuditInterval(id tenant.ID, interval time.Duration) {
	if id == "" || interval <= 0 {
		return
	}
	s.repositorySyncFullAuditIntervals.Store(id, interval)
}

func (s *Service) repositorySyncFullAuditInterval(ctx context.Context) time.Duration {
	info, err := tenant.Require(ctx)
	if err == nil {
		if value, ok := s.repositorySyncFullAuditIntervals.Load(info.ID); ok {
			if interval, valid := value.(time.Duration); valid && interval > 0 {
				return interval
			}
		}
	}
	return 7 * 24 * time.Hour
}

func (s *Service) CreateRepositorySyncBinding(ctx context.Context, in CreateRepositorySyncBindingInput) (db.SyncBinding, error) {
	viewer, ok := UserFromContext(ctx)
	if !ok || viewer.ID == 0 {
		return db.SyncBinding{}, ErrUnauthorized
	}
	if in.AGSRepositoryID == 0 {
		return db.SyncBinding{}, fmt.Errorf("%w: ags_repository_id is required", ErrValidation)
	}
	githubID := strings.TrimSpace(in.GitHubRepositoryID)
	githubFullName := strings.TrimSpace(in.GitHubRepositoryFullName)
	connectionID := strings.TrimSpace(in.GitHubConnectionID)
	if githubID == "" || connectionID == "" {
		return db.SyncBinding{}, fmt.Errorf("%w: GitHub repository identity and connection are required", ErrValidation)
	}
	info, err := tenant.Require(ctx)
	if err != nil {
		return db.SyncBinding{}, err
	}
	value, configured := s.repositorySyncValidators.Load(info.ID)
	if !configured {
		return db.SyncBinding{}, fmt.Errorf("%w: repository sync is not configured for this tenant", ErrInvalidState)
	}
	if _, onboardingEnabled := value.(reposync.GitHubConnectionAuthorizer); onboardingEnabled {
		authorized, grantErr := s.repositorySyncGitHubUserHasGrant(ctx, connectionID)
		if grantErr != nil {
			return db.SyncBinding{}, grantErr
		}
		if !authorized {
			return db.SyncBinding{}, fmt.Errorf("%w: GitHub repository access was not authorized by the current user", ErrForbidden)
		}
	}
	githubVisibility := ""
	if resolver, ok := value.(RepositorySyncConnectionVisibilityResolver); ok {
		githubFullName, githubVisibility, err = resolver.ResolveRepositoryWithVisibility(ctx, connectionID, githubID, githubFullName)
	} else if resolver, ok := value.(RepositorySyncConnectionResolver); ok {
		githubFullName, err = resolver.ResolveRepository(ctx, connectionID, githubID, githubFullName)
	} else if githubFullName == "" {
		return db.SyncBinding{}, fmt.Errorf("%w: github_repository_full_name is required by the configured connection", ErrValidation)
	} else {
		err = value.(RepositorySyncConnectionValidator).ValidateRepository(ctx, connectionID, githubID, githubFullName)
	}
	if err != nil {
		return db.SyncBinding{}, fmt.Errorf("%w: GitHub connection validation failed: %v", ErrValidation, err)
	}

	var repository db.Repository
	if err := s.DBForCtx(ctx).First(&repository, "id = ?", in.AGSRepositoryID).Error; err != nil {
		return db.SyncBinding{}, wrapErr(err)
	}
	permission, err := s.HasRepoAccess(ctx, repository.ID, viewer.ID)
	if err != nil {
		return db.SyncBinding{}, wrapErr(err)
	}
	if !permission.AtLeast(RepoPermissionAdmin) {
		return db.SyncBinding{}, ErrForbidden
	}

	authoritySide, err := syncSideForProvider(in.InitialAuthority)
	if err != nil {
		return db.SyncBinding{}, err
	}
	agsVisibility := normalizedRepositorySyncVisibility(repository.Visibility, repository.Private)
	visibilityConfirmation, exposure := repositorySyncVisibilityConfirmation([]db.SyncBindingEndpoint{
		{Side: SyncSideA, Provider: SyncProviderAGS, RepositoryID: strconv.FormatUint(uint64(repository.ID), 10)},
		{Side: SyncSideB, Provider: SyncProviderGitHub, RepositoryID: githubID},
	}, authoritySide, agsVisibility, githubVisibility)
	if exposure && !in.ConfirmVisibilityExposure {
		return db.SyncBinding{}, fmt.Errorf("%w: standby repository is more public than the authority; confirm_visibility_exposure is required", ErrValidation)
	}
	if !exposure {
		visibilityConfirmation = ""
	}
	scheduleMode, scheduleCron, err := normalizeSyncSchedule(in.ScheduleMode, in.ScheduleCron)
	if err != nil {
		return db.SyncBinding{}, err
	}

	database := s.DBForCtx(ctx)
	var binding db.SyncBinding
	err = database.Transaction(func(tx *gorm.DB) error {
		claims := []string{syncEndpointClaim(SyncProviderAGS, strconv.FormatUint(uint64(repository.ID), 10)), syncEndpointClaim(SyncProviderGitHub, githubID)}
		// A repository pair is a team-owned relationship, not a relationship
		// owned by the first user who created it. Reuse the exact active pair so
		// a colleague can view and continue the same audit history.
		var existingPair db.SyncBinding
		pairErr := tx.Model(&db.SyncBinding{}).
			Joins("JOIN sync_binding_endpoints AS ags_endpoint ON ags_endpoint.binding_id = sync_bindings.id").
			Joins("JOIN sync_binding_endpoints AS github_endpoint ON github_endpoint.binding_id = sync_bindings.id").
			Where("sync_bindings.status = ? AND ags_endpoint.provider = ? AND ags_endpoint.ags_repository_id = ? AND github_endpoint.provider = ? AND github_endpoint.repository_id = ?",
				SyncBindingStatusActive, SyncProviderAGS, repository.ID, SyncProviderGitHub, githubID).
			First(&existingPair).Error
		if pairErr == nil {
			binding = existingPair
			return nil
		}
		if pairErr != nil && pairErr != gorm.ErrRecordNotFound {
			return pairErr
		}

		targetClaim := claims[0]
		if authoritySide == SyncSideA {
			targetClaim = claims[1]
		}
		var existing int64
		if err := tx.Model(&db.SyncBindingEndpoint{}).Where("active_claim_key = ?", targetClaim).Count(&existing).Error; err != nil {
			return err
		}
		if existing != 0 {
			return fmt.Errorf("%w: selected target repository already belongs to an active sync binding", ErrConflict)
		}

		binding = db.SyncBinding{
			Status: SyncBindingStatusActive, AuthoritySide: authoritySide, AuthorityEpoch: 1,
			ScheduleMode: scheduleMode, ScheduleCron: scheduleCron, SyncEnabled: false, NextRunAt: nil,
			VisibilityConfirmation: visibilityConfirmation, CreatedByID: viewer.ID,
		}
		if err := tx.Create(&binding).Error; err != nil {
			return err
		}
		agsClaim, githubClaim := claims[0], claims[1]
		var activeAGSClaim, activeGitHubClaim *string
		if authoritySide == SyncSideA {
			activeGitHubClaim = &githubClaim
		} else {
			activeAGSClaim = &agsClaim
		}
		endpoints := []db.SyncBindingEndpoint{
			{
				BindingID: binding.ID, Side: SyncSideA, Provider: SyncProviderAGS,
				RepositoryID: strconv.FormatUint(uint64(repository.ID), 10), RepositoryFullName: repository.FullName,
				AGSRepositoryID: &repository.ID, ActiveClaimKey: activeAGSClaim,
			},
			{
				BindingID: binding.ID, Side: SyncSideB, Provider: SyncProviderGitHub,
				RepositoryID: githubID, RepositoryFullName: githubFullName,
				ConnectionID: connectionID, ActiveClaimKey: activeGitHubClaim,
			},
		}
		if err := tx.Create(&endpoints).Error; err != nil {
			return err
		}
		if authoritySide == SyncSideB {
			return tx.Model(&db.Repository{}).Where("id = ? AND (sync_write_lock_binding_id IS NULL OR sync_write_lock_binding_id = ?)", repository.ID, binding.ID).
				Updates(map[string]any{"sync_write_locked": true, "sync_write_lock_binding_id": binding.ID}).Error
		}
		return nil
	})
	if err != nil {
		if isDuplicateErr(err) {
			return db.SyncBinding{}, fmt.Errorf("%w: selected target repository already belongs to an active sync binding", ErrConflict)
		}
		return db.SyncBinding{}, wrapErr(err)
	}
	return s.GetRepositorySyncBinding(ctx, binding.ID)
}

func (s *Service) GetRepositorySyncBinding(ctx context.Context, id uint) (db.SyncBinding, error) {
	binding, err := s.loadRepositorySyncBinding(ctx, id)
	if err != nil {
		return db.SyncBinding{}, err
	}
	if err := s.authorizeRepositorySyncBinding(ctx, binding); err != nil {
		return db.SyncBinding{}, err
	}
	return binding, nil
}

type ListRepositorySyncBindingsInput struct {
	AGSRepositoryID uint
	Status          string
	Limit           int
}

func (s *Service) ListRepositorySyncBindings(ctx context.Context, in ListRepositorySyncBindingsInput) ([]db.SyncBinding, error) {
	viewer, ok := UserFromContext(ctx)
	if !ok || viewer.ID == 0 {
		return nil, ErrUnauthorized
	}
	if in.Limit <= 0 || in.Limit > 100 {
		in.Limit = 30
	}
	status := strings.ToLower(strings.TrimSpace(in.Status))
	if status != "" && status != SyncBindingStatusActive && status != SyncBindingStatusPaused && status != SyncBindingStatusClosed {
		return nil, fmt.Errorf("%w: unsupported binding status %q", ErrValidation, status)
	}
	query := s.DBForCtx(ctx).Model(&db.SyncBinding{}).Preload("Endpoints").Order("sync_bindings.id DESC").Limit(in.Limit)
	if status != "" {
		query = query.Where("sync_bindings.status = ?", status)
	}
	if in.AGSRepositoryID != 0 {
		permission, err := s.HasRepoAccess(ctx, in.AGSRepositoryID, viewer.ID)
		if err != nil {
			return nil, wrapErr(err)
		}
		if !permission.AtLeast(RepoPermissionAdmin) {
			return nil, ErrForbidden
		}
		query = query.Joins("JOIN sync_binding_endpoints AS e ON e.binding_id = sync_bindings.id").
			Where("e.provider = ? AND e.ags_repository_id = ?", SyncProviderAGS, in.AGSRepositoryID)
	}
	var candidates []db.SyncBinding
	if err := query.Find(&candidates).Error; err != nil {
		return nil, wrapErr(err)
	}
	if in.AGSRepositoryID != 0 {
		return candidates, nil
	}
	bindings := make([]db.SyncBinding, 0, len(candidates))
	for _, binding := range candidates {
		if err := s.authorizeRepositorySyncBinding(ctx, binding); err == nil {
			bindings = append(bindings, binding)
		} else if err != ErrForbidden && err != ErrUnauthorized {
			return nil, err
		}
	}
	return bindings, nil
}

func (s *Service) UpdateRepositorySyncBinding(ctx context.Context, id uint, in UpdateRepositorySyncBindingInput) (db.SyncBinding, error) {
	binding, err := s.GetRepositorySyncBinding(ctx, id)
	if err != nil {
		return db.SyncBinding{}, err
	}
	updates := map[string]any{}
	if in.Status != nil {
		status := strings.ToLower(strings.TrimSpace(*in.Status))
		switch status {
		case SyncBindingStatusActive, SyncBindingStatusPaused, SyncBindingStatusClosed:
		default:
			return db.SyncBinding{}, fmt.Errorf("%w: unsupported binding status %q", ErrValidation, status)
		}
		if binding.Status == SyncBindingStatusClosed && status != SyncBindingStatusClosed {
			return db.SyncBinding{}, fmt.Errorf("%w: a closed sync binding cannot be reopened", ErrInvalidState)
		}
		updates["status"] = status
	}
	if in.SyncEnabled != nil {
		updates["sync_enabled"] = *in.SyncEnabled
	}
	if in.RefConflictPolicy != nil {
		policy := strings.ToLower(strings.TrimSpace(*in.RefConflictPolicy))
		switch policy {
		case SyncRefConflictPolicyPreserve, SyncRefConflictPolicyUseSource:
			updates["ref_conflict_policy"] = policy
		default:
			return db.SyncBinding{}, fmt.Errorf("%w: unsupported ref conflict policy %q", ErrValidation, policy)
		}
	}
	if in.ConfirmVisibilityExposure != nil {
		if *in.ConfirmVisibilityExposure {
			confirmation, exposure, visibilityErr := s.repositorySyncHandoffVisibility(ctx, binding, binding.AuthoritySide)
			if visibilityErr != nil {
				return db.SyncBinding{}, visibilityErr
			}
			if exposure {
				updates["visibility_confirmation"] = confirmation
			} else {
				updates["visibility_confirmation"] = ""
			}
		} else {
			updates["visibility_confirmation"] = ""
		}
	}
	if in.ScheduleMode != nil || in.ScheduleCron != nil {
		mode, cron := binding.ScheduleMode, binding.ScheduleCron
		if in.ScheduleMode != nil {
			mode = *in.ScheduleMode
		}
		if in.ScheduleCron != nil {
			cron = *in.ScheduleCron
		}
		mode, cron, err = normalizeSyncSchedule(mode, cron)
		if err != nil {
			return db.SyncBinding{}, err
		}
		updates["schedule_mode"], updates["schedule_cron"] = mode, cron
		if mode == SyncScheduleScheduled && effectiveSyncEnabled(binding, in.SyncEnabled) {
			schedule, parseErr := reposync.ParseCron(cron)
			if parseErr != nil {
				return db.SyncBinding{}, fmt.Errorf("%w: %v", ErrValidation, parseErr)
			}
			next, nextErr := schedule.Next(time.Now().UTC())
			if nextErr != nil {
				return db.SyncBinding{}, fmt.Errorf("%w: %v", ErrValidation, nextErr)
			}
			updates["next_run_at"] = &next
		} else {
			updates["next_run_at"] = nil
		}
	}
	if len(updates) == 0 {
		return binding, nil
	}

	database := s.DBForCtx(ctx)
	err = database.Transaction(func(tx *gorm.DB) error {
		var locked db.SyncBinding
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, "id = ?", id).Error; err != nil {
			return err
		}
		if (in.SyncEnabled != nil && *in.SyncEnabled) || updates["status"] == SyncBindingStatusClosed {
			if err := requireNoRepositorySyncSafetyFenceTx(tx, id); err != nil {
				return err
			}
		}
		if in.SyncEnabled != nil && *in.SyncEnabled {
			if locked.Status != SyncBindingStatusActive && updates["status"] != SyncBindingStatusActive {
				return fmt.Errorf("%w: only an active binding can enable scheduled sync", ErrInvalidState)
			}
			if locked.ScheduleMode != SyncScheduleScheduled && updates["schedule_mode"] != SyncScheduleScheduled {
				return fmt.Errorf("%w: scheduled sync requires a scheduled cron", ErrInvalidState)
			}
			if err := requireRepositorySyncFullApplyTx(tx, locked); err != nil {
				return err
			}
			var activeRuns int64
			if err := tx.Model(&db.SyncRun{}).Where("binding_id = ? AND status IN ?", id, syncActiveRunStatuses()).Count(&activeRuns).Error; err != nil {
				return err
			}
			if activeRuns != 0 {
				return fmt.Errorf("%w: binding has an active sync run", ErrConflict)
			}
			cron := locked.ScheduleCron
			if value, ok := updates["schedule_cron"].(string); ok {
				cron = value
			}
			schedule, parseErr := reposync.ParseCron(cron)
			if parseErr != nil {
				return fmt.Errorf("%w: %v", ErrValidation, parseErr)
			}
			next, nextErr := schedule.Next(time.Now().UTC())
			if nextErr != nil {
				return fmt.Errorf("%w: %v", ErrValidation, nextErr)
			}
			updates["next_run_at"] = &next
		}
		if in.SyncEnabled != nil && !*in.SyncEnabled {
			updates["next_run_at"] = nil
		}
		if updates["status"] == SyncBindingStatusClosed {
			updates["sync_enabled"], updates["next_run_at"] = false, nil
			var activeRuns int64
			if err := tx.Model(&db.SyncRun{}).Where("binding_id = ? AND status IN ?", id, syncActiveRunStatuses()).Count(&activeRuns).Error; err != nil {
				return err
			}
			if activeRuns != 0 {
				return fmt.Errorf("%w: binding has an active sync run", ErrConflict)
			}
			if err := tx.Model(&db.SyncBindingEndpoint{}).Where("binding_id = ?", id).Update("active_claim_key", nil).Error; err != nil {
				return err
			}
			if err := setAGSRepositorySyncWriteLockTx(tx, id, false); err != nil {
				return err
			}
		}
		return tx.Model(&db.SyncBinding{}).Where("id = ?", id).Updates(updates).Error
	})
	if err != nil {
		return db.SyncBinding{}, wrapErr(err)
	}
	return s.GetRepositorySyncBinding(ctx, id)
}

// setRepositorySyncTargetClaimTx reserves the writable endpoint only. A
// source endpoint deliberately has no claim so one source can seed multiple
// independent standby repositories. The unique claim still prevents two
// different sources from writing the same target concurrently.
func setRepositorySyncTargetClaimTx(tx *gorm.DB, bindingID uint, targetSide string) error {
	var target db.SyncBindingEndpoint
	if err := tx.Where("binding_id = ? AND side = ?", bindingID, targetSide).Take(&target).Error; err != nil {
		return err
	}
	claim := syncEndpointClaim(target.Provider, target.RepositoryID)
	var occupied int64
	if err := tx.Model(&db.SyncBindingEndpoint{}).
		Where("active_claim_key = ? AND binding_id <> ?", claim, bindingID).Count(&occupied).Error; err != nil {
		return err
	}
	if occupied != 0 {
		return fmt.Errorf("%w: handoff target repository already belongs to an active sync binding", ErrConflict)
	}
	if err := tx.Model(&db.SyncBindingEndpoint{}).Where("binding_id = ?", bindingID).Update("active_claim_key", nil).Error; err != nil {
		return err
	}
	return tx.Model(&db.SyncBindingEndpoint{}).Where("binding_id = ? AND side = ?", bindingID, targetSide).Update("active_claim_key", &claim).Error
}

func (s *Service) CreateRepositorySyncRun(ctx context.Context, bindingID uint, in CreateRepositorySyncRunInput) (db.SyncRun, error) {
	mode := strings.ToLower(strings.TrimSpace(in.Mode))
	if mode == "" {
		mode = SyncRunModeApply
	}
	if mode != SyncRunModeApply && mode != SyncRunModeDryRun {
		return db.SyncRun{}, fmt.Errorf("%w: mode must be apply or dry_run", ErrValidation)
	}
	if _, err := s.GetRepositorySyncBinding(ctx, bindingID); err != nil {
		return db.SyncRun{}, err
	}
	auditMode := strings.ToLower(strings.TrimSpace(in.AuditMode))
	if auditMode == "" {
		auditMode = SyncAuditModeCheckpoint
	}
	if auditMode != SyncAuditModeCheckpoint && auditMode != SyncAuditModeFull {
		return db.SyncRun{}, fmt.Errorf("%w: audit_mode must be checkpoint or full", ErrValidation)
	}
	return s.createRepositorySyncRun(ctx, bindingID, db.SyncRun{Kind: SyncRunKindSync, Mode: mode, Phase: SyncRunPhaseSync, AuditMode: auditMode}, nil)
}

func (s *Service) CreateRepositorySyncHandoff(ctx context.Context, bindingID uint, in CreateRepositorySyncHandoffInput) (db.SyncRun, error) {
	if !in.AuthorityWritesStopped {
		return db.SyncRun{}, fmt.Errorf("%w: authority_writes_stopped must be confirmed", ErrValidation)
	}
	binding, err := s.GetRepositorySyncBinding(ctx, bindingID)
	if err != nil {
		return db.SyncRun{}, err
	}
	toSide, err := syncSideForProvider(in.ToProvider)
	if err != nil {
		return db.SyncRun{}, err
	}
	if in.ExpectedAuthorityEpoch != binding.AuthorityEpoch {
		return db.SyncRun{}, fmt.Errorf("%w: authority epoch changed", ErrConflict)
	}
	if toSide == binding.AuthoritySide {
		return db.SyncRun{}, fmt.Errorf("%w: requested provider is already authoritative", ErrInvalidState)
	}
	handoffVisibilityConfirmation, exposure, err := s.repositorySyncHandoffVisibility(ctx, binding, toSide)
	if err != nil {
		return db.SyncRun{}, err
	}
	if exposure && !in.ConfirmVisibilityExposure {
		return db.SyncRun{}, fmt.Errorf("%w: new standby repository is more public than the new authority; confirm_visibility_exposure is required", ErrValidation)
	}
	if !exposure {
		handoffVisibilityConfirmation = ""
	}
	return s.createRepositorySyncRun(ctx, bindingID, db.SyncRun{
		Kind: SyncRunKindHandoff, Mode: SyncRunModeApply, Phase: SyncRunPhaseHandoffFinal,
		HandoffToSide: toSide, AuditMode: SyncAuditModeFull,
		HandoffVisibilityConfirmation: handoffVisibilityConfirmation,
	}, &in.ExpectedAuthorityEpoch)
}

func (s *Service) createRepositorySyncRun(ctx context.Context, bindingID uint, run db.SyncRun, expectedAuthorityEpoch *uint64) (db.SyncRun, error) {
	database := s.DBForCtx(ctx)
	err := database.Transaction(func(tx *gorm.DB) error {
		var binding db.SyncBinding
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&binding, "id = ?", bindingID).Error; err != nil {
			return err
		}
		if binding.Status != SyncBindingStatusActive {
			return fmt.Errorf("%w: sync binding is not active", ErrInvalidState)
		}
		if expectedAuthorityEpoch != nil && binding.AuthorityEpoch != *expectedAuthorityEpoch {
			return fmt.Errorf("%w: authority epoch changed", ErrConflict)
		}
		if run.Kind == SyncRunKindHandoff && run.HandoffToSide == binding.AuthoritySide {
			return fmt.Errorf("%w: requested provider is already authoritative", ErrInvalidState)
		}
		var active int64
		if err := tx.Model(&db.SyncRun{}).Where("binding_id = ? AND status IN ?", bindingID, syncActiveRunStatuses()).Count(&active).Error; err != nil {
			return err
		}
		if active != 0 {
			return fmt.Errorf("%w: sync binding already has an active run", ErrConflict)
		}
		if err := requireNoRepositorySyncSafetyFenceTx(tx, bindingID); err != nil {
			return err
		}
		run.BindingID = bindingID
		run.AuthorityEpoch = binding.AuthorityEpoch
		run.SourceSide = binding.AuthoritySide
		run.TargetSide = otherSyncSide(binding.AuthoritySide)
		run.Status = SyncRunStatusQueued
		run.Stage = SyncRunStageQueued
		run.PlannerMode = SyncPlannerModeAuto
		run.MapperVersion = canonical.MapperVersion
		run.VisibilityConfirmation = binding.VisibilityConfirmation
		if run.Kind == "" {
			run.Kind = SyncRunKindSync
		}
		if run.Mode == "" {
			run.Mode = SyncRunModeApply
		}
		if run.Phase == "" {
			run.Phase = SyncRunPhaseSync
		}
		if run.AuditMode == "" {
			run.AuditMode = SyncAuditModeCheckpoint
		}
		if run.Kind == SyncRunKindHandoff {
			run.AuditMode = SyncAuditModeFull
		} else if run.Mode == SyncRunModeApply {
			var convergedFull int64
			if err := tx.Model(&db.SyncRun{}).Where("binding_id = ? AND authority_epoch = ? AND kind = ? AND mode = ? AND audit_mode = ? AND status = ?",
				bindingID, binding.AuthorityEpoch, SyncRunKindSync, SyncRunModeApply, SyncAuditModeFull, SyncRunStatusCompleted).Count(&convergedFull).Error; err != nil {
				return err
			}
			if convergedFull == 0 {
				run.AuditMode = SyncAuditModeFull
			}
		}
		if err := tx.Create(&run).Error; err != nil {
			return err
		}
		if run.Kind == SyncRunKindHandoff && binding.AuthoritySide == SyncSideA {
			if err := setAGSRepositorySyncWriteLockTx(tx, binding.ID, true); err != nil {
				return err
			}
		}
		return tx.Model(&db.SyncBinding{}).Where("id = ?", bindingID).Update("last_run_id", run.ID).Error
	})
	if err != nil {
		return db.SyncRun{}, wrapErr(err)
	}
	return run, nil
}

func (s *Service) GetRepositorySyncRun(ctx context.Context, id uint) (db.SyncRun, error) {
	var run db.SyncRun
	if err := s.DBForCtx(ctx).First(&run, "id = ?", id).Error; err != nil {
		return db.SyncRun{}, wrapErr(err)
	}
	if _, err := s.GetRepositorySyncBinding(ctx, run.BindingID); err != nil {
		return db.SyncRun{}, err
	}
	return run, nil
}

func (s *Service) ListRepositorySyncRuns(ctx context.Context, bindingID uint, limit int) ([]db.SyncRun, error) {
	if _, err := s.GetRepositorySyncBinding(ctx, bindingID); err != nil {
		return nil, err
	}
	if limit <= 0 || limit > 100 {
		limit = 30
	}
	var runs []db.SyncRun
	if err := s.DBForCtx(ctx).Where("binding_id = ?", bindingID).Order("id DESC").Limit(limit).Find(&runs).Error; err != nil {
		return nil, wrapErr(err)
	}
	return runs, nil
}

type ListRepositorySyncRunItemsInput struct {
	State      string
	ObjectType string
	Action     string
	AfterID    uint
	Limit      int
}

// RepositorySyncRunProgress is a lightweight aggregate for an in-flight run.
// It deliberately avoids returning every RunItem: large bootstrap runs can have
// many thousands of objects, while the activity UI only needs state counts.
type RepositorySyncRunProgress struct {
	Total        int64
	Processed    int64
	Remaining    int64
	Planned      int64
	Applying     int64
	Verifying    int64
	Completed    int64
	Blocked      int64
	Failed       int64
	Superseded   int64
	ByObjectType map[string]RepositorySyncRunProgressCounts
}

type RepositorySyncRunProgressCounts struct {
	Total      int64
	Processed  int64
	Remaining  int64
	Planned    int64
	Applying   int64
	Verifying  int64
	Completed  int64
	Blocked    int64
	Failed     int64
	Superseded int64
}

func (s *Service) ListRepositorySyncRunItems(ctx context.Context, runID uint, in ListRepositorySyncRunItemsInput) ([]db.SyncRunItem, error) {
	if _, err := s.GetRepositorySyncRun(ctx, runID); err != nil {
		return nil, err
	}
	if in.Limit <= 0 || in.Limit > 500 {
		in.Limit = 100
	}
	query := s.DBForCtx(ctx).Where("run_id = ?", runID).Order("id ASC").Limit(in.Limit)
	if in.AfterID != 0 {
		query = query.Where("id > ?", in.AfterID)
	}
	if value := strings.TrimSpace(in.State); value != "" {
		query = query.Where("state = ?", value)
	}
	if value := strings.TrimSpace(in.ObjectType); value != "" {
		query = query.Where("object_type = ?", value)
	}
	if value := strings.TrimSpace(in.Action); value != "" {
		query = query.Where("action = ?", value)
	}
	var items []db.SyncRunItem
	if err := query.Find(&items).Error; err != nil {
		return nil, wrapErr(err)
	}
	return items, nil
}

// GetRepositorySyncRunProgress returns aggregate RunItem state for a run the
// caller is authorized to inspect. It is safe for frequent polling by the web
// UI and does not load individual RunItems into memory.
func (s *Service) GetRepositorySyncRunProgress(ctx context.Context, runID uint) (RepositorySyncRunProgress, error) {
	if _, err := s.GetRepositorySyncRun(ctx, runID); err != nil {
		return RepositorySyncRunProgress{}, err
	}
	var rows []struct {
		ObjectType string
		State      string
		Count      int64
	}
	if err := s.DBForCtx(ctx).Model(&db.SyncRunItem{}).
		Select("object_type, state, COUNT(*) AS count").
		Where("run_id = ?", runID).
		Group("object_type, state").
		Scan(&rows).Error; err != nil {
		return RepositorySyncRunProgress{}, wrapErr(err)
	}

	result := RepositorySyncRunProgress{ByObjectType: map[string]RepositorySyncRunProgressCounts{}}
	for _, row := range rows {
		result.Total += row.Count
		byType := result.ByObjectType[row.ObjectType]
		byType.Total += row.Count
		switch row.State {
		case SyncRunItemStatePlanned:
			result.Planned += row.Count
			byType.Planned += row.Count
		case SyncRunItemStateApplying:
			result.Applying += row.Count
			byType.Applying += row.Count
		case SyncRunItemStateVerifying:
			result.Verifying += row.Count
			byType.Verifying += row.Count
		case SyncRunItemStateCompleted:
			result.Completed += row.Count
			byType.Completed += row.Count
		case SyncRunItemStateBlocked:
			result.Blocked += row.Count
			byType.Blocked += row.Count
		case SyncRunItemStateFailed:
			result.Failed += row.Count
			byType.Failed += row.Count
		case SyncRunItemStateSuperseded:
			result.Superseded += row.Count
			byType.Superseded += row.Count
		}
		result.ByObjectType[row.ObjectType] = byType
	}
	result.Processed = result.Completed + result.Blocked + result.Failed + result.Superseded
	result.Remaining = result.Total - result.Processed
	for objectType, counts := range result.ByObjectType {
		counts.Processed = counts.Completed + counts.Blocked + counts.Failed + counts.Superseded
		counts.Remaining = counts.Total - counts.Processed
		result.ByObjectType[objectType] = counts
	}
	return result, nil
}

func (s *Service) CancelRepositorySyncRun(ctx context.Context, id uint) (db.SyncRun, error) {
	run, err := s.GetRepositorySyncRun(ctx, id)
	if err != nil {
		return db.SyncRun{}, err
	}
	now := time.Now().UTC()
	updates := map[string]any{"cancel_requested": true}
	cancelImmediately := false
	if run.Status == SyncRunStatusQueued {
		updates["status"] = SyncRunStatusCancelled
		updates["stage"] = SyncRunStageCompleted
		updates["completed_at"] = &now
	} else if run.Status != SyncRunStatusRunning {
		return db.SyncRun{}, fmt.Errorf("%w: sync run is already terminal", ErrInvalidState)
	} else if repositorySyncPreApplyStage(run.Stage) {
		// Planning/inventory has not sent a target write. It is safe to cancel
		// immediately once we also prove there is no applying/verifying intent.
		cancelImmediately = true
	}
	if err := s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		if cancelImmediately {
			var uncertain int64
			if err := tx.Model(&db.SyncRunItem{}).Where("run_id = ? AND state IN ?", id,
				[]string{SyncRunItemStateApplying, SyncRunItemStateVerifying}).Count(&uncertain).Error; err != nil {
				return err
			}
			if uncertain != 0 {
				cancelImmediately = false
			} else {
				updates["status"] = SyncRunStatusCancelled
				updates["stage"] = SyncRunStageCompleted
				updates["completed_at"] = &now
			}
		}
		result := tx.Model(&db.SyncRun{}).Where("id = ? AND status = ?", id, run.Status).Updates(updates)
		if result.Error != nil {
			return result.Error
		}
		if result.RowsAffected != 1 {
			return fmt.Errorf("%w: sync run state changed", ErrConflict)
		}
		if run.Kind == SyncRunKindSync && run.Mode == SyncRunModeDryRun && run.Status == SyncRunStatusQueued {
			var binding db.SyncBinding
			if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&binding, "id = ?", run.BindingID).Error; err != nil {
				return err
			}
			if _, err := releaseUnstartedRepositorySyncBindingTx(tx, binding, run, SyncRunStatusCancelled); err != nil {
				return err
			}
		}
		if cancelImmediately {
			var binding db.SyncBinding
			if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&binding, "id = ?", run.BindingID).Error; err != nil {
				return err
			}
			// Fence an inventory/planning goroutine before releasing the lease. It
			// can no longer persist a plan after the Run became terminal.
			if binding.LeaseGeneration == run.LeaseGeneration {
				if err := tx.Model(&db.SyncBinding{}).Where("id = ? AND lease_generation = ?", binding.ID, binding.LeaseGeneration).
					Updates(map[string]any{"lease_owner": "", "lease_expires_at": nil, "lease_generation": binding.LeaseGeneration + 1}).Error; err != nil {
					return err
				}
			}
			if _, err := releaseUnstartedRepositorySyncBindingTx(tx, binding, run, SyncRunStatusCancelled); err != nil {
				return err
			}
		}
		if run.Kind == SyncRunKindHandoff && run.Status == SyncRunStatusQueued && run.SourceSide == SyncSideA {
			return setAGSRepositorySyncWriteLockTx(tx, run.BindingID, false)
		}
		return nil
	}); err != nil {
		return db.SyncRun{}, wrapErr(err)
	}
	return s.GetRepositorySyncRun(ctx, id)
}

func repositorySyncPreApplyStage(stage string) bool {
	switch stage {
	case SyncRunStageQueued, SyncRunStageCheckingAccess, SyncRunStageInventorySource, SyncRunStageInventoryTarget, SyncRunStagePlanning:
		return true
	default:
		return false
	}
}

// RecoverRepositorySyncRun requeues only ambiguous external intents. The
// worker is allowed to perform target read-back, but never blindly repeats an
// operation whose previous outcome is unknown.
func (s *Service) RecoverRepositorySyncRun(ctx context.Context, id uint) (db.SyncRun, error) {
	run, err := s.GetRepositorySyncRun(ctx, id)
	if err != nil {
		return db.SyncRun{}, err
	}
	if run.Status != SyncRunStatusBlocked {
		return db.SyncRun{}, fmt.Errorf("%w: only a blocked sync run can be recovered", ErrInvalidState)
	}
	if run.MapperVersion != canonical.MapperVersion {
		return db.SyncRun{}, fmt.Errorf("%w: recovery requires the run's original mapping version", ErrConflict)
	}
	database := s.DBForCtx(ctx)
	err = database.Transaction(func(tx *gorm.DB) error {
		var locked db.SyncRun
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, "id = ?", id).Error; err != nil {
			return err
		}
		if locked.Status != SyncRunStatusBlocked || locked.AuthorityEpoch != run.AuthorityEpoch {
			return fmt.Errorf("%w: sync run state changed", ErrConflict)
		}
		var recoverable int64
		if err := tx.Model(&db.SyncRunItem{}).Where("run_id = ? AND state = ? AND error_code = ?", id,
			SyncRunItemStateBlocked, SyncRunItemErrorApplyResultUnknown).Count(&recoverable).Error; err != nil {
			return err
		}
		if recoverable == 0 {
			return fmt.Errorf("%w: run has no ambiguous external outcome to recover", ErrInvalidState)
		}
		if err := tx.Model(&db.SyncRunItem{}).Where("run_id = ? AND state = ? AND error_code = ?", id,
			SyncRunItemStateBlocked, SyncRunItemErrorApplyResultUnknown).Update("state", SyncRunItemStateVerifying).Error; err != nil {
			return err
		}
		return tx.Model(&locked).Updates(map[string]any{
			"status": SyncRunStatusQueued, "stage": SyncRunStageQueued, "completed_at": nil, "error_code": "", "error_summary": "", "cancel_requested": false,
		}).Error
	})
	if err != nil {
		return db.SyncRun{}, wrapErr(err)
	}
	return s.GetRepositorySyncRun(ctx, id)
}

func setAGSRepositorySyncWriteLockTx(tx *gorm.DB, bindingID uint, locked bool) error {
	var endpoint db.SyncBindingEndpoint
	if err := tx.Where("binding_id = ? AND provider = ?", bindingID, SyncProviderAGS).Take(&endpoint).Error; err != nil {
		return err
	}
	if endpoint.AGSRepositoryID == nil {
		return fmt.Errorf("%w: sync binding has no AGS repository", ErrInvalidState)
	}
	query := tx.Model(&db.Repository{}).Where("id = ?", *endpoint.AGSRepositoryID)
	if locked {
		query = query.Where("sync_write_lock_binding_id IS NULL OR sync_write_lock_binding_id = ?", bindingID)
		result := query.Updates(map[string]any{"sync_write_locked": true, "sync_write_lock_binding_id": bindingID})
		if result.Error != nil {
			return result.Error
		}
		if result.RowsAffected != 1 {
			return fmt.Errorf("%w: AGS repository is write-locked by another binding", ErrConflict)
		}
		return nil
	}
	return query.Where("sync_write_lock_binding_id = ?", bindingID).Updates(map[string]any{"sync_write_locked": false, "sync_write_lock_binding_id": nil}).Error
}

// releaseUnstartedRepositorySyncBindingTx closes a failed initial preflight
// and releases its endpoint claims and AGS write lock. A dry-run never writes
// repository data, so retaining a binding-level write lock after it reports a
// conflict or error would prevent the user from correcting the setup and
// trying again. Completed dry-runs remain active so the user can confirm the
// first apply; apply runs and established bindings keep their safety fence.
//
// The binding and Run audit records deliberately remain in the database. A
// closed binding can be inspected, while its released endpoint claims allow a
// fresh setup attempt to create a new binding safely.
func releaseUnstartedRepositorySyncBindingTx(tx *gorm.DB, binding db.SyncBinding, run db.SyncRun, terminalStatus string) (bool, error) {
	if run.Kind != SyncRunKindSync || run.Mode != SyncRunModeDryRun ||
		binding.LastConvergedRunID != nil || terminalStatus == SyncRunStatusCompleted {
		return false, nil
	}
	if !isRepositorySyncTerminalStatus(terminalStatus) {
		return false, fmt.Errorf("%w: preflight release requires a terminal run status", ErrValidation)
	}
	if err := tx.Model(&db.SyncBindingEndpoint{}).Where("binding_id = ?", binding.ID).Update("active_claim_key", nil).Error; err != nil {
		return false, err
	}
	if err := setAGSRepositorySyncWriteLockTx(tx, binding.ID, false); err != nil {
		return false, err
	}
	if err := tx.Model(&db.SyncBinding{}).Where("id = ?", binding.ID).Updates(map[string]any{
		"status": SyncBindingStatusClosed, "sync_enabled": false, "next_run_at": nil,
	}).Error; err != nil {
		return false, err
	}
	return true, nil
}

func (s *Service) loadRepositorySyncBinding(ctx context.Context, id uint) (db.SyncBinding, error) {
	var binding db.SyncBinding
	if err := s.DBForCtx(ctx).Preload("Endpoints").First(&binding, "id = ?", id).Error; err != nil {
		return db.SyncBinding{}, wrapErr(err)
	}
	return binding, nil
}

func (s *Service) authorizeRepositorySyncBinding(ctx context.Context, binding db.SyncBinding) error {
	viewer, ok := UserFromContext(ctx)
	if !ok || viewer.ID == 0 {
		return ErrUnauthorized
	}
	for _, endpoint := range binding.Endpoints {
		if endpoint.Provider != SyncProviderAGS || endpoint.AGSRepositoryID == nil {
			continue
		}
		permission, err := s.HasRepoAccess(ctx, *endpoint.AGSRepositoryID, viewer.ID)
		if err != nil {
			return wrapErr(err)
		}
		if !permission.AtLeast(RepoPermissionAdmin) {
			return ErrForbidden
		}
		return nil
	}
	return fmt.Errorf("%w: sync binding has no AGS endpoint", ErrInvalidState)
}

func normalizeSyncSchedule(mode, cron string) (string, string, error) {
	mode = strings.ToLower(strings.TrimSpace(mode))
	cron = strings.TrimSpace(cron)
	if mode == "" {
		mode = SyncScheduleManual
	}
	switch mode {
	case SyncScheduleManual:
		return mode, "", nil
	case SyncScheduleScheduled:
		if cron == "" {
			return "", "", fmt.Errorf("%w: schedule cron is required in scheduled mode", ErrValidation)
		}
		if _, err := reposync.ParseCron(cron); err != nil {
			return "", "", fmt.Errorf("%w: invalid schedule cron: %v", ErrValidation, err)
		}
		return mode, cron, nil
	default:
		return "", "", fmt.Errorf("%w: schedule mode must be manual or scheduled", ErrValidation)
	}
}

func effectiveSyncEnabled(binding db.SyncBinding, requested *bool) bool {
	if requested != nil {
		return *requested
	}
	return binding.SyncEnabled
}

func requireNoRepositorySyncSafetyFenceTx(tx *gorm.DB, bindingID uint) error {
	var fenced int64
	err := tx.Table("sync_run_items AS i").Joins("JOIN sync_runs AS r ON r.id = i.run_id").
		Where("r.binding_id = ? AND i.state = ? AND i.error_code IN ?", bindingID, SyncRunItemStateBlocked,
			[]string{SyncRunItemErrorApplyResultUnknown, SyncRunItemErrorMapperChanged}).Count(&fenced).Error
	if err != nil {
		return err
	}
	if fenced != 0 {
		return fmt.Errorf("%w: sync binding has an unresolved external outcome", ErrConflict)
	}
	return nil
}

func requireRepositorySyncFullApplyTx(tx *gorm.DB, binding db.SyncBinding) error {
	var count int64
	err := tx.Model(&db.SyncRun{}).Where("binding_id = ? AND authority_epoch = ? AND kind = ? AND mode = ? AND audit_mode = ? AND status = ?",
		binding.ID, binding.AuthorityEpoch, SyncRunKindSync, SyncRunModeApply, SyncAuditModeFull, SyncRunStatusCompleted).Count(&count).Error
	if err != nil {
		return err
	}
	if count == 0 {
		return fmt.Errorf("%w: scheduled sync requires a successful full apply in the current authority epoch", ErrInvalidState)
	}
	return nil
}

func (s *Service) repositorySyncHandoffVisibility(ctx context.Context, binding db.SyncBinding, newAuthoritySide string) (string, bool, error) {
	info, err := tenant.Require(ctx)
	if err != nil {
		return "", false, err
	}
	value, configured := s.repositorySyncValidators.Load(info.ID)
	if !configured {
		return "", false, fmt.Errorf("%w: repository sync is not configured for this tenant", ErrInvalidState)
	}
	resolver, ok := value.(RepositorySyncConnectionVisibilityResolver)
	if !ok {
		return "", false, nil
	}
	var githubEndpoint, agsEndpoint db.SyncBindingEndpoint
	for _, endpoint := range binding.Endpoints {
		switch endpoint.Provider {
		case SyncProviderGitHub:
			githubEndpoint = endpoint
		case SyncProviderAGS:
			agsEndpoint = endpoint
		}
	}
	if agsEndpoint.AGSRepositoryID == nil || githubEndpoint.RepositoryID == "" {
		return "", false, fmt.Errorf("%w: sync binding endpoints are incomplete", ErrInvalidState)
	}
	var repository db.Repository
	if err := s.DBForCtx(ctx).First(&repository, "id = ?", *agsEndpoint.AGSRepositoryID).Error; err != nil {
		return "", false, wrapErr(err)
	}
	_, githubVisibility, err := resolver.ResolveRepositoryWithVisibility(ctx, githubEndpoint.ConnectionID, githubEndpoint.RepositoryID, githubEndpoint.RepositoryFullName)
	if err != nil {
		return "", false, fmt.Errorf("%w: GitHub connection validation failed: %v", ErrValidation, err)
	}
	confirmation, exposure := repositorySyncVisibilityConfirmation(binding.Endpoints, newAuthoritySide,
		normalizedRepositorySyncVisibility(repository.Visibility, repository.Private), githubVisibility)
	return confirmation, exposure, nil
}

func (s *Service) AssertRepositorySyncVisibility(ctx context.Context, claim RepositorySyncClaim, sourceSide, sourceVisibility, targetVisibility, confirmation string) error {
	agsVisibility, githubVisibility := sourceVisibility, targetVisibility
	if sourceSide == SyncSideB {
		agsVisibility, githubVisibility = targetVisibility, sourceVisibility
	}
	expected, exposure := repositorySyncVisibilityConfirmation(claim.Endpoints, sourceSide, agsVisibility, githubVisibility)
	if !exposure {
		return nil
	}
	if confirmation == expected {
		return nil
	}
	if err := s.DBForCtx(ctx).Model(&db.SyncBinding{}).Where("id = ?", claim.Binding.ID).
		Updates(map[string]any{"sync_enabled": false, "next_run_at": nil}).Error; err != nil {
		return fmt.Errorf("%w: standby visibility confirmation is stale; disabling its schedule also failed: %v", reposync.ErrPermission, err)
	}
	return fmt.Errorf("%w: standby visibility is more public than authority and the bound confirmation is missing or stale", reposync.ErrPermission)
}

func repositorySyncVisibilityConfirmation(endpoints []db.SyncBindingEndpoint, sourceSide, agsVisibility, githubVisibility string) (string, bool) {
	agsVisibility = normalizedRepositorySyncVisibility(agsVisibility, false)
	githubVisibility = normalizedRepositorySyncVisibility(githubVisibility, false)
	if visibilityRank(agsVisibility) < 0 || visibilityRank(githubVisibility) < 0 {
		return "", false
	}
	sourceVisibility, targetVisibility := agsVisibility, githubVisibility
	if sourceSide == SyncSideB {
		sourceVisibility, targetVisibility = githubVisibility, agsVisibility
	}
	if visibilityRank(targetVisibility) <= visibilityRank(sourceVisibility) {
		return "", false
	}
	var source, target db.SyncBindingEndpoint
	for _, endpoint := range endpoints {
		if endpoint.Side == sourceSide {
			source = endpoint
		} else if endpoint.Side == otherSyncSide(sourceSide) {
			target = endpoint
		}
	}
	sum := sha256.Sum256([]byte(source.Provider + "\x00" + source.RepositoryID + "\x00" + sourceVisibility + "\x00" + target.Provider + "\x00" + target.RepositoryID + "\x00" + targetVisibility))
	return "visibility-v1-" + hex.EncodeToString(sum[:]), true
}

func normalizedRepositorySyncVisibility(value string, private bool) string {
	value = strings.ToLower(strings.TrimSpace(value))
	if value == "" {
		if private {
			return "private"
		}
		return "public"
	}
	return value
}

func visibilityRank(value string) int {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "private":
		return 0
	case "internal":
		return 1
	case "public":
		return 2
	default:
		return -1
	}
}

func syncSideForProvider(provider string) (string, error) {
	switch strings.ToLower(strings.TrimSpace(provider)) {
	case SyncProviderAGS:
		return SyncSideA, nil
	case SyncProviderGitHub:
		return SyncSideB, nil
	default:
		return "", fmt.Errorf("%w: authority provider must be ags or github", ErrValidation)
	}
}

func otherSyncSide(side string) string {
	if side == SyncSideA {
		return SyncSideB
	}
	return SyncSideA
}

func syncEndpointClaim(provider, repositoryID string) string {
	return provider + ":" + repositoryID
}

func syncActiveRunStatuses() []string {
	return []string{SyncRunStatusQueued, SyncRunStatusRunning}
}
