using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Security.Authentication;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Api;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Import;
using DodoSSH.Client.ObjectStore;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;

namespace DodoSSH.Client.Shell.ViewModels;

/// <summary>Which of the shell's mutually exclusive screens is showing.</summary>
internal enum ShellState
{
    /// <summary>Reading the cache to find out whether this machine is enrolled.</summary>
    Starting = 0,

    /// <summary>Nothing is cached. The user has to name a server and sign in, which needs a network.</summary>
    NeedsServer = 1,

    /// <summary>Signed in, but the account has no vault key yet.</summary>
    NeedsEnrollment = 2,

    /// <summary>
    /// Showing the recovery code, and refusing to move on until the user confirms they have it.
    /// </summary>
    /// <remarks>
    /// A separate state rather than a dismissible banner, because this is the only moment the code exists.
    /// Losing it along with the passphrase means the vault is unrecoverable and there is no server-side
    /// reset by design — so this is the one screen a user must not be able to click past.
    /// </remarks>
    ShowingRecoveryCode = 3,

    /// <summary>Enrolled. The passphrase opens the vault, with or without a network.</summary>
    Locked = 4,

    /// <summary>Open.</summary>
    Unlocked = 5,
}

/// <summary>
/// Which of the unlocked application's screens the nav rail is pointing at.
/// </summary>
/// <remarks>
/// <para>
/// Only meaningful while <see cref="ShellState.Unlocked"/>. The setup and unlock screens are
/// <see cref="ShellState"/>, and the two are deliberately different things: one is how far through getting
/// in you are, the other is what you are looking at once you are.
/// </para>
/// <para>
/// <see cref="Team"/> is in this list without anything behind it, which is stated on the screen itself
/// rather than hidden by dropping it from the rail. See <c>docs/design-import-gaps.md</c>: teams are M3, and
/// a rail that quietly had four entries would make its eventual arrival look like a new product rather than
/// a milestone. <see cref="Transfers"/> was the other one until M2 built it.
/// </para>
/// </remarks>
internal enum ShellScreen
{
    /// <summary>The host list, which is where the application opens.</summary>
    Hosts = 0,

    /// <summary>File transfer over SFTP: two directory panes and a queue.</summary>
    Transfers = 1,

    /// <summary>Everything in the vault that is not a host.</summary>
    Vault = 2,

    /// <summary>Shared vaults and the people in them. Nothing implements it yet.</summary>
    Team = 3,

    /// <summary>Preferences.</summary>
    Preferences = 4,

    /// <summary>The host keys this keychain has approved.</summary>
    /// <remarks>
    /// Appended rather than slotted in beside the keychain screen it came out of. These values are written
    /// into <c>NavRail.axaml</c> as <c>x:Static</c> literals and read by tests; renumbering them would be a
    /// silent change to what every one of those means.
    /// </remarks>
    KnownHosts = 5,

    /// <summary>Importing hosts from the machine's own <c>~/.ssh/config</c>.</summary>
    /// <remarks>
    /// Reachable from preferences and not from the nav rail, unlike every other member here. It is a task
    /// done once rather than a place to be, and a seventh rail entry would cost every screen a slot for
    /// something almost nobody is looking at.
    /// </remarks>
    Import = 6,

    /// <summary>The saved commands in this keychain.</summary>
    /// <inheritdoc cref="KnownHosts" path="/remarks" />
    Snippets = 7,

    /// <summary>What has been connected to, and what has been changed.</summary>
    /// <inheritdoc cref="KnownHosts" path="/remarks" />
    Logs = 8,
}

/// <summary>
/// What the area beside the nav rail is showing: one of the rail's screens, or a terminal.
/// </summary>
/// <remarks>
/// <para>
/// Two properties rather than a sixth <see cref="ShellScreen"/>, and the reason is that a terminal is not a
/// destination in the same sense the rail's entries are. The tab strip is always visible, so a terminal can
/// be opened from any screen — and when it is dismissed the user expects to be back where they were, which
/// means "which page" has to survive "a terminal is showing". Folding the terminal into
/// <see cref="ShellScreen"/> would need a private field remembering the page underneath, which is this pair
/// with one half hidden.
/// </para>
/// </remarks>
internal enum ShellSurface
{
    /// <summary>The screen named by <see cref="MainWindowViewModel.Screen"/>.</summary>
    Page = 0,

    /// <summary>The pane of the tab named by <see cref="MainWindowViewModel.SelectedTab"/>.</summary>
    Terminal = 1,
}

/// <summary>
/// The shell: get to an unlocked vault, then hand over to <see cref="VaultViewModel"/>.
/// </summary>
/// <remarks>
/// <para>
/// The order of these states is the product's onboarding story. A fresh machine needs a server URL and one
/// browser sign-in; everything about the identity provider comes from
/// <c>/.well-known/dodossh-configuration</c>, so the user never configures an authority or a client id.
/// After that the network is optional — the cached salt and wrapped bundle mean the passphrase alone
/// unlocks, which is the state the application spends nearly all of its life in.
/// </para>
/// <para>
/// Key derivation runs on a worker thread. At the shipped profile it is a third of a second of solid CPU,
/// and doing that on the UI thread would freeze the window at exactly the moment the user is watching it.
/// </para>
/// </remarks>
internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisposable
{
    private readonly ClientPaths paths;
    private readonly ClientCacheFactory caches;
    private readonly TerminalWorkspace workspace;

    /// <remarks>
    /// The concrete store rather than <c>IKnownHostStore</c>, because this is where its lifecycle belongs:
    /// the interface is what the handshake asks, and opening a vault behind it, refreshing it and forgetting
    /// it are this state machine's business. The same instance was handed to the connection factory when the
    /// application was composed.
    /// </remarks>
    private readonly VaultKnownHostStore knownHosts;

    /// <remarks>
    /// Whatever this machine can keep a device key in, chosen once at composition. An interface because the
    /// answer is a platform decision — see ADR 0007 — and because a machine with no TPM gets a store that
    /// reports itself unavailable rather than a null this state machine would have to check for.
    /// </remarks>
    private readonly IDeviceKeyStore deviceKeys;

    private readonly SignInHandler signIn;

    /// <remarks>
    /// Optional, and null is not merely "not configured": a shell with no resume handler is one that can
    /// only be online because somebody signed in during this run, which is what every test that asserts
    /// offline behaviour relies on.
    /// </remarks>
    private readonly ResumeHandler? resume;

    private readonly TimeProvider clock;
    private readonly Argon2Profile? passphraseProfile;

    /// <remarks>
    /// Held here only to hand to each vault as it is opened. The shell has nothing to copy of its own; the
    /// keychain screen does. Null on a machine with no clipboard, which is a state that reports itself
    /// rather than one that fails silently — see <see cref="VaultViewModel"/>.
    /// </remarks>
    private readonly Func<string, Task>? copyToClipboard;

    /// <remarks>
    /// Created once and kept for the life of the process, like <see cref="workspace"/> and for the same
    /// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy
    /// a transfer in flight any more than it closes a running shell. See <see cref="LockAsync"/>. The vault
    /// is attached to it on unlock and detached on lock, which is all the vault is for here — the host list.
    /// </remarks>
    private readonly TransfersViewModel transfers;

    /// <summary>
    /// Where connections are recorded, for as long as a vault is open to record them into.
    /// </summary>
    /// <remarks>
    /// A process-lifetime object with session-scoped contents, exactly like the known-host store beside it,
    /// and for the same reason: the thing that calls it — the workspace — outlives every lock.
    /// </remarks>
    private readonly ConnectionRecorder connectionLog;

    private readonly TeamsViewModel teams;

    /// <summary>
    /// The tab standing in for each connection that has been asked for and has not answered yet.
    /// </summary>
    /// <remarks>
    /// Keyed on the attempt rather than on the host, because connecting no longer holds the vault and two
    /// attempts against the same machine are a thing a user can now do by clicking twice. An entry lives
    /// exactly as long as the attempt: it goes when the session opens, when the connection is refused, and
    /// when the user closes the tab out from under it.
    /// </remarks>
    private readonly Dictionary<Guid, TerminalTabViewModel> attempts = [];

    private IVaultServer? connection;

    /// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
    private string? rememberedToken;

    /// <summary>Guards against two resume attempts overlapping.</summary>
    /// <remarks>
    /// A plain flag rather than a semaphore because every caller is on the UI thread — the sync loop and
    /// the Sync button — and what has to be prevented is a second attempt starting while the first is
    /// waiting on a token endpoint, not a data race.
    /// </remarks>
    private bool resuming;

    private bool disposed;

    /// <summary>
    /// Establishes a connection to a server.
    /// </summary>
    /// <remarks>
    /// A delegate rather than a direct call to <see cref="ServerConnection.SignInAsync"/>, so this whole
    /// state machine can be driven by a test against an in-memory server. Sign-in is the one step that
    /// genuinely needs a browser and a network, and letting it be the reason nothing else is testable
    /// would be the wrong trade.
    /// </remarks>
    internal delegate Task<IVaultServer> SignInHandler(Uri serverUrl, CancellationToken cancellationToken);

    /// <summary>
    /// Re-establishes a connection from a remembered sign-in, without a browser.
    /// </summary>
    /// <remarks>
    /// A delegate for the same reason <see cref="SignInHandler"/> is one: a real resume needs discovery
    /// and a token endpoint, and making that the only way to reach this state machine would put the whole
    /// "comes back online by itself" behaviour out of reach of a test.
    /// </remarks>
    internal delegate Task<IVaultServer> ResumeHandler(
        Uri serverUrl,
        string refreshToken,
        CancellationToken cancellationToken);

    /// <param name="sftpSessions">
    /// How file-transfer sessions are opened. The same object as the connection factory in the composed
    /// application — one type implements both — and a separate parameter because it is a separate capability
    /// and the tests that drive this state machine have no use for it.
    /// </param>
    internal MainWindowViewModel(
        ClientPaths paths,
        ClientCacheFactory caches,
        TerminalWorkspace workspace,
        VaultKnownHostStore knownHosts,
        IDeviceKeyStore deviceKeys,
        SignInHandler signIn,
        TimeProvider clock,
        ISftpSessionFactory sftpSessions,
        Argon2Profile? passphraseProfile = null,
        ResumeHandler? resume = null,
        Func<string, Task>? copyToClipboard = null)
    {
        this.paths = paths;
        this.caches = caches;
        this.workspace = workspace;
        this.knownHosts = knownHosts;
        this.deviceKeys = deviceKeys;
        this.signIn = signIn;
        this.resume = resume;
        this.clock = clock;
        this.passphraseProfile = passphraseProfile;
        this.copyToClipboard = copyToClipboard;

        transfers = new TransfersViewModel(sftpSessions, clock);

        // Built once, like the workspace it writes for, and given a vault only while one is open. It has to
        // outlive every lock for the same reason the workspace does: a shell opened before a lock is still
        // running after it, and the entry it eventually produces belongs to the vault it was made in.
        connectionLog = new ConnectionRecorder(clock, Environment.MachineName);
        this.workspace.ConnectionLog = connectionLog;

        // Both dependencies as functions rather than values: the connection arrives after sign-in and the
        // session after unlock, and both go away again on lock. Capturing either would give this screen a
        // reference that outlives what it points at — which for a session means holding vault keys past the
        // moment locking is supposed to have zeroed them.
        teams = new TeamsViewModel(() => connection, () => Vault?.Session);

        // Subscribed for the life of the process, because the workspace lives that long and so does the tab
        // list. Detached in DisposeAsync, which is the only point either of them ends.
        this.workspace.SessionEnded += OnWorkspaceSessionEnded;
    }

    [ObservableProperty]
    private ShellState state = ShellState.Starting;

    [ObservableProperty]
    private string statusMessage = "Opening the local cache…";

    [ObservableProperty]
    private bool isBusy;

    /// <summary>Whether the unlock screen should offer a gesture instead of the passphrase.</summary>
    [ObservableProperty]
    private bool canUnlockWithDevice;

    /// <summary>Whether an unlocked vault should offer to register this machine.</summary>
    [ObservableProperty]
    private bool canRegisterDevice;

    /// <summary>Whether this machine has a device key to withdraw.</summary>
    [ObservableProperty]
    private bool canForgetDevice;

    /// <summary>
    /// Whether this machine can neither register a device key nor withdraw one.
    /// </summary>
    /// <remarks>
    /// Not the negation of either flag on its own, which is exactly why it is worth a name. The two are
    /// independent: a machine with no TPM cannot register, and a machine already registered has nothing to
    /// register either — and only the second has something to take back. Both false at once is the one case
    /// that means "this machine has nowhere to keep a key", which is worth saying out loud on a preferences
    /// screen where the alternative is a section with no controls in it and no explanation.
    /// </remarks>
    internal bool HasNoDeviceKeyOption => !CanRegisterDevice && !CanForgetDevice;

    /// <summary>
    /// The address offered on first launch, before anything is enrolled.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Two defaults, because the two audiences never overlap. A release build is installed by somebody
    /// signing in to the hosted deployment, and typing its address is the only thing standing between
    /// them and a working application. A debug build is run from a clone, next to
    /// <c>dotnet run --project src/DodoSSH.Api</c>, and shipping the hosted address there would point
    /// every development launch at production — which is worse than an inconvenience, since sign-in is
    /// the step that enrolls a device.
    /// </para>
    /// <para>
    /// Note the schemes. <c>http</c> locally is not an oversight: the API's first launch profile — the
    /// one a plain <c>dotnet run</c> and the README both select — is plaintext on 5233, and pointing an
    /// HTTPS client at a plaintext port fails as "The SSL connection could not be established", which
    /// sends people looking for a certificate problem. See <see cref="ExplainSignInFailure" />.
    /// </para>
    /// </remarks>
#if DEBUG
    internal const string DefaultServerUrl = "http://localhost:5233";
#else
    internal const string DefaultServerUrl = "https://ssh.dodotech.cloud";
#endif

    [ObservableProperty]
    private string serverUrl = DefaultServerUrl;

    [ObservableProperty]
    private string passphrase = string.Empty;

    [ObservableProperty]
    private string confirmPassphrase = string.Empty;

    /// <summary>Shown once, immediately after enrolling, and never stored anywhere.</summary>
    [ObservableProperty]
    private string? recoveryCode;

    [ObservableProperty]
    private bool recoveryCodeWrittenDown;

    /// <summary>Who this machine is enrolled as, readable without the passphrase.</summary>
    [ObservableProperty]
    private string? accountName;

    [ObservableProperty]
    private VaultViewModel? vault;

    /// <summary>The approved-host-keys screen, which exists exactly as long as the vault behind it does.</summary>
    /// <remarks>
    /// Assigned from <see cref="OnVaultChanged"/> and nowhere else, so the three paths that open or close a
    /// vault — unlocking, locking and signing out — cannot get out of step with it.
    /// </remarks>
    [ObservableProperty]
    private KnownHostsViewModel? knownHostsScreen;

    /// <inheritdoc cref="KnownHostsScreen" />
    [ObservableProperty]
    private ImportViewModel? importScreen;

    /// <inheritdoc cref="KnownHostsScreen" />
    [ObservableProperty]
    private SnippetsViewModel? snippetsScreen;

    /// <inheritdoc cref="KnownHostsScreen" />
    [ObservableProperty]
    private LogsViewModel? logsScreen;

    /// <summary>
    /// The teams screen, which the window binds to whether or not a vault is open.
    /// </summary>
    /// <remarks>
    /// Not nullable and never replaced, for the reason <see cref="Transfers"/> is not: the screen reads a
    /// server rather than a vault, and both of its dependencies are fetched through a function at the
    /// moment they are needed. That means a lock does not have to tear it down and an unlock does not have
    /// to rebuild it, and the list it is showing survives both.
    /// </remarks>
    internal TeamsViewModel Teams => teams;

    /// <summary>The transfers screen, which the window binds to whether or not a vault is open.</summary>
    /// <remarks>
    /// Not nullable and never replaced, unlike <see cref="Vault"/>. The screen is unreachable while locked —
    /// the whole shell is — but the object behind it is what holds a transfer that is still running, so a
    /// property that went null on lock would be a transfer nothing could report on afterwards.
    /// </remarks>
    internal TransfersViewModel Transfers => transfers;

    /// <summary>
    /// Shells that were left running when the vault was locked.
    /// </summary>
    /// <remarks>
    /// Refreshed by <see cref="LockAsync"/>, which is where the policy this reports is explained.
    /// </remarks>
    [ObservableProperty]
    private int liveSessionCount;

    internal bool HasLiveSessions => LiveSessionCount > 0;

    /// <summary>The count as a sentence, because a bare number on a lock screen explains nothing.</summary>
    internal string LiveSessionSummary => LiveSessionCount == 1
        ? "1 shell is still connected and still running."
        : $"{LiveSessionCount} shells are still connected and still running.";

    /// <summary>Where the embedded browser should navigate.</summary>
    internal Uri TerminalPageUrl => workspace.PageUrl;

    /// <summary>
    /// Types into a terminal on behalf of something that is not the keyboard.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Exposed on the shell rather than reached through the workspace directly, because the workspace is a
    /// composition-root object and a view has no business holding one — the same reason tabs go through
    /// here rather than through <c>TerminalWorkspace.CloseSessionAsync</c>.
    /// </para>
    /// <para>
    /// The Android head's accessory key row is what needs it: a software keyboard has no Ctrl, Esc, Tab or
    /// arrows, so those keys are drawn and their bytes sent from here. Ordinary typing never comes this
    /// way — it goes from the renderer straight down the socket.
    /// </para>
    /// </remarks>
    internal ValueTask SendTerminalInputAsync(uint sessionId, ReadOnlyMemory<byte> data) =>
        workspace.SendInputAsync(sessionId, data, CancellationToken.None);

    /// <summary>
    /// Raised when a terminal session opens, so the view can hand the terminal the keyboard.
    /// </summary>
    /// <remarks>
    /// Forwarded from <see cref="VaultViewModel.SessionOpened"/> rather than exposed there directly,
    /// because <see cref="Vault"/> is replaced on every unlock and the view would have to re-subscribe
    /// each time. This shell is the window's data context for the life of the process, so one
    /// subscription is enough.
    /// </remarks>
    internal event EventHandler? TerminalSessionOpened;

    internal bool IsStarting => State == ShellState.Starting;

    internal bool IsNeedingServer => State == ShellState.NeedsServer;

    internal bool IsNeedingEnrollment => State == ShellState.NeedsEnrollment;

    internal bool IsShowingRecoveryCode => State == ShellState.ShowingRecoveryCode;

    internal bool IsLocked => State == ShellState.Locked;

    /// <summary>Whether the unlock card itself is showing, rather than the confirmation over it.</summary>
    /// <remarks>
    /// Its own property because the markup cannot express <c>IsLocked &amp;&amp; !IsConfirmingSignOut</c>,
    /// and the two cards genuinely swap rather than stack: the unlock card is already near the height the
    /// window guarantees at its minimum size, so putting a second question underneath it would push
    /// buttons off a screen with nothing to scroll.
    /// </remarks>
    internal bool IsAskingForThePassphrase => IsLocked && !IsConfirmingSignOut;

    internal bool IsUnlocked => State == ShellState.Unlocked;

    /// <summary>Whether a connection to the server is currently held.</summary>
    internal bool IsOnline => connection is not null;

    /// <summary>
    /// Whether everything this machine has changed has reached the server.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The design's titlebar says "SYNCED" beside a green dot, unconditionally. This is the honest version
    /// of that claim, and it is deliberately conservative: true only while a connection is held, the last
    /// pass actually reached the server, and the outbox is empty.
    /// </para>
    /// <para>
    /// The middle condition is the one that is easy to leave out, and was. Holding an <c>IVaultServer</c>
    /// proves a sign-in once succeeded and nothing more — it is obtained once and never dropped — so a
    /// laptop whose lid has been shut all afternoon still has one, with an empty outbox, which is precisely
    /// the shape of a green light that is lying. See <c>VaultViewModel.LastSyncFailed</c>.
    /// </para>
    /// <para>
    /// It still does not mean this machine has a colleague's change from a second ago. Nothing short of a
    /// completed pull could say that, and the pull runs on a one-minute timer. What it means is that this
    /// machine can reach the server and has nothing stuck.
    /// </para>
    /// </remarks>
    internal bool IsFullySynced => IsOnline && Vault is { PendingChanges: 0, LastSyncFailed: false };

    /// <summary>The same fact as a word, for the titlebar.</summary>
    internal string SyncLabel => (IsOnline, Vault?.LastSyncFailed ?? true, Vault?.PendingChanges ?? 0) switch
    {
        (false, _, _) => "OFFLINE",
        (true, true, _) => "UNREACHABLE",
        (true, false, 0) => "SYNCED",
        (true, false, 1) => "1 PENDING",
        (true, false, var pending) => $"{pending} PENDING",
    };

    // ---- Which screen is showing ----

    /// <summary>
    /// Which of the nav rail's screens the page area holds.
    /// </summary>
    /// <remarks>
    /// This always names a page, even while a terminal is showing over it — see <see cref="ShellSurface"/>.
    /// It is what dismissing a terminal returns to.
    /// </remarks>
    [ObservableProperty]
    private ShellScreen screen;

    /// <summary>
    /// Whether the page area is showing rather than a terminal.
    /// </summary>
    /// <remarks>
    /// Bound by the one wrapper that holds every screen, rather than by each screen. Avalonia cannot express
    /// <c>IsHostsScreen &amp;&amp; IsShowingPages</c> in a binding, so the alternative is five compound
    /// properties — and, worse, a way to add a sixth screen and forget one. A screen that fails to collapse
    /// does not merely look wrong: it is drawn underneath the terminal's native child window and its buttons
    /// cannot be clicked. See <see cref="IsTerminalShowing"/>.
    /// </remarks>
    internal bool IsShowingPages => Surface is ShellSurface.Page;

    internal bool IsHostsScreen => Screen is ShellScreen.Hosts;

    /// <inheritdoc cref="IsHostsScreen" />
    internal bool IsTransfersScreen => Screen is ShellScreen.Transfers;

    /// <inheritdoc cref="IsHostsScreen" />
    internal bool IsVaultScreen => Screen is ShellScreen.Vault;

    /// <inheritdoc cref="IsHostsScreen" />
    internal bool IsTeamScreen => Screen is ShellScreen.Team;

    /// <inheritdoc cref="IsHostsScreen" />
    internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences;

    /// <inheritdoc cref="IsHostsScreen" />
    internal bool IsKnownHostsScreen => Screen is ShellScreen.KnownHosts;

    /// <inheritdoc cref="IsHostsScreen" />
    internal bool IsImportScreen => Screen is ShellScreen.Import;

    /// <inheritdoc cref="IsHostsScreen" />
    internal bool IsSnippetsScreen => Screen is ShellScreen.Snippets;

    /// <inheritdoc cref="IsHostsScreen" />
    internal bool IsLogsScreen => Screen is ShellScreen.Logs;

    /// <summary>
    /// Whether the nav rail should light its Hosts entry.
    /// </summary>
    /// <remarks>
    /// Not the same question as <see cref="IsHostsScreen"/>, and the rail has to ask this one. A terminal
    /// opened from the hosts screen leaves <see cref="Screen"/> on Hosts — deliberately, so closing the tab
    /// comes back here — and a rail that lit HOSTS while a terminal filled the window would be pointing at a
    /// screen that is not showing. The selected tab is already marked in the strip; two "you are here" marks
    /// at once is one too many.
    /// </remarks>
    internal bool IsHostsShowing => IsShowingPages && IsHostsScreen;

    /// <inheritdoc cref="IsHostsShowing" />
    internal bool IsTransfersShowing => IsShowingPages && IsTransfersScreen;

    /// <inheritdoc cref="IsHostsShowing" />
    internal bool IsVaultShowing => IsShowingPages && IsVaultScreen;

    /// <inheritdoc cref="IsHostsShowing" />
    internal bool IsTeamShowing => IsShowingPages && IsTeamScreen;

    /// <inheritdoc cref="IsHostsShowing" />
    internal bool IsPreferencesShowing => IsShowingPages && IsPreferencesScreen;

    /// <inheritdoc cref="IsHostsShowing" />
    internal bool IsKnownHostsShowing => IsShowingPages && IsKnownHostsScreen;

    /// <inheritdoc cref="IsHostsShowing" />
    internal bool IsSnippetsShowing => IsShowingPages && IsSnippetsScreen;

    /// <inheritdoc cref="IsHostsShowing" />
    internal bool IsLogsShowing => IsShowingPages && IsLogsScreen;

    /// <summary>
    /// Whether the terminal's WebView may be on screen at this instant.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>This is an occlusion rule, not a styling one.</b> The WebView is a native child window on Windows,
    /// and a child window composites above everything its parent paints — so whatever Avalonia draws in the
    /// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
    /// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
    /// locked vault (the unlock card), the page area (every screen uses the full width), and the
    /// quick-connect palette.
    /// </para>
    /// <para>
    /// <b>The terminal and the pages are exclusive, and that is the whole of the rule.</b> They share one
    /// rectangle, so exactly one of <see cref="IsShowingPages"/> and this may be true. That is why
    /// <see cref="Surface"/> exists as a single enum rather than as two independent flags a caller could set
    /// to the same value.
    /// </para>
    /// <para>
    /// <b>Not gated on there being a tab.</b> Closing the last tab returns <see cref="Surface"/> to
    /// <see cref="ShellSurface.Page"/> instead, so the empty case never arises — and gating here as well
    /// would be a second answer to one question. The empty-state sentence lives in the tab strip, which
    /// Avalonia draws and nothing occludes.
    /// </para>
    /// <para>
    /// <b>Revealing and focusing now happen in the same turn, routinely.</b> Opening a terminal from the
    /// files screen, or clicking a tab while a page is showing, both flip this from false to true and then
    /// want the keyboard. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so focusing
    /// microseconds ahead of that pass races the thing the focus depends on. The view answers that by
    /// posting the focus at <c>DispatcherPriority.Loaded</c> — see <c>MainWindow.axaml.cs</c>. It is not
    /// answered here, and it cannot be: this property has no way to know when layout ran.
    /// </para>
    /// <para>
    /// Collapsing is cheap and safe. <c>NativeControlHost</c> creates the native control on attach rather
    /// than on show, so WebView2 still starts, still loads the page and still lets the renderer connect
    /// while this is false; only the bounds are withheld. Removing the control from the tree would not be
    /// safe — that detaches it and destroys the whole WebView2 process tree.
    /// </para>
    /// </remarks>
    internal bool IsTerminalShowing => IsTerminalSurface && SelectedTab is { HasSession: true };

    /// <summary>
    /// Whether the terminal half of the window is the half being shown, pane or no pane.
    /// </summary>
    /// <remarks>
    /// Every condition in <see cref="IsTerminalShowing"/> except the one about there being a session, and it
    /// is worth its own name because a tab exists before its session does — see
    /// <see cref="TerminalTabViewModel"/>. This is what "the user is looking at the terminal" means; the
    /// other two say which of the two things that can be in that rectangle is drawn.
    /// </remarks>
    internal bool IsTerminalSurface => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching;

    /// <summary>
    /// Whether the card that stands in for a pane is showing.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The other half of <see cref="IsTerminalShowing"/>, and exclusive with it by construction: a selected
    /// tab either has a session or it does not. It covers both of the states in which it does not — still
    /// connecting, and failed — because both are a tab with something to say and nothing to draw it in.
    /// </para>
    /// <para>
    /// It obeys the same occlusion rule as everything else in that rectangle, which is why it has to turn the
    /// terminal off rather than merely draw over it. See <see cref="IsTerminalShowing"/>.
    /// </para>
    /// </remarks>
    internal bool IsConnectingShowing => IsTerminalSurface && SelectedTab is { HasSession: false };

    /// <inheritdoc cref="ShellSurface" />
    [ObservableProperty]
    private ShellSurface surface;

    /// <summary>Points the nav rail at a screen.</summary>
    /// <remarks>
    /// Dismisses the terminal as well as moving the page, because the rail is how a user says "show me
    /// something else" and a rail click that changed a screen nobody could see would do nothing visible.
    /// The tab itself is untouched: its shell goes on running and the strip goes on naming it.
    /// </remarks>
    [RelayCommand]
    private void ShowScreen(ShellScreen target)
    {
        Screen = target;
        Surface = ShellSurface.Page;
    }

    /// <summary>Switches to the terminal surface.</summary>
    /// <remarks>
    /// <para>
    /// The other half of <see cref="ShowScreenCommand"/>, and it exists because the phone's bottom bar
    /// names the terminal beside the pages. The desktop reaches this surface only implicitly — opening a
    /// session or clicking a tab — because its tab strip is always on screen and is itself the way back.
    /// A phone has no room for a permanent strip beside a full-height screen, so the destination needs a
    /// button, and a button needs a command.
    /// </para>
    /// <para>
    /// Not gated on there being a tab, for the same reason <see cref="IsTerminalShowing"/> is not: closing
    /// the last tab returns the surface to a page, so the empty case does not arise here — and the terminal
    /// screen carries an empty state anyway, which is worth being able to reach deliberately.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private void ShowTerminal() => Surface = ShellSurface.Terminal;

    // ---- Open terminals ----

    /// <summary>
    /// Every terminal that has been opened this run, in the order they were opened.
    /// </summary>
    /// <remarks>
    /// On the shell rather than on the vault, and that follows from the lock policy rather than from
    /// convenience. Locking disposes the vault and leaves shells running, so tabs rebuilt per unlock would
    /// lose sessions that are still connected — the very sessions <see cref="LiveSessionCount"/> exists to
    /// admit to. This object is the window's data context for the life of the process, and so is this list.
    /// </remarks>
    internal ObservableCollection<TerminalTabViewModel> Tabs { get; } = [];

    [ObservableProperty]
    private TerminalTabViewModel? selectedTab;

    internal bool HasTabs => Tabs.Count > 0;

    private void RaiseTabState() => OnPropertyChanged(nameof(HasTabs));

    /// <summary>
    /// Closes one terminal, ending its shell.
    /// </summary>
    /// <remarks>
    /// This is the one thing in the application that deliberately ends a session, which is why it is a tab's
    /// close button and not a menu item: closing the window somebody's job is running in should take exactly
    /// as much intent as it looks like it does. Locking does not do this, and neither does anything else.
    /// </remarks>
    [RelayCommand]
    private async Task CloseTabAsync(TerminalTabViewModel tab)
    {
        if (tab is null)
        {
            return;
        }

        // Removed first, so the workspace's SessionEnded — which fires as the pump unwinds — finds no tab to
        // mark dead and does nothing. The alternative ordering leaves a window in which a tab that is on its
        // way out is repainted as disconnected.
        var index = Tabs.IndexOf(tab);
        Tabs.Remove(tab);

        if (ReferenceEquals(SelectedTab, tab))
        {
            // The neighbour, preferring the one on the left, which is where the eye already is.
            SelectedTab = Tabs.Count == 0
                ? null
                : Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
        }

        // The one place the surface is forced back to a page. Closing a tab that leaves others open keeps the
        // terminal showing — the neighbour above is what it shows — but closing the last one would otherwise
        // leave a visible WebView with no pane in it, which reads as the application having broken.
        if (Tabs.Count == 0)
        {
            Surface = ShellSurface.Page;
        }

        RaiseTabState();

        // Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
        // changes nothing about the selection, so OnSelectedTabChanged does not run — and the host whose
        // terminal just went would keep a lit dot until something else happened to move the selection.
        RefreshConnectedHosts();

        // What the rectangle holds is decided by the selected tab's state, and the line above may well have
        // moved the selection from a card to a pane or the other way round.
        RaiseTerminalState();

        if (!tab.HasSession)
        {
            // Nothing to end: this tab is a connection that has not happened, or one that never will. The
            // attempt is forgotten so a handshake still in flight does not come back and reopen a tab the
            // user has just dismissed — it becomes a session with no tab, which OnVaultSessionOpened adopts
            // rather than drops, because a running shell nothing names is worse than a tab that reappears.
            foreach (var attemptId in attempts
                .Where(entry => ReferenceEquals(entry.Value, tab))
                .Select(entry => entry.Key)
                .ToArray())
            {
                attempts.Remove(attemptId);
            }

            return;
        }

        await workspace.CloseSessionAsync(tab.SessionId).ConfigureAwait(true);
    }

    // ---- Quick connect ----

    /// <summary>Whether the quick-connect palette is open over the window.</summary>
    /// <remarks>
    /// It has to collapse the terminal while it is open — see <see cref="IsTerminalShowing"/> — which is why
    /// this is shell state rather than something a view could hold on its own.
    /// </remarks>
    [ObservableProperty]
    private bool isSearching;

    [ObservableProperty]
    private string searchText = string.Empty;

    /// <summary>
    /// The hosts the palette is offering, best match first.
    /// </summary>
    /// <remarks>
    /// The design's box says "search hosts · run command". Only the first half is here: a command palette
    /// needs commands to run, and this application has no snippet or saved-command item type — see
    /// <c>docs/design-import-gaps.md</c>. Offering an empty command list under a box that promised one is
    /// worse than a box that promises only what it does.
    /// </remarks>
    internal ObservableCollection<HostRowViewModel> SearchResults { get; } = [];

    [ObservableProperty]
    private HostRowViewModel? selectedSearchResult;

    /// <summary>Whether the palette has anything to offer.</summary>
    /// <remarks>
    /// A property rather than <c>{Binding !SearchResults.Count}</c> in the markup. Avalonia's <c>!</c> is a
    /// boolean operator: against an <c>int</c> it produces a binding error, <c>IsVisible</c> falls back to
    /// its default of true, and "No host matches that" is shown permanently — under a list of matches.
    /// </remarks>
    internal bool HasSearchResults => SearchResults.Count > 0;

    /// <summary>Opens the palette, or closes it if it is already open.</summary>
    [RelayCommand]
    private void ToggleSearch()
    {
        if (IsSearching)
        {
            CloseSearch();
            return;
        }

        if (!IsUnlocked)
        {
            return;
        }

        SearchText = string.Empty;
        RefreshSearchResults();
        IsSearching = true;
    }

    /// <summary>Dismisses the palette without connecting.</summary>
    [RelayCommand]
    private void CloseSearch()
    {
        IsSearching = false;
        SearchText = string.Empty;
        SearchResults.Clear();
        SelectedSearchResult = null;

        OnPropertyChanged(nameof(HasSearchResults));
    }

    /// <summary>
    /// Selects the highlighted host and connects to it.
    /// </summary>
    /// <remarks>
    /// Goes through the vault's own <c>ConnectCommand</c> rather than opening a session directly, so the
    /// palette inherits every refusal that path already makes — a dangling key binding, a host with no
    /// username, a host key that has changed. A second connect path would be a second place for those to be
    /// forgotten.
    /// </remarks>
    [RelayCommand]
    private async Task ConnectToSearchResultAsync()
    {
        if (Vault is not { } vault || SelectedSearchResult is not { } row)
        {
            return;
        }

        CloseSearch();

        // The hosts page, because that is where this connection's questions get asked. An unknown or changed
        // host key is answered by a prompt drawn on that page and the palette opens from any screen, so
        // connecting from the files screen without this would leave the question behind the screen that asked
        // it. The surface does not stay here — the tab that appears for the attempt takes it — and it does not
        // need to: a refusal that needs an answer puts the page back, which is where this leaves the screen.
        Screen = ShellScreen.Hosts;
        Surface = ShellSurface.Page;
        vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);

        // Null, not the token. A [RelayCommand] over a method whose only parameter is a CancellationToken
        // generates ExecuteAsync(object? parameter) that ignores the argument and supplies a token from its
        // own source — so passing this one would read as cancellation plumbing that is not there.
        await vault.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);
    }

    /// <remarks>
    /// Ranked rather than merely filtered: a host whose name starts with what was typed comes before one
    /// that merely contains it, and both come before a match found only in the address. Typing three
    /// characters of a name people use daily should not put that host third.
    /// </remarks>
    private void RefreshSearchResults()
    {
        SearchResults.Clear();

        if (Vault is not { } vault)
        {
            SelectedSearchResult = null;
            return;
        }

        var query = SearchText.Trim();

        var matches = query.Length == 0
            ? vault.Hosts.AsEnumerable()
            : vault.Hosts
                .Select(host => (host, rank: Rank(host, query)))
                .Where(candidate => candidate.rank < int.MaxValue)
                .OrderBy(candidate => candidate.rank)
                .ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture)
                .Select(candidate => candidate.host);

        foreach (var host in matches.Take(8))
        {
            SearchResults.Add(host);
        }

        SelectedSearchResult = SearchResults.FirstOrDefault();

        OnPropertyChanged(nameof(HasSearchResults));
    }

    private static int Rank(HostRowViewModel host, string query)
    {
        if (host.Label.StartsWith(query, StringComparison.CurrentCultureIgnoreCase))
        {
            return 0;
        }

        if (host.Label.Contains(query, StringComparison.CurrentCultureIgnoreCase))
        {
            return 1;
        }

        return host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase)
            ? 2
            : int.MaxValue;
    }

    /// <summary>
    /// Brings the schema up to date and works out which screen to show.
    /// </summary>
    /// <remarks>
    /// Migrating happens before unlock and touches no encrypted content — only the shape of the tables.
    /// That is the point of migrating rather than recreating: a user who upgrades while offline must still
    /// be able to open their vault.
    /// </remarks>
    internal async Task StartAsync(CancellationToken cancellationToken)
    {
        try
        {
            paths.EnsureCreated();
            await caches.MigrateAsync(cancellationToken).ConfigureAwait(true);

            var profile = await Opener().ReadProfileAsync(cancellationToken).ConfigureAwait(true);

            if (profile is null)
            {
                State = ShellState.NeedsServer;
                StatusMessage = "Sign in to a DodoSSH server to set this machine up.";
                return;
            }

            AccountName = profile.DisplayName ?? profile.Email ?? profile.Subject;
            ServerUrl = profile.ServerUrl;
            State = ShellState.Locked;
            StatusMessage = $"Enrolled against {profile.ServerUrl}.";

            // Both halves have to hold: a wrap in the cache, and a machine still willing to hand the key
            // back. Offering the button without the second would prompt for a key that is not there; without
            // the first it would prompt for a wrap that is not there. Neither failure is one a user could
            // make sense of, so the button simply does not appear.
            CanUnlockWithDevice = profile.DeviceWrappedPrivateKey is not null
                && await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(true);
        }
        catch (Exception exception) when (exception is not OperationCanceledException)
        {
            State = ShellState.NeedsServer;
            StatusMessage = $"The local cache could not be opened: {exception.Message}";
        }
    }

    /// <summary>Discovers the server and runs the browser sign-in.</summary>
    [RelayCommand]
    private async Task SignInAsync(CancellationToken cancellationToken)
    {
        if (!Uri.TryCreate(ServerUrl, UriKind.Absolute, out var url))
        {
            StatusMessage = "That is not a valid server URL.";
            return;
        }

        // Checked separately from parsing, because "localhost:5233" parses perfectly well as an absolute
        // URI whose scheme is "localhost" — and then fails much later with something unrelated to the
        // actual mistake.
        if (url.Scheme is not ("http" or "https"))
        {
            StatusMessage = $"A server URL has to start with http:// or https://, not {url.Scheme}:.";
            return;
        }

        await RunAsync(
            "Opening your browser to sign in…",
            explain: exception => ExplainSignInFailure(exception, url),
            work: async () =>
            {
                connection?.Dispose();
                connection = null;

                connection = await signIn(url, cancellationToken).ConfigureAwait(true);

                OnPropertyChanged(nameof(IsOnline));
                RaiseSyncState();

                // The browser is finished with, and what follows is a round trip to the DodoSSH server
                // that can take a while or fail on its own. Saying so is the difference between a wait
                // and a hang: a screen still reading "Opening your browser to sign in…" while the server
                // is the thing struggling sends the user back to a browser that did nothing wrong.
                StatusMessage = $"Signed in. Asking {url.Host} about your account…";

                var outcome = await Provisioner()!
                    .RefreshAsync(ServerUrl, cancellationToken)
                    .ConfigureAwait(true);

                AccountName = outcome.Me.DisplayName ?? outcome.Me.Email ?? outcome.Me.Subject;
                StatusMessage = outcome.Message;

                if (outcome.Status == ProvisionStatus.EnrollmentRequired)
                {
                    State = ShellState.NeedsEnrollment;
                    return;
                }

                // An unlocked vault stays unlocked. This command is reachable from the preferences screen
                // of a running application — it is how somebody whose sign-in expired gets back online —
                // and moving the state machine to Locked there would throw an unlock screen over an open
                // vault whose keys are still in memory, which is neither locked nor honest.
                if (IsUnlocked)
                {
                    await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
                    return;
                }

                State = ShellState.Locked;
            }).ConfigureAwait(true);
    }

    /// <summary>Creates the identity key and the personal vault.</summary>
    [RelayCommand]
    private async Task EnrollAsync(CancellationToken cancellationToken)
    {
        if (Provisioner() is not { } provisioner)
        {
            StatusMessage = "Sign in first.";
            return;
        }

        if (!ValidateNewPassphrase())
        {
            return;
        }

        await RunAsync(
            "Creating your keychain. This deliberately takes a moment…",
            async () =>
            {
                var chosen = Passphrase;

                var outcome = await Task
                    .Run(
                        () => provisioner.EnrollAsync(
                            ServerUrl,
                            chosen,
                            Environment.MachineName,
                            "Personal",
                            cancellationToken),
                        cancellationToken)
                    .ConfigureAwait(true);

                ConfirmPassphrase = string.Empty;
                RecoveryCode = outcome.RecoveryCode;
                RecoveryCodeWrittenDown = false;
                StatusMessage = outcome.Message;

                // A brand-new account always yields a code. An account someone else already enrolled does
                // not, and there is nothing to show.
                State = RecoveryCode is null ? ShellState.Locked : ShellState.ShowingRecoveryCode;
            }).ConfigureAwait(true);
    }

    /// <summary>Leaves the recovery-code screen, once the user says they have it.</summary>
    [RelayCommand]
    private void ConfirmRecoveryCode()
    {
        if (!RecoveryCodeWrittenDown)
        {
            StatusMessage = "Confirm you have written the recovery code down first.";
            return;
        }

        // Cleared from memory as well as from the screen. It was never persisted, and keeping it in a view
        // model for the rest of the session would undo that.
        RecoveryCode = null;
        State = ShellState.Locked;
        StatusMessage = "Unlock with the passphrase you just chose.";
    }

    /// <summary>Opens the vault.</summary>
    [RelayCommand]
    private async Task UnlockAsync(CancellationToken cancellationToken)
    {
        if (Passphrase.Length == 0)
        {
            StatusMessage = "Enter your keychain passphrase.";
            return;
        }

        await RunAsync(
            "Unlocking…",
            async () =>
            {
                var entered = Passphrase;

                // Off the UI thread: Argon2id at the shipped profile is a third of a second of solid CPU
                // and would otherwise freeze the window mid-unlock.
                var outcome = await Task
                    .Run(() => Opener().UnlockAsync(entered, cancellationToken), cancellationToken)
                    .ConfigureAwait(true);

                StatusMessage = outcome.Message;

                if (!outcome.IsUnlocked)
                {
                    return;
                }

                Passphrase = string.Empty;

                await AdoptAsync(outcome.Session!, cancellationToken).ConfigureAwait(true);
            }).ConfigureAwait(true);
    }

    /// <summary>Opens the vault with this machine's device key instead of the passphrase.</summary>
    /// <remarks>
    /// No <c>Task.Run</c>, unlike the passphrase path: there is no Argon2 to pay for here, and the work that
    /// does block is a Windows consent dialog which belongs on the UI thread anyway.
    /// </remarks>
    [RelayCommand]
    private async Task UnlockWithDeviceAsync(CancellationToken cancellationToken)
    {
        await RunAsync(
            "Waiting for Windows…",
            async () =>
            {
                var outcome = await Opener()
                    .UnlockWithDeviceAsync(deviceKeys, cancellationToken)
                    .ConfigureAwait(true);

                StatusMessage = outcome.Message;

                if (!outcome.IsUnlocked)
                {
                    // A declined gesture leaves the passphrase box exactly where it was, which is the whole
                    // fallback: the user types instead. Nothing about the screen changes but the message.
                    return;
                }

                await AdoptAsync(outcome.Session!, cancellationToken).ConfigureAwait(true);
            }).ConfigureAwait(true);
    }

    /// <summary>
    /// Registers this machine so a later launch can unlock with a gesture.
    /// </summary>
    /// <remarks>
    /// Needs a network, because the wrap has to reach the server — a wrap that exists only here would be
    /// lost with the cache file and could never be revoked. Needs an unlocked vault too, because only an
    /// open session can seal the bundle.
    /// </remarks>
    [RelayCommand]
    private async Task RegisterDeviceAsync(CancellationToken cancellationToken)
    {
        if (Vault is not { } vault || connection is null)
        {
            StatusMessage = "Sign in first: registering this machine has to reach the server.";
            return;
        }

        await RunAsync(
            "Waiting for Windows…",
            async () =>
            {
                var name = Environment.MachineName;

                var registered = await vault.Session
                    .RegisterDeviceAsync(connection.Account, deviceKeys, name, cancellationToken)
                    .ConfigureAwait(true);

                if (!registered)
                {
                    StatusMessage = "This machine has nowhere to keep a device key.";
                    return;
                }

                CanRegisterDevice = false;
                CanForgetDevice = true;
                StatusMessage = $"'{name}' can now unlock without your passphrase.";
            }).ConfigureAwait(true);
    }

    /// <summary>
    /// Withdraws this machine's device key, here and on the account.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Offered without a confirmation prompt, which is deliberate. The cost of pressing it by accident is one
    /// passphrase and one re-registration; the cost of a confirmation dialog is a moment's hesitation at the
    /// point somebody has realised a machine is in the wrong hands. Reversible and urgent beats guarded.
    /// </para>
    /// <para>
    /// Works offline, and says so. What decides whether this machine may unlock itself is entirely local, so
    /// the useful half always happens — the account being told is the half that can be out of reach.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task ForgetDeviceAsync(CancellationToken cancellationToken)
    {
        if (Vault is not { } vault)
        {
            return;
        }

        await RunAsync(
            "Waiting for Windows…",
            async () =>
            {
                var revocation = await vault.Session
                    .ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken)
                    .ConfigureAwait(true);

                CanForgetDevice = false;

                // Not re-offered here even though it is now true, because registering probes the TPM and
                // this is not the moment to do it: somebody who has just withdrawn a device is not about to
                // add one back, and the offer reappears on the next unlock.
                StatusMessage = revocation switch
                {
                    DeviceRevocation.Complete =>
                        "This machine no longer unlocks without your passphrase, and the account no longer "
                        + "lists it.",
                    DeviceRevocation.LocalOnly =>
                        "This machine no longer unlocks without your passphrase. You are offline, so the "
                        + "account still lists it — sign in and withdraw it again to finish.",
                    _ => "There was no device key on this machine.",
                };
            }).ConfigureAwait(true);
    }

    /// <summary>
    /// Takes ownership of a freshly opened session, whichever door opened it.
    /// </summary>
    /// <remarks>
    /// Shared by both unlock paths rather than duplicated, because the ordering in here is load-bearing and
    /// a second copy would be a second chance to get it wrong.
    /// </remarks>
    private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
    {
        await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);

        Vault = new VaultViewModel(
            session,
            workspace,
            knownHosts,
            () => connection,
            ReconnectAsync,
            copyToClipboard,
            connectionLog);
        State = ShellState.Unlocked;

        // Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
        // has not already registered one. Asked once here rather than recomputed, because the answer
        // involves a TPM probe.
        CanRegisterDevice = session.Profile.DeviceWrappedPrivateKey is null
            && await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(true);

        // The other side of the same fact, and it needs its own flag rather than the negation of that one:
        // "not offered because this machine has no TPM" and "not offered because it is already registered"
        // are both !CanRegisterDevice, and only the second has anything to withdraw.
        CanForgetDevice = session.Profile.DeviceWrappedPrivateKey is not null;

        await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);

        // After the load, because what the transfers screen takes from the vault is the host list and an
        // empty one would leave its picker blank until the next unlock.
        transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory());

        // After the list exists, and it matters after a lock rather than after the first unlock: shells kept
        // running while the vault was closed, so some of these hosts are connected before their rows are a
        // second old.
        RefreshConnectedHosts();

        // After the first load, so the list is on screen before anything talks to a server. The loop is
        // started from the UI thread deliberately: every pass resumes here, which is what keeps the
        // observable collections single-threaded.
        //
        // Its first pass is also what brings this machine online: the pass asks ReconnectAsync for a
        // server, and that is where a remembered sign-in is resumed. Nothing here has to know whether
        // this unlock followed a sign-in or a cold launch on a train.
        //
        // Deliberately not awaited here, and not done before this point either. Resuming is a discovery
        // call and a token exchange — a network round trip, and on an unreachable network a slow one —
        // and unlocking must never wait on one. Everything the unlock screen promises about working
        // offline stops being true the moment the passphrase leads to a socket. So the vault opens, and
        // the titlebar says OFFLINE until the round trip this starts has an answer.
        Vault.StartAutoSync();
    }

    /// <summary>
    /// Points the two process-lifetime stores at the session that has just opened.
    /// </summary>
    /// <remarks>
    /// Both live longer than any vault — the known-host store answers the SSH handshake, the recorder is
    /// called by the workspace — so both are attached here rather than constructed per session, and both are
    /// released together on every path that closes a vault.
    /// </remarks>
    private async Task AttachStoresAsync(VaultSession session, CancellationToken cancellationToken)
    {
        // Before the vault view model, so the first connection after an unlock already knows which host keys
        // this user has approved. Reading them is one listing; doing it here rather than lazily is what
        // keeps it off the SSH handshake thread.
        try
        {
            await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
        }
        catch
        {
            // Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
            // session is vault keys left in memory for the life of the process, which is precisely what
            // unlocking must be able to undo.
            await session.DisposeAsync().ConfigureAwait(true);
            throw;
        }

        // The actor is the account that unlocked, which is what makes this an audit record rather than a
        // list of events with nobody attached to them.
        connectionLog.Open(session, session.Profile.UserId);
    }

    /// <summary>
    /// Gets this machine online if it is not, and keeps the remembered sign-in current if it is.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Handed to the vault, which asks once per synchronisation pass. That cadence is the whole design:
    /// there is no connectivity monitor and no reconnect backoff, because a pass a minute already is one,
    /// and a machine that comes back from a closed lid is online again within a minute of having a
    /// network — with nothing pressed and no browser opened.
    /// </para>
    /// <para>
    /// <b>Resuming needs an unlocked vault, and that is deliberate rather than incidental.</b> The
    /// remembered refresh token is sealed under the vault's own cache key, so this can only succeed after
    /// somebody has opened the vault — a stolen laptop yields a cache file that cannot reach the account
    /// any more than it can read the hosts.
    /// </para>
    /// <para>
    /// Every failure returns null and stays quiet, with one exception: a provider that <em>refuses</em> the
    /// token is not a transient condition and will refuse it again once a minute forever, so that one is
    /// said out loud and the token is dropped.
    /// </para>
    /// </remarks>
    private async Task<IVaultServer?> ReconnectAsync(CancellationToken cancellationToken)
    {
        if (connection is { } held)
        {
            await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
            return held;
        }

        if (resume is not { } handler || resuming || Vault is not { } vault)
        {
            return null;
        }

        resuming = true;

        try
        {
            return await ResumeAsync(vault, handler, cancellationToken).ConfigureAwait(true);
        }
        finally
        {
            resuming = false;
        }
    }

    /// <remarks>
    /// Split from <see cref="ReconnectAsync"/> only so the guard, the flag and the attempt are three short
    /// things rather than one long one. Everything about why this behaves as it does is up there.
    /// </remarks>
    private async Task<IVaultServer?> ResumeAsync(
        VaultViewModel vault,
        ResumeHandler handler,
        CancellationToken cancellationToken)
    {
        try
        {
            var token = await vault.Session
                .ReadRememberedSignInAsync(cancellationToken)
                .ConfigureAwait(true);

            if (token is null
                || !Uri.TryCreate(vault.Session.Profile.ServerUrl, UriKind.Absolute, out var server))
            {
                return null;
            }

            var resumed = await handler(server, token, cancellationToken).ConfigureAwait(true);

            connection = resumed;
            rememberedToken = token;

            OnPropertyChanged(nameof(IsOnline));
            RaiseSyncState();

            // The refresh that just happened may have rotated the token, and the rotated one is the only
            // one the next launch can use.
            await RememberSignInAsync(cancellationToken).ConfigureAwait(true);

            return resumed;
        }
        catch (OidcException exception)
        {
            // The provider answered and said no: the session was revoked, or the token was rotated and
            // this machine kept the old one. Retrying costs a round trip a minute and can only ever get
            // the same answer, so the token goes and the user is told the one thing that fixes it.
            await ForgetSignInAsync(cancellationToken).ConfigureAwait(true);

            Announce($"Your sign-in has expired, so this machine is offline: {exception.Message} "
                + "Sign in again from Preferences to start syncing.");

            return null;
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            // Everything else is a machine with no network, a server that is down, or a vault that was
            // locked mid-attempt — all of which are ordinary and all of which resolve themselves. The
            // titlebar already says OFFLINE; a socket error once a minute would say nothing more.
            return null;
        }
    }

    /// <summary>
    /// Writes the connection's current refresh token into the vault, if it has changed.
    /// </summary>
    /// <remarks>
    /// Called on every pass rather than driven by an event, because providers rotate the token inside a
    /// refresh that happens on whatever thread an API call was made from — and a value read once a minute
    /// is current enough for something only a relaunch reads. A failure here costs one browser sign-in on
    /// the next launch and nothing else, which is not worth interrupting anybody over.
    /// </remarks>
    private async Task RememberSignInAsync(CancellationToken cancellationToken)
    {
        if (connection?.RefreshToken is not { } token
            || Vault is not { } vault
            || string.Equals(token, rememberedToken, StringComparison.Ordinal))
        {
            return;
        }

        try
        {
            await vault.Session.RememberSignInAsync(token, cancellationToken).ConfigureAwait(true);
            rememberedToken = token;
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            // Left unremembered. The application is signed in for this run either way.
        }
    }

    /// <summary>Drops the remembered sign-in, so nothing tries to resume it again.</summary>
    private async Task ForgetSignInAsync(CancellationToken cancellationToken)
    {
        rememberedToken = null;

        if (Vault is not { } vault)
        {
            return;
        }

        try
        {
            await vault.Session.ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            // A cache that will not take the deletion is one the next launch will fail to resume from and
            // then delete itself. Nothing here is worth a message.
        }
    }

    /// <summary>
    /// Says something wherever the user is looking.
    /// </summary>
    /// <remarks>
    /// The shell's own message is on the setup and unlock cards, and the status bar shows the vault's — so
    /// a message about the connection, which is the shell's business but only interesting while somebody
    /// is using an open vault, has to go to both or it is invisible half the time.
    /// </remarks>
    private void Announce(string message)
    {
        StatusMessage = message;

        if (Vault is { } vault)
        {
            vault.Status = message;
        }
    }

    /// <summary>
    /// Closes the vault and forgets every key it held. Open shells keep running.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Lock is a vault operation, and deliberately not a disconnect.</b> The reason a person locks is
    /// that they are walking away from the machine, which is exactly the moment a long upgrade, build or
    /// transfer is most likely to be in flight — so killing every shell would make Lock a button that
    /// destroys work, and the predictable response is to stop using it and leave the vault open instead.
    /// The same argument decides it for the idle auto-lock this will grow: an unattended timeout that
    /// terminated a running job would be worse than the exposure it removes.
    /// </para>
    /// <para>
    /// <b>What "locked" therefore describes.</b> Disposing the vault zeroes the identity keys, the vault
    /// keys and the cache key, so nothing on disk can be read without the passphrase again. It says
    /// nothing about this machine's access to remote hosts: an SSH channel authenticated at connect time
    /// needs no vault key to keep running, and the credential it used was already spent. Locking cannot
    /// retroactively un-authorise a session any more than revocation can — the same honest limit the
    /// README records for a removed team member. So a locked DodoSSH still holds open, authenticated
    /// channels, and <see cref="LiveSessionCount"/> is shown on the unlock screen rather than left to be
    /// inferred from a terminal that the lock screen hides.
    /// </para>
    /// <para>
    /// The count is a snapshot taken here. While locked it can only fall — opening a session needs the
    /// vault — so a stale value over-reports and never under-reports, which is the safe direction for a
    /// warning of this kind.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task LockAsync()
    {
        // First, and before the session it read from goes: a synchronisation pass may be in flight, and it
        // ends by refreshing this store. Detaching now makes that refresh a no-op instead of a set of pins
        // reappearing behind a lock screen.
        knownHosts.Close();

        // Beside it, and for the mirror-image reason: no new connection may be filed into a vault that is
        // about to be disposed. Tickets already open keep the repository they were opened against, so a
        // shell still running closes out into the vault it was actually made in.
        connectionLog.Close();

        // Before the vault goes, because its host rows carry decrypted secrets and the transfers screen is
        // holding references to them. What it does not give up is its connection or its queue — a transfer
        // in flight is exactly the work this method exists not to destroy.
        transfers.Detach();

        if (Vault is { } open)
        {
            Vault = null;
            await open.DisposeAsync().ConfigureAwait(true);
        }

        LiveSessionCount = workspace.LiveSessionCount;

        // A confirmation armed on the preferences screen must not survive onto the unlock screen, where
        // the same card is offered with a warning it can no longer count.
        IsConfirmingSignOut = false;

        State = ShellState.Locked;
        StatusMessage = "Locked.";
    }

    // ---- Signing out ----

    /// <summary>Whether the sign-out confirmation is showing.</summary>
    /// <remarks>
    /// A state rather than a dialog, for the same reason the recovery code is a screen: this is the one
    /// action in the application that destroys something a user cannot get back from here — an unpushed
    /// change — and it has to be able to say what is about to go before it goes.
    /// </remarks>
    [ObservableProperty]
    private bool isConfirmingSignOut;

    /// <summary>
    /// What signing out costs, on this machine, right now.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The count is the part worth having. Everything else in the vault is on the server and comes back
    /// with the next sign-in; an operation still in the outbox exists nowhere else in the world, and
    /// "your changes will be lost" without a number leaves somebody guessing whether it means theirs.
    /// </para>
    /// <para>
    /// A locked vault cannot be counted — the outbox is sealed under the key the vault holds — so it gets
    /// the honest form of the same warning rather than a zero it has not earned.
    /// </para>
    /// </remarks>
    internal string SignOutWarning => (IsUnlocked, Vault?.PendingChanges ?? 0) switch
    {
        (false, _) =>
            "Anything this machine changed and has not sent to the server yet will be lost. It cannot be "
            + "counted from here, because the keychain is locked.",
        (true, 0) =>
            "Everything this machine has changed has reached the server, so nothing will be lost.",
        (true, 1) =>
            "1 change has not reached the server yet and will be lost. Sync first to keep it.",
        (true, var pending) =>
            $"{pending} changes have not reached the server yet and will be lost. Sync first to keep them.",
    };

    /// <summary>Asks whether the user means it.</summary>
    [RelayCommand]
    private void SignOut()
    {
        // Taken now so the card can disclose it, on the same reasoning as the lock screen's: signing out
        // does not close a shell any more than locking does, and a screen that sends somebody back to
        // "connect to your server" while their upgrade is still running should say so.
        LiveSessionCount = workspace.LiveSessionCount;

        OnPropertyChanged(nameof(SignOutWarning));

        IsConfirmingSignOut = true;
    }

    /// <summary>Thinks better of it.</summary>
    [RelayCommand]
    private void CancelSignOut() => IsConfirmingSignOut = false;

    /// <summary>
    /// Signs out: closes the vault, withdraws this machine, and deletes its copy of everything.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>What this does and does not destroy.</b> It empties the local cache — the profile, the wrapped
    /// bundle, the item mirror, the outbox and the conflict log — and forgets this machine's device key
    /// here and on the account. The vault itself is on the server and is untouched, which is what makes
    /// this safe to offer beside a passphrase box: somebody who has forgotten their passphrase can reset
    /// this machine and sign in again, and the only thing they lose is what this machine had not yet sent.
    /// </para>
    /// <para>
    /// <b>Ordered so that a failure cannot leave a half-signed-out machine.</b> The device is withdrawn
    /// while there is still a session and a connection to withdraw it through; the vault is closed before
    /// the cache under it is emptied; and the cache is emptied last, because it is the step that makes
    /// this machine unenrolled and everything before it is a courtesy that a wiped profile makes moot.
    /// </para>
    /// <para>
    /// It does not end the session at the identity provider — there is no back channel to it from here,
    /// and pretending otherwise would be the sort of claim this project writes down instead of implying.
    /// The refresh token this machine held is dropped and never used again; the provider's own session
    /// outlives it, which is what the preferences screen says out loud.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task ConfirmSignOutAsync(CancellationToken cancellationToken)
    {
        await RunAsync(
            "Signing out…",
            async () =>
            {
                IsConfirmingSignOut = false;

                await WithdrawThisMachineAsync(cancellationToken).ConfigureAwait(true);

                // As Lock does, and before the session it reads from goes.
                knownHosts.Close();
                connectionLog.Close();

                // The same detach locking does, and the same reasoning carried one step further: the host
                // rows go because the vault behind them is about to be disposed, and the session and its
                // queue stay because a transfer in flight is somebody's work. Signing out is the strongest
                // thing this application does to itself and it still does not destroy that, for exactly the
                // reason it does not close a shell — quitting DodoSSH is what ends both.
                transfers.Detach();

                if (Vault is { } open)
                {
                    Vault = null;
                    await open.DisposeAsync().ConfigureAwait(true);
                }

                connection?.Dispose();
                connection = null;
                rememberedToken = null;

                await caches.ResetAsync(cancellationToken).ConfigureAwait(true);

                LiveSessionCount = workspace.LiveSessionCount;

                AccountName = null;
                Passphrase = string.Empty;
                ConfirmPassphrase = string.Empty;
                RecoveryCode = null;
                RecoveryCodeWrittenDown = false;
                CanUnlockWithDevice = false;
                CanRegisterDevice = false;
                CanForgetDevice = false;

                State = ShellState.NeedsServer;

                OnPropertyChanged(nameof(IsOnline));
                RaiseSyncState();

                StatusMessage = "Signed out. This machine's copy of the keychain has been deleted; the "
                    + "keychain itself is untouched. Sign in to set this machine up again.";
            }).ConfigureAwait(true);
    }

    /// <remarks>
    /// Best effort, and swallowed on purpose. Withdrawing the device is the tidy half of signing out — the
    /// half that stops the account listing a machine whose key is about to be deleted — and a server that
    /// cannot be reached, or a keystore that declines, must not be able to strand somebody on a screen
    /// they asked to leave. The half that decides whether this machine can let itself in happens anyway,
    /// because the profile holding the wrap is emptied a moment later.
    /// </remarks>
    private async Task WithdrawThisMachineAsync(CancellationToken cancellationToken)
    {
        try
        {
            if (Vault is { } vault)
            {
                await vault.Session
                    .ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken)
                    .ConfigureAwait(true);

                await vault.Session.ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
                return;
            }

            await deviceKeys.ForgetAsync(cancellationToken).ConfigureAwait(true);
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            // Nothing to report: the wipe below is what signing out actually is.
        }
    }

    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        if (disposed)
        {
            return;
        }

        disposed = true;

        workspace.SessionEnded -= OnWorkspaceSessionEnded;

        knownHosts.Close();

        // Detached before it is disposed, so a session torn down after this point finds nothing to post to
        // rather than a completed channel. Disposed rather than merely closed, because it owns a background
        // task — and it waits only as long as that task takes to stop, never for the queue to drain.
        workspace.ConnectionLog = null;
        await connectionLog.DisposeAsync().ConfigureAwait(false);

        // Before the vault, and it waits: a transfer still writing has an open remote file and an open local
        // one, and a process that exits while those are in flight leaves a part file longer than the bytes
        // that reached it.
        await transfers.DisposeAsync().ConfigureAwait(false);

        if (Vault is { } open)
        {
            await open.DisposeAsync().ConfigureAwait(false);
        }

        connection?.Dispose();
    }

    /// <remarks>
    /// The passphrase is the entire defence for the vault — docs/crypto.md §2 says so plainly, and no
    /// server-side reset exists. A length floor is a crude check and still the one that matters most.
    /// </remarks>
    private bool ValidateNewPassphrase()
    {
        if (Passphrase.Length < 12)
        {
            StatusMessage = "Use a passphrase of at least 12 characters.";
            return false;
        }

        if (!string.Equals(Passphrase, ConfirmPassphrase, StringComparison.Ordinal))
        {
            StatusMessage = "The two passphrases do not match.";
            return false;
        }

        return true;
    }

    /// <remarks>
    /// Sync limits come from the server when there is one, so a batch is never larger than this
    /// particular deployment accepts. Offline, the defaults apply and nothing is pushed anyway.
    /// </remarks>
    private SessionOpener Opener() => new(caches, clock, connection?.SyncOptions);

    private AccountProvisioner? Provisioner() =>
        connection is null
            ? null
            : new AccountProvisioner(
                connection.Account, connection.KeyBinding, caches, clock, passphraseProfile);

    /// <remarks>
    /// Every command funnels through here so the busy flag and the failure message are handled once. A
    /// command that forgot either would leave the window permanently disabled or silently doing nothing.
    /// </remarks>
    /// <param name="busyMessage">Shown while the work runs.</param>
    /// <param name="work">The work.</param>
    /// <param name="explain">
    /// Turns a failure into something a user can act on. Optional, because most failures here already
    /// carry their own explanation; the ones that do not are the ones crossing into another process's
    /// vocabulary, where the exception describes a symptom and not the mistake.
    /// </param>
    private async Task RunAsync(
        string busyMessage,
        Func<Task> work,
        Func<Exception, string>? explain = null)
    {
        if (IsBusy)
        {
            return;
        }

        IsBusy = true;
        StatusMessage = busyMessage;

        try
        {
            await work().ConfigureAwait(true);
        }
        catch (OperationCanceledException)
        {
            StatusMessage = "Cancelled.";
        }
        catch (Exception exception)
        {
            StatusMessage = explain?.Invoke(exception) ?? exception.Message;
        }
        finally
        {
            IsBusy = false;
        }
    }

    /// <remarks>
    /// <para>
    /// Two cases earn a translation rather than the exception's own words, and both are cases where the
    /// exception names a symptom belonging to somebody else's process.
    /// </para>
    /// <para>
    /// Pointing an HTTPS client at a plaintext port reports "The SSL connection could not be
    /// established", which sends people looking for a certificate problem. The scheme is the mistake, and
    /// the development stack serves HTTP, so this is the first thing a new user will hit.
    /// </para>
    /// <para>
    /// A 5xx from the DodoSSH server is the other. By the time it arrives the browser flow has already
    /// succeeded — the identity provider authenticated the user and the tokens are in hand — so "The
    /// server returned 500" read underneath a sign-in button is naturally taken as the sign-in having
    /// failed, and the search starts in the wrong place. Naming which server, and saying that its own
    /// logs hold the reason, is the whole content of the fix; the client cannot know more than that.
    /// </para>
    /// </remarks>
    private static string ExplainSignInFailure(Exception exception, Uri server)
    {
        if (exception is DodoSshApiException api && (int)api.StatusCode >= 500)
        {
            return $"Signing in worked. The DodoSSH server at {server.Host} then failed while answering "
                + $"for your account ({(int)api.StatusCode}), which is a fault on the server rather than "
                + "anything to fix here — its own logs carry the reason.";
        }

        var secureChannelFailed = exception is HttpRequestException
            && exception.GetBaseException() is AuthenticationException;

        if (secureChannelFailed && server.Scheme is "https")
        {
            var plain = new UriBuilder(server) { Scheme = "http" }.Uri;

            return $"{exception.Message} {server.Host} answered, but not with TLS. If this is a "
                + $"development server it probably serves plain HTTP — try {plain.GetLeftPart(UriPartial.Authority)}.";
        }

        return exception.Message;
    }

    /// <remarks>
    /// One place for the subscription, so unlocking, locking and disposing all route through it rather
    /// than each remembering to detach.
    /// </remarks>
    partial void OnVaultChanged(VaultViewModel? oldValue, VaultViewModel? newValue)
    {
        if (oldValue is not null)
        {
            oldValue.PropertyChanged -= OnVaultPropertyChanged;
            oldValue.Hosts.CollectionChanged -= OnVaultHostsChanged;

            // The three connection events are kept while an attempt is still in flight, and that is not an
            // oversight. Locking does not end a handshake any more than it ends a shell — the workspace is
            // what holds both, and it outlives every vault — so a connection started just before a lock still
            // has an answer coming, and the tab standing in for it is still in the strip afterwards, because
            // tabs are this object's rather than the vault's. Detaching here would strand that tab on
            // "connecting…" for ever and leave the session it eventually opened with nothing in the window
            // naming it, and so no way to close it. The subscription dies with the vault once the attempt
            // resolves: the vault holds the handler, not the other way round.
            if (attempts.Count == 0)
            {
                oldValue.ConnectionStarting -= OnVaultConnectionStarting;
                oldValue.ConnectionFailed -= OnVaultConnectionFailed;
                oldValue.SessionOpened -= OnVaultSessionOpened;
            }
        }

        if (newValue is not null)
        {
            newValue.ConnectionStarting += OnVaultConnectionStarting;
            newValue.ConnectionFailed += OnVaultConnectionFailed;
            newValue.SessionOpened += OnVaultSessionOpened;
            newValue.PropertyChanged += OnVaultPropertyChanged;

            // The host list is rebuilt from scratch on every synchronisation pass, and a rebuilt row starts
            // disconnected — so without this the status dots go out once a minute underneath terminals that
            // are still open. The rows belong to the vault and the connection state belongs to the shell,
            // which is exactly why the shell has to repaint them rather than the vault carrying the flag.
            newValue.Hosts.CollectionChanged += OnVaultHostsChanged;
        }

        // Built from the vault and thrown away with it, here rather than at each of the three places a
        // vault is opened or closed. It holds a subscription to the vault's pin list, so leaving one behind
        // would keep a disposed vault alive and repaint a screen nobody can reach.
        KnownHostsScreen?.Detach();
        KnownHostsScreen = newValue is null ? null : new KnownHostsViewModel(newValue);
        ImportScreen = newValue is null ? null : new ImportViewModel(newValue, new SshConfigLocator());

        SnippetsScreen?.Detach();
        SnippetsScreen = newValue is null
            ? null
            : new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync);

        LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections);

        RaiseSyncState();
    }

    /// <remarks>
    /// One property is watched rather than all of them: the titlebar's sync state is the vault's outbox
    /// depth, which lives on the vault, and re-raising the shell's two derived properties on every
    /// notification a busy vault produces would repaint the titlebar on every keystroke in an editor.
    /// </remarks>
    private void OnVaultPropertyChanged(object? sender, PropertyChangedEventArgs e)
    {
        if (string.Equals(e.PropertyName, nameof(VaultViewModel.PendingChanges), StringComparison.Ordinal)
            || string.Equals(e.PropertyName, nameof(VaultViewModel.LastSyncFailed), StringComparison.Ordinal))
        {
            RaiseSyncState();
        }
    }

    private void OnVaultHostsChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
        RefreshConnectedHosts();

    private void RaiseSyncState()
    {
        OnPropertyChanged(nameof(IsFullySynced));
        OnPropertyChanged(nameof(SyncLabel));

        // The same fact from a third direction: what signing out would cost is the outbox depth, and a
        // confirmation card left showing a count from before the last pass would be quoting a number that
        // has since been sent.
        OnPropertyChanged(nameof(SignOutWarning));
    }

    /// <summary>
    /// Puts a tab in the strip for a connection that has only just been asked for.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This is what stops connecting looking like the application having stopped. The tab appears in the same
    /// turn as the click, carrying its own status, and the window switches to it — so a handshake against a
    /// machine that is asleep is a card that says which machine, rather than a status line under a window
    /// that does nothing for thirty seconds.
    /// </para>
    /// <para>
    /// Kept by attempt id rather than by label: several connections can be in flight now that one does not
    /// hold the vault, and two of them can perfectly well be to the same host.
    /// </para>
    /// </remarks>
    private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
    {
        var tab = new TerminalTabViewModel(e.Label, e.Address);
        attempts[e.AttemptId] = tab;

        AdoptTab(tab);
    }

    /// <remarks>
    /// The vault opens SSH sessions and this shell owns the strip they appear in, so this is the seam between
    /// them and nothing more.
    /// </remarks>
    private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e)
    {
        if (!attempts.Remove(e.AttemptId, out var tab))
        {
            // No tab was opened for this attempt, which means the user closed the connecting tab while the
            // handshake was still running. The session is real and has to be adopted rather than dropped:
            // dropping it would leave a shell running with nothing in the window naming it.
            AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
            RefreshConnectedHosts();
            return;
        }

        tab.Opened(e.SessionId);

        // The pane exists from this moment, so what the rectangle should hold has changed — the card goes and
        // the WebView comes back. Only for the tab being looked at, which is what these flags already ask.
        RaiseTerminalState();

        // Now, and not when the tab appeared. Activating tells the renderer which pane to show, and there was
        // no pane to name until this line.
        Activate(tab);

        RefreshConnectedHosts();

        TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
    }

    /// <summary>
    /// Answers a connection that did not become a session.
    /// </summary>
    /// <remarks>
    /// Two outcomes, because there are two kinds of not-connecting. A refusal stays in the strip as a tab
    /// carrying its reason — connecting no longer holds the window, so the user may be three screens away by
    /// now, and the status line they are not looking at is not where a failure should end. A host key
    /// question is not a refusal: it is a prompt on the hosts screen, so the tab goes and the window is put
    /// back where the question is being asked.
    /// </remarks>
    private void OnVaultConnectionFailed(object? sender, ConnectionFailedEventArgs e)
    {
        if (!attempts.Remove(e.AttemptId, out var tab))
        {
            return;
        }

        if (!e.IsAwaitingAnAnswer)
        {
            tab.Failed(e.Reason);
            RaiseTerminalState();
            return;
        }

        var index = Tabs.IndexOf(tab);
        Tabs.Remove(tab);
        RaiseTabState();

        if (ReferenceEquals(SelectedTab, tab))
        {
            // The neighbour, preferring the one on the left, exactly as closing a tab by hand does.
            SelectedTab = Tabs.Count == 0
                ? null
                : Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
        }

        // The screen the question is drawn on, and the page rather than a terminal. A connection can be
        // started from the palette on any screen, so without this the prompt would be behind whatever the
        // user was looking at, with the connection waiting on an answer they cannot reach.
        Screen = ShellScreen.Hosts;
        Surface = ShellSurface.Page;
    }

    /// <summary>
    /// Takes a tab into the strip and shows it.
    /// </summary>
    /// <remarks>
    /// One method rather than one per way of opening a session, so the order of these steps is decided once.
    /// It is not arbitrary: the tab is in the strip before anything is told about it, so a handler runs
    /// against a strip that already shows what it is about.
    /// </remarks>
    private void AdoptTab(TerminalTabViewModel tab)
    {
        Tabs.Add(tab);
        RaiseTabState();

        // Selecting it is what tells the renderer to show its pane, through OnSelectedTabChanged — for a tab
        // that has one. A tab that is still connecting has none, and selecting it shows the card instead.
        SelectedTab = tab;

        // The surface, but deliberately not the screen. A session opened from the files screen shows its
        // terminal — that is what was asked for — and leaves Screen on Transfers, so closing the tab or
        // clicking away comes back to the transfer that is presumably still running.
        Surface = ShellSurface.Terminal;

        if (tab.HasSession)
        {
            TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
        }
    }

    /// <remarks>
    /// Everything the selection decides, in the order it has to be decided in: which tab is marked, what the
    /// terminal's rectangle holds, which hosts show as connected, and finally the frame that tells the
    /// renderer. See <see cref="Activate"/> for why the last of those is not awaited.
    /// </remarks>
    partial void OnSelectedTabChanged(TerminalTabViewModel? value)
    {
        foreach (var tab in Tabs)
        {
            tab.IsSelected = ReferenceEquals(tab, value);
        }

        // Which of the two things can be in the terminal's rectangle depends on the selected tab having a
        // session, so moving the selection is one of the ways that answer changes. It also repaints the
        // strip's active mark, which follows the selection and the surface together.
        RaiseTerminalState();

        RefreshConnectedHosts();

        // The snippets screen names the terminal its buttons will type into, and it has no way to learn that
        // a different tab is selected — the tab list is the shell's, and a subscription the other way would
        // be a screen keeping the shell alive.
        SnippetsScreen?.TargetChanged();

        if (value is not null)
        {
            Activate(value);
        }
    }

    /// <summary>Tells the renderer which pane to show.</summary>
    /// <remarks>
    /// <para>
    /// Fire-and-forget, and it has to be: one caller is a property setter, and a selection that awaited a
    /// socket write would make clicking a tab an operation that can fail. A dropped activation frame costs
    /// one wrong pane until the next click; blocking the setter would cost the tab strip.
    /// </para>
    /// <para>
    /// The workspace's own token is not available here, so this passes none. The send is a single frame on
    /// an already-open socket and returns immediately when there is no renderer.
    /// </para>
    /// <para>
    /// A tab with no session is skipped rather than sent as session zero, which is not a pane the renderer
    /// has: selecting a tab that is still connecting shows the card, and there is nothing to activate until
    /// the handshake finishes.
    /// </para>
    /// </remarks>
    private void Activate(TerminalTabViewModel tab)
    {
        if (!tab.HasSession)
        {
            return;
        }

        _ = workspace.ActivateSessionAsync(tab.SessionId, CancellationToken.None).AsTask();
    }

    /// <summary>Which terminal a snippet would go into right now.</summary>
    /// <remarks>
    /// The selected tab, and nothing cleverer. A snippet is typed into the terminal the user is working in,
    /// so "which one" has exactly the same answer as "which pane is on screen" — and a screen that picked,
    /// say, the most recently opened would send a command somewhere the user is not looking.
    /// </remarks>
    /// <summary>The connections that are open and therefore have no log entry yet.</summary>
    /// <remarks>
    /// Read from the recorder rather than from the tab strip, so the rows on the logs screen appear and
    /// vanish in step with the entries that will replace them. A tab is a nearly-but-not-quite equivalent —
    /// an SFTP session has no tab at all, and a tab whose remote hung up still has one.
    /// </remarks>
    private IReadOnlyList<LiveConnection> LiveConnections() =>
        [
            .. connectionLog.Open().Select(open => new LiveConnection(
                open.HostLabel, open.Address, open.StartedAt, Environment.MachineName)),
        ];

    private InsertTarget CurrentInsertTarget() =>
        SelectedTab is { } tab ? new InsertTarget(tab.SessionId, tab.Label) : InsertTarget.None;

    /// <summary>Brings one terminal's pane to the front, and shows it.</summary>
    /// <remarks>
    /// Both halves are needed. The strip is visible from every screen, so a click on it is as often "come
    /// back to my terminal" as it is "switch between two of them" — and selecting a pane the user cannot see
    /// would answer only one of those.
    /// </remarks>
    [RelayCommand]
    private void SelectTab(TerminalTabViewModel tab)
    {
        SelectedTab = tab;
        Surface = ShellSurface.Terminal;
    }

    /// <summary>
    /// Marks a tab dead when its shell ends on its own.
    /// </summary>
    /// <remarks>
    /// Marshalled onto the UI thread, because the workspace raises this from whichever thread the session's
    /// pump finished on and the tab list is only ever touched from one. The tab stays: its pane still holds
    /// the scrollback, and the renderer has already written the reason into it.
    /// </remarks>
    private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) =>
        Dispatcher.UIThread.Post(() =>
        {
            if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
            {
                tab.IsLive = false;
            }

            RefreshConnectedHosts();
        });

    /// <summary>
    /// Repaints the host list's status dots from the tab list.
    /// </summary>
    /// <remarks>
    /// Matched on the label, which is what a tab was named after, because that is the only handle the two
    /// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would
    /// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller
    /// wrong than a dot that goes dark when the vault is reopened.
    /// </remarks>
    private void RefreshConnectedHosts()
    {
        if (Vault is not { } vault)
        {
            return;
        }

        foreach (var host in vault.Hosts)
        {
            host.IsConnected = Tabs.Any(
                tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
        }
    }

    partial void OnLiveSessionCountChanged(int value)
    {
        OnPropertyChanged(nameof(HasLiveSessions));
        OnPropertyChanged(nameof(LiveSessionSummary));
    }

    partial void OnStateChanged(ShellState value)
    {
        OnPropertyChanged(nameof(IsStarting));
        OnPropertyChanged(nameof(IsNeedingServer));
        OnPropertyChanged(nameof(IsNeedingEnrollment));
        OnPropertyChanged(nameof(IsShowingRecoveryCode));
        OnPropertyChanged(nameof(IsLocked));
        OnPropertyChanged(nameof(IsAskingForThePassphrase));
        OnPropertyChanged(nameof(IsUnlocked));
        RaiseTerminalState();
        OnPropertyChanged(nameof(SignOutWarning));
        RaiseSyncState();

        // Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list.
        // The hosts screen is what this application is for. The surface as well as the screen: shells outlive
        // a lock, so there can be a selected tab from before it, and coming back to a terminal rather than to
        // the application would not be what "unlocked" looks like.
        if (value is ShellState.Unlocked)
        {
            Screen = ShellScreen.Hosts;
            Surface = ShellSurface.Page;
        }
    }

    /// <remarks>
    /// Every screen flag, on every change, for the same reason the vault column raises all four of its
    /// section flags: a rail lighting the current screen and a body showing it are one fact read from two
    /// directions, and raising only the one that became true leaves the old button lit.
    /// </remarks>
    partial void OnScreenChanged(ShellScreen value)
    {
        RaiseSurfaceState();

        // Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
        // thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
        // appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
        if (value is ShellScreen.Logs && LogsScreen is { } logs)
        {
            _ = logs.RefreshCommand.ExecuteAsync(null);
        }

        // Teams are read from the server rather than from the vault, so there is nothing to show until
        // somebody asks for it — and asking for it on every unlock would be a request per launch for a
        // screen most people never open. Fire-and-forget because a property change cannot await, and
        // because the view model turns every failure into its own status line rather than throwing.
        if (value is ShellScreen.Team)
        {
            _ = teams.LoadAsync(CancellationToken.None);
        }
    }

    /// <inheritdoc cref="OnScreenChanged" />
    partial void OnSurfaceChanged(ShellSurface value) => RaiseSurfaceState();

    /// <remarks>
    /// Both changes raise the same set, and they have to: <see cref="IsHostsShowing"/> and its four siblings
    /// read <see cref="Screen"/> and <see cref="Surface"/> together, so which of the two moved does not
    /// narrow what became stale.
    /// </remarks>
    private void RaiseSurfaceState()
    {
        OnPropertyChanged(nameof(IsHostsScreen));
        OnPropertyChanged(nameof(IsTransfersScreen));
        OnPropertyChanged(nameof(IsVaultScreen));
        OnPropertyChanged(nameof(IsTeamScreen));
        OnPropertyChanged(nameof(IsPreferencesScreen));
        OnPropertyChanged(nameof(IsKnownHostsScreen));
        OnPropertyChanged(nameof(IsImportScreen));
        OnPropertyChanged(nameof(IsSnippetsScreen));
        OnPropertyChanged(nameof(IsLogsScreen));

        OnPropertyChanged(nameof(IsShowingPages));
        OnPropertyChanged(nameof(IsHostsShowing));
        OnPropertyChanged(nameof(IsTransfersShowing));
        OnPropertyChanged(nameof(IsVaultShowing));
        OnPropertyChanged(nameof(IsTeamShowing));
        OnPropertyChanged(nameof(IsPreferencesShowing));
        OnPropertyChanged(nameof(IsKnownHostsShowing));
        OnPropertyChanged(nameof(IsSnippetsShowing));
        OnPropertyChanged(nameof(IsLogsShowing));

        RaiseTerminalState();
    }

    /// <summary>
    /// Re-reads what the terminal's rectangle should hold, and which tab is lit.
    /// </summary>
    /// <remarks>
    /// One method for all four, because they are one fact read from four directions: the surface, the
    /// selection and the selected tab's own state decide together whether a pane, a card or a page is drawn —
    /// and the strip's active mark has to agree with the answer. Raising a subset is how one of them ends up
    /// pointing at something nobody can see.
    /// </remarks>
    private void RaiseTerminalState()
    {
        OnPropertyChanged(nameof(IsTerminalSurface));
        OnPropertyChanged(nameof(IsTerminalShowing));
        OnPropertyChanged(nameof(IsConnectingShowing));

        // The tabs themselves, and not only the window's own flags. A tab that stayed lit after the user
        // navigated to preferences would be a second "you are here" mark pointing at a terminal that is not
        // on screen; see TerminalTabViewModel.IsShowing.
        foreach (var tab in Tabs)
        {
            tab.IsShowing = IsTerminalSurface && ReferenceEquals(tab, SelectedTab);
        }
    }

    partial void OnIsSearchingChanged(bool value) => RaiseTerminalState();

    /// <remarks>
    /// The unlock card and the confirmation swap, so arming one has to hide the other — see
    /// <see cref="IsAskingForThePassphrase"/>.
    /// </remarks>
    partial void OnIsConfirmingSignOutChanged(bool value) =>
        OnPropertyChanged(nameof(IsAskingForThePassphrase));

    partial void OnCanRegisterDeviceChanged(bool value) =>
        OnPropertyChanged(nameof(HasNoDeviceKeyOption));

    partial void OnCanForgetDeviceChanged(bool value) =>
        OnPropertyChanged(nameof(HasNoDeviceKeyOption));

    partial void OnSearchTextChanged(string value) => RefreshSearchResults();
}
