package service

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"strings"
	"time"

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

const (
	ImportSourceGitHub = "github"

	ImportTargetModeCreate        = "create"
	ImportTargetModeExistingEmpty = "existing_empty"
	ImportTargetModeReplace       = "replace"

	ImportStatusQueued    = "queued"
	ImportStatusRunning   = "running"
	ImportStatusSucceeded = "succeeded"
	ImportStatusFailed    = "failed"
	ImportStatusCanceled  = "canceled"

	ImportStageQueued    = "queued"
	ImportStageInventory = "inventory"
	ImportStageGitMirror = "git_mirror"
	ImportStageBaseData  = "base_data"
	ImportStageIssues    = "issues"
	ImportStagePulls     = "pull_requests"
	ImportStageWiki      = "wiki"
	ImportStageReleases  = "releases"
	ImportStageSettings  = "settings"
	ImportStageTeams     = "teams"
	ImportStageSecrets   = "secrets"
	ImportStageVerify    = "verify"

	ImportBatchStatusStaged   = "staged"
	ImportBatchStatusApplying = "applying"
	ImportBatchStatusApplied  = "applied"
	ImportBatchStatusFailed   = "failed"

	ImportStagingStatusStaged  = "staged"
	ImportStagingStatusApplied = "applied"
	ImportStagingStatusFailed  = "failed"
)

// GitHubImportOptions controls which first-class GitHub resources a job should
// import. The first API version stores these options durably; later worker
// implementations consume them stage by stage.
type GitHubImportOptions struct {
	DryRun                 bool  `json:"dry_run"`
	MirrorGit              bool  `json:"mirror_git"`
	ImportLabels           bool  `json:"import_labels"`
	ImportMilestones       bool  `json:"import_milestones"`
	ImportIssues           bool  `json:"import_issues"`
	ImportIssueComments    bool  `json:"import_issue_comments"`
	ImportPullRequests     bool  `json:"import_pull_requests"`
	ImportPRReviews        bool  `json:"import_pr_reviews"`
	ImportPRReviewComments bool  `json:"import_pr_review_comments"`
	ImportWiki             bool  `json:"import_wiki"`
	ImportReleases         bool  `json:"import_releases"`
	ImportReleaseAssets    bool  `json:"import_release_assets"`
	ImportBranchProtection bool  `json:"import_branch_protection"`
	ImportWebhooks         bool  `json:"import_webhooks"`
	ImportCollaborators    bool  `json:"import_collaborators"`
	ImportTeams            bool  `json:"import_teams"`
	ImportSecretNames      bool  `json:"import_secret_names"`
	ImportActionsMetadata  bool  `json:"import_actions_metadata"`
	MaxReleaseAssetBytes   int64 `json:"max_release_asset_bytes,omitempty"`
}

// CreateGitHubImportInput creates a durable, staged import job. The executor
// keeps the source token locally; this AGS request only establishes the target
// authorization and durable checkpoint.
type CreateGitHubImportInput struct {
	SourceOwner   string
	SourceRepo    string
	SourceAPIURL  string
	SourceHTMLURL string
	TargetOwner   string
	TargetRepo    string
	TargetMode    string
	// TargetRepository contains non-secret source settings collected locally by
	// ags-migrate. It is applied only when target_mode=create.
	TargetRepository    ImportTargetRepositoryOptions
	TargetRepositorySet bool
	InitiatorID         uint
	Options             GitHubImportOptions
}

// ImportTargetRepositoryOptions describes basic source settings copied to a
// newly created import target. It deliberately contains no source credential.
type ImportTargetRepositoryOptions struct {
	Description   string `json:"description,omitempty"`
	Visibility    string `json:"visibility,omitempty"`
	Private       bool   `json:"private"`
	DefaultBranch string `json:"default_branch,omitempty"`
	HasIssues     bool   `json:"has_issues"`
	HasWiki       bool   `json:"has_wiki"`
}

// GitHubImportRuntimeOptions is retained for internal legacy worker callers.
// New public imports use ags-migrate and never send a source token to AGS.
type GitHubImportRuntimeOptions struct {
	SourceToken string
}

// CreateGitHubImportJob creates a target-authorized staged GitHub import job.
// The caller must be the current authenticated AGS human or agent and must be
// allowed to create the target repository or administer an empty existing one.
func (s *Service) CreateGitHubImportJob(ctx context.Context, in CreateGitHubImportInput) (db.ImportJob, error) {
	normalizeGitHubImportInput(&in)
	if err := validateGitHubImportInput(in); err != nil {
		return db.ImportJob{}, err
	}
	if err := s.requireImportInitiator(ctx, in.InitiatorID); err != nil {
		return db.ImportJob{}, err
	}

	optionsJSON, err := marshalJSON(in.Options)
	if err != nil {
		return db.ImportJob{}, err
	}

	targetFullName := in.TargetOwner + "/" + in.TargetRepo
	reservation, err := s.reserveImportTarget(ctx, in.TargetOwner, in.TargetRepo)
	if err != nil {
		return db.ImportJob{}, err
	}
	keepReservation := false
	defer func() {
		if !keepReservation {
			_ = s.DBForCtx(ctx).Delete(&db.ImportTargetReservation{}, reservation.ID).Error
		}
	}()

	targetRepo, err := s.authorizeAndPrepareImportTarget(ctx, in)
	if err != nil {
		return db.ImportJob{}, err
	}
	targetRepoID := targetRepo.ID

	job := db.ImportJob{
		Source:        ImportSourceGitHub,
		Status:        ImportStatusQueued,
		Stage:         ImportStageQueued,
		SourceOwner:   in.SourceOwner,
		SourceRepo:    in.SourceRepo,
		SourceAPIURL:  in.SourceAPIURL,
		SourceHTMLURL: in.SourceHTMLURL,
		TargetOwner:   in.TargetOwner,
		TargetRepo:    in.TargetRepo,
		TargetMode:    in.TargetMode,
		TargetRepoID:  &targetRepoID,
		InitiatorID:   in.InitiatorID,
		DryRun:        in.Options.DryRun,
		MirrorGit:     in.Options.MirrorGit,
		OptionsJSON:   db.LargeText(optionsJSON),
	}

	if err := s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		var lockedReservation db.ImportTargetReservation
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Take(&lockedReservation, "id = ?", reservation.ID).Error; err != nil {
			return err
		}
		if err := tx.Create(&job).Error; err != nil {
			return err
		}
		if err := tx.Model(&db.ImportTargetReservation{}).Where("id = ?", reservation.ID).Update("import_job_id", job.ID).Error; err != nil {
			return err
		}
		return createImportLogTx(tx, job.ID, "info", ImportStageQueued, "GitHub import job queued", map[string]any{
			"source":      in.SourceOwner + "/" + in.SourceRepo,
			"target":      targetFullName,
			"target_mode": in.TargetMode,
			"dry_run":     in.Options.DryRun,
			"mirror_git":  in.Options.MirrorGit,
		})
	}); err != nil {
		return db.ImportJob{}, wrapErr(err)
	}
	keepReservation = true
	return s.GetImportJob(ctx, job.ID)
}

// StartGitHubImportJob schedules an import worker and returns immediately.
// It intentionally uses the server lifecycle context instead of the HTTP
// request context so the import can continue after the create request returns.
func (s *Service) StartGitHubImportJob(ctx context.Context, id uint, runtime GitHubImportRuntimeOptions) (db.ImportJob, error) {
	job, err := s.GetImportJob(ctx, id)
	if err != nil {
		return job, err
	}
	if isTerminalImportStatus(job.Status) {
		return job, fmt.Errorf("%w: import job is already %s", ErrInvalidState, job.Status)
	}

	runCtx, err := s.DetachContext(ctx)
	if err != nil {
		return job, err
	}

	s.Wg.Add(1)
	go func() {
		defer s.Wg.Done()
		if err := s.RunGitHubImportJob(runCtx, id, runtime); err != nil {
			slog.WarnContext(runCtx, "GitHub import job failed", "import_job_id", id, "error", err)
		}
	}()
	return job, nil
}

// RunGitHubImportJob runs a queued import synchronously. It is separated from
// StartGitHubImportJob so tests, CLIs, and future schedulers can run jobs
// deterministically.
func (s *Service) RunGitHubImportJob(ctx context.Context, id uint, runtime GitHubImportRuntimeOptions) error {
	return s.runGitHubImportJob(ctx, id, runtime)
}

// GetImportJob returns one import job with useful associations preloaded.
func (s *Service) GetImportJob(ctx context.Context, id uint) (db.ImportJob, error) {
	var job db.ImportJob
	if err := s.DBForCtx(ctx).
		Preload("Initiator").
		Preload("TargetRepository").
		Take(&job, "id = ?", id).Error; err != nil {
		return job, wrapErr(err)
	}
	return job, nil
}

// CancelImportJob marks a queued/running job as canceled.
func (s *Service) CancelImportJob(ctx context.Context, id uint) (db.ImportJob, error) {
	now := time.Now().UTC()
	if err := s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		var job db.ImportJob
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Take(&job, "id = ?", id).Error; err != nil {
			return err
		}
		if isTerminalImportStatus(job.Status) {
			return fmt.Errorf("%w: import job is already %s", ErrInvalidState, job.Status)
		}
		if err := tx.Model(&db.ImportJob{}).Where("id = ?", id).Updates(map[string]any{
			"status":       ImportStatusCanceled,
			"stage":        job.Stage,
			"completed_at": &now,
		}).Error; err != nil {
			return err
		}
		if err := releaseImportTargetReservationTx(tx, id); err != nil {
			return err
		}
		return createImportLogTx(tx, id, "warn", job.Stage, "GitHub import job canceled", nil)
	}); err != nil {
		return db.ImportJob{}, wrapErr(err)
	}
	return s.GetImportJob(ctx, id)
}

func releaseImportTargetReservationTx(tx *gorm.DB, jobID uint) error {
	return tx.Where("import_job_id = ?", jobID).Delete(&db.ImportTargetReservation{}).Error
}

// ImportJobPermission controls access to an existing import job. Import
// artifacts include source metadata and can mutate target history, therefore
// ordinary reads and writes both require target repository Admin today.
type ImportJobPermission int

const (
	ImportJobPermissionRead ImportJobPermission = iota + 1
	ImportJobPermissionManage
)

// AuthorizeImportJob resolves a job and checks the current human or agent
// against the target repository's normal AGS access model. It intentionally
// does not use InitiatorID: job ownership is audit data, not an authorization
// shortcut.
func (s *Service) AuthorizeImportJob(ctx context.Context, id, userID uint, required ImportJobPermission) (db.ImportJob, error) {
	if userID == 0 {
		return db.ImportJob{}, ErrUnauthorized
	}
	job, err := s.GetImportJob(ctx, id)
	if err != nil {
		return db.ImportJob{}, err
	}
	targetRepoID := uint(0)
	if job.TargetRepoID != nil {
		targetRepoID = *job.TargetRepoID
	}
	if targetRepoID == 0 {
		var target db.Repository
		if err := s.DBForCtx(ctx).Select("id").Take(&target, "full_name = ?", job.TargetOwner+"/"+job.TargetRepo).Error; err != nil {
			if errors.Is(err, gorm.ErrRecordNotFound) {
				return db.ImportJob{}, fmt.Errorf("%w: import job %d has no target repository", ErrInvalidState, id)
			}
			return db.ImportJob{}, wrapErr(err)
		}
		targetRepoID = target.ID
		if err := s.DBForCtx(ctx).Model(&db.ImportJob{}).Where("id = ? AND target_repo_id IS NULL", id).Update("target_repo_id", targetRepoID).Error; err != nil {
			return db.ImportJob{}, wrapErr(err)
		}
		job.TargetRepoID = &targetRepoID
	}
	permission, err := s.HasRepoAccess(ctx, targetRepoID, userID)
	if err != nil {
		return db.ImportJob{}, wrapErr(err)
	}
	minimum := RepoPermissionAdmin
	if required == ImportJobPermissionRead {
		minimum = RepoPermissionAdmin
	}
	if !permission.AtLeast(minimum) {
		return db.ImportJob{}, ErrForbidden
	}
	return job, nil
}

func (s *Service) requireImportInitiator(ctx context.Context, initiatorID uint) error {
	viewer, ok := UserFromContext(ctx)
	if !ok || viewer.ID == 0 {
		return ErrUnauthorized
	}
	if viewer.ID != initiatorID {
		return ErrForbidden
	}
	return nil
}

func (s *Service) reserveImportTarget(ctx context.Context, owner, repo string) (db.ImportTargetReservation, error) {
	reservation := db.ImportTargetReservation{TargetOwner: owner, TargetRepo: repo}
	if err := s.DBForCtx(ctx).Create(&reservation).Error; err != nil {
		if errors.Is(err, gorm.ErrDuplicatedKey) || isDuplicateErr(err) {
			return db.ImportTargetReservation{}, fmt.Errorf("%w: an import is already active for target repository %s/%s", ErrConflict, owner, repo)
		}
		return db.ImportTargetReservation{}, wrapErr(err)
	}
	return reservation, nil
}

func (s *Service) authorizeAndPrepareImportTarget(ctx context.Context, in CreateGitHubImportInput) (db.Repository, error) {
	viewer, ok := UserFromContext(ctx)
	if !ok || viewer.ID == 0 {
		return db.Repository{}, ErrUnauthorized
	}
	owner, err := s.GetUser(ctx, in.TargetOwner)
	if err != nil {
		return db.Repository{}, err
	}
	fullName := in.TargetOwner + "/" + in.TargetRepo
	var existing db.Repository
	err = s.DBForCtx(ctx).Take(&existing, "full_name = ?", fullName).Error
	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
		return db.Repository{}, wrapErr(err)
	}
	exists := err == nil

	switch in.TargetMode {
	case ImportTargetModeCreate:
		if exists {
			return db.Repository{}, fmt.Errorf("%w: target repository %s already exists; use target_mode=existing_empty only for an empty repository you administer", ErrConflict, fullName)
		}
		if err := s.authorizeImportTargetCreate(ctx, owner, viewer); err != nil {
			return db.Repository{}, err
		}
		targetSettings := importTargetRepositorySettings(in)
		repository, err := s.CreateRepo(ctx, CreateRepoInput{
			OwnerLogin:      owner.Login,
			Name:            in.TargetRepo,
			Description:     targetSettings.Description,
			Visibility:      importTargetVisibility(targetSettings),
			Private:         targetSettings.Private,
			DefaultBranch:   targetSettings.DefaultBranch,
			HasIssues:       targetSettings.HasIssues,
			HasIssuesSet:    true,
			HasWiki:         targetSettings.HasWiki,
			HasWikiSet:      true,
			RequireOrgAdmin: owner.Type == db.TypeOrganization,
		})
		if err != nil {
			return db.Repository{}, err
		}
		return repository, nil
	case ImportTargetModeExistingEmpty:
		if !exists {
			return db.Repository{}, fmt.Errorf("%w: target repository %s does not exist; use target_mode=create", ErrNotFound, fullName)
		}
		permission, err := s.HasRepoAccess(ctx, existing.ID, viewer.ID)
		if err != nil {
			return db.Repository{}, wrapErr(err)
		}
		if !permission.AtLeast(RepoPermissionAdmin) {
			return db.Repository{}, ErrForbidden
		}
		if err := s.ensureImportTargetEmpty(ctx, existing); err != nil {
			return db.Repository{}, err
		}
		return existing, nil
	case ImportTargetModeReplace:
		return db.Repository{}, fmt.Errorf("%w: target_mode=replace is not implemented; create a backup and use a separate explicit destructive migration workflow", ErrValidation)
	default:
		return db.Repository{}, fmt.Errorf("%w: target_mode must be create or existing_empty", ErrValidation)
	}
}

func importTargetRepositorySettings(in CreateGitHubImportInput) ImportTargetRepositoryOptions {
	if in.TargetRepositorySet {
		return in.TargetRepository
	}
	// Preserve the previous public API default for callers that do not yet send
	// source metadata. ags-migrate always sets TargetRepositorySet.
	return ImportTargetRepositoryOptions{Visibility: "private", Private: true, DefaultBranch: "main", HasIssues: true, HasWiki: true}
}

func importTargetVisibility(options ImportTargetRepositoryOptions) string {
	visibility := strings.ToLower(strings.TrimSpace(options.Visibility))
	if visibility != "" {
		return visibility
	}
	if options.Private {
		return "private"
	}
	return "public"
}

func (s *Service) authorizeImportTargetCreate(ctx context.Context, owner, viewer db.User) error {
	if owner.Type != db.TypeOrganization {
		if owner.ID != viewer.ID && !viewer.SiteAdmin {
			return ErrForbidden
		}
		return nil
	}
	allowed, err := s.IsOrgAdmin(ctx, owner.ID, viewer.ID)
	if err != nil {
		return err
	}
	if !allowed {
		return ErrForbidden
	}
	return nil
}

func (s *Service) ensureImportTargetEmpty(ctx context.Context, repository db.Repository) error {
	database := s.DBForCtx(ctx)
	// Target access is intentionally excluded. An organization can preconfigure
	// its Admins, teams, and invitations before cutover; importer access
	// handoffs never overwrite those target-side grants.
	for _, resource := range []struct {
		name  string
		model any
	}{
		{"labels", &db.Label{}},
		{"milestones", &db.Milestone{}},
		{"issues", &db.Issue{}},
		{"issue comments", &db.IssueComment{}},
		{"pull requests", &db.PullRequest{}},
		{"releases", &db.Release{}},
		{"wiki pages", &db.WikiPage{}},
		{"deploy keys", &db.DeployKey{}},
		{"rulesets", &db.Ruleset{}},
		{"commit statuses", &db.CommitStatus{}},
		{"webhooks", &db.Webhook{}},
		{"deployments", &db.Deployment{}},
		{"branch protections", &db.BranchProtection{}},
		{"stars", &db.Star{}},
		{"Actions variables", &db.Variable{}},
		{"Actions secrets", &db.Secret{}},
		{"Actions environments", &db.Environment{}},
		{"Actions workflows", &db.Workflow{}},
		{"Actions workflow runs", &db.WorkflowRun{}},
		{"Actions caches", &db.ActionCache{}},
		{"Pages configuration", &db.PagesConfig{}},
		{"pull request stacks", &db.PullRequestStack{}},
		{"asynchronous merges", &db.AsyncMergeRequest{}},
		{"merge queues", &db.MergeQueue{}},
		{"project links", &db.ProjectRepoLink{}},
	} {
		var count int64
		if err := database.Model(resource.model).Where("repository_id = ?", repository.ID).Count(&count).Error; err != nil {
			return wrapErr(err)
		}
		if count > 0 {
			return fmt.Errorf("%w: target repository %s is not empty (%s already exist)", ErrConflict, repository.FullName, resource.name)
		}
	}
	var autolinks int64
	if err := database.Model(&db.Autolink{}).Where("repository_full_name = ?", repository.FullName).Count(&autolinks).Error; err != nil {
		return wrapErr(err)
	}
	if autolinks > 0 {
		return fmt.Errorf("%w: target repository %s is not empty (autolinks already exist)", ErrConflict, repository.FullName)
	}
	git, err := s.GitForCtx(ctx)
	if err != nil {
		return err
	}
	refs, err := git.ListRefsWithPrefix(ctx, repository.FullName, "")
	if err != nil {
		return err
	}
	if len(refs) > 0 {
		return fmt.Errorf("%w: target repository %s has Git refs", ErrConflict, repository.FullName)
	}
	if git.Exists(ctx, repository.FullName+".wiki") {
		wikiRefs, err := git.ListRefsWithPrefix(ctx, repository.FullName+".wiki", "")
		if err != nil {
			return err
		}
		if len(wikiRefs) > 0 {
			return fmt.Errorf("%w: target repository %s has Wiki Git refs", ErrConflict, repository.FullName)
		}
	}
	return nil
}

// ListImportLogs returns newest-first logs for a job.
func (s *Service) ListImportLogs(ctx context.Context, jobID uint, limit int) ([]db.ImportLog, error) {
	limit = clampImportLimit(limit)
	var logs []db.ImportLog
	if err := s.DBForCtx(ctx).
		Where("import_job_id = ?", jobID).
		Order("id desc").
		Limit(limit).
		Find(&logs).Error; err != nil {
		return nil, wrapErr(err)
	}
	return logs, nil
}

// ListImportMappings returns source-to-target mappings for a job.
func (s *Service) ListImportMappings(ctx context.Context, jobID uint, limit int) ([]db.ImportItemMapping, error) {
	return s.ListImportMappingsByTargetType(ctx, jobID, "", limit)
}

// ListImportMappingsByTargetType returns mappings for a specific target type
// when requested. Import verification uses it to check durable facts such as a
// wiki mirror without reading an arbitrary first page of a large migration.
func (s *Service) ListImportMappingsByTargetType(ctx context.Context, jobID uint, targetType string, limit int) ([]db.ImportItemMapping, error) {
	limit = clampImportLimit(limit)
	var mappings []db.ImportItemMapping
	query := s.DBForCtx(ctx).Where("import_job_id = ?", jobID)
	if targetType = strings.TrimSpace(targetType); targetType != "" {
		query = query.Where("target_type = ?", targetType)
	}
	if err := query.Order("source_type asc, source_number asc, source_id asc").Limit(limit).Find(&mappings).Error; err != nil {
		return nil, wrapErr(err)
	}
	return mappings, nil
}

// ListImportMappingsByTargetTypeAfter returns mappings in stable ID order for
// large import verification. A source-number ordering cannot safely resume
// when several source object types have the same number.
func (s *Service) ListImportMappingsByTargetTypeAfter(ctx context.Context, jobID, afterID uint, targetType string, limit int) ([]db.ImportItemMapping, error) {
	limit = clampImportLimit(limit)
	var mappings []db.ImportItemMapping
	query := s.DBForCtx(ctx).Where("import_job_id = ?", jobID)
	if afterID > 0 {
		query = query.Where("id > ?", afterID)
	}
	if targetType = strings.TrimSpace(targetType); targetType != "" {
		query = query.Where("target_type = ?", targetType)
	}
	if err := query.Order("id asc").Limit(limit).Find(&mappings).Error; err != nil {
		return nil, wrapErr(err)
	}
	return mappings, nil
}

// ImportSourceUserMappingInput maps one imported GitHub placeholder to an AGS
// organization member. The default flow creates an attribution invitation; an
// immediate reattribution is reserved for managed deployments that explicitly
// set SkipInvitation.
type ImportSourceUserMappingInput struct {
	SourceUserID    uint
	TargetUserLogin string
	SkipInvitation  bool
}

// ListImportSourceUsers returns the historical identities observed by an
// import. They are kept separately from item mappings because one source user
// can author many imported objects.
func (s *Service) ListImportSourceUsers(ctx context.Context, jobID uint, limit int) ([]db.ImportSourceUser, error) {
	return s.ListImportSourceUsersAfter(ctx, jobID, 0, limit)
}

// ListImportSourceUsersAfter returns a stable, cursor-paginated view of source
// identities. Large organization migrations can easily have more than one
// page of authors, so callers must not rely on the first 500 records.
func (s *Service) ListImportSourceUsersAfter(ctx context.Context, jobID, afterID uint, limit int) ([]db.ImportSourceUser, error) {
	limit = clampImportLimit(limit)
	var users []db.ImportSourceUser
	query := s.DBForCtx(ctx).
		Preload("TargetUser").
		Preload("ProposedUser").
		Where("import_job_id = ?", jobID)
	if afterID > 0 {
		query = query.Where("id > ?", afterID)
	}
	if err := query.Order("id asc").Limit(limit).Find(&users).Error; err != nil {
		return nil, wrapErr(err)
	}
	return users, nil
}

// ListClaimableImportSourceUsers finds placeholders for which the viewer has
// already verified the same immutable GitHub subject. A matching login or
// commit email deliberately does not qualify.
func (s *Service) ListClaimableImportSourceUsers(ctx context.Context, userID uint, limit int) ([]db.ImportSourceUser, error) {
	limit = clampImportLimit(limit)
	var identities []db.UserIdentity
	if err := s.DBForCtx(ctx).Where("user_id = ? AND provider LIKE ?", userID, "github%").Find(&identities).Error; err != nil {
		return nil, wrapErr(err)
	}
	if len(identities) == 0 {
		return []db.ImportSourceUser{}, nil
	}
	byProviderSubject := make(map[string]struct{}, len(identities))
	subjects := make([]string, 0, len(identities))
	for _, identity := range identities {
		byProviderSubject[identity.Provider+"\x00"+identity.Subject] = struct{}{}
		subjects = append(subjects, identity.Subject)
	}

	matched := make([]db.ImportSourceUser, 0, limit)
	var afterID uint
	for len(matched) < limit {
		var candidates []db.ImportSourceUser
		if err := s.DBForCtx(ctx).
			Preload("ImportJob").
			Where("state = ? AND source_id IN ? AND id > ?", "pending_claim", subjects, afterID).
			Order("id asc").
			Limit(500).
			Find(&candidates).Error; err != nil {
			return nil, wrapErr(err)
		}
		if len(candidates) == 0 {
			break
		}
		for _, candidate := range candidates {
			afterID = candidate.ID
			provider := githubIdentityProviderForAPIURL(candidate.ImportJob.SourceAPIURL)
			if _, ok := byProviderSubject[provider+"\x00"+candidate.SourceID]; !ok {
				continue
			}
			matched = append(matched, candidate)
			if len(matched) == limit {
				break
			}
		}
		if len(candidates) < 500 {
			break
		}
	}
	return matched, nil
}

// ListMyImportAttributionInvitations returns pending invitations for an AGS
// member. The UI can render these in the user's normal notifications surface.
func (s *Service) ListMyImportAttributionInvitations(ctx context.Context, userID uint, limit int) ([]db.ImportSourceUser, error) {
	limit = clampImportLimit(limit)
	var users []db.ImportSourceUser
	if err := s.DBForCtx(ctx).
		Preload("ImportJob").
		Preload("ProposedUser").
		Where("proposed_user_id = ? AND invitation_state = ?", userID, "pending").
		Order("id asc").
		Limit(limit).
		Find(&users).Error; err != nil {
		return nil, wrapErr(err)
	}
	return users, nil
}

// MapImportSourceUsers creates attribution invitations in bulk. It does not
// grant repository access, team membership, or review rights. Passing
// SkipInvitation is an explicitly recorded managed-identity exception.
func (s *Service) MapImportSourceUsers(ctx context.Context, jobID uint, inputs []ImportSourceUserMappingInput) ([]db.ImportSourceUser, error) {
	if len(inputs) == 0 {
		return nil, fmt.Errorf("%w: at least one source user mapping is required", ErrValidation)
	}
	if len(inputs) > 500 {
		return nil, fmt.Errorf("%w: source user mapping count exceeds 500", ErrValidation)
	}

	out := make([]db.ImportSourceUser, 0, len(inputs))
	err := s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		var job db.ImportJob
		if err := tx.Take(&job, "id = ?", jobID).Error; err != nil {
			return err
		}
		if job.Source != ImportSourceGitHub {
			return fmt.Errorf("%w: source identity mapping is only supported for GitHub imports", ErrValidation)
		}
		for _, input := range inputs {
			mapped, err := mapImportSourceUserTx(tx, job, input)
			if err != nil {
				return err
			}
			out = append(out, mapped)
		}
		return createImportLogTx(tx, jobID, "info", ImportStageVerify, "imported source identities mapped", map[string]any{
			"count": len(out),
		})
	})
	if err != nil {
		return nil, wrapErr(err)
	}
	return out, nil
}

// ClaimImportSourceUser lets a member immediately claim a placeholder only
// when AGS has already verified the same GitHub numeric subject for that
// member. It is intentionally not an email or login-name shortcut.
func (s *Service) ClaimImportSourceUser(ctx context.Context, jobID, sourceUserID, claimantID uint) (db.ImportSourceUser, error) {
	if claimantID == 0 {
		return db.ImportSourceUser{}, ErrUnauthorized
	}
	var claimant db.User
	if err := s.DBForCtx(ctx).Take(&claimant, "id = ?", claimantID).Error; err != nil {
		return db.ImportSourceUser{}, wrapErr(err)
	}
	var source db.ImportSourceUser
	if err := s.DBForCtx(ctx).Take(&source, "id = ? AND import_job_id = ?", sourceUserID, jobID).Error; err != nil {
		return db.ImportSourceUser{}, wrapErr(err)
	}
	if strings.TrimSpace(source.SourceID) == "" {
		return db.ImportSourceUser{}, fmt.Errorf("%w: this historical identity has no stable GitHub ID", ErrValidation)
	}
	var job db.ImportJob
	if err := s.DBForCtx(ctx).Take(&job, "id = ?", jobID).Error; err != nil {
		return db.ImportSourceUser{}, wrapErr(err)
	}
	provider := githubIdentityProviderForAPIURL(job.SourceAPIURL)
	var identity db.UserIdentity
	if err := s.DBForCtx(ctx).Take(&identity, "provider = ? AND subject = ?", provider, source.SourceID).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return db.ImportSourceUser{}, ErrForbidden
		}
		return db.ImportSourceUser{}, wrapErr(err)
	}
	if identity.UserID != claimant.ID {
		return db.ImportSourceUser{}, ErrForbidden
	}
	mapped, err := s.MapImportSourceUsers(ctx, jobID, []ImportSourceUserMappingInput{{
		SourceUserID: sourceUserID, TargetUserLogin: claimant.Login, SkipInvitation: true,
	}})
	if err != nil {
		return db.ImportSourceUser{}, err
	}
	return mapped[0], nil
}

// RespondToImportAttributionInvitation changes history only after the invited
// AGS member accepts it. A rejection keeps the immutable placeholder intact.
func (s *Service) RespondToImportAttributionInvitation(ctx context.Context, jobID, sourceUserID, userID uint, accept bool) (db.ImportSourceUser, error) {
	var out db.ImportSourceUser
	err := s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		var job db.ImportJob
		if err := tx.Take(&job, "id = ?", jobID).Error; err != nil {
			return err
		}
		var source db.ImportSourceUser
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Take(&source, "id = ? AND import_job_id = ?", sourceUserID, jobID).Error; err != nil {
			return err
		}
		if source.ProposedUserID == nil || *source.ProposedUserID != userID || source.InvitationState != "pending" {
			return ErrForbidden
		}
		now := time.Now().UTC()
		if !accept {
			if err := tx.Model(&db.ImportSourceUser{}).Where("id = ?", source.ID).Updates(map[string]any{
				"state":            "rejected",
				"invitation_state": "rejected",
				"responded_at":     &now,
			}).Error; err != nil {
				return err
			}
			source.State = "rejected"
			source.InvitationState = "rejected"
			source.RespondedAt = &now
			out = source
			return createImportLogTx(tx, jobID, "info", ImportStageVerify, "import attribution invitation rejected", map[string]any{"source_user_id": source.ID, "user_id": userID})
		}
		var target db.User
		if err := tx.Take(&target, "id = ?", userID).Error; err != nil {
			return err
		}
		if err := completeImportSourceUserAttributionTx(tx, job, source, target, now); err != nil {
			return err
		}
		source.TargetUserID = &target.ID
		source.TargetUser = target
		source.State = "claimed"
		source.InvitationState = "accepted"
		source.RespondedAt = &now
		out = source
		return createImportLogTx(tx, jobID, "info", ImportStageVerify, "import attribution invitation accepted", map[string]any{"source_user_id": source.ID, "user_id": userID})
	})
	if err != nil {
		return db.ImportSourceUser{}, wrapErr(err)
	}
	return out, nil
}

func mapImportSourceUserTx(tx *gorm.DB, job db.ImportJob, input ImportSourceUserMappingInput) (db.ImportSourceUser, error) {
	if input.SourceUserID == 0 || strings.TrimSpace(input.TargetUserLogin) == "" {
		return db.ImportSourceUser{}, fmt.Errorf("%w: source_user_id and target_user_login are required", ErrValidation)
	}
	var source db.ImportSourceUser
	if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Take(&source, "id = ? AND import_job_id = ?", input.SourceUserID, job.ID).Error; err != nil {
		return db.ImportSourceUser{}, err
	}
	if strings.TrimSpace(source.SourceID) == "" {
		return db.ImportSourceUser{}, fmt.Errorf("%w: source user %q has no stable GitHub ID and cannot be claimed", ErrValidation, source.SourceLogin)
	}
	var target db.User
	if err := tx.Take(&target, "login = ?", strings.TrimSpace(input.TargetUserLogin)).Error; err != nil {
		return db.ImportSourceUser{}, err
	}
	if err := validateImportAttributionTargetTx(tx, job, target); err != nil {
		return db.ImportSourceUser{}, err
	}
	if source.State == "claimed" && source.TargetUserID != nil && *source.TargetUserID != target.ID {
		return db.ImportSourceUser{}, fmt.Errorf("%w: imported source user is already claimed by another AGS user", ErrConflict)
	}
	now := time.Now().UTC()
	if input.SkipInvitation {
		if err := completeImportSourceUserAttributionTx(tx, job, source, target, now); err != nil {
			return db.ImportSourceUser{}, err
		}
		source.TargetUserID = &target.ID
		source.TargetUser = target
		source.ProposedUserID = &target.ID
		source.ProposedUser = target
		source.State = "claimed"
		source.InvitationState = "accepted"
		source.InvitedAt = &now
		source.RespondedAt = &now
		return source, nil
	}
	if source.ProposedUserID != nil && *source.ProposedUserID != target.ID && source.InvitationState == "pending" {
		return db.ImportSourceUser{}, fmt.Errorf("%w: a different attribution invitation is already pending", ErrConflict)
	}
	if err := tx.Model(&db.ImportSourceUser{}).Where("id = ?", source.ID).Updates(map[string]any{
		"proposed_user_id": target.ID,
		"state":            "pending_invitation",
		"invitation_state": "pending",
		"invited_at":       &now,
		"responded_at":     nil,
	}).Error; err != nil {
		return db.ImportSourceUser{}, err
	}
	source.ProposedUserID = &target.ID
	source.ProposedUser = target
	source.State = "pending_invitation"
	source.InvitationState = "pending"
	source.InvitedAt = &now
	source.RespondedAt = nil
	return source, nil
}

func validateImportAttributionTargetTx(tx *gorm.DB, job db.ImportJob, target db.User) error {
	if target.UserKind != db.UserKindHuman || target.Status != db.UserStatusActive {
		return fmt.Errorf("%w: target user %q is not an active human user", ErrValidation, target.Login)
	}
	eligible, err := eligibleImportAttributionUserIDsTx(tx, job, []uint{target.ID})
	if err != nil {
		return err
	}
	if !eligible[target.ID] {
		return fmt.Errorf("%w: target user %q is not an eligible member of the destination repository owner", ErrValidation, target.Login)
	}
	return nil
}

// eligibleImportAttributionUserIDsTx scopes automatic source identity reuse to
// the destination owner. A GitHub identity verified in one organization must
// not silently reattribute history in another organization.
func eligibleImportAttributionUserIDsTx(tx *gorm.DB, job db.ImportJob, userIDs []uint) (map[uint]bool, error) {
	eligible := make(map[uint]bool, len(userIDs))
	if len(userIDs) == 0 {
		return eligible, nil
	}
	var targetRepo db.Repository
	if job.TargetRepoID != nil {
		if err := tx.Take(&targetRepo, "id = ?", *job.TargetRepoID).Error; err != nil {
			return nil, err
		}
	} else if err := tx.Take(&targetRepo, "full_name = ?", job.TargetOwner+"/"+job.TargetRepo).Error; err != nil {
		return nil, err
	}
	var owner db.User
	if err := tx.Take(&owner, "id = ?", targetRepo.OwnerID).Error; err != nil {
		return nil, err
	}
	if owner.Type != db.TypeOrganization {
		for _, userID := range userIDs {
			if owner.ID == userID {
				eligible[userID] = true
			}
		}
		return eligible, nil
	}
	var memberships []db.OrganizationMember
	if err := tx.Where("organization_id = ? AND user_id IN ?", owner.ID, userIDs).Find(&memberships).Error; err != nil {
		return nil, err
	}
	for _, membership := range memberships {
		eligible[membership.UserID] = true
	}
	return eligible, nil
}

func completeImportSourceUserAttributionTx(tx *gorm.DB, job db.ImportJob, source db.ImportSourceUser, target db.User, now time.Time) error {
	if err := ensureGitHubIdentityOwnerTx(tx, githubIdentityProviderForAPIURL(job.SourceAPIURL), source.SourceID, target.ID); err != nil {
		return err
	}
	if err := reattributeImportSourceUserTx(tx, job, source, target); err != nil {
		return err
	}
	return tx.Model(&db.ImportSourceUser{}).Where("id = ?", source.ID).Updates(map[string]any{
		"target_user_id":   target.ID,
		"state":            "claimed",
		"invitation_state": "accepted",
		"responded_at":     &now,
	}).Error
}

func ensureGitHubIdentityOwnerTx(tx *gorm.DB, provider, subject string, userID uint) error {
	var existing db.UserIdentity
	err := tx.Take(&existing, "provider = ? AND subject = ?", provider, subject).Error
	if err == nil {
		if existing.UserID != userID {
			return fmt.Errorf("%w: GitHub identity is already linked to another AGS user", ErrConflict)
		}
		return nil
	}
	if !errors.Is(err, gorm.ErrRecordNotFound) {
		return err
	}
	return tx.Create(&db.UserIdentity{Provider: provider, Subject: subject, UserID: userID}).Error
}

func reattributeImportSourceUserTx(tx *gorm.DB, job db.ImportJob, source db.ImportSourceUser, target db.User) error {
	if strings.TrimSpace(source.PlaceholderLogin) == "" {
		return nil
	}
	var placeholder db.User
	if err := tx.Take(&placeholder, "login = ?", source.PlaceholderLogin).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil
		}
		return err
	}
	var repoID uint
	if job.TargetRepoID != nil {
		repoID = *job.TargetRepoID
	} else if err := tx.Model(&db.Repository{}).Select("id").Take(&repoID, "full_name = ?", job.TargetOwner+"/"+job.TargetRepo).Error; err != nil {
		return err
	}
	if err := tx.Model(&db.Milestone{}).Where("repository_id = ? AND creator_id = ?", repoID, placeholder.ID).Update("creator_id", target.ID).Error; err != nil {
		return err
	}
	if err := tx.Model(&db.Issue{}).Where("repository_id = ? AND author_id = ?", repoID, placeholder.ID).Update("author_id", target.ID).Error; err != nil {
		return err
	}
	if err := tx.Model(&db.Issue{}).Where("repository_id = ? AND closed_by_login = ?", repoID, source.SourceLogin).Update("closed_by_login", target.Login).Error; err != nil {
		return err
	}
	if err := tx.Model(&db.IssueComment{}).Where("repository_id = ? AND author_id = ?", repoID, placeholder.ID).Update("author_id", target.ID).Error; err != nil {
		return err
	}
	if err := tx.Model(&db.PullRequest{}).Where("repository_id = ? AND author_id = ?", repoID, placeholder.ID).Update("author_id", target.ID).Error; err != nil {
		return err
	}
	if err := tx.Model(&db.PullRequest{}).Where("repository_id = ? AND merged_by_login = ?", repoID, source.SourceLogin).Update("merged_by_login", target.Login).Error; err != nil {
		return err
	}
	if err := tx.Model(&db.Release{}).Where("repository_id = ? AND author_id = ?", repoID, placeholder.ID).Update("author_id", target.ID).Error; err != nil {
		return err
	}
	prIDs := tx.Model(&db.PullRequest{}).Select("id").Where("repository_id = ?", repoID)
	if err := tx.Model(&db.PullRequestReview{}).Where("pull_request_id IN (?) AND author_login = ?", prIDs, source.SourceLogin).Update("author_login", target.Login).Error; err != nil {
		return err
	}
	return tx.Model(&db.PRReviewComment{}).Where("pull_request_id IN (?) AND author_login = ?", prIDs, source.SourceLogin).Update("author_login", target.Login).Error
}

func normalizeGitHubImportInput(in *CreateGitHubImportInput) {
	in.SourceOwner = strings.TrimSpace(in.SourceOwner)
	in.SourceRepo = strings.TrimSpace(in.SourceRepo)
	in.SourceAPIURL = strings.TrimSpace(in.SourceAPIURL)
	in.SourceHTMLURL = strings.TrimSpace(in.SourceHTMLURL)
	in.TargetOwner = strings.TrimSpace(in.TargetOwner)
	in.TargetRepo = strings.TrimSpace(in.TargetRepo)
	in.TargetMode = strings.ToLower(strings.TrimSpace(in.TargetMode))
	in.TargetRepository.Description = strings.TrimSpace(in.TargetRepository.Description)
	in.TargetRepository.Visibility = strings.ToLower(strings.TrimSpace(in.TargetRepository.Visibility))
	in.TargetRepository.DefaultBranch = strings.TrimSpace(in.TargetRepository.DefaultBranch)
	if in.TargetMode == "" {
		in.TargetMode = ImportTargetModeCreate
	}
	if in.SourceAPIURL == "" {
		in.SourceAPIURL = "https://api.github.com"
	}
	if in.SourceHTMLURL == "" {
		in.SourceHTMLURL = "https://github.com/" + in.SourceOwner + "/" + in.SourceRepo
	}
	if !anyGitHubImportOptionEnabled(in.Options) {
		in.Options.MirrorGit = true
		in.Options.ImportLabels = true
		in.Options.ImportMilestones = true
		in.Options.ImportIssues = true
		in.Options.ImportIssueComments = true
		in.Options.ImportPullRequests = true
		in.Options.ImportPRReviews = true
		in.Options.ImportPRReviewComments = true
		in.Options.ImportWiki = true
		in.Options.ImportReleases = true
	}
}

func validateGitHubImportInput(in CreateGitHubImportInput) error {
	switch {
	case in.Options.DryRun:
		// Public staged imports create or reserve a real AGS target. Source
		// inventory is intentionally local in ags-migrate plan, so accepting a
		// server-side dry run here would be a surprising target-side mutation.
		return fmt.Errorf("%w: dry_run is local-only; use ags-migrate plan before creating an import job", ErrValidation)
	case in.SourceOwner == "":
		return fmt.Errorf("%w: source_owner is required", ErrValidation)
	case in.SourceRepo == "":
		return fmt.Errorf("%w: source_repo is required", ErrValidation)
	case in.TargetOwner == "":
		return fmt.Errorf("%w: target_owner is required", ErrValidation)
	case in.TargetRepo == "":
		return fmt.Errorf("%w: target_repo is required", ErrValidation)
	case in.InitiatorID == 0:
		return fmt.Errorf("%w: initiator is required", ErrValidation)
	default:
		return nil
	}
}

func anyGitHubImportOptionEnabled(options GitHubImportOptions) bool {
	return options.MirrorGit ||
		options.ImportLabels ||
		options.ImportMilestones ||
		options.ImportIssues ||
		options.ImportIssueComments ||
		options.ImportPullRequests ||
		options.ImportPRReviews ||
		options.ImportPRReviewComments ||
		options.ImportWiki ||
		options.ImportReleases ||
		options.ImportReleaseAssets ||
		options.ImportBranchProtection ||
		options.ImportWebhooks ||
		options.ImportCollaborators ||
		options.ImportTeams ||
		options.ImportSecretNames ||
		options.ImportActionsMetadata
}

func isTerminalImportStatus(status string) bool {
	switch status {
	case ImportStatusSucceeded, ImportStatusFailed, ImportStatusCanceled:
		return true
	default:
		return false
	}
}

func clampImportLimit(limit int) int {
	if limit <= 0 {
		return 100
	}
	if limit > 500 {
		return 500
	}
	return limit
}

func createImportLogTx(tx *gorm.DB, jobID uint, level, stage, message string, details any) error {
	detailsJSON := ""
	if details != nil {
		encoded, err := marshalJSON(details)
		if err != nil {
			return err
		}
		detailsJSON = encoded
	}
	return tx.Create(&db.ImportLog{
		ImportJobID: jobID,
		Level:       level,
		Stage:       stage,
		Message:     message,
		DetailsJSON: db.LargeText(detailsJSON),
	}).Error
}

// resolveGitHubImportToken is used only by the legacy in-process runner. New
// public imports deliberately keep GitHub credentials in ags-migrate.
func resolveGitHubImportToken(runtime GitHubImportRuntimeOptions, ref string) (string, error) {
	if strings.TrimSpace(runtime.SourceToken) != "" {
		return strings.TrimSpace(runtime.SourceToken), nil
	}
	ref = strings.TrimSpace(ref)
	if ref == "" {
		return "", nil
	}
	if name, ok := strings.CutPrefix(ref, "env:"); ok {
		name = strings.TrimSpace(name)
		if name == "" {
			return "", fmt.Errorf("%w: source_token_ref env name is empty", ErrValidation)
		}
		token := strings.TrimSpace(os.Getenv(name))
		if token == "" {
			return "", fmt.Errorf("%w: source_token_ref %s is empty", ErrValidation, ref)
		}
		return token, nil
	}
	if path, ok := strings.CutPrefix(ref, "file:"); ok {
		path = strings.TrimSpace(path)
		if path == "" {
			return "", fmt.Errorf("%w: source_token_ref file path is empty", ErrValidation)
		}
		data, err := os.ReadFile(path)
		if err != nil {
			return "", fmt.Errorf("read source_token_ref %s: %w", ref, err)
		}
		token := strings.TrimSpace(string(data))
		if token == "" {
			return "", fmt.Errorf("%w: source_token_ref %s is empty", ErrValidation, ref)
		}
		return token, nil
	}
	return "", fmt.Errorf("%w: source_token_ref must use env:NAME or file:/path", ErrValidation)
}
