package workspace

import (
	"bufio"
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
	fdiff "github.com/go-git/go-git/v5/plumbing/format/diff"
	"github.com/go-git/go-git/v5/plumbing/format/gitignore"
	"github.com/go-git/go-git/v5/plumbing/object"
	"github.com/go-git/go-git/v5/utils/merkletrie"

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

const (
	reviewDiffMaxBytes              = 256 * 1024
	reviewDiffMaxContentBytes       = 512 * 1024
	reviewDiffMaxLines              = 5000
	reviewDiffMaxCells              = 2_000_000
	reviewDiffTooLargeMarker        = "\x00agentteam-review-diff-too-large\x00"
	reviewDiffBinaryMarker          = "\x00agentteam-review-diff-binary\x00"
	reviewSnapshotInitialDiffLimit  = 10
	reviewSnapshotInitialDiffBudget = time.Second
)

var errReviewDiffTooLarge = errors.New("review diff input is too large")

// ErrReviewControlCharPath is returned when ANY Review entry path — branch
// DiffTree, working status scan, or single-file diff — touches a git tree
// entry whose path contains control characters (e.g. an embedded newline).
// go-git's path validators (worktree_fs.validPath and pathutil.ValidTreePath)
// reject these paths outright, so the Review snapshot / files / diff /
// fingerprint chain cannot complete for any commit pair whose tree contains
// such an entry, OR for any working tree that contains one.
//
// This is a PRODUCT LIMITATION of the current go-git-backed diff, acked as
// a stable sentinel so callers (and the frontend) can distinguish "unsupported
// repo content" from transient failures. The original go-git error is
// discarded because it includes the offending path verbatim; the sentinel's
// Error() text carries no path content. Callers match via errors.Is.
var ErrReviewControlCharPath = errors.New("review target contains a git path with control characters (unsupported by the current diff engine)")

// wrapReviewTreeError converts a go-git tree/status error into a stable
// sentinel when the cause is a control-character path rejection. The original
// error is dropped because its message includes the offending path verbatim
// (e.g. `invalid path "line\nbreak.txt": contains control character`); only
// the fixed sentinel text survives, so neither Error() nor downstream logs can
// leak the path. Non-control-char errors pass through unchanged.
//
// The match is anchored to go-git's known validator output shape rather than
// a loose substring, so an unrelated error that happens to mention "control
// character" (e.g. a future scanner surfacing file content snippets) cannot
// be misclassified as a path-validator rejection. Both validator sites —
// worktree_fs.go validPath and internal/pathutil/tree.go ValidTreePath —
// produce errors of the form `invalid path %q: contains control character`
// (the latter wraps an unexported ErrInvalidPath sentinel but the textual
// shape is identical). Matching on `invalid path` + `: contains control
// character` substrings together is specific to that format.
func wrapReviewTreeError(err error) error {
	if err == nil {
		return nil
	}
	msg := err.Error()
	if !strings.Contains(msg, "invalid path") {
		return err
	}
	if !strings.Contains(msg, ": contains control character") {
		return err
	}
	return ErrReviewControlCharPath
}

// validateReviewTreePath mirrors go-git's pathutil.ValidTreePath rule (reject
// bytes < 0x20 or == 0x7f) at the workspace boundary. It exists because not
// every Review entry path goes through a go-git validator: as of v5.19.1,
// Worktree.StatusWithOptions silently walks control-char-named entries
// without calling validPath, so a working-tree fixture with an uncommitted
// `line\nbreak.txt` would otherwise be returned as a normal ReviewFile (with
// the offending bytes traveling out as ReviewFile.Path). Validating here at
// the workspace edge makes the unsupported-sentinel behavior uniform across
// every entry path — branch DiffTree rejects via the validator and reaches
// the sentinel through wrapReviewTreeError; working status rejects via this
// explicit check. The path byte is never embedded in any returned error
// text — the sentinel's fixed message is path-free.
func validateReviewTreePath(path string) error {
	for i := 0; i < len(path); i++ {
		if path[i] < 0x20 || path[i] == 0x7f {
			return ErrReviewControlCharPath
		}
	}
	return nil
}

type reviewRepoState struct {
	repo           *git.Repository
	root           string
	resolvedRoot   string
	branch         string
	defaultBranch  string
	baseRef        string
	headRef        string
	comparisonMode string
	availableRefs  []string
	headCommit     string
	mergeBase      string
}

type reviewDiffTrees struct {
	baseTree *object.Tree
	headTree *object.Tree
}

type reviewDiffPreviewText struct {
	oldText string
	newText string
}

type reviewLineStats struct {
	additions int
	deletions int
}

type reviewBranchChanges struct {
	baseTree *object.Tree
	headTree *object.Tree
	changes  object.Changes
}

type reviewInitialDiffLoader func(context.Context, types.ReviewFile) (types.ReviewDiff, error)

// ReviewSnapshot returns the initial review summary and requested file list
// from one repository scan. It also seeds the first few diffs for each scope so
// the browser can paint the first screen without waiting for a second round
// trip; the remaining file diffs stay lazy/background reads.
func ReviewSnapshot(agentDir string, scope string, baseRef string, headRef string, comparisonMode string, compareRef string) (types.ReviewSnapshot, error) {
	return ReviewSnapshotContext(context.Background(), agentDir, scope, baseRef, headRef, comparisonMode, compareRef)
}

func ReviewSnapshotContext(ctx context.Context, agentDir string, scope string, baseRef string, headRef string, comparisonMode string, compareRef string) (types.ReviewSnapshot, error) {
	if ctx == nil {
		ctx = context.Background()
	}
	if err := ctx.Err(); err != nil {
		return types.ReviewSnapshot{}, err
	}
	repo, summary, err := loadReviewRepoState(agentDir, baseRef, headRef, comparisonMode, compareRef)
	if err != nil || summary.Status != "" {
		return types.ReviewSnapshot{Summary: summary, Files: []types.ReviewFile{}}, err
	}

	branchDiff, err := loadBranchDiff(repo)
	if err != nil {
		return types.ReviewSnapshot{}, err
	}
	if err := ctx.Err(); err != nil {
		return types.ReviewSnapshot{}, err
	}
	branchFiles := branchDiff.files

	workingFiles, err := reviewWorkingFiles(repo)
	if err != nil {
		return types.ReviewSnapshot{}, err
	}
	if err := ctx.Err(); err != nil {
		return types.ReviewSnapshot{}, err
	}

	stagedCount, unstagedCount := reviewWorkingFileCounts(workingFiles)

	aheadCount, behindCount, err := reviewAheadBehind(repo.repo, repo.baseRef, repo.headRef)
	if err != nil {
		return types.ReviewSnapshot{}, err
	}
	if err := ctx.Err(); err != nil {
		return types.ReviewSnapshot{}, err
	}

	workingLineStats, err := reviewWorkingLineStats(repo, workingFiles)
	if err != nil {
		return types.ReviewSnapshot{}, err
	}
	if err := ctx.Err(); err != nil {
		return types.ReviewSnapshot{}, err
	}

	files := branchFiles
	initialScope := "branch"
	switch strings.TrimSpace(scope) {
	case "", "branch":
		if len(branchFiles) == 0 && len(workingFiles) > 0 {
			// The frontend automatically opens the working scope when the
			// branch is clean, so seed that first screen instead.
			initialScope = "working"
		}
	case "working":
		files = workingFiles
		initialScope = "working"
	default:
		return types.ReviewSnapshot{}, fmt.Errorf("unsupported review scope %q", scope)
	}

	snapshot := types.ReviewSnapshot{
		Summary:      buildReviewSummary(repo, len(branchFiles), stagedCount, unstagedCount, aheadCount, behindCount, branchDiff.lineStats, workingLineStats),
		Files:        files,
		WorkingFiles: workingFiles,
	}
	if err := ctx.Err(); err != nil {
		return types.ReviewSnapshot{}, err
	}
	seedCtx, cancelSeed := context.WithTimeout(ctx, reviewSnapshotInitialDiffBudget)
	defer cancelSeed()
	initialDiffs, err := reviewInitialDiffsForFilesContext(
		ctx,
		seedCtx,
		repo,
		initialScope,
		filesForReviewScope(initialScope, branchFiles, workingFiles),
		reviewSnapshotInitialDiffLimit,
	)
	if err != nil {
		return types.ReviewSnapshot{}, err
	}
	return finishReviewSnapshot(ctx, snapshot, initialScope, initialDiffs)
}

func finishReviewSnapshot(
	parentCtx context.Context,
	snapshot types.ReviewSnapshot,
	initialScope string,
	initialDiffs map[string]types.ReviewDiff,
) (types.ReviewSnapshot, error) {
	if err := parentCtx.Err(); err != nil {
		return types.ReviewSnapshot{}, err
	}
	if initialScope == "working" {
		snapshot.InitialWorkingDiffs = initialDiffs
	} else {
		snapshot.InitialDiffs = initialDiffs
	}
	if err := parentCtx.Err(); err != nil {
		return types.ReviewSnapshot{}, err
	}
	return snapshot, nil
}

func filesForReviewScope(scope string, branchFiles []types.ReviewFile, workingFiles []types.ReviewFile) []types.ReviewFile {
	if scope == "working" {
		return workingFiles
	}
	return branchFiles
}

func reviewInitialDiffsForFilesContext(
	parentCtx context.Context,
	seedCtx context.Context,
	repo reviewRepoState,
	scope string,
	files []types.ReviewFile,
	limit int,
) (map[string]types.ReviewDiff, error) {
	if exhausted, err := reviewSeedContextState(parentCtx, seedCtx); err != nil {
		return nil, err
	} else if exhausted {
		return nil, nil
	}
	if limit <= 0 || len(files) == 0 {
		return nil, nil
	}
	if len(files) < limit {
		limit = len(files)
	}
	trees, err := reviewDiffTreesForScope(repo, scope)
	if err != nil {
		return nil, err
	}
	if exhausted, err := reviewSeedContextState(parentCtx, seedCtx); err != nil {
		return nil, err
	} else if exhausted {
		return nil, nil
	}
	return collectReviewInitialDiffs(
		parentCtx,
		seedCtx,
		files,
		limit,
		func(ctx context.Context, file types.ReviewFile) (types.ReviewDiff, error) {
			return reviewDiffForReviewFileContext(ctx, repo, scope, file, trees)
		},
	)
}

func collectReviewInitialDiffs(
	parentCtx context.Context,
	seedCtx context.Context,
	files []types.ReviewFile,
	limit int,
	load reviewInitialDiffLoader,
) (map[string]types.ReviewDiff, error) {
	if _, err := reviewSeedContextState(parentCtx, seedCtx); err != nil {
		return nil, err
	}
	if limit <= 0 || len(files) == 0 {
		return nil, nil
	}
	if len(files) < limit {
		limit = len(files)
	}
	diffs := make(map[string]types.ReviewDiff, limit)
	for _, file := range files[:limit] {
		if exhausted, err := reviewSeedContextState(parentCtx, seedCtx); err != nil {
			return nil, err
		} else if exhausted {
			break
		}
		if file.Path == "" {
			continue
		}
		diff, err := load(seedCtx, file)
		if exhausted, contextErr := reviewSeedContextState(parentCtx, seedCtx); contextErr != nil {
			return nil, contextErr
		} else if exhausted {
			break
		}
		if err != nil {
			if errors.Is(err, ErrReviewControlCharPath) {
				return nil, err
			}
			// Seed diffs are an opportunistic first-paint optimization. A
			// transient per-file read error must not make the metadata snapshot
			// unavailable; the ordinary lazy/batch path can retry that file.
			continue
		}
		diffs[file.Path] = diff
	}
	if len(diffs) == 0 {
		return nil, nil
	}
	return diffs, nil
}

func reviewSeedContextState(parentCtx context.Context, seedCtx context.Context) (bool, error) {
	if err := parentCtx.Err(); err != nil {
		return false, err
	}
	seedErr := seedCtx.Err()
	if err := parentCtx.Err(); err != nil {
		return false, err
	}
	if seedErr == nil {
		return false, nil
	}
	if errors.Is(seedErr, context.DeadlineExceeded) {
		return true, nil
	}
	return false, seedErr
}

func reviewDiffTreesForScope(repo reviewRepoState, scope string) (*reviewDiffTrees, error) {
	switch strings.TrimSpace(scope) {
	case "branch":
		baseCommitHash, headCommitHash := reviewBranchDiffCommitHashes(repo)
		baseTree, err := reviewTreeForCommitHash(repo.repo, baseCommitHash)
		if err != nil {
			return nil, err
		}
		headTree, err := reviewTreeForCommitHash(repo.repo, headCommitHash)
		if err != nil {
			return nil, err
		}
		return &reviewDiffTrees{baseTree: baseTree, headTree: headTree}, nil
	default:
		return nil, nil
	}
}

// ReviewSummary returns a summary of the review state for an agent directory.
func ReviewSummary(agentDir string, baseRef string, headRef string, comparisonMode string, compareRef string) (types.ReviewSummary, error) {
	repo, summary, err := loadReviewRepoState(agentDir, baseRef, headRef, comparisonMode, compareRef)
	if err != nil || summary.Status != "" {
		return summary, err
	}

	branchFiles, err := reviewBranchFiles(repo)
	if err != nil {
		return types.ReviewSummary{}, err
	}
	workingFiles, err := reviewWorkingFiles(repo)
	if err != nil {
		return types.ReviewSummary{}, err
	}
	stagedCount, unstagedCount := reviewWorkingFileCounts(workingFiles)
	aheadCount, behindCount, err := reviewAheadBehind(repo.repo, repo.baseRef, repo.headRef)
	if err != nil {
		return types.ReviewSummary{}, err
	}
	branchLineStats, err := reviewBranchLineStats(repo)
	if err != nil {
		return types.ReviewSummary{}, err
	}
	workingLineStats, err := reviewWorkingLineStats(repo, workingFiles)
	if err != nil {
		return types.ReviewSummary{}, err
	}
	return buildReviewSummary(repo, len(branchFiles), stagedCount, unstagedCount, aheadCount, behindCount, branchLineStats, workingLineStats), nil
}

func reviewWorkingFileCounts(files []types.ReviewFile) (stagedCount, unstagedCount int) {
	for _, file := range files {
		if file.Staged {
			stagedCount++
		}
		if file.Unstaged {
			unstagedCount++
		}
	}
	return stagedCount, unstagedCount
}

func buildReviewSummary(repo reviewRepoState, branchFileCount, stagedCount, unstagedCount, aheadCount, behindCount int, branchLineStats, workingLineStats reviewLineStats) types.ReviewSummary {
	return types.ReviewSummary{
		Status:               "ready",
		RepositoryRoot:       repo.root,
		Branch:               repo.branch,
		DefaultBranch:        repo.defaultBranch,
		BaseRef:              repo.baseRef,
		HeadRef:              repo.headRef,
		ComparisonMode:       repo.comparisonMode,
		AvailableRefs:        append([]string(nil), repo.availableRefs...),
		CompareRef:           repo.baseRef,
		AvailableCompareRefs: append([]string(nil), repo.availableRefs...),
		HeadCommit:           repo.headCommit,
		MergeBaseCommit:      repo.mergeBase,
		AheadCount:           aheadCount,
		BehindCount:          behindCount,
		Dirty:                stagedCount > 0 || unstagedCount > 0,
		BranchFileCount:      branchFileCount,
		BranchAdditionCount:  branchLineStats.additions,
		BranchDeletionCount:  branchLineStats.deletions,
		WorkingAdditionCount: workingLineStats.additions,
		WorkingDeletionCount: workingLineStats.deletions,
		StagedFileCount:      stagedCount,
		UnstagedFileCount:    unstagedCount,
		LastScannedAt:        time.Now().UTC(),
	}
}

// ReviewFiles returns the list of changed files for a given review scope.
func ReviewFiles(agentDir string, scope string, baseRef string, headRef string, comparisonMode string, compareRef string) ([]types.ReviewFile, error) {
	repo, summary, err := loadReviewRepoState(agentDir, baseRef, headRef, comparisonMode, compareRef)
	if err != nil {
		return nil, err
	}
	if summary.Status != "" {
		return []types.ReviewFile{}, nil
	}

	switch strings.TrimSpace(scope) {
	case "branch":
		return reviewBranchFiles(repo)
	case "working":
		return reviewWorkingFiles(repo)
	default:
		return nil, fmt.Errorf("unsupported review scope %q", scope)
	}
}

// ReviewDiff returns the diff for a single file in the given review scope.
func ReviewDiff(agentDir string, scope string, path string, baseRef string, headRef string, comparisonMode string, compareRef string) (types.ReviewDiff, error) {
	repo, summary, err := loadReviewRepoState(agentDir, baseRef, headRef, comparisonMode, compareRef)
	if err != nil {
		return types.ReviewDiff{}, err
	}
	if summary.Status != "" {
		return types.ReviewDiff{
			Status:  summary.Status,
			Message: summary.Message,
			// Preserve the raw caller-supplied path: Git paths may legitimately
			// contain leading/trailing whitespace or newlines, and trimming here
			// would mislead the frontend about which file the status applies to.
			Path: path,
		}, nil
	}
	return reviewDiffForPath(repo, scope, path, agentDir)
}

// ReviewDiffBatchResult is the per-path outcome of ReviewDiffBatch. A non-empty
// Error indicates the diff for this path could not be produced (e.g. invalid
// path encoding); the caller should still surface results for any other paths
// in the same batch.
type ReviewDiffBatchResult struct {
	Diff  types.ReviewDiff
	Error error
}

// ReviewDiffBatch computes diffs for multiple paths in a single call. It
// resolves the review repo state once and reuses it for every requested path,
// so the expensive parts of loadReviewRepoState — `git.PlainOpen`, the
// `git for-each-ref` CLI calls inside reviewDefaultBranch / reviewAvailableRefs,
// and the merge-base graph walk — happen exactly once regardless of how many
// paths are in the batch.
//
// Inputs mirror ReviewDiff. When the repo itself is in a terminal "not_git"
// state (e.g. workspace is not yet a git repository), every requested path
// receives the same not-git status rather than an error.
//
// Per-path errors do not abort the batch EXCEPT for ErrReviewControlCharPath,
// which is a tree-level product limitation: the diff engine cannot process a
// repo tree that contains a control-char path, and a per-path sentinel means
// either the caller-supplied path or the underlying tree tripped the
// validator. Either way the batch cannot meaningfully continue — every path
// against the same tree would fail the same way — so the sentinel is
// escalated to a top-level error and the frontend sees the same stable 422
// (review_target_unsupported) that the single-file Diff endpoint surfaces.
// Other per-path errors (transient I/O, blob lookup failure) stay per-path.
//
// On a top-level loadReviewRepoState failure the function returns a single
// error and no map.
func ReviewDiffBatch(agentDir string, scope string, paths []string, baseRef string, headRef string, comparisonMode string, compareRef string) (map[string]ReviewDiffBatchResult, error) {
	repo, summary, err := loadReviewRepoState(agentDir, baseRef, headRef, comparisonMode, compareRef)
	if err != nil {
		return nil, err
	}
	results := make(map[string]ReviewDiffBatchResult, len(paths))
	recordPathError := func(path string, err error) error {
		if errors.Is(err, ErrReviewControlCharPath) {
			return err
		}
		results[path] = ReviewDiffBatchResult{Error: err}
		return nil
	}
	for _, path := range paths {
		if path == "" {
			results[path] = ReviewDiffBatchResult{Error: fmt.Errorf("path is required")}
			continue
		}
		if summary.Status != "" {
			results[path] = ReviewDiffBatchResult{Diff: types.ReviewDiff{
				Status:  summary.Status,
				Message: summary.Message,
				Path:    path,
			}}
			continue
		}

	}
	if summary.Status != "" {
		return results, nil
	}

	switch strings.TrimSpace(scope) {
	case "branch":
		files, err := reviewBranchFiles(repo)
		if err != nil {
			for _, path := range paths {
				if path == "" {
					continue
				}
				if recordErr := recordPathError(path, err); recordErr != nil {
					return nil, recordErr
				}
			}
			return results, nil
		}
		trees, err := reviewDiffTreesForScope(repo, "branch")
		if err != nil {
			for _, path := range paths {
				if path == "" {
					continue
				}
				if recordErr := recordPathError(path, err); recordErr != nil {
					return nil, recordErr
				}
			}
			return results, nil
		}
		for _, path := range paths {
			if path == "" {
				continue
			}
			gitPath, err := normalizeReviewPath(agentDir, path)
			if err != nil {
				if recordErr := recordPathError(path, err); recordErr != nil {
					return nil, recordErr
				}
				continue
			}
			file := findReviewFile(files, gitPath)
			if file == nil {
				results[path] = ReviewDiffBatchResult{Diff: types.ReviewDiff{
					Status:  "not_changed",
					Message: "File is not part of the selected review scope.",
					Path:    gitPath,
				}}
				continue
			}
			diff, err := reviewDiffForReviewFile(repo, "branch", *file, trees)
			if err != nil {
				if recordErr := recordPathError(path, err); recordErr != nil {
					return nil, recordErr
				}
				continue
			}
			results[path] = ReviewDiffBatchResult{Diff: diff}
		}
	case "working":
		files, err := reviewWorkingFiles(repo)
		if err != nil {
			for _, path := range paths {
				if path == "" {
					continue
				}
				if recordErr := recordPathError(path, err); recordErr != nil {
					return nil, recordErr
				}
			}
			return results, nil
		}
		for _, path := range paths {
			if path == "" {
				continue
			}
			gitPath, err := normalizeReviewPath(agentDir, path)
			if err != nil {
				if recordErr := recordPathError(path, err); recordErr != nil {
					return nil, recordErr
				}
				continue
			}
			file := findReviewFile(files, gitPath)
			if file == nil {
				results[path] = ReviewDiffBatchResult{Diff: types.ReviewDiff{
					Status:  "not_changed",
					Message: "File is not part of the selected review scope.",
					Path:    gitPath,
				}}
				continue
			}
			diff, err := reviewDiffForReviewFile(repo, "working", *file, nil)
			if err != nil {
				if recordErr := recordPathError(path, err); recordErr != nil {
					return nil, recordErr
				}
				continue
			}
			results[path] = ReviewDiffBatchResult{Diff: diff}
		}
	default:
		err := fmt.Errorf("unsupported review scope %q", scope)
		for _, path := range paths {
			if path == "" {
				continue
			}
			results[path] = ReviewDiffBatchResult{Error: err}
		}
	}
	return results, nil
}

// reviewDiffForPath produces the per-file diff under an already-resolved
// reviewRepoState. It is the shared core of ReviewDiff and ReviewDiffBatch —
// every caller pays the loadReviewRepoState cost once and reuses this for the
// per-path work (one blob lookup + diff materialization per file).
func reviewDiffForPath(repo reviewRepoState, scope string, path string, agentDir string) (types.ReviewDiff, error) {
	gitPath, err := normalizeReviewPath(agentDir, path)
	if err != nil {
		return types.ReviewDiff{}, err
	}

	switch strings.TrimSpace(scope) {
	case "branch":
		files, err := reviewBranchFiles(repo)
		if err != nil {
			return types.ReviewDiff{}, err
		}
		file := findReviewFile(files, gitPath)
		if file == nil {
			return types.ReviewDiff{
				Status:  "not_changed",
				Message: "File is not part of the selected review scope.",
				Path:    gitPath,
			}, nil
		}
		return reviewDiffForReviewFile(repo, "branch", *file, nil)
	case "working":
		files, err := reviewWorkingFiles(repo)
		if err != nil {
			return types.ReviewDiff{}, err
		}
		file := findReviewFile(files, gitPath)
		if file == nil {
			return types.ReviewDiff{
				Status:  "not_changed",
				Message: "File is not part of the selected review scope.",
				Path:    gitPath,
			}, nil
		}
		return reviewDiffForReviewFile(repo, "working", *file, nil)
	default:
		return types.ReviewDiff{}, fmt.Errorf("unsupported review scope %q", scope)
	}
}

func reviewDiffForReviewFile(repo reviewRepoState, scope string, file types.ReviewFile, trees *reviewDiffTrees) (types.ReviewDiff, error) {
	return reviewDiffForReviewFileContext(context.Background(), repo, scope, file, trees)
}

func reviewDiffForReviewFileContext(ctx context.Context, repo reviewRepoState, scope string, file types.ReviewFile, trees *reviewDiffTrees) (types.ReviewDiff, error) {
	if err := ctx.Err(); err != nil {
		return types.ReviewDiff{}, err
	}
	gitPath := file.Path
	switch strings.TrimSpace(scope) {
	case "branch":
		diffText, err := reviewBranchDiffTextWithTreesContext(ctx, repo, gitPath, trees)
		if err != nil {
			return types.ReviewDiff{}, err
		}
		section := buildReviewDiffSection("branch", "Branch changes", file.ChangeType, diffText)
		if !section.TooLarge && !section.Binary {
			preview, err := reviewBranchPreviewTextContext(ctx, repo, file)
			if err != nil {
				return types.ReviewDiff{}, err
			}
			attachReviewDiffPreviewText(&section, preview)
		}
		return types.ReviewDiff{
			Status:     sectionStatus(section),
			Path:       file.Path,
			OldPath:    file.OldPath,
			ChangeType: file.ChangeType,
			Sections:   []types.ReviewDiffSection{section},
		}, nil
	case "working":
		sections := make([]types.ReviewDiffSection, 0, 2)
		if file.Staged {
			diffText, err := reviewStagedReviewFileDiffTextContext(ctx, repo, file)
			if err != nil {
				return types.ReviewDiff{}, err
			}
			section := buildReviewDiffSection("staged", "Staged changes", file.StagedChangeType, diffText)
			if !section.TooLarge && !section.Binary {
				preview, err := reviewStagedPreviewTextContext(ctx, repo, file)
				if err != nil {
					return types.ReviewDiff{}, err
				}
				attachReviewDiffPreviewText(&section, preview)
			}
			sections = append(sections, section)
		}
		if file.Unstaged {
			diffText, err := reviewUnstagedReviewFileDiffTextContext(ctx, repo, file)
			if err != nil {
				return types.ReviewDiff{}, err
			}
			section := buildReviewDiffSection("unstaged", "Unstaged changes", file.UnstagedChangeType, diffText)
			if !section.TooLarge && !section.Binary {
				preview, err := reviewUnstagedPreviewTextContext(ctx, repo, file)
				if err != nil {
					return types.ReviewDiff{}, err
				}
				attachReviewDiffPreviewText(&section, preview)
			}
			sections = append(sections, section)
		}
		return types.ReviewDiff{
			Status:     reviewDiffStatusFromSections(sections),
			Path:       file.Path,
			OldPath:    file.OldPath,
			ChangeType: file.ChangeType,
			Sections:   sections,
		}, nil
	default:
		return types.ReviewDiff{}, fmt.Errorf("unsupported review scope %q", scope)
	}
}

// ValidateOutputTarget validates an output target directory or git repository.
func ValidateOutputTarget(rootPath string, kind string) error {
	trimmedPath := strings.TrimSpace(rootPath)
	if trimmedPath == "" {
		return fmt.Errorf("output target path is required")
	}

	info, err := os.Stat(trimmedPath)
	if err != nil {
		if os.IsNotExist(err) {
			return fmt.Errorf("output target does not exist")
		}
		return err
	}
	if !info.IsDir() {
		return fmt.Errorf("output target must be a directory")
	}

	switch strings.TrimSpace(kind) {
	case "directory":
		return nil
	case "git_repo":
		if err := validateGitOutputTarget(trimmedPath); err != nil {
			if errors.Is(err, git.ErrRepositoryNotExists) || errors.Is(err, plumbing.ErrReferenceNotFound) {
				return fmt.Errorf("output target is not a git repository")
			}
			return err
		}
		return nil
	default:
		return fmt.Errorf("unsupported output target kind %q", strings.TrimSpace(kind))
	}
}

func validateGitOutputTarget(rootPath string) error {
	r, err := git.PlainOpenWithOptions(rootPath, &git.PlainOpenOptions{
		DetectDotGit:          true,
		EnableDotGitCommonDir: true,
	})
	if err != nil {
		return err
	}
	if _, err := r.Worktree(); err != nil {
		return err
	}
	_, err = r.Head()
	return err
}

func loadReviewRepoState(agentDir string, requestedBaseRef string, requestedHeadRef string, requestedComparisonMode string, legacyCompareRef string) (reviewRepoState, types.ReviewSummary, error) {
	r, err := git.PlainOpenWithOptions(agentDir, &git.PlainOpenOptions{
		DetectDotGit:          true,
		EnableDotGitCommonDir: true,
	})
	if err != nil {
		if errors.Is(err, git.ErrRepositoryNotExists) {
			return reviewRepoState{}, types.ReviewSummary{
				Status:  "not_git",
				Message: "This workspace is not in a git repository yet.",
			}, nil
		}
		return reviewRepoState{}, types.ReviewSummary{}, err
	}

	wt, err := r.Worktree()
	if err != nil {
		return reviewRepoState{}, types.ReviewSummary{}, err
	}
	root := reviewDisplayRoot(agentDir, wt.Filesystem.Root())
	resolvedRoot := reviewResolvedRoot(wt.Filesystem.Root())

	headRefObj, err := r.Head()
	if err != nil {
		if errors.Is(err, plumbing.ErrReferenceNotFound) {
			return reviewRepoState{}, types.ReviewSummary{
				Status:  "not_git",
				Message: "This workspace is not in a git repository yet.",
			}, nil
		}
		return reviewRepoState{}, types.ReviewSummary{}, err
	}
	branch := headRefObj.Name().Short()
	headCommitHash := headRefObj.Hash().String()

	defaultBranch, err := reviewDefaultBranch(r, branch)
	if err != nil {
		return reviewRepoState{}, types.ReviewSummary{}, err
	}
	availableRefs, err := reviewAvailableRefs(r, defaultBranch, branch)
	if err != nil {
		return reviewRepoState{}, types.ReviewSummary{}, err
	}
	resolvedBaseRef, resolvedHeadRef, err := resolveReviewRefs(
		r,
		strings.TrimSpace(requestedBaseRef),
		strings.TrimSpace(requestedHeadRef),
		strings.TrimSpace(legacyCompareRef),
		strings.TrimSpace(defaultBranch),
		strings.TrimSpace(branch),
		strings.TrimSpace(headCommitHash),
		availableRefs,
	)
	if err != nil {
		return reviewRepoState{}, types.ReviewSummary{}, err
	}
	comparisonMode, err := resolveReviewComparisonMode(requestedComparisonMode)
	if err != nil {
		return reviewRepoState{}, types.ReviewSummary{}, err
	}

	resolvedHeadCommitHash, err := reviewResolveToCommitHash(r, resolvedHeadRef)
	if err != nil {
		return reviewRepoState{}, types.ReviewSummary{}, err
	}

	mergeBaseHash, err := reviewMergeBase(r, resolvedBaseRef, resolvedHeadRef)
	if err != nil {
		return reviewRepoState{}, types.ReviewSummary{}, err
	}

	return reviewRepoState{
		repo:           r,
		root:           root,
		resolvedRoot:   resolvedRoot,
		branch:         branch,
		defaultBranch:  defaultBranch,
		baseRef:        resolvedBaseRef,
		headRef:        resolvedHeadRef,
		comparisonMode: comparisonMode,
		availableRefs:  append([]string(nil), availableRefs...),
		headCommit:     resolvedHeadCommitHash,
		mergeBase:      mergeBaseHash,
	}, types.ReviewSummary{}, nil
}

func reviewDisplayRoot(agentDir, discoveredRoot string) string {
	rootAbs, err := filepath.Abs(discoveredRoot)
	if err != nil {
		return discoveredRoot
	}
	rootAbs = filepath.Clean(rootAbs)

	agentAbs, err := filepath.Abs(agentDir)
	if err != nil {
		return rootAbs
	}
	agentAbs = filepath.Clean(agentAbs)

	rootReal, err := filepath.EvalSymlinks(rootAbs)
	if err != nil {
		return rootAbs
	}
	agentReal, err := filepath.EvalSymlinks(agentAbs)
	if err != nil {
		return rootAbs
	}

	rel, err := filepath.Rel(rootReal, agentReal)
	if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
		return rootAbs
	}
	if rel == "." {
		return agentAbs
	}

	displayRoot := agentAbs
	for _, part := range strings.Split(rel, string(filepath.Separator)) {
		if part == "" || part == "." {
			continue
		}
		displayRoot = filepath.Dir(displayRoot)
	}
	return displayRoot
}

// reviewResolvedRoot returns the cache-identity root for a worktree: the
// realpath of the worktree filesystem root, fully resolved through symlinks.
// Two callers that arrive via different alias paths to the same worktree must
// produce the same canonical key; two linked worktrees that share a git
// common dir must produce distinct keys (the worktree filesystem roots are
// physically separate).
//
// This is intentionally separate from reviewDisplayRoot: the display root
// preserves caller-supplied alias prefixes for UI continuity, while the
// canonical root is the cache identity and never carries alias information.
func reviewResolvedRoot(worktreeRoot string) string {
	abs, err := filepath.Abs(worktreeRoot)
	if err != nil {
		return filepath.Clean(worktreeRoot)
	}
	abs = filepath.Clean(abs)
	real, err := filepath.EvalSymlinks(abs)
	if err != nil {
		return abs
	}
	return real
}

// reviewResolveToCommitHash resolves a ref string to a commit hash string.
func reviewResolveToCommitHash(r *git.Repository, ref string) (string, error) {
	h, err := r.ResolveRevision(plumbing.Revision(ref))
	if err != nil {
		return "", fmt.Errorf("resolve %q: %w", ref, err)
	}
	return h.String(), nil
}

// reviewParseImmutableHash strictly parses a full hex commit hash. It rejects
// anything that is not exactly 40 hex chars (SHA-1) — symbolic refs ("HEAD",
// "main"), partial hashes, empty strings, the all-zero hash, or strings of
// the wrong length all fail. The immutable loader path (cache key + DiffTree
// + aggregate token) MUST consume a hash that has already been resolved
// elsewhere; this helper is the seam that enforces "no fallback revision
// resolution" so a moving branch cannot silently re-enter the authoritative
// chain. Object existence and type (commit vs blob/tree) are validated by
// the caller via r.CommitObject, which is the authoritative check.
//
// Error messages intentionally exclude the offending value: the strict path
// is reached only after the caller's own resolution, so the value is at best
// a downstream hash but at worst an arbitrary string passed by mistake —
// echoing it would risk leaking an uncontrolled ref/path fragment.
func reviewParseImmutableHash(hash string) (plumbing.Hash, error) {
	if len(hash) != 40 {
		return plumbing.ZeroHash, fmt.Errorf("immutable commit hash must be 40 hex chars (SHA-1); got len %d", len(hash))
	}
	for _, r := range hash {
		switch {
		case r >= '0' && r <= '9':
		case r >= 'a' && r <= 'f':
		case r >= 'A' && r <= 'F':
		default:
			return plumbing.ZeroHash, fmt.Errorf("immutable commit hash contains non-hex character")
		}
	}
	parsed := plumbing.NewHash(hash)
	if parsed == plumbing.ZeroHash {
		return plumbing.ZeroHash, fmt.Errorf("immutable commit hash is the zero hash")
	}
	return parsed, nil
}

// reviewTreeForImmutableCommitHash fetches a commit's tree by EXACT hash, with
// no fallback revision resolution. Use this on the authoritative cache/token
// path where the caller has already resolved refs to immutable hashes. The
// lenient reviewTreeForCommitHash (with revision fallback) is reserved for
// non-authoritative paths that may receive either a hash or a ref name.
func reviewTreeForImmutableCommitHash(r *git.Repository, commitHash string) (*object.Tree, error) {
	h, err := reviewParseImmutableHash(commitHash)
	if err != nil {
		return nil, err
	}
	commit, err := r.CommitObject(h)
	if err != nil {
		return nil, fmt.Errorf("get commit %s: %w", commitHash, err)
	}
	return commit.Tree()
}

// resolveReviewBranchCommitPair resolves the review branch diff pair to
// immutable commit hashes in ONE pass. The output pair is the canonical input
// for everything on the authoritative path: branch signature cache key,
// DiffTree (via loadReviewBranchChangesAtCommits), RepositoryToken, and
// BranchToken all MUST consume exactly this pair. Centralizing the resolve
// keeps the cache key + loader + token inputs in lockstep so a moving branch
// cannot re-enter via a later revision resolution.
func resolveReviewBranchCommitPair(repo reviewRepoState) (baseCommit string, headCommit string, err error) {
	baseRef, headRef := reviewBranchDiffCommitHashes(repo)
	baseCommit, err = reviewResolveToCommitHash(repo.repo, baseRef)
	if err != nil {
		return "", "", err
	}
	headCommit, err = reviewResolveToCommitHash(repo.repo, headRef)
	if err != nil {
		return "", "", err
	}
	return baseCommit, headCommit, nil
}

// reviewMergeBase finds the merge-base commit between two refs and returns its hash string.
func reviewMergeBase(r *git.Repository, baseRef string, headRef string) (string, error) {
	baseHash, err := r.ResolveRevision(plumbing.Revision(baseRef))
	if err != nil {
		return "", fmt.Errorf("resolve base ref %q: %w", baseRef, err)
	}
	headHash, err := r.ResolveRevision(plumbing.Revision(headRef))
	if err != nil {
		return "", fmt.Errorf("resolve head ref %q: %w", headRef, err)
	}
	baseCommit, err := r.CommitObject(*baseHash)
	if err != nil {
		return "", fmt.Errorf("get base commit: %w", err)
	}
	headCommit, err := r.CommitObject(*headHash)
	if err != nil {
		return "", fmt.Errorf("get head commit: %w", err)
	}
	bases, err := baseCommit.MergeBase(headCommit)
	if err != nil {
		return "", fmt.Errorf("merge-base: %w", err)
	}
	if len(bases) == 0 {
		return "", fmt.Errorf("no merge base found between %s and %s", baseRef, headRef)
	}
	return bases[0].Hash.String(), nil
}

// reviewBranchFiles lists files changed between the comparison base and head.
func reviewBranchFiles(repo reviewRepoState) ([]types.ReviewFile, error) {
	entry, err := loadBranchDiff(repo)
	if err != nil {
		return nil, err
	}
	return entry.files, nil
}

func reviewBranchLineStats(repo reviewRepoState) (reviewLineStats, error) {
	entry, err := loadBranchDiff(repo)
	if err != nil {
		return reviewLineStats{}, err
	}
	return entry.lineStats, nil
}

func reviewBranchLineStatsFromChanges(branchChanges reviewBranchChanges) (reviewLineStats, error) {
	var stats reviewLineStats
	for _, change := range branchChanges.changes {
		if reviewChangeTooLarge(branchChanges.baseTree, branchChanges.headTree, change) {
			continue
		}
		patch, err := change.Patch()
		if err != nil {
			// change.Patch() materializes file blobs for the diff text; on
			// control-char paths go-git's underlying validators can still
			// surface the invalid-path error here. Wrap it at the workspace
			// boundary so the Summary line-stats path never leaks.
			return reviewLineStats{}, wrapReviewTreeError(err)
		}
		stats.add(reviewLineStatsFromFilePatches(patch.FilePatches()))
	}
	return stats, nil
}

// loadReviewBranchChanges computes the branch diff for the current review
// state. It resolves the symbolic refs to immutable commit hashes via
// resolveReviewBranchCommitPair and delegates to loadReviewBranchChangesAtCommits.
// Callers that already hold a resolved commit pair (notably the fingerprint
// cache layer, which needs the resolved pair to be the SAME pair used for the
// cache key and aggregate token) MUST call loadReviewBranchChangesAtCommits
// directly with that pair — going through this entry point would re-resolve
// the symbolic refs and reintroduce the TOCTOU window where a moving branch
// can store a new pair's diff under an old pair's cache key.
func loadReviewBranchChanges(repo reviewRepoState) (reviewBranchChanges, error) {
	baseCommit, headCommit, err := resolveReviewBranchCommitPair(repo)
	if err != nil {
		return reviewBranchChanges{}, err
	}
	return loadReviewBranchChangesAtCommits(repo, baseCommit, headCommit)
}

// loadReviewBranchChangesAtCommits computes the branch diff for an EXACT,
// already-resolved commit pair. The caller is responsible for resolving
// symbolic refs to immutable hashes before invoking this — the DiffTree
// output is anchored at exactly these two commits, regardless of any
// concurrent branch movement. This is the entry point the fingerprint
// authoritative chain uses so that cache key, DiffTree, and aggregate
// token all see the same byte-identical pair.
//
// Inputs are parsed strictly via reviewParseImmutableHash and trees fetched
// via reviewTreeForImmutableCommitHash — there is NO fallback revision
// resolution. A symbolic ref name passed here is an error, not silently
// re-resolved, so a moving branch cannot re-enter the authoritative path.
func loadReviewBranchChangesAtCommits(repo reviewRepoState, baseCommitHash, headCommitHash string) (reviewBranchChanges, error) {
	baseTree, err := reviewTreeForImmutableCommitHash(repo.repo, baseCommitHash)
	if err != nil {
		return reviewBranchChanges{}, err
	}
	headTree, err := reviewTreeForImmutableCommitHash(repo.repo, headCommitHash)
	if err != nil {
		return reviewBranchChanges{}, err
	}
	changes, err := object.DiffTreeWithOptions(context.Background(), baseTree, headTree, &object.DiffTreeOptions{
		DetectRenames:    true,
		OnlyExactRenames: true,
	})
	if err != nil {
		return reviewBranchChanges{}, wrapReviewTreeError(err)
	}
	return reviewBranchChanges{
		baseTree: baseTree,
		headTree: headTree,
		changes:  changes,
	}, nil
}

// reviewWorkingFiles lists staged and unstaged file changes in the worktree.
//
// The status walk is the heaviest operation on this path — a full filesystem +
// index scan — so it runs through the git-CLI acceleration layer first and
// falls back to go-git's StatusWithOptions when the CLI is unavailable or
// errors. Everything downstream (HEAD tree lookup, index map, per-file hint
// token, staged/unstaged/change-type derivation) is identical for both engines:
// the CLI result is normalized into the same git.Status type go-git produces.
func reviewWorkingFiles(repo reviewRepoState) ([]types.ReviewFile, error) {
	wt, err := repo.repo.Worktree()
	if err != nil {
		return nil, err
	}
	workingRoot := wt.Filesystem.Root()
	status, oldPathByNewPath, usedCLI, err := reviewWorktreeStatus(repo, wt, workingRoot)
	if err != nil {
		return nil, err
	}
	intentToAddPaths, err := reviewIntentToAddPaths(repo.repo)
	if err != nil {
		return nil, err
	}
	// The CLI path reports intent-to-add files with porcelain's own XY codes
	// (worktree-add), whereas go-git reports them as {staged-add, worktree-
	// modified}. Normalize CLI entries to the go-git shape so the downstream
	// flag derivation matches byte-for-byte regardless of which engine ran.
	if usedCLI {
		for path := range intentToAddPaths {
			if _, ok := status[path]; ok {
				status[path] = &git.FileStatus{Staging: git.Added, Worktree: git.Modified}
			}
		}
	}

	// The hint token covers all three sides of the working diff (HEAD, index,
	// worktree). The HEAD tree comes from r.Head() — NOT repo.headRef —
	// because the working diff is always anchored to the actual HEAD of the
	// worktree, even in compare mode where repo.headRef may point elsewhere.
	// A missing HEAD (newborn repo) leaves headEntry nil and the token's head
	// segment falls back to the "missing" sentinel.
	headTree, err := reviewHeadTree(repo.repo)
	if err != nil {
		return nil, err
	}
	idx, err := repo.repo.Storer.Index()
	if err != nil {
		return nil, err
	}
	indexByPath := make(map[string]*object.TreeEntry, len(idx.Entries))
	for _, e := range idx.Entries {
		entry := e // capture for pointer
		indexByPath[entry.Name] = &object.TreeEntry{
			Name: entry.Name,
			Mode: entry.Mode,
			Hash: entry.Hash,
		}
	}

	combined := make(map[string]types.ReviewFile)
	for filePath, fileStatus := range status {
		// StatusWithOptions silently includes control-char-named entries in
		// v5.19.1 (the worktree_fs.validPath validator only fires on
		// Create/Open/Add/FindEntry, not on the status walker). Validate
		// here so the working scan surfaces the same unsupported sentinel
		// as the branch DiffTree path — otherwise the offending bytes
		// would travel out as ReviewFile.Path AND skip the typed error
		// downstream layers rely on for HTTP 422 mapping.
		if err := validateReviewTreePath(filePath); err != nil {
			return nil, err
		}
		entry := combined[filePath]
		entry.Path = filePath
		entry.OldPath = oldPathByNewPath[filePath]
		if entry.OldPath != "" {
			if err := validateReviewTreePath(entry.OldPath); err != nil {
				return nil, err
			}
		}

		if fileStatus.Staging != git.Unmodified && fileStatus.Staging != git.Untracked && !intentToAddPaths[filePath] {
			entry.Staged = true
			entry.StagedChangeType = reviewStatusCodeToChangeType(fileStatus.Staging)
			if entry.ChangeType == "" {
				entry.ChangeType = entry.StagedChangeType
			}
		}

		if fileStatus.Worktree != git.Unmodified {
			entry.Unstaged = true
			entry.UnstagedChangeType = reviewStatusCodeToChangeType(fileStatus.Worktree)
			if intentToAddPaths[filePath] {
				entry.UnstagedChangeType = "A"
			}
			if entry.ChangeType == "" {
				entry.ChangeType = entry.UnstagedChangeType
			}
		}

		if entry.Staged || entry.Unstaged {
			oldPath, _ := reviewDiffPaths(entry)
			var headEntry *object.TreeEntry
			if headTree != nil {
				if te, err := headTree.FindEntry(oldPath); err == nil && te != nil {
					headEntry = te
				}
			}
			entry.Token = reviewWorkingFileHintToken(
				workingRoot,
				entry,
				headEntry,
				indexByPath[filePath],
			)
			combined[filePath] = entry
		}
	}

	files := make([]types.ReviewFile, 0, len(combined))
	for _, file := range combined {
		files = append(files, file)
	}
	sort.Slice(files, func(i, j int) bool {
		return files[i].Path < files[j].Path
	})
	return files, nil
}

// reviewWorktreeStatus acquires the worktree status via the git CLI when
// available, falling back to go-git's StatusWithOptions (Preload) otherwise.
// The returned usedCLI flag tells the caller whether CLI-specific
// normalization (e.g. intent-to-add) should be applied.
//
// The go-git fallback still applies the linked-worktree info/exclude patterns
// manually because go-git's status does not read info/exclude by default;
// native `git status` honors info/exclude, .gitignore, and worktree-local
// excludes automatically, so the CLI path needs no exclude plumbing.
func reviewWorktreeStatus(repo reviewRepoState, wt *git.Worktree, workingRoot string) (git.Status, map[string]string, bool, error) {
	if result, err := reviewStatusViaGitCLI(workingRoot); err == nil {
		return result.status, result.oldPathByNewPath, true, nil
	}
	extraExcludes, err := reviewLinkedWorktreeExcludes(repo.root)
	if err != nil {
		return nil, nil, false, err
	}
	wt.Excludes = append(wt.Excludes[:0], extraExcludes...)

	// Preload reads the worktree once before comparing it with the index.
	// The default Empty strategy repeatedly walks the filesystem and becomes
	// disproportionately expensive in large repositories.
	status, err := wt.StatusWithOptions(git.StatusOptions{Strategy: git.Preload})
	if err != nil {
		// StatusWithOptions walks the worktree and index; a working-or-staged
		// control-char path triggers worktree_fs.validPath rejection here.
		// Wrap at the workspace boundary so the working scan never leaks the
		// offending path and the unsupported sentinel propagates uniformly.
		return nil, nil, false, wrapReviewTreeError(err)
	}
	result, err := collapseExactWorkingRenames(repo.repo, status)
	if err != nil {
		return nil, nil, false, err
	}
	return result.status, result.oldPathByNewPath, false, nil
}

func collapseExactWorkingRenames(r *git.Repository, status git.Status) (reviewPorcelainStatus, error) {
	result := reviewPorcelainStatus{
		status:           make(git.Status, len(status)),
		oldPathByNewPath: make(map[string]string),
	}
	deletedByHash := make(map[plumbing.Hash][]string)
	addedByHash := make(map[plumbing.Hash][]string)
	idx, err := r.Storer.Index()
	if err != nil {
		return reviewPorcelainStatus{}, err
	}
	indexByPath := make(map[string]plumbing.Hash, len(idx.Entries))
	for _, entry := range idx.Entries {
		indexByPath[entry.Name] = entry.Hash
	}
	headTree, err := reviewHeadTree(r)
	if err != nil {
		return reviewPorcelainStatus{}, err
	}
	for path, fileStatus := range status {
		result.status[path] = fileStatus
		if fileStatus.Staging == git.Deleted && headTree != nil {
			if entry, findErr := headTree.FindEntry(path); findErr == nil {
				deletedByHash[entry.Hash] = append(deletedByHash[entry.Hash], path)
			}
		}
		if fileStatus.Staging == git.Added {
			if hash := indexByPath[path]; !hash.IsZero() {
				addedByHash[hash] = append(addedByHash[hash], path)
			}
		}
	}
	for hash, oldPaths := range deletedByHash {
		newPaths := addedByHash[hash]
		if len(oldPaths) != 1 || len(newPaths) != 1 {
			continue
		}
		oldPath, newPath := oldPaths[0], newPaths[0]
		newStatus := result.status[newPath]
		if newStatus == nil {
			continue
		}
		result.status[newPath] = &git.FileStatus{
			Staging:  git.Renamed,
			Worktree: newStatus.Worktree,
		}
		result.oldPathByNewPath[newPath] = oldPath
		delete(result.status, oldPath)
	}
	return result, nil
}

func reviewWorkingLineStats(repo reviewRepoState, files []types.ReviewFile) (reviewLineStats, error) {
	var stats reviewLineStats
	for _, file := range files {
		// Raw Git path: do not TrimSpace. Trimming would let a real path
		// like " main.go" be stat-ed as "main.go" — reading a different file
		// and breaking the byte-identical-diff guarantee the token promises.
		path := file.Path
		if path == "" {
			continue
		}
		if file.Staged {
			nextStats, err := reviewStagedLineStats(repo, file)
			if err != nil {
				return reviewLineStats{}, err
			}
			stats.add(nextStats)
		}
		if file.Unstaged {
			nextStats, err := reviewUnstagedLineStats(repo, file)
			if err != nil {
				return reviewLineStats{}, err
			}
			stats.add(nextStats)
		}
	}
	return stats, nil
}

func reviewStagedLineStats(repo reviewRepoState, file types.ReviewFile) (reviewLineStats, error) {
	oldPath, newPath := reviewDiffPaths(file)
	headTree, err := reviewHeadTree(repo.repo)
	if err != nil {
		return reviewLineStats{}, err
	}
	oldContent, err := reviewTreeFileContent(headTree, oldPath)
	if err != nil {
		if errors.Is(err, errReviewDiffTooLarge) {
			return reviewLineStats{}, nil
		}
		oldContent = ""
	}
	newContent, err := reviewIndexFileContent(repo.repo, newPath)
	if err != nil {
		if errors.Is(err, errReviewDiffTooLarge) {
			return reviewLineStats{}, nil
		}
		newContent = ""
	}
	return reviewLineStatsFromContent(oldContent, newContent), nil
}

func reviewUnstagedLineStats(repo reviewRepoState, file types.ReviewFile) (reviewLineStats, error) {
	newPath := file.Path
	oldContent, err := reviewIndexFileContent(repo.repo, newPath)
	if err != nil {
		if errors.Is(err, errReviewDiffTooLarge) {
			return reviewLineStats{}, nil
		}
		oldContent = ""
	}
	wt, err := repo.repo.Worktree()
	if err != nil {
		return reviewLineStats{}, err
	}
	newContent, err := reviewWorkingTreeFileContent(wt.Filesystem.Root(), newPath)
	if err != nil {
		if errors.Is(err, errReviewDiffTooLarge) {
			return reviewLineStats{}, nil
		}
		newContent = ""
	}
	return reviewLineStatsFromContent(oldContent, newContent), nil
}

func (stats *reviewLineStats) add(next reviewLineStats) {
	if stats == nil {
		return
	}
	stats.additions += next.additions
	stats.deletions += next.deletions
}

func reviewLineStatsFromFilePatches(filePatches []fdiff.FilePatch) reviewLineStats {
	var stats reviewLineStats
	for _, filePatch := range filePatches {
		if filePatch == nil || len(filePatch.Chunks()) == 0 {
			continue
		}
		stats.add(reviewLineStatsFromChunks(filePatch.Chunks()))
	}
	return stats
}

func reviewLineStatsFromChunks(chunks []fdiff.Chunk) reviewLineStats {
	var stats reviewLineStats
	for _, chunk := range chunks {
		if chunk == nil {
			continue
		}
		count := reviewDiffContentLineCount(chunk.Content())
		switch chunk.Type() {
		case fdiff.Add:
			stats.additions += count
		case fdiff.Delete:
			stats.deletions += count
		}
	}
	return stats
}

func reviewLineStatsFromContent(oldContent string, newContent string) reviewLineStats {
	if oldContent == newContent || reviewContentTooLargeForDiff(oldContent, newContent) {
		return reviewLineStats{}
	}
	if strings.Contains(oldContent, "\x00") || strings.Contains(newContent, "\x00") {
		return reviewLineStats{}
	}
	return reviewLineStatsFromChunks(reviewComputeChunks(oldContent, newContent))
}

func reviewDiffContentLineCount(content string) int {
	if content == "" {
		return 0
	}
	count := strings.Count(content, "\n")
	if content[len(content)-1] != '\n' {
		count++
	}
	return count
}

func reviewIntentToAddPaths(r *git.Repository) (map[string]bool, error) {
	idx, err := r.Storer.Index()
	if err != nil {
		return nil, err
	}
	paths := make(map[string]bool)
	for _, entry := range idx.Entries {
		if entry.IntentToAdd {
			paths[entry.Name] = true
		}
	}
	return paths, nil
}

// reviewStatusCodeToChangeType converts a go-git StatusCode to a change type letter.
func reviewStatusCodeToChangeType(code git.StatusCode) string {
	switch code {
	case git.Added:
		return "A"
	case git.Deleted:
		return "D"
	case git.Modified:
		return "M"
	case git.Renamed:
		return "R"
	case git.Copied:
		return "C"
	case git.Untracked:
		return "A"
	default:
		return "M"
	}
}

// reviewExcludeFilePath resolves the info/exclude file go-git's status applies
// manually: <root>/.git/info/exclude for a primary worktree, or the linked
// worktree's own gitdir .../info/exclude when <root>/.git is a gitdir pointer
// file. Returns an empty path (no error) when there is nothing to resolve.
//
// This is shared by the go-git path (which reads the patterns directly) and the
// git-CLI path (which points native git at the file via core.excludesFile), so
// both engines apply exactly the same local excludes. That matters for linked
// worktrees: native git reads info/exclude from the COMMON dir, whereas go-git
// (and thus this path) reads the worktree gitdir's copy — without sharing the
// path the two engines would honor different exclude files.
func reviewExcludeFilePath(root string) (string, error) {
	dotGitPath := filepath.Join(root, ".git")
	info, err := os.Stat(dotGitPath)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return "", nil
		}
		return "", err
	}
	if info.IsDir() {
		return filepath.Join(dotGitPath, "info", "exclude"), nil
	}

	data, err := os.ReadFile(dotGitPath)
	if err != nil {
		return "", err
	}
	line := strings.TrimSpace(string(data))
	const prefix = "gitdir:"
	if !strings.HasPrefix(line, prefix) {
		return "", nil
	}
	gitDir := strings.TrimSpace(strings.TrimPrefix(line, prefix))
	if gitDir == "" {
		return "", nil
	}
	if !filepath.IsAbs(gitDir) {
		gitDir = filepath.Join(root, gitDir)
	}
	return filepath.Join(gitDir, "info", "exclude"), nil
}

func reviewLinkedWorktreeExcludes(root string) ([]gitignore.Pattern, error) {
	path, err := reviewExcludeFilePath(root)
	if err != nil {
		return nil, err
	}
	if path == "" {
		return nil, nil
	}
	return reviewReadIgnorePatterns(path)
}

func reviewReadIgnorePatterns(path string) ([]gitignore.Pattern, error) {
	file, err := os.Open(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil, nil
		}
		return nil, err
	}
	defer file.Close()

	patterns := make([]gitignore.Pattern, 0)
	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		line := scanner.Text()
		if strings.HasPrefix(line, "#") || strings.TrimSpace(line) == "" {
			continue
		}
		patterns = append(patterns, gitignore.ParsePattern(line, nil))
	}
	if err := scanner.Err(); err != nil {
		return nil, err
	}
	return patterns, nil
}

func resolveReviewComparisonMode(requestedComparisonMode string) (string, error) {
	trimmed := strings.TrimSpace(requestedComparisonMode)
	if trimmed == "" {
		return "common_base", nil
	}
	switch trimmed {
	case "common_base", "direct":
		return trimmed, nil
	default:
		return "", fmt.Errorf("unsupported comparison mode %q", trimmed)
	}
}

func findReviewFile(files []types.ReviewFile, path string) *types.ReviewFile {
	for i := range files {
		if files[i].Path == path {
			return &files[i]
		}
	}
	return nil
}

func buildReviewDiffSection(key string, label string, changeType string, diffText string) types.ReviewDiffSection {
	section := types.ReviewDiffSection{
		Key:        key,
		Label:      label,
		ChangeType: changeType,
	}
	trimmed := strings.TrimSpace(diffText)
	switch {
	case trimmed == "":
		section.Message = "No diff available for this file."
	case diffText == reviewDiffTooLargeMarker:
		section.TooLarge = true
		section.Message = "Diff is too large to preview."
	case diffText == reviewDiffBinaryMarker:
		section.Binary = true
		section.Message = "Binary files cannot be previewed."
	case len(diffText) > reviewDiffMaxBytes:
		section.TooLarge = true
		section.Message = "Diff is too large to preview."
	case strings.Contains(diffText, "\x00"):
		section.Binary = true
		section.Message = "Binary files cannot be previewed."
	default:
		section.Diff = diffText
	}
	return section
}

func attachReviewDiffPreviewText(section *types.ReviewDiffSection, preview reviewDiffPreviewText) {
	if section == nil || section.Binary || section.TooLarge || strings.TrimSpace(section.Diff) == "" {
		return
	}
	oldText, ok := sanitizeReviewPreviewText(preview.oldText)
	if !ok {
		return
	}
	newText, ok := sanitizeReviewPreviewText(preview.newText)
	if !ok {
		return
	}
	section.OldText = oldText
	section.NewText = newText
}

func sanitizeReviewPreviewText(text string) (string, bool) {
	if len(text) > reviewDiffMaxBytes {
		return "", false
	}
	if strings.Contains(text, "\x00") {
		return "", false
	}
	return text, true
}

func sectionStatus(section types.ReviewDiffSection) string {
	if section.Binary {
		return "binary"
	}
	if section.TooLarge {
		return "too_large"
	}
	if strings.TrimSpace(section.Diff) == "" {
		return "not_changed"
	}
	return "ready"
}

func reviewDiffStatusFromSections(sections []types.ReviewDiffSection) string {
	if len(sections) == 0 {
		return "not_changed"
	}
	status := "not_changed"
	for _, section := range sections {
		switch sectionStatus(section) {
		case "too_large":
			return "too_large"
		case "binary":
			return "binary"
		case "ready":
			status = "ready"
		}
	}
	return status
}

func normalizeReviewPath(agentDir string, path string) (string, error) {
	// Raw Git path: only check emptiness. No TrimSpace / Clean / normalize —
	// the caller-supplied path enters safeJoin verbatim (traversal protection
	// is preserved there), and the resulting relative path keeps any leading
	// / trailing whitespace or newlines the original Git path carried.
	if path == "" {
		return "", fmt.Errorf("review path is required")
	}
	joined, err := safeJoin(agentDir, path)
	if err != nil {
		return "", err
	}
	relative, err := filepath.Rel(agentDir, joined)
	if err != nil {
		return "", err
	}
	return filepath.ToSlash(relative), nil
}

// reviewDefaultBranch discovers the default branch of the repository. It uses
// the git CLI (`git symbolic-ref` for origin/HEAD, then `git for-each-ref` to
// test main/master) when available and falls back to go-git's reference API
// otherwise.
func reviewDefaultBranch(r *git.Repository, currentBranch string) (string, error) {
	if root, ok := reviewRepoWorktreeRoot(r); ok {
		if target, err := reviewOriginHEADTargetViaCLI(root); err == nil && target != "" {
			return target, nil
		}
		if discovered, err := reviewDiscoveredRefsViaCLI(root); err == nil {
			if reviewRefInList(discovered, "main") {
				return "main", nil
			}
			if reviewRefInList(discovered, "master") {
				return "master", nil
			}
			return currentBranch, nil
		}
	}

	// go-git fallback.
	ref, err := r.Reference(plumbing.NewRemoteReferenceName("origin", "HEAD"), false)
	if err == nil && ref.Type() == plumbing.SymbolicReference {
		target := ref.Target().Short()
		target = strings.TrimPrefix(target, "origin/")
		if target != "" {
			return target, nil
		}
	}
	if reviewRefExists(r, "refs/heads/main") {
		return "main", nil
	}
	if reviewRefExists(r, "refs/heads/master") {
		return "master", nil
	}
	return currentBranch, nil
}

// reviewRefInList reports whether a short ref name appears in the list.
func reviewRefInList(refs []string, name string) bool {
	for _, ref := range refs {
		if ref == name {
			return true
		}
	}
	return false
}

func resolveReviewRefs(
	r *git.Repository,
	requestedBaseRef string,
	requestedHeadRef string,
	legacyCompareRef string,
	defaultBranch string,
	currentBranch string,
	headCommit string,
	availableRefs []string,
) (string, string, error) {
	headFallback := strings.TrimSpace(currentBranch)
	if headFallback == "" || headFallback == "HEAD" {
		headFallback = strings.TrimSpace(headCommit)
	}
	headRef, err := resolveReviewRef(r, requestedHeadRef, headFallback, availableRefs)
	if err != nil {
		return "", "", err
	}

	baseFallback := strings.TrimSpace(requestedBaseRef)
	if baseFallback == "" {
		baseFallback = strings.TrimSpace(legacyCompareRef)
	}
	if baseFallback == "" {
		baseFallback = strings.TrimSpace(defaultBranch)
	}
	baseRef, err := resolveReviewRef(r, requestedBaseRef, baseFallback, availableRefs)
	if err != nil {
		return "", "", err
	}
	return baseRef, headRef, nil
}

func resolveReviewRef(r *git.Repository, requestedRef string, fallbackRef string, availableRefs []string) (string, error) {
	candidate := strings.TrimSpace(requestedRef)
	if candidate == "" {
		candidate = strings.TrimSpace(fallbackRef)
	}
	if candidate == "" {
		return "", fmt.Errorf("review ref is required")
	}
	for _, ref := range availableRefs {
		if strings.TrimSpace(ref) == candidate {
			return candidate, nil
		}
	}
	if reviewRefExists(r, candidate) {
		return candidate, nil
	}
	return "", fmt.Errorf("unsupported review ref %q", candidate)
}

// reviewAvailableRefs lists all available branch, remote-tracking, and tag
// refs. It prefers `git for-each-ref` and falls back to go-git's reference
// iterator when the CLI is unavailable or errors.
func reviewAvailableRefs(r *git.Repository, defaultBranch string, currentBranch string) ([]string, error) {
	discovered, ok := reviewDiscoveredRefs(r)
	if !ok {
		var err error
		discovered, err = reviewDiscoveredRefsViaGoGit(r)
		if err != nil {
			return nil, err
		}
	}

	refs := make([]string, 0)
	seen := make(map[string]struct{})
	addRef := func(ref string) {
		trimmed := strings.TrimSpace(ref)
		if trimmed == "" {
			return
		}
		if _, exists := seen[trimmed]; exists {
			return
		}
		seen[trimmed] = struct{}{}
		refs = append(refs, trimmed)
	}

	addRef(defaultBranch)
	addRef(currentBranch)

	sort.Strings(discovered)
	for _, ref := range discovered {
		addRef(ref)
	}
	if len(refs) == 0 {
		addRef(currentBranch)
	}
	return refs, nil
}

// reviewDiscoveredRefs lists branch, remote-tracking, and tag short names via
// the git CLI. The ok result is false when the CLI is unavailable or errors so
// the caller falls back to go-git.
func reviewDiscoveredRefs(r *git.Repository) ([]string, bool) {
	root, ok := reviewRepoWorktreeRoot(r)
	if !ok {
		return nil, false
	}
	refs, err := reviewDiscoveredRefsViaCLI(root)
	if err != nil {
		return nil, false
	}
	return refs, true
}

// reviewDiscoveredRefsViaGoGit is the go-git fallback for reviewDiscoveredRefs.
func reviewDiscoveredRefsViaGoGit(r *git.Repository) ([]string, error) {
	iter, err := r.References()
	if err != nil {
		return nil, err
	}
	discovered := make([]string, 0)
	err = iter.ForEach(func(ref *plumbing.Reference) error {
		name := ref.Name()
		if !name.IsBranch() && !name.IsRemote() && !name.IsTag() {
			return nil
		}
		short := name.Short()
		if short == "origin/HEAD" {
			return nil
		}
		discovered = append(discovered, short)
		return nil
	})
	if err != nil {
		return nil, err
	}
	return discovered, nil
}

// reviewRefExists checks if a ref name resolves to a valid commit.
func reviewRefExists(r *git.Repository, ref string) bool {
	h, err := r.ResolveRevision(plumbing.Revision(ref + "^{commit}"))
	if err != nil {
		return false
	}
	return !h.IsZero()
}

// reviewAheadBehind counts how many commits head is ahead/behind base. It uses
// `git rev-list --count` when the CLI is available (matching the go-git
// merge-base-derived counts for the common ancestry case) and falls back to the
// go-git traversal otherwise.
func reviewAheadBehind(r *git.Repository, baseRef string, headRef string) (ahead int, behind int, err error) {
	if root, ok := reviewRepoWorktreeRoot(r); ok {
		if a, b, err := reviewAheadBehindViaCLI(root, baseRef, headRef); err == nil {
			return a, b, nil
		}
	}
	return reviewAheadBehindViaGoGit(r, baseRef, headRef)
}

// reviewAheadBehindViaGoGit is the go-git fallback for reviewAheadBehind.
func reviewAheadBehindViaGoGit(r *git.Repository, baseRef string, headRef string) (ahead int, behind int, err error) {
	baseHash, err := r.ResolveRevision(plumbing.Revision(baseRef))
	if err != nil {
		return 0, 0, fmt.Errorf("resolve base ref %q: %w", baseRef, err)
	}
	headHash, err := r.ResolveRevision(plumbing.Revision(headRef))
	if err != nil {
		return 0, 0, fmt.Errorf("resolve head ref %q: %w", headRef, err)
	}
	if *baseHash == *headHash {
		return 0, 0, nil
	}

	baseCommit, err := r.CommitObject(*baseHash)
	if err != nil {
		return 0, 0, err
	}
	headCommit, err := r.CommitObject(*headHash)
	if err != nil {
		return 0, 0, err
	}

	// Find merge base(s)
	bases, err := baseCommit.MergeBase(headCommit)
	if err != nil {
		return 0, 0, err
	}
	mergeBaseSet := make(map[plumbing.Hash]bool, len(bases))
	for _, b := range bases {
		mergeBaseSet[b.Hash] = true
	}

	// Count commits from head to merge-base (ahead)
	ahead, err = countCommitsToAncestors(headCommit, mergeBaseSet)
	if err != nil {
		return 0, 0, err
	}
	// Count commits from base to merge-base (behind)
	behind, err = countCommitsToAncestors(baseCommit, mergeBaseSet)
	if err != nil {
		return 0, 0, err
	}
	return ahead, behind, nil
}

// countCommitsToAncestors counts commits from start down to (but not including)
// any of the ancestor set.
func countCommitsToAncestors(start *object.Commit, ancestors map[plumbing.Hash]bool) (int, error) {
	if ancestors[start.Hash] {
		return 0, nil
	}
	count := 0
	seen := make(map[plumbing.Hash]bool)
	stack := []*object.Commit{start}
	for len(stack) > 0 {
		commit := stack[len(stack)-1]
		stack = stack[:len(stack)-1]
		if commit == nil || seen[commit.Hash] {
			continue
		}
		seen[commit.Hash] = true
		if ancestors[commit.Hash] {
			continue
		}
		count++
		parentIter := commit.Parents()
		err := parentIter.ForEach(func(parent *object.Commit) error {
			stack = append(stack, parent)
			return nil
		})
		if err != nil {
			return 0, err
		}
	}
	return count, nil
}

// reviewTreeForCommitHash resolves a commit hash string to its tree.
func reviewTreeForCommitHash(r *git.Repository, commitHash string) (*object.Tree, error) {
	h := plumbing.NewHash(commitHash)
	commit, err := r.CommitObject(h)
	if err != nil {
		// Maybe it's a ref name, not a hash — try resolving as revision.
		resolved, resolveErr := r.ResolveRevision(plumbing.Revision(commitHash))
		if resolveErr != nil {
			return nil, fmt.Errorf("get commit %s: %w", commitHash, err)
		}
		commit, err = r.CommitObject(*resolved)
		if err != nil {
			return nil, fmt.Errorf("get commit %s: %w", commitHash, err)
		}
	}
	return commit.Tree()
}

// reviewHeadTree returns the tree of the HEAD commit.
func reviewHeadTree(r *git.Repository) (*object.Tree, error) {
	headRef, err := r.Head()
	if err != nil {
		if errors.Is(err, plumbing.ErrReferenceNotFound) {
			return nil, nil
		}
		return nil, err
	}
	commit, err := r.CommitObject(headRef.Hash())
	if err != nil {
		return nil, err
	}
	return commit.Tree()
}

// reviewBranchDiffCommitHashes returns the base and head commit hashes for branch diff.
func reviewBranchDiffCommitHashes(repo reviewRepoState) (string, string) {
	if repo.comparisonMode == "direct" {
		return repo.baseRef, repo.headRef
	}
	return repo.mergeBase, repo.headCommit
}

// changesToReviewFiles converts go-git Changes to ReviewFile entries.
func changesToReviewFiles(changes object.Changes) []types.ReviewFile {
	files := make([]types.ReviewFile, 0, len(changes))
	for _, ch := range changes {
		action, err := ch.Action()
		if err != nil {
			continue
		}
		var file types.ReviewFile
		switch action {
		case merkletrie.Insert:
			file.Path = ch.To.Name
			file.ChangeType = "A"
			file.Token = reviewBranchFileToken(file, ch.From.TreeEntry, ch.To.TreeEntry)
		case merkletrie.Delete:
			file.Path = ch.From.Name
			file.ChangeType = "D"
			file.Token = reviewBranchFileToken(file, ch.From.TreeEntry, ch.To.TreeEntry)
		case merkletrie.Modify:
			file.Path = ch.To.Name
			if ch.From.Name != ch.To.Name {
				file.ChangeType = "R"
				file.OldPath = ch.From.Name
			} else {
				file.ChangeType = "M"
			}
			file.Token = reviewBranchFileToken(file, ch.From.TreeEntry, ch.To.TreeEntry)
		}
		if file.Path == "" {
			continue
		}
		files = append(files, file)
	}
	sort.Slice(files, func(i, j int) bool {
		return files[i].Path < files[j].Path
	})
	return files
}

// reviewBranchFileToken / reviewWorkingFileHintToken / reviewWorkingFileInode
// live in fingerprint_file_tokens.go and inode_*.go (platform-split). The
// branch token is the full 64-hex SHA-256 of branchFileTokenInputs; the
// working hint token is the 16-hex truncation of workingFileTokenInputs.

// reviewBranchDiffText produces a unified diff for a single file between branch base and head.
func reviewBranchDiffText(repo reviewRepoState, path string) (string, error) {
	return reviewBranchDiffTextWithTrees(repo, path, nil)
}

func reviewBranchDiffTextWithTrees(repo reviewRepoState, path string, trees *reviewDiffTrees) (string, error) {
	return reviewBranchDiffTextWithTreesContext(context.Background(), repo, path, trees)
}

func reviewBranchDiffTextWithTreesContext(ctx context.Context, repo reviewRepoState, path string, trees *reviewDiffTrees) (string, error) {
	if err := ctx.Err(); err != nil {
		return "", err
	}
	if trees != nil && trees.baseTree != nil && trees.headTree != nil {
		return reviewUnifiedDiffForBranchFileContext(ctx, repo, trees, path)
	}
	baseCommitHash, headCommitHash := reviewBranchDiffCommitHashes(repo)

	baseTree, err := reviewTreeForCommitHash(repo.repo, baseCommitHash)
	if err != nil {
		return "", err
	}
	if err := ctx.Err(); err != nil {
		return "", err
	}
	headTree, err := reviewTreeForCommitHash(repo.repo, headCommitHash)
	if err != nil {
		return "", err
	}

	return reviewUnifiedDiffForBranchFileContext(ctx, repo, &reviewDiffTrees{baseTree: baseTree, headTree: headTree}, path)
}

// reviewStagedDiffText produces a unified diff for a single staged file (HEAD vs index).
func reviewStagedDiffText(repo reviewRepoState, path string) (string, error) {
	return reviewStagedDiffTextContext(context.Background(), repo, path)
}

func reviewStagedDiffTextContext(ctx context.Context, repo reviewRepoState, path string) (string, error) {
	return reviewStagedReviewFileDiffTextContext(ctx, repo, types.ReviewFile{Path: path})
}

func reviewStagedReviewFileDiffTextContext(ctx context.Context, repo reviewRepoState, file types.ReviewFile) (string, error) {
	if err := ctx.Err(); err != nil {
		return "", err
	}
	oldPath, newPath := reviewDiffPaths(file)
	headTree, err := reviewHeadTree(repo.repo)
	if err != nil {
		return "", err
	}
	if err := ctx.Err(); err != nil {
		return "", err
	}
	// Get file content from HEAD
	oldContent, err := reviewTreeFileContent(headTree, oldPath)
	if err != nil {
		if errors.Is(err, errReviewDiffTooLarge) {
			return reviewDiffTooLargeMarker, nil
		}
		oldContent = ""
	}
	if err := ctx.Err(); err != nil {
		return "", err
	}
	// Get file content from index
	newContent, err := reviewIndexFileContent(repo.repo, newPath)
	if err != nil {
		if errors.Is(err, errReviewDiffTooLarge) {
			if diffText, ok := reviewStagedDiffTextViaGitCLIContext(ctx, repo, newPath); ok {
				return diffText, nil
			}
			if err := ctx.Err(); err != nil {
				return "", err
			}
			return reviewDiffTooLargeMarker, nil
		}
		return "", err
	}
	if err := ctx.Err(); err != nil {
		return "", err
	}
	if diffText, ok := reviewDiffFromContentOrGitCLIContext(ctx, repo, newPath, oldContent, newContent, true); ok {
		return diffText, nil
	}
	return reviewUnifiedDiffFromContent(oldContent, newContent, oldPath, newPath)
}

// reviewUnstagedDiffText produces a unified diff for a single unstaged file (index vs worktree).
func reviewUnstagedDiffText(repo reviewRepoState, path string) (string, error) {
	return reviewUnstagedDiffTextContext(context.Background(), repo, path)
}

func reviewUnstagedDiffTextContext(ctx context.Context, repo reviewRepoState, path string) (string, error) {
	return reviewUnstagedReviewFileDiffTextContext(ctx, repo, types.ReviewFile{Path: path})
}

func reviewUnstagedReviewFileDiffTextContext(ctx context.Context, repo reviewRepoState, file types.ReviewFile) (string, error) {
	if err := ctx.Err(); err != nil {
		return "", err
	}
	newPath := file.Path
	// Get file content from index
	oldContent, err := reviewIndexFileContent(repo.repo, newPath)
	if err != nil {
		if errors.Is(err, errReviewDiffTooLarge) {
			return reviewDiffTooLargeMarker, nil
		}
		// If not in index, old content is empty
		oldContent = ""
	}
	if err := ctx.Err(); err != nil {
		return "", err
	}
	// Get file content from working tree
	wt, err := repo.repo.Worktree()
	if err != nil {
		return "", err
	}
	newContent, err := reviewWorkingTreeFileContent(wt.Filesystem.Root(), newPath)
	if err != nil {
		if errors.Is(err, errReviewDiffTooLarge) {
			if diffText, ok := reviewUnstagedDiffTextViaGitCLIContext(ctx, repo, newPath); ok {
				return diffText, nil
			}
			if err := ctx.Err(); err != nil {
				return "", err
			}
			return reviewDiffTooLargeMarker, nil
		}
		newContent = ""
	}
	if err := ctx.Err(); err != nil {
		return "", err
	}
	if diffText, ok := reviewDiffFromContentOrGitCLIContext(ctx, repo, newPath, oldContent, newContent, false); ok {
		return diffText, nil
	}
	return reviewUnifiedDiffFromContent(oldContent, newContent, newPath, newPath)
}

func reviewDiffFromContentOrGitCLI(repo reviewRepoState, path string, oldContent string, newContent string, staged bool) (string, bool) {
	return reviewDiffFromContentOrGitCLIContext(context.Background(), repo, path, oldContent, newContent, staged)
}

func reviewDiffFromContentOrGitCLIContext(ctx context.Context, repo reviewRepoState, path string, oldContent string, newContent string, staged bool) (string, bool) {
	if !reviewContentTooLargeForDiff(oldContent, newContent) {
		return "", false
	}
	if staged {
		if diffText, ok := reviewStagedDiffTextViaGitCLIContext(ctx, repo, path); ok {
			return diffText, true
		}
	} else if diffText, ok := reviewUnstagedDiffTextViaGitCLIContext(ctx, repo, path); ok {
		return diffText, true
	}
	return reviewDiffTooLargeMarker, true
}

// reviewUnifiedDiffForFile produces the unified diff text for a specific file
// between two trees. It looks up ONLY this path's blob on each side (O(one
// file)) instead of running a full DiffTree over every file — the per-file
// Diff endpoint is called once per visible file, so a whole-tree diff here
// would make "show all files" O(files × whole-tree-diff).
//
// Rename note: the path is looked up verbatim on both sides, so a modify or
// add/delete diffs correctly. A renamed file (path present only as the new
// name) diffs as an add against an empty base; the file list's aggregate diff
// remains the authority for rename detection.
func reviewUnifiedDiffForFile(baseTree, headTree *object.Tree, path string) (string, error) {
	baseEntry, baseFromTree, err := reviewTreeEntryForPath(baseTree, path)
	if err != nil {
		return "", err
	}
	headEntry, headFromTree, err := reviewTreeEntryForPath(headTree, path)
	if err != nil {
		return "", err
	}
	if baseEntry.Hash == plumbing.ZeroHash && headEntry.Hash == plumbing.ZeroHash {
		// Path exists on neither side: nothing to diff here.
		return "", nil
	}

	change := &object.Change{}
	if baseEntry.Hash != plumbing.ZeroHash {
		change.From = object.ChangeEntry{Name: path, Tree: baseFromTree, TreeEntry: baseEntry}
	}
	if headEntry.Hash != plumbing.ZeroHash {
		change.To = object.ChangeEntry{Name: path, Tree: headFromTree, TreeEntry: headEntry}
	}
	if reviewChangeTooLarge(baseTree, headTree, change) {
		return reviewDiffTooLargeMarker, nil
	}

	patch, err := change.Patch()
	if err != nil {
		// Patch() materializes file blobs; on a control-char path go-git's
		// validators can surface the invalid-path error here. Wrap so the
		// per-file Diff endpoint surfaces the same unsupported sentinel.
		return "", wrapReviewTreeError(err)
	}

	var buf bytes.Buffer
	ue := fdiff.NewUnifiedEncoder(&buf, fdiff.DefaultContextLines)
	if err := ue.Encode(patch); err != nil {
		return "", err
	}
	return buf.String(), nil
}

func reviewUnifiedDiffForBranchFile(repo reviewRepoState, trees *reviewDiffTrees, path string) (string, error) {
	return reviewUnifiedDiffForBranchFileContext(context.Background(), repo, trees, path)
}

func reviewUnifiedDiffForBranchFileContext(ctx context.Context, repo reviewRepoState, trees *reviewDiffTrees, path string) (string, error) {
	if err := ctx.Err(); err != nil {
		return "", err
	}
	diffText, err := reviewUnifiedDiffForFile(trees.baseTree, trees.headTree, path)
	if err != nil || diffText != reviewDiffTooLargeMarker {
		return diffText, err
	}
	if err := ctx.Err(); err != nil {
		return "", err
	}
	if cliDiff, ok := reviewBranchDiffTextViaGitCLIContext(ctx, repo, path); ok {
		return cliDiff, nil
	}
	if err := ctx.Err(); err != nil {
		return "", err
	}
	return reviewDiffTooLargeMarker, nil
}

// reviewTreeEntryForPath looks up a single path in a tree for the per-file diff
// path. It returns a zero TreeEntry (with the tree) when the path is absent so
// the caller can build an Insert/Delete ChangeEntry; not-found is swallowed,
// any other error (including control-char path rejection) is wrapped.
func reviewTreeEntryForPath(tree *object.Tree, path string) (object.TreeEntry, *object.Tree, error) {
	var zero object.TreeEntry
	if tree == nil {
		return zero, nil, nil
	}
	entry, err := tree.FindEntry(path)
	if err == nil {
		return *entry, tree, nil
	}
	if errors.Is(err, object.ErrEntryNotFound) || errors.Is(err, object.ErrDirectoryNotFound) {
		return zero, tree, nil
	}
	return zero, nil, wrapReviewTreeError(err)
}

func reviewChangeTooLarge(baseTree, headTree *object.Tree, ch *object.Change) bool {
	if ch == nil {
		return false
	}
	for _, filePath := range []string{ch.From.Name, ch.To.Name} {
		// Raw Git path: only check emptiness. No TrimSpace — paths with
		// surrounding whitespace are legitimate and must size-check the
		// actual file, not a trimmed alias.
		if filePath == "" {
			continue
		}
		if reviewTreeFileExceedsLimit(baseTree, filePath) || reviewTreeFileExceedsLimit(headTree, filePath) {
			return true
		}
	}
	return false
}

func reviewTreeFileExceedsLimit(tree *object.Tree, path string) bool {
	if tree == nil || path == "" {
		return false
	}
	f, err := tree.File(path)
	if err != nil {
		return false
	}
	return f.Size > reviewDiffMaxContentBytes
}

// reviewTextAtRef retrieves file content at a specific commit ref.
func reviewTextAtRef(r *git.Repository, ref string, path string) (string, error) {
	// ref is an enum-like control string (branch / commit / tag) and is still
	// trimmed. path is a Git path and MUST be preserved verbatim — only its
	// emptiness is checked, the raw value reaches tree.File.
	trimmedRef := strings.TrimSpace(ref)
	if trimmedRef == "" || path == "" {
		return "", nil
	}
	h, err := r.ResolveRevision(plumbing.Revision(trimmedRef))
	if err != nil {
		return "", nil
	}
	commit, err := r.CommitObject(*h)
	if err != nil {
		return "", nil
	}
	tree, err := commit.Tree()
	if err != nil {
		return "", nil
	}
	content, err := reviewTreeFileContent(tree, path)
	if err != nil {
		return "", nil
	}
	return content, nil
}

// reviewTreeFileContent reads file content from a tree.
func reviewTreeFileContent(tree *object.Tree, path string) (string, error) {
	if tree == nil {
		return "", fmt.Errorf("tree is nil")
	}
	f, err := tree.File(path)
	if err != nil {
		return "", err
	}
	if f.Size > reviewDiffMaxContentBytes {
		return "", errReviewDiffTooLarge
	}
	reader, err := f.Reader()
	if err != nil {
		return "", err
	}
	defer reader.Close()
	content, err := readReviewContent(reader, f.Size)
	if err != nil {
		return "", err
	}
	return content, nil
}

// reviewIndexFileContent reads a file's content from the git index (staging area).
func reviewIndexFileContent(r *git.Repository, path string) (string, error) {
	idx, err := r.Storer.Index()
	if err != nil {
		return "", err
	}
	for _, entry := range idx.Entries {
		if entry.Name == path {
			obj, err := r.Storer.EncodedObject(plumbing.BlobObject, entry.Hash)
			if err != nil {
				return "", err
			}
			if obj.Size() > reviewDiffMaxContentBytes {
				return "", errReviewDiffTooLarge
			}
			reader, err := obj.Reader()
			if err != nil {
				return "", err
			}
			defer reader.Close()
			content, err := readReviewContent(reader, obj.Size())
			if err != nil {
				return "", err
			}
			return content, nil
		}
	}
	return "", fmt.Errorf("file %q not found in index", path)
}

// reviewWorkingTreeFileContent reads a file from the working directory.
func reviewWorkingTreeFileContent(root string, path string) (string, error) {
	// Raw Git path: only check emptiness. No TrimSpace — paths with leading
	// or trailing whitespace (or newlines) are legitimate and MUST stat the
	// actual file, not a trimmed alias. Traversal protection is preserved by
	// safeJoin below.
	if path == "" {
		return "", nil
	}
	target, err := safeJoin(root, path)
	if err != nil {
		return "", err
	}
	info, err := os.Stat(target)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return "", nil
		}
		return "", err
	}
	if info.Size() > reviewDiffMaxContentBytes {
		return "", errReviewDiffTooLarge
	}
	f, err := os.Open(target)
	if err != nil {
		return "", err
	}
	defer f.Close()
	return readReviewContent(f, info.Size())
}

func readReviewContent(r io.Reader, size int64) (string, error) {
	if size > reviewDiffMaxContentBytes {
		return "", errReviewDiffTooLarge
	}
	limit := int64(reviewDiffMaxContentBytes) + 1
	data, err := io.ReadAll(io.LimitReader(r, limit))
	if err != nil {
		return "", err
	}
	if len(data) > reviewDiffMaxContentBytes {
		return "", errReviewDiffTooLarge
	}
	return string(data), nil
}

func reviewBranchPreviewText(repo reviewRepoState, file types.ReviewFile) (reviewDiffPreviewText, error) {
	return reviewBranchPreviewTextContext(context.Background(), repo, file)
}

func reviewBranchPreviewTextContext(ctx context.Context, repo reviewRepoState, file types.ReviewFile) (reviewDiffPreviewText, error) {
	if err := ctx.Err(); err != nil {
		return reviewDiffPreviewText{}, err
	}
	baseRef := repo.baseRef
	if repo.comparisonMode != "direct" {
		baseRef = repo.mergeBase
	}
	return reviewRefPreviewTextContext(ctx, repo.repo, baseRef, repo.headRef, file, file.ChangeType)
}

func reviewStagedPreviewText(repo reviewRepoState, file types.ReviewFile) (reviewDiffPreviewText, error) {
	return reviewStagedPreviewTextContext(context.Background(), repo, file)
}

func reviewStagedPreviewTextContext(ctx context.Context, repo reviewRepoState, file types.ReviewFile) (reviewDiffPreviewText, error) {
	return reviewIndexPreviewTextContext(ctx, repo.repo, "HEAD", file, file.StagedChangeType)
}

func reviewUnstagedPreviewText(repo reviewRepoState, file types.ReviewFile) (reviewDiffPreviewText, error) {
	return reviewUnstagedPreviewTextContext(context.Background(), repo, file)
}

func reviewUnstagedPreviewTextContext(ctx context.Context, repo reviewRepoState, file types.ReviewFile) (reviewDiffPreviewText, error) {
	if err := ctx.Err(); err != nil {
		return reviewDiffPreviewText{}, err
	}
	wt, err := repo.repo.Worktree()
	if err != nil {
		return reviewDiffPreviewText{}, err
	}
	unstagedFile := file
	unstagedFile.OldPath = ""
	return reviewWorkingTreePreviewTextContext(ctx, repo.repo, wt.Filesystem.Root(), unstagedFile, file.UnstagedChangeType)
}

func reviewRefPreviewText(r *git.Repository, oldRef string, newRef string, file types.ReviewFile, changeType string) (reviewDiffPreviewText, error) {
	return reviewRefPreviewTextContext(context.Background(), r, oldRef, newRef, file, changeType)
}

func reviewRefPreviewTextContext(ctx context.Context, r *git.Repository, oldRef string, newRef string, file types.ReviewFile, changeType string) (reviewDiffPreviewText, error) {
	oldPath, newPath := reviewDiffPaths(file)
	preview := reviewDiffPreviewText{}
	if reviewNeedsOldText(changeType) {
		if err := ctx.Err(); err != nil {
			return reviewDiffPreviewText{}, err
		}
		text, err := reviewTextAtRef(r, oldRef, oldPath)
		if err != nil {
			return reviewDiffPreviewText{}, err
		}
		preview.oldText = text
	}
	if reviewNeedsNewText(changeType) {
		if err := ctx.Err(); err != nil {
			return reviewDiffPreviewText{}, err
		}
		text, err := reviewTextAtRef(r, newRef, newPath)
		if err != nil {
			// A newly-added file may be addressed by a symbolic headRef such as
			// "HEAD" while loadReviewRepoState has already resolved the review
			// pair to repo.headCommit. If the branch moves concurrently or the
			// caller supplied a non-current head ref, the preview blob lookup can
			// miss even though the diff text above was produced successfully from
			// the resolved trees. Keep the diff renderable and simply omit the
			// full-file preview text for this best-effort context expansion path.
			text = ""
		}
		preview.newText = text
	}
	if err := ctx.Err(); err != nil {
		return reviewDiffPreviewText{}, err
	}
	return preview, nil
}

func reviewIndexPreviewText(r *git.Repository, oldRef string, file types.ReviewFile, changeType string) (reviewDiffPreviewText, error) {
	return reviewIndexPreviewTextContext(context.Background(), r, oldRef, file, changeType)
}

func reviewIndexPreviewTextContext(ctx context.Context, r *git.Repository, oldRef string, file types.ReviewFile, changeType string) (reviewDiffPreviewText, error) {
	oldPath, newPath := reviewDiffPaths(file)
	preview := reviewDiffPreviewText{}
	if reviewNeedsOldText(changeType) {
		if err := ctx.Err(); err != nil {
			return reviewDiffPreviewText{}, err
		}
		text, err := reviewTextAtRef(r, oldRef, oldPath)
		if err != nil {
			return reviewDiffPreviewText{}, err
		}
		preview.oldText = text
	}
	if reviewNeedsNewText(changeType) {
		if err := ctx.Err(); err != nil {
			return reviewDiffPreviewText{}, err
		}
		text, err := reviewIndexFileContent(r, newPath)
		if err != nil {
			// File might not be in index for additions
			text = ""
		}
		preview.newText = text
	}
	if err := ctx.Err(); err != nil {
		return reviewDiffPreviewText{}, err
	}
	return preview, nil
}

func reviewWorkingTreePreviewText(r *git.Repository, root string, file types.ReviewFile, changeType string) (reviewDiffPreviewText, error) {
	return reviewWorkingTreePreviewTextContext(context.Background(), r, root, file, changeType)
}

func reviewWorkingTreePreviewTextContext(ctx context.Context, r *git.Repository, root string, file types.ReviewFile, changeType string) (reviewDiffPreviewText, error) {
	oldPath, newPath := reviewDiffPaths(file)
	preview := reviewDiffPreviewText{}
	if reviewNeedsOldText(changeType) {
		if err := ctx.Err(); err != nil {
			return reviewDiffPreviewText{}, err
		}
		text, err := reviewIndexFileContent(r, oldPath)
		if err != nil {
			text = ""
		}
		preview.oldText = text
	}
	if reviewNeedsNewText(changeType) {
		if err := ctx.Err(); err != nil {
			return reviewDiffPreviewText{}, err
		}
		text, err := reviewWorkingTreeFileContent(root, newPath)
		if err != nil {
			return reviewDiffPreviewText{}, err
		}
		preview.newText = text
	}
	if err := ctx.Err(); err != nil {
		return reviewDiffPreviewText{}, err
	}
	return preview, nil
}

func reviewDiffPaths(file types.ReviewFile) (string, string) {
	// Raw Git paths: do not TrimSpace. Paths with leading/trailing whitespace
	// or newlines are legitimate Git entries and the diff must read the
	// actual file at that exact path, not a trimmed alias.
	oldPath := file.OldPath
	if oldPath == "" {
		oldPath = file.Path
	}
	newPath := file.Path
	return oldPath, newPath
}

func reviewNeedsOldText(changeType string) bool {
	return strings.ToUpper(strings.TrimSpace(changeType)) != "A"
}

func reviewNeedsNewText(changeType string) bool {
	return strings.ToUpper(strings.TrimSpace(changeType)) != "D"
}
