package service

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"testing"
	"time"

	"gitcode-mcp/internal/cache"
	"gitcode-mcp/internal/gitcode"
)

func TestDurableOfflineFixtureIssueBatchCommits(t *testing.T) {
	ctx := context.Background()
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: "fixture-a", Owner: "owner", Name: "repo", APIBaseURL: "https://example.invalid/api", Scopes: []cache.RepositoryScope{cache.RepositoryScopeIssues}}); err != nil {
		t.Fatal(err)
	}
	svc, err := NewWithMode(store, gitcode.ProviderModeFixture, "", ServiceConfig{})
	if err != nil {
		t.Fatal(err)
	}
	batch, err := svc.FetchIssueSyncBatch(ctx, BulkSyncRequest{RepoID: "fixture-a", PerPage: 100, Bounds: &SyncBounds{MaxPages: 1}})
	if err != nil {
		t.Fatalf("FetchIssueSyncBatch: %v", err)
	}
	result, err := svc.CommitIssueSyncBatch(ctx, batch, nil)
	if err != nil {
		t.Fatalf("CommitIssueSyncBatch: %T %v; batch=%+v result=%+v", err, err, batch, result)
	}
	if result.SuccessCount != 1 {
		t.Fatalf("result=%+v", result)
	}
}

func TestDurableIssueBatchFetchDoesNotHoldTargetWriterAndCommitDoesNotRefetch(t *testing.T) {
	ctx, metrics := syncMetricContext()
	base := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
	client := &fakeGitCodeClient{listIssuesPages: []gitcode.Page[gitcode.IssueSummary]{{
		Items: []gitcode.IssueSummary{{ID: "provider-42", Number: 42, Title: "Fetched once", Body: "durable body", State: "open", CreatedAt: base, UpdatedAt: base}},
		Page:  1, PerPage: 100,
	}}}
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: "durable-issues", Owner: "owner", Name: "repo", APIBaseURL: "https://example.invalid/api", Scopes: []cache.RepositoryScope{cache.RepositoryScopeIssues}}); err != nil {
		t.Fatal(err)
	}
	svc := NewWithClient(store, client)
	held, err := store.AcquireWriter(ctx, cache.WriterRequest{Operation: "external-writer", RepoID: "durable-issues", LockPath: svc.lockPath})
	if err != nil {
		t.Fatalf("AcquireWriter: %v", err)
	}
	batch, err := svc.FetchIssueSyncBatch(ctx, BulkSyncRequest{RepoID: "durable-issues", PerPage: 100, Bounds: &SyncBounds{MaxPages: 1}})
	if err != nil {
		t.Fatalf("FetchIssueSyncBatch while target writer held: %v", err)
	}
	if len(client.listIssueRequests) != 1 || batch.RecordCount() != 1 || batch.TraversalStatus != "complete" {
		t.Fatalf("fetch calls=%d batch=%+v", len(client.listIssueRequests), batch)
	}
	encoded, err := json.Marshal(batch)
	if err != nil {
		t.Fatalf("Marshal batch: %v", err)
	}
	var replay DurableIssueSyncBatch
	if err := json.Unmarshal(encoded, &replay); err != nil {
		t.Fatalf("Unmarshal batch: %v", err)
	}
	if _, err := svc.CommitIssueSyncBatch(ctx, replay, nil); err == nil {
		t.Fatal("commit succeeded while target writer was held")
	} else {
		var contention cache.ErrLockContention
		if !errors.As(err, &contention) {
			t.Fatalf("commit error = %T %v, want cache contention", err, err)
		}
	}
	if len(client.listIssueRequests) != 1 {
		t.Fatalf("contended commit refetched provider: calls=%d", len(client.listIssueRequests))
	}
	requireSyncCounter(t, metrics, "issues", 0)
	if err := store.ReleaseWriter(ctx, held); err != nil {
		t.Fatalf("ReleaseWriter: %v", err)
	}
	result, err := svc.CommitIssueSyncBatch(ctx, replay, nil)
	if err != nil {
		t.Fatalf("CommitIssueSyncBatch retry: %v", err)
	}
	if result.SuccessCount != 1 || len(client.listIssueRequests) != 1 {
		t.Fatalf("result=%+v provider calls=%d", result, len(client.listIssueRequests))
	}
	requireSyncCounter(t, metrics, "issues", 1)
	stored, err := store.GetSourceScoped(ctx, "durable-issues", "ISSUE-42")
	if err != nil {
		t.Fatalf("GetSourceScoped: %v", err)
	}
	if stored.Body != "durable body" {
		t.Fatalf("stored body = %q", stored.Body)
	}
	frontier, ok, err := store.GetSyncFrontier(ctx, "durable-issues", "issue", syncOrderingUpdatedAtDesc, syncFilterStateAll)
	if err != nil || !ok || frontier.Status != "complete" || frontier.RecordsListed != 1 {
		t.Fatalf("frontier=%+v ok=%v err=%v", frontier, ok, err)
	}
}

func TestDurableIssueBatchValidatesWholeBatchBeforePublishing(t *testing.T) {
	ctx, metrics := syncMetricContext()
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: "durable-atomic-validation", Owner: "owner", Name: "repo", APIBaseURL: "https://example.invalid/api", Scopes: []cache.RepositoryScope{cache.RepositoryScopeIssues}}); err != nil {
		t.Fatal(err)
	}
	batch := DurableIssueSyncBatch{Version: DurableSyncBatchVersion, RepoID: "durable-atomic-validation", Collection: "issues", IdempotencyKey: "atomic-validation", Items: []DurableIssueItem{{Number: 1, Title: "valid first"}, {Number: 0, Title: "invalid second"}}, PagesListed: 1, RecordsListed: 2, StopReason: "end_of_collection", TraversalStatus: "complete"}
	if _, err := NewWithClient(store, &fakeGitCodeClient{}).CommitIssueSyncBatch(ctx, batch, nil); err == nil {
		t.Fatal("CommitIssueSyncBatch accepted invalid staged item")
	}
	if _, err := store.GetSourceScoped(ctx, batch.RepoID, "ISSUE-1"); err == nil {
		t.Fatal("valid first item was published before full-batch validation")
	}
	if _, ok, err := store.GetSyncFrontier(ctx, batch.RepoID, "issue", syncOrderingUpdatedAtDesc, syncFilterStateAll); err != nil || ok {
		t.Fatalf("frontier ok=%v err=%v after rejected batch", ok, err)
	}
	requireSyncCounter(t, metrics, "issues", 0)
}

func TestDurablePullAndWikiBatchesCommitWithoutProviderReplay(t *testing.T) {
	ctx, metrics := syncMetricContext()
	base := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
	client := &fakeGitCodeClient{
		listPRPages:   []gitcode.Page[gitcode.PullRequest]{{Items: []gitcode.PullRequest{{ID: "pr-7", Number: 7, Title: "Durable PR", Body: "pull body", State: "open", CreatedAt: base, UpdatedAt: base}}, Page: 1, PerPage: 100}},
		listWikiPages: []gitcode.Page[gitcode.WikiPage]{{Items: []gitcode.WikiPage{{ID: "wiki-home", Slug: "Home", Title: "Home", Body: "wiki body", Revision: "rev-1", CreatedAt: base, UpdatedAt: base}}, Page: 1, PerPage: 100, NextPage: 18}},
	}
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: "durable-mixed", Owner: "owner", Name: "repo", APIBaseURL: "https://example.invalid/api", Scopes: []cache.RepositoryScope{cache.RepositoryScopeIssues, cache.RepositoryScopeWiki}}); err != nil {
		t.Fatal(err)
	}
	svc := NewWithClient(store, client)
	held, err := store.AcquireWriter(ctx, cache.WriterRequest{Operation: "external-writer", RepoID: "durable-mixed", LockPath: svc.lockPath})
	if err != nil {
		t.Fatal(err)
	}
	pulls, err := svc.FetchPullSyncBatch(ctx, BulkSyncRequest{RepoID: "durable-mixed", PerPage: 100, Bounds: &SyncBounds{MaxPages: 1}})
	if err != nil {
		t.Fatalf("FetchPullSyncBatch: %v", err)
	}
	wiki, err := svc.FetchWikiSyncBatch(ctx, BulkSyncRequest{RepoID: "durable-mixed", PerPage: 17, Bounds: &SyncBounds{MaxPages: 1, MaxRecords: 10_000, MaxBytes: 1234}})
	if err != nil {
		t.Fatalf("FetchWikiSyncBatch: %v", err)
	}
	if got := client.lastListWikiRequest.Bounds; got == nil || got.MaxRecords != 17 || got.MaxBytes != 1234 || !got.OffsetPaging {
		t.Fatalf("wiki provider bounds=%+v", got)
	}
	if wiki.NextPage != 18 || wiki.TraversalStatus != "bounded" {
		t.Fatalf("wiki continuation=%+v", wiki)
	}
	if wiki.ProviderRevision == "" {
		t.Fatal("FetchWikiSyncBatch omitted provider revision")
	}
	if _, err := svc.CommitPullSyncBatch(ctx, pulls, nil); err == nil {
		t.Fatal("pull commit succeeded under external writer")
	}
	if _, err := svc.CommitWikiSyncBatch(ctx, wiki, nil); err == nil {
		t.Fatal("wiki commit succeeded under external writer")
	}
	if client.listPRCalls != 1 || client.listWikiPagesCallCount != 1 {
		t.Fatalf("provider calls after contention: pulls=%d wiki=%d", client.listPRCalls, client.listWikiPagesCallCount)
	}
	requireSyncCounter(t, metrics, "pulls", 0)
	requireSyncCounter(t, metrics, "wiki", 0)
	if err := store.ReleaseWriter(ctx, held); err != nil {
		t.Fatal(err)
	}
	if result, err := svc.CommitPullSyncBatch(ctx, pulls, nil); err != nil || result.SuccessCount != 1 {
		t.Fatalf("CommitPullSyncBatch result=%+v err=%v", result, err)
	}
	if result, err := svc.CommitWikiSyncBatch(ctx, wiki, nil); err != nil || result.SuccessCount != 1 {
		t.Fatalf("CommitWikiSyncBatch result=%+v err=%v", result, err)
	}
	if client.listPRCalls != 1 || client.listWikiPagesCallCount != 1 || client.wikiCalls != 0 {
		t.Fatalf("commit replay called provider: pulls=%d wiki-list=%d wiki-detail=%d", client.listPRCalls, client.listWikiPagesCallCount, client.wikiCalls)
	}
	requireSyncCounter(t, metrics, "pulls", 1)
	requireSyncCounter(t, metrics, "wiki", 1)
}

func TestDurableCommentBatchesCommitWithoutProviderReplay(t *testing.T) {
	ctx, metrics := syncMetricContext()
	base := time.Date(2026, 9, 1, 11, 0, 0, 0, time.UTC)
	client := &fakeGitCodeClient{
		commentsByIssue: map[int][]gitcode.Comment{7: {{ID: "ic-1", IssueID: "provider-7", IssueNumber: 7, Author: "alice", Body: "issue comment", CreatedAt: base, UpdatedAt: base}, {ID: "ic-2", IssueID: "provider-7", IssueNumber: 7, Author: "bob", Body: "second fixture comment", CreatedAt: base, UpdatedAt: base}}},
		prCommentsByPR:  map[int][]gitcode.PRComment{9: {{ID: "pc-1", Author: "bob", Body: "PR comment", CreatedAt: base, UpdatedAt: base}}},
	}
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	const repoID = "durable-comments"
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: repoID, Owner: "owner", Name: "repo", APIBaseURL: "https://example.invalid/api", Scopes: []cache.RepositoryScope{cache.RepositoryScopeIssues}}); err != nil {
		t.Fatal(err)
	}
	issue := cache.Source{RepoID: repoID, ID: "ISSUE-7", Kind: "issue", Path: "issues/7.md", Title: "Issue 7", ContentHash: "issue", CreatedAt: base, UpdatedAt: base}
	if err := store.UpsertRecordGraph(ctx, cache.RecordGraph{Record: cache.Record{RepoID: repoID, ID: issue.ID, Type: issue.Kind, Path: issue.Path, Title: issue.Title, ContentHash: issue.ContentHash, CreatedAt: base, UpdatedAt: base}, Identities: []cache.Identity{{RepoID: repoID, SourceID: issue.ID, AliasType: "issue", Alias: "7"}, {RepoID: repoID, SourceID: issue.ID, AliasType: "gitcode_issue_id", Alias: "provider-7"}}}); err != nil {
		t.Fatal(err)
	}
	if err := store.UpsertIssueCommentSync(ctx, cache.IssueCommentSync{RepoID: repoID, SourceID: issue.ID, IssueNumber: 7, RemoteID: "7", ProviderID: "provider-7", RemoteRevision: "issue-r1", ExpectedCount: 1, Status: "pending", UpdatedAt: base}); err != nil {
		t.Fatal(err)
	}
	pull := cache.Source{RepoID: repoID, ID: "PR-9", Kind: "pull_request", Path: "pulls/9.md", Title: "PR 9", ContentHash: "pull", CreatedAt: base, UpdatedAt: base}
	if err := store.UpsertSourceGraph(ctx, cache.SourceGraph{Source: pull, Identities: []cache.Identity{{RepoID: repoID, SourceID: pull.ID, AliasType: "pull_request", Alias: "9"}}}); err != nil {
		t.Fatal(err)
	}
	svc := NewWithClient(store, client)
	if batch, err := svc.FetchIssueCommentSyncBatch(ctx, BulkSyncRequest{RepoID: repoID, Bounds: &SyncBounds{MaxPages: 1, MaxRecords: 1, MaxBytes: 1 << 20}}); err == nil || len(batch.Groups) != 0 {
		t.Fatalf("issue comment record fan-out escaped pre-stage bound: batch=%+v err=%v", batch, err)
	}
	if batch, err := svc.FetchIssueCommentSyncBatch(ctx, BulkSyncRequest{RepoID: repoID, Bounds: &SyncBounds{MaxPages: 1, MaxRecords: 10, MaxBytes: 1}}); err == nil || len(batch.Groups) != 0 {
		t.Fatalf("issue comment bytes escaped pre-stage bound: batch=%+v err=%v", batch, err)
	}
	issueBatch, err := svc.FetchIssueCommentSyncBatch(ctx, BulkSyncRequest{RepoID: repoID, Bounds: &SyncBounds{MaxRecords: 10}})
	if err != nil {
		t.Fatalf("FetchIssueCommentSyncBatch: %v", err)
	}
	prBatch, err := svc.FetchPRCommentSyncBatch(ctx, BulkSyncRequest{RepoID: repoID, Bounds: &SyncBounds{MaxRecords: 10}})
	if err != nil {
		t.Fatalf("FetchPRCommentSyncBatch: %v", err)
	}
	if issueBatch.RecordCount() != 3 || prBatch.RecordCount() != 1 || client.commentCalls != 3 || client.prCommentCalls != 1 {
		t.Fatalf("batches issue=%+v pr=%+v calls issue=%d pr=%d", issueBatch, prBatch, client.commentCalls, client.prCommentCalls)
	}
	if result, err := svc.CommitIssueCommentSyncBatch(ctx, issueBatch, nil); err != nil || result.SuccessCount != 1 {
		t.Fatalf("CommitIssueCommentSyncBatch result=%+v err=%v", result, err)
	}
	if result, err := svc.CommitPRCommentSyncBatch(ctx, prBatch, nil); err != nil || result.SuccessCount != 1 {
		t.Fatalf("CommitPRCommentSyncBatch result=%+v err=%v", result, err)
	}
	if client.commentCalls != 3 || client.prCommentCalls != 1 {
		t.Fatalf("comment commit refetched provider: issue=%d pr=%d", client.commentCalls, client.prCommentCalls)
	}
	// Two comment graphs, not the one completed parent group, contribute.
	requireSyncCounter(t, metrics, "issue_comments", 2)
	requireSyncCounter(t, metrics, "pr_comments", 1)
	if _, err := store.GetSourceScoped(ctx, repoID, issueCommentStableID(7, "ic-1")); err != nil {
		t.Fatalf("issue comment projection: %v", err)
	}
	if _, err := store.GetSourceScoped(ctx, repoID, prCommentStableID(9, "pc-1")); err != nil {
		t.Fatalf("PR comment projection: %v", err)
	}
}

type aggregateIssueCommentClient struct {
	*fakeGitCodeClient
	pages    map[int]gitcode.Page[gitcode.Comment]
	err      error
	requests []gitcode.RepositoryIssueCommentListRequest
}

func (c *aggregateIssueCommentClient) ListRepositoryIssueComments(_ context.Context, req gitcode.RepositoryIssueCommentListRequest) (gitcode.Page[gitcode.Comment], error) {
	c.requests = append(c.requests, req)
	if c.err != nil {
		return gitcode.Page[gitcode.Comment]{}, c.err
	}
	return c.pages[req.Page], nil
}

func TestBulkSyncIssuesSyncsListedIssuesAndZeroDeltaOnResync(t *testing.T) {
	ctx := context.Background()
	base := time.Date(2026, 6, 22, 14, 0, 0, 0, time.UTC)
	client := &fakeGitCodeClient{
		listIssuesPages: []gitcode.Page[gitcode.IssueSummary]{
			{Items: []gitcode.IssueSummary{{ID: "1", Number: 1, Title: "First", State: "open", Comments: 1}, {ID: "2", Number: 2, Title: "Second", State: "open"}}, Page: 1, PerPage: 100},
			{Items: []gitcode.IssueSummary{{ID: "1", Number: 1, Title: "First", State: "open", Comments: 1}, {ID: "2", Number: 2, Title: "Second", State: "open"}}, Page: 1, PerPage: 100},
		},
		issuesByNumber: map[int]gitcode.Issue{
			1: {ID: "1", Number: 1, Title: "First", Body: "first body", State: "open", CreatedAt: base, UpdatedAt: base},
			2: {ID: "2", Number: 2, Title: "Second", Body: "second body", State: "open", CreatedAt: base, UpdatedAt: base},
		},
		commentsByIssue: map[int][]gitcode.Comment{
			1: {{ID: "c1", Author: "author", Body: "comment one", CreatedAt: base, UpdatedAt: base}},
		},
	}
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: "bulk-issues", Owner: "owner", Name: "repo", APIBaseURL: "https://example.invalid/api", Scopes: []cache.RepositoryScope{cache.RepositoryScopeIssues}}); err != nil {
		t.Fatal(err)
	}
	svc := NewWithClient(store, client)
	first, err := svc.BulkSyncIssues(ctx, BulkSyncRequest{RepoID: "bulk-issues", PerPage: 100})
	if err != nil {
		t.Fatalf("BulkSyncIssues first returned error: %v", err)
	}
	if first.SuccessCount != 2 || first.FailureCount != 0 {
		t.Fatalf("first counts = success %d failure %d, want 2/0", first.SuccessCount, first.FailureCount)
	}
	if client.issueCalls != 0 {
		t.Fatalf("bulk issue sync GetIssue calls = %d, want 0 because list payload is the current sync source", client.issueCalls)
	}
	if client.commentCalls != 0 {
		t.Fatalf("first parent backfill ListIssueComments calls = %d, want 0", client.commentCalls)
	}
	seenKeys := map[string]bool{}
	seenEvents := map[string]bool{}
	for i, result := range first.Results {
		if result.IdempotencyKey == "" || result.SyncEventID == "" {
			t.Fatalf("first result %d missing idempotency/event: %+v", i, result)
		}
		if seenKeys[result.IdempotencyKey] {
			t.Fatalf("first result %d duplicate idempotency key %q", i, result.IdempotencyKey)
		}
		if seenEvents[result.SyncEventID] {
			t.Fatalf("first result %d duplicate sync event id %q", i, result.SyncEventID)
		}
		if result.Counts.Listed != 1 || result.Counts.FetchedDetail != 0 {
			t.Fatalf("first result %d counts = %#v, want parent-only listed=1 fetched_detail=0", i, result.Counts)
		}
		seenKeys[result.IdempotencyKey] = true
		seenEvents[result.SyncEventID] = true
	}
	if _, err := store.GetSourceScoped(ctx, "bulk-issues", "ISSUE-1"); err != nil {
		t.Fatalf("ISSUE-1 missing: %v", err)
	}
	if _, err := store.GetSourceScoped(ctx, "bulk-issues", "ISSUE-2"); err != nil {
		t.Fatalf("ISSUE-2 missing: %v", err)
	}
	if first.IssueComments == nil || first.IssueComments.Pending != 1 || first.IssueComments.Complete != 1 {
		t.Fatalf("first issue comment queue = %#v, want pending=1 complete=1", first.IssueComments)
	}
	status, err := svc.GetSyncStatus(ctx, SyncStatusRequest{RepoID: "bulk-issues", ID: "ISSUE-1"})
	if err != nil {
		t.Fatalf("GetSyncStatus ISSUE-1 returned error: %v", err)
	}
	if status.Status != "fresh" || status.IssueComments == nil || status.IssueComments.Status != "pending" || status.IssueComments.ExpectedCount != 1 {
		t.Fatalf("ISSUE-1 sync status = %#v, want fresh parent with pending comment coverage", status)
	}
	summary, err := svc.SyncStatus(ctx, ListSourcesRequest{RepoID: "bulk-issues"})
	if err != nil {
		t.Fatalf("SyncStatus returned error: %v", err)
	}
	if summary.IssueComments == nil || summary.IssueComments.Pending != 1 || summary.IssueComments.Complete != 1 {
		t.Fatalf("aggregate issue comment status = %#v", summary.IssueComments)
	}
	drained, err := svc.BulkSyncIssueComments(ctx, BulkSyncRequest{RepoID: "bulk-issues", PerPage: 100})
	if err != nil {
		t.Fatalf("BulkSyncIssueComments returned error: %v", err)
	}
	if drained.IssueComments == nil || drained.IssueComments.Pending != 0 || drained.IssueComments.Complete != 2 || client.commentCalls != 1 {
		t.Fatalf("drained queue=%#v comment_calls=%d", drained.IssueComments, client.commentCalls)
	}
	second, err := svc.BulkSyncIssues(ctx, BulkSyncRequest{RepoID: "bulk-issues", IdempotencyKey: "bulk-issues-second", PerPage: 100})
	if err != nil {
		t.Fatalf("BulkSyncIssues second returned error: %v", err)
	}
	if second.SuccessCount != 2 || second.FailureCount != 0 {
		t.Fatalf("second counts = success %d failure %d, want 2/0", second.SuccessCount, second.FailureCount)
	}
	if client.commentCalls != 1 {
		t.Fatalf("parent resync ListIssueComments calls = %d, want drain-only call count 1", client.commentCalls)
	}
	for i, result := range second.Results {
		if !result.ZeroDelta {
			t.Fatalf("second result %d ZeroDelta = false, want true", i)
		}
		if result.Counts.Fetched != 1 || result.Counts.Skipped != 1 || result.Counts.Inserted != 0 || result.Counts.Updated != 0 {
			t.Fatalf("second result %d counts = %#v, want fetched/skipped only", i, result.Counts)
		}
		if result.Counts.Listed != 1 || result.Counts.FetchedDetail != 0 || result.Counts.SkippedByRevision != 1 {
			t.Fatalf("second result %d counts = %#v, want listed=1 fetched_detail=0 skipped_by_revision=1", i, result.Counts)
		}
	}
	sources, err := store.ListSources(ctx, cache.SourceFilter{RepoID: "bulk-issues", Kind: "issue"})
	if err != nil {
		t.Fatal(err)
	}
	if len(sources) != 2 {
		t.Fatalf("issue source count = %d, want 2", len(sources))
	}
}

func TestBulkSyncIssueCommentsUsesRepositoryAggregateAndReportsAvoidedRequests(t *testing.T) {
	ctx := context.Background()
	base := time.Date(2026, 7, 13, 14, 0, 0, 0, time.UTC)
	parents := []gitcode.IssueSummary{
		{ID: "7001", Number: 7, Title: "First", State: "open", Comments: 1, CreatedAt: base, UpdatedAt: base.Add(3 * time.Minute)},
		{ID: "7002", Number: 8, Title: "Second", State: "open", Comments: 1, CreatedAt: base, UpdatedAt: base.Add(2 * time.Minute)},
		{ID: "7003", Number: 9, Title: "Third", State: "open", Comments: 1, CreatedAt: base, UpdatedAt: base.Add(time.Minute)},
	}
	client := &aggregateIssueCommentClient{
		fakeGitCodeClient: &fakeGitCodeClient{listIssuesPages: []gitcode.Page[gitcode.IssueSummary]{{Items: parents}, {Items: nil}}},
		pages: map[int]gitcode.Page[gitcode.Comment]{
			1: {Items: []gitcode.Comment{{ID: "c7", IssueID: "7001", IssueNumber: 7, Body: "issue-comment-seven-unique", CreatedAt: base, UpdatedAt: base}, {ID: "c8", IssueID: "7002", IssueNumber: 8, Body: "issue-comment-eight-unique", CreatedAt: base, UpdatedAt: base}}, Page: 1, PerPage: 2, TotalCount: 3, NextPage: 2},
			2: {Items: []gitcode.Comment{{ID: "c9", IssueID: "7003", IssueNumber: 9, Body: "issue-comment-nine-unique", CreatedAt: base, UpdatedAt: base}}, Page: 2, PerPage: 2, TotalCount: 3},
		},
	}
	store := newBulkIssueCommentStore(t, ctx, "aggregate-comments")
	defer store.Close()
	svc := NewWithClient(store, client)
	if _, err := svc.BulkSyncIssues(ctx, BulkSyncRequest{RepoID: "aggregate-comments", PerPage: 3, Bounds: &SyncBounds{MaxPages: 10}}); err != nil {
		t.Fatalf("BulkSyncIssues returned error: %v", err)
	}
	if err := store.UpsertRecordComments(ctx, "aggregate-comments", "ISSUE-7", []cache.RecordComment{{CommentID: "stale", Body: "remove me", CreatedAt: base, UpdatedAt: base}}); err != nil {
		t.Fatal(err)
	}
	if err := store.UpsertSyncGraph(ctx, cache.SyncGraph{
		RepoID:     "aggregate-comments",
		Provenance: cache.ProvenanceLive,
		Record:     cache.Record{ID: "ISSUECOMMENT-7-stale", Type: "issue_comment", Path: "issues/7/comments/stale.md", Title: "Stale comment", Body: "issue-comment-stale-unique", Status: "current", ContentHash: "stale", RemoteType: "issue_comment", RemoteID: "7:stale", CreatedAt: base, UpdatedAt: base},
		Links:      []cache.Link{{SourceID: "ISSUECOMMENT-7-stale", TargetID: "ISSUE-7", Kind: "parent", Text: "issue"}},
	}); err != nil {
		t.Fatal(err)
	}
	progress := make(chan ProgressEvent, 8)
	result, err := svc.BulkSyncIssueComments(ctx, BulkSyncRequest{RepoID: "aggregate-comments", PerPage: 2, Bounds: &SyncBounds{ProgressChan: progress}, ProgressChan: progress})
	if err != nil {
		t.Fatalf("BulkSyncIssueComments returned error: %v", err)
	}
	if client.commentCalls != 0 || len(client.requests) != 2 {
		t.Fatalf("per_issue=%d aggregate=%d", client.commentCalls, len(client.requests))
	}
	if result.SuccessCount != 3 || result.PagesListed != 2 || result.RecordsListed != 3 || result.IssueComments == nil || result.IssueComments.Strategy != "repository_aggregate" || result.IssueComments.ParentRequestsAvoided != 1 || result.IssueComments.Unreconciled != 0 {
		t.Fatalf("result=%#v", result)
	}
	for number, commentID := range map[int]string{7: "c7", 8: "c8", 9: "c9"} {
		record, err := store.GetRecord(ctx, "aggregate-comments", fmt.Sprintf("ISSUE-%d", number))
		if err != nil || len(record.Comments) != 1 || record.Comments[0].CommentID != commentID {
			t.Fatalf("number=%d record=%#v err=%v", number, record, err)
		}
		sourceID := fmt.Sprintf("ISSUECOMMENT-%d-%s", number, commentID)
		source, err := svc.GetSource(ctx, GetSourceRequest{RepoID: "aggregate-comments", ID: sourceID})
		if err != nil || source.Kind != "issue_comment" || source.Body == "" || len(source.Links) != 1 || source.Links[0].TargetID != fmt.Sprintf("ISSUE-%d", number) || source.Links[0].Kind != "parent" {
			t.Fatalf("number=%d source=%#v err=%v", number, source, err)
		}
	}
	listed, err := svc.ListSources(ctx, ListSourcesRequest{RepoID: "aggregate-comments", Kind: "issue_comment"})
	if err != nil || len(listed.Results) != 3 {
		t.Fatalf("listed=%#v err=%v", listed, err)
	}
	found, err := svc.SearchSources(ctx, SearchSourcesRequest{RepoID: "aggregate-comments", Query: "issue-comment-seven-unique", Kind: "issue_comment"})
	if err != nil || len(found.Results) != 1 || found.Results[0].ID != "ISSUECOMMENT-7-c7" {
		t.Fatalf("found=%#v err=%v", found, err)
	}
	if _, err := store.GetSourceScoped(ctx, "aggregate-comments", "ISSUECOMMENT-7-stale"); !errors.Is(err, cache.ErrNotFound) {
		t.Fatalf("stale source error = %v, want ErrNotFound", err)
	}
	stale, err := store.SearchSources(ctx, cache.SearchQuery{RepoID: "aggregate-comments", Query: "issue-comment-stale-unique", Kind: "issue_comment", Limit: 10})
	if err != nil || len(stale) != 0 {
		t.Fatalf("stale search=%#v err=%v", stale, err)
	}
	second, err := svc.BulkSyncIssueComments(ctx, BulkSyncRequest{RepoID: "aggregate-comments", PerPage: 2, Bounds: &SyncBounds{}})
	if err != nil || second.StopReason != "queue_empty" || len(client.requests) != 2 {
		t.Fatalf("second=%#v requests=%d err=%v", second, len(client.requests), err)
	}
}

func TestBulkSyncIssueCommentsAggregateBoundedRunRestartsSafely(t *testing.T) {
	ctx := context.Background()
	base := time.Date(2026, 7, 13, 15, 0, 0, 0, time.UTC)
	parents := []gitcode.IssueSummary{{ID: "7101", Number: 11, Title: "One", State: "open", Comments: 2, CreatedAt: base, UpdatedAt: base.Add(time.Minute)}}
	client := &aggregateIssueCommentClient{
		fakeGitCodeClient: &fakeGitCodeClient{listIssuesPages: []gitcode.Page[gitcode.IssueSummary]{{Items: parents}, {Items: nil}}},
		pages: map[int]gitcode.Page[gitcode.Comment]{
			1: {Items: []gitcode.Comment{{ID: "c11a", IssueID: "7101", IssueNumber: 11, Body: "first"}}, Page: 1, PerPage: 1, NextPage: 2},
			2: {Items: []gitcode.Comment{{ID: "c11b", IssueID: "7101", IssueNumber: 11, Body: "second"}}, Page: 2, PerPage: 1},
		},
	}
	store := newBulkIssueCommentStore(t, ctx, "aggregate-resume")
	defer store.Close()
	svc := NewWithClient(store, client)
	if _, err := svc.BulkSyncIssues(ctx, BulkSyncRequest{RepoID: "aggregate-resume", PerPage: 1, Bounds: &SyncBounds{MaxPages: 10}}); err != nil {
		t.Fatal(err)
	}
	first, err := svc.BulkSyncIssueComments(ctx, BulkSyncRequest{RepoID: "aggregate-resume", Page: 7, PerPage: 1, Bounds: &SyncBounds{MaxPages: 1}})
	if err != nil || first.TraversalStatus != "bounded" || first.StopReason != "max_pages" || first.SuccessCount != 0 || first.IssueComments.Pending != 1 {
		t.Fatalf("first=%#v err=%v", first, err)
	}
	second, err := svc.BulkSyncIssueComments(ctx, BulkSyncRequest{RepoID: "aggregate-resume", PerPage: 1, Bounds: &SyncBounds{}})
	if err != nil || second.SuccessCount != 1 || second.IssueComments.Complete != 1 {
		t.Fatalf("second=%#v err=%v", second, err)
	}
	if len(client.requests) != 3 || client.requests[0].Page != 1 || client.requests[1].Page != 1 || client.requests[2].Page != 2 {
		t.Fatalf("requests=%#v", client.requests)
	}
	record, err := store.GetRecord(ctx, "aggregate-resume", "ISSUE-11")
	if err != nil || len(record.Comments) != 2 {
		t.Fatalf("record=%#v err=%v", record, err)
	}
}

func TestBulkSyncIssueCommentsIncrementalQueueDrainsBoundedWork(t *testing.T) {
	ctx := context.Background()
	base := time.Date(2026, 8, 11, 14, 0, 0, 0, time.UTC)
	parents := []gitcode.IssueSummary{
		{ID: "8101", Number: 11, Title: "One", State: "open", Comments: 1, CreatedAt: base, UpdatedAt: base},
		{ID: "8102", Number: 12, Title: "Two", State: "open", Comments: 1, CreatedAt: base, UpdatedAt: base.Add(time.Minute)},
	}
	client := &fakeGitCodeClient{
		listIssuesPages: []gitcode.Page[gitcode.IssueSummary]{{Items: parents, Page: 1, PerPage: 100}},
		commentsByIssue: map[int][]gitcode.Comment{
			11: {{ID: "c11", IssueID: "8101", IssueNumber: 11, Body: "one", CreatedAt: base, UpdatedAt: base}},
			12: {{ID: "c12", IssueID: "8102", IssueNumber: 12, Body: "two", CreatedAt: base, UpdatedAt: base}},
		},
	}
	store := newBulkIssueCommentStore(t, ctx, "incremental-comments")
	defer store.Close()
	svc := NewWithClient(store, client)
	if _, err := svc.BulkSyncIssues(ctx, BulkSyncRequest{RepoID: "incremental-comments", PerPage: 100, Bounds: &SyncBounds{MaxPages: 10}}); err != nil {
		t.Fatal(err)
	}
	request := BulkSyncRequest{RepoID: "incremental-comments", IncrementalQueue: true, Bounds: &SyncBounds{MaxPages: 1}}
	first, err := svc.BulkSyncIssueComments(ctx, request)
	if err != nil || first.SuccessCount != 1 || first.PagesListed != 1 || first.TraversalStatus != "bounded" || first.IssueComments.Pending != 1 {
		t.Fatalf("first=%#v err=%v", first, err)
	}
	second, err := svc.BulkSyncIssueComments(ctx, request)
	if err != nil || second.SuccessCount != 1 || second.PagesListed != 1 || second.TraversalStatus != "complete" || second.IssueComments.Complete != 2 {
		t.Fatalf("second=%#v err=%v", second, err)
	}
}

func TestBulkSyncIssueCommentsAggregateParentFailureIsExplicitAndRetryable(t *testing.T) {
	ctx := context.Background()
	base := time.Date(2026, 7, 13, 16, 0, 0, 0, time.UTC)
	client := &aggregateIssueCommentClient{
		fakeGitCodeClient: &fakeGitCodeClient{listIssuesPages: []gitcode.Page[gitcode.IssueSummary]{
			{Items: []gitcode.IssueSummary{{ID: "7201", Number: 21, Title: "Known", State: "open", Comments: 1, CreatedAt: base, UpdatedAt: base}}},
			{Items: nil},
		}},
		pages: map[int]gitcode.Page[gitcode.Comment]{1: {Items: []gitcode.Comment{{ID: "orphan", IssueID: "9999", IssueNumber: 99, Body: "orphan"}, {ID: "known", IssueID: "7201", IssueNumber: 21, Body: "known"}}, Page: 1, PerPage: 100}},
	}
	store := newBulkIssueCommentStore(t, ctx, "aggregate-reconcile")
	defer store.Close()
	svc := NewWithClient(store, client)
	if _, err := svc.BulkSyncIssues(ctx, BulkSyncRequest{RepoID: "aggregate-reconcile", PerPage: 1, Bounds: &SyncBounds{MaxPages: 10}}); err != nil {
		t.Fatal(err)
	}
	result, err := svc.BulkSyncIssueComments(ctx, BulkSyncRequest{RepoID: "aggregate-reconcile", PerPage: 100, Bounds: &SyncBounds{}})
	var partial *PartialSyncError
	if !errors.As(err, &partial) || result.TraversalStatus != "partial" || result.StopReason != "reconciliation_failed" || len(result.Failures) != 1 || result.Failures[0].RemoteType != "issue_comment_reconciliation" || result.Failures[0].RecoveryAction == "" || result.IssueComments.Unreconciled != 1 {
		t.Fatalf("result=%#v err=%T %v", result, err, err)
	}
	queue, ok, queueErr := store.GetIssueCommentSync(ctx, "aggregate-reconcile", "ISSUE-21")
	if queueErr != nil || !ok || queue.Status != "pending" {
		t.Fatalf("queue=%#v ok=%t err=%v", queue, ok, queueErr)
	}
}

func TestBulkSyncIssueCommentsAggregateRejectsIncompleteReportedTotal(t *testing.T) {
	ctx := context.Background()
	base := time.Date(2026, 7, 13, 16, 30, 0, 0, time.UTC)
	client := &aggregateIssueCommentClient{
		fakeGitCodeClient: &fakeGitCodeClient{listIssuesPages: []gitcode.Page[gitcode.IssueSummary]{
			{Items: []gitcode.IssueSummary{{ID: "7251", Number: 25, Title: "Known", State: "open", Comments: 1, CreatedAt: base, UpdatedAt: base}}},
			{Items: nil},
		}},
		pages: map[int]gitcode.Page[gitcode.Comment]{1: {Items: []gitcode.Comment{{ID: "known", IssueID: "7251", IssueNumber: 25, Body: "known"}}, Page: 1, PerPage: 100, TotalCount: 2}},
	}
	store := newBulkIssueCommentStore(t, ctx, "aggregate-total")
	defer store.Close()
	svc := NewWithClient(store, client)
	if _, err := svc.BulkSyncIssues(ctx, BulkSyncRequest{RepoID: "aggregate-total", PerPage: 1, Bounds: &SyncBounds{MaxPages: 10}}); err != nil {
		t.Fatal(err)
	}
	result, err := svc.BulkSyncIssueComments(ctx, BulkSyncRequest{RepoID: "aggregate-total", PerPage: 100, Bounds: &SyncBounds{}})
	var partial *PartialSyncError
	if !errors.As(err, &partial) || result.FailureCount != 1 || result.Failures[0].RemoteType != "issue_comment_reconciliation" || !strings.Contains(result.Failures[0].Message, "expected total_count 2") {
		t.Fatalf("result=%#v err=%T %v", result, err, err)
	}
}

func TestBulkSyncIssueCommentsFallsBackWhenAggregateEndpointUnsupported(t *testing.T) {
	ctx := context.Background()
	base := time.Date(2026, 7, 13, 17, 0, 0, 0, time.UTC)
	client := &aggregateIssueCommentClient{
		fakeGitCodeClient: &fakeGitCodeClient{
			listIssuesPages: []gitcode.Page[gitcode.IssueSummary]{
				{Items: []gitcode.IssueSummary{{ID: "7301", Number: 31, Title: "Fallback", State: "open", Comments: 1, CreatedAt: base, UpdatedAt: base}}},
				{Items: nil},
			},
			commentsByIssue: map[int][]gitcode.Comment{31: {{ID: "fallback-comment", Body: "fallback"}}},
		},
		err: gitcode.ErrUnsupportedCapability{CapabilityKey: "repository_issue_comments", Message: "unsupported fixture"},
	}
	store := newBulkIssueCommentStore(t, ctx, "aggregate-fallback")
	defer store.Close()
	svc := NewWithClient(store, client)
	if _, err := svc.BulkSyncIssues(ctx, BulkSyncRequest{RepoID: "aggregate-fallback", PerPage: 1, Bounds: &SyncBounds{MaxPages: 10}}); err != nil {
		t.Fatal(err)
	}
	result, err := svc.BulkSyncIssueComments(ctx, BulkSyncRequest{RepoID: "aggregate-fallback", Bounds: &SyncBounds{}})
	if err != nil || len(client.requests) != 1 || client.commentCalls != 1 || result.IssueComments.Strategy != "per_issue_fallback" || result.IssueComments.FallbackReason != "aggregate_endpoint_unsupported" || result.SuccessCount != 1 {
		t.Fatalf("result=%#v aggregate=%d per_issue=%d err=%v", result, len(client.requests), client.commentCalls, err)
	}
}

func newBulkIssueCommentStore(t *testing.T, ctx context.Context, repoID string) *cache.SQLiteStore {
	t.Helper()
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: repoID, Owner: "owner", Name: "repo", APIBaseURL: "https://example.invalid/api", Scopes: []cache.RepositoryScope{cache.RepositoryScopeIssues}}); err != nil {
		store.Close()
		t.Fatal(err)
	}
	return store
}

func TestBulkSyncIssuesIgnoresDeferredIssueCommentsRead(t *testing.T) {
	ctx := context.Background()
	base := time.Date(2026, 6, 26, 16, 30, 0, 0, time.UTC)
	client := &fakeGitCodeClient{
		listIssuesPages: []gitcode.Page[gitcode.IssueSummary]{
			{Items: []gitcode.IssueSummary{{ID: "4119847", Number: 16, Title: "Live issue", Body: "live body", State: "open", Comments: 1, CreatedAt: base, UpdatedAt: base}}, Page: 1, PerPage: 1},
		},
		listIssueCommentsErr: gitcode.ErrUnsupportedCapability{
			CapabilityKey: "comments_read",
			Message:       "comments are deferred",
		},
	}
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: "bulk-issues-comments-deferred", Owner: "owner", Name: "repo", APIBaseURL: "https://example.invalid/api", Scopes: []cache.RepositoryScope{cache.RepositoryScopeIssues}}); err != nil {
		t.Fatal(err)
	}
	svc := NewWithClient(store, client)
	result, err := svc.BulkSyncIssues(ctx, BulkSyncRequest{RepoID: "bulk-issues-comments-deferred", PerPage: 1})
	if err != nil {
		t.Fatalf("BulkSyncIssues returned error: %v", err)
	}
	if result.SuccessCount != 1 || result.FailureCount != 0 {
		t.Fatalf("counts = success %d failure %d, want 1/0", result.SuccessCount, result.FailureCount)
	}
	if len(result.Results) != 1 || result.Results[0].Counts.Deferred != 1 || client.commentCalls != 0 {
		t.Fatalf("parent result = %+v calls=%d, want queued comments without child read", result.Results, client.commentCalls)
	}
	source, err := store.GetSourceScoped(ctx, "bulk-issues-comments-deferred", "ISSUE-16")
	if err != nil {
		t.Fatalf("ISSUE-16 missing: %v", err)
	}
	if source.Title != "Live issue" || source.Body != "live body" {
		t.Fatalf("source=%+v", source)
	}
	drained, err := svc.BulkSyncIssueComments(ctx, BulkSyncRequest{RepoID: "bulk-issues-comments-deferred", PerPage: 1})
	if err != nil {
		t.Fatalf("BulkSyncIssueComments returned error: %v", err)
	}
	if drained.TraversalStatus != "deferred" || drained.StopReason != "comments_read_unsupported" || drained.IssueComments == nil || drained.IssueComments.Deferred != 1 || client.commentCalls != 1 {
		t.Fatalf("drained=%+v calls=%d", drained, client.commentCalls)
	}
}

func TestBulkSyncIssuesDefersRateLimitedIssueCommentsRead(t *testing.T) {
	ctx := context.Background()
	base := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
	client := &fakeGitCodeClient{
		listIssuesPages: []gitcode.Page[gitcode.IssueSummary]{
			{Items: []gitcode.IssueSummary{{ID: "11985", Number: 11985, Title: "Runtime issue", Body: "primary issue body", State: "open", Comments: 3, CreatedAt: base, UpdatedAt: base}}, Page: 1, PerPage: 1},
			{Items: []gitcode.IssueSummary{{ID: "11985", Number: 11985, Title: "Runtime issue", Body: "primary issue body", State: "open", Comments: 3, CreatedAt: base, UpdatedAt: base}}, Page: 1, PerPage: 1},
		},
		listIssueCommentsErr: gitcode.ErrRateLimited{
			Endpoint:      "/api/v5/repos/owner/repo/issues/11985/comments",
			RetryAfter:    2 * time.Second,
			RawRetryAfter: "2",
			Attempts:      3,
		},
	}
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: "bulk-issues-comments-rate-limited", Owner: "owner", Name: "repo", APIBaseURL: "https://example.invalid/api", Scopes: []cache.RepositoryScope{cache.RepositoryScopeIssues}}); err != nil {
		t.Fatal(err)
	}
	svc := NewWithClient(store, client)
	result, err := svc.BulkSyncIssues(ctx, BulkSyncRequest{RepoID: "bulk-issues-comments-rate-limited", PerPage: 1})
	if err != nil {
		t.Fatalf("BulkSyncIssues returned error: %v", err)
	}
	if result.SuccessCount != 1 || result.FailureCount != 0 {
		t.Fatalf("counts = success %d failure %d, want 1/0", result.SuccessCount, result.FailureCount)
	}
	if len(result.Results) != 1 || result.Results[0].Counts.Deferred != 1 || result.Results[0].Counts.FetchedDetail != 0 || client.commentCalls != 0 {
		t.Fatalf("result counts = %+v calls=%d, want queued comment work without child reads", result.Results, client.commentCalls)
	}
	source, err := store.GetSourceScoped(ctx, "bulk-issues-comments-rate-limited", "ISSUE-11985")
	if err != nil {
		t.Fatalf("ISSUE-11985 missing: %v", err)
	}
	if source.Title != "Runtime issue" || source.Body != "primary issue body" {
		t.Fatalf("source=%+v", source)
	}
	record, err := store.GetRecord(ctx, "bulk-issues-comments-rate-limited", "ISSUE-11985")
	if err != nil {
		t.Fatalf("record missing: %v", err)
	}
	if len(record.Comments) != 0 {
		t.Fatalf("comments=%+v, want comments deferred", record.Comments)
	}
	status, err := store.GetSyncStatusScoped(ctx, "bulk-issues-comments-rate-limited", "ISSUE-11985")
	if err != nil {
		t.Fatalf("sync status missing: %v", err)
	}
	if status.Status != "fresh" {
		t.Fatalf("primary sync status = %q, want fresh despite queued child coverage", status.Status)
	}
	deferred, err := svc.BulkSyncIssueComments(ctx, BulkSyncRequest{RepoID: "bulk-issues-comments-rate-limited", PerPage: 1})
	if err != nil {
		t.Fatalf("BulkSyncIssueComments deferred returned error: %v", err)
	}
	if deferred.TraversalStatus != "deferred" || deferred.StopReason != "rate_limited" || deferred.IssueComments == nil || deferred.IssueComments.Deferred != 1 {
		t.Fatalf("deferred drain=%+v", deferred)
	}

	client.listIssueCommentsErr = nil
	client.commentsByIssue = map[int][]gitcode.Comment{
		11985: {{ID: "comment-1", Author: "reviewer", Body: "late comment", CreatedAt: base.Add(time.Minute), UpdatedAt: base.Add(time.Minute)}},
	}
	second, err := svc.BulkSyncIssueComments(ctx, BulkSyncRequest{RepoID: "bulk-issues-comments-rate-limited", IdempotencyKey: "retry-comments", PerPage: 1})
	if err != nil {
		t.Fatalf("second BulkSyncIssues returned error: %v", err)
	}
	if len(second.Results) != 1 || second.Results[0].Counts.FetchedDetail != 1 || second.Results[0].Counts.Deferred != 0 || second.IssueComments == nil || second.IssueComments.Complete != 1 {
		t.Fatalf("second counts = %+v queue=%#v, want completed queued comment retry", second.Results, second.IssueComments)
	}
	record, err = store.GetRecord(ctx, "bulk-issues-comments-rate-limited", "ISSUE-11985")
	if err != nil {
		t.Fatalf("record missing after retry: %v", err)
	}
	if len(record.Comments) != 1 || record.Comments[0].CommentID != "comment-1" {
		t.Fatalf("comments=%+v, want retry comment", record.Comments)
	}
}

func TestBulkSyncIssuesFetchesCommentsWhenListRevisionChanges(t *testing.T) {
	ctx := context.Background()
	base := time.Date(2026, 6, 27, 11, 0, 0, 0, time.UTC)
	client := &fakeGitCodeClient{
		listIssuesPages: []gitcode.Page[gitcode.IssueSummary]{
			{Items: []gitcode.IssueSummary{{ID: "11", Number: 11, Title: "Issue", Body: "body", State: "open", Comments: 0, CreatedAt: base, UpdatedAt: base}}, Page: 1, PerPage: 100},
			{Items: []gitcode.IssueSummary{{ID: "11", Number: 11, Title: "Issue", Body: "body", State: "open", Comments: 1, CreatedAt: base, UpdatedAt: base.Add(time.Minute)}}, Page: 1, PerPage: 100},
			{Items: []gitcode.IssueSummary{{ID: "11", Number: 11, Title: "Issue", Body: "body", State: "open", Comments: 0, CreatedAt: base, UpdatedAt: base.Add(2 * time.Minute)}}, Page: 1, PerPage: 100},
		},
		commentsByIssue: map[int][]gitcode.Comment{
			11: {{ID: "c11", Author: "author", Body: "new comment", CreatedAt: base.Add(time.Minute), UpdatedAt: base.Add(time.Minute)}},
		},
	}
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: "bulk-issues-comment-revision", Owner: "owner", Name: "repo", APIBaseURL: "https://example.invalid/api", Scopes: []cache.RepositoryScope{cache.RepositoryScopeIssues}}); err != nil {
		t.Fatal(err)
	}
	svc := NewWithClient(store, client)

	if _, err := svc.BulkSyncIssues(ctx, BulkSyncRequest{RepoID: "bulk-issues-comment-revision", IdempotencyKey: "issues-first", PerPage: 100}); err != nil {
		t.Fatalf("first BulkSyncIssues returned error: %v", err)
	}
	second, err := svc.BulkSyncIssues(ctx, BulkSyncRequest{RepoID: "bulk-issues-comment-revision", IdempotencyKey: "issues-second", PerPage: 100})
	if err != nil {
		t.Fatalf("second BulkSyncIssues returned error: %v", err)
	}
	if client.commentCalls != 0 {
		t.Fatalf("parent ListIssueComments calls = %d, want 0 after changed list revision", client.commentCalls)
	}
	if second.SuccessCount != 1 || len(second.Results) != 1 {
		t.Fatalf("second result count = %d/%d, want 1/1", second.SuccessCount, len(second.Results))
	}
	if second.Results[0].Counts.FetchedDetail != 0 || second.Results[0].Counts.SkippedByRevision != 0 || second.Results[0].Counts.Deferred != 1 {
		t.Fatalf("second counts = %#v, want changed parent plus queued comments", second.Results[0].Counts)
	}
	if _, err := svc.BulkSyncIssueComments(ctx, BulkSyncRequest{RepoID: "bulk-issues-comment-revision", PerPage: 100}); err != nil {
		t.Fatalf("BulkSyncIssueComments returned error: %v", err)
	}
	if client.commentCalls != 1 {
		t.Fatalf("ListIssueComments calls after explicit drain = %d, want 1", client.commentCalls)
	}
	record, err := store.GetRecord(ctx, "bulk-issues-comment-revision", "ISSUE-11")
	if err != nil {
		t.Fatalf("ISSUE-11 record missing: %v", err)
	}
	if len(record.Comments) != 1 || record.Comments[0].CommentID != "c11" {
		t.Fatalf("comments=%+v, want c11", record.Comments)
	}
	if _, err := svc.BulkSyncIssues(ctx, BulkSyncRequest{RepoID: "bulk-issues-comment-revision", IdempotencyKey: "issues-third", PerPage: 100}); err != nil {
		t.Fatalf("third BulkSyncIssues returned error: %v", err)
	}
	if client.commentCalls != 1 {
		t.Fatalf("ListIssueComments calls after zero-comment parent revision = %d, want 1", client.commentCalls)
	}
	record, err = store.GetRecord(ctx, "bulk-issues-comment-revision", "ISSUE-11")
	if err != nil {
		t.Fatalf("ISSUE-11 record missing after zero-comment revision: %v", err)
	}
	if len(record.Comments) != 0 {
		t.Fatalf("comments=%+v, want exact empty coverage after list count reached zero", record.Comments)
	}
}

func TestBulkSyncWikiPartialFailureCollectsSuccessAndFailure(t *testing.T) {
	ctx := context.Background()
	base := time.Date(2026, 6, 22, 15, 0, 0, 0, time.UTC)
	client := &fakeGitCodeClient{
		listWikiPages: []gitcode.Page[gitcode.WikiPage]{
			{Items: []gitcode.WikiPage{{Slug: "Home", Title: "Home"}, {Slug: "Missing", Title: "Missing"}}, Page: 1, PerPage: 100},
		},
		wikiBySlug: map[string]gitcode.WikiPage{
			"Home": {Slug: "Home", Title: "Home", Body: "home body", Revision: "rev-home", CreatedAt: base, UpdatedAt: base},
		},
		errors: []error{nil, gitcode.ErrRemoteNotFound{Endpoint: "/wiki/Missing", Alias: "wiki:Missing"}},
	}
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: "bulk-wiki", Owner: "owner", Name: "repo", APIBaseURL: "https://example.invalid/api", Scopes: []cache.RepositoryScope{cache.RepositoryScopeWiki}}); err != nil {
		t.Fatal(err)
	}
	svc := NewWithClient(store, client)
	result, err := svc.BulkSyncWiki(ctx, BulkSyncRequest{RepoID: "bulk-wiki", IdempotencyKey: "bulk-wiki"})
	if err == nil {
		t.Fatal("BulkSyncWiki expected partial error, got nil")
	}
	var partial *PartialSyncError
	if !errors.As(err, &partial) {
		t.Fatalf("BulkSyncWiki error = %T %v, want *PartialSyncError", err, err)
	}
	if result.SuccessCount != 1 || result.FailureCount != 1 {
		t.Fatalf("counts = success %d failure %d, want 1/1", result.SuccessCount, result.FailureCount)
	}
	if len(result.Results) != 1 || len(result.Failures) != 1 {
		t.Fatalf("result lengths = %d/%d, want 1/1", len(result.Results), len(result.Failures))
	}
	if !strings.Contains(partial.Error(), "1 succeeded") || !strings.Contains(partial.Error(), "1 failed") {
		t.Fatalf("PartialSyncError.Error() = %q, want summary counts", partial.Error())
	}
	if _, err := store.GetSourceScoped(ctx, "bulk-wiki", "WIKI-HOME"); err != nil {
		t.Fatalf("WIKI-HOME missing: %v", err)
	}
}

func TestBulkSyncWikiSkipsUnchangedPageByListRevision(t *testing.T) {
	ctx := context.Background()
	base := time.Date(2026, 6, 27, 9, 0, 0, 0, time.UTC)
	client := &fakeGitCodeClient{
		listWikiPages: []gitcode.Page[gitcode.WikiPage]{
			{Items: []gitcode.WikiPage{{Slug: "Home", Title: "Home", Revision: "rev-home"}}, Page: 1, PerPage: 100},
			{Items: []gitcode.WikiPage{{Slug: "Home", Title: "Home", Revision: "rev-home"}}, Page: 1, PerPage: 100},
		},
		wikiBySlug: map[string]gitcode.WikiPage{
			"Home": {ID: "wiki-home", Slug: "Home", Title: "Home", Body: "home body", Revision: "rev-home", CreatedAt: base, UpdatedAt: base},
		},
	}
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: "wiki-revision-skip", Owner: "owner", Name: "repo", APIBaseURL: "https://example.invalid/api", Scopes: []cache.RepositoryScope{cache.RepositoryScopeWiki}}); err != nil {
		t.Fatal(err)
	}
	svc := NewWithClient(store, client)

	first, err := svc.BulkSyncWiki(ctx, BulkSyncRequest{RepoID: "wiki-revision-skip", IdempotencyKey: "wiki-first", PerPage: 100})
	if err != nil {
		t.Fatalf("first BulkSyncWiki returned error: %v", err)
	}
	if first.SuccessCount != 1 || client.wikiCalls != 1 {
		t.Fatalf("first sync success/wikiCalls = %d/%d, want 1/1", first.SuccessCount, client.wikiCalls)
	}

	second, err := svc.BulkSyncWiki(ctx, BulkSyncRequest{RepoID: "wiki-revision-skip", IdempotencyKey: "wiki-second", PerPage: 100})
	if err != nil {
		t.Fatalf("second BulkSyncWiki returned error: %v", err)
	}
	if client.wikiCalls != 1 {
		t.Fatalf("wiki body fetches = %d, want 1 after unchanged second sync", client.wikiCalls)
	}
	if second.SuccessCount != 1 || len(second.Results) != 1 {
		t.Fatalf("second result count = %d/%d, want 1/1", second.SuccessCount, len(second.Results))
	}
	result := second.Results[0]
	if !result.ZeroDelta {
		t.Fatalf("second result ZeroDelta = false, want true")
	}
	if result.Counts.Listed != 1 || result.Counts.SkippedByRevision != 1 || result.Counts.FetchedDetail != 0 {
		t.Fatalf("second counts = %#v, want listed=1 skipped_by_revision=1 fetched_detail=0", result.Counts)
	}
	if result.Counts.Fetched != 1 || result.Counts.Skipped != 1 {
		t.Fatalf("compat counts = %#v, want fetched=1 skipped=1", result.Counts)
	}
}

func TestBulkSyncWikiFetchesChangedListRevision(t *testing.T) {
	ctx := context.Background()
	base := time.Date(2026, 6, 27, 10, 0, 0, 0, time.UTC)
	client := &fakeGitCodeClient{
		listWikiPages: []gitcode.Page[gitcode.WikiPage]{
			{Items: []gitcode.WikiPage{{Slug: "Home", Title: "Home", Revision: "rev-1"}}, Page: 1, PerPage: 100},
			{Items: []gitcode.WikiPage{{Slug: "Home", Title: "Home", Revision: "rev-2"}}, Page: 1, PerPage: 100},
		},
		wikiBySlug: map[string]gitcode.WikiPage{
			"Home": {ID: "wiki-home", Slug: "Home", Title: "Home", Body: "first body", Revision: "rev-1", CreatedAt: base, UpdatedAt: base},
		},
	}
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: "wiki-revision-change", Owner: "owner", Name: "repo", APIBaseURL: "https://example.invalid/api", Scopes: []cache.RepositoryScope{cache.RepositoryScopeWiki}}); err != nil {
		t.Fatal(err)
	}
	svc := NewWithClient(store, client)

	if _, err := svc.BulkSyncWiki(ctx, BulkSyncRequest{RepoID: "wiki-revision-change", IdempotencyKey: "wiki-first", PerPage: 100}); err != nil {
		t.Fatalf("first BulkSyncWiki returned error: %v", err)
	}
	client.wikiBySlug["Home"] = gitcode.WikiPage{ID: "wiki-home", Slug: "Home", Title: "Home", Body: "second body", Revision: "rev-2", CreatedAt: base, UpdatedAt: base.Add(time.Hour)}

	second, err := svc.BulkSyncWiki(ctx, BulkSyncRequest{RepoID: "wiki-revision-change", IdempotencyKey: "wiki-second", PerPage: 100})
	if err != nil {
		t.Fatalf("second BulkSyncWiki returned error: %v", err)
	}
	if client.wikiCalls != 2 {
		t.Fatalf("wiki body fetches = %d, want 2 after changed revision", client.wikiCalls)
	}
	if second.SuccessCount != 1 || len(second.Results) != 1 {
		t.Fatalf("second result count = %d/%d, want 1/1", second.SuccessCount, len(second.Results))
	}
	if second.Results[0].Counts.Listed != 1 || second.Results[0].Counts.FetchedDetail != 1 || second.Results[0].Counts.Updated != 1 {
		t.Fatalf("second counts = %#v, want listed=1 fetched_detail=1 updated=1", second.Results[0].Counts)
	}
	source, err := store.GetSourceScoped(ctx, "wiki-revision-change", "WIKI-HOME")
	if err != nil {
		t.Fatalf("WIKI-HOME missing: %v", err)
	}
	status, err := store.GetSyncStatusScoped(ctx, "wiki-revision-change", "WIKI-HOME")
	if err != nil {
		t.Fatalf("WIKI-HOME sync status missing: %v", err)
	}
	if source.Body != "second body" || status.RemoteRevision != "rev-2" {
		t.Fatalf("source body/revision = %q/%q, want second body/rev-2", source.Body, status.RemoteRevision)
	}
}

func TestBulkSyncAllAggregatesIssuesAndWiki(t *testing.T) {
	ctx := context.Background()
	base := time.Date(2026, 6, 22, 16, 0, 0, 0, time.UTC)
	client := &fakeGitCodeClient{
		listIssuesPages: []gitcode.Page[gitcode.IssueSummary]{{Items: []gitcode.IssueSummary{{Number: 42, Title: "Issue"}}, Page: 1, PerPage: 100}},
		issuesByNumber:  map[int]gitcode.Issue{42: {Number: 42, Title: "Issue", Body: "body", State: "open", CreatedAt: base, UpdatedAt: base}},
		listWikiPages:   []gitcode.Page[gitcode.WikiPage]{{Items: []gitcode.WikiPage{{Slug: "Home", Title: "Home"}}, Page: 1, PerPage: 100}},
		wikiBySlug:      map[string]gitcode.WikiPage{"Home": {Slug: "Home", Title: "Home", Body: "body", Revision: "rev", CreatedAt: base, UpdatedAt: base}},
	}
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: "bulk-all", Owner: "owner", Name: "repo", APIBaseURL: "https://example.invalid/api", Scopes: []cache.RepositoryScope{cache.RepositoryScopeIssues, cache.RepositoryScopeWiki}}); err != nil {
		t.Fatal(err)
	}
	svc := NewWithClient(store, client)
	result, err := svc.BulkSyncAll(ctx, BulkSyncRequest{RepoID: "bulk-all", IdempotencyKey: "bulk-all"})
	if err != nil {
		t.Fatalf("BulkSyncAll returned error: %v", err)
	}
	if result.SuccessCount != 2 || result.FailureCount != 0 {
		t.Fatalf("counts = success %d failure %d, want 2/0", result.SuccessCount, result.FailureCount)
	}
	if _, err := store.GetSourceScoped(ctx, "bulk-all", "ISSUE-42"); err != nil {
		t.Fatalf("ISSUE-42 missing: %v", err)
	}
	if _, err := store.GetSourceScoped(ctx, "bulk-all", "WIKI-HOME"); err != nil {
		t.Fatalf("WIKI-HOME missing: %v", err)
	}
}

func TestBulkSyncPullRequestsAndCommentsCreatesSearchableSources(t *testing.T) {
	ctx := context.Background()
	base := time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC)
	client := &fakeGitCodeClient{
		listPRPages: []gitcode.Page[gitcode.PullRequest]{
			{Items: []gitcode.PullRequest{{ID: "9001", Number: 7, Title: "Add live PR sync", Body: "PR body with search needle", State: "open", Labels: []string{"enhancement"}, Base: "main", Head: "topic", CreatedAt: base, UpdatedAt: base}}, Page: 1, PerPage: 100},
		},
		prCommentsByPR: map[int][]gitcode.PRComment{
			7: {{ID: "301", Body: "review comment needle", Author: "alice", DiscussionID: "D7", PRNumber: 7, CreatedAt: base, UpdatedAt: base}},
		},
	}
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: "bulk-pr", Owner: "owner", Name: "repo", APIBaseURL: "https://example.invalid/api", Scopes: []cache.RepositoryScope{cache.RepositoryScopeIssues}}); err != nil {
		t.Fatal(err)
	}
	svc := NewWithClient(store, client)
	prResult, err := svc.BulkSyncPullRequests(ctx, BulkSyncRequest{RepoID: "bulk-pr", IdempotencyKey: "bulk-pr", PerPage: 100})
	if err != nil {
		t.Fatalf("BulkSyncPullRequests returned error: %v", err)
	}
	if prResult.SuccessCount != 1 || prResult.Results[0].Record.ID != "PR-7" || prResult.Results[0].Record.Kind != "pull_request" {
		t.Fatalf("PR result=%+v", prResult)
	}
	if client.prCalls != 0 {
		t.Fatalf("bulk pull request sync GetPR calls = %d, want 0 because list payload is the current sync source", client.prCalls)
	}
	if prResult.Results[0].Counts.Listed != 1 || prResult.Results[0].Counts.FetchedDetail != 0 {
		t.Fatalf("PR counts=%#v, want listed=1 fetched_detail=0", prResult.Results[0].Counts)
	}
	commentResult, err := svc.BulkSyncPRComments(ctx, BulkSyncRequest{RepoID: "bulk-pr", IdempotencyKey: "bulk-pr-comments"})
	if err != nil {
		t.Fatalf("BulkSyncPRComments returned error: %v", err)
	}
	if commentResult.SuccessCount != 1 || commentResult.Results[0].Record.ID != "PRCOMMENT-7-301" || commentResult.Results[0].Record.Kind != "pr_comment" {
		t.Fatalf("comment result=%+v", commentResult)
	}
	if commentResult.Results[0].Counts.Listed != 1 || commentResult.Results[0].Counts.FetchedDetail != 0 {
		t.Fatalf("PR comment counts=%#v, want listed=1 fetched_detail=0", commentResult.Results[0].Counts)
	}
	pr, err := store.GetSourceScoped(ctx, "bulk-pr", "PR-7")
	if err != nil || pr.Kind != "pull_request" {
		t.Fatalf("PR source=%+v err=%v", pr, err)
	}
	comment, err := store.GetSourceScoped(ctx, "bulk-pr", "PRCOMMENT-7-301")
	if err != nil || comment.Kind != "pr_comment" {
		t.Fatalf("PR comment source=%+v err=%v", comment, err)
	}
	search, err := svc.SearchSources(ctx, SearchSourcesRequest{RepoID: "bulk-pr", Query: "needle", Kind: "pr_comment"})
	if err != nil {
		t.Fatalf("SearchSources returned error: %v", err)
	}
	if len(search.Results) != 1 || search.Results[0].ID != "PRCOMMENT-7-301" {
		t.Fatalf("search results=%+v", search.Results)
	}
}

func TestListPRDiscussionsGroupsRepliesAndFiltersUnresolved(t *testing.T) {
	ctx := context.Background()
	base := time.Date(2026, 6, 24, 9, 0, 0, 0, time.UTC)
	resolvedFalse := false
	resolvedTrue := true
	client := &fakeGitCodeClient{
		listPRPages: []gitcode.Page[gitcode.PullRequest]{
			{Items: []gitcode.PullRequest{{ID: "9001", Number: 7, Title: "Review target", Body: "body", State: "open", CreatedAt: base, UpdatedAt: base}}},
		},
		prCommentsByPR: map[int][]gitcode.PRComment{
			7: {
				{ID: "301", Body: "inline root", Author: "alice", DiscussionID: "D7", ReviewKind: "inline", Path: "internal/service/service.go", Line: 42, Resolved: &resolvedFalse, ParentID: "", Positions: []gitcode.PRCommentPosition{{PositionKind: "current", PositionType: "text", BaseSHA: "base-sha", StartSHA: "base-sha", HeadSHA: "head-sha", OldPath: "internal/service/service.go", NewPath: "internal/service/service.go", NewLine: 42, LineCode: "line-code", PatchsetIID: 1, DiffID: 99, VersionSHA: "head-sha", Side: "new", IsOutdated: &resolvedFalse}}, PRNumber: 7, CreatedAt: base, UpdatedAt: base},
				{ID: "302", Body: "reply", Author: "bob", DiscussionID: "D7", ReviewKind: "inline", Path: "internal/service/service.go", Line: 42, Resolved: &resolvedFalse, ParentID: "301", PRNumber: 7, CreatedAt: base.Add(time.Minute), UpdatedAt: base.Add(time.Minute)},
				{ID: "303", Body: "general note", Author: "carol", ReviewKind: "general", Resolved: &resolvedTrue, PRNumber: 7, CreatedAt: base.Add(2 * time.Minute), UpdatedAt: base.Add(2 * time.Minute)},
			},
		},
	}
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: "review-pr", Owner: "owner", Name: "repo", APIBaseURL: "https://example.invalid/api", Scopes: []cache.RepositoryScope{cache.RepositoryScopeIssues}}); err != nil {
		t.Fatal(err)
	}
	svc := NewWithClient(store, client)
	if _, err := svc.BulkSyncPullRequests(ctx, BulkSyncRequest{RepoID: "review-pr"}); err != nil {
		t.Fatalf("BulkSyncPullRequests returned error: %v", err)
	}
	if _, err := svc.BulkSyncPRComments(ctx, BulkSyncRequest{RepoID: "review-pr"}); err != nil {
		t.Fatalf("BulkSyncPRComments returned error: %v", err)
	}
	all, err := svc.ListPRDiscussions(ctx, PRDiscussionRequest{RepoID: "review-pr", Number: 7})
	if err != nil {
		t.Fatalf("ListPRDiscussions returned error: %v", err)
	}
	if len(all.Discussions) != 2 {
		t.Fatalf("discussions=%+v, want 2 groups", all.Discussions)
	}
	inline := all.Discussions[0]
	if inline.ID != "D7" || !inline.Replyable || inline.ReplyDiscussionID != "D7" || inline.ReplyUnavailableReason != "" || inline.Kind != "inline" || inline.Path != "internal/service/service.go" || inline.Line != 42 || len(inline.Comments) != 2 {
		t.Fatalf("inline discussion=%+v", inline)
	}
	if inline.Comments[0].Author != "alice" || inline.Comments[1].ParentID != "301" || inline.Comments[1].Body != "reply" {
		t.Fatalf("inline comments=%+v", inline.Comments)
	}
	if inline.Position == nil || inline.Position.Kind != "current" || inline.Position.NewPath != "internal/service/service.go" || inline.Position.NewLine != 42 || inline.Position.LineCode != "line-code" || inline.Position.DiffID != 99 {
		t.Fatalf("inline position=%+v", inline.Position)
	}
	if len(inline.Comments[0].Positions) != 1 || inline.Comments[0].Positions[0].BaseSHA != "base-sha" {
		t.Fatalf("inline comment positions=%+v", inline.Comments[0].Positions)
	}
	general := all.Discussions[1]
	if general.ID != "comment:303" || general.Replyable || general.ReplyUnavailableReason == "" || general.Kind != "general" || len(general.Comments) != 1 || general.Comments[0].Author != "carol" {
		t.Fatalf("general discussion=%+v", general)
	}
	unresolved, err := svc.ListPRDiscussions(ctx, PRDiscussionRequest{RepoID: "review-pr", Number: 7, UnresolvedOnly: true})
	if err != nil {
		t.Fatalf("ListPRDiscussions unresolved returned error: %v", err)
	}
	if len(unresolved.Discussions) != 1 || unresolved.Discussions[0].ID != "D7" {
		t.Fatalf("unresolved discussions=%+v, want D7 only", unresolved.Discussions)
	}
	empty, err := svc.ListPRDiscussions(ctx, PRDiscussionRequest{RepoID: "review-pr", Number: 99})
	if err != nil {
		t.Fatalf("empty ListPRDiscussions returned error: %v", err)
	}
	if len(empty.Discussions) != 0 {
		t.Fatalf("empty discussions=%+v, want empty", empty.Discussions)
	}
}

func TestListPRDiscussionsKeepsV5OnlyNestedRepliesInOneSyntheticThread(t *testing.T) {
	ctx := context.Background()
	base := time.Date(2026, 8, 18, 9, 0, 0, 0, time.UTC)
	client := &fakeGitCodeClient{
		listPRPages: []gitcode.Page[gitcode.PullRequest]{{Items: []gitcode.PullRequest{{ID: "9001", Number: 7, Title: "Review target", State: "open", CreatedAt: base, UpdatedAt: base}}}},
		prCommentsByPR: map[int][]gitcode.PRComment{7: {
			{ID: "301", Body: "root", ReviewKind: "inline", Path: "x.go", Line: 7, PRNumber: 7, CreatedAt: base, UpdatedAt: base},
			{ID: "302", Body: "nested", ReviewKind: "inline", ParentID: "301", Path: "x.go", Line: 7, PRNumber: 7, CreatedAt: base.Add(time.Minute), UpdatedAt: base.Add(time.Minute)},
		}},
	}
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: "v5-only", Owner: "owner", Name: "repo", Scopes: []cache.RepositoryScope{cache.RepositoryScopeIssues}}); err != nil {
		t.Fatal(err)
	}
	svc := NewWithClient(store, client)
	if _, err := svc.BulkSyncPullRequests(ctx, BulkSyncRequest{RepoID: "v5-only"}); err != nil {
		t.Fatal(err)
	}
	if _, err := svc.BulkSyncPRComments(ctx, BulkSyncRequest{RepoID: "v5-only"}); err != nil {
		t.Fatal(err)
	}
	got, err := svc.ListPRDiscussions(ctx, PRDiscussionRequest{RepoID: "v5-only", Number: 7})
	if err != nil {
		t.Fatal(err)
	}
	if len(got.Discussions) != 1 || got.Discussions[0].ID != "comment:301" || got.Discussions[0].Replyable || len(got.Discussions[0].Comments) != 2 {
		t.Fatalf("discussions=%+v, want one non-replyable synthetic thread", got.Discussions)
	}
}

func TestBulkSyncIssuesListFailureReturnsError(t *testing.T) {
	ctx := context.Background()
	client := &fakeGitCodeClient{listIssuesErrors: []error{gitcode.ErrRateLimited{Endpoint: "/issues", RetryAfter: time.Second, Attempts: 1}}}
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: "bulk-list-failure", Owner: "owner", Name: "repo", APIBaseURL: "https://example.invalid/api", Scopes: []cache.RepositoryScope{cache.RepositoryScopeIssues}}); err != nil {
		t.Fatal(err)
	}
	svc := NewWithClient(store, client)
	result, err := svc.BulkSyncIssues(ctx, BulkSyncRequest{RepoID: "bulk-list-failure"})
	if err == nil {
		t.Fatal("BulkSyncIssues expected error, got nil")
	}
	var partial *PartialSyncError
	if !errors.As(err, &partial) {
		t.Fatalf("BulkSyncIssues error = %T %v, want *PartialSyncError", err, err)
	}
	if result.SuccessCount != 0 || result.FailureCount != 1 {
		t.Fatalf("counts = success %d failure %d, want 0/1", result.SuccessCount, result.FailureCount)
	}
	if result.PagesListed != 0 || result.RecordsListed != 0 {
		t.Fatalf("list stats = pages %d records %d, want 0/0", result.PagesListed, result.RecordsListed)
	}
	var failure ErrSyncFailure
	if !errors.As(result.Failures[0].Err, &failure) || failure.Mode != "rate_limited" {
		t.Fatalf("failure error = %T %v, want rate_limited ErrSyncFailure", result.Failures[0].Err, result.Failures[0].Err)
	}
}

func TestBulkSyncIssuesCachesRecoverablePrefixAndKeepsPartialFrontier(t *testing.T) {
	ctx := context.Background()
	base := time.Date(2026, 8, 18, 13, 0, 0, 0, time.UTC)
	decodeErr := gitcode.ErrPartialResponse{Endpoint: "/issues", Got: 512, Offset: 511, Attempts: 2, Message: "truncated JSON"}
	client := &fakeGitCodeClient{
		listIssuesErrors: []error{decodeErr},
		listIssuesErrorPages: []gitcode.Page[gitcode.IssueSummary]{{Items: []gitcode.IssueSummary{
			{ID: "1", Number: 1, Title: "First recovered", Body: "first", State: "open", CreatedAt: base, UpdatedAt: base},
			{ID: "2", Number: 2, Title: "Second recovered", Body: "second", State: "open", CreatedAt: base.Add(-time.Minute), UpdatedAt: base.Add(-time.Minute)},
		}, Page: 1, PerPage: 10}},
	}
	store, err := cache.NewInMemorySQLiteStore(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	const repoID = "bulk-partial-prefix"
	if err := store.AddRepository(ctx, cache.RepositoryBinding{RepoID: repoID, Owner: "owner", Name: "repo", APIBaseURL: "https://example.invalid/api", Scopes: []cache.RepositoryScope{cache.RepositoryScopeIssues}}); err != nil {
		t.Fatal(err)
	}
	svc := NewWithClient(store, client)
	result, err := svc.BulkSyncIssues(ctx, BulkSyncRequest{RepoID: repoID, PerPage: 10, Bounds: &SyncBounds{MaxPages: 5}})
	var partial *PartialSyncError
	if !errors.As(err, &partial) {
		t.Fatalf("result=%+v err=%T %v, want typed partial", result, err, err)
	}
	if result.SuccessCount != 2 || result.FailureCount != 1 || len(result.Results) != 2 || len(result.Failures) != 1 {
		t.Fatalf("result=%+v, want 2 successes plus collection failure", result)
	}
	if result.Failures[0].FailureClass != "partial_response" || result.Failures[0].ResponseBytes != 512 || result.Failures[0].DecodeOffset != 511 || result.Failures[0].Attempts != 2 {
		t.Fatalf("failure=%+v", result.Failures[0])
	}
	for _, id := range []string{"ISSUE-1", "ISSUE-2"} {
		if _, err := store.GetSourceScoped(ctx, repoID, id); err != nil {
			t.Fatalf("recovered %s missing: %v", id, err)
		}
	}
	frontier, ok, err := store.GetSyncFrontier(ctx, repoID, "issue", syncOrderingUpdatedAtDesc, syncFilterStateAll)
	if err != nil || !ok || frontier.Status == "complete" {
		t.Fatalf("frontier=%+v ok=%t err=%v, want non-complete retryable frontier", frontier, ok, err)
	}
}