using System.Collections.ObjectModel;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Helpers;
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 enum LogKind { Sys, Tool, Claude, Stdout, Stderr, Done, Msg }

public sealed class LogLineViewModel
{
    public required LogKind Kind { get; init; }
    public required string Text { get; init; }
    public string TimestampFormatted { get; } = DateTime.Now.ToString("HH:mm:ss");
    public string KindMarker => Kind switch
    {
        LogKind.Sys    => "sys",
        LogKind.Tool   => "tool",
        LogKind.Claude => "claude",
        LogKind.Stdout => "out",
        LogKind.Stderr => "err",
        LogKind.Done   => "done",
        LogKind.Msg    => "claude",
        _              => "",
    };
    public string ClassName => Kind switch
    {
        LogKind.Sys    => "log-sys",
        LogKind.Tool   => "log-tool",
        LogKind.Claude => "log-claude",
        LogKind.Stdout => "log-stdout",
        LogKind.Stderr => "log-stderr",
        LogKind.Done   => "log-done",
        LogKind.Msg    => "log-msg",
        _              => "",
    };
}

public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
{
    private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
    private readonly IWorkerClient _worker;
    private readonly IServiceProvider _services;
    private readonly INotesApi _notesApi;
    private readonly IMergeCoordinator _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;

    [ObservableProperty] private bool _isNotesMode;
    [ObservableProperty] private bool _isPrepMode;

    public bool IsTaskDetailVisible => !IsNotesMode && !IsPrepMode;

    partial void OnIsNotesModeChanged(bool value) => OnPropertyChanged(nameof(IsTaskDetailVisible));
    partial void OnIsPrepModeChanged(bool value) => OnPropertyChanged(nameof(IsTaskDetailVisible));

    public NotesEditorViewModel Notes { get; private set; } = null!;

    // Current task row (set by IslandsShellViewModel via Bind)
    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
    [NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
    [NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
    [NotifyPropertyChangedFor(nameof(TaskIdBadge))]
    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}";

    public bool ShowRoadblock => IsFailed;
    public string RoadblockMessage =>
        IsFailed ? "The session ended with an error." : "";

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(ShowSessionOutcome))]
    private string? _sessionOutcome;

    public bool ShowSessionOutcome =>
        !string.IsNullOrWhiteSpace(SessionOutcome)
        && (IsWaitingForReview || IsDone || IsFailed || IsCancelled);

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(ShowRoadblockCard))]
    private string? _roadblocks;

    public bool ShowRoadblockCard =>
        !string.IsNullOrWhiteSpace(Roadblocks)
        && (IsWaitingForReview || IsDone || IsFailed || IsCancelled);

    private const string RoadblockMarker = "Roadblocks reported during the run:";

    private void ApplyOutcome(string? result, string? errorFallback)
    {
        if (string.IsNullOrWhiteSpace(result))
        {
            SessionOutcome = errorFallback;
            Roadblocks = null;
            return;
        }

        var idx = result.IndexOf(RoadblockMarker, StringComparison.Ordinal);
        if (idx < 0)
        {
            SessionOutcome = result;
            Roadblocks = null;
            return;
        }

        var summary = result[..idx].TrimEnd().TrimEnd('⚠').TrimEnd();
        SessionOutcome = string.IsNullOrWhiteSpace(summary) ? null : summary;
        Roadblocks = result[(idx + RoadblockMarker.Length)..].Trim();
    }

    public string SessionLabel => "claude-session";

    public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";

    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
    [NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
    [NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
    [NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
    private string _agentState = "idle";
    public string AgentStatusLabel => Loc.T($"vm.agentStatus.{AgentState}");
    public bool IsIdle               => AgentState == "idle";
    public bool IsQueued             => AgentState == "queued";
    public bool IsRunning            => AgentState == "running";
    public bool IsWaitingForReview   => AgentState == "review";
    public bool IsWaitingForChildren => AgentState == "children";
    public bool IsDone               => AgentState == "done";
    public bool IsFailed             => AgentState == "failed";
    public bool IsCancelled          => AgentState == "cancelled";

    public bool ShowContinue => IsFailed || IsCancelled;
    public bool ShowResetAndRetry => IsFailed || IsCancelled || IsDone;

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

    partial void OnAgentStateChanged(string value)
    {
        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));
        AgentSettings.IsRunning = IsRunning;
        NotifySessionSections();
        OnPropertyChanged(nameof(CanAcceptDrop));
    }

    [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 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<LogLineViewModel> Log { get; } = new();
    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 = "";

    // Claude CLI stream-json parser + buffer for partial text deltas
    private readonly StreamLineFormatter _formatter = new();
    private readonly StringBuilder _claudeBuf = new();

    private string? _subscribedTaskId;
    private CancellationTokenSource? _loadCts;

    private bool _suppressDescSave;
    private CancellationTokenSource? _descSaveCts;

    // Set by shell so CloseDetailCommand 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);

    private static string StatusToStateKey(ClaudeDo.Data.Models.TaskStatus status) => status switch
    {
        ClaudeDo.Data.Models.TaskStatus.Queued           => "queued",
        ClaudeDo.Data.Models.TaskStatus.Running          => "running",
        ClaudeDo.Data.Models.TaskStatus.WaitingForReview    => "review",
        ClaudeDo.Data.Models.TaskStatus.WaitingForChildren => "children",
        ClaudeDo.Data.Models.TaskStatus.Done             => "done",
        ClaudeDo.Data.Models.TaskStatus.Failed           => "failed",
        ClaudeDo.Data.Models.TaskStatus.Cancelled        => "cancelled",
        _                                                => "idle",
    };

    private static string FinishedStatusToStateKey(string status) => status switch
    {
        "done"               => "done",
        "failed"             => "failed",
        "cancelled"          => "cancelled",
        "waiting_for_review"    => "review",
        "waiting_for_children" => "children",
        _                    => status.ToLowerInvariant(),
    };

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

            AgentState = StatusToStateKey(entity.Status);
        }
        catch { }
    }

    private async System.Threading.Tasks.Task RefreshOutcomeAsync(string taskId)
    {
        try
        {
            await using var ctx = await _dbFactory.CreateDbContextAsync();
            var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
            var latestRun = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId);
            if (Task?.Id != taskId) return;
            ApplyOutcome(entity?.Result, latestRun?.ErrorMarkdown);
        }
        catch { }
    }

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

        AgentSettings = new AgentConfigEditorViewModel(worker, AgentConfigScope.Task);
        Merge = new MergeSectionViewModel(worker, services);
        Prep = new PrepPanelViewModel(worker);

        Notes = new NotesEditorViewModel(_notesApi);
        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;

        _worker.TaskMessageEvent += OnTaskMessage;

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

        _workerTaskStartedHandler = (slot, taskId, startedAt) =>
        {
            if (Task?.Id == taskId) AgentState = "running";
            _ = RefreshChildOutcomeAsync(taskId);
        };
        _worker.TaskStartedEvent += _workerTaskStartedHandler;

        _workerTaskFinishedHandler = (slot, taskId, status, finishedAt) =>
        {
            if (Task?.Id != taskId) return;
            FlushClaudeBuffer();
            Log.Add(new LogLineViewModel
            {
                Kind = LogKind.Done,
                Text = $"── {status.ToUpperInvariant()} · {finishedAt.ToLocalTime():HH:mm:ss} ──",
            });
            AgentState = FinishedStatusToStateKey(status);
            _ = RefreshWorktreeAsync(taskId);
            _ = RefreshChildOutcomeAsync(taskId);
            _ = RefreshOutcomeAsync(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?.Id == taskId) _ = RefreshStatusAsync(taskId);
            if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
            _ = RefreshChildOutcomeAsync(taskId);
        };
        _worker.TaskUpdatedEvent += _workerTaskUpdatedHandler;

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

    public void Dispose()
    {
        Loc.LanguageChanged -= _langChangedHandler;
        _worker.PropertyChanged -= _workerPropertyChangedHandler;
        _worker.TaskStartedEvent -= _workerTaskStartedHandler;
        _worker.TaskFinishedEvent -= _workerTaskFinishedHandler;
        _worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler;
        _worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler;
        _worker.TaskMessageEvent -= OnTaskMessage;
        AgentSettings.Dispose();
        Prep.Dispose();
    }

    private void OnTaskMessage(string taskId, string line)
    {
        if (taskId != _subscribedTaskId) return;

        if (line.StartsWith("[stdout]", StringComparison.OrdinalIgnoreCase))
        {
            var body = line["[stdout]".Length..].TrimStart();
            AppendStdoutLine(body);
            return;
        }

        FlushClaudeBuffer();

        var kind = line.StartsWith("[sys]", StringComparison.OrdinalIgnoreCase)    ? LogKind.Sys
                 : line.StartsWith("[tool]", StringComparison.OrdinalIgnoreCase)   ? LogKind.Tool
                 : line.StartsWith("[claude]", StringComparison.OrdinalIgnoreCase) ? LogKind.Claude
                 : line.StartsWith("[stderr]", StringComparison.OrdinalIgnoreCase) ? LogKind.Stderr
                 : line.StartsWith("[done]", StringComparison.OrdinalIgnoreCase)   ? LogKind.Done
                 : LogKind.Msg;
        Log.Add(new LogLineViewModel { Kind = kind, Text = line });
    }

    private void AppendStdoutLine(string line)
    {
        var formatted = _formatter.FormatLine(line);
        if (formatted is null) return;
        _claudeBuf.Append(formatted);
        while (true)
        {
            var text = _claudeBuf.ToString();
            var nl = text.IndexOf('\n');
            if (nl < 0) break;
            var piece = text[..nl].TrimEnd('\r');
            if (!string.IsNullOrWhiteSpace(piece))
                Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
            _claudeBuf.Clear();
            _claudeBuf.Append(text[(nl + 1)..]);
        }
    }

    private void FlushClaudeBuffer()
    {
        if (_claudeBuf.Length == 0) return;
        var piece = _claudeBuf.ToString().TrimEnd();
        _claudeBuf.Clear();
        if (!string.IsNullOrWhiteSpace(piece))
            Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
    }

    partial void OnEditableDescriptionChanged(string value)
    {
        if (_suppressDescSave || Task is null) return;
        _descSaveCts?.Cancel();
        _descSaveCts = new CancellationTokenSource();
        _ = SaveDescriptionAsync(_descSaveCts.Token);
    }

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

    public void ShowNotes()
    {
        Bind(null);
        IsPrepMode = false;
        IsNotesMode = true;
        _ = Notes.LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
    }

    public void ShowPrep()
    {
        Bind(null);
        IsNotesMode = false;
        IsPrepMode = true;
        _ = Prep.LoadLastPrepLogIfEmptyAsync();
    }

    public void Bind(TaskRowViewModel? row)
    {
        IsNotesMode = false;
        IsPrepMode = false;
        _loadCts?.Cancel();
        _loadCts?.Dispose();
        _loadCts = new CancellationTokenSource();
        var ct = _loadCts.Token;

        Task = row;
        OnPropertyChanged(nameof(TaskIdBadge));
        Log.Clear();
        Subtasks.Clear();
        ChildOutcomes.Clear();
        Attachments.Clear();
        DropStatus = null;
        OnPropertyChanged(nameof(HasChildOutcomes));
        SessionOutcome = null;
        Roadblocks = null;
        _claudeBuf.Clear();
        Merge.Clear();

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

        _ = BindAsync(row, ct);
    }

    private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct)
    {
        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;

            EditableTitle = entity.Title;
            _suppressDescSave = true;
            try { EditableDescription = entity.Description ?? ""; }
            finally { _suppressDescSave = false; }
            Model = entity.Model;
            _listWorkingDir = entity.List?.WorkingDir;
            WorktreePath = entity.Worktree?.Path;
            WorktreeBaseCommit = entity.Worktree?.BaseCommit;
            WorktreeHeadCommit = entity.Worktree?.HeadCommit;
            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;
            AgentState = StatusToStateKey(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;
            ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown);

            _subscribedTaskId = row.Id;

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

    private async System.Threading.Tasks.Task LoadChildOutcomesAsync(string parentTaskId, CancellationToken ct)
    {
        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();
            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 */ }
    }

    private async System.Threading.Tasks.Task ReplayLogFileAsync(string? logPath, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(logPath)) return;
        var expanded = ExpandUserPath(logPath);
        if (!System.IO.File.Exists(expanded)) return;

        try
        {
            const int maxLines = 2000;
            string[] all;
            await using (var fs = new System.IO.FileStream(
                             expanded,
                             System.IO.FileMode.Open,
                             System.IO.FileAccess.Read,
                             System.IO.FileShare.ReadWrite | System.IO.FileShare.Delete))
            using (var reader = new System.IO.StreamReader(fs))
            {
                var list = new List<string>();
                while (await reader.ReadLineAsync(ct) is { } line)
                    list.Add(line);
                all = list.ToArray();
            }
            ct.ThrowIfCancellationRequested();

            var start = Math.Max(0, all.Length - maxLines);
            for (int i = start; i < all.Length; i++)
            {
                ct.ThrowIfCancellationRequested();
                if (_subscribedTaskId is null) return;
                var line = all[i];
                var normalized = line.StartsWith("[", StringComparison.Ordinal) ? line : "[stdout] " + line;
                OnTaskMessage(_subscribedTaskId, normalized);
            }
            FlushClaudeBuffer();
        }
        catch (OperationCanceledException) { throw; }
        catch { /* best-effort replay */ }
    }

    private static string ExpandUserPath(string path)
    {
        if (path.StartsWith("~/", StringComparison.Ordinal) || path.StartsWith("~\\", StringComparison.Ordinal))
            return System.IO.Path.Combine(
                Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
                path[2..]);
        if (path == "~")
            return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
        return path;
    }

    private async System.Threading.Tasks.Task LoadPlanningChildrenAsync(string parentTaskId, CancellationToken ct)
    {
        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();

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

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

    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;
            WorktreeBaseCommit = entity.Worktree?.BaseCommit;
            WorktreeHeadCommit = entity.Worktree?.HeadCommit;
            WorktreeStateLabel = entity.Worktree?.State.ToString();
            BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
            AgentState = StatusToStateKey(entity.Status);
            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();
    }

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

    partial void OnTaskChanged(TaskRowViewModel? value)
    {
        Merge.SyncTaskContext(Task?.Id, Task?.Title, Task?.IsPlanningParent == true);
        NotifySessionSections();
        OnPropertyChanged(nameof(CanAcceptDrop));
    }

    [RelayCommand]
    private void CloseDetails() => CloseDetail?.Invoke();

    [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;
        Task.Done = !Task.Done;
        await using var ctx = _dbFactory.CreateDbContext();
        var repo = new TaskRepository(ctx);
        var entity = await repo.GetByIdAsync(Task.Id);
        if (entity is null) return;
        entity.Status = Task.Done
            ? ClaudeDo.Data.Models.TaskStatus.Done
            : ClaudeDo.Data.Models.TaskStatus.Idle;
        Task.Status = entity.Status;
        AgentState = StatusToStateKey(entity.Status);
        await repo.UpdateAsync(entity);
    }

    [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]
    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;
        }
        try
        {
            await using var ctx = _dbFactory.CreateDbContext();
            var repo = new TaskRepository(ctx);
            await repo.DeleteAsync(row.Id);
        }
        catch (Microsoft.EntityFrameworkCore.DbUpdateException ex) when (
               ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
            || ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true)
        {
            if (ShowErrorAsync != null)
                await ShowErrorAsync("This task has child tasks. Discard the planning session or delete child tasks first.");
            return;
        }
        if (DeleteFromList != null)
            await DeleteFromList(row);
        CloseDetail?.Invoke();
    }

    [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) return;
        try { await _worker.CancelTaskAsync(Task.Id); }
        catch { /* offline */ }
    }

    [RelayCommand(CanExecute = nameof(CanEnqueue))]
    private async System.Threading.Tasks.Task EnqueueAsync()
    {
        if (Task == null) return;
        try
        {
            await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
            AgentState = "queued";
        }
        catch { /* offline */ }
    }

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

    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(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
        {
            await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
            AgentState = "queued";
        }
        catch { /* offline */ }
    }

    private bool CanResetAndRetry() =>
        Task != null && _worker.IsConnected && ShowResetAndRetry;

    [RelayCommand]
    private async System.Threading.Tasks.Task ApproveReviewAsync()
    {
        if (Task is null || !_worker.IsConnected) return;
        try
        {
            var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0;
            var result = await _worker.ApproveReviewAsync(Task.Id, Merge.SelectedMergeTarget ?? "");
            if (!hasChildren && result?.Status == "conflict")
                await _merge.ResolveConflictAsync(Task.Id, Merge.SelectedMergeTarget ?? "");
        }
        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;
        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]
    private async System.Threading.Tasks.Task ParkReviewAsync()
    {
        if (Task is null || !_worker.IsConnected) return;
        try { await _worker.RejectReviewToIdleAsync(Task.Id); }
        catch { /* stale review action; broadcast reconciles */ }
    }

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

    [RelayCommand]
    private async System.Threading.Tasks.Task CancelReviewAsync()
    {
        if (Task is null || !_worker.IsConnected) return;
        try { await _worker.CancelReviewAsync(Task.Id); }
        catch { /* stale review action; broadcast reconciles */ }
    }

    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)
            {
                failures.Add($"{fileName}: {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";
}
