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

namespace DodoSSH.Client.Shell.ViewModels;

/// <summary>One team, as a row in the list.</summary>
internal sealed record TeamRowViewModel(TeamSummary Team)
{
    internal Guid TeamId => Team.TeamId;

    internal string Name => Team.Name;

    internal string Slug => Team.Slug;

    /// <summary>The caller's own role, as the chip the list shows.</summary>
    internal string Role => Team.Role.ToString().ToUpperInvariant();

    internal string Detail => string.Create(
        CultureInfo.CurrentCulture,
        $"{Team.MemberCount} member(s) · {Team.VaultCount} vault(s)");

    /// <summary>Whether this account may add members and create vaults here.</summary>
    internal bool CanAdminister =>
        Team.Role is TeamMemberRole.Admin or TeamMemberRole.Owner;
}

/// <summary>One member, as a row in the members table.</summary>
internal sealed record TeamMemberRowViewModel(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 a 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>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 TeamGrantRowViewModel(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>One invitation, as a row under the members it will join.</summary>
internal sealed record TeamInvitationRowViewModel(TeamInvitationSummary Invitation)
{
    internal Guid InvitationId => Invitation.InvitationId;

    internal string Email => Invitation.Email;

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

    /// <summary>
    /// What has become of it, said as a sentence rather than a status word.
    /// </summary>
    /// <remarks>
    /// The pending case has to carry the whole mechanism, because there is nothing else on this screen
    /// that could: nothing was sent, so somebody reading "invited" would reasonably wait for an email
    /// that is never coming.
    /// </remarks>
    internal string State => Invitation.State switch
    {
        TeamInvitationState.Accepted => "joined",
        TeamInvitationState.Revoked => "withdrawn",
        TeamInvitationState.Expired => "expired — invite them again if they still need it",
        _ => "waiting — they join when they first sign in here. Nothing was sent; tell them yourself.",
    };

    /// <summary>Whether this invitation can still be withdrawn.</summary>
    internal bool IsPending => Invitation.State == TeamInvitationState.Pending;
}

/// <summary>
/// A destructive team operation, armed and waiting to be confirmed.
/// </summary>
/// <remarks>
/// The armed-state idiom the vault 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 id is 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="TeamId">The team the action is aimed at.</param>
/// <param name="MemberId">The member it is aimed at, for a transfer.</param>
/// <param name="Question">What is being asked.</param>
/// <param name="Consequence">What will actually happen, stated honestly.</param>
internal sealed record TeamActionRequest(
    Guid TeamId,
    Guid MemberId,
    string Question,
    string Consequence);

/// <summary>One vault of the selected team, with what this account can do to it.</summary>
internal sealed record TeamVaultRowViewModel(Guid VaultId, string Name, bool IsReadable, bool RekeyRequired)
{
    /// <summary>What the row says about itself.</summary>
    /// <remarks>
    /// The unreadable case is the one that has to read clearly, because it is normal rather than broken:
    /// somebody has been added to a team and nobody has wrapped the vault key to them yet.
    /// </remarks>
    internal string State => (IsReadable, RekeyRequired) switch
    {
        (false, _) => "waiting for a key — ask a member who has one to share it",
        (true, true) => "readable · a rekey is owed after a membership change",
        _ => "readable",
    };
}

/// <summary>
/// The teams screen: who is in a team, what they may do, and which vaults they hold a key to.
/// </summary>
/// <remarks>
/// <para>
/// <b>Two separate acts, and the screen is built around saying so.</b> Adding somebody to a team is a
/// server-side authorization change and takes effect immediately. Giving them a 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 <c>TeamService</c> and ADR 0001.
/// </para>
/// <para>
/// Nothing on this screen is cached across a lock. It reads the server on open and after each change,
/// because membership is not vault content and has no local mirror — a team list in the encrypted cache
/// would be a second copy of something the server is authoritative for.
/// </para>
/// </remarks>
internal sealed partial class TeamsViewModel(
    Func<IVaultServer?> connection,
    Func<VaultSession?> session) : ObservableObject
{
    /// <summary>Teams this account belongs to.</summary>
    internal ObservableCollection<TeamRowViewModel> Teams { get; } = [];

    /// <summary>Members of the selected team.</summary>
    internal ObservableCollection<TeamMemberRowViewModel> Members { get; } = [];

    /// <summary>Vaults the selected team owns, as far as this account can see them.</summary>
    internal ObservableCollection<TeamVaultRowViewModel> Vaults { 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<TeamGrantRowViewModel> Grants { get; } = [];

    /// <summary>Invitations to addresses that are not accounts here yet.</summary>
    internal ObservableCollection<TeamInvitationRowViewModel> Invitations { get; } = [];

    [ObservableProperty]
    private TeamRowViewModel? selectedTeam;

    [ObservableProperty]
    private TeamMemberRowViewModel? selectedMember;

    [ObservableProperty]
    private TeamVaultRowViewModel? selectedVault;

    [ObservableProperty]
    private TeamInvitationRowViewModel? selectedInvitation;

    [ObservableProperty]
    private string status = string.Empty;

    [ObservableProperty]
    private bool isBusy;

    // ---- Creating a team ----

    [ObservableProperty]
    private bool isCreatingTeam;

    [ObservableProperty]
    private string newTeamName = string.Empty;

    [ObservableProperty]
    private string newTeamSlug = string.Empty;

    // ---- Renaming a team ----

    [ObservableProperty]
    private bool isEditingTeam;

    [ObservableProperty]
    private string editTeamName = string.Empty;

    [ObservableProperty]
    private string editTeamDescription = string.Empty;

    // ---- Adding a member ----

    [ObservableProperty]
    private string inviteEmail = string.Empty;

    /// <summary>
    /// The role a newly added or invited 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 TeamActionRequest? pendingAction;

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

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

    /// <summary>Whether this account owns the selected team.</summary>
    /// <remarks>
    /// A narrower gate than <see cref="CanAdministerSelected"/>, and the server draws the same line:
    /// archiving a team and handing it over decide whether it goes on existing and who controls it,
    /// so an admin the owner promoted must not be able to do either.
    /// </remarks>
    internal bool OwnsSelected => SelectedTeam?.Team.Role == TeamMemberRole.Owner;

    /// <summary>Whether there is anything to show below the team list.</summary>
    internal bool HasSelection => SelectedTeam is not null;

    internal bool HasTeams => Teams.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 team 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 ShowsTeamActions => !IsConfirming;

    /// <summary>Whether the selected team has any invitation worth drawing a list for.</summary>
    internal bool HasInvitations => Invitations.Count > 0;

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

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

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

    /// <summary>Reads the teams this account belongs to, 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 on this screen 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 team that was created moments ago missing from the list it was just added to.
    /// </remarks>
    private async Task ReloadAsync(CancellationToken cancellationToken)
    {
        if (connection() is not { } server)
        {
            Teams.Clear();
            Members.Clear();
            Vaults.Clear();
            RaiseState();

            Status = "Offline. Teams are read from the server, so this screen needs a connection.";
            return;
        }

        var selectedId = SelectedTeam?.TeamId;

        var teams = await server.Teams.ListTeamsAsync(cancellationToken).ConfigureAwait(true);

        Teams.Clear();

        foreach (var team in teams)
        {
            Teams.Add(new TeamRowViewModel(team));
        }

        SelectedTeam =
            Teams.FirstOrDefault(row => row.TeamId == selectedId) ?? Teams.FirstOrDefault();

        RaiseState();

        await LoadSelectedAsync(cancellationToken).ConfigureAwait(true);

        Status = Teams.Count == 0
            ? "You are not in a team yet. Create one to share hosts and credentials with colleagues."
            : string.Empty;
    }

    /// <summary>Opens the create-a-team form.</summary>
    [RelayCommand]
    private void NewTeam()
    {
        NewTeamName = string.Empty;
        NewTeamSlug = string.Empty;
        IsCreatingTeam = true;
        Status = string.Empty;
    }

    /// <summary>Abandons the create-a-team form.</summary>
    [RelayCommand]
    private void CancelNewTeam()
    {
        IsCreatingTeam = false;
        Status = string.Empty;
    }

    /// <summary>Creates a team, with this account as its owner.</summary>
    /// <remarks>
    /// The id is generated here, which is what makes a create whose response was lost safe to send again —
    /// the server treats an identical repeat as the same team rather than a second one.
    /// </remarks>
    [RelayCommand]
    private async Task CreateTeamAsync(CancellationToken cancellationToken)
    {
        if (connection() is not { } server)
        {
            Status = "Offline. Creating a team needs a connection.";
            return;
        }

        var name = NewTeamName.Trim();
        var slug = NewTeamSlug.Trim().ToLowerInvariant();

        if (name.Length == 0 || slug.Length == 0)
        {
            Status = "A team needs a name and a slug.";
            return;
        }

        await RunAsync(async () =>
        {
            var created = await server.Teams
                .CreateTeamAsync(
                    new CreateTeamRequest(Guid.CreateVersion7(), name, slug, null), cancellationToken)
                .ConfigureAwait(true);

            IsCreatingTeam = false;

            await ReloadAsync(cancellationToken).ConfigureAwait(true);

            SelectedTeam = Teams.FirstOrDefault(row => row.TeamId == created.TeamId) ?? SelectedTeam;

            Status = $"Created '{created.Name}'. Add a vault to it, then share that vault's key with "
                + "whoever needs it.";
        }).ConfigureAwait(true);
    }

    /// <summary>
    /// Adds a member, by looking their address up in the directory first.
    /// </summary>
    /// <remarks>
    /// Two calls rather than one, and the order is the point: the directory is what turns an address into
    /// an account and a public key, and the key that gets verified before any sharing is the one that
    /// lookup returned. Letting the server resolve an address to an account inside the add would put an
    /// unwitnessed step between the two.
    /// </remarks>
    [RelayCommand]
    private async Task AddMemberAsync(CancellationToken cancellationToken)
    {
        if (connection() is not { } server || SelectedTeam is not { } team)
        {
            return;
        }

        var email = InviteEmail.Trim();

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

        await RunAsync(async () =>
        {
            var found = await server.Directory.LookupByEmailAsync(email, cancellationToken)
                .ConfigureAwait(true);

            if (found.Count == 0)
            {
                await InviteAsync(server, team, email, cancellationToken).ConfigureAwait(true);
                return;
            }

            var member = await server.Teams
                .AddTeamMemberAsync(
                    team.TeamId,
                    new AddTeamMemberRequest(found[0].UserId, NewMemberRole),
                    cancellationToken)
                .ConfigureAwait(true);

            InviteEmail = string.Empty;

            await ReloadAsync(cancellationToken).ConfigureAwait(true);

            // Said out loud, every time. The single most common misunderstanding this design invites is
            // that adding somebody gave them the vault.
            Status = $"Added {member.Email ?? member.DisplayName ?? "the account"} as a member. They "
                + "cannot read anything yet — select a vault below and share its key.";
        }).ConfigureAwait(true);
    }

    /// <summary>
    /// Invites an address the directory does not know.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Reached by falling through from <see cref="AddMemberAsync"/> rather than from a second button,
    /// because the person typing an address does not know or care which of the two applies — that is a
    /// fact about the server's account table, not about what they are trying to do. Which one happened
    /// is reported afterwards, because the difference decides what they have to do next.
    /// </para>
    /// <para>
    /// The message has to carry the whole mechanism. Nothing is sent — this server has no outbound
    /// mail — so somebody who reads "invited" and waits has been misled by an interface that knew
    /// better.
    /// </para>
    /// </remarks>
    private async Task InviteAsync(
        IVaultServer server,
        TeamRowViewModel team,
        string email,
        CancellationToken cancellationToken)
    {
        var invitation = await server.Teams
            .CreateTeamInvitationAsync(
                team.TeamId,
                new CreateTeamInvitationRequest(Guid.CreateVersion7(), email, NewMemberRole),
                cancellationToken)
            .ConfigureAwait(true);

        InviteEmail = string.Empty;

        await ReloadAsync(cancellationToken).ConfigureAwait(true);

        Status = $"No account here has the address '{email}' yet, so it has been invited instead. "
            + $"They join this team as {invitation.Role.ToString().ToLowerInvariant()} the first time "
            + "they sign in. Nothing was sent — this server cannot send mail, so tell them yourself — "
            + "and their identity provider has to confirm the address is theirs.";
    }

    /// <summary>Withdraws an invitation that has not been taken up.</summary>
    [RelayCommand]
    private async Task RevokeInvitationAsync(CancellationToken cancellationToken)
    {
        if (connection() is not { } server
            || SelectedTeam is not { } team
            || SelectedInvitation is not { } invitation)
        {
            return;
        }

        await RunAsync(async () =>
        {
            var revoked = await server.Teams
                .RevokeTeamInvitationAsync(team.TeamId, invitation.InvitationId, cancellationToken)
                .ConfigureAwait(true);

            await ReloadAsync(cancellationToken).ConfigureAwait(true);

            Status = revoked
                ? $"Withdrew the invitation to {invitation.Email}. Signing in will no longer put them "
                    + "in this team."
                : $"The invitation to {invitation.Email} was already taken up or withdrawn. If they "
                    + "are a member now, remove them instead.";
        }).ConfigureAwait(true);
    }

    /// <summary>Picks the role a newly added or invited 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
            || SelectedTeam is not { } team
            || 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(
                    team.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>Opens the rename form for the selected team.</summary>
    [RelayCommand]
    private void RenameTeam()
    {
        if (SelectedTeam is not { } team)
        {
            return;
        }

        EditTeamName = team.Name;
        EditTeamDescription = team.Team.Description ?? string.Empty;
        IsEditingTeam = true;
        Status = string.Empty;
    }

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

    /// <summary>Saves the renamed team.</summary>
    [RelayCommand]
    private async Task SaveTeamAsync(CancellationToken cancellationToken)
    {
        if (connection() is not { } server || SelectedTeam is not { } team)
        {
            return;
        }

        var name = EditTeamName.Trim();

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

        var description = EditTeamDescription.Trim();

        await RunAsync(async () =>
        {
            await server.Teams
                .UpdateTeamAsync(
                    team.TeamId,
                    new UpdateTeamRequest(name, description.Length == 0 ? null : description),
                    cancellationToken)
                .ConfigureAwait(true);

            IsEditingTeam = false;

            await ReloadAsync(cancellationToken).ConfigureAwait(true);

            // The slug is named because it did not change and somebody expecting it to would
            // otherwise find out from a URL much later.
            Status = $"Renamed to '{name}'. Its slug is still '{team.Slug}' — that is what URLs and "
                + "the server's own records use, and it does not change.";
        }).ConfigureAwait(true);
    }

    /// <summary>Arms the archive confirmation for the selected team.</summary>
    [RelayCommand]
    private void ArchiveTeam()
    {
        if (SelectedTeam is not { } team)
        {
            return;
        }

        PendingAction = new TeamActionRequest(
            team.TeamId,
            Guid.Empty,
            $"Archive '{team.Name}'?",
            "Everybody loses sight of it at once, and only somebody with database access can bring it "
            + "back. It is refused outright if the team still owns any vault.");
    }

    /// <summary>Arms the hand-over confirmation for the selected member.</summary>
    [RelayCommand]
    private void TransferOwnership()
    {
        if (SelectedTeam is not { } team || SelectedMember is not { } member)
        {
            return;
        }

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

        PendingAction = new TeamActionRequest(
            team.TeamId,
            member.UserId,
            $"Hand '{team.Name}' to {member.Name}?",
            "They become the owner and you become an admin. You will not be able to take it back "
            + "yourself — only the new owner can hand it on.");
    }

    /// <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(async () =>
        {
            if (request.MemberId == Guid.Empty)
            {
                var archived = await server.Teams
                    .ArchiveTeamAsync(request.TeamId, cancellationToken)
                    .ConfigureAwait(true);

                SelectedTeam = null;

                await ReloadAsync(cancellationToken).ConfigureAwait(true);

                Status = archived
                    ? "Archived. It is gone from everybody's list; the rows are still in the database "
                        + "and only an operator can bring them back."
                    : "There was no such team to archive.";

                return;
            }

            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 team now, and only its new owner can hand "
                + "it on again.";
        }).ConfigureAwait(true);
    }

    /// <summary>Removes a member, revoking every vault key grant they hold from this team.</summary>
    [RelayCommand]
    private async Task RemoveMemberAsync(CancellationToken cancellationToken)
    {
        if (connection() is not { } server
            || SelectedTeam is not { } team
            || SelectedMember is not { } member)
        {
            return;
        }

        await RunAsync(async () =>
        {
            await server.Teams
                .RemoveTeamMemberAsync(team.TeamId, member.UserId, 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.
            Status = $"Removed {member.Name}. They can no longer fetch this team's vaults, and anything "
                + "they had already downloaded is still on their machine — rotate the credentials that "
                + "matter.";
        }).ConfigureAwait(true);
    }

    /// <summary>Creates a vault owned by the selected team.</summary>
    [RelayCommand]
    private async Task CreateVaultAsync(CancellationToken cancellationToken)
    {
        if (connection() is not { } server
            || session() is not { } open
            || SelectedTeam is not { } team)
        {
            return;
        }

        await RunAsync(async () =>
        {
            var vault = await open
                .CreateTeamVaultAsync(server.Teams, team.TeamId, team.Name, cancellationToken)
                .ConfigureAwait(true);

            await ReloadAsync(cancellationToken).ConfigureAwait(true);

            Status = $"Created the vault '{vault.Name}'. It is yours alone until you share its key; new "
                + "hosts and credentials can be filed into it from the Vault screen.";
        }).ConfigureAwait(true);
    }

    /// <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);

            Status = outcome.Shared
                ? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}"
                : $"Did not share '{vault.Name}': {outcome.Message}";
        }).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 OnSelectedTeamChanged(TeamRowViewModel? value)
    {
        RaiseState();

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

        // 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. Failures land in Status
        // through RunAsync exactly as a command's would.
        _ = LoadSelectedAsync(CancellationToken.None);
    }

    partial void OnPendingActionChanged(TeamActionRequest? value)
    {
        OnPropertyChanged(nameof(IsConfirming));
        OnPropertyChanged(nameof(ShowsTeamActions));
    }

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

    /// <remarks>
    /// The grants list belongs to a vault rather than to a team, so it is reloaded on selection here
    /// rather than in <see cref="LoadSelectedAsync"/> — which would leave it showing the previous
    /// vault's key-holders after a click.
    /// </remarks>
    partial void OnSelectedVaultChanged(TeamVaultRowViewModel? value) =>
        _ = LoadGrantsAsync(CancellationToken.None);

    /// <summary>Reads who holds a key to the selected vault.</summary>
    private async Task LoadGrantsAsync(CancellationToken cancellationToken)
    {
        Grants.Clear();

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

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

            Grants.Clear();

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

    /// <summary>Reads the selected team's members, invitations and vaults.</summary>
    private async Task LoadSelectedAsync(CancellationToken cancellationToken)
    {
        Members.Clear();
        Invitations.Clear();
        Vaults.Clear();
        Grants.Clear();

        if (connection() is not { } server || SelectedTeam is not { } team)
        {
            return;
        }

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

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

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

        var invitations = await server.Teams
            .ListTeamInvitationsAsync(team.TeamId, cancellationToken)
            .ConfigureAwait(true);

        foreach (var invitation in invitations)
        {
            Invitations.Add(new TeamInvitationRowViewModel(invitation));
        }

        SelectedInvitation = Invitations.FirstOrDefault(row => row.IsPending);

        OnPropertyChanged(nameof(HasInvitations));

        if (open is null)
        {
            return;
        }

        // Read from the session rather than from a team-vaults endpoint, because the interesting fact
        // about a team vault here is whether *this* machine can open it — which is a property of the
        // keyring and not something the server can answer.
        var readable = open.ReadableVaults.Select(vault => vault.VaultId).ToHashSet();

        foreach (var vault in open.Vaults.Where(vault => vault.TeamId == team.TeamId))
        {
            Vaults.Add(new TeamVaultRowViewModel(
                vault.VaultId, vault.Name, readable.Contains(vault.VaultId), vault.RekeyRequired));
        }

        SelectedVault = Vaults.FirstOrDefault();
    }

    private void RaiseState()
    {
        OnPropertyChanged(nameof(HasTeams));
        OnPropertyChanged(nameof(HasSelection));
        OnPropertyChanged(nameof(CanAdministerSelected));
        OnPropertyChanged(nameof(OwnsSelected));
        OnPropertyChanged(nameof(HasInvitations));
        OnPropertyChanged(nameof(IsOnline));
    }

    /// <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;
        }
    }
}
