package service_test

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"strconv"
	"strings"
	"sync"
	"testing"

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

func setupImportTestService(t testing.TB) (*service.Service, context.Context, func()) {
	t.Helper()
	svc, tenantHandle, cleanup := setupTestService(t)
	return svc, tenantHandle.Context(), cleanup
}

func TestImportBatchStagingIsIdempotent(t *testing.T) {
	svc, ctx, cleanup := setupImportTestService(t)
	defer cleanup()
	job := createImportBatchTestJob(t, svc, ctx)

	in := service.CreateImportBatchInput{
		ImportJobID: job.ID,
		BatchID:     "labels-000001",
		ObjectType:  "labels",
		Items: []service.ImportBatchItemInput{
			{SourceID: "1", SourceNodeID: "L1", PayloadJSON: `{"id":1,"node_id":"L1","name":"bug","color":"d73a4a"}`},
			{SourceID: "2", SourceNodeID: "L2", PayloadJSON: `{"id":2,"node_id":"L2","name":"docs","color":"0075ca"}`},
		},
	}
	first, err := svc.CreateImportBatch(ctx, in)
	if err != nil {
		t.Fatalf("CreateImportBatch first: %v", err)
	}
	second, err := svc.CreateImportBatch(ctx, in)
	if err != nil {
		t.Fatalf("CreateImportBatch duplicate: %v", err)
	}
	if first.ID != second.ID {
		t.Fatalf("duplicate batch created new row: first=%d second=%d", first.ID, second.ID)
	}

	var count int64
	if err := svc.DBForCtx(ctx).Model(&db.ImportStagingItem{}).Where("import_batch_id = ?", first.ID).Count(&count).Error; err != nil {
		t.Fatalf("count staging: %v", err)
	}
	if count != 2 {
		t.Fatalf("staging count = %d, want 2", count)
	}

	in.Items[0].PayloadJSON = `{"id":1,"node_id":"L1","name":"bug","color":"000000"}`
	if _, err := svc.CreateImportBatch(ctx, in); !errors.Is(err, service.ErrConflict) {
		t.Fatalf("CreateImportBatch conflicting duplicate err = %v, want ErrConflict", err)
	}
}

func TestConcurrentApplyClaimsOneBatchWithoutMarkingItFailed(t *testing.T) {
	svc, ctx, cleanup := setupImportTestService(t)
	defer cleanup()
	job := createImportBatchTestJob(t, svc, ctx)
	if _, err := svc.CreateImportBatch(ctx, service.CreateImportBatchInput{
		ImportJobID: job.ID,
		BatchID:     "labels-000001",
		ObjectType:  "labels",
		Items: []service.ImportBatchItemInput{{
			SourceID:    "1",
			PayloadJSON: `{"id":1,"node_id":"L1","name":"bug","color":"d73a4a"}`,
		}},
	}); err != nil {
		t.Fatalf("CreateImportBatch: %v", err)
	}

	var wg sync.WaitGroup
	reports := make([]service.ImportApplyReport, 2)
	errs := make([]error, 2)
	start := make(chan struct{})
	for i := range reports {
		wg.Add(1)
		go func(index int) {
			defer wg.Done()
			<-start
			reports[index], errs[index] = svc.ApplyImportBatches(ctx, job.ID)
		}(i)
	}
	close(start)
	wg.Wait()
	for _, err := range errs {
		if err != nil {
			t.Fatalf("concurrent ApplyImportBatches: %v", err)
		}
	}
	if reports[0].AppliedBatches+reports[1].AppliedBatches != 1 {
		t.Fatalf("applied batch counts = %d + %d, want exactly 1", reports[0].AppliedBatches, reports[1].AppliedBatches)
	}
	var batch db.ImportBatch
	if err := svc.DBForCtx(ctx).Take(&batch, "import_job_id = ? AND batch_id = ?", job.ID, "labels-000001").Error; err != nil {
		t.Fatalf("load import batch: %v", err)
	}
	if batch.Status != "applied" || batch.ErrorMessage != "" {
		t.Fatalf("batch status/error = %q/%q, want applied with no error", batch.Status, batch.ErrorMessage)
	}
}

func TestImportBatchBulkStagingIsIdempotent(t *testing.T) {
	svc, ctx, cleanup := setupImportTestService(t)
	defer cleanup()
	job := createImportBatchTestJob(t, svc, ctx)

	inputs := []service.CreateImportBatchInput{
		{
			ImportJobID: job.ID,
			BatchID:     "labels-000001",
			ObjectType:  "labels",
			Items: []service.ImportBatchItemInput{
				{SourceID: "1", SourceNodeID: "L1", PayloadJSON: `{"id":1,"node_id":"L1","name":"bug","color":"d73a4a"}`},
			},
		},
		{
			ImportJobID: job.ID,
			BatchID:     "milestones-000001",
			ObjectType:  "milestones",
			Items: []service.ImportBatchItemInput{
				{SourceID: "10", SourceNumber: intPtr(1), PayloadJSON: `{"id":10,"node_id":"M1","number":1,"title":"v1"}`},
			},
		},
	}
	first, err := svc.CreateImportBatches(ctx, inputs)
	if err != nil {
		t.Fatalf("CreateImportBatches first: %v", err)
	}
	second, err := svc.CreateImportBatches(ctx, inputs)
	if err != nil {
		t.Fatalf("CreateImportBatches duplicate: %v", err)
	}
	if len(first) != 2 || len(second) != 2 {
		t.Fatalf("bulk batch lengths = %d/%d, want 2/2", len(first), len(second))
	}
	if first[0].ID != second[0].ID || first[1].ID != second[1].ID {
		t.Fatalf("duplicate bulk created new rows: first=%v second=%v", first, second)
	}

	var count int64
	if err := svc.DBForCtx(ctx).Model(&db.ImportStagingItem{}).Where("import_job_id = ?", job.ID).Count(&count).Error; err != nil {
		t.Fatalf("count staging: %v", err)
	}
	if count != 2 {
		t.Fatalf("staging count = %d, want 2", count)
	}
}

func TestApplyImportBatchesLabelsAndMilestones(t *testing.T) {
	svc, ctx, cleanup := setupImportTestService(t)
	defer cleanup()
	job := createImportBatchTestJob(t, svc, ctx)

	if _, err := svc.CreateImportBatch(ctx, service.CreateImportBatchInput{
		ImportJobID: job.ID,
		BatchID:     "labels-000001",
		ObjectType:  "labels",
		Items: []service.ImportBatchItemInput{
			{SourceID: "1", SourceNodeID: "L1", SourceURL: "https://github.test/source/repo/labels/bug", PayloadJSON: `{"id":1,"node_id":"L1","name":"bug","color":"#d73a4a","description":"Bug report","default":true}`},
		},
	}); err != nil {
		t.Fatalf("CreateImportBatch labels: %v", err)
	}
	if _, err := svc.CreateImportBatch(ctx, service.CreateImportBatchInput{
		ImportJobID: job.ID,
		BatchID:     "milestones-000001",
		ObjectType:  "milestones",
		Items: []service.ImportBatchItemInput{
			{SourceID: "10", SourceNumber: intPtr(7), SourceURL: "https://github.test/source/repo/milestone/7", PayloadJSON: `{"id":10,"node_id":"M1","number":7,"title":"v1","state":"closed","description":"first release"}`},
		},
	}); err != nil {
		t.Fatalf("CreateImportBatch milestones: %v", err)
	}

	report, err := svc.ApplyImportBatches(ctx, job.ID)
	if err != nil {
		t.Fatalf("ApplyImportBatches: %v", err)
	}
	if report.AppliedBatches != 2 || report.AppliedItems != 2 {
		t.Fatalf("apply report batches/items = %d/%d, want 2/2", report.AppliedBatches, report.AppliedItems)
	}
	if report.RemainingBatches != 0 || report.RemainingSupportedBatches != 0 {
		t.Fatalf("remaining batches = %d/%d, want 0/0", report.RemainingBatches, report.RemainingSupportedBatches)
	}
	completed, err := svc.GetImportJob(ctx, job.ID)
	if err != nil {
		t.Fatalf("GetImportJob after apply: %v", err)
	}
	if completed.Status != service.ImportStatusSucceeded {
		t.Fatalf("job status after verified apply = %q, want %q", completed.Status, service.ImportStatusSucceeded)
	}
	var reservationCount int64
	if err := svc.DBForCtx(ctx).Model(&db.ImportTargetReservation{}).Where("import_job_id = ?", job.ID).Count(&reservationCount).Error; err != nil {
		t.Fatalf("count completed target reservations: %v", err)
	}
	if reservationCount != 0 {
		t.Fatalf("completed import still holds %d target reservations", reservationCount)
	}

	var label db.Label
	if err := svc.DBForCtx(ctx).Take(&label, "repository_id = ? AND name = ?", *job.TargetRepoID, "bug").Error; err != nil {
		t.Fatalf("label not applied: %v", err)
	}
	if label.Color != "d73a4a" || label.Description != "Bug report" || !label.Default {
		t.Fatalf("label = %#v", label)
	}
	var milestone db.Milestone
	if err := svc.DBForCtx(ctx).Take(&milestone, "repository_id = ? AND number = ?", *job.TargetRepoID, 7).Error; err != nil {
		t.Fatalf("milestone not applied: %v", err)
	}
	if milestone.Title != "v1" || milestone.State != "closed" {
		t.Fatalf("milestone = %#v", milestone)
	}

	verify, err := svc.VerifyImportJob(ctx, job.ID)
	if err != nil {
		t.Fatalf("VerifyImportJob: %v", err)
	}
	if verify.MappingCount != 2 {
		t.Fatalf("mapping count = %d, want 2", verify.MappingCount)
	}
	if len(verify.PendingWrites) != 0 || len(verify.FailedWrites) != 0 {
		t.Fatalf("verify pending=%v failed=%v, want none", verify.PendingWrites, verify.FailedWrites)
	}
}

func TestApplyImportBatchesRecordsAccessHandoffsWithoutGrantingAccess(t *testing.T) {
	svc, ctx, cleanup := setupImportTestService(t)
	defer cleanup()
	job := createImportBatchTestJob(t, svc, ctx)

	if _, err := svc.CreateImportBatch(ctx, service.CreateImportBatchInput{
		ImportJobID: job.ID,
		BatchID:     "access-handoffs-000001",
		ObjectType:  "access_handoffs",
		Items: []service.ImportBatchItemInput{
			{SourceID: "alice", SourceNodeID: "U101", PayloadJSON: `{"kind":"collaborator","source_id":"alice","source_node_id":"U101","source_login":"alice","github_id":101,"source_url":"https://github.test/alice","permission":"write"}`},
			{SourceID: "201", SourceNodeID: "T201", PayloadJSON: `{"kind":"team","source_id":"201","source_node_id":"T201","source_url":"https://github.test/orgs/source/teams/maintainers","team":"maintainers","team_name":"Maintainers","permission":"admin"}`},
			{SourceID: "maintainers:alice", SourceNodeID: "U101", PayloadJSON: `{"kind":"team_member","source_id":"maintainers:alice","source_node_id":"U101","source_login":"alice","github_id":101,"source_url":"https://github.test/alice","team":"maintainers","team_name":"Maintainers","permission":"admin"}`},
		},
	}); err != nil {
		t.Fatalf("CreateImportBatch access handoffs: %v", err)
	}
	if _, err := svc.ApplyImportBatches(ctx, job.ID); err != nil {
		t.Fatalf("ApplyImportBatches access handoffs: %v", err)
	}

	var handoffs []db.ImportItemMapping
	if err := svc.DBForCtx(ctx).Where("import_job_id = ? AND target_type = ?", job.ID, "access_handoff").Order("source_type asc").Find(&handoffs).Error; err != nil {
		t.Fatalf("load handoff mappings: %v", err)
	}
	if len(handoffs) != 3 {
		t.Fatalf("handoff mapping count = %d, want 3", len(handoffs))
	}
	var collaboratorCount, teamCount, teamMemberCount, teamRepositoryCount int64
	for model, count := range map[any]*int64{
		&db.Collaborator{}:   &collaboratorCount,
		&db.Team{}:           &teamCount,
		&db.TeamMember{}:     &teamMemberCount,
		&db.TeamRepository{}: &teamRepositoryCount,
	} {
		if err := svc.DBForCtx(ctx).Model(model).Count(count).Error; err != nil {
			t.Fatalf("count destination access rows: %v", err)
		}
	}
	if collaboratorCount != 0 || teamCount != 0 || teamMemberCount != 0 || teamRepositoryCount != 0 {
		t.Fatalf("source access was granted: collaborators=%d teams=%d members=%d repositories=%d", collaboratorCount, teamCount, teamMemberCount, teamRepositoryCount)
	}
}

func TestApplyImportBatchesHonorsBatchLimit(t *testing.T) {
	svc, ctx, cleanup := setupImportTestService(t)
	defer cleanup()
	job := createImportBatchTestJob(t, svc, ctx)

	if _, err := svc.CreateImportBatch(ctx, service.CreateImportBatchInput{
		ImportJobID: job.ID,
		BatchID:     "labels-000001",
		ObjectType:  "labels",
		Items: []service.ImportBatchItemInput{
			{SourceID: "1", PayloadJSON: `{"id":1,"name":"bug","color":"d73a4a"}`},
		},
	}); err != nil {
		t.Fatalf("CreateImportBatch labels: %v", err)
	}
	if _, err := svc.CreateImportBatch(ctx, service.CreateImportBatchInput{
		ImportJobID: job.ID,
		BatchID:     "milestones-000001",
		ObjectType:  "milestones",
		Items: []service.ImportBatchItemInput{
			{SourceID: "10", SourceNumber: intPtr(7), PayloadJSON: `{"id":10,"number":7,"title":"v1","state":"open"}`},
		},
	}); err != nil {
		t.Fatalf("CreateImportBatch milestones: %v", err)
	}

	first, err := svc.ApplyImportBatchesWithOptions(ctx, job.ID, service.ApplyImportBatchesOptions{
		BatchLimit: 1,
		SkipVerify: true,
	})
	if err != nil {
		t.Fatalf("ApplyImportBatches first: %v", err)
	}
	if first.AppliedBatches != 1 || first.RemainingSupportedBatches != 1 {
		t.Fatalf("first apply report = %+v, want one applied and one remaining supported", first)
	}

	second, err := svc.ApplyImportBatchesWithOptions(ctx, job.ID, service.ApplyImportBatchesOptions{
		BatchLimit: 1,
		SkipVerify: true,
	})
	if err != nil {
		t.Fatalf("ApplyImportBatches second: %v", err)
	}
	if second.AppliedBatches != 1 || second.RemainingSupportedBatches != 0 {
		t.Fatalf("second apply report = %+v, want one applied and zero remaining supported", second)
	}
	completed, err := svc.GetImportJob(ctx, job.ID)
	if err != nil {
		t.Fatalf("GetImportJob after final skipped intermediate verification: %v", err)
	}
	if completed.Status != service.ImportStatusSucceeded {
		t.Fatalf("job status after final apply = %q, want %q", completed.Status, service.ImportStatusSucceeded)
	}
}

func TestApplyImportBatchesCoreCollaborationObjects(t *testing.T) {
	svc, ctx, cleanup := setupImportTestService(t)
	defer cleanup()
	job := createImportBatchTestJob(t, svc, ctx)

	createBatch := func(in service.CreateImportBatchInput) {
		t.Helper()
		in.ImportJobID = job.ID
		if _, err := svc.CreateImportBatch(ctx, in); err != nil {
			t.Fatalf("CreateImportBatch %s: %v", in.BatchID, err)
		}
	}

	createBatch(service.CreateImportBatchInput{
		BatchID:    "issues-000001",
		ObjectType: "issues",
		Items: []service.ImportBatchItemInput{
			{SourceID: "101", SourceNumber: intPtr(1), PayloadJSON: `{"id":101,"node_id":"I1","number":1,"title":"bug one","body":"issue body","state":"open","user":{"id":1,"login":"alice","type":"User"},"labels":[{"id":9,"node_id":"L9","name":"bug","color":"d73a4a"}]}`},
		},
	})
	createBatch(service.CreateImportBatchInput{
		BatchID:    "issue-comments-000001",
		ObjectType: "issue_comments",
		Items: []service.ImportBatchItemInput{
			{SourceID: "201", SourceNumber: intPtr(1), PayloadJSON: `{"id":201,"node_id":"IC1","body":"first comment","user":{"id":2,"login":"bob","type":"User"}}`},
		},
	})
	createBatch(service.CreateImportBatchInput{
		BatchID:    "pull-requests-000001",
		ObjectType: "pull_requests",
		Items: []service.ImportBatchItemInput{
			{SourceID: "301", SourceNumber: intPtr(2), PayloadJSON: `{"id":301,"node_id":"PR1","number":2,"title":"change","body":"pr body","state":"open","user":{"id":3,"login":"carol","type":"User"},"head":{"ref":"feature","sha":"1111111111111111111111111111111111111111"},"base":{"ref":"main","sha":"2222222222222222222222222222222222222222"}}`},
		},
	})
	createBatch(service.CreateImportBatchInput{
		BatchID:    "pull-request-reviews-000001",
		ObjectType: "pull_request_reviews",
		Items: []service.ImportBatchItemInput{
			{SourceID: "401", SourceNumber: intPtr(2), PayloadJSON: `{"id":401,"node_id":"RV1","body":"looks good","state":"APPROVED","commit_id":"1111111111111111111111111111111111111111","user":{"id":4,"login":"dora","type":"User"}}`},
		},
	})
	createBatch(service.CreateImportBatchInput{
		BatchID:    "pull-request-review-comments-000001",
		ObjectType: "pull_request_review_comments",
		Items: []service.ImportBatchItemInput{
			{SourceID: "501", SourceNumber: intPtr(2), PayloadJSON: `{"id":501,"node_id":"RC1","pull_request_review_id":401,"body":"line note","commit_id":"1111111111111111111111111111111111111111","path":"main.go","line":3,"original_line":3,"side":"RIGHT","user":{"id":5,"login":"erin","type":"User"}}`},
		},
	})
	createBatch(service.CreateImportBatchInput{
		BatchID:    "releases-000001",
		ObjectType: "releases",
		Items: []service.ImportBatchItemInput{
			{SourceID: "601", PayloadJSON: `{"id":601,"node_id":"REL1","tag_name":"v1.0.0","name":"v1","body":"release notes","author":{"id":6,"login":"frank","type":"User"}}`},
			{SourceID: "602", PayloadJSON: `{"id":602,"node_id":"REL2","tag_name":"v2.0.0","name":"v2","body":"second release","author":{"id":7,"login":"grace","type":"User"}}`},
		},
	})
	stageAsset := func(sourceID, content string) string {
		t.Helper()
		sum := sha256.Sum256([]byte(content))
		checksum := "sha256:" + hex.EncodeToString(sum[:])
		if _, err := svc.StageImportReleaseAsset(ctx, service.StageImportReleaseAssetInput{
			ImportJobID: job.ID,
			SourceID:    sourceID,
			Size:        int64(len(content)),
			Checksum:    checksum,
		}, strings.NewReader(content)); err != nil {
			t.Fatalf("StageImportReleaseAsset(%s): %v", sourceID, err)
		}
		return checksum
	}
	assetChecksum := stageAsset("701", "hello asset")
	secondAssetChecksum := stageAsset("702", "second asset")
	createBatch(service.CreateImportBatchInput{
		BatchID:    "release-assets-000001",
		ObjectType: "release_assets",
		Items: []service.ImportBatchItemInput{
			{SourceID: "701", PayloadJSON: `{"id":701,"node_id":"RA1","release_id":601,"release_tag_name":"v1.0.0","name":"asset.txt","content_type":"text/plain","size":11,"content_size":11,"content_sha256":"` + assetChecksum + `"}`},
			{SourceID: "702", PayloadJSON: `{"id":702,"node_id":"RA2","release_id":602,"release_tag_name":"v2.0.0","name":"second.txt","content_type":"text/plain","size":12,"content_size":12,"content_sha256":"` + secondAssetChecksum + `"}`},
		},
	})
	createBatch(service.CreateImportBatchInput{
		BatchID:    "branch-protections-000001",
		ObjectType: "branch_protections",
		Items: []service.ImportBatchItemInput{
			{SourceID: "main", PayloadJSON: `{"branch_name":"main","url":"https://github.test/source/repo/branches/main/protection","protection":{"required_status_checks":{"strict":true,"contexts":["ci"]},"enforce_admins":{"enabled":true},"required_signatures":{"enabled":false},"required_pull_request_reviews":{"required_approving_review_count":1},"restrictions":{"users":[],"teams":[]}}}`},
		},
	})
	createBatch(service.CreateImportBatchInput{
		BatchID:    "workflows-000001",
		ObjectType: "workflows",
		Items: []service.ImportBatchItemInput{
			{SourceID: "801", PayloadJSON: `{"id":801,"node_id":"WF1","name":"CI","path":".github/workflows/ci.yml","state":"active","html_url":"https://github.test/source/repo/actions/workflows/ci.yml"}`},
			{SourceID: "802", PayloadJSON: `{"id":802,"node_id":"WF2","name":"Release","path":".github/workflows/release.yml","state":"disabled_manually","html_url":"https://github.test/source/repo/actions/workflows/release.yml"}`},
		},
	})

	report, err := svc.ApplyImportBatches(ctx, job.ID)
	if err != nil {
		t.Fatalf("ApplyImportBatches: %v", err)
	}
	if report.AppliedBatches != 9 || report.AppliedItems != 12 {
		t.Fatalf("apply report batches/items = %d/%d, want 9/12", report.AppliedBatches, report.AppliedItems)
	}

	repoID := *job.TargetRepoID
	var issue db.Issue
	if err := svc.DBForCtx(ctx).Take(&issue, "repository_id = ? AND number = ?", repoID, 1).Error; err != nil {
		t.Fatalf("issue not applied: %v", err)
	}
	var issueComment db.IssueComment
	if err := svc.DBForCtx(ctx).Take(&issueComment, "repository_id = ? AND issue_number = ?", repoID, 1).Error; err != nil {
		t.Fatalf("issue comment not applied: %v", err)
	}
	var pr db.PullRequest
	if err := svc.DBForCtx(ctx).Take(&pr, "repository_id = ? AND number = ?", repoID, 2).Error; err != nil {
		t.Fatalf("pull request not applied: %v", err)
	}
	var review db.PullRequestReview
	if err := svc.DBForCtx(ctx).Take(&review, "pull_request_id = ?", pr.ID).Error; err != nil {
		t.Fatalf("review not applied: %v", err)
	}
	var reviewComment db.PRReviewComment
	if err := svc.DBForCtx(ctx).Take(&reviewComment, "pull_request_id = ?", pr.ID).Error; err != nil {
		t.Fatalf("review comment not applied: %v", err)
	}
	var release db.Release
	if err := svc.DBForCtx(ctx).Take(&release, "repository_id = ? AND tag_name = ?", repoID, "v1.0.0").Error; err != nil {
		t.Fatalf("release not applied: %v", err)
	}
	var asset db.ReleaseAsset
	if err := svc.DBForCtx(ctx).Take(&asset, "release_id = ? AND name = ?", release.ID, "asset.txt").Error; err != nil {
		t.Fatalf("release asset not applied: %v", err)
	}
	if string(asset.Content) != "hello asset" {
		t.Fatalf("asset content = %q, want hello asset", string(asset.Content))
	}
	var secondRelease db.Release
	if err := svc.DBForCtx(ctx).Take(&secondRelease, "repository_id = ? AND tag_name = ?", repoID, "v2.0.0").Error; err != nil {
		t.Fatalf("second release not applied: %v", err)
	}
	var secondAsset db.ReleaseAsset
	if err := svc.DBForCtx(ctx).Take(&secondAsset, "release_id = ? AND name = ?", secondRelease.ID, "second.txt").Error; err != nil {
		t.Fatalf("second release asset not applied: %v", err)
	}
	if string(secondAsset.Content) != "second asset" {
		t.Fatalf("second asset content = %q, want second asset", string(secondAsset.Content))
	}
	var protection db.BranchProtection
	if err := svc.DBForCtx(ctx).Take(&protection, "repository_id = ? AND branch_name = ?", repoID, "main").Error; err != nil {
		t.Fatalf("branch protection not applied: %v", err)
	}
	if !protection.EnforceAdmins || protection.RequiredStatusChecksJSON == "" || protection.RequiredPullRequestJSON == "" {
		t.Fatalf("branch protection = %#v", protection)
	}
	var workflows []db.Workflow
	if err := svc.DBForCtx(ctx).Where("repository_id = ?", repoID).Order("path asc").Find(&workflows).Error; err != nil {
		t.Fatalf("workflows query failed: %v", err)
	}
	if len(workflows) != 2 {
		t.Fatalf("workflow count = %d, want 2", len(workflows))
	}
	if workflows[0].Path != ".github/workflows/ci.yml" || workflows[1].Path != ".github/workflows/release.yml" {
		t.Fatalf("workflows = %#v", workflows)
	}
}

func TestApplyImportBatchesRestoresGitHubStackAfterPullRequestMappings(t *testing.T) {
	svc, ctx, cleanup := setupImportTestService(t)
	defer cleanup()
	job := createImportBatchTestJob(t, svc, ctx)

	// Deliberately stage the dependent Stack first. Apply must still claim and
	// write the PR batch before it resolves the Stack's source PR numbers.
	if _, err := svc.CreateImportBatch(ctx, service.CreateImportBatchInput{
		ImportJobID: job.ID,
		BatchID:     "pull-request-stacks-000001",
		ObjectType:  "pull_request_stacks",
		Items: []service.ImportBatchItemInput{{
			SourceID:     "9001",
			SourceNodeID: "S1",
			SourceNumber: intPtr(42),
			SourceURL:    "https://api.github.test/repos/source/repo/stacks/42",
			PayloadJSON:  `{"id":9001,"node_id":"S1","number":42,"url":"https://api.github.test/repos/source/repo/stacks/42","base":{"ref":"main"},"open":true,"pull_requests":[{"number":7},{"number":8},{"number":9}]}`,
		}},
	}); err != nil {
		t.Fatalf("CreateImportBatch Stack: %v", err)
	}
	if _, err := svc.CreateImportBatch(ctx, service.CreateImportBatchInput{
		ImportJobID: job.ID,
		BatchID:     "pull-requests-000001",
		ObjectType:  "pull_requests",
		Items: []service.ImportBatchItemInput{
			{SourceID: "7001", SourceNumber: intPtr(7), PayloadJSON: `{"id":7001,"node_id":"PR7","number":7,"title":"bottom","state":"closed","merged":true,"user":{"id":1,"login":"alice","type":"User"},"head":{"ref":"bottom","sha":"1111111111111111111111111111111111111111"},"base":{"ref":"main","sha":"2222222222222222222222222222222222222222"}}`},
			// GitHub retargets this still-open member to main after #7 merged.
			{SourceID: "8001", SourceNumber: intPtr(8), PayloadJSON: `{"id":8001,"node_id":"PR8","number":8,"title":"middle","state":"open","user":{"id":2,"login":"bob","type":"User"},"head":{"ref":"middle","sha":"3333333333333333333333333333333333333333"},"base":{"ref":"main","sha":"2222222222222222222222222222222222222222"}}`},
			{SourceID: "9002", SourceNumber: intPtr(9), PayloadJSON: `{"id":9002,"node_id":"PR9","number":9,"title":"top","state":"open","user":{"id":3,"login":"carol","type":"User"},"head":{"ref":"top","sha":"4444444444444444444444444444444444444444"},"base":{"ref":"middle","sha":"3333333333333333333333333333333333333333"}}`},
		},
	}); err != nil {
		t.Fatalf("CreateImportBatch pull requests: %v", err)
	}

	report, err := svc.ApplyImportBatches(ctx, job.ID)
	if err != nil {
		t.Fatalf("ApplyImportBatches: %v", err)
	}
	if report.AppliedBatches != 2 || report.RemainingSupportedBatches != 0 {
		t.Fatalf("apply report = %+v", report)
	}

	var stack db.PullRequestStack
	if err := svc.DBForCtx(ctx).Take(&stack, "repository_id = ?", *job.TargetRepoID).Error; err != nil {
		t.Fatalf("imported Stack: %v", err)
	}
	var entries []db.PullRequestStackEntry
	if err := svc.DBForCtx(ctx).Where("stack_id = ?", stack.ID).Order("position asc").Find(&entries).Error; err != nil {
		t.Fatalf("Stack entries: %v", err)
	}
	if len(entries) != 3 || entries[0].Position != 1 || entries[1].Position != 2 || entries[2].Position != 3 {
		t.Fatalf("Stack entries = %#v", entries)
	}
	var bottom, middle, top db.PullRequest
	if err := svc.DBForCtx(ctx).Take(&bottom, "id = ?", entries[0].PullRequestID).Error; err != nil {
		t.Fatalf("bottom PR: %v", err)
	}
	if err := svc.DBForCtx(ctx).Take(&middle, "id = ?", entries[1].PullRequestID).Error; err != nil {
		t.Fatalf("middle PR: %v", err)
	}
	if err := svc.DBForCtx(ctx).Take(&top, "id = ?", entries[2].PullRequestID).Error; err != nil {
		t.Fatalf("top PR: %v", err)
	}
	if bottom.Number != 7 || !bottom.Merged || middle.Number != 8 || top.Number != 9 {
		t.Fatalf("Stack PR order/state = %#v / %#v / %#v", bottom, middle, top)
	}
	var mapping db.ImportItemMapping
	if err := svc.DBForCtx(ctx).Take(&mapping, "import_job_id = ? AND source_type = ? AND source_id = ?", job.ID, "pull_request_stack", "9001").Error; err != nil {
		t.Fatalf("Stack mapping: %v", err)
	}
	if mapping.TargetID != strconv.FormatUint(uint64(stack.ID), 10) || mapping.TargetURL == "" {
		t.Fatalf("Stack mapping = %#v", mapping)
	}
}

func TestListImportMappingsByTargetTypeAfterUsesStableIDCursor(t *testing.T) {
	svc, ctx, cleanup := setupImportTestService(t)
	defer cleanup()
	job := createImportBatchTestJob(t, svc, ctx)
	for _, mapping := range []db.ImportItemMapping{
		{ImportJobID: job.ID, SourceType: "pull_request_stack", SourceID: "one", TargetType: "pull_request_stack", TargetID: "101"},
		{ImportJobID: job.ID, SourceType: "wiki", SourceID: "wiki", TargetType: "wiki_repository", TargetID: "201"},
		{ImportJobID: job.ID, SourceType: "pull_request_stack", SourceID: "two", TargetType: "pull_request_stack", TargetID: "102"},
		{ImportJobID: job.ID, SourceType: "pull_request_stack", SourceID: "three", TargetType: "pull_request_stack", TargetID: "103"},
	} {
		if err := svc.DBForCtx(ctx).Create(&mapping).Error; err != nil {
			t.Fatalf("create mapping: %v", err)
		}
	}
	first, err := svc.ListImportMappingsByTargetTypeAfter(ctx, job.ID, 0, "pull_request_stack", 2)
	if err != nil {
		t.Fatalf("first page: %v", err)
	}
	if len(first) != 2 || first[0].SourceID != "one" || first[1].SourceID != "two" || first[0].ID >= first[1].ID {
		t.Fatalf("first mapping page = %#v", first)
	}
	second, err := svc.ListImportMappingsByTargetTypeAfter(ctx, job.ID, first[1].ID, "pull_request_stack", 2)
	if err != nil {
		t.Fatalf("second page: %v", err)
	}
	if len(second) != 1 || second[0].SourceID != "three" || second[0].ID <= first[1].ID {
		t.Fatalf("second mapping page = %#v", second)
	}
}

func createImportBatchTestJob(t *testing.T, svc *service.Service, ctx context.Context) db.ImportJob {
	t.Helper()
	initiator := db.User{Login: "import-admin", Name: "import-admin", Type: db.TypeUser, UserKind: db.UserKindHuman, SiteAdmin: true}
	owner := db.User{Login: "target-org", Name: "target-org", Type: db.TypeOrganization, UserKind: db.UserKindHuman}
	if err := svc.DBForCtx(ctx).Create(&initiator).Error; err != nil {
		t.Fatalf("create initiator: %v", err)
	}
	if err := svc.DBForCtx(ctx).Create(&owner).Error; err != nil {
		t.Fatalf("create owner: %v", err)
	}
	repo := db.Repository{Name: "repo", FullName: "target-org/repo", OwnerID: owner.ID, DefaultBranch: "main", HasIssues: true, HasWiki: true}
	if err := svc.DBForCtx(ctx).Create(&repo).Error; err != nil {
		t.Fatalf("create repo: %v", err)
	}
	git, err := svc.GitForCtx(ctx)
	if err != nil {
		t.Fatalf("get git store: %v", err)
	}
	if err := git.Init(ctx, repo.FullName, repo.DefaultBranch, false); err != nil {
		t.Fatalf("initialize repo: %v", err)
	}
	initiatorCtx := testContextWithUser(ctx, initiator)
	job, err := svc.CreateGitHubImportJob(initiatorCtx, service.CreateGitHubImportInput{
		SourceOwner: "source",
		SourceRepo:  "repo",
		TargetOwner: "target-org",
		TargetRepo:  "repo",
		TargetMode:  service.ImportTargetModeExistingEmpty,
		InitiatorID: initiator.ID,
		Options:     service.GitHubImportOptions{ImportLabels: true, ImportMilestones: true},
	})
	if err != nil {
		t.Fatalf("CreateGitHubImportJob: %v", err)
	}
	if job.TargetRepoID == nil {
		t.Fatalf("job.TargetRepoID is nil")
	}
	return job
}

func intPtr(v int) *int {
	return &v
}
