using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
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.Agent;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.IO;

namespace ClaudeDo.Ui.ViewModels.Islands;

public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
{
    private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
    private readonly IWorkerClient _worker;
    private readonly IServiceProvider _services;
    private readonly MergeCoordinator _merge;

    // ── Section view models ───────────────────────────────────────────────────
    public AgentConfigEditorViewModel AgentSettings { get; }
    public MergeSectionViewModel Merge { get; }
    public PrepPanelViewModel Prep { get; }

    // Captured handler delegates for disposal
    private readonly EventHandler _langChangedHandler;
    private readonly System.ComponentModel.PropertyChangedEventHandler _workerPropertyChangedHandler;
    private readonly Action<string, string, DateTime> _workerTaskStartedHandler;
    private readonly Action<string, string, string, DateTime> _workerTaskFinishedHandler;
    private readonly Action<string> _workerWorktreeUpdatedHandler;
    private readonly Action<string> _workerTaskUpdatedHandler;
    private readonly Action<string, string> _workerPlanningMergeStartedHandler;
    private readonly Action<string, string?> _workerPlanningMergeAbortedHandler;
    private readonly Action<string> _workerPlanningCompletedHandler;

    [ObservableProperty] private bool _isPrepMode;

    public bool IsTaskDetailVisible => !IsPrepMode;

    /// <summary>Centered placeholder shown when no task is selected — hidden in Prep mode,
    /// where <see cref="IsTaskDetailVisible"/> is already false.</summary>
    public bool IsEmptyStateVisible => Task is null && IsTaskDetailVisible;

    partial void OnIsPrepModeChanged(bool value)
    {
        OnPropertyChanged(nameof(IsTaskDetailVisible));
        OnPropertyChanged(nameof(IsEmptyStateVisible));
    }

    // Current task row (set by IslandsShellViewModel via Bind)
    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
    [NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
    [NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
    [NotifyPropertyChangedFor(nameof(TaskIdBadge))]
    [NotifyPropertyChangedFor(nameof(IsEmptyStateVisible))]
    private TaskRowViewModel? _task;

    // Editable fields
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(ComposedPreview))]
    private string _editableTitle = "";
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(ComposedPreview))]
    private string _editableDescription = "";
    [ObservableProperty] private bool _isEditingDescription;
    [ObservableProperty] private bool _isDescriptionExpanded = true;

    public bool IsDescriptionEditorVisible => IsDescriptionExpanded && IsEditingDescription;
    public bool IsDescriptionPreviewVisible => IsDescriptionExpanded && !IsEditingDescription;

    partial void OnIsDescriptionExpandedChanged(bool value)
    {
        OnPropertyChanged(nameof(IsDescriptionEditorVisible));
        OnPropertyChanged(nameof(IsDescriptionPreviewVisible));
    }

    partial void OnIsEditingDescriptionChanged(bool value)
    {
        OnPropertyChanged(nameof(IsDescriptionEditorVisible));
        OnPropertyChanged(nameof(IsDescriptionPreviewVisible));
    }

    [RelayCommand]
    private void ToggleEditDescription() => IsEditingDescription = !IsEditingDescription;

    [RelayCommand]
    private void ToggleDescriptionExpanded() => IsDescriptionExpanded = !IsDescriptionExpanded;

    // Which section of the details card is shown (header acts as a segment switcher).
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsDescriptionSection))]
    [NotifyPropertyChangedFor(nameof(IsStepsSection))]
    [NotifyPropertyChangedFor(nameof(IsFilesSection))]
    private string _detailSection = "description";

    public bool IsDescriptionSection => DetailSection == "description";
    public bool IsStepsSection       => DetailSection == "steps";
    public bool IsFilesSection       => DetailSection == "files";

    [RelayCommand]
    private void SelectDetailSection(string? section) => DetailSection = section ?? "description";

    public int TotalStepCount => Subtasks.Count;
    public int DoneStepCount => Subtasks.Count(s => s.Done);
    public string StepsBadge => TotalStepCount > 0 ? $"{DoneStepCount}/{TotalStepCount}" : "";
    public string FilesBadge => Attachments.Count > 0 ? Attachments.Count.ToString() : "";

    private void NotifyStepsChanged()
    {
        OnPropertyChanged(nameof(TotalStepCount));
        OnPropertyChanged(nameof(DoneStepCount));
        OnPropertyChanged(nameof(StepsBadge));
        OnPropertyChanged(nameof(ComposedPreview));
    }

    public string ComposedPreview =>
        ClaudeDo.Data.TaskPromptComposer.Compose(
            EditableTitle, EditableDescription, Subtasks.Select(s => (s.Title, s.Done)),
            Task is not null
                ? Attachments.Select(a => Path.Combine(new AttachmentStore().TaskDir(Task.Id), a.FileName))
                : null);

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsOutputTab))]
    [NotifyPropertyChangedFor(nameof(IsGitTab))]
    [NotifyPropertyChangedFor(nameof(IsSessionTab))]
    private string _selectedTab = "output";

    public bool IsOutputTab  => SelectedTab == "output";
    public bool IsGitTab     => SelectedTab == "git";
    public bool IsSessionTab => SelectedTab == "session";

    [RelayCommand]
    private void SelectTab(string? tab) => SelectedTab = tab ?? "output";

    private void NotifySessionSections()
    {
        OnPropertyChanged(nameof(HasChildOutcomes));
        Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
        NotifyAttention();

        if (!HasChildOutcomes && SelectedTab == "session")
            SelectedTab = "output";
    }

    public string TurnsText   => $"{Turns}/{AgentSettings.EffectiveMaxTurns}";
    public string DiffAddText => $"+{DiffAdditions}";
    public string DiffDelText => $"-{DiffDeletions}";

    // ── Monitor forwarding ───────────────────────────────────────────────────
    public TaskMonitorViewModel Monitor { get; }

    public ObservableCollection<LogLineViewModel> Log => Monitor.Log;

    public string AgentState
    {
        get => Monitor.AgentState;
        set => Monitor.AgentState = value;
    }

    public string AgentStatusLabel        => Monitor.AgentStatusLabel;
    public bool IsIdle                    => Monitor.IsIdle;
    public bool IsQueued                  => Monitor.IsQueued;
    public bool IsRunning                 => Monitor.IsRunning;
    public bool IsWaitingForReview        => Monitor.IsWaitingForReview;
    public bool IsWaitingForChildren      => Monitor.IsWaitingForChildren;
    public bool IsDone                    => Monitor.IsDone;
    public bool IsFailed                  => Monitor.IsFailed;
    public bool IsCancelled               => Monitor.IsCancelled;
    public bool ShowContinue              => Monitor.ShowContinue;
    public bool ShowResetAndRetry         => Monitor.ShowResetAndRetry;
    public bool ShowRoadblock             => Monitor.ShowRoadblock;
    public string RoadblockMessage        => Monitor.RoadblockMessage;
    public bool ShowSessionOutcome        => Monitor.ShowSessionOutcome;
    public bool ShowRoadblockCard         => Monitor.ShowRoadblockCard;

    public string? SessionOutcome
    {
        get => Monitor.SessionOutcome;
        set => Monitor.SessionOutcome = value;
    }

    public string? Roadblocks
    {
        get => Monitor.Roadblocks;
        set => Monitor.Roadblocks = value;
    }

    public string SessionLabel => "claude-session";

    public string TaskIdBadge => Task != null ? $"#{Task.Number}" : "";

    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
    [NotifyPropertyChangedFor(nameof(CanReplyToRoadblock))]
    [NotifyCanExecuteChangedFor(nameof(SendRoadblockReplyCommand))]
    private string? _latestRunSessionId;

    // A resumable session to reply to a reported roadblock with (via ContinueAsync's
    // transport). Distinct from ContinueCommand: that one is Failed/Cancelled-only and
    // reruns with a fixed prompt, this one answers a roadblock reported on any terminal
    // state (see ShowRoadblockCard) with the user's own text.
    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(SendRoadblockReplyCommand))]
    private string _roadblockReplyDraft = string.Empty;

    public bool CanReplyToRoadblock => !string.IsNullOrEmpty(LatestRunSessionId);

    // Surfaces a UI-originated failure (e.g. override slot busy) to the shell's footer
    // error strip — mirrors TasksIslandViewModel.ErrorReported.
    public event Action<string>? ErrorReported;

    [ObservableProperty] private string? _model;

    [ObservableProperty] private string? _worktreePath;
    [ObservableProperty] private string? _worktreeBaseCommit;
    [ObservableProperty] private string? _worktreeHeadCommit;
    [ObservableProperty] private string? _worktreeStateLabel;
    private string? _listWorkingDir;
    [ObservableProperty] private string? _branchLine;
    [ObservableProperty] private int _turns;
    [ObservableProperty] private int _tokens;
    [ObservableProperty] private string? _tokensBreakdown;
    [ObservableProperty] private int _diffAdditions;
    [ObservableProperty] private int _diffDeletions;
    [ObservableProperty] private int _commitsOnBranch;

    public string TokensFormatted => Tokens >= 1000 ? $"{Tokens / 1000.0:F1}k" : Tokens.ToString();
    public string ElapsedFormatted => "";

    partial void OnTokensChanged(int value)   => OnPropertyChanged(nameof(TokensFormatted));
    partial void OnTurnsChanged(int value)    => OnPropertyChanged(nameof(TurnsText));
    partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(DiffMeterRatio)); OnPropertyChanged(nameof(DiffAddText)); }
    partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(DiffMeterRatio)); OnPropertyChanged(nameof(DiffDelText)); }

    public double DiffMeterRatio
    {
        get
        {
            var total = DiffAdditions + DiffDeletions;
            return total == 0 ? 0.0 : (double)DiffAdditions / total;
        }
    }

    public ObservableCollection<SubtaskRowViewModel> Subtasks { get; } = new();
    public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();
    public ObservableCollection<AttachmentRowViewModel> Attachments { get; } = new();

    [ObservableProperty] private bool _isDragOver;
    [ObservableProperty] private string? _dropStatus;

    public bool CanAcceptDrop => Task is not null && !Task.IsRunning;
    public bool HasChildOutcomes => ChildOutcomes.Count > 0;

    public int ChildrenNeedingAttention => ChildOutcomes.Count(c =>
        c.Status == ClaudeDo.Data.Models.TaskStatus.Failed
        || c.Status == ClaudeDo.Data.Models.TaskStatus.Cancelled
        || c.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview
        || c.RoadblockCount > 0);
    public bool HasChildrenNeedingAttention => ChildrenNeedingAttention > 0;
    public string ChildrenAttentionText => ChildrenNeedingAttention == 1
        ? "1 child needs attention"
        : $"{ChildrenNeedingAttention} children need attention";

    private void NotifyAttention()
    {
        OnPropertyChanged(nameof(ChildrenNeedingAttention));
        OnPropertyChanged(nameof(HasChildrenNeedingAttention));
        OnPropertyChanged(nameof(ChildrenAttentionText));
    }

    [ObservableProperty] private string _newSubtaskTitle = "";

    private CancellationTokenSource? _loadCts;

    private bool _suppressDescSave;
    private CancellationTokenSource? _descSaveCts;

    private bool _suppressTitleSave;
    private CancellationTokenSource? _titleSaveCts;

    // Set by shell so deleting a task can clear SelectedTask
    public Action? CloseDetail { get; set; }

    // Set by shell so DeleteTaskCommand can remove from list
    public Func<TaskRowViewModel, System.Threading.Tasks.Task>? DeleteFromList { get; set; }

    // Set by the view so DeleteTaskCommand can prompt yes/no before deleting
    public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }

    // Set by the view so DeleteTaskCommand can show an error message
    public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }

    // ── Review ──────────────────────────────────────────────────────────────
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(HasReviewFeedback))]
    [NotifyCanExecuteChangedFor(nameof(RejectReviewCommand))]
    private string _reviewFeedback = "";

    public bool HasReviewFeedback => !string.IsNullOrWhiteSpace(ReviewFeedback) && !RejectOp.IsRunning;

    public OperationStatus ApproveOp { get; } = new();
    public OperationStatus SubmitOp { get; } = new();
    public OperationStatus RejectOp { get; } = new();
    public OperationStatus ParkOp { get; } = new();

    public DetailsIslandViewModel(
        IDbContextFactory<ClaudeDoDbContext> dbFactory,
        IWorkerClient worker,
        IServiceProvider services,
        MergeCoordinator merge)
    {
        _dbFactory = dbFactory;
        _worker = worker;
        _services = services;
        _merge = merge;

        Monitor = new TaskMonitorViewModel(dbFactory, worker);
        Monitor.PropertyChanged += OnMonitorPropertyChanged;

        AgentSettings = new AgentConfigEditorViewModel(worker, AgentConfigScope.Task);
        Merge = new MergeSectionViewModel(worker, services);
        Merge.DiffViewed = () => ReviewDiffViewed = true;
        Merge.ErrorReported += msg => ErrorReported?.Invoke(msg);
        Merge.PropertyChanged += (_, e) =>
        {
            if (e.PropertyName == nameof(MergeSectionViewModel.HasReviewableDiff))
            {
                ApproveReviewCommand.NotifyCanExecuteChanged();
                OnPropertyChanged(nameof(ShowReviewDiffHint));
            }
        };
        Prep = new PrepPanelViewModel(worker);

        // OperationStatus.IsRunning is a nested ObservableObject property, so
        // [NotifyCanExecuteChangedFor] on the command can't see it -- without this, the
        // button stays clickable for the whole duration of the call.
        ApproveOp.PropertyChanged += (_, e) =>
        {
            if (e.PropertyName == nameof(OperationStatus.IsRunning)) ApproveReviewCommand.NotifyCanExecuteChanged();
        };
        SubmitOp.PropertyChanged += (_, e) =>
        {
            if (e.PropertyName == nameof(OperationStatus.IsRunning)) SubmitForReviewCommand.NotifyCanExecuteChanged();
        };
        RejectOp.PropertyChanged += (_, e) =>
        {
            if (e.PropertyName == nameof(OperationStatus.IsRunning))
            {
                OnPropertyChanged(nameof(HasReviewFeedback));
                RejectReviewCommand.NotifyCanExecuteChanged();
            }
        };
        ParkOp.PropertyChanged += (_, e) =>
        {
            if (e.PropertyName == nameof(OperationStatus.IsRunning)) ParkReviewCommand.NotifyCanExecuteChanged();
        };

        Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
        Subtasks.CollectionChanged += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
        Attachments.CollectionChanged += (_, _) => OnPropertyChanged(nameof(FilesBadge));

        AgentSettings.PropertyChanged += (_, e) =>
        {
            if (e.PropertyName == nameof(AgentConfigEditorViewModel.EffectiveMaxTurns))
                OnPropertyChanged(nameof(TurnsText));
        };

        _langChangedHandler = (_, _) => OnPropertyChanged(nameof(AgentStatusLabel));
        Loc.LanguageChanged += _langChangedHandler;

        _workerPropertyChangedHandler = (_, e) =>
        {
            if (e.PropertyName == nameof(IWorkerClient.IsConnected))
            {
                EnqueueCommand.NotifyCanExecuteChanged();
                DequeueCommand.NotifyCanExecuteChanged();
                ResetAndRetryCommand.NotifyCanExecuteChanged();
                ContinueCommand.NotifyCanExecuteChanged();
                SendRoadblockReplyCommand.NotifyCanExecuteChanged();
                CancelReviewCommand.NotifyCanExecuteChanged();
                DeleteTaskCommand.NotifyCanExecuteChanged();
            }
        };
        _worker.PropertyChanged += _workerPropertyChangedHandler;

        _workerTaskStartedHandler = (slot, taskId, startedAt) =>
        {
            _ = RefreshChildOutcomeAsync(taskId);
        };
        _worker.TaskStartedEvent += _workerTaskStartedHandler;

        _workerTaskFinishedHandler = (slot, taskId, status, finishedAt) =>
        {
            if (Task?.Id != taskId) return;
            _ = RefreshWorktreeAsync(taskId);
            _ = RefreshChildOutcomeAsync(taskId);
            // The run only gets a session id once it has started, so a task that was already
            // selected while it ran still holds the null from selection time. Without this the
            // roadblock reply box and Continue stay dead until the task is re-selected.
            _ = RefreshLatestRunSessionIdAsync(taskId);
        };
        _worker.TaskFinishedEvent += _workerTaskFinishedHandler;

        _workerWorktreeUpdatedHandler = taskId =>
        {
            if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId);
            if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
            _ = RefreshChildOutcomeAsync(taskId);
        };
        _worker.WorktreeUpdatedEvent += _workerWorktreeUpdatedHandler;

        _workerTaskUpdatedHandler = taskId =>
        {
            if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
            _ = RefreshChildOutcomeAsync(taskId);
            // The bound row's own status can change from outside (worker run, MCP tool, another
            // session) — refresh it directly instead of relying on TasksIslandViewModel mutating
            // the same instance, so the detail pane stays correct even if that row was replaced.
            if (Task?.Id == taskId) _ = RefreshBoundTaskAsync(taskId);
        };
        _worker.TaskUpdatedEvent += _workerTaskUpdatedHandler;

        _workerPlanningMergeStartedHandler = (planningTaskId, _) =>
        {
            if (Task?.Id == planningTaskId) IsMergeDraining = true;
        };
        _worker.PlanningMergeStartedEvent += _workerPlanningMergeStartedHandler;

        _workerPlanningMergeAbortedHandler = (planningTaskId, _) =>
        {
            if (Task?.Id == planningTaskId) IsMergeDraining = false;
        };
        _worker.PlanningMergeAbortedEvent += _workerPlanningMergeAbortedHandler;

        _workerPlanningCompletedHandler = planningTaskId =>
        {
            if (Task?.Id == planningTaskId) IsMergeDraining = false;
        };
        _worker.PlanningCompletedEvent += _workerPlanningCompletedHandler;

        ChildOutcomes.CollectionChanged += (_, _) =>
        {
            Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
            NotifySessionSections();
        };
    }

    public void Dispose()
    {
        if (_subscribedTask is not null)
            _subscribedTask.PropertyChanged -= OnBoundTaskPropertyChanged;
        Monitor.PropertyChanged -= OnMonitorPropertyChanged;
        Monitor.Dispose();
        Loc.LanguageChanged -= _langChangedHandler;
        _worker.PropertyChanged -= _workerPropertyChangedHandler;
        _worker.TaskStartedEvent -= _workerTaskStartedHandler;
        _worker.TaskFinishedEvent -= _workerTaskFinishedHandler;
        _worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler;
        _worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler;
        _worker.PlanningMergeStartedEvent -= _workerPlanningMergeStartedHandler;
        _worker.PlanningMergeAbortedEvent -= _workerPlanningMergeAbortedHandler;
        _worker.PlanningCompletedEvent -= _workerPlanningCompletedHandler;
        AgentSettings.Dispose();
        Prep.Dispose();
    }

    private void OnMonitorPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        switch (e.PropertyName)
        {
            case nameof(TaskMonitorViewModel.AgentState):
                OnPropertyChanged(nameof(AgentState));
                OnPropertyChanged(nameof(AgentStatusLabel));
                OnPropertyChanged(nameof(IsIdle));
                OnPropertyChanged(nameof(IsQueued));
                OnPropertyChanged(nameof(IsRunning));
                OnPropertyChanged(nameof(IsWaitingForReview));
                OnPropertyChanged(nameof(IsWaitingForChildren));
                OnPropertyChanged(nameof(IsDone));
                OnPropertyChanged(nameof(IsFailed));
                OnPropertyChanged(nameof(IsCancelled));
                OnPropertyChanged(nameof(ShowContinue));
                OnPropertyChanged(nameof(ShowResetAndRetry));
                OnPropertyChanged(nameof(ShowRoadblock));
                OnPropertyChanged(nameof(RoadblockMessage));
                OnPropertyChanged(nameof(ShowSessionOutcome));
                OnPropertyChanged(nameof(ShowRoadblockCard));
                EnqueueCommand.NotifyCanExecuteChanged();
                DequeueCommand.NotifyCanExecuteChanged();
                ResetAndRetryCommand.NotifyCanExecuteChanged();
                ContinueCommand.NotifyCanExecuteChanged();
                CancelReviewCommand.NotifyCanExecuteChanged();
                // A state change means a new run/review cycle: the diff must be
                // re-inspected before merge can be approved again.
                ReviewDiffViewed = false;
                ApproveReviewCommand.NotifyCanExecuteChanged();
                OnPropertyChanged(nameof(ShowReviewDiffHint));
                OnPropertyChanged(nameof(CanSubmitForReview));
                SubmitForReviewCommand.NotifyCanExecuteChanged();
                AgentSettings.IsRunning = IsRunning;
                NotifySessionSections();
                OnPropertyChanged(nameof(CanAcceptDrop));
                break;
            case nameof(TaskMonitorViewModel.SessionOutcome):
                OnPropertyChanged(nameof(SessionOutcome));
                OnPropertyChanged(nameof(ShowSessionOutcome));
                break;
            case nameof(TaskMonitorViewModel.Roadblocks):
                OnPropertyChanged(nameof(Roadblocks));
                OnPropertyChanged(nameof(ShowRoadblockCard));
                break;
        }
    }

    partial void OnEditableTitleChanged(string value)
    {
        if (_suppressTitleSave || Task is null) return;
        _titleSaveCts?.Cancel();
        _titleSaveCts = new CancellationTokenSource();
        // Capture the row so a task switch mid-debounce cannot retitle the new task.
        _ = SaveTitleAsync(Task, value, _titleSaveCts.Token);
    }

    private async System.Threading.Tasks.Task SaveTitleAsync(
        TaskRowViewModel row, string value, CancellationToken ct)
    {
        try
        {
            await System.Threading.Tasks.Task.Delay(400, ct);
            var title = value?.Trim();
            // An empty title would make the row unidentifiable — keep the stored one.
            if (string.IsNullOrEmpty(title)) return;
            await using var ctx = _dbFactory.CreateDbContext();
            var repo = new TaskRepository(ctx);
            var entity = await repo.GetByIdAsync(row.Id);
            if (entity is null || entity.Title == title) return;
            entity.Title = title;
            await repo.UpdateAsync(entity);
            ct.ThrowIfCancellationRequested();
            row.Title = title;
            if (ReferenceEquals(Task, row))
                Merge.SyncTaskContext(row.Id, title, row.IsPlanningParent);
        }
        catch (OperationCanceledException) { }
        catch { }
    }

    partial void OnEditableDescriptionChanged(string value)
    {
        if (_suppressDescSave || Task is null) return;
        _descSaveCts?.Cancel();
        _descSaveCts = new CancellationTokenSource();
        // Capture the row so a task switch mid-debounce cannot write A's text into B's description.
        _ = SaveDescriptionAsync(Task, value, _descSaveCts.Token);
    }

    private async System.Threading.Tasks.Task SaveDescriptionAsync(
        TaskRowViewModel row, string value, CancellationToken ct)
    {
        try
        {
            await System.Threading.Tasks.Task.Delay(400, ct);
            await using var ctx = _dbFactory.CreateDbContext();
            var repo = new TaskRepository(ctx);
            var entity = await repo.GetByIdAsync(row.Id);
            if (entity is null) return;
            entity.Description = string.IsNullOrWhiteSpace(value) ? null : value;
            await repo.UpdateAsync(entity);
        }
        catch (OperationCanceledException) { }
        catch { }
    }

    public void ShowPrep()
    {
        Bind(null, "prep");
        IsPrepMode = true;
        _ = Prep.LoadLastPrepLogIfEmptyAsync();
    }

    /// <param name="source">What triggered the selection (<see cref="TasksIslandViewModel.SelectionSource"/>);
    /// logged with the bind timing so rebind churn can be attributed to a trigger.</param>
    public void Bind(TaskRowViewModel? row, string source = "?")
    {
        IsPrepMode = false;
        _loadCts?.Cancel();
        _loadCts?.Dispose();
        _loadCts = new CancellationTokenSource();
        var ct = _loadCts.Token;

        // A pending debounced title/description save targets the row being unbound —
        // let it be, don't have it land on whichever row replaces it.
        _titleSaveCts?.Cancel();
        _descSaveCts?.Cancel();

        Task = row;
        OnPropertyChanged(nameof(TaskIdBadge));
        Monitor.Reset();
        RoadblockReplyDraft = string.Empty;
        IsMergeDraining = false;
        Subtasks.Clear();
        ChildOutcomes.Clear();
        Attachments.Clear();
        DropStatus = null;
        OnPropertyChanged(nameof(HasChildOutcomes));
        Merge.Clear();

        if (row == null)
        {
            EditableTitle = "";
            EditableDescription = "";
            Model = null;
            WorktreePath = null;
            WorktreeHeadCommit = null;
            _listWorkingDir = null;
            WorktreeStateLabel = null;
            BranchLine = null;
            DiffAdditions = 0;
            DiffDeletions = 0;
            LatestRunSessionId = null;
            AgentSettings.Clear();
            return;
        }

        // A hand-driven task (manual reminder or a live ConPTY session) produces no streamed
        // agent output, so the Output tab would open empty -- its work shows up as git changes.
        SelectedTab = row.IsManual || row.HasInteractiveSession ? "git" : "output";

        _ = BindAsync(row, ct, source);
    }

    private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct, string source)
    {
        var sw = System.Diagnostics.Stopwatch.StartNew();
        var ok = false;
        try
        {
            await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
            var subtaskRepo = new SubtaskRepository(ctx);

            var entity = await ctx.Tasks
                .AsNoTracking()
                .Include(t => t.Worktree)
                .Include(t => t.List)
                .FirstOrDefaultAsync(t => t.Id == row.Id, ct);
            ct.ThrowIfCancellationRequested();
            if (entity == null) return;

            _suppressTitleSave = true;
            try { EditableTitle = entity.Title; }
            finally { _suppressTitleSave = false; }
            _suppressDescSave = true;
            try { EditableDescription = entity.Description ?? ""; }
            finally { _suppressDescSave = false; }
            Model = entity.Model;
            _listWorkingDir = entity.List?.WorkingDir;
            WorktreePath = entity.Worktree?.Path;
            // A worktree-less list-handler host task (Mission Control's "Let Claude handle it")
            // has no WorktreeEntity, so its review range falls back to HandlerBaseCommit/
            // HandlerHeadCommit -- see TaskEntity for why.
            WorktreeBaseCommit = entity.Worktree?.BaseCommit ?? entity.HandlerBaseCommit;
            WorktreeHeadCommit = entity.Worktree?.HeadCommit ?? entity.HandlerHeadCommit;
            WorktreeStateLabel = entity.Worktree?.State.ToString();
            BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
            var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
            DiffAdditions = add;
            DiffDeletions = del;
            Monitor.ApplyState(entity.Status);

            Merge.SyncTaskContext(row.Id, row.Title, row.IsPlanningParent);
            Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
                WorktreeStateLabel, _listWorkingDir);

            await AgentSettings.LoadForTaskAsync(entity, ct);
            ct.ThrowIfCancellationRequested();

            var runRepo = new TaskRunRepository(ctx);
            var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct);
            ct.ThrowIfCancellationRequested();
            LatestRunSessionId = latestRun?.SessionId;
            // Restore turn/token counts from the last run so a reloaded terminal task
            // shows its real turns instead of "0/max".
            Turns = latestRun?.TurnCount ?? 0;
            // Raw total = what actually counts against the 5h/7d Claude usage limit: cached
            // context resend (cache-read + cache-write) dwarfs fresh input/output on a
            // resumed session, so it must be included, not just the uncached input/output.
            var tokensIn = latestRun?.TokensIn ?? 0;
            var tokensOut = latestRun?.TokensOut ?? 0;
            var cacheRead = latestRun?.CacheReadTokens ?? 0;
            var cacheWrite = latestRun?.CacheWriteTokens ?? 0;
            Tokens = tokensIn + tokensOut + cacheRead + cacheWrite;
            TokensBreakdown = $"in {tokensIn} · out {tokensOut} · cache-read {cacheRead} · cache-write {cacheWrite}";
            Monitor.ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown);

            Monitor.SetTaskId(row.Id);

            await Monitor.ReplayLogFileAsync(entity.LogPath, ct);

            var subs = await subtaskRepo.GetByTaskIdAsync(row.Id);
            ct.ThrowIfCancellationRequested();
            foreach (var s in subs)
                Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });

            var attachmentRepo = new TaskAttachmentRepository(ctx);
            var attachments = await attachmentRepo.ListByTaskIdAsync(row.Id, ct);
            ct.ThrowIfCancellationRequested();
            foreach (var a in attachments)
                Attachments.Add(new AttachmentRowViewModel { FileName = a.FileName, ByteSize = a.ByteSize });

            if (entity.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.None)
                await LoadPlanningChildrenAsync(row.Id, ct);
            await LoadChildOutcomesAsync(row.Id, ct);

            if (entity.Worktree != null
                && entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
                && Merge.MergeTargetBranches.Count == 0)
            {
                var targets = await _worker.GetMergeTargetsAsync(row.Id);
                if (targets != null)
                {
                    Merge.MergeTargetBranches.Clear();
                    foreach (var b in targets.LocalBranches) Merge.MergeTargetBranches.Add(b);
                    Merge.SelectedMergeTarget = targets.DefaultBranch;
                }
            }
            await Merge.RefreshMergePreviewAsync();
            ok = true;
        }
        catch (OperationCanceledException) { }
        finally { OperationTiming.Shared.Record("db", $"DetailsIsland.BindAsync:{source}", sw.Elapsed, ok); }
    }

    private async System.Threading.Tasks.Task LoadChildOutcomesAsync(string parentTaskId, CancellationToken ct)
    {
        var sw = System.Diagnostics.Stopwatch.StartNew();
        var ok = false;
        try
        {
            await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
            var children = await ctx.Tasks
                .AsNoTracking()
                .Include(t => t.Worktree)
                .Where(t => t.ParentTaskId == parentTaskId)
                .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
                .ToListAsync(ct);
            ct.ThrowIfCancellationRequested();
            ok = true;
            if (children.Count == 0) return;

            ChildOutcomes.Clear();
            foreach (var c in children)
                ChildOutcomes.Add(new ChildOutcomeRowViewModel
                {
                    Id = c.Id,
                    Title = c.Title,
                    Status = c.Status,
                    RoadblockCount = c.RoadblockCount,
                    WorktreeState = c.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active,
                });
            OnPropertyChanged(nameof(HasChildOutcomes));

            if (Merge.MergeTargetBranches.Count == 0)
            {
                var childWithWorktree = children.FirstOrDefault(c => c.Worktree != null);
                if (childWithWorktree != null)
                {
                    var targets = await _worker.GetMergeTargetsAsync(childWithWorktree.Id);
                    if (targets != null)
                    {
                        Merge.MergeTargetBranches.Clear();
                        foreach (var b in targets.LocalBranches)
                            Merge.MergeTargetBranches.Add(b);
                        Merge.SelectedMergeTarget = targets.DefaultBranch;
                    }
                }
            }
        }
        catch (OperationCanceledException) { }
        catch { /* best-effort */ }
        finally { OperationTiming.Shared.Record("db", "DetailsIsland.LoadChildOutcomesAsync", sw.Elapsed, ok); }
    }

    private async System.Threading.Tasks.Task LoadPlanningChildrenAsync(string parentTaskId, CancellationToken ct)
    {
        var sw = System.Diagnostics.Stopwatch.StartNew();
        var ok = false;
        try
        {
            await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
            var children = await ctx.Tasks
                .AsNoTracking()
                .Include(t => t.Worktree)
                .Where(t => t.ParentTaskId == parentTaskId)
                .ToListAsync(ct);
            ct.ThrowIfCancellationRequested();
            ok = true;

            foreach (var child in children)
            {
                var existing = Subtasks.FirstOrDefault(s => s.Id == child.Id);
                if (existing != null)
                {
                    existing.Status = child.Status;
                    existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
                }
            }

            if (Merge.MergeTargetBranches.Count == 0)
            {
                var childWithWorktree = children.FirstOrDefault(c => c.Worktree != null);
                if (childWithWorktree != null)
                {
                    var targets = await _worker.GetMergeTargetsAsync(childWithWorktree.Id);
                    if (targets != null)
                    {
                        Merge.MergeTargetBranches.Clear();
                        foreach (var b in targets.LocalBranches)
                            Merge.MergeTargetBranches.Add(b);
                        Merge.SelectedMergeTarget = targets.DefaultBranch;
                    }
                }
            }
        }
        catch (OperationCanceledException) { }
        catch { /* best-effort */ }
        finally { OperationTiming.Shared.Record("db", "DetailsIsland.LoadPlanningChildrenAsync", sw.Elapsed, ok); }
    }

    private async System.Threading.Tasks.Task RefreshPlanningChildAsync(string childTaskId)
    {
        if (Task is null) return;
        try
        {
            await using var ctx = await _dbFactory.CreateDbContextAsync();
            var child = await ctx.Tasks
                .AsNoTracking()
                .Include(t => t.Worktree)
                .FirstOrDefaultAsync(t => t.Id == childTaskId && t.ParentTaskId == Task.Id);
            if (child == null) return;

            var existing = Subtasks.FirstOrDefault(s => s.Id == child.Id);
            if (existing != null)
            {
                existing.Status = child.Status;
                existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
            }
        }
        catch { /* best-effort */ }
    }

    private async System.Threading.Tasks.Task RefreshChildOutcomeAsync(string childTaskId)
    {
        var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId);
        if (row is null) return;
        try
        {
            await using var ctx = await _dbFactory.CreateDbContextAsync();
            var child = await ctx.Tasks
                .AsNoTracking()
                .Include(t => t.Worktree)
                .FirstOrDefaultAsync(t => t.Id == childTaskId);
            if (child is null) return;
            row.Status = child.Status;
            row.RoadblockCount = child.RoadblockCount;
            row.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
            Merge.ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
            NotifyAttention();
        }
        catch { /* best-effort */ }
    }

    // Refreshes the bound row itself (status, planning phase, worktree/branch mirrors, etc.) from
    // the DB. Kept independent of TasksIslandViewModel's own handler: the row instance backing
    // Task may have been replaced by a full list reload, so this must not assume it stayed live.
    private async System.Threading.Tasks.Task RefreshLatestRunSessionIdAsync(string taskId)
    {
        try
        {
            await using var ctx = await _dbFactory.CreateDbContextAsync();
            var latestRun = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId);
            if (Task?.Id != taskId) return;
            LatestRunSessionId = latestRun?.SessionId;
        }
        catch { /* best-effort */ }
    }

    private async System.Threading.Tasks.Task RefreshBoundTaskAsync(string taskId)
    {
        try
        {
            await using var ctx = await _dbFactory.CreateDbContextAsync();
            var entity = await ctx.Tasks
                .AsNoTracking()
                .Include(t => t.Worktree)
                .Include(t => t.List)
                .FirstOrDefaultAsync(t => t.Id == taskId);
            if (entity is null || Task?.Id != taskId) return;
            Task.UpdateFromEntity(entity);
            OnPropertyChanged(nameof(CanAcceptDrop));
        }
        catch { /* best-effort */ }
    }

    private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId)
    {
        try
        {
            await using var ctx = await _dbFactory.CreateDbContextAsync();
            var entity = await ctx.Tasks
                .AsNoTracking()
                .Include(t => t.Worktree)
                .Include(t => t.List)
                .FirstOrDefaultAsync(t => t.Id == taskId);
            if (entity == null || Task?.Id != taskId) return;

            _listWorkingDir = entity.List?.WorkingDir;
            WorktreePath = entity.Worktree?.Path;
            // A worktree-less list-handler host task (Mission Control's "Let Claude handle it")
            // has no WorktreeEntity, so its review range falls back to HandlerBaseCommit/
            // HandlerHeadCommit -- see TaskEntity for why.
            WorktreeBaseCommit = entity.Worktree?.BaseCommit ?? entity.HandlerBaseCommit;
            WorktreeHeadCommit = entity.Worktree?.HeadCommit ?? entity.HandlerHeadCommit;
            WorktreeStateLabel = entity.Worktree?.State.ToString();
            BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
            if (Task is { } row && entity.Worktree?.DiffStat is { } stat)
                row.DiffStat = stat;
            var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
            DiffAdditions = add;
            DiffDeletions = del;

            Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
                WorktreeStateLabel, _listWorkingDir);
        }
        catch { /* best-effort refresh */ }
    }

    partial void OnWorktreePathChanged(string? value)
    {
        Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
            WorktreeStateLabel, _listWorkingDir);
        NotifySessionSections();
        OnPropertyChanged(nameof(CanSubmitForReview));
        SubmitForReviewCommand.NotifyCanExecuteChanged();
    }

    partial void OnWorktreeHeadCommitChanged(string? value) =>
        Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
            WorktreeStateLabel, _listWorkingDir);

    // Tracks whichever row we last subscribed to, so a later Task switch can unsubscribe the old
    // one cleanly even though the generated OnTaskChanged only hands us the new value.
    private TaskRowViewModel? _subscribedTask;

    partial void OnTaskChanged(TaskRowViewModel? value)
    {
        if (_subscribedTask is not null)
            _subscribedTask.PropertyChanged -= OnBoundTaskPropertyChanged;
        _subscribedTask = value;
        if (value is not null)
            value.PropertyChanged += OnBoundTaskPropertyChanged;

        ReviewDiffViewed = false;
        Merge.SyncTaskContext(Task?.Id, Task?.Title, Task?.IsPlanningParent == true);
        NotifySessionSections();
        OnPropertyChanged(nameof(CanAcceptDrop));
    }

    // The bound row's HasInteractiveSession can flip from outside (Mission Control opening/closing
    // a ConPTY pane) without Task itself changing, so ResetAndRetryCommand needs its own listener
    // to stay in sync with the gate in CanResetAndRetry.
    private void OnBoundTaskPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        if (e.PropertyName == nameof(TaskRowViewModel.HasInteractiveSession))
        {
            ResetAndRetryCommand.NotifyCanExecuteChanged();
            DeleteTaskCommand.NotifyCanExecuteChanged();
        }
    }

    [RelayCommand]
    private async System.Threading.Tasks.Task ToggleStarAsync()
    {
        if (Task is null) return;
        Task.IsStarred = !Task.IsStarred;
        await using var ctx = _dbFactory.CreateDbContext();
        var repo = new TaskRepository(ctx);
        var entity = await repo.GetByIdAsync(Task.Id);
        if (entity is null) return;
        entity.IsStarred = Task.IsStarred;
        await repo.UpdateAsync(entity);
    }

    [RelayCommand]
    private async System.Threading.Tasks.Task ToggleDoneAsync()
    {
        if (Task is null) return;

        var newDone = !Task.Done;
        var previousStatus = Task.Status;
        Task.Done = newDone;
        var newStatus = newDone ? ClaudeDo.Data.Models.TaskStatus.Done : ClaudeDo.Data.Models.TaskStatus.Idle;
        Task.Status = newStatus;
        Monitor.ApplyState(newStatus);
        try
        {
            if (newDone) await _worker.SetTaskDoneAsync(Task.Id);
            else await _worker.UnsetTaskDoneAsync(Task.Id);
        }
        catch (Exception ex)
        {
            Task.Done = !newDone;
            Task.Status = previousStatus;
            Monitor.ApplyState(previousStatus);
            ErrorReported?.Invoke(Loc.T("vm.detailsIsland.toggleDoneFailed", ex.Message));
        }
    }

    [RelayCommand]
    private async System.Threading.Tasks.Task ToggleSubtaskDoneAsync(SubtaskRowViewModel? row)
    {
        if (row is null) return;
        row.Done = !row.Done;
        NotifyStepsChanged();
        await using var ctx = _dbFactory.CreateDbContext();
        var repo = new SubtaskRepository(ctx);
        var subs = await repo.GetByTaskIdAsync(Task?.Id ?? "");
        var entity = subs.FirstOrDefault(s => s.Id == row.Id);
        if (entity is null) return;
        entity.Completed = row.Done;
        await repo.UpdateAsync(entity);
    }

    [RelayCommand(CanExecute = nameof(CanDeleteTask))]
    private async System.Threading.Tasks.Task DeleteTaskAsync()
    {
        if (Task == null) return;
        var row = Task;
        if (ConfirmAsync != null)
        {
            var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
            if (!ok) return;
        }
        // Routed through the worker (mirrors the MCP delete_task tool) so a deleted child
        // correctly advances a WaitingForChildren parent — a direct-repo delete from here used
        // to bypass TaskStateService.TryAdvanceParentAsync and could wedge the parent forever.
        bool deleted;
        string? error;
        try
        {
            (deleted, error) = await _worker.DeleteTaskAsync(row.Id);
        }
        catch (Exception ex)
        {
            // Belt and braces: the connection can drop between the CanExecute check and
            // this call, so a stray throw here must surface, not vanish silently.
            if (ShowErrorAsync != null)
                await ShowErrorAsync(ex.Message);
            return;
        }
        if (!deleted)
        {
            if (ShowErrorAsync != null)
                await ShowErrorAsync(error ?? "Delete failed.");
            return;
        }
        if (DeleteFromList != null)
            await DeleteFromList(row);
        CloseDetail?.Invoke();
    }

    private bool CanDeleteTask() => Task != null && _worker.IsConnected && !Task.HasInteractiveSession;

    [RelayCommand]
    private async System.Threading.Tasks.Task CommitSubtaskEditAsync(SubtaskRowViewModel? row)
    {
        if (row is null || !row.IsEditing) return;
        row.IsEditing = false;

        var title = row.Title?.Trim() ?? "";
        await using var ctx = _dbFactory.CreateDbContext();
        var repo = new SubtaskRepository(ctx);

        if (string.IsNullOrEmpty(title))
        {
            await repo.DeleteAsync(row.Id);
            Subtasks.Remove(row);
            return;
        }

        var subs = await repo.GetByTaskIdAsync(Task?.Id ?? "");
        var entity = subs.FirstOrDefault(s => s.Id == row.Id);
        if (entity is null) return;
        if (entity.Title != title)
        {
            entity.Title = title;
            await repo.UpdateAsync(entity);
        }
        row.Title = title;
        OnPropertyChanged(nameof(ComposedPreview));
    }

    [RelayCommand]
    private async System.Threading.Tasks.Task AddSubtaskAsync()
    {
        if (Task is null) return;
        var title = NewSubtaskTitle?.Trim();
        if (string.IsNullOrEmpty(title)) return;

        var entity = new ClaudeDo.Data.Models.SubtaskEntity
        {
            Id = Guid.NewGuid().ToString(),
            TaskId = Task.Id,
            Title = title,
            Completed = false,
            OrderNum = Subtasks.Count,
            CreatedAt = DateTime.UtcNow,
        };

        await using var ctx = _dbFactory.CreateDbContext();
        await new SubtaskRepository(ctx).AddAsync(entity);

        Subtasks.Add(new SubtaskRowViewModel { Id = entity.Id, Title = entity.Title, Done = entity.Completed });
        NewSubtaskTitle = "";
    }

    [RelayCommand]
    private async System.Threading.Tasks.Task StopAsync()
    {
        if (Task == null || !IsRunning) return;
        if (!_worker.IsConnected)
        {
            ErrorReported?.Invoke(Loc.T("vm.detailsIsland.stopOffline"));
            return;
        }
        try { await _worker.CancelTaskAsync(Task.Id); }
        catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.detailsIsland.stopFailed", ex.Message)); }
    }

    [RelayCommand(CanExecute = nameof(CanEnqueue))]
    private async System.Threading.Tasks.Task EnqueueAsync()
    {
        if (Task == null) return;
        try
        {
            var baseDirty = await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
            AgentState = "queued";
            ReportBaseDirty(baseDirty);
        }
        catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.detailsIsland.enqueueFailed", ex.Message)); }
    }

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

    private bool CanEnqueue() =>
        Task != null && _worker.IsConnected && IsIdle
        && (!Task.IsChild || Task.ParentFinalized);

    [RelayCommand(CanExecute = nameof(CanDequeue))]
    private async System.Threading.Tasks.Task DequeueAsync()
    {
        if (Task == null) return;
        try
        {
            await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Idle);
            AgentState = "idle";
        }
        catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.detailsIsland.dequeueFailed", ex.Message)); }
    }

    private bool CanDequeue() =>
        Task != null && _worker.IsConnected && IsQueued;

    [RelayCommand(CanExecute = nameof(CanContinue))]
    private async System.Threading.Tasks.Task ContinueAsync()
    {
        if (Task == null) return;
        await _worker.ContinueTaskAsync(Task.Id, "Continue working on this task.");
    }

    private bool CanContinue() =>
        Task != null && _worker.IsConnected && ShowContinue && !string.IsNullOrEmpty(LatestRunSessionId);

    [RelayCommand(CanExecute = nameof(CanSendRoadblockReply))]
    private async System.Threading.Tasks.Task SendRoadblockReplyAsync()
    {
        if (Task == null) return;
        var text = RoadblockReplyDraft;
        if (string.IsNullOrWhiteSpace(text)) return;
        try
        {
            await _worker.ContinueTaskAsync(Task.Id, text);
            RoadblockReplyDraft = string.Empty;
        }
        catch (Exception ex)
        {
            ErrorReported?.Invoke(Loc.T("details.roadblockReply.failed", ex.Message));
        }
    }

    private bool CanSendRoadblockReply() =>
        Task != null && _worker.IsConnected && CanReplyToRoadblock && !string.IsNullOrWhiteSpace(RoadblockReplyDraft);

    [RelayCommand(CanExecute = nameof(CanResetAndRetry))]
    private async System.Threading.Tasks.Task ResetAndRetryAsync()
    {
        if (Task == null) return;
        if (ConfirmAsync == null) return;

        var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
        var ok = await ConfirmAsync(
            $"Reset and retry?\nThis discards branch {branchName} (and uncommitted changes), then queues the task to run from the beginning.");
        if (!ok) return;

        if (WorktreePath != null)
            await _worker.ResetTaskAsync(Task.Id);

        try
        {
            var baseDirty = await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
            AgentState = "queued";
            ReportBaseDirty(baseDirty);
        }
        catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.detailsIsland.resetAndRetryFailed", ex.Message)); }
    }

    // Reset & Retry discards the branch/uncommitted work and queues an autonomous run into the
    // same worktree — must stay off while the user is hand-editing it in an interactive ConPTY
    // pane, same reasoning as TaskRowViewModel.CanSendToQueue.
    private bool CanResetAndRetry() =>
        Task != null && _worker.IsConnected && ShowResetAndRetry && !Task.HasInteractiveSession;

    // Set once the user opens the diff/combined-diff for the current review. Reset on
    // task switch and on every state change (a new run means a new diff to read), so
    // Approve & Merge stays blocked until the pending changes have been inspected.
    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(ApproveReviewCommand))]
    [NotifyPropertyChangedFor(nameof(ShowReviewDiffHint))]
    private bool _reviewDiffViewed;

    // True while a review is pending, there is a diff to inspect, and it has not been
    // opened yet — drives the "open the diff first" hint next to Approve & Merge.
    public bool ShowReviewDiffHint =>
        IsWaitingForReview && Merge.HasReviewableDiff && !ReviewDiffViewed;

    [RelayCommand(CanExecute = nameof(CanApproveReview))]
    private async System.Threading.Tasks.Task ApproveReviewAsync()
    {
        if (Task is null || !_worker.IsConnected) return;
        var taskId = Task.Id;
        using var op = ApproveOp.Begin(Loc.T("ops.merge.merging"));
        // Subscribed only for the duration of the call: the worker broadcasts to every
        // client, and a VM left on this event would keep reacting to merges for other tasks.
        void OnMergeProgress(string progressTaskId, string phase, int elapsedSeconds)
        {
            if (progressTaskId != taskId || phase != MergePhaseVerifying) return;
            ApproveOp.Report(Loc.T("ops.merge.verifying", FormatElapsed(elapsedSeconds)));
        }
        // Separate event: MergeProgressEvent forwards elapsed-seconds-only (verifying), but the
        // rebase phase needs the branch count too, which only rides the untrimmed OperationProgress.
        void OnOperationProgress(string opKey, string phase, int current, int total)
        {
            if (opKey != taskId || phase != MergePhaseRebasing) return;
            ApproveOp.Report(FormatRebasing(current, total));
        }
        _worker.MergeProgressEvent += OnMergeProgress;
        _worker.OperationProgressEvent += OnOperationProgress;
        try
        {
            var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0;
            var result = await _worker.ApproveReviewAsync(taskId, Merge.SelectedMergeTarget ?? "");
            if (!hasChildren && result?.Status == "conflict")
                await _merge.ResolveConflictAsync(taskId, Merge.SelectedMergeTarget ?? "");
            // The merge itself already landed; the verify command failed, so the task stayed
            // out of Done. Surface that instead of silently looking like nothing happened.
            else if (!hasChildren && result?.Status == "verify_failed" && ShowErrorAsync != null)
                await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
            // Nothing landed -- the merge was refused before touching the repo because the
            // branch would have overwritten an untracked file in the target working directory.
            else if (!hasChildren && result?.Status == "untracked_collision" && ShowErrorAsync != null)
                await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.untrackedCollision"));
        }
        catch (Exception ex)
        {
            if (ShowErrorAsync != null)
                await ShowErrorAsync(ex.Message);
        }
        finally
        {
            _worker.MergeProgressEvent -= OnMergeProgress;
            _worker.OperationProgressEvent -= OnOperationProgress;
        }
    }

    /// Mirrors TaskMergeService.PhaseVerifying — a hub payload token, not a display string.
    private const string MergePhaseVerifying = "verifying";

    /// Mirrors TaskMergeService.PhaseRebasing — a hub payload token, not a display string.
    private const string MergePhaseRebasing = "rebasing";

    private static string FormatElapsed(int seconds) =>
        TimeSpan.FromSeconds(Math.Max(0, seconds)).ToString(@"mm\:ss");

    private static string FormatRebasing(int current, int total) =>
        total > 0
            ? $"{Loc.T("ops.worker.rebasingAfterMerge")} ({current}/{total})"
            : Loc.T("ops.worker.rebasingAfterMerge");

    // Force the diff to have been opened before a merge can happen — but only when
    // there is actually something to inspect (a childless sandbox run with no worktree
    // has no diff, so it approves straight through).
    private bool CanApproveReview() =>
        Task != null && _worker.IsConnected && IsWaitingForReview
        && (!Merge.HasReviewableDiff || ReviewDiffViewed)
        && !ApproveOp.IsRunning;

    // An interactive (ConPTY) session leaves its worktree changed but never flips the task
    // status. Offer "Submit for review" for an Idle/Failed task that still has a worktree, so
    // its hand-driven work can enter the normal review/merge flow (commits first, server-side).
    public bool CanSubmitForReview =>
        Task != null && _worker.IsConnected && (IsIdle || IsFailed) && !string.IsNullOrEmpty(WorktreePath)
        && !SubmitOp.IsRunning;

    [RelayCommand(CanExecute = nameof(CanSubmitForReview))]
    private async System.Threading.Tasks.Task SubmitForReviewAsync()
    {
        if (Task is null || !_worker.IsConnected) return;
        using var op = SubmitOp.Begin(Loc.T("ops.review.submitting"));
        try { await _worker.SubmitTaskForReviewAsync(Task.Id); }
        catch (Exception ex)
        {
            if (ShowErrorAsync != null) await ShowErrorAsync(ex.Message);
        }
    }

    [RelayCommand(CanExecute = nameof(HasReviewFeedback))]
    private async System.Threading.Tasks.Task RejectReviewAsync()
    {
        if (Task is null || !_worker.IsConnected) return;
        var feedback = ReviewFeedback;
        if (string.IsNullOrWhiteSpace(feedback)) return;
        using var op = RejectOp.Begin(Loc.T("ops.review.rejecting"));
        try { await _worker.RejectReviewToQueueAsync(Task.Id, feedback); }
        catch { /* stale review action; broadcast reconciles */ return; }
        ReviewFeedback = "";
    }

    // Park: set the task aside (back to Idle), keeping its worktree intact.
    [RelayCommand(CanExecute = nameof(CanParkReview))]
    private async System.Threading.Tasks.Task ParkReviewAsync()
    {
        if (Task is null || !_worker.IsConnected) return;
        using var op = ParkOp.Begin(Loc.T("ops.review.parking"));
        try { await _worker.RejectReviewToIdleAsync(Task.Id); }
        catch { /* stale review action; broadcast reconciles */ }
    }

    private bool CanParkReview() => Task != null && _worker.IsConnected && !ParkOp.IsRunning;

    [RelayCommand]
    private async System.Threading.Tasks.Task ResetReviewAsync()
    {
        if (Task is null || !_worker.IsConnected || ConfirmAsync is null) return;
        var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
        var ok = await ConfirmAsync(
            $"Reset working tree?\nThis discards branch {branchName} (and all changes) and returns the task to Idle.");
        if (!ok) return;
        try { await _worker.ResetTaskAsync(Task.Id); }
        catch { /* stale review action; broadcast reconciles */ }
    }

    // True while a unit merge is actively draining this task's subtasks onto the target
    // branch — set from PlanningMergeStarted, cleared on PlanningMergeAborted/Completed.
    // Mirrors the worker-side guard in TaskStateService.CancelAsync (the real correctness
    // fix); this just keeps the button from inviting a click the worker will reject anyway.
    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(CancelReviewCommand))]
    [NotifyPropertyChangedFor(nameof(ShowMergeDrainHint))]
    private bool _isMergeDraining;

    // Drives the visible hint next to Cancel while a merge drain is in progress, so the
    // silently-disabled button doesn't look broken.
    public bool ShowMergeDrainHint => IsMergeDraining;

    [RelayCommand(CanExecute = nameof(CanCancelReview))]
    private async System.Threading.Tasks.Task CancelReviewAsync()
    {
        if (Task is null || !_worker.IsConnected) return;
        try { await _worker.CancelReviewAsync(Task.Id); }
        catch (Exception ex)
        {
            ErrorReported?.Invoke(ex.Message);
        }
    }

    private bool CanCancelReview() =>
        Task != null && _worker.IsConnected && !IsMergeDraining;

    private async System.Threading.Tasks.Task ReloadAttachmentsAsync()
    {
        if (Task is null) return;
        try
        {
            await using var ctx = await _dbFactory.CreateDbContextAsync();
            var attachments = await new TaskAttachmentRepository(ctx).ListByTaskIdAsync(Task.Id);
            Attachments.Clear();
            foreach (var a in attachments)
                Attachments.Add(new AttachmentRowViewModel { FileName = a.FileName, ByteSize = a.ByteSize });
            OnPropertyChanged(nameof(ComposedPreview));
        }
        catch { /* best-effort */ }
    }

    public async System.Threading.Tasks.Task AddFilesAsync(IReadOnlyList<(string FileName, Stream Content)> files)
    {
        DetailSection = "files";
        if (Task is null || Task.IsRunning)
        {
            DropStatus = Loc.T("details.attachments.selectIdleTask");
            return;
        }

        var store = new AttachmentStore();
        var successes = new List<string>();
        var failures = new List<string>();

        foreach (var (fileName, content) in files)
        {
            try
            {
                var byteSize = await store.SaveAsync(Task.Id, fileName, content);
                await using var ctx = await _dbFactory.CreateDbContextAsync();
                var repo = new TaskAttachmentRepository(ctx);
                var existing = await repo.GetAsync(Task.Id, fileName);
                if (existing is not null)
                {
                    existing.ByteSize = byteSize;
                    await repo.UpdateAsync(existing);
                }
                else
                {
                    await repo.AddAsync(new ClaudeDo.Data.Models.TaskAttachmentEntity
                    {
                        Id = Guid.NewGuid().ToString(),
                        TaskId = Task.Id,
                        FileName = fileName,
                        ByteSize = byteSize,
                        CreatedAt = DateTime.UtcNow,
                    });
                }
                successes.Add(fileName);
            }
            catch (InvalidOperationException ex)
            {
                failures.Add(string.Format(Loc.T("details.attachments.overLimitError"), fileName, ex.Message));
            }
            catch (ArgumentException ex)
            {
                failures.Add(string.Format(Loc.T("details.attachments.invalidNameError"), fileName, ex.Message));
            }
            catch (Exception ex)
            {
                // Keep the exception type in the message so an intermittent failure is analyzable.
                failures.Add($"{fileName}: {ex.GetType().Name}: {ex.Message}");
            }
        }

        await ReloadAttachmentsAsync();

        if (failures.Count == 0)
        {
            var names = string.Join(", ", successes);
            DropStatus = string.Format(Loc.T("details.attachments.addedSummary"), names, successes.Count);
        }
        else if (successes.Count == 0)
        {
            DropStatus = string.Join(" · ", failures);
        }
        else
        {
            var names = string.Join(", ", successes);
            var addedPart = string.Format(Loc.T("details.attachments.addedSummary"), names, successes.Count);
            DropStatus = addedPart + " · " + string.Join(" · ", failures);
        }
    }

    [RelayCommand]
    private async System.Threading.Tasks.Task RemoveAttachment(AttachmentRowViewModel? row)
    {
        if (row is null || Task is null) return;
        try
        {
            new AttachmentStore().DeleteFile(Task.Id, row.FileName);
            await using var ctx = await _dbFactory.CreateDbContextAsync();
            await new TaskAttachmentRepository(ctx).DeleteAsync(Task.Id, row.FileName);
            await ReloadAttachmentsAsync();
        }
        catch { /* best-effort */ }
    }

    internal static (int Additions, int Deletions) ParseDiffStat(string? stat)
    {
        if (string.IsNullOrEmpty(stat)) return (0, 0);
        int add = 0, del = 0;
        var m1 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)\s+insertion");
        var m2 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)\s+deletion");
        if (m1.Success) int.TryParse(m1.Groups[1].Value, out add);
        if (m2.Success) int.TryParse(m2.Groups[1].Value, out del);
        return (add, del);
    }
}

public sealed class AttachmentRowViewModel
{
    public required string FileName { get; init; }
    public required long ByteSize { get; init; }
    public string SizeText => ByteSize switch
    {
        >= 1024 * 1024 => $"{ByteSize / (1024.0 * 1024.0):F1} MB",
        >= 1024 => $"{ByteSize / 1024.0:F1} KB",
        _ => $"{ByteSize} B",
    };
}

public sealed partial class SubtaskRowViewModel : ViewModelBase
{
    public required string Id { get; init; }
    [ObservableProperty] private string _title = "";
    [ObservableProperty] private bool _done;
    [ObservableProperty] private bool _isEditing;
    [ObservableProperty] private ClaudeDo.Data.Models.TaskStatus _status;
    [ObservableProperty] private ClaudeDo.Data.Models.WorktreeState _worktreeState = ClaudeDo.Data.Models.WorktreeState.Active;
}

// A suggested child's outcome on an improvement parent's review card. Observable so the
// row reflects the child's live status (Idle → Running → Done/Failed) as it executes.
public sealed partial class ChildOutcomeRowViewModel : ViewModelBase
{
    public required string Id { get; init; }
    public required string Title { get; init; }

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(StatusLabel))]
    private ClaudeDo.Data.Models.TaskStatus _status;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(HasRoadblock))]
    [NotifyPropertyChangedFor(nameof(RoadblockText))]
    private int _roadblockCount;

    [ObservableProperty]
    private ClaudeDo.Data.Models.WorktreeState _worktreeState = ClaudeDo.Data.Models.WorktreeState.Active;

    public string StatusLabel => Status switch
    {
        ClaudeDo.Data.Models.TaskStatus.Done      => Loc.T("vm.taskStatus.done"),
        ClaudeDo.Data.Models.TaskStatus.Failed    => Loc.T("vm.taskStatus.failed"),
        ClaudeDo.Data.Models.TaskStatus.Cancelled => Loc.T("vm.taskStatus.cancelled"),
        ClaudeDo.Data.Models.TaskStatus.Running   => Loc.T("vm.taskStatus.running"),
        ClaudeDo.Data.Models.TaskStatus.Queued    => Loc.T("vm.taskStatus.queued"),
        ClaudeDo.Data.Models.TaskStatus.WaitingForReview => Loc.T("vm.taskStatus.waitingForReview"),
        ClaudeDo.Data.Models.TaskStatus.WaitingForChildren => Loc.T("vm.taskStatus.waitingForChildren"),
        _ => Loc.T("vm.taskStatus.idle"),
    };

    public bool HasRoadblock => RoadblockCount > 0;
    public string RoadblockText => RoadblockCount == 1 ? "1 roadblock" : $"{RoadblockCount} roadblocks";
}
