package main

import (
	"context"
	"encoding/csv"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"testing"
	"time"
)

func TestMigrationPlanRejectsDuplicateTargets(t *testing.T) {
	plan := migrationPlan{
		Source: migrationPlanSource{TokenEnv: "GITHUB_TOKEN"},
		Target: migrationPlanTarget{APIURL: "https://ags.test", TokenEnv: "AGS_TOKEN"},
		Repositories: []migrationPlanRepo{
			{Source: "source/one", Target: "ags/shared"},
			{Source: "source/two", Target: "ags/shared"},
		},
	}
	if err := normalizeAndValidateMigrationPlan(&plan); err == nil || !strings.Contains(err.Error(), "duplicate target") {
		t.Fatalf("expected duplicate target error, got %v", err)
	}
}

func TestMigrationPlanRejectsCaseInsensitiveDuplicateSource(t *testing.T) {
	plan := migrationPlan{
		Source: migrationPlanSource{TokenEnv: "GITHUB_TOKEN"},
		Target: migrationPlanTarget{APIURL: "https://ags.test", TokenEnv: "AGS_TOKEN"},
		Repositories: []migrationPlanRepo{
			{Source: "Source/Repo", Target: "ags/one"},
			{Source: "source/repo", Target: "ags/two"},
		},
	}
	if err := normalizeAndValidateMigrationPlan(&plan); err == nil || !strings.Contains(err.Error(), "duplicate source") {
		t.Fatalf("expected duplicate source error, got %v", err)
	}
}

func TestMigrationSnapshotObjectsIncludesAccessHandoffsOnlyWhenRequested(t *testing.T) {
	withHandoff := migrationSnapshotObjects(migrationPlanRepo{Mode: "core", Access: "handoff"}, nil)
	if !migrationSnapshotObjectSelected(withHandoff, "access_handoffs") {
		t.Fatalf("handoff plan objects = %v, want access_handoffs", withHandoff)
	}
	withoutHandoff := migrationSnapshotObjects(migrationPlanRepo{Mode: "core", Access: "skip"}, nil)
	if migrationSnapshotObjectSelected(withoutHandoff, "access_handoffs") {
		t.Fatalf("skip plan objects = %v, must not include access_handoffs", withoutHandoff)
	}
	full := migrationSnapshotObjects(migrationPlanRepo{Mode: "full", Access: "handoff"}, nil)
	count := 0
	for _, objectType := range full {
		if objectType == "access_handoffs" {
			count++
		}
	}
	if count != 1 {
		t.Fatalf("full plan access_handoffs count = %d, want 1", count)
	}
}

func TestMigrationPlanRunRequiresSourceFreezeConfirmation(t *testing.T) {
	path := filepath.Join(t.TempDir(), "migration.yaml")
	plan := "version: 1\nsource:\n  token_env: GITHUB_TOKEN\ntarget:\n  api_url: https://ags.test\n  token_env: AGS_TOKEN\nrepositories:\n  - source: source/repo\n    target: ags/repo\n"
	if err := os.WriteFile(path, []byte(plan), 0o600); err != nil {
		t.Fatalf("write plan: %v", err)
	}
	if err := migrationPlanRun(context.Background(), []string{"--file", path}); err == nil || !strings.Contains(err.Error(), "confirm-source-frozen") {
		t.Fatalf("expected source freeze confirmation error, got %v", err)
	}
}

func TestReportFileNameDisambiguatesAmbiguousRepositoryNames(t *testing.T) {
	first := reportFileName("foo-bar/baz")
	second := reportFileName("foo/bar-baz")
	if first == second {
		t.Fatalf("ambiguous repository names collide: %q", first)
	}
}

func TestValidateMigrationSnapshotSourceRejectsCrossRepositoryReuse(t *testing.T) {
	dir := t.TempDir()
	if err := writeManifest(dir, snapshotManifest{SchemaVersion: 1, SourceRepo: "source/one"}); err != nil {
		t.Fatalf("write manifest: %v", err)
	}
	if err := validateMigrationSnapshotSource(dir, "source/two"); err == nil || !strings.Contains(err.Error(), "does not match") {
		t.Fatalf("expected snapshot mismatch, got %v", err)
	}
}

func TestMigrationPreflightInventoryDigestChangesWithSourceData(t *testing.T) {
	base := migrationPreflight{SourceUpdatedAt: time.Unix(100, 0).UTC(), Counts: map[string]int{"issues": 1}, Bytes: map[string]int64{"release_assets": 10}}
	first, err := migrationPreflightInventoryDigest(base)
	if err != nil {
		t.Fatalf("first digest: %v", err)
	}
	base.Counts["issues"] = 2
	second, err := migrationPreflightInventoryDigest(base)
	if err != nil {
		t.Fatalf("second digest: %v", err)
	}
	if first == second {
		t.Fatal("source inventory change did not change its freeze digest")
	}
}

func TestMigrationPreflightProbesGitHubStacksCapability(t *testing.T) {
	stackStatus := http.StatusOK
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		switch r.URL.Path {
		case "/repos/source/repo":
			_, _ = w.Write([]byte(`{"has_wiki":false,"size":1,"updated_at":"2026-08-06T00:00:00Z","default_branch":"main"}`))
		case "/repos/source/repo/stacks":
			if got := r.Header.Get("X-GitHub-Api-Version"); got != githubStacksAPIVersion {
				t.Fatalf("Stack API version = %q, want %q", got, githubStacksAPIVersion)
			}
			if stackStatus != http.StatusOK {
				w.WriteHeader(stackStatus)
				return
			}
			_, _ = w.Write([]byte(`[{"id":1,"number":1,"pull_requests":[{"number":1},{"number":2}]}]`))
		default:
			_, _ = w.Write([]byte(`[]`))
		}
	}))
	defer server.Close()

	plan := migrationPlan{Source: migrationPlanSource{APIURL: server.URL}, Execution: migrationPlanExecution{ReleaseAssetMaxBytes: defaultMaxAssetBytes}}
	repo := migrationPlanRepo{Source: "source/repo", Target: "ags/repo", Mode: "core", Wiki: "disabled"}
	report, err := preflightMigrationRepository(context.Background(), plan, repo, "token")
	if err != nil {
		t.Fatalf("preflight Stack-enabled repository: %v", err)
	}
	if report.Counts["pull_request_stacks"] != 1 || hasFindingCode(report.Findings, "github_stacks_not_enabled") {
		t.Fatalf("Stack-enabled preflight = %#v", report)
	}

	stackStatus = http.StatusNotFound
	report, err = preflightMigrationRepository(context.Background(), plan, repo, "token")
	if err != nil {
		t.Fatalf("preflight Stack-disabled repository: %v", err)
	}
	if report.Counts["pull_request_stacks"] != 0 || !hasFindingCode(report.Findings, "github_stacks_not_enabled") {
		t.Fatalf("Stack-disabled preflight = %#v", report)
	}

	stackStatus = http.StatusServiceUnavailable
	report, err = preflightMigrationRepository(context.Background(), plan, repo, "token")
	if err != nil {
		t.Fatalf("preflight Stack-unreadable repository: %v", err)
	}
	if !hasFindingCode(report.Findings, "github_stacks_unreadable") || !hasBlockingFindings(report.Findings) {
		t.Fatalf("Stack-unreadable preflight = %#v", report)
	}

	repo.StackAPI = "skip_if_unreadable"
	report, err = preflightMigrationRepository(context.Background(), plan, repo, "token")
	if err != nil {
		t.Fatalf("preflight Stack-unreadable waiver: %v", err)
	}
	if !hasFindingCode(report.Findings, "github_stacks_unreadable_waived") || hasBlockingFindings(report.Findings) {
		t.Fatalf("Stack-unreadable waiver preflight = %#v", report)
	}
	if got := migrationExcludedObjects(repo, report); len(got) != 1 || got[0] != "pull_request_stacks" {
		t.Fatalf("waived Stack exclusions = %#v", got)
	}
}

func TestMigrationSourceFenceBlocksSourceDrift(t *testing.T) {
	issueCount := 0
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		switch r.URL.Path {
		case "/repos/source/repo":
			_, _ = w.Write([]byte(`{"has_wiki":false,"size":1,"updated_at":"2026-08-05T00:00:00Z","pushed_at":"2026-08-05T00:00:00Z","default_branch":"main"}`))
		case "/repos/source/repo/issues":
			if issueCount == 0 {
				_, _ = w.Write([]byte(`[]`))
				return
			}
			_, _ = w.Write([]byte(`[{"id":1,"number":1,"body":"issue"}]`))
		case "/repos/source/repo/git/matching-refs/":
			_, _ = w.Write([]byte(`[{"ref":"refs/heads/main","object":{"sha":"abc"}}]`))
		case "/repos/source/repo/actions/workflows":
			_, _ = w.Write([]byte(`{"workflows":[]}`))
		case "/repos/source/repo/actions/secrets":
			_, _ = w.Write([]byte(`{"secrets":[]}`))
		default:
			_, _ = w.Write([]byte(`[]`))
		}
	}))
	defer server.Close()

	plan := migrationPlan{Source: migrationPlanSource{APIURL: server.URL}, Execution: migrationPlanExecution{ReleaseAssetMaxBytes: defaultMaxAssetBytes}}
	repo := migrationPlanRepo{Source: "source/repo", Target: "ags/repo", Mode: "core", Wiki: "disabled"}
	fence, err := captureMigrationSourceFence(context.Background(), plan, repo, "token")
	if err != nil {
		t.Fatalf("capture source fence: %v", err)
	}
	contentDigest, err := migrationSourceContentDigest(context.Background(), plan, repo, "token", fence.ExcludedObjects)
	if err != nil {
		t.Fatalf("capture source content fence: %v", err)
	}
	fence.ContentDigest = contentDigest
	issueCount = 1
	findings, err := verifyMigrationSourceFence(context.Background(), plan, repo, "token", fence)
	if err != nil {
		t.Fatalf("verify source fence: %v", err)
	}
	if !hasFindingCode(findings, "source_changed_during_cutover") {
		t.Fatalf("source drift was not blocked: %#v", findings)
	}
}

func TestMigrationSourceFenceBlocksContentOnlyDrift(t *testing.T) {
	issueBody := "before"
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		switch r.URL.Path {
		case "/repos/source/repo":
			_, _ = w.Write([]byte(`{"has_wiki":false,"size":1,"updated_at":"2026-08-05T00:00:00Z","pushed_at":"2026-08-05T00:00:00Z","default_branch":"main"}`))
		case "/repos/source/repo/issues":
			_, _ = w.Write([]byte(`[{"id":1,"number":1,"body":` + strconv.Quote(issueBody) + `}]`))
		case "/repos/source/repo/git/matching-refs/":
			_, _ = w.Write([]byte(`[{"ref":"refs/heads/main","object":{"sha":"abc"}}]`))
		case "/repos/source/repo/actions/workflows":
			_, _ = w.Write([]byte(`{"workflows":[]}`))
		case "/repos/source/repo/actions/secrets":
			_, _ = w.Write([]byte(`{"secrets":[]}`))
		default:
			_, _ = w.Write([]byte(`[]`))
		}
	}))
	defer server.Close()

	plan := migrationPlan{Source: migrationPlanSource{APIURL: server.URL}, Execution: migrationPlanExecution{ReleaseAssetMaxBytes: defaultMaxAssetBytes}}
	repo := migrationPlanRepo{Source: "source/repo", Target: "ags/repo", Mode: "core", Wiki: "disabled"}
	fence, err := captureMigrationSourceFence(context.Background(), plan, repo, "token")
	if err != nil {
		t.Fatalf("capture source fence: %v", err)
	}
	fence.ContentDigest, err = migrationSourceContentDigest(context.Background(), plan, repo, "token", fence.ExcludedObjects)
	if err != nil {
		t.Fatalf("capture source content fence: %v", err)
	}

	issueBody = "after"
	findings, err := verifyMigrationSourceFence(context.Background(), plan, repo, "token", fence)
	if err != nil {
		t.Fatalf("verify source fence: %v", err)
	}
	if !hasFindingCode(findings, "source_changed_during_cutover") {
		t.Fatalf("content-only source drift was not blocked: %#v", findings)
	}
}

func TestMigrationVerificationFindingsBlockPendingAndFailedWrites(t *testing.T) {
	findings := migrationVerificationFindings(migrationImportVerification{
		PendingWrites: map[string]int{"issues": 2},
		FailedWrites:  map[string]int{"release_assets": 1},
	})
	if !hasFindingCode(findings, "staged_import_pending_writes") || !hasFindingCode(findings, "staged_import_failed_writes") {
		t.Fatalf("verification findings did not block incomplete import: %#v", findings)
	}
}

func TestMigrationSnapshotContentDigestMatchesAssetFingerprint(t *testing.T) {
	dir := t.TempDir()
	snapshotPayload := json.RawMessage(`{"id":1,"name":"asset.tar.gz","release_id":9,"content_base64":"Ynl0ZXM=","content_sha256":"sha256:277089d91c0bdf4f2e6862cd1a3b4f422ad66d5f9582df7492c7d5b2a0b0db94"}`)
	shards, err := writeSnapshotShards(dir, "release_assets", []json.RawMessage{snapshotPayload}, 10, defaultMaxShardBytes)
	if err != nil {
		t.Fatalf("write snapshot shard: %v", err)
	}
	if err := writeManifest(dir, snapshotManifest{SchemaVersion: 1, SourceRepo: "source/repo", Shards: shards}); err != nil {
		t.Fatalf("write manifest: %v", err)
	}
	snapshotDigest, err := migrationSnapshotContentDigest(dir)
	if err != nil {
		t.Fatalf("snapshot digest: %v", err)
	}
	fingerprintDigest, err := migrationPayloadDigest(map[string][]json.RawMessage{
		"release_assets": {json.RawMessage(`{"id":1,"name":"asset.tar.gz","release_id":9,"content_sha256":"sha256:277089d91c0bdf4f2e6862cd1a3b4f422ad66d5f9582df7492c7d5b2a0b0db94"}`)},
	})
	if err != nil {
		t.Fatalf("fingerprint digest: %v", err)
	}
	if snapshotDigest != fingerprintDigest {
		t.Fatalf("snapshot digest = %s, fingerprint digest = %s", snapshotDigest, fingerprintDigest)
	}
}

func TestMigrationPlanReportBlocksLegacySuccessWithoutSourceFence(t *testing.T) {
	dir := t.TempDir()
	planPath := filepath.Join(dir, "migration.yaml")
	statePath := filepath.Join(dir, "state.json")
	reportDir := filepath.Join(dir, "reports")
	planYAML := "version: 1\nsource:\n  token_env: GITHUB_TOKEN\ntarget:\n  api_url: https://ags.test\n  token_env: AGS_TOKEN\nrepositories:\n  - source: source/repo\n    target: ags/repo\n"
	if err := os.WriteFile(planPath, []byte(planYAML), 0o600); err != nil {
		t.Fatalf("write plan: %v", err)
	}
	state := migrationPlanState{Version: 1, Repositories: map[string]*migrationRepoState{
		"source/repo->ags/repo": {Source: "source/repo", Target: "ags/repo", Status: "succeeded"},
	}}
	data, err := json.Marshal(state)
	if err != nil {
		t.Fatalf("marshal state: %v", err)
	}
	if err := os.WriteFile(statePath, data, 0o600); err != nil {
		t.Fatalf("write state: %v", err)
	}
	t.Setenv("AGS_TOKEN", "")
	if err := migrationPlanReport([]string{"--file", planPath, "--state", statePath, "--out", reportDir}); err != nil {
		t.Fatalf("migration plan report: %v", err)
	}
	var report migrationRepoReport
	reportData, err := os.ReadFile(filepath.Join(reportDir, reportFileName("source/repo")+".json"))
	if err != nil {
		t.Fatalf("read report: %v", err)
	}
	if err := json.Unmarshal(reportData, &report); err != nil {
		t.Fatalf("decode report: %v", err)
	}
	if report.CutoverReady || !hasFindingCode(report.Findings, "source_freeze_unverified") {
		t.Fatalf("legacy success was cutover-ready: %#v", report)
	}
}

func TestSucceededLegacyStateWithoutContentFenceIsRejected(t *testing.T) {
	repo := migrationPlanRepo{Source: "source/repo", Target: "ags/repo"}
	state := &migrationPlanState{Repositories: map[string]*migrationRepoState{
		migrationPlanRepoKey(repo): {
			Source:      repo.Source,
			Target:      repo.Target,
			Status:      "succeeded",
			SourceFence: &migrationSourceFence{GitRefsDigest: "sha256:refs", InventoryDigest: "sha256:inventory"},
		},
	}}
	var mu sync.Mutex
	err := runPlannedRepository(context.Background(), migrationPlan{}, repo, state, filepath.Join(t.TempDir(), "state.json"), t.TempDir(), "source", "target", false, &mu)
	if err == nil || !strings.Contains(err.Error(), "content fence") {
		t.Fatalf("legacy success without content fence was accepted: %v", err)
	}
}

func TestMigrationPlanReportBlocksTargetRecoveryRequired(t *testing.T) {
	dir := t.TempDir()
	planPath := filepath.Join(dir, "migration.yaml")
	statePath := filepath.Join(dir, "state.json")
	reportDir := filepath.Join(dir, "reports")
	planYAML := "version: 1\nsource:\n  token_env: GITHUB_TOKEN\ntarget:\n  api_url: https://ags.test\n  token_env: AGS_TOKEN\nrepositories:\n  - source: source/repo\n    target: ags/repo\n"
	if err := os.WriteFile(planPath, []byte(planYAML), 0o600); err != nil {
		t.Fatalf("write plan: %v", err)
	}
	state := migrationPlanState{Version: 1, Repositories: map[string]*migrationRepoState{
		"source/repo->ags/repo": {Source: "source/repo", Target: "ags/repo", Status: "failed", RecoveryRequired: "target may contain partial metadata"},
	}}
	data, err := json.Marshal(state)
	if err != nil {
		t.Fatalf("marshal state: %v", err)
	}
	if err := os.WriteFile(statePath, data, 0o600); err != nil {
		t.Fatalf("write state: %v", err)
	}
	t.Setenv("AGS_TOKEN", "")
	if err := migrationPlanReport([]string{"--file", planPath, "--state", statePath, "--out", reportDir}); err != nil {
		t.Fatalf("migration plan report: %v", err)
	}
	var report migrationRepoReport
	reportData, err := os.ReadFile(filepath.Join(reportDir, reportFileName("source/repo")+".json"))
	if err != nil {
		t.Fatalf("read report: %v", err)
	}
	if err := json.Unmarshal(reportData, &report); err != nil {
		t.Fatalf("decode report: %v", err)
	}
	if report.CutoverReady || !hasFindingCode(report.Findings, "target_reset_required") {
		t.Fatalf("recovery-required target was cutover-ready: %#v", report)
	}
}

func TestWriteMigrationReportMarkdownIncludesStackVerification(t *testing.T) {
	path := filepath.Join(t.TempDir(), "summary.md")
	report := migrationRepoReport{
		Source: "source/repo", Target: "ags/repo", Status: "failed",
		StackVerification: &migrationStackVerification{
			SourceCount: 1, TargetCount: 1, MappingCount: 1,
			Checks: []migrationStackCheck{{SourceNumber: 42, SourceURL: "https://github.com/source/repo/stacks/42", TargetNumber: 9, TargetURL: "https://ags.test/ags/repo/stacks/9", Status: "member_order_mismatch"}},
		},
	}
	if err := writeMigrationReportMarkdown(path, []migrationRepoReport{report}); err != nil {
		t.Fatalf("write report: %v", err)
	}
	contents, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read report: %v", err)
	}
	text := string(contents)
	if !strings.Contains(text, "Stack verification: source 1, target 1, mappings 1, verified 0") || !strings.Contains(text, "[source Stack #42](<https://github.com/source/repo/stacks/42>)") || !strings.Contains(text, "[target Stack #9](<https://ags.test/ags/repo/stacks/9>)") {
		t.Fatalf("Stack report markdown = %q", text)
	}
}

func hasFindingCode(findings []migrationFinding, code string) bool {
	for _, finding := range findings {
		if finding.Code == code {
			return true
		}
	}
	return false
}

func TestListMigrationSourceUsersPaginates(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/api/ext/v1/imports/42/source-users" {
			t.Fatalf("unexpected path %s", r.URL.Path)
		}
		after := r.URL.Query().Get("after_id")
		w.Header().Set("Content-Type", "application/json")
		switch after {
		case "0":
			_, _ = w.Write([]byte(`[{"id":1,"import_id":42,"source_login":"one","source_id":"101"},{"id":2,"import_id":42,"source_login":"two","source_id":"102"}]`))
		case "2":
			_, _ = w.Write([]byte(`[{"id":3,"import_id":42,"source_login":"three","source_id":"103"}]`))
		case "3":
			_, _ = w.Write([]byte(`[]`))
		default:
			t.Fatalf("unexpected cursor %q", after)
		}
	}))
	defer server.Close()

	users, err := listMigrationSourceUsers(context.Background(), server.URL, "token", 42)
	if err != nil {
		t.Fatalf("listMigrationSourceUsers: %v", err)
	}
	if len(users) != 3 || users[2].SourceLogin != "three" {
		t.Fatalf("unexpected users: %+v", users)
	}
}

func TestMigrationPlanReportRefreshesIdentitySummary(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/api/ext/v1/imports/42/source-users" {
			t.Fatalf("unexpected path %s", r.URL.Path)
		}
		w.Header().Set("Content-Type", "application/json")
		switch r.URL.Query().Get("after_id") {
		case "0":
			_, _ = w.Write([]byte(`[{"id":1,"import_id":42,"source_login":"claimed","source_id":"101","state":"claimed"},{"id":2,"import_id":42,"source_login":"pending","source_id":"102","state":"pending_claim"}]`))
		case "2":
			_, _ = w.Write([]byte(`[]`))
		default:
			t.Fatalf("unexpected cursor %q", r.URL.Query().Get("after_id"))
		}
	}))
	defer server.Close()

	dir := t.TempDir()
	planPath := filepath.Join(dir, "migration.yaml")
	statePath := filepath.Join(dir, "state.json")
	reportDir := filepath.Join(dir, "reports")
	planYAML := "version: 1\nsource:\n  token_env: GITHUB_TOKEN\ntarget:\n  api_url: " + server.URL + "\n  token_env: AGS_TOKEN\nrepositories:\n  - source: source/repo\n    target: ags/repo\n"
	if err := os.WriteFile(planPath, []byte(planYAML), 0o600); err != nil {
		t.Fatalf("write plan: %v", err)
	}
	state := migrationPlanState{Version: 1, Repositories: map[string]*migrationRepoState{
		"source/repo->ags/repo": {Source: "source/repo", Target: "ags/repo", Status: "succeeded", MetadataImportID: 42},
	}}
	stateData, err := json.Marshal(state)
	if err != nil {
		t.Fatalf("marshal state: %v", err)
	}
	if err := os.WriteFile(statePath, stateData, 0o600); err != nil {
		t.Fatalf("write state: %v", err)
	}

	t.Setenv("AGS_TOKEN", "target-token")
	if err := migrationPlanReport([]string{"--file", planPath, "--state", statePath, "--out", reportDir}); err != nil {
		t.Fatalf("migrationPlanReport: %v", err)
	}
	updated, err := readMigrationState(statePath)
	if err != nil {
		t.Fatalf("read state: %v", err)
	}
	summary := updated.Repositories["source/repo->ags/repo"].IdentitySummary
	if summary == nil || summary.Total != 2 || summary.Claimed != 1 || summary.PendingClaim != 1 {
		t.Fatalf("identity summary = %#v", summary)
	}
}

func TestMigrationPlanApplyIdentitiesBatchesCSV(t *testing.T) {
	var batchSizes []int
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPatch || r.URL.Path != "/api/ext/v1/imports/42/source-users" {
			t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
		}
		var request struct {
			Mappings []json.RawMessage `json:"mappings"`
		}
		if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		batchSizes = append(batchSizes, len(request.Mappings))
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`[]`))
	}))
	defer server.Close()

	dir := t.TempDir()
	planPath := filepath.Join(dir, "migration.yaml")
	statePath := filepath.Join(dir, "state.json")
	csvPath := filepath.Join(dir, "identities.csv")
	planYAML := "version: 1\nsource:\n  token_env: GITHUB_TOKEN\ntarget:\n  api_url: " + server.URL + "\n  token_env: AGS_TOKEN\nrepositories:\n  - source: source/repo\n    target: ags/repo\n"
	if err := os.WriteFile(planPath, []byte(planYAML), 0o600); err != nil {
		t.Fatalf("write plan: %v", err)
	}
	state := migrationPlanState{Version: 1, Repositories: map[string]*migrationRepoState{
		"source/repo->ags/repo": {Source: "source/repo", Target: "ags/repo", MetadataImportID: 42},
	}}
	stateData, err := json.Marshal(state)
	if err != nil {
		t.Fatalf("marshal state: %v", err)
	}
	if err := os.WriteFile(statePath, stateData, 0o600); err != nil {
		t.Fatalf("write state: %v", err)
	}
	file, err := os.Create(csvPath)
	if err != nil {
		t.Fatalf("create CSV: %v", err)
	}
	writer := csv.NewWriter(file)
	if err := writer.Write([]string{"source_repository", "import_id", "source_user_id", "source_login", "github_numeric_id", "target_ags_login", "skip_invitation"}); err != nil {
		t.Fatalf("write CSV header: %v", err)
	}
	for i := 1; i <= 501; i++ {
		if err := writer.Write([]string{"source/repo", "42", strconv.Itoa(i), "user", strconv.Itoa(1000 + i), "ags-user", "false"}); err != nil {
			t.Fatalf("write CSV row: %v", err)
		}
	}
	writer.Flush()
	if err := writer.Error(); err != nil {
		t.Fatalf("flush CSV: %v", err)
	}
	if err := file.Close(); err != nil {
		t.Fatalf("close CSV: %v", err)
	}

	t.Setenv("AGS_TOKEN", "target-token")
	if err := migrationPlanApplyIdentities(context.Background(), []string{"--file", planPath, "--state", statePath, "--csv", csvPath}); err != nil {
		t.Fatalf("migrationPlanApplyIdentities: %v", err)
	}
	if len(batchSizes) != 2 || batchSizes[0] != 500 || batchSizes[1] != 1 {
		t.Fatalf("unexpected batch sizes %v", batchSizes)
	}
}

func TestReadMigrationIdentityCSVRejectsBadBoolean(t *testing.T) {
	path := filepath.Join(t.TempDir(), "identities.csv")
	contents := "source_repository,import_id,source_user_id,target_ags_login,skip_invitation\nsource/repo,42,1,user,not-a-bool\n"
	if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
		t.Fatalf("write CSV: %v", err)
	}
	if _, err := readMigrationIdentityCSV(path); err == nil || !strings.Contains(err.Error(), "skip_invitation") {
		t.Fatalf("expected skip_invitation error, got %v", err)
	}
}

func TestCompareMigrationGitRefsReportsMissingMismatchedAndExtra(t *testing.T) {
	missing, mismatched, extra := compareMigrationGitRefs(
		map[string]string{"refs/heads/main": "aaa", "refs/tags/v1": "bbb"},
		map[string]string{"refs/heads/main": "ccc", "refs/heads/old": "ddd"},
	)
	if got := strings.Join(missing, ","); got != "refs/tags/v1" {
		t.Fatalf("missing = %q", got)
	}
	if got := strings.Join(mismatched, ","); got != "refs/heads/main" {
		t.Fatalf("mismatched = %q", got)
	}
	if got := strings.Join(extra, ","); got != "refs/heads/old" {
		t.Fatalf("extra = %q", got)
	}
}

func TestMigrationComparableGitRefsExcludesEphemeralRefs(t *testing.T) {
	filtered := migrationComparableGitRefs(map[string]string{
		"refs/heads/main":                 "main",
		"refs/pull/42/head":               "pr-head",
		"refs/pull/42/merge":              "preview",
		"refs/remotes/github-import/main": "remote-main",
	})
	if len(filtered) != 2 {
		t.Fatalf("filtered ref count = %d, want 2: %#v", len(filtered), filtered)
	}
	if filtered["refs/heads/main"] != "main" || filtered["refs/pull/42/head"] != "pr-head" {
		t.Fatalf("stable refs missing: %#v", filtered)
	}
}

func TestVerifyMigrationStackImportChecksMembersOrderAndGHWebURL(t *testing.T) {
	dir := t.TempDir()
	shardPath := "pull_request_stacks-000001.ndjson"
	payload := `{"id":9001,"number":42,"url":"https://api.github.test/repos/source/repo/stacks/42","pull_requests":[{"number":7},{"number":8},{"number":9}]}` + "\n"
	if err := os.WriteFile(filepath.Join(dir, shardPath), []byte(payload), 0o600); err != nil {
		t.Fatalf("write Stack shard: %v", err)
	}
	if err := writeManifest(dir, snapshotManifest{SchemaVersion: 1, SourceRepo: "source/repo", Shards: []snapshotShard{{ObjectType: "pull_request_stacks", Path: shardPath, ItemCount: 1}}}); err != nil {
		t.Fatalf("write manifest: %v", err)
	}

	targetMembers := []int{7, 8, 9}
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		switch r.URL.Path {
		case "/api/v3/repos/ags/repo/stacks":
			if got := r.URL.Query().Get("per_page"); got != "100" || r.URL.Query().Get("page") != "1" {
				t.Fatalf("Stack list query = %s", r.URL.RawQuery)
			}
			members := make([]map[string]int, 0, len(targetMembers))
			for _, number := range targetMembers {
				members = append(members, map[string]int{"number": number})
			}
			_ = json.NewEncoder(w).Encode([]map[string]any{{"id": 501, "number": 9, "url": serverURL(r) + "/api/v3/repos/ags/repo/stacks/9", "pull_requests": members}})
		case "/api/ext/v1/imports/77/mapping":
			if got := r.URL.Query().Get("target_type"); got != "pull_request_stack" {
				t.Fatalf("mapping target_type = %q", got)
			}
			_ = json.NewEncoder(w).Encode([]map[string]any{{"id": 1, "source_type": "pull_request_stack", "source_id": "9001", "source_number": 42, "source_url": "https://api.github.test/repos/source/repo/stacks/42", "target_type": "pull_request_stack", "target_id": "501", "target_number": 9, "target_url": serverURL(r) + "/ags/repo/stacks/9"}})
		default:
			t.Fatalf("unexpected request %s", r.URL.String())
		}
	}))
	defer server.Close()

	repo := migrationPlanRepo{Source: "source/repo", Target: "ags/repo"}
	verification, findings, err := verifyMigrationStackImport(context.Background(), server.URL, "token", 77, dir, repo, migrationSourceFence{})
	if err != nil {
		t.Fatalf("verify Stack import: %v", err)
	}
	if len(findings) != 0 || verification.SourceCount != 1 || verification.TargetCount != 1 || verification.MappingCount != 1 || verification.VerifiedCount != 1 {
		t.Fatalf("Stack verification = %#v, findings = %#v", verification, findings)
	}
	check := verification.Checks[0]
	if !check.MembersMatch || !check.OrderMatches || !check.URLMappingMatches || check.Status != "verified" {
		t.Fatalf("Stack check = %#v", check)
	}

	targetMembers = []int{7, 9, 8}
	verification, findings, err = verifyMigrationStackImport(context.Background(), server.URL, "token", 77, dir, repo, migrationSourceFence{})
	if err != nil {
		t.Fatalf("verify Stack import with bad order: %v", err)
	}
	if !hasFindingCode(findings, "stack_verification_failed") || verification.VerifiedCount != 0 || !verification.Checks[0].MembersMatch || verification.Checks[0].OrderMatches {
		t.Fatalf("bad-order Stack verification = %#v, findings = %#v", verification, findings)
	}
}

func TestGetMigrationImportMappingsPaginatesByStableID(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/api/ext/v1/imports/77/mapping" {
			t.Fatalf("unexpected path %s", r.URL.Path)
		}
		if r.URL.Query().Get("target_type") != "pull_request_stack" || r.URL.Query().Get("limit") != "500" {
			t.Fatalf("unexpected query %s", r.URL.RawQuery)
		}
		start := 1
		if after := r.URL.Query().Get("after_id"); after != "" {
			if after != "500" {
				t.Fatalf("after_id = %q, want 500", after)
			}
			start = 501
		}
		mappings := make([]migrationImportMapping, 0, 500)
		for id := start; id <= 501 && len(mappings) < 500; id++ {
			mappings = append(mappings, migrationImportMapping{ID: uint(id), SourceType: "pull_request_stack", SourceID: strconv.Itoa(id), TargetType: "pull_request_stack", TargetID: strconv.Itoa(id)})
		}
		_ = json.NewEncoder(w).Encode(mappings)
	}))
	defer server.Close()

	mappings, err := getMigrationImportMappings(context.Background(), server.URL, "token", 77, "pull_request_stack")
	if err != nil {
		t.Fatalf("get mappings: %v", err)
	}
	if len(mappings) != 501 || mappings[0].ID != 1 || mappings[len(mappings)-1].ID != 501 {
		t.Fatalf("mappings = %d, first/last = %#v/%#v", len(mappings), mappings[0], mappings[len(mappings)-1])
	}
}

func serverURL(r *http.Request) string { return "http://" + r.Host }

func TestPreflightAttachmentURLsCountsIssueAndReviewComments(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		switch r.URL.Path {
		case "/repos/source/repo/issues/comments":
			_, _ = io.WriteString(w, `[{"body":"https://github.com/user-attachments/files/1/a"}]`)
		case "/repos/source/repo/pulls/comments":
			_, _ = io.WriteString(w, `[{"body":"https://user-images.githubusercontent.com/1/a.png"}]`)
		default:
			t.Fatalf("unexpected path %s", r.URL.Path)
		}
	}))
	defer server.Close()

	client := newGitHubClient(server.URL, "")
	issueURLs, issueCount, err := preflightAttachmentURLs(context.Background(), client, "/repos/source/repo/issues/comments")
	if err != nil {
		t.Fatalf("issue attachment scan: %v", err)
	}
	reviewURLs, reviewCount, err := preflightAttachmentURLs(context.Background(), client, "/repos/source/repo/pulls/comments")
	if err != nil {
		t.Fatalf("review attachment scan: %v", err)
	}
	if issueURLs != 1 || reviewURLs != 1 || issueCount != 1 || reviewCount != 1 {
		t.Fatalf("unexpected attachment scan issue=%d/%d review=%d/%d", issueURLs, issueCount, reviewURLs, reviewCount)
	}
}
