using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.IO;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.MissionControl;
using Microsoft.EntityFrameworkCore;

namespace ClaudeDo.Ui.ViewModels;

public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
{
    private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
    private readonly IWorkerClient _worker;
    private readonly Action<string, string, DateTime> _onTaskStarted;
    private readonly Action<string, string, string, DateTime> _onTaskFinished;
    private readonly Action<string> _onTaskUpdated;
    private readonly Action _onConnectionRestored;
    private readonly Action<string, IReadOnlyList<string>, string> _onHandoffRequested;

    // Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the
    // review/merge/status machinery.
    public ObservableCollection<ConPtyPaneViewModel> ConPtySessions { get; } = new();

    // Claims a TaskId/listId for the duration of an Open*ConPtySessionAsync call so a second,
    // overlapping invocation for the same key (e.g. a double-click) bails out instead of racing
    // past the ConPtySessions dedupe check before the first call has added its pane. Checked and
    // claimed synchronously at method entry — before any await — and released in a finally.
    private readonly HashSet<string> _pendingTaskOpens = new();
    private readonly HashSet<string> _pendingMergeHelperLists = new();

    // Mirror of ConPtySessions typed as the pane abstraction so the layout toggle (grid/tabs)
    // binds one contract rather than a ConPTY-specific type.
    public ObservableCollection<ConPtyPaneViewModel> Panes { get; } = new();

    [ObservableProperty] private int _columnCount = 1;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(LayoutToggleLabel))]
    private bool _isFocusMode;

    [ObservableProperty] private ConPtyPaneViewModel? _focusedPane;

    public string LayoutToggleLabel => Loc.T(IsFocusMode ? "missionControl.overviewMode" : "missionControl.focusMode");

    /// <summary>Surfaces a Command Center failure (e.g. a ConPTY launch spec fetch) — the shell
    /// wires this into the footer error strip, same as the island view models' ErrorReported.</summary>
    public event Action<string>? ErrorReported;

    public Action<string>? OpenInApp { get; set; }

    // View-layer seam: open the app Settings modal from the Mission Control window.
    public Action? OpenSettingsRequested { get; set; }

    public bool HasPanes => Panes.Count > 0;

    // Read-only activity strip. Beyond the queue itself (Queued/Running) it also carries the rows
    // that want the user's attention (Failed, WaitingForReview, roadblocked) — colour-coded only;
    // clicking a row opens the task in the main app, where the actual actions live.
    public ObservableCollection<QueuedTaskViewModel> Queued { get; } = new();
    public bool HasQueued => Queued.Count > 0;

    // "New session" targets: one entry per list with a working dir, plus a browse fallback, so a
    // fresh session is two clicks instead of navigating a folder picker.
    public ObservableCollection<SessionTargetViewModel> SessionTargets { get; } = new();

    // View-layer seam: the folder picker needs a TopLevel, so the view owns it (see
    // MissionControlView.OnNewSessionClicked).
    public Action? BrowseForSessionRequested { get; set; }

    public UsagePillViewModel UsagePill { get; }

    public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill)
    {
        _dbFactory = dbFactory;
        _worker = worker;
        UsagePill = usagePill;

        ConPtySessions.CollectionChanged += OnConPtySessionsChanged;
        Panes.CollectionChanged += OnPanesChanged;

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

        _onTaskFinished = (slot, taskId, status, finishedAt) => _ = RefreshQueueAsync();
        _worker.TaskFinishedEvent += _onTaskFinished;

        _onTaskUpdated = taskId => _ = RefreshQueueAsync();
        _worker.TaskUpdatedEvent += _onTaskUpdated;

        _onConnectionRestored = () => { _ = RefreshQueueAsync(); };
        _worker.ConnectionRestoredEvent += _onConnectionRestored;

        _onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); };
        _worker.HandoffRequestedEvent += _onHandoffRequested;

        _ = RefreshQueueAsync();
        _ = RefreshSessionTargetsAsync();
    }

    internal async System.Threading.Tasks.Task RefreshQueueAsync()
    {
        try
        {
            await using var ctx = await _dbFactory.CreateDbContextAsync();
            var rows = await ctx.Tasks.AsNoTracking()
                .Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued
                         || t.Status == ClaudeDo.Data.Models.TaskStatus.Running
                         || t.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview
                         || t.Status == ClaudeDo.Data.Models.TaskStatus.Failed)
                // Attention first (failed, then review), then the queue itself.
                .OrderBy(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Failed ? 0
                            : t.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview ? 1
                            : t.Status == ClaudeDo.Data.Models.TaskStatus.Running ? 2 : 3)
                .ThenBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
                .Select(t => new { t.Id, t.Title, t.BlockedByTaskId, t.Status, t.RoadblockCount })
                .ToListAsync();

            Queued.Clear();
            foreach (var r in rows)
            {
                var id = r.Id;
                var running = r.Status == ClaudeDo.Data.Models.TaskStatus.Running;
                var (tint, colorKey, labelKey) = Decorate(r.Status, r.RoadblockCount, r.BlockedByTaskId != null);
                Queued.Add(new QueuedTaskViewModel
                {
                    Id = id,
                    Title = r.Title ?? string.Empty,
                    IsRunning = running,
                    TintKey = tint,
                    StatusColorKey = colorKey,
                    StatusLabel = labelKey is null ? null : Loc.T(labelKey),
                    OpenInAppCommand = new RelayCommand(() => OpenInApp?.Invoke(id)),
                    OpenSessionCommand = new RelayCommand(() => _ = OpenConPtySessionAsync(id)),
                });
            }
            OnPropertyChanged(nameof(HasQueued));
        }
        catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
    }

    // Row tint + status label for the activity strip. Keys are resolved to brushes by
    // DotBrushConverter ("<key>Brush"), so they must match tokens in Tokens.axaml.
    private static (string? Tint, string? ColorKey, string? LabelKey) Decorate(
        ClaudeDo.Data.Models.TaskStatus status, int roadblockCount, bool blocked)
        => status switch
        {
            ClaudeDo.Data.Models.TaskStatus.Failed => ("ErrorTint", "StatusError", "missionControl.failed"),
            ClaudeDo.Data.Models.TaskStatus.WaitingForReview when roadblockCount > 0
                => ("RoadblockTint", "Amber", "missionControl.roadblock"),
            ClaudeDo.Data.Models.TaskStatus.WaitingForReview => ("DoneTint", "StatusReview", "missionControl.review"),
            ClaudeDo.Data.Models.TaskStatus.Running => ("RunningTint", "StatusRunning", "missionControl.running"),
            _ when blocked => (null, "Amber", "missionControl.blocked"),
            _ => (null, null, null),
        };

    // Rebuilt on every flyout open — lists change rarely, but a stale target would launch a
    // session in a directory that is no longer configured.
    internal async System.Threading.Tasks.Task RefreshSessionTargetsAsync()
    {
        SessionTargets.Clear();
        // Browse goes in first so the menu stays usable even if the list query below fails.
        SessionTargets.Add(new SessionTargetViewModel
        {
            Label = Loc.T("missionControl.browseForFolder"),
            StartCommand = new RelayCommand(() => BrowseForSessionRequested?.Invoke()),
        });
        try
        {
            await using var ctx = await _dbFactory.CreateDbContextAsync();
            var lists = await ctx.Lists.AsNoTracking()
                .Where(l => l.WorkingDir != null && l.WorkingDir != "")
                .OrderBy(l => l.SortOrder).ThenBy(l => l.Name)
                .Select(l => new { l.Name, l.WorkingDir })
                .ToListAsync();

            foreach (var l in lists)
            {
                var dir = l.WorkingDir!;
                SessionTargets.Add(new SessionTargetViewModel
                {
                    Label = l.Name,
                    StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(dir)),
                });
            }
        }
        catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
    }

    // Drop-to-queue: a task dragged from the main app onto Mission Control gets queued. Goes
    // through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write so the
    // manual/draft-child guards apply here too. The interactive-session check has to live here
    // rather than on the worker side: the worker never touches task status for a UI-hosted ConPTY
    // session, so it has no way to know one is open — only Mission Control's own pane list does.
    public async System.Threading.Tasks.Task EnqueueTaskAsync(string taskId)
    {
        if (string.IsNullOrEmpty(taskId)) return;
        if (ConPtySessions.Any(s => s.TaskId == taskId))
        {
            ErrorReported?.Invoke(Loc.T("missionControl.enqueueAlreadyOpen"));
            return;
        }
        try
        {
            var baseDirty = await _worker.SetTaskStatusAsync(taskId, ClaudeDo.Data.Models.TaskStatus.Queued);
            if (baseDirty is not null)
                ErrorReported?.Invoke(Loc.T(
                    "vm.queue.baseDirtyWarning", baseDirty.ModifiedCount, baseDirty.UntrackedCount));
        }
        catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.enqueueFailed", ex.Message)); }
        await RefreshQueueAsync();
    }

    [RelayCommand]
    private void OpenSettings() => OpenSettingsRequested?.Invoke();

    [RelayCommand]
    private void ToggleLayout() => IsFocusMode = !IsFocusMode;

    // Fetches the launch spec for a task's worktree and hosts an embedded ConPTY session as a
    // Command Center pane (task-based only).
    public async System.Threading.Tasks.Task OpenConPtySessionAsync(string taskId)
    {
        if (string.IsNullOrEmpty(taskId)) return;
        // One pane per TaskId is an invariant -- a merge-helper handoff closes its outgoing pane
        // before opening the next phase's (see OpenMergeHelperHandoffConPtySessionAsync).
        if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
        {
            FocusedPane = existing;
            return;
        }
        if (!_pendingTaskOpens.Add(taskId)) return;

        try
        {
            var title = taskId;
            try
            {
                await using var ctx = await _dbFactory.CreateDbContextAsync();
                var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
                if (entity?.Title is { Length: > 0 } t) title = t;
            }
            catch { /* best-effort title lookup */ }

            AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
                () => DescribeAsync(() => _worker.GetInteractiveLaunchSpecAsync(taskId))));
        }
        finally
        {
            _pendingTaskOpens.Remove(taskId);
        }
    }

    // Starts (or resumes) a planning session and hosts it as an embedded ConPTY Command Center
    // pane — the ConPTY replacement for the old external wt planning window. Deduped by TaskId
    // like OpenConPtySessionAsync.
    public async System.Threading.Tasks.Task OpenPlanningConPtySessionAsync(string taskId, bool resume)
    {
        if (string.IsNullOrEmpty(taskId)) return;
        if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
        {
            FocusedPane = existing;
            return;
        }
        if (!_pendingTaskOpens.Add(taskId)) return;

        try
        {
            var title = taskId;
            try
            {
                await using var ctx = await _dbFactory.CreateDbContextAsync();
                var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
                if (entity?.Title is { Length: > 0 } t) title = t;
            }
            catch { /* best-effort title lookup */ }
            title += Loc.T("missionControl.planningTitleSuffix");

            AddConPtyPane(new ConPtyPaneViewModel(taskId, title, () => DescribeAsync(() => resume
                ? _worker.GetPlanningResumeLaunchSpecAsync(taskId)
                : _worker.GetPlanningStartLaunchSpecAsync(taskId))));
        }
        finally
        {
            _pendingTaskOpens.Remove(taskId);
        }
    }

    // Ad-hoc (task-less) ConPTY session in a user-chosen directory. Never deduped — every call
    // opens a fresh pane, unlike the task-based OpenConPtySessionAsync above.
    public System.Threading.Tasks.Task OpenAdHocConPtySessionAsync(string directory)
    {
        if (string.IsNullOrEmpty(directory)) return System.Threading.Tasks.Task.CompletedTask;

        var title = Path.GetFileName(directory.TrimEnd('\\', '/'));
        if (string.IsNullOrEmpty(title)) title = directory;

        AddConPtyPane(ConPtyPaneViewModel.CreateAdHoc(title,
            () => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory))));
        return System.Threading.Tasks.Task.CompletedTask;
    }

    // List-handler session over a hand-picked set of tasks ("Let Claude handle it"). Task-based:
    // creates one new ClaudeDo task per run to own the session (title/diff/result), deduped by
    // TaskId like OpenConPtySessionAsync. The handler still merges the tasks it handles itself
    // (no worktree of its own) — see TaskEntity.HandlerBaseCommit/HandlerHeadCommit.
    public System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
        => OpenListHandlerConPtySessionAsync(listId, taskIds,
            "missionControl.mergeHelperTitle", "missionControl.mergeHelperTaskTitle",
            taskId => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId, taskId));

    // Merge-only list-handler session over a list's WaitingForReview tasks (the review list's
    // "Let Claude merge the reviews"). Same host-task shape as the full run, but the session
    // starts directly at the merge phase via the existing handoff spec — no triage/wait first.
    public System.Threading.Tasks.Task OpenMergeReviewConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
        => OpenListHandlerConPtySessionAsync(listId, taskIds,
            "missionControl.mergeReviewsTitle", "missionControl.mergeReviewsTaskTitle",
            taskId => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, taskIds, "merge"));

    private async System.Threading.Tasks.Task OpenListHandlerConPtySessionAsync(
        string listId, IReadOnlyList<string> taskIds, string titleKey, string taskTitleKey,
        Func<string, System.Threading.Tasks.Task<LaunchSpec>> launchSpec)
    {
        if (taskIds is not { Count: > 0 }) return;
        // The TaskId dedupe below can't help here — CreateMergeHelperTaskAsync mints a brand-new
        // task id every call, so a double-trigger for the same list would always mint two host
        // tasks before either pane exists to dedupe against. Guard the whole method per listId.
        if (!_pendingMergeHelperLists.Add(listId)) return;

        try
        {
            var title = Loc.T(titleKey);
            var listName = listId;
            try
            {
                await using var ctx = await _dbFactory.CreateDbContextAsync();
                var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
                if (list?.Name is { Length: > 0 } name) { listName = name; title = $"{title} — {name}"; }
            }
            catch { /* best-effort title lookup */ }

            string taskId;
            try
            {
                taskId = await _worker.CreateMergeHelperTaskAsync(taskIds, listId,
                    Loc.T(taskTitleKey, listName),
                    Loc.T("missionControl.mergeHelperTaskDescriptionHeader"));
            }
            catch (Exception ex)
            {
                ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
                return;
            }

            if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
            {
                FocusedPane = existing;
                return;
            }

            AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
                () => DescribeAsync(() => launchSpec(taskId))));
        }
        finally
        {
            _pendingMergeHelperLists.Remove(listId);
        }
    }

    // List-handler handoff: the running session called handoff_list_handler at the end of a phase.
    // Opens a NEW tile for the SAME handler task id to carry out nextPhase. Each tile is a live
    // `claude` process with the full mcp__claudedo__* surface, so the outgoing phase's pane is
    // closed first -- it was only ever told to end its turn, never to exit, and a multi-phase run
    // would otherwise leave one live process per phase behind. This keeps the one-pane-per-TaskId
    // invariant intact, so OpenConPtySessionAsync's dedupe and OnPaneSubmitForReview can keep using
    // FirstOrDefault. No new task is created here; see
    // InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync.
    public async System.Threading.Tasks.Task OpenMergeHelperHandoffConPtySessionAsync(
        string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase)
    {
        if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return;

        foreach (var stale in ConPtySessions.Where(s => s.TaskId == taskId).ToList())
            CloseConPtySession(stale);

        var baseTitle = Loc.T("missionControl.mergeHelperTitle");
        var title = baseTitle + Loc.T("missionControl.mergeHelperHandoffTitleSuffix");
        try
        {
            await using var ctx = await _dbFactory.CreateDbContextAsync();
            var task = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
            if (task is not null)
            {
                var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == task.ListId);
                if (list?.Name is { Length: > 0 } name)
                    title = $"{baseTitle} — {name}{Loc.T("missionControl.mergeHelperHandoffTitleSuffix")}";
            }
        }
        catch { /* best-effort title lookup */ }

        AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
            () => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase))));
    }

    // Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec,
    // so the tile is on screen (spinner running) while the worker is still preparing the worktree.
    private void AddConPtyPane(ConPtyPaneViewModel pane)
    {
        pane.ErrorReported += OnConPtyPaneError;
        pane.CloseRequested += CloseConPtySession;
        pane.SubmitForReviewRequested += OnPaneSubmitForReview;
        ConPtySessions.Add(pane);
        pane.Start();
    }

    private static async System.Threading.Tasks.Task<TerminalLaunchDescriptor> DescribeAsync(
        Func<System.Threading.Tasks.Task<LaunchSpec>> fetch)
    {
        var spec = await fetch();
        return new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
    }

    private void OnConPtyPaneError(string message)
        => ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", message));

    // Submit a task's hand-driven ConPTY work for review, then close the pane (the interactive
    // session is finished). The worker commits the worktree and moves the task to WaitingForReview.
    // Guarded by the pane's IsSubmitPending flag — a rapid double-click would otherwise race two
    // SubmitTaskForReviewAsync calls, with the loser flashing a spurious footer error.
    private async void OnPaneSubmitForReview(string taskId)
    {
        // See OpenConPtySessionAsync -- one pane per TaskId is an invariant, so FirstOrDefault is
        // always the current/active pane for this task.
        if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane || pane.IsSubmitPending)
            return;

        pane.IsSubmitPending = true;
        try
        {
            await _worker.SubmitTaskForReviewAsync(taskId);
            CloseConPtySession(pane);
        }
        catch (Exception ex)
        {
            pane.IsSubmitPending = false;
            ErrorReported?.Invoke(Loc.T("missionControl.submitForReviewFailed", ex.Message));
        }
    }

    private void CloseConPtySession(ConPtyPaneViewModel pane)
    {
        if (!ConPtySessions.Contains(pane)) return;
        pane.ErrorReported -= OnConPtyPaneError;
        pane.CloseRequested -= CloseConPtySession;
        pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
        ConPtySessions.Remove(pane);
        pane.Dispose();
    }

    // Mirrors ConPtySessions' add/remove 1:1 into Panes (typed as the pane abstraction).
    private void OnConPtySessionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
    {
        switch (e.Action)
        {
            case NotifyCollectionChangedAction.Add:
                Panes.Insert(e.NewStartingIndex, (ConPtyPaneViewModel)e.NewItems![0]!);
                break;
            case NotifyCollectionChangedAction.Remove:
                Panes.RemoveAt(e.OldStartingIndex);
                break;
            default: // Reset
                Panes.Clear();
                break;
        }
    }

    private void OnPanesChanged(object? sender, NotifyCollectionChangedEventArgs e)
    {
        ColumnCount = Panes.Count switch
        {
            <= 1 => 1,
            <= 4 => 2,
            _    => 3,
        };
        OnPropertyChanged(nameof(HasPanes));
        if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems?[0] is ConPtyPaneViewModel added)
            FocusedPane = added;
        else if (e.Action == NotifyCollectionChangedAction.Remove && ReferenceEquals(FocusedPane, e.OldItems?[0]))
            FocusedPane = Panes.LastOrDefault();
    }

    public void Dispose()
    {
        _worker.TaskStartedEvent -= _onTaskStarted;
        _worker.TaskFinishedEvent -= _onTaskFinished;
        _worker.TaskUpdatedEvent -= _onTaskUpdated;
        _worker.ConnectionRestoredEvent -= _onConnectionRestored;
        _worker.HandoffRequestedEvent -= _onHandoffRequested;
        ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
        Panes.CollectionChanged -= OnPanesChanged;
        foreach (var c in ConPtySessions.ToList())
        {
            c.ErrorReported -= OnConPtyPaneError;
            c.CloseRequested -= CloseConPtySession;
            c.SubmitForReviewRequested -= OnPaneSubmitForReview;
            c.Dispose();
        }
        ConPtySessions.Clear();
        Panes.Clear();
    }
}

/// <summary>Display row in the Mission Control activity strip (queued, running, or wanting
/// attention). Read-only apart from opening the task in the app / in a session.</summary>
public sealed class QueuedTaskViewModel
{
    public required string Id { get; init; }
    public required string Title { get; init; }
    public bool IsRunning { get; init; }
    /// <summary>Brush key of the row tint (e.g. "RunningTint"); null = untinted.</summary>
    public string? TintKey { get; init; }
    public string? TintBorderKey => TintKey is null ? null : TintKey + "Border";
    public string? StatusLabel { get; init; }
    public string? StatusColorKey { get; init; }
    /// <summary>A running task's worktree belongs to the autonomous run — never open a second,
    /// interactive claude process into it.</summary>
    public bool CanOpenSession => !IsRunning;
    public IRelayCommand? OpenInAppCommand { get; init; }
    public IRelayCommand? OpenSessionCommand { get; init; }
}

/// <summary>One entry in the "new session" menu: a configured list's working dir, or browse.</summary>
public sealed class SessionTargetViewModel
{
    public required string Label { get; init; }
    public required IRelayCommand StartCommand { get; init; }
}
