using System.Collections.ObjectModel;
using System.Globalization;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Api;
using DodoSSH.Client.Session;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;

namespace DodoSSH.Client.Shell.ViewModels;

/// <summary>One vault, as a row in the list.</summary>
/// <remarks>
/// <para>
/// Built from what this machine holds — <see cref="VaultSession.Vaults"/> — with the membership facts
/// laid over it from the server. That order is the right way round: whether a vault exists and whether
/// this machine can open it are things the session knows and the server cannot, and how many people are
/// in it is the reverse. A row assembled the other way would go blank the moment the network did.
/// </para>
/// <para>
/// <paramref name="Role"/> is <see cref="TeamMemberRole.Unspecified"/> for a personal vault and for any
/// vault whose team has not been read yet, which is what <see cref="MembershipIsKnown"/> distinguishes.
/// </para>
/// </remarks>
/// <param name="VaultId">The vault.</param>
/// <param name="Name">What it is called. Plaintext, unlike everything in it.</param>
/// <param name="IsPersonal">Whether this is the vault nobody else can ever be added to.</param>
/// <param name="TeamId">The membership list behind it, for a shared vault.</param>
/// <param name="Role">This account's own role in it.</param>
/// <param name="MemberCount">How many people are in it, including this account.</param>
/// <param name="SharedWithOtherVaults">
/// How many <em>other</em> vaults the same people are in by virtue of the same membership list.
/// <para>
/// Zero for everything this screen makes, because a vault made here gets a membership list of its own.
/// It is not zero for a team that owned several vaults before this screen existed, or one an operator
/// arranged that way — and in that case adding somebody here adds them to those vaults too, which is the
/// one thing a vault-shaped screen could otherwise quietly hide.
/// </para>
/// </param>
/// <param name="IsReadable">Whether this machine holds the key.</param>
/// <param name="CanWrite">Whether the server would accept a change to it.</param>
/// <param name="RekeyRequired">Whether a membership change has left it owing a rekey.</param>
internal sealed record VaultRowViewModel(
    Guid VaultId,
    string Name,
    bool IsPersonal,
    Guid? TeamId,
    TeamMemberRole Role,
    int MemberCount,
    int SharedWithOtherVaults,
    bool IsReadable,
    bool CanWrite,
    bool RekeyRequired)
{
    /// <summary>Whether anybody else can be in this vault at all.</summary>
    internal bool IsShared => TeamId is not null;

    /// <summary>Whether the server has told us who is in it.</summary>
    internal bool MembershipIsKnown => Role != TeamMemberRole.Unspecified;

    /// <summary>The chip on the right of the row.</summary>
    /// <remarks>
    /// A personal vault says PERSONAL rather than a role. It has no membership list, so OWNER would be
    /// naming a position in a group of one — and the fact worth carrying about it is precisely that it
    /// is the one vault sharing cannot reach.
    /// </remarks>
    internal string RoleLabel => (IsPersonal, MembershipIsKnown) switch
    {
        (true, _) => "PERSONAL",
        (_, true) => Role.ToString().ToUpperInvariant(),
        _ => string.Empty,
    };

    /// <summary>What the row says under its name.</summary>
    internal string Detail => (IsPersonal, MembershipIsKnown) switch
    {
        (true, _) => "only you — nothing in here is shared",
        (_, false) => "shared — connect to see who is in it",
        _ => string.Create(CultureInfo.CurrentCulture, $"{MemberCount} member(s)"),
    };

    /// <summary>
    /// Whether this account may add people to it and rename it.
    /// </summary>
    /// <remarks>
    /// True of the personal vault, where there is nobody to add and the rename is still the owner's to
    /// make. The server agrees: a personal vault answers to its owner with every permission there is.
    /// </remarks>
    internal bool CanAdminister =>
        IsPersonal || (IsShared && Role is TeamMemberRole.Admin or TeamMemberRole.Owner);

    /// <summary>Whether this account is the one that can hand the vault on.</summary>
    internal bool IsOwned => IsShared && Role == TeamMemberRole.Owner;

    /// <summary>What is true of this vault right now, where that is worth a line.</summary>
    /// <remarks>
    /// Empty in the ordinary case rather than saying "fine". The two states here are both temporary and
    /// both mean somebody has to do something, so a row that carried a permanent reassurance beside them
    /// would train people to stop reading it.
    /// </remarks>
    internal string State => (IsReadable, RekeyRequired) switch
    {
        (false, _) => "waiting for a key — ask somebody who holds one to share it",
        (true, true) => "a rekey is owed after a membership change",
        _ => string.Empty,
    };

    internal bool HasState => State.Length > 0;

    /// <summary>The single letter the settings page's card draws on this vault's tile.</summary>
    internal string Initial => Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?";
}

/// <summary>One member of a vault, as a row in the members table.</summary>
internal sealed record VaultMemberRowViewModel(TeamMemberSummary Member, bool IsSelf)
{
    internal Guid UserId => Member.UserId;

    /// <summary>What to call them. The address, or the id when the account has neither.</summary>
    /// <remarks>
    /// Falling through to the id rather than to "Unknown": an account with no display name and no email is
    /// rare and is exactly the row somebody needs to be able to identify in order to remove it.
    /// </remarks>
    internal string Name =>
        Member.DisplayName ?? Member.Email ?? Member.UserId.ToString();

    internal string Email => Member.Email ?? "—";

    internal string Role => Member.Role.ToString().ToUpperInvariant();

    /// <summary>
    /// What the account can be given, in one phrase.
    /// </summary>
    /// <remarks>
    /// Not a two-factor column: there is no second-factor concept anywhere in the server. What is
    /// true and worth a column is whether a vault key can be wrapped to them at all.
    /// </remarks>
    internal string KeyState => Member.IsEnrolled
        ? "key published"
        : "no key yet — cannot be given this vault";

    /// <summary>
    /// The day they were last here, or that they never have been.
    /// </summary>
    /// <remarks>
    /// A date to the day, not a time and not a "3 hours ago". Two reasons, and they point the same
    /// way: the server writes this at most once an hour, so anything finer would be reading a
    /// precision into it that is not there — and a relative phrase would have to be recomputed against
    /// a clock, which this row does not have and which the pinned-host list already decided against by
    /// rendering its own dates the same way.
    /// </remarks>
    internal string LastActive => Member.LastActiveAt is { } seen
        ? "last here " + seen.ToLocalTime().ToString("d MMM yyyy", CultureInfo.CurrentCulture)
        : "never signed in";

    internal bool CanBeRemoved => Member.Role != TeamMemberRole.Owner;

    /// <summary>
    /// The two letters the members panel draws on this row's avatar.
    /// </summary>
    /// <remarks>
    /// The same rule <c>HostRowViewModel.Monogram</c> draws a card's monogram by — the first letters of the
    /// first two words in the name, or the first two characters where it is one word — except uppercase,
    /// which is how the design draws a person's initials rather than a host's.
    /// </remarks>
    internal string Initials
    {
        get
        {
            var words = Name.Split(
                InitialsWordSeparators,
                StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);

            var letters = words.Length >= 2
                ? string.Concat(words[0][0], words[1][0])
                : Name.Length >= 2 ? Name[..2] : Name;

            return letters.ToUpperInvariant();
        }
    }

    private static readonly char[] InitialsWordSeparators = [' ', '-', '_', '.'];

    /// <summary>Whether this member's role can be changed at all.</summary>
    /// <remarks>
    /// The owner's cannot, and not for want of an endpoint: ownership is sole, so demoting them is
    /// only meaningful as half of a transfer. That is its own command.
    /// </remarks>
    internal bool CanChangeRole => Member.Role != TeamMemberRole.Owner;
}

/// <summary>One vault key grant, as a row under the vault it opens.</summary>
/// <remarks>
/// This is the "shared with" list the design drew as a row of avatars. It is drawn as names and a
/// state instead, and it is a list rather than a count for a reason worth keeping: a grant is per
/// vault, so a number on an item row would imply per-item sharing, which does not exist.
/// </remarks>
internal sealed record VaultGrantRowViewModel(VaultGrantSummary Grant, uint VaultGeneration)
{
    internal Guid UserId => Grant.RecipientUserId;

    internal string Name => Grant.DisplayName ?? Grant.Email ?? Grant.RecipientUserId.ToString();

    /// <summary>
    /// What this grant is worth, in one phrase.
    /// </summary>
    /// <remarks>
    /// Staleness is decided by comparing generations rather than by reading
    /// <see cref="VaultGrantState"/> alone, which is what <c>VaultGrantsResponse.KeyGeneration</c>
    /// exists for: a grant can be Active and still open nothing, because it was wrapped to a key the
    /// vault has since moved past.
    /// </remarks>
    internal string State => Grant.State switch
    {
        VaultGrantState.Revoked => "withdrawn — blocks future reads only",
        VaultGrantState.AwaitingRewrap => "needs wrapping again — their key changed",
        _ when Grant.KeyGeneration < VaultGeneration => "stale — wrapped to an older key, opens nothing",
        _ => "holds a key",
    };

    /// <summary>Whether this row still represents somebody who can read the vault.</summary>
    internal bool IsLive =>
        Grant.State == VaultGrantState.Active && Grant.KeyGeneration >= VaultGeneration;
}

/// <summary>
/// A destructive vault operation, armed and waiting to be confirmed.
/// </summary>
/// <remarks>
/// The armed-state idiom the keychain screen uses, and for the same reason: this window has no modal, so
/// a confirmation is drawn in place of the buttons that armed it. The target ids are carried here rather
/// than read from the selection at confirm time — otherwise selecting a different row between arming
/// and confirming would apply the answer to something else.
/// </remarks>
/// <param name="Kind">Which operation the answer applies to.</param>
/// <param name="TeamId">The membership list the action is aimed at.</param>
/// <param name="MemberId">The member it is aimed at, or empty where it is not aimed at one.</param>
/// <param name="VaultId">The vault it is aimed at.</param>
/// <param name="Question">What is being asked.</param>
/// <param name="Consequence">What will actually happen, stated honestly.</param>
internal sealed record VaultActionRequest(
    VaultActionKind Kind,
    Guid TeamId,
    Guid MemberId,
    Guid VaultId,
    string Question,
    string Consequence);

/// <summary>Which destructive operation a confirmation is standing in front of.</summary>
/// <remarks>
/// Carried on the request rather than inferred from which fields are set. There were two of these the
/// moment deletion existed, and a confirm handler that guessed from a member id being empty would be one
/// refactor away from carrying out the wrong one of the two.
/// </remarks>
internal enum VaultActionKind
{
    /// <summary>Not a legal value.</summary>
    Unspecified = 0,

    /// <summary>Hand the vault's membership list to somebody else.</summary>
    HandOver = 1,

    /// <summary>Delete the vault.</summary>
    Delete = 2,
}

/// <summary>
/// The vaults screen: which vaults there are, who is in each, and who holds a key to it.
/// </summary>
/// <remarks>
/// <para>
/// <b>This was the teams screen, and the team is now behind the vault rather than above it.</b> A team is
/// still what the server authorises against — <c>VaultAccessService</c> resolves a team vault through
/// <c>team_membership</c>, and every membership call here names a team id. What changed is that nobody is
/// asked to make one: naming a vault makes the membership list to carry it, so the thing people came to
/// share is the thing they create, and "which team is this in" stops being a question they have to have an
/// answer to before they can share four servers with two colleagues.
/// </para>
/// <para>
/// <b>Two separate acts, and the screen is still built around saying so.</b> Adding somebody to a vault is
/// a server-side authorization change and takes effect immediately. Giving them the vault key is a
/// cryptographic act only a machine with that key can perform, and until somebody does it their vault list
/// shows an entry they cannot open. Every product that hides this ends up implying the server can hand out
/// access on its own — which, here, it cannot. See ADR 0009.
/// </para>
/// <para>
/// The vault list is read from the session and works with no network. Everything under it — members,
/// key holders — is read from the server on selection and after each change, because
/// membership is not vault content and has no local mirror.
/// </para>
/// </remarks>
/// <param name="connection">The server, or null where there is none.</param>
/// <param name="session">The unlocked session, or null while the keychain is locked.</param>
/// <param name="vaultsChanged">
/// Told when this screen has made or renamed a vault, or null where nobody is listening.
/// <para>
/// A delegate rather than an event, and optional, for the reason the two dependencies above are functions:
/// this screen is built once and outlives every lock, so a subscription would be one more thing to detach
/// at exactly the right moment. The one listener is the shell, which has a tab-strip menu and a set of host
/// lists that are both a vault out of date the instant this screen makes one.
/// </param>
internal sealed partial class VaultsViewModel(
    Func<IVaultServer?> connection,
    Func<VaultSession?> session,
    Func<CancellationToken, Task>? vaultsChanged = null) : ObservableObject
{
    /// <summary>
    /// How long a slug may be, mirroring the server's own cap.
    /// </summary>
    /// <remarks>
    /// Mirrored rather than shared because it belongs to <c>TeamService.RequireSlug</c>, which is server
    /// code this assembly does not reference. Being wrong here costs a refusal the user cannot act on, so
    /// it is a constant with a comment rather than a number in the middle of a method.
    /// </remarks>
    private const int MaximumSlugLength = 128;

    /// <summary>Every vault this account can see, readable or not.</summary>
    internal ObservableCollection<VaultRowViewModel> Vaults { get; } = [];

    /// <summary>The people in the selected vault.</summary>
    internal ObservableCollection<VaultMemberRowViewModel> Members { get; } = [];

    /// <summary>Who holds a key to the selected vault.</summary>
    /// <remarks>
    /// Read from the server rather than from the session, and it is the one list on this screen that
    /// has to be: the keyring can only answer whether <em>this</em> machine can open a vault, and this
    /// question is about everybody else.
    /// </remarks>
    internal ObservableCollection<VaultGrantRowViewModel> Grants { get; } = [];

    [ObservableProperty]
    private VaultRowViewModel? selectedVault;

    [ObservableProperty]
    private VaultMemberRowViewModel? selectedMember;

    [ObservableProperty]
    private string status = string.Empty;

    [ObservableProperty]
    private bool isBusy;

    /// <summary>
    /// Whether the members panel — v5c's settings-page overlay over the selected vault's people — is open.
    /// </summary>
    /// <remarks>
    /// Desktop-only presentation state, not a fact about any vault: it says which rectangle is on screen and
    /// nothing else. Selecting a vault is what actually reads its members, in
    /// <see cref="OnSelectedVaultChanged"/>; opening this panel over one already selected re-reads nothing.
    /// </remarks>
    [ObservableProperty]
    private bool isMembersPanelOpen;

    /// <summary>Opens the members panel, selecting the vault it is about first if it is not selected already.</summary>
    [RelayCommand]
    private void OpenMembersPanel(VaultRowViewModel? vault)
    {
        if (vault is not null && !ReferenceEquals(vault, SelectedVault))
        {
            SelectedVault = vault;
        }

        IsMembersPanelOpen = true;
    }

    /// <summary>Closes the members panel.</summary>
    [RelayCommand]
    private void CloseMembersPanel() => IsMembersPanelOpen = false;

    // ---- Creating a vault ----

    [ObservableProperty]
    private bool isCreatingVault;

    [ObservableProperty]
    private string newVaultName = string.Empty;

    /// <summary>
    /// The membership list a half-finished create already made, held so the retry does not make a second.
    /// </summary>
    /// <remarks>
    /// Creating a vault is two calls, and the first can succeed while the second fails. The id is
    /// generated once and kept here, which is the whole of the idempotency story: <c>TeamService</c>
    /// treats an identical repeat of a create it has already accepted as the same team rather than a new
    /// one, so pressing CREATE again resends the first call harmlessly and then retries the second.
    /// </remarks>
    private Guid? pendingVaultTeamId;

    // ---- Renaming a vault ----

    [ObservableProperty]
    private bool isRenamingVault;

    [ObservableProperty]
    private string editVaultName = string.Empty;

    // ---- Adding somebody ----

    [ObservableProperty]
    private string newMemberEmail = string.Empty;

    /// <summary>
    /// The role a newly added account gets.
    /// </summary>
    /// <remarks>
    /// Member by default, which is the role somebody adding a colleague almost always means. Viewer
    /// would be safer and would be the wrong default: an interface whose default is wrong teaches
    /// people to change it without reading it.
    /// </remarks>
    [ObservableProperty]
    private TeamMemberRole newMemberRole = TeamMemberRole.Member;

    // ---- Confirming something that cannot be undone ----

    [ObservableProperty]
    private VaultActionRequest? pendingAction;

    /// <summary>Set while <see cref="ReloadAsync"/> reselects, so the handler does not read as well.</summary>
    private bool isReselecting;

    /// <summary>
    /// Which selection read owns the lists beside the vault list.
    /// </summary>
    /// <remarks>
    /// Selecting a second vault before the first one's read has answered leaves two reads in flight
    /// against the same collections, and the one that started first can answer last — so the
    /// superseded read drops its answer instead of appending another vault's members to the list. UI
    /// thread only, which is where every selection change and every continuation on this screen runs.
    /// </remarks>
    private int selectionGeneration;

    /// <summary>Whether there is a server to talk to at all.</summary>
    internal bool IsOnline => connection() is not null;

    /// <summary>Whether the keychain is unlocked, which is what makes the vault list readable.</summary>
    internal bool IsUnlocked => session() is not null;

    /// <summary>Whether the selected vault can be administered by this account.</summary>
    internal bool CanAdministerSelected => SelectedVault?.CanAdminister == true;

    /// <summary>Whether this account owns the selected vault's membership list.</summary>
    /// <remarks>
    /// A narrower gate than <see cref="CanAdministerSelected"/>, and the server draws the same line:
    /// handing a vault on decides who controls it, so an admin the owner promoted must not be able to.
    /// </remarks>
    internal bool OwnsSelected => SelectedVault?.IsOwned == true;

    /// <summary>Whether the selected vault is one other people can be in.</summary>
    /// <remarks>
    /// False for the personal vault, which is not a shortcoming of this screen: the server refuses a
    /// grant on a personal vault outright, because a key sealed to somebody it will go on refusing to
    /// serve is a row that looks like sharing and is not. See <c>VaultGrantService</c>.
    /// </remarks>
    internal bool SelectedIsShared => SelectedVault?.IsShared == true;

    /// <summary>Whether the selected vault is the personal one.</summary>
    internal bool SelectedIsPersonal => SelectedVault?.IsPersonal == true;

    /// <summary>
    /// Whether the selected vault is one this account may delete.
    /// </summary>
    /// <remarks>
    /// Narrower than <see cref="CanAdministerSelected"/> by exactly the personal vault, which cannot be
    /// deleted by anybody: it is where everything filed nowhere else lives and nothing can make another.
    /// The server refuses one too, so a button offered here would be a button that leads to a refusal.
    /// </remarks>
    internal bool CanDeleteSelected => SelectedVault is { IsPersonal: false, CanAdminister: true };

    /// <summary>Whether there is anything to show beside the vault list.</summary>
    internal bool HasSelection => SelectedVault is not null;

    internal bool HasVaults => Vaults.Count > 0;

    /// <summary>Whether a destructive action is armed and waiting for an answer.</summary>
    internal bool IsConfirming => PendingAction is not null;

    /// <summary>Whether the ordinary vault buttons should be showing.</summary>
    /// <remarks>
    /// The inverse of <see cref="IsConfirming"/>, so the confirmation replaces the buttons that armed
    /// it rather than appearing beneath them still pressable.
    /// </remarks>
    internal bool ShowsVaultActions => !IsConfirming;

    /// <summary>
    /// The warning a vault sharing its membership list with others has to carry.
    /// </summary>
    /// <remarks>
    /// Empty in every case this screen creates, because a vault made here gets a membership list of its
    /// own. It is not empty for the arrangements that pre-date this screen, and there the sentence is the
    /// whole point: on a vault-shaped screen, adding somebody to one vault silently adding them to three
    /// others is exactly the fact a vault-shaped screen is in a position to hide.
    /// </remarks>
    internal string SharedMembershipWarning => SelectedVault is { SharedWithOtherVaults: > 0 } row
        ? string.Create(
            CultureInfo.CurrentCulture,
            $"These people are in {row.SharedWithOtherVaults} other vault(s) as well: this vault's membership list is shared with them. Adding or removing somebody here does the same there.")
        : string.Empty;

    internal bool HasSharedMembershipWarning => SharedMembershipWarning.Length > 0;

    internal bool AddsAsViewer => NewMemberRole == TeamMemberRole.Viewer;

    internal bool AddsAsMember => NewMemberRole == TeamMemberRole.Member;

    internal bool AddsAsAdmin => NewMemberRole == TeamMemberRole.Admin;

    /// <summary>Reads the vaults this account can see, and the selected one's detail.</summary>
    internal Task LoadAsync(CancellationToken cancellationToken) =>
        RunAsync(() => ReloadAsync(cancellationToken));

    /// <summary>Reads it all again.</summary>
    /// <remarks>
    /// The same work as <see cref="LoadAsync"/>, exposed as a command because markup cannot invoke a
    /// method. The phone needs it and the desktop does not: this screen is loaded on arrival, and on
    /// the desktop leaving the rail and coming back is one click, where on the phone it is a trip out
    /// to MORE and back. Nothing below the vault list is cached, so a re-read is the only way to see a
    /// change somebody else made.
    /// </remarks>
    [RelayCommand]
    private Task RefreshAsync(CancellationToken cancellationToken) =>
        RunAsync(() => ReloadAsync(cancellationToken));

    /// <summary>
    /// The reload itself, without the busy gate.
    /// </summary>
    /// <remarks>
    /// Separate from <see cref="LoadAsync"/> because every command ends by reloading, and a command that
    /// called the gated version would find the gate held by itself and skip the reload silently — leaving
    /// a vault that was created moments ago missing from the list it was just added to.
    /// </remarks>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <param name="select">
    /// The vault to land on, or null to keep the one already selected.
    /// <para>
    /// Here rather than assigned after the call, because the assignment fires
    /// <see cref="OnSelectedVaultChanged"/> and that starts a read nothing can await — so a caller wanting
    /// the new vault's members on screen would be racing a fire-and-forget. Passed in, the reselect happens
    /// under the same guard as every other one and the detail read below is the awaited one.
    /// </para>
    /// </param>
    private async Task ReloadAsync(CancellationToken cancellationToken, Guid? select = null)
    {
        var selectedId = select ?? SelectedVault?.VaultId;

        Vaults.Clear();

        if (session() is not { } open)
        {
            Members.Clear();
            Grants.Clear();
            RaiseState();

            Status = "Locked. Unlock your keychain to see your vaults.";
            return;
        }

        // The membership facts, where there is a server to ask. Offline the vault list is still right —
        // it is what this machine holds — and every row simply says its membership is not known.
        var teams = connection() is { } server
            ? await server.Teams.ListTeamsAsync(cancellationToken).ConfigureAwait(true)
            : [];

        BuildRows(open, teams);

        // The assignment reselects the same vault through a new row object, so the selection handler
        // would start its own read of the very lists this method is about to read — two reads clearing
        // and then appending into the same collections, which draws every member and key holder twice. Suppressed rather than deduplicated, because the read below is awaited and the
        // handler's is not: this is the one that has to be the reload's.
        isReselecting = true;

        try
        {
            SelectedVault =
                Vaults.FirstOrDefault(row => row.VaultId == selectedId) ?? Vaults.FirstOrDefault();
        }
        finally
        {
            isReselecting = false;
        }

        RaiseState();

        await LoadSelectedAsync(cancellationToken).ConfigureAwait(true);

        Status = connection() is null
            ? "Offline. The vaults are this machine's; who is in them is read from the server."
            : string.Empty;
    }

    /// <summary>Fills the vault list from the session, with the server's membership facts laid over it.</summary>
    /// <remarks>
    /// Personal first and then by name, which is the order every other vault list in the application
    /// uses — and the personal one is first because it is the one that is always there.
    /// </remarks>
    private void BuildRows(VaultSession open, IReadOnlyList<TeamSummary> teams)
    {
        var byTeam = teams.ToDictionary(team => team.TeamId);
        var readable = open.ReadableVaults.Select(vault => vault.VaultId).ToHashSet();

        // How many vaults each membership list carries, so a row can say when it is not the only one.
        var perTeam = open.Vaults
            .Where(vault => vault.TeamId is not null)
            .GroupBy(vault => vault.TeamId!.Value)
            .ToDictionary(group => group.Key, group => group.Count());

        foreach (var vault in open.Vaults
            .OrderByDescending(vault => vault.IsPersonal)
            .ThenBy(vault => vault.Name, StringComparer.CurrentCulture))
        {
            var team = vault.TeamId is { } teamId && byTeam.TryGetValue(teamId, out var found)
                ? found
                : null;

            Vaults.Add(new VaultRowViewModel(
                vault.VaultId,
                vault.Name,
                vault.IsPersonal,
                vault.TeamId,
                team?.Role ?? TeamMemberRole.Unspecified,
                team?.MemberCount ?? 0,
                vault.TeamId is { } id && perTeam.TryGetValue(id, out var count) ? count - 1 : 0,
                readable.Contains(vault.VaultId),
                vault.CanWrite,
                vault.RekeyRequired));
        }
    }

    /// <summary>Opens the name-a-vault form.</summary>
    /// <remarks>
    /// What the tab strip's vault menu reaches as well as the button above the list. One field: the
    /// membership list behind the vault is made with it and named after it — see
    /// <see cref="CreateVaultAsync"/> — because somebody who wants to share four servers with two
    /// colleagues is not asking to found an organisation first.
    /// </remarks>
    [RelayCommand]
    private void NewVault()
    {
        // Opening the form is a fresh attempt, so a membership list left behind by a create that got half
        // way is not carried into it — the name box has just been emptied, and a retry that reused it
        // would put a vault called one thing inside a list called another. Finishing the half-done one is
        // pressing CREATE again on the form that is still open, which is what its message says.
        pendingVaultTeamId = null;

        NewVaultName = string.Empty;
        IsCreatingVault = true;
        IsRenamingVault = false;
        Status = string.Empty;
    }

    /// <summary>
    /// Abandons the name-a-vault form.
    /// </summary>
    /// <remarks>
    /// Tidies away the membership list a half-finished create left behind, which is a deliberate
    /// exception to this application's rule against cleaning up on the user's behalf. The reason is that
    /// nothing on this screen can reach it: a membership list with no vault has no row here, so leaving it
    /// would be leaving a thing the user cannot see, cannot use and cannot remove. Cancelling is the one
    /// moment somebody says they are done with this attempt, and it is empty, so archiving it is refused
    /// by nothing. A failure is ignored: the alternative is an error about something the user was not
    /// told existed.
    /// </remarks>
    [RelayCommand]
    private async Task CancelNewVaultAsync(CancellationToken cancellationToken)
    {
        IsCreatingVault = false;
        Status = string.Empty;

        if (pendingVaultTeamId is not { } orphan)
        {
            return;
        }

        pendingVaultTeamId = null;

        if (connection() is not { } server)
        {
            return;
        }

        try
        {
            await server.Teams.ArchiveTeamAsync(orphan, cancellationToken).ConfigureAwait(true);
        }
        catch (Exception exception) when (exception is not OutOfMemoryException
            and not OperationCanceledException)
        {
            // Nothing to say. It is a membership list nobody was shown, holding nobody but its creator.
        }
    }

    /// <summary>
    /// Creates a vault, and the membership list to carry it.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>A vault always belongs to a team, and this is what keeps that from being the user's problem.</b>
    /// Naming a vault is enough: the team is derived from the name, created with this account as its owner,
    /// and the vault goes into it. What that buys is the rest of this screen — members, roles and key
    /// holders all hang off it, so they are all there the moment the vault is.
    /// </para>
    /// <para>
    /// <b>Two calls, and the first can succeed alone.</b> When it does, the membership list is kept rather
    /// than tidied away here — see <see cref="pendingVaultTeamId"/> for how the retry avoids making a
    /// second one, and <see cref="CancelNewVaultAsync"/> for where it does get tidied away.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task CreateVaultAsync(CancellationToken cancellationToken)
    {
        if (connection() is not { } server)
        {
            Status = "Offline. Creating a vault needs a connection.";
            return;
        }

        if (session() is not { } open)
        {
            Status = "Unlock your keychain first: a vault's key is generated on this machine.";
            return;
        }

        var name = NewVaultName.Trim();

        if (name.Length == 0)
        {
            Status = "A vault needs a name.";
            return;
        }

        await RunAsync(() => AddVaultAsync(server, open, name, cancellationToken)).ConfigureAwait(true);
    }

    /// <summary>The two calls behind <see cref="CreateVaultAsync"/>, once its arguments are known good.</summary>
    private async Task AddVaultAsync(
        IVaultServer server,
        VaultSession open,
        string name,
        CancellationToken cancellationToken)
    {
        var teamId = await EnsureTeamForVaultAsync(server, name, cancellationToken).ConfigureAwait(true);

        StoredVault vault;

        try
        {
            vault = await open
                .CreateTeamVaultAsync(server.Teams, teamId, name, cancellationToken)
                .ConfigureAwait(true);
        }
        catch (Exception exception) when (exception is not OperationCanceledException)
        {
            // The whole state, not "creating the vault failed". Pressing CREATE again finishes the job
            // rather than making a second membership list, and cancelling takes the half-made one away —
            // none of which the user can work out from the failure alone.
            Status = $"The vault '{name}' was not created: {exception.Message} Press CREATE again to "
                + "finish it, or CANCEL to undo what was made.";
            return;
        }

        IsCreatingVault = false;
        pendingVaultTeamId = null;
        NewVaultName = string.Empty;

        await ReloadAsync(cancellationToken, select: vault.VaultId).ConfigureAwait(true);

        // After the reload, so this screen is already right when the rest of the shell redraws against the
        // same session. Nothing here depends on it having happened.
        await NotifyVaultsChangedAsync(cancellationToken).ConfigureAwait(true);

        Status = $"Created the vault '{vault.Name}'. You are the only one who can open it until you share "
            + "its key — add people beside it, then press SHARE KEY.";
    }

    /// <summary>Makes the membership list a new vault will belong to, or returns the one a retry made.</summary>
    /// <remarks>
    /// The slug is derived rather than asked for. It is a URL-safe handle the server needs and not a thing
    /// somebody naming a vault has an opinion about, so making them invent one would be a second field for
    /// a fact the first one already contains.
    /// </remarks>
    private async Task<Guid> EnsureTeamForVaultAsync(
        IVaultServer server,
        string name,
        CancellationToken cancellationToken)
    {
        var teamId = pendingVaultTeamId ?? Guid.CreateVersion7();

        // Before the call, not after: if this throws, the id has to survive so the retry resends the same
        // request rather than creating a second one.
        pendingVaultTeamId = teamId;

        var slug = Slugify(name, teamId);

        try
        {
            await server.Teams
                .CreateTeamAsync(new CreateTeamRequest(teamId, name, slug, null), cancellationToken)
                .ConfigureAwait(true);
        }
        catch (DodoSshApiException exception)
            when (string.Equals(exception.Code, ProblemCodes.TeamSlugTaken, StringComparison.Ordinal))
        {
            // Once, and not in a loop. A second collision on a suffixed slug means something other than
            // "somebody already has this name", and a client that kept trying would be hammering a server
            // that is refusing for a reason retrying cannot fix.
            await server.Teams
                .CreateTeamAsync(
                    new CreateTeamRequest(teamId, name, Disambiguate(slug, teamId), null),
                    cancellationToken)
                .ConfigureAwait(true);
        }

        return teamId;
    }

    /// <summary>
    /// Turns a vault name into a slug the server will accept.
    /// </summary>
    /// <remarks>
    /// Mirrors <c>TeamService.RequireSlug</c>: lowercase, anything outside a-z0-9 becomes a hyphen, runs of
    /// hyphens collapse, and the ends are trimmed. A name with nothing sluggable in it — one written
    /// entirely in a non-Latin script, or in emoji — leaves nothing behind, so it falls back to the id
    /// rather than to a refusal the user cannot see the cause of in what they typed.
    /// </remarks>
    private static string Slugify(string name, Guid teamId)
    {
        var slug = new StringBuilder(name.Length);

        foreach (var character in name.ToLowerInvariant())
        {
            if (character is >= 'a' and <= 'z' or >= '0' and <= '9')
            {
                slug.Append(character);
            }
            else if (slug.Length > 0 && slug[^1] != '-')
            {
                slug.Append('-');
            }
        }

        var trimmed = slug.ToString().Trim('-');

        if (trimmed.Length > MaximumSlugLength)
        {
            trimmed = trimmed[..MaximumSlugLength].TrimEnd('-');
        }

        return trimmed.Length > 0 ? trimmed : Disambiguate("vault", teamId);
    }

    /// <summary>Adds enough of the id to a slug to get past one somebody else has taken.</summary>
    private static string Disambiguate(string slug, Guid teamId)
    {
        var suffix = "-" + teamId.ToString("N", CultureInfo.InvariantCulture)[..8];
        var room = MaximumSlugLength - suffix.Length;

        return (slug.Length > room ? slug[..room].TrimEnd('-') : slug) + suffix;
    }

    /// <summary>Opens the rename form for the selected vault.</summary>
    [RelayCommand]
    private void RenameVault()
    {
        if (SelectedVault is not { } vault)
        {
            return;
        }

        EditVaultName = vault.Name;
        IsRenamingVault = true;
        IsCreatingVault = false;
        Status = string.Empty;
    }

    /// <summary>Abandons the rename form.</summary>
    [RelayCommand]
    private void CancelRenameVault()
    {
        IsRenamingVault = false;
        Status = string.Empty;
    }

    /// <summary>Opens the rename form for a specific vault, from the settings page's per-card edit icon.</summary>
    /// <remarks>
    /// A thin wrapper around <see cref="RenameVault"/> rather than a second implementation: the settings
    /// page draws one card per vault with no list selection to lean on the way this screen used to have, so
    /// this selects the row first and then asks the command that already knows how to open the form.
    /// </remarks>
    /// <param name="vault">The vault to rename.</param>
    [RelayCommand]
    private void RenameVaultRow(VaultRowViewModel? vault)
    {
        if (vault is not null)
        {
            SelectedVault = vault;
        }

        RenameVaultCommand.Execute(null);
    }

    /// <summary>Arms the delete confirmation for a specific vault, from the settings page's per-card delete icon.</summary>
    /// <inheritdoc cref="RenameVaultRow" path="/remarks" />
    /// <param name="vault">The vault to ask about deleting.</param>
    [RelayCommand]
    private void DeleteVaultRow(VaultRowViewModel? vault)
    {
        if (vault is not null)
        {
            SelectedVault = vault;
        }

        DeleteVaultCommand.Execute(null);
    }

    /// <summary>
    /// Saves the renamed vault.
    /// </summary>
    /// <remarks>
    /// The whole shell is told, because a vault's name is drawn in more places than this screen: the badge
    /// on every host card in a session holding several, the file-this-into picker, and the tab strip's
    /// vault menu.
    /// </remarks>
    [RelayCommand]
    private async Task SaveVaultNameAsync(CancellationToken cancellationToken)
    {
        if (connection() is not { } server || session() is not { } open || SelectedVault is not { } vault)
        {
            Status = connection() is null
                ? "Offline. A vault's name is the server's, so renaming it needs a connection."
                : string.Empty;
            return;
        }

        var name = EditVaultName.Trim();

        if (name.Length == 0)
        {
            Status = "A vault needs a name.";
            return;
        }

        await RunAsync(async () =>
        {
            var renamed = await open
                .RenameVaultAsync(server.Grants, vault.VaultId, name, cancellationToken)
                .ConfigureAwait(true);

            IsRenamingVault = false;

            await ReloadAsync(cancellationToken, select: vault.VaultId).ConfigureAwait(true);
            await NotifyVaultsChangedAsync(cancellationToken).ConfigureAwait(true);

            // What a rename does not touch, said once rather than left to be discovered. A vault name is
            // plaintext on the server — it always was, because a person has to pick a vault before
            // anything is decrypted — and nothing inside it is re-encrypted by this.
            Status = $"Renamed to '{renamed.Name}'. The name is stored in plain text, as it was before; "
                + "nothing in the vault was re-encrypted, and everybody's key still opens it.";
        }).ConfigureAwait(true);
    }

    /// <summary>
    /// Adds somebody to the selected vault, by looking their address up in the directory first.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The directory is asked first, and the order is the point: it is what turns an address into an
    /// account <em>and a public key</em>, and the key that gets verified before any sharing is the one
    /// that lookup returned. Resolving the address server-side when the directory could answer would
    /// put an unwitnessed step between the two.
    /// </para>
    /// <para>
    /// <b>A directory miss is not an absent account, and treating it as one was a bug worth naming.</b>
    /// The directory returns only accounts that have published a key, so everybody between their first
    /// sign-in and their enrollment is missing from it. Reporting that miss as "no account here" told
    /// somebody who was standing right there that they were not, and left the members list unchanged. So
    /// the miss is retried as an add by address, and only a server saying there is no such account is
    /// taken as an answer.
    /// </para>
    /// <para>
    /// <b>And that answer is the end of it.</b> There is nothing to fall through to: a membership is
    /// granted to an account, so somebody who has never signed in here cannot be added yet, and the
    /// remedy belongs to them rather than to the person at this screen. Saying so plainly is the whole
    /// of what this command can do about it — see <c>docs/adr/0009-team-access-model.md</c>.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task AddMemberAsync(CancellationToken cancellationToken)
    {
        if (connection() is not { } server || SelectedVault?.TeamId is not { } teamId)
        {
            // Never silent. This command's failures used to be visible only as a flicker of the busy
            // flag, which reads as a button that does nothing at all.
            Status = WhyNobodyCanBeAdded();
            return;
        }

        var email = NewMemberEmail.Trim();

        if (email.Length == 0)
        {
            Status = "Type the email address of somebody who has signed in to this server.";
            return;
        }

        await RunAsync(() => AddAsync(server, teamId, email, cancellationToken))
            .ConfigureAwait(true);
    }

    /// <summary>Which of the three reasons the ADD button had nothing to act on.</summary>
    private string WhyNobodyCanBeAdded() => connection() is null
        ? "Offline. Adding somebody changes who the server will serve, so it needs a connection."
        : SelectedIsPersonal
            ? "Your personal vault is yours alone and cannot be shared. Make a vault for the things you "
                + "want to share, and put them in it."
            : "Select a vault on the left first — somebody is added to one vault, not to all.";

    /// <summary>The calls behind <see cref="AddMemberAsync"/>, once its arguments are known good.</summary>
    private async Task AddAsync(
        IVaultServer server,
        Guid teamId,
        string email,
        CancellationToken cancellationToken)
    {
        var found = await server.Directory.LookupByEmailAsync(email, cancellationToken)
            .ConfigureAwait(true);

        var request = found.Count > 0
            ? new AddTeamMemberRequest(found[0].UserId, NewMemberRole)
            : new AddTeamMemberRequest(Guid.Empty, NewMemberRole, email);

        TeamMemberSummary member;

        try
        {
            member = await server.Teams
                .AddTeamMemberAsync(teamId, request, cancellationToken)
                .ConfigureAwait(true);
        }
        catch (DodoSshApiException exception)
            when (string.Equals(exception.Code, ProblemCodes.NoSuchAccount, StringComparison.Ordinal))
        {
            // The address really is unknown here, which only the server can say. Reported rather than
            // rethrown, because it is the answer rather than a failure — and the sentence has to carry
            // what happens next, or somebody retypes the address expecting a different outcome.
            Status = $"No account on this server uses '{email}'. Ask them to sign in here once, "
                + "which is what creates the account, and then add them. Nothing is held for them in "
                + "the meantime — an address is not a way into a vault.";

            return;
        }

        NewMemberEmail = string.Empty;

        // Before the reload, so the vault list this screen redraws already shows what they can open. The
        // sharing is what makes the membership worth anything, and doing it here rather than leaving a
        // SHARE KEY button to be pressed is the difference between adding a colleague and adding a
        // colleague who then waits for somebody to notice.
        var shared = await ShareWithAsync(server, teamId, member, cancellationToken).ConfigureAwait(true);

        await ReloadAsync(cancellationToken).ConfigureAwait(true);

        Status = Describe(member, shared);
    }

    /// <summary>
    /// Wraps every vault behind this membership list that this machine can open to somebody just added.
    /// </summary>
    /// <returns>What to tell the user about the keys, or null when there was nothing to say.</returns>
    /// <remarks>
    /// <para>
    /// The membership list rather than the one vault, and that is not a slip: adding somebody is a
    /// change to the list, so it is every vault the list carries that they can now fetch. This screen
    /// makes lists that carry one vault, so the sentence names one — and where it does not, naming them
    /// all is the honest report of what just happened.
    /// </para>
    /// <para>
    /// Skipped outright for an account with no identity key: there is nothing to wrap to, and a
    /// refusal per vault would bury that one fact under a list. Their row says so, and adding them was
    /// still worth doing.
    /// </para>
    /// <para>
    /// A failure here is reported and never thrown. The membership has already been recorded on the
    /// server and is not undone by a key that could not be wrapped — so the honest outcome is "they are
    /// in it, and this vault still needs sharing", which is a state somebody can act on.
    /// </para>
    /// </remarks>
    private async Task<string?> ShareWithAsync(
        IVaultServer server,
        Guid teamId,
        TeamMemberSummary member,
        CancellationToken cancellationToken)
    {
        if (!member.IsEnrolled)
        {
            return null;
        }

        if (session() is not { } open)
        {
            // Distinguished from holding no keys, because the two lead somewhere different: this one is
            // fixed by unlocking, and the other by asking somebody who holds the vault.
            return "Nothing was shared with them — a vault key is wrapped on an unlocked machine, and "
                + "this keychain is locked.";
        }

        var reports = await open
            .ShareTeamVaultsAsync(
                server.Grants, server.Directory, teamId, member.UserId, cancellationToken)
            .ConfigureAwait(true);

        if (reports.Count == 0)
        {
            return null;
        }

        var shared = reports.Where(report => report.Succeeded).ToList();
        var refused = reports.Where(report => !report.Succeeded).ToList();

        var sentence = shared.Count > 0
            ? $"Shared {VaultCount(shared.Count)} with them: {Join(shared.Select(r => r.Name))}."
            : null;

        if (refused.Count == 0)
        {
            return sentence;
        }

        // Named one by one rather than counted. Each of these is a vault somebody now expects them to
        // be able to open, and which one it is decides who has to fix it.
        var reasons = refused.Select(report =>
            $"'{report.Name}' ({report.Failure?.Message ?? report.Outcome?.Message})");

        return (sentence is null ? string.Empty : sentence + " ")
            + $"Could not share {Join(reasons)}.";
    }

    /// <summary>"1 vault" or "3 vaults", for a sentence that has to read either way.</summary>
    private static string VaultCount(int count) =>
        string.Create(CultureInfo.CurrentCulture, $"{count} vault{(count == 1 ? string.Empty : "s")}");

    /// <summary>Joins names into a phrase a person would say, rather than a comma-separated list.</summary>
    private static string Join(IEnumerable<string> parts)
    {
        var list = parts.ToList();

        return list.Count switch
        {
            0 => string.Empty,
            1 => list[0],
            2 => $"{list[0]} and {list[1]}",
            _ => string.Join(", ", list.Take(list.Count - 1)) + " and " + list[^1],
        };
    }

    /// <summary>
    /// What just happened to the account that was added, and what is still owed them.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The enrolled branch reports what the keys did, because that is the half of "adding somebody"
    /// that this machine performs and the half that can partly fail. A vault that could not be wrapped
    /// is named there rather than left to be noticed when they say they cannot open it.
    /// </para>
    /// <para>
    /// The unenrolled branch says more, and has to: their row will sit in the list saying it holds no
    /// key, and without this somebody would read that as the addition having half-failed rather than as
    /// a colleague who has not finished setting their machine up. Nothing was shared with them and
    /// nothing could have been — there is no key to wrap to — so the membership is all there is yet.
    /// </para>
    /// </remarks>
    private static string Describe(TeamMemberSummary member, string? shared)
    {
        var who = member.Email ?? member.DisplayName ?? "the account";

        if (!member.IsEnrolled)
        {
            return $"Added {who}. They have no key yet, so their row says so and this vault cannot be "
                + "shared with them until they finish signing in on their own machine. The membership "
                + "is real in the meantime.";
        }

        return shared is null
            ? $"Added {who}. This machine holds no key to give them — press SHARE KEY from one that "
                + "does."
            : $"Added {who}. {shared}";
    }

    /// <summary>Picks the role a newly added account will get.</summary>
    [RelayCommand]
    private void ChooseNewMemberRole(TeamMemberRole role) => NewMemberRole = role;

    /// <summary>Changes the selected member's role.</summary>
    /// <remarks>
    /// Owner is not offered, and the command refuses it rather than relying on the view not to send
    /// it: the server refuses it too, and a button that produced a server error would be reporting a
    /// rule the interface should have known.
    /// </remarks>
    [RelayCommand]
    private async Task ChangeRoleAsync(TeamMemberRole role, CancellationToken cancellationToken)
    {
        if (connection() is not { } server
            || SelectedVault?.TeamId is not { } teamId
            || SelectedMember is not { } member)
        {
            return;
        }

        if (role is TeamMemberRole.Owner or TeamMemberRole.Unspecified)
        {
            Status = "Ownership is handed over rather than assigned. Use HAND OVER below.";
            return;
        }

        if (member.Member.Role == role)
        {
            return;
        }

        await RunAsync(async () =>
        {
            var changed = await server.Teams
                .ChangeTeamMemberRoleAsync(
                    teamId,
                    member.UserId,
                    new ChangeTeamMemberRoleRequest(role),
                    cancellationToken)
                .ConfigureAwait(true);

            await ReloadAsync(cancellationToken).ConfigureAwait(true);

            SelectedMember = Members.FirstOrDefault(row => row.UserId == member.UserId);

            // What a role does and does not reach. A viewer still holds whatever key they were
            // wrapped, so demoting somebody is not a way of taking a vault back from them.
            Status = $"{member.Name} is now {changed.Role.ToString().ToLowerInvariant()}. This changes "
                + "what the server will serve them; it does not withdraw a vault key they already "
                + "hold — use WITHDRAW KEY for that.";
        }).ConfigureAwait(true);
    }

    /// <summary>Arms the hand-over confirmation for the selected member.</summary>
    [RelayCommand]
    private void HandOver()
    {
        if (SelectedVault is not { TeamId: { } teamId } vault || SelectedMember is not { } member)
        {
            return;
        }

        if (member.IsSelf)
        {
            Status = "You already own this vault.";
            return;
        }

        PendingAction = new VaultActionRequest(
            VaultActionKind.HandOver,
            teamId,
            member.UserId,
            vault.VaultId,
            $"Hand '{vault.Name}' to {member.Name}?",
            "They become its owner and you become an admin. You will not be able to take it back "
            + "yourself — only the new owner can hand it on. Your key to it is untouched.");
    }

    /// <summary>
    /// Arms the deletion confirmation for the selected vault.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The consequence is spelled out at length rather than summarised, and every sentence in it is one
    /// somebody could otherwise be surprised by afterwards. The last is the one this product must never
    /// leave implied: deleting a vault does not reach the machines it has already synced to. That is the
    /// same limit revocation has, for the same reason, and it is written down in ADR 0001.
    /// </para>
    /// <para>
    /// The personal vault is refused here as well as by the server. A button that reached a refusal would
    /// be teaching somebody to try things and read errors, and the reason is a fact this screen knows.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private void DeleteVault()
    {
        if (SelectedVault is not { } vault)
        {
            return;
        }

        if (vault.IsPersonal)
        {
            Status = "Your personal vault cannot be deleted. It is where everything filed nowhere else "
                + "lives, and there is no way to make another.";
            return;
        }

        if (vault.TeamId is not { } teamId)
        {
            return;
        }

        var others = Members.Count(member => !member.IsSelf);

        var shared = others > 0
            ? string.Create(
                CultureInfo.CurrentCulture,
                $" {others} other member(s) lose it at the same moment, without being asked.")
            : string.Empty;

        PendingAction = new VaultActionRequest(
            VaultActionKind.Delete,
            teamId,
            Guid.Empty,
            vault.VaultId,
            $"Delete '{vault.Name}'?",
            $"Everything in it goes: its hosts, keys, passwords, snippets and buckets stop being readable "
            + $"by anybody, including you, and nothing here can undo it.{shared} What it cannot do is "
            + "reach a machine that has already synced this vault — a copy pulled yesterday is still "
            + "there. Rotate the credentials that mattered.");
    }

    /// <summary>Cancels an armed action.</summary>
    [RelayCommand]
    private void CancelAction() => PendingAction = null;

    /// <summary>
    /// Carries out whichever action was armed.
    /// </summary>
    /// <remarks>
    /// Disarmed before the work rather than after it, so the card goes the moment it is answered and a
    /// second press during a slow round trip has nothing left to agree to.
    /// </remarks>
    [RelayCommand]
    private async Task ConfirmActionAsync(CancellationToken cancellationToken)
    {
        if (connection() is not { } server || PendingAction is not { } request)
        {
            return;
        }

        PendingAction = null;

        await RunAsync(() => request.Kind switch
        {
            VaultActionKind.Delete => DeleteAsync(server, request, cancellationToken),
            _ => HandOverAsync(server, request, cancellationToken),
        }).ConfigureAwait(true);
    }

    private async Task HandOverAsync(
        IVaultServer server,
        VaultActionRequest request,
        CancellationToken cancellationToken)
    {
        await server.Teams
            .TransferTeamOwnershipAsync(
                request.TeamId,
                new TransferTeamOwnershipRequest(request.MemberId),
                cancellationToken)
            .ConfigureAwait(true);

        await ReloadAsync(cancellationToken).ConfigureAwait(true);

        Status = "Handed over. You are an admin of this vault now, and only its new owner can hand "
            + "it on again.";
    }

    /// <summary>
    /// Deletes the vault the confirmation was armed for.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The name is read before the call, because afterwards there is no row to read it from and the
    /// sentence this ends with is about a vault that no longer exists.
    /// </para>
    /// <para>
    /// The rest of the shell is told, as a rename tells it: the tab strip's vault menu, the file-this-into
    /// picker and every host list are built from the session's vault list, and all of them are a vault out
    /// of date the moment one goes.
    /// </para>
    /// </remarks>
    private async Task DeleteAsync(
        IVaultServer server,
        VaultActionRequest request,
        CancellationToken cancellationToken)
    {
        if (session() is not { } open)
        {
            Status = "Unlock your keychain first: deleting a vault gives up this machine's key to it.";
            return;
        }

        var name = Vaults.FirstOrDefault(row => row.VaultId == request.VaultId)?.Name ?? "That vault";

        var deleted = await open
            .DeleteVaultAsync(server.Grants, request.VaultId, cancellationToken)
            .ConfigureAwait(true);

        await ReloadAsync(cancellationToken).ConfigureAwait(true);
        await NotifyVaultsChangedAsync(cancellationToken).ConfigureAwait(true);

        Status = deleted
            ? $"Deleted '{name}'. Everybody's key to it is withdrawn. What anybody had already synced is "
                + "still on their machine — rotate the credentials that mattered."
            : $"'{name}' was already gone. Somebody else deleted it, or your access to it ended.";
    }

    /// <summary>
    /// Removes somebody, revoking their grants and rotating the vaults they could read.
    /// </summary>
    /// <remarks>
    /// The removal and the rotation are separate acts and only the first is the server's. Nothing here
    /// undoes the removal if the rotation fails, and nothing waits for it: the membership change is what
    /// stops them fetching anything more, and it has already happened by then.
    /// </remarks>
    [RelayCommand]
    private async Task RemoveMemberAsync(CancellationToken cancellationToken)
    {
        if (connection() is not { } server
            || SelectedVault?.TeamId is not { } teamId
            || SelectedMember is not { } member)
        {
            return;
        }

        // Read before the removal, because afterwards this list no longer contains them — and it is the
        // list of who the new key goes to.
        var remaining = Members
            .Where(row => row.UserId != member.UserId)
            .Select(row => row.UserId)
            .ToList();

        await RunAsync(async () =>
        {
            await server.Teams
                .RemoveTeamMemberAsync(teamId, member.UserId, cancellationToken)
                .ConfigureAwait(true);

            var rotated = await RotateAfterRemovalAsync(server, teamId, remaining, cancellationToken)
                .ConfigureAwait(true);

            await ReloadAsync(cancellationToken).ConfigureAwait(true);

            // The honest sentence, not the reassuring one. See ADR 0001: revocation is not retroactive,
            // and a message implying otherwise is the one thing this screen must not say. The rotation
            // is described in the same breath for the same reason — it decides what happens next, not
            // what already happened.
            Status = $"Removed {member.Name}. {rotated} Anything they had already downloaded is still "
                + "on their machine — rotate the credentials that matter.";
        }).ConfigureAwait(true);
    }

    /// <summary>
    /// Rotates every vault behind this membership list that this machine can open, handing each new key
    /// to the members who remain.
    /// </summary>
    /// <returns>What to tell the user about the keys. Never null — something always happened.</returns>
    /// <remarks>
    /// The list rather than the one vault, for the reason <see cref="ShareWithAsync"/> gives: removing
    /// somebody is a change to the list, so every vault it carries is one they have just lost. A vault
    /// this machine cannot open is not rotated and is not counted as a failure: its key belongs to
    /// somebody else, the server has flagged it as owing a rekey, and its row says so until one of them
    /// does it.
    /// </remarks>
    private async Task<string> RotateAfterRemovalAsync(
        IVaultServer server,
        Guid teamId,
        IReadOnlyList<Guid> remaining,
        CancellationToken cancellationToken)
    {
        if (session() is not { } open)
        {
            return "Their key grants are withdrawn, so they can fetch nothing more. Unlock your "
                + "keychain to rotate the vault keys themselves.";
        }

        var reports = await open
            .RekeyTeamVaultsAsync(
                server.Grants, server.Directory, server.Sync, teamId, remaining, cancellationToken)
            .ConfigureAwait(true);

        if (reports.Count == 0)
        {
            return "Their key grants are withdrawn, so they can fetch nothing more. This machine holds "
                + "no key to any of it, so there was nothing here to rotate.";
        }

        var rotated = reports.Where(report => report.Rotated).ToList();
        var failed = reports.Where(report => !report.Rotated).ToList();

        var sentences = new List<string>();

        if (rotated.Count > 0)
        {
            sentences.AddRange(Describe(rotated));
        }

        if (failed.Count > 0)
        {
            sentences.Add(
                $"Could not rotate {Join(failed.Select(r => $"'{r.Name}' ({r.Failure?.Message})"))}.");
        }

        return string.Join(" ", sentences);
    }

    /// <summary>What the vaults that did rotate are now worth, in the order somebody needs it.</summary>
    private IEnumerable<string> Describe(List<VaultRekeyReport> rotated)
    {
        yield return
            $"Rotated {VaultCount(rotated.Count)} — {Join(rotated.Select(r => r.Name))} — so nothing "
            + "written from now on is readable to them.";

        // Two different promises, so two different sentences. A vault whose items were all moved onto
        // the new key is closed to them completely; one where some were left is closed to what happens
        // next, and the difference is not the interface's to blur.
        var sealedUp = rotated.Count(report => report.Sealed);

        yield return sealedUp == rotated.Count
            ? "Everything already stored was re-sealed under the new key too, so their old key opens "
                + "nothing."
            : $"{sealedUp} of {rotated.Count} had everything already stored re-sealed under the new "
                + "key; the rest still hold items under the old one and will be picked up next time. "
                + "Rotate the credentials that mattered either way.";

        // The members who did not get the new key. They are still in the vault and can still write, but
        // until somebody wraps it to them they will find it stops updating. Distinct by id rather than
        // by name, because two accounts can share a display name and collapsing them would tell
        // somebody one person is owed a key when two are.
        var missed = rotated
            .SelectMany(report => report.NotShared.Select(entry => entry.UserId))
            .Distinct()
            .Select(Name)
            .ToList();

        if (missed.Count > 0)
        {
            yield return $"The new key did not reach {Join(missed)} — press SHARE KEY for them, or "
                + "they will stop seeing changes.";
        }
    }

    /// <summary>What to call a member in a sentence, from the list this screen already has.</summary>
    private string Name(Guid userId) =>
        Members.FirstOrDefault(row => row.UserId == userId)?.Name ?? userId.ToString();

    /// <summary>
    /// Wraps the selected vault's key to the selected member.
    /// </summary>
    /// <remarks>
    /// Everything that makes this safe happens inside <see cref="VaultSession.ShareVaultAsync"/>: the key
    /// log is read and its chain verified, and the directory's answer has to appear in it unchanged before
    /// anything is wrapped. A refusal is reported here in full rather than as "sharing failed", because
    /// the reasons are not interchangeable — one of them means somebody is substituting keys.
    /// </remarks>
    [RelayCommand]
    private async Task ShareVaultAsync(CancellationToken cancellationToken)
    {
        if (connection() is not { } server
            || session() is not { } open
            || SelectedVault is not { } vault
            || SelectedMember is not { } member)
        {
            return;
        }

        if (member.IsSelf)
        {
            Status = "You already hold this vault's key.";
            return;
        }

        await RunAsync(async () =>
        {
            var outcome = await open
                .ShareVaultAsync(server.Grants, server.Directory, vault.VaultId, member.UserId, cancellationToken)
                .ConfigureAwait(true);

            // The generation count is said out loud when there is more than one, because it is the
            // answer to a question somebody will have about a rotated vault: whether the person they
            // just shared it with can see what was in it before the rotation.
            var history = outcome.Generations > 1
                ? $" All {outcome.Generations} generations of the key were wrapped, so they can read "
                    + "what was in the vault before it was last rotated."
                : string.Empty;

            Status = outcome.Shared
                ? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}{history}"
                : $"Did not share '{vault.Name}': {outcome.Message}";

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

    /// <summary>Withdraws the selected member's key to the selected vault.</summary>
    [RelayCommand]
    private async Task RevokeVaultAsync(CancellationToken cancellationToken)
    {
        if (connection() is not { } server
            || SelectedVault is not { } vault
            || SelectedMember is not { } member)
        {
            return;
        }

        await RunAsync(async () =>
        {
            var revoked = await server.Grants
                .RevokeVaultGrantAsync(vault.VaultId, member.UserId, cancellationToken)
                .ConfigureAwait(true);

            await ReloadAsync(cancellationToken).ConfigureAwait(true);

            Status = revoked
                ? $"Withdrew {member.Name}'s key to '{vault.Name}'. Future reads are blocked; what they "
                    + "already have is unaffected."
                : $"{member.Name} held no key to '{vault.Name}'.";
        }).ConfigureAwait(true);
    }

    partial void OnSelectedVaultChanged(VaultRowViewModel? value)
    {
        RaiseState();

        // An armed confirmation names the vault it was armed for, so a selection change has to disarm
        // it — otherwise the card stays on screen above a different vault and reads as being about it.
        PendingAction = null;
        IsRenamingVault = false;

        // The members panel is drawn over one vault's people; a selection that clears altogether — the
        // vault it was showing got deleted, or the list emptied — leaves nothing for it to be about.
        if (value is null)
        {
            IsMembersPanelOpen = false;
        }

        if (isReselecting)
        {
            return;
        }

        // Fire-and-forget on purpose, and the only place in this class that is: selection changes come
        // from a list box, which has no cancellation token and no way to await. Wrapped here rather than
        // inside the read, because the read is also awaited from inside a reload that already holds the
        // busy gate — and a second RunAsync there would find the gate held by its own caller and skip the
        // members list entirely.
        _ = RunAsync(() => LoadSelectedAsync(CancellationToken.None));
    }

    partial void OnPendingActionChanged(VaultActionRequest? value)
    {
        OnPropertyChanged(nameof(IsConfirming));
        OnPropertyChanged(nameof(ShowsVaultActions));
    }

    partial void OnNewMemberRoleChanged(TeamMemberRole value)
    {
        OnPropertyChanged(nameof(AddsAsViewer));
        OnPropertyChanged(nameof(AddsAsMember));
        OnPropertyChanged(nameof(AddsAsAdmin));
    }

    /// <summary>Reads who holds a key to the selected vault.</summary>
    /// <remarks>
    /// Every vault, including the personal one: its own self-grant is what makes it readable here, and a
    /// list that hid it would leave the one vault everybody has looking as though nobody could open it.
    /// </remarks>
    private async Task LoadGrantsAsync(CancellationToken cancellationToken)
    {
        if (connection() is not { } server || SelectedVault is not { } vault)
        {
            return;
        }

        // Read rather than incremented: this is a read *within* a selection rather than a new one, and it
        // is also called on its own after a share. Compared again below for the reason
        // <see cref="selectionGeneration"/> exists — two vaults' key holders in one list is the failure,
        // and it is the one an in-order answer cannot be relied on to prevent.
        var generation = selectionGeneration;

        var response = await server.Grants
            .ListVaultGrantsAsync(vault.VaultId, cancellationToken)
            .ConfigureAwait(true);

        if (generation != selectionGeneration)
        {
            return;
        }

        Grants.Clear();

        foreach (var grant in response.Grants)
        {
            Grants.Add(new VaultGrantRowViewModel(grant, response.KeyGeneration));
        }
    }

    /// <summary>Reads the selected vault's members and key holders.</summary>
    private async Task LoadSelectedAsync(CancellationToken cancellationToken)
    {
        var generation = ++selectionGeneration;

        Members.Clear();
        Grants.Clear();

        if (connection() is not { } server || SelectedVault is not { } vault)
        {
            return;
        }

        await LoadGrantsAsync(cancellationToken).ConfigureAwait(true);

        if (vault.TeamId is not { } teamId)
        {
            // The personal vault. There is no membership list behind it, and there is nothing to
            // read — which is a fact about this vault rather than about the connection.
            return;
        }

        var selfId = session()?.Profile.UserId;

        var members = await server.Teams
            .ListTeamMembersAsync(teamId, cancellationToken)
            .ConfigureAwait(true);

        if (generation != selectionGeneration)
        {
            return;
        }

        foreach (var member in members)
        {
            Members.Add(new VaultMemberRowViewModel(member, member.UserId == selfId));
        }
    }

    /// <summary>Tells the shell that the set of vaults, or one of their names, has moved.</summary>
    private Task NotifyVaultsChangedAsync(CancellationToken cancellationToken) =>
        vaultsChanged is { } notify ? notify(cancellationToken) : Task.CompletedTask;

    private void RaiseState()
    {
        OnPropertyChanged(nameof(HasVaults));
        OnPropertyChanged(nameof(HasSelection));
        OnPropertyChanged(nameof(CanAdministerSelected));
        OnPropertyChanged(nameof(OwnsSelected));
        OnPropertyChanged(nameof(SelectedIsShared));
        OnPropertyChanged(nameof(SelectedIsPersonal));
        OnPropertyChanged(nameof(CanDeleteSelected));
        OnPropertyChanged(nameof(SharedMembershipWarning));
        OnPropertyChanged(nameof(HasSharedMembershipWarning));
        OnPropertyChanged(nameof(IsOnline));
        OnPropertyChanged(nameof(IsUnlocked));
    }

    /// <remarks>
    /// One place that raises the busy flag and turns a failure into a sentence. An API exception's message
    /// is the server's problem detail, which is written for a person to read — see <c>Problems</c> — so it
    /// is shown rather than replaced with something vaguer.
    /// </remarks>
    private async Task RunAsync(Func<Task> work)
    {
        if (IsBusy)
        {
            return;
        }

        IsBusy = true;

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