using Avalonia.Threading;
using System;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;

namespace ClaudeDo.Ui.ViewModels;

public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
{
    public ListsIslandViewModel? Lists { get; }
    public TasksIslandViewModel? Tasks { get; }
    public DetailsIslandViewModel? Details { get; }
    public IWorkerClient? Worker { get; internal set; }
    public MissionControlViewModel? MissionControl { get; }
    public UsagePillViewModel? UsagePill { get; }
    public UpdateCheckService UpdateCheck => _updateCheck;

    // Stable opKey the six Lifecycle/*Recovery startup sweeps broadcast under (see
    // OperationProgressOpKeys.StartupRecovery on the worker side — not shared across the
    // SignalR wire, so the literal is duplicated here on purpose).
    private const string StartupRecoveryOpKey = "startup-recovery";

    // Last startup-recovery text received this connection cycle, shown in place of the generic
    // "connecting" label. Cleared once actually connected so a later transient reconnect (not a
    // fresh worker boot) doesn't replay stale recovery text.
    private string? _startupRecoveryText;

    public string ConnectionText =>
        Worker?.IsConnected == true ? Loc.T("vm.connection.online")
        : Worker?.IsReconnecting == true ? (_startupRecoveryText ?? Loc.T("vm.connection.connecting"))
        : Loc.T("vm.connection.offline");

    public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true;

    // Gate for the footer pill: the help dialog says "WORKER NOT REACHABLE", so it must never open
    // on a connected worker. Not `IsOffline` — the retry loop stays in "connecting" forever while
    // the worker is down, and that's exactly when the dialog's "Start Worker" is needed.
    public bool CanOpenWorkerConnectionHelp => DecideCanOpenConnectionHelp(Worker?.IsConnected == true);

    internal static bool DecideCanOpenConnectionHelp(bool isConnected) => !isConnected;

    private readonly UpdateCheckService _updateCheck = null!;
    private readonly InstallerLocator _installerLocator = null!;
    private readonly WorkerLocator _workerLocator = null!;
    private readonly GitService? _git;
    private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
    private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
    private readonly Func<WeeklyReportModalViewModel> _weeklyReportVmFactory = () => null!;
    private readonly Func<UsageMonitorModalViewModel> _usageMonitorVmFactory = () => null!;
    private readonly Func<MergeModalViewModel> _mergeVmFactory = () => null!;
    private readonly Func<RepoImportModalViewModel>? _repoImportVmFactory;

    public Func<MergeModalViewModel> ResolveMergeVm => _mergeVmFactory;

    // Layer C seam: composition root sets the factory; the dialog service shows the resolver.
    public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }

    // Set by MainWindow so a reveal can bring the main window to the foreground.
    public Action? BringToFront { get; set; }

    // Single dialog seam (set by MainWindow); propagated to the lists island.
    private IDialogService? _dialogs;
    public IDialogService? Dialogs
    {
        get => _dialogs;
        set
        {
            _dialogs = value;
            if (Lists is not null) Lists.Dialogs = value;
            if (Tasks is not null) Tasks.Dialogs = value;
        }
    }

    public async Task RevealTaskAsync(string taskId)
    {
        if (Tasks is null || Lists is null) { BringToFront?.Invoke(); return; }

        string? listId = null;
        if (_dbFactory is not null)
        {
            try
            {
                await using var ctx = await _dbFactory.CreateDbContextAsync();
                var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
                listId = entity?.ListId;
            }
            catch { /* best-effort list resolution */ }
        }

        if (listId is not null)
        {
            var navItem = Lists.Items.FirstOrDefault(i => i.Id == $"user:{listId}");
            if (navItem is not null && !ReferenceEquals(Lists.SelectedList, navItem))
                Lists.SelectedList = navItem; // raises SelectionChanged → Tasks.LoadForList
        }

        await Tasks.SelectByIdAsync(taskId);
        BringToFront?.Invoke();
    }

    public async Task RequestConflictResolutionAsync(string taskId, string targetBranch)
    {
        if (ConflictResolverFactory is null || Dialogs is null) return;
        var vm = ConflictResolverFactory(taskId);
        var hasConflicts = await vm.OpenAsync(targetBranch);
        if (hasConflicts)
            await Dialogs.ShowConflictResolverAsync(vm);
    }

    [ObservableProperty] private bool _isUpdateBannerVisible;
    [ObservableProperty] private string? _updateBannerLatestVersion;
    private bool _bannerDismissedThisSession;

    // Persistent (not auto-clearing) banner: the running worker predates the selected list's
    // merged HEAD, so "verified" claims made against the current process are stale. ClaudeDo-repo
    // only — see RefreshStaleWorkerCheckAsync.
    [ObservableProperty] private bool _isStaleWorkerBannerVisible;

    // planningTaskId -> subtaskId, for unit-merge conflicts an MCP session (not the UI) started.
    // Kept as a dictionary so a later conflict on the same planning task updates in place instead
    // of piling up duplicate entries.
    private readonly Dictionary<string, string> _externalMergeConflicts = new();
    [ObservableProperty] private bool _isExternalMergeBannerVisible;


    [ObservableProperty]
    private double _windowWidth = 1280;

    [ObservableProperty]
    private string? _workerLogText;

    [ObservableProperty]
    private WorkerLogLevel _workerLogLevel;

    [ObservableProperty]
    private bool _isWorkerLogVisible;

    /// <summary>The notes list has nothing to show a detail pane for, so the pane goes away and
    /// the editor takes the space instead of leaving an empty column beside it.</summary>
    public bool ShowDetails => WindowWidth >= 1100 && Tasks?.IsNotesList != true;

    /// <summary>Widens the tasks island over the splitter and the details column when that column
    /// is hidden — a fixed-width ColumnDefinition keeps its space even with an invisible child.</summary>
    public int TasksColumnSpan => ShowDetails ? 1 : 3;

    public bool ShowLists => WindowWidth >= 780;

    private readonly System.Timers.Timer _clearTimer = new(30_000) { AutoReset = false };
    private readonly System.Timers.Timer _connectTimer = new(12_000) { AutoReset = false };

    [ObservableProperty] private string? _primeStatus;
    private readonly System.Timers.Timer _primeStatusTimer = new(5_000) { AutoReset = false };

    [RelayCommand]
    private void FocusSearch() => Lists?.RequestFocusSearch();

    [RelayCommand]
    private void FocusAddTask() => Tasks?.RequestFocusAddTask();

    public async Task ToggleSelectedDoneAsync()
    {
        if (Tasks?.SelectedTask is { } row)
            await Tasks.ToggleDoneCommand.ExecuteAsync(row);
    }

    partial void OnWindowWidthChanged(double value)
    {
        NotifyDetailsVisibility();
        OnPropertyChanged(nameof(ShowLists));
    }

    private void NotifyDetailsVisibility()
    {
        OnPropertyChanged(nameof(ShowDetails));
        OnPropertyChanged(nameof(TasksColumnSpan));
    }

    public void OnWorkerLogReceived(WorkerLogEntry entry)
    {
        var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm");
        WorkerLogText = $"{hhmm} · {entry.Message}";
        WorkerLogLevel = entry.Level;
        IsWorkerLogVisible = true;
        _clearTimer.Stop();
        _clearTimer.Start();
    }

    public void ClearWorkerLog()
    {
        IsWorkerLogVisible = false;
        WorkerLogText = null;
    }

    // Surfaces a UI-originated failure in the footer status strip (same line as the
    // worker log), color-coded as an error and auto-cleared by _clearTimer.
    public void FlashFooterError(string message) => FlashFooter(message, WorkerLogLevel.Error);

    // Same strip, for an action that succeeded and has something to report ("3 new tasks").
    // The strip has always carried a level — routing a success through FlashFooterError would
    // paint a good outcome red, which reads as a failure.
    public void FlashFooterSuccess(string message) => FlashFooter(message, WorkerLogLevel.Success);

    private void FlashFooter(string message, WorkerLogLevel level)
    {
        WorkerLogText = $"{DateTime.Now:HH:mm} · {message}";
        WorkerLogLevel = level;
        IsWorkerLogVisible = true;
        _clearTimer.Stop();
        _clearTimer.Start();
    }

    private void OnPrimeFired(PrimeFiredEvent evt)
    {
        var when = evt.FiredAt.LocalDateTime.ToString("HH:mm");
        PrimeStatus = evt.Success
            ? $"✓ Primed Claude at {when}"
            : $"⚠ Prime failed: {evt.Message}";
        _primeStatusTimer.Stop();
        _primeStatusTimer.Start();
    }

    public void OnPlanningMergeConflict(
        string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles, bool externallyDriven)
    {
        // Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
        if (externallyDriven)
        {
            // An MCP session (review_task/continue_merge) is driving this merge — it owns
            // resolution. Auto-opening the resolver here raced with the session's own writes
            // (two parties editing the same shared checkout at once); show a banner instead and
            // leave the resolver reachable only via a deliberate click.
            _externalMergeConflicts[planningTaskId] = subtaskId;
            IsExternalMergeBannerVisible = true;
            return;
        }

        // A unit-merge conflict resolves in the same in-app 3-way editor as a single-task merge.
        _ = OpenPlanningConflictAsync(planningTaskId, subtaskId);
    }

    public void OnPlanningMergeAborted(string planningTaskId, string? reason = null)
    {
        ClearExternalMergeConflict(planningTaskId);
        // A deliberate abort/conflict pause and a real merge failure both flow through this
        // event; only the latter carries a reason worth surfacing in the footer strip.
        if (!string.IsNullOrWhiteSpace(reason))
            FlashFooterError(reason);
    }
    public void OnPlanningMergeCompleted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);

    // Wired to Worker.OperationProgressEvent; also called directly by tests. A foreign opKey
    // (merge phases, worktree cleanup, ...) is left untouched — this only tracks the
    // startup-recovery channel.
    public void OnOperationProgress(string opKey, string phase, int current, int total)
    {
        if (opKey != StartupRecoveryOpKey) return;
        _startupRecoveryText = total > 0
            ? $"{Loc.T("ops.worker.startupRecovery")} ({current}/{total})"
            : Loc.T("ops.worker.startupRecovery");
        OnPropertyChanged(nameof(ConnectionText));
    }

    private void ClearExternalMergeConflict(string planningTaskId)
    {
        _externalMergeConflicts.Remove(planningTaskId);
        IsExternalMergeBannerVisible = _externalMergeConflicts.Count > 0;
    }

    /// <summary>Re-syncs the external-merge banner from the worker on (re)connect — the
    /// one-shot PlanningMergeConflict broadcast isn't replayed after a UI restart, so this is
    /// the recovery path. The worker checks MERGE_HEAD before reporting a conflict as active,
    /// so a session that died mid-merge without cleaning up doesn't leave a stale banner up.</summary>
    private async Task RefreshExternalMergeConflictsAsync()
    {
        if (Worker is null) return;
        IReadOnlyList<PlanningMergeConflictStateDto> active;
        try { active = await Worker.GetActiveExternalPlanningMergeConflictsAsync(); }
        catch { return; }

        _externalMergeConflicts.Clear();
        foreach (var c in active)
            _externalMergeConflicts[c.PlanningTaskId] = c.SubtaskId;
        IsExternalMergeBannerVisible = _externalMergeConflicts.Count > 0;
    }

    [RelayCommand]
    private Task OpenExternalMergeConflictAsync()
    {
        if (_externalMergeConflicts.Count == 0) return Task.CompletedTask;
        var (planningTaskId, subtaskId) = _externalMergeConflicts.First();
        return OpenPlanningConflictAsync(planningTaskId, subtaskId);
    }

    private async Task OpenPlanningConflictAsync(string planningTaskId, string subtaskId)
    {
        if (ConflictResolverFactory is null || Dialogs is null) return;
        var vm = ConflictResolverFactory(subtaskId);
        var hasConflicts = await vm.OpenForPlanningAsync(planningTaskId, subtaskId);
        if (hasConflicts)
            await Dialogs.ShowConflictResolverAsync(vm);
    }

    // For tests only — does NOT wire up events.
    internal IslandsShellViewModel() { }

    public IslandsShellViewModel(
        ListsIslandViewModel lists,
        TasksIslandViewModel tasks,
        DetailsIslandViewModel details,
        IWorkerClient worker,
        UpdateCheckService updateCheck,
        InstallerLocator installerLocator,
        WorkerLocator workerLocator,
        IDbContextFactory<ClaudeDoDbContext> dbFactory,
        Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory,
        Func<WeeklyReportModalViewModel> weeklyReportVmFactory,
        Func<UsageMonitorModalViewModel> usageMonitorVmFactory,
        Func<MergeModalViewModel> mergeVmFactory,
        Func<RepoImportModalViewModel> repoImportVmFactory,
        MissionControlViewModel missionControl,
        UsagePillViewModel usagePill,
        GitService? git = null)
    {
        Lists = lists; Tasks = tasks; Details = details; Worker = worker;
        MissionControl = missionControl;
        UsagePill = usagePill;
        UsagePill.OpenMonitorRequested += () => _ = OpenUsageMonitor();
        MissionControl.OpenInApp = id => _ = RevealTaskAsync(id);
        MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null);
        MissionControl.ErrorReported += FlashFooterError;
        // Keep the task rows' "Interactive" chip in step with Mission Control's open ConPTY panes.
        MissionControl.ConPtySessions.CollectionChanged += (_, _) => SyncInteractiveSessionChips();
        _updateCheck = updateCheck;
        _installerLocator = installerLocator;
        _workerLocator = workerLocator;
        _dbFactory = dbFactory;
        _worktreesOverviewVmFactory = worktreesOverviewVmFactory;
        _weeklyReportVmFactory = weeklyReportVmFactory;
        _usageMonitorVmFactory = usageMonitorVmFactory;
        _mergeVmFactory = mergeVmFactory;
        _repoImportVmFactory = repoImportVmFactory;
        _git = git;
        Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
        Lists.SelectionChanged += (_, _) => _ = RefreshStaleWorkerCheckAsync();
        Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask, Tasks.SelectionSource);
        Tasks.PrepRequested += () => Details.ShowPrep();
        Tasks.PropertyChanged += (_, e) =>
        {
            if (e.PropertyName == nameof(TasksIslandViewModel.IsNotesList)) NotifyDetailsVisibility();
        };
        Tasks.ErrorReported += FlashFooterError;
        Lists.ErrorReported += FlashFooterError;
        Lists.SuccessReported += FlashFooterSuccess;
        Tasks.OpenConPtySessionRequested += taskId =>
        {
            OpenMissionControl();
            _ = MissionControl.OpenConPtySessionAsync(taskId);
        };
        Tasks.OpenQuickClaudeSessionRequested += dir =>
        {
            OpenMissionControl();
            _ = MissionControl.OpenAdHocConPtySessionAsync(dir);
        };
        Tasks.OpenPlanningConPtyRequested += (taskId, resume) =>
        {
            OpenMissionControl();
            _ = MissionControl.OpenPlanningConPtySessionAsync(taskId, resume);
        };
        Lists.LetClaudeHandleRequested += req =>
        {
            OpenMissionControl();
            _ = MissionControl.OpenMergeHelperConPtySessionAsync(req.ListId, req.TaskIds);
        };
        Lists.MergeReviewsRequested += req =>
        {
            OpenMissionControl();
            _ = MissionControl.OpenMergeReviewConPtySessionAsync(req.ListId, req.TaskIds);
        };
        Tasks.TasksChanged     += (_, _) => _ = Lists.RefreshCountsAsync();
        Tasks.OpenListSettingsRequested += (_, _) =>
        {
            if (Lists.SelectedList is { } row)
                Lists.OpenListSettingsCommand.Execute(row);
        };
        Tasks.LetClaudeHandleRequested += (_, _) =>
        {
            if (Lists.SelectedList is { } row)
                Lists.LetClaudeHandleListCommand.Execute(row);
        };
        Tasks.MergeReviewsRequested += (_, _) => Lists.MergeReviewsCommand.Execute(null);
        Details.ErrorReported += FlashFooterError;
        Details.CloseDetail = () => Tasks.SelectFrom(null, "close-detail");
        Details.DeleteFromList = row =>
        {
            Tasks.LoadForList(Lists.SelectedList);
            _ = Lists.RefreshCountsAsync();
            return System.Threading.Tasks.Task.CompletedTask;
        };
        Worker.PropertyChanged += (_, e) =>
        {
            if (e.PropertyName is nameof(IWorkerClient.IsConnected) or nameof(IWorkerClient.IsReconnecting))
            {
                if (Worker.IsConnected) _startupRecoveryText = null;
                OnPropertyChanged(nameof(ConnectionText));
                OnPropertyChanged(nameof(IsOffline));
                OnPropertyChanged(nameof(CanOpenWorkerConnectionHelp));
            }
        };
        Worker.OperationProgressEvent += OnOperationProgress;
        Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
        Worker.ConnectionRestoredEvent += () => _ = RefreshStaleWorkerCheckAsync();
        Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict;
        Worker.PlanningMergeAbortedEvent += OnPlanningMergeAborted;
        Worker.PlanningCompletedEvent += OnPlanningMergeCompleted;
        Worker.ConnectionRestoredEvent += () => _ = RefreshExternalMergeConflictsAsync();
        Worker.PrimeFired += OnPrimeFired;
        _clearTimer.Elapsed += (_, _) =>
        {
            if (Dispatcher.UIThread.CheckAccess())
                ClearWorkerLog();
            else
                Dispatcher.UIThread.Post(ClearWorkerLog);
        };
        _primeStatusTimer.Elapsed += (_, _) =>
            Avalonia.Threading.Dispatcher.UIThread.Post(() => PrimeStatus = null);
        _connectTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(() =>
        {
            if (DecideShowConnectionPrompt(IsOffline)) _ = OpenWorkerConnectionHelpAsync();
        });
        _connectTimer.Start();
        _ = Lists.LoadAsync();
        _updateCheck.PropertyChanged += (_, e) =>
        {
            if (e.PropertyName == nameof(UpdateCheckService.LastCheckStatus))
            {
                RefreshBannerFromStatus();
            }
        };
        _updateCheck.Op.PropertyChanged += (_, e) =>
        {
            if (e.PropertyName == nameof(OperationStatus.IsRunning)) CheckForUpdatesCommand.NotifyCanExecuteChanged();
        };
        // Fire-and-forget startup check — never block UI. Deliberately *not* Task.Run: the check is
        // pure async I/O (no sync blocking), and `OperationStatus` writes its observable properties
        // on the calling thread. From a threadpool thread the final `ShowIndicator=false` never
        // reaches the binding, so the indicator froze on the last tick ("Checking… 00:03").
        _ = StartupUpdateCheckAsync();
    }

    private async Task StartupUpdateCheckAsync()
    {
        try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { }
    }

    public void Dispose()
    {
        if (Worker is not null) Worker.OperationProgressEvent -= OnOperationProgress;
        _clearTimer.Stop();
        _clearTimer.Dispose();
        _connectTimer.Stop();
        _connectTimer.Dispose();
        _primeStatusTimer.Stop();
        _primeStatusTimer.Dispose();
        _staleWorkerCts?.Cancel();
        _staleWorkerCts?.Dispose();
    }

    private void RefreshBannerFromStatus()
    {
        switch (_updateCheck.LastCheckStatus)
        {
            case UpdateCheckStatus.UpdateAvailable:
                if (_bannerDismissedThisSession) { IsUpdateBannerVisible = false; break; }
                UpdateBannerLatestVersion = _updateCheck.LatestVersion;
                IsUpdateBannerVisible = true;
                break;
            case UpdateCheckStatus.UpToDate:
                IsUpdateBannerVisible = false;
                break;
            case UpdateCheckStatus.CheckFailed:
                break;
        }
    }

    private CancellationTokenSource? _staleWorkerCts;

    // Re-evaluates the stale-worker banner for the currently selected list. Cheap (one hub call +
    // up to two git subprocesses) and only ever runs for a git-backed list, so it's fine to fire on
    // every selection change / reconnect rather than caching.
    private async Task RefreshStaleWorkerCheckAsync()
    {
        _staleWorkerCts?.Cancel();
        var cts = new CancellationTokenSource();
        _staleWorkerCts = cts;

        var stale = await ComputeIsStaleWorkerAsync(Lists?.SelectedList?.WorkingDir, cts.Token);
        if (cts.IsCancellationRequested) return;
        IsStaleWorkerBannerVisible = stale;
    }

    private async Task<bool> ComputeIsStaleWorkerAsync(string? workingDir, CancellationToken ct)
    {
        if (_git is null || Worker is null || string.IsNullOrWhiteSpace(workingDir)) return false;

        try
        {
            var buildInfo = await Worker.GetWorkerBuildInfoAsync();
            var buildSha = buildInfo?.BuildSha;
            if (string.IsNullOrWhiteSpace(buildSha)) return false;
            if (!await _git.IsGitRepoAsync(workingDir, ct)) return false;

            var head = await _git.RevParseHeadAsync(workingDir, ct);
            var isAncestor = string.Equals(buildSha, head, StringComparison.OrdinalIgnoreCase)
                ? (bool?)false // equal — never "stale" on a match, and no need to ask git
                : await _git.IsAncestorAsync(workingDir, buildSha, head, ct);
            return ShouldShowStaleWorkerBanner(buildSha, head, isAncestor);
        }
        catch
        {
            // Worker offline, dir no longer a repo, etc. — unknown, so stay quiet.
            return false;
        }
    }

    // Pure decision extracted for testability. isAncestor is the tri-state result of
    // `git merge-base --is-ancestor buildSha head`: true = worker predates head (stale), false =
    // equal or diverged (never claim "stale" on a match or an unrelated history), null = unknown
    // (e.g. buildSha isn't a commit this repo knows about — never treat "unknown" as "stale").
    internal static bool ShouldShowStaleWorkerBanner(string? buildSha, string? headSha, bool? isAncestor)
    {
        if (string.IsNullOrWhiteSpace(buildSha) || string.IsNullOrWhiteSpace(headSha)) return false;
        if (string.Equals(buildSha, headSha, StringComparison.OrdinalIgnoreCase)) return false;
        return isAncestor == true;
    }

    [RelayCommand]
    private void OpenMissionControl()
    {
        if (Dialogs is not null && MissionControl is not null)
            Dialogs.ShowMissionControl(MissionControl);
    }

    /// <summary>Number of Mission Control ConPTY panes currently open — the main window's
    /// close guard asks for confirmation whenever this is greater than zero.</summary>
    public int OpenMissionControlSessionCount => MissionControl?.ConPtySessions.Count ?? 0;

    // Pure decision extracted for testability — closing the main window kills every open
    // Mission Control pane's `claude` process (ConPtyPaneViewModel.Dispose), so ask first
    // rather than let it happen silently.
    internal static bool RequiresCloseConfirmation(int openMissionControlSessionCount) =>
        openMissionControlSessionCount > 0;

    private void SyncInteractiveSessionChips()
    {
        if (MissionControl is null || Tasks is null) return;
        Tasks.SyncInteractiveSessions(
            MissionControl.ConPtySessions
                .Select(s => s.TaskId)
                .Where(id => !string.IsNullOrEmpty(id))
                .Select(id => id!));
    }

    [RelayCommand]
    private async Task OpenAbout()
    {
        var vm = new AboutModalViewModel();
        vm.ErrorReported += FlashFooterError;
        if (Dialogs is not null) await Dialogs.ShowAboutAsync(vm);
    }

    [RelayCommand]
    private async Task OpenLogVisualizer()
    {
        if (Dialogs is null || Worker is null) return;
        var vm = new LogVisualizerViewModel(Worker);
        await vm.RefreshAsync();
        await Dialogs.ShowLogVisualizerAsync(vm);
    }

    private bool _connectionPromptShown;

    internal bool DecideShowConnectionPrompt(bool isOffline)
    {
        if (!isOffline) return false;
        if (_connectionPromptShown) return false;
        _connectionPromptShown = true;
        return true;
    }

    private async Task OpenWorkerConnectionHelpAsync()
    {
        var vm = new WorkerConnectionModalViewModel(_workerLocator, _installerLocator);
        if (Dialogs is not null) await Dialogs.ShowWorkerConnectionAsync(vm);
    }

    // The gate lives in the body, not in CanExecute: the footer pill *is* this button, so a
    // disabled command renders the "ONLINE" chip greyed out — which reads as a broken connection,
    // the opposite of what it says.
    [RelayCommand]
    private Task OpenWorkerConnectionHelp() =>
        CanOpenWorkerConnectionHelp ? OpenWorkerConnectionHelpAsync() : Task.CompletedTask;

    [RelayCommand]
    private async Task OpenRepoImport()
    {
        if (Dialogs is null || _repoImportVmFactory is null) return;
        var vm = _repoImportVmFactory();
        await vm.LoadAsync();
        await Dialogs.ShowRepoImportAsync(vm);
        if (Lists is not null) await Lists.LoadAsync();
    }

    private bool _worktreesOverviewOpen;

    [RelayCommand]
    private async Task OpenWorktreesOverviewGlobalAsync()
    {
        if (Dialogs is null || _worktreesOverviewOpen) return;
        _worktreesOverviewOpen = true;
        try
        {
            var vm = _worktreesOverviewVmFactory();
            vm.ErrorReported += FlashFooterError;
            vm.Configure(null, null);
            await vm.LoadAsync();
            await Dialogs.ShowWorktreesOverviewAsync(vm);
        }
        finally { _worktreesOverviewOpen = false; }
    }

    private bool _weeklyReportOpen;

    [RelayCommand]
    private async Task OpenWeeklyReport()
    {
        if (Dialogs is null || _weeklyReportOpen) return;
        _weeklyReportOpen = true;
        try
        {
            var vm = _weeklyReportVmFactory();
            await vm.InitializeAsync();
            await Dialogs.ShowWeeklyReportAsync(vm);
        }
        finally { _weeklyReportOpen = false; }
    }

    private bool _usageMonitorOpen;

    [RelayCommand]
    private async Task OpenUsageMonitor()
    {
        if (Dialogs is null || _usageMonitorOpen) return;
        _usageMonitorOpen = true;
        try
        {
            var vm = _usageMonitorVmFactory();
            vm.ErrorReported += FlashFooterError;
            // Show first, load after: the initial transcript scan takes seconds, and awaiting it
            // here left the pill looking unresponsive until the window finally appeared.
            vm.BeginLoad();
            await Dialogs.ShowUsageMonitorAsync(vm);
        }
        finally { _usageMonitorOpen = false; }
    }

    private bool CanCheckForUpdates() => !_updateCheck.Op.IsRunning;

    [RelayCommand(CanExecute = nameof(CanCheckForUpdates))]
    private async Task CheckForUpdatesAsync()
    {
        await _updateCheck.CheckNowAsync(CancellationToken.None);
    }

    [ObservableProperty] private string? _restartWorkerStatus;

    [RelayCommand]
    private async Task RestartWorkerAsync()
    {
        RestartWorkerStatus = Loc.T("vm.shell.restartingWorker");
        try
        {
            await Task.Run(RestartWorkerService);
            await FlashRestartStatusAsync("Worker restarted.");
        }
        catch (Exception ex)
        {
            await FlashRestartStatusAsync($"Restart failed: {ex.Message}");
        }
    }

    private void RestartWorkerService()
    {
        var exe = _workerLocator.Find();
        if (exe is null) throw new InvalidOperationException("Worker executable not found.");

        // Only kill the worker belonging to THIS installation — not any other
        // ClaudeDo.Worker on the machine (e.g. a second install).
        var exeFull = System.IO.Path.GetFullPath(exe);
        foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
        {
            try
            {
                var path = p.MainModule?.FileName;
                if (path is not null &&
                    !string.Equals(System.IO.Path.GetFullPath(path), exeFull, StringComparison.OrdinalIgnoreCase))
                    continue;
                p.Kill(entireProcessTree: true);
                p.WaitForExit(10000);
            }
            catch { /* may have exited or be inaccessible */ }
            finally { p.Dispose(); }
        }
        System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
    }

    private async Task FlashRestartStatusAsync(string text)
    {
        RestartWorkerStatus = text;
        await Task.Delay(3000);
        if (RestartWorkerStatus == text) RestartWorkerStatus = null;
    }

    [RelayCommand]
    private void DismissBanner()
    {
        _bannerDismissedThisSession = true;
        IsUpdateBannerVisible = false;
    }

    [RelayCommand]
    private void UpdateNow()
    {
        var path = _installerLocator.Find();
        if (path is null) return;

        try
        {
            // WorkingDirectory must NOT stay empty: the child would inherit ours (<InstallDir>\app)
            // and its locked current directory blocks the installer's own app\ rename.
            System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path)
            {
                UseShellExecute = true,
                WorkingDirectory = System.IO.Path.GetTempPath(),
            });
            Environment.Exit(0);
        }
        catch
        {
            // Intentionally silent — if this fails there's nothing useful to show.
        }
    }
}
