using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.Tests.State;

file sealed class FakeActiveMergeState : IActiveMergeState
{
    public HashSet<string> ActiveTaskIds { get; } = new();
    public bool HasActiveMerge(string taskId) => ActiveTaskIds.Contains(taskId);
    public Task<bool> HasActiveMergeAsync(string taskId, CancellationToken ct) => Task.FromResult(HasActiveMerge(taskId));
}

public sealed class TaskStateServiceTests : IDisposable
{
    private readonly DbFixture _db = new();
    private readonly TestDbContextFactory _factory;
    private readonly TaskStateServiceBuilder.Built _built;
    private readonly ITaskStateService _sut;
    private readonly string _listId;
    private readonly List<GitRepoFixture> _repos = new();
    private int _numberSeed;

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

    public TaskStateServiceTests()
    {
        _factory = _db.CreateFactory();
        _built = TaskStateServiceBuilder.Build(_factory);
        _sut = _built.State;

        _listId = Guid.NewGuid().ToString();
        using var ctx = _factory.CreateDbContext();
        ctx.Lists.Add(new ListEntity
        {
            Id = _listId,
            Name = "Test",
            CreatedAt = DateTime.UtcNow,
            DefaultCommitType = "chore",
        });
        ctx.SaveChanges();
    }

    public void Dispose()
    {
        foreach (var r in _repos) r.Dispose();
        _db.Dispose();
    }

    private async Task SetListWorkingDirAsync(string workingDir)
    {
        await using var ctx = _factory.CreateDbContext();
        var list = await ctx.Lists.FirstAsync(l => l.Id == _listId);
        list.WorkingDir = workingDir;
        await ctx.SaveChangesAsync();
    }

    private async Task<string> SeedTaskAsync(
        TaskStatus status,
        string? parentId = null,
        int sortOrder = 0,
        string? blockedBy = null,
        PlanningPhase phase = PlanningPhase.None,
        bool isManual = false)
    {
        var id = Guid.NewGuid().ToString();
        await using var ctx = _factory.CreateDbContext();
        ctx.Tasks.Add(new TaskEntity
        {
            Id = id,
            ListId = _listId,
            Title = "task",
            Number = ++_numberSeed,
            Status = status,
            PlanningPhase = phase,
            CreatedAt = DateTime.UtcNow,
            ParentTaskId = parentId,
            SortOrder = sortOrder,
            BlockedByTaskId = blockedBy,
            IsManual = isManual,
        });
        await ctx.SaveChangesAsync();
        return id;
    }

    private async Task<TaskStatus> GetStatusAsync(string id)
    {
        await using var ctx = _factory.CreateDbContext();
        return await ctx.Tasks.Where(t => t.Id == id).Select(t => t.Status).FirstAsync();
    }

    private async Task<TaskEntity> GetTaskAsync(string id)
    {
        await using var ctx = _factory.CreateDbContext();
        return await new TaskRepository(ctx).GetByIdAsync(id) ?? throw new InvalidOperationException($"task {id} not found");
    }

    // ─── EnqueueAsync ─────────────────────────────────────────────────────

    [Fact]
    public async Task EnqueueAsync_FromIdle_TransitionsToQueued_AndWakesQueue()
    {
        var id = await SeedTaskAsync(TaskStatus.Idle);
        var wakesBefore = _built.WakeCount();

        var result = await _sut.EnqueueAsync(id, default);

        Assert.True(result.Ok);
        Assert.Equal(TaskStatus.Queued, await GetStatusAsync(id));
        Assert.True(_built.WakeCount() > wakesBefore);
        Assert.Contains(_built.Hub.Proxy.Calls, c => c.Method == "TaskUpdated");
    }

    [Fact]
    public async Task EnqueueAsync_NoWorkingDir_QueuesNormally_NoBaseDirtyWarning()
    {
        // The list created in the constructor has no WorkingDir -- covers the
        // "no valid repo" acceptance case: queuing must not throw or block.
        var id = await SeedTaskAsync(TaskStatus.Idle);

        var result = await _sut.EnqueueAsync(id, default);

        Assert.True(result.Ok);
        Assert.Null(result.BaseDirty);
        Assert.Equal(TaskStatus.Queued, await GetStatusAsync(id));
    }

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

        var repo = new GitRepoFixture();
        _repos.Add(repo);
        await SetListWorkingDirAsync(repo.RepoDir);
        var id = await SeedTaskAsync(TaskStatus.Idle);

        var result = await _sut.EnqueueAsync(id, default);

        Assert.True(result.Ok);
        Assert.Null(result.BaseDirty);
    }

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

        var repo = new GitRepoFixture();
        _repos.Add(repo);
        File.WriteAllText(Path.Combine(repo.RepoDir, "new-file.txt"), "not committed");
        await SetListWorkingDirAsync(repo.RepoDir);
        var id = await SeedTaskAsync(TaskStatus.Idle);

        var result = await _sut.EnqueueAsync(id, default);

        Assert.True(result.Ok);
        Assert.NotNull(result.BaseDirty);
        Assert.Equal(0, result.BaseDirty!.ModifiedCount);
        Assert.Equal(1, result.BaseDirty!.UntrackedCount);
    }

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

        var repo = new GitRepoFixture();
        _repos.Add(repo);
        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "changed on disk");
        await SetListWorkingDirAsync(repo.RepoDir);
        var id = await SeedTaskAsync(TaskStatus.Idle);

        var result = await _sut.EnqueueAsync(id, default);

        Assert.True(result.Ok);
        Assert.NotNull(result.BaseDirty);
        Assert.Equal(1, result.BaseDirty!.ModifiedCount);
        Assert.Equal(0, result.BaseDirty!.UntrackedCount);
    }

    [Fact]
    public async Task EnqueueAsync_ManualTask_Rejected_AndStaysIdle()
    {
        var id = await SeedTaskAsync(TaskStatus.Idle, isManual: true);

        var result = await _sut.EnqueueAsync(id, default);

        Assert.False(result.Ok);
        Assert.Contains("Manual task", result.Reason);
        Assert.Equal(TaskStatus.Idle, await GetStatusAsync(id));
    }

    [Fact]
    public async Task StartRunningAsync_ManualTask_Rejected_AndStaysIdle()
    {
        var id = await SeedTaskAsync(TaskStatus.Idle, isManual: true);

        var result = await _sut.StartRunningAsync(id, DateTime.UtcNow, default);

        Assert.False(result.Ok);
        Assert.Contains("Manual task", result.Reason);
        Assert.Equal(TaskStatus.Idle, await GetStatusAsync(id));
    }

    [Fact]
    public async Task EnqueueAsync_FromRunning_Rejects_AndDoesNotMutate()
    {
        var id = await SeedTaskAsync(TaskStatus.Running);

        var result = await _sut.EnqueueAsync(id, default);

        Assert.False(result.Ok);
        Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
    }

    [Fact]
    public async Task EnqueueAsync_DraftChild_Rejected_WhenParentNotFinalized()
    {
        var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Active);
        var child = await SeedTaskAsync(TaskStatus.Idle, parentId: parent);

        var result = await _sut.EnqueueAsync(child, default);

        Assert.False(result.Ok);
        Assert.Equal(TaskStatus.Idle, await GetStatusAsync(child));
    }

    [Fact]
    public async Task EnqueueAsync_PlannedChild_Succeeds_WhenParentFinalized()
    {
        var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Finalized);
        var child = await SeedTaskAsync(TaskStatus.Idle, parentId: parent);

        var result = await _sut.EnqueueAsync(child, default);

        Assert.True(result.Ok);
        Assert.Equal(TaskStatus.Queued, await GetStatusAsync(child));
    }

    // ─── StartRunningAsync ────────────────────────────────────────────────

    [Fact]
    public async Task StartRunningAsync_FromQueued_TransitionsToRunning_AndStampsStartedAt()
    {
        var id = await SeedTaskAsync(TaskStatus.Queued);
        var startedAt = new DateTime(2026, 4, 27, 10, 0, 0, DateTimeKind.Utc);

        var result = await _sut.StartRunningAsync(id, startedAt, default);

        Assert.True(result.Ok);
        var t = await GetTaskAsync(id);
        Assert.Equal(TaskStatus.Running, t.Status);
        Assert.Equal(startedAt, t.StartedAt);
    }

    [Fact]
    public async Task StartRunningAsync_FromRunning_Rejects()
    {
        var id = await SeedTaskAsync(TaskStatus.Running);

        var result = await _sut.StartRunningAsync(id, DateTime.UtcNow, default);

        Assert.False(result.Ok);
    }

    [Fact]
    public async Task StartRunningAsync_TwoParallelClaims_ExactlyOneWins()
    {
        var id = await SeedTaskAsync(TaskStatus.Queued);
        var startedAt = DateTime.UtcNow;

        // Two concurrent calls: only one ExecuteUpdate should affect a row.
        var t1 = Task.Run(() => _sut.StartRunningAsync(id, startedAt, default));
        var t2 = Task.Run(() => _sut.StartRunningAsync(id, startedAt, default));
        var results = await Task.WhenAll(t1, t2);

        var winners = results.Count(r => r.Ok);
        Assert.Equal(1, winners);
        Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
    }

    [Fact]
    public async Task StartRunningAsync_DraftChild_Rejected_WhenParentNotFinalized()
    {
        var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Active);
        var child = await SeedTaskAsync(TaskStatus.Idle, parentId: parent);

        var result = await _sut.StartRunningAsync(child, DateTime.UtcNow, default);

        Assert.False(result.Ok);
        Assert.Equal(TaskStatus.Idle, await GetStatusAsync(child));
    }

    [Fact]
    public async Task StartRunningAsync_PlannedChild_Succeeds_WhenParentFinalized()
    {
        var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Finalized);
        var child = await SeedTaskAsync(TaskStatus.Idle, parentId: parent);

        var result = await _sut.StartRunningAsync(child, DateTime.UtcNow, default);

        Assert.True(result.Ok);
        Assert.Equal(TaskStatus.Running, await GetStatusAsync(child));
    }

    // ─── CompleteAsync ────────────────────────────────────────────────────

    [Fact]
    public async Task CompleteAsync_FromRunning_TransitionsToDone()
    {
        var id = await SeedTaskAsync(TaskStatus.Running);

        var result = await _sut.CompleteAsync(id, DateTime.UtcNow, "ok", default);

        Assert.True(result.Ok);
        var t = await GetTaskAsync(id);
        Assert.Equal(TaskStatus.Done, t.Status);
        Assert.Equal("ok", t.Result);
        Assert.NotNull(t.FinishedAt);
    }

    [Fact]
    public async Task CompleteAsync_FromQueued_Rejects()
    {
        var id = await SeedTaskAsync(TaskStatus.Queued);

        var result = await _sut.CompleteAsync(id, DateTime.UtcNow, "ok", default);

        Assert.False(result.Ok);
        Assert.Equal(TaskStatus.Queued, await GetStatusAsync(id));
    }

    // ─── FailAsync ────────────────────────────────────────────────────────

    [Fact]
    public async Task FailAsync_FromRunning_TransitionsToFailed()
    {
        var id = await SeedTaskAsync(TaskStatus.Running);

        var result = await _sut.FailAsync(id, DateTime.UtcNow, "boom", default);

        Assert.True(result.Ok);
        var t = await GetTaskAsync(id);
        Assert.Equal(TaskStatus.Failed, t.Status);
        Assert.Equal("boom", t.Result);
    }

    [Fact]
    public async Task FailAsync_FromQueued_TransitionsToFailed()
    {
        // OverrideSlotService can call MarkFailed before StartRunningAsync when a
        // preflight step (list lookup, worktree setup) fails — the task is still Queued.
        var id = await SeedTaskAsync(TaskStatus.Queued);

        var result = await _sut.FailAsync(id, DateTime.UtcNow, "list not found", default);

        Assert.True(result.Ok);
        var t = await GetTaskAsync(id);
        Assert.Equal(TaskStatus.Failed, t.Status);
        Assert.Equal("list not found", t.Result);
    }

    [Fact]
    public async Task FailAsync_FromDone_Rejects()
    {
        var id = await SeedTaskAsync(TaskStatus.Done);

        var result = await _sut.FailAsync(id, DateTime.UtcNow, "boom", default);

        Assert.False(result.Ok);
        Assert.Equal(TaskStatus.Done, await GetStatusAsync(id));
    }

    [Fact]
    public async Task FailAsync_FromWaitingForReview_IsNoOp()
    {
        var id = await SeedTaskAsync(TaskStatus.WaitingForReview);

        var result = await _sut.FailAsync(id, DateTime.UtcNow, "oops", default);

        Assert.False(result.Ok);
        Assert.Equal(TaskStatus.WaitingForReview, await GetStatusAsync(id));
    }

    // ─── CancelAsync ──────────────────────────────────────────────────────

    [Fact]
    public async Task CancelAsync_FromRunning_TransitionsToCancelled()
    {
        var id = await SeedTaskAsync(TaskStatus.Running);

        var result = await _sut.CancelAsync(id, DateTime.UtcNow, default);

        Assert.True(result.Ok);
        Assert.Equal(TaskStatus.Cancelled, await GetStatusAsync(id));
    }

    [Fact]
    public async Task CancelAsync_FromDone_Rejects()
    {
        var id = await SeedTaskAsync(TaskStatus.Done);

        var result = await _sut.CancelAsync(id, DateTime.UtcNow, default);

        Assert.False(result.Ok);
        Assert.Equal(TaskStatus.Done, await GetStatusAsync(id));
    }

    [Fact]
    public async Task CancelAsync_WhileUnitMergeDraining_Rejects_AndDoesNotMutate()
    {
        var mergeState = new FakeActiveMergeState();
        var built = TaskStateServiceBuilder.Build(_factory, () => mergeState);
        var id = await SeedTaskAsync(TaskStatus.WaitingForReview);
        mergeState.ActiveTaskIds.Add(id);

        var result = await built.State.CancelAsync(id, DateTime.UtcNow, default);

        Assert.False(result.Ok);
        Assert.Equal(TaskStatus.WaitingForReview, await GetStatusAsync(id));
    }

    [Fact]
    public async Task CancelAsync_AfterUnitMergeDrainCompletes_Succeeds()
    {
        var mergeState = new FakeActiveMergeState();
        var built = TaskStateServiceBuilder.Build(_factory, () => mergeState);
        var id = await SeedTaskAsync(TaskStatus.WaitingForReview);
        // Merge drain finished (or never started) for this task — cancel behaves as before.

        var result = await built.State.CancelAsync(id, DateTime.UtcNow, default);

        Assert.True(result.Ok);
        Assert.Equal(TaskStatus.Cancelled, await GetStatusAsync(id));
    }

    // ─── ResetToIdleAsync ─────────────────────────────────────────────────

    [Fact]
    public async Task ResetToIdleAsync_FromFailed_ClearsTimestamps()
    {
        var id = await SeedTaskAsync(TaskStatus.Failed);
        await using (var ctx = _factory.CreateDbContext())
        {
            await ctx.Tasks.Where(t => t.Id == id)
                .ExecuteUpdateAsync(s => s
                    .SetProperty(t => t.StartedAt, DateTime.UtcNow.AddMinutes(-5))
                    .SetProperty(t => t.FinishedAt, DateTime.UtcNow.AddMinutes(-1))
                    .SetProperty(t => t.Result, "old"));
        }

        var result = await _sut.ResetToIdleAsync(id, default);

        Assert.True(result.Ok);
        var t = await GetTaskAsync(id);
        Assert.Equal(TaskStatus.Idle, t.Status);
        Assert.Null(t.StartedAt);
        Assert.Null(t.FinishedAt);
        Assert.Null(t.Result);
    }

    [Fact]
    public async Task ResetToIdleAsync_FromRunning_Rejects()
    {
        var id = await SeedTaskAsync(TaskStatus.Running);

        var result = await _sut.ResetToIdleAsync(id, default);

        Assert.False(result.Ok);
        Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
    }

    // ─── MarkDoneAsync / UnmarkDoneAsync ─────────────────────────────────

    [Fact]
    public async Task MarkDoneAsync_FromIdle_TransitionsToDone_AndStampsFinishedAt()
    {
        var id = await SeedTaskAsync(TaskStatus.Idle);
        var now = DateTime.UtcNow;

        var result = await _sut.MarkDoneAsync(id, now, default);

        Assert.True(result.Ok);
        var t = await GetTaskAsync(id);
        Assert.Equal(TaskStatus.Done, t.Status);
        Assert.Equal(now, t.FinishedAt);
    }

    [Fact]
    public async Task MarkDoneAsync_FromRunning_Rejects_AndLeavesStatusUnchanged()
    {
        var id = await SeedTaskAsync(TaskStatus.Running);

        var result = await _sut.MarkDoneAsync(id, DateTime.UtcNow, default);

        Assert.False(result.Ok);
        Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
    }

    // The done toggle is a manual affordance and must work from any non-Running status --
    // notably WaitingForReview, where it deliberately skips the merge.
    [Theory]
    [InlineData(TaskStatus.Queued)]
    [InlineData(TaskStatus.WaitingForReview)]
    [InlineData(TaskStatus.WaitingForChildren)]
    [InlineData(TaskStatus.Failed)]
    [InlineData(TaskStatus.Cancelled)]
    public async Task MarkDoneAsync_FromNonRunningStatus_TransitionsToDone(TaskStatus from)
    {
        var id = await SeedTaskAsync(from);

        var result = await _sut.MarkDoneAsync(id, DateTime.UtcNow, default);

        Assert.True(result.Ok);
        Assert.Equal(TaskStatus.Done, await GetStatusAsync(id));
    }

    [Fact]
    public async Task UnmarkDoneAsync_FromDone_TransitionsToIdle()
    {
        var id = await SeedTaskAsync(TaskStatus.Done);

        var result = await _sut.UnmarkDoneAsync(id, default);

        Assert.True(result.Ok);
        Assert.Equal(TaskStatus.Idle, await GetStatusAsync(id));
    }

    [Fact]
    public async Task UnmarkDoneAsync_FromRunning_Rejects_AndLeavesStatusUnchanged()
    {
        var id = await SeedTaskAsync(TaskStatus.Running);

        var result = await _sut.UnmarkDoneAsync(id, default);

        Assert.False(result.Ok);
        Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
    }

    // ─── DequeueToIdleAsync ───────────────────────────────────────────────

    [Fact]
    public async Task DequeueToIdleAsync_FromQueued_TransitionsToIdle_AndClearsBlockedByTaskId()
    {
        var pred = await SeedTaskAsync(TaskStatus.Queued);
        var id = await SeedTaskAsync(TaskStatus.Queued, blockedBy: pred);

        var result = await _sut.DequeueToIdleAsync(id, default);

        Assert.True(result.Ok);
        var t = await GetTaskAsync(id);
        Assert.Equal(TaskStatus.Idle, t.Status);
        Assert.Null(t.BlockedByTaskId);
    }

    [Fact]
    public async Task DequeueToIdleAsync_FromRunning_Rejects_AndLeavesStatusUnchanged()
    {
        // Simulates the picker having already claimed the task between the UI reading
        // its row and the dequeue call landing.
        var id = await SeedTaskAsync(TaskStatus.Running);

        var result = await _sut.DequeueToIdleAsync(id, default);

        Assert.False(result.Ok);
        Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
    }

    // ─── StartPlanningAsync ───────────────────────────────────────────────

    [Fact]
    public async Task StartPlanningAsync_FromIdle_SetsPlanningPhase()
    {
        var id = await SeedTaskAsync(TaskStatus.Idle);

        var result = await _sut.StartPlanningAsync(id, default);

        Assert.True(result.Ok);
        var t = await GetTaskAsync(id);
        Assert.Equal(TaskStatus.Idle, t.Status);
        Assert.Equal(PlanningPhase.Active, t.PlanningPhase);
    }

    [Fact]
    public async Task StartPlanningAsync_FromRunning_Rejects()
    {
        var id = await SeedTaskAsync(TaskStatus.Running);

        var result = await _sut.StartPlanningAsync(id, default);

        Assert.False(result.Ok);
    }

    // ─── FinalizePlanningAsync ────────────────────────────────────────────

    [Fact]
    public async Task FinalizePlanningAsync_OnActivePhase_TransitionsToFinalized()
    {
        var id = await SeedTaskAsync(TaskStatus.Idle);
        await _sut.StartPlanningAsync(id, default);

        var result = await _sut.FinalizePlanningAsync(id, default);

        Assert.True(result.Ok);
        var t = await GetTaskAsync(id);
        Assert.Equal(PlanningPhase.Finalized, t.PlanningPhase);
        Assert.NotNull(t.PlanningFinalizedAt);
    }

    [Fact]
    public async Task FinalizePlanningAsync_OnNonePhase_Rejects()
    {
        var id = await SeedTaskAsync(TaskStatus.Idle);

        var result = await _sut.FinalizePlanningAsync(id, default);

        Assert.False(result.Ok);
    }

    // ─── BlockOnAsync / UnblockAsync ─────────────────────────────────────

    [Fact]
    public async Task BlockOnAsync_SetsBlockedByTaskId()
    {
        var pred = await SeedTaskAsync(TaskStatus.Queued);
        var task = await SeedTaskAsync(TaskStatus.Queued);

        var result = await _sut.BlockOnAsync(task, pred, default);

        Assert.True(result.Ok);
        var t = await GetTaskAsync(task);
        Assert.Equal(pred, t.BlockedByTaskId);
    }

    [Fact]
    public async Task UnblockAsync_ClearsBlockedByTaskId_AndWakesQueue()
    {
        var pred = await SeedTaskAsync(TaskStatus.Queued);
        var task = await SeedTaskAsync(TaskStatus.Queued);
        await _sut.BlockOnAsync(task, pred, default);
        var wakesBefore = _built.WakeCount();

        var result = await _sut.UnblockAsync(task, default);

        Assert.True(result.Ok);
        var t = await GetTaskAsync(task);
        Assert.Null(t.BlockedByTaskId);
        Assert.True(_built.WakeCount() > wakesBefore);
    }

    // ─── SetDependsOnAsync ────────────────────────────────────────────────

    [Fact]
    public async Task SetDependsOnAsync_SetsDependsOnTaskId()
    {
        var predecessor = await SeedTaskAsync(TaskStatus.Idle);
        var task = await SeedTaskAsync(TaskStatus.Queued);

        var result = await _sut.SetDependsOnAsync(task, predecessor, default);

        Assert.True(result.Ok);
        var t = await GetTaskAsync(task);
        Assert.Equal(predecessor, t.DependsOnTaskId);
    }

    [Fact]
    public async Task SetDependsOnAsync_Null_ClearsDependsOnTaskId_AndWakesQueue()
    {
        var predecessor = await SeedTaskAsync(TaskStatus.Idle);
        var task = await SeedTaskAsync(TaskStatus.Queued);
        await _sut.SetDependsOnAsync(task, predecessor, default);
        var wakesBefore = _built.WakeCount();

        var result = await _sut.SetDependsOnAsync(task, null, default);

        Assert.True(result.Ok);
        var t = await GetTaskAsync(task);
        Assert.Null(t.DependsOnTaskId);
        Assert.True(_built.WakeCount() > wakesBefore);
    }

    [Fact]
    public async Task SetDependsOnAsync_SelfReference_Rejected()
    {
        var task = await SeedTaskAsync(TaskStatus.Queued);

        var result = await _sut.SetDependsOnAsync(task, task, default);

        Assert.False(result.Ok);
        var t = await GetTaskAsync(task);
        Assert.Null(t.DependsOnTaskId);
    }

    [Fact]
    public async Task SetDependsOnAsync_UnknownDependency_Rejected()
    {
        var task = await SeedTaskAsync(TaskStatus.Queued);

        var result = await _sut.SetDependsOnAsync(task, "does-not-exist", default);

        Assert.False(result.Ok);
    }

    [Fact]
    public async Task SetDependsOnAsync_NotFound_Rejected()
    {
        var predecessor = await SeedTaskAsync(TaskStatus.Idle);

        var result = await _sut.SetDependsOnAsync("does-not-exist", predecessor, default);

        Assert.False(result.Ok);
    }

    [Fact]
    public async Task SetDependsOnAsync_DirectCycle_Rejected()
    {
        var a = await SeedTaskAsync(TaskStatus.Queued);
        var b = await SeedTaskAsync(TaskStatus.Queued);
        var setup = await _sut.SetDependsOnAsync(b, a, default);
        Assert.True(setup.Ok);

        // a -> b would close the loop a -> b -> a.
        var result = await _sut.SetDependsOnAsync(a, b, default);

        Assert.False(result.Ok);
        var t = await GetTaskAsync(a);
        Assert.Null(t.DependsOnTaskId);
    }

    [Fact]
    public async Task SetDependsOnAsync_TransitiveCycle_Rejected()
    {
        var a = await SeedTaskAsync(TaskStatus.Queued);
        var b = await SeedTaskAsync(TaskStatus.Queued);
        var c = await SeedTaskAsync(TaskStatus.Queued);
        Assert.True((await _sut.SetDependsOnAsync(b, a, default)).Ok);
        Assert.True((await _sut.SetDependsOnAsync(c, b, default)).Ok);

        // a -> c would close the loop a -> c -> b -> a.
        var result = await _sut.SetDependsOnAsync(a, c, default);

        Assert.False(result.Ok);
    }

    // ─── RecoverStaleRunningAsync ─────────────────────────────────────────

    [Fact]
    public async Task RecoverStaleRunningAsync_FlipsAllRunningToFailed_ReturnsCount()
    {
        var r1 = await SeedTaskAsync(TaskStatus.Running);
        var r2 = await SeedTaskAsync(TaskStatus.Running);
        var q = await SeedTaskAsync(TaskStatus.Queued);

        var count = await _sut.RecoverStaleRunningAsync("worker restart", default);

        Assert.Equal(2, count);
        Assert.Equal(TaskStatus.Failed, await GetStatusAsync(r1));
        Assert.Equal(TaskStatus.Failed, await GetStatusAsync(r2));
        Assert.Equal(TaskStatus.Queued, await GetStatusAsync(q));
        var t = await GetTaskAsync(r1);
        Assert.StartsWith("[stale] ", t.Result);
    }

    [Fact]
    public async Task RecoverStaleRunningAsync_ResolvesBlockedSuccessor_AndAdvancesParent()
    {
        var parent = await SeedTaskAsync(TaskStatus.WaitingForChildren, phase: PlanningPhase.Finalized);
        var c0 = await SeedTaskAsync(TaskStatus.Running, parentId: parent, sortOrder: 0);
        var c1 = await SeedTaskAsync(TaskStatus.Queued, parentId: parent, sortOrder: 1, blockedBy: c0);

        var count = await _sut.RecoverStaleRunningAsync("worker restart", default);

        Assert.Equal(1, count);
        Assert.Equal(TaskStatus.Failed, await GetStatusAsync(c0));
        // c1 was blocked on the crashed task; the chain coordinator resolves it (cancels it,
        // since the predecessor didn't finish successfully) instead of leaving it Queued with
        // a dangling BlockedByTaskId that the picker would skip forever.
        Assert.Equal(TaskStatus.Cancelled, await GetStatusAsync(c1));
        // Both children are now terminal, so the WaitingForChildren parent advances.
        Assert.Equal(TaskStatus.WaitingForReview, await GetStatusAsync(parent));
    }

    // ─── Child terminal → chain advance ───────────────────────────────────

    [Fact]
    public async Task CompleteAsync_OnChild_AdvancesNextBlockedSibling()
    {
        var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Finalized);
        var c0 = await SeedTaskAsync(TaskStatus.Running, parentId: parent, sortOrder: 0);
        var c1 = await SeedTaskAsync(TaskStatus.Queued, parentId: parent, sortOrder: 1, blockedBy: c0);
        var c2 = await SeedTaskAsync(TaskStatus.Queued, parentId: parent, sortOrder: 2, blockedBy: c1);

        var result = await _sut.CompleteAsync(c0, DateTime.UtcNow, "ok", default);

        Assert.True(result.Ok);
        Assert.Equal(TaskStatus.Done, await GetStatusAsync(c0));
        // c1 was BlockedBy=c0 → chain coordinator unblocks → BlockedByTaskId cleared, still Queued.
        var t1 = await GetTaskAsync(c1);
        Assert.Equal(TaskStatus.Queued, t1.Status);
        Assert.Null(t1.BlockedByTaskId);
        // c2 still blocked on c1.
        var t2 = await GetTaskAsync(c2);
        Assert.Equal(TaskStatus.Queued, t2.Status);
        Assert.Equal(c1, t2.BlockedByTaskId);
    }

    // ─── TryAdvanceParentAsync ────────────────────────────────────────────

    [Fact]
    public async Task TryAdvanceParentAsync_ZeroChildren_AdvancesToWaitingForReview()
    {
        // Mirrors deleting the last remaining child: no terminal transition fires for it,
        // so the caller invokes TryAdvanceParentAsync directly with zero children left.
        var parent = await SeedTaskAsync(TaskStatus.WaitingForChildren, phase: PlanningPhase.Finalized);

        await _sut.TryAdvanceParentAsync(parent);

        Assert.Equal(TaskStatus.WaitingForReview, await GetStatusAsync(parent));
    }

    [Fact]
    public async Task TryAdvanceParentAsync_AllChildrenTerminal_AdvancesToWaitingForReview()
    {
        var parent = await SeedTaskAsync(TaskStatus.WaitingForChildren, phase: PlanningPhase.Finalized);
        await SeedTaskAsync(TaskStatus.Done, parentId: parent);
        await SeedTaskAsync(TaskStatus.Done, parentId: parent);

        await _sut.TryAdvanceParentAsync(parent);

        Assert.Equal(TaskStatus.WaitingForReview, await GetStatusAsync(parent));
    }

    [Fact]
    public async Task TryAdvanceParentAsync_SomeChildrenStillRunning_DoesNotAdvance()
    {
        var parent = await SeedTaskAsync(TaskStatus.WaitingForChildren, phase: PlanningPhase.Finalized);
        await SeedTaskAsync(TaskStatus.Done, parentId: parent);
        await SeedTaskAsync(TaskStatus.Running, parentId: parent);

        await _sut.TryAdvanceParentAsync(parent);

        Assert.Equal(TaskStatus.WaitingForChildren, await GetStatusAsync(parent));
    }
}
