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.Runner;
using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.Tests.Runner;

/// Verifies InteractiveLaunchSpecService's guards
/// (Running/Queued rejected, missing/invalid worktree rejected) and that it reuses the
/// autonomous-run worktree prep: session-skills seeding via ISessionSkillSeeder, and
/// produces --resume for a resumable task vs. a fresh-start spec for a never-run task.
/// Never spawns the real claude CLI: ClaudeBin points at a stub file on disk.
public sealed class InteractiveLaunchSpecServiceTests : IDisposable
{
    private readonly DbFixture _db = new();
    private readonly string _tempDir;
    private readonly string _worktreeDir;
    private readonly string _claudeStubPath;
    private readonly FakeSessionSkillSeeder _seeder = new();
    private readonly FakeSessionSkillRegistry _registry = new();
    private readonly List<GitRepoFixture> _gitFixtures = new();
    private readonly List<string> _seededTaskIds = new();

    private static bool GitAvailable => GitRepoFixture.IsGitAvailable();

    public InteractiveLaunchSpecServiceTests()
    {
        _tempDir = Path.Combine(Path.GetTempPath(), $"cd_ilss_{Guid.NewGuid():N}");
        Directory.CreateDirectory(_tempDir);

        _worktreeDir = Path.Combine(_tempDir, "worktree");
        Directory.CreateDirectory(_worktreeDir);

        _claudeStubPath = Path.Combine(_tempDir, "claude.exe");
        File.WriteAllText(_claudeStubPath, "stub");
    }

    private GitRepoFixture CreateRepo()
    {
        var f = new GitRepoFixture();
        _gitFixtures.Add(f);
        return f;
    }

    public void Dispose()
    {
        _db.Dispose();
        foreach (var f in _gitFixtures) f.Dispose();
        try { Directory.Delete(_tempDir, true); } catch { /* best effort */ }
        foreach (var d in _mergeHelperSessionDirs)
            try { Directory.Delete(d, true); } catch { /* best effort */ }
        foreach (var id in _seededTaskIds)
            try { Directory.Delete(Path.Combine(Paths.AppDataRoot(), "task-sessions", id), true); } catch { /* best effort */ }
    }

    private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry
    {
        public List<SessionSkillEntity> Installed { get; } = new();

        public Task<IReadOnlyList<string>> InstallAsync(string url, CancellationToken ct) => throw new NotImplementedException();
        public Task UpdateAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException();
        public Task RemoveAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException();
        public Task<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct)
            => Task.FromResult<IReadOnlyList<SessionSkillEntity>>(Installed);
    }

    private InteractiveLaunchSpecService BuildService() =>
        new(_db.CreateFactory(), _seeder, _registry,
            new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance),
            new GitService(),
            new WorkerConfig { ClaudeBin = _claudeStubPath });

    private async Task<string> SeedListAsync(string? workingDir = null, string name = "L")
    {
        var listId = Guid.NewGuid().ToString();
        using var ctx = _db.CreateContext();
        await new ListRepository(ctx).AddAsync(new ListEntity
        {
            Id = listId, Name = name, WorkingDir = workingDir ?? _tempDir, CreatedAt = DateTime.UtcNow,
        });
        return listId;
    }

    private async Task SeedTaskAsync(string taskId, string listId, TaskStatus status, string? sessionSkillsJson = null, string title = "T", string? description = null)
    {
        using var ctx = _db.CreateContext();
        await new TaskRepository(ctx).AddAsync(new TaskEntity
        {
            Id = taskId, ListId = listId, Title = title, Status = status,
            CreatedAt = DateTime.UtcNow, SessionSkills = sessionSkillsJson, Description = description,
        });
        _seededTaskIds.Add(taskId); // a fresh-task ConPTY spec may write a real brief.md under
                                     // ~/.todo-app/task-sessions/<taskId> -- clean it up on dispose
    }

    private async Task SeedWorktreeAsync(string taskId, WorktreeState state, string? path = null)
    {
        using var ctx = _db.CreateContext();
        ctx.Worktrees.Add(new WorktreeEntity
        {
            TaskId = taskId, Path = path ?? _worktreeDir, BranchName = "claudedo/x",
            BaseCommit = "abc123", State = state, CreatedAt = DateTime.UtcNow,
        });
        await ctx.SaveChangesAsync();
    }

    private async Task SeedRunAsync(string taskId, string? sessionId)
    {
        using var ctx = _db.CreateContext();
        await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity
        {
            Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, IsRetry = false,
            Prompt = "p", SessionId = sessionId,
            StartedAt = DateTime.UtcNow.AddMinutes(-5), FinishedAt = DateTime.UtcNow.AddMinutes(-1),
            ExitCode = 0, ResultMarkdown = "ok",
        });
    }

    [Fact]
    public async Task BuildForTaskAsync_TaskNotFound_ThrowsKeyNotFound()
    {
        var svc = BuildService();
        await Assert.ThrowsAsync<KeyNotFoundException>(
            () => svc.BuildForTaskAsync(Guid.NewGuid().ToString(), CancellationToken.None));
    }

    [Theory]
    [InlineData(TaskStatus.Running)]
    [InlineData(TaskStatus.Queued)]
    public async Task BuildForTaskAsync_TaskRunningOrQueued_ThrowsInvalidOperation(TaskStatus status)
    {
        var listId = await SeedListAsync();
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, status);
        await SeedWorktreeAsync(taskId, WorktreeState.Active);

        var svc = BuildService();
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => svc.BuildForTaskAsync(taskId, CancellationToken.None));
    }

    [Fact]
    public async Task BuildForTaskAsync_NoWorktreeRow_NotAGitRepo_OpensDirectlyInWorkingDir_NoWorktreeCreated()
    {
        var listId = await SeedListAsync(); // default WorkingDir (_tempDir) is not a git repo
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle);

        var svc = BuildService();
        var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);

        Assert.Equal(_tempDir, spec.Cwd);
        Assert.Equal(_claudeStubPath, spec.Exe);

        using var readCtx = _db.CreateContext();
        Assert.Null(await new WorktreeRepository(readCtx).GetByTaskIdAsync(taskId));
    }

    [Fact]
    public async Task BuildForTaskAsync_NoWorktreeRow_NotAGitRepo_SeedsSessionSkillsWithIsWorktreeFalse()
    {
        _registry.Installed.Add(new SessionSkillEntity
        {
            Name = "installed-skill", SourceUrl = "https://example.com/x.git",
            PinnedRef = "abc", Subpath = "skills/installed-skill", Description = "d",
            AddedAt = DateTimeOffset.UtcNow,
        });

        var listId = await SeedListAsync();
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle,
            sessionSkillsJson: "[\"installed-skill\"]");

        var svc = BuildService();
        var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);

        Assert.Equal(_tempDir, spec.Cwd);
        var call = Assert.Single(_seeder.Calls);
        Assert.Equal(_tempDir, call.WorkingDir);
        Assert.False(call.IsWorktree);
        Assert.Equal(new[] { "installed-skill" }, call.SkillNames);
    }

    [Fact]
    public async Task BuildForTaskAsync_NoWorktreeRow_NotAGitRepo_ReopeningResumesItsOwnPriorSessionId()
    {
        var listId = await SeedListAsync();
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle);

        var svc = BuildService();
        var firstSpec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
        var (_, _, _, firstSessionId) = ParseFreshTaskArgs(firstSpec);

        var secondSpec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);

        Assert.Equal(_tempDir, secondSpec.Cwd);
        Assert.Equal(new[] { "--resume", firstSessionId }, ArgsAfterEffort(secondSpec));

        using var readCtx = _db.CreateContext();
        Assert.Null(await new WorktreeRepository(readCtx).GetByTaskIdAsync(taskId));
    }

    [Fact]
    public async Task BuildForTaskAsync_NoWorktreeRow_NoWorkingDirConfigured_ThrowsInvalidOperation()
    {
        var listId = await SeedListAsync();
        using (var ctx = _db.CreateContext())
        {
            var list = await ctx.Lists.FindAsync(listId);
            list!.WorkingDir = null; // distinct from "not a git repo" -- no directory at all
            await ctx.SaveChangesAsync();
        }
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle);

        var svc = BuildService();
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => svc.BuildForTaskAsync(taskId, CancellationToken.None));
    }

    [Fact]
    public async Task BuildForTaskAsync_FreshTask_NoWorktreeButGitRepoConfigured_CreatesWorktreeOnDemand()
    {
        if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }

        var repo = CreateRepo();
        var listId = await SeedListAsync(workingDir: repo.RepoDir);
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
        // No worktree row seeded -- task has never run.

        var svc = BuildService();
        var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);

        using var readCtx = _db.CreateContext();
        var wtRow = await new WorktreeRepository(readCtx).GetByTaskIdAsync(taskId);
        Assert.NotNull(wtRow);
        Assert.Equal(WorktreeState.Active, wtRow!.State);
        Assert.True(Directory.Exists(wtRow.Path));

        Assert.Equal(wtRow.Path, spec.Cwd);
        Assert.Equal(_claudeStubPath, spec.Exe);

        var (_, briefPath, kickoff, _) = ParseFreshTaskArgs(spec);
        Assert.Contains(briefPath, kickoff); // fresh session points claude at the brief file, not a raw prompt
        Assert.Equal("T", File.ReadAllText(briefPath)); // brief file holds the task title
    }

    [Theory]
    [InlineData(WorktreeState.Merged)]
    [InlineData(WorktreeState.Discarded)]
    public async Task BuildForTaskAsync_WorktreeNotActiveOrKept_GitRepoConfigured_CreatesFreshWorktree(WorktreeState state)
    {
        if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }

        var repo = CreateRepo();
        var listId = await SeedListAsync(workingDir: repo.RepoDir);
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
        await SeedWorktreeAsync(taskId, state);

        var svc = BuildService();
        var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);

        using var readCtx = _db.CreateContext();
        var wtRow = await new WorktreeRepository(readCtx).GetByTaskIdAsync(taskId);
        Assert.NotNull(wtRow);
        Assert.Equal(WorktreeState.Active, wtRow!.State);
        Assert.Equal(wtRow.Path, spec.Cwd);
    }

    [Theory]
    [InlineData(WorktreeState.Merged)]
    [InlineData(WorktreeState.Discarded)]
    public async Task BuildForTaskAsync_WorktreeNotActiveOrKept_NotAGitRepo_OpensDirectlyInWorkingDir(WorktreeState state)
    {
        var listId = await SeedListAsync(); // default WorkingDir (_tempDir) is not a git repo
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
        await SeedWorktreeAsync(taskId, state);

        var svc = BuildService();
        var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);

        Assert.Equal(_tempDir, spec.Cwd);

        using var readCtx = _db.CreateContext();
        var wtRow = await new WorktreeRepository(readCtx).GetByTaskIdAsync(taskId);
        Assert.NotNull(wtRow);
        Assert.Equal(state, wtRow!.State); // the stale row is left untouched, not deleted or reused
    }

    [Fact]
    public async Task BuildForTaskAsync_WorktreeDirectoryMissing_ThrowsInvalidOperation()
    {
        var listId = await SeedListAsync();
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
        await SeedWorktreeAsync(taskId, WorktreeState.Active, path: Path.Combine(_tempDir, "does-not-exist"));

        var svc = BuildService();
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => svc.BuildForTaskAsync(taskId, CancellationToken.None));
    }

    [Fact]
    public async Task BuildForTaskAsync_ResumableTask_ProducesResumeArgsAndCwd()
    {
        var listId = await SeedListAsync();
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
        await SeedWorktreeAsync(taskId, WorktreeState.Active);
        await SeedRunAsync(taskId, "sess-123");

        var svc = BuildService();
        var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);

        Assert.Equal(_worktreeDir, spec.Cwd);
        Assert.Equal(_claudeStubPath, spec.Exe);
        Assert.Equal(new[] { "--resume", "sess-123" }, ArgsAfterEffort(spec));
        Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
    }

    [Fact]
    public async Task BuildForTaskAsync_KeptWorktree_IsAllowed()
    {
        var listId = await SeedListAsync();
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
        await SeedWorktreeAsync(taskId, WorktreeState.Kept);
        await SeedRunAsync(taskId, "sess-kept");

        var svc = BuildService();
        var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);

        Assert.Equal(new[] { "--resume", "sess-kept" }, ArgsAfterEffort(spec));
    }

    [Fact]
    public async Task BuildForTaskAsync_NeverRunTask_ProducesFreshStartSpec_NoResumeArg()
    {
        var listId = await SeedListAsync();
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
        await SeedWorktreeAsync(taskId, WorktreeState.Active);
        // No TaskRunEntity at all -- never run.

        var svc = BuildService();
        var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);

        var (_, briefPath, kickoff, sessionId) = ParseFreshTaskArgs(spec); // fresh: no --resume, brief file holds the title
        Assert.Contains(briefPath, kickoff);
        Assert.Equal("T", File.ReadAllText(briefPath));
        Assert.Equal(_worktreeDir, spec.Cwd);

        // The generated session id is persisted BEFORE this call returns, so a closed/aborted
        // session still leaves a resumable id.
        Assert.Equal(sessionId, await ReadInteractiveSessionIdAsync(taskId));
    }

    [Fact]
    public async Task BuildForTaskAsync_RunWithoutSessionId_ProducesFreshStartSpec()
    {
        var listId = await SeedListAsync();
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
        await SeedWorktreeAsync(taskId, WorktreeState.Active);
        await SeedRunAsync(taskId, sessionId: null);

        var svc = BuildService();
        var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);

        var (_, briefPath, kickoff, sessionId) = ParseFreshTaskArgs(spec); // fresh: brief file holds the title
        Assert.Contains(briefPath, kickoff);
        Assert.Equal("T", File.ReadAllText(briefPath));
        Assert.Equal(sessionId, await ReadInteractiveSessionIdAsync(taskId));
    }

    [Fact]
    public async Task BuildForTaskAsync_ExistingInteractiveSessionId_TakesPrecedenceOverRunSessionId()
    {
        var listId = await SeedListAsync();
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
        await SeedWorktreeAsync(taskId, WorktreeState.Active);
        await SeedRunAsync(taskId, "autonomous-run-session");
        using (var ctx = _db.CreateContext())
        {
            await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, "interactive-session-1");
        }

        var svc = BuildService();
        var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);

        Assert.Equal(new[] { "--resume", "interactive-session-1" }, ArgsAfterEffort(spec));
    }

    [Fact]
    public async Task BuildForTaskAsync_ReopeningInteractiveSession_ResumesItsOwnPriorSessionId()
    {
        var listId = await SeedListAsync();
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
        await SeedWorktreeAsync(taskId, WorktreeState.Active);
        // No autonomous run at all -- only a prior interactive session for this task.

        var svc = BuildService();
        var firstSpec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
        var (_, _, _, firstSessionId) = ParseFreshTaskArgs(firstSpec);

        // Reopen (e.g. the pane was closed/aborted) -- must resume the SAME session id, not
        // start a new conversation.
        var secondSpec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);

        Assert.Equal(new[] { "--resume", firstSessionId }, ArgsAfterEffort(secondSpec));
    }

    [Fact]
    public async Task BuildForTaskAsync_FreshlyRecreatedWorktree_IgnoresAndOverwritesStaleInteractiveSessionId()
    {
        if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }

        var repo = CreateRepo();
        var listId = await SeedListAsync(workingDir: repo.RepoDir);
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
        // No worktree row seeded -- BuildForTaskAsync will create one fresh.
        using (var ctx = _db.CreateContext())
        {
            await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, "stale-interactive-session");
        }

        var svc = BuildService();
        var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);

        // Fresh-start, not a resume of the stale id.
        var (_, _, _, newSessionId) = ParseFreshTaskArgs(spec);
        Assert.NotEqual("stale-interactive-session", newSessionId);
        Assert.Equal(newSessionId, await ReadInteractiveSessionIdAsync(taskId));
    }

    [Fact]
    public async Task BuildForTaskAsync_FreshTask_WithDescription_SeedsTitleAndDescriptionPrompt()
    {
        var listId = await SeedListAsync();
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
        await SeedWorktreeAsync(taskId, WorktreeState.Active);
        using (var ctx = _db.CreateContext())
        {
            var t = await ctx.Tasks.FindAsync(taskId);
            t!.Description = "Do the thing";
            await ctx.SaveChangesAsync();
        }

        var svc = BuildService();
        var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);

        var (_, briefPath, kickoff, _) = ParseFreshTaskArgs(spec);
        Assert.Contains(briefPath, kickoff);
        Assert.Equal("T\n\nDo the thing", File.ReadAllText(briefPath));
    }

    [Fact]
    public async Task BuildForTaskAsync_FreshTask_DashInDescription_DoesNotAppearInArgs()
    {
        var listId = await SeedListAsync();
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle, title: "Worker: OAuth-Usage-Client");
        await SeedWorktreeAsync(taskId, WorktreeState.Active);
        var description = "WorkerHub.PreviewMerge(taskId, targetBranch) -> MergePreviewDto\n" +
                           "run with --abort -Seconds 5 -m -A and `backticks` \"quotes\"";
        using (var ctx = _db.CreateContext())
        {
            var t = await ctx.Tasks.FindAsync(taskId);
            t!.Description = description;
            await ctx.SaveChangesAsync();
        }

        var svc = BuildService();
        var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);

        var (_, briefPath, kickoff, _) = ParseFreshTaskArgs(spec);
        Assert.Contains(briefPath, kickoff);
        Assert.DoesNotContain('\n', kickoff);

        // No arg element carries any fragment of the title/description -- it only ever
        // reaches the session via the brief file.
        foreach (var arg in spec.Args)
        {
            Assert.DoesNotContain("->", arg);
            Assert.DoesNotContain("--abort", arg);
            Assert.DoesNotContain("OAuth-Usage-Client", arg);
        }

        var briefContent = File.ReadAllText(briefPath);
        Assert.Equal($"Worker: OAuth-Usage-Client\n\n{description}", briefContent);
    }

    [Fact]
    public async Task BuildForTaskAsync_FreshTask_NoTitleNoDescription_NoPositionalArg()
    {
        var listId = await SeedListAsync();
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle, title: "");
        await SeedWorktreeAsync(taskId, WorktreeState.Active);

        var svc = BuildService();
        var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);

        // no brief -> no --add-dir, no positional arg -- but a session id is still pre-assigned
        var rest = ArgsAfterEffort(spec);
        Assert.Equal("--session-id", rest[0]);
        Assert.Equal(rest[1], await ReadInteractiveSessionIdAsync(taskId));
    }

    [Fact]
    public async Task BuildForTaskAsync_SeedsSessionSkills_FilteredToInstalled()
    {
        _registry.Installed.Add(new SessionSkillEntity
        {
            Name = "installed-skill", SourceUrl = "https://example.com/x.git",
            PinnedRef = "abc", Subpath = "skills/installed-skill", Description = "d",
            AddedAt = DateTimeOffset.UtcNow,
        });

        var listId = await SeedListAsync();
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle,
            sessionSkillsJson: "[\"installed-skill\",\"missing-skill\"]");
        await SeedWorktreeAsync(taskId, WorktreeState.Active);

        var svc = BuildService();
        await svc.BuildForTaskAsync(taskId, CancellationToken.None);

        var call = Assert.Single(_seeder.Calls);
        Assert.Equal(_worktreeDir, call.WorkingDir);
        Assert.True(call.IsWorktree);
        Assert.Equal(new[] { "installed-skill" }, call.SkillNames);
    }

    [Fact]
    public async Task BuildForDirectoryAsync_ExistingDirectory_ReturnsFreshStartSpec()
    {
        var svc = BuildService();
        var spec = await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None);

        Assert.Equal(_tempDir, spec.Cwd);
        Assert.Equal(_claudeStubPath, spec.Exe);
        Assert.Empty(ArgsAfterEffort(spec));
        Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
        Assert.Empty(_seeder.Calls);
    }

    [Fact]
    public async Task BuildForDirectoryAsync_NonExistentDirectory_ThrowsInvalidOperation()
    {
        var svc = BuildService();
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), CancellationToken.None));
    }

    // ── Merge helper ──

    /// Every ConPTY spec now leads with `--effort <level>` from the per-model preset (the seeded
    /// settings row has no overrides, so the shipped default for the default model applies).
    /// Asserts that pair and returns the rest of the args for the test's own comparison.
    private static string[] ArgsAfterEffort(LaunchSpec spec)
    {
        var args = spec.Args.ToList();
        Assert.Equal("--effort", args[0]);
        Assert.Equal(ModelPresets.For(ModelPresets.Defaults, ModelRegistry.DefaultAlias).Effort, args[1]);
        return args.Skip(2).ToArray();
    }

    /// A fresh (never-resumed) task with a title/description produces
    /// `--add-dir <sessionDir> --effort <level> --session-id <guid> <kickoff>` -- the brief
    /// travels via the file at <sessionDir>/brief.md, never as a raw CLI argument. Asserts that
    /// exact shape and returns (sessionDir, briefPath, kickoff, sessionId) for the test's own
    /// checks. Cleanup happens via `_seededTaskIds` in Dispose (the session dir is keyed by task id).
    private static (string SessionDir, string BriefPath, string Kickoff, string SessionId) ParseFreshTaskArgs(LaunchSpec spec)
    {
        var args = spec.Args.ToList();
        Assert.Equal(7, args.Count);
        Assert.Equal("--add-dir", args[0]);
        var sessionDir = args[1];
        Assert.Equal("--effort", args[2]);
        Assert.Equal(ModelPresets.For(ModelPresets.Defaults, ModelRegistry.DefaultAlias).Effort, args[3]);
        Assert.Equal("--session-id", args[4]);
        var sessionId = args[5];
        var kickoff = args[6];

        var briefPath = Path.Combine(sessionDir, "brief.md");
        return (sessionDir, briefPath, kickoff, sessionId);
    }

    private async Task<string?> ReadInteractiveSessionIdAsync(string taskId)
    {
        using var ctx = _db.CreateContext();
        var task = await new TaskRepository(ctx).GetByIdAsync(taskId);
        return task!.InteractiveSessionId;
    }

    private readonly List<string> _mergeHelperSessionDirs = new();

    /// The session dir is the value right after --add-dir; register it for cleanup
    /// (BuildForMergeHelperAsync writes under the real ~/.todo-app).
    private string TrackSessionDir(LaunchSpec spec)
    {
        var args = spec.Args.ToList();
        var dir = args[args.IndexOf("--add-dir") + 1];
        _mergeHelperSessionDirs.Add(dir);
        return dir;
    }

    [Fact]
    public async Task BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation()
    {
        var listId = await SeedListAsync(workingDir: _tempDir);
        var svc = BuildService();
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, Guid.NewGuid().ToString(), CancellationToken.None));
    }

    [Fact]
    public async Task BuildForMergeHelperAsync_ListWithoutExistingWorkingDir_ThrowsInvalidOperation()
    {
        var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone"));
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);

        var svc = BuildService();
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => svc.BuildForMergeHelperAsync(new[] { taskId }, listId, Guid.NewGuid().ToString(), CancellationToken.None));
        Assert.Contains("working directory", ex.Message);
    }

    [Fact]
    public async Task BuildForMergeHelperAsync_UnknownList_Throws()
    {
        var listId = await SeedListAsync(workingDir: _tempDir);
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle);

        var svc = BuildService();
        await Assert.ThrowsAsync<KeyNotFoundException>(
            () => svc.BuildForMergeHelperAsync(new[] { taskId }, "no-such-list", Guid.NewGuid().ToString(), CancellationToken.None));
    }

    [Fact]
    public async Task BuildForMergeHelperAsync_BuildsListScopedSpecWithSingleRepo()
    {
        var repo = Path.Combine(_tempDir, "repoOnly");
        Directory.CreateDirectory(repo);

        var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
        var t1 = Guid.NewGuid().ToString();
        var t2 = Guid.NewGuid().ToString();
        await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
        await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");

        var svc = BuildService();
        var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
        var sessionDir = TrackSessionDir(spec);

        Assert.Equal(repo, spec.Cwd);
        Assert.Equal(_claudeStubPath, spec.Exe);

        var args = spec.Args.ToList();

        var modelIdx = args.IndexOf("--model");
        Assert.True(modelIdx >= 0);
        Assert.Equal(ModelRegistry.HandlerTriageAlias, args[modelIdx + 1]);

        var effortIdx = args.IndexOf("--effort");
        Assert.True(effortIdx >= 0);
        Assert.Equal(ModelPresets.For(ModelPresets.Defaults, ModelRegistry.HandlerTriageAlias).Effort, args[effortIdx + 1]);

        var pmIdx = args.IndexOf("--permission-mode");
        Assert.True(pmIdx >= 0);
        Assert.Equal("auto", args[pmIdx + 1]);

        var atIdx = args.IndexOf("--allowedTools");
        Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill,Task", args[atIdx + 1]);

        // --add-dir: session dir + the list's single repo dir
        var addIdx = args.IndexOf("--add-dir");
        var appendIdx = args.IndexOf("--append-system-prompt-file");
        var addDirs = args.GetRange(addIdx + 1, appendIdx - addIdx - 1);
        Assert.Equal(new[] { sessionDir, repo }, addDirs);

        var systemPromptPath = args[appendIdx + 1];
        Assert.Equal(Path.Combine(sessionDir, "system-prompt.md"), systemPromptPath);
        Assert.True(File.Exists(systemPromptPath));

        // kickoff is the LAST arg (positional), single line, points at brief.md
        var kickoff = args[^1];
        var briefPath = Path.Combine(sessionDir, "brief.md");
        Assert.Contains(briefPath, kickoff);
        Assert.DoesNotContain('\n', kickoff);

        Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
    }

    // A directory argument that keeps its trailing separator escapes its own closing quote once the
    // ConPTY host flattens Args into a single Windows command line ("C:\repo\" -> \" is a literal
    // quote), so --add-dir's variadic list swallows every following argument -- including the
    // positional kickoff, leaving the session with no prompt at all.
    [Fact]
    public async Task BuildForMergeHelperAsync_WorkingDirWithTrailingSeparator_EmitsNoArgEndingInSeparator()
    {
        var repo = Path.Combine(_tempDir, "repoTrailingSep");
        Directory.CreateDirectory(repo);

        var listId = await SeedListAsync(workingDir: repo + Path.DirectorySeparatorChar, name: "Trailing");
        var t1 = Guid.NewGuid().ToString();
        await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");

        var svc = BuildService();
        var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
        var sessionDir = TrackSessionDir(spec);

        var args = spec.Args.ToList();
        var addIdx = args.IndexOf("--add-dir");
        var appendIdx = args.IndexOf("--append-system-prompt-file");
        Assert.Equal(new[] { sessionDir, repo }, args.GetRange(addIdx + 1, appendIdx - addIdx - 1));
        Assert.DoesNotContain(args, a => a.EndsWith('\\') || a.EndsWith('/'));
    }

    [Fact]
    public async Task BuildForMergeHelperAsync_BriefNamesListRepoAndEveryTask()
    {
        var repo = Path.Combine(_tempDir, "repoBrief");
        Directory.CreateDirectory(repo);

        var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
        var t1 = Guid.NewGuid().ToString();
        var t2 = Guid.NewGuid().ToString();
        await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
        await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");

        var svc = BuildService();
        var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
        var sessionDir = TrackSessionDir(spec);

        var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
        Assert.Contains("Scope: List: Alpha", brief);
        Assert.Contains($"Repo: {repo}", brief);
        Assert.Contains("First task", brief);
        Assert.Contains("Second task", brief);
        Assert.Contains(t1, brief);
        Assert.Contains(t2, brief);
    }

    // Regression guard: no session ever learned its own handler task id, so it could never call
    // handoff_list_handler(taskId, ...) on itself -- the handoff chain broke exactly where it was
    // needed. The brief must carry it.
    [Fact]
    public async Task BuildForMergeHelperAsync_BriefIncludesHandlerTaskId()
    {
        var repo = Path.Combine(_tempDir, "repoHandlerId");
        Directory.CreateDirectory(repo);

        var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
        var t1 = Guid.NewGuid().ToString();
        await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "First task");
        var handlerTaskId = Guid.NewGuid().ToString();

        var svc = BuildService();
        var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, handlerTaskId, CancellationToken.None);
        var sessionDir = TrackSessionDir(spec);

        var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
        Assert.Contains(handlerTaskId, brief);
    }

    [Fact]
    public async Task BuildForMergeHelperAsync_BriefIncludesTaskDescription()
    {
        var repo = Path.Combine(_tempDir, "repoDesc");
        Directory.CreateDirectory(repo);

        var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
        var t1 = Guid.NewGuid().ToString();
        await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "First task",
            description: "Do the thing carefully and report back.");

        var svc = BuildService();
        var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
        var sessionDir = TrackSessionDir(spec);

        var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
        Assert.Contains("Do the thing carefully and report back.", brief);
    }

    [Fact]
    public async Task BuildForMergeHelperAsync_TaskWithoutDescription_RendersCleanly()
    {
        var repo = Path.Combine(_tempDir, "repoNoDesc");
        Directory.CreateDirectory(repo);

        var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
        var t1 = Guid.NewGuid().ToString();
        await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "No description task", description: null);

        var svc = BuildService();
        var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
        var sessionDir = TrackSessionDir(spec);

        var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
        var line = brief.Split('\n').Single(l => l.Contains("No description task"));
        Assert.Equal($"- [Idle] No description task (id: {t1})", line.TrimEnd('\r'));
    }

    [Fact]
    public async Task BuildForMergeHelperAsync_BriefDescriptionWithCodeFenceAndHeadingsKeepsTaskListRecognizable()
    {
        var repo = Path.Combine(_tempDir, "repoMd");
        Directory.CreateDirectory(repo);

        var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
        var t1 = Guid.NewGuid().ToString();
        var t2 = Guid.NewGuid().ToString();
        var trickyDescription = """
            # Heading inside description

            - a nested list item
            - another one

            ```csharp
            var x = "fenced code block";
            ```
            """;
        await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "First task", description: trickyDescription);
        await SeedTaskAsync(t2, listId, TaskStatus.WaitingForReview, title: "Second task", description: "plain description");

        var svc = BuildService();
        var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
        var sessionDir = TrackSessionDir(spec);

        var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
        var lines = brief.Replace("\r\n", "\n").Split('\n');

        // The two task bullets must still be recognizable top-level list items, unbroken
        // by the embedded heading/list/code-fence in the first task's description.
        Assert.Contains(lines, l => l == $"- [Idle] First task (id: {t1})");
        Assert.Contains(lines, l => l == $"- [WaitingForReview] Second task (id: {t2})");
        Assert.Contains("Heading inside description", brief);
        Assert.Contains("fenced code block", brief);
        Assert.Contains("var x = \"fenced code block\";", brief);
    }

    // ── CreateMergeHelperTaskAsync ──

    [Fact]
    public async Task CreateMergeHelperTaskAsync_EmptyTaskIds_ThrowsInvalidOperation()
    {
        var listId = await SeedListAsync(workingDir: _tempDir);
        var svc = BuildService();
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => svc.CreateMergeHelperTaskAsync(Array.Empty<string>(), listId, "title", "header", CancellationToken.None));
    }

    [Fact]
    public async Task CreateMergeHelperTaskAsync_UnknownList_ThrowsKeyNotFound()
    {
        var taskId = Guid.NewGuid().ToString();
        var svc = BuildService();
        await Assert.ThrowsAsync<KeyNotFoundException>(
            () => svc.CreateMergeHelperTaskAsync(new[] { taskId }, "no-such-list", "title", "header", CancellationToken.None));
    }

    [Fact]
    public async Task CreateMergeHelperTaskAsync_ListWithoutExistingWorkingDir_ThrowsInvalidOperation()
    {
        var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone"));
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);

        var svc = BuildService();
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => svc.CreateMergeHelperTaskAsync(new[] { taskId }, listId, "title", "header", CancellationToken.None));
    }

    [Fact]
    public async Task CreateMergeHelperTaskAsync_CreatesIdleManualTask_StampsHandlerBaseCommit()
    {
        if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }

        var repo = CreateRepo();
        var listId = await SeedListAsync(workingDir: repo.RepoDir, name: "Alpha");
        var t1 = Guid.NewGuid().ToString();
        await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");

        var svc = BuildService();
        var newTaskId = await svc.CreateMergeHelperTaskAsync(
            new[] { t1 }, listId, "List handler: Alpha", "Tasks handled by this run:", CancellationToken.None);

        using var readCtx = _db.CreateContext();
        var created = await new TaskRepository(readCtx).GetByIdAsync(newTaskId);
        Assert.NotNull(created);
        Assert.Equal("List handler: Alpha", created!.Title);
        Assert.Equal(TaskStatus.Idle, created.Status);
        Assert.True(created.IsManual);
        Assert.Equal(repo.BaseCommit, created.HandlerBaseCommit);
        Assert.Null(created.HandlerHeadCommit);
        Assert.Contains("Tasks handled by this run:", created.Description);
        Assert.Contains("First task", created.Description);
        Assert.Contains(t1, created.Description);
    }

    // ── BuildForMergeHelperHandoffAsync ──

    [Fact]
    public async Task BuildForMergeHelperHandoffAsync_EmptySurvivingTaskIds_ThrowsInvalidOperation()
    {
        var listId = await SeedListAsync(workingDir: _tempDir);
        var handlerTaskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle);

        var svc = BuildService();
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => svc.BuildForMergeHelperHandoffAsync(handlerTaskId, Array.Empty<string>(), "wait", CancellationToken.None));
    }

    [Fact]
    public async Task BuildForMergeHelperHandoffAsync_UnknownHandlerTask_ThrowsKeyNotFound()
    {
        await Assert.ThrowsAsync<KeyNotFoundException>(
            () => BuildService().BuildForMergeHelperHandoffAsync("no-such-task", new[] { "x" }, "wait", CancellationToken.None));
    }

    [Fact]
    public async Task BuildForMergeHelperHandoffAsync_UnknownSurvivingTask_ThrowsKeyNotFound()
    {
        var listId = await SeedListAsync(workingDir: _tempDir);
        var handlerTaskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle);

        var svc = BuildService();
        await Assert.ThrowsAsync<KeyNotFoundException>(
            () => svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { "no-such-task" }, "wait", CancellationToken.None));
    }

    [Fact]
    public async Task BuildForMergeHelperHandoffAsync_ListWithoutExistingWorkingDir_ThrowsInvalidOperation()
    {
        var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone"));
        var handlerTaskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle);
        var survivor = Guid.NewGuid().ToString();
        await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview);

        var svc = BuildService();
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "wait", CancellationToken.None));
        Assert.Contains("working directory", ex.Message);
    }

    [Fact]
    public async Task BuildForMergeHelperHandoffAsync_WorkingDirWithTrailingSeparator_EmitsNoArgEndingInSeparator()
    {
        var repo = Path.Combine(_tempDir, "repoHandoffTrailingSep");
        Directory.CreateDirectory(repo);

        var listId = await SeedListAsync(workingDir: repo + Path.DirectorySeparatorChar, name: "Trailing");
        var handlerTaskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle, title: "List handler: Trailing");
        var survivor = Guid.NewGuid().ToString();
        await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview, title: "Survivor");

        var svc = BuildService();
        var spec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "wait", CancellationToken.None);
        var sessionDir = TrackSessionDir(spec);

        var args = spec.Args.ToList();
        var addIdx = args.IndexOf("--add-dir");
        var appendIdx = args.IndexOf("--append-system-prompt-file");
        Assert.Equal(new[] { sessionDir, repo }, args.GetRange(addIdx + 1, appendIdx - addIdx - 1));
        Assert.DoesNotContain(args, a => a.EndsWith('\\') || a.EndsWith('/'));
    }

    // Regression guard: the handoff session must see the SAME handler task id as the run that
    // called handoff_list_handler -- it's the id already passed in as taskId, just never rendered
    // into the handoff kickoff.
    [Fact]
    public async Task BuildForMergeHelperHandoffAsync_HandoffIncludesSameHandlerTaskId()
    {
        var listId = await SeedListAsync(workingDir: _tempDir);
        var handlerTaskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle, title: "Handler");
        var survivor = Guid.NewGuid().ToString();
        await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview);

        var svc = BuildService();
        var spec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "wait", CancellationToken.None);
        var sessionDir = TrackSessionDir(spec);

        var handoff = File.ReadAllText(Path.Combine(sessionDir, "handoff.md"));
        Assert.Contains(handlerTaskId, handoff);
    }

    [Fact]
    public async Task BuildForMergeHelperHandoffAsync_UnknownPhase_Throws()
    {
        var listId = await SeedListAsync(workingDir: _tempDir);
        var handlerTaskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle);
        var survivor = Guid.NewGuid().ToString();
        await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview);

        var svc = BuildService();
        var ex = await Assert.ThrowsAsync<ArgumentException>(
            () => svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "phase-9", CancellationToken.None));
        Assert.Contains("phase-9", ex.Message);
    }

    [Theory]
    [InlineData("wait", "phase 3", false)]
    [InlineData("wait_final", "phase 3", true)]
    [InlineData("merge", "phase 4", false)]
    [InlineData("merge_final", "phase 4", true)]
    public async Task BuildForMergeHelperHandoffAsync_ReusesHandlerTaskId_PerPhaseModelEffortPromptAndKickoff(
        string nextPhase, string expectedPhaseLabel, bool expectFinalNote)
    {
        var repo = Path.Combine(_tempDir, $"repoHandoff-{nextPhase}");
        Directory.CreateDirectory(repo);

        var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
        var handlerTaskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle, title: "List handler: Alpha");
        var survivor = Guid.NewGuid().ToString();
        await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview, title: "Survivor task");

        var beforeCount = await CountTasksAsync();

        var svc = BuildService();
        var spec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, nextPhase, CancellationToken.None);
        var sessionDir = TrackSessionDir(spec);

        var afterCount = await CountTasksAsync();
        Assert.Equal(beforeCount, afterCount);

        Assert.Equal(repo, spec.Cwd);
        Assert.Equal(_claudeStubPath, spec.Exe);

        var expectedModel = nextPhase is "wait" or "wait_final" ? ModelRegistry.HandlerWaitAlias : ModelRegistry.HandlerMergeAlias;
        var expectedPromptKind = nextPhase is "wait" or "wait_final" ? PromptKind.MergeHelperWait : PromptKind.MergeHelperMerge;

        var args = spec.Args.ToList();

        var modelIdx = args.IndexOf("--model");
        Assert.Equal(expectedModel, args[modelIdx + 1]);

        var effortIdx = args.IndexOf("--effort");
        Assert.Equal(ModelPresets.For(ModelPresets.Defaults, expectedModel).Effort, args[effortIdx + 1]);

        var pmIdx = args.IndexOf("--permission-mode");
        Assert.Equal("auto", args[pmIdx + 1]);

        var atIdx = args.IndexOf("--allowedTools");
        Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill,Task", args[atIdx + 1]);

        var appendIdx = args.IndexOf("--append-system-prompt-file");
        var systemPromptPath = args[appendIdx + 1];
        Assert.Equal(Path.Combine(sessionDir, "system-prompt.md"), systemPromptPath);
        // The handoff session only ever gets the prompt for the role it is about to run --
        // never the Triage prompt the first session ran with, and never the OTHER role's prompt.
        Assert.Equal(PromptFiles.ReadOrDefault(expectedPromptKind), File.ReadAllText(systemPromptPath));

        var kickoff = args[^1];
        var handoffPath = Path.Combine(sessionDir, "handoff.md");
        Assert.Contains(handoffPath, kickoff);
        Assert.DoesNotContain('\n', kickoff);
        Assert.Contains(expectedPhaseLabel, kickoff);

        var handoffBrief = File.ReadAllText(handoffPath);
        Assert.Contains("Scope: List: Alpha", handoffBrief);
        Assert.Contains($"Repo: {repo}", handoffBrief);
        Assert.Contains("Survivor task", handoffBrief);
        Assert.Contains(survivor, handoffBrief);
        Assert.Contains("phase 3", handoffBrief, StringComparison.OrdinalIgnoreCase);

        if (expectFinalNote)
        {
            Assert.Contains("FINAL round", handoffBrief);
            Assert.Contains("rerun", handoffBrief, StringComparison.OrdinalIgnoreCase);
        }
        else
        {
            Assert.DoesNotContain("FINAL round", handoffBrief);
        }

        Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
    }

    private async Task<int> CountTasksAsync()
    {
        using var ctx = _db.CreateContext();
        return await ctx.Tasks.CountAsync();
    }

    [Fact]
    public void BuildPlanningStart_MapsPlanningArgsAndEnv()
    {
        var sessionDir = Path.Combine(_tempDir, "sess");
        Directory.CreateDirectory(sessionDir);
        var ctx = new PlanningSessionStartContext(
            ParentTaskId: "p1", WorkingDir: _worktreeDir, Token: "tok-1",
            WorktreePath: _worktreeDir, BranchName: "claudedo/planning/p1",
            Files: new PlanningSessionFiles(sessionDir,
                Path.Combine(sessionDir, "system-prompt.md"),
                Path.Combine(sessionDir, "initial-prompt.txt")));

        var spec = BuildService().BuildPlanningStart(ctx);

        Assert.Equal(_worktreeDir, spec.Cwd);
        Assert.Equal(_claudeStubPath, spec.Exe);
        Assert.Contains("--permission-mode", spec.Args);
        // Default mode, not plan mode -- plan mode would gate the MCP planning tools.
        Assert.Contains("default", spec.Args);
        Assert.Equal("tok-1", spec.Env["CLAUDEDO_PLANNING_TOKEN"]);
        Assert.Equal("20000", spec.Env["MAX_THINKING_TOKENS"]);
    }

    [Fact]
    public void BuildPlanningResume_MapsResumeArgsAndToken()
    {
        var ctx = new PlanningSessionResumeContext(
            ParentTaskId: "p1", WorkingDir: _worktreeDir,
            ClaudeSessionId: "sess-42", Token: "tok-2", WorktreePath: _worktreeDir);

        var spec = BuildService().BuildPlanningResume(ctx);

        var args = ArgsAfterEffort(spec);
        Assert.Equal("--permission-mode", args[0]);
        Assert.Equal("default", args[1]);
        Assert.Equal("--allowedTools", args[2]);
        Assert.Contains("mcp__claudedo__", args[3]);
        Assert.Equal(new[] { "--resume", "sess-42" }, args.Skip(4).ToArray());
        Assert.Equal("tok-2", spec.Env["CLAUDEDO_PLANNING_TOKEN"]);
        Assert.Equal(_worktreeDir, spec.Cwd);
    }

    // Regression guard for the bug where both merge-helper builders wrote the SAME system prompt,
    // so the handoff session received the phase 0-2 (dedupe/enhance) instructions and was told to
    // ignore them by its brief alone. Each session must carry only its own phases.
    [Fact]
    public async Task MergeHelperBuilders_WriteDifferentSystemPrompts_EachScopedToItsOwnPhases()
    {
        var repo = Path.Combine(_tempDir, "repoPromptSplit");
        Directory.CreateDirectory(repo);

        var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
        var handlerTaskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle, title: "Handler");
        var survivor = Guid.NewGuid().ToString();
        await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview, title: "Survivor");

        var svc = BuildService();

        var triageSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, handlerTaskId, CancellationToken.None);
        var triageDir = TrackSessionDir(triageSpec);
        var executeSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "merge", CancellationToken.None);
        var executeDir = TrackSessionDir(executeSpec);

        var triagePrompt = File.ReadAllText(Path.Combine(triageDir, "system-prompt.md"));
        var executePrompt = File.ReadAllText(Path.Combine(executeDir, "system-prompt.md"));

        Assert.NotEqual(triagePrompt, executePrompt);

        Assert.Contains("## Phase 1", triagePrompt);
        Assert.DoesNotContain("## Phase 4", triagePrompt);

        Assert.Contains("## Phase 4", executePrompt);
        Assert.DoesNotContain("## Phase 1", executePrompt);
    }

    // Regression guard for the bug where BuildForMergeHelperHandoffAsync set MCP_TOOL_TIMEOUT to
    // an older, shorter value (200000) than every other ConPTY spec (930000) after a parallel
    // merge landed the two changes independently. Every spec this service builds must carry the
    // SAME value, sourced from the one constant, so the value can never drift again.
    [Fact]
    public async Task AllBuiltSpecs_CarryTheSameMcpToolTimeout()
    {
        var repo = Path.Combine(_tempDir, "repoTimeoutCheck");
        Directory.CreateDirectory(repo);

        var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
        await SeedWorktreeAsync(taskId, WorktreeState.Active);
        await SeedRunAsync(taskId, "sess-timeout-check");

        var handlerTaskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle, title: "Handler");
        var survivor = Guid.NewGuid().ToString();
        await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview, title: "Survivor");

        var sessionDir = Path.Combine(_tempDir, "sess-timeout-check");
        Directory.CreateDirectory(sessionDir);
        var planningStartCtx = new PlanningSessionStartContext(
            ParentTaskId: "p1", WorkingDir: _worktreeDir, Token: "tok-1",
            WorktreePath: _worktreeDir, BranchName: "claudedo/planning/p1",
            Files: new PlanningSessionFiles(sessionDir,
                Path.Combine(sessionDir, "system-prompt.md"),
                Path.Combine(sessionDir, "initial-prompt.txt")));
        var planningResumeCtx = new PlanningSessionResumeContext(
            ParentTaskId: "p1", WorkingDir: _worktreeDir,
            ClaudeSessionId: "sess-42", Token: "tok-2", WorktreePath: _worktreeDir);

        var svc = BuildService();
        var mergeHelperSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, handlerTaskId, CancellationToken.None);
        TrackSessionDir(mergeHelperSpec);
        var handoffSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "wait", CancellationToken.None);
        TrackSessionDir(handoffSpec);

        var specs = new List<LaunchSpec>
        {
            await svc.BuildForTaskAsync(taskId, CancellationToken.None),
            await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None),
            mergeHelperSpec,
            handoffSpec,
            svc.BuildPlanningStart(planningStartCtx),
            svc.BuildPlanningResume(planningResumeCtx),
        };

        Assert.All(specs, spec =>
            Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]));
    }
}
