using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Globalization;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.ObjectStore;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Transfer;

namespace DodoSSH.Client.Shell.ViewModels;

/// <summary>What sort of remote the file browser's right-hand pane is showing.</summary>
internal enum RemoteKind
{
    /// <summary>A host, over SFTP.</summary>
    Host,

    /// <summary>An S3-compatible bucket.</summary>
    Bucket,
}

/// <summary>One segment of a path, as a button in a breadcrumb trail.</summary>
/// <param name="Name">What the segment is called.</param>
/// <param name="Path">The absolute path that reaches it.</param>
internal sealed record CrumbViewModel(string Name, string Path);

/// <summary>One remote file or directory, as a row.</summary>
internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
{
    internal SftpEntry Entry => entry;

    internal string Name => entry.Name;

    internal string FullPath => entry.FullPath;

    internal bool IsNavigable => entry.IsNavigable;

    internal bool IsFile => entry.Kind is SftpEntryKind.File;

    /// <remarks>
    /// A directory shows nothing rather than a zero. Its inode's size is a number no user has ever wanted,
    /// and a column of zeroes beside real sizes reads as a listing that failed to measure them.
    /// </remarks>
    internal string Size => entry.Kind is SftpEntryKind.File ? ByteSize.Format(entry.Length) : string.Empty;

    /// <remarks>
    /// Blank when there is no timestamp, for the reason the size above is blank for a directory. A bucket
    /// has no folders — a prefix is inferred from the keys under it — so the object store has nothing to
    /// report for one and leaves the timestamp at its default. Formatting that prints
    /// <c>0001-01-01 00:00</c>, which is not a quiet "unknown": it is a date, and a listing that states one
    /// for every folder in a bucket is stating something untrue.
    /// </remarks>
    internal string Modified =>
        entry.LastWriteTimeUtc == default ? string.Empty : Timestamps.Format(entry.LastWriteTimeUtc);

    /// <summary>The mode as <c>drwxr-xr-x</c>, which is the design's <c>PERMS</c> column.</summary>
    internal string Permissions => entry.Permissions;

    /// <summary>Whether the row is a file with an execute bit, which the NAME column colours for.</summary>
    internal bool IsExecutable => entry.IsExecutable;

    /// <summary>Whether the row is a file anyone may write to, which the PERMS column colours for.</summary>
    internal bool IsWorldWritable => entry.IsWorldWritable;
}

/// <summary>One local file or directory, as a row.</summary>
/// <remarks>
/// The same shape as the remote row minus the permissions, which have no honest value here: this client is
/// developed on Windows, where a POSIX mode is not a fact about a file. The column is empty on this side
/// rather than filled with a plausible-looking <c>-rw-r--r--</c>.
/// </remarks>
internal sealed class LocalEntryRowViewModel(LocalEntry entry)
{
    internal LocalEntry Entry => entry;

    internal string Name => entry.Name;

    internal string FullPath => entry.FullPath;

    internal bool IsNavigable => entry.IsDirectory;

    internal bool IsFile => !entry.IsDirectory;

    internal string Size => entry.IsDirectory ? string.Empty : ByteSize.Format(entry.Length);

    internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
}

/// <summary>How this screen writes a modification time.</summary>
/// <remarks>
/// <para>
/// UTC and ISO-ordered, in one place, because both panes show this column side by side: a local pane in this
/// machine's conventions beside a remote pane in the server's would invite comparing two timestamps that are
/// not written the same way, which is the only thing anybody does with this column.
/// </para>
/// <para>
/// The one place in this application that deliberately ignores the user's locale — see the App project's
/// <c>InvariantGlobalization</c>, which is false precisely so dates elsewhere follow it. Sortable order and
/// an unambiguous zone beat familiarity when the two columns have to be read against each other.
/// </para>
/// </remarks>
internal static class Timestamps
{
    internal static string Format(DateTimeOffset moment) =>
        moment.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
}

/// <summary>One transfer, as a row in the queue.</summary>
/// <remarks>
/// Observable and long-lived, unlike the two listing rows, because a transfer's progress changes several
/// times a second while its identity does not. It is refreshed from <see cref="TransferSnapshot"/> rather
/// than holding the queue's own object: the queue mutates its entries from a thread-pool thread, and this is
/// only ever read from the UI thread.
/// </remarks>
internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) : ObservableObject
{
    [ObservableProperty]
    private TransferSnapshot transfer = snapshot;

    internal Guid Id => Transfer.Id;

    internal string Name => Transfer.Name;

    /// <summary>Which way, as an arrow the eye can scan a column of.</summary>
    internal string Arrow => Transfer.Direction is TransferDirection.Download ? "↓" : "↑";

    /// <summary>The end that is not this machine, which is the one worth showing.</summary>
    internal string Path => Transfer.Direction is TransferDirection.Download
        ? Transfer.RemotePath
        : Transfer.LocalPath;

    internal double Percent => Transfer.Fraction * 100;

    /// <summary>
    /// What the row says about where it has got to.
    /// </summary>
    /// <remarks>
    /// The bytes and the rate together while it runs, because either alone leaves the obvious question
    /// unanswered — a rate with no total cannot say how long is left, and a total with no rate cannot say
    /// whether it is still moving.
    /// </remarks>
    internal string Progress => Transfer.State switch
    {
        TransferState.Queued => "queued",
        TransferState.Running =>
            $"{ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}"
            + $" · {ByteSize.Format((long)Transfer.BytesPerSecond)}/s",
        TransferState.Completed => ByteSize.Format(Transfer.Length),
        TransferState.Cancelled when Transfer.Transferred > 0 =>
            $"stopped at {ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}",
        TransferState.Cancelled => "stopped",
        _ => Transfer.Failure ?? "failed",
    };

    internal string StateLabel => Transfer.State switch
    {
        TransferState.Queued => "QUEUED",
        TransferState.Running => "RUNNING",
        TransferState.Completed => "DONE",
        TransferState.Cancelled => "STOPPED",
        _ => "FAILED",
    };

    internal bool IsRunning => Transfer.State is TransferState.Running or TransferState.Queued;

    internal bool IsFinished => Transfer.IsFinished;

    internal bool CanResume => Transfer.CanResume;

    internal bool HasFailed => Transfer.State is TransferState.Failed;

    /// <summary>Whether a stopped transfer is worth offering to run again at all.</summary>
    /// <remarks>
    /// Wider than <see cref="CanResume"/>: a transfer that failed before it moved a byte — the host was
    /// unreachable, the destination was occupied — is worth retrying from the start, and only the button's
    /// wording differs. See <see cref="RetryLabel"/>.
    /// </remarks>
    internal bool CanRetry => Transfer.IsFinished && Transfer.State is not TransferState.Completed;

    internal string RetryLabel => CanResume ? "RESUME" : "RETRY";

    /// <remarks>
    /// Every derived member at once. They are one fact — the snapshot — read from eight directions, and
    /// raising only the ones that happened to change is how a progress bar moves under a label that still
    /// says "queued".
    /// </remarks>
    partial void OnTransferChanged(TransferSnapshot value)
    {
        OnPropertyChanged(nameof(Progress));
        OnPropertyChanged(nameof(Percent));
        OnPropertyChanged(nameof(StateLabel));
        OnPropertyChanged(nameof(IsRunning));
        OnPropertyChanged(nameof(IsFinished));
        OnPropertyChanged(nameof(CanResume));
        OnPropertyChanged(nameof(CanRetry));
        OnPropertyChanged(nameof(HasFailed));
        OnPropertyChanged(nameof(RetryLabel));
    }
}

/// <summary>
/// Something on the host that has been asked about and not yet agreed to.
/// </summary>
/// <remarks>
/// <para>
/// The one deletion in this application that nothing can walk back. A vault item is a tombstone against a
/// copy the server still holds until the pass lands; a file on somebody's host is bytes, and this screen
/// has no wastebasket to put them in.
/// </para>
/// <para>
/// It carries the full path rather than only the name, because the name is the half that does not identify
/// anything: <c>config</c> in the directory that was showing a moment ago and <c>config</c> in the one
/// showing now look identical in a confirmation, and only one of them is the file somebody meant.
/// </para>
/// </remarks>
/// <param name="Name">What the row was called.</param>
/// <param name="FullPath">Where it is, which is what the question actually promises to delete.</param>
/// <param name="IsDirectory">Whether it is a directory, which the host treats differently.</param>
internal sealed record RemoteDeletionRequest(string Name, string FullPath, bool IsDirectory)
{
    /// <summary>The question, naming the kind because the two behave differently.</summary>
    internal string Question => IsDirectory
        ? $"Delete the directory '{Name}' on the host?"
        : $"Delete '{Name}' on the host?";

    /// <summary>What it costs, which is everything: there is no copy here and no undo there.</summary>
    internal string Consequence => IsDirectory
        ? "It is removed on the host itself. The host refuses a directory that still has anything in it, so "
            + "this either removes an empty one or fails — and if it goes, it is gone: nothing here keeps a "
            + "copy and there is no undo."
        : "It is removed on the host itself. Nothing here keeps a copy, the folder on this machine is not "
            + "touched, and there is no undo.";
}

/// <summary>
/// The transfers screen: a host, two directory panes, and the queue between them.
/// </summary>
/// <remarks>
/// <para>
/// <b>Its connection is its own.</b> SSH.NET cannot open an SFTP subsystem on a transport that is already
/// carrying a shell — see <c>ISftpSession</c> — so this screen authenticates separately, and connecting here
/// is a deliberate act with its own button rather than something that happens because a terminal is open.
/// The consequence a user sees is that the host records a second login, and that a host whose password is
/// typed each time asks for it again here.
/// </para>
/// <para>
/// <b>It outlives a lock, as terminals do.</b> This object is created once and the vault is attached to it
/// on unlock and detached on lock, the same arrangement <c>VaultKnownHostStore</c> has and for the same
/// reason: <c>MainWindowViewModel.LockAsync</c> argues that locking must not destroy work in flight, and a
/// half-finished transfer is the clearest case of work in flight there is. What locking takes away is the
/// host list — those are decrypted vault items — and not the connection or the queue.
/// </para>
/// </remarks>
internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDisposable
{
    private readonly ISftpSessionFactory sftp;
    private readonly FileTransferQueue queue;
    private readonly Action<Action> post;

    private VaultViewModel? vault;
    private VaultKnownHostStore? knownHosts;
    private IRemoteFileStore? session;
    private ConnectionRecorder? connectionLog;

    /// <summary>How a bucket is opened, or null in a build that was not given one.</summary>
    private IObjectStoreFactory? objectStores;

    /// <summary>The open SFTP connection, as the log will record it, or null when there is none.</summary>
    /// <remarks>
    /// Held rather than rebuilt at close time, because by then the session is being disposed and the host
    /// row it came from may have been replaced by a background sync. The address is the one that was
    /// actually dialled, which is the whole point of capturing it at connect.
    /// </remarks>
    private (string Address, string HostLabel, Guid HostId, DateTimeOffset StartedAt)? connected;

    private bool disposed;

    /// <param name="sftp">Opens SFTP sessions.</param>
    /// <param name="clock">Time source, for transfer rates and timestamps.</param>
    /// <param name="post">
    /// Runs an action on the thread this view model's collections are read from. Defaults to the UI thread's
    /// dispatcher, which is the answer in every real head.
    /// <para>
    /// A delegate rather than <c>Dispatcher.UIThread</c> reached directly, for the reason
    /// <c>VaultViewModel</c>'s clipboard is one. <c>Dispatcher.UIThread</c> is process-wide and belongs to
    /// whichever thread touched it first, so a test that posts through it is asserting on a queue owned by
    /// some other test's thread — which passes or throws "the calling thread cannot access this object"
    /// depending on the order a runner happened to schedule its classes in. Running the action inline
    /// removes the thread from the question rather than making the test guess it right.
    /// </para>
    /// </param>
    internal TransfersViewModel(
        ISftpSessionFactory sftp,
        TimeProvider clock,
        Action<Action>? post = null)
    {
        this.sftp = sftp;
        this.post = post ?? (action => Dispatcher.UIThread.Post(action));

        // The supplier answers with whatever session is current at the moment a transfer starts, which is
        // what lets a queue survive a disconnect and reconnect without every queued row failing.
        queue = new FileTransferQueue(_ => Task.FromResult<IRemoteFileStore>(RequireSession()), clock);
        queue.Changed += OnTransferChanged;

        // The three "is there anything in it" flags follow their collections rather than being raised by
        // hand at each of the eight places that add or clear a row. Subscribed for the life of this object,
        // which is the life of the process.
        RemoteEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRemoteEntries));
        LocalEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasLocalEntries));
        Transfers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasTransfers));
    }

    /// <summary>The hosts that can be connected to, which is the vault's list.</summary>
    /// <remarks>
    /// A collection of its own rather than the vault's, because it is empty while locked and the vault's is
    /// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would
    /// be a second decrypted copy of a secret for no gain.
    /// </remarks>
    internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];

    [ObservableProperty]
    private HostRowViewModel? selectedHost;

    /// <summary>The buckets that can be browsed, which is the vault's list.</summary>
    /// <inheritdoc cref="Hosts" path="/remarks" />
    internal ObservableCollection<ObjectStoreRowViewModel> Buckets { get; } = [];

    [ObservableProperty]
    private ObjectStoreRowViewModel? selectedBucket;

    /// <summary>
    /// Which sort of remote the right-hand pane is about to open.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Two buttons and a command rather than one picker holding both kinds, which is the opposite of what
    /// the host editor's authentication picker does — and the reason is that these two are not
    /// interchangeable the way a key and a password are. A host brings a password box, a host key prompt and
    /// a mismatch refusal with it; a bucket brings none of those and has no equivalent. One picker would
    /// mean a form whose surrounding half appears and disappears with the selection, which is a worse thing
    /// to look at than two clearly separate choices.
    /// </para>
    /// <para>
    /// Settable, and the markup binds buttons rather than a selector's selection, for the reason the
    /// keychain's categories do: a selection binding moves before a command could refuse it.
    /// </para>
    /// </remarks>
    [ObservableProperty]
    private RemoteKind remote;

    /// <summary>Whether the picker is showing hosts.</summary>
    internal bool ShowsHostPicker => Remote is RemoteKind.Host;

    /// <summary>Whether the picker is showing buckets.</summary>
    internal bool ShowsBucketPicker => Remote is RemoteKind.Bucket;

    /// <summary>
    /// What the button that opens the remote says.
    /// </summary>
    /// <remarks>
    /// "Connect" is wrong for a bucket and worth not saying: S3 is request-per-operation, so nothing is
    /// connected and nothing stays open. A word that implied otherwise would make the absence of a
    /// DISCONNECT step look like a bug rather than the shape of the protocol.
    /// </remarks>
    internal string ConnectLabel => Remote is RemoteKind.Bucket ? "OPEN" : "CONNECT";

    /// <remarks>
    /// The transfers screen's own box, and deliberately not the one on the hosts screen. This connection is a
    /// separate authentication, so a password typed to open a terminal has not been offered here — and a
    /// screen that quietly reused it would make a one-time password appear to work twice.
    /// </remarks>
    [ObservableProperty]
    private string typedPassword = string.Empty;

    [ObservableProperty]
    private string status = "Choose a host and connect to browse its files.";

    [ObservableProperty]
    private bool isBusy;

    [ObservableProperty]
    private bool isConnected;

    /// <summary>
    /// Whether something is being dragged over the local pane, and whether it would be accepted.
    /// </summary>
    /// <remarks>
    /// Two flags rather than one tri-state, because the markup binds visibility and Avalonia has no
    /// three-way binding — and because the refusing state is worth showing rather than merely not showing
    /// the accepting one. A pane that lights up nowhere while something is dragged over it reads as a
    /// window that has stopped responding.
    /// </remarks>
    [ObservableProperty]
    private bool isLocalDropTarget;

    /// <inheritdoc cref="IsLocalDropTarget" />
    [ObservableProperty]
    private bool isRemoteDropTarget;

    /// <inheritdoc cref="IsLocalDropTarget" />
    [ObservableProperty]
    private bool isRemoteDropRefused;

    /// <summary>The account and endpoint actually dialled, once connected.</summary>
    [ObservableProperty]
    private string? connectedTo;

    [ObservableProperty]
    private HostKeyPresentation? pendingHostKey;

    [ObservableProperty]
    private string? hostKeyMismatch;

    internal bool HasPendingHostKey => PendingHostKey is not null;

    internal bool HasHostKeyMismatch => HostKeyMismatch is not null;

    /// <summary>Whether the chosen host will want something typed into the password box.</summary>
    /// <remarks>
    /// The resolved binding, for the reason its counterpart on <c>VaultViewModel</c> gives: a host naming
    /// neither a key nor a credential used to mean "type one" and now means "take the group's". This screen
    /// has its own password box and so needs its own copy of the question — but it must give the same
    /// answer, or one screen would ask for a password the other knew was not wanted.
    /// </remarks>
    internal bool SelectedHostAsksForAPassword =>
        ShowsHostPicker
        && (SelectedHost is null
            || SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);

    // ---- The remote pane ----

    [ObservableProperty]
    private string remotePath = string.Empty;

    internal ObservableCollection<RemoteEntryRowViewModel> RemoteEntries { get; } = [];

    internal ObservableCollection<CrumbViewModel> RemoteTrail { get; } = [];

    [ObservableProperty]
    private RemoteEntryRowViewModel? selectedRemoteEntry;

    /// <summary>What a new directory would be called, when the user is making one.</summary>
    [ObservableProperty]
    private string newRemoteFolder = string.Empty;

    internal bool HasRemoteEntries => RemoteEntries.Count > 0;

    /// <summary>The deletion on the host that has been asked about, or null when none has.</summary>
    [ObservableProperty]
    private RemoteDeletionRequest? pendingRemoteDeletion;

    internal bool IsConfirmingRemoteDeletion => PendingRemoteDeletion is not null;

    /// <summary>Whether the pane's DELETE is live.</summary>
    /// <remarks>
    /// Off while its own question is up, so a second press cannot arm a second one behind the card — and
    /// disabled rather than hidden, because this button sits in a row of three and a gap where it was would
    /// move UP and REFRESH out from under the pointer.
    /// </remarks>
    internal bool CanDeleteRemote => IsConnected && !IsConfirmingRemoteDeletion;

    // ---- The local pane ----

    [ObservableProperty]
    private string localPath = LocalDirectory.Home;

    internal ObservableCollection<LocalEntryRowViewModel> LocalEntries { get; } = [];

    internal ObservableCollection<CrumbViewModel> LocalTrail { get; } = [];

    /// <summary>
    /// The drives this machine has, as somewhere the local pane can jump to.
    /// </summary>
    /// <remarks>
    /// The remote pane's breadcrumb reaches everywhere, because a POSIX filesystem has one root. This one
    /// does not: above <c>C:\</c> is a list of drives rather than a directory, so without this the pane
    /// could be walked to the top of the drive it opened on and no further — and a file on <c>D:</c> would
    /// be unreachable from an application whose whole purpose on this screen is to move one.
    /// </remarks>
    internal ObservableCollection<CrumbViewModel> LocalRoots { get; } = [];

    [ObservableProperty]
    private LocalEntryRowViewModel? selectedLocalEntry;

    internal bool HasLocalEntries => LocalEntries.Count > 0;

    // ---- The queue ----

    internal ObservableCollection<TransferRowViewModel> Transfers { get; } = [];

    internal bool HasTransfers => Transfers.Count > 0;

    /// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
    internal bool CanDownload => IsConnected && SelectedRemoteEntry is { IsFile: true };

    /// <summary>Whether an upload of the chosen local file would have somewhere to go.</summary>
    internal bool CanUpload => IsConnected && SelectedLocalEntry is { IsFile: true };

    /// <summary>Takes an unlocked vault, so the host list has something in it.</summary>
    /// <param name="openVault">The open keychain.</param>
    /// <param name="hostKeys">The pins this screen's own trust decisions are written to.</param>
    /// <param name="log">
    /// Where an SFTP session is recorded, or null to record none. Arrives here rather than being read off
    /// the vault, for the reason the recorder itself exists: it outlives the vault, and a session still open
    /// when the keychain locks still ends somewhere.
    /// </param>
    internal void Attach(
        VaultViewModel openVault,
        VaultKnownHostStore hostKeys,
        ConnectionRecorder? log = null,
        IObjectStoreFactory? buckets = null)
    {
        vault = openVault;
        knownHosts = hostKeys;
        connectionLog = log;
        objectStores = buckets;

        // Followed rather than copied once, which is the difference between a picker that is right at unlock
        // and one that is right afterwards. A host or a bucket added on this machine — or pulled in by a
        // synchronisation pass from another — rebuilds the vault's collections, and a screen that had taken a
        // snapshot at unlock went on offering the list as it was when the vault opened. Detached again in
        // Detach: these collections belong to a vault that is about to be disposed.
        openVault.Hosts.CollectionChanged += OnVaultListChanged;
        openVault.ObjectStores.CollectionChanged += OnVaultListChanged;

        RefreshHosts();

        // Read once per unlock rather than per navigation: a drive appearing while the application is open
        // is possible and rare, and probing every removable drive on every click into a folder is not.
        LocalRoots.Clear();

        foreach (var root in LocalDirectory.Roots())
        {
            LocalRoots.Add(new CrumbViewModel(root.TrimEnd(Path.DirectorySeparatorChar), root));
        }

        RefreshLocalCommand.Execute(null);
    }

    /// <summary>
    /// Gives up the vault, keeping the connection and anything in flight.
    /// </summary>
    /// <remarks>
    /// The host list goes because those rows carry decrypted secrets and the vault they came from is being
    /// disposed. The session and the queue stay, which is the whole point: see the remark on this type.
    /// </remarks>
    internal void Detach()
    {
        if (vault is { } open)
        {
            open.Hosts.CollectionChanged -= OnVaultListChanged;
            open.ObjectStores.CollectionChanged -= OnVaultListChanged;
        }

        vault = null;
        knownHosts = null;

        Hosts.Clear();
        Buckets.Clear();
        SelectedHost = null;
        SelectedBucket = null;
        TypedPassword = string.Empty;
    }

    // ShowRemoteCommand was here, and it went with the toggle that invoked it. Which kind of remote this
    // screen offers is a destination now rather than a control on the screen — both heads reach it through
    // MainWindowViewModel.ShowFiles, which sets Remote directly because it has a refusal to make first.
    // Keeping the command would have left one nothing could invoke.

    /// <summary>Opens the chosen remote, whichever kind it is.</summary>
    [RelayCommand]
    private Task ConnectAsync(CancellationToken cancellationToken) =>
        Remote is RemoteKind.Bucket
            ? OpenBucketAsync(cancellationToken)
            : ConnectToHostAsync(cancellationToken);

    /// <summary>
    /// Opens the chosen bucket.
    /// </summary>
    /// <remarks>
    /// <para>
    /// No host key prompt, no password box, and no connect step: S3 is request-per-operation, so the factory
    /// only builds a client and the first listing is what actually tests the keys and the endpoint. That is
    /// why the failure this reports is a listing failure rather than a connection one — there is no
    /// connection to fail.
    /// </para>
    /// <para>
    /// It goes through the same session field, the same queue and the same panes as a host, because by this
    /// point it is an <c>IRemoteFileStore</c> like any other. Everything below this method was written for
    /// SFTP and needed no change.
    /// </para>
    /// </remarks>
    private async Task OpenBucketAsync(CancellationToken cancellationToken)
    {
        if (objectStores is not { } factory)
        {
            Status = "This build cannot open buckets.";
            return;
        }

        if (SelectedBucket is not { } row)
        {
            Status = "Choose a bucket first.";
            return;
        }

        PendingHostKey = null;
        HostKeyMismatch = null;

        await RunAsync(
            $"Opening {row.Label}…",
            async () =>
            {
                await CloseSessionAsync().ConfigureAwait(true);

                session = factory.Open(row.Store);

                IsConnected = true;
                ConnectedTo = string.Create(
                    CultureInfo.InvariantCulture, $"s3://{row.Store.Bucket}");

                connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());

                await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);

                Status = $"Opened {row.Label}.";
            }).ConfigureAwait(true);
    }

    /// <summary>Opens a file-transfer session on the chosen host.</summary>
    private async Task ConnectToHostAsync(CancellationToken cancellationToken)
    {
        if (vault is not { } open || SelectedHost is not { } row)
        {
            Status = "Choose a host first.";
            return;
        }

        if (!open.TryBuildConnectionRequest(row.Host, TypedPassword, out var request, out var refusal))
        {
            Status = refusal;
            return;
        }

        PendingHostKey = null;
        HostKeyMismatch = null;

        await RunAsync(
            $"Connecting to {row.Label}…",
            async () =>
            {
                await CloseSessionAsync().ConfigureAwait(true);

                try
                {
                    session = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
                }
                catch (SshHostKeyUnknownException exception)
                {
                    // First contact, decided here rather than inherited from a terminal. File transfer is
                    // its own connection, so it makes its own trust decision — and the pin it writes is the
                    // same pin a shell would then find.
                    PendingHostKey = exception.Presentation;
                    Status = "This host has not been seen before.";
                    return;
                }
                catch (SshHostKeyMismatchException exception)
                {
                    HostKeyMismatch = exception.Message;
                    Status = "The host key has changed. Nothing was connected.";
                    return;
                }

                TypedPassword = string.Empty;
                IsConnected = true;
                ConnectedTo = string.Create(
                    CultureInfo.InvariantCulture,
                    $"{request.Username}@{request.Host}:{request.Port}");

                // Recorded, and not hidden because it is "only" the file browser. Opening this is a second
                // login as far as the remote's own auth.log is concerned, so a log of ours that omitted it
                // would disagree with the host's — and anybody comparing the two would be right to believe
                // the host.
                connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());

                await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);

                Status = $"Connected to {row.Label}.";
            }).ConfigureAwait(true);
    }

    /// <summary>
    /// Closes the file-transfer session.
    /// </summary>
    /// <remarks>
    /// Refuses while the queue has work, rather than cancelling it. Disconnecting is a tidy-up and stopping
    /// a transfer is a decision about somebody's file; a button that did both would make the second one by
    /// accident.
    /// </remarks>
    [RelayCommand]
    private async Task DisconnectAsync()
    {
        if (queue.IsBusy)
        {
            Status = "There are transfers still running. Stop them first, or let them finish.";
            return;
        }

        await CloseSessionAsync().ConfigureAwait(true);

        Status = "Disconnected. Anything already transferred is where it landed.";
    }

    /// <summary>Pins the offered host key and connects.</summary>
    [RelayCommand]
    private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
    {
        if (PendingHostKey is not { } presentation || knownHosts is null)
        {
            return;
        }

        try
        {
            await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
        }
        catch (Exception exception) when (exception is not OperationCanceledException)
        {
            Status = $"The host key could not be stored, so nothing was connected: {exception.Message}";
            return;
        }

        PendingHostKey = null;

        await ConnectAsync(cancellationToken).ConfigureAwait(true);
    }

    /// <summary>
    /// Dismisses whichever host key card is showing, without pinning or forgetting anything.
    /// </summary>
    /// <remarks>
    /// One command for both cards, because both are dismissals and the two states are mutually exclusive.
    /// The mismatch card has nothing else it may offer: withdrawing a pin is a deliberate act performed in
    /// the host's editor, away from the moment of connecting, and a button here would be "continue anyway"
    /// with two clicks instead of one.
    /// </remarks>
    [RelayCommand]
    private void RejectHostKey()
    {
        var wasOffered = PendingHostKey is not null;

        PendingHostKey = null;
        HostKeyMismatch = null;

        Status = wasOffered
            ? "The host key was not trusted, so nothing was connected."
            : "Nothing was connected.";
    }

    // ---- Navigation ----

    /// <summary>Goes to a remote directory.</summary>
    [RelayCommand]
    private async Task GoRemoteAsync(string path, CancellationToken cancellationToken)
    {
        await NavigateRemoteAsync(path, cancellationToken).ConfigureAwait(true);
    }

    /// <summary>Goes up one remote directory.</summary>
    [RelayCommand]
    private async Task RemoteUpAsync(CancellationToken cancellationToken)
    {
        if (RemotePath.Length > 0)
        {
            await NavigateRemoteAsync(SftpPath.Parent(RemotePath), cancellationToken).ConfigureAwait(true);
        }
    }

    /// <summary>Re-reads the remote directory.</summary>
    [RelayCommand]
    private async Task RefreshRemoteAsync(CancellationToken cancellationToken)
    {
        if (RemotePath.Length > 0)
        {
            await NavigateRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
        }
    }

    /// <summary>
    /// Opens whatever is selected in the remote pane, if it is somewhere to go.
    /// </summary>
    /// <remarks>
    /// A symbolic link is tried as a directory: a listing carries <c>lstat</c> attributes, so a link to a
    /// directory reports as a link and resolving every one of them would be a round trip per row. The
    /// failure, when it is a link to a file, is the server's own and says so.
    /// </remarks>
    [RelayCommand]
    private async Task OpenRemoteAsync(CancellationToken cancellationToken)
    {
        if (SelectedRemoteEntry is { IsNavigable: true } row)
        {
            await NavigateRemoteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
        }
    }

    /// <summary>Goes to a local directory.</summary>
    [RelayCommand]
    private void GoLocal(string path) => NavigateLocal(path);

    /// <summary>Goes up one local directory, as far as the top of the drive.</summary>
    /// <remarks>
    /// Above a drive root there is no directory to list, so this stops there and says so. Getting to another
    /// drive is <see cref="LocalRoots"/>, which is a list of places rather than a step upwards.
    /// </remarks>
    [RelayCommand]
    private void LocalUp()
    {
        if (LocalDirectory.Parent(LocalPath) is { } parent)
        {
            NavigateLocal(parent);
            return;
        }

        Status = "That is the top of this drive. Use the drive list to go to another one.";
    }

    /// <summary>Re-reads the local directory.</summary>
    [RelayCommand]
    private void RefreshLocal() => NavigateLocal(LocalPath);

    /// <summary>Opens whatever is selected in the local pane, if it is a directory.</summary>
    [RelayCommand]
    private void OpenLocal()
    {
        if (SelectedLocalEntry is { IsNavigable: true } row)
        {
            NavigateLocal(row.FullPath);
        }
    }

    // ---- Moving files ----

    /// <summary>Queues the chosen remote file for download into the local directory showing.</summary>
    [RelayCommand]
    private void Download()
    {
        if (SelectedRemoteEntry is not { } row)
        {
            Status = "Choose a file on the host to download.";
            return;
        }

        QueueDownloads([row]);
    }

    /// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
    [RelayCommand]
    private void Upload()
    {
        if (SelectedLocalEntry is not { } row)
        {
            Status = "Choose a file on this machine to upload.";
            return;
        }

        QueueUploads([row.FullPath]);
    }

    /// <summary>
    /// Queues every one of these local paths for upload into the remote directory showing.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The one path both the button and a drop go through, so there is one set of rules about what can be
    /// queued rather than two that have to agree. The button hands it one path; a drop hands it however many
    /// were dragged, from this window's own pane or from the file manager.
    /// </para>
    /// <para>
    /// <b>Directories are skipped and counted.</b> The queue moves files: there is no recursive upload, and
    /// silently ignoring the folder somebody just dragged would look like a transfer that failed to start.
    /// </para>
    /// <para>
    /// <b>Reported per item, not per drop.</b> The queue refuses to overwrite, so a drop of five files where
    /// two names already exist is three transfers and two refusals — and "the drop failed" would be wrong
    /// about all five.
    /// </para>
    /// </remarks>
    internal void QueueUploads(IReadOnlyList<string> paths)
    {
        ArgumentNullException.ThrowIfNull(paths);

        if (!IsConnected)
        {
            Status = "Connect to a host first.";
            return;
        }

        var queued = 0;
        var directories = 0;
        var missing = 0;

        foreach (var path in paths)
        {
            if (Directory.Exists(path))
            {
                directories++;
                continue;
            }

            // Between the drag starting and the drop landing, a file can be moved or deleted — and the
            // paths in an OS drop come from another process, which is not obliged to be right about them.
            if (!File.Exists(path))
            {
                missing++;
                continue;
            }

            var length = new FileInfo(path).Length;
            var destination = SftpPath.Combine(RemotePath, Path.GetFileName(path));

            queue.Enqueue(TransferDirection.Upload, path, destination, length);
            queued++;
        }

        Status = Describe(queued, "upload into", RemotePath, directories, missing);
    }

    /// <summary>Queues every one of these remote entries for download into the local directory showing.</summary>
    /// <inheritdoc cref="QueueUploads" path="/remarks" />
    internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows)
    {
        ArgumentNullException.ThrowIfNull(rows);

        if (!IsConnected)
        {
            Status = "Connect to a host first.";
            return;
        }

        var queued = 0;
        var directories = 0;

        foreach (var row in rows)
        {
            if (!row.IsFile)
            {
                directories++;
                continue;
            }

            queue.Enqueue(
                TransferDirection.Download,
                Path.Combine(LocalPath, row.Name),
                row.FullPath,
                row.Entry.Length);

            queued++;
        }

        Status = Describe(queued, "download into", LocalPath, directories, missing: 0);
    }

    /// <remarks>
    /// One sentence for both directions and every shape of partial success. What it must never do is stay
    /// silent about the difference: a drop of six that queued four and reported "queued 4" leaves somebody
    /// looking for the other two in a queue they are not in.
    /// </remarks>
    private static string Describe(int queued, string verb, string destination, int directories, int missing)
    {
        var files = queued == 1 ? "1 file" : $"{queued} files";
        var said = queued == 0
            ? "Nothing was queued."
            : $"Queued {files} for {verb} {destination}.";

        if (directories > 0)
        {
            var folders = directories == 1 ? "1 folder was" : $"{directories} folders were";
            said += $" {folders} skipped — only files can be transferred.";
        }

        if (missing > 0)
        {
            var gone = missing == 1 ? "1 item was" : $"{missing} items were";
            said += $" {gone} no longer there.";
        }

        return said;
    }

    /// <summary>Stops one transfer.</summary>
    [RelayCommand]
    private void CancelTransfer(TransferRowViewModel row) => queue.Cancel(row.Id);

    /// <summary>Runs a stopped transfer again, resuming where there is something to resume from.</summary>
    [RelayCommand]
    private void RetryTransfer(TransferRowViewModel row) => queue.Retry(row.Id);

    /// <summary>Removes one stopped transfer and whatever it left behind.</summary>
    [RelayCommand]
    private async Task DiscardTransferAsync(TransferRowViewModel row, CancellationToken cancellationToken)
    {
        // Only when the queue agreed. It refuses to discard a transfer that has not finished, and a row
        // removed anyway would take the only view of a transfer that was still running.
        if (await queue.DiscardAsync(row.Id, cancellationToken).ConfigureAwait(true))
        {
            Transfers.Remove(row);
        }
    }

    /// <summary>Clears the finished transfers, which have nothing left on disk.</summary>
    [RelayCommand]
    private void ClearCompleted()
    {
        queue.ClearCompleted();

        foreach (var row in Transfers.Where(row => row.Transfer.State is TransferState.Completed).ToArray())
        {
            Transfers.Remove(row);
        }

    }

    // ---- Changing the remote directory ----

    /// <summary>Creates a directory on the host.</summary>
    [RelayCommand]
    private async Task CreateRemoteFolderAsync(CancellationToken cancellationToken)
    {
        var name = NewRemoteFolder.Trim();

        if (name.Length == 0)
        {
            Status = "Type a name for the new directory.";
            return;
        }

        if (name.Contains('/', StringComparison.Ordinal))
        {
            // One directory, whose parent must exist. A name with a separator in it would be a request to
            // create a path, and this button creates a directory in the one on screen.
            Status = "A directory name cannot contain '/'. Make one level at a time.";
            return;
        }

        await RunAsync(
            $"Creating {name}…",
            async () =>
            {
                await RequireSession()
                    .CreateDirectoryAsync(SftpPath.Combine(RemotePath, name), cancellationToken)
                    .ConfigureAwait(true);

                NewRemoteFolder = string.Empty;

                await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);

                Status = $"Created {name}.";
            }).ConfigureAwait(true);
    }

    /// <summary>
    /// Asks whether the chosen remote file, or empty directory, should go.
    /// </summary>
    /// <remarks>
    /// Deleting on the host is offered because the queue refuses to overwrite: without a way to remove what
    /// is in the way, "that file is already there" would be a dead end. It is asked about first because of
    /// what it is — the only thing this application destroys that neither the server nor this machine has a
    /// copy of.
    /// </remarks>
    [RelayCommand]
    private void DeleteRemote()
    {
        if (SelectedRemoteEntry is not { } row)
        {
            Status = "Choose something on the host to delete.";
            return;
        }

        PendingRemoteDeletion = new RemoteDeletionRequest(row.Name, row.FullPath, !row.IsFile);
    }

    /// <summary>
    /// Deletes what was agreed to.
    /// </summary>
    /// <remarks>
    /// Not recursive, and the refusal comes from the server rather than from a check here — see
    /// <c>ISftpSession.DeleteAsync</c>. It acts on the path the question named rather than on the selection,
    /// which is what makes the question a promise: nothing between asking and answering can point it
    /// somewhere else.
    /// </remarks>
    [RelayCommand]
    private async Task ConfirmDeleteRemoteAsync(CancellationToken cancellationToken)
    {
        if (PendingRemoteDeletion is not { } request)
        {
            return;
        }

        PendingRemoteDeletion = null;

        await RunAsync(
            $"Deleting {request.Name}…",
            async () =>
            {
                await RequireSession().DeleteAsync(request.FullPath, cancellationToken).ConfigureAwait(true);

                await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);

                Status = $"Deleted {request.Name}.";
            }).ConfigureAwait(true);
    }

    /// <summary>Thinks better of it.</summary>
    [RelayCommand]
    private void CancelDeleteRemote() => PendingRemoteDeletion = null;

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

        disposed = true;

        queue.Changed -= OnTransferChanged;

        // The queue first: it holds the session, and a transfer still writing into a part file has to finish
        // unwinding before the transport under it goes.
        await queue.DisposeAsync().ConfigureAwait(false);

        if (session is not null)
        {
            await session.DisposeAsync().ConfigureAwait(false);
            session = null;
        }
    }

    /// <summary>
    /// Goes to a remote directory, on its own.
    /// </summary>
    /// <remarks>
    /// Split from <see cref="ListRemoteAsync"/> so that the commands which navigate <em>as part of</em>
    /// something else — connecting, making a directory, deleting one — can list without going back through
    /// the busy guard. <see cref="RunAsync"/> returns immediately when a command is already running, so a
    /// nested call did nothing at all: the pane simply stayed empty after connecting, with no failure
    /// anywhere to explain it.
    /// </remarks>
    private Task NavigateRemoteAsync(string path, CancellationToken cancellationToken)
    {
        if (session is null)
        {
            Status = "Connect to a host first.";
            return Task.CompletedTask;
        }

        return RunAsync($"Reading {path}…", () => ListRemoteAsync(path, cancellationToken));
    }

    private async Task ListRemoteAsync(string path, CancellationToken cancellationToken)
    {
        var entries = await RequireSession().ListAsync(path, cancellationToken).ConfigureAwait(true);

        RemotePath = path;
        SelectedRemoteEntry = null;

        RemoteEntries.Clear();

        foreach (var entry in entries)
        {
            RemoteEntries.Add(new RemoteEntryRowViewModel(entry));
        }

        RemoteTrail.Clear();

        foreach (var (name, crumb) in SftpPath.Trail(path))
        {
            RemoteTrail.Add(new CrumbViewModel(name, crumb));
        }


        Status = string.Empty;
    }

    /// <remarks>
    /// Synchronous, unlike its remote counterpart. A local directory listing is a filesystem call rather than
    /// a network round trip, and wrapping it in a task would put a state machine and a thread hop behind
    /// something that returns before the click has finished being handled.
    /// </remarks>
    private void NavigateLocal(string path)
    {
        try
        {
            var entries = LocalDirectory.List(path);

            LocalPath = Path.GetFullPath(path);
            SelectedLocalEntry = null;

            LocalEntries.Clear();

            foreach (var entry in entries)
            {
                LocalEntries.Add(new LocalEntryRowViewModel(entry));
            }

            RebuildLocalTrail();

        }
        catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
        {
            // The pane stays where it was. A listing that failed leaves nothing to show, and emptying the
            // pane would look like a directory that had become empty.
            Status = $"{path} could not be read: {exception.Message}";
        }
    }

    /// <remarks>
    /// Built by walking up rather than by splitting on the separator, because a Windows path's first segment
    /// is <c>C:\</c> — a root with a separator inside it, which splitting turns into a crumb called
    /// <c>C:</c> that navigates to the process's current directory on that drive rather than to its root.
    /// </remarks>
    private void RebuildLocalTrail()
    {
        var crumbs = new List<CrumbViewModel>();

        for (var walk = LocalPath; walk is not null; walk = LocalDirectory.Parent(walk))
        {
            crumbs.Insert(0, new CrumbViewModel(Path.GetFileName(walk) is { Length: > 0 } name ? name : walk, walk));
        }

        LocalTrail.Clear();

        foreach (var crumb in crumbs)
        {
            LocalTrail.Add(crumb);
        }
    }

    private void OnVaultListChanged(object? sender, NotifyCollectionChangedEventArgs e) => RefreshHosts();

    /// <summary>
    /// Rebuilds both pickers from the vault's lists.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Called whenever either of those lists changes, not only at unlock — so a host created on the hosts
    /// screen, or a bucket added to the keychain, can be picked here without locking and unlocking first.
    /// </para>
    /// <para>
    /// <b>The selection is re-found by id rather than kept.</b> The vault replaces every row on every reload,
    /// which a synchronisation pass does once a minute, so holding the object would leave the picker showing
    /// nothing at all: the row it points at is no longer one of the items in the list. Re-finding it also
    /// means a host deleted elsewhere falls back to the first entry rather than to a selection that cannot be
    /// connected to.
    /// </para>
    /// </remarks>
    private void RefreshHosts()
    {
        var host = SelectedHost?.EntityId;
        var bucket = SelectedBucket?.EntityId;

        Hosts.Clear();

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

        foreach (var row in open.Hosts)
        {
            Hosts.Add(row);
        }

        SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == host) ?? Hosts.FirstOrDefault();

        Buckets.Clear();

        foreach (var row in open.ObjectStores)
        {
            Buckets.Add(row);
        }

        SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == bucket) ?? Buckets.FirstOrDefault();
    }

    /// <summary>The session, or a failure a queue row can carry.</summary>
    private IRemoteFileStore RequireSession() =>
        session ?? throw new InvalidOperationException(
            "This screen is not connected to a host, so there is nowhere to move the file.");

    private async Task CloseSessionAsync()
    {
        if (session is { } open)
        {
            session = null;
            await open.DisposeAsync().ConfigureAwait(true);
        }

        // Written whole here rather than through an open/close ticket, because this connection is not one
        // the terminal workspace ever knew about — it has no session id, and borrowing one would collide
        // with a real terminal's.
        if (connected is { } record)
        {
            connected = null;

            connectionLog?.Record(
                record.Address,
                record.HostLabel,
                record.HostId,
                ConnectionKind.Sftp,
                record.StartedAt,
                TimeProvider.System.GetUtcNow(),
                ConnectionOutcome.Closed);
        }

        IsConnected = false;
        ConnectedTo = null;
        RemotePath = string.Empty;
        RemoteEntries.Clear();
        RemoteTrail.Clear();
        SelectedRemoteEntry = null;

    }

    /// <remarks>
    /// The queue raises this from whichever thread its pump is on, so everything here is marshalled. The row
    /// is created on first sight rather than at enqueue time, which keeps one path for "a transfer changed"
    /// instead of one for the first change and one for the rest.
    /// </remarks>
    private void OnTransferChanged(object? sender, TransferChangedEventArgs e) =>
        post(() =>
        {
            if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing)
            {
                existing.Transfer = e.Transfer;
                return;
            }

            Transfers.Add(new TransferRowViewModel(e.Transfer));
        });

    /// <remarks>
    /// The same funnel <c>VaultViewModel</c> uses, and here for the same reason: every command on this screen
    /// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the
    /// pane permanently disabled.
    /// </remarks>
    private async Task RunAsync(string busyMessage, Func<Task> work)
    {
        if (IsBusy)
        {
            return;
        }

        IsBusy = true;
        Status = busyMessage;

        try
        {
            await work().ConfigureAwait(true);
        }
        catch (OperationCanceledException)
        {
            Status = "Cancelled.";
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            Status = exception.Message;
        }
        finally
        {
            IsBusy = false;
        }
    }

    partial void OnSelectedHostChanged(HostRowViewModel? value) =>
        OnPropertyChanged(nameof(SelectedHostAsksForAPassword));

    /// <remarks>
    /// The password box follows this as well as the host, because it is shown only for a host that asks for
    /// one — and a bucket never does. Without this, switching to BUCKET would leave a password box beside a
    /// picker that has nothing to do with passwords.
    /// </remarks>
    partial void OnRemoteChanged(RemoteKind value)
    {
        OnPropertyChanged(nameof(ShowsHostPicker));
        OnPropertyChanged(nameof(ShowsBucketPicker));
        OnPropertyChanged(nameof(ConnectLabel));
        OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
    }

    partial void OnIsConnectedChanged(bool value)
    {
        OnPropertyChanged(nameof(CanDownload));
        OnPropertyChanged(nameof(CanUpload));
        OnPropertyChanged(nameof(CanDeleteRemote));
    }

    /// <remarks>
    /// Any change to the selection takes the question away, which is stricter than the vault's rule and can
    /// afford to be: this list is refilled only by a navigation or a refresh somebody asked for, so there is
    /// no background pass to pull a card out from under a reader. Listing and disconnecting both null the
    /// selection, so this one hook covers all three ways the answer could stop being about what was asked.
    /// </remarks>
    partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value)
    {
        OnPropertyChanged(nameof(CanDownload));

        PendingRemoteDeletion = null;
    }

    partial void OnPendingRemoteDeletionChanged(RemoteDeletionRequest? value)
    {
        OnPropertyChanged(nameof(IsConfirmingRemoteDeletion));
        OnPropertyChanged(nameof(CanDeleteRemote));
    }

    partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
        OnPropertyChanged(nameof(CanUpload));

    partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
        OnPropertyChanged(nameof(HasPendingHostKey));

    partial void OnHostKeyMismatchChanged(string? value) =>
        OnPropertyChanged(nameof(HasHostKeyMismatch));
}
