using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Worktrees;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol;
using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.External;

public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
public sealed record DeleteTaskResult(bool Deleted, string Id, int Number);
public sealed record CancelTaskResult(bool Cancelled, string Id, int? Number = null);
// EmptyChildren is non-null only for a parent's approve (unit merge): the Done children whose
// review range (worktree ahead, or HandlerBaseCommit..HandlerHeadCommit for a worktree-less
// child) contributed nothing, so a reviewer sees them before approving instead of after.
public sealed record ReviewTaskResult(TaskRefDto Task, string? MergeStatus, IReadOnlyList<string> MergeConflicts, string? MergeMessage, string? RepoPath = null, IReadOnlyList<TaskRefDto>? EmptyChildren = null);
public sealed record RunTaskNowResult(bool Started, string TaskId, DirtyBaseWarning? BaseDirty = null, int? Number = null);

public sealed record TaskDto(
    string Id,
    int Number,
    string ListId,
    string Title,
    string? Description,
    string Status,
    string? Result,
    string? CreatedBy,
    DateTime CreatedAt,
    DateTime? StartedAt,
    DateTime? FinishedAt,
    bool IsMyDay,
    int SortOrder,
    // Count of CLAUDEDO_BLOCKED roadblocks the run reported, stamped by TaskRunner on finish.
    // A planning/improvement child reporting > 0 still goes straight to Done (see
    // ClaudeDo.Worker/CLAUDE.md → Unified parent model) -- this is the only MCP-visible signal
    // that it may have delivered nothing despite that Done status.
    int RoadblockCount = 0,
    // The three below are non-null only when Status=Failed; stamped by TaskRunner.MarkFailed via
    // TaskStateService.FailAsync. failureReason is "unknown" for a Failed task that predates this
    // field. Lets a caller triage without pulling get_task_log's raw NDJSON.
    string? FailureReason = null,
    int? FailureTurnsUsed = null,
    int? FailureMaxTurns = null,
    // A user/MCP-declared predecessor (set via add_task/update_task), distinct from the
    // planning chain's own internal BlockedByTaskId link.
    string? DependsOnTaskId = null,
    // True only while Status is Queued and the picker will not claim this task yet -- either a
    // planning-chain predecessor or DependsOnTaskId hasn't reached Done. See BlockedReason.
    bool Blocked = false,
    string? BlockedReason = null,
    bool IsManual = false);

// Lean counterpart to TaskDto for writing/status-changing tools: echoes back what changed
// without re-sending Description/Result, which the caller just sent or already has.
// BaseDirty is populated only where the task just transitioned to Queued (or ran immediately
// via run_task_now) and its list's working directory has uncommitted changes -- see
// ClaudeDo.Worker.Git.BaseDirtyChecker. Every other caller leaves it null.
public sealed record TaskRefDto(
    string Id,
    int Number,
    string ListId,
    string Title,
    string Status,
    int SortOrder,
    bool IsMyDay,
    int RoadblockCount = 0,
    string? FailureReason = null,
    int? FailureTurnsUsed = null,
    int? FailureMaxTurns = null,
    string? DependsOnTaskId = null,
    bool Blocked = false,
    string? BlockedReason = null,
    DirtyBaseWarning? BaseDirty = null,
    bool IsManual = false);

// tasks is populated when includeDescription=false (the default): lean references, no
// Description/Result. tasksFull is populated when includeDescription=true: full tasks incl.
// Description/Result. Exactly one of the two is non-null per the includeDescription flag —
// same "flag alongside nullable payload" idiom as BatchGetTaskResult.
public sealed record ListTasksResult(
    bool IncludeDescription,
    IReadOnlyList<TaskRefDto>? Tasks,
    IReadOnlyList<TaskDto>? TasksFull);

// Deliberately small: no descriptions, capped candidate count (see AddTask's duplicate check).
public sealed record PossibleDuplicateDto(string TaskId, int Number, string Title, string Status);

public sealed record AddTaskResult(TaskRefDto Task, IReadOnlyList<PossibleDuplicateDto> PossibleDuplicates);


public sealed record WorktreeInfoDto(
    string Path, string Branch, string HeadCommit, string BaseCommit,
    int Ahead, int Behind, bool IsDirty, string? MergeCommit = null);

public sealed record TaskDiffDto(
    string Content, IReadOnlyList<string> Files, bool Truncated, int TotalBytes);

public sealed record MergeTaskResultDto(
    bool Merged, string? MergeCommit, IReadOnlyList<string> Conflicts,
    bool ConflictsInTree = false, string? RepoPath = null);

public sealed record MergeContinuationResultDto(
    bool Merged, string TaskStatus, IReadOnlyList<string> Conflicts,
    string? RepoPath, string? Message);

public sealed record ConflictHunkDto(
    int Index, int StartLine, string Ours, string? Base, string Theirs);

public sealed record ConflictFileHunksDto(
    string Path, bool IsBinary, IReadOnlyList<ConflictHunkDto> Hunks);

public sealed record GetMergeConflictsResultDto(
    IReadOnlyList<ConflictFileHunksDto> Files, int RemainingHunks, string ConflictStyleNote = McpToolDocs.Diff3Note);

public sealed record ResolveConflictHunkResultDto(
    bool Resolved, int RemainingHunks, string ConflictStyleNote = McpToolDocs.Diff3Note);

// IsEmpty = the review range contributed nothing (worktree ahead-of-base is empty, or a
// worktree-less handler task's HandlerBaseCommit == HandlerHeadCommit) -- distinguishable from
// a merge that is merely small, so an empty branch can't be misread as "changedFileCount: 0
// means tiny" when it actually means "nothing to review".
// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are null unless a verify run was attempted
// (see PreviewMerge/PreviewMergeSet descriptions) -- 0 exit means the merge-tree result built/
// tested clean; a non-zero or -1 (timeout/failed to start) exit means it doesn't, with the tail
// of its output in VerifyOutputTail.
public sealed record MergePreviewToolDto(
    string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, bool IsEmpty = false,
    int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null,
    // Files this branch touches that the target branch ALSO touched since the branch's fork
    // point -- an honest staleness signal `behind` alone can't give (a branch can be far behind
    // yet touch nothing the target changed, or close behind yet collide on the one file that
    // matters). Empty for a worktree-less handler task, which commits straight onto the list's
    // working dir and has no fork point to compare against.
    IReadOnlyList<string>? StaleFiles = null);

public sealed record MergePreviewSetEntryDto(
    string TaskId, string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false,
    int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null,
    IReadOnlyList<string>? StaleFiles = null, int? Number = null);

public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds, IReadOnlyList<int> Numbers);

// TaskId's changed-file list is a non-empty PROPER subset of SupersetTaskId's -- e.g. one task
// deletes two files and another deletes just one of those same two. Stronger than an Overlaps
// entry (which only says some files are shared): a real subset is the best available machine
// signal that TaskId's work may already be entirely covered by SupersetTaskId.
public sealed record SubsetRelationDto(string TaskId, int Number, string SupersetTaskId, int SupersetNumber);

public sealed record MergePreviewSetResultDto(
    IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps,
    IReadOnlyList<SubsetRelationDto> Subsets);

public sealed record WorktreeListItemDto(
    string? TaskId, int? Number, string Path, string Branch,
    string HeadCommit, bool IsDirty, bool MergedIntoMain);

public sealed record CleanupWorktreeResult(
    bool Removed, string WorktreePath, bool BranchDeleted, int? Number = null);

public sealed record RevertMergeResultDto(
    bool Reverted, string? RevertCommit, IReadOnlyList<string> Conflicts, string? Message);

public sealed record DailyPrepCandidateDto(
    string Id, int Number, string ListId, string ListName, string Title, string? Description,
    bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt);

public sealed record DailyPrepDataDto(
    int MaxTasks,
    IReadOnlyList<DailyPrepCandidateDto> Candidates,
    IReadOnlyList<DailyPrepCandidateDto> CurrentMyDay);

[McpServerToolType]
public sealed class ExternalMcpService
{
    // Mirrors TaskMergeService.ProgressReportInterval / TaskWaitMcpTools.ProgressReportInterval:
    // a separate field (not shared) so shrinking one for a test can't race another's tests. Used
    // by the single-element long-runners here (GetTaskDiff, CleanupTaskWorktree) that wrap a bare
    // GitService/WorktreeMaintenanceService call with no natural i/n to report instead.
    internal static TimeSpan ProgressReportInterval = TimeSpan.FromSeconds(30);

    private readonly TaskRepository _tasks;
    private readonly ListRepository _lists;
    private readonly QueueService _queue;
    private readonly HubBroadcaster _broadcaster;
    private readonly ITaskStateService _state;
    private readonly GitService _git;
    private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
    private readonly WorktreeMaintenanceService _maintenance;
    private readonly TaskMergeService _merge;
    private readonly PlanningMergeOrchestrator _planningMerge;
    private readonly BaseDirtyChecker _baseDirtyChecker;

    public ExternalMcpService(
        TaskRepository tasks,
        ListRepository lists,
        QueueService queue,
        HubBroadcaster broadcaster,
        ITaskStateService state,
        GitService git,
        IDbContextFactory<ClaudeDoDbContext> dbFactory,
        WorktreeMaintenanceService maintenance,
        TaskMergeService merge,
        PlanningMergeOrchestrator planningMerge,
        BaseDirtyChecker baseDirtyChecker)
    {
        _tasks = tasks;
        _lists = lists;
        _queue = queue;
        _broadcaster = broadcaster;
        _state = state;
        _git = git;
        _dbFactory = dbFactory;
        _maintenance = maintenance;
        _merge = merge;
        _planningMerge = planningMerge;
        _baseDirtyChecker = baseDirtyChecker;
    }

    [McpServerTool, Description(
        "List all task lists available in ClaudeDo. Start here — every task tool needs a listId from this call.")]
    public async Task<IReadOnlyList<TaskListDto>> ListTaskLists(CancellationToken cancellationToken)
    {
        var lists = await _lists.GetAllAsync(cancellationToken);
        return lists.Select(l => new TaskListDto(l.Id, l.Name, l.WorkingDir)).ToList();
    }

    [McpServerTool, Description(
        "List the tasks in one list — the usual way to find a taskId. Optionally filter by creator and/or status." +
        McpToolDocs.TaskNumberHint)]
    public async Task<ListTasksResult> ListTasks(
        string listId,
        [Description("Only return tasks with this CreatedBy value.")]
        string? createdBy = null,
        [Description("Only return tasks in this status: Idle, Queued, Running, WaitingForReview, " +
                     "WaitingForChildren, Done, Failed or Cancelled.")]
        string? status = null,
        [Description("false (default): lean references in `tasks`, no Description/Result — keep this unless you " +
                     "need the description text, since verbosely-described tasks can blow past the response size " +
                     "limit. true: full tasks in `tasksFull` instead (`tasks` is then null).")]
        bool includeDescription = false,
        CancellationToken cancellationToken = default)
    {
        TaskStatus? statusFilter = null;
        if (!string.IsNullOrWhiteSpace(status))
        {
            if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
                throw new InvalidOperationException(
                    $"Unknown status '{status}'. Valid values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled.");
            statusFilter = parsed;
        }

        var tasks = await _tasks.GetByListIdAsync(listId, cancellationToken);
        IEnumerable<TaskEntity> query = tasks;
        if (createdBy is not null)
            query = query.Where(t => t.CreatedBy == createdBy);
        if (statusFilter is not null)
            query = query.Where(t => t.Status == statusFilter);

        var filtered = query.ToList();
        var blocked = await ComputeBlockedInfoAsync(filtered, cancellationToken);
        return includeDescription
            ? new ListTasksResult(true, null, filtered.Select(t => ToDto(t, blocked[t.Id].Blocked, blocked[t.Id].Reason)).ToList())
            : new ListTasksResult(false, filtered.Select(t => ToRefDto(t, blocked[t.Id].Blocked, blocked[t.Id].Reason)).ToList(), null);
    }

    // Batch-resolves, for each task, whether the picker is currently skipping it (Queued with
    // either a planning-chain BlockedByTaskId or an unmet DependsOnTaskId) and why. Only Queued
    // tasks can be blocked -- once a task has run, or hasn't been queued yet, blocking is moot.
    private async Task<Dictionary<string, (bool Blocked, string? Reason)>> ComputeBlockedInfoAsync(
        IReadOnlyList<TaskEntity> tasks, CancellationToken ct)
    {
        var dependencyIds = tasks
            .Where(t => t.Status == TaskStatus.Queued && t.BlockedByTaskId is null && t.DependsOnTaskId is not null)
            .Select(t => t.DependsOnTaskId!)
            .Distinct()
            .ToList();

        var dependencyStatuses = new Dictionary<string, TaskStatus>();
        if (dependencyIds.Count > 0)
        {
            await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
            dependencyStatuses = await ctx.Tasks.AsNoTracking()
                .Where(t => dependencyIds.Contains(t.Id))
                .Select(t => new { t.Id, t.Status })
                .ToDictionaryAsync(t => t.Id, t => t.Status, ct);
        }

        var result = new Dictionary<string, (bool Blocked, string? Reason)>();
        foreach (var t in tasks)
        {
            if (t.Status != TaskStatus.Queued)
            {
                result[t.Id] = (false, null);
            }
            else if (t.BlockedByTaskId is not null)
            {
                result[t.Id] = (true, $"Blocked by planning-chain predecessor {t.BlockedByTaskId}.");
            }
            else if (t.DependsOnTaskId is not null)
            {
                var known = dependencyStatuses.TryGetValue(t.DependsOnTaskId, out var depStatus);
                result[t.Id] = known && depStatus == TaskStatus.Done
                    ? (false, null)
                    : (true, $"Blocked: depends on task {t.DependsOnTaskId} (status: " +
                             (known ? depStatus.ToString() : "not found") + ").");
            }
            else
            {
                result[t.Id] = (false, null);
            }
        }
        return result;
    }

    [McpServerTool, Description(
        "Get a single task by id, including its current status and result — the canonical reference for what a " +
        "status means. Lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " +
        "A successful run lands in WaitingForReview; use review_task to approve, reject or cancel it. " +
        "Done/Failed/Cancelled tasks can be reset to Idle for re-execution. A Queued task with a blocker waits " +
        "for its predecessor before the picker will claim it, and WaitingForChildren is a parent whose own work " +
        "is done but whose children are still running. For Status=Failed, failureReason (max_turns|timeout|" +
        "usage_limit|error|cancelled|unknown) plus failureTurnsUsed/failureMaxTurns say why without pulling get_task_log." +
        McpToolDocs.TaskNumberHint)]
    public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
    {
        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
        var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        var blocked = await ComputeBlockedInfoAsync([task], cancellationToken);
        return ToDto(task, blocked[task.Id].Blocked, blocked[task.Id].Reason);
    }

    // Lean counterpart to GetTask, used internally by BatchGetTasks' default (includeDescription=false)
    // path. Not an MCP tool itself — GetTask's own behavior stays untouched.
    internal async Task<TaskRefDto> GetTaskRefAsync(string taskId, CancellationToken cancellationToken)
    {
        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
        var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        var blocked = await ComputeBlockedInfoAsync([task], cancellationToken);
        return ToRefDto(task, blocked[task.Id].Blocked, blocked[task.Id].Reason);
    }

    [McpServerTool, Description(
        "Create a new task in the given list. The task is always created — possibleDuplicates is a non-blocking " +
        "heads-up (up to 3 open tasks in the same list with a strongly overlapping title); check it and mention " +
        "any hit to the caller, but do not treat it as an error." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint)]
    public async Task<AddTaskResult> AddTask(
        string listId,
        string title,
        string? description = null,
        string? createdBy = null,
        [Description("true: enqueue the task for agent execution right away.")]
        bool queueImmediately = false,
        [Description("Cheapest model that can do the task well: 'haiku' for trivial/mechanical work, 'sonnet' " +
                     "for normal coding, 'opus' only for complex or cross-cutting work. null inherits the " +
                     "list/global default (normally sonnet).")]
        string? model = null,
        [Description("Id of a task that must reach Done (i.e. be merged) before the picker will claim this one, " +
                     "even once queued. Rejected if it doesn't exist, is this task's own id, or would create a " +
                     "dependency cycle. If that predecessor instead ends up Failed or Cancelled, this task simply " +
                     "stays blocked rather than starving silently -- check get_task/list_tasks' blocked field.")]
        string? dependsOnTaskId = null,
        CancellationToken cancellationToken = default)
    {
        if (string.IsNullOrWhiteSpace(listId))
            throw new InvalidOperationException("listId is required.");
        if (string.IsNullOrWhiteSpace(title))
            throw new InvalidOperationException("title is required.");

        var list = await _lists.GetByIdAsync(listId, cancellationToken)
            ?? throw new InvalidOperationException($"List {listId} not found.");

        dependsOnTaskId = await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, cancellationToken);

        var possibleDuplicates = await FindPossibleDuplicatesAsync(listId, title, cancellationToken);

        var entity = new TaskEntity
        {
            Id = Guid.NewGuid().ToString(),
            ListId = listId,
            Title = title,
            Description = description,
            Status = TaskStatus.Idle,
            CreatedAt = DateTime.UtcNow,
            CommitType = list.DefaultCommitType,
            CreatedBy = createdBy.NullIfBlank() ?? "mcp",
            Model = ModelRegistry.NormalizeAlias(model),
            // A manual list holds reminders, so anything filed there starts out manual.
            IsManual = list.IsManual,
        };
        await _tasks.AddAsync(entity, cancellationToken);

        if (dependsOnTaskId is not null)
        {
            var dependsResult = await _state.SetDependsOnAsync(entity.Id, dependsOnTaskId, cancellationToken);
            if (!dependsResult.Ok)
                throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId.");
            entity.DependsOnTaskId = dependsOnTaskId;
        }

        if (queueImmediately)
        {
            var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken);
            if (!enqueue.Ok)
                throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task.");
            entity.Status = TaskStatus.Queued;
        }

        await _broadcaster.TaskUpdated(entity.Id);
        var blocked = await ComputeBlockedInfoAsync([entity], cancellationToken);
        return new AddTaskResult(ToRefDto(entity, blocked[entity.Id].Blocked, blocked[entity.Id].Reason), possibleDuplicates);
    }

    // Non-terminal: a task still open enough that a new, similarly-titled task might be a duplicate
    // of it. Done/Failed/Cancelled tasks are retired and never flagged.
    private static readonly TaskStatus[] NonTerminalStatuses =
    [
        TaskStatus.Idle, TaskStatus.Queued, TaskStatus.Running,
        TaskStatus.WaitingForChildren, TaskStatus.WaitingForReview,
    ];

    private const int MaxPossibleDuplicates = 3;

    // Cheap, dependency-free "near-duplicate title" heuristic — no embeddings, no LLM call, just a
    // normalized-word overlap coefficient (shared significant words / smaller word-set size, not
    // Jaccard). Overlap-coefficient beats Jaccard here because titles in this project vary wildly in
    // length (a terse one-liner vs. a fully-spelled-out German sentence) — dividing by the union
    // would wash out a real match against a longer title. Threshold tuned against the pair that
    // motivated this check: "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal" vs.
    // "Settings: MaxTurnsCeiling editierbar machen" (>=2 shared significant words, >=50% overlap of
    // the shorter title's words) without over-firing on titles that merely share one generic word.
    private async Task<IReadOnlyList<PossibleDuplicateDto>> FindPossibleDuplicatesAsync(
        string listId, string title, CancellationToken ct)
    {
        var newWords = NormalizeTitleWords(title);
        if (newWords.Count == 0) return [];

        var candidates = await _tasks.GetByListIdAsync(listId, ct);

        return candidates
            .Where(t => NonTerminalStatuses.Contains(t.Status))
            .Select(t => (Task: t, Words: NormalizeTitleWords(t.Title)))
            .Select(x => (x.Task, Shared: newWords.Count(x.Words.Contains), x.Words))
            .Where(x => x.Shared >= 2 && x.Words.Count > 0
                        && (double)x.Shared / Math.Min(newWords.Count, x.Words.Count) >= 0.5)
            .OrderByDescending(x => (double)x.Shared / Math.Min(newWords.Count, x.Words.Count))
            .ThenByDescending(x => x.Shared)
            .Take(MaxPossibleDuplicates)
            .Select(x => new PossibleDuplicateDto(x.Task.Id, x.Task.Number, x.Task.Title, x.Task.Status.ToString()))
            .ToList();
    }

    private static readonly Regex NonWordChars = new(@"[^a-z0-9]+", RegexOptions.Compiled);

    // Common structural filler that would otherwise dominate the overlap score across most titles
    // in this project's lists without indicating a real duplicate (German articles/prepositions
    // plus the recurring "mcp"/"task(s)"/"cleanup" nouns called out in the task write-up).
    private static readonly HashSet<string> TitleStopWords = new(StringComparer.Ordinal)
    {
        "der", "die", "das", "und", "von", "auf", "mit", "fuer", "ein", "eine",
        "ist", "sind", "oder", "nicht", "mcp", "task", "tasks", "cleanup",
    };

    // Lowercases, transliterates German umlauts/ß to their digraph spelling (ä→ae, ß→ss, ...) so
    // "Prüfung" and "Pruefung" normalize to the same token, strips remaining diacritics/punctuation,
    // then drops short (<3 char) and stopword tokens.
    private static HashSet<string> NormalizeTitleWords(string title)
    {
        var s = title.ToLowerInvariant()
            .Replace("ä", "ae").Replace("ö", "oe").Replace("ü", "ue").Replace("ß", "ss");
        s = s.Normalize(NormalizationForm.FormD);

        var sb = new StringBuilder(s.Length);
        foreach (var ch in s)
            if (CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark)
                sb.Append(ch);

        var ascii = NonWordChars.Replace(sb.ToString(), " ");
        return ascii.Split(' ', StringSplitOptions.RemoveEmptyEntries)
            .Where(w => w.Length >= 3 && !TitleStopWords.Contains(w))
            .ToHashSet(StringComparer.Ordinal);
    }

    [McpServerTool, Description(
        "Update an existing task's title, description, commit type, dependsOn link, and/or manual flag. Pass " +
        "null to leave a field unchanged." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
    public async Task<TaskRefDto> UpdateTask(
        string taskId,
        string? title = null,
        string? description = null,
        string? commitType = null,
        [Description("Id of a task that must reach Done before the picker will claim this one. Pass an empty " +
                     "string to clear an existing link; null leaves it unchanged. Rejected if it doesn't exist, " +
                     "is this task's own id, or would create a dependency cycle.")]
        string? dependsOnTaskId = null,
        [Description("true: mark this task as a manual reminder only the user can complete — the queue picker, " +
                     "daily prep, and the list handler all skip it (a ConPTY session may still touch it). false: " +
                     "clear the flag. null (default): leave unchanged.")]
        bool? isManual = null,
        CancellationToken cancellationToken = default)
    {
        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
        var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        if (task.Status == TaskStatus.Running)
            throw new InvalidOperationException("Cannot update a running task. Cancel it first.");

        if (title is not null) task.Title = title;
        if (description is not null) task.Description = description;
        if (commitType is not null) task.CommitType = commitType;
        if (isManual is not null) task.IsManual = isManual.Value;
        await _tasks.UpdateAsync(task, cancellationToken);

        if (dependsOnTaskId is not null)
        {
            // Empty string is a deliberate sentinel (see the dependsOnTaskId parameter doc) to
            // clear the link — TaskIdResolver passes it through untouched, then NullIfBlank
            // below still turns it into the clear signal SetDependsOnAsync expects.
            dependsOnTaskId = await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, cancellationToken);
            var dependsResult = await _state.SetDependsOnAsync(taskId, dependsOnTaskId.NullIfBlank(), cancellationToken);
            if (!dependsResult.Ok)
                throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId.");
        }

        var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
        await _broadcaster.TaskUpdated(taskId);
        var blocked = await ComputeBlockedInfoAsync([reload], cancellationToken);
        return ToRefDto(reload, blocked[reload.Id].Blocked, blocked[reload.Id].Reason);
    }

    [McpServerTool, Description(
        "Append a subtask (step) to a task. Subtasks are surfaced to the agent at run time and shown in the " +
        "task's Steps list." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
    public async Task<TaskRefDto> AddSubtask(
        string taskId,
        string title,
        [Description("Position among the existing steps; defaults to the end.")]
        int? orderNum = null,
        CancellationToken cancellationToken = default)
    {
        if (string.IsNullOrWhiteSpace(title))
            throw new InvalidOperationException("title is required.");

        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);

        await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
        var tasks = new TaskRepository(ctx);
        var subtasks = new SubtaskRepository(ctx);

        var task = await tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        if (task.Status == TaskStatus.Running)
            throw new InvalidOperationException("Cannot add a subtask to a running task. Cancel it first.");

        var existing = await subtasks.GetByTaskIdAsync(taskId, cancellationToken);
        var order = orderNum ?? (existing.Count == 0 ? 0 : existing.Max(s => s.OrderNum) + 1);

        await subtasks.AddAsync(new SubtaskEntity
        {
            Id = Guid.NewGuid().ToString(),
            TaskId = taskId,
            Title = title.Trim(),
            Completed = false,
            OrderNum = order,
            CreatedAt = DateTime.UtcNow,
        }, cancellationToken);

        await _broadcaster.TaskUpdated(taskId);
        return ToRefDto(task);
    }

    [McpServerTool, Description(
        "Move a task between the statuses a caller may set directly. Use run_task_now for execution control and " +
        "review_task to act on a WaitingForReview task — neither is reachable from here." +
        McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint)]
    public async Task<TaskRefDto> UpdateTaskStatus(
        string taskId,
        [Description("'Idle' (reset to editable), 'Queued' (enqueue for execution), 'Cancelled' (retire without " +
                     "deleting; can be reset to Idle later) or 'Done' (mark complete; refused if the task has an " +
                     "active worktree — use review_task to approve and merge that worktree instead). 'Idle' and " +
                     "'Done' are both refused while the task is Running — cancel it first. No other value is " +
                     "settable externally.")]
        string status,
        CancellationToken cancellationToken)
    {
        if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var target))
            throw new InvalidOperationException(
                $"Unknown status '{status}'. Valid values: Idle, Queued, Running, Done, Failed, Cancelled.");

        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
        var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");

        DirtyBaseWarning? baseDirty = null;
        switch (target)
        {
            case TaskStatus.Idle:
                var idleResult = await _state.ResetToIdleAsync(taskId, cancellationToken);
                if (!idleResult.Ok)
                    throw new InvalidOperationException(idleResult.Reason ?? "Cannot reset task to Idle.");
                break;

            case TaskStatus.Queued:
                var enqueueResult = await _state.EnqueueAsync(taskId, cancellationToken);
                if (!enqueueResult.Ok)
                    throw new InvalidOperationException(enqueueResult.Reason ?? "Cannot enqueue task.");
                baseDirty = enqueueResult.BaseDirty;
                break;

            case TaskStatus.Cancelled:
                var cancelResult = await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken, allowFromIdle: true);
                if (!cancelResult.Ok)
                    throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task.");
                break;

            case TaskStatus.Done:
                if (task.Status == TaskStatus.Running)
                    throw new InvalidOperationException("Cannot mark a running task Done — cancel it first.");

                await using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken))
                {
                    var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken);
                    if (wt is not null && wt.State == WorktreeState.Active)
                        throw new InvalidOperationException(
                            "Task has an active worktree — use review_task to approve and merge instead.");
                }

                var doneResult = await _state.ForceSetStatusAsync(taskId, TaskStatus.Done, cancellationToken);
                if (!doneResult.Ok)
                    throw new InvalidOperationException(doneResult.Reason ?? "Cannot set task to Done.");
                break;

            default:
                throw new InvalidOperationException(
                    $"Status '{target}' is not settable externally. Use run_task_now or review_task.");
        }

        var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
        return ToRefDto(reload) with { BaseDirty = baseDirty };
    }

    [McpServerTool, Description(
        "Act on a task that is WaitingForReview — the only way to approve, reject or retire a reviewed run. " +
        "'approve' is review+merge, exactly like the UI's Approve: a childless task merges its worktree into " +
        "targetBranch then goes Done; a task with children drives the unit merge (parent worktree if active + each " +
        "Done child in order); a task without an active worktree approves straight to Done. Fails if the task is " +
        "not WaitingForReview (except 'cancel', which also works while Running/Queued). mergeStatus 'conflict' " +
        "means the merge stopped on conflicts, with the files listed. emptyChildren (parent approve only) lists " +
        "the Done children about to be unit-merged whose own review range contributed nothing (e.g. a child that " +
        "reported CLAUDEDO_BLOCKED and committed no code) — check it before trusting that every child actually " +
        "delivered something." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint + McpToolDocs.ProgressHint)]
    public async Task<ReviewTaskResult> ReviewTask(
        string taskId,
        [Description("'approve', 'reject_rerun', 'reject_park' or 'cancel'.")]
        string decision,
        [Description("Rejection comment. Required for 'reject_rerun', where the task goes Queued and re-runs with " +
                     "this text as the next turn of the agent's resumed session; ignored for 'reject_park', which " +
                     "just returns the task to Idle for manual editing.")]
        string? feedback = null,
        [Description("Branch an approve merges into; defaults to the repo's current branch.")]
        string? targetBranch = null,
        [Description("What an approve does when the merge hits conflicts. false (default): abort cleanly, leaving " +
                     "no half-merged state, and you resolve in the ClaudeDo UI. true: leave the conflict markers " +
                     "in the working tree (repoPath in the result) so you can resolve them and call continue_merge, " +
                     "or abort_merge to cancel.")]
        bool leaveConflictsInTree = false,
        [Description("Batch mode for an approve: merge normally but defer the list's verify command instead of " +
                     "running it after this one merge. The task then stays WaitingForReview with mergeStatus " +
                     "merged_verify_pending; after the LAST merge of the batch call verify_merges once with every " +
                     "pending task id — it runs the verify command a single time and promotes them all to Done. " +
                     "No-op when the list has no verify command.")]
        bool skipVerify = false,
        CancellationToken cancellationToken = default,
        IProgress<ProgressNotificationValue>? progress = null)
    {
        // First report fires before any git/verify work starts -- an approve that has to wait on
        // the per-repo merge gate (another merge/verify already running) must not sit silent long
        // enough to trip Claude Code's ~300s MCP idle-silence abort before RunVerifyGateAsync's own
        // periodic reports even begin.
        progress?.Report(new ProgressNotificationValue { Progress = 0, Message = "review_task started" });

        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
        var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");

        string? mergeStatus = null;
        IReadOnlyList<string> mergeConflicts = Array.Empty<string>();
        string? mergeMessage = null;
        string? repoPath = null;
        IReadOnlyList<TaskRefDto>? emptyChildren = null;

        if (decision.Trim().ToLowerInvariant() == "approve")
        {
            // Approve is the single review+merge action — mirror the hub's ApproveReview
            // routing instead of only flipping the status (which left branches unmerged).
            bool hasChildren;
            await using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken))
                hasChildren = await ctx.Tasks.AnyAsync(t => t.ParentTaskId == taskId, cancellationToken);

            if (hasChildren)
            {
                // Compute before the merge starts -- children are still Done with their own
                // pre-merge worktree/commit range at this point, so "did it contribute anything"
                // reflects the review range the reviewer is about to approve.
                emptyChildren = await GetEmptyDoneChildrenAsync(taskId, cancellationToken);
                // externallyDriven: true — this call came from an MCP session, not the UI's
                // Approve button. A unit-merge conflict must not auto-open the in-app resolver;
                // the driving session resolves it via continue_merge/abort_merge instead.
                var startResult = await _planningMerge.StartAsync(taskId, targetBranch ?? "", cancellationToken, externallyDriven: true, progress, skipVerify);
                if (startResult.Status == TaskMergeService.StatusBlocked)
                    throw new InvalidOperationException(startResult.Reason ?? "approve failed");
                mergeStatus = startResult.Status;
                if (startResult.Status == TaskMergeService.StatusConflict)
                {
                    var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
                    repoPath = list?.WorkingDir;
                    mergeMessage = "unit merge paused on a conflict — markers left in the working tree; " +
                        "resolve them then call continue_merge with the parent task id, or abort_merge to cancel";
                }
                else if (startResult.Status == TaskMergeService.StatusMergedVerifyPending)
                {
                    mergeMessage = "unit merged; verify deferred — the parent stays WaitingForReview until " +
                        "verify_merges (with this parent's id) passes";
                }
                else if (startResult.Status != TaskMergeService.StatusMerged)
                {
                    mergeMessage = startResult.Reason;
                }
            }
            else
            {
                var r = await _merge.ApproveAndMergeAsync(taskId, targetBranch ?? "", leaveConflictsInTree, cancellationToken, progress, skipVerify);
                if (r.Status == TaskMergeService.StatusBlocked)
                    throw new InvalidOperationException(r.ErrorMessage ?? "approve failed");
                mergeStatus = r.Status;
                mergeConflicts = r.ConflictFiles;
                if (r.Status == TaskMergeService.StatusMergedVerifyPending)
                {
                    mergeMessage = "merged; verify deferred — the task stays WaitingForReview until " +
                        "verify_merges (with its id) passes";
                }
                else if (r.Status == TaskMergeService.StatusConflict)
                {
                    if (leaveConflictsInTree)
                    {
                        var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
                        repoPath = list?.WorkingDir;
                        mergeMessage = "merge conflict — markers left in the working tree; " +
                            "resolve them then call continue_merge, or abort_merge to cancel";
                    }
                    else
                    {
                        mergeMessage = "merge conflict — the task stays WaitingForReview; resolve it in the ClaudeDo UI";
                    }
                }
                else
                {
                    mergeMessage = r.ErrorMessage;
                }
            }
        }
        else
        {
            TransitionResult result = decision.Trim().ToLowerInvariant() switch
            {
                "reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken),
                "reject_park"  => await _state.RejectToIdleAsync(taskId, cancellationToken),
                "cancel"       => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken),
                _ => throw new InvalidOperationException(
                    $"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."),
            };

            if (!result.Ok)
                throw new InvalidOperationException(result.Reason ?? "Review action failed.");
        }

        return new ReviewTaskResult(
            ToRefDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!),
            mergeStatus, mergeConflicts, mergeMessage, repoPath, emptyChildren);
    }

    // Done children about to be unit-merged whose own review range contributed nothing: an
    // active worktree with zero changed files against its base commit, or a worktree-less
    // handler child whose HandlerBaseCommit == HandlerHeadCommit. A child with neither (never
    // committed anything at all) also counts as empty. Best-effort per child -- a diff failure
    // on one child must not block the caller from seeing the others or from approving.
    private async Task<IReadOnlyList<TaskRefDto>> GetEmptyDoneChildrenAsync(string parentTaskId, CancellationToken ct)
    {
        await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
        var children = await ctx.Tasks
            .AsNoTracking()
            .Include(t => t.Worktree)
            .Where(t => t.ParentTaskId == parentTaskId && t.Status == TaskStatus.Done)
            .ToListAsync(ct);

        var empty = new List<TaskRefDto>();
        foreach (var child in children)
        {
            if (await IsChildEmptyAsync(child, ct))
                empty.Add(ToRefDto(child));
        }
        return empty;
    }

    private async Task<bool> IsChildEmptyAsync(TaskEntity child, CancellationToken ct)
    {
        try
        {
            if (child.Worktree is not null)
            {
                if (!Directory.Exists(child.Worktree.Path)) return false;
                var files = ParseDiffStatFileNames(
                    await _git.DiffStatAsync(child.Worktree.Path, child.Worktree.BaseCommit, "HEAD", ct: ct));
                return files.Count == 0;
            }
            if (child.HandlerBaseCommit is { Length: > 0 } handlerBase && child.HandlerHeadCommit is { Length: > 0 } handlerHead)
                return string.Equals(handlerBase, handlerHead, StringComparison.Ordinal);
            return true;
        }
        catch
        {
            return false;
        }
    }

    [McpServerTool, Description(
        "Run a task immediately in the override execution slot, bypassing the agent queue. That slot is single-" +
        "occupancy and shared with continue_task — throws \"Override slot busy\" if something else holds it; " +
        "enqueue via update_task_status instead of retrying in a loop.")]
    public async Task<RunTaskNowResult> RunTaskNow(string taskId, CancellationToken cancellationToken)
    {
        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
        try
        {
            await _queue.RunNow(taskId);
        }
        catch (InvalidOperationException)
        {
            throw new InvalidOperationException("Override slot busy. Try again later.");
        }
        catch (KeyNotFoundException)
        {
            throw new InvalidOperationException($"Task {taskId} not found.");
        }
        await _broadcaster.TaskUpdated(taskId);

        // Heads-up only, same as update_task_status's Queued path: a worktree forks from the
        // commit tip, not the working tree, so uncommitted changes in the list's repo right now
        // won't be included.
        var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
        var list = task is not null ? await _lists.GetByIdAsync(task.ListId, cancellationToken) : null;
        var baseDirty = await _baseDirtyChecker.CheckAsync(list?.WorkingDir, cancellationToken);

        return new RunTaskNowResult(true, taskId, baseDirty, task?.Number);
    }

    [McpServerTool, Description(
        "Cancel a running task, killing its agent process. cancelled=false means the task was not running.")]
    public async Task<CancelTaskResult> CancelTask(string taskId, CancellationToken cancellationToken)
    {
        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
        var cancelled = _queue.CancelTask(taskId);
        if (cancelled) await _broadcaster.TaskUpdated(taskId);
        var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
        return new CancelTaskResult(cancelled, taskId, task?.Number);
    }

    [McpServerTool, Description(
        "Delete a task permanently. Prefer update_task_status 'Cancelled' to retire a task you may want back." +
        McpToolDocs.NotWhileRunning)]
    public async Task<DeleteTaskResult> DeleteTask(string taskId, CancellationToken cancellationToken)
    {
        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
        var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        if (task.Status == TaskStatus.Running)
            throw new InvalidOperationException("Cannot delete a running task. Cancel it first.");

        try
        {
            await _tasks.DeleteAsync(taskId, cancellationToken);
        }
        // TaskRepository.DeleteAsync uses ExecuteDeleteAsync, which bypasses SaveChanges and
        // surfaces provider errors directly as SqliteException rather than DbUpdateException.
        catch (Exception ex) when (
            (ex is Microsoft.Data.Sqlite.SqliteException || ex.InnerException is Microsoft.Data.Sqlite.SqliteException)
            && (ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
                || ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true))
        {
            throw new InvalidOperationException(
                "This task has child tasks. Discard the planning session or delete child tasks first.");
        }

        if (task.ParentTaskId is not null)
            await _state.TryAdvanceParentAsync(task.ParentTaskId);
        await _broadcaster.TaskUpdated(taskId);
        return new DeleteTaskResult(true, taskId, task.Number);
    }

    // ── Worktree / git tools ──────────────────────────────────────────────────

    [McpServerTool, Description(
        "Get a task's git worktree state — path, branch, base/head commit, ahead/behind counts, isDirty, and the " +
        "mergeCommit its branch produced once merged. behind is 0 when the 'main' ref is unreachable, so do not " +
        "read 0 as \"up to date\" without checking. A null mergeCommit means revert_merge cannot act on this task. " +
        "Throws if the task or its worktree does not exist.")]
    public async Task<WorktreeInfoDto> GetTaskWorktree(string taskId, CancellationToken cancellationToken)
    {
        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
        var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken);

        var headCommit = !string.IsNullOrWhiteSpace(wt.HeadCommit)
            ? wt.HeadCommit
            : await TryRunGitAsync(wt.Path, ["rev-parse", "HEAD"], cancellationToken) ?? wt.BaseCommit;

        var isDirty = Directory.Exists(wt.Path) && await _git.HasChangesAsync(wt.Path, cancellationToken);
        var ahead  = await GitRevListCountAsync(wt.Path, $"{wt.BaseCommit}..HEAD", cancellationToken);
        var behind = await GitRevListCountAsync(wt.Path, "HEAD..main", cancellationToken);

        return new WorktreeInfoDto(wt.Path, wt.BranchName, headCommit!, wt.BaseCommit, ahead, behind, isDirty, wt.MergeCommit);
    }

    [McpServerTool, Description(
        "Read what a task actually changed — the diff of its worktree against its base commit (for a worktree-less " +
        "list-handler host task, the fixed HandlerBaseCommit..HandlerHeadCommit range over the list's working dir " +
        "instead). files lists the changed paths in either mode; truncated=true means the diff was capped and " +
        "totalBytes holds its real size. Throws if the task has no worktree/review range, or the relevant " +
        "directory is missing from disk.")]
    public async Task<TaskDiffDto> GetTaskDiff(
        string taskId,
        [Description("false (default): the full unified diff, capped at 200 KB. true: a --stat summary with " +
                     "per-file insertion/deletion counts — start here when the diff may be large.")]
        bool stat = false,
        [Description("Restrict the diff to these paths (relative to the repo root), e.g. after a --stat pass or a " +
                     "conflict report already narrowed down which files matter. Omit/empty for every changed file " +
                     "— the default and the only prior behavior. Works in both stat and full-diff mode.")]
        IReadOnlyList<string>? paths = null,
        CancellationToken cancellationToken = default,
        IProgress<ProgressNotificationValue>? progress = null)
    {
        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
        var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken);

        const int maxBytes = 200 * 1024;

        if (stat)
        {
            var diffStat = await ProgressReporter.RunAsync(
                _git.DiffStatAsync(repoPath, baseCommit, headCommit ?? "HEAD", paths, cancellationToken),
                ProgressReportInterval, progress, "computing diff stat");
            return new TaskDiffDto(diffStat, ParseDiffStatFileNames(diffStat), false, diffStat.Length);
        }

        var diff = headCommit is null
            ? await ProgressReporter.RunAsync(
                _git.GetBranchDiffAsync(repoPath, baseCommit, paths, cancellationToken), ProgressReportInterval, progress, "computing diff")
            : await ProgressReporter.RunAsync(
                _git.GetCommitRangeDiffAsync(repoPath, baseCommit, headCommit, paths, cancellationToken), ProgressReportInterval, progress, "computing diff");
        var files = ParseDiffFileNames(diff);

        if (diff.Length <= maxBytes)
            return new TaskDiffDto(diff, files, false, diff.Length);

        return new TaskDiffDto(diff[..maxBytes], files, true, diff.Length);
    }

    // Resolves where a task's diff lives: a live worktree (repo path = worktree path, diffed
    // against HEAD) or, for a worktree-less list-handler host task, the fixed
    // HandlerBaseCommit..HandlerHeadCommit range over the list's working dir (headCommit
    // non-null signals "fixed range" to the caller instead of "diff against live HEAD").
    private async Task<(string RepoPath, string BaseCommit, string? HeadCommit)> LoadDiffRangeAsync(
        string taskId, CancellationToken ct)
    {
        using var ctx = _dbFactory.CreateDbContext();
        var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);

        if (wt is not null)
        {
            if (!Directory.Exists(wt.Path))
                throw new InvalidOperationException($"Worktree directory does not exist on disk: {wt.Path}");
            return (wt.Path, wt.BaseCommit, null);
        }

        if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead)
        {
            var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
                ?? throw new InvalidOperationException("List not found.");
            if (string.IsNullOrEmpty(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
                throw new InvalidOperationException("The list's working directory no longer exists.");
            return (list.WorkingDir, handlerBase, handlerHead);
        }

        throw new InvalidOperationException($"Task {taskId} has no worktree.");
    }

    [McpServerTool, Description(
        "Merge a Done task's worktree branch into targetBranch. For a task still in WaitingForReview prefer " +
        "review_task, which merges as part of approving. merged=true carries the new mergeCommit SHA; on conflict " +
        "merged=false and conflicts lists the affected files." + McpToolDocs.ProgressHint)]
    public async Task<MergeTaskResultDto> MergeTask(
        string taskId,
        string targetBranch = "main",
        [Description("true (default): always create a merge commit (--no-ff).")]
        bool noFf = true,
        [Description("true: validate preconditions only and do not merge — merged=false then means \"not attempted\".")]
        bool dryRun = false,
        [Description("true: also allow merging a task in WaitingForReview; false (default) allows Done only.")]
        bool allowWaitingForReview = false,
        [Description("What to do on conflict. false (default): abort cleanly, leaving no half-merged state. true: " +
                     "leave the conflict markers in the working tree at repoPath (conflictsInTree=true) so you can " +
                     "resolve them there and call continue_merge, or abort_merge to cancel.")]
        bool leaveConflictsInTree = false,
        [Description("Batch mode: defer the list's verify command instead of running it after this one merge " +
                     "(see review_task's skipVerify). Call verify_merges once after the batch.")]
        bool skipVerify = false,
        CancellationToken cancellationToken = default,
        IProgress<ProgressNotificationValue>? progress = null)
    {
        progress?.Report(new ProgressNotificationValue { Progress = 0, Message = "merge_task started" });

        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
        var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        var canMerge = task.Status == TaskStatus.Done ||
                       (allowWaitingForReview && task.Status == TaskStatus.WaitingForReview);
        if (!canMerge)
            throw new InvalidOperationException(
                $"Task must be Done to merge (current status: {task.Status}). " +
                "Pass allowWaitingForReview=true to also merge a WaitingForReview task.");

        var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);

        if (dryRun)
        {
            using var ctx = _dbFactory.CreateDbContext();
            var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken)
                ?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
            if (wt.State != WorktreeState.Active)
                throw new InvalidOperationException(
                    $"Worktree state must be Active to merge (current: {wt.State}).");
            return new MergeTaskResultDto(false, null, []);
        }

        // Blank on purpose: TaskMergeService builds the conventional default message.
        var result = await _merge.MergeAsync(
            taskId, targetBranch, removeWorktree: false, commitMessage: "", leaveConflictsInTree, cancellationToken, progress, skipVerify);

        // merged_verify_pending: the branch IS merged (that's what this tool reports on) — only
        // the task's own Done transition is deferred to verify_merges.
        if (result.Status is TaskMergeService.StatusMerged or TaskMergeService.StatusMergedVerifyPending)
        {
            string? mergeCommit = null;
            try
            {
                if (!string.IsNullOrWhiteSpace(list?.WorkingDir) && Directory.Exists(list.WorkingDir))
                    mergeCommit = await _git.RevParseHeadAsync(list.WorkingDir, cancellationToken);
            }
            catch { /* mergeCommit is optional */ }
            return new MergeTaskResultDto(true, mergeCommit, []);
        }

        if (result.Status == TaskMergeService.StatusConflict)
            return leaveConflictsInTree
                ? new MergeTaskResultDto(false, null, result.ConflictFiles,
                    ConflictsInTree: true, RepoPath: list?.WorkingDir)
                : new MergeTaskResultDto(false, null, result.ConflictFiles);

        throw new InvalidOperationException(result.ErrorMessage ?? $"Merge blocked: {result.Status}");
    }

    [McpServerTool, Description(
        "Finish an in-progress conflicted merge once you have resolved the conflict markers in the working tree " +
        "(repoPath from merge_task/review_task). Pass the PARENT task id to continue a parent/children unit merge. " +
        "merged=false with conflicts listed means markers are still present — resolve them and call again. " +
        "Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead." +
        McpToolDocs.ProgressHint)]
    public async Task<MergeContinuationResultDto> ContinueMerge(
        string taskId,
        [Description("Batch mode: defer the list's verify command (see review_task's skipVerify). Pass the same " +
                     "value you approved with — a unit merge remembers its own flag and ignores this one.")]
        bool skipVerify = false,
        CancellationToken cancellationToken = default,
        IProgress<ProgressNotificationValue>? progress = null)
    {
        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
        var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
        var workingDir = list?.WorkingDir;

        bool merged;
        IReadOnlyList<string> conflicts = Array.Empty<string>();
        string? repoPath = null;
        string? message = null;

        if (_planningMerge.HasActiveMerge(taskId))
        {
            var unitResult = await _planningMerge.ContinueAsync(taskId, cancellationToken, progress);
            var parent = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
            if (unitResult.Status == TaskMergeService.StatusMergedVerifyPending)
            {
                merged = true;
                message = "unit merged; verify deferred — the parent stays WaitingForReview until " +
                    "verify_merges (with this parent's id) passes";
            }
            else if (parent.Status == TaskStatus.Done)
            {
                merged = true;
            }
            else
            {
                var remaining = !string.IsNullOrWhiteSpace(workingDir)
                    ? await _git.ListConflictedFilesAsync(workingDir, cancellationToken)
                    : new List<string>();
                merged = false;
                if (remaining.Count > 0)
                {
                    conflicts = remaining;
                    repoPath = workingDir;
                    message = "conflicts remain — resolve and call continue_merge again";
                }
                else
                {
                    message = "unit merge did not complete — the orchestrator aborted or was blocked; " +
                        "check the parent task and approve again to restart the merge";
                }
            }
        }
        else
        {
            var r = await _merge.ContinueMergeAsync(taskId, cancellationToken, progress, skipVerify);
            if (r.Status == TaskMergeService.StatusMerged)
            {
                merged = true;
            }
            else if (r.Status == TaskMergeService.StatusMergedVerifyPending)
            {
                merged = true;
                message = "merged; verify deferred — the task stays WaitingForReview until " +
                    "verify_merges (with its id) passes";
            }
            else if (r.Status == TaskMergeService.StatusConflict)
            {
                merged = false;
                conflicts = r.ConflictFiles;
                repoPath = workingDir;
                message = r.ErrorMessage;
            }
            else
            {
                throw new InvalidOperationException(r.ErrorMessage ?? "continue failed");
            }
        }

        var reloaded = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
        await _broadcaster.TaskUpdated(taskId);
        return new MergeContinuationResultDto(merged, reloaded.Status.ToString(), conflicts, repoPath, message);
    }

    [McpServerTool, Description(
        "Run the list's verify command ONCE for a batch of merges made with skipVerify=true, then promote the " +
        "listed tasks to Done. Call it a single time after the LAST merge of the batch instead of paying the " +
        "verify per merge. All tasks must belong to the same list; each must be WaitingForReview with its merge " +
        "already landed (mergeStatus merged_verify_pending). status verify_passed: check each entry's promoted " +
        "flag (an ineligible task is skipped with a reason, the rest still promote). status verify_failed: the " +
        "merges stay in place but NO task reaches Done; the command's output tail is in errorMessage — report it, " +
        "don't fix it yourself. A list without a verify command promotes directly." + McpToolDocs.ProgressHint)]
    public async Task<ListVerifyResult> VerifyMerges(
        [Description("Every task merged with skipVerify=true in this batch (accepts #numbers or ids).")]
        string[] taskIds,
        CancellationToken cancellationToken = default,
        IProgress<ProgressNotificationValue>? progress = null)
    {
        if (taskIds.Length == 0)
            throw new InvalidOperationException("taskIds must not be empty.");
        // Same rationale as review_task's first report: the verify itself reports periodically,
        // but the wait on the per-repo merge gate before it would otherwise sit silent.
        progress?.Report(new ProgressNotificationValue { Progress = 0, Message = "verify_merges started" });

        string? listId = null;
        var resolved = new List<string>(taskIds.Length);
        foreach (var raw in taskIds)
        {
            var id = await TaskIdResolver.ResolveAsync(_tasks, raw, cancellationToken);
            var task = await _tasks.GetByIdAsync(id, cancellationToken)
                ?? throw new InvalidOperationException($"Task {raw} not found.");
            listId ??= task.ListId;
            if (task.ListId != listId)
                throw new InvalidOperationException(
                    "All tasks must belong to the same list — one verify command gates one repo.");
            resolved.Add(id);
        }

        var result = await _merge.RunListVerifyAsync(listId!, resolved, cancellationToken, progress);
        if (result.Status == TaskMergeService.StatusBlocked)
            throw new InvalidOperationException(result.ErrorMessage ?? "verify blocked");
        return result;
    }

    [McpServerTool, Description(
        "Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " +
        "Pass the PARENT task id to abort a parent/children unit merge. The task keeps its pre-merge status " +
        "(e.g. WaitingForReview). Throws if there is no in-progress merge for the task." + McpToolDocs.LeanTaskRef)]
    public async Task<TaskRefDto> AbortMerge(string taskId, CancellationToken cancellationToken)
    {
        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
        _ = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");

        if (_planningMerge.HasActiveMerge(taskId))
        {
            await _planningMerge.AbortAsync(taskId, cancellationToken);
        }
        else
        {
            var r = await _merge.AbortMergeAsync(taskId, cancellationToken);
            if (r.Status == TaskMergeService.StatusBlocked)
                throw new InvalidOperationException(r.ErrorMessage ?? "abort failed");
        }

        await _broadcaster.TaskUpdated(taskId);
        return ToRefDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
    }

    [McpServerTool, Description(
        "List every conflict hunk left in a paused merge (started via merge_task/review_task with " +
        "leaveConflictsInTree=true), so you can resolve them with resolve_conflict_hunk instead of parsing marker " +
        "text yourself. Each hunk carries the file's path, its index within that file (what resolve_conflict_hunk " +
        "expects back), and its startLine. remainingHunks is the total across every file — call continue_merge once " +
        "it reaches 0, or abort_merge to cancel. Throws if the task has no in-progress merge." + " " + McpToolDocs.Diff3Note)]
    public async Task<GetMergeConflictsResultDto> GetMergeConflicts(string taskId, CancellationToken cancellationToken = default)
    {
        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
        _ = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");

        var docs = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
        var files = docs.Files.Select(ToConflictFileHunksDto).ToList();
        var remaining = files.Sum(f => f.Hunks.Count);
        return new GetMergeConflictsResultDto(files, remaining);
    }

    [McpServerTool, Description(
        "Resolve exactly one conflict hunk from get_merge_conflicts, identified by file path and its index within " +
        "that file. Writes only that hunk — every other hunk (in this file or another) is left exactly as it was, " +
        "so call get_merge_conflicts again afterward: resolved hunks disappear and every remaining hunk's index " +
        "shifts down to stay zero-based. This never decides FOR you which side wins — resolution must be 'ours', " +
        "'theirs', 'base' (only valid when that hunk has a diff3 base), or literal replacement text (used exactly " +
        "as given, including any trailing newline the surrounding file needs). remainingHunks is the total across " +
        "every file; call continue_merge once it reaches 0. Throws if the file/index don't match a current hunk." +
        " " + McpToolDocs.Diff3Note)]
    public async Task<ResolveConflictHunkResultDto> ResolveConflictHunk(
        string taskId,
        [Description("File path exactly as returned by get_merge_conflicts, relative to the repo root.")]
        string file,
        [Description("Zero-based hunk index within that file, as returned by get_merge_conflicts.")]
        int index,
        [Description("'ours', 'theirs', 'base', or literal text to use for this hunk.")]
        string resolution,
        CancellationToken cancellationToken = default)
    {
        if (string.IsNullOrEmpty(resolution))
            throw new InvalidOperationException("resolution is required.");

        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
        var docs = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
        var doc = docs.Files.FirstOrDefault(f => string.Equals(f.Path, file, StringComparison.Ordinal))
            ?? throw new InvalidOperationException($"File '{file}' has no conflict for task {taskId}.");
        if (doc.IsBinary)
            throw new InvalidOperationException($"File '{file}' is binary; resolve it directly in the working tree.");

        var hunkCount = doc.Segments.Count(s => s.IsConflict);
        if (index < 0 || index >= hunkCount)
            throw new InvalidOperationException(
                $"Hunk index {index} is out of range for '{file}' ({hunkCount} hunk(s) remaining).");

        var counter = -1;
        var composed = ConflictMarkerParser.Compose(doc.Segments, seg =>
        {
            counter++;
            if (counter != index) return ReconstructConflictMarkers(seg);

            var choice = resolution.Trim().ToLowerInvariant();
            return choice switch
            {
                "ours" => seg.Ours,
                "theirs" => seg.Theirs,
                "base" => seg.Base ?? throw new InvalidOperationException(
                    $"Hunk {index} in '{file}' has no diff3 base to resolve to."),
                _ => resolution,
            };
        });

        await _merge.WriteConflictFileAsync(taskId, file, composed, cancellationToken);

        var refreshed = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
        var remaining = refreshed.Files.Sum(f => f.Segments.Count(s => s.IsConflict));
        return new ResolveConflictHunkResultDto(true, remaining);
    }

    private static ConflictFileHunksDto ToConflictFileHunksDto(ConflictDocumentContent f)
    {
        var hunks = new List<ConflictHunkDto>();
        foreach (var seg in f.Segments)
        {
            if (!seg.IsConflict) continue;
            hunks.Add(new ConflictHunkDto(hunks.Count, seg.StartLine, seg.Ours, seg.Base, seg.Theirs));
        }
        return new ConflictFileHunksDto(f.Path, f.IsBinary, hunks);
    }

    // Re-renders a still-unresolved hunk as valid (unlabeled) conflict markers so it round-trips through
    // ConflictMarkerParser.Parse again untouched, and git still refuses continue_merge while it stands. The
    // original marker labels (e.g. "<<<<<<< HEAD") aren't recoverable from a parsed MergeSegment and aren't
    // needed for either check -- ConflictMarkerParser.IsMarker and ContinueMergeAsync's rescan only match the
    // marker prefixes.
    private static string ReconstructConflictMarkers(MergeSegment seg)
    {
        var sb = new StringBuilder();
        sb.Append("<<<<<<<\n").Append(seg.Ours);
        if (seg.Base is not null)
            sb.Append("|||||||\n").Append(seg.Base);
        sb.Append("=======\n").Append(seg.Theirs);
        sb.Append(">>>>>>>\n");
        return sb.ToString();
    }

    [McpServerTool, Description(
        "Check whether a task would merge cleanly before touching anything — `git merge-tree --write-tree`, so the " +
        "working tree, index and HEAD are untouched. status is 'clean', 'conflict' (conflictFiles lists where git " +
        "would stop), or 'untracked_collision' (conflictFiles lists a path the branch adds that already exists, " +
        "untracked, in the target working directory — merge-tree can't see the working tree at all, so this is the " +
        "only way to catch it before a real merge either refuses or, if that path became tracked in the meantime, " +
        "silently overwrites it); behind counts commits on targetBranch not yet on this branch, which flags a stale " +
        "branch even when the preview is clean. If the list has a verify command configured, a clean preview is " +
        "additionally built/tested in a scratch worktree (still without touching the real working tree) — " +
        "verifyExitCode 0 means it built clean, non-zero or -1 (timeout/failed to start) means it doesn't, with the " +
        "tail of its output in verifyOutputTail; verifyExitCode stays null when no verify command is configured. " +
        "isEmpty=true means the task's review range contributed nothing; check that flag rather than reading a " +
        "small changedFileCount as empty. staleFiles lists files this branch touches that the target branch ALSO " +
        "changed since this branch's fork point — a more honest staleness signal than `behind` alone, since a " +
        "branch can be far behind yet touch nothing the target changed, or barely behind yet collide on the one " +
        "file that matters (always empty for a worktree-less handler task, which has no fork point). Throws if the " +
        "task has neither an active worktree nor a handler commit range, or the list's working directory is missing " +
        "from disk." + McpToolDocs.ProgressHint)]
    public async Task<MergePreviewToolDto> PreviewMerge(
        string taskId,
        [Description("Branch to preview against; defaults to the repo's current branch.")]
        string? targetBranch = null,
        CancellationToken cancellationToken = default,
        IProgress<ProgressNotificationValue>? progress = null)
    {
        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
        var (preview, behind, _, isEmpty, staleFiles, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken, progress);
        return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty,
            preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles);
    }

    [McpServerTool, Description(
        "Plan a batch merge: preview_merge for several tasks against the same targetBranch, plus a file-overlap " +
        "check between them. Per entry you get preview_merge's fields, or error instead when that task could not " +
        "be previewed (it is then left out of the overlap/subset computation). overlaps names, for each file " +
        "touched by MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields no " +
        "overlaps. subsets flags a stronger relation: TaskId's changed files are a PROPER subset of " +
        "SupersetTaskId's — the strongest hint you get post-hoc that TaskId may be redundant with SupersetTaskId, " +
        "worth checking before merging both. IMPORTANT: neither overlap nor subset is a safety guarantee — two " +
        "tasks touching entirely different files (one deleting a symbol, another still referencing it) can still " +
        "collide unflagged, and as with preview_merge a clean result does not mean the merge builds. " +
        "runVerify=false (default) never builds — set it true to also run each task's list's verify command in a " +
        "scratch worktree per entry (same fields as preview_merge); this can take a long time across many tasks, " +
        "since builds run one at a time." + McpToolDocs.ProgressHint)]
    public async Task<MergePreviewSetResultDto> PreviewMergeSet(
        IReadOnlyList<string> taskIds,
        [Description("Branch to preview every task against; defaults to the repo's current branch.")]
        string? targetBranch = null,
        [Description("true: also run the verify command (if configured) for each task, one build at a time. " +
                     "false (default): no builds, however many tasks are given.")]
        bool runVerify = false,
        CancellationToken cancellationToken = default,
        IProgress<ProgressNotificationValue>? progress = null)
    {
        if (taskIds is null || taskIds.Count == 0)
            throw new InvalidOperationException("taskIds must contain at least one task id.");

        taskIds = await TaskIdResolver.ResolveManyAsync(_tasks, taskIds, cancellationToken);

        var entries = new List<MergePreviewSetEntryDto>();
        var filesByTask = new Dictionary<string, IReadOnlyList<string>>();
        var numbersByTask = new Dictionary<string, int>();

        for (var i = 0; i < taskIds.Count; i++)
        {
            var taskId = taskIds[i];
            try
            {
                var (preview, behind, changedFiles, isEmpty, staleFiles, number) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
                entries.Add(new MergePreviewSetEntryDto(
                    taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty,
                    preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number));
                filesByTask[taskId] = changedFiles;
                numbersByTask[taskId] = number;
            }
            catch (InvalidOperationException ex)
            {
                var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
                entries.Add(new MergePreviewSetEntryDto(
                    taskId, TaskMergeService.PreviewUnavailable, Array.Empty<string>(), 0, 0, ex.Message,
                    Number: task?.Number));
            }

            ProgressReporter.ReportItem(progress, "Previewing merges", i + 1, taskIds.Count);
        }

        var overlaps = filesByTask
            .SelectMany(kv => kv.Value.Select(f => (File: f, TaskId: kv.Key)))
            .GroupBy(x => x.File, StringComparer.OrdinalIgnoreCase)
            .Where(g => g.Select(x => x.TaskId).Distinct().Count() > 1)
            .Select(g =>
            {
                var ids = g.Select(x => x.TaskId).Distinct().ToList();
                return new FileOverlapDto(g.Key, ids, ids.Select(id => numbersByTask[id]).ToList());
            })
            .OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase)
            .ToList();

        var subsets = FindSubsetRelations(filesByTask, numbersByTask);

        return new MergePreviewSetResultDto(entries, overlaps, subsets);
    }

    // A task's changed-file set is flagged only against another task's STRICTLY LARGER set that
    // contains every one of its files -- an empty set (isEmpty task) never qualifies, since that
    // case is already surfaced via IsEmpty and would otherwise match every other task trivially.
    private static List<SubsetRelationDto> FindSubsetRelations(
        Dictionary<string, IReadOnlyList<string>> filesByTask, Dictionary<string, int> numbersByTask)
    {
        var fileSets = filesByTask.ToDictionary(
            kv => kv.Key,
            kv => new HashSet<string>(kv.Value, StringComparer.OrdinalIgnoreCase));

        var subsets = new List<SubsetRelationDto>();
        foreach (var (taskId, fileSet) in fileSets)
        {
            if (fileSet.Count == 0) continue;
            foreach (var (otherTaskId, otherFileSet) in fileSets)
            {
                if (otherTaskId == taskId || otherFileSet.Count <= fileSet.Count) continue;
                if (fileSet.IsSubsetOf(otherFileSet))
                    subsets.Add(new SubsetRelationDto(taskId, numbersByTask[taskId], otherTaskId, numbersByTask[otherTaskId]));
            }
        }

        return subsets
            .OrderBy(s => s.TaskId, StringComparer.OrdinalIgnoreCase)
            .ThenBy(s => s.SupersetTaskId, StringComparer.OrdinalIgnoreCase)
            .ToList();
    }

    // Shared core for PreviewMerge/PreviewMergeSet: throws a clear InvalidOperationException instead of
    // TaskMergeService.PreviewAsync's silent "unavailable" status, and adds `behind` + the task's own
    // changed-file list (via diff-stat, not the merge-tree preview) for overlap detection, plus
    // `isEmpty`. A worktree-less list-handler host task has no branch to merge-tree-preview at all
    // (its commits already sit on the list's working dir) — falls back to the fixed
    // HandlerBaseCommit..HandlerHeadCommit range, reporting a synthetic "clean" preview of that
    // range's own diff-stat instead of throwing "has no worktree".
    private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles, int Number)> PreviewMergeCoreAsync(
        string taskId, string? targetBranch, bool runVerify, CancellationToken ct,
        IProgress<ProgressNotificationValue>? progress = null)
    {
        using var ctx = _dbFactory.CreateDbContext();
        var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
            ?? throw new InvalidOperationException("List not found.");
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);

        if (wt is not null)
        {
            if (wt.State != WorktreeState.Active)
                throw new InvalidOperationException(
                    $"Worktree state must be Active to preview a merge (current: {wt.State}).");
            if (string.IsNullOrWhiteSpace(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
                throw new InvalidOperationException("The list's working directory no longer exists.");

            var preview = await _merge.PreviewAsync(taskId, targetBranch ?? "", runVerify, ct, progress);
            if (preview.Status == TaskMergeService.PreviewUnavailable)
                throw new InvalidOperationException(
                    "Merge preview unavailable for this task (worktree inactive or repo is not a git repository).");

            var target = string.IsNullOrWhiteSpace(targetBranch)
                ? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
                : targetBranch;
            var behind = await GitRevListCountAsync(list.WorkingDir, $"{wt.BranchName}..{target}", ct);

            var changedFiles = Directory.Exists(wt.Path)
                ? ParseDiffStatFileNames(await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", ct: ct))
                : Array.Empty<string>();

            // What the target itself picked up since this branch's fork point, so `behind`
            // (a commit count) doesn't have to stand in for "does this collide" -- a branch can
            // be far behind but touch nothing the target changed, or close behind and collide on
            // the one file that matters.
            var targetChangedSinceFork = await _git.GetChangedFileNamesAsync(list.WorkingDir, wt.BaseCommit, target, ct);
            var staleFiles = changedFiles
                .Intersect(targetChangedSinceFork, StringComparer.OrdinalIgnoreCase)
                .ToList();

            return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number);
        }

        if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead)
        {
            if (string.IsNullOrWhiteSpace(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
                throw new InvalidOperationException("The list's working directory no longer exists.");

            var isEmpty = string.Equals(handlerBase, handlerHead, StringComparison.Ordinal);
            var changedFiles = isEmpty
                ? Array.Empty<string>()
                : ParseDiffStatFileNames(await _git.DiffStatAsync(list.WorkingDir, handlerBase, handlerHead, ct: ct));

            var preview = new MergePreviewResult(TaskMergeService.PreviewClean, Array.Empty<string>(), changedFiles.Count);
            // No fork point to diff against: a handler task commits straight onto the list's
            // working dir instead of a branch, so there is nothing else that could have "changed
            // in the target since the fork".
            return (preview, 0, changedFiles, isEmpty, Array.Empty<string>(), task.Number);
        }

        throw new InvalidOperationException($"Task {taskId} has no worktree.");
    }

    [McpServerTool, Description(
        "Undo a merged task by reverting its merge commit — `git revert -m 1`, always a new commit and never a " +
        "reset/rewrite, since the target working directory is shared with other sessions. Requires the task to be " +
        "Done with a Merged worktree that has a recorded merge commit (check get_task_worktree's mergeCommit " +
        "first). On success the task returns to WaitingForReview so it can be reconsidered. On conflict the revert " +
        "is aborted immediately and conflicts lists the files. Throws if there is no recorded merge commit, the " +
        "repo is mid-merge/mid-revert, or the target working tree has uncommitted changes from another session.")]
    public async Task<RevertMergeResultDto> RevertMerge(
        string taskId,
        [Description("Branch carrying the merge commit; defaults to main.")]
        string targetBranch = "main",
        CancellationToken cancellationToken = default)
    {
        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
        var result = await _merge.RevertMergeAsync(taskId, targetBranch, cancellationToken);

        if (result.Status == TaskMergeService.StatusReverted)
        {
            await _broadcaster.TaskUpdated(taskId);
            return new RevertMergeResultDto(true, result.RevertCommit, Array.Empty<string>(), null);
        }

        if (result.Status == TaskMergeService.StatusConflictAborted)
            return new RevertMergeResultDto(false, null, result.ConflictFiles, result.ErrorMessage);

        throw new InvalidOperationException(result.ErrorMessage ?? $"Revert blocked: {result.Status}");
    }

    [McpServerTool, Description(
        "Survey every worktree ClaudeDo tracks — use it to find leftovers to clean up. Only worktrees recorded in " +
        "the ClaudeDo database appear here, and headCommit is empty when the path is missing from disk." +
        McpToolDocs.ProgressHint)]
    public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees(
        CancellationToken cancellationToken = default,
        IProgress<ProgressNotificationValue>? progress = null)
    {
        var rows = await _maintenance.GetOverviewAsync(null, cancellationToken);
        // One git status + one rev-parse per row, run concurrently -- with many tracked worktrees
        // (this tool's whole point is surfacing leftovers nobody cleaned up) that can still take a
        // while, so this reports on the same elapsed-time cadence as a single long git call rather
        // than per-row (rows finish out of order under Task.WhenAll, so there's no natural i/n).
        var results = await ProgressReporter.RunAsync(
            Task.WhenAll(rows.Select(async row =>
            {
                var isDirty = row.PathExistsOnDisk && await TryGetIsDirtyAsync(row.Path, cancellationToken);
                var headCommit = row.PathExistsOnDisk
                    ? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "")
                    : "";
                return new WorktreeListItemDto(
                    row.TaskId, row.TaskNumber, row.Path, row.BranchName, headCommit,
                    isDirty, row.State == WorktreeState.Merged);
            })),
            ProgressReportInterval, progress, "surveying worktrees");
        return results;
    }

    [McpServerTool, Description(
        "Remove a task's worktree directory and delete its git branch. branchDeleted reports whether the branch " +
        "went too." + McpToolDocs.NotWhileRunning)]
    public async Task<CleanupWorktreeResult> CleanupTaskWorktree(
        string taskId,
        [Description("false (default): refuse a worktree with uncommitted changes. true: remove it anyway, losing " +
                     "those changes.")]
        bool force = false,
        CancellationToken cancellationToken = default,
        IProgress<ProgressNotificationValue>? progress = null)
    {
        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);

        using var ctx = _dbFactory.CreateDbContext();
        var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} has no worktree.");

        if (task.Status == TaskStatus.Running)
            throw new InvalidOperationException("Cannot remove worktree of a running task.");

        if (!force && Directory.Exists(wt.Path))
        {
            var isDirty = await ProgressReporter.RunAsync(
                _git.HasChangesAsync(wt.Path, cancellationToken), ProgressReportInterval, progress, "checking worktree for changes");
            if (isDirty)
                throw new InvalidOperationException(
                    "Worktree has uncommitted changes. Use force=true to remove anyway (changes will be lost).");
        }

        var path = wt.Path;
        var result = await ProgressReporter.RunAsync(
            _maintenance.ForceRemoveAsync(taskId, cancellationToken), ProgressReportInterval, progress, "removing worktree");
        return new CleanupWorktreeResult(result.Removed, path, result.BranchDeleted, task.Number);
    }

    [McpServerTool, Description(
        "Send a follow-up prompt to a task's existing Claude session instead of starting a fresh run — the agent " +
        "resumes via --resume with the session id from the task's last run, so its prior context is kept. Uses the " +
        "same single-occupancy override slot as run_task_now and throws \"Override slot busy\" when that is taken.")]
    public async Task<string> ContinueTask(
        string taskId,
        string followUpPrompt,
        CancellationToken cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(followUpPrompt))
            throw new InvalidOperationException("followUpPrompt is required.");

        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);

        string result;
        try
        {
            result = await _queue.ContinueTask(taskId, followUpPrompt);
        }
        catch (InvalidOperationException)
        {
            throw new InvalidOperationException("Override slot busy. Try again later.");
        }
        catch (KeyNotFoundException)
        {
            throw new InvalidOperationException($"Task {taskId} not found.");
        }
        await _broadcaster.TaskUpdated(taskId);
        return result;
    }

    // ── Daily prep ───────────────────────────────────────────────────────────

    [McpServerTool, Description(
        "Daily prep: the open tasks eligible for today's MyDay selection. candidates are Idle, unblocked, " +
        "non-manual and in a git repo not excluded from the weekly report; currentMyDay are Idle tasks already " +
        "flagged and count toward maxTasks, the hard cap on open MyDay tasks. Add your picks with set_my_day and " +
        "never exceed maxTasks.")]
    public async Task<DailyPrepDataDto> GetDailyPrepCandidates(CancellationToken cancellationToken)
    {
        await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);

        var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
        var excludes = DailyPrepFilter.ParseExcludes(settings.ReportExcludedPaths);
        var maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;

        var idle = await ctx.Tasks
            .AsNoTracking()
            .Include(t => t.List)
            .Where(t => t.Status == TaskStatus.Idle)
            .ToListAsync(cancellationToken);

        var currentMyDay = idle
            .Where(t => t.IsMyDay)
            .OrderBy(t => t.SortOrder)
            .Select(ToCandidate)
            .ToList();

        var candidates = idle
            .Where(t => !t.IsMyDay
                        && t.BlockedByTaskId == null
                        // A manual task is a reminder only the user can do — never a Claude candidate.
                        && !t.IsManual
                        && DailyPrepFilter.IsIncludedRepo(t.List?.WorkingDir, excludes))
            .OrderBy(t => t.CreatedAt)
            .Select(ToCandidate)
            .ToList();

        return new DailyPrepDataDto(maxTasks, candidates, currentMyDay);
    }

    [McpServerTool, Description(
        "Daily prep: set or clear a task's MyDay flag. Setting it is rejected once the MyDay cap " +
        "(DailyPrepMaxTasks open MyDay tasks) would be exceeded; clearing is always allowed." +
        McpToolDocs.LeanTaskRef)]
    public async Task<TaskRefDto> SetMyDay(
        string taskId,
        bool isMyDay,
        [Description("Position in the MyDay list; use consecutive values to keep related tasks together.")]
        int? sortOrder = null,
        CancellationToken cancellationToken = default)
    {
        taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);

        await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);

        var task = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");

        if (isMyDay && !task.IsMyDay)
        {
            var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
            var max = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
            var openMyDay = await ctx.Tasks.CountAsync(
                t => t.IsMyDay && t.Status == TaskStatus.Idle, cancellationToken);
            if (openMyDay >= max)
                throw new InvalidOperationException(
                    $"MyDay limit {max} reached. Clear a task before adding another.");
        }

        task.IsMyDay = isMyDay;
        if (sortOrder is not null) task.SortOrder = sortOrder.Value;
        await ctx.SaveChangesAsync(cancellationToken);

        await _broadcaster.TaskUpdated(taskId);
        return ToRefDto(task);
    }

    private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new(
        t.Id, t.Number, t.ListId, t.List?.Name ?? "", t.Title, t.Description,
        t.IsStarred, t.ScheduledFor, t.CreatedAt);

    // ── Private helpers ───────────────────────────────────────────────────────

    private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity Wt)> LoadWorktreeContextAsync(
        string taskId, CancellationToken ct)
    {
        using var ctx = _dbFactory.CreateDbContext();
        var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
            ?? throw new InvalidOperationException("List not found.");
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct)
            ?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
        return (task, list, wt);
    }

    private async Task<bool> TryGetIsDirtyAsync(string path, CancellationToken ct)
    {
        try { return await _git.HasChangesAsync(path, ct); }
        catch { return false; }
    }

    // Minimal git runner for operations not covered by GitService (rev-list --count, rev-parse from worktree).
    private static async Task<string?> TryRunGitAsync(string dir, string[] args, CancellationToken ct)
    {
        try
        {
            var psi = new ProcessStartInfo("git")
            {
                UseShellExecute = false,
                RedirectStandardOutput = true,
                CreateNoWindow = true,
            };
            psi.ArgumentList.Add("-C");
            psi.ArgumentList.Add(dir);
            foreach (var a in args) psi.ArgumentList.Add(a);
            using var proc = Process.Start(psi)!;
            await using var _ = ct.Register(() => { try { proc.Kill(entireProcessTree: true); } catch { } });
            var stdout = await proc.StandardOutput.ReadToEndAsync();
            await proc.WaitForExitAsync(CancellationToken.None);
            ct.ThrowIfCancellationRequested();
            return proc.ExitCode == 0 ? stdout.Trim() : null;
        }
        catch (OperationCanceledException) { throw; }
        catch { return null; }
    }

    private static async Task<int> GitRevListCountAsync(string dir, string range, CancellationToken ct)
    {
        var result = await TryRunGitAsync(dir, ["rev-list", "--count", range], ct);
        return int.TryParse(result, out var n) ? n : 0;
    }

    private static IReadOnlyList<string> ParseDiffFileNames(string diff)
    {
        var files = new List<string>();
        foreach (var line in diff.Split('\n'))
        {
            var s = line.TrimEnd('\r');
            if (s.StartsWith("+++ b/", StringComparison.Ordinal))
                files.Add(s[6..]);
        }
        return files;
    }

    private static IReadOnlyList<string> ParseDiffStatFileNames(string stat)
    {
        var files = new List<string>();
        foreach (var line in stat.Split('\n'))
        {
            var idx = line.IndexOf('|');
            if (idx > 0) files.Add(line[..idx].Trim());
        }
        return files;
    }

    private static TaskDto ToDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new(
        t.Id,
        t.Number,
        t.ListId,
        t.Title,
        t.Description,
        t.Status.ToString(),
        t.Result,
        t.CreatedBy,
        t.CreatedAt,
        t.StartedAt,
        t.FinishedAt,
        t.IsMyDay,
        t.SortOrder,
        t.RoadblockCount,
        FailureReasonOf(t),
        t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null,
        t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null,
        t.DependsOnTaskId,
        blocked,
        blockedReason,
        t.IsManual);

    private static TaskRefDto ToRefDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new(
        t.Id,
        t.Number,
        t.ListId,
        t.Title,
        t.Status.ToString(),
        t.SortOrder,
        t.IsMyDay,
        t.RoadblockCount,
        FailureReasonOf(t),
        t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null,
        t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null,
        t.DependsOnTaskId,
        blocked,
        blockedReason,
        BaseDirty: null,
        IsManual: t.IsManual);

    // "unknown" covers a Failed task that predates this field (never got a classified reason
    // stamped) — a defined value rather than null so callers don't have to special-case it.
    private static string? FailureReasonOf(TaskEntity t) =>
        t.Status == TaskStatus.Failed ? (t.FailureReason ?? "unknown") : null;
}

internal static class DailyPrepFilter
{
    public static string[] ParseExcludes(string? json)
    {
        if (string.IsNullOrWhiteSpace(json)) return [];
        try
        {
            var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
            return list is null ? [] : list.Select(Normalize).Where(p => p.Length > 0).ToArray();
        }
        catch (System.Text.Json.JsonException) { return []; }
    }

    public static bool IsIncludedRepo(string? workingDir, string[] excludes)
    {
        if (string.IsNullOrWhiteSpace(workingDir)) return false;
        var norm = Normalize(workingDir);
        return !excludes.Any(p => norm.StartsWith(p, StringComparison.OrdinalIgnoreCase));
    }

    private static string Normalize(string path) =>
        path.Trim().Replace('/', '\\').TrimEnd('\\');
}
