using System.Collections.Concurrent;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.Lifecycle;

public sealed record MergeResult(
    string Status,
    IReadOnlyList<string> ConflictFiles,
    string? ErrorMessage);

public sealed record MergeTargets(
    string DefaultBranch,
    IReadOnlyList<string> LocalBranches,
    string DefaultCommitMessage);

// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are only populated when the caller asked for
// a verify run (runVerify=true) AND the list has a verify command configured AND the preview came
// back clean — a conflicting or unavailable preview never reaches the verify step. -1 is used for
// VerifyExitCode when the command timed out or failed to start (mirrors the post-merge gate).
public sealed record MergePreviewResult(
    string Status,
    IReadOnlyList<string> ConflictFiles,
    int ChangedFileCount,
    int? VerifyExitCode = null,
    long? VerifyDurationMs = null,
    string? VerifyOutputTail = null);

public sealed record ConflictDocuments(
    string TaskId,
    IReadOnlyList<ConflictDocumentContent> Files);

public sealed record ConflictDocumentContent(
    string Path,
    bool IsBinary,
    IReadOnlyList<MergeSegment> Segments);

public sealed record RevertResult(
    string Status,
    string? RevertCommit,
    IReadOnlyList<string> ConflictFiles,
    string? ErrorMessage);

// Per-task outcome of a batch verify (RunListVerifyAsync): Promoted=true means the task reached
// Done; otherwise Reason says why it was left alone (invalid candidate, or the verify failed).
public sealed record ListVerifyTaskOutcome(string TaskId, int Number, bool Promoted, string? Reason);

public sealed record ListVerifyResult(
    string Status,
    IReadOnlyList<ListVerifyTaskOutcome> Tasks,
    string? ErrorMessage);

public sealed class TaskMergeService
{
    public const string StatusMerged      = "merged";
    public const string StatusConflict    = "conflict";
    public const string StatusBlocked     = "blocked";
    public const string StatusAborted     = "aborted";
    public const string StatusVerifyFailed = "verify_failed";
    // Batch mode (skipVerify): the merge landed but the verify gate was deliberately deferred --
    // the task stays out of Done until RunListVerifyAsync passes and promotes it.
    public const string StatusMergedVerifyPending = "merged_verify_pending";
    public const string StatusVerifyPassed = "verify_passed";
    public const string StatusUntrackedCollision = "untracked_collision";

    public const string StatusReverted       = "reverted";
    public const string StatusConflictAborted = "conflict_aborted";

    // Phase tokens for the OperationProgress broadcast — stable identifiers, localized by the UI.
    public const string PhaseMerging   = "merging";
    public const string PhaseVerifying = "verifying";
    public const string PhaseRebasing  = "rebasing";

    public const string PreviewClean       = "clean";
    public const string PreviewConflict    = "conflict";
    public const string PreviewUnavailable = "unavailable";
    public const string PreviewUntrackedCollision = "untracked_collision";

    // The verify command is a trusted, list-owner-configured build/test invocation (not
    // per-request user input), so a generous fixed timeout is enough — no need for a
    // per-list configurable value on top of what the spec calls for.
    private static readonly TimeSpan VerifyTimeout = TimeSpan.FromMinutes(10);

    // Mirrors TaskWaitMcpTools.ProgressReportInterval (External/TaskWaitMcpTools.cs): a verify
    // run can take up to VerifyTimeout, well past Claude Code's ~300s MCP idle-silence abort, so
    // ProgressReporter.RunAsync reports on this cadence to keep the calling review_task/
    // merge_task/preview_merge* call alive. Not readonly -- tests shrink it to observe a report
    // without waiting 30s. A separate field from TaskWaitMcpTools' own (rather than sharing it)
    // so shrinking one for a test can't race the other's tests.
    internal static TimeSpan ProgressReportInterval = TimeSpan.FromSeconds(30);

    // Serializes merge (+ verify) against the same repo working dir: a verify command running
    // in list.WorkingDir must not see a second merge land mid-build. Keyed by working dir since
    // TaskMergeService is a process-wide singleton and merges across different lists are independent.
    private static readonly ConcurrentDictionary<string, SemaphoreSlim> MergeGates =
        new(StringComparer.OrdinalIgnoreCase);

    private static SemaphoreSlim GetMergeGate(string workingDir) =>
        MergeGates.GetOrAdd(workingDir, static _ => new SemaphoreSlim(1, 1));

    private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
    private readonly GitService _git;
    private readonly HubBroadcaster _broadcaster;
    private readonly ITaskStateService _state;
    private readonly IVerifyCommandRunner _verify;
    private readonly ILogger<TaskMergeService> _logger;

    public TaskMergeService(
        IDbContextFactory<ClaudeDoDbContext> dbFactory,
        GitService git,
        HubBroadcaster broadcaster,
        ITaskStateService state,
        IVerifyCommandRunner verify,
        ILogger<TaskMergeService> logger)
    {
        _dbFactory = dbFactory;
        _git = git;
        _broadcaster = broadcaster;
        _state = state;
        _verify = verify;
        _logger = logger;
    }

    private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity? Worktree, string? VerifyCommand)> LoadMergeContextAsync(
        string taskId, CancellationToken ct)
    {
        using var ctx = _dbFactory.CreateDbContext();
        var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
            ?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
        var listRepo = new ListRepository(ctx);
        var list = await listRepo.GetByIdAsync(task.ListId, ct)
            ?? throw new InvalidOperationException("List not found.");
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
        var config = await listRepo.GetConfigAsync(task.ListId, ct);
        return (task, list, wt, config?.VerifyCommand);
    }

    /// <summary>
    /// Runs the list's configured verify command (if any) in <paramref name="workingDir"/> after
    /// a successful merge. Returns null when there is nothing to gate on (identical to today's
    /// behavior); otherwise returns the terminal <see cref="MergeResult"/> to report instead of
    /// merged (the merge itself is left in place either way — see the design notes in Worker's
    /// CLAUDE.md — only the Done transition is withheld).
    /// </summary>
    private async Task<MergeResult?> RunVerifyGateAsync(
        string? verifyCommand, string workingDir, CancellationToken ct,
        IProgress<ProgressNotificationValue>? progress = null,
        Action<TimeSpan>? onTick = null)
    {
        if (string.IsNullOrWhiteSpace(verifyCommand)) return null;

        VerifyCommandResult result;
        try
        {
            result = await ProgressReporter.RunAsync(
                _verify.RunAsync(workingDir, verifyCommand, VerifyTimeout, ct), ProgressReportInterval, progress, "verify gate running", onTick);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "verify command failed to start: {Command}", verifyCommand);
            return new MergeResult(StatusVerifyFailed, Array.Empty<string>(),
                $"verify command failed to start: {ex.Message}");
        }

        if (!result.TimedOut && result.ExitCode == 0) return null;

        var reason = result.TimedOut
            ? $"verify command timed out after {VerifyTimeout.TotalMinutes:0} min: {verifyCommand}"
            : $"verify command failed (exit {result.ExitCode}): {verifyCommand}";
        return new MergeResult(StatusVerifyFailed, Array.Empty<string>(), $"{reason}\n{TailOutput(result.Output)}");
    }

    private static string TailOutput(string output, int maxChars = 4000)
    {
        var trimmed = output.Trim();
        return trimmed.Length <= maxChars ? trimmed : trimmed[^maxChars..];
    }

    /// <summary>
    /// Guards against a merge silently overwriting a file that sits untracked in the target
    /// working directory: git already refuses that itself while the path is still untracked at
    /// merge time, but a path that only became trackable in between (e.g. an unrelated conflict
    /// resolution's `git add -A` sweeping it up) loses that protection. Compares the paths
    /// <paramref name="branchName"/> newly added since its merge base with <paramref name="targetRef"/>
    /// against what's currently untracked in <paramref name="workingDir"/> — reusing
    /// <see cref="GitService.GetCommittedFilesAsync"/> (run against the branch's own worktree, where
    /// HEAD is the branch tip) and <see cref="GitService.GetStatusPorcelainAsync"/> rather than adding
    /// new git plumbing. Returns null when there's nothing to flag.
    /// </summary>
    private async Task<MergeResult?> CheckUntrackedCollisionsAsync(
        string workingDir, string targetRef, string worktreePath, string branchName, CancellationToken ct)
    {
        var mergeBase = await _git.MergeBaseAsync(workingDir, targetRef, branchName, ct);
        if (mergeBase is null) return null;

        List<string> addedByBranch;
        try
        {
            addedByBranch = ParseAddedPaths(await _git.GetCommittedFilesAsync(worktreePath, mergeBase, ct));
        }
        catch { return null; } // worktree missing — nothing to compare, let the merge report its own outcome

        if (addedByBranch.Count == 0) return null;

        var untracked = ParseUntrackedPaths(await _git.GetStatusPorcelainAsync(workingDir, ct));
        if (untracked.Count == 0) return null;

        var untrackedSet = new HashSet<string>(untracked, StringComparer.OrdinalIgnoreCase);
        var collisions = addedByBranch.Where(untrackedSet.Contains).ToList();
        if (collisions.Count == 0) return null;

        var details = collisions.Select(p => $"{p} ({DescribeSize(workingDir, p)})");
        return new MergeResult(StatusUntrackedCollision, collisions,
            "merge would overwrite untracked file(s) that exist in the target working directory: " +
            string.Join(", ", details));
    }

    private static string DescribeSize(string workingDir, string relativePath)
    {
        try
        {
            var full = Path.Combine(workingDir, relativePath.Replace('/', Path.DirectorySeparatorChar));
            return $"{new FileInfo(full).Length} bytes";
        }
        catch { return "size unknown"; }
    }

    // Only "A" (pure add) entries — a path the branch modifies but that already exists on the
    // target's own history is a normal merge, not a collision with something untracked.
    private static List<string> ParseAddedPaths(string nameStatus)
    {
        var result = new List<string>();
        foreach (var raw in nameStatus.Split('\n', StringSplitOptions.RemoveEmptyEntries))
        {
            var line = raw.TrimEnd('\r');
            if (line.Length < 2 || line[0] != 'A') continue;
            var tab = line.IndexOf('\t');
            if (tab >= 0) result.Add(line[(tab + 1)..].Trim());
        }
        return result;
    }

    private static List<string> ParseUntrackedPaths(string porcelain)
    {
        var result = new List<string>();
        foreach (var raw in porcelain.Split('\n', StringSplitOptions.RemoveEmptyEntries))
        {
            var line = raw.TrimEnd('\r');
            if (line.StartsWith("?? ", StringComparison.Ordinal))
                result.Add(line[3..].Trim().Trim('"'));
        }
        return result;
    }

    private async Task MarkWorktreeMergedAsync(string taskId, string mergeCommitSha, CancellationToken ct)
    {
        using (var ctx = _dbFactory.CreateDbContext())
        {
            await new WorktreeRepository(ctx).SetMergedAsync(taskId, mergeCommitSha, ct);
        }
        await _broadcaster.WorktreeUpdated(taskId);
    }

    private async Task ApproveIfWaitingForReviewAsync(TaskEntity task, CancellationToken ct)
    {
        // A merged worktree means the work is integrated, so the task must reach Done.
        // MarkWorktreeMergedAsync only flips the worktree state; transition the task
        // itself when it was still awaiting review (a Done task is already terminal).
        if (task.Status == TaskStatus.WaitingForReview)
            await _state.ApproveReviewAsync(task.Id, ct);
    }

    /// <summary>
    /// After a merge lands on <paramref name="targetBranch"/>, rebases every other WaitingForReview
    /// task's active worktree in the same list onto the new tip -- but only ones that actually
    /// touch a file the merge just changed. A branch merely behind (no overlap) is left alone: a
    /// rebase is disruptive busywork when there's nothing to gain from it. Best-effort throughout --
    /// this runs after the merge that matters has already succeeded, so a failure here must never
    /// surface as a failure of that merge.
    /// </summary>
    private async Task RebaseOthersAfterMergeAsync(
        TaskEntity mergedTask, ListEntity list, string targetBranch,
        string oldTargetTip, string newTargetTip, CancellationToken ct)
    {
        if (string.Equals(oldTargetTip, newTargetTip, StringComparison.Ordinal)) return;

        IReadOnlyList<string> landedFiles;
        try
        {
            landedFiles = await _git.GetChangedFileNamesAsync(list.WorkingDir!, oldTargetTip, newTargetTip, ct);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Auto-rebase: could not compute files landed by merging task {TaskId}", mergedTask.Id);
            return;
        }
        if (landedFiles.Count == 0) return;

        List<TaskEntity> candidates;
        using (var ctx = _dbFactory.CreateDbContext())
        {
            var tasks = await new TaskRepository(ctx).GetByListIdAsync(list.Id, ct);
            candidates = tasks.Where(t => t.Id != mergedTask.Id && t.Status == TaskStatus.WaitingForReview).ToList();
        }
        if (candidates.Count == 0) return;

        // Runs where the stalled "Merging…" phase would otherwise sit unchanged in the UI while
        // this loop does real (if best-effort) work — see the correction note in the task spec.
        for (var i = 0; i < candidates.Count; i++)
        {
            await _broadcaster.OperationProgress(mergedTask.Id, PhaseRebasing, i + 1, candidates.Count);
            await RebaseOneIfOverlappingAsync(candidates[i], targetBranch, newTargetTip, landedFiles, ct);
        }
    }

    private async Task RebaseOneIfOverlappingAsync(
        TaskEntity candidate, string targetBranch, string newTargetTip,
        IReadOnlyList<string> landedFiles, CancellationToken ct)
    {
        WorktreeEntity? wt;
        using (var ctx = _dbFactory.CreateDbContext())
            wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(candidate.Id, ct);

        if (wt is null || wt.State != WorktreeState.Active || !Directory.Exists(wt.Path)) return;

        IReadOnlyList<string> ownFiles;
        try
        {
            if (await _git.HasChangesAsync(wt.Path, includeUntracked: false, ct)) return;
            ownFiles = await _git.GetChangedFileNamesAsync(wt.Path, wt.BaseCommit, "HEAD", ct);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Auto-rebase: could not inspect worktree for task {TaskId}", candidate.Id);
            return;
        }

        if (!landedFiles.Intersect(ownFiles, StringComparer.OrdinalIgnoreCase).Any()) return; // merely behind

        var (exitCode, stderr, conflictFiles) = await _git.RebaseAsync(wt.Path, targetBranch, ct);
        if (exitCode != 0)
        {
            var detail = conflictFiles.Count > 0 ? $"conflicts in {string.Join(", ", conflictFiles)}" : stderr;
            _logger.LogWarning(
                "Auto-rebase of task {TaskId} branch {Branch} onto {Target} failed, left as-is: {Detail}",
                candidate.Id, wt.BranchName, targetBranch, detail);
            await _broadcaster.WorkerLog(
                $"Auto-rebase failed for #{candidate.Number} \"{candidate.Title}\" onto {targetBranch} — left as-is ({detail})",
                WorkerLogLevel.Warn, DateTime.UtcNow);
            return;
        }

        var newHead = await _git.RevParseHeadAsync(wt.Path, ct);
        using (var ctx = _dbFactory.CreateDbContext())
            await new WorktreeRepository(ctx).SetRebasedAsync(candidate.Id, newTargetTip, newHead, ct);

        await _broadcaster.WorktreeUpdated(candidate.Id);
        _logger.LogInformation(
            "Auto-rebased task {TaskId} branch {Branch} onto {Target}", candidate.Id, wt.BranchName, targetBranch);
    }

    public async Task<MergeResult> MergeAsync(
        string taskId,
        string targetBranch,
        bool removeWorktree,
        string commitMessage,
        bool leaveConflictsInTree,
        CancellationToken ct,
        IProgress<ProgressNotificationValue>? progress = null,
        bool skipVerify = false)
    {
        var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);

        if (task.Status == TaskStatus.Running)
            return Blocked("task is running");
        if (wt is null)
            return Blocked("task has no worktree");
        if (wt.State != WorktreeState.Active)
            return Blocked($"worktree state is {wt.State}");
        if (string.IsNullOrWhiteSpace(list.WorkingDir))
            return Blocked("list has no working directory");

        // Announced before the gate wait: another merge holding the repo is itself a reason the
        // caller sees nothing happen, and a UI waiting on this call needs a phase to show at once.
        await _broadcaster.OperationProgress(taskId, PhaseMerging, 0, 0);

        var gate = GetMergeGate(list.WorkingDir);
        await gate.WaitAsync(ct);
        try
        {
            if (!await _git.IsGitRepoAsync(list.WorkingDir, ct))
                return Blocked("working directory is not a git repository");
            if (await _git.IsMidMergeAsync(list.WorkingDir, ct))
                return Blocked("target working directory is mid-merge");
            if (await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct))
                return Blocked("target working tree has uncommitted changes");

            var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
            if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal))
            {
                try { await _git.CheckoutBranchAsync(list.WorkingDir, targetBranch, ct); }
                catch (Exception ex) { return Blocked($"failed to switch target branch: {ex.Message}"); }
            }

            var collision = await CheckUntrackedCollisionsAsync(list.WorkingDir, "HEAD", wt.Path, wt.BranchName, ct);
            if (collision is not null) return collision;

            var oldTargetTip = await _git.RevParseHeadAsync(list.WorkingDir, ct);
            var message = string.IsNullOrWhiteSpace(commitMessage)
                ? DefaultMergeMessage(task, list)
                : commitMessage;
            var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, message, ct);
            if (exitCode != 0)
            {
                List<string> files;
                try { files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); }
                catch { files = new(); }

                if (leaveConflictsInTree && files.Count > 0)
                {
                    return new MergeResult(StatusConflict, files, null);
                }

                // If abort fails the repo is left mid-merge; the caller must resolve manually.
                // Return Blocked (not conflict) so the UI does not offer a stale conflict list.
                try { await _git.MergeAbortAsync(list.WorkingDir, ct); }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "git merge --abort failed after conflict — repo is mid-merge");
                    return Blocked($"merge conflict and abort failed: {ex.Message} — repo is mid-merge, resolve manually");
                }

                if (files.Count == 0)
                {
                    // Non-conflict failure (e.g. unrelated histories).
                    return new MergeResult(StatusBlocked, Array.Empty<string>(), $"merge failed: {stderr}");
                }

                return new MergeResult(StatusConflict, files, null);
            }

            var mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct);

            string? cleanupWarning = null;
            if (removeWorktree)
            {
                try
                {
                    await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: false, ct);
                    try { await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: false, ct); }
                    catch (Exception ex)
                    {
                        _logger.LogWarning(ex, "branch delete failed for {Branch}", wt.BranchName);
                        cleanupWarning = $"worktree removed, branch delete failed: {ex.Message}";
                    }
                }
                catch (Exception ex)
                {
                    _logger.LogWarning(ex, "worktree remove failed for {Path}", wt.Path);
                    cleanupWarning = $"worktree remove failed: {ex.Message}";
                }
            }

            await MarkWorktreeMergedAsync(taskId, mergeSha, ct);
            await RebaseOthersAfterMergeAsync(task, list, targetBranch, oldTargetTip, mergeSha, ct);

            // The merge itself is instant; the verify gate is what makes this call take minutes.
            // Tell every client (the waiting modal and the footer log strip) that it started —
            // silence here is what makes a working merge look like a dead button.
            if (!string.IsNullOrWhiteSpace(verifyCommand))
            {
                if (skipVerify)
                {
                    // Batch mode: the caller defers the gate to one RunListVerifyAsync at the end.
                    // The Done transition is withheld with it — same invariant as verify_failed.
                    await _broadcaster.WorkerLog(
                        $"Merged #{task.Number} \"{task.Title}\" into {targetBranch} — verify deferred to batch",
                        WorkerLogLevel.Info, DateTime.UtcNow);
                    return new MergeResult(StatusMergedVerifyPending, Array.Empty<string>(), cleanupWarning);
                }

                await _broadcaster.OperationProgress(taskId, PhaseVerifying, 0, 0);
                await _broadcaster.WorkerLog(
                    $"Verify command running after merging #{task.Number} \"{task.Title}\" into {targetBranch}",
                    WorkerLogLevel.Info, DateTime.UtcNow);
            }

            var verifyFailure = await RunVerifyGateAsync(
                verifyCommand, list.WorkingDir, ct, progress,
                elapsed => _ = _broadcaster.OperationProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds, 0));
            if (verifyFailure is not null)
            {
                _logger.LogWarning("Verify command failed after merging task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
                await _broadcaster.WorkerLog($"Verify failed for #{task.Number} \"{task.Title}\" after merge into {targetBranch}", WorkerLogLevel.Warn, DateTime.UtcNow);
                return verifyFailure;
            }

            await ApproveIfWaitingForReviewAsync(task, ct);

            _logger.LogInformation(
                "Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
                taskId, wt.BranchName, targetBranch, removeWorktree);
            await _broadcaster.WorkerLog($"Merged #{task.Number} \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow);

            return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
        }
        finally { gate.Release(); }
    }

    public Task<MergeResult> MergeAsync(
        string taskId,
        string targetBranch,
        bool removeWorktree,
        string commitMessage,
        CancellationToken ct)
        => MergeAsync(taskId, targetBranch, removeWorktree, commitMessage, leaveConflictsInTree: false, ct);

    public async Task<MergeResult> ContinueMergeAsync(
        string taskId, CancellationToken ct, IProgress<ProgressNotificationValue>? progress = null,
        bool skipVerify = false)
    {
        var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);

        if (wt is null) return Blocked("task has no worktree");
        if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}");
        if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory");

        var gate = GetMergeGate(list.WorkingDir);
        await gate.WaitAsync(ct);
        try
        {
            if (!await _git.IsMidMergeAsync(list.WorkingDir, ct))
                return Blocked("repo is not mid-merge");

            // HEAD still points at the pre-merge tip here: a conflicted `git merge` never moves it,
            // only MERGE_HEAD plus the working tree/index change.
            var oldTargetTip = await _git.RevParseHeadAsync(list.WorkingDir, ct);

            // Validate BEFORE staging: `git add` marks a conflicted path resolved regardless of
            // its content, so an unresolved file needs a positively-checked resolution, not just
            // "whatever happens to be on disk". For an ordinary content conflict, git always
            // starts the file with markers, so their absence reliably means someone resolved it
            // (in-app or in an external editor). A modify/delete or rename/delete conflict never
            // gets markers at all (GetConflictDocumentsAsync) — the same marker-less state also
            // describes a file nobody has touched — so those are additionally checked against
            // what git itself left on disk by default (whichever side wasn't deleted); still
            // matching that default means still unresolved.
            var unresolved = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
            var stillConflicted = new List<string>();
            var toAdd = new List<string>();
            var toRemove = new List<string>();
            foreach (var path in unresolved)
            {
                var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
                string? text = null;
                if (File.Exists(full))
                {
                    try { text = await File.ReadAllTextAsync(full, ct); }
                    catch { /* treated as unresolved below */ }
                }

                if (text is not null && (LooksBinary(text) || ConflictMarkerParser.HasConflicts(text)))
                {
                    stillConflicted.Add(path);
                    continue;
                }

                var oursStage = await _git.ShowConflictStageAsync(list.WorkingDir, 2, path, ct);
                var theirsStage = await _git.ShowConflictStageAsync(list.WorkingDir, 3, path, ct);
                var isModifyDeleteStyle = oursStage is null || theirsStage is null;
                if (isModifyDeleteStyle &&
                    NormalizeLineEndings(text ?? "") == NormalizeLineEndings(oursStage ?? theirsStage ?? ""))
                {
                    stillConflicted.Add(path);
                    continue;
                }

                if (text is null || text.Length == 0)
                    toRemove.Add(path);
                else
                    toAdd.Add(path);
            }

            if (stillConflicted.Count > 0)
                return new MergeResult(StatusConflict, stillConflicted, "conflicts not fully resolved");

            // Closes the window between the original `git merge` (which already refused any
            // untracked collision at that point) and this call: an untracked file matching one
            // of the branch's own added paths could have appeared in the meantime, leaving the
            // index and the working tree disagreeing about that path — committing then would
            // either lose the local content or silently drop the branch's own added file.
            var collision = await CheckUntrackedCollisionsAsync(list.WorkingDir, "HEAD", wt.Path, wt.BranchName, ct);
            if (collision is not null) return collision;

            // Stage exactly the resolved conflict paths — never `git add -A`, which would sweep
            // untracked/unrelated changes left by other sessions into this merge commit (the
            // target working dir is shared).
            foreach (var path in toAdd)
                await _git.AddPathAsync(list.WorkingDir, path, ct);
            foreach (var path in toRemove)
                await _git.RemovePathAsync(list.WorkingDir, path, ct);

            var remaining = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
            if (remaining.Count > 0)
                return new MergeResult(StatusConflict, remaining, "conflicts not fully resolved");

            try { await _git.CommitAsync(list.WorkingDir, $"Merge branch '{wt.BranchName}'", ct); }
            catch (Exception ex) { return Blocked($"commit failed: {ex.Message}"); }

            var mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct);
            await MarkWorktreeMergedAsync(taskId, mergeSha, ct);

            var targetBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
            await RebaseOthersAfterMergeAsync(task, list, targetBranch, oldTargetTip, mergeSha, ct);

            if (!string.IsNullOrWhiteSpace(verifyCommand) && skipVerify)
            {
                await _broadcaster.WorkerLog(
                    $"Merged #{task.Number} \"{task.Title}\" (conflict resolved) — verify deferred to batch",
                    WorkerLogLevel.Info, DateTime.UtcNow);
                return new MergeResult(StatusMergedVerifyPending, Array.Empty<string>(), null);
            }

            var verifyFailure = await RunVerifyGateAsync(verifyCommand, list.WorkingDir, ct, progress);
            if (verifyFailure is not null)
            {
                _logger.LogWarning("Verify command failed after continuing merge of task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
                await _broadcaster.WorkerLog($"Verify failed for #{task.Number} \"{task.Title}\" after merge", WorkerLogLevel.Warn, DateTime.UtcNow);
                return verifyFailure;
            }

            await ApproveIfWaitingForReviewAsync(task, ct);
            _logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName);

            return new MergeResult(StatusMerged, Array.Empty<string>(), null);
        }
        finally { gate.Release(); }
    }

    public async Task<MergeResult> AbortMergeAsync(string taskId, CancellationToken ct)
    {
        var (_, list, wt, _) = await LoadMergeContextAsync(taskId, ct);

        if (wt is null) return Blocked("task has no worktree");
        if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}");
        if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory");
        if (!await _git.IsMidMergeAsync(list.WorkingDir, ct))
            return Blocked("repo is not mid-merge");

        try { await _git.MergeAbortAsync(list.WorkingDir, ct); }
        catch (Exception ex) { return Blocked($"abort failed: {ex.Message}"); }
        _logger.LogInformation("Aborted merge of task {TaskId}", taskId);

        return new MergeResult(StatusAborted, Array.Empty<string>(), null);
    }

    /// <summary>
    /// Reverts the merge commit recorded for this task (<see cref="WorktreeEntity.MergeCommit"/>)
    /// via `git revert -m 1`, a new commit that undoes the merge without rewriting history — the
    /// target working directory is shared with other sessions, so a reset/rebase is never an option.
    /// On success the task returns to WaitingForReview so it can be reconsidered, and the worktree
    /// state moves to Kept: Merged/Discarded are swept by WorktreeMaintenanceService, and by the time
    /// a merge can be reverted its worktree directory and branch are typically already gone (removed
    /// during the original merge cleanup), so Active — which implies a live, resumable worktree —
    /// would be misleading. A conflicting revert is aborted immediately (`git revert --abort`); no
    /// partial/half-resolved state is ever left in the tree.
    /// </summary>
    public async Task<RevertResult> RevertMergeAsync(string taskId, string targetBranch, CancellationToken ct)
    {
        var (task, list, wt, _) = await LoadMergeContextAsync(taskId, ct);

        if (task.Status != TaskStatus.Done)
            return RevertBlocked("task is not Done; only a merged task's revert can be undone");
        if (wt is null)
            return RevertBlocked("task has no worktree");
        if (wt.State != WorktreeState.Merged)
            return RevertBlocked($"worktree state is {wt.State}, expected Merged");
        if (string.IsNullOrWhiteSpace(wt.MergeCommit))
            return RevertBlocked("no merge commit recorded for this task; cannot revert");
        if (string.IsNullOrWhiteSpace(list.WorkingDir))
            return RevertBlocked("list has no working directory");
        if (!await _git.IsGitRepoAsync(list.WorkingDir, ct))
            return RevertBlocked("working directory is not a git repository");
        if (await _git.IsMidMergeAsync(list.WorkingDir, ct))
            return RevertBlocked("target working directory is mid-merge");
        if (await _git.IsMidRevertAsync(list.WorkingDir, ct))
            return RevertBlocked("target working directory is mid-revert");
        if (await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct))
            return RevertBlocked("target working tree has uncommitted changes");

        var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
        if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal))
        {
            try { await _git.CheckoutBranchAsync(list.WorkingDir, targetBranch, ct); }
            catch (Exception ex) { return RevertBlocked($"failed to switch target branch: {ex.Message}"); }
        }

        var (exitCode, stderr) = await _git.RevertMergeCommitAsync(list.WorkingDir, wt.MergeCommit!, ct);
        if (exitCode != 0)
        {
            List<string> files;
            try { files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); }
            catch { files = new(); }

            try { await _git.RevertAbortAsync(list.WorkingDir, ct); }
            catch (Exception ex)
            {
                _logger.LogError(ex, "git revert --abort failed after conflict — repo is mid-revert");
                return RevertBlocked($"revert conflict and abort failed: {ex.Message} — repo is mid-revert, resolve manually");
            }

            if (files.Count == 0)
                return RevertBlocked($"revert failed: {stderr}");

            return new RevertResult(StatusConflictAborted, null, files, "revert conflicted; aborted cleanly, no changes made");
        }

        var revertSha = await _git.RevParseHeadAsync(list.WorkingDir, ct);

        using (var ctx = _dbFactory.CreateDbContext())
        {
            await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Kept, ct);
        }
        await _broadcaster.WorktreeUpdated(taskId);
        await _state.ForceSetStatusAsync(taskId, TaskStatus.WaitingForReview, ct);

        _logger.LogInformation(
            "Reverted merge of task {TaskId} (merge commit {MergeSha}) via revert commit {RevertSha}",
            taskId, wt.MergeCommit, revertSha);
        await _broadcaster.WorkerLog($"Reverted merge of #{task.Number} \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);

        return new RevertResult(StatusReverted, revertSha, Array.Empty<string>(), null);
    }

    /// <summary>
    /// Reads each conflicted working-tree file and parses its conflict markers into line-level
    /// segments (with the diff3 merge base when present). Binary files are flagged and skipped.
    /// A path with no text markers on disk is either an ordinary content conflict that's already
    /// been resolved (git keeps both index stages until the path is staged, whether or not the
    /// working tree still looks conflicted — reads as a single stable segment, 0 hunks left), or
    /// a modify/delete or rename/delete conflict — git never writes markers for those at all, it
    /// just leaves whichever side wasn't deleted sitting in the working tree, so the same
    /// "no markers" state also describes an untouched file. The index tells the two apart: a
    /// modify/delete-style conflict only ever populates ONE of the ours/theirs stages. Those are
    /// synthesized into a single whole-file conflict block straight from the stages (empty side
    /// = that side deleted the path) so the resolver still shows a real choice.
    /// </summary>
    public async Task<ConflictDocuments> GetConflictDocumentsAsync(string taskId, CancellationToken ct)
    {
        var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct);
        if (string.IsNullOrWhiteSpace(list.WorkingDir))
            throw new InvalidOperationException("list has no working directory");

        var files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
        var result = new List<ConflictDocumentContent>(files.Count);
        foreach (var path in files)
        {
            var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
            string? diskText = null;
            if (File.Exists(full))
            {
                try { diskText = await File.ReadAllTextAsync(full, ct); }
                catch { /* unreadable — fall through to the index stages below */ }
            }

            if (diskText is not null && LooksBinary(diskText))
            {
                result.Add(new ConflictDocumentContent(path, true, Array.Empty<MergeSegment>()));
                continue;
            }

            if (diskText is not null && ConflictMarkerParser.HasConflicts(diskText))
            {
                result.Add(new ConflictDocumentContent(path, false, ConflictMarkerParser.Parse(diskText)));
                continue;
            }

            var ours = await _git.ShowConflictStageAsync(list.WorkingDir, 2, path, ct);
            var theirs = await _git.ShowConflictStageAsync(list.WorkingDir, 3, path, ct);
            if (ours is not null && theirs is not null)
            {
                // Ordinary two-sided conflict, already resolved (no markers left) but not yet staged.
                result.Add(new ConflictDocumentContent(path, false,
                    new[] { MergeSegment.Stable(diskText ?? "") }));
                continue;
            }

            if (LooksBinary(ours ?? "") || LooksBinary(theirs ?? ""))
            {
                result.Add(new ConflictDocumentContent(path, true, Array.Empty<MergeSegment>()));
                continue;
            }

            var @base = await _git.ShowConflictStageAsync(list.WorkingDir, 1, path, ct);
            result.Add(new ConflictDocumentContent(
                path, false, new[] { MergeSegment.Conflict(ours ?? "", @base, theirs ?? "") }));
        }
        return new ConflictDocuments(taskId, result);
    }

    // Working-tree checkouts can go through autocrlf while `git show :stage:path` never does —
    // normalize before comparing the two or an untouched file reads as "resolved" on a machine
    // with autocrlf enabled.
    private static string NormalizeLineEndings(string text) => text.Replace("\r\n", "\n").Replace('\r', '\n');

    // A NUL byte in the head of the file is the conventional binary sniff.
    private static bool LooksBinary(string text)
    {
        var n = Math.Min(text.Length, 8000);
        for (var i = 0; i < n; i++)
            if (text[i] == '\0') return true;
        return false;
    }

    public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
    {
        var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct);
        if (string.IsNullOrWhiteSpace(list.WorkingDir))
            throw new InvalidOperationException("list has no working directory");

        if (content.Length == 0)
        {
            // An empty resolution for a whole-file conflict (modify/delete, rename/delete) means
            // "keep the deletion" — `git add` on an empty file would instead stage it as a
            // tracked, zero-byte file, which is not what accepting the deleted side means.
            await _git.RemovePathAsync(list.WorkingDir, path, ct);
            return;
        }

        var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
        await File.WriteAllTextAsync(full, content, ct);
        await _git.AddPathAsync(list.WorkingDir, path, ct);
    }

    /// <summary>
    /// Writes a conflicted file's content without staging it. Unlike <see cref="WriteResolutionAsync"/> this must
    /// NOT run `git add` — that marks the path resolved in git's index regardless of its content, so a partially
    /// resolved hunk (markers still present elsewhere in the file) would drop out of
    /// <see cref="GetConflictDocumentsAsync"/>'s conflicted-file list and <see cref="ContinueMergeAsync"/>'s
    /// pre-stage marker scan, letting a still-conflicted file slip into a commit. Staging happens once, for
    /// everything, inside <see cref="ContinueMergeAsync"/>.
    /// </summary>
    public async Task WriteConflictFileAsync(string taskId, string path, string content, CancellationToken ct)
    {
        var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct);
        if (string.IsNullOrWhiteSpace(list.WorkingDir))
            throw new InvalidOperationException("list has no working directory");

        var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
        await File.WriteAllTextAsync(full, content, ct);
    }

    public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
    {
        var (task, list, _, _) = await LoadMergeContextAsync(taskId, ct);
        var defaultMessage = DefaultMergeMessage(task, list);

        if (string.IsNullOrWhiteSpace(list.WorkingDir))
            return new MergeTargets("", Array.Empty<string>(), defaultMessage);

        var current  = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
        var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
        return new MergeTargets(current, branches, defaultMessage);
    }

    /// The commit message a merge uses when the caller passes none. Built here rather than in the
    /// UI/MCP callers because only this layer knows the task's commit type and its list's name.
    private static string DefaultMergeMessage(TaskEntity task, ListEntity list) =>
        CommitMessageBuilder.BuildMerge(task.CommitType, list.Name, task.Title, task.Id);

    public Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct)
        => PreviewAsync(taskId, targetBranch, runVerify: false, ct);

    /// <summary>
    /// Non-destructive merge preview via <see cref="GitService.PreviewMergeAsync"/>. When
    /// <paramref name="runVerify"/> is true and the list has a verify command configured, a clean
    /// preview is additionally materialized into a scratch worktree (outside the target repo, always
    /// cleaned up) and built/tested there — the real working tree is never touched. No verify
    /// command configured, or runVerify=false, reproduces the pre-existing behavior exactly: no
    /// delay, no scratch worktree.
    /// </summary>
    public async Task<MergePreviewResult> PreviewAsync(
        string taskId, string targetBranch, bool runVerify, CancellationToken ct,
        IProgress<ProgressNotificationValue>? progress = null)
    {
        var (_, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);

        if (wt is null || wt.State != WorktreeState.Active)
            return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
        if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct))
            return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);

        var target = string.IsNullOrWhiteSpace(targetBranch)
            ? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
            : targetBranch;

        // merge-tree (below) is a pure object-level check — it has no idea the working directory
        // has an untracked file that would block (or, worse, silently be lost by) the real merge.
        var collision = await CheckUntrackedCollisionsAsync(list.WorkingDir, target, wt.Path, wt.BranchName, ct);
        if (collision is not null)
            return new MergePreviewResult(PreviewUntrackedCollision, collision.ConflictFiles, 0);

        var preview = await _git.PreviewMergeAsync(list.WorkingDir, target, wt.BranchName, ct);
        if (!preview.Supported)
            return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
        if (!preview.Clean)
            return new MergePreviewResult(PreviewConflict, preview.ConflictFiles, 0);

        var count = await _git.CountChangedFilesAsync(list.WorkingDir, target, wt.BranchName, ct);

        if (!runVerify || string.IsNullOrWhiteSpace(verifyCommand) || preview.TreeOid is null)
            return new MergePreviewResult(PreviewClean, Array.Empty<string>(), count);

        var (exitCode, durationMs, outputTail) = await RunPreviewVerifyAsync(
            list.WorkingDir, target, preview.TreeOid, verifyCommand, ct, progress);
        return new MergePreviewResult(PreviewClean, Array.Empty<string>(), count, exitCode, durationMs, outputTail);
    }

    /// <summary>
    /// Builds the tree a clean merge-tree preview would produce into a scratch, detached-HEAD
    /// worktree (never a branch, never the real working tree) and runs the verify command there.
    /// The scratch worktree lives under the OS temp dir and is always removed, even on failure or
    /// cancellation.
    /// </summary>
    private async Task<(int ExitCode, long DurationMs, string OutputTail)> RunPreviewVerifyAsync(
        string repoDir, string targetBranch, string treeOid, string verifyCommand, CancellationToken ct,
        IProgress<ProgressNotificationValue>? progress = null)
    {
        var sw = System.Diagnostics.Stopwatch.StartNew();
        var scratchPath = Path.Combine(Path.GetTempPath(), "claudedo-preview-verify", Guid.NewGuid().ToString("N"));
        try
        {
            var parentSha = await _git.RevParseAsync(repoDir, targetBranch, ct);
            var commitSha = await _git.CommitTreeAsync(repoDir, treeOid, parentSha, "claudedo preview verify", ct);
            Directory.CreateDirectory(Path.GetDirectoryName(scratchPath)!);
            await _git.WorktreeAddDetachedAsync(repoDir, scratchPath, commitSha, ct);

            VerifyCommandResult result;
            try
            {
                result = await ProgressReporter.RunAsync(
                    _verify.RunAsync(scratchPath, verifyCommand, VerifyTimeout, ct), ProgressReportInterval, progress, "merge preview verify running");
            }
            catch (Exception ex)
            {
                return (-1, sw.ElapsedMilliseconds, $"verify command failed to start: {ex.Message}");
            }

            var exitCode = result.TimedOut ? -1 : result.ExitCode;
            var tail = exitCode == 0 ? "" : TailOutput(result.Output);
            return (exitCode, sw.ElapsedMilliseconds, tail);
        }
        catch (Exception ex)
        {
            return (-1, sw.ElapsedMilliseconds, $"failed to prepare merge preview for verification: {ex.Message}");
        }
        finally
        {
            // Best-effort cleanup on the un-cancelled token — a caller-cancelled ct must not
            // leave the scratch worktree behind.
            try { await _git.WorktreeRemoveAsync(repoDir, scratchPath, force: true, CancellationToken.None); }
            catch { /* nothing more we can do */ }
            try { if (Directory.Exists(scratchPath)) Directory.Delete(scratchPath, recursive: true); }
            catch { /* best-effort */ }
        }
    }

    public Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
        => ApproveAndMergeAsync(taskId, targetBranch, leaveConflictsInTree: false, ct);

    public async Task<MergeResult> ApproveAndMergeAsync(
        string taskId, string targetBranch, bool leaveConflictsInTree, CancellationToken ct,
        IProgress<ProgressNotificationValue>? progress = null,
        bool skipVerify = false)
    {
        var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);

        if (task.Status != TaskStatus.WaitingForReview)
            return Blocked("task is not waiting for review");

        if (wt is not null && wt.State == WorktreeState.Kept)
        {
            // Kept means either RevertMergeAsync undid a previous merge, or the user manually
            // parked an active worktree -- either way its branch may still exist. Re-running the
            // real merge (not the "nothing to merge" no-op below) is the only way approve can be
            // trusted after a revert; if the branch is truly gone (the common case once the
            // original approve already deleted it via removeWorktree:true) there is nothing left
            // to merge and that must come back as Blocked, never a silent fake "merged".
            if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct))
                return Blocked("list has no working directory");

            var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
            if (!branches.Contains(wt.BranchName, StringComparer.Ordinal))
                return Blocked(
                    $"worktree was reverted and its branch '{wt.BranchName}' no longer exists — cannot re-approve; discard the task or start a fresh worktree instead.");

            var reviveTarget = string.IsNullOrWhiteSpace(targetBranch)
                ? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
                : targetBranch;

            // A branch that was already merged once (reverted or not) is already an ancestor of
            // the target tip -- `git merge` on it again is a silent "Already up to date." no-op,
            // since merge only checks ancestry, never content. Detect that case up front and
            // reapply the original merge commit's own diff instead of falling into the normal
            // merge path below, which would report success without actually restoring anything.
            var branchTip = await _git.RevParseAsync(list.WorkingDir, wt.BranchName, ct);
            var targetTip = await _git.RevParseAsync(list.WorkingDir, reviveTarget, ct);
            if (await _git.IsAncestorAsync(list.WorkingDir, branchTip, targetTip, ct) == true)
            {
                if (string.IsNullOrWhiteSpace(wt.MergeCommit))
                    return Blocked("worktree branch has no new commits to merge and no merge commit is recorded to reapply");

                return await ReapplyRevertedMergeAsync(
                    task, list, wt, reviveTarget, verifyCommand, ct, progress, skipVerify);
            }

            using (var ctx = _dbFactory.CreateDbContext())
                await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Active, ct);
            await _broadcaster.WorktreeUpdated(taskId);
            wt.State = WorktreeState.Active;
        }

        if (wt is null || wt.State != WorktreeState.Active)
        {
            // There is nothing left to merge -- a sandbox run, or a list-handler task that
            // committed straight into the list's working dir. The verify command still has to
            // pass before the task may reach Done: skipping it here would exempt exactly the
            // runs that land the most on the target branch at once. Same working dir and same
            // per-repo gate as the merge path, so a concurrent merge can't land mid-verify.
            if (!string.IsNullOrWhiteSpace(verifyCommand) && !string.IsNullOrWhiteSpace(list.WorkingDir))
            {
                if (skipVerify)
                    return new MergeResult(StatusMergedVerifyPending, Array.Empty<string>(), null);

                var verifyGate = GetMergeGate(list.WorkingDir!);
                await _broadcaster.OperationProgress(taskId, PhaseVerifying, 0, 0);
                await verifyGate.WaitAsync(ct);
                try
                {
                    // Same reason as the post-merge gate: this holds the approve call for minutes.
                    var failed = await RunVerifyGateAsync(
                        verifyCommand, list.WorkingDir!, ct, progress,
                        elapsed => _ = _broadcaster.OperationProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds, 0));
                    if (failed is not null) return failed;
                }
                finally { verifyGate.Release(); }
            }

            var done = await _state.ApproveReviewAsync(taskId, ct);
            return done.Ok
                ? new MergeResult(StatusMerged, Array.Empty<string>(), null)
                : Blocked(done.Reason ?? "approve failed");
        }

        if (string.IsNullOrWhiteSpace(list.WorkingDir))
            return Blocked("list has no working directory");

        var target = string.IsNullOrWhiteSpace(targetBranch)
            ? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
            : targetBranch;

        // MergeAsync transitions the task WaitingForReview -> Done on a successful merge.
        // Remove the worktree on approve (matching the unit-merge path) so merged
        // worktrees don't pile up; the merge commit on the target branch is the record.
        return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify);
    }

    /// <summary>
    /// Redoes a merge that <see cref="RevertMergeAsync"/> undid (or that predates it, if the branch
    /// was already merged before its worktree reached <see cref="WorktreeState.Kept"/>), by
    /// cherry-picking the original merge commit's own diff onto <paramref name="targetBranch"/> --
    /// see <see cref="GitService.CherryPickMergeCommitAsync"/> for why a plain re-merge can't be
    /// used once the branch tip is already an ancestor of the target. Mirrors <see cref="MergeAsync"/>'s
    /// shape (gate, dirty/mid-op checks, cleanup, verify gate, approve) but produces a cherry-pick
    /// commit instead of a merge commit, and <see cref="WorktreeRepository.SetMergedAsync"/> still
    /// records it as this task's <see cref="WorktreeEntity.MergeCommit"/>.
    /// </summary>
    private async Task<MergeResult> ReapplyRevertedMergeAsync(
        TaskEntity task, ListEntity list, WorktreeEntity wt, string targetBranch, string? verifyCommand,
        CancellationToken ct, IProgress<ProgressNotificationValue>? progress, bool skipVerify)
    {
        var taskId = task.Id;
        var workingDir = list.WorkingDir!;
        await _broadcaster.OperationProgress(taskId, PhaseMerging, 0, 0);

        var gate = GetMergeGate(workingDir);
        await gate.WaitAsync(ct);
        try
        {
            if (await _git.IsMidMergeAsync(workingDir, ct))
                return Blocked("target working directory is mid-merge");
            if (await _git.IsMidCherryPickAsync(workingDir, ct))
                return Blocked("target working directory is mid-cherry-pick");
            if (await _git.HasChangesAsync(workingDir, includeUntracked: false, ct))
                return Blocked("target working tree has uncommitted changes");

            var currentBranch = await _git.GetCurrentBranchAsync(workingDir, ct);
            if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal))
            {
                try { await _git.CheckoutBranchAsync(workingDir, targetBranch, ct); }
                catch (Exception ex) { return Blocked($"failed to switch target branch: {ex.Message}"); }
            }

            var oldTargetTip = await _git.RevParseHeadAsync(workingDir, ct);

            var (exitCode, stderr) = await _git.CherryPickMergeCommitAsync(workingDir, wt.MergeCommit!, ct);
            if (exitCode != 0)
            {
                try { await _git.CherryPickAbortAsync(workingDir, ct); }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "git cherry-pick --abort failed after conflict — repo is mid-cherry-pick");
                    return Blocked($"reapply conflict and abort failed: {ex.Message} — repo is mid-cherry-pick, resolve manually");
                }
                return Blocked($"reapplying the reverted merge conflicted and was aborted: {stderr}");
            }

            var mergeSha = await _git.RevParseHeadAsync(workingDir, ct);

            string? cleanupWarning = null;
            try
            {
                await _git.WorktreeRemoveAsync(workingDir, wt.Path, force: false, ct);
                try { await _git.BranchDeleteAsync(workingDir, wt.BranchName, force: false, ct); }
                catch (Exception ex)
                {
                    _logger.LogWarning(ex, "branch delete failed for {Branch}", wt.BranchName);
                    cleanupWarning = $"worktree removed, branch delete failed: {ex.Message}";
                }
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "worktree remove failed for {Path}", wt.Path);
                cleanupWarning = $"worktree remove failed: {ex.Message}";
            }

            await MarkWorktreeMergedAsync(taskId, mergeSha, ct);
            await RebaseOthersAfterMergeAsync(task, list, targetBranch, oldTargetTip, mergeSha, ct);

            if (!string.IsNullOrWhiteSpace(verifyCommand) && skipVerify)
            {
                await _broadcaster.WorkerLog(
                    $"Re-merged #{task.Number} \"{task.Title}\" into {targetBranch} after a revert — verify deferred to batch",
                    WorkerLogLevel.Info, DateTime.UtcNow);
                return new MergeResult(StatusMergedVerifyPending, Array.Empty<string>(), cleanupWarning);
            }

            var verifyFailure = await RunVerifyGateAsync(
                verifyCommand, workingDir, ct, progress,
                elapsed => _ = _broadcaster.OperationProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds, 0));
            if (verifyFailure is not null)
            {
                _logger.LogWarning("Verify command failed after reapplying reverted merge for task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
                await _broadcaster.WorkerLog($"Verify failed for #{task.Number} \"{task.Title}\" after re-merge into {targetBranch}", WorkerLogLevel.Warn, DateTime.UtcNow);
                return verifyFailure;
            }

            await ApproveIfWaitingForReviewAsync(task, ct);
            _logger.LogInformation(
                "Reapplied reverted merge for task {TaskId} branch {Branch} into {Target}",
                taskId, wt.BranchName, targetBranch);
            await _broadcaster.WorkerLog($"Re-merged #{task.Number} \"{task.Title}\" into {targetBranch} after a revert", WorkerLogLevel.Success, DateTime.UtcNow);

            return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
        }
        finally { gate.Release(); }
    }

    /// <summary>
    /// The batch counterpart to the per-merge verify gate: runs the list's verify command ONCE
    /// (under the same per-repo gate) and, on success, promotes every listed task to Done via
    /// ApproveReviewAsync. Tasks were merged with skipVerify=true and sit in WaitingForReview.
    /// A task is only eligible when its work verifiably landed: worktree state Merged, or no
    /// worktree at all (sandbox/list-handler run) -- an Active worktree means the branch was
    /// never merged, and promoting it would produce a Done task with an unmerged branch.
    /// No verify command configured = no gate (same rule as everywhere else): promote directly.
    /// </summary>
    public async Task<ListVerifyResult> RunListVerifyAsync(
        string listId, IReadOnlyList<string> taskIds, CancellationToken ct,
        IProgress<ProgressNotificationValue>? progress = null)
    {
        if (taskIds.Count == 0)
            return new ListVerifyResult(StatusBlocked, Array.Empty<ListVerifyTaskOutcome>(), "no task ids given");

        ListEntity? list;
        string? verifyCommand;
        var candidates = new List<(TaskEntity Task, string? IneligibleReason)>();
        using (var ctx = _dbFactory.CreateDbContext())
        {
            var listRepo = new ListRepository(ctx);
            list = await listRepo.GetByIdAsync(listId, ct);
            if (list is null)
                return new ListVerifyResult(StatusBlocked, Array.Empty<ListVerifyTaskOutcome>(), $"list '{listId}' not found");
            verifyCommand = (await listRepo.GetConfigAsync(listId, ct))?.VerifyCommand;

            var taskRepo = new TaskRepository(ctx);
            var wtRepo = new WorktreeRepository(ctx);
            foreach (var id in taskIds.Distinct(StringComparer.OrdinalIgnoreCase))
            {
                var task = await taskRepo.GetByIdAsync(id, ct);
                if (task is null)
                    return new ListVerifyResult(StatusBlocked, Array.Empty<ListVerifyTaskOutcome>(), $"task '{id}' not found");
                var reason = task.ListId != list.Id ? "task belongs to a different list"
                    : task.Status != TaskStatus.WaitingForReview ? $"task is {task.Status}, not WaitingForReview"
                    : (await wtRepo.GetByTaskIdAsync(task.Id, ct)) is { } wt && wt.State != WorktreeState.Merged
                        ? $"worktree state is {wt.State}, not Merged — its branch never landed"
                        : null;
                candidates.Add((task, reason));
            }
        }

        if (string.IsNullOrWhiteSpace(list.WorkingDir))
            return new ListVerifyResult(StatusBlocked, Array.Empty<ListVerifyTaskOutcome>(), "list has no working directory");

        if (!string.IsNullOrWhiteSpace(verifyCommand))
        {
            var gate = GetMergeGate(list.WorkingDir);
            await _broadcaster.OperationProgress(listId, PhaseVerifying, 0, 0);
            await gate.WaitAsync(ct);
            MergeResult? failure;
            try
            {
                await _broadcaster.WorkerLog(
                    $"Batch verify running for {candidates.Count} merged task(s) in \"{list.Name}\"",
                    WorkerLogLevel.Info, DateTime.UtcNow);
                failure = await RunVerifyGateAsync(
                    verifyCommand, list.WorkingDir, ct, progress,
                    elapsed => _ = _broadcaster.OperationProgress(listId, PhaseVerifying, (int)elapsed.TotalSeconds, 0));
            }
            finally { gate.Release(); }

            if (failure is not null)
            {
                await _broadcaster.WorkerLog(
                    $"Batch verify failed in \"{list.Name}\" — {candidates.Count} task(s) stay out of Done",
                    WorkerLogLevel.Warn, DateTime.UtcNow);
                var failed = candidates
                    .Select(c => new ListVerifyTaskOutcome(c.Task.Id, c.Task.Number, false,
                        c.IneligibleReason ?? "verify failed"))
                    .ToList();
                return new ListVerifyResult(StatusVerifyFailed, failed, failure.ErrorMessage);
            }
        }

        var outcomes = new List<ListVerifyTaskOutcome>(candidates.Count);
        foreach (var (task, ineligibleReason) in candidates)
        {
            if (ineligibleReason is not null)
            {
                outcomes.Add(new ListVerifyTaskOutcome(task.Id, task.Number, false, ineligibleReason));
                continue;
            }
            var result = await _state.ApproveReviewAsync(task.Id, ct);
            outcomes.Add(new ListVerifyTaskOutcome(task.Id, task.Number, result.Ok, result.Ok ? null : result.Reason));
        }

        var promoted = outcomes.Count(o => o.Promoted);
        await _broadcaster.WorkerLog(
            $"Batch verify passed in \"{list.Name}\" — {promoted} task(s) promoted to Done",
            WorkerLogLevel.Success, DateTime.UtcNow);
        return new ListVerifyResult(StatusVerifyPassed, outcomes, null);
    }

    private static MergeResult Blocked(string reason) =>
        new(StatusBlocked, Array.Empty<string>(), reason);

    private static RevertResult RevertBlocked(string reason) =>
        new(StatusBlocked, null, Array.Empty<string>(), reason);
}
