using System.Diagnostics;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Skills;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.Runner;

// Builds the launch spec an embedded ConPTY terminal needs to open an interactive Claude
// session for a task -- the SAME worktree prep as an autonomous run when the task's list
// points at a git repo: session-skills seeded onto disk (reuses ISessionSkillSeeder +
// TaskRunner.UnionSkillNames, exactly like TaskRunner.RunAsync/ContinueAsync) and the same
// run environment variables (matches ClaudeProcess's MCP_TOOL_TIMEOUT). When the list's
// WorkingDir is not a git repo, no worktree is created at all -- the session opens directly
// in that directory (session-skills seeded there too, but isWorktree: false so no
// .git/info/exclude write is attempted). Exe/Args reuse WindowsTerminalLauncher's --resume
// argument construction. Guards: no running/queued task, and (once a worktree exists) it
// must be live on disk -- but a never-run task (no persisted SessionId) is not an error
// here, it's a fresh-start spec.
//
// The run-scoped "claudedo_run" MCP server (AskUser/SuggestImprovement) that TaskRunner
// wires per headless run is intentionally NOT reused: it exists so an unattended run can
// ask the user a question, which is moot when the user is already driving the session by
// hand. The always-on `mcp__claudedo__*` tools remain available via the globally-registered
// MCP server (installer's RegisterMcpStep), exactly as they already are for a plain
// `--resume` pickup in a Windows Terminal window.
public sealed class InteractiveLaunchSpecService
{
    // Claude Code caps HTTP MCP tool calls at 60s unless raised; every ConPTY spec built by this
    // service lifts it well past wait_for_task_change's 900s server-side cap. Keep in sync with
    // ClaudeProcess's own MCP_TOOL_TIMEOUT (same value, set independently for headless runs).
    public const string McpToolTimeoutMs = "930000";

    private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
    private readonly ISessionSkillSeeder _skillSeeder;
    private readonly ISessionSkillRegistry _skillRegistry;
    private readonly WorktreeManager _wtManager;
    private readonly GitService _git;
    private readonly string _claudePath;

    public InteractiveLaunchSpecService(
        IDbContextFactory<ClaudeDoDbContext> dbFactory,
        ISessionSkillSeeder skillSeeder,
        ISessionSkillRegistry skillRegistry,
        WorktreeManager wtManager,
        GitService git,
        WorkerConfig cfg)
    {
        _dbFactory = dbFactory;
        _skillSeeder = skillSeeder;
        _skillRegistry = skillRegistry;
        _wtManager = wtManager;
        _git = git;
        _claudePath = cfg.ClaudeBin;
    }

    /// <summary>Builds a LaunchSpec for opening an interactive session in a task's worktree.
    /// Throws KeyNotFoundException if the task doesn't exist, InvalidOperationException
    /// if it's Running/Queued. If the task has no usable worktree yet, one is created on
    /// demand (same mechanism as an autonomous run) provided the task's list has a working
    /// directory pointing at a git repo -- otherwise throws InvalidOperationException. Resumes
    /// (--resume) this task's own last interactive session (TaskEntity.InteractiveSessionId) if
    /// it has one, else the latest autonomous run's session; a task that has never run either
    /// way, or whose worktree was just created fresh, gets a fresh-start spec instead -- pre-
    /// assigned a new session id via --session-id and persisted to InteractiveSessionId before
    /// launch, so a closed/aborted session can be resumed next time.</summary>
    public async Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct)
    {
        await using var ctx = await _dbFactory.CreateDbContextAsync(ct);

        var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
            ?? throw new KeyNotFoundException();
        if (task.Status is TaskStatus.Running or TaskStatus.Queued)
            throw new InvalidOperationException("Can't open an interactive session for a running or queued task -- interrupt it first.");

        var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
        var isFreshWorktree = false;
        string sessionDir;
        bool sessionIsWorktree;

        if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
        {
            // No usable worktree yet -- if the task's list points at a git repo, create one
            // on demand via the SAME mechanism an autonomous run uses (WorktreeManager.CreateAsync:
            // branch naming, base commit resolution, worktree-root strategy, DB registration).
            // If the list's directory isn't a git repo at all, there is nothing to branch from --
            // open the session directly in that directory instead, worktree-less. This mirrors an
            // existing shape in the codebase (a list-handler task also has no worktree and commits
            // straight to the list's working dir), so no WorktreeEntity is created here either.
            var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct);
            if (list?.WorkingDir is null)
                throw new InvalidOperationException("This task has no working directory configured -- can't create a worktree.");

            if (await _git.IsGitRepoAsync(list.WorkingDir, ct))
            {
                // A stale (Merged/Discarded) row is already tracked in `ctx`'s identity map from
                // the read above -- CreateAsync replaces it via its OWN context, so re-querying on
                // `ctx` without detaching first would hand back the old cached instance instead of
                // the freshly created row.
                if (worktree is not null)
                    ctx.Entry(worktree).State = EntityState.Detached;

                await _wtManager.CreateAsync(task, list, ct);
                worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct)
                    ?? throw new InvalidOperationException("Worktree creation did not persist a worktree row.");
                isFreshWorktree = true;
                sessionDir = worktree.Path;
                sessionIsWorktree = true;
            }
            else
            {
                sessionDir = list.WorkingDir;
                sessionIsWorktree = false;
            }
        }
        else if (!Directory.Exists(worktree.Path))
        {
            throw new InvalidOperationException("The task's worktree directory no longer exists.");
        }
        else
        {
            sessionDir = worktree.Path;
            sessionIsWorktree = true;
        }

        var listConfig = await new ListRepository(ctx).GetConfigAsync(task.ListId, ct);
        var globalSettings = await new AppSettingsRepository(ctx).GetAsync(ct);
        // A brand-new worktree has no prior session to resume, regardless of any session
        // history the task accumulated before its previous worktree went away.
        var run = isFreshWorktree ? null : await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, ct);

        var requestedSkills = TaskRunner.UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, globalSettings.SessionSkills);
        var skillNames = await FilterToInstalledSkillsAsync(requestedSkills, ct);
        await _skillSeeder.SeedAsync(sessionDir, skillNames, isWorktree: sessionIsWorktree, ct);

        var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
            ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");

        // Start the hand-driven session at the effort configured for the model this task would run
        // under, instead of inheriting whatever the user's global Claude Code config happens to be.
        // The model itself is deliberately NOT forced here — the user can still switch it in the TUI.
        var effort = EffortFor(globalSettings, task.Model ?? listConfig?.Model);

        // Resume this task's own last interactive conversation when it has one -- it takes
        // precedence over the latest autonomous run's session, since an interactive session is a
        // distinct conversation from an autonomous run even against the same worktree. Fall back
        // to the autonomous run's session so opening a task interactively for the first time still
        // picks up prior context. Neither survives a freshly (re)created worktree (isFreshWorktree
        // already forced `run` to null above).
        var resumeSessionId = isFreshWorktree ? null : task.InteractiveSessionId ?? run?.SessionId;

        // For a fresh session, seed the interactive TUI with the task's brief (title +
        // description) via a file, never as a positional CLI argument -- see
        // BuildFreshTaskArgsAsync for why. The session id claude will run under is generated and
        // persisted HERE, before launch, so a closed/aborted session -- even one that never got
        // past startup -- still leaves an id the next open can resume.
        IReadOnlyList<string> args;
        if (resumeSessionId is { Length: > 0 })
        {
            args = WithEffort(WindowsTerminalLauncher.BuildResumeArgs(resumeSessionId), effort);
        }
        else
        {
            var sessionId = Guid.NewGuid().ToString();
            await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, sessionId, ct);
            args = await BuildFreshTaskArgsAsync(task, effort, sessionId, ct);
        }

        // Same run environment variable ClaudeProcess sets for every headless run: the
        // AskUser MCP tool call and wait_for_task_change cap at 60s unless raised, and lifting
        // it is harmless for every other tool. Keep in sync with ClaudeProcess's MCP_TOOL_TIMEOUT.
        var env = new Dictionary<string, string>
        {
            ["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
        };

        return new LaunchSpec(sessionDir, resolvedClaude, args, env);
    }

    /// <summary>Maps an already-prepared planning START context (worktree + prompt files + token,
    /// produced by PlanningSessionManager.StartAsync) into a LaunchSpec for an embedded ConPTY
    /// planning session — same planning CLI args as the wt launcher, planning env carried in Env.</summary>
    public LaunchSpec BuildPlanningStart(PlanningSessionStartContext ctx)
    {
        var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
            ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");

        // Mirrors the env the wt planning launcher set (MAX_THINKING_TOKENS + the per-session
        // planning token); MCP_TOOL_TIMEOUT matches the other embedded-ConPTY specs. Applied to
        // the UI process env at spawn time (see PtyTerminalSession) — process-global by design.
        var env = new Dictionary<string, string>
        {
            ["MAX_THINKING_TOKENS"] = "20000",
            ["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token,
            ["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
        };

        return new LaunchSpec(
            ctx.WorkingDir, resolvedClaude,
            WithEffort(WindowsTerminalLauncher.BuildPlanningStartArgs(ctx),
                       EffortFor(ReadSettings(), ModelRegistry.PlanningAlias)),
            env);
    }

    /// <summary>Maps a planning RESUME context (from PlanningSessionManager.ResumeAsync) into a
    /// LaunchSpec for an embedded ConPTY planning session (--permission-mode plan --resume).</summary>
    public LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx)
    {
        var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
            ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");

        var env = new Dictionary<string, string>
        {
            ["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token,
            ["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
        };

        return new LaunchSpec(
            ctx.WorkingDir, resolvedClaude,
            WithEffort(WindowsTerminalLauncher.BuildPlanningResumeArgs(ctx.ClaudeSessionId),
                       EffortFor(ReadSettings(), ModelRegistry.PlanningAlias)),
            env);
    }

    /// <summary>Builds a LaunchSpec for an ad-hoc interactive session in an arbitrary directory --
    /// no task, no worktree, no session-skills seeding. Throws InvalidOperationException if the
    /// directory doesn't exist.</summary>
    public async Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct)
    {
        if (!Directory.Exists(directory))
            throw new InvalidOperationException($"Directory does not exist: {directory}");

        var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
            ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");

        await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
        var settings = await new AppSettingsRepository(ctx).GetAsync(ct);

        var env = new Dictionary<string, string>
        {
            ["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
        };

        // No task and no list here — the global default model's preset decides the effort.
        return new LaunchSpec(
            directory, resolvedClaude,
            WithEffort(Array.Empty<string>(), EffortFor(settings, settings.DefaultModel)), env);
    }

    // Tools the merge helper may use without prompting: the claudedo MCP surface (run, poll,
    // diff, review/merge, continue/abort merge), read/search, Edit + Bash for hand-resolving
    // conflict markers the MCP tools left behind, web/skill lookups, and Task so the Merge phase
    // can delegate diff reviews to sonnet subagents instead of reading every diff itself.
    private const string MergeHelperAllowedTools =
        "mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill,Task";

    /// <summary>Builds a LaunchSpec for an embedded ConPTY "merge helper" session that drives the
    /// given tasks to a merged/Done state via the mcp__claudedo__* tools. Writes a per-session
    /// system prompt + task brief under ~/.claudeDo/merge-helper-sessions/&lt;guid&gt; and exposes
    /// that dir plus the list's repo dir via --add-dir. cwd is the list's working directory.
    /// handlerTaskId (the id returned by CreateMergeHelperTaskAsync) is rendered into the brief so
    /// the session can call handoff_list_handler/submit_task_for_review on its own handler task.
    /// Throws KeyNotFoundException if the list doesn't exist; InvalidOperationException if
    /// taskIds is empty or the list has no existing working directory.</summary>
    public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct)
    {
        if (taskIds.Count == 0)
            throw new InvalidOperationException("No tasks selected for the list handler.");

        await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
        var taskRepo = new TaskRepository(ctx);
        var listRepo = new ListRepository(ctx);

        var list = await listRepo.GetByIdAsync(listId, ct)
            ?? throw new KeyNotFoundException($"List not found: {listId}");

        var repoDir = list.WorkingDir;
        if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
            throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");
        repoDir = TrimTrailingSeparator(repoDir);

        var briefLines = new List<string>();
        foreach (var id in taskIds)
        {
            var task = await taskRepo.GetByIdAsync(id, ct)
                ?? throw new KeyNotFoundException($"Task not found: {id}");
            briefLines.Add(RenderBriefEntry(task));
        }

        var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString());
        Directory.CreateDirectory(sessionDir);

        var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md");
        await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(PromptKind.MergeHelperTriage), ct);

        var briefPath = Path.Combine(sessionDir, "brief.md");
        await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperInitial,
            new Dictionary<string, string>
            {
                ["scope"] = $"List: {list.Name}",
                ["repo"] = repoDir,
                ["handlerTaskId"] = handlerTaskId,
                ["tasks"] = string.Join("\n", briefLines),
            }), ct);

        var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
            ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");

        // Mirrors WindowsTerminalLauncher.BuildPlanningStartArgs ordering: variadic flags
        // (--allowedTools, --add-dir) first, then a single-value flag, then the single-line
        // positional kickoff LAST — a multi-line positional prompt truncates at the first
        // newline, so the full multi-line brief travels via the file exposed through --add-dir.
        // Model/effort/permission-mode are fixed to the Triage role -- the list config's model no
        // longer influences a handler session at all, it would otherwise apply the LIST's effort
        // preset to a completely different model.
        var settings = await new AppSettingsRepository(ctx).GetAsync(ct);

        var args = new List<string>
        {
            "--model", ModelRegistry.HandlerTriageAlias,
            "--effort", EffortFor(settings, ModelRegistry.HandlerTriageAlias),
            "--permission-mode", PermissionModeResolver.Resolve(ModelRegistry.HandlerTriageAlias, "auto"),
            "--allowedTools", MergeHelperAllowedTools,
            "--add-dir", sessionDir, repoDir,
            "--append-system-prompt-file", systemPromptPath,
            $"Read the file {briefPath} first. It lists the tasks you must handle and their status. " +
            "After reading it, begin the session as your instructions describe.",
        };

        var env = new Dictionary<string, string>
        {
            ["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
        };

        return new LaunchSpec(repoDir, resolvedClaude, args, env);
    }

    // Builds the LaunchSpec for the fresh session a merge-helper run hands off to for the given
    // phase -- SAME handler task id as the run that called handoff_list_handler, so
    // HandlerBaseCommit/HandlerHeadCommit and the review range stay untouched; this never creates
    // a task. nextPhase picks the system prompt (MergeHelperWait for wait/wait_final,
    // MergeHelperMerge for merge/merge_final) so the handoff session only ever carries the
    // instructions for the role it is actually about to run, never the Triage dedupe/enhance ones.
    // Writes a fresh handoff kickoff file in a NEW session dir -- the old ConPTY tile keeps running
    // against its own session-dir files untouched.
    /// <summary>Builds a LaunchSpec for the fresh ConPTY session a merge-helper run hands off to for
    /// the given phase -- reuses the SAME handler task id (no new task, HandlerBaseCommit untouched),
    /// writing only a fresh handoff kickoff naming the surviving tasks. nextPhase selects both the
    /// system prompt and the model: "wait"/"wait_final" -> MergeHelperWait + HandlerWaitAlias,
    /// "merge"/"merge_final" -> MergeHelperMerge + HandlerMergeAlias; the "_final" variants render an
    /// extra line in the handoff kickoff marking the final round and forbidding further reruns. Throws
    /// ArgumentException for an unrecognized nextPhase; KeyNotFoundException if the task/list doesn't
    /// exist; InvalidOperationException if survivingTaskIds is empty or the list has no working
    /// directory.</summary>
    public async Task<LaunchSpec> BuildForMergeHelperHandoffAsync(
        string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct)
    {
        if (survivingTaskIds.Count == 0)
            throw new InvalidOperationException("No surviving tasks to hand off.");

        var phase = ResolveHandoffPhase(nextPhase);

        await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
        var taskRepo = new TaskRepository(ctx);
        var listRepo = new ListRepository(ctx);

        var handlerTask = await taskRepo.GetByIdAsync(taskId, ct)
            ?? throw new KeyNotFoundException($"Task not found: {taskId}");
        var list = await listRepo.GetByIdAsync(handlerTask.ListId, ct)
            ?? throw new KeyNotFoundException($"List not found: {handlerTask.ListId}");

        var repoDir = list.WorkingDir;
        if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
            throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");
        repoDir = TrimTrailingSeparator(repoDir);

        var briefLines = new List<string>();
        foreach (var id in survivingTaskIds)
        {
            var task = await taskRepo.GetByIdAsync(id, ct)
                ?? throw new KeyNotFoundException($"Task not found: {id}");
            briefLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})");
        }

        var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString());
        Directory.CreateDirectory(sessionDir);

        var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md");
        await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(phase.PromptKind), ct);

        // A "_final" round differs from its non-final counterpart in exactly one rendered line --
        // the same system prompt and model drive both -- marking the final round and forbidding a
        // further rerun, so the chain is bounded structurally rather than by prompt appeal alone.
        var finalNote = phase.IsFinal
            ? "\nThis is the FINAL round of this list-handler run -- do not start any further reruns; " +
              "merge what you can, report the rest, and print the summary."
            : "";

        var briefPath = Path.Combine(sessionDir, "handoff.md");
        await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperHandoff,
            new Dictionary<string, string>
            {
                ["scope"] = $"List: {list.Name}",
                ["repo"] = repoDir,
                ["handlerTaskId"] = taskId,
                ["tasks"] = string.Join("\n", briefLines),
                ["finalNote"] = finalNote,
            }), ct);

        var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
            ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");

        var settings = await new AppSettingsRepository(ctx).GetAsync(ct);

        var args = new List<string>
        {
            "--model", phase.Model,
            "--effort", EffortFor(settings, phase.Model),
            "--permission-mode", PermissionModeResolver.Resolve(phase.Model, "auto"),
            "--allowedTools", MergeHelperAllowedTools,
            "--add-dir", sessionDir, repoDir,
            "--append-system-prompt-file", systemPromptPath,
            $"Read the file {briefPath} first. It lists the surviving tasks and their status. " +
            $"After reading it, begin at {phase.PhaseLabel} as your instructions describe.",
        };

        var env = new Dictionary<string, string>
        {
            ["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
        };

        return new LaunchSpec(repoDir, resolvedClaude, args, env);
    }

    // The ConPTY host flattens Args into ONE Windows command line and quotes each token, so a token
    // ending in '\' escapes its own closing quote and every following argument is absorbed into the
    // preceding variadic flag -- for a list handler that means --add-dir swallows
    // --append-system-prompt-file AND the positional kickoff, and the session opens with no prompt
    // at all. ListRepository normalizes on write now, but rows written before that still carry one.
    private static string TrimTrailingSeparator(string dir) => Paths.TrimTrailingSeparator(dir)!;

    // Renders one task as a brief list item. A description can itself be arbitrary Markdown
    // (headings, lists, fenced code) — those must not merge into the brief's own task list, so
    // the description is placed in a fenced code block indented to the list item's continuation
    // column (2 spaces, matching "- "). That keeps CommonMark parsing the fence as part of THIS
    // bullet rather than breaking the list, while the code fence itself stops any inner heading
    // or list syntax from being interpreted. The fence length is extended past the longest run of
    // backticks already present in the description so an embedded ``` block can't prematurely
    // close it.
    private static string RenderBriefEntry(TaskEntity task)
    {
        var header = $"- [{task.Status}] {task.Title} (id: {task.Id})";
        var description = task.Description?.Trim();
        if (string.IsNullOrEmpty(description)) return header;

        var fence = new string('`', Math.Max(3, LongestBacktickRun(description) + 1));
        var lines = new List<string>(4) { header, $"  {fence}" };
        lines.AddRange(description.Replace("\r\n", "\n").Split('\n').Select(line => $"  {line}"));
        lines.Add($"  {fence}");
        return string.Join("\n", lines);
    }

    private static int LongestBacktickRun(string text)
    {
        var max = 0;
        var current = 0;
        foreach (var ch in text)
        {
            current = ch == '`' ? current + 1 : 0;
            if (current > max) max = current;
        }
        return max;
    }

    // Creates the ClaudeDo task that hosts a list-handler run (Mission Control's "Let Claude
    // handle it") and stamps the list repo's current HEAD as the review range's base commit.
    // The handler never gets its own worktree -- it commits straight to the list's working
    // dir -- so this HandlerBaseCommit/HandlerHeadCommit pair (see TaskEntity) is what lets the
    // normal diff/get_task_diff paths show what the run changed once it submits for review.
    // IsManual=true so the queue picker, daily prep, and the "send to queue"/"refine" UI
    // affordances all skip it, matching the "reminder only a human/ConPTY session can act on"
    // semantics IsManual already carries elsewhere; the ConPTY session itself is still allowed.
    /// <summary>Creates the ClaudeDo task that hosts a list-handler run (Mission Control's
    /// "Let Claude handle it") and stamps the list repo's current HEAD as the review range's
    /// base commit (see TaskEntity.HandlerBaseCommit). Returns the new task's id. Throws
    /// KeyNotFoundException if the list doesn't exist; InvalidOperationException if taskIds
    /// is empty or the list has no existing working directory.</summary>
    public async Task<string> CreateMergeHelperTaskAsync(
        IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct)
    {
        if (taskIds.Count == 0)
            throw new InvalidOperationException("No tasks selected for the list handler.");

        await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
        var listRepo = new ListRepository(ctx);
        var taskRepo = new TaskRepository(ctx);

        var list = await listRepo.GetByIdAsync(listId, ct)
            ?? throw new KeyNotFoundException($"List not found: {listId}");

        var repoDir = list.WorkingDir;
        if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
            throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");

        var descriptionLines = new List<string>();
        foreach (var id in taskIds)
        {
            var task = await taskRepo.GetByIdAsync(id, ct);
            if (task is not null) descriptionLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})");
        }

        var baseCommit = await _git.RevParseHeadAsync(repoDir, ct);

        var handlerTask = new TaskEntity
        {
            Id = Guid.NewGuid().ToString(),
            ListId = listId,
            Title = title,
            Description = descriptionLines.Count > 0
                ? $"{descriptionHeader}\n{string.Join("\n", descriptionLines)}"
                : descriptionHeader,
            IsManual = true,
            HandlerBaseCommit = baseCommit,
            CreatedAt = DateTime.UtcNow,
        };
        await taskRepo.AddAsync(handlerTask, ct);

        return handlerTask.Id;
    }

    // Maps a handoff nextPhase to the system prompt/model the next session runs under, plus the
    // "phase N" label used in its short positional kickoff. wait/wait_final share MergeHelperWait
    // + HandlerWaitAlias; merge/merge_final share MergeHelperMerge + HandlerMergeAlias -- only
    // IsFinal differs between a phase and its "_final" counterpart.
    private static (PromptKind PromptKind, string Model, string PhaseLabel, bool IsFinal) ResolveHandoffPhase(string nextPhase)
    {
        MergeHelperPhase.Validate(nextPhase);
        return nextPhase switch
        {
            MergeHelperPhase.Wait => (PromptKind.MergeHelperWait, ModelRegistry.HandlerWaitAlias, "phase 3", false),
            MergeHelperPhase.WaitFinal => (PromptKind.MergeHelperWait, ModelRegistry.HandlerWaitAlias, "phase 3", true),
            MergeHelperPhase.Merge => (PromptKind.MergeHelperMerge, ModelRegistry.HandlerMergeAlias, "phase 4", false),
            MergeHelperPhase.MergeFinal => (PromptKind.MergeHelperMerge, ModelRegistry.HandlerMergeAlias, "phase 4", true),
            _ => throw new UnreachableException(),
        };
    }

    // The reasoning effort configured for a model in Settings → General. Falls back to the shipped
    // preset for that model, so a missing/malformed settings row can never block a launch.
    private static string EffortFor(AppSettingsEntity settings, string? model)
        => ModelPresets.For(settings.ModelPresets, model ?? settings.DefaultModel).Effort;

    // Prepends `--effort <level>`. It has to lead: a positional kickoff prompt must stay last, and
    // it may only follow a single-value flag — a variadic flag would swallow it.
    private static IReadOnlyList<string> WithEffort(IReadOnlyList<string> args, string effort)
    {
        var result = new List<string>(args.Count + 2) { "--effort", effort };
        result.AddRange(args);
        return result;
    }

    private AppSettingsEntity ReadSettings()
    {
        using var ctx = _dbFactory.CreateDbContext();
        return new AppSettingsRepository(ctx).GetAsync().GetAwaiter().GetResult();
    }

    // The task's brief (title + description, unmodified/multi-line) must never travel as a CLI
    // argument: the ConPTY host flattens Args into one command line to spawn the process, and
    // claude re-splits that line on whitespace, so any token in the description that starts with
    // '-' (e.g. "->", "--abort") gets misread as an unknown option, and a newline would truncate
    // the brief at its first line even if it didn't. So it's written to a file instead --
    // same pattern as BuildForMergeHelperAsync / WindowsTerminalLauncher.BuildPlanningStartArgs --
    // and claude is pointed at it with a single-line kickoff. --add-dir exposes the session dir to
    // Read; --effort (single-value) must sit directly before the positional kickoff so the
    // preceding variadic --add-dir doesn't swallow the kickoff as another directory.
    // No brief (task has neither a title nor a description) -> no positional arg at all.
    // `--session-id` pre-assigns the claude session id the caller already persisted (see
    // BuildForTaskAsync) so this fresh conversation is resumable from its very first turn --
    // it's a single-value flag, so it may sit directly before the positional kickoff.
    private static async Task<IReadOnlyList<string>> BuildFreshTaskArgsAsync(
        TaskEntity task, string effort, string sessionId, CancellationToken ct)
    {
        var brief = BuildTaskBrief(task);
        if (string.IsNullOrEmpty(brief))
            return new[] { "--effort", effort, "--session-id", sessionId };

        var sessionDir = Path.Combine(Paths.AppDataRoot(), "task-sessions", task.Id);
        Directory.CreateDirectory(sessionDir);
        var briefPath = Path.Combine(sessionDir, "brief.md");
        await File.WriteAllTextAsync(briefPath, brief, ct);

        return new[]
        {
            "--add-dir", sessionDir,
            "--effort", effort,
            "--session-id", sessionId,
            $"Read the file {briefPath} first. It contains the task you must work on. " +
            "After reading it, begin the session as your instructions describe.",
        };
    }

    private static string BuildTaskBrief(TaskEntity task)
    {
        var title = task.Title?.Trim();
        var description = task.Description?.Trim();
        return (string.IsNullOrEmpty(title), string.IsNullOrEmpty(description)) switch
        {
            (false, false) => $"{title}\n\n{description}",
            (false, true) => title!,
            (true, false) => description!,
            _ => string.Empty,
        };
    }

    private async Task<IReadOnlyList<string>> FilterToInstalledSkillsAsync(IReadOnlyList<string> requested, CancellationToken ct)
    {
        if (requested.Count == 0) return requested;

        var installed = (await _skillRegistry.ListAsync(ct))
            .Select(s => s.Name)
            .ToHashSet(StringComparer.Ordinal);
        return requested.Where(installed.Contains).ToList();
    }
}
