using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using ClaudeDo.Worker.Tests.Services;
using ClaudeDo.Worker.Usage;
using ClaudeDo.Worker.Worktrees;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.Tests.External;

internal sealed class ExternalRecordingHubClients : IHubClients
{
    public ExternalRecordingClientProxy Proxy { get; } = new();
    public IClientProxy All => Proxy;
    public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy;
    public IClientProxy Client(string connectionId) => Proxy;
    public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy;
    public IClientProxy Group(string groupName) => Proxy;
    public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy;
    public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy;
    public IClientProxy User(string userId) => Proxy;
    public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy;
}

internal sealed class ExternalRecordingClientProxy : IClientProxy
{
    public List<(string Method, object?[] Args)> Calls { get; } = new();
    public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
    {
        Calls.Add((method, args));
        return Task.CompletedTask;
    }
}

internal sealed class ExternalFakeHubContext : IHubContext<WorkerHub>
{
    public ExternalRecordingHubClients RecordingClients { get; } = new();
    public IHubClients Clients => RecordingClients;
    public IGroupManager Groups => throw new NotImplementedException();
}

// A synchronously-collecting IProgress<T>: Progress<T> marshals through the SynchronizationContext
// captured at construction, which is unreliable to assert on immediately in a test.
file sealed class SyncProgress<T> : IProgress<T>
{
    public readonly List<T> Reports = new();
    public void Report(T value) => Reports.Add(value);
}

public sealed class ExternalMcpServiceTests : IDisposable
{
    private readonly DbFixture _db = new();
    private readonly ClaudeDoDbContext _ctx;
    private readonly TaskRepository _tasks;
    private readonly ListRepository _lists;
    private readonly ExternalFakeHubContext _hub = new();
    private readonly HubBroadcaster _broadcaster;
    private readonly List<GitRepoFixture> _repos = new();
    private readonly List<(string repoDir, string wtPath)> _worktreeCleanups = new();
    private int _numberSeed;

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

    public ExternalMcpServiceTests()
    {
        _ctx = _db.CreateContext();
        _tasks = new TaskRepository(_ctx);
        _lists = new ListRepository(_ctx);
        _broadcaster = new HubBroadcaster(_hub);
    }

    public void Dispose()
    {
        foreach (var (repoDir, wtPath) in _worktreeCleanups)
        {
            try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { }
        }
        foreach (var r in _repos) r.Dispose();
        _ctx.Dispose();
        _db.Dispose();
    }

    private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedWorktreeAsync(
        TaskStatus status = TaskStatus.Done)
    {
        var repo = new GitRepoFixture();
        _repos.Add(repo);

        var listId = Guid.NewGuid().ToString();
        var list = new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow };
        await _lists.AddAsync(list);
        var task = await SeedTaskAsync(listId, status: status);

        var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
        var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
        var wt = await mgr.CreateAsync(task, list, CancellationToken.None);
        _worktreeCleanups.Add((repo.RepoDir, wt.WorktreePath));
        return (task, list, wt);
    }

    private async Task<string> SeedListAsync(string name = "L")
    {
        var id = Guid.NewGuid().ToString();
        await _lists.AddAsync(new ListEntity { Id = id, Name = name, CreatedAt = DateTime.UtcNow });
        return id;
    }

    private async Task<TaskEntity> SeedTaskAsync(
        string listId, string title = "t", TaskStatus status = TaskStatus.Idle, string? parentId = null)
    {
        var task = new TaskEntity
        {
            Id = Guid.NewGuid().ToString(),
            ListId = listId,
            Title = title,
            Status = status,
            ParentTaskId = parentId,
            CreatedAt = DateTime.UtcNow,
            CommitType = "chore",
            Number = ++_numberSeed,
        };
        await _tasks.AddAsync(task);
        return task;
    }

    private ExternalMcpService BuildSut(QueueService queue) => BuildSut(queue, new VerifyCommandRunner());

    private ExternalMcpService BuildSut(QueueService queue, IVerifyCommandRunner verify)
    {
        var git = new GitService();
        var factory = _db.CreateFactory();
        var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
        var state = TaskStateServiceBuilder.Build(factory).State;
        var merge = new TaskMergeService(factory, git, _broadcaster, state, verify, NullLogger<TaskMergeService>.Instance);
        var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
        var planningMerge = new PlanningMergeOrchestrator(
            factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
        return new ExternalMcpService(
            _tasks, _lists, queue, _broadcaster,
            state,
            git, factory, maintenance, merge, planningMerge,
            new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
    }

    private QueueService CreateQueue()
    {
        var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_ext_{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
        var cfg = new WorkerConfig
        {
            SandboxRoot = Path.Combine(tempDir, "sandbox"),
            LogRoot = Path.Combine(tempDir, "logs"),
            QueueBackstopIntervalMs = 50,
        };
        var fake = new FakeClaudeProcess();
        var hubCtx = new CapturingHubContext();
        var broadcaster = new HubBroadcaster(hubCtx);
        var dbFactory = _db.CreateFactory();
        var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
        var argsBuilder = new ClaudeArgsBuilder();
        var state = TaskStateServiceBuilder.Build(dbFactory).State;
        var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg,
            NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
        var waker = new ClaudeDo.Worker.Queue.QueueWaker();
        var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
        var runCancels = new RunCancellationRegistry(NullLogger<RunCancellationRegistry>.Instance);
        var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, runCancels);
        var usageState = new UsageState();
        return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state, runCancels,
            new FakeUsageGate(), usageState, broadcaster,
            new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger<UsageLimitAutoContinueCoordinator>.Instance));
    }

    [Fact]
    public async Task SeededListAndTask_AreRetrievable()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId);
        Assert.NotNull(await _tasks.GetByIdAsync(task.Id));
    }

    [Fact]
    public async Task UpdateTask_PatchesNonNullFieldsOnly()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, "old title");
        var queue = CreateQueue();
        var sut = BuildSut(queue);

        var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, null, CancellationToken.None);

        Assert.Equal("new title", dto.Title);
        var loaded = await _tasks.GetByIdAsync(task.Id);
        Assert.Equal("new title", loaded!.Title);
    }

    [Fact]
    public async Task UpdateTask_ReturnsLeanReference_NotFullTaskDto()
    {
        // TaskRefDto has no Description/Result properties -- a writing tool that echoes it back
        // would re-send the (possibly long) description the caller just sent.
        Assert.DoesNotContain(nameof(TaskDto.Description), typeof(TaskRefDto).GetProperties().Select(p => p.Name));
        Assert.DoesNotContain("Result", typeof(TaskRefDto).GetProperties().Select(p => p.Name));

        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, "old title");
        task.Description = "a long description the caller already has";
        await _tasks.UpdateAsync(task, CancellationToken.None);
        var sut = BuildSut(CreateQueue());

        var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, null, CancellationToken.None);

        Assert.Equal(task.Id, dto.Id);
        Assert.Equal(listId, dto.ListId);
        Assert.Equal("new title", dto.Title);
    }

    [Fact]
    public async Task UpdateTask_SetsIsManualTrue()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId);
        var sut = BuildSut(CreateQueue());

        var dto = await sut.UpdateTask(task.Id, isManual: true, cancellationToken: CancellationToken.None);

        Assert.True(dto.IsManual);
        var loaded = await _tasks.GetByIdAsync(task.Id);
        Assert.True(loaded!.IsManual);
    }

    [Fact]
    public async Task UpdateTask_SetsIsManualFalse_ClearsExistingFlag()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId);
        task.IsManual = true;
        await _tasks.UpdateAsync(task, CancellationToken.None);
        var sut = BuildSut(CreateQueue());

        var dto = await sut.UpdateTask(task.Id, isManual: false, cancellationToken: CancellationToken.None);

        Assert.False(dto.IsManual);
        var loaded = await _tasks.GetByIdAsync(task.Id);
        Assert.False(loaded!.IsManual);
    }

    [Fact]
    public async Task UpdateTask_IsManualNull_LeavesUnchanged()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId);
        task.IsManual = true;
        await _tasks.UpdateAsync(task, CancellationToken.None);
        var sut = BuildSut(CreateQueue());

        var dto = await sut.UpdateTask(task.Id, title: "new title", cancellationToken: CancellationToken.None);

        Assert.True(dto.IsManual);
    }

    [Fact]
    public async Task GetTask_ReturnsTaskNumber()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId);
        var sut = BuildSut(CreateQueue());

        var dto = await sut.GetTask(task.Id, CancellationToken.None);

        Assert.Equal(task.Number, dto.Number);
    }

    [Fact]
    public async Task GetTask_ByHashNumber_ResolvesToTask()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId);
        var sut = BuildSut(CreateQueue());

        var dto = await sut.GetTask($"#{task.Number}", CancellationToken.None);

        Assert.Equal(task.Id, dto.Id);
    }

    [Fact]
    public async Task GetTask_ByBareNumber_ResolvesToTask()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId);
        var sut = BuildSut(CreateQueue());

        var dto = await sut.GetTask(task.Number.ToString(), CancellationToken.None);

        Assert.Equal(task.Id, dto.Id);
    }

    [Fact]
    public async Task GetTask_UnknownNumber_ThrowsWithNumberInMessage()
    {
        var sut = BuildSut(CreateQueue());

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
            sut.GetTask("#999999", CancellationToken.None));

        Assert.Contains("999999", ex.Message);
    }

    [Fact]
    public async Task GetTask_ReturnsFullDtoIncludingDescription()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, "with description");
        task.Description = "the full description text";
        await _tasks.UpdateAsync(task, CancellationToken.None);
        var sut = BuildSut(CreateQueue());

        var dto = await sut.GetTask(task.Id, CancellationToken.None);

        Assert.Equal("the full description text", dto.Description);
    }

    [Fact]
    public async Task GetTask_Failed_ReturnsClassifiedFailureReason()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, status: TaskStatus.Failed);
        task.FailureReason = "max_turns";
        task.FailureTurnsUsed = 55;
        task.FailureMaxTurns = 60;
        await _tasks.UpdateAsync(task, CancellationToken.None);
        var sut = BuildSut(CreateQueue());

        var dto = await sut.GetTask(task.Id, CancellationToken.None);

        Assert.Equal("max_turns", dto.FailureReason);
        Assert.Equal(55, dto.FailureTurnsUsed);
        Assert.Equal(60, dto.FailureMaxTurns);
    }

    [Fact]
    public async Task GetTask_FailedWithoutStoredReason_ReturnsUnknownNotError()
    {
        // A Failed task written before this field existed has FailureReason=null in the DB —
        // the MCP surface must still hand back a defined value, not null or a throw.
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, status: TaskStatus.Failed);
        var sut = BuildSut(CreateQueue());

        var dto = await sut.GetTask(task.Id, CancellationToken.None);

        Assert.Equal("unknown", dto.FailureReason);
        Assert.Null(dto.FailureTurnsUsed);
        Assert.Null(dto.FailureMaxTurns);
    }

    [Fact]
    public async Task GetTask_NotFailed_FailureReasonIsNull()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
        var sut = BuildSut(CreateQueue());

        var dto = await sut.GetTask(task.Id, CancellationToken.None);

        Assert.Null(dto.FailureReason);
    }

    [Fact]
    public async Task GetTaskRefAsync_Failed_ReturnsClassifiedFailureReason()
    {
        // GetTaskRefAsync backs batch_get_tasks' default (lean) path — the same field must be
        // present there, not just on the full get_task DTO.
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, status: TaskStatus.Failed);
        task.FailureReason = "error";
        await _tasks.UpdateAsync(task, CancellationToken.None);
        var sut = BuildSut(CreateQueue());

        var dto = await sut.GetTaskRefAsync(task.Id, CancellationToken.None);

        Assert.Equal("error", dto.FailureReason);
    }

    [Fact]
    public async Task GetTask_QueuedWithUnmetDependsOn_ReportsBlockedTrueAndReason()
    {
        var listId = await SeedListAsync();
        var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
        var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
        task.DependsOnTaskId = predecessor.Id;
        await _tasks.UpdateAsync(task, CancellationToken.None);
        var sut = BuildSut(CreateQueue());

        var dto = await sut.GetTask(task.Id, CancellationToken.None);

        Assert.True(dto.Blocked);
        Assert.Contains(predecessor.Id, dto.BlockedReason);
    }

    [Fact]
    public async Task GetTask_QueuedWithDoneDependsOn_ReportsBlockedFalse()
    {
        var listId = await SeedListAsync();
        var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Done);
        var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
        task.DependsOnTaskId = predecessor.Id;
        await _tasks.UpdateAsync(task, CancellationToken.None);
        var sut = BuildSut(CreateQueue());

        var dto = await sut.GetTask(task.Id, CancellationToken.None);

        Assert.False(dto.Blocked);
        Assert.Null(dto.BlockedReason);
    }

    [Fact]
    public async Task GetTask_IdleWithDependsOnTaskId_ReportsBlockedFalse_NotYetQueued()
    {
        // Blocking is only meaningful once the picker would actually be gating the task --
        // an Idle task hasn't been queued at all, so DependsOnTaskId doesn't apply yet.
        var listId = await SeedListAsync();
        var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
        var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
        task.DependsOnTaskId = predecessor.Id;
        await _tasks.UpdateAsync(task, CancellationToken.None);
        var sut = BuildSut(CreateQueue());

        var dto = await sut.GetTask(task.Id, CancellationToken.None);

        Assert.False(dto.Blocked);
    }

    [Fact]
    public async Task ListTasks_ReportsBlockedPerTask()
    {
        var listId = await SeedListAsync();
        var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle);
        var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued);
        blocked.DependsOnTaskId = predecessor.Id;
        await _tasks.UpdateAsync(blocked, CancellationToken.None);
        var unblocked = await SeedTaskAsync(listId, "unblocked", TaskStatus.Queued);
        var sut = BuildSut(CreateQueue());

        var result = await sut.ListTasks(listId, cancellationToken: CancellationToken.None);

        var blockedRef = result.Tasks!.Single(t => t.Id == blocked.Id);
        var unblockedRef = result.Tasks!.Single(t => t.Id == unblocked.Id);
        Assert.True(blockedRef.Blocked);
        Assert.Contains(predecessor.Id, blockedRef.BlockedReason);
        Assert.False(unblockedRef.Blocked);
    }

    [Fact]
    public async Task ListTasks_IncludeDescriptionTrue_ReportsBlockedOnFullDto()
    {
        var listId = await SeedListAsync();
        var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle);
        var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued);
        blocked.DependsOnTaskId = predecessor.Id;
        await _tasks.UpdateAsync(blocked, CancellationToken.None);
        var sut = BuildSut(CreateQueue());

        var result = await sut.ListTasks(listId, includeDescription: true, cancellationToken: CancellationToken.None);

        var dto = result.TasksFull!.Single(t => t.Id == blocked.Id);
        Assert.True(dto.Blocked);
    }

    [Fact]
    public async Task UpdateTask_OnRunning_Throws()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
        var queue = CreateQueue();
        var sut = BuildSut(queue);

        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            sut.UpdateTask(task.Id, "x", null, null, null, null, CancellationToken.None));
    }

    [Fact]
    public async Task UpdateTask_NotFound_Throws()
    {
        var queue = CreateQueue();
        var sut = BuildSut(queue);

        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            sut.UpdateTask("does-not-exist", "x", null, null, null, null, CancellationToken.None));
    }

    [Fact]
    public async Task UpdateTask_WithDependsOnTaskId_SetsLinkAndReportsBlocked()
    {
        var listId = await SeedListAsync();
        var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
        var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
        var sut = BuildSut(CreateQueue());

        var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: predecessor.Id, cancellationToken: CancellationToken.None);

        Assert.Equal(predecessor.Id, dto.DependsOnTaskId);
        Assert.True(dto.Blocked);
        var loaded = await _tasks.GetByIdAsync(task.Id);
        Assert.Equal(predecessor.Id, loaded!.DependsOnTaskId);
    }

    [Fact]
    public async Task UpdateTask_WithEmptyStringDependsOnTaskId_ClearsExistingLink()
    {
        var listId = await SeedListAsync();
        var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
        var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
        var sut = BuildSut(CreateQueue());
        await sut.UpdateTask(task.Id, dependsOnTaskId: predecessor.Id, cancellationToken: CancellationToken.None);

        var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: "", cancellationToken: CancellationToken.None);

        Assert.Null(dto.DependsOnTaskId);
        Assert.False(dto.Blocked);
    }

    [Fact]
    public async Task UpdateTask_WithCyclicDependsOnTaskId_Throws()
    {
        var listId = await SeedListAsync();
        var a = await SeedTaskAsync(listId, status: TaskStatus.Idle);
        var b = await SeedTaskAsync(listId, status: TaskStatus.Idle);
        var sut = BuildSut(CreateQueue());
        await sut.UpdateTask(b.Id, dependsOnTaskId: a.Id, cancellationToken: CancellationToken.None);

        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            sut.UpdateTask(a.Id, dependsOnTaskId: b.Id, cancellationToken: CancellationToken.None));
    }

    [Fact]
    public async Task ReviewTask_Approve_SetsDone()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
        var sut = BuildSut(CreateQueue());

        var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None);

        Assert.Equal("Done", result.Task.Status);
        Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
    }

    [Fact]
    public async Task ReviewTask_RejectRerun_WithoutFeedback_Throws()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
        var sut = BuildSut(CreateQueue());

        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            sut.ReviewTask(task.Id, "reject_rerun", null, null, cancellationToken: CancellationToken.None));
    }

    [Fact]
    public async Task ReviewTask_RejectRerun_QueuesAndStoresFeedback()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
        var sut = BuildSut(CreateQueue());

        var result = await sut.ReviewTask(task.Id, "reject_rerun", "fix it", null, cancellationToken: CancellationToken.None);

        Assert.Equal("Queued", result.Task.Status);
        var loaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
        Assert.Equal("fix it", loaded!.ReviewFeedback);
    }

    [Fact]
    public async Task ReviewTask_UnknownDecision_Throws()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
        var sut = BuildSut(CreateQueue());

        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            sut.ReviewTask(task.Id, "bogus", null, null, cancellationToken: CancellationToken.None));
    }

    [Fact]
    public async Task ReviewTask_Approve_WithActiveWorktree_MergesIntoTargetBranch()
    {
        if (!GitAvailable) return;
        var (task, list, wt) = await SeedWorktreeAsync(status: TaskStatus.WaitingForReview);

        File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n");
        GitRepoFixture.RunGit(wt.WorktreePath, "add", "feature.txt");
        GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "add feature");
        using (var ctx = _db.CreateContext())
        {
            var row = ctx.Worktrees.Single(w => w.TaskId == task.Id);
            row.HeadCommit = GitRepoFixture.RunGit(wt.WorktreePath, "rev-parse", "HEAD").Trim();
            ctx.SaveChanges();
        }

        var sut = BuildSut(CreateQueue());
        var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
        Assert.Equal("Done", result.Task.Status);
        Assert.True(File.Exists(Path.Combine(list.WorkingDir!, "feature.txt")));
        using var verify = _db.CreateContext();
        Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == task.Id).State);
    }

    // Regression coverage for the incident this fixes: an approve whose post-merge verify gate
    // runs long enough gets no MCP traffic at all until RunVerifyGateAsync returns, so Claude
    // Code's ~300s idle-silence abort kills the call -- the merge had already landed and
    // committed, but the task never reached Done and every dependsOn successor stayed blocked
    // until someone noticed and force-set the status by hand. Shrinks TaskMergeService's own
    // progress interval (not TaskWaitMcpTools') to observe a report without waiting 30s real time.
    [Fact]
    public async Task ReviewTask_ApproveWithSlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout()
    {
        if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }

        var original = TaskMergeService.ProgressReportInterval;
        TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50);
        try
        {
            var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
            File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n");
            var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
            var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
            await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);

            using (var ctx = _db.CreateContext())
                await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });

            var fakeVerify = new FakeVerifyCommandRunner
            {
                Result = new VerifyCommandResult(0, false, "ok"),
                Delay = TimeSpan.FromMilliseconds(300),
            };
            var sut = BuildSut(CreateQueue(), fakeVerify);

            var reports = new List<ProgressNotificationValue>();
            var progress = new Progress<ProgressNotificationValue>(reports.Add);

            var result = await sut.ReviewTask(
                task.Id, "approve", null, null, cancellationToken: CancellationToken.None, progress: progress);

            Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
            Assert.Equal("Done", result.Task.Status);
            // Progress<T> marshals via the SynchronizationContext captured at construction; give
            // any queued callbacks a beat to run before asserting on `reports`.
            await Task.Delay(200);
            Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running"));
        }
        finally
        {
            TaskMergeService.ProgressReportInterval = original;
        }
    }

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

        var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
        File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n");
        var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
        var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
        await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);

        using (var ctx = _db.CreateContext())
            await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });

        var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
        var sut = BuildSut(CreateQueue(), fakeVerify);

        var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
        Assert.Equal("Done", result.Task.Status);
    }

    [Fact]
    public async Task ReviewTask_Approve_ParentWithChildren_RunsUnitMerge()
    {
        if (!GitAvailable) return;
        var repo = new GitRepoFixture();
        _repos.Add(repo);
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");

        var listId = Guid.NewGuid().ToString();
        var parentId = Guid.NewGuid().ToString();
        var childId = Guid.NewGuid().ToString();
        using (var ctx = _db.CreateContext())
        {
            ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
            ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
                Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed });
            ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
                ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed });

            var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
            _worktreeCleanups.Add((repo.RepoDir, wtPath));
            var branch = $"claudedo/{childId[..8]}";
            GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
            File.WriteAllText(Path.Combine(wtPath, "child.txt"), "c\n");
            GitRepoFixture.RunGit(wtPath, "add", "child.txt");
            GitRepoFixture.RunGit(wtPath, "commit", "-m", "add child.txt");
            ctx.Worktrees.Add(new WorktreeEntity
            {
                TaskId = childId, Path = wtPath, BranchName = branch,
                BaseCommit = repo.BaseCommit,
                HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
                State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
            });
            await ctx.SaveChangesAsync();
        }

        var sut = BuildSut(CreateQueue());
        var result = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
        Assert.Equal("Done", result.Task.Status);
        Assert.True(File.Exists(Path.Combine(repo.RepoDir, "child.txt")));
        using var verify = _db.CreateContext();
        Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State);
    }

    // Regression coverage for the parent/children unit-merge path specifically: PlanningMergeOrchestrator
    // used to drain every child's merge (and verify gate) without ever forwarding review_task's own
    // progress token, so a multi-child approve with a slow verify command reported only the single
    // "review_task started" tick and then went silent for as long as every child's gate took combined.
    [Fact]
    public async Task ReviewTask_Approve_ParentWithChildren_SlowVerifyCommand_ReportsProgress()
    {
        if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }

        var original = TaskMergeService.ProgressReportInterval;
        TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50);
        try
        {
            var repo = new GitRepoFixture();
            _repos.Add(repo);
            GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");

            var listId = Guid.NewGuid().ToString();
            var parentId = Guid.NewGuid().ToString();
            var childId = Guid.NewGuid().ToString();
            using (var ctx = _db.CreateContext())
            {
                ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
                ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
                    Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed });
                ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
                    ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed });

                var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
                _worktreeCleanups.Add((repo.RepoDir, wtPath));
                var branch = $"claudedo/{childId[..8]}";
                GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
                File.WriteAllText(Path.Combine(wtPath, "child.txt"), "c\n");
                GitRepoFixture.RunGit(wtPath, "add", "child.txt");
                GitRepoFixture.RunGit(wtPath, "commit", "-m", "add child.txt");
                ctx.Worktrees.Add(new WorktreeEntity
                {
                    TaskId = childId, Path = wtPath, BranchName = branch,
                    BaseCommit = repo.BaseCommit,
                    HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
                    State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
                });
                await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" });
                await ctx.SaveChangesAsync();
            }

            var fakeVerify = new FakeVerifyCommandRunner
            {
                Result = new VerifyCommandResult(0, false, "ok"),
                Delay = TimeSpan.FromMilliseconds(300),
            };
            var sut = BuildSut(CreateQueue(), fakeVerify);

            var reports = new List<ProgressNotificationValue>();
            var progress = new Progress<ProgressNotificationValue>(reports.Add);

            var result = await sut.ReviewTask(
                parentId, "approve", null, "main", cancellationToken: CancellationToken.None, progress: progress);

            Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
            Assert.Equal("Done", result.Task.Status);
            await Task.Delay(200);
            Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running"));
        }
        finally
        {
            TaskMergeService.ProgressReportInterval = original;
        }
    }

    [Fact]
    public async Task ReviewTask_Approve_ParentWithChildren_ReportsEmptyChildByName()
    {
        // A blocked child (CLAUDEDO_BLOCKED, committed nothing) still gets an active worktree at
        // its base commit -- TaskRunner creates one before the run starts -- so "empty" shows up
        // as zero commits ahead of base, not as a missing worktree.
        if (!GitAvailable) return;
        var repo = new GitRepoFixture();
        _repos.Add(repo);
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");

        var listId = Guid.NewGuid().ToString();
        var parentId = Guid.NewGuid().ToString();
        var normalChildId = Guid.NewGuid().ToString();
        var blockedChildId = Guid.NewGuid().ToString();
        using (var ctx = _db.CreateContext())
        {
            ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
            ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
                Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed });
            ctx.Tasks.Add(new TaskEntity { Id = normalChildId, ListId = listId, Title = "normal child", CreatedAt = DateTime.UtcNow,
                ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed });
            ctx.Tasks.Add(new TaskEntity { Id = blockedChildId, ListId = listId, Title = "blocked child", CreatedAt = DateTime.UtcNow,
                ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 2, RoadblockCount = 1, Number = ++_numberSeed });

            var normalWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
            _worktreeCleanups.Add((repo.RepoDir, normalWtPath));
            var normalBranch = $"claudedo/{normalChildId[..8]}";
            GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", normalBranch, normalWtPath, repo.BaseCommit);
            File.WriteAllText(Path.Combine(normalWtPath, "child.txt"), "c\n");
            GitRepoFixture.RunGit(normalWtPath, "add", "child.txt");
            GitRepoFixture.RunGit(normalWtPath, "commit", "-m", "add child.txt");
            ctx.Worktrees.Add(new WorktreeEntity
            {
                TaskId = normalChildId, Path = normalWtPath, BranchName = normalBranch,
                BaseCommit = repo.BaseCommit,
                HeadCommit = GitRepoFixture.RunGit(normalWtPath, "rev-parse", "HEAD").Trim(),
                State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
            });

            var blockedWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
            _worktreeCleanups.Add((repo.RepoDir, blockedWtPath));
            var blockedBranch = $"claudedo/{blockedChildId[..8]}";
            GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", blockedBranch, blockedWtPath, repo.BaseCommit);
            ctx.Worktrees.Add(new WorktreeEntity
            {
                TaskId = blockedChildId, Path = blockedWtPath, BranchName = blockedBranch,
                BaseCommit = repo.BaseCommit,
                HeadCommit = repo.BaseCommit,
                State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
            });
            await ctx.SaveChangesAsync();
        }

        var sut = BuildSut(CreateQueue());
        var result = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
        Assert.Equal("Done", result.Task.Status);
        Assert.NotNull(result.EmptyChildren);
        var empty = Assert.Single(result.EmptyChildren!);
        Assert.Equal(blockedChildId, empty.Id);
        Assert.DoesNotContain(result.EmptyChildren!, c => c.Id == normalChildId);
    }

    [Fact]
    public async Task DeleteTask_RemovesTask()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId);
        var queue = CreateQueue();
        var sut = BuildSut(queue);

        await sut.DeleteTask(task.Id, CancellationToken.None);

        Assert.Null(await _tasks.GetByIdAsync(task.Id));
    }

    [Fact]
    public async Task DeleteTask_OnRunning_Throws()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
        var queue = CreateQueue();
        var sut = BuildSut(queue);

        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            sut.DeleteTask(task.Id, CancellationToken.None));
    }

    [Fact]
    public async Task DeleteTask_WithChildren_ThrowsUnderstandableMessage()
    {
        var listId = await SeedListAsync();
        var parent = await SeedTaskAsync(listId, status: TaskStatus.WaitingForChildren);
        await SeedTaskAsync(listId, status: TaskStatus.Idle, parentId: parent.Id);
        var sut = BuildSut(CreateQueue());

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
            sut.DeleteTask(parent.Id, CancellationToken.None));

        Assert.Contains("child task", ex.Message, StringComparison.OrdinalIgnoreCase);
        Assert.NotNull(await _tasks.GetByIdAsync(parent.Id));
    }

    [Fact]
    public async Task DeleteTask_NotFound_Throws()
    {
        var queue = CreateQueue();
        var sut = BuildSut(queue);

        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            sut.DeleteTask("does-not-exist", CancellationToken.None));
    }

    [Fact]
    public async Task DeleteTask_LastNonTerminalChild_AdvancesParentToWaitingForReview()
    {
        var listId = await SeedListAsync();
        var parent = await SeedTaskAsync(listId, status: TaskStatus.WaitingForChildren);
        await SeedTaskAsync(listId, status: TaskStatus.Done, parentId: parent.Id);
        var lastChild = await SeedTaskAsync(listId, status: TaskStatus.Idle, parentId: parent.Id);
        var sut = BuildSut(CreateQueue());

        await sut.DeleteTask(lastChild.Id, CancellationToken.None);

        var reloadedParent = await _tasks.GetByIdAsync(parent.Id);
        Assert.Equal(TaskStatus.WaitingForReview, reloadedParent!.Status);
    }

    [Fact]
    public async Task UpdateTaskStatus_Cancelled_CancelsAnIdleTask()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
        var sut = BuildSut(CreateQueue());

        var dto = await sut.UpdateTaskStatus(task.Id, "Cancelled", CancellationToken.None);

        Assert.Equal("Cancelled", dto.Status);
        var loaded = await _tasks.GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.Cancelled, loaded!.Status);
    }

    [Fact]
    public async Task UpdateTaskStatus_WaitingForReview_StillRejected()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
        var sut = BuildSut(CreateQueue());

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.UpdateTaskStatus(task.Id, "WaitingForReview", CancellationToken.None));
        Assert.Contains("not settable externally", ex.Message);
    }

    [Fact]
    public async Task UpdateTaskStatus_Done_NoWorktree_SetsDoneAndFinishedAt()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
        var sut = BuildSut(CreateQueue());

        var dto = await sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None);

        Assert.Equal("Done", dto.Status);
        var loaded = await _tasks.GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.Done, loaded!.Status);
        Assert.NotNull(loaded.FinishedAt);
    }

    [Fact]
    public async Task UpdateTaskStatus_Done_WithActiveWorktree_Throws()
    {
        if (!GitAvailable) return;
        var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
        var sut = BuildSut(CreateQueue());

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None));

        Assert.Contains("active worktree", ex.Message, StringComparison.OrdinalIgnoreCase);
        var loaded = await _tasks.GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.WaitingForReview, loaded!.Status);
    }

    [Fact]
    public async Task UpdateTaskStatus_Idle_OnRunningTask_Throws()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
        var sut = BuildSut(CreateQueue());

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.UpdateTaskStatus(task.Id, "Idle", CancellationToken.None));

        var loaded = await _tasks.GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.Running, loaded!.Status);
    }

    [Fact]
    public async Task UpdateTaskStatus_Done_OnRunningTask_Throws()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
        var sut = BuildSut(CreateQueue());

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None));

        Assert.Contains("running", ex.Message, StringComparison.OrdinalIgnoreCase);
        var loaded = await _tasks.GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.Running, loaded!.Status);
    }

    private ExternalMcpService NewService() => BuildSut(CreateQueue());

    private async Task<string> SeedIdleTask(string title = "t")
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, title, TaskStatus.Idle);
        return task.Id;
    }

    private async Task SeedAppSettingsAsync(string? reportExcludedPaths, int dailyPrepMaxTasks = 5)
    {
        var settings = new AppSettingsEntity
        {
            Id = AppSettingsEntity.SingletonId,
            ReportExcludedPaths = reportExcludedPaths,
            DailyPrepMaxTasks = dailyPrepMaxTasks,
        };
        // Upsert via AppSettingsRepository
        await using var ctx = _db.CreateContext();
        var repo = new AppSettingsRepository(ctx);
        await repo.UpdateAsync(settings);
    }

    [Fact]
    public async Task GetDailyPrepCandidates_filters_by_status_block_and_excluded_repo()
    {
        // List 1: included repo D:\work\repo — 4 tasks seeded
        var listId1 = Guid.NewGuid().ToString();
        await _lists.AddAsync(new ListEntity { Id = listId1, Name = "Work", WorkingDir = @"D:\work\repo", CreatedAt = DateTime.UtcNow });

        // idle, not blocked, not MyDay → should be candidate
        var idleUnblocked = new TaskEntity
        {
            Id = "idle-unblocked",
            ListId = listId1,
            Title = "Idle unblocked",
            Status = TaskStatus.Idle,
            CreatedAt = DateTime.UtcNow,
            CommitType = "chore",
            Number = ++_numberSeed,
        };
        await _tasks.AddAsync(idleUnblocked);

        // idle but blocked → excluded (BlockedByTaskId references idle-unblocked as its predecessor)
        var idleBlocked = new TaskEntity
        {
            Id = "idle-blocked",
            ListId = listId1,
            Title = "Idle blocked",
            Status = TaskStatus.Idle,
            BlockedByTaskId = "idle-unblocked",
            CreatedAt = DateTime.UtcNow,
            CommitType = "chore",
            Number = ++_numberSeed,
        };
        await _tasks.AddAsync(idleBlocked);

        // Done → excluded
        var doneTask = new TaskEntity
        {
            Id = "done-task",
            ListId = listId1,
            Title = "Done task",
            Status = TaskStatus.Done,
            CreatedAt = DateTime.UtcNow,
            CommitType = "chore",
            Number = ++_numberSeed,
        };
        await _tasks.AddAsync(doneTask);

        // idle, IsMyDay → goes into currentMyDay, not candidates
        var myDayTask = new TaskEntity
        {
            Id = "myday-task",
            ListId = listId1,
            Title = "MyDay task",
            Status = TaskStatus.Idle,
            IsMyDay = true,
            CreatedAt = DateTime.UtcNow,
            CommitType = "chore",
            Number = ++_numberSeed,
        };
        await _tasks.AddAsync(myDayTask);

        // List 2: excluded repo C:\Private\secret
        var listId2 = Guid.NewGuid().ToString();
        await _lists.AddAsync(new ListEntity { Id = listId2, Name = "Secret", WorkingDir = @"C:\Private\secret", CreatedAt = DateTime.UtcNow });

        var excludedRepoTask = new TaskEntity
        {
            Id = "excluded-repo-task",
            ListId = listId2,
            Title = "Excluded repo",
            Status = TaskStatus.Idle,
            CreatedAt = DateTime.UtcNow,
            CommitType = "chore",
            Number = ++_numberSeed,
        };
        await _tasks.AddAsync(excludedRepoTask);

        // List 3: no WorkingDir → excluded
        var listId3 = Guid.NewGuid().ToString();
        await _lists.AddAsync(new ListEntity { Id = listId3, Name = "NoRepo", WorkingDir = null, CreatedAt = DateTime.UtcNow });

        var noRepoTask = new TaskEntity
        {
            Id = "no-repo-task",
            ListId = listId3,
            Title = "No repo",
            Status = TaskStatus.Idle,
            CreatedAt = DateTime.UtcNow,
            CommitType = "chore",
            Number = ++_numberSeed,
        };
        await _tasks.AddAsync(noRepoTask);

        await SeedAppSettingsAsync(@"[""C:\\Private""]", dailyPrepMaxTasks: 5);

        var sut = BuildSut(CreateQueue());
        var result = await sut.GetDailyPrepCandidates(CancellationToken.None);

        Assert.Single(result.Candidates);
        Assert.Equal("idle-unblocked", result.Candidates[0].Id);
        Assert.Single(result.CurrentMyDay);
        Assert.Equal("myday-task", result.CurrentMyDay[0].Id);
        Assert.Equal(5, result.MaxTasks);
    }

    [Fact]
    public async Task SetMyDay_sets_flag_and_sort_order()
    {
        var svc = NewService();
        var id = await SeedIdleTask("My task");

        var dto = await svc.SetMyDay(id, isMyDay: true, sortOrder: 3, CancellationToken.None);

        Assert.True(dto.IsMyDay);
        Assert.Equal(3, dto.SortOrder);
    }

    [Fact]
    public async Task SetMyDay_rejects_when_cap_reached()
    {
        await SeedAppSettingsAsync(null, dailyPrepMaxTasks: 1);
        var svc = NewService();
        var first = await SeedIdleTask("a");
        var second = await SeedIdleTask("b");
        await svc.SetMyDay(first, true, null, CancellationToken.None);

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => svc.SetMyDay(second, true, null, CancellationToken.None));
        Assert.Contains("limit", ex.Message, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public async Task SetMyDay_unset_is_always_allowed()
    {
        var svc = NewService();
        var id = await SeedIdleTask("a");
        await svc.SetMyDay(id, true, null, CancellationToken.None);

        var dto = await svc.SetMyDay(id, false, null, CancellationToken.None);
        Assert.False(dto.IsMyDay);
    }

    // ── GetTaskWorktree ────────────────────────────────────────────────────────

    [Fact]
    public async Task GetTaskWorktree_NoWorktree_Throws()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId);
        var sut = BuildSut(CreateQueue());

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.GetTaskWorktree(task.Id, CancellationToken.None));
    }

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

        var (task, _, wt) = await SeedWorktreeAsync();
        var sut = BuildSut(CreateQueue());

        var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None);

        Assert.Equal(wt.BranchName, info.Branch);
        Assert.Equal(wt.BaseCommit, info.BaseCommit);
        Assert.Equal(0, info.Ahead);
        Assert.False(info.IsDirty);
    }

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

        var (task, _, _) = await SeedWorktreeAsync();
        var sut = BuildSut(CreateQueue());

        var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None);

        Assert.Null(info.MergeCommit);
    }

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

        var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done);
        File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "new\n");
        GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A");
        GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file");

        var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
        var sut = BuildSut(CreateQueue());
        await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None);

        var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None);

        var expected = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim();
        Assert.Equal(expected, info.MergeCommit);
    }

    // ── GetTaskDiff ────────────────────────────────────────────────────────────

    [Fact]
    public async Task GetTaskDiff_NoWorktree_Throws()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId);
        var sut = BuildSut(CreateQueue());

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.GetTaskDiff(task.Id, false, null, CancellationToken.None));
    }

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

        var (task, list, wt) = await SeedWorktreeAsync();
        File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");

        var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
        var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
        await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);

        var sut = BuildSut(CreateQueue());
        var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None);

        Assert.Contains("added.txt", diff.Files);
        Assert.False(diff.Truncated);
    }

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

        var (task, list, wt) = await SeedWorktreeAsync();
        File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
        File.WriteAllText(Path.Combine(wt.WorktreePath, "other.txt"), "other content");

        var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
        var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
        await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);

        var sut = BuildSut(CreateQueue());
        var diff = await sut.GetTaskDiff(task.Id, false, ["added.txt"], CancellationToken.None);

        Assert.Equal(new[] { "added.txt" }, diff.Files);
        Assert.DoesNotContain("other.txt", diff.Content);
    }

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

        var (task, list, wt) = await SeedWorktreeAsync();
        File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
        File.WriteAllText(Path.Combine(wt.WorktreePath, "other.txt"), "other content");

        var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
        var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
        await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);

        var sut = BuildSut(CreateQueue());
        var diff = await sut.GetTaskDiff(task.Id, true, ["other.txt"], CancellationToken.None);

        Assert.Equal(new[] { "other.txt" }, diff.Files);
        Assert.DoesNotContain("added.txt", diff.Content);
    }

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

        var repo = new GitRepoFixture();
        _repos.Add(repo);
        var listId = Guid.NewGuid().ToString();
        await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
        var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
        // No WorktreeEntity for this task -- it's a worktree-less list-handler host task
        // (Mission Control's "Let Claude handle it"): the handler merged its own commit
        // straight into the list's working dir after HandlerBaseCommit was stamped.

        File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
        GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
        var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();

        using (var ctx = _db.CreateContext())
        {
            var t = await ctx.Tasks.FindAsync(task.Id);
            t!.HandlerBaseCommit = repo.BaseCommit;
            t.HandlerHeadCommit = headCommit;
            await ctx.SaveChangesAsync();
        }

        var sut = BuildSut(CreateQueue());
        var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None);

        Assert.Contains("handled.txt", diff.Files);
        Assert.False(diff.Truncated);
    }

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

        var repo = new GitRepoFixture();
        _repos.Add(repo);
        var listId = Guid.NewGuid().ToString();
        await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
        var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);

        File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
        GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
        var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();

        using (var ctx = _db.CreateContext())
        {
            var t = await ctx.Tasks.FindAsync(task.Id);
            t!.HandlerBaseCommit = repo.BaseCommit;
            t.HandlerHeadCommit = headCommit;
            await ctx.SaveChangesAsync();
        }

        var sut = BuildSut(CreateQueue());
        var diff = await sut.GetTaskDiff(task.Id, true, null, CancellationToken.None);

        Assert.Contains("handled.txt", diff.Content);
    }

    // get_task_diff has no natural i/n (it's one git process, not a loop over items), so it
    // reports on a timer like merge_task's verify gate. Shrinking the interval to zero forces a
    // report on every poll of the still-running git process instead of waiting out a real
    // interval -- Task.Delay(TimeSpan.Zero) resolves synchronously, so the diff process (a real
    // subprocess, never already complete when polled) always loses the race at least once.
    [Fact]
    public async Task GetTaskDiff_ReportsProgressWhileDiffing()
    {
        if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }

        var original = ExternalMcpService.ProgressReportInterval;
        ExternalMcpService.ProgressReportInterval = TimeSpan.Zero;
        try
        {
            var (task, list, wt) = await SeedWorktreeAsync();
            File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
            var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
            var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
            await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);

            var sut = BuildSut(CreateQueue());
            var progress = new SyncProgress<ProgressNotificationValue>();

            var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None, progress);

            Assert.Contains("added.txt", diff.Files);
            Assert.NotEmpty(progress.Reports);
            Assert.All(progress.Reports, r => Assert.Contains("computing diff", r.Message));
        }
        finally
        {
            ExternalMcpService.ProgressReportInterval = original;
        }
    }

    // ── MergeTask ──────────────────────────────────────────────────────────────

    [Fact]
    public async Task MergeTask_NotDone_Throws()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
        var sut = BuildSut(CreateQueue());

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None));
        Assert.Contains("Done", ex.Message);
    }

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

        var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done);
        var sut = BuildSut(CreateQueue());

        var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, cancellationToken: CancellationToken.None);

        Assert.False(result.Merged);
        Assert.Null(result.MergeCommit);
    }

    // ── ListWorktrees ──────────────────────────────────────────────────────────

    [Fact]
    public async Task ListWorktrees_Empty_ReturnsEmpty()
    {
        var sut = BuildSut(CreateQueue());

        var rows = await sut.ListWorktrees(CancellationToken.None);

        Assert.Empty(rows);
    }

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

        var (task, _, _) = await SeedWorktreeAsync();
        var sut = BuildSut(CreateQueue());

        var rows = await sut.ListWorktrees(CancellationToken.None);

        Assert.Contains(rows, r => r.TaskId == task.Id);
    }

    // list_worktrees fans out one git status + one rev-parse per tracked worktree under
    // Task.WhenAll -- no natural i/n since rows finish out of order, so it reports on the same
    // elapsed-time cadence as CleanupTaskWorktree above.
    [Fact]
    public async Task ListWorktrees_ReportsProgressWhileSurveying()
    {
        if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }

        var original = ExternalMcpService.ProgressReportInterval;
        ExternalMcpService.ProgressReportInterval = TimeSpan.Zero;
        try
        {
            await SeedWorktreeAsync();
            var sut = BuildSut(CreateQueue());
            var progress = new SyncProgress<ProgressNotificationValue>();

            var rows = await sut.ListWorktrees(CancellationToken.None, progress);

            Assert.NotEmpty(rows);
            Assert.NotEmpty(progress.Reports);
        }
        finally
        {
            ExternalMcpService.ProgressReportInterval = original;
        }
    }

    // ── CleanupTaskWorktree ────────────────────────────────────────────────────

    [Fact]
    public async Task CleanupTaskWorktree_NotFound_Throws()
    {
        var sut = BuildSut(CreateQueue());

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.CleanupTaskWorktree("does-not-exist", false, CancellationToken.None));
    }

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

        var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Running);
        var sut = BuildSut(CreateQueue());

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None));
        Assert.Contains("running", ex.Message, StringComparison.OrdinalIgnoreCase);
    }

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

        var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done);
        var sut = BuildSut(CreateQueue());

        var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None);

        Assert.True(result.Removed);
        Assert.False(Directory.Exists(wt.WorktreePath));
    }

    // cleanup_task_worktree has no natural i/n either (dirty check + one removal, not a loop), so
    // it reports on the same timer/zero-interval technique as GetTaskDiff above.
    [Fact]
    public async Task CleanupTaskWorktree_ReportsProgressWhileRemoving()
    {
        if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }

        var original = ExternalMcpService.ProgressReportInterval;
        ExternalMcpService.ProgressReportInterval = TimeSpan.Zero;
        try
        {
            var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done);
            var sut = BuildSut(CreateQueue());
            var progress = new SyncProgress<ProgressNotificationValue>();

            var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None, progress);

            Assert.True(result.Removed);
            Assert.False(Directory.Exists(wt.WorktreePath));
            Assert.NotEmpty(progress.Reports);
        }
        finally
        {
            ExternalMcpService.ProgressReportInterval = original;
        }
    }

    // ── GetTaskConfig ─────────────────────────────────────────────────────────

    private ConfigMcpTools BuildConfigSut() => new(_lists, _tasks, _broadcaster, _db.CreateFactory());

    [Fact]
    public async Task GetTaskConfig_NotFound_Throws()
    {
        var sut = BuildConfigSut();
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.GetTaskConfig("does-not-exist", CancellationToken.None));
    }

    [Fact]
    public async Task GetTaskConfig_NoOverrides_ReturnsNull()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId);
        var sut = BuildConfigSut();

        var result = await sut.GetTaskConfig(task.Id, CancellationToken.None);

        Assert.False(result.Found);
        Assert.Null(result.Config);
    }

    [Fact]
    public async Task GetTaskConfig_WithOverrides_ReturnsValues()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId);
        await _tasks.UpdateAgentSettingsAsync(task.Id, "claude-sonnet-4-6", "be concise", null, 10, ct: CancellationToken.None);
        var sut = BuildConfigSut();

        var result = await sut.GetTaskConfig(task.Id, CancellationToken.None);

        Assert.True(result.Found);
        Assert.NotNull(result.Config);
        Assert.Equal("claude-sonnet-4-6", result.Config!.Model);
        Assert.Equal("be concise", result.Config.SystemPrompt);
        Assert.Null(result.Config.AgentPath);
        Assert.Equal(10, result.Config.MaxTurns);
    }

    // ── ListTasks status filter ───────────────────────────────────────────────

    [Fact]
    public async Task ListTasks_FilterByWaitingForReview_ReturnsMatchingTasks()
    {
        var listId = await SeedListAsync();
        await SeedTaskAsync(listId, "wfr", TaskStatus.WaitingForReview);
        await SeedTaskAsync(listId, "idle", TaskStatus.Idle);
        var sut = NewService();

        var result = await sut.ListTasks(listId, null, "WaitingForReview", cancellationToken: CancellationToken.None);

        Assert.NotNull(result.Tasks);
        Assert.Single(result.Tasks!);
        Assert.Equal("WaitingForReview", result.Tasks![0].Status);
    }

    [Fact]
    public async Task ListTasks_FilterByWaitingForChildren_ReturnsMatchingTasks()
    {
        var listId = await SeedListAsync();
        await SeedTaskAsync(listId, "wfc", TaskStatus.WaitingForChildren);
        await SeedTaskAsync(listId, "done", TaskStatus.Done);
        var sut = NewService();

        var result = await sut.ListTasks(listId, null, "WaitingForChildren", cancellationToken: CancellationToken.None);

        Assert.NotNull(result.Tasks);
        Assert.Single(result.Tasks!);
        Assert.Equal("WaitingForChildren", result.Tasks![0].Status);
    }

    [Fact]
    public async Task ListTasks_Default_ReturnsLeanReferences_NoDescriptionOrTasksFull()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, "with desc");
        task.Description = "a long description that should not come back by default";
        await _tasks.UpdateAsync(task);
        var sut = NewService();

        var result = await sut.ListTasks(listId, cancellationToken: CancellationToken.None);

        Assert.False(result.IncludeDescription);
        Assert.NotNull(result.Tasks);
        Assert.Null(result.TasksFull);
        Assert.Single(result.Tasks!);
        Assert.Equal(task.Id, result.Tasks![0].Id);
        Assert.Equal(task.Number, result.Tasks![0].Number);
    }

    [Fact]
    public async Task ListTasks_IncludeDescriptionTrue_ReturnsFullTasks()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, "with desc");
        task.Description = "the full description";
        await _tasks.UpdateAsync(task);
        var sut = NewService();

        var result = await sut.ListTasks(listId, includeDescription: true, cancellationToken: CancellationToken.None);

        Assert.True(result.IncludeDescription);
        Assert.Null(result.Tasks);
        Assert.NotNull(result.TasksFull);
        Assert.Single(result.TasksFull!);
        Assert.Equal("the full description", result.TasksFull![0].Description);
        Assert.Equal(task.Number, result.TasksFull![0].Number);
    }

    // ── MergeTask allowWaitingForReview ───────────────────────────────────────

    [Fact]
    public async Task MergeTask_WaitingForReview_WithoutFlag_Throws()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
        var sut = BuildSut(CreateQueue());

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None));
        Assert.Contains("Done", ex.Message);
    }

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

        var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
        var sut = BuildSut(CreateQueue());

        var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, allowWaitingForReview: true, cancellationToken: CancellationToken.None);

        Assert.False(result.Merged);
        Assert.Null(result.MergeCommit);
    }

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

        var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
        File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
        var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
        var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
        await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);

        var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir, CancellationToken.None);
        var sut = BuildSut(CreateQueue());

        var result = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, cancellationToken: CancellationToken.None);

        Assert.True(result.Merged);
        var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.Done, reloaded!.Status);
    }

    // ── leaveConflictsInTree ──────────────────────────────────────────────────

    private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedConflictingWorktreeAsync(
        TaskStatus status = TaskStatus.WaitingForReview)
    {
        var (task, list, wt) = await SeedWorktreeAsync(status);

        File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# from worktree\n");
        GitRepoFixture.RunGit(wt.WorktreePath, "add", "README.md");
        GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "worktree edit");

        File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# from main\n");
        GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
        GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "main edit");

        return (task, list, wt);
    }

    // Three edits spread far enough apart in a 20-line file that git's 3-way merge keeps them as three
    // separate, non-adjacent hunks instead of folding them into one.
    private static readonly int[] MultiHunkEditLines = [3, 9, 15];

    private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedMultiHunkConflictingWorktreeAsync()
    {
        var repo = new GitRepoFixture();
        _repos.Add(repo);

        var baseLines = Enumerable.Range(1, 20).Select(n => $"line{n}").ToArray();
        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), string.Join('\n', baseLines) + "\n");
        GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "multi-line base");

        var listId = Guid.NewGuid().ToString();
        var list = new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow };
        await _lists.AddAsync(list);
        var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);

        var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
        var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
        var wt = await mgr.CreateAsync(task, list, CancellationToken.None);
        _worktreeCleanups.Add((repo.RepoDir, wt.WorktreePath));

        WriteMultiHunkVariant(wt.WorktreePath, baseLines, "WT");
        GitRepoFixture.RunGit(wt.WorktreePath, "add", "README.md");
        GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "worktree edit");

        WriteMultiHunkVariant(list.WorkingDir!, baseLines, "MAIN");
        GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
        GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "main edit");

        return (task, list, wt);
    }

    private static void WriteMultiHunkVariant(string dir, string[] baseLines, string tag)
    {
        var lines = (string[])baseLines.Clone();
        foreach (var lineNum in MultiHunkEditLines)
            lines[lineNum - 1] = $"{tag}-{lineNum}";
        File.WriteAllText(Path.Combine(dir, "README.md"), string.Join('\n', lines) + "\n");
    }

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

        var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
        var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
        var sut = BuildSut(CreateQueue());

        var result = await sut.MergeTask(task.Id, target, true, dryRun: false,
            allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);

        Assert.False(result.Merged);
        Assert.True(result.ConflictsInTree);
        Assert.Equal(list.WorkingDir, result.RepoPath);
        Assert.Contains("README.md", result.Conflicts);
        Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
        Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
        var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status);

        GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
    }

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

        var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
        var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
        var sut = BuildSut(CreateQueue());

        var result = await sut.MergeTask(task.Id, target, true, dryRun: false,
            allowWaitingForReview: true, leaveConflictsInTree: false, cancellationToken: CancellationToken.None);

        Assert.False(result.Merged);
        Assert.False(result.ConflictsInTree);
        Assert.Null(result.RepoPath);
        Assert.Contains("README.md", result.Conflicts);
        Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
        Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
    }

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

        var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
        var sut = BuildSut(CreateQueue());

        var result = await sut.ReviewTask(task.Id, "approve", null, null,
            leaveConflictsInTree: true, cancellationToken: CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusConflict, result.MergeStatus);
        Assert.Equal(list.WorkingDir, result.RepoPath);
        Assert.Contains("continue_merge", result.MergeMessage);
        Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
        Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
        Assert.Equal("WaitingForReview", result.Task.Status);

        GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
    }

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

        var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
        var sut = BuildSut(CreateQueue());

        var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusConflict, result.MergeStatus);
        Assert.Null(result.RepoPath);
        Assert.Contains("ClaudeDo UI", result.MergeMessage);
        Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
        Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
        Assert.Equal("WaitingForReview", result.Task.Status);
    }

    // ── continue_merge / abort_merge ──────────────────────────────────────────

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

        var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
        var sut = BuildSut(CreateQueue());
        await sut.ReviewTask(task.Id, "approve", null, null,
            leaveConflictsInTree: true, cancellationToken: CancellationToken.None);

        // Resolve the conflict on disk and stage it.
        File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
        GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");

        var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);

        Assert.True(result.Merged);
        Assert.Equal("Done", result.TaskStatus);
        Assert.Empty(result.Conflicts);
        Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
    }

    // Regression coverage for the same class of incident as ReviewTask_ApproveWithSlowVerifyCommand
    // above, but for the resume path: continue_merge re-runs the verify gate too, and until this
    // fix it never forwarded its progress token into RunVerifyGateAsync at all.
    [Fact]
    public async Task ContinueMerge_SlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout()
    {
        if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }

        var original = TaskMergeService.ProgressReportInterval;
        TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50);
        try
        {
            var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
            var plainSut = BuildSut(CreateQueue());
            await plainSut.ReviewTask(task.Id, "approve", null, null,
                leaveConflictsInTree: true, cancellationToken: CancellationToken.None);

            File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
            GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");

            using (var ctx = _db.CreateContext())
                await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });

            var fakeVerify = new FakeVerifyCommandRunner
            {
                Result = new VerifyCommandResult(0, false, "ok"),
                Delay = TimeSpan.FromMilliseconds(300),
            };
            var sut = BuildSut(CreateQueue(), fakeVerify);

            var reports = new List<ProgressNotificationValue>();
            var progress = new Progress<ProgressNotificationValue>(reports.Add);

            var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None, progress: progress);

            Assert.True(result.Merged);
            Assert.Equal("Done", result.TaskStatus);
            await Task.Delay(200);
            Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running"));
        }
        finally
        {
            TaskMergeService.ProgressReportInterval = original;
        }
    }

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

        var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
        var sut = BuildSut(CreateQueue());
        await sut.ReviewTask(task.Id, "approve", null, null,
            leaveConflictsInTree: true, cancellationToken: CancellationToken.None);

        // Markers never resolved — continue must refuse and list the files.
        var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);

        Assert.False(result.Merged);
        Assert.Contains("README.md", result.Conflicts);
        Assert.Equal(list.WorkingDir, result.RepoPath);
        Assert.Equal("WaitingForReview", result.TaskStatus);
        Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));

        GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
    }

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

        var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
        var sut = BuildSut(CreateQueue());
        await sut.ReviewTask(task.Id, "approve", null, null,
            leaveConflictsInTree: true, cancellationToken: CancellationToken.None);

        var dto = await sut.AbortMerge(task.Id, CancellationToken.None);

        Assert.Equal("WaitingForReview", dto.Status);
        Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
        Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
    }

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

        var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
        var sut = BuildSut(CreateQueue());

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None));
        Assert.Contains("mid-merge", ex.Message);
    }

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

        var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
        var sut = BuildSut(CreateQueue());

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.AbortMerge(task.Id, CancellationToken.None));
        Assert.Contains("mid-merge", ex.Message);
    }

    // ── get_merge_conflicts / resolve_conflict_hunk ─────────────────────────────

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

        var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
        var sut = BuildSut(CreateQueue());
        await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);

        var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None);

        Assert.Equal(1, result.RemainingHunks);
        var file = Assert.Single(result.Files);
        Assert.Equal("README.md", file.Path);
        Assert.False(file.IsBinary);
        var hunk = Assert.Single(file.Hunks);
        Assert.Equal(0, hunk.Index);
        Assert.True(hunk.StartLine >= 1);
        Assert.Equal("# from main", hunk.Ours.TrimEnd('\r', '\n'));
        Assert.Equal("# from worktree", hunk.Theirs.TrimEnd('\r', '\n'));
        Assert.NotEmpty(result.ConflictStyleNote);

        GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
    }

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

        var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
        var sut = BuildSut(CreateQueue());

        var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None);

        Assert.Equal(0, result.RemainingHunks);
        Assert.Empty(result.Files);
    }

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

        var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync();
        var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
        var sut = BuildSut(CreateQueue());

        var merge = await sut.MergeTask(task.Id, target, true, dryRun: false,
            allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);
        Assert.True(merge.ConflictsInTree);

        var before = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
        Assert.Equal(3, before.RemainingHunks);
        Assert.Equal(3, Assert.Single(before.Files).Hunks.Count);

        // Resolve hunk 0 three times in a row: each resolution removes a hunk from the front, so the
        // next still-open hunk always slides into index 0.
        var r1 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None);
        Assert.Equal(2, r1.RemainingHunks);

        var r2 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "theirs", CancellationToken.None);
        Assert.Equal(1, r2.RemainingHunks);

        var r3 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "CUSTOM-15\n", CancellationToken.None);
        Assert.Equal(0, r3.RemainingHunks);

        var finalConflicts = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
        Assert.Equal(0, finalConflicts.RemainingHunks);

        var continueResult = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);
        Assert.True(continueResult.Merged);
        Assert.Equal("Done", continueResult.TaskStatus);

        var mergedText = File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"));
        Assert.Contains("MAIN-3", mergedText);   // hunk 1 resolved "ours" (the target branch)
        Assert.Contains("WT-9", mergedText);     // hunk 2 resolved "theirs" (the incoming branch)
        Assert.Contains("CUSTOM-15", mergedText); // hunk 3 resolved to literal text
        Assert.DoesNotContain("<<<<<<<", mergedText);
    }

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

        var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync();
        var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
        var sut = BuildSut(CreateQueue());
        await sut.MergeTask(task.Id, target, true, dryRun: false,
            allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);

        // Resolve only one of the three hunks.
        await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None);

        // continue_merge must still refuse: writing one hunk must not have staged the file as resolved.
        var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None);

        Assert.False(result.Merged);
        Assert.Contains("README.md", result.Conflicts);
        Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));

        GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
    }

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

        var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
        var sut = BuildSut(CreateQueue());
        await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.ResolveConflictHunk(task.Id, "not-a-real-file.md", 0, "ours", CancellationToken.None));
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.ResolveConflictHunk(task.Id, "README.md", 5, "ours", CancellationToken.None));

        GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
    }

    // ── RevertMerge ────────────────────────────────────────────────────────────

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

        var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done);
        var sut = BuildSut(CreateQueue());

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.RevertMerge(task.Id, "main", CancellationToken.None));
        Assert.Contains("expected Merged", ex.Message);
    }

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

        var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done);
        File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "new\n");
        GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A");
        GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file");

        var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
        var sut = BuildSut(CreateQueue());
        await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None);
        Assert.True(File.Exists(Path.Combine(list.WorkingDir!, "added.txt")));

        var result = await sut.RevertMerge(task.Id, target, CancellationToken.None);

        Assert.True(result.Reverted);
        Assert.False(string.IsNullOrWhiteSpace(result.RevertCommit));
        Assert.Empty(result.Conflicts);
        Assert.False(File.Exists(Path.Combine(list.WorkingDir!, "added.txt")));

        var reloaded = await _tasks.GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status);
    }

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

        var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done);
        File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# test repo\nfeature\n");
        GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A");
        GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: append feature line");

        var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
        var sut = BuildSut(CreateQueue());
        await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None);

        // A later commit on main edits the exact line the merge introduced, so the revert conflicts.
        File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# test repo\npost-merge edit\n");
        GitRepoFixture.RunGit(list.WorkingDir!, "add", "-A");
        GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "chore: edit the same line");
        var headBefore = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim();

        var result = await sut.RevertMerge(task.Id, target, CancellationToken.None);

        Assert.False(result.Reverted);
        Assert.Contains("README.md", result.Conflicts);
        Assert.False(await new GitService().IsMidRevertAsync(list.WorkingDir!));
        Assert.Equal(headBefore, GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim());

        var reloaded = await _tasks.GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.Done, reloaded!.Status);
    }

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

        var repo = new GitRepoFixture();
        _repos.Add(repo);
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");

        // main edits README.md so the child's edit conflicts.
        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
        GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");

        var listId = Guid.NewGuid().ToString();
        var parentId = Guid.NewGuid().ToString();
        var childId = Guid.NewGuid().ToString();
        using (var ctx = _db.CreateContext())
        {
            ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
            ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
                Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed });
            ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
                ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed });

            var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
            _worktreeCleanups.Add((repo.RepoDir, wtPath));
            var branch = $"claudedo/{childId[..8]}";
            GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
            File.WriteAllText(Path.Combine(wtPath, "README.md"), "# child change\n");
            GitRepoFixture.RunGit(wtPath, "add", "README.md");
            GitRepoFixture.RunGit(wtPath, "commit", "-m", "child edit");
            ctx.Worktrees.Add(new WorktreeEntity
            {
                TaskId = childId, Path = wtPath, BranchName = branch,
                BaseCommit = repo.BaseCommit,
                HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
                State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
            });
            await ctx.SaveChangesAsync();
        }

        var sut = BuildSut(CreateQueue());
        var review = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None);
        Assert.Equal(TaskMergeService.StatusConflict, review.MergeStatus);
        Assert.Equal(repo.RepoDir, review.RepoPath);
        Assert.Contains("continue_merge", review.MergeMessage);

        // Resolve the conflict on disk.
        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");

        var result = await sut.ContinueMerge(parentId, cancellationToken: CancellationToken.None);

        Assert.True(result.Merged);
        Assert.Equal("Done", result.TaskStatus);
        Assert.Empty(result.Conflicts);
        Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
        using var verify = _db.CreateContext();
        Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State);
    }

    // ── PreviewMerge / PreviewMergeSet ────────────────────────────────────────

    private async Task<string> CreateActiveWorktreeAsync(
        GitRepoFixture repo, string taskId, string fileName, string content)
    {
        var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
        _worktreeCleanups.Add((repo.RepoDir, wtPath));
        var branch = $"claudedo/{taskId[..8]}";
        GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
        File.WriteAllText(Path.Combine(wtPath, fileName), content);
        GitRepoFixture.RunGit(wtPath, "add", fileName);
        GitRepoFixture.RunGit(wtPath, "commit", "-m", $"edit {fileName}");

        using var ctx = _db.CreateContext();
        ctx.Worktrees.Add(new WorktreeEntity
        {
            TaskId = taskId, Path = wtPath, BranchName = branch,
            BaseCommit = repo.BaseCommit,
            HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
            State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
        });
        await ctx.SaveChangesAsync();
        return wtPath;
    }

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

        var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
        File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
        var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
        var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
        await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);

        var git = new GitService();
        var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
        var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
        var sut = BuildSut(CreateQueue());

        var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);

        Assert.Equal(TaskMergeService.PreviewClean, result.Status);
        Assert.Empty(result.ConflictFiles);
        Assert.Equal(1, result.ChangedFileCount);
        Assert.Equal(0, result.Behind);
        Assert.False(result.IsEmpty);
        Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None));
        Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
    }

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

        // Worktree created but nothing committed beyond base -- e.g. a child that reported
        // CLAUDEDO_BLOCKED before writing any code.
        var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
        var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
        var sut = BuildSut(CreateQueue());

        var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);

        Assert.Equal(TaskMergeService.PreviewClean, result.Status);
        Assert.Equal(0, result.ChangedFileCount);
        Assert.True(result.IsEmpty);
    }

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

        var repo = new GitRepoFixture();
        _repos.Add(repo);
        var listId = Guid.NewGuid().ToString();
        await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
        var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
        // Worktree-less list-handler host task whose handler range never actually committed
        // anything: HandlerBaseCommit == HandlerHeadCommit, not "no worktree -> unknown".
        using (var ctx = _db.CreateContext())
        {
            var t = await ctx.Tasks.FindAsync(task.Id);
            t!.HandlerBaseCommit = repo.BaseCommit;
            t.HandlerHeadCommit = repo.BaseCommit;
            await ctx.SaveChangesAsync();
        }

        var sut = BuildSut(CreateQueue());
        var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);

        Assert.Equal(TaskMergeService.PreviewClean, result.Status);
        Assert.Equal(0, result.ChangedFileCount);
        Assert.True(result.IsEmpty);
    }

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

        var repo = new GitRepoFixture();
        _repos.Add(repo);
        var listId = Guid.NewGuid().ToString();
        await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
        var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);

        File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
        GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
        var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();

        using (var ctx = _db.CreateContext())
        {
            var t = await ctx.Tasks.FindAsync(task.Id);
            t!.HandlerBaseCommit = repo.BaseCommit;
            t.HandlerHeadCommit = headCommit;
            await ctx.SaveChangesAsync();
        }

        var sut = BuildSut(CreateQueue());
        var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);

        Assert.Equal(TaskMergeService.PreviewClean, result.Status);
        Assert.Equal(1, result.ChangedFileCount);
        Assert.False(result.IsEmpty);
    }

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

        var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
        var git = new GitService();
        var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
        var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
        var sut = BuildSut(CreateQueue());

        var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);

        Assert.Equal(TaskMergeService.PreviewConflict, result.Status);
        Assert.Contains("README.md", result.ConflictFiles);
        Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None));
        Assert.False(await git.IsMidMergeAsync(list.WorkingDir!));
        Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
    }

    [Fact]
    public async Task PreviewMerge_NoWorktree_Throws()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId);
        var sut = BuildSut(CreateQueue());

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.PreviewMerge(task.Id, null, CancellationToken.None));
    }

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

        var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
        using (var ctx = _db.CreateContext())
        {
            var row = await ctx.Lists.FindAsync(list.Id);
            row!.WorkingDir = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}");
            await ctx.SaveChangesAsync();
        }

        var sut = BuildSut(CreateQueue());
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.PreviewMerge(task.Id, null, CancellationToken.None));
        Assert.Contains("working directory", ex.Message, StringComparison.OrdinalIgnoreCase);
    }

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

        var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
        File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
        var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
        var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
        await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);

        using (var ctx = _db.CreateContext())
            await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });

        var git = new GitService();
        var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
        var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
        var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
        var sut = BuildSut(CreateQueue(), fakeVerify);

        var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);

        Assert.Equal(TaskMergeService.PreviewClean, result.Status);
        Assert.Equal(0, result.VerifyExitCode);
        Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
        Assert.NotEqual(list.WorkingDir, fakeVerify.CapturedWorkingDir);
        Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None));
        Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
    }

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

        var repo = new GitRepoFixture();
        _repos.Add(repo);
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");

        var listId = Guid.NewGuid().ToString();
        await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
        var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
        await CreateActiveWorktreeAsync(repo, task.Id, "shared.txt", "from branch\n");

        // The target branch itself moved on and touched the same file after the fork point.
        File.WriteAllText(Path.Combine(repo.RepoDir, "shared.txt"), "from main\n");
        GitRepoFixture.RunGit(repo.RepoDir, "add", "shared.txt");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main also touched shared.txt");

        var sut = BuildSut(CreateQueue());
        var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None);

        Assert.Equal(1, result.Behind);
        Assert.NotNull(result.StaleFiles);
        Assert.Contains("shared.txt", result.StaleFiles!);
    }

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

        var repo = new GitRepoFixture();
        _repos.Add(repo);
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");

        var listId = Guid.NewGuid().ToString();
        await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
        var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
        await CreateActiveWorktreeAsync(repo, task.Id, "branch-only.txt", "from branch\n");

        // Target moved on (behind > 0) but touched an entirely different file -- a stale
        // branch that still collides with nothing.
        File.WriteAllText(Path.Combine(repo.RepoDir, "unrelated.txt"), "from main\n");
        GitRepoFixture.RunGit(repo.RepoDir, "add", "unrelated.txt");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main touched something else");

        var sut = BuildSut(CreateQueue());
        var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None);

        Assert.Equal(1, result.Behind);
        Assert.NotNull(result.StaleFiles);
        Assert.Empty(result.StaleFiles!);
    }

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

        var repo = new GitRepoFixture();
        _repos.Add(repo);
        var listId = Guid.NewGuid().ToString();
        await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
        var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);

        File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
        GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
        var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();

        using (var ctx = _db.CreateContext())
        {
            var t = await ctx.Tasks.FindAsync(task.Id);
            t!.HandlerBaseCommit = repo.BaseCommit;
            t.HandlerHeadCommit = headCommit;
            await ctx.SaveChangesAsync();
        }

        var sut = BuildSut(CreateQueue());
        var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);

        Assert.NotNull(result.StaleFiles);
        Assert.Empty(result.StaleFiles!);
    }

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

        var repo = new GitRepoFixture();
        _repos.Add(repo);
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");

        var listId = Guid.NewGuid().ToString();
        await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
        var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
        var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);

        await CreateActiveWorktreeAsync(repo, taskA.Id, "shared.txt", "a change\n");
        await CreateActiveWorktreeAsync(repo, taskB.Id, "shared.txt", "b change\n");

        var sut = BuildSut(CreateQueue());
        var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);

        Assert.Equal(2, result.Tasks.Count);
        Assert.All(result.Tasks, t => Assert.Null(t.Error));
        Assert.All(result.Tasks, t => Assert.Equal(TaskMergeService.PreviewClean, t.Status));
        var overlap = Assert.Single(result.Overlaps);
        Assert.Equal("shared.txt", overlap.File);
        Assert.Equal(
            new[] { taskA.Id, taskB.Id }.OrderBy(x => x),
            overlap.TaskIds.OrderBy(x => x));
        // Equal file sets overlap but neither is a PROPER subset of the other.
        Assert.Empty(result.Subsets);
    }

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

        var repo = new GitRepoFixture();
        _repos.Add(repo);
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");

        var listId = Guid.NewGuid().ToString();
        await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
        var subsetTask = await SeedTaskAsync(listId, "subset", TaskStatus.WaitingForReview);
        var supersetTask = await SeedTaskAsync(listId, "superset", TaskStatus.WaitingForReview);

        await CreateActiveWorktreeAsync(repo, subsetTask.Id, "shared.txt", "shared change\n");

        var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
        _worktreeCleanups.Add((repo.RepoDir, wtPath));
        var branch = $"claudedo/{supersetTask.Id[..8]}";
        GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
        File.WriteAllText(Path.Combine(wtPath, "shared.txt"), "shared change too\n");
        File.WriteAllText(Path.Combine(wtPath, "extra.txt"), "extra\n");
        GitRepoFixture.RunGit(wtPath, "add", "-A");
        GitRepoFixture.RunGit(wtPath, "commit", "-m", "edit shared+extra");
        using (var ctx = _db.CreateContext())
        {
            ctx.Worktrees.Add(new WorktreeEntity
            {
                TaskId = supersetTask.Id, Path = wtPath, BranchName = branch,
                BaseCommit = repo.BaseCommit,
                HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
                State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
            });
            await ctx.SaveChangesAsync();
        }

        var sut = BuildSut(CreateQueue());
        var result = await sut.PreviewMergeSet([subsetTask.Id, supersetTask.Id], "main", cancellationToken: CancellationToken.None);

        var relation = Assert.Single(result.Subsets);
        Assert.Equal(subsetTask.Id, relation.TaskId);
        Assert.Equal(supersetTask.Id, relation.SupersetTaskId);
    }

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

        var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
        var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
        var sut = BuildSut(CreateQueue());

        var result = await sut.PreviewMergeSet([task.Id], target, cancellationToken: CancellationToken.None);

        Assert.Single(result.Tasks);
        Assert.Empty(result.Overlaps);
    }

    [Fact]
    public async Task PreviewMergeSet_ReportsProgressPerTask()
    {
        var listId = await SeedListAsync();
        var t1 = await SeedTaskAsync(listId, "a");
        var t2 = await SeedTaskAsync(listId, "b");
        var sut = BuildSut(CreateQueue());
        var progress = new SyncProgress<ProgressNotificationValue>();

        // Neither task has a worktree -- each entry fails individually (PreviewUnavailable), but
        // the loop still reports progress per task since a per-task InvalidOperationException
        // doesn't abort the batch.
        await sut.PreviewMergeSet(
            [t1.Id, t2.Id], "main", cancellationToken: CancellationToken.None, progress: progress);

        Assert.Equal(2, progress.Reports.Count);
        Assert.Equal("Previewing merges (1/2)", progress.Reports[0].Message);
        Assert.Equal("Previewing merges (2/2)", progress.Reports[1].Message);
        Assert.Equal(2, progress.Reports[1].Total);
    }

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

        var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
        var listId2 = await SeedListAsync("no-worktree-list");
        var noWorktreeTask = await SeedTaskAsync(listId2, "b", TaskStatus.Idle);
        var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
        var sut = BuildSut(CreateQueue());

        var result = await sut.PreviewMergeSet([task.Id, noWorktreeTask.Id], target, cancellationToken: CancellationToken.None);

        Assert.Equal(2, result.Tasks.Count);
        var ok = result.Tasks.Single(t => t.TaskId == task.Id);
        Assert.Null(ok.Error);
        Assert.Equal(TaskMergeService.PreviewClean, ok.Status);
        var failed = result.Tasks.Single(t => t.TaskId == noWorktreeTask.Id);
        Assert.NotNull(failed.Error);
        Assert.Equal(TaskMergeService.PreviewUnavailable, failed.Status);
        Assert.Empty(result.Overlaps);
    }

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

        var repo = new GitRepoFixture();
        _repos.Add(repo);
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");

        var listId = Guid.NewGuid().ToString();
        await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
        var emptyTask = await SeedTaskAsync(listId, "empty", TaskStatus.WaitingForReview);
        var fullTask = await SeedTaskAsync(listId, "full", TaskStatus.WaitingForReview);

        var emptyBranch = $"claudedo/{emptyTask.Id[..8]}";
        var emptyWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
        _worktreeCleanups.Add((repo.RepoDir, emptyWtPath));
        GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", emptyBranch, emptyWtPath, repo.BaseCommit);
        using (var ctx = _db.CreateContext())
        {
            ctx.Worktrees.Add(new WorktreeEntity
            {
                TaskId = emptyTask.Id, Path = emptyWtPath, BranchName = emptyBranch,
                BaseCommit = repo.BaseCommit,
                HeadCommit = repo.BaseCommit,
                State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
            });
            await ctx.SaveChangesAsync();
        }

        await CreateActiveWorktreeAsync(repo, fullTask.Id, "full.txt", "content\n");

        var sut = BuildSut(CreateQueue());
        var result = await sut.PreviewMergeSet([emptyTask.Id, fullTask.Id], "main", cancellationToken: CancellationToken.None);

        var emptyEntry = result.Tasks.Single(t => t.TaskId == emptyTask.Id);
        var fullEntry = result.Tasks.Single(t => t.TaskId == fullTask.Id);
        Assert.Null(emptyEntry.Error);
        Assert.True(emptyEntry.IsEmpty);
        Assert.Null(fullEntry.Error);
        Assert.False(fullEntry.IsEmpty);
    }

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

        var repo = new GitRepoFixture();
        _repos.Add(repo);
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
        var listId = Guid.NewGuid().ToString();
        await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
        using (var ctx = _db.CreateContext())
            await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" });
        var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
        await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n");

        var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
        var sut = BuildSut(CreateQueue(), fakeVerify);

        var result = await sut.PreviewMergeSet([task.Id], "main", cancellationToken: CancellationToken.None);

        Assert.Null(result.Tasks.Single().VerifyExitCode);
        Assert.Null(fakeVerify.CapturedCommand);
    }

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

        var repo = new GitRepoFixture();
        _repos.Add(repo);
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
        var listId = Guid.NewGuid().ToString();
        await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
        using (var ctx = _db.CreateContext())
            await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" });
        var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
        await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n");

        var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
        var sut = BuildSut(CreateQueue(), fakeVerify);

        var result = await sut.PreviewMergeSet([task.Id], "main", runVerify: true, cancellationToken: CancellationToken.None);

        Assert.Equal(0, result.Tasks.Single().VerifyExitCode);
        Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
    }

    [Fact]
    public async Task AddTask_ReturnsAllocatedTaskNumber()
    {
        var listId = await SeedListAsync();
        var sut = NewService();

        var dto = await sut.AddTask(listId, "t", cancellationToken: CancellationToken.None);

        var loaded = await _tasks.GetByIdAsync(dto.Task.Id);
        Assert.True(dto.Task.Number > 0);
        Assert.Equal(loaded!.Number, dto.Task.Number);
    }

    // ── AddTask model override ────────────────────────────────────────────────

    [Fact]
    public async Task AddTask_NoModel_LeavesModelNull()
    {
        var listId = await SeedListAsync();
        var sut = NewService();

        var dto = await sut.AddTask(listId, "t", cancellationToken: CancellationToken.None);

        var loaded = await _tasks.GetByIdAsync(dto.Task.Id);
        Assert.Null(loaded!.Model);
    }

    [Fact]
    public async Task AddTask_PersistsNormalizedModel()
    {
        var listId = await SeedListAsync();
        var sut = NewService();

        var dto = await sut.AddTask(listId, "t", model: "HAIKU", cancellationToken: CancellationToken.None);

        var loaded = await _tasks.GetByIdAsync(dto.Task.Id);
        Assert.Equal("haiku", loaded!.Model);
    }

    [Fact]
    public async Task AddTask_RejectsUnknownModel()
    {
        var listId = await SeedListAsync();
        var sut = NewService();

        await Assert.ThrowsAsync<ArgumentException>(
            () => sut.AddTask(listId, "t", model: "gpt4", cancellationToken: CancellationToken.None));
    }

    [Fact]
    public async Task AddTask_WithDependsOnTaskId_PersistsLinkAndReportsBlocked()
    {
        var listId = await SeedListAsync();
        var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
        var sut = NewService();

        var dto = await sut.AddTask(
            listId, "t", dependsOnTaskId: predecessor.Id, queueImmediately: true, cancellationToken: CancellationToken.None);

        Assert.Equal(predecessor.Id, dto.Task.DependsOnTaskId);
        Assert.True(dto.Task.Blocked);
        Assert.Contains(predecessor.Id, dto.Task.BlockedReason);
        var loaded = await _tasks.GetByIdAsync(dto.Task.Id);
        Assert.Equal(predecessor.Id, loaded!.DependsOnTaskId);
    }

    [Fact]
    public async Task AddTask_WithSelfReferencingDependsOnTaskId_Throws()
    {
        // Can't reference its own not-yet-known id, so this exercises the not-found path --
        // an unknown dependsOnTaskId is rejected the same way a self-reference would be.
        var listId = await SeedListAsync();
        var sut = NewService();

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.AddTask(listId, "t", dependsOnTaskId: "does-not-exist", cancellationToken: CancellationToken.None));
    }

    // ── AddTask possible-duplicate check ────────────────────────────────────────

    [Fact]
    public async Task AddTask_SimilarOpenTitleInSameList_IsReportedAsPossibleDuplicate()
    {
        // The exact pair that motivated this check (bf4cd901 vs f3718cd8, see task write-up).
        var listId = await SeedListAsync();
        var existing = await SeedTaskAsync(
            listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.WaitingForReview);
        var sut = NewService();

        var result = await sut.AddTask(
            listId, "Settings: MaxTurnsCeiling editierbar machen", cancellationToken: CancellationToken.None);

        var dup = Assert.Single(result.PossibleDuplicates);
        Assert.Equal(existing.Id, dup.TaskId);
        Assert.Equal(existing.Title, dup.Title);
        Assert.Equal("WaitingForReview", dup.Status);
    }

    [Fact]
    public async Task AddTask_StillCreatesTheTask_EvenWhenAPossibleDuplicateIsFound()
    {
        var listId = await SeedListAsync();
        await SeedTaskAsync(listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.Idle);
        var sut = NewService();

        var result = await sut.AddTask(
            listId, "Settings: MaxTurnsCeiling editierbar machen", cancellationToken: CancellationToken.None);

        Assert.NotEmpty(result.PossibleDuplicates);
        Assert.NotNull(await _tasks.GetByIdAsync(result.Task.Id));
    }

    [Fact]
    public async Task AddTask_SimilarTitle_ButExistingTaskIsTerminal_IsNotReported()
    {
        var listId = await SeedListAsync();
        await SeedTaskAsync(listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.Done);
        var sut = NewService();

        var result = await sut.AddTask(
            listId, "Settings: MaxTurnsCeiling editierbar machen", cancellationToken: CancellationToken.None);

        Assert.Empty(result.PossibleDuplicates);
    }

    [Fact]
    public async Task AddTask_ClearlyDifferentTitle_IsNotReported()
    {
        var listId = await SeedListAsync();
        await SeedTaskAsync(listId, "Refactor GitService worktree cleanup", TaskStatus.Idle);
        var sut = NewService();

        var result = await sut.AddTask(
            listId, "Add dark mode toggle to the settings page", cancellationToken: CancellationToken.None);

        Assert.Empty(result.PossibleDuplicates);
    }

    [Fact]
    public async Task AddTask_TitlesSharingOnlyOneWordPlusTheGermanStopwordFuer_AreNotReportedAsDuplicates()
    {
        // Regression for the "fur" (never-transliterated) stopword typo: NormalizeTitleWords
        // transliterates "für" -> "fuer" before the stopword check, so a stopword list containing
        // "fur" can never filter it. Before the fix these two titles shared "report" and "fuer"
        // (2 words, 50% of the shorter title) and cleared the duplicate threshold undeservedly;
        // with "fuer" correctly filtered as filler only "report" remains shared (1 word).
        var listId = await SeedListAsync();
        await SeedTaskAsync(listId, "Neuer Report für Bestand", TaskStatus.Idle);
        var sut = NewService();

        var result = await sut.AddTask(
            listId, "Anderer Report für Kunden", cancellationToken: CancellationToken.None);

        Assert.Empty(result.PossibleDuplicates);
    }

    [Fact]
    public async Task AddTask_MoreThanThreeMatches_CapsPossibleDuplicatesAtThree()
    {
        var listId = await SeedListAsync();
        for (var i = 0; i < 5; i++)
            await SeedTaskAsync(listId, $"Fix flaky queue picker test number {i}", TaskStatus.Idle);
        var sut = NewService();

        var result = await sut.AddTask(
            listId, "Fix flaky queue picker test number extra", cancellationToken: CancellationToken.None);

        Assert.Equal(3, result.PossibleDuplicates.Count);
    }

    // ── ContinueTask validation ───────────────────────────────────────────────

    [Fact]
    public async Task ContinueTask_EmptyPrompt_Throws()
    {
        var listId = await SeedListAsync();
        var task = await SeedTaskAsync(listId);
        var sut = NewService();

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.ContinueTask(task.Id, "   ", CancellationToken.None));
    }
}
