using System.Linq;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.MissionControl;
using Microsoft.EntityFrameworkCore;
using Xunit;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Ui.Tests.ViewModels;

public class MissionControlViewModelTests : IDisposable
{
    private readonly string _dbPath;

    public MissionControlViewModelTests()
    {
        _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_mc_test_{Guid.NewGuid():N}.db");
        using var ctx = NewContext();
        ctx.Database.EnsureCreated();
    }

    public void Dispose()
    {
        try { File.Delete(_dbPath); } catch { }
        try { File.Delete(_dbPath + "-wal"); } catch { }
        try { File.Delete(_dbPath + "-shm"); } catch { }
    }

    private ClaudeDoDbContext NewContext()
    {
        var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
            .UseSqlite($"Data Source={_dbPath}")
            .Options;
        return new ClaudeDoDbContext(opts);
    }


    private sealed class FakeWorker : StubWorkerClient { }

    private MissionControlViewModel BuildVm(StubWorkerClient worker)
        => new MissionControlViewModel(new TestDbFactory(NewContext), worker, new UsagePillViewModel(worker));

    // Gates the title/list-name DB lookup behind a manually-released TaskCompletionSource so two
    // overlapping Open*ConPtySessionAsync calls both run their synchronous dedupe-check prefix
    // to completion before either resumes past the DB await. This reproduces the open-path races
    // deterministically: a real async gap (e.g. Task.Yield) resumes its continuation on a
    // thread-pool thread with no synchronization context to serialize it back onto the caller,
    // which turns the repro into a genuine (flaky) data race instead of the intended
    // same-thread double-invocation the bug describes.
    private sealed class GatedDbFactory : IDbContextFactory<ClaudeDoDbContext>
    {
        private readonly Func<ClaudeDoDbContext> _create;
        private readonly TaskCompletionSource _gate = new();
        public GatedDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
        public ClaudeDoDbContext CreateDbContext() => _create();
        public async Task<ClaudeDoDbContext> CreateDbContextAsync(CancellationToken ct = default)
        {
            await _gate.Task;
            return _create();
        }
        public void Release() => _gate.TrySetResult();
    }

    private MissionControlViewModel BuildGatedVm(StubWorkerClient worker, out GatedDbFactory factory)
    {
        factory = new GatedDbFactory(NewContext);
        return new MissionControlViewModel(factory, worker, new UsagePillViewModel(worker));
    }

    // ── acceptance criterion (a): TaskStarted must NOT add a pane ──────────────

    [Fact]
    public void TaskStarted_DoesNotAddPane()
    {
        var worker = new FakeWorker();
        using var vm = BuildVm(worker);

        worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
        worker.RaiseTaskStarted("slot-2", "t2", DateTime.UtcNow);

        Assert.Empty(vm.Panes);
    }

    [Fact]
    public void Construction_DoesNotSeedMonitorsEvenWithActiveTasks()
    {
        var worker = new SeededFakeWorker();
        using var vm = BuildVm(worker);

        Assert.Empty(vm.Panes);
    }

    private sealed class SeededFakeWorker : StubWorkerClient
    {
        public override IReadOnlyList<ActiveTaskDto> GetActiveTasks()
            => new[] { new ActiveTaskDto("slot-1", "seed1", DateTime.UtcNow) };
    }

    // ── acceptance criterion (b): RefreshQueueAsync returns Running rows first ─

    [Fact]
    public async Task RefreshQueueAsync_RunningFirst_WithIsRunningSet()
    {
        await using (var db = NewContext())
        {
            db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
            // Running task has higher SortOrder — must still appear first
            db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "r1", ListId = "L1", Title = "running-task", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 10 });
            db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "q1", ListId = "L1", Title = "queued-task", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
            await db.SaveChangesAsync();
        }

        var worker = new FakeWorker();
        using var vm = BuildVm(worker);

        await vm.RefreshQueueAsync();

        Assert.True(vm.HasQueued);
        Assert.Equal(2, vm.Queued.Count);

        Assert.Equal("r1", vm.Queued[0].Id);
        Assert.True(vm.Queued[0].IsRunning);

        Assert.Equal("q1", vm.Queued[1].Id);
        Assert.False(vm.Queued[1].IsRunning);
    }

    [Fact]
    public async Task HasQueued_TrueWhenOnlyRunningTasksExist()
    {
        await using (var db = NewContext())
        {
            db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
            db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "r1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
            await db.SaveChangesAsync();
        }

        var worker = new FakeWorker();
        using var vm = BuildVm(worker);

        await vm.RefreshQueueAsync();

        Assert.True(vm.HasQueued);
    }

    // ── acceptance criterion (c): row click invokes OpenInApp ──────────────────

    [Fact]
    public async Task Row_Click_InvokesOpenInApp()
    {
        await using (var db = NewContext())
        {
            db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
            db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "q1", ListId = "L1", Title = "clickable", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
            await db.SaveChangesAsync();
        }

        var worker = new FakeWorker();
        using var vm = BuildVm(worker);
        string? opened = null;
        vm.OpenInApp = id => opened = id;

        await vm.RefreshQueueAsync();
        vm.Queued[0].OpenInAppCommand!.Execute(null);

        Assert.Equal("q1", opened);
    }

    [Fact]
    public async Task Row_Click_RunningTask_InvokesOpenInApp()
    {
        await using (var db = NewContext())
        {
            db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
            db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "r1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
            await db.SaveChangesAsync();
        }

        var worker = new FakeWorker();
        using var vm = BuildVm(worker);
        string? opened = null;
        vm.OpenInApp = id => opened = id;

        await vm.RefreshQueueAsync();
        vm.Queued[0].OpenInAppCommand!.Execute(null);

        Assert.Equal("r1", opened);
    }

    [Fact]
    public async Task Queue_ReflectsQueuedTasks_InSortOrder()
    {
        await SeedQueueAsync();
        var worker = new FakeWorker();
        using var vm = BuildVm(worker);

        await vm.RefreshQueueAsync();

        Assert.True(vm.HasQueued);
        Assert.Equal(new[] { "first", "second" }, vm.Queued.Select(q => q.Title).ToArray());
    }

    // ── activity strip: attention rows come first and carry a tint ─────────────

    [Fact]
    public async Task RefreshQueueAsync_IncludesAttentionRows_AttentionFirst_WithTint()
    {
        await using (var db = NewContext())
        {
            db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
            db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "q1", ListId = "L1", Title = "queued", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
            db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "run1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 1 });
            db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "rev1", ListId = "L1", Title = "review", Status = TaskStatus.WaitingForReview, CreatedAt = DateTime.UtcNow, SortOrder = 2 });
            db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "rb1", ListId = "L1", Title = "roadblocked", Status = TaskStatus.WaitingForReview, RoadblockCount = 2, CreatedAt = DateTime.UtcNow, SortOrder = 3 });
            db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "f1", ListId = "L1", Title = "failed", Status = TaskStatus.Failed, CreatedAt = DateTime.UtcNow, SortOrder = 4 });
            db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "done1", ListId = "L1", Title = "done", Status = TaskStatus.Done, CreatedAt = DateTime.UtcNow, SortOrder = 5 });
            await db.SaveChangesAsync();
        }

        using var vm = BuildVm(new FakeWorker());
        await vm.RefreshQueueAsync();

        // Done is not activity; the other five are, ordered failed → review → running → queued.
        Assert.Equal(new[] { "f1", "rev1", "rb1", "run1", "q1" }, vm.Queued.Select(q => q.Id).ToArray());

        Assert.Equal("ErrorTint", vm.Queued[0].TintKey);
        Assert.Equal("DoneTint", vm.Queued[1].TintKey);
        Assert.Equal("RoadblockTint", vm.Queued[2].TintKey);   // roadblocks outrank plain review
        Assert.Equal("RunningTint", vm.Queued[3].TintKey);
        Assert.Null(vm.Queued[4].TintKey);
        Assert.Equal("RunningTintBorder", vm.Queued[3].TintBorderKey);

        // A running task's worktree belongs to the autonomous run — no interactive session on it.
        Assert.False(vm.Queued[3].CanOpenSession);
        Assert.True(vm.Queued[4].CanOpenSession);
    }

    [Fact]
    public async Task RefreshSessionTargetsAsync_ListsWithWorkingDir_PlusBrowse()
    {
        await using (var db = NewContext())
        {
            db.Lists.Add(new ListEntity { Id = "L1", Name = "Alpha", WorkingDir = @"C:\repos\alpha", CreatedAt = DateTime.UtcNow, SortOrder = 1 });
            db.Lists.Add(new ListEntity { Id = "L2", Name = "Beta", WorkingDir = @"C:\repos\beta", CreatedAt = DateTime.UtcNow, SortOrder = 0 });
            db.Lists.Add(new ListEntity { Id = "L3", Name = "NoDir", CreatedAt = DateTime.UtcNow, SortOrder = 2 });
            await db.SaveChangesAsync();
        }

        using var vm = BuildVm(new FakeWorker());
        var browsed = false;
        vm.BrowseForSessionRequested = () => browsed = true;

        await vm.RefreshSessionTargetsAsync();

        // Browse first (survives a failing list query), then the lists in sort order.
        Assert.Equal(3, vm.SessionTargets.Count);
        Assert.Equal(new[] { "Beta", "Alpha" }, vm.SessionTargets.Skip(1).Select(t => t.Label).ToArray());

        vm.SessionTargets[0].StartCommand.Execute(null);
        Assert.True(browsed);
    }

    private async Task SeedQueueAsync()
    {
        await using var db = NewContext();
        db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
        db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "q2", ListId = "L1", Title = "second", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 1 });
        db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "q1", ListId = "L1", Title = "first", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
        db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "idle1", ListId = "L1", Title = "idle", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 2 });
        await db.SaveChangesAsync();
    }

    // Mirrors TaskStateService.EnqueueAsync writing to the same DB so the test can verify the
    // resulting task state without the real worker process — EnqueueTaskAsync now delegates the
    // actual status write to the worker hub instead of doing a raw EF write itself.
    private sealed class QueueingWorkerClient : StubWorkerClient
    {
        private readonly Func<ClaudeDoDbContext> _newContext;
        public List<string> QueuedTaskIds { get; } = new();

        public QueueingWorkerClient(Func<ClaudeDoDbContext> newContext) => _newContext = newContext;

        public override async Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, TaskStatus status)
        {
            QueuedTaskIds.Add(taskId);
            await using var db = _newContext();
            var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == taskId);
            if (entity is null) return null;
            entity.Status = status;
            await db.SaveChangesAsync();
            return null;
        }
    }

    [Fact]
    public async Task EnqueueTaskAsync_SetsTaskQueued_AndShowsInStrip()
    {
        await using (var db = NewContext())
        {
            db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
            db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "idleTask", ListId = "L1", Title = "Do the thing", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
            await db.SaveChangesAsync();
        }

        var worker = new QueueingWorkerClient(NewContext);
        using var vm = BuildVm(worker);

        await vm.EnqueueTaskAsync("idleTask");

        Assert.Equal(new[] { "idleTask" }, worker.QueuedTaskIds);
        Assert.True(vm.HasQueued);
        Assert.Contains(vm.Queued, q => q.Id == "idleTask");

        await using var verify = NewContext();
        var entity = await verify.Tasks.FirstAsync(t => t.Id == "idleTask");
        Assert.Equal(TaskStatus.Queued, entity.Status);
    }

    [Fact]
    public async Task EnqueueTaskAsync_TaskHasOpenConPtySession_DoesNotQueue()
    {
        await using (var db = NewContext())
        {
            db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
            db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "t1", ListId = "L1", Title = "Do the thing", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
            await db.SaveChangesAsync();
        }

        var worker = new QueueingWorkerClient(NewContext);
        using var vm = BuildVm(worker);
        await vm.OpenConPtySessionAsync("t1");

        string? error = null;
        vm.ErrorReported += msg => error = msg;

        await vm.EnqueueTaskAsync("t1");

        Assert.Empty(worker.QueuedTaskIds);
        Assert.NotNull(error);
        await using var verify = NewContext();
        var entity = await verify.Tasks.FirstAsync(t => t.Id == "t1");
        Assert.Equal(TaskStatus.Idle, entity.Status);
    }

    private sealed class ThrowingSetStatusWorkerClient : StubWorkerClient
    {
        public Exception Error { get; init; } = new Exception("enqueue offline");
        public override Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, TaskStatus status) =>
            throw Error;
    }

    [Fact]
    public async Task EnqueueTaskAsync_WhenWorkerThrows_RaisesErrorReported()
    {
        var worker = new ThrowingSetStatusWorkerClient();
        using var vm = BuildVm(worker);

        string? error = null;
        vm.ErrorReported += msg => error = msg;

        await vm.EnqueueTaskAsync("t1");

        Assert.NotNull(error);
        Assert.Contains("enqueue offline", error);
    }

    private sealed class ThrowingLaunchSpecWorker : StubWorkerClient
    {
        public override Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
            => throw new InvalidOperationException("no worktree");
    }

    [Fact]
    public async Task OpenConPtySessionAsync_AddsPane_ToConPtySessionsAndPanes()
    {
        var worker = new FakeWorker();
        using var vm = BuildVm(worker);

        await vm.OpenConPtySessionAsync("t1");

        Assert.Single(vm.ConPtySessions);
        Assert.Equal("t1", vm.ConPtySessions[0].TaskId);
        Assert.Single(vm.Panes);
        Assert.Same(vm.ConPtySessions[0], vm.Panes[0]);
        Assert.True(vm.HasPanes);
    }

    [Fact]
    public async Task OpenConPtySessionAsync_SameTaskTwice_DoesNotDuplicate()
    {
        var worker = new FakeWorker();
        using var vm = BuildVm(worker);

        await vm.OpenConPtySessionAsync("t1");
        await vm.OpenConPtySessionAsync("t1");

        Assert.Single(vm.ConPtySessions);
    }

    [Fact]
    public async Task OpenConPtySessionAsync_OverlappingCallsSameTask_ProducesSinglePane()
    {
        var worker = new FakeWorker();
        using var vm = BuildGatedVm(worker, out var factory);

        var t1 = vm.OpenConPtySessionAsync("t1");
        var t2 = vm.OpenConPtySessionAsync("t1");
        factory.Release();
        await System.Threading.Tasks.Task.WhenAll(t1, t2);

        Assert.Single(vm.ConPtySessions);
        Assert.Single(vm.Panes);
    }

    [Fact]
    public async Task OpenPlanningConPtySessionAsync_AddsPane_ToConPtySessionsAndPanes()
    {
        var worker = new FakeWorker();
        using var vm = BuildVm(worker);

        await vm.OpenPlanningConPtySessionAsync("t1", resume: false);

        Assert.Single(vm.ConPtySessions);
        Assert.Equal("t1", vm.ConPtySessions[0].TaskId);
        Assert.Single(vm.Panes);
    }

    [Fact]
    public async Task OpenPlanningConPtySessionAsync_OverlappingCallsSameTask_ProducesSinglePane()
    {
        var worker = new FakeWorker();
        using var vm = BuildGatedVm(worker, out var factory);

        var t1 = vm.OpenPlanningConPtySessionAsync("t1", resume: false);
        var t2 = vm.OpenPlanningConPtySessionAsync("t1", resume: false);
        factory.Release();
        await System.Threading.Tasks.Task.WhenAll(t1, t2);

        Assert.Single(vm.ConPtySessions);
        Assert.Single(vm.Panes);
    }

    [Fact]
    public async Task OpenConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure()
    {
        var worker = new ThrowingLaunchSpecWorker();
        using var vm = BuildVm(worker);
        string? error = null;
        vm.ErrorReported += msg => error = msg;

        await vm.OpenConPtySessionAsync("t1");

        // The tile is shown up-front (spinner) and keeps the failure inline rather than vanishing.
        var pane = Assert.Single(vm.ConPtySessions);
        Assert.NotNull(pane.Terminal.StartError);
        Assert.False(pane.Terminal.IsStarting);
        Assert.NotNull(error);
    }

    [Fact]
    public async Task Panes_MirrorsConPtySessions_InOrder()
    {
        var worker = new FakeWorker();
        using var vm = BuildVm(worker);

        await vm.OpenConPtySessionAsync("t1");
        await vm.OpenConPtySessionAsync("t2");

        Assert.Equal(2, vm.Panes.Count);
        Assert.Same(vm.ConPtySessions[0], vm.Panes[0]);
        Assert.Same(vm.ConPtySessions[1], vm.Panes[1]);
        Assert.Equal(2, vm.ColumnCount);
    }

    [Fact]
    public async Task CloseConPtySession_RemovesFromConPtySessionsAndPanes()
    {
        var worker = new FakeWorker();
        using var vm = BuildVm(worker);
        await vm.OpenConPtySessionAsync("t1");
        var pane = vm.ConPtySessions[0];

        pane.CloseCommand.Execute(null);

        Assert.Empty(vm.ConPtySessions);
        Assert.Empty(vm.Panes);
    }

    private sealed class ThrowingAdHocLaunchSpecWorker : StubWorkerClient
    {
        public override Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
            => throw new InvalidOperationException("bad directory");
    }

    [Fact]
    public async Task OpenAdHocConPtySessionAsync_AddsPane_TitleFromDirectoryLeaf()
    {
        var worker = new FakeWorker();
        using var vm = BuildVm(worker);

        await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path", "MyProject"));

        Assert.Single(vm.ConPtySessions);
        Assert.Null(vm.ConPtySessions[0].TaskId);
        Assert.Equal("MyProject", vm.ConPtySessions[0].DisplayTitle);
        Assert.Single(vm.Panes);
        Assert.Same(vm.ConPtySessions[0], vm.Panes[0]);
    }

    [Fact]
    public async Task OpenAdHocConPtySessionAsync_TwoOpens_NeverDeduped_YieldsTwoPanes()
    {
        var worker = new FakeWorker();
        using var vm = BuildVm(worker);

        var dir = Path.Combine("C:", "Some", "Path");
        await vm.OpenAdHocConPtySessionAsync(dir);
        await vm.OpenAdHocConPtySessionAsync(dir);

        Assert.Equal(2, vm.ConPtySessions.Count);
        Assert.Equal(2, vm.Panes.Count);
        Assert.All(vm.ConPtySessions, s => Assert.Null(s.TaskId));
    }

    [Fact]
    public async Task OpenAdHocConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure()
    {
        var worker = new ThrowingAdHocLaunchSpecWorker();
        using var vm = BuildVm(worker);
        string? error = null;
        vm.ErrorReported += msg => error = msg;

        await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path"));

        var pane = Assert.Single(vm.ConPtySessions);
        Assert.NotNull(pane.Terminal.StartError);
        Assert.False(pane.Terminal.IsStarting);
        Assert.NotNull(error);
    }

    [Fact]
    public async Task CloseConPtySession_RemovesAdHocPane_FromConPtySessionsAndPanes()
    {
        var worker = new FakeWorker();
        using var vm = BuildVm(worker);
        await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path"));
        var pane = vm.ConPtySessions[0];

        pane.CloseCommand.Execute(null);

        Assert.Empty(vm.ConPtySessions);
        Assert.Empty(vm.Panes);
    }

    private sealed class ThrowingMergeHelperLaunchSpecWorker : StubWorkerClient
    {
        public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default)
            => throw new InvalidOperationException("spec failed");
    }

    private sealed class ThrowingCreateMergeHelperTaskWorker : StubWorkerClient
    {
        public override Task<string> CreateMergeHelperTaskAsync(
            IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
            => throw new InvalidOperationException("create failed");
    }

    private sealed class FixedTaskIdMergeHelperWorker : StubWorkerClient
    {
        public string TaskId { get; } = "fixed-handler-task";
        public int CreateCallCount { get; private set; }
        public override Task<string> CreateMergeHelperTaskAsync(
            IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
        {
            CreateCallCount++;
            return Task.FromResult(TaskId);
        }
    }

    [Fact]
    public async Task OpenMergeHelperConPtySessionAsync_AddsTaskBasedPane()
    {
        var worker = new FakeWorker();
        using var vm = BuildVm(worker);

        await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1", "t2" });

        var pane = Assert.Single(vm.ConPtySessions);
        Assert.NotNull(pane.TaskId);
        Assert.True(pane.IsTaskBased);
        Assert.Single(vm.Panes);
        Assert.Same(vm.ConPtySessions[0], vm.Panes[0]);
    }

    [Fact]
    public async Task OpenMergeHelperConPtySessionAsync_TwoRuns_EachGetsItsOwnTaskAndPane()
    {
        // Each run creates a brand-new handler task (the stub returns a fresh Guid per call),
        // so two runs still yield two distinct, non-deduped panes -- but for a different reason
        // than the old ad-hoc pane (never deduped by construction): dedup is now by TaskId, and
        // there simply is no shared TaskId across separate runs.
        var worker = new FakeWorker();
        using var vm = BuildVm(worker);

        await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
        await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });

        Assert.Equal(2, vm.ConPtySessions.Count);
        Assert.NotEqual(vm.ConPtySessions[0].TaskId, vm.ConPtySessions[1].TaskId);
    }

    [Fact]
    public async Task OpenMergeHelperConPtySessionAsync_SameHandlerTaskId_FocusesExistingPane_NoDuplicate()
    {
        var worker = new FixedTaskIdMergeHelperWorker();
        using var vm = BuildVm(worker);

        await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
        var firstPane = Assert.Single(vm.ConPtySessions);

        await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });

        Assert.Single(vm.ConPtySessions);
        Assert.Same(firstPane, vm.FocusedPane);
    }

    [Fact]
    public async Task OpenMergeHelperConPtySessionAsync_OverlappingCallsSameList_CreatesOnlyOneHostTask()
    {
        var worker = new FixedTaskIdMergeHelperWorker();
        var factory = new GatedDbFactory(NewContext);
        using var vm = new MissionControlViewModel(factory, worker, new UsagePillViewModel(worker));

        // Both calls run their synchronous guard-check prefix before either resumes past the
        // gated DB lookup — call 2 must lose the race and bail out immediately.
        var t1 = vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
        var t2 = vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
        factory.Release();
        await System.Threading.Tasks.Task.WhenAll(t1, t2);

        Assert.Equal(1, worker.CreateCallCount);
        Assert.Single(vm.ConPtySessions);
        Assert.Single(vm.Panes);
    }

    [Fact]
    public async Task OpenMergeHelperConPtySessionAsync_EmptySelection_NoPane()
    {
        var worker = new FakeWorker();
        using var vm = BuildVm(worker);

        await vm.OpenMergeHelperConPtySessionAsync("L1", Array.Empty<string>());

        Assert.Empty(vm.ConPtySessions);
        Assert.Empty(vm.Panes);
    }

    [Fact]
    public async Task OpenMergeHelperConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure()
    {
        var worker = new ThrowingMergeHelperLaunchSpecWorker();
        using var vm = BuildVm(worker);
        string? error = null;
        vm.ErrorReported += msg => error = msg;

        await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });

        var pane = Assert.Single(vm.ConPtySessions);
        Assert.NotNull(pane.Terminal.StartError);
        Assert.False(pane.Terminal.IsStarting);
        Assert.NotNull(error);
    }

    [Fact]
    public async Task OpenMergeHelperConPtySessionAsync_TaskCreationThrows_RaisesErrorReported_NoPaneCreated()
    {
        var worker = new ThrowingCreateMergeHelperTaskWorker();
        using var vm = BuildVm(worker);
        string? error = null;
        vm.ErrorReported += msg => error = msg;

        await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });

        Assert.Empty(vm.ConPtySessions);
        Assert.Empty(vm.Panes);
        Assert.NotNull(error);
    }

    private sealed class RecordingReviewMergeWorker : StubWorkerClient
    {
        public string TaskId { get; } = "review-handler-task";
        public readonly TaskCompletionSource<(string TaskId, IReadOnlyList<string> Ids, string Phase)> HandoffCall = new();
        public override Task<string> CreateMergeHelperTaskAsync(
            IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
            => Task.FromResult(TaskId);
        public override Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
            string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default)
        {
            HandoffCall.TrySetResult((taskId, survivingTaskIds, nextPhase));
            return base.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase, ct);
        }
    }

    // The review-list "merge the reviews" entry: same host-task shape as the full list handler,
    // but the pane's launch spec must come from the handoff path with phase "merge" and the
    // freshly created handler task id — no triage phase first.
    [Fact]
    public async Task OpenMergeReviewConPtySessionAsync_AddsPane_LaunchingMergePhaseForHandlerTask()
    {
        var worker = new RecordingReviewMergeWorker();
        using var vm = BuildVm(worker);

        await vm.OpenMergeReviewConPtySessionAsync("L1", new[] { "r1", "r2" });

        var pane = Assert.Single(vm.ConPtySessions);
        Assert.Equal(worker.TaskId, pane.TaskId);
        Assert.True(pane.IsTaskBased);

        var call = await worker.HandoffCall.Task.WaitAsync(TimeSpan.FromSeconds(5));
        Assert.Equal(worker.TaskId, call.TaskId);
        Assert.Equal(new[] { "r1", "r2" }, call.Ids);
        Assert.Equal("merge", call.Phase);
    }

    [Fact]
    public async Task OpenMergeHelperHandoffConPtySessionAsync_ExistingPaneForTaskId_ClosesOldPane_OpensNewFocusedPane()
    {
        var worker = new FakeWorker();
        using var vm = BuildVm(worker);
        await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
        var outgoingPane = Assert.Single(vm.ConPtySessions);
        var taskId = outgoingPane.TaskId!;

        await vm.OpenMergeHelperHandoffConPtySessionAsync(taskId, new[] { "t1" }, "wait");

        var survivingPane = Assert.Single(vm.ConPtySessions);
        Assert.Equal(taskId, survivingPane.TaskId);
        Assert.NotSame(outgoingPane, survivingPane);
        Assert.Same(survivingPane, vm.FocusedPane);
        Assert.Single(vm.Panes);
        Assert.Same(survivingPane, vm.Panes[0]);
        // The outgoing pane went through CloseConPtySession (not just spliced out of the
        // collection) -- it unsubscribed its event handlers, same signal the
        // CloseConPtySession_UnsubscribesSubmitForReviewRequested test relies on.
        Assert.Equal(0, SubscriberCount(outgoingPane, "SubmitForReviewRequested"));
    }

    [Fact]
    public async Task OpenMergeHelperHandoffConPtySessionAsync_SubmitForReview_ResolvesToSurvivingPane()
    {
        var worker = new BlockingSubmitWorker();
        using var vm = BuildVm(worker);
        await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
        var outgoingPane = Assert.Single(vm.ConPtySessions);
        var taskId = outgoingPane.TaskId!;

        await vm.OpenMergeHelperHandoffConPtySessionAsync(taskId, new[] { "t1" }, "wait");
        var survivingPane = Assert.Single(vm.ConPtySessions);
        survivingPane.Terminal.IsRunning = true; // simulate a live hand-driven session

        survivingPane.SubmitForReviewCommand.Execute(null);

        Assert.Equal(1, worker.CallCount);
        Assert.True(survivingPane.IsSubmitPending);

        worker.Gate.SetResult(null);
        await Task.Delay(20);

        Assert.Empty(vm.ConPtySessions);
    }

    private sealed class BlockingSubmitWorker : StubWorkerClient
    {
        public int CallCount { get; private set; }
        public readonly TaskCompletionSource<object?> Gate = new();

        public override Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default)
        {
            CallCount++;
            return Gate.Task;
        }
    }

    [Fact]
    public async Task SubmitForReview_RapidDoubleClick_OnlyCallsWorkerOnce()
    {
        var worker = new BlockingSubmitWorker();
        using var vm = BuildVm(worker);
        await vm.OpenConPtySessionAsync("t1");
        var pane = vm.ConPtySessions[0];
        pane.Terminal.IsRunning = true; // simulate a live hand-driven session

        // Bypass CanExecute entirely -- Execute(null) is what a genuinely simultaneous
        // double-click would still reach even if the button briefly disables itself.
        pane.SubmitForReviewCommand.Execute(null);
        pane.SubmitForReviewCommand.Execute(null);

        Assert.Equal(1, worker.CallCount);
        Assert.True(pane.IsSubmitPending);

        worker.Gate.SetResult(null);
        await Task.Delay(20);

        Assert.Empty(vm.ConPtySessions);
    }

    [Fact]
    public async Task SubmitForReview_Failure_ClearsIsSubmitPending_AllowingRetry()
    {
        var worker = new ThrowingSubmitWorker();
        using var vm = BuildVm(worker);
        await vm.OpenConPtySessionAsync("t1");
        var pane = vm.ConPtySessions[0];
        pane.Terminal.IsRunning = true;
        string? error = null;
        vm.ErrorReported += msg => error = msg;

        pane.SubmitForReviewCommand.Execute(null);

        Assert.NotNull(error);
        Assert.False(pane.IsSubmitPending);
        Assert.True(pane.SubmitForReviewCommand.CanExecute(null));
    }

    private sealed class ThrowingSubmitWorker : StubWorkerClient
    {
        public override Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default)
            => throw new InvalidOperationException("worker unreachable");
    }

    private static int SubscriberCount(ConPtyPaneViewModel pane, string eventFieldName)
    {
        var field = typeof(ConPtyPaneViewModel).GetField(eventFieldName,
            System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
        var del = (Delegate?)field!.GetValue(pane);
        return del?.GetInvocationList().Length ?? 0;
    }

    [Fact]
    public async Task CloseConPtySession_UnsubscribesSubmitForReviewRequested()
    {
        var worker = new FakeWorker();
        using var vm = BuildVm(worker);
        await vm.OpenConPtySessionAsync("t1");
        var pane = vm.ConPtySessions[0];

        Assert.Equal(1, SubscriberCount(pane, "SubmitForReviewRequested"));

        pane.CloseCommand.Execute(null);

        Assert.Equal(0, SubscriberCount(pane, "SubmitForReviewRequested"));
        Assert.Equal(0, SubscriberCount(pane, "ErrorReported"));
    }

    [Fact]
    public async Task Dispose_UnsubscribesSubmitForReviewRequested()
    {
        var worker = new FakeWorker();
        var vm = BuildVm(worker);
        await vm.OpenConPtySessionAsync("t1");
        var pane = vm.ConPtySessions[0];

        vm.Dispose();

        Assert.Equal(0, SubscriberCount(pane, "SubmitForReviewRequested"));
    }

    [Fact]
    public void ToggleLayoutCommand_FlipsIsFocusMode()
    {
        var worker = new FakeWorker();
        using var vm = BuildVm(worker);

        Assert.False(vm.IsFocusMode);
        vm.ToggleLayoutCommand.Execute(null);
        Assert.True(vm.IsFocusMode);
        vm.ToggleLayoutCommand.Execute(null);
        Assert.False(vm.IsFocusMode);
    }
}
