using System.Diagnostics;
using System.Text;

namespace ClaudeDo.Data.Git;

// TreeOid is only populated when Clean — the tree `merge-tree --write-tree` would produce,
// usable to materialize the merge result into a scratch worktree without touching the real
// working tree, index, or refs (see GitService.CommitTreeAsync / WorktreeAddDetachedAsync).
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles, string? TreeOid = null);

public sealed class GitService
{
    // git mutates shared .git/worktrees/ metadata during `worktree add`; concurrent adds
    // race and fail with "failed to read .git/worktrees/<other>/commondir". Serialize them
    // process-wide so parallel task starts don't collide.
    private static readonly SemaphoreSlim WorktreeAddGate = new(1, 1);

    public async Task<bool> IsGitRepoAsync(string dir, CancellationToken ct = default)
    {
        var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--git-dir"], ct);
        return exitCode == 0;
    }

    public async Task<string> RevParseHeadAsync(string dir, CancellationToken ct = default)
    {
        var (exitCode, stdout, stderr) = await RunGitAsync(dir, ["rev-parse", "HEAD"], ct);
        if (exitCode != 0)
        {
            // Unborn HEAD (fresh `git init`, no commits) — the raw git stderr ("ambiguous
            // argument 'HEAD'") is useless to a user; call sites should have run
            // EnsureHeadCommitAsync, this message is the safety net for any that didn't.
            if (stderr.Contains("ambiguous argument 'HEAD'", StringComparison.Ordinal) ||
                stderr.Contains("unknown revision", StringComparison.Ordinal))
                throw new InvalidOperationException(
                    $"The repository at {dir} has no commits yet (unborn or broken HEAD) — ClaudeDo needs at least one commit to work in it.");
            throw new InvalidOperationException($"git rev-parse HEAD failed (exit {exitCode}): {stderr}");
        }
        return stdout.Trim();
    }

    /// <summary>
    /// True if <paramref name="ancestorSha"/> is an ancestor of (or equal to) <paramref name="descendantSha"/>,
    /// via `git merge-base --is-ancestor`. Null means the answer can't be determined (e.g. the commit is
    /// unknown in this repo) — callers must treat that as "unknown", never as "not an ancestor".
    /// </summary>
    public async Task<bool?> IsAncestorAsync(string repoDir, string ancestorSha, string descendantSha, CancellationToken ct = default)
    {
        var (exitCode, _, _) = await RunGitAsync(repoDir, ["merge-base", "--is-ancestor", ancestorSha, descendantSha], ct);
        return exitCode switch
        {
            0 => true,
            1 => false,
            _ => null,
        };
    }

    /// <summary>
    /// The merge base of two refs, or null when git can't find one (e.g. an unresolvable ref) —
    /// callers treat that as "can't evaluate", not "no common history".
    /// </summary>
    public async Task<string?> MergeBaseAsync(string repoDir, string refA, string refB, CancellationToken ct = default)
    {
        var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["merge-base", refA, refB], ct);
        return exitCode == 0 ? stdout.Trim() : null;
    }

    /// <summary>HEAD resolves to a commit (false for a fresh `git init` repo or a broken HEAD).</summary>
    public async Task<bool> HasHeadCommitAsync(string dir, CancellationToken ct = default)
    {
        // ^{commit} forces the object to actually exist and be a commit — a bare
        // `--verify HEAD` happily accepts a detached HEAD pointing at a missing object.
        var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], ct);
        return exitCode == 0;
    }

    private const string FallbackUserName = "ClaudeDo";
    private const string FallbackUserEmail = "claudedo@local";

    // Two parallel task starts on the same empty repo must create exactly one initial commit.
    private static readonly SemaphoreSlim BootstrapGate = new(1, 1);

    /// <summary>
    /// Guarantees HEAD resolves to a commit so worktrees can be created. A repo with an
    /// unborn HEAD (fresh `git init`) gets an empty initial commit built from plumbing only
    /// (hash-object/commit-tree/update-ref) — the user's index and working tree are never
    /// touched, so staged and untracked files stay exactly as they were. Identity is passed
    /// inline, so this also works on machines with no git identity configured. A repo whose
    /// HEAD is broken (not a symbolic ref and not resolvable) throws instead of guessing.
    /// </summary>
    /// <returns>true if a bootstrap commit was created; false if HEAD already resolved.</returns>
    public async Task<bool> EnsureHeadCommitAsync(string dir, CancellationToken ct = default)
    {
        if (await HasHeadCommitAsync(dir, ct)) return false;

        var (symExit, symRef, _) = await RunGitAsync(dir, ["symbolic-ref", "--quiet", "HEAD"], ct);
        if (symExit != 0)
            throw new InvalidOperationException(
                $"The repository at {dir} has a broken HEAD (detached onto a missing commit, or a corrupt .git/HEAD). Repair the repository manually.");

        await BootstrapGate.WaitAsync(ct);
        try
        {
            if (await HasHeadCommitAsync(dir, ct)) return false;

            var (treeExit, treeSha, treeErr) = await RunGitAsync(dir, ["mktree"], ct, stdinData: "");
            if (treeExit != 0)
                throw new InvalidOperationException($"git mktree failed (exit {treeExit}): {treeErr}");

            var (commitExit, commitSha, commitErr) = await RunGitAsync(dir,
                ["-c", $"user.name={FallbackUserName}", "-c", $"user.email={FallbackUserEmail}",
                 "commit-tree", treeSha.Trim(), "-m", "chore: initialize repository (ClaudeDo)"], ct);
            if (commitExit != 0)
                throw new InvalidOperationException($"git commit-tree failed (exit {commitExit}): {commitErr}");

            var (refExit, _, refErr) = await RunGitAsync(dir,
                ["update-ref", symRef.Trim(), commitSha.Trim()], ct);
            if (refExit != 0)
                throw new InvalidOperationException($"git update-ref failed (exit {refExit}): {refErr}");

            return true;
        }
        finally
        {
            BootstrapGate.Release();
        }
    }

    public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
    {
        await WorktreeAddGate.WaitAsync(ct);
        try
        {
            const int maxAttempts = 3;
            for (var attempt = 1; ; attempt++)
            {
                var (exitCode, _, stderr) = await RunGitAsync(repoDir,
                    ["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct);
                if (exitCode == 0)
                    return;

                // Transient races leave a half-written worktree metadata dir; retry briefly.
                var transient = stderr.Contains("commondir", StringComparison.OrdinalIgnoreCase)
                    || stderr.Contains("failed to read", StringComparison.OrdinalIgnoreCase);
                if (!transient || attempt >= maxAttempts)
                    throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");

                await Task.Delay(150 * attempt, ct);
            }
        }
        finally
        {
            WorktreeAddGate.Release();
        }
    }

    // --untracked-files=all: without it, a brand-new untracked directory collapses into a single
    // "?? dir/" entry instead of listing the files inside it — callers matching against specific
    // paths (e.g. the untracked-collision guard) need the individual files.
    public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
    {
        var (exitCode, stdout, stderr) = await RunGitAsync(workingDirectory, ["status", "--porcelain", "--untracked-files=all"], ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
        return stdout;
    }

    public async Task<string> GetCommittedFilesAsync(string worktreePath, string baseCommit, CancellationToken ct = default)
    {
        var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
            ["diff", "--name-status", $"{baseCommit}..HEAD"], ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git diff --name-status failed (exit {exitCode}): {stderr}");
        return stdout;
    }

    public Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default) =>
        HasChangesAsync(worktreePath, includeUntracked: true, ct);

    /// <summary>
    /// Uncommitted-changes check. <paramref name="includeUntracked"/>=false ignores untracked
    /// files — use this for merge preflights on a shared target working dir, where stray
    /// untracked files (e.g. from a concurrent session) shouldn't block a merge. Auto-commit
    /// and data-loss-guard callers keep the default (true): a new file a task created, or an
    /// untracked file about to be discarded, is a real uncommitted change.
    /// </summary>
    public async Task<bool> HasChangesAsync(string worktreePath, bool includeUntracked, CancellationToken ct = default)
    {
        string[] args = includeUntracked
            ? ["status", "--porcelain"]
            : ["status", "--porcelain", "--untracked-files=no"];
        var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, args, ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
        return !string.IsNullOrWhiteSpace(stdout);
    }

    public async Task AddAllAsync(string worktreePath, CancellationToken ct = default)
    {
        var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["add", "-A"], ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git add -A failed (exit {exitCode}): {stderr}");
    }

    public async Task CommitAsync(string worktreePath, string message, CancellationToken ct = default)
    {
        // Use -F - (read message from stdin) to handle multi-line messages safely.
        var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["commit", "-F", "-"], ct, stdinData: message);
        if (exitCode != 0 && IsMissingIdentityError(stderr))
        {
            // A machine without git identity configured must not fail every auto-commit —
            // retry once as ClaudeDo. The happy path pays no preflight cost.
            (exitCode, _, stderr) = await RunGitAsync(worktreePath,
                ["-c", $"user.name={FallbackUserName}", "-c", $"user.email={FallbackUserEmail}",
                 "commit", "-F", "-"], ct, stdinData: message);
        }
        if (exitCode != 0)
            throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}");
    }

    private static bool IsMissingIdentityError(string stderr) =>
        stderr.Contains("tell me who you are", StringComparison.OrdinalIgnoreCase) ||
        stderr.Contains("empty ident", StringComparison.OrdinalIgnoreCase) ||
        stderr.Contains("no email was given", StringComparison.OrdinalIgnoreCase) ||
        stderr.Contains("unable to auto-detect email address", StringComparison.OrdinalIgnoreCase);

    public async Task<string> GetDiffAsync(
        string worktreePath, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
    {
        var args = new List<string> { "diff", "HEAD" };
        AppendPathFilter(args, paths);
        var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, args, ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git diff HEAD failed (exit {exitCode}): {stderr}");
        // If nothing staged vs HEAD, try the index (untracked is never in diff)
        if (string.IsNullOrWhiteSpace(stdout))
        {
            var cachedArgs = new List<string> { "diff", "--cached" };
            AppendPathFilter(cachedArgs, paths);
            var (e2, s2, _) = await RunGitAsync(worktreePath, cachedArgs, ct);
            if (e2 == 0) return s2;
        }
        return stdout;
    }

    /// <summary>
    /// Full diff between <paramref name="baseRef"/> and the current working tree
    /// (committed-on-branch changes + uncommitted work). Used for viewing a Claude
    /// task's total impact relative to where the branch started.
    /// </summary>
    public async Task<string> GetBranchDiffAsync(
        string worktreePath, string baseRef, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
    {
        var args = new List<string> { "diff", baseRef };
        AppendPathFilter(args, paths);
        var (exitCode, stdout, _) = await RunGitAsync(worktreePath, args, ct);
        if (exitCode == 0 && !string.IsNullOrWhiteSpace(stdout))
            return stdout;
        // Fallback: whatever the worktree has vs HEAD (uncommitted only). The same pathspec has to
        // come along — a filter makes an empty ranged diff the common case (the caller asked for
        // paths this range never touched), and an unfiltered fallback would answer that with the
        // whole worktree diff, the exact opposite of what was requested.
        return await GetDiffAsync(worktreePath, paths, ct);
    }

    /// <summary>
    /// Diff between two commits, run in any repo that can reach them. Used to view a
    /// task's changes after its worktree has been merged away (the commits survive on
    /// the target branch even though the worktree directory and branch ref are gone).
    /// </summary>
    public async Task<string> GetCommitRangeDiffAsync(
        string repoDir, string baseCommit, string headCommit, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
    {
        var args = new List<string> { "diff", $"{baseCommit}..{headCommit}" };
        AppendPathFilter(args, paths);
        var (exitCode, stdout, stderr) = await RunGitAsync(repoDir, args, ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git diff {baseCommit}..{headCommit} failed (exit {exitCode}): {stderr}");
        return stdout;
    }

    public async Task<string> DiffStatAsync(
        string worktreePath, string baseCommit, string headCommit, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
    {
        var args = new List<string> { "diff", "--stat", $"{baseCommit}..{headCommit}" };
        AppendPathFilter(args, paths);
        var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, args, ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git diff --stat failed (exit {exitCode}): {stderr}");
        return stdout.Trim();
    }

    // Appends a `-- <paths>` pathspec filter so git itself narrows the diff instead of the
    // caller filtering the result after the fact (works identically for --stat and full diffs).
    // No-op when paths is null/empty so existing callers see no behavior change.
    private static void AppendPathFilter(List<string> args, IReadOnlyList<string>? paths)
    {
        if (paths is not { Count: > 0 }) return;
        args.Add("--");
        args.AddRange(paths);
    }

    public async Task WorktreeRemoveAsync(string repoDir, string worktreePath, bool force = false, CancellationToken ct = default)
    {
        var args = new List<string> { "worktree", "remove" };
        if (force) args.Add("--force");
        args.Add(worktreePath);

        var (exitCode, _, stderr) = await RunGitAsync(repoDir, args, ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git worktree remove failed (exit {exitCode}): {stderr}");
    }

    public async Task<List<string>> ListWorktreePathsForBranchAsync(string repoDir, string branchName, CancellationToken ct = default)
    {
        var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["worktree", "list", "--porcelain"], ct);
        if (exitCode != 0) return new();

        var target = $"refs/heads/{branchName}";
        var paths = new List<string>();
        string? currentPath = null;
        foreach (var raw in stdout.Split('\n'))
        {
            var line = raw.TrimEnd('\r');
            if (line.StartsWith("worktree ", StringComparison.Ordinal))
            {
                currentPath = line["worktree ".Length..].Trim();
            }
            else if (line.StartsWith("branch ", StringComparison.Ordinal))
            {
                var b = line["branch ".Length..].Trim();
                if (b == target && currentPath is not null) paths.Add(currentPath);
            }
            else if (string.IsNullOrWhiteSpace(line))
            {
                currentPath = null;
            }
        }
        return paths;
    }

    public async Task WorktreePruneAsync(string repoDir, CancellationToken ct = default)
    {
        var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["worktree", "prune"], ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git worktree prune failed (exit {exitCode}): {stderr}");
    }

    public async Task BranchDeleteAsync(string repoDir, string branchName, bool force = false, CancellationToken ct = default)
    {
        var flag = force ? "-D" : "-d";
        var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["branch", flag, branchName], ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git branch {flag} failed (exit {exitCode}): {stderr}");
    }

    public async Task<string> GetCurrentBranchAsync(string repoDir, CancellationToken ct = default)
    {
        var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
            ["symbolic-ref", "--short", "HEAD"], ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git symbolic-ref --short HEAD failed (exit {exitCode}): {stderr}");
        return stdout.Trim();
    }

    public async Task CheckoutBranchAsync(string repoDir, string branchName, CancellationToken ct = default)
    {
        var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["checkout", branchName], ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git checkout '{branchName}' failed (exit {exitCode}): {stderr}");
    }

    public async Task<List<string>> ListLocalBranchesAsync(string repoDir, CancellationToken ct = default)
    {
        var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
            ["branch", "--format=%(refname:short)"], ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git branch --format failed (exit {exitCode}): {stderr}");

        return stdout
            .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
            .Where(s => s.Length > 0)
            .ToList();
    }

    public async Task<bool> IsMidMergeAsync(string repoDir, CancellationToken ct = default)
    {
        var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct);
        if (exitCode != 0) return false;
        var gitDir = stdout.Trim();
        if (!Path.IsPathRooted(gitDir))
            gitDir = Path.Combine(repoDir, gitDir);
        return File.Exists(Path.Combine(gitDir, "MERGE_HEAD"));
    }

    public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync(
        string repoDir, string sourceBranch, string message, CancellationToken ct = default)
    {
        // diff3 conflict style writes the merge base (|||||||) into conflict markers so the
        // in-app resolver can show a true three-way view. It only enriches conflicted hunks;
        // clean merges are unaffected.
        var (exitCode, _, stderr) = await RunGitAsync(repoDir,
            ["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "-m", message, sourceBranch], ct);
        return (exitCode, stderr);
    }

    public async Task MergeAbortAsync(string repoDir, CancellationToken ct = default)
    {
        var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--abort"], ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git merge --abort failed (exit {exitCode}): {stderr}");
    }

    public async Task<bool> IsMidRevertAsync(string repoDir, CancellationToken ct = default)
    {
        var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct);
        if (exitCode != 0) return false;
        var gitDir = stdout.Trim();
        if (!Path.IsPathRooted(gitDir))
            gitDir = Path.Combine(repoDir, gitDir);
        return File.Exists(Path.Combine(gitDir, "REVERT_HEAD"));
    }

    /// <summary>
    /// Reverts a single commit with `-m 1` (diff against its first parent) — the form needed to
    /// revert a merge commit. On success this creates a new commit with the inverse changes;
    /// the original commit and all history stay intact (no rewrite, no reset).
    /// </summary>
    public async Task<(int ExitCode, string Stderr)> RevertMergeCommitAsync(
        string repoDir, string mergeCommitSha, CancellationToken ct = default)
    {
        var (exitCode, _, stderr) = await RunGitAsync(repoDir,
            ["-c", "merge.conflictStyle=diff3", "revert", "--no-edit", "-m", "1", mergeCommitSha], ct);
        return (exitCode, stderr);
    }

    public async Task RevertAbortAsync(string repoDir, CancellationToken ct = default)
    {
        var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["revert", "--abort"], ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git revert --abort failed (exit {exitCode}): {stderr}");
    }

    /// <summary>
    /// Re-applies a merge commit's own diff (against its first parent, `-m 1`) as a new commit on
    /// top of HEAD. Needed to redo a merge that was previously reverted: once a branch tip is an
    /// ancestor of HEAD, `git merge` on that branch again is a silent "Already up to date." no-op
    /// (ancestry, not content, is what merge checks) -- cherry-picking the original merge commit's
    /// diff is what actually restores the content.
    /// </summary>
    public async Task<(int ExitCode, string Stderr)> CherryPickMergeCommitAsync(
        string repoDir, string mergeCommitSha, CancellationToken ct = default)
    {
        var (exitCode, _, stderr) = await RunGitAsync(repoDir,
            ["-c", "merge.conflictStyle=diff3", "cherry-pick", "-m", "1", "--no-edit", mergeCommitSha], ct);
        return (exitCode, stderr);
    }

    public async Task<bool> IsMidCherryPickAsync(string repoDir, CancellationToken ct = default)
    {
        var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct);
        if (exitCode != 0) return false;
        var gitDir = stdout.Trim();
        if (!Path.IsPathRooted(gitDir))
            gitDir = Path.Combine(repoDir, gitDir);
        return File.Exists(Path.Combine(gitDir, "CHERRY_PICK_HEAD"));
    }

    public async Task CherryPickAbortAsync(string repoDir, CancellationToken ct = default)
    {
        var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["cherry-pick", "--abort"], ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git cherry-pick --abort failed (exit {exitCode}): {stderr}");
    }

    public async Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default)
    {
        var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
            ["diff", "--name-only", "--diff-filter=U"], ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git diff --diff-filter=U failed (exit {exitCode}): {stderr}");

        return stdout
            .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
            .Where(s => s.Length > 0)
            .ToList();
    }

    public async Task AddPathAsync(string repoDir, string path, CancellationToken ct = default)
    {
        var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["add", "--", path], ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git add '{path}' failed (exit {exitCode}): {stderr}");
    }

    /// <summary>
    /// Stages <paramref name="path"/> as deleted (`git rm -f`), removing it from both the index
    /// and, if present, the working tree. Used to resolve a merge conflict to "keep the
    /// deletion" — unlike <see cref="AddPathAsync"/>, which would stage an empty file as a
    /// tracked, zero-byte file rather than actually removing it.
    /// </summary>
    public async Task RemovePathAsync(string repoDir, string path, CancellationToken ct = default)
    {
        var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["rm", "-f", "--", path], ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git rm '{path}' failed (exit {exitCode}): {stderr}");
    }

    /// <summary>
    /// Content of <paramref name="path"/> at a merge stage (1=base, 2=ours, 3=theirs) via
    /// `git show :&lt;stage&gt;:&lt;path&gt;`. Null when that stage doesn't exist for the path —
    /// the conventional way to tell a modify/delete or rename/delete conflict apart from an
    /// ordinary content conflict (git never writes `&lt;&lt;&lt;&lt;&lt;&lt;&lt;` markers for those,
    /// it just leaves whichever side wasn't deleted in the working tree).
    /// </summary>
    public async Task<string?> ShowConflictStageAsync(string repoDir, int stage, string path, CancellationToken ct = default)
    {
        var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $":{stage}:{path}"], ct, trimOutput: false);
        return exitCode == 0 ? stdout : null;
    }

    /// <summary>
    /// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
    /// loose objects — the working tree, index, and refs are left untouched.
    /// </summary>
    public async Task<MergePreview> PreviewMergeAsync(
        string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
    {
        var (exitCode, stdout, _) = await RunGitAsync(repoDir,
            ["merge-tree", "--write-tree", "--name-only", targetBranch, sourceBranch], ct);

        if (exitCode == 0)
            // stdout is just the written tree's oid on a clean merge.
            return new MergePreview(true, true, Array.Empty<string>(), stdout.Trim());

        if (exitCode == 1)
        {
            // stdout: <tree-oid>\n<file>\n...\n\n<informational messages>
            var lines = stdout.Split('\n');
            var files = new List<string>();
            for (int i = 1; i < lines.Length; i++)
            {
                var line = lines[i].TrimEnd('\r');
                if (string.IsNullOrWhiteSpace(line)) break;
                files.Add(line.Trim());
            }
            return new MergePreview(true, false, files);
        }

        // Any other exit (e.g. git too old: "unknown option --write-tree").
        return new MergePreview(false, false, Array.Empty<string>());
    }

    /// <summary>Resolves <paramref name="revision"/> (a branch, tag, or SHA) to a full commit SHA.</summary>
    public async Task<string> RevParseAsync(string repoDir, string revision, CancellationToken ct = default)
    {
        var (exitCode, stdout, stderr) = await RunGitAsync(repoDir, ["rev-parse", revision], ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git rev-parse '{revision}' failed (exit {exitCode}): {stderr}");
        return stdout.Trim();
    }

    /// <summary>
    /// Creates a new commit object wrapping <paramref name="treeOid"/> with <paramref name="parentSha"/>
    /// as its single parent, writing only a loose object — no ref is created or moved.
    /// </summary>
    public async Task<string> CommitTreeAsync(
        string repoDir, string treeOid, string parentSha, string message, CancellationToken ct = default)
    {
        var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
            ["commit-tree", treeOid, "-p", parentSha, "-m", message], ct);
        if (exitCode != 0)
            throw new InvalidOperationException($"git commit-tree failed (exit {exitCode}): {stderr}");
        return stdout.Trim();
    }

    /// <summary>
    /// Materializes <paramref name="commitish"/> into a new, branchless worktree at
    /// <paramref name="worktreePath"/> (detached HEAD) — used to build/verify a merge-tree
    /// result without ever creating a branch or touching the real working tree.
    /// </summary>
    public async Task WorktreeAddDetachedAsync(
        string repoDir, string worktreePath, string commitish, CancellationToken ct = default)
    {
        await WorktreeAddGate.WaitAsync(ct);
        try
        {
            const int maxAttempts = 3;
            for (var attempt = 1; ; attempt++)
            {
                var (exitCode, _, stderr) = await RunGitAsync(repoDir,
                    ["worktree", "add", "--detach", worktreePath, commitish], ct);
                if (exitCode == 0)
                    return;

                var transient = stderr.Contains("commondir", StringComparison.OrdinalIgnoreCase)
                    || stderr.Contains("failed to read", StringComparison.OrdinalIgnoreCase);
                if (!transient || attempt >= maxAttempts)
                    throw new InvalidOperationException($"git worktree add --detach failed (exit {exitCode}): {stderr}");

                await Task.Delay(150 * attempt, ct);
            }
        }
        finally
        {
            WorktreeAddGate.Release();
        }
    }

    /// <summary>Count of files that differ on <paramref name="sourceBranch"/> since its merge base with the target.</summary>
    public async Task<int> CountChangedFilesAsync(
        string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
    {
        var (exitCode, stdout, _) = await RunGitAsync(repoDir,
            ["diff", "--name-only", $"{targetBranch}...{sourceBranch}"], ct);
        if (exitCode != 0) return 0;
        return stdout
            .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
            .Count(s => s.Length > 0);
    }

    /// <summary>Files that differ between two exact refs (2-dot, no merge-base resolution) -- used to see
    /// what a target branch itself picked up since a task's fork point, as opposed to
    /// <see cref="CountChangedFilesAsync"/>'s 3-dot count of a branch's own changes.</summary>
    public async Task<IReadOnlyList<string>> GetChangedFileNamesAsync(
        string repoDir, string fromRef, string toRef, CancellationToken ct = default)
    {
        var (exitCode, stdout, _) = await RunGitAsync(repoDir,
            ["diff", "--name-only", $"{fromRef}..{toRef}"], ct);
        if (exitCode != 0) return Array.Empty<string>();
        return stdout
            .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
            .Where(s => s.Length > 0)
            .ToList();
    }

    /// <summary>
    /// Rebases the branch checked out at <paramref name="worktreePath"/> onto <paramref name="ontoRef"/>.
    /// On conflict or any other failure the rebase is aborted before returning, so the worktree is left
    /// exactly as it was rather than stranded mid-rebase; <c>ConflictFiles</c> is best-effort and only
    /// populated when the failure was an actual conflict.
    /// </summary>
    public async Task<(int ExitCode, string Stderr, IReadOnlyList<string> ConflictFiles)> RebaseAsync(
        string worktreePath, string ontoRef, CancellationToken ct = default)
    {
        var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["rebase", ontoRef], ct);
        if (exitCode == 0)
            return (0, stderr, Array.Empty<string>());

        List<string> conflictFiles;
        try { conflictFiles = await ListConflictedFilesAsync(worktreePath, ct); }
        catch { conflictFiles = new(); }

        await RunGitAsync(worktreePath, ["rebase", "--abort"], ct);
        return (exitCode, stderr, conflictFiles);
    }


    private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
        string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
    {
        var psi = new ProcessStartInfo
        {
            FileName = "git",
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            RedirectStandardInput = stdinData is not null,
            UseShellExecute = false,
            CreateNoWindow = true,
            StandardOutputEncoding = Encoding.UTF8,
            StandardErrorEncoding = Encoding.UTF8,
            // BOM-less: Encoding.UTF8 writes a BOM preamble, which git rejects as input
            // (mktree "input format error") and would prepend to stdin commit messages.
            StandardInputEncoding = stdinData is not null ? new UTF8Encoding(false) : null,
        };
        psi.ArgumentList.Add("-C");
        psi.ArgumentList.Add(workDir);
        foreach (var a in args) psi.ArgumentList.Add(a);

        using var proc = new Process { StartInfo = psi };
        proc.Start();

        // On cancellation: kill the git process tree. Killing closes the
        // redirected pipes, which unblocks the ReadToEndAsync calls below
        // and lets WaitForExitAsync return so the process is reaped.
        // Without this, cancelling mid-git leaves zombie processes.
        await using var ctr = ct.Register(() =>
        {
            try { proc.Kill(entireProcessTree: true); }
            catch { /* already exited */ }
        });

        if (stdinData is not null)
        {
            await proc.StandardInput.WriteAsync(stdinData.AsMemory(), ct);
            proc.StandardInput.Close();
        }

        // Drain output without ct — pipes close when the process exits
        // (whether naturally or via Kill above), so these always complete.
        var stdoutTask = proc.StandardOutput.ReadToEndAsync();
        var stderrTask = proc.StandardError.ReadToEndAsync();

        await proc.WaitForExitAsync(CancellationToken.None);

        var stdout = await stdoutTask;
        var stderr = await stderrTask;

        ct.ThrowIfCancellationRequested();

        return (proc.ExitCode, trimOutput ? stdout.TrimEnd() : stdout, stderr.TrimEnd());
    }
}
