using System.Collections.ObjectModel;
using System.Globalization;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Filtering;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Services.Interfaces;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Ui.ViewModels.Islands;

/// <summary>A group header entry in <see cref="TasksIslandViewModel.Rows"/> — the flat list's
/// other union member alongside <see cref="TaskRowViewModel"/>. Instances are persistent (one per
/// section, mutated in place by <see cref="TasksIslandViewModel.Regroup"/>) so an unchanged header
/// keeps its identity across a reconcile.</summary>
public sealed partial class HeaderRow : ViewModelBase
{
    [ObservableProperty] private string _label = "";
    [ObservableProperty] private int _count;
    public bool IsOverdue { get; init; }
    public IRelayCommand? ActionCommand { get; init; }
    public bool HasAction => ActionCommand is not null;
}

public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{
    private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
    private readonly IWorkerClient? _worker;
    private readonly Dictionary<string, bool> _expandedState = new();
    private ListNavItemViewModel? _currentList;
    private CancellationTokenSource? _loadCts;
    // Task ids with a live ConPTY pane in Mission Control; kept here so rows loaded later still
    // pick the flag up (see SyncInteractiveSessions).
    private readonly HashSet<string> _interactiveSessionIds = new();
    private static readonly TaskListFilterRegistry _filters = new();
    // Two events (TaskUpdated + WorktreeUpdated) drive the same delta refresh, so two reads for
    // one task can be in flight at once. Only the newest may write to the row.
    private readonly Dictionary<string, long> _deltaSeq = new();
    private long _deltaCounter;
    // Phase 3 reconcile tick — see the block at the end of this class.
    private readonly System.Timers.Timer _reconcileTimer = new(4_000);

    public event EventHandler? SelectionChanged;

    /// <summary>Why the selection last changed. Recorded with the detail-pane bind, so the timing
    /// log attributes rebind churn to a trigger instead of leaving an anonymous count.</summary>
    public string SelectionSource { get; private set; } = "init";

    /// <summary>Sets <see cref="SelectedTask"/> and tags what triggered it — use this instead of
    /// assigning the property, otherwise the bind is logged as "?" .</summary>
    public void SelectFrom(TaskRowViewModel? row, string source)
    {
        SelectionSource = source;
        SelectedTask = row;
    }

    public event EventHandler? FocusAddTaskRequested;
    public event EventHandler? TasksChanged;
    public event Action? PrepRequested;
    public event Action<string>? ErrorReported;
    public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty);

    [RelayCommand]
    private void ShowPrepLog() => PrepRequested?.Invoke();

    [RelayCommand]
    private async Task ClearDayAsync()
    {
        if (_worker is null) return;
        try { await _worker.ClearMyDayAsync(); }
        catch { /* worker offline; broadcast will reconcile on return */ }
    }

    public ObservableCollection<TaskRowViewModel> Items { get; } = new();

    /// <summary>Flat, virtualization-friendly view of <see cref="Items"/>: group headers
    /// (<see cref="HeaderRow"/>) interleaved with visible <see cref="TaskRowViewModel"/> rows, in
    /// display order. Reconciled granularly by <see cref="Regroup"/> — never Clear+Add.</summary>
    public ObservableCollection<object> Rows { get; } = new();

    private readonly HeaderRow _overdueHeaderRow = new() { IsOverdue = true };
    private readonly HeaderRow _openHeaderRow = new();
    private readonly HeaderRow _completedHeaderRow;

    [ObservableProperty] private string _newTaskTitle = "";
    [ObservableProperty] private TaskRowViewModel? _selectedTask;
    [ObservableProperty] private string _headerTitle = "";
    [ObservableProperty] private string _headerEyebrow = "";
    [ObservableProperty] private string _subtitle = "";
    [ObservableProperty] private string _statusPill = "";
    [ObservableProperty] private bool _hasStatusPill;
    [ObservableProperty] private bool _isShowingCompleted = true;
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsTasksEmptyHintVisible))]
    [NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
    private bool _hasOverdue;
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsTasksEmptyHintVisible))]
    [NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
    private bool _hasOpen;
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsTasksEmptyHintVisible))]
    [NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
    private bool _hasCompleted;
    [ObservableProperty] private bool _showOpenLabel;
    [ObservableProperty] private string _completedHeader = "";
    [ObservableProperty] private bool _isMyDayList;

    /// <summary>The notes list is content, not a task list — the island swaps its rows for the
    /// notes editor instead of showing an empty task list.</summary>
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsTasksEmptyHintVisible))]
    [NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
    private bool _isNotesList;

    /// <summary>Null only in tests that construct the island without a notes API.</summary>
    public NotesEditorViewModel? Notes { get; }
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
    private bool _isLetClaudeVisible;
    [ObservableProperty] private bool _isQuickClaudeVisible;
    [ObservableProperty] private bool _isMergeReviewsVisible;

    /// <summary>No visible tasks below the add-task row — every item lands in one of
    /// Overdue/Open/Completed, so all-false here always means the list has zero items.</summary>
    public bool IsTasksEmptyHintVisible => !HasOverdue && !HasOpen && !HasCompleted && !IsNotesList;

    /// <summary>Extra empty-state line for a User list with no linked working dir, where the
    /// execution features (queue, Let Claude handle it, Quick session) are unavailable. Derived
    /// separately from <see cref="IsLetClaudeVisible"/> so Smart/Virtual lists — which also have
    /// no working dir but aren't missing a repo link — don't pick it up.</summary>
    public bool IsTasksEmptyRepoHintVisible =>
        IsTasksEmptyHintVisible && _currentList?.Kind == ListKind.User && !IsLetClaudeVisible;

    // Shared by QueuePlanningSubtasksAsync and FinalizePlanningSessionAsync — both are triggered
    // from a context menu that closes the instant a click lands, so there is no per-row surface
    // left standing to anchor an indicator to. The island header is the nearest surface still
    // visible after the menu closes.
    public OperationStatus PlanningOperation { get; } = new();

    public event EventHandler? LetClaudeHandleRequested;

    [RelayCommand]
    private void LetClaudeHandle() => LetClaudeHandleRequested?.Invoke(this, EventArgs.Empty);

    // "Let Claude merge the reviews" on the virtual review list — the shell routes it to the
    // review-scoped selection modal, which starts one merge-phase handler session per list.
    public event EventHandler? MergeReviewsRequested;

    [RelayCommand]
    private void MergeReviews() => MergeReviewsRequested?.Invoke(this, EventArgs.Empty);

    // Opens a task-less ConPTY session directly in the current list's working dir. The shell owns
    // Mission Control, so this just raises an event for it to act on (mirrors OpenConPtySessionRequested).
    public event Action<string>? OpenQuickClaudeSessionRequested;

    [RelayCommand]
    private void OpenQuickClaudeSession()
    {
        var dir = _currentList?.WorkingDir;
        if (string.IsNullOrWhiteSpace(dir))
        {
            ErrorReported?.Invoke(Loc.T("vm.tasksIsland.quickClaudeNoWorkingDir"));
            return;
        }
        if (!System.IO.Directory.Exists(dir))
        {
            ErrorReported?.Invoke(Loc.T("vm.tasksIsland.quickClaudeDirMissing", dir));
            return;
        }
        OpenQuickClaudeSessionRequested?.Invoke(dir);
    }

    internal Task? LoadTask { get; private set; }

    public Func<UnfinishedPlanningModalViewModel, Task>? ShowUnfinishedPlanningModal { get; set; }

    private readonly EventHandler _langChangedHandler;

    public TasksIslandViewModel(
        IDbContextFactory<ClaudeDoDbContext> dbFactory,
        IWorkerClient? worker = null,
        INotesApi? notesApi = null)
    {
        _dbFactory = dbFactory;
        _worker = worker;
        if (notesApi is not null)
        {
            Notes = new NotesEditorViewModel(notesApi);
            Notes.ErrorReported += msg => ErrorReported?.Invoke(msg);
            if (worker is not null)
                worker.NotesUpdatedEvent += () => { if (IsNotesList) _ = Notes.LoadAsync(); };
        }
        _completedHeaderRow = new() { ActionCommand = ClearCompletedCommand };
        CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
        if (_worker is not null)
        {
            _worker.TaskUpdatedEvent       += OnWorkerTaskUpdated;
            _worker.WorktreeUpdatedEvent   += OnWorkerTaskUpdated;
            _worker.ListUpdatedEvent       += OnWorkerListUpdated;
            _worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
            _worker.RefineStartedEvent     += OnRefineStarted;
            _worker.RefineFinishedEvent    += OnRefineFinished;
            _worker.OperationProgressEvent += OnWorkerOperationProgress;
        }
        _langChangedHandler = (_, _) => RefreshLocalizedText();
        Loc.LanguageChanged += _langChangedHandler;
        _reconcileTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(() => _ = ReconcileTickAsync());
        _reconcileTimer.Start();

        // OperationStatus is a nested ObservableObject — [NotifyCanExecuteChangedFor] can't see
        // into it, so IsRunning changes are relayed by hand.
        PlanningOperation.PropertyChanged += (_, e) =>
        {
            if (e.PropertyName != nameof(OperationStatus.IsRunning)) return;
            QueuePlanningSubtasksCommand.NotifyCanExecuteChanged();
            FinalizePlanningSessionCommand.NotifyCanExecuteChanged();
        };
    }

    public void Dispose()
    {
        Loc.LanguageChanged -= _langChangedHandler;
        _reconcileTimer.Stop();
        _reconcileTimer.Dispose();
    }

    private void RefreshLocalizedText()
    {
        CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
        foreach (var row in Items) row.RefreshLocalized();
        // The section headers are now Rows entries whose Label is a resolved string, not a live
        // {loc:Tr} binding in the view — without re-emitting them they keep the old language.
        Regroup();
    }

    private async void OnWorkerListUpdated(string listId)
    {
        // Mirror the renamed list onto every task row that references it,
        // so the per-row ListName chip on virtual lists stays current.
        try
        {
            await using var db = await _dbFactory.CreateDbContextAsync();
            var entity = await db.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
            if (entity is null) return;
            var visibleIds = Items.Select(r => r.Id).ToHashSet();
            if (visibleIds.Count == 0) return;
            var matchingIds = await db.Tasks.AsNoTracking()
                .Where(t => t.ListId == listId && visibleIds.Contains(t.Id))
                .Select(t => t.Id)
                .ToListAsync();
            var matching = matchingIds.ToHashSet();
            foreach (var row in Items)
                if (matching.Contains(row.Id) && row.ListName != entity.Name)
                    row.ListName = entity.Name;
        }
        catch { }
    }

    private async void OnWorkerTaskUpdated(string taskId)
        => await RefreshTaskFromWorkerAsync(taskId);

    // Awaitable so tests can drive it deterministically. One retry, then a full reload:
    // a swallowed exception here used to leave the row on a stale status permanently.
    internal async Task RefreshTaskFromWorkerAsync(string taskId)
    {
        var list = _currentList;
        if (list is null) return;

        // virtual:queued / virtual:running include Planning parents whose children match,
        // which can't be decided from a single entity. Always full-reload in those cases.
        if (list.Kind == ListKind.Virtual &&
            (list.Id == "virtual:queued" || list.Id == "virtual:running"))
        {
            LoadForList(list);
            return;
        }

        var seq = ++_deltaCounter;
        _deltaSeq[taskId] = seq;

        try
        {
            await ApplyDeltaAsync(taskId, list, seq);
        }
        catch (Exception first)
        {
            System.Diagnostics.Debug.WriteLine(
                $"TasksIsland: delta refresh for {taskId} failed ({first.Message}); retrying");
            try
            {
                await ApplyDeltaAsync(taskId, list, seq);
            }
            catch (Exception second)
            {
                System.Diagnostics.Debug.WriteLine(
                    $"TasksIsland: delta retry for {taskId} failed ({second.Message}); full reload");
                LoadForList(list);
            }
        }
    }

    private async Task ApplyDeltaAsync(string taskId, ListNavItemViewModel list, long seq)
    {
        await using var db = await _dbFactory.CreateDbContextAsync();
        var entity = await db.Tasks
            .Include(t => t.List)
            .Include(t => t.Worktree)
            .FirstOrDefaultAsync(t => t.Id == taskId);

        // A newer refresh for this task started while we were reading — its result is fresher.
        if (_deltaSeq.TryGetValue(taskId, out var current) && current != seq) return;

        // A parent transition (finalize/discard) broadcasts only the parent's id, but it
        // changes its children's derived state — finalize flips them Draft→Planned, discard
        // deletes them. The delta path below only touches the parent row and never recomputes
        // the child-derived flags (ParentFinalized, HasPlanningChildren) nor drops deleted
        // children, so reconcile the whole list when the updated task is (or owns) a subtree.
        if (entity is not null &&
            (entity.PlanningPhase != PlanningPhase.None || Items.Any(r => r.ParentTaskId == entity.Id)))
        {
            LoadForList(list);
            return;
        }

        var existing = Items.FirstOrDefault(r => r.Id == taskId);
        var removed = false;

        if (entity is null)
        {
            if (existing is not null) { Items.Remove(existing); removed = true; }
        }
        else
        {
            var matches = TaskMatchesList(entity, list);
            if (existing is not null && matches)  existing.UpdateFromEntity(entity);
            else if (existing is not null)        { Items.Remove(existing); removed = true; }
            else if (matches)                     { LoadForList(list); return; }
            else                                  return;
        }

        // The row is gone from Items but SelectedTask (and the bound detail pane) would still
        // point at it — happens when a task is deleted externally (MCP/another session) while
        // selected here, since that path never runs SelectFrom(null, ...) like the in-UI delete
        // does.
        if (removed && SelectedTask == existing)
            SelectFrom(null, "row-removed");

        // Keep the parent's HasQueuedSubtasks flag in sync when a child's status flips.
        if (entity is not null && !string.IsNullOrEmpty(entity.ParentTaskId))
        {
            var parent = Items.FirstOrDefault(r => r.Id == entity.ParentTaskId);
            if (parent is not null)
                parent.HasQueuedSubtasks = Items.Any(r =>
                    r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting));
        }

        Regroup();
        UpdateSubtitle();
    }

    // NOTE: virtual:queued/virtual:running cannot be decided by a single entity — a Planning
    // parent matches iff any child has the matching status. OnWorkerTaskUpdated handles those
    // lists via a full reload rather than the delta path.
    private static bool TaskMatchesList(TaskEntity t, ListNavItemViewModel list) => list.Kind switch
    {
        ListKind.Smart   when list.Id == "smart:my-day"    => t.IsMyDay,
        ListKind.Smart   when list.Id == "smart:important" => t.IsStarred,
        ListKind.Smart   when list.Id == "smart:planned"   => t.ScheduledFor != null,
        ListKind.Virtual when list.Id == "virtual:review"  => t.Status == TaskStatus.WaitingForReview,
        ListKind.User                                       => $"user:{t.ListId}" == list.Id,
        _                                                   => false,
    };

    private void OnCurrentListPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        if (sender is not ListNavItemViewModel vm) return;
        if (e.PropertyName == nameof(ListNavItemViewModel.Name))
            HeaderTitle = vm.Name;
        else if (e.PropertyName == nameof(ListNavItemViewModel.WorkingDir))
        {
            IsLetClaudeVisible = vm.Kind == ListKind.User && !string.IsNullOrWhiteSpace(vm.WorkingDir);
            IsQuickClaudeVisible = IsLetClaudeVisible;
        }
    }

    public void LoadForList(ListNavItemViewModel? list)
    {
        _loadCts?.Cancel();
        _loadCts?.Dispose();
        _loadCts = new CancellationTokenSource();
        var ct = _loadCts.Token;

        // Items is rebuilt from scratch below, so a selection carried over from the previous list
        // would leave the detail pane bound to a task the visible list no longer contains. Only a
        // *different* list drops it — a reload of the same list (worker refresh, reconnect) keeps
        // the selection so a live update never yanks the detail pane away.
        var listChanged = !string.Equals(_currentList?.Id, list?.Id, StringComparison.Ordinal);

        // A same-list reload (triggered by OnWorkerTaskUpdated's full-reload branches) rebuilds
        // every row from scratch. Reusing the previous instances by id — instead of handing back
        // brand new ones — keeps SelectedTask (and the bound DetailsIslandViewModel.Task) pointed
        // at a live row instead of an orphan that never receives another update.
        var reusable = listChanged ? null : Items.ToDictionary(r => r.Id);

        if (_currentList is not null)
            _currentList.PropertyChanged -= OnCurrentListPropertyChanged;
        _currentList = list;
        if (_currentList is not null)
            _currentList.PropertyChanged += OnCurrentListPropertyChanged;

        // IsTasksEmptyRepoHintVisible reads _currentList?.Kind directly, but Kind isn't itself an
        // observed property — the [NotifyPropertyChangedFor] chain only fires when IsLetClaudeVisible's
        // *value* changes. Switching between two lists that both resolve IsLetClaudeVisible to the
        // same bool (e.g. a Smart list -> an empty User list without a WorkingDir) changes Kind
        // without ever notifying, leaving the hint stale. Force it explicitly on every list switch.
        OnPropertyChanged(nameof(IsTasksEmptyRepoHintVisible));

        Items.Clear();
        Rows.Clear();
        HasOverdue    = false;
        HasOpen       = false;
        HasCompleted  = false;
        ShowOpenLabel = false;
        if (listChanged) SelectFrom(null, "list-change");
        if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; IsMergeReviewsVisible = false; LoadTask = Task.CompletedTask; return; }

        HeaderTitle = list.Name;
        HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant();
        IsMyDayList  = list.Id == "smart:my-day";
        IsNotesList  = list.Id == ListsIslandViewModel.NotesListId;
        if (IsNotesList && Notes is not null) _ = Notes.LoadAsync();
        IsLetClaudeVisible = list.Kind == ListKind.User && !string.IsNullOrWhiteSpace(list.WorkingDir);
        IsQuickClaudeVisible = IsLetClaudeVisible;
        IsMergeReviewsVisible = list.Id == "virtual:review";

        LoadTask = LoadForListAsync(list, ct, reusable);
    }

    private async Task LoadForListAsync(
        ListNavItemViewModel list, CancellationToken ct, Dictionary<string, TaskRowViewModel>? reusable)
    {
        var sw = System.Diagnostics.Stopwatch.StartNew();
        var ok = false;
        try
        {
            await using var db = await _dbFactory.CreateDbContextAsync(ct);
            var filter = _filters.Resolve(list.Id);
            var baseQuery = db.Tasks.Include(t => t.List).Include(t => t.Worktree);

            var filteredList = filter is null
                ? new List<TaskEntity>()
                : await baseQuery.Where(filter.MatchExpression).ToListAsync(ct);

            ct.ThrowIfCancellationRequested();

            if (filter is not null)
            {
                // Contextual parent rows (e.g. a planning parent hosting an already-matched queued/running
                // child): only fetch candidates that could plausibly qualify — parents of tasks we already
                // matched — instead of scanning the whole table.
                var candidateParentIds = filteredList
                    .Where(t => t.ParentTaskId != null)
                    .Select(t => t.ParentTaskId!)
                    .Distinct()
                    .ToList();
                if (candidateParentIds.Count > 0)
                {
                    var existingParentIds = filteredList.Select(t => t.Id).ToHashSet();
                    var parentCandidates = await baseQuery
                        .Where(t => candidateParentIds.Contains(t.Id) && !existingParentIds.Contains(t.Id))
                        .ToListAsync(ct);
                    foreach (var p in parentCandidates)
                        if (filter.MatchesAsContext(p, filteredList))
                            filteredList.Add(p);
                }
            }

            ct.ThrowIfCancellationRequested();

            // Pull in every child of an already-matched top-level row, regardless of whether the child
            // itself matches the filter, so subtasks render nested under their parent.
            var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
            if (topIds.Count > 0)
            {
                var existingIds = filteredList.Select(t => t.Id).ToHashSet();
                var extraChildren = await baseQuery
                    .Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!) && !existingIds.Contains(t.Id))
                    .ToListAsync(ct);
                filteredList.AddRange(extraChildren);
            }

            filteredList = filteredList.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt).ToList();

            var showListChip = list.Kind == ListKind.Virtual;
            foreach (var t in filteredList)
            {
                TaskRowViewModel row;
                if (reusable is not null && reusable.TryGetValue(t.Id, out var existing))
                {
                    row = existing;
                    row.UpdateFromEntity(t);
                }
                else
                {
                    row = TaskRowViewModel.FromEntity(t);
                }
                row.ShowListChip = showListChip;
                row.HasInteractiveSession = _interactiveSessionIds.Contains(row.Id);
                Items.Add(row);
            }

            // Mark any top-level row that has at least one child as a planning parent,
            // so its subtasks remain expandable even after the parent is queued/running.
            var parentsWithChildren = Items
                .Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId))
                .Select(r => r.ParentTaskId!)
                .ToHashSet();
            foreach (var r in Items)
                if (parentsWithChildren.Contains(r.Id))
                    r.HasPlanningChildren = true;

            // Mark planning parents whose children are currently queued/waiting,
            // so the dequeue affordance is visible on the parent row.
            var parentsWithQueuedKids = Items
                .Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId)
                            && (r.IsQueued || r.IsWaiting))
                .Select(r => r.ParentTaskId!)
                .ToHashSet();
            foreach (var r in Items)
                r.HasQueuedSubtasks = parentsWithQueuedKids.Contains(r.Id);

            // A subtask is "Planned" (queueable) once its planning parent is finalized;
            // until then it is a "Draft".
            var finalizedParents = Items
                .Where(r => r.PlanningPhase == PlanningPhase.Finalized)
                .Select(r => r.Id)
                .ToHashSet();
            foreach (var r in Items)
                r.ParentFinalized = !string.IsNullOrEmpty(r.ParentTaskId)
                                    && finalizedParents.Contains(r.ParentTaskId!);

            Regroup();
            UpdateSubtitle();
            ok = true;
        }
        catch (OperationCanceledException) { }
        finally { OperationTiming.Shared.Record("db", "TasksIsland.LoadForListAsync", sw.Elapsed, ok); }
    }

    internal void Regroup()
    {
        // Collapse parents that have children by default, so subtasks stay tucked away until
        // the user expands the row (an explicit toggle is saved and wins over this default).
        var childrenByParent = Items
            .Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId))
            .GroupBy(r => r.ParentTaskId!)
            .ToDictionary(g => g.Key, g => g.ToList());
        foreach (var parent in Items.Where(r => r.IsPlanningParent && !r.IsChild && !r.Done))
        {
            if (_expandedState.ContainsKey(parent.Id)) continue;
            if (childrenByParent.TryGetValue(parent.Id, out var kids) && kids.Count > 0)
                parent.IsExpanded = false;
        }

        // Restore IsExpanded from saved state
        foreach (var r in Items)
        {
            if (_expandedState.TryGetValue(r.Id, out var saved))
                r.IsExpanded = saved;
        }

        var (overdue, open, completed) = ClassifyItems();

        HasOverdue     = overdue.Count > 0;
        HasOpen        = open.Count > 0;
        HasCompleted   = completed.Count > 0;
        ShowOpenLabel  = HasOpen && HasOverdue;
        CompletedHeader = Loc.T("vm.tasksIsland.completedHeaderCount", completed.Count);

        // Build the flat Rows target: header + rows per section, omitting a section entirely
        // when it has nothing to show (no header, no rows) instead of hiding it via IsVisible.
        var target = new List<object>();
        if (HasOverdue)
        {
            _overdueHeaderRow.Label = Loc.T("tasks.overdue");
            _overdueHeaderRow.Count = overdue.Count;
            target.Add(_overdueHeaderRow);
            target.AddRange(overdue);
        }
        if (HasOpen)
        {
            if (ShowOpenLabel)
            {
                _openHeaderRow.Label = Loc.T("tasks.tasks");
                _openHeaderRow.Count = open.Count;
                target.Add(_openHeaderRow);
            }
            target.AddRange(open);
        }
        if (HasCompleted && IsShowingCompleted)
        {
            _completedHeaderRow.Label = CompletedHeader;
            _completedHeaderRow.Count = completed.Count;
            target.Add(_completedHeaderRow);
            target.AddRange(completed);
        }

        // Reconcile Rows in place (granular Insert/Move/Remove) rather than Clear+Add, so toggling
        // a parent only touches its own child rows — the ListBox keeps every unchanged container
        // instead of tearing the whole list down on a Reset.
        SyncCollection(Rows, target);
    }

    // Builds the hierarchy-aware flat ordering (top-level rows interleaved with visible children,
    // orphans flagged so they render flat) and partitions it into Overdue/Open/Completed. Pure
    // with respect to chain state (ShowAsChainMember/ChainStep/ChainAfterLabel) — callers that
    // only need section membership/counts (e.g. ClearCompletedAsync) should use this directly
    // rather than ClassifyItems, which additionally mutates every row via ApplyChainGrouping.
    private (List<TaskRowViewModel> Overdue, List<TaskRowViewModel> Open, List<TaskRowViewModel> Completed) PartitionItems()
    {
        // Items is already ordered by SortOrder from the DB query.
        // Treat rows whose ParentTaskId is not in the current view as orphans -> top-level.
        var visibleIds = Items.Select(r => r.Id).ToHashSet();
        // A child reads as a child only while its parent is in the view. Flag orphans so they
        // render flat (no indent, no Draft/Planned badge) instead of breaking the layout.
        foreach (var r in Items)
            r.ParentInView = string.IsNullOrEmpty(r.ParentTaskId) || visibleIds.Contains(r.ParentTaskId!);
        bool IsTopLevel(TaskRowViewModel r) =>
            !r.IsChild
            || string.IsNullOrEmpty(r.ParentTaskId)
            || !visibleIds.Contains(r.ParentTaskId!);
        var topLevel = Items.Where(IsTopLevel);
        var flat = new List<TaskRowViewModel>();
        var emitted = new HashSet<string>();
        foreach (var parent in topLevel)
        {
            if (!emitted.Add(parent.Id)) continue;
            flat.Add(parent);
            // Also expand for Done parents so their (Done) children reach the classification
            // loop and land alongside the parent in Completed.
            if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded)
            {
                var children = Items.Where(r => r.ParentTaskId == parent.Id);
                foreach (var c in children)
                    if (emitted.Add(c.Id))
                        flat.Add(c);
            }
        }

        var today = DateTime.Today;
        var overdue   = new List<TaskRowViewModel>();
        var open      = new List<TaskRowViewModel>();
        var completed = new List<TaskRowViewModel>();
        foreach (var r in flat)
        {
            var underOpenPlanningParent = r.IsChild &&
                flat.Any(p => p.Id == r.ParentTaskId && p.IsPlanningParent && !p.Done);

            if (r.Done && !underOpenPlanningParent)
                completed.Add(r);
            else if (r.ScheduledFor is { } d && d.ToLocalTime().Date < today)
                overdue.Add(r);
            else
                open.Add(r);
        }

        return (overdue, open, completed);
    }

    // ClassifyItems layers the chain-grouping mutation on top of PartitionItems, exactly as
    // Regroup renders it — every row in the result has ShowAsChainMember/ChainStep/ChainAfterLabel
    // written. Only call this from a path that actually renders the result; a caller that just
    // needs section membership (e.g. a count) should call PartitionItems instead.
    private (List<TaskRowViewModel> Overdue, List<TaskRowViewModel> Open, List<TaskRowViewModel> Completed) ClassifyItems()
    {
        var (overdue, open, completed) = PartitionItems();

        // Dependency chains are resolved and pulled together per section (not on the
        // pre-split `flat` list): a chain head might land in a different section than its
        // dependent (e.g. a Done head in Completed, an open dependent in Open) — in that case
        // the two are never rendered adjacent, so the dependent must fall back to a flat row
        // with a label rather than an orphaned rail. Whether the head is resolvable at all
        // still uses the whole-Items graph (rowsById), independent of section.
        var rowsById = Items.ToDictionary(r => r.Id);
        overdue   = ApplyChainGrouping(overdue, rowsById);
        open      = ApplyChainGrouping(open, rowsById);
        completed = ApplyChainGrouping(completed, rowsById);

        return (overdue, open, completed);
    }

    // Cycles are rejected by TaskStateService.SetDependsOnAsync, so this should always
    // terminate quickly — capped anyway against a corrupt/legacy row forming a loop.
    private const int MaxChainWalkDepth = 64;

    private readonly record struct ChainWalkResult(TaskRowViewModel Head, int Step);

    // Walks DependsOnTaskId back to its root. Returns null when the chain can't be resolved —
    // either the row has no predecessor at all, or a predecessor along the way isn't loaded
    // into the current Items (e.g. filtered out of this list/view).
    private static ChainWalkResult? WalkChainHead(TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById)
    {
        var current = row;
        var step = 0;
        var visited = new HashSet<string> { row.Id };
        while (!string.IsNullOrEmpty(current.DependsOnTaskId))
        {
            if (!rowsById.TryGetValue(current.DependsOnTaskId, out var predecessor)) return null;
            if (step >= MaxChainWalkDepth || !visited.Add(predecessor.Id)) return null;
            current = predecessor;
            step++;
        }
        return step == 0 ? null : new ChainWalkResult(current, step);
    }

    // Content only — "after " + localization is Slice 2's concern (the view), which is why this
    // holds just the identifying fragment rather than an assembled sentence.
    private static string? BuildAfterLabel(TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById)
    {
        if (string.IsNullOrEmpty(row.DependsOnTaskId)) return null;
        if (!rowsById.TryGetValue(row.DependsOnTaskId, out var predecessor)) return null;
        return predecessor.Number > 0 ? $"#{predecessor.Number}" : predecessor.Title;
    }

    // Assigns ShowAsChainMember/ChainStep/ChainAfterLabel for every row in `section`, then
    // returns a re-ordered copy with chain dependents pulled directly under their head
    // (ascending ChainStep), regardless of their SortOrder-derived position — mirroring how
    // planning children already sit right after their parent regardless of Items order.
    private static List<TaskRowViewModel> ApplyChainGrouping(
        List<TaskRowViewModel> section, Dictionary<string, TaskRowViewModel> rowsById)
    {
        var sectionIds = section.Select(r => r.Id).ToHashSet();
        var headIdOf = new Dictionary<TaskRowViewModel, string>();
        var stepOf = new Dictionary<TaskRowViewModel, int>();

        foreach (var r in section)
        {
            if (string.IsNullOrEmpty(r.DependsOnTaskId))
            {
                r.ShowAsChainMember = false;
                r.ChainStep = null;
                r.ChainAfterLabel = null;
                continue;
            }

            // A planning child keeps its parent indent — chain membership only ever shows as a
            // label for it, never as a second, nested rail (design: "parent wins").
            if (r.IsChild)
            {
                r.ShowAsChainMember = false;
                r.ChainStep = null;
                r.ChainAfterLabel = BuildAfterLabel(r, rowsById);
                continue;
            }

            var walk = WalkChainHead(r, rowsById);
            if (walk is { } w && sectionIds.Contains(w.Head.Id))
            {
                r.ShowAsChainMember = true;
                r.ChainStep = w.Step;
                r.ChainAfterLabel = null;
                headIdOf[r] = w.Head.Id;
                stepOf[r] = w.Step;
            }
            else
            {
                r.ShowAsChainMember = false;
                r.ChainStep = null;
                r.ChainAfterLabel = BuildAfterLabel(r, rowsById);
            }
        }

        if (headIdOf.Count == 0) return section;

        var membersByHeadId = section
            .Where(headIdOf.ContainsKey)
            .GroupBy(r => headIdOf[r])
            .ToDictionary(g => g.Key, g => g.OrderBy(r => stepOf[r]).ToList());

        var ordered = new List<TaskRowViewModel>(section.Count);
        var consumed = new HashSet<string>();
        foreach (var r in section)
        {
            if (consumed.Contains(r.Id)) continue;
            if (headIdOf.ContainsKey(r)) continue; // placed via its head below, in step order
            ordered.Add(r);
            consumed.Add(r.Id);
            if (membersByHeadId.TryGetValue(r.Id, out var members))
                foreach (var m in members)
                    if (consumed.Add(m.Id))
                        ordered.Add(m);
        }
        return ordered;
    }

    private void UpdateSubtitle()
    {
        var now     = DateTime.Now;
        var open    = Items.Count(i => !i.Done);
        var running = Items.Count(i => i.Status == TaskStatus.Running);
        var review  = Items.Count(i => i.Status == TaskStatus.Done && i.Branch != null);

        // The notes list holds no tasks — an "0 open tasks" line under its header is just noise.
        Subtitle = IsNotesList ? "" : open == 1 ? "1 open task" : $"{open} open tasks";

        if (running > 0 || review > 0)
        {
            StatusPill    = $"{running} running · {review} review";
            HasStatusPill = true;
        }
        else
        {
            StatusPill    = "";
            HasStatusPill = false;
        }
    }

    [RelayCommand]
    private async Task AddAsync()
    {
        if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return;
        var listId = _currentList.Id["user:".Length..];
        await using var db = await _dbFactory.CreateDbContextAsync();
        // A manual list holds reminders, so tasks created in it start out manual.
        var listIsManual = await db.Lists.Where(l => l.Id == listId).Select(l => l.IsManual).FirstOrDefaultAsync();
        var entity = new TaskEntity
        {
            Id        = Guid.NewGuid().ToString("N"),
            ListId    = listId,
            Title     = NewTaskTitle.Trim(),
            Status    = TaskStatus.Idle,
            CreatedAt = DateTime.UtcNow,
            IsManual  = listIsManual,
        };
        await new TaskRepository(db).AddAsync(entity);
        var row = TaskRowViewModel.FromEntity(entity);
        row.ShowListChip = _currentList?.Kind == ListKind.Virtual;
        Items.Add(row);
        Regroup();
        SelectFrom(row, "new-task");
        NewTaskTitle = "";
        UpdateSubtitle();
        TasksChanged?.Invoke(this, EventArgs.Empty);
    }

    /// <summary>Replaces the set of tasks that currently have an open interactive (ConPTY) session,
    /// so their lifecycle chip reads "Interactive" instead of "Parked".</summary>
    public void SyncInteractiveSessions(IEnumerable<string> taskIds)
    {
        _interactiveSessionIds.Clear();
        foreach (var id in taskIds)
            _interactiveSessionIds.Add(id);
        foreach (var r in Items)
            r.HasInteractiveSession = _interactiveSessionIds.Contains(r.Id);
    }

    public bool CanReorder => _currentList?.Kind == ListKind.User;
    public string? CurrentListId => _currentList?.Id;

    /// <summary>Set by the shell (mirrors <see cref="ListsIslandViewModel.Dialogs"/>) so a
    /// cross-repo move can confirm via the shared modal seam.</summary>
    public IDialogService? Dialogs { get; set; }

    public void ClearDropHints()
    {
        foreach (var r in Items)
        {
            r.DropHintAbove = false;
            r.DropHintBelow = false;
        }
    }

    public void SetDropHint(TaskRowViewModel target, bool placeBelow)
    {
        foreach (var r in Items)
        {
            var isTarget = ReferenceEquals(r, target);
            r.DropHintAbove = isTarget && !placeBelow;
            r.DropHintBelow = isTarget && placeBelow;
        }
    }

    public async Task ReorderAsync(TaskRowViewModel source, TaskRowViewModel target, bool placeBelow)
    {
        if (!CanReorder || _currentList is null) return;
        if (source.IsRunning || target.IsRunning) return;
        if (ReferenceEquals(source, target)) return;

        // Master Items: single Move event (no Reset) so ItemsControls animate, not rebuild.
        MoveWithinCollection(Items, source, target, placeBelow);

        // Apply the same move in Rows, but only when both rows live in the same section.
        // Reorder never changes which section (Open/Overdue/Completed) a row belongs to —
        // that's determined by Done flag and ScheduledFor date, not drag-drop.
        var sourceSection = SectionFor(source);
        var targetSection = SectionFor(target);
        if (sourceSection is not null && ReferenceEquals(sourceSection, targetSection))
            MoveWithinCollection<object>(Rows, source, target, placeBelow);

        var listId = _currentList.Id["user:".Length..];
        var orderedIds = Items.Select(i => i.Id).ToList();

        var sw = System.Diagnostics.Stopwatch.StartNew();
        var ok = false;
        try
        {
            await using var db = await _dbFactory.CreateDbContextAsync();
            var idSet = orderedIds.ToHashSet();
            var entities = await db.Tasks
                .Where(t => t.ListId == listId && idSet.Contains(t.Id))
                .ToListAsync();
            for (int i = 0; i < orderedIds.Count; i++)
            {
                var e = entities.FirstOrDefault(x => x.Id == orderedIds[i]);
                if (e is not null) e.SortOrder = i;
            }
            await db.SaveChangesAsync();
            ok = true;
        }
        finally { OperationTiming.Shared.Record("db", "TasksIsland.ReorderAsync", sw.Elapsed, ok); }
    }

    private static void MoveWithinCollection<T>(
        System.Collections.ObjectModel.ObservableCollection<T> coll,
        T source,
        T target,
        bool placeBelow)
    {
        var srcIdx = coll.IndexOf(source);
        var tgtIdx = coll.IndexOf(target);
        if (srcIdx < 0 || tgtIdx < 0 || srcIdx == tgtIdx) return;

        var finalIdx = placeBelow ? tgtIdx + 1 : tgtIdx;
        if (srcIdx < finalIdx) finalIdx--;
        if (finalIdx < 0) finalIdx = 0;
        if (finalIdx >= coll.Count) finalIdx = coll.Count - 1;
        if (finalIdx == srcIdx) return;

        coll.Move(srcIdx, finalIdx);
    }

    // Reconcile a bound collection toward a target order using granular Remove/Move/Insert,
    // so unchanged rows keep their containers (no Reset-driven full re-render).
    private static void SyncCollection<T>(
        System.Collections.ObjectModel.ObservableCollection<T> dst,
        List<T> target)
    {
        var keep = new HashSet<T>(target);
        for (int i = dst.Count - 1; i >= 0; i--)
            if (!keep.Contains(dst[i]))
                dst.RemoveAt(i);

        for (int i = 0; i < target.Count; i++)
        {
            var item = target[i];
            if (i < dst.Count && ReferenceEquals(dst[i], item)) continue;

            var cur = dst.IndexOf(item);
            if (cur >= 0) dst.Move(cur, i);
            else dst.Insert(i, item);
        }
    }

    // Marker returned for a row that sits in the flat Open section before any header — Open is the
    // only section that can render without a preceding HeaderRow (no Overdue and ShowOpenLabel
    // false). Distinct from null, which means "row not found in Rows at all".
    private static readonly object UnlabeledSection = new();

    // A row's section is read off the nearest preceding HeaderRow in Rows rather than
    // re-classified from Done/ScheduledFor, so it always matches what Regroup actually rendered
    // (including planning-parent edge cases the classification alone can't reconstruct).
    private object? SectionFor(TaskRowViewModel row)
    {
        var idx = Rows.IndexOf(row);
        if (idx < 0) return null;
        for (int i = idx - 1; i >= 0; i--)
            if (Rows[i] is HeaderRow header) return header;
        return UnlabeledSection;
    }

    /// <summary>
    /// Drag-drop move: reassigns a task (and every descendant) to <paramref name="targetList"/>.
    /// No-op for anything that isn't a user list the task doesn't already sit in.
    /// Rejects running tasks and tasks (or descendants) holding an Active/Kept worktree — the
    /// worktree would keep pointing at the source repo. A move across repos without a worktree
    /// is allowed but asks for confirmation first.
    /// </summary>
    public async Task MoveTaskToListAsync(TaskRowViewModel row, ListNavItemViewModel targetList)
    {
        if (targetList.Kind != ListKind.User) return;
        var targetListId = targetList.Id.StartsWith("user:", StringComparison.Ordinal)
            ? targetList.Id["user:".Length..]
            : targetList.Id;

        await using var db = await _dbFactory.CreateDbContextAsync();

        // The row's own list, not _currentList — the task island also shows smart/virtual lists,
        // whose rows come from many lists and which carry no working dir of their own. Dropping a
        // task on the list it already sits in is a silent no-op.
        var sourceListId = await db.Tasks.AsNoTracking()
            .Where(t => t.Id == row.Id).Select(t => t.ListId).FirstOrDefaultAsync();
        if (sourceListId is null || sourceListId == targetListId) return;

        if (row.IsRunning)
        {
            ErrorReported?.Invoke(Loc.T("vm.tasksIsland.moveRunningRejected"));
            return;
        }

        var repo = new TaskRepository(db);

        var descendantIds = await repo.GetDescendantIdsAsync(row.Id);
        var ownIds = new List<string> { row.Id };
        ownIds.AddRange(descendantIds);
        var hasBlockingWorktree = await db.Worktrees.AsNoTracking()
            .Where(w => ownIds.Contains(w.TaskId)
                        && (w.State == ClaudeDo.Data.Models.WorktreeState.Active
                            || w.State == ClaudeDo.Data.Models.WorktreeState.Kept))
            .AnyAsync();
        if (hasBlockingWorktree)
        {
            ErrorReported?.Invoke(Loc.T("vm.tasksIsland.moveWorktreeRejected"));
            return;
        }

        var sourceDir = await db.Lists.AsNoTracking()
            .Where(l => l.Id == sourceListId).Select(l => l.WorkingDir).FirstOrDefaultAsync();
        var targetDir = targetList.WorkingDir;
        var repoChanges = !string.Equals(sourceDir, targetDir, StringComparison.OrdinalIgnoreCase);
        if (repoChanges)
        {
            if (Dialogs is null)
            {
                ErrorReported?.Invoke(Loc.T("vm.tasksIsland.moveConfirmUnavailable"));
                return;
            }
            var ok = await Dialogs.ConfirmAsync(Loc.T("vm.tasksIsland.moveRepoConfirm",
                string.IsNullOrWhiteSpace(sourceDir) ? "—" : sourceDir,
                string.IsNullOrWhiteSpace(targetDir) ? "—" : targetDir));
            if (!ok) return;
        }

        await repo.MoveToListAsync(row.Id, targetListId);

        Items.Remove(row);
        Regroup();
        UpdateSubtitle();
        TasksChanged?.Invoke(this, EventArgs.Empty);
    }

    [RelayCommand]
    private async Task ToggleDoneAsync(TaskRowViewModel row)
    {
        if (_worker is null) return;

        var newDone = !row.Done;
        var previousStatus = row.Status;
        row.Done = newDone;
        row.Status = newDone ? TaskStatus.Done : TaskStatus.Idle;
        try
        {
            if (newDone) await _worker.SetTaskDoneAsync(row.Id);
            else await _worker.UnsetTaskDoneAsync(row.Id);
        }
        catch (Exception ex)
        {
            row.Done = !newDone;
            row.Status = previousStatus;
            ErrorReported?.Invoke(Loc.T("vm.tasksIsland.toggleDoneFailed", ex.Message));
            return;
        }
        Regroup();
        UpdateSubtitle();
        TasksChanged?.Invoke(this, EventArgs.Empty);
    }

    [RelayCommand]
    private async Task ClearCompletedAsync()
    {
        var (_, _, completed) = PartitionItems();
        if (completed.Count == 0) return;

        // Delete children before parents so the parent-child FK (Restrict) doesn't
        // block removing a completed planning parent together with its done children.
        var toDelete = completed.OrderByDescending(r => r.IsChild).ToList();

        if (ConfirmAsync is not null)
        {
            var ok = await ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone.");
            if (!ok) return;
        }

        var sw = System.Diagnostics.Stopwatch.StartNew();
        try
        {
            await using var db = await _dbFactory.CreateDbContextAsync();
            var repo = new TaskRepository(db);
            foreach (var row in toDelete)
            {
                try
                {
                    await repo.DeleteAsync(row.Id);
                    Items.Remove(row);
                }
                catch { /* still referenced by open child tasks; leave it visible */ }
            }
        }
        finally { OperationTiming.Shared.Record("db", "TasksIsland.ClearCompletedAsync", sw.Elapsed, ok: true); }

        Regroup();
        UpdateSubtitle();
        TasksChanged?.Invoke(this, EventArgs.Empty);
    }

    [RelayCommand]
    private async Task ToggleStarAsync(TaskRowViewModel row)
    {
        row.IsStarred = !row.IsStarred;
        await using var db = await _dbFactory.CreateDbContextAsync();
        var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
        if (entity != null)
        {
            entity.IsStarred = row.IsStarred;
            await db.SaveChangesAsync();
        }
        TasksChanged?.Invoke(this, EventArgs.Empty);
    }

    /// <summary>Flips a task between "Claude can run this" and "manual reminder". A manual task
    /// hides every hand-off affordance and is skipped by the queue picker and daily prep.</summary>
    [RelayCommand]
    private async Task ToggleManualAsync(TaskRowViewModel? row)
    {
        if (row is null) return;
        row.IsManual = !row.IsManual;
        await using var db = await _dbFactory.CreateDbContextAsync();
        var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
        if (entity != null)
        {
            entity.IsManual = row.IsManual;
            await db.SaveChangesAsync();
        }
        TasksChanged?.Invoke(this, EventArgs.Empty);
    }

    [RelayCommand]
    private async Task AddToMyDayAsync(TaskRowViewModel? row)
    {
        if (row is null || row.IsMyDay) return;
        row.IsMyDay = true;
        await using var db = await _dbFactory.CreateDbContextAsync();
        var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
        if (entity != null)
        {
            entity.IsMyDay = true;
            await db.SaveChangesAsync();
        }
        Regroup();
        UpdateSubtitle();
        TasksChanged?.Invoke(this, EventArgs.Empty);
    }

    [RelayCommand]
    private async Task RemoveFromMyDayAsync(TaskRowViewModel? row)
    {
        if (row is null) return;
        row.IsMyDay = false;
        await using var db = await _dbFactory.CreateDbContextAsync();
        // Removing a parent takes its whole plan off My Day: clear the task and every child, so no
        // orphaned child is left behind (independently-IsMyDay children included). A leaf child has
        // no children of its own, so this collapses to just clearing the row itself.
        var affected = await db.Tasks
            .Where(t => t.Id == row.Id || t.ParentTaskId == row.Id)
            .ToListAsync();
        foreach (var t in affected)
            t.IsMyDay = false;
        if (affected.Count > 0)
            await db.SaveChangesAsync();
        if (_currentList?.Id == "smart:my-day")
        {
            var drop = Items
                .Where(r => r.Id == row.Id || r.ParentTaskId == row.Id)
                .ToList();
            foreach (var r in drop)
                Items.Remove(r);
        }
        Regroup();
        UpdateSubtitle();
        TasksChanged?.Invoke(this, EventArgs.Empty);
    }

    public async Task SetStatusOnRowAsync(TaskRowViewModel row, TaskStatus status)
    {
        if (_worker is null) return;
        try
        {
            var baseDirty = await _worker.SetTaskStatusAsync(row.Id, status);
            ReportBaseDirty(baseDirty);
        }
        catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.setStatusFailed", ex.Message)); }
    }

    private void ReportBaseDirty(BaseDirtyWarningDto? warning)
    {
        if (warning is null) return;
        ErrorReported?.Invoke(Loc.T(
            "vm.queue.baseDirtyWarning", warning.ModifiedCount, warning.UntrackedCount));
    }

    // Row-level delete for the task context menu. Routed through the worker (mirrors
    // DetailsIslandViewModel.DeleteTaskAsync) so a deleted child correctly advances a
    // WaitingForChildren parent instead of bypassing TaskStateService.
    [RelayCommand]
    private async Task DeleteTaskAsync(TaskRowViewModel? row)
    {
        if (row is null || row.HasInteractiveSession || _worker is null) return;
        if (ConfirmAsync is not null)
        {
            var ok = await ConfirmAsync(Loc.T("vm.tasksIsland.deleteTaskConfirm", row.Title));
            if (!ok) return;
        }

        bool deleted;
        string? error;
        try
        {
            (deleted, error) = await _worker.DeleteTaskAsync(row.Id);
        }
        catch (Exception ex)
        {
            ErrorReported?.Invoke(Loc.T("vm.tasksIsland.deleteTaskFailed", ex.Message));
            return;
        }
        if (!deleted)
        {
            ErrorReported?.Invoke(error ?? Loc.T("vm.tasksIsland.deleteTaskFailed", "unknown error"));
            return;
        }

        Items.Remove(row);
        if (SelectedTask == row) SelectFrom(null, "row-removed");
        Regroup();
        UpdateSubtitle();
        TasksChanged?.Invoke(this, EventArgs.Empty);
    }

    [RelayCommand]
    private async Task SendToQueueAsync(TaskRowViewModel? row)
    {
        if (row is null || row.IsRunning || row.HasInteractiveSession || _worker is null) return;
        // A finalized planning parent queues its plan (children sequentially), not itself.
        if (row.CanQueuePlan)
        {
            // The hub's QueuePlanningSubtasksAsync queues every Idle child unconditionally — it has
            // no notion of a UI-hosted ConPTY session. Block the whole plan if any child has one open,
            // otherwise that child's worktree would get an autonomous run racing the user's own edits.
            var interactiveChildren = Items
                .Where(r => r.ParentTaskId == row.Id && r.HasInteractiveSession)
                .Select(r => r.Title)
                .ToList();
            if (interactiveChildren.Count > 0)
            {
                ErrorReported?.Invoke(Loc.T(
                    "vm.tasksIsland.queuePlanBlockedInteractive", string.Join(", ", interactiveChildren)));
                return;
            }
            await QueuePlanningSubtasksAsync(row);
            return;
        }
        // Goes through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write
        // so the manual/draft-child guards apply here too; the row refreshes from the resulting
        // TaskUpdated broadcast.
        try
        {
            var baseDirty = await _worker.SetTaskStatusAsync(row.Id, TaskStatus.Queued);
            ReportBaseDirty(baseDirty);
        }
        catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.sendToQueueFailed", ex.Message)); }
    }

    [RelayCommand]
    private async Task RemoveFromQueueAsync(TaskRowViewModel? row)
    {
        if (row is null || _worker is null) return;

        // Cascade to queued children when present — covers both planning parents
        // (PlanningPhase != None) and bare parents that have a manually-queued
        // chain. The X button's visibility is gated by the same condition
        // (HasQueuedSubtasks), so the handler matches what the user can see.
        List<string> queuedChildIds;
        await using (var db = await _dbFactory.CreateDbContextAsync())
        {
            queuedChildIds = await db.Tasks.AsNoTracking()
                .Where(t => t.ParentTaskId == row.Id && t.Status == TaskStatus.Queued)
                .Select(t => t.Id)
                .ToListAsync();
        }

        var failures = new List<string>();

        foreach (var childId in queuedChildIds)
        {
            var childRow = Items.FirstOrDefault(r => r.Id == childId);
            if (childRow is null) continue;
            var previousBlockedBy = childRow.BlockedByTaskId;
            childRow.Status = TaskStatus.Idle;
            childRow.BlockedByTaskId = null;
            try { await _worker.DequeueTaskAsync(childId); }
            catch (Exception ex)
            {
                childRow.Status = TaskStatus.Queued;
                childRow.BlockedByTaskId = previousBlockedBy;
                failures.Add(ex.Message);
            }
        }

        if (row.Status == TaskStatus.Queued)
        {
            row.Status = TaskStatus.Idle;
            try { await _worker.DequeueTaskAsync(row.Id); }
            catch (Exception ex)
            {
                row.Status = TaskStatus.Queued;
                failures.Add(ex.Message);
            }
        }

        row.HasQueuedSubtasks = queuedChildIds
            .Select(id => Items.FirstOrDefault(r => r.Id == id))
            .Any(r => r?.Status == TaskStatus.Queued);

        if (failures.Count > 0)
            ErrorReported?.Invoke(Loc.T("vm.tasksIsland.removeFromQueueFailed", string.Join("; ", failures)));

        Regroup();
        UpdateSubtitle();
        TasksChanged?.Invoke(this, EventArgs.Empty);
    }

    [RelayCommand]
    private async Task CancelRunningTaskAsync(TaskRowViewModel? row)
    {
        if (row is null || !row.IsRunning || _worker is null) return;
        try { await _worker.CancelTaskAsync(row.Id); }
        catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.cancelFailed", ex.Message)); }
    }

    // ── Review actions (visible when a task is WaitingForReview) ─────────────
    // Each delegates to the worker hub, which performs the transition and
    // broadcasts TaskUpdated; the row refreshes from that broadcast.

    [RelayCommand]
    private async Task ApproveReviewAsync(TaskRowViewModel? row)
    {
        if (row is null || !row.IsWaitingForReview || _worker is null) return;
        try { await _worker.ApproveReviewAsync(row.Id, ""); }
        catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.approveFailed", ex.Message)); }
    }

    public async Task RejectReviewToQueueAsync(TaskRowViewModel row, string feedback)
    {
        if (!row.IsWaitingForReview || _worker is null) return;
        if (string.IsNullOrWhiteSpace(feedback)) return;
        try { await _worker.RejectReviewToQueueAsync(row.Id, feedback); }
        catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.rejectToQueueFailed", ex.Message)); }
    }

    [RelayCommand]
    private async Task RejectReviewToIdleAsync(TaskRowViewModel? row)
    {
        if (row is null || !row.IsWaitingForReview || _worker is null) return;
        try { await _worker.RejectReviewToIdleAsync(row.Id); }
        catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.rejectToIdleFailed", ex.Message)); }
    }

    [RelayCommand]
    private async Task CancelReviewAsync(TaskRowViewModel? row)
    {
        if (row is null || !row.IsWaitingForReview || _worker is null) return;
        try { await _worker.CancelReviewAsync(row.Id); }
        catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.cancelReviewFailed", ex.Message)); }
    }

    public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
    {
        if (row is null) return;
        // ThemedDatePicker builds Unspecified/Local wall-clock values; the DB's UtcConverter only
        // tags DateTimes as Utc on read, it never converts on write, so this is the one place that
        // must turn "local wall clock" into a real UTC instant before it's persisted.
        var whenUtc = when is { } w
            ? (w.Kind == DateTimeKind.Utc ? w : DateTime.SpecifyKind(w, DateTimeKind.Local).ToUniversalTime())
            : (DateTime?)null;
        await using var db = await _dbFactory.CreateDbContextAsync();
        var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
        if (entity is null) return;
        entity.ScheduledFor = whenUtc;
        await db.SaveChangesAsync();
        row.ScheduledFor = whenUtc;
        Regroup();
        UpdateSubtitle();
        TasksChanged?.Invoke(this, EventArgs.Empty);
    }

    [RelayCommand]
    private Task ClearScheduleAsync(TaskRowViewModel? row) =>
        row is null ? Task.CompletedTask : SetScheduledForAsync(row, null);

    [RelayCommand]
    private void Select(TaskRowViewModel row) => SelectFrom(row, "row-click");

    public async System.Threading.Tasks.Task<bool> SelectByIdAsync(string taskId)
    {
        if (LoadTask is { } lt)
        {
            try { await lt; } catch { /* load cancelled/failed — fall through */ }
        }
        var row = Items.FirstOrDefault(r => r.Id == taskId);
        if (row is null) return false;
        SelectFrom(row, "select-by-id");
        return true;
    }

    [RelayCommand]
    private void ToggleShowCompleted() => IsShowingCompleted = !IsShowingCompleted;

    // The completed section is only emitted into Rows while the toggle is on — flipping it
    // doesn't change grouping/order data, so a full reload isn't needed, just a re-emit.
    partial void OnIsShowingCompletedChanged(bool value) => Regroup();

    public event EventHandler? OpenListSettingsRequested;

    [RelayCommand]
    private void OpenListSettings() => OpenListSettingsRequested?.Invoke(this, EventArgs.Empty);

    [RelayCommand]
    private void OpenPlanningSession(TaskRowViewModel? row)
    {
        if (row is null) return;
        if (row.Status != TaskStatus.Idle || row.PlanningPhase != PlanningPhase.None) return;
        // Planning now runs as an embedded ConPTY pane in the Command Center (not an external wt
        // window). The shell owns Mission Control, so raise an event; the actual StartAsync happens
        // server-side inside GetPlanningStartLaunchSpec when the pane opens.
        OpenPlanningConPtyRequested?.Invoke(row.Id, false);
    }

    // Opens the task in an embedded ConPTY terminal pane in the Command Center. The shell owns
    // the Mission Control view model, so this just raises an event for it to act on.
    public event Action<string>? OpenConPtySessionRequested;

    // Opens (resume=false) or resumes (resume=true) a planning session as an embedded ConPTY
    // pane in the Command Center.
    public event Action<string, bool>? OpenPlanningConPtyRequested;

    [RelayCommand]
    private void OpenConPtySession(TaskRowViewModel? row)
    {
        if (row is null) return;
        OpenConPtySessionRequested?.Invoke(row.Id);
    }

    [RelayCommand]
    private void OpenTaskWorktree(TaskRowViewModel? row)
    {
        var (ok, error) = ShellOpen.Path(row?.WorktreePath);
        if (!ok && error is not null)
            ErrorReported?.Invoke(Loc.T("vm.tasksIsland.openWorktreeFailed", error));
    }

    [RelayCommand]
    private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
    {
        if (row is null || !row.IsPlanningParent) return;
        if (_worker is null) return;
        try
        {
            var draftCount = await _worker.GetPendingDraftCountAsync(row.Id);
            var modalVm = new UnfinishedPlanningModalViewModel
            {
                TaskTitle  = row.Title,
                DraftCount = draftCount,
            };

            if (ShowUnfinishedPlanningModal is null)
                return;
            await ShowUnfinishedPlanningModal(modalVm);

            var choice = await modalVm.Result.Task;

            switch (choice)
            {
                case UnfinishedPlanningModalResult.Resume:
                    // Resume as an embedded ConPTY pane (server-side ResumeAsync runs inside
                    // GetPlanningResumeLaunchSpec when the pane opens).
                    OpenPlanningConPtyRequested?.Invoke(row.Id, true);
                    break;
                case UnfinishedPlanningModalResult.FinalizeNow:
                    await FinalizePlanningSessionAsync(row);
                    break;
                case UnfinishedPlanningModalResult.Discard:
                    await TryDiscardPlanningWithRetryAsync(row.Id);
                    break;
                case UnfinishedPlanningModalResult.Cancel:
                default:
                    break;
            }
        }
        catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.planningResumeFailed", ex.Message)); }
    }

    [RelayCommand]
    private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row)
    {
        if (row is null || _worker is null) return;
        await TryDiscardPlanningWithRetryAsync(row.Id);
    }

    /// <summary>
    /// Calls discard, and if it is blocked because children are queued, prompts the
    /// user to dequeue them and retries. Running children are surfaced as a hard
    /// block — the user must cancel them first.
    /// </summary>
    private async Task TryDiscardPlanningWithRetryAsync(string taskId)
    {
        if (_worker is null) return;
        DiscardPlanningOutcome outcome;
        try { outcome = await _worker.DiscardPlanningSessionAsync(taskId); }
        catch { return; }

        if (outcome.Result == DiscardPlanningResult.BlockedByQueuedChildren)
        {
            if (ConfirmAsync is null) return;
            var ok = await ConfirmAsync(
                $"{outcome.QueuedChildrenCount} child task(s) are queued.\n" +
                "Dequeue them and discard the planning session?");
            if (!ok) return;
            try { await _worker.DiscardPlanningSessionAsync(taskId, dequeueQueuedChildren: true); }
            catch { }
        }
        else if (outcome.Result == DiscardPlanningResult.BlockedByRunningChildren)
        {
            if (ConfirmAsync is null) return;
            await ConfirmAsync(
                $"{outcome.RunningChildrenCount} child task(s) are still running.\n" +
                "Cancel them first, then try again.");
        }
    }

    /// <summary>
    /// Wired by the view via <see cref="ShowConfirmAsync"/>. Returns true when the user confirms.
    /// </summary>
    public Func<string, Task<bool>>? ConfirmAsync { get; set; }

    private bool CanRunPlanningOperation() => !PlanningOperation.IsRunning;

    [RelayCommand(CanExecute = nameof(CanRunPlanningOperation))]
    private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row)
    {
        if (row is null || _worker is null) return;
        using var op = PlanningOperation.Begin(Loc.T("ops.planning.queuingSubtasks"));
        try { await _worker.QueuePlanningSubtasksAsync(row.Id); }
        catch { }
    }

    [RelayCommand(CanExecute = nameof(CanRunPlanningOperation))]
    private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
    {
        if (row is null) return;
        using var op = PlanningOperation.Begin(Loc.T("ops.planning.finalizing"));
        try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); }
        catch { }
    }

    [RelayCommand]
    private void ToggleExpand(TaskRowViewModel? row)
    {
        if (row is null) return;
        var next = !(_expandedState.TryGetValue(row.Id, out var current) ? current : row.IsExpanded);
        _expandedState[row.Id] = next;
        row.IsExpanded = next;
        Regroup();
    }

    [RelayCommand]
    private async Task RefineTask(TaskRowViewModel row)
    {
        if (row is null || !row.CanRefine) return;
        row.IsRefining = true;
        try { await _worker!.RefineTaskAsync(row.Id); }
        catch { row.IsRefining = false; }
    }

    private void OnRefineStarted(string taskId)
    {
        var row = Items.FirstOrDefault(r => r.Id == taskId);
        if (row is not null) row.IsRefining = true;
    }

    private void OnRefineFinished(string taskId, bool ok, string? error)
    {
        var row = Items.FirstOrDefault(r => r.Id == taskId);
        if (row is not null) row.IsRefining = false;
    }

    // OperationProgress is a generic channel shared with merge phases (TaskMergeService), whose
    // opKey is also the task id — filter on the phase token so a merge in flight can't clobber
    // this row's creation-phase banner.
    private const string CreationPhaseCreatingWorktree = "creating_worktree";

    private void OnWorkerOperationProgress(string opKey, string phase, int current, int total)
    {
        if (phase != CreationPhaseCreatingWorktree) return;
        var row = Items.FirstOrDefault(r => r.Id == opKey);
        if (row is not null) row.CreationPhase = phase;
    }

    partial void OnSelectedTaskChanged(TaskRowViewModel? value)
    {
        foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value);
        SelectionChanged?.Invoke(this, EventArgs.Empty);
    }

    // ── Phase 3: reconcile tick ──────────────────────────────────────────────
    // Self-healing safety net for a lost broadcast: every few seconds, diff the flat `Items`
    // master collection against SQLite and patch properties in place. Never rebuilds a row and
    // never falls back to LoadForList — the delta path already owns that escalation. It DOES
    // call Regroup(), but only when a patch actually moved a row's grouping inputs: a healed
    // row can change section, order or rail label, and none of that shows until Rows is
    // re-emitted.

    // Above this many rows, only the first N (in Items order) are reconciled per tick, so the
    // query cost stays bounded instead of growing with an ever-larger list.
    internal const int ReconcileRowCap = 500;

    // Set by the shell from the main window's WindowState; the tick no-ops while minimized.
    public bool IsWindowVisible { get; set; } = true;

    // Test seam: awaited right after the reconcile query returns and before its results are
    // applied, so a test can land a fresher, higher-sequence update in that gap and exercise the
    // stale-sequence guard deterministically. Always null outside tests.
    internal Func<Task>? ReconcileTickTestBarrier { get; set; }

    // Awaitable so tests can drive it deterministically (mirrors RefreshTaskFromWorkerAsync).
    internal async Task ReconcileTickAsync(CancellationToken ct = default)
    {
        var list = _currentList;
        if (!IsWindowVisible || list is null) return;

        // Mirrors the full-reload guard in RefreshTaskFromWorkerAsync: matching virtual:queued /
        // virtual:running depends on a Planning parent's children, not a single entity.
        if (list.Kind == ListKind.Virtual &&
            (list.Id == "virtual:queued" || list.Id == "virtual:running"))
            return;

        var ids = Items.Select(r => r.Id).Take(ReconcileRowCap).ToList();
        if (ids.Count == 0) return;

        // Same monotonic per-task sequence as the Phase 1 delta path, so whichever of the two
        // started most recently for a given task id wins, regardless of completion order.
        var seqByTaskId = new Dictionary<string, long>(ids.Count);
        foreach (var id in ids)
        {
            var seq = ++_deltaCounter;
            seqByTaskId[id] = seq;
            _deltaSeq[id] = seq;
        }

        List<TaskEntity> entities;
        var sw = System.Diagnostics.Stopwatch.StartNew();
        var dbOk = false;
        try
        {
            var idSet = ids.ToHashSet();
            await using var db = await _dbFactory.CreateDbContextAsync(ct);
            entities = await db.Tasks
                .Include(t => t.List)
                .Include(t => t.Worktree)
                .Where(t => idSet.Contains(t.Id))
                .ToListAsync(ct);
            dbOk = true;
        }
        catch (OperationCanceledException) { return; }
        catch (Exception ex)
        {
            System.Diagnostics.Debug.WriteLine($"TasksIsland: reconcile tick failed ({ex.Message})");
            return;
        }
        finally { OperationTiming.Shared.Record("db", "TasksIsland.ReconcileTickAsync", sw.Elapsed, dbOk); }

        if (ReconcileTickTestBarrier is { } barrier) await barrier();

        var byId = entities.ToDictionary(e => e.Id);
        // Index the rows once instead of scanning Items per id — at the 500-row cap a linear
        // scan per id is 250k comparisons every few seconds, for nothing.
        var rowById = new Dictionary<string, TaskRowViewModel>(Items.Count);
        foreach (var r in Items) rowById[r.Id] = r;
        var groupingChanged = false;
        foreach (var id in ids)
        {
            // Superseded by a fresher delta refresh or a later tick that landed while this one
            // was reading — its result is stale, discard it.
            if (!_deltaSeq.TryGetValue(id, out var current) || current != seqByTaskId[id]) continue;
            if (!byId.TryGetValue(id, out var entity)) continue; // deleted; the delta path removes rows, not the tick

            if (!rowById.TryGetValue(id, out var row)) continue;
            var before = GroupingKey(row);
            row.UpdateFromEntity(entity);
            if (!before.Equals(GroupingKey(row))) groupingChanged = true;
        }

        // A patched row can have left its section (Done), moved inside it (a new/removed
        // depends-on link re-orders the chain), or changed what its rail label reads. Rows only
        // reflects any of that after a Regroup — without this the tick would heal the row's data
        // while leaving a completed task sitting in the Open section under a stale count. Gated
        // on a real change so an idle tick stays free.
        if (groupingChanged) Regroup();
    }

    // The slice of a row that Regroup reads: which section it lands in, where it sits inside it,
    // and what its chain rail/after-chip says. Only fields UpdateFromEntity actually writes —
    // IsExpanded and HasPlanningChildren are owned elsewhere and would produce false positives.
    private static (bool, DateTime?, string?, string?, PlanningPhase, int, string) GroupingKey(TaskRowViewModel r) =>
        (r.Done, r.ScheduledFor, r.ParentTaskId, r.DependsOnTaskId, r.PlanningPhase, r.Number, r.Title);
}
