package service

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"os/exec"
	"sort"
	"strconv"
	"strings"
	"time"

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

	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

const (
	importTypeRepository        = "repository"
	importTypeLabel             = "label"
	importTypeMilestone         = "milestone"
	importTypeIssue             = "issue"
	importTypeIssueComment      = "issue_comment"
	importTypePullRequest       = "pull_request"
	importTypePullRequestStack  = "pull_request_stack"
	importTypePullReview        = "pull_request_review"
	importTypePullReviewComment = "pull_request_review_comment"
	importTypeRelease           = "release"
	importTypeReleaseAsset      = "release_asset"
	importTypeBranchProtection  = "branch_protection"
	importTypeWebhook           = "webhook"
	importTypeCollaborator      = "collaborator"
	importTypeTeam              = "team"
	importTypeTeamMember        = "team_member"
	importTypeWorkflow          = "workflow"
	importTypeSecretName        = "secret_name"
)

const defaultMaxReleaseAssetBytes int64 = 100 * 1024 * 1024
const githubStacksAPIVersion = "2026-03-10"

var (
	errGitHubImportNotFound = errors.New("github resource not found")
	errGitHubImportCanceled = errors.New("github import canceled")
)

type githubImportRunner struct {
	s           *Service
	job         db.ImportJob
	options     GitHubImportOptions
	client      *githubImportClient
	token       string
	baseURL     string
	htmlBaseURL string
	git         *gitstore.Store
}

func (s *Service) runGitHubImportJob(ctx context.Context, id uint, runtime GitHubImportRuntimeOptions) error {
	job, err := s.beginImportJob(ctx, id)
	if err != nil {
		return err
	}

	var options GitHubImportOptions
	if err := json.Unmarshal([]byte(job.OptionsJSON), &options); err != nil {
		return s.failImportJob(ctx, id, job.Stage, fmt.Errorf("decode import options: %w", err))
	}
	normalizeGitHubImportOptions(&options)
	scope, err := s.Scope(ctx)
	if err != nil {
		return s.failImportJob(ctx, id, job.Stage, err)
	}
	baseURL := scope.Info.BaseURL()
	htmlBaseURL := scope.Info.HTMLBaseURL()

	token, err := resolveGitHubImportToken(runtime, job.SourceTokenRef)
	if err != nil {
		return s.failImportJob(ctx, id, job.Stage, err)
	}

	r := &githubImportRunner{
		s:           s,
		job:         job,
		options:     options,
		token:       token,
		client:      newGitHubImportClient(job.SourceAPIURL, token),
		baseURL:     baseURL,
		htmlBaseURL: htmlBaseURL,
		git:         scope.Resources.Git,
	}
	if err := r.run(ctx); err != nil {
		if errors.Is(err, errGitHubImportCanceled) || r.isCanceled(ctx) {
			return nil
		}
		return s.failImportJob(ctx, id, r.job.Stage, err)
	}
	return s.completeImportJob(ctx, id)
}

func (s *Service) beginImportJob(ctx context.Context, id uint) (db.ImportJob, error) {
	now := time.Now().UTC()
	var job db.ImportJob
	err := s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		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":     ImportStatusRunning,
			"stage":      ImportStageInventory,
			"started_at": &now,
		}).Error; err != nil {
			return err
		}
		return createImportLogTx(tx, id, "info", ImportStageInventory, "GitHub import job started", nil)
	})
	if err != nil {
		return db.ImportJob{}, wrapErr(err)
	}
	job.Status = ImportStatusRunning
	job.Stage = ImportStageInventory
	job.StartedAt = &now
	return job, nil
}

func (s *Service) completeImportJob(ctx context.Context, id uint) error {
	now := time.Now().UTC()
	return 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 job.Status == ImportStatusCanceled {
			return nil
		}
		if err := tx.Model(&db.ImportJob{}).Where("id = ?", id).Updates(map[string]any{
			"status":       ImportStatusSucceeded,
			"stage":        ImportStageVerify,
			"completed_at": &now,
		}).Error; err != nil {
			return err
		}
		if err := releaseImportTargetReservationTx(tx, id); err != nil {
			return err
		}
		return createImportLogTx(tx, id, "info", ImportStageVerify, "GitHub import job completed", nil)
	})
}

func (s *Service) failImportJob(ctx context.Context, id uint, stage string, err error) error {
	now := time.Now().UTC()
	message := err.Error()
	if stage == "" {
		stage = ImportStageQueued
	}
	updateErr := 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 job.Status == ImportStatusCanceled {
			return nil
		}
		if err := tx.Model(&db.ImportJob{}).Where("id = ?", id).Updates(map[string]any{
			"status":        ImportStatusFailed,
			"stage":         stage,
			"error_message": db.LargeText(message),
			"completed_at":  &now,
		}).Error; err != nil {
			return err
		}
		if err := releaseImportTargetReservationTx(tx, id); err != nil {
			return err
		}
		return createImportLogTx(tx, id, "error", stage, "GitHub import job failed", map[string]any{
			"error": message,
		})
	})
	if updateErr != nil {
		return updateErr
	}
	return err
}

func (r *githubImportRunner) run(ctx context.Context) error {
	sourceRepo, err := r.client.repo(ctx, r.job.SourceOwner, r.job.SourceRepo)
	if err != nil {
		return err
	}
	if err := r.runStage(ctx, ImportStageInventory, func(ctx context.Context) error {
		return r.inventory(ctx, sourceRepo)
	}); err != nil {
		return err
	}
	if r.options.DryRun {
		return r.log(ctx, "info", ImportStageVerify, "dry-run completed; no AGS data was written", nil)
	}

	var targetRepo db.Repository
	if err := r.runStage(ctx, ImportStageBaseData, func(ctx context.Context) error {
		var err error
		targetRepo, err = r.ensureTargetRepository(ctx, sourceRepo)
		return err
	}); err != nil {
		return err
	}
	if r.options.MirrorGit {
		if err := r.runStage(ctx, ImportStageGitMirror, func(ctx context.Context) error {
			return r.mirrorRepositoryGit(ctx, targetRepo, sourceRepo)
		}); err != nil {
			return err
		}
	}
	if r.options.ImportLabels {
		if err := r.runStage(ctx, ImportStageBaseData, func(ctx context.Context) error {
			count, err := r.importLabels(ctx, targetRepo)
			if err == nil {
				err = r.log(ctx, "info", ImportStageBaseData, "labels imported", map[string]any{"count": count})
			}
			return err
		}); err != nil {
			return err
		}
	}
	if r.options.ImportMilestones {
		if err := r.runStage(ctx, ImportStageBaseData, func(ctx context.Context) error {
			count, err := r.importMilestones(ctx, targetRepo)
			if err == nil {
				err = r.log(ctx, "info", ImportStageBaseData, "milestones imported", map[string]any{"count": count})
			}
			return err
		}); err != nil {
			return err
		}
	}
	if r.options.ImportCollaborators || r.options.ImportTeams {
		if err := r.runStage(ctx, ImportStageTeams, func(ctx context.Context) error {
			return r.importAccess(ctx)
		}); err != nil {
			return err
		}
	}
	if r.options.ImportIssues {
		if err := r.runStage(ctx, ImportStageIssues, func(ctx context.Context) error {
			return r.importIssues(ctx, targetRepo)
		}); err != nil {
			return err
		}
	}
	if r.options.ImportPullRequests {
		if err := r.runStage(ctx, ImportStagePulls, func(ctx context.Context) error {
			return r.importPullRequests(ctx, targetRepo)
		}); err != nil {
			return err
		}
	}
	if r.options.ImportWiki {
		if err := r.runStage(ctx, ImportStageWiki, func(ctx context.Context) error {
			return r.importWiki(ctx, targetRepo, sourceRepo)
		}); err != nil {
			return err
		}
	}
	if r.options.ImportReleases {
		if err := r.runStage(ctx, ImportStageReleases, func(ctx context.Context) error {
			return r.importReleases(ctx, targetRepo)
		}); err != nil {
			return err
		}
	}
	if r.options.ImportBranchProtection || r.options.ImportWebhooks {
		if err := r.runStage(ctx, ImportStageSettings, func(ctx context.Context) error {
			return r.importSettings(ctx, targetRepo)
		}); err != nil {
			return err
		}
	}
	if r.options.ImportSecretNames || r.options.ImportActionsMetadata {
		if err := r.runStage(ctx, ImportStageSecrets, func(ctx context.Context) error {
			return r.importActionsManifest(ctx, targetRepo)
		}); err != nil {
			return err
		}
	}
	return r.runStage(ctx, ImportStageVerify, func(ctx context.Context) error {
		return r.verify(ctx, targetRepo)
	})
}

func (r *githubImportRunner) runStage(ctx context.Context, stage string, fn func(context.Context) error) error {
	if r.isCanceled(ctx) {
		return errGitHubImportCanceled
	}
	if err := r.setStage(ctx, stage); err != nil {
		return err
	}
	started := time.Now()
	if err := fn(ctx); err != nil {
		if errors.Is(err, errGitHubImportNotFound) {
			return r.log(ctx, "warn", stage, "GitHub resource not found; stage skipped", nil)
		}
		return err
	}
	return r.log(ctx, "info", stage, "stage completed", map[string]any{
		"duration_ms": time.Since(started).Milliseconds(),
	})
}

func (r *githubImportRunner) setStage(ctx context.Context, stage string) error {
	r.job.Stage = stage
	return r.s.DBForCtx(ctx).Model(&db.ImportJob{}).Where("id = ? AND status <> ?", r.job.ID, ImportStatusCanceled).Update("stage", stage).Error
}

func (r *githubImportRunner) isCanceled(ctx context.Context) bool {
	var status string
	if err := r.s.DBForCtx(ctx).Model(&db.ImportJob{}).Where("id = ?", r.job.ID).Select("status").Scan(&status).Error; err != nil {
		slog.WarnContext(ctx, "check import cancellation", "import_job_id", r.job.ID, "error", err)
		return false
	}
	return status == ImportStatusCanceled
}

func (r *githubImportRunner) log(ctx context.Context, level, stage, message string, details any) error {
	return createImportLogTx(r.s.DBForCtx(ctx), r.job.ID, level, stage, message, details)
}

func (r *githubImportRunner) inventory(ctx context.Context, repo ghRepository) error {
	inventory := map[string]any{
		"source_repo": map[string]any{
			"id":             repo.ID,
			"node_id":        repo.NodeID,
			"full_name":      repo.FullName,
			"private":        repo.Private,
			"default_branch": repo.DefaultBranch,
			"html_url":       repo.HTMLURL,
			"clone_url":      repo.CloneURL,
			"has_issues":     repo.HasIssues,
			"has_wiki":       repo.HasWiki,
			"archived":       repo.Archived,
			"disabled":       repo.Disabled,
		},
		"target_repo": r.job.TargetOwner + "/" + r.job.TargetRepo,
		"dry_run":     r.options.DryRun,
	}
	counts := map[string]int{}
	if labels, err := r.client.labels(ctx, r.job.SourceOwner, r.job.SourceRepo); err == nil {
		counts["labels"] = len(labels)
	}
	if milestones, err := r.client.milestones(ctx, r.job.SourceOwner, r.job.SourceRepo); err == nil {
		counts["milestones"] = len(milestones)
	}
	if issues, err := r.client.issues(ctx, r.job.SourceOwner, r.job.SourceRepo); err == nil {
		issueCount, pullCount := 0, 0
		for _, issue := range issues {
			if issue.PullRequest != nil {
				pullCount++
			} else {
				issueCount++
			}
		}
		counts["issues"] = issueCount
		counts["issues_endpoint_pull_refs"] = pullCount
	}
	if pulls, err := r.client.pulls(ctx, r.job.SourceOwner, r.job.SourceRepo); err == nil {
		counts["pull_requests"] = len(pulls)
	}
	if releases, err := r.client.releases(ctx, r.job.SourceOwner, r.job.SourceRepo); err == nil {
		counts["releases"] = len(releases)
	}
	if branches, err := r.client.protectedBranches(ctx, r.job.SourceOwner, r.job.SourceRepo); err == nil {
		counts["protected_branches"] = len(branches)
	}
	if hooks, err := r.client.webhooks(ctx, r.job.SourceOwner, r.job.SourceRepo); err == nil {
		counts["webhooks"] = len(hooks)
	}
	if teams, err := r.client.repoTeams(ctx, r.job.SourceOwner, r.job.SourceRepo); err == nil {
		counts["teams"] = len(teams)
	}
	inventory["counts"] = counts
	encoded, err := marshalJSON(inventory)
	if err != nil {
		return err
	}
	return r.s.DBForCtx(ctx).Model(&db.ImportJob{}).Where("id = ?", r.job.ID).Update("inventory_json", db.LargeText(encoded)).Error
}

func (r *githubImportRunner) ensureTargetRepository(ctx context.Context, source ghRepository) (db.Repository, error) {
	targetFullName := r.job.TargetOwner + "/" + r.job.TargetRepo
	var target db.Repository
	err := r.s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		owner, err := ensureImportOwnerTx(tx, r.job.TargetOwner)
		if err != nil {
			return err
		}
		now := time.Now().UTC()
		license := ""
		if source.License != nil {
			license = importFirstNonEmpty(source.License.SPDXID, source.License.Key, source.License.Name)
		}
		visibility := "public"
		if source.Private {
			visibility = "private"
		}
		repo := db.Repository{
			Name:                r.job.TargetRepo,
			FullName:            targetFullName,
			Description:         source.Description,
			OwnerID:             owner.ID,
			Private:             source.Private,
			Visibility:          visibility,
			Fork:                source.Fork,
			DefaultBranch:       importFirstNonEmpty(source.DefaultBranch, "main"),
			Language:            source.Language,
			License:             license,
			Archived:            source.Archived,
			Disabled:            source.Disabled,
			IsTemplate:          source.IsTemplate,
			IsMirror:            r.options.MirrorGit,
			HasWiki:             source.HasWiki,
			HasIssues:           source.HasIssues,
			HasProjects:         source.HasProjects,
			HasDownloads:        source.HasDownloads,
			HasDiscussions:      source.HasDiscussions,
			Homepage:            source.Homepage,
			AllowMergeCommit:    source.AllowMergeCommit,
			AllowSquashMerge:    source.AllowSquashMerge,
			AllowRebaseMerge:    source.AllowRebaseMerge,
			AllowAutoMerge:      source.AllowAutoMerge,
			AllowUpdateBranch:   source.AllowUpdateBranch,
			DeleteBranchOnMerge: source.DeleteBranchOnMerge,
			PushedAt:            source.PushedAt,
			CreatedAt:           nonZeroTime(source.CreatedAt, now),
			UpdatedAt:           nonZeroTime(source.UpdatedAt, now),
		}
		if err := tx.Clauses(clause.OnConflict{
			Columns: []clause.Column{{Name: "full_name"}},
			DoUpdates: clause.AssignmentColumns([]string{
				"description", "private", "visibility", "fork", "default_branch", "language",
				"license", "archived", "disabled", "is_template", "is_mirror", "has_wiki",
				"has_issues", "has_projects", "has_downloads", "has_discussions", "homepage",
				"allow_merge_commit", "allow_squash_merge", "allow_rebase_merge", "allow_auto_merge",
				"allow_update_branch", "delete_branch_on_merge", "pushed_at", "updated_at",
			}),
		}).Create(&repo).Error; err != nil {
			return err
		}
		if err := tx.First(&target, "full_name = ?", targetFullName).Error; err != nil {
			return err
		}
		if err := ensureIssuePRNumberCounterTx(tx, target.ID); err != nil {
			return err
		}
		if err := upsertImportMappingTx(tx, r.job.ID, importTypeRepository, strconv.FormatInt(source.ID, 10), source.NodeID, nil, source.HTMLURL, "repository", strconv.FormatUint(uint64(target.ID), 10), nil, r.targetRepoURL(), map[string]any{
			"source_full_name": source.FullName,
			"target_full_name": target.FullName,
		}); err != nil {
			return err
		}
		return tx.Model(&db.ImportJob{}).Where("id = ?", r.job.ID).Update("target_repo_id", target.ID).Error
	})
	if err != nil {
		return db.Repository{}, err
	}
	if r.git == nil {
		return target, fmt.Errorf("git store unavailable")
	}
	if err := r.git.Init(ctx, target.FullName, importFirstNonEmpty(target.DefaultBranch, "main"), false); err != nil {
		return target, fmt.Errorf("init target git repo: %w", err)
	}
	_ = r.git.SetupConfig(ctx, target.FullName, r.baseURL)
	return target, nil
}

func (r *githubImportRunner) mirrorRepositoryGit(ctx context.Context, target db.Repository, source ghRepository) error {
	remote := importFirstNonEmpty(source.CloneURL, "https://github.com/"+r.job.SourceOwner+"/"+r.job.SourceRepo+".git")
	return r.mirrorBareRemote(ctx, target.FullName, remote)
}

func (r *githubImportRunner) importLabels(ctx context.Context, target db.Repository) (int, error) {
	labels, err := r.client.labels(ctx, r.job.SourceOwner, r.job.SourceRepo)
	if err != nil {
		return 0, err
	}
	for _, label := range labels {
		if err := r.s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
			_, err := r.ensureLabelTx(tx, target.ID, label)
			return err
		}); err != nil {
			return 0, err
		}
	}
	return len(labels), nil
}

func (r *githubImportRunner) importMilestones(ctx context.Context, target db.Repository) (int, error) {
	milestones, err := r.client.milestones(ctx, r.job.SourceOwner, r.job.SourceRepo)
	if err != nil {
		return 0, err
	}
	for _, m := range milestones {
		if err := r.s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
			creator, err := r.ensureSourceUserTx(tx, m.Creator)
			if err != nil {
				return err
			}
			row := db.Milestone{
				RepositoryID: target.ID,
				Number:       m.Number,
				Title:        m.Title,
				Description:  valueOrEmpty(m.Description),
				State:        normalizeImportState(m.State),
				CreatorID:    creator.ID,
				DueOn:        m.DueOn,
				CreatedAt:    nonZeroTime(m.CreatedAt, time.Now().UTC()),
				UpdatedAt:    nonZeroTime(m.UpdatedAt, time.Now().UTC()),
				ClosedAt:     m.ClosedAt,
			}
			if err := tx.Clauses(clause.OnConflict{
				Columns:   []clause.Column{{Name: "repository_id"}, {Name: "number"}},
				DoUpdates: clause.AssignmentColumns([]string{"title", "description", "state", "creator_id", "due_on", "updated_at", "closed_at"}),
			}).Create(&row).Error; err != nil {
				return err
			}
			if err := tx.First(&row, "repository_id = ? AND number = ?", target.ID, m.Number).Error; err != nil {
				return err
			}
			return upsertImportMappingTx(tx, r.job.ID, importTypeMilestone, strconv.FormatInt(m.ID, 10), m.NodeID, &m.Number, m.HTMLURL, "milestone", strconv.FormatUint(uint64(row.ID), 10), &row.Number, r.targetIssueURL(row.Number), nil)
		}); err != nil {
			return 0, err
		}
	}
	return len(milestones), nil
}

// importAccess records source authorization as a handoff plan. Historical
// attribution and repository authorization are intentionally separate: a
// trusted source identity proves who wrote history, not that the person should
// receive access to the destination repository today.
func (r *githubImportRunner) importAccess(ctx context.Context) error {
	handoffs := 0
	if r.options.ImportCollaborators {
		collaborators, err := r.client.collaborators(ctx, r.job.SourceOwner, r.job.SourceRepo)
		if err != nil && !errors.Is(err, errGitHubImportNotFound) {
			return err
		}
		for _, collaborator := range collaborators {
			if err := r.s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
				user, err := r.ensureSourceUserTx(tx, collaborator.ghUser())
				if err != nil {
					return err
				}
				permission := collaborator.importPermission()
				metadata := map[string]any{
					"permission": permission,
					"reason":     "requires a separate, explicit destination access grant",
				}
				if user.Status == db.UserStatusActive {
					metadata["matched_ags_login"] = user.Login
				}
				return upsertImportMappingTx(tx, r.job.ID, importTypeCollaborator, collaborator.Login, collaborator.NodeID, nil, collaborator.HTMLURL, "access_handoff", "", nil, "", metadata)
			}); err != nil {
				return err
			}
			handoffs++
		}
	}
	if r.options.ImportTeams {
		teams, err := r.client.repoTeams(ctx, r.job.SourceOwner, r.job.SourceRepo)
		if err != nil && !errors.Is(err, errGitHubImportNotFound) {
			return err
		}
		for _, team := range teams {
			count, err := r.importTeam(ctx, team)
			if err != nil {
				return err
			}
			handoffs += count
		}
	}
	return r.log(ctx, "info", ImportStageTeams, "source access recorded for handoff", map[string]any{"access_handoffs": handoffs})
}

func (r *githubImportRunner) importTeam(ctx context.Context, source ghTeam) (int, error) {
	permission := normalizeImportPermission(source.Permission)
	if err := r.s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		return upsertImportMappingTx(tx, r.job.ID, importTypeTeam, strconv.FormatInt(source.ID, 10), source.NodeID, nil, source.HTMLURL, "access_handoff", "", nil, "", map[string]any{
			"team":       source.Slug,
			"team_name":  importFirstNonEmpty(source.Name, source.Slug),
			"permission": permission,
			"reason":     "requires a separate, explicit destination team and repository access grant",
		})
	}); err != nil {
		return 0, err
	}
	members, err := r.client.teamMembers(ctx, r.job.SourceOwner, source.Slug)
	if err != nil {
		if errors.Is(err, errGitHubImportNotFound) {
			return 1, nil
		}
		return 0, err
	}
	for _, member := range members {
		if err := r.s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
			user, err := r.ensureSourceUserTx(tx, &member)
			if err != nil {
				return err
			}
			sourceID := source.Slug + ":" + member.Login
			metadata := map[string]any{
				"team":       source.Slug,
				"permission": permission,
				"reason":     "requires a separate, explicit destination team membership and repository access grant",
			}
			if user.Status == db.UserStatusActive {
				metadata["matched_ags_login"] = user.Login
			}
			return upsertImportMappingTx(tx, r.job.ID, importTypeTeamMember, sourceID, member.NodeID, nil, member.HTMLURL, "access_handoff", "", nil, "", metadata)
		}); err != nil {
			return 0, err
		}
	}
	return 1 + len(members), nil
}

func (r *githubImportRunner) importIssues(ctx context.Context, target db.Repository) error {
	issues, err := r.client.issues(ctx, r.job.SourceOwner, r.job.SourceRepo)
	if err != nil {
		return err
	}
	imported := 0
	for _, issue := range issues {
		if issue.PullRequest != nil {
			continue
		}
		if err := r.importIssue(ctx, target, issue); err != nil {
			return err
		}
		imported++
		if r.options.ImportIssueComments {
			if err := r.importIssueComments(ctx, target, issue.Number); err != nil {
				return err
			}
		}
	}
	return r.log(ctx, "info", ImportStageIssues, "issues imported", map[string]any{"count": imported})
}

func (r *githubImportRunner) importIssue(ctx context.Context, target db.Repository, source ghIssue) error {
	return r.s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		author, err := r.ensureSourceUserTx(tx, source.User)
		if err != nil {
			return err
		}
		milestoneID, err := r.milestoneIDTx(tx, target.ID, source.Milestone)
		if err != nil {
			return err
		}
		closedBy := ""
		if source.ClosedBy != nil {
			closedBy = source.ClosedBy.Login
			if _, err := r.ensureSourceUserTx(tx, source.ClosedBy); err != nil {
				return err
			}
		}
		issue := db.Issue{
			Number:           source.Number,
			RepositoryID:     target.ID,
			Title:            source.Title,
			Body:             db.LargeText(valueOrEmpty(source.Body)),
			State:            normalizeImportState(source.State),
			AuthorID:         author.ID,
			AssigneeLogins:   joinUserLogins(source.Assignees),
			MilestoneID:      milestoneID,
			Locked:           source.Locked,
			ActiveLockReason: strings.ToUpper(source.ActiveLockReason),
			StateReason:      normalizeImportStateReason(source.StateReason),
			CreatedAt:        nonZeroTime(source.CreatedAt, time.Now().UTC()),
			UpdatedAt:        nonZeroTime(source.UpdatedAt, time.Now().UTC()),
			ClosedAt:         source.ClosedAt,
			ClosedByLogin:    closedBy,
		}
		if err := tx.Clauses(clause.OnConflict{
			Columns: []clause.Column{{Name: "repository_id"}, {Name: "number"}},
			DoUpdates: clause.AssignmentColumns([]string{
				"title", "body", "state", "author_id", "assignee_logins", "milestone_id",
				"locked", "active_lock_reason", "state_reason", "updated_at", "closed_at", "closed_by_login",
			}),
		}).Create(&issue).Error; err != nil {
			return err
		}
		if err := tx.First(&issue, "repository_id = ? AND number = ?", target.ID, source.Number).Error; err != nil {
			return err
		}
		if err := r.replaceIssueLabelsTx(tx, target.ID, issue.ID, source.Labels); err != nil {
			return err
		}
		next := source.Number + 1
		if err := ensureIssuePRCounterAtLeastTx(tx, target.ID, next); err != nil {
			return err
		}
		return upsertImportMappingTx(tx, r.job.ID, importTypeIssue, strconv.FormatInt(source.ID, 10), source.NodeID, &source.Number, source.HTMLURL, "issue", strconv.FormatUint(uint64(issue.ID), 10), &issue.Number, r.targetIssueURL(issue.Number), nil)
	})
}

func (r *githubImportRunner) importIssueComments(ctx context.Context, target db.Repository, number int) error {
	comments, err := r.client.issueComments(ctx, r.job.SourceOwner, r.job.SourceRepo, number)
	if err != nil {
		return err
	}
	for _, source := range comments {
		if err := r.s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
			author, err := r.ensureSourceUserTx(tx, source.User)
			if err != nil {
				return err
			}
			existingID, ok, err := r.mappingTargetIDTx(tx, importTypeIssueComment, strconv.FormatInt(source.ID, 10))
			if err != nil {
				return err
			}
			comment := db.IssueComment{
				RepositoryID: target.ID,
				IssueNumber:  number,
				Body:         db.LargeText(source.Body),
				AuthorID:     author.ID,
				CreatedAt:    nonZeroTime(source.CreatedAt, time.Now().UTC()),
				UpdatedAt:    nonZeroTime(source.UpdatedAt, time.Now().UTC()),
			}
			if ok {
				comment.ID = existingID
				if err := tx.Model(&db.IssueComment{}).Where("id = ?", existingID).Updates(map[string]any{
					"body":       comment.Body,
					"author_id":  comment.AuthorID,
					"updated_at": comment.UpdatedAt,
				}).Error; err != nil {
					return err
				}
			} else if err := tx.Create(&comment).Error; err != nil {
				return err
			}
			return upsertImportMappingTx(tx, r.job.ID, importTypeIssueComment, strconv.FormatInt(source.ID, 10), source.NodeID, &number, source.HTMLURL, "issue_comment", strconv.FormatUint(uint64(comment.ID), 10), nil, r.targetIssueCommentURL(number, comment.ID), nil)
		}); err != nil {
			return err
		}
	}
	return nil
}

func (r *githubImportRunner) importPullRequests(ctx context.Context, target db.Repository) error {
	pulls, err := r.client.pulls(ctx, r.job.SourceOwner, r.job.SourceRepo)
	if err != nil {
		return err
	}
	for _, pull := range pulls {
		if err := r.importPullRequest(ctx, target, pull); err != nil {
			return err
		}
		if r.options.ImportIssueComments {
			if err := r.importIssueComments(ctx, target, pull.Number); err != nil {
				return err
			}
		}
		if r.options.ImportPRReviews {
			if err := r.importPullRequestReviews(ctx, target, pull.Number); err != nil {
				return err
			}
		}
		if r.options.ImportPRReviewComments {
			if err := r.importPullRequestReviewComments(ctx, target, pull.Number); err != nil {
				return err
			}
		}
	}
	if err := r.importPullRequestStacks(ctx, target); err != nil {
		return err
	}
	return r.log(ctx, "info", ImportStagePulls, "pull requests imported", map[string]any{"count": len(pulls)})
}

// importPullRequestStacks runs only after every source PR has a durable import
// mapping. GitHub returns Stack members in bottom-to-top order, and the shared
// writer resolves each source PR through that mapping before creating the AGS
// Stack. A 404 is GitHub's documented capability response for a repository
// without Stacks enabled; it is logged explicitly rather than called an export
// limitation.
func (r *githubImportRunner) importPullRequestStacks(ctx context.Context, target db.Repository) error {
	stacks, err := r.client.pullRequestStacks(ctx, r.job.SourceOwner, r.job.SourceRepo)
	if errors.Is(err, errGitHubImportNotFound) {
		return r.log(ctx, "info", ImportStagePulls, "GitHub Stacks API is not enabled for this repository; no Stack relationships imported", map[string]any{"pull_request_stacks": 0})
	}
	if err != nil {
		return fmt.Errorf("read GitHub pull request stacks: %w", err)
	}
	for _, stack := range stacks {
		if err := r.s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
			_, err := importPullRequestStackTx(tx, r.job, target, stack, r.targetPullRequestStackURL)
			return err
		}); err != nil {
			return fmt.Errorf("import GitHub Stack %d: %w", stack.Number, err)
		}
	}
	return r.log(ctx, "info", ImportStagePulls, "pull request stacks imported", map[string]any{"pull_request_stacks": len(stacks)})
}

func (r *githubImportRunner) importPullRequest(ctx context.Context, target db.Repository, source ghPullRequest) error {
	return r.s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		author, err := r.ensureSourceUserTx(tx, source.User)
		if err != nil {
			return err
		}
		milestoneID, err := r.milestoneIDTx(tx, target.ID, source.Milestone)
		if err != nil {
			return err
		}
		if source.MergedBy != nil {
			if _, err := r.ensureSourceUserTx(tx, source.MergedBy); err != nil {
				return err
			}
		}
		headRepoID := target.ID
		if source.Head.Repo != nil && strings.EqualFold(source.Head.Repo.FullName, r.job.TargetOwner+"/"+r.job.TargetRepo) {
			headRepoID = target.ID
		}
		pr := db.PullRequest{
			Number:              source.Number,
			RepositoryID:        target.ID,
			HeadRepositoryID:    headRepoID,
			Title:               source.Title,
			Body:                db.LargeText(valueOrEmpty(source.Body)),
			State:               normalizeImportState(source.State),
			AuthorID:            author.ID,
			AssigneeLogins:      joinUserLogins(source.Assignees),
			MilestoneID:         milestoneID,
			HeadRef:             source.Head.Ref,
			HeadSHA:             source.Head.SHA,
			BaseRef:             importFirstNonEmpty(source.Base.Ref, target.DefaultBranch),
			BaseSHA:             source.Base.SHA,
			Draft:               source.Draft,
			MaintainerCanModify: source.MaintainerCanModify,
			Merged:              source.Merged,
			MergeCommitSHA:      source.MergeCommitSHA,
			MergedByLogin:       loginOrEmpty(source.MergedBy),
			CreatedAt:           nonZeroTime(source.CreatedAt, time.Now().UTC()),
			UpdatedAt:           nonZeroTime(source.UpdatedAt, time.Now().UTC()),
			ClosedAt:            source.ClosedAt,
			MergedAt:            source.MergedAt,
		}
		if source.Merged {
			pr.State = db.StateClosed
		}
		if err := tx.Clauses(clause.OnConflict{
			Columns: []clause.Column{{Name: "repository_id"}, {Name: "number"}},
			DoUpdates: clause.AssignmentColumns([]string{
				"head_repository_id", "title", "body", "state", "author_id", "assignee_logins",
				"milestone_id", "head_ref", "head_sha", "base_ref", "base_sha", "draft",
				"maintainer_can_modify", "merged", "merge_commit_sha", "merged_by_login",
				"updated_at", "closed_at", "merged_at",
			}),
		}).Create(&pr).Error; err != nil {
			return err
		}
		if err := tx.First(&pr, "repository_id = ? AND number = ?", target.ID, source.Number).Error; err != nil {
			return err
		}
		if err := r.replacePRLabelsTx(tx, target.ID, pr.ID, source.Labels); err != nil {
			return err
		}
		next := source.Number + 1
		if err := ensureIssuePRCounterAtLeastTx(tx, target.ID, next); err != nil {
			return err
		}
		return upsertImportMappingTx(tx, r.job.ID, importTypePullRequest, strconv.FormatInt(source.ID, 10), source.NodeID, &source.Number, source.HTMLURL, "pull_request", strconv.FormatUint(uint64(pr.ID), 10), &pr.Number, r.targetPullURL(pr.Number), map[string]any{
			"source_head_repo": source.Head.repoFullName(),
			"source_base_repo": source.Base.repoFullName(),
		})
	})
}

func (r *githubImportRunner) importPullRequestReviews(ctx context.Context, target db.Repository, number int) error {
	reviews, err := r.client.pullReviews(ctx, r.job.SourceOwner, r.job.SourceRepo, number)
	if err != nil {
		return err
	}
	var pr db.PullRequest
	if err := r.s.DBForCtx(ctx).First(&pr, "repository_id = ? AND number = ?", target.ID, number).Error; err != nil {
		return err
	}
	for _, source := range reviews {
		if err := r.s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
			if _, err := r.ensureSourceUserTx(tx, source.User); err != nil {
				return err
			}
			existingID, ok, err := r.mappingTargetIDTx(tx, importTypePullReview, strconv.FormatInt(source.ID, 10))
			if err != nil {
				return err
			}
			createdAt := source.CreatedAt
			if source.SubmittedAt != nil {
				createdAt = *source.SubmittedAt
			}
			review := db.PullRequestReview{
				PullRequestID: pr.ID,
				AuthorLogin:   loginOrEmpty(source.User),
				State:         normalizeImportReviewState(source.State),
				Body:          db.LargeText(source.Body),
				CommitSHA:     source.CommitID,
				SubmittedAt:   source.SubmittedAt,
				CreatedAt:     nonZeroTime(createdAt, time.Now().UTC()),
				UpdatedAt:     nonZeroTime(source.UpdatedAt, time.Now().UTC()),
			}
			if ok {
				review.ID = existingID
				if err := tx.Model(&db.PullRequestReview{}).Where("id = ?", existingID).Updates(map[string]any{
					"author_login": review.AuthorLogin,
					"state":        review.State,
					"body":         review.Body,
					"commit_sha":   review.CommitSHA,
					"submitted_at": review.SubmittedAt,
					"updated_at":   review.UpdatedAt,
				}).Error; err != nil {
					return err
				}
			} else if err := tx.Create(&review).Error; err != nil {
				return err
			}
			return upsertImportMappingTx(tx, r.job.ID, importTypePullReview, strconv.FormatInt(source.ID, 10), source.NodeID, &number, source.HTMLURL, "pull_request_review", strconv.FormatUint(uint64(review.ID), 10), nil, r.targetPullURL(number)+"#pullrequestreview-"+strconv.FormatUint(uint64(review.ID), 10), nil)
		}); err != nil {
			return err
		}
	}
	return nil
}

func (r *githubImportRunner) importPullRequestReviewComments(ctx context.Context, target db.Repository, number int) error {
	comments, err := r.client.pullReviewComments(ctx, r.job.SourceOwner, r.job.SourceRepo, number)
	if err != nil {
		return err
	}
	var pr db.PullRequest
	if err := r.s.DBForCtx(ctx).First(&pr, "repository_id = ? AND number = ?", target.ID, number).Error; err != nil {
		return err
	}
	for _, source := range comments {
		if err := r.s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
			if _, err := r.ensureSourceUserTx(tx, source.User); err != nil {
				return err
			}
			var reviewID *uint
			if source.PullRequestReviewID != 0 {
				if id, ok, err := r.mappingTargetIDTx(tx, importTypePullReview, strconv.FormatInt(source.PullRequestReviewID, 10)); err != nil {
					return err
				} else if ok {
					reviewID = &id
				}
			}
			existingID, ok, err := r.mappingTargetIDTx(tx, importTypePullReviewComment, strconv.FormatInt(source.ID, 10))
			if err != nil {
				return err
			}
			comment := db.PRReviewComment{
				PullRequestReviewID: reviewID,
				PullRequestID:       pr.ID,
				AuthorLogin:         loginOrEmpty(source.User),
				Body:                db.LargeText(source.Body),
				CommitID:            source.CommitID,
				Path:                source.Path,
				Line:                source.Line,
				OriginalLine:        source.OriginalLine,
				StartLine:           source.StartLine,
				Side:                strings.ToUpper(source.Side),
				SubjectType:         importFirstNonEmpty(strings.ToLower(source.SubjectType), "line"),
				DiffHunk:            source.DiffHunk,
				CreatedAt:           nonZeroTime(source.CreatedAt, time.Now().UTC()),
				UpdatedAt:           nonZeroTime(source.UpdatedAt, time.Now().UTC()),
			}
			if ok {
				comment.ID = existingID
				if err := tx.Model(&db.PRReviewComment{}).Where("id = ?", existingID).Updates(map[string]any{
					"pull_request_review_id": comment.PullRequestReviewID,
					"author_login":           comment.AuthorLogin,
					"body":                   comment.Body,
					"commit_id":              comment.CommitID,
					"path":                   comment.Path,
					"line":                   comment.Line,
					"original_line":          comment.OriginalLine,
					"start_line":             comment.StartLine,
					"side":                   comment.Side,
					"subject_type":           comment.SubjectType,
					"diff_hunk":              comment.DiffHunk,
					"updated_at":             comment.UpdatedAt,
				}).Error; err != nil {
					return err
				}
			} else if err := tx.Create(&comment).Error; err != nil {
				return err
			}
			return upsertImportMappingTx(tx, r.job.ID, importTypePullReviewComment, strconv.FormatInt(source.ID, 10), source.NodeID, &number, source.HTMLURL, "pull_request_review_comment", strconv.FormatUint(uint64(comment.ID), 10), nil, r.targetPullReviewCommentURL(number, comment.ID), map[string]any{
				"source_in_reply_to_id": source.InReplyToID,
			})
		}); err != nil {
			return err
		}
	}
	return r.resolveReviewCommentReplies(ctx, comments)
}

func (r *githubImportRunner) resolveReviewCommentReplies(ctx context.Context, comments []ghReviewComment) error {
	for _, source := range comments {
		if source.InReplyToID == 0 {
			continue
		}
		if err := r.s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
			childID, ok, err := r.mappingTargetIDTx(tx, importTypePullReviewComment, strconv.FormatInt(source.ID, 10))
			if err != nil || !ok {
				return err
			}
			parentID, ok, err := r.mappingTargetIDTx(tx, importTypePullReviewComment, strconv.FormatInt(source.InReplyToID, 10))
			if err != nil || !ok {
				return err
			}
			return tx.Model(&db.PRReviewComment{}).Where("id = ?", childID).Update("in_reply_to_id", parentID).Error
		}); err != nil {
			return err
		}
	}
	return nil
}

func (r *githubImportRunner) importWiki(ctx context.Context, target db.Repository, source ghRepository) error {
	if !source.HasWiki {
		return r.log(ctx, "info", ImportStageWiki, "source repository has wiki disabled", nil)
	}
	wikiRemote := strings.TrimSuffix(importFirstNonEmpty(source.CloneURL, "https://github.com/"+r.job.SourceOwner+"/"+r.job.SourceRepo+".git"), ".git") + ".wiki.git"
	wikiFullName := target.FullName + ".wiki"
	if err := r.mirrorBareRemote(ctx, wikiFullName, wikiRemote); err != nil {
		if strings.Contains(err.Error(), "Repository not found") || strings.Contains(err.Error(), "not found") {
			return r.log(ctx, "warn", ImportStageWiki, "source wiki repository not found", nil)
		}
		return err
	}
	if _, err := r.s.ReconcileWikiV2(ctx, target.FullName); err != nil {
		return fmt.Errorf("reconcile mirrored wiki catalog: %w", err)
	}
	return upsertImportMapping(r.s.DBForCtx(ctx), r.job.ID, importTypeRepository, r.job.SourceOwner+"/"+r.job.SourceRepo+".wiki", "", nil, wikiRemote, "wiki_repository", wikiFullName, nil, r.targetRepoURL()+"/wiki", nil)
}

func (r *githubImportRunner) importReleases(ctx context.Context, target db.Repository) error {
	releases, err := r.client.releases(ctx, r.job.SourceOwner, r.job.SourceRepo)
	if err != nil {
		return err
	}
	for _, source := range releases {
		if err := r.importRelease(ctx, target, source); err != nil {
			return err
		}
	}
	return r.log(ctx, "info", ImportStageReleases, "releases imported", map[string]any{"count": len(releases)})
}

func (r *githubImportRunner) importRelease(ctx context.Context, target db.Repository, source ghRelease) error {
	var release db.Release
	err := r.s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		author, err := r.ensureSourceUserTx(tx, source.Author)
		if err != nil {
			return err
		}
		release = db.Release{
			RepositoryID: target.ID,
			TagName:      source.TagName,
			Name:         source.Name,
			Body:         db.LargeText(source.Body),
			Draft:        source.Draft,
			PreRelease:   source.Prerelease,
			AuthorID:     author.ID,
			CreatedAt:    nonZeroTime(source.CreatedAt, time.Now().UTC()),
			PublishedAt:  source.PublishedAt,
		}
		if err := tx.Clauses(clause.OnConflict{
			Columns:   []clause.Column{{Name: "repository_id"}, {Name: "tag_name"}},
			DoUpdates: clause.AssignmentColumns([]string{"name", "body", "draft", "pre_release", "author_id", "published_at"}),
		}).Create(&release).Error; err != nil {
			return err
		}
		if err := tx.First(&release, "repository_id = ? AND tag_name = ?", target.ID, source.TagName).Error; err != nil {
			return err
		}
		return upsertImportMappingTx(tx, r.job.ID, importTypeRelease, strconv.FormatInt(source.ID, 10), source.NodeID, nil, source.HTMLURL, "release", strconv.FormatUint(uint64(release.ID), 10), nil, r.targetReleaseURL(release.ID), map[string]any{
			"tag_name": source.TagName,
		})
	})
	if err != nil {
		return err
	}
	if !r.options.ImportReleaseAssets {
		return nil
	}
	assets := source.Assets
	if source.AssetsURL != "" {
		assets, err = r.client.releaseAssets(ctx, source.AssetsURL)
		if err != nil {
			return err
		}
	}
	for _, asset := range assets {
		if err := r.importReleaseAsset(ctx, release, asset); err != nil {
			return err
		}
	}
	return nil
}

func (r *githubImportRunner) importReleaseAsset(ctx context.Context, release db.Release, source ghReleaseAsset) error {
	maxBytes := r.options.MaxReleaseAssetBytes
	if maxBytes <= 0 {
		maxBytes = defaultMaxReleaseAssetBytes
	}
	if source.Size > maxBytes {
		return fmt.Errorf("%w: release asset %q exceeds max_release_asset_bytes (%d > %d)", ErrValidation, source.Name, source.Size, maxBytes)
	}
	content, err := r.client.releaseAssetContent(ctx, source.URL, maxBytes)
	if err != nil {
		return err
	}
	return r.s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		existingID, ok, err := r.mappingTargetIDTx(tx, importTypeReleaseAsset, strconv.FormatInt(source.ID, 10))
		if err != nil {
			return err
		}
		asset := db.ReleaseAsset{
			ReleaseID:   release.ID,
			Name:        source.Name,
			Label:       source.Label,
			ContentType: importFirstNonEmpty(source.ContentType, "application/octet-stream"),
			Size:        int64(len(content)),
			Content:     content,
			CreatedAt:   nonZeroTime(source.CreatedAt, time.Now().UTC()),
			UpdatedAt:   nonZeroTime(source.UpdatedAt, time.Now().UTC()),
		}
		if ok {
			asset.ID = existingID
			if err := tx.Model(&db.ReleaseAsset{}).Where("id = ?", existingID).Updates(map[string]any{
				"name":         asset.Name,
				"label":        asset.Label,
				"content_type": asset.ContentType,
				"size":         asset.Size,
				"content":      asset.Content,
				"updated_at":   asset.UpdatedAt,
			}).Error; err != nil {
				return err
			}
		} else if err := tx.Create(&asset).Error; err != nil {
			return err
		}
		return upsertImportMappingTx(tx, r.job.ID, importTypeReleaseAsset, strconv.FormatInt(source.ID, 10), source.NodeID, nil, source.BrowserDownloadURL, "release_asset", strconv.FormatUint(uint64(asset.ID), 10), nil, r.targetReleaseAssetURL(asset.ID), map[string]any{
			"name": source.Name,
			"size": asset.Size,
		})
	})
}

func (r *githubImportRunner) importSettings(ctx context.Context, target db.Repository) error {
	if r.options.ImportBranchProtection {
		if err := r.importBranchProtection(ctx, target); err != nil {
			return err
		}
	}
	if r.options.ImportWebhooks {
		return r.importWebhooks(ctx, target)
	}
	return nil
}

func (r *githubImportRunner) importBranchProtection(ctx context.Context, target db.Repository) error {
	branches, err := r.client.protectedBranches(ctx, r.job.SourceOwner, r.job.SourceRepo)
	if err != nil {
		if errors.Is(err, errGitHubImportNotFound) {
			return nil
		}
		return err
	}
	for _, branch := range branches {
		protection, err := r.client.branchProtection(ctx, r.job.SourceOwner, r.job.SourceRepo, branch.Name)
		if err != nil {
			if errors.Is(err, errGitHubImportNotFound) {
				continue
			}
			return err
		}
		if err := r.s.Transaction(ctx, func(txCtx context.Context, tx *gorm.DB) error {
			bp := db.BranchProtection{
				RepositoryID:             target.ID,
				BranchName:               branch.Name,
				RequiredStatusChecksJSON: mustJSON(protection["required_status_checks"]),
				EnforceAdmins:            protectionEnabled(protection["enforce_admins"]),
				RequiredSignatures:       protectionEnabled(protection["required_signatures"]),
				RequiredPullRequestJSON:  mustJSON(protection["required_pull_request_reviews"]),
				RestrictionsJSON:         mustJSON(protection["restrictions"]),
			}
			if err := r.s.UpdateBranchProtection(txCtx, &bp); err != nil {
				return err
			}
			return upsertImportMappingTx(tx, r.job.ID, importTypeBranchProtection, branch.Name, "", nil, branch.URL, "branch_protection", strconv.FormatUint(uint64(bp.ID), 10), nil, r.targetRepoURL()+"/settings/branches", protection)
		}); err != nil {
			return err
		}
	}
	return r.log(ctx, "info", ImportStageSettings, "branch protections imported", map[string]any{"count": len(branches)})
}

func (r *githubImportRunner) importWebhooks(ctx context.Context, target db.Repository) error {
	hooks, err := r.client.webhooks(ctx, r.job.SourceOwner, r.job.SourceRepo)
	if err != nil {
		if errors.Is(err, errGitHubImportNotFound) {
			return nil
		}
		return err
	}
	for _, source := range hooks {
		if err := r.s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
			existingID, ok, err := r.mappingTargetIDTx(tx, importTypeWebhook, strconv.FormatInt(source.ID, 10))
			if err != nil {
				return err
			}
			config := cloneStringMap(source.Config)
			config["secret"] = ""
			config["import_note"] = "GitHub webhook secret is not readable; configure a new secret before enabling."
			configJSON, err := marshalJSON(config)
			if err != nil {
				return err
			}
			eventsJSON, err := marshalJSON(source.Events)
			if err != nil {
				return err
			}
			hook := db.Webhook{
				RepositoryID: target.ID,
				Name:         importFirstNonEmpty(source.Name, "web"),
				Active:       false,
				EventsJSON:   eventsJSON,
				ConfigJSON:   configJSON,
				CreatedAt:    nonZeroTime(source.CreatedAt, time.Now().UTC()),
				UpdatedAt:    nonZeroTime(source.UpdatedAt, time.Now().UTC()),
			}
			if ok {
				hook.ID = existingID
				if err := tx.Model(&db.Webhook{}).Where("id = ?", existingID).Updates(map[string]any{
					"name":        hook.Name,
					"active":      hook.Active,
					"events_json": hook.EventsJSON,
					"config_json": hook.ConfigJSON,
					"updated_at":  hook.UpdatedAt,
				}).Error; err != nil {
					return err
				}
			} else if err := tx.Create(&hook).Error; err != nil {
				return err
			}
			return upsertImportMappingTx(tx, r.job.ID, importTypeWebhook, strconv.FormatInt(source.ID, 10), "", nil, source.URL, "webhook", strconv.FormatUint(uint64(hook.ID), 10), nil, r.targetRepoURL()+"/settings/hooks", map[string]any{
				"active": false,
				"note":   "imported inactive because GitHub does not expose webhook secrets",
			})
		}); err != nil {
			return err
		}
	}
	return r.log(ctx, "warn", ImportStageSettings, "webhooks imported inactive; secrets must be reconfigured", map[string]any{"count": len(hooks)})
}

func (r *githubImportRunner) importActionsManifest(ctx context.Context, target db.Repository) error {
	if r.options.ImportActionsMetadata {
		workflows, err := r.client.workflows(ctx, r.job.SourceOwner, r.job.SourceRepo)
		if err != nil && !errors.Is(err, errGitHubImportNotFound) {
			return err
		}
		for _, workflow := range workflows {
			if err := r.s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
				row := db.Workflow{
					RepositoryID: target.ID,
					Name:         workflow.Name,
					Path:         workflow.Path,
					State:        normalizeWorkflowState(workflow.State),
					CreatedAt:    nonZeroTime(workflow.CreatedAt, time.Now().UTC()),
					UpdatedAt:    nonZeroTime(workflow.UpdatedAt, time.Now().UTC()),
				}
				if err := tx.Clauses(clause.OnConflict{
					Columns:   []clause.Column{{Name: "repository_id"}, {Name: "path"}},
					DoUpdates: clause.AssignmentColumns([]string{"name", "state", "updated_at"}),
				}).Create(&row).Error; err != nil {
					return err
				}
				if err := tx.First(&row, "repository_id = ? AND path = ?", target.ID, workflow.Path).Error; err != nil {
					return err
				}
				return upsertImportMappingTx(tx, r.job.ID, importTypeWorkflow, strconv.FormatInt(workflow.ID, 10), workflow.NodeID, nil, workflow.HTMLURL, "workflow", strconv.FormatUint(uint64(row.ID), 10), nil, r.targetRepoURL()+"/actions/workflows/"+url.PathEscape(workflow.Path), map[string]any{
					"path": workflow.Path,
				})
			}); err != nil {
				return err
			}
		}
		if err := r.log(ctx, "warn", ImportStageSecrets, "workflow definitions imported; run history is not replayed", map[string]any{"count": len(workflows)}); err != nil {
			return err
		}
	}
	if r.options.ImportSecretNames {
		secrets, err := r.client.secretNames(ctx, r.job.SourceOwner, r.job.SourceRepo)
		if err != nil && !errors.Is(err, errGitHubImportNotFound) {
			return err
		}
		for _, secret := range secrets {
			if err := upsertImportMapping(r.s.DBForCtx(ctx), r.job.ID, importTypeSecretName, secret.Name, "", nil, "", "secret_name", secret.Name, nil, "", map[string]any{
				"name":       secret.Name,
				"created_at": secret.CreatedAt,
				"updated_at": secret.UpdatedAt,
				"note":       "GitHub does not expose secret values; recreate this secret in AGS.",
			}); err != nil {
				return err
			}
		}
		return r.log(ctx, "warn", ImportStageSecrets, "secret names inventoried; values must be recreated", map[string]any{"count": len(secrets)})
	}
	return nil
}

func (r *githubImportRunner) verify(ctx context.Context, target db.Repository) error {
	counts := map[string]int64{}
	tables := []struct {
		name  string
		model any
		where string
		args  []any
	}{
		{"labels", &db.Label{}, "repository_id = ?", []any{target.ID}},
		{"milestones", &db.Milestone{}, "repository_id = ?", []any{target.ID}},
		{"issues", &db.Issue{}, "repository_id = ?", []any{target.ID}},
		{"pull_requests", &db.PullRequest{}, "repository_id = ?", []any{target.ID}},
		{"issue_comments", &db.IssueComment{}, "repository_id = ?", []any{target.ID}},
		{"releases", &db.Release{}, "repository_id = ?", []any{target.ID}},
		{"branch_protections", &db.BranchProtection{}, "repository_id = ?", []any{target.ID}},
		{"webhooks", &db.Webhook{}, "repository_id = ?", []any{target.ID}},
	}
	for _, table := range tables {
		var count int64
		if err := r.s.DBForCtx(ctx).Model(table.model).Where(table.where, table.args...).Count(&count).Error; err != nil {
			return err
		}
		counts[table.name] = count
	}
	return r.log(ctx, "info", ImportStageVerify, "target repository verification counts collected", counts)
}

func (r *githubImportRunner) mirrorBareRemote(ctx context.Context, fullName, remote string) error {
	if r.git == nil {
		return fmt.Errorf("git store unavailable")
	}
	if err := r.git.Init(ctx, fullName, "main", false); err != nil {
		return err
	}
	repoPath, err := r.git.GetRepoPath(ctx, fullName)
	if err != nil {
		return err
	}
	fetchURL := gitRemoteURLWithToken(remote, r.token)
	cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "remote", "remove", "github-import")
	_ = cmd.Run()
	cmd = exec.CommandContext(ctx, "git", "-C", repoPath, "remote", "add", "github-import", fetchURL)
	if out, err := cmd.CombinedOutput(); err != nil {
		return fmt.Errorf("git remote add github-import: %v\n%s", err, sanitizeTokenOutput(string(out), r.token))
	}
	cmd = exec.CommandContext(ctx, "git", "-C", repoPath, "fetch", "--prune", "--tags", "github-import",
		"+refs/heads/*:refs/heads/*",
		"+refs/tags/*:refs/tags/*",
		"+refs/pull/*/head:refs/pull/*/head",
	)
	if out, err := cmd.CombinedOutput(); err != nil {
		return fmt.Errorf("git fetch github-import: %v\n%s", err, sanitizeTokenOutput(string(out), r.token))
	}
	if err := setMirroredRemoteHEAD(ctx, repoPath, r.token); err != nil {
		return err
	}
	_ = r.git.SetupConfig(ctx, fullName, r.baseURL)
	return nil
}

// setMirroredRemoteHEAD preserves the source remote's advertised default
// branch after a ref-only fetch. Git fetch intentionally does not update a
// bare repository's symbolic HEAD, leaving a newly initialized repository
// pointed at its local default branch (usually main). That breaks imported
// repositories whose source default is another branch, including GitHub wikis
// which conventionally use master.
func setMirroredRemoteHEAD(ctx context.Context, repoPath, token string) error {
	cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "ls-remote", "--symref", "github-import", "HEAD")
	out, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("git resolve github-import default branch: %v\n%s", err, sanitizeTokenOutput(string(out), token))
	}

	branch := ""
	for _, line := range strings.Split(string(out), "\n") {
		fields := strings.SplitN(line, "\t", 2)
		if len(fields) != 2 || fields[1] != "HEAD" || !strings.HasPrefix(fields[0], "ref: refs/heads/") {
			continue
		}
		branch = strings.TrimPrefix(fields[0], "ref: refs/heads/")
		break
	}
	// An empty remote has no default branch and therefore no valid HEAD to set.
	if branch == "" {
		return nil
	}

	ref := "refs/heads/" + branch
	cmd = exec.CommandContext(ctx, "git", "-C", repoPath, "rev-parse", "--verify", ref)
	if out, err := cmd.CombinedOutput(); err != nil {
		return fmt.Errorf("git verify mirrored default branch %s: %v\n%s", ref, err, sanitizeTokenOutput(string(out), token))
	}
	cmd = exec.CommandContext(ctx, "git", "-C", repoPath, "symbolic-ref", "HEAD", ref)
	if out, err := cmd.CombinedOutput(); err != nil {
		return fmt.Errorf("git set mirrored default branch %s: %v\n%s", ref, err, sanitizeTokenOutput(string(out), token))
	}
	return nil
}

func (r *githubImportRunner) ensureSourceUserTx(tx *gorm.DB, source *ghUser) (db.User, error) {
	users, err := r.ensureSourceUsersTx(tx, []*ghUser{source})
	if err != nil {
		return db.User{}, err
	}
	login := importSourceLogin(source)
	user, ok := users[login]
	if !ok {
		return db.User{}, fmt.Errorf("%w: source user %q was not created", ErrInvalidState, login)
	}
	return user, nil
}

func (r *githubImportRunner) ensureLabelTx(tx *gorm.DB, repoID uint, source ghLabel) (db.Label, error) {
	label := db.Label{
		RepositoryID: repoID,
		Name:         source.Name,
		Color:        normalizeHexColor(source.Color),
		Description:  source.Description,
		Default:      source.Default,
	}
	if err := tx.Clauses(clause.OnConflict{
		Columns:   []clause.Column{{Name: "repository_id"}, {Name: "name"}},
		DoUpdates: clause.AssignmentColumns([]string{"color", "description", "default", "updated_at"}),
	}).Create(&label).Error; err != nil {
		return db.Label{}, err
	}
	if err := tx.First(&label, "repository_id = ? AND name = ?", repoID, source.Name).Error; err != nil {
		return db.Label{}, err
	}
	return label, upsertImportMappingTx(tx, r.job.ID, importTypeLabel, strconv.FormatInt(source.ID, 10), source.NodeID, nil, source.URL, "label", strconv.FormatUint(uint64(label.ID), 10), nil, r.targetRepoURL()+"/labels/"+url.PathEscape(label.Name), nil)
}

func (r *githubImportRunner) replaceIssueLabelsTx(tx *gorm.DB, repoID, issueID uint, sourceLabels []ghLabel) error {
	if err := tx.Exec("DELETE FROM issue_labels WHERE issue_id = ?", issueID).Error; err != nil {
		return err
	}
	for _, source := range sourceLabels {
		label, err := r.ensureLabelTx(tx, repoID, source)
		if err != nil {
			return err
		}
		if err := tx.Exec("INSERT INTO issue_labels (issue_id, label_id) VALUES (?, ?)", issueID, label.ID).Error; err != nil && !isDuplicateErr(err) {
			return err
		}
	}
	return nil
}

func (r *githubImportRunner) replacePRLabelsTx(tx *gorm.DB, repoID, prID uint, sourceLabels []ghLabel) error {
	if err := tx.Exec("DELETE FROM pr_labels WHERE pull_request_id = ?", prID).Error; err != nil {
		return err
	}
	for _, source := range sourceLabels {
		label, err := r.ensureLabelTx(tx, repoID, source)
		if err != nil {
			return err
		}
		if err := tx.Exec("INSERT INTO pr_labels (pull_request_id, label_id) VALUES (?, ?)", prID, label.ID).Error; err != nil && !isDuplicateErr(err) {
			return err
		}
	}
	return nil
}

func (r *githubImportRunner) milestoneIDTx(tx *gorm.DB, repoID uint, source *ghMilestone) (*uint, error) {
	if source == nil || source.Number == 0 {
		return nil, nil
	}
	var row db.Milestone
	if err := tx.Select("id").First(&row, "repository_id = ? AND number = ?", repoID, source.Number).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil
		}
		return nil, err
	}
	return &row.ID, nil
}

func (r *githubImportRunner) mappingTargetIDTx(tx *gorm.DB, sourceType, sourceID string) (uint, bool, error) {
	var mapping db.ImportItemMapping
	if err := tx.Select("target_id").First(&mapping, "import_job_id = ? AND source_type = ? AND source_id = ?", r.job.ID, sourceType, sourceID).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return 0, false, nil
		}
		return 0, false, err
	}
	id, err := strconv.ParseUint(mapping.TargetID, 10, 64)
	if err != nil || id == 0 {
		return 0, false, nil
	}
	return uint(id), true, nil
}

func (r *githubImportRunner) targetRepoURL() string {
	return strings.TrimRight(r.htmlBaseURL, "/") + "/" + r.job.TargetOwner + "/" + r.job.TargetRepo
}

func (r *githubImportRunner) targetIssueURL(number int) string {
	return r.targetRepoURL() + "/issues/" + strconv.Itoa(number)
}

func (r *githubImportRunner) targetIssueCommentURL(number int, commentID uint) string {
	return r.targetIssueURL(number) + "#issuecomment-" + strconv.FormatUint(uint64(commentID), 10)
}

func (r *githubImportRunner) targetPullURL(number int) string {
	return r.targetRepoURL() + "/pull/" + strconv.Itoa(number)
}

func (r *githubImportRunner) targetPullRequestStackURL(number int) string {
	return r.targetRepoURL() + "/stacks/" + strconv.Itoa(number)
}

func (r *githubImportRunner) targetPullReviewCommentURL(number int, commentID uint) string {
	return r.targetPullURL(number) + "#discussion_r" + strconv.FormatUint(uint64(commentID), 10)
}

func (r *githubImportRunner) targetReleaseURL(id uint) string {
	return r.targetRepoURL() + "/releases/" + strconv.FormatUint(uint64(id), 10)
}

func (r *githubImportRunner) targetReleaseAssetURL(id uint) string {
	return strings.TrimRight(r.baseURL, "/") + "/api/v3/repos/" + r.job.TargetOwner + "/" + r.job.TargetRepo + "/releases/assets/" + strconv.FormatUint(uint64(id), 10)
}

func (r *githubImportRunner) targetUserURL(login string) string {
	return strings.TrimRight(r.htmlBaseURL, "/") + "/" + login
}

type githubImportClient struct {
	baseURL string
	token   string
	client  *http.Client
}

func newGitHubImportClient(baseURL, token string) *githubImportClient {
	if strings.TrimSpace(baseURL) == "" {
		baseURL = "https://api.github.com"
	}
	return &githubImportClient{
		baseURL: strings.TrimRight(baseURL, "/"),
		token:   strings.TrimSpace(token),
		client:  &http.Client{Timeout: 60 * time.Second},
	}
}

func (c *githubImportClient) repo(ctx context.Context, owner, repo string) (ghRepository, error) {
	var out ghRepository
	return out, c.get(ctx, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo), nil, &out)
}

func (c *githubImportClient) labels(ctx context.Context, owner, repo string) ([]ghLabel, error) {
	return githubListAll[ghLabel](c, ctx, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/labels", nil)
}

func (c *githubImportClient) milestones(ctx context.Context, owner, repo string) ([]ghMilestone, error) {
	q := url.Values{"state": {"all"}, "sort": {"due_on"}, "direction": {"asc"}}
	return githubListAll[ghMilestone](c, ctx, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/milestones", q)
}

func (c *githubImportClient) issues(ctx context.Context, owner, repo string) ([]ghIssue, error) {
	q := url.Values{"state": {"all"}, "sort": {"created"}, "direction": {"asc"}}
	return githubListAll[ghIssue](c, ctx, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/issues", q)
}

func (c *githubImportClient) issueComments(ctx context.Context, owner, repo string, number int) ([]ghIssueComment, error) {
	return githubListAll[ghIssueComment](c, ctx, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/issues/"+strconv.Itoa(number)+"/comments", nil)
}

func (c *githubImportClient) pulls(ctx context.Context, owner, repo string) ([]ghPullRequest, error) {
	q := url.Values{"state": {"all"}, "sort": {"created"}, "direction": {"asc"}}
	return githubListAll[ghPullRequest](c, ctx, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/pulls", q)
}

func (c *githubImportClient) pullRequestStacks(ctx context.Context, owner, repo string) ([]ghPullRequestStack, error) {
	return githubListAllWithAPIVersion[ghPullRequestStack](c, ctx, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/stacks", nil, githubStacksAPIVersion)
}

func (c *githubImportClient) pullReviews(ctx context.Context, owner, repo string, number int) ([]ghReview, error) {
	return githubListAll[ghReview](c, ctx, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/pulls/"+strconv.Itoa(number)+"/reviews", nil)
}

func (c *githubImportClient) pullReviewComments(ctx context.Context, owner, repo string, number int) ([]ghReviewComment, error) {
	return githubListAll[ghReviewComment](c, ctx, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/pulls/"+strconv.Itoa(number)+"/comments", nil)
}

func (c *githubImportClient) releases(ctx context.Context, owner, repo string) ([]ghRelease, error) {
	return githubListAll[ghRelease](c, ctx, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/releases", nil)
}

func (c *githubImportClient) releaseAssets(ctx context.Context, assetsURL string) ([]ghReleaseAsset, error) {
	return githubListAllURL[ghReleaseAsset](c, ctx, assetsURL, nil)
}

func (c *githubImportClient) releaseAssetContent(ctx context.Context, apiURL string, maxBytes int64) ([]byte, error) {
	body, err := c.getBytes(ctx, apiURL, "application/octet-stream", maxBytes)
	if err != nil {
		return nil, err
	}
	return body, nil
}

func (c *githubImportClient) protectedBranches(ctx context.Context, owner, repo string) ([]ghBranch, error) {
	q := url.Values{"protected": {"true"}}
	return githubListAll[ghBranch](c, ctx, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/branches", q)
}

func (c *githubImportClient) branchProtection(ctx context.Context, owner, repo, branch string) (map[string]any, error) {
	var out map[string]any
	return out, c.get(ctx, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/branches/"+url.PathEscape(branch)+"/protection", nil, &out)
}

func (c *githubImportClient) webhooks(ctx context.Context, owner, repo string) ([]ghHook, error) {
	return githubListAll[ghHook](c, ctx, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/hooks", nil)
}

func (c *githubImportClient) collaborators(ctx context.Context, owner, repo string) ([]ghCollaborator, error) {
	q := url.Values{"affiliation": {"all"}}
	return githubListAll[ghCollaborator](c, ctx, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/collaborators", q)
}

func (c *githubImportClient) repoTeams(ctx context.Context, owner, repo string) ([]ghTeam, error) {
	return githubListAll[ghTeam](c, ctx, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/teams", nil)
}

func (c *githubImportClient) teamMembers(ctx context.Context, org, slug string) ([]ghUser, error) {
	return githubListAll[ghUser](c, ctx, "/orgs/"+url.PathEscape(org)+"/teams/"+url.PathEscape(slug)+"/members", nil)
}

func (c *githubImportClient) workflows(ctx context.Context, owner, repo string) ([]ghWorkflow, error) {
	var out struct {
		TotalCount int          `json:"total_count"`
		Workflows  []ghWorkflow `json:"workflows"`
	}
	if err := c.get(ctx, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/actions/workflows", nil, &out); err != nil {
		return nil, err
	}
	return out.Workflows, nil
}

func (c *githubImportClient) secretNames(ctx context.Context, owner, repo string) ([]ghSecretName, error) {
	var out struct {
		TotalCount int            `json:"total_count"`
		Secrets    []ghSecretName `json:"secrets"`
	}
	if err := c.get(ctx, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/actions/secrets", nil, &out); err != nil {
		return nil, err
	}
	return out.Secrets, nil
}

func githubListAll[T any](c *githubImportClient, ctx context.Context, path string, q url.Values) ([]T, error) {
	return githubListAllWithAPIVersion[T](c, ctx, path, q, "")
}

func githubListAllWithAPIVersion[T any](c *githubImportClient, ctx context.Context, path string, q url.Values, apiVersion string) ([]T, error) {
	return githubListAllURLWithAPIVersion[T](c, ctx, c.endpoint(path, q), nil, apiVersion)
}

func githubListAllURL[T any](c *githubImportClient, ctx context.Context, endpoint string, q url.Values) ([]T, error) {
	return githubListAllURLWithAPIVersion[T](c, ctx, endpoint, q, "")
}

func githubListAllURLWithAPIVersion[T any](c *githubImportClient, ctx context.Context, endpoint string, q url.Values, apiVersion string) ([]T, error) {
	if q == nil {
		q = url.Values{}
	}
	q.Set("per_page", "100")
	if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
		endpoint = c.endpoint(endpoint, q)
	} else if len(q) > 0 {
		u, err := url.Parse(endpoint)
		if err != nil {
			return nil, err
		}
		values := u.Query()
		for k, items := range q {
			for _, item := range items {
				values.Add(k, item)
			}
		}
		u.RawQuery = values.Encode()
		endpoint = u.String()
	}
	var all []T
	for endpoint != "" {
		req, err := c.newRequest(ctx, http.MethodGet, endpoint, nil, "application/vnd.github+json")
		if err != nil {
			return nil, err
		}
		if apiVersion != "" {
			req.Header.Set("X-GitHub-Api-Version", apiVersion)
		}
		resp, err := c.client.Do(req)
		if err != nil {
			return nil, err
		}
		var page []T
		if err := decodeGitHubResponse(resp, &page); err != nil {
			return nil, err
		}
		all = append(all, page...)
		endpoint = nextLink(resp.Header.Get("Link"))
	}
	return all, nil
}

func (c *githubImportClient) get(ctx context.Context, path string, q url.Values, out any) error {
	endpoint := c.endpoint(path, q)
	if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
		endpoint = path
	}
	req, err := c.newRequest(ctx, http.MethodGet, endpoint, nil, "application/vnd.github+json")
	if err != nil {
		return err
	}
	resp, err := c.client.Do(req)
	if err != nil {
		return err
	}
	return decodeGitHubResponse(resp, out)
}

func (c *githubImportClient) getBytes(ctx context.Context, endpoint, accept string, maxBytes int64) ([]byte, error) {
	req, err := c.newRequest(ctx, http.MethodGet, endpoint, nil, accept)
	if err != nil {
		return nil, err
	}
	resp, err := c.client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode == http.StatusNotFound {
		return nil, errGitHubImportNotFound
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		return nil, fmt.Errorf("github API %s: %s", resp.Status, string(body))
	}
	reader := io.LimitReader(resp.Body, maxBytes+1)
	data, err := io.ReadAll(reader)
	if err != nil {
		return nil, err
	}
	if int64(len(data)) > maxBytes {
		return nil, fmt.Errorf("%w: release asset exceeds %d bytes", ErrValidation, maxBytes)
	}
	return data, nil
}

func (c *githubImportClient) endpoint(path string, q url.Values) string {
	endpoint := c.baseURL + path
	if len(q) > 0 {
		endpoint += "?" + q.Encode()
	}
	return endpoint
}

func (c *githubImportClient) newRequest(ctx context.Context, method, endpoint string, body []byte, accept string) (*http.Request, error) {
	var reader io.Reader
	if body != nil {
		reader = bytes.NewReader(body)
	}
	req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
	if err != nil {
		return nil, err
	}
	if c.token != "" {
		req.Header.Set("Authorization", "Bearer "+c.token)
	}
	req.Header.Set("Accept", accept)
	req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
	req.Header.Set("User-Agent", "agent-git-service-importer")
	return req, nil
}

func decodeGitHubResponse(resp *http.Response, out any) error {
	defer resp.Body.Close()
	if resp.StatusCode == http.StatusNotFound {
		io.Copy(io.Discard, resp.Body)
		return errGitHubImportNotFound
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		return fmt.Errorf("github API %s: %s", resp.Status, string(body))
	}
	return json.NewDecoder(resp.Body).Decode(out)
}

func nextLink(header string) string {
	for _, part := range strings.Split(header, ",") {
		segments := strings.Split(part, ";")
		if len(segments) < 2 {
			continue
		}
		link := strings.TrimSpace(segments[0])
		if !strings.HasPrefix(link, "<") || !strings.HasSuffix(link, ">") {
			continue
		}
		for _, segment := range segments[1:] {
			if strings.TrimSpace(segment) == `rel="next"` {
				return strings.TrimSuffix(strings.TrimPrefix(link, "<"), ">")
			}
		}
	}
	return ""
}

type ghRepository struct {
	ID                  int64      `json:"id"`
	NodeID              string     `json:"node_id"`
	Name                string     `json:"name"`
	FullName            string     `json:"full_name"`
	Description         string     `json:"description"`
	Private             bool       `json:"private"`
	Fork                bool       `json:"fork"`
	HTMLURL             string     `json:"html_url"`
	CloneURL            string     `json:"clone_url"`
	DefaultBranch       string     `json:"default_branch"`
	Language            string     `json:"language"`
	Archived            bool       `json:"archived"`
	Disabled            bool       `json:"disabled"`
	IsTemplate          bool       `json:"is_template"`
	HasWiki             bool       `json:"has_wiki"`
	HasIssues           bool       `json:"has_issues"`
	HasProjects         bool       `json:"has_projects"`
	HasDownloads        bool       `json:"has_downloads"`
	HasDiscussions      bool       `json:"has_discussions"`
	Homepage            string     `json:"homepage"`
	AllowMergeCommit    bool       `json:"allow_merge_commit"`
	AllowSquashMerge    bool       `json:"allow_squash_merge"`
	AllowRebaseMerge    bool       `json:"allow_rebase_merge"`
	AllowAutoMerge      bool       `json:"allow_auto_merge"`
	AllowUpdateBranch   bool       `json:"allow_update_branch"`
	DeleteBranchOnMerge bool       `json:"delete_branch_on_merge"`
	Owner               *ghUser    `json:"owner"`
	License             *ghLicense `json:"license"`
	CreatedAt           time.Time  `json:"created_at"`
	UpdatedAt           time.Time  `json:"updated_at"`
	PushedAt            *time.Time `json:"pushed_at"`
}

type ghLicense struct {
	Key    string `json:"key"`
	SPDXID string `json:"spdx_id"`
	Name   string `json:"name"`
}

type ghUser struct {
	ID      int64  `json:"id"`
	NodeID  string `json:"node_id"`
	Login   string `json:"login"`
	Type    string `json:"type"`
	HTMLURL string `json:"html_url"`
}

type ghLabel struct {
	ID          int64  `json:"id"`
	NodeID      string `json:"node_id"`
	URL         string `json:"url"`
	Name        string `json:"name"`
	Color       string `json:"color"`
	Description string `json:"description"`
	Default     bool   `json:"default"`
}

type ghMilestone struct {
	ID          int64      `json:"id"`
	NodeID      string     `json:"node_id"`
	Number      int        `json:"number"`
	Title       string     `json:"title"`
	Description *string    `json:"description"`
	State       string     `json:"state"`
	Creator     *ghUser    `json:"creator"`
	HTMLURL     string     `json:"html_url"`
	DueOn       *time.Time `json:"due_on"`
	CreatedAt   time.Time  `json:"created_at"`
	UpdatedAt   time.Time  `json:"updated_at"`
	ClosedAt    *time.Time `json:"closed_at"`
}

type ghIssue struct {
	ID               int64           `json:"id"`
	NodeID           string          `json:"node_id"`
	Number           int             `json:"number"`
	Title            string          `json:"title"`
	Body             *string         `json:"body"`
	State            string          `json:"state"`
	User             *ghUser         `json:"user"`
	Labels           []ghLabel       `json:"labels"`
	Assignees        []ghUser        `json:"assignees"`
	Milestone        *ghMilestone    `json:"milestone"`
	Locked           bool            `json:"locked"`
	ActiveLockReason string          `json:"active_lock_reason"`
	StateReason      string          `json:"state_reason"`
	HTMLURL          string          `json:"html_url"`
	PullRequest      *map[string]any `json:"pull_request"`
	CreatedAt        time.Time       `json:"created_at"`
	UpdatedAt        time.Time       `json:"updated_at"`
	ClosedAt         *time.Time      `json:"closed_at"`
	ClosedBy         *ghUser         `json:"closed_by"`
}

type ghIssueComment struct {
	ID        int64     `json:"id"`
	NodeID    string    `json:"node_id"`
	HTMLURL   string    `json:"html_url"`
	Body      string    `json:"body"`
	User      *ghUser   `json:"user"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

type ghPullRequest struct {
	ID                  int64        `json:"id"`
	NodeID              string       `json:"node_id"`
	Number              int          `json:"number"`
	State               string       `json:"state"`
	Title               string       `json:"title"`
	Body                *string      `json:"body"`
	User                *ghUser      `json:"user"`
	Labels              []ghLabel    `json:"labels"`
	Assignees           []ghUser     `json:"assignees"`
	Milestone           *ghMilestone `json:"milestone"`
	Head                ghPullRef    `json:"head"`
	Base                ghPullRef    `json:"base"`
	Draft               bool         `json:"draft"`
	MaintainerCanModify bool         `json:"maintainer_can_modify"`
	Merged              bool         `json:"merged"`
	MergeCommitSHA      string       `json:"merge_commit_sha"`
	MergedBy            *ghUser      `json:"merged_by"`
	HTMLURL             string       `json:"html_url"`
	CreatedAt           time.Time    `json:"created_at"`
	UpdatedAt           time.Time    `json:"updated_at"`
	ClosedAt            *time.Time   `json:"closed_at"`
	MergedAt            *time.Time   `json:"merged_at"`
}

// ghPullRequestStack follows GitHub's public-preview Stacks REST resource.
// PullRequests is deliberately not sorted here: GitHub guarantees this list
// is bottom-to-top and that order is the Stack relationship we must preserve.
type ghPullRequestStack struct {
	ID           int64                  `json:"id"`
	NodeID       string                 `json:"node_id"`
	Number       int                    `json:"number"`
	URL          string                 `json:"url"`
	Base         ghPullRequestStackBase `json:"base"`
	Open         bool                   `json:"open"`
	CreatedAt    time.Time              `json:"created_at"`
	PullRequests []ghPullRequestStackPR `json:"pull_requests"`
}

type ghPullRequestStackBase struct {
	Ref string `json:"ref"`
	SHA string `json:"sha"`
}

type ghPullRequestStackPR struct {
	Number   int        `json:"number"`
	State    string     `json:"state"`
	Draft    bool       `json:"draft"`
	MergedAt *time.Time `json:"merged_at"`
	Head     ghPullRef  `json:"head"`
}

type ghPullRef struct {
	Ref  string        `json:"ref"`
	SHA  string        `json:"sha"`
	Repo *ghRepository `json:"repo"`
}

func (r ghPullRef) repoFullName() string {
	if r.Repo == nil {
		return ""
	}
	return r.Repo.FullName
}

type ghReview struct {
	ID          int64      `json:"id"`
	NodeID      string     `json:"node_id"`
	User        *ghUser    `json:"user"`
	Body        string     `json:"body"`
	State       string     `json:"state"`
	CommitID    string     `json:"commit_id"`
	HTMLURL     string     `json:"html_url"`
	SubmittedAt *time.Time `json:"submitted_at"`
	CreatedAt   time.Time  `json:"created_at"`
	UpdatedAt   time.Time  `json:"updated_at"`
}

type ghReviewComment struct {
	ID                  int64     `json:"id"`
	NodeID              string    `json:"node_id"`
	PullRequestReviewID int64     `json:"pull_request_review_id"`
	InReplyToID         int64     `json:"in_reply_to_id"`
	User                *ghUser   `json:"user"`
	Body                string    `json:"body"`
	CommitID            string    `json:"commit_id"`
	Path                string    `json:"path"`
	Line                int       `json:"line"`
	OriginalLine        int       `json:"original_line"`
	StartLine           *int      `json:"start_line"`
	Side                string    `json:"side"`
	SubjectType         string    `json:"subject_type"`
	DiffHunk            string    `json:"diff_hunk"`
	HTMLURL             string    `json:"html_url"`
	CreatedAt           time.Time `json:"created_at"`
	UpdatedAt           time.Time `json:"updated_at"`
}

type ghRelease struct {
	ID           int64            `json:"id"`
	NodeID       string           `json:"node_id"`
	TagName      string           `json:"tag_name"`
	TargetCommit string           `json:"target_commitish"`
	Name         string           `json:"name"`
	Body         string           `json:"body"`
	Draft        bool             `json:"draft"`
	Prerelease   bool             `json:"prerelease"`
	Author       *ghUser          `json:"author"`
	HTMLURL      string           `json:"html_url"`
	AssetsURL    string           `json:"assets_url"`
	Assets       []ghReleaseAsset `json:"assets"`
	CreatedAt    time.Time        `json:"created_at"`
	PublishedAt  *time.Time       `json:"published_at"`
}

type ghReleaseAsset struct {
	ID                 int64     `json:"id"`
	NodeID             string    `json:"node_id"`
	Name               string    `json:"name"`
	Label              string    `json:"label"`
	ContentType        string    `json:"content_type"`
	Size               int64     `json:"size"`
	URL                string    `json:"url"`
	BrowserDownloadURL string    `json:"browser_download_url"`
	CreatedAt          time.Time `json:"created_at"`
	UpdatedAt          time.Time `json:"updated_at"`
}

type ghBranch struct {
	Name      string `json:"name"`
	URL       string `json:"url"`
	Protected bool   `json:"protected"`
}

type ghHook struct {
	ID        int64             `json:"id"`
	Name      string            `json:"name"`
	Active    bool              `json:"active"`
	Events    []string          `json:"events"`
	Config    map[string]string `json:"config"`
	URL       string            `json:"url"`
	CreatedAt time.Time         `json:"created_at"`
	UpdatedAt time.Time         `json:"updated_at"`
}

type ghCollaborator struct {
	ID          int64           `json:"id"`
	NodeID      string          `json:"node_id"`
	Login       string          `json:"login"`
	Type        string          `json:"type"`
	HTMLURL     string          `json:"html_url"`
	Permissions map[string]bool `json:"permissions"`
	RoleName    string          `json:"role_name"`
}

func (c ghCollaborator) ghUser() *ghUser {
	return &ghUser{ID: c.ID, NodeID: c.NodeID, Login: c.Login, Type: c.Type, HTMLURL: c.HTMLURL}
}

func (c ghCollaborator) importPermission() string {
	if c.Permissions["admin"] || c.RoleName == "admin" {
		return "admin"
	}
	if c.Permissions["maintain"] || c.Permissions["push"] || c.RoleName == "maintain" || c.RoleName == "write" {
		return "write"
	}
	return "read"
}

type ghTeam struct {
	ID          int64  `json:"id"`
	NodeID      string `json:"node_id"`
	Name        string `json:"name"`
	Slug        string `json:"slug"`
	Description string `json:"description"`
	Privacy     string `json:"privacy"`
	Permission  string `json:"permission"`
	HTMLURL     string `json:"html_url"`
}

type ghWorkflow struct {
	ID        int64     `json:"id"`
	NodeID    string    `json:"node_id"`
	Name      string    `json:"name"`
	Path      string    `json:"path"`
	State     string    `json:"state"`
	HTMLURL   string    `json:"html_url"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

type ghSecretName struct {
	Name      string    `json:"name"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

func normalizeGitHubImportOptions(options *GitHubImportOptions) {
	if options.MaxReleaseAssetBytes <= 0 {
		options.MaxReleaseAssetBytes = defaultMaxReleaseAssetBytes
	}
}

func ensureImportOwnerTx(tx *gorm.DB, login string) (db.User, error) {
	login = strings.TrimSpace(login)
	var owner db.User
	if err := tx.First(&owner, "login = ?", login).Error; err != nil {
		if !errors.Is(err, gorm.ErrRecordNotFound) {
			return db.User{}, err
		}
		owner = db.User{
			Login:     login,
			Name:      login,
			Type:      db.TypeOrganization,
			Status:    db.UserStatusActive,
			UserKind:  db.UserKindHuman,
			CreatedAt: time.Now().UTC(),
			UpdatedAt: time.Now().UTC(),
		}
		if err := tx.Create(&owner).Error; err != nil {
			return db.User{}, err
		}
	}
	return owner, nil
}

func ensureIssuePRCounterAtLeastTx(tx *gorm.DB, repoID uint, next int) error {
	if err := ensureIssuePRNumberCounterTx(tx, repoID); err != nil {
		return err
	}
	now := time.Now().UTC()
	return tx.Model(&db.IssuePRNumberCounter{}).
		Where("repository_id = ? AND next_number < ?", repoID, next).
		Updates(map[string]any{"next_number": next, "updated_at": now}).Error
}

func upsertImportMapping(tx *gorm.DB, jobID uint, sourceType, sourceID, sourceNodeID string, sourceNumber *int, sourceURL, targetType, targetID string, targetNumber *int, targetURL string, metadata any) error {
	return upsertImportMappingTx(tx, jobID, sourceType, sourceID, sourceNodeID, sourceNumber, sourceURL, targetType, targetID, targetNumber, targetURL, metadata)
}

func upsertImportMappingTx(tx *gorm.DB, jobID uint, sourceType, sourceID, sourceNodeID string, sourceNumber *int, sourceURL, targetType, targetID string, targetNumber *int, targetURL string, metadata any) error {
	metadataJSON := ""
	if metadata != nil {
		encoded, err := marshalJSON(metadata)
		if err != nil {
			return err
		}
		metadataJSON = encoded
	}
	mapping := db.ImportItemMapping{
		ImportJobID:  jobID,
		SourceType:   sourceType,
		SourceID:     sourceID,
		SourceNodeID: sourceNodeID,
		SourceNumber: sourceNumber,
		SourceURL:    sourceURL,
		TargetType:   targetType,
		TargetID:     targetID,
		TargetNumber: targetNumber,
		TargetURL:    targetURL,
		MetadataJSON: db.LargeText(metadataJSON),
	}
	return tx.Clauses(clause.OnConflict{
		Columns: []clause.Column{{Name: "import_job_id"}, {Name: "source_type"}, {Name: "source_id"}},
		DoUpdates: clause.AssignmentColumns([]string{
			"source_node_id", "source_number", "source_url", "target_type", "target_id", "target_number", "target_url", "metadata_json", "updated_at",
		}),
	}).Create(&mapping).Error
}

func gitRemoteURLWithToken(raw, token string) string {
	if strings.TrimSpace(token) == "" {
		return raw
	}
	u, err := url.Parse(raw)
	if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
		return raw
	}
	u.User = url.UserPassword("x-access-token", token)
	return u.String()
}

func sanitizeTokenOutput(out, token string) string {
	if token == "" {
		return out
	}
	return strings.ReplaceAll(out, token, "[redacted]")
}

func normalizeImportState(raw string) string {
	if strings.EqualFold(raw, db.StateClosed) {
		return db.StateClosed
	}
	return db.StateOpen
}

func normalizeImportStateReason(raw string) string {
	switch strings.ToLower(strings.TrimSpace(raw)) {
	case "not_planned":
		return db.StateReasonNotPlanned
	case "reopened":
		return db.StateReasonReopened
	case "completed":
		return db.StateReasonCompleted
	default:
		return ""
	}
}

func normalizeImportReviewState(raw string) string {
	switch strings.ToUpper(strings.TrimSpace(raw)) {
	case db.ReviewApproved, db.ReviewChangesRequested, db.ReviewCommented, db.ReviewDismissed, db.ReviewPending:
		return strings.ToUpper(strings.TrimSpace(raw))
	case "APPROVE":
		return db.ReviewApproved
	case "REQUEST_CHANGES":
		return db.ReviewChangesRequested
	case "COMMENT":
		return db.ReviewCommented
	case "DISMISS":
		return db.ReviewDismissed
	default:
		return db.ReviewCommented
	}
}

func normalizeImportPermission(raw string) string {
	switch strings.ToLower(strings.TrimSpace(raw)) {
	case "admin":
		return "admin"
	case "maintain", "write", "push":
		return "write"
	default:
		return "read"
	}
}

func normalizeWorkflowState(raw string) string {
	switch strings.ToLower(strings.TrimSpace(raw)) {
	case db.WorkflowDisabled:
		return db.WorkflowDisabled
	default:
		return db.WorkflowActive
	}
}

func importUserType(source *ghUser) string {
	if source != nil && strings.EqualFold(source.Type, db.TypeOrganization) {
		return db.TypeOrganization
	}
	return db.TypeUser
}

func typeOrEmpty(source *ghUser) string {
	if source == nil {
		return ""
	}
	return source.Type
}

func normalizeHexColor(color string) string {
	color = strings.TrimPrefix(strings.TrimSpace(color), "#")
	if len(color) == 6 {
		return color
	}
	return "ededed"
}

func importFirstNonEmpty(values ...string) string {
	for _, value := range values {
		if strings.TrimSpace(value) != "" {
			return value
		}
	}
	return ""
}

func valueOrEmpty(v *string) string {
	if v == nil {
		return ""
	}
	return *v
}

func loginOrEmpty(u *ghUser) string {
	if u == nil {
		return ""
	}
	return u.Login
}

func joinUserLogins(users []ghUser) string {
	if len(users) == 0 {
		return ""
	}
	logins := make([]string, 0, len(users))
	for _, user := range users {
		if user.Login != "" {
			logins = append(logins, user.Login)
		}
	}
	sort.Strings(logins)
	return strings.Join(logins, ",")
}

func nonZeroTime(value time.Time, fallback time.Time) time.Time {
	if value.IsZero() {
		return fallback
	}
	return value
}

func firstTimePtr(values ...*time.Time) *time.Time {
	for _, value := range values {
		if value != nil && !value.IsZero() {
			return value
		}
	}
	return nil
}

func mustJSON(v any) string {
	if v == nil {
		return ""
	}
	data, err := json.Marshal(v)
	if err != nil {
		return ""
	}
	return string(data)
}

func protectionEnabled(v any) bool {
	m, ok := v.(map[string]any)
	if !ok {
		return false
	}
	enabled, _ := m["enabled"].(bool)
	return enabled
}

func cloneStringMap(in map[string]string) map[string]string {
	out := make(map[string]string, len(in)+1)
	for k, v := range in {
		out[k] = v
	}
	return out
}
