package workspace

import (
	"context"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/desktop/internal/types"
)

// reviewFilesWithClearedTokens returns a copy of files with the Token field
// cleared. Token is derived from git blob hashes (for branch files) or
// filesystem stat (for working files) and is therefore non-deterministic
// across test runs; tests that focus on path/change-type/etc. assert against
// the cleared form and verify token behavior separately.
func reviewFilesWithClearedTokens(files []types.ReviewFile) []types.ReviewFile {
	out := make([]types.ReviewFile, len(files))
	for i, f := range files {
		f.Token = ""
		out[i] = f
	}
	return out
}

type manuallyExpiredReviewSeedContext struct {
	context.Context
	expired bool
}

func (ctx *manuallyExpiredReviewSeedContext) Err() error {
	if ctx.expired {
		return context.DeadlineExceeded
	}
	return ctx.Context.Err()
}

type interleavingReviewSeedContext struct {
	context.Context
	err   error
	onErr func()
}

func (ctx *interleavingReviewSeedContext) Err() error {
	if ctx.onErr != nil {
		onErr := ctx.onErr
		ctx.onErr = nil
		onErr()
	}
	return ctx.err
}

func TestReviewFilesWorkingScopeHonorsGitInfoExclude(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)

	excludePath := filepath.Join(repoDir, ".git", "info", "exclude")
	require.NoError(t, os.MkdirAll(filepath.Dir(excludePath), 0o755))
	require.NoError(t, os.WriteFile(excludePath, []byte(".codexpotter/\n"), 0o644))

	require.NoError(t, os.MkdirAll(filepath.Join(repoDir, ".codexpotter"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".codexpotter", "kb.md"), []byte("hidden\n"), 0o644))
	require.NoError(t, os.MkdirAll(filepath.Join(repoDir, "notes"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "notes", "visible.md"), []byte("todo\n"), 0o644))

	files, err := ReviewFiles(repoDir, "working", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, []types.ReviewFile{{
		Path:               "notes/visible.md",
		ChangeType:         "A",
		Unstaged:           true,
		UnstagedChangeType: "A",
	}}, reviewFilesWithClearedTokens(files))

	summary, err := ReviewSummary(repoDir, "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, "ready", summary.Status)
	require.True(t, summary.Dirty)
	require.Equal(t, 1, summary.UnstagedFileCount)
	require.Zero(t, summary.StagedFileCount)
	require.Equal(t, 1, summary.WorkingAdditionCount)
	require.Zero(t, summary.WorkingDeletionCount)

	diff, err := ReviewDiff(repoDir, "working", ".codexpotter/kb.md", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, "not_changed", diff.Status)
	require.Equal(t, ".codexpotter/kb.md", diff.Path)
}

func TestReviewFilesWorkingScopeTreatsIntentToAddAsUnstaged(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)

	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "abc"), []byte("abc\n"), 0o644))
	runGit(t, repoDir, "add", "-N", "abc")
	require.Equal(t, " A abc\n", runGitOutput(t, repoDir, "status", "--short"))

	files, err := ReviewFiles(repoDir, "working", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, []types.ReviewFile{{
		Path:               "abc",
		ChangeType:         "A",
		Unstaged:           true,
		UnstagedChangeType: "A",
	}}, reviewFilesWithClearedTokens(files))

	summary, err := ReviewSummary(repoDir, "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, "ready", summary.Status)
	require.True(t, summary.Dirty)
	require.Zero(t, summary.StagedFileCount)
	require.Equal(t, 1, summary.UnstagedFileCount)
	require.Equal(t, 1, summary.WorkingAdditionCount)
	require.Zero(t, summary.WorkingDeletionCount)

	diff, err := ReviewDiff(repoDir, "working", "abc", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, "ready", diff.Status)
	require.Equal(t, "abc", diff.Path)
	require.Len(t, diff.Sections, 1)
	require.Equal(t, "unstaged", diff.Sections[0].Key)
	require.Equal(t, "A", diff.Sections[0].ChangeType)
	require.Contains(t, diff.Sections[0].Diff, "+abc")
}

func TestReviewWorktreeSupportsSummaryFilesAndDiffs(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)

	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "branch.txt"), []byte("branch base\n"), 0o644))
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "staged.txt"), []byte("staged base\n"), 0o644))
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "unstaged.txt"), []byte("unstaged base\n"), 0o644))
	runGit(t, repoDir, "add", "branch.txt", "staged.txt", "unstaged.txt")
	runGit(t, repoDir, "commit", "-m", "add review fixtures")

	worktreeDir := filepath.Join(t.TempDir(), "feature-worktree")
	runGit(t, repoDir, "worktree", "add", "-b", "feature", worktreeDir)

	require.NoError(t, os.WriteFile(filepath.Join(worktreeDir, "branch.txt"), []byte("branch head\n"), 0o644))
	runGit(t, worktreeDir, "add", "branch.txt")
	runGit(t, worktreeDir, "commit", "-m", "feature change")

	gitDir := strings.TrimSpace(runGitOutput(t, worktreeDir, "rev-parse", "--git-dir"))
	if !filepath.IsAbs(gitDir) {
		gitDir = filepath.Join(worktreeDir, gitDir)
	}
	excludePath := filepath.Join(gitDir, "info", "exclude")
	require.NoError(t, os.MkdirAll(filepath.Dir(excludePath), 0o755))
	require.NoError(t, os.WriteFile(excludePath, []byte("ignored-dir/\n"), 0o644))

	require.NoError(t, os.WriteFile(filepath.Join(worktreeDir, "staged.txt"), []byte("staged new\n"), 0o644))
	runGit(t, worktreeDir, "add", "staged.txt")

	require.NoError(t, os.WriteFile(filepath.Join(worktreeDir, "unstaged.txt"), []byte("unstaged new\n"), 0o644))

	require.NoError(t, os.MkdirAll(filepath.Join(worktreeDir, "ignored-dir"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(worktreeDir, "ignored-dir", "hidden.txt"), []byte("ignored\n"), 0o644))

	summary, err := ReviewSummary(worktreeDir, "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, "ready", summary.Status)
	require.Equal(t, worktreeDir, summary.RepositoryRoot)
	require.Equal(t, "feature", summary.Branch)
	require.Equal(t, "main", summary.DefaultBranch)
	require.Equal(t, "main", summary.BaseRef)
	require.Equal(t, "feature", summary.HeadRef)
	require.Equal(t, "common_base", summary.ComparisonMode)
	require.Equal(t, 1, summary.AheadCount)
	require.Zero(t, summary.BehindCount)
	require.True(t, summary.Dirty)
	require.Equal(t, 1, summary.BranchFileCount)
	require.Equal(t, 1, summary.BranchAdditionCount)
	require.Equal(t, 1, summary.BranchDeletionCount)
	require.Equal(t, 1, summary.StagedFileCount)
	require.Equal(t, 1, summary.UnstagedFileCount)
	require.Equal(t, 2, summary.WorkingAdditionCount)
	require.Equal(t, 2, summary.WorkingDeletionCount)
	require.NotEmpty(t, summary.HeadCommit)
	require.NotEmpty(t, summary.MergeBaseCommit)

	branchFiles, err := ReviewFiles(worktreeDir, "branch", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, []types.ReviewFile{{
		Path:       "branch.txt",
		ChangeType: "M",
	}}, reviewFilesWithClearedTokens(branchFiles))

	workingFiles, err := ReviewFiles(worktreeDir, "working", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, []types.ReviewFile{
		{
			Path:             "staged.txt",
			ChangeType:       "M",
			Staged:           true,
			StagedChangeType: "M",
		},
		{
			Path:               "unstaged.txt",
			ChangeType:         "M",
			Unstaged:           true,
			UnstagedChangeType: "M",
		},
	}, reviewFilesWithClearedTokens(workingFiles))

	branchDiff, err := ReviewDiff(worktreeDir, "branch", "branch.txt", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, "ready", branchDiff.Status)
	require.Equal(t, "branch.txt", branchDiff.Path)
	require.Len(t, branchDiff.Sections, 1)
	require.Equal(t, "branch", branchDiff.Sections[0].Key)
	require.Equal(t, "branch base\n", branchDiff.Sections[0].OldText)
	require.Equal(t, "branch head\n", branchDiff.Sections[0].NewText)
	require.Contains(t, branchDiff.Sections[0].Diff, "--- a/branch.txt")
	require.Contains(t, branchDiff.Sections[0].Diff, "+++ b/branch.txt")

	stagedDiff, err := ReviewDiff(worktreeDir, "working", "staged.txt", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, "ready", stagedDiff.Status)
	require.Equal(t, "staged.txt", stagedDiff.Path)
	require.Len(t, stagedDiff.Sections, 1)
	require.Equal(t, "staged", stagedDiff.Sections[0].Key)
	require.Equal(t, "staged base\n", stagedDiff.Sections[0].OldText)
	require.Equal(t, "staged new\n", stagedDiff.Sections[0].NewText)
	require.Contains(t, stagedDiff.Sections[0].Diff, "--- a/staged.txt")
	require.Contains(t, stagedDiff.Sections[0].Diff, "+++ b/staged.txt")

	unstagedDiff, err := ReviewDiff(worktreeDir, "working", "unstaged.txt", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, "ready", unstagedDiff.Status)
	require.Equal(t, "unstaged.txt", unstagedDiff.Path)
	require.Len(t, unstagedDiff.Sections, 1)
	require.Equal(t, "unstaged", unstagedDiff.Sections[0].Key)
	require.Equal(t, "unstaged base\n", unstagedDiff.Sections[0].OldText)
	require.Equal(t, "unstaged new\n", unstagedDiff.Sections[0].NewText)
	require.Contains(t, unstagedDiff.Sections[0].Diff, "--- a/unstaged.txt")
	require.Contains(t, unstagedDiff.Sections[0].Diff, "+++ b/unstaged.txt")

	ignoredDiff, err := ReviewDiff(worktreeDir, "working", "ignored-dir/hidden.txt", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, "not_changed", ignoredDiff.Status)
	require.Equal(t, "ignored-dir/hidden.txt", ignoredDiff.Path)
}

func TestReviewDiffBatchReturnsAddedMarkdownInNewDirectories(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)

	runGit(t, repoDir, "checkout", "-b", "feature")
	plansDir := filepath.Join(repoDir, "docs", "superpowers", "plans")
	specsDir := filepath.Join(repoDir, "docs", "superpowers", "specs")
	require.NoError(t, os.MkdirAll(plansDir, 0o755))
	require.NoError(t, os.MkdirAll(specsDir, 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(plansDir, "2026-07-13-explorer-project-detail.md"), []byte("# Plan\n\nhello\n"), 0o644))
	require.NoError(t, os.WriteFile(filepath.Join(specsDir, "2026-07-13-explorer-project-detail-design.md"), []byte("# Design\n\nworld\n"), 0o644))
	runGit(t, repoDir, "add", ".")
	runGit(t, repoDir, "commit", "-m", "add nested markdown files")

	paths := []string{
		"docs/superpowers/plans/2026-07-13-explorer-project-detail.md",
		"docs/superpowers/specs/2026-07-13-explorer-project-detail-design.md",
	}
	results, err := ReviewDiffBatch(repoDir, "branch", paths, "main", "HEAD", "direct", "")
	require.NoError(t, err)
	for _, path := range paths {
		result := results[path]
		require.NoError(t, result.Error)
		require.Equal(t, "ready", result.Diff.Status)
		require.Equal(t, path, result.Diff.Path)
		require.Len(t, result.Diff.Sections, 1)
		require.Contains(t, result.Diff.Sections[0].Diff, "+++ b/"+path)
	}

	snapshot, err := ReviewSnapshot(repoDir, "branch", "main", "HEAD", "direct", "")
	require.NoError(t, err)
	for _, path := range paths {
		diff, ok := snapshot.InitialDiffs[path]
		require.True(t, ok, "snapshot must seed initial diff for %s", path)
		require.Equal(t, "ready", diff.Status)
		require.Contains(t, diff.Sections[0].Diff, "+++ b/"+path)
	}
}

func TestReviewSnapshotReturnsSummaryAndRequestedFilesFromOneScan(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)

	runGit(t, repoDir, "checkout", "-b", "feature")
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("feature\n"), 0o644))
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "added.txt"), []byte("added\n"), 0o644))
	runGit(t, repoDir, "add", "tracked.txt", "added.txt")
	runGit(t, repoDir, "commit", "-m", "feature changes")

	snapshot, err := ReviewSnapshot(repoDir, "branch", "main", "HEAD", "direct", "")
	require.NoError(t, err)
	require.Equal(t, "ready", snapshot.Summary.Status)
	require.Equal(t, "main", snapshot.Summary.BaseRef)
	require.Equal(t, "HEAD", snapshot.Summary.HeadRef)
	require.Equal(t, 2, snapshot.Summary.BranchFileCount)
	require.Equal(t, 2, snapshot.Summary.BranchAdditionCount)
	require.Equal(t, 1, snapshot.Summary.BranchDeletionCount)
	require.Equal(t, []types.ReviewFile{
		{Path: "added.txt", ChangeType: "A"},
		{Path: "tracked.txt", ChangeType: "M"},
	}, reviewFilesWithClearedTokens(snapshot.Files))
	require.Len(t, snapshot.InitialDiffs, 2)
	require.Equal(t, "added.txt", snapshot.InitialDiffs["added.txt"].Path)
	require.Equal(t, "tracked.txt", snapshot.InitialDiffs["tracked.txt"].Path)
	require.Contains(t, snapshot.InitialDiffs["tracked.txt"].Sections[0].Diff, "+feature")
	require.Equal(t, "base\n", snapshot.InitialDiffs["tracked.txt"].Sections[0].OldText)
	require.Equal(t, "feature\n", snapshot.InitialDiffs["tracked.txt"].Sections[0].NewText)
}

func TestReviewSnapshotContextRejectsCanceledParent(t *testing.T) {
	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	_, err := ReviewSnapshotContext(ctx, repoDir, "branch", "", "", "", "")
	require.ErrorIs(t, err, context.Canceled)
}

func TestReviewSnapshotCompatibilityWrapperStillSeedsDiffs(t *testing.T) {
	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("changed\n"), 0o644))

	snapshot, err := ReviewSnapshot(repoDir, "working", "", "", "", "")
	require.NoError(t, err)
	require.Contains(t, snapshot.InitialWorkingDiffs, "tracked.txt")
}

func TestReviewSnapshotReturnsWorkingFilesWhenRequested(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("working\n"), 0o644))

	snapshot, err := ReviewSnapshot(repoDir, "working", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, "ready", snapshot.Summary.Status)
	require.Equal(t, 1, snapshot.Summary.UnstagedFileCount)
	require.Equal(t, []types.ReviewFile{{
		Path:               "tracked.txt",
		ChangeType:         "M",
		Unstaged:           true,
		UnstagedChangeType: "M",
	}}, reviewFilesWithClearedTokens(snapshot.Files))
	require.Len(t, snapshot.InitialWorkingDiffs, 1)
	require.Equal(t, "tracked.txt", snapshot.InitialWorkingDiffs["tracked.txt"].Path)
	require.Contains(t, snapshot.InitialWorkingDiffs["tracked.txt"].Sections[0].Diff, "+working")
}

func TestReviewFilesWorkingScopeCollapsesExactStagedRename(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)
	require.NoError(t, os.MkdirAll(filepath.Join(repoDir, "moved"), 0o755))
	runGit(t, repoDir, "mv", "tracked.txt", "moved/tracked.txt")

	files, err := ReviewFiles(repoDir, "working", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, []types.ReviewFile{{
		Path:             "moved/tracked.txt",
		OldPath:          "tracked.txt",
		ChangeType:       "R",
		Staged:           true,
		StagedChangeType: "R",
	}}, reviewFilesWithClearedTokens(files))

	diff, err := ReviewDiff(repoDir, "working", "moved/tracked.txt", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, "moved/tracked.txt", diff.Path)
	require.Equal(t, "tracked.txt", diff.OldPath)
	require.Equal(t, "R", diff.ChangeType)
	require.Equal(t, "R", diff.Sections[0].ChangeType)
	require.NotContains(t, diff.Sections[0].Diff, "new file mode")
	require.NotContains(t, diff.Sections[0].Diff, "deleted file mode")
}

func TestReviewDiffWorkingScopeKeepsUnstagedChangesAfterStagedRename(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)
	runGit(t, repoDir, "mv", "tracked.txt", "renamed.txt")
	require.NoError(t, os.WriteFile(
		filepath.Join(repoDir, "renamed.txt"),
		[]byte("base\nworking\n"),
		0o644,
	))

	files, err := ReviewFiles(repoDir, "working", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, []types.ReviewFile{{
		Path:               "renamed.txt",
		OldPath:            "tracked.txt",
		ChangeType:         "R",
		Staged:             true,
		Unstaged:           true,
		StagedChangeType:   "R",
		UnstagedChangeType: "M",
	}}, reviewFilesWithClearedTokens(files))

	diff, err := ReviewDiff(repoDir, "working", "renamed.txt", "", "", "", "")
	require.NoError(t, err)
	require.Len(t, diff.Sections, 2)
	require.Equal(t, "staged", diff.Sections[0].Key)
	require.Equal(t, "unstaged", diff.Sections[1].Key)
	require.Contains(t, diff.Sections[1].Diff, "+working")
	require.NotContains(t, diff.Sections[1].Diff, "+base")
	require.Equal(t, "base\n", diff.Sections[1].OldText)
	require.Equal(t, "base\nworking\n", diff.Sections[1].NewText)
}

func TestReviewSnapshotInitialDiffsAreCappedAtTenTotalForRequestedScope(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)

	runGit(t, repoDir, "checkout", "-b", "feature")
	for i := 0; i < 12; i++ {
		name := fmt.Sprintf("branch-%02d.txt", i)
		require.NoError(t, os.WriteFile(filepath.Join(repoDir, name), []byte(fmt.Sprintf("branch %d\n", i)), 0o644))
	}
	runGit(t, repoDir, "add", ".")
	runGit(t, repoDir, "commit", "-m", "many branch files")

	for i := 0; i < 12; i++ {
		name := fmt.Sprintf("working-%02d.txt", i)
		require.NoError(t, os.WriteFile(filepath.Join(repoDir, name), []byte(fmt.Sprintf("working %d\n", i)), 0o644))
	}

	snapshot, err := ReviewSnapshot(repoDir, "branch", "main", "HEAD", "direct", "")
	require.NoError(t, err)
	require.Len(t, snapshot.InitialDiffs, 10)
	require.Empty(t, snapshot.InitialWorkingDiffs)
	require.Contains(t, snapshot.InitialDiffs, "branch-00.txt")
	require.NotContains(t, snapshot.InitialDiffs, "branch-10.txt")

	workingSnapshot, err := ReviewSnapshot(repoDir, "working", "main", "HEAD", "direct", "")
	require.NoError(t, err)
	require.Empty(t, workingSnapshot.InitialDiffs)
	require.Len(t, workingSnapshot.InitialWorkingDiffs, 10)
	require.Contains(t, workingSnapshot.InitialWorkingDiffs, "working-00.txt")
	require.NotContains(t, workingSnapshot.InitialWorkingDiffs, "working-10.txt")
}

func TestCollectReviewInitialDiffsReturnsOnlyFilesCompletedBeforeBudget(t *testing.T) {
	parentCtx := context.Background()
	seedCtx := &manuallyExpiredReviewSeedContext{Context: parentCtx}
	files := []types.ReviewFile{
		{Path: "a.ts"},
		{Path: "b.ts"},
		{Path: "c.ts"},
	}
	started := []string{}

	diffs, err := collectReviewInitialDiffs(
		parentCtx,
		seedCtx,
		files,
		len(files),
		func(_ context.Context, file types.ReviewFile) (types.ReviewDiff, error) {
			started = append(started, file.Path)
			if file.Path == "b.ts" {
				seedCtx.expired = true
			}
			return types.ReviewDiff{Status: "ready", Path: file.Path}, nil
		},
	)

	require.NoError(t, err)
	require.Equal(t, []string{"a.ts", "b.ts"}, started)
	require.Equal(t, map[string]types.ReviewDiff{
		"a.ts": {Status: "ready", Path: "a.ts"},
	}, diffs)
	require.NotContains(t, diffs, "b.ts", "a completion after the seed deadline must be dropped")
}

func TestCollectReviewInitialDiffsReturnsNilWhenNothingCompletes(t *testing.T) {
	parentCtx := context.Background()
	seedCtx := &manuallyExpiredReviewSeedContext{
		Context: parentCtx,
		expired: true,
	}
	started := 0

	diffs, err := collectReviewInitialDiffs(
		parentCtx,
		seedCtx,
		[]types.ReviewFile{{Path: "a.ts"}},
		1,
		func(context.Context, types.ReviewFile) (types.ReviewDiff, error) {
			started++
			return types.ReviewDiff{}, nil
		},
	)

	require.NoError(t, err)
	require.Nil(t, diffs)
	require.Zero(t, started)
}

func TestCollectReviewInitialDiffsParentCancellationWins(t *testing.T) {
	parentCtx, cancelParent := context.WithCancel(context.Background())
	seedCtx := &manuallyExpiredReviewSeedContext{Context: parentCtx}

	_, err := collectReviewInitialDiffs(
		parentCtx,
		seedCtx,
		[]types.ReviewFile{{Path: "a.ts"}, {Path: "b.ts"}},
		2,
		func(_ context.Context, file types.ReviewFile) (types.ReviewDiff, error) {
			cancelParent()
			seedCtx.expired = true
			return types.ReviewDiff{Status: "ready", Path: file.Path}, nil
		},
	)

	require.ErrorIs(t, err, context.Canceled)
}

func TestReviewSeedContextStateRechecksParentAfterReadingChildError(t *testing.T) {
	parentCtx, cancelParent := context.WithCancel(context.Background())
	seedCtx := &interleavingReviewSeedContext{
		Context: parentCtx,
		err:     context.DeadlineExceeded,
		onErr:   cancelParent,
	}

	exhausted, err := reviewSeedContextState(parentCtx, seedCtx)

	require.False(t, exhausted)
	require.ErrorIs(t, err, context.Canceled)
}

func TestFinishReviewSnapshotRejectsParentCanceledAfterCollector(t *testing.T) {
	parentCtx, cancelParent := context.WithCancel(context.Background())
	cancelParent()

	snapshot, err := finishReviewSnapshot(
		parentCtx,
		types.ReviewSnapshot{},
		"branch",
		map[string]types.ReviewDiff{
			"a.ts": {Status: "ready", Path: "a.ts"},
		},
	)

	require.ErrorIs(t, err, context.Canceled)
	require.Empty(t, snapshot.InitialDiffs)
}

func TestReviewBranchFileTokenIsAuthoritativeAndStable(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)

	runGit(t, repoDir, "checkout", "-b", "feature")
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("feature content\n"), 0o644))
	runGit(t, repoDir, "add", "tracked.txt")
	runGit(t, repoDir, "commit", "-m", "modify tracked")

	first, err := ReviewFiles(repoDir, "branch", "main", "HEAD", "direct", "")
	require.NoError(t, err)
	require.Len(t, first, 1)
	require.NotEmpty(t, first[0].Token, "branch file must carry an authoritative token")
	require.Len(t, first[0].Token, reviewFingerprintAuthoritativeTokenHexLen,
		"branch per-file token must be full SHA-256 hex (opaque authoritative)")
	// Re-running ReviewFiles against the same commit must produce the same
	// token, because the token is derived from the (immutable) head blob.
	second, err := ReviewFiles(repoDir, "branch", "main", "HEAD", "direct", "")
	require.NoError(t, err)
	require.Equal(t, first[0].Token, second[0].Token, "branch token must be stable across calls for the same commit")

	// After committing another change to the same file the head blob changes,
	// so the token must change too.
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("different content\n"), 0o644))
	runGit(t, repoDir, "add", "tracked.txt")
	runGit(t, repoDir, "commit", "-m", "modify tracked again")
	third, err := ReviewFiles(repoDir, "branch", "main", "HEAD", "direct", "")
	require.NoError(t, err)
	require.Len(t, third, 1)
	require.NotEqual(t, first[0].Token, third[0].Token, "branch token must change when content changes")
}

func TestReviewWorkingFileTokenReflectsStatAndIsEmptyForMissingFile(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("working\n"), 0o644))

	first, err := ReviewFiles(repoDir, "working", "", "", "", "")
	require.NoError(t, err)
	require.Len(t, first, 1)
	require.NotEmpty(t, first[0].Token, "working file must carry a hint token covering HEAD/index/wt")
	require.Len(t, first[0].Token, reviewFingerprintTokenHexLen,
		"working file hint token must be %d hex chars (opaque, hint strength)", reviewFingerprintTokenHexLen)

	// Second read without changes must return the same hint token, because
	// HEAD/index/stat have not changed.
	second, err := ReviewFiles(repoDir, "working", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, first[0].Token, second[0].Token, "hint token must be stable across calls when underlying state is unchanged")

	// Editing the file changes size and/or mtime, so the hint token must
	// change.
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("now with different content\n"), 0o644))
	third, err := ReviewFiles(repoDir, "working", "", "", "", "")
	require.NoError(t, err)
	require.Len(t, third, 1)
	require.NotEqual(t, first[0].Token, third[0].Token, "hint token must change when worktree stat changes")

	// Removing the worktree file must still surface the file with a hint
	// token (NOT empty): the missing-wt sentinel participates in the digest
	// so a worktree-only delete (HEAD/index unchanged) is still detectable.
	require.NoError(t, os.Remove(filepath.Join(repoDir, "tracked.txt")))
	fourth, err := ReviewFiles(repoDir, "working", "", "", "", "")
	require.NoError(t, err)
	require.Len(t, fourth, 1, "deleted worktree file must still surface so the deletion is observable")
	require.NotEmpty(t, fourth[0].Token, "deleted worktree file must still carry a hint token (missing-wt sentinel)")
	require.NotEqual(t, first[0].Token, fourth[0].Token, "deleted worktree file must produce a different token from the present version")
}

func TestReviewBranchFileTokenReflectsHeadMode(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)
	runGit(t, repoDir, "checkout", "-b", "feature")
	// A non-executable file committed first, then the same path committed
	// again with the executable bit set: the blob hash changes (content is
	// the same, but git stores mode in the tree entry which the diff sees as
	// a change), so the token derived from hash+mode must change.
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("payload\n"), 0o644))
	runGit(t, repoDir, "add", "tracked.txt")
	runGit(t, repoDir, "commit", "-m", "regular file")
	first, err := ReviewFiles(repoDir, "branch", "main", "HEAD", "direct", "")
	require.NoError(t, err)
	require.Len(t, first, 1)

	require.NoError(t, os.Chmod(filepath.Join(repoDir, "tracked.txt"), 0o755))
	runGit(t, repoDir, "add", "tracked.txt")
	runGit(t, repoDir, "commit", "-m", "make executable")
	second, err := ReviewFiles(repoDir, "branch", "main", "HEAD", "direct", "")
	require.NoError(t, err)
	require.Len(t, second, 1)
	require.NotEqual(t, first[0].Token, second[0].Token, "mode-only change must still flip the token")
}

func TestValidateOutputTargetGitRepoSkipsReviewReadinessScan(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)

	runGit(t, repoDir, "checkout", "--orphan", "isolated")
	runGit(t, repoDir, "rm", "-rf", ".")
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "isolated.txt"), []byte("isolated\n"), 0o644))
	runGit(t, repoDir, "add", "isolated.txt")
	runGit(t, repoDir, "commit", "-m", "isolated history")

	_, err := ReviewSummary(repoDir, "", "", "", "")
	require.ErrorContains(t, err, "no merge base")
	require.NoError(t, ValidateOutputTarget(repoDir, "git_repo"))
}

func TestReviewDiffBranchScopeLimitsLargeBlob(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)

	runGit(t, repoDir, "checkout", "-b", "feature")
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "large.txt"), []byte(strings.Repeat("x", reviewDiffMaxContentBytes+1)), 0o644))
	runGit(t, repoDir, "add", "large.txt")
	runGit(t, repoDir, "commit", "-m", "add large file")

	diff, err := ReviewDiff(repoDir, "branch", "large.txt", "main", "feature", "", "")
	require.NoError(t, err)
	require.Equal(t, "too_large", diff.Status)
	require.Len(t, diff.Sections, 1)
	require.True(t, diff.Sections[0].TooLarge)
	require.NotEmpty(t, diff.Sections[0].Message)
	require.Empty(t, diff.Sections[0].Diff)
	require.Empty(t, diff.Sections[0].OldText)
	require.Empty(t, diff.Sections[0].NewText)
}

func TestReviewDiffWorkingScopeLimitsLargeWorktreeFile(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)

	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte(strings.Repeat("y", reviewDiffMaxContentBytes+1)), 0o644))

	diff, err := ReviewDiff(repoDir, "working", "tracked.txt", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, "too_large", diff.Status)
	require.Len(t, diff.Sections, 1)
	require.True(t, diff.Sections[0].TooLarge)
	require.NotEmpty(t, diff.Sections[0].Message)
	require.Empty(t, diff.Sections[0].Diff)
	require.Empty(t, diff.Sections[0].OldText)
	require.Empty(t, diff.Sections[0].NewText)
}

func TestReviewDiffWorkingScopeUsesGitCLIForLargeButCompactDiff(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)

	lines := make([]string, 0, 6000)
	for index := 0; index < 6000; index++ {
		lines = append(lines, fmt.Sprintf("line-%04d\n", index))
	}
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "large-compact.txt"), []byte(strings.Join(lines, "")), 0o644))
	runGit(t, repoDir, "add", "large-compact.txt")
	runGit(t, repoDir, "commit", "-m", "add large compact file")

	lines[3000] = "line-3000 changed\n"
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "large-compact.txt"), []byte(strings.Join(lines, "")), 0o644))

	diff, err := ReviewDiff(repoDir, "working", "large-compact.txt", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, "ready", diff.Status)
	require.Len(t, diff.Sections, 1)
	require.False(t, diff.Sections[0].TooLarge)
	require.Contains(t, diff.Sections[0].Diff, "-line-3000")
	require.Contains(t, diff.Sections[0].Diff, "+line-3000 changed")
}

func TestReviewDiffBranchScopeUsesGitCLIForLargeButCompactDiff(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)

	lines := make([]string, 0, 6000)
	for index := 0; index < 6000; index++ {
		lines = append(lines, fmt.Sprintf("line-%04d\n", index))
	}
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "large-branch.txt"), []byte(strings.Join(lines, "")), 0o644))
	runGit(t, repoDir, "add", "large-branch.txt")
	runGit(t, repoDir, "commit", "-m", "add large branch file")

	runGit(t, repoDir, "checkout", "-b", "feature")
	lines[3000] = "line-3000 changed\n"
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "large-branch.txt"), []byte(strings.Join(lines, "")), 0o644))
	runGit(t, repoDir, "add", "large-branch.txt")
	runGit(t, repoDir, "commit", "-m", "change one line in large branch file")

	diff, err := ReviewDiff(repoDir, "branch", "large-branch.txt", "main", "HEAD", "direct", "")
	require.NoError(t, err)
	require.Equal(t, "ready", diff.Status)
	require.Len(t, diff.Sections, 1)
	require.False(t, diff.Sections[0].TooLarge)
	require.Contains(t, diff.Sections[0].Diff, "-line-3000")
	require.Contains(t, diff.Sections[0].Diff, "+line-3000 changed")
}

func TestReviewDiffWorkingScopeLimitsManyLineComplexity(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)

	const lineCount = 20_000
	baseLines := make([]string, 0, lineCount)
	newLines := make([]string, 0, lineCount)
	for i := 0; i < lineCount; i++ {
		baseLines = append(baseLines, fmt.Sprintf("base-%08d\n", i))
		newLines = append(newLines, fmt.Sprintf("new-%08d\n", i))
	}
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte(strings.Join(baseLines, "")), 0o644))
	runGit(t, repoDir, "add", "tracked.txt")
	runGit(t, repoDir, "commit", "-m", "many lines")
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte(strings.Join(newLines, "")), 0o644))

	diff, err := ReviewDiff(repoDir, "working", "tracked.txt", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, "too_large", diff.Status)
	require.Len(t, diff.Sections, 1)
	require.True(t, diff.Sections[0].TooLarge)
	require.NotEmpty(t, diff.Sections[0].Message)
	require.Empty(t, diff.Sections[0].Diff)
}

func TestReviewDiffWorkingScopeReturnsBinaryWithoutDiffing(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}

	repoDir := t.TempDir()
	setupReviewTestRepo(t, repoDir)

	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("base\x00new"), 0o644))

	diff, err := ReviewDiff(repoDir, "working", "tracked.txt", "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, "binary", diff.Status)
	require.Len(t, diff.Sections, 1)
	require.True(t, diff.Sections[0].Binary)
	require.NotEmpty(t, diff.Sections[0].Message)
	require.Empty(t, diff.Sections[0].Diff)
	require.Empty(t, diff.Sections[0].OldText)
	require.Empty(t, diff.Sections[0].NewText)
}

func setupReviewTestRepo(t *testing.T, repoDir string) {
	t.Helper()

	runGit(t, repoDir, "init")
	runGit(t, repoDir, "checkout", "-b", "main")
	runGit(t, repoDir, "config", "user.name", "AgentTeam Test")
	runGit(t, repoDir, "config", "user.email", "agentteam-test@example.com")

	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("base\n"), 0o644))
	runGit(t, repoDir, "add", "tracked.txt")
	runGit(t, repoDir, "commit", "-m", "init")
}

func runGit(t *testing.T, dir string, args ...string) {
	t.Helper()

	cmd := exec.Command("git", args...)
	cmd.Dir = dir
	output, err := cmd.CombinedOutput()
	require.NoError(t, err, "git %v failed: %s", args, string(output))
}

func runGitOutput(t *testing.T, dir string, args ...string) string {
	t.Helper()

	cmd := exec.Command("git", args...)
	cmd.Dir = dir
	output, err := cmd.CombinedOutput()
	require.NoError(t, err, "git %v failed: %s", args, string(output))
	return string(output)
}
