using System.Collections.ObjectModel;
using System.Globalization;
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.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Ui.ViewModels.Islands;

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;
    private static readonly TaskListFilterRegistry _filters = new();

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

    [RelayCommand]
    private void OpenNotes()
    {
        SelectedTask = null;
        NotesRequested?.Invoke();
    }

    [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();
    public ObservableCollection<TaskRowViewModel> OverdueItems { get; } = new();
    public ObservableCollection<TaskRowViewModel> OpenItems { get; } = new();
    public ObservableCollection<TaskRowViewModel> CompletedItems { get; } = new();

    [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] private bool _hasOverdue;
    [ObservableProperty] private bool _hasOpen;
    [ObservableProperty] private bool _hasCompleted;
    [ObservableProperty] private bool _showOpenLabel;
    [ObservableProperty] private string _completedHeader = "";
    [ObservableProperty] private bool _showNotesRow;
    [ObservableProperty] private bool _isMyDayList;

    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)
    {
        _dbFactory = dbFactory;
        _worker = worker;
        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;
        }
        _langChangedHandler = (_, _) => RefreshLocalizedText();
        Loc.LanguageChanged += _langChangedHandler;
    }

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

    private void RefreshLocalizedText()
    {
        CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
        foreach (var row in Items) row.RefreshLocalized();
        foreach (var row in CompletedItems) row.RefreshLocalized();
    }

    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)
    {
        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;
        }

        try
        {
            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);

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

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

            // 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();
        }
        catch { }
    }

    // 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 (e.PropertyName == nameof(ListNavItemViewModel.Name) && sender is ListNavItemViewModel vm)
            HeaderTitle = vm.Name;
    }

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

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

        Items.Clear();
        OverdueItems.Clear();
        OpenItems.Clear();
        CompletedItems.Clear();
        HasOverdue    = false;
        HasOpen       = false;
        HasCompleted  = false;
        ShowOpenLabel = false;
        ShowNotesRow  = false;
        if (list is null) { LoadTask = Task.CompletedTask; return; }

        HeaderTitle = list.Name;
        HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant();
        ShowNotesRow = list.Id == "smart:my-day";
        IsMyDayList  = list.Id == "smart:my-day";

        LoadTask = LoadForListAsync(list, ct);
    }

    private async Task LoadForListAsync(ListNavItemViewModel list, CancellationToken ct)
    {
        try
        {
            await using var db = await _dbFactory.CreateDbContextAsync(ct);
            var all = await db.Tasks
                .Include(t => t.List)
                .Include(t => t.Worktree)
                .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
                .ToListAsync(ct);

            ct.ThrowIfCancellationRequested();

            var filter = _filters.Resolve(list.Id);
            var filteredList = filter is null
                ? new List<TaskEntity>()
                : all.Where(t => filter.Matches(t) || filter.MatchesAsContext(t, all)).ToList();
            var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
            var existingIds = filteredList.Select(t => t.Id).ToHashSet();
            foreach (var c in all.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!)))
            {
                if (existingIds.Add(c.Id))
                    filteredList.Add(c);
            }

            var showListChip = list.Kind == ListKind.Virtual;
            foreach (var t in filteredList)
            {
                var row = TaskRowViewModel.FromEntity(t);
                row.ShowListChip = showListChip;
                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();
        }
        catch (OperationCanceledException) { }
    }

    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;
        }

        // Build hierarchy-aware flat list: top-level rows interleaved with visible children.
        // 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 in CompletedItems alongside the parent.
            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.Date < today)
                overdue.Add(r);
            else
                open.Add(r);
        }

        // Reconcile the bound collections in place (granular Insert/Move/Remove) rather than
        // Clear+Add, so toggling a parent only touches its own child rows — the ItemsControl
        // keeps every unchanged container instead of tearing the whole list down on a Reset.
        SyncCollection(OverdueItems, overdue);
        SyncCollection(OpenItems, open);
        SyncCollection(CompletedItems, completed);

        HasOverdue     = OverdueItems.Count > 0;
        HasOpen        = OpenItems.Count > 0;
        HasCompleted   = CompletedItems.Count > 0;
        ShowOpenLabel  = HasOpen && HasOverdue;
        CompletedHeader = Loc.T("vm.tasksIsland.completedHeaderCount", CompletedItems.Count);
    }

    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);

        Subtitle = 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();
        var entity = new TaskEntity
        {
            Id        = Guid.NewGuid().ToString("N"),
            ListId    = listId,
            Title     = NewTaskTitle.Trim(),
            Status    = TaskStatus.Idle,
            CreatedAt = DateTime.UtcNow,
        };
        await new TaskRepository(db).AddAsync(entity);
        var row = TaskRowViewModel.FromEntity(entity);
        row.ShowListChip = _currentList?.Kind == ListKind.Virtual;
        Items.Add(row);
        Regroup();
        NewTaskTitle = "";
        UpdateSubtitle();
        TasksChanged?.Invoke(this, EventArgs.Empty);
    }

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

    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 whichever section the row lives in.
        // 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(sourceSection, source, target, placeBelow);

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

        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();
    }

    private static void MoveWithinCollection(
        System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> coll,
        TaskRowViewModel source,
        TaskRowViewModel 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(
        System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> dst,
        List<TaskRowViewModel> target)
    {
        var keep = new HashSet<TaskRowViewModel>(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);
        }
    }

    private System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel>? SectionFor(TaskRowViewModel row)
    {
        if (OverdueItems.Contains(row))   return OverdueItems;
        if (OpenItems.Contains(row))      return OpenItems;
        if (CompletedItems.Contains(row)) return CompletedItems;
        return null;
    }

    [RelayCommand]
    private async Task ToggleDoneAsync(TaskRowViewModel row)
    {
        row.Done = !row.Done;
        await using var db = await _dbFactory.CreateDbContextAsync();
        var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
        if (entity != null)
        {
            entity.Status = row.Done ? TaskStatus.Done : TaskStatus.Idle;
            row.Status = entity.Status;
            await db.SaveChangesAsync();
        }
        Regroup();
        UpdateSubtitle();
        TasksChanged?.Invoke(this, EventArgs.Empty);
    }

    [RelayCommand]
    private async Task ClearCompletedAsync()
    {
        if (CompletedItems.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 = CompletedItems.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;
        }

        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 */ }
        }

        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);
    }

    [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 { await _worker.SetTaskStatusAsync(row.Id, status); }
        catch { /* offline; broadcast won't fire */ }
    }

    [RelayCommand]
    private async Task SendToQueueAsync(TaskRowViewModel? row)
    {
        if (row is null || row.IsRunning) return;
        // A finalized planning parent queues its plan (children sequentially), not itself.
        if (row.CanQueuePlan)
        {
            await QueuePlanningSubtasksAsync(row);
            return;
        }
        await using var db = await _dbFactory.CreateDbContextAsync();
        var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
        if (entity is null) return;
        entity.Status = TaskStatus.Queued;
        await db.SaveChangesAsync();
        row.Status = TaskStatus.Queued;
        if (_worker is not null)
        {
            try { await _worker.WakeQueueAsync(); } catch { }
        }
        Regroup();
        UpdateSubtitle();
        TasksChanged?.Invoke(this, EventArgs.Empty);
    }

    [RelayCommand]
    private async Task RemoveFromQueueAsync(TaskRowViewModel? row)
    {
        if (row is null) return;
        await using var db = await _dbFactory.CreateDbContextAsync();
        var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
        if (entity 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.
        var queuedChildren = await db.Tasks
            .Where(t => t.ParentTaskId == row.Id && t.Status == TaskStatus.Queued)
            .ToListAsync();
        foreach (var c in queuedChildren)
        {
            c.Status = TaskStatus.Idle;
            c.BlockedByTaskId = null;
        }
        if (entity.Status == TaskStatus.Queued)
            entity.Status = TaskStatus.Idle;
        await db.SaveChangesAsync();

        foreach (var c in queuedChildren)
        {
            var childRow = Items.FirstOrDefault(r => r.Id == c.Id);
            if (childRow is not null)
            {
                childRow.Status = TaskStatus.Idle;
                childRow.BlockedByTaskId = null;
            }
        }
        if (row.Status == TaskStatus.Queued)
            row.Status = TaskStatus.Idle;
        row.HasQueuedSubtasks = false;

        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 { /* worker offline; the broadcast will reconcile when it returns */ }
    }

    // ── 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 { /* offline; broadcast reconciles on return */ }
    }

    [RelayCommand]
    private async Task RejectReviewToIdleAsync(TaskRowViewModel? row)
    {
        if (row is null || !row.IsWaitingForReview || _worker is null) return;
        try { await _worker.RejectReviewToIdleAsync(row.Id); }
        catch { /* offline; broadcast reconciles on return */ }
    }

    [RelayCommand]
    private async Task CancelReviewAsync(TaskRowViewModel? row)
    {
        if (row is null || !row.IsWaitingForReview || _worker is null) return;
        try { await _worker.CancelReviewAsync(row.Id); }
        catch { /* offline; broadcast reconciles on return */ }
    }

    public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
    {
        if (row is null) return;
        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 = when;
        await db.SaveChangesAsync();
        row.ScheduledFor = when;
        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) => SelectedTask = row;

    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;
        SelectedTask = row;
        return true;
    }

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

    public event EventHandler? OpenListSettingsRequested;

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

    [RelayCommand]
    private async Task OpenPlanningSessionAsync(TaskRowViewModel? row)
    {
        if (row is null) return;
        if (row.Status != TaskStatus.Idle || row.PlanningPhase != PlanningPhase.None) return;
        ForegroundHelper.AllowAny();
        try { await _worker!.StartPlanningSessionAsync(row.Id); }
        catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.planningOpenFailed", ex.Message)); }
    }

    // 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;

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

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

    [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:
                    ForegroundHelper.AllowAny();
                    await _worker.ResumePlanningSessionAsync(row.Id);
                    break;
                case UnfinishedPlanningModalResult.FinalizeNow:
                    await _worker.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false);
                    break;
                case UnfinishedPlanningModalResult.Discard:
                    await TryDiscardPlanningWithRetryAsync(row.Id);
                    break;
                case UnfinishedPlanningModalResult.Cancel:
                default:
                    break;
            }
        }
        catch { }
    }

    [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; }

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

    [RelayCommand]
    private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
    {
        if (row is null) return;
        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;
    }

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