using System.Security.Cryptography;
using DodoSSH.Client.Api;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
using DodoSSH.Crypto;

namespace DodoSSH.Client.Session;

/// <summary>What a share attempt did.</summary>
/// <param name="Shared">Whether a grant was recorded.</param>
/// <param name="Verification">
/// How the recipient's key was checked. Present whether or not the share went ahead, because a refusal
/// is the interesting outcome and the reason for it is the whole of what a user needs to see.
/// </param>
/// <param name="Message">One line for a person. Never contains key material.</param>
/// <param name="Generations">
/// How many generations of the vault key were wrapped. One for a vault that has never been rotated;
/// more for one that has, because its older items are still sealed under the keys they were written
/// with and a recipient given only the newest would find them unreadable.
/// </param>
public sealed record ShareOutcome(
    bool Shared,
    RecipientVerification Verification,
    string Message,
    int Generations = 0);

/// <summary>What sharing or rotating one vault did, named so a message can say which vault.</summary>
/// <param name="VaultId">The vault.</param>
/// <param name="Name">Its display name.</param>
/// <param name="Outcome">What happened, when the attempt was made.</param>
/// <param name="Failure">
/// Why it was not, when it failed. Carried rather than thrown for the reason a per-vault sync report
/// carries its own: one unreachable vault must not stop the others, and a vault that silently did not
/// get the key is the outcome this whole design exists to make visible.
/// </param>
public sealed record VaultShareReport(
    Guid VaultId,
    string Name,
    ShareOutcome? Outcome,
    Exception? Failure)
{
    /// <summary>Whether a grant was recorded for this vault.</summary>
    public bool Succeeded => Outcome is { Shared: true };
}

/// <summary>What rotating one vault did.</summary>
/// <param name="VaultId">The vault.</param>
/// <param name="Name">Its display name.</param>
/// <param name="KeyGeneration">The generation it now holds, or zero if it was not rotated.</param>
/// <param name="Shared">The members the new key was wrapped to.</param>
/// <param name="NotShared">
/// The members it was not, with the reason. A rotation that re-wrapped to nobody has locked the
/// remaining members out of everything written from now on, which they must be told rather than left
/// to discover.
/// </param>
/// <param name="Failure">Why the rotation itself did not happen, when it did not.</param>
/// <param name="Reseal">
/// What moving the vault's stored items onto the new key achieved, or null when the rotation did not
/// get that far. A rotation without this has re-keyed the vault and not its contents, which is a
/// different guarantee — see <see cref="VaultResealer"/>.
/// </param>
public sealed record VaultRekeyReport(
    Guid VaultId,
    string Name,
    uint KeyGeneration,
    IReadOnlyList<Guid> Shared,
    IReadOnlyList<(Guid UserId, string Reason)> NotShared,
    Exception? Failure,
    ResealReport? Reseal = null)
{
    /// <summary>Whether the vault moved to a new key.</summary>
    public bool Rotated => Failure is null && KeyGeneration > 0;

    /// <summary>Whether everything in the vault is now sealed under that new key.</summary>
    public bool Sealed => Reseal is { Complete: true };
}

/// <summary>
/// Sharing, from the side that holds the keys.
/// </summary>
/// <remarks>
/// <para>
/// These live on <see cref="VaultSession"/> rather than in a service above it for the reason
/// registering a device does: wrapping a vault key is the one step only an unlocked session can
/// perform, and this type is the keyring's custodian. Everything else — the calls, the directory —
/// arrives as a parameter, so the session still knows nothing about how either is implemented.
/// </para>
/// <para>
/// <b>Nothing here trusts the server's answer about somebody else's key.</b> Every share reads the
/// whole key log, verifies its hash chain, and refuses unless the directory's answer appears in it
/// unchanged. That check is the difference between end-to-end encryption and a server that can read
/// everything by handing out a key of its own; see <see cref="KeyLogAudit"/> and ADR 0001.
/// </para>
/// </remarks>
public sealed partial class VaultSession
{
    /// <summary>
    /// Creates a vault owned by a team, generating its key here.
    /// </summary>
    /// <param name="api">The team calls.</param>
    /// <param name="teamId">The owning team.</param>
    /// <param name="name">Display name. Plaintext, as all vault names are.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The new vault, already readable by this session.</returns>
    /// <remarks>
    /// The key never leaves this process in the clear: it is generated here, sealed to this user's own
    /// encryption key, and the seal is what the server stores. The creator's grant carries no key log
    /// head, exactly as a personal vault's does not — there is no third party whose key could have been
    /// substituted when you wrap something to yourself.
    /// </remarks>
    public async Task<StoredVault> CreateTeamVaultAsync(
        ITeamApi api,
        Guid teamId,
        string name,
        CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);
        ArgumentNullException.ThrowIfNull(api);
        ArgumentException.ThrowIfNullOrWhiteSpace(name);

        var vaultId = Guid.CreateVersion7();
        var vaultKey = VaultKeys.Create();
        var now = clock.GetUtcNow();

        try
        {
            var request = BuildCreateRequest(vaultId, vaultKey, name, now);

            var summary = await api.CreateTeamVaultAsync(teamId, request, cancellationToken)
                .ConfigureAwait(false);

            var stored = ToStored(summary);

            await Vault.UpsertAsync(stored, cancellationToken).ConfigureAwait(false);

            // Adopted rather than unwrapped from the response: this process generated the key, so
            // unwrapping the server's copy of our own seal would be a round trip to learn something we
            // already know. The keyring takes ownership from here.
            keyring.Adopt(vaultId, vaultKey, summary.KeyGeneration);

            Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);

            return stored;
        }
        catch
        {
            // Never reached the keyring, so this is the only thing that can release it.
            CryptographicOperations.ZeroMemory(vaultKey);
            throw;
        }
    }

    /// <summary>
    /// Renames a vault, here and on the server.
    /// </summary>
    /// <param name="api">The vault calls.</param>
    /// <param name="vaultId">The vault to rename.</param>
    /// <param name="name">What to call it. Plaintext, as all vault names are.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The vault as this machine now holds it.</returns>
    /// <remarks>
    /// <para>
    /// Nothing is re-encrypted. A vault's name is the one thing about it the server stores in the clear —
    /// a person has to be able to choose a vault before anything is decrypted — so a rename is a plain
    /// column write at both ends and touches no key.
    /// </para>
    /// <para>
    /// <b>The cached row is edited rather than replaced with the response.</b> The server answers with a
    /// summary written for a caller who is not this one: no wrapped key and no permissions, because it
    /// has nothing to say about either that this session does not already hold. Replacing the cached row
    /// with it would take this machine's own grant away and leave the vault unreadable until the next
    /// refresh.
    /// </para>
    /// </remarks>
    public async Task<StoredVault> RenameVaultAsync(
        IVaultGrantApi api,
        Guid vaultId,
        string name,
        CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);
        ArgumentNullException.ThrowIfNull(api);
        ArgumentException.ThrowIfNullOrWhiteSpace(name);

        var summary = await api
            .RenameVaultAsync(vaultId, new UpdateVaultRequest(name), cancellationToken)
            .ConfigureAwait(false);

        var stored = Vaults.FirstOrDefault(vault => vault.VaultId == vaultId) is { } known
            ? known with { Name = summary.Name }
            : ToStored(summary);

        await Vault.UpsertAsync(stored, cancellationToken).ConfigureAwait(false);

        Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);

        return stored;
    }

    /// <summary>
    /// Deletes a vault, here and on the server.
    /// </summary>
    /// <param name="api">The vault calls.</param>
    /// <param name="vaultId">The vault to delete.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Whether there was a vault to delete.</returns>
    /// <remarks>
    /// <para>
    /// The server first, and this machine only if it agreed. The other order would take a vault off this
    /// screen and leave it on everybody else's, which is the one outcome worse than the deletion failing:
    /// the person who pressed it is then the only one who believes it is gone.
    /// </para>
    /// <para>
    /// <b>The key is dropped from the keyring, and the items are not.</b> Dropping the key is what makes
    /// this machine unable to read what is left, which is the honest end state — the ciphertext is still
    /// in this cache, as it is in every other member's, and pretending otherwise by deleting the rows
    /// would be claiming a reach this product does not have. See ADR 0001. The rows go with the next
    /// wipe of the cache; nothing reads them meanwhile, because every list is built from the vaults the
    /// keyring can open.
    /// </para>
    /// </remarks>
    public async Task<bool> DeleteVaultAsync(
        IVaultGrantApi api,
        Guid vaultId,
        CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);
        ArgumentNullException.ThrowIfNull(api);

        if (!await api.DeleteVaultAsync(vaultId, cancellationToken).ConfigureAwait(false))
        {
            return false;
        }

        keyring.Forget(vaultId);

        await Vault.RemoveAsync(vaultId, cancellationToken).ConfigureAwait(false);

        Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);

        return true;
    }

    /// <summary>
    /// Wraps a vault's key to another member, after verifying their published key.
    /// </summary>
    /// <param name="grants">The grant calls.</param>
    /// <param name="directory">The directory and the key log that makes it checkable.</param>
    /// <param name="vaultId">The vault to share.</param>
    /// <param name="recipientUserId">Who to share it with.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <remarks>
    /// <para>
    /// The verification is not optional and is not a parameter. A caller that could pass
    /// <c>skipChecks: true</c> is a caller that will, on the day the log is briefly unreachable, and the
    /// resulting grant is indistinguishable from a correct one afterwards.
    /// </para>
    /// <para>
    /// What this still cannot promise is that the key belongs to the person you meant. Compare
    /// <see cref="VerifiedRecipient.Fingerprint"/> with them over a channel this server does not carry;
    /// that is the only step that closes the gap, and the outcome message says so.
    /// </para>
    /// <para>
    /// <b>Every generation this session holds is wrapped, not only the newest.</b> A rotation does not
    /// re-encrypt what is already stored, so a vault that has been rotated twice holds items under three
    /// keys — and a recipient handed only the current one would open the vault to find most of it
    /// unreadable. This is also the only party that can do it: the server holds ciphertext it cannot
    /// read, and the recipient holds nothing yet.
    /// </para>
    /// </remarks>
    public async Task<ShareOutcome> ShareVaultAsync(
        IVaultGrantApi grants,
        IDirectoryApi directory,
        Guid vaultId,
        Guid recipientUserId,
        CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);
        ArgumentNullException.ThrowIfNull(grants);
        ArgumentNullException.ThrowIfNull(directory);

        if (!keyring.TryGet(vaultId, out _, out _))
        {
            throw new VaultUnreadableException(vaultId);
        }

        var entry = await directory.LookupByIdAsync(recipientUserId, cancellationToken)
            .ConfigureAwait(false);

        var log = await KeyLogAudit.ReadAsync(directory, cancellationToken).ConfigureAwait(false);
        var verification = KeyLogAudit.Verify(log, entry);

        if (!verification.IsVerified)
        {
            return new ShareOutcome(false, verification, verification.Message);
        }

        var recipient = verification.Recipient!;
        var generations = keyring.GenerationsHeld(vaultId);

        // Oldest first, so an interruption leaves the recipient holding history without the present
        // rather than the reverse. Both are incomplete; only one of them looks like a working vault
        // that is quietly missing its recent items.
        foreach (var generation in generations)
        {
            if (!keyring.TryGetAt(vaultId, generation, out var vaultKey))
            {
                continue;
            }

            await IssueAsync(grants, vaultId, vaultKey, generation, recipient, cancellationToken)
                .ConfigureAwait(false);
        }

        return new ShareOutcome(
            true,
            verification,
            "Shared. Check the fingerprint with them out of band — everything the client can verify on "
            + "its own only proves this server has been consistent with itself.",
            generations.Count);
    }

    /// <summary>
    /// Moves a vault to a fresh key and hands it to the members who are left.
    /// </summary>
    /// <param name="grants">The grant calls.</param>
    /// <param name="directory">The directory and the key log that makes it checkable.</param>
    /// <param name="sync">
    /// The synchronisation calls, for the last step: moving what is already stored onto the new key.
    /// </param>
    /// <param name="vaultId">The vault to rotate.</param>
    /// <param name="recipients">
    /// Who should hold the new key. The caller's own id may be in here and is ignored: this session
    /// wrapped the new key to itself as part of the rotation.
    /// </param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <remarks>
    /// <para>
    /// <b>Three acts, and only the first is atomic.</b> The generation advances in one server
    /// transaction, so there is no moment at which two clients disagree about which key is current.
    /// Wrapping it to each remaining member is a separate call per member, each verified against the key
    /// log the same way an ordinary share is — and any of them can fail. A member who was missed holds
    /// the vault's history and cannot read anything written since, which the report says so the
    /// interface can too.
    /// </para>
    /// <para>
    /// <b>The third act is re-sealing what is already there</b>, and it is what makes the rotation worth
    /// the name: until it has run, the vault's stored items are still sealed under keys the departed
    /// member may have kept. It runs last for a reason — it needs the new key, and it is the only step
    /// that can be interrupted without leaving anything broken, because a vault at mixed generations
    /// stays readable to everybody holding the grants. A pass that stops half way is re-run.
    /// </para>
    /// <para>
    /// <b>What none of it can do</b> is take back what the departed member already pulled onto their own
    /// machine. Retroactive revocation is not achievable; rotate the credentials themselves. See
    /// ADR 0001.
    /// </para>
    /// </remarks>
    public async Task<VaultRekeyReport> RekeyVaultAsync(
        IVaultGrantApi grants,
        IDirectoryApi directory,
        ISyncApi sync,
        Guid vaultId,
        IReadOnlyList<Guid> recipients,
        CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);
        ArgumentNullException.ThrowIfNull(grants);
        ArgumentNullException.ThrowIfNull(directory);
        ArgumentNullException.ThrowIfNull(sync);
        ArgumentNullException.ThrowIfNull(recipients);

        if (!keyring.TryGet(vaultId, out _, out var keyGeneration))
        {
            throw new VaultUnreadableException(vaultId);
        }

        var name = Vaults.FirstOrDefault(vault => vault.VaultId == vaultId)?.Name ?? "this vault";
        var summary = await RotateAsync(grants, vaultId, keyGeneration, cancellationToken)
            .ConfigureAwait(false);

        var shared = new List<Guid>();
        var missed = new List<(Guid UserId, string Reason)>();

        foreach (var recipient in recipients.Distinct().Where(id => id != Profile.UserId))
        {
            try
            {
                var outcome = await ShareVaultAsync(
                        grants, directory, vaultId, recipient, cancellationToken)
                    .ConfigureAwait(false);

                if (outcome.Shared)
                {
                    shared.Add(recipient);
                }
                else
                {
                    missed.Add((recipient, outcome.Message));
                }
            }
            catch (Exception exception) when (exception is not OperationCanceledException)
            {
                // One member's key being unusable — never enrolled, rotated their identity key mid-call
                // — is not a reason to leave the rest of the team without the new one.
                missed.Add((recipient, exception.Message));
            }
        }

        // Synced before re-sealing, and it is not tidiness. The pass rewrites each item against the
        // version the server holds, so a mirror that is behind produces a batch of conflicts instead of
        // a re-sealed vault — and the sync also carries out anything queued here, which the push path
        // re-seals on the way rather than leaving to be found later.
        await SyncAsync(sync, vaultId, cancellationToken).ConfigureAwait(false);

        var resealed = await ResealVaultAsync(sync, vaultId, cancellationToken).ConfigureAwait(false);

        return new VaultRekeyReport(
            vaultId, name, summary.KeyGeneration, shared, missed, Failure: null, resealed);
    }

    /// <summary>Generates the next vault key, records it, and takes it into the keyring.</summary>
    /// <remarks>
    /// The key is adopted only after the server has accepted the rotation. The other order would leave
    /// this session sealing items under a generation the vault never reached, and every one of them
    /// would be unreadable to everybody including its author at the next unlock.
    /// </remarks>
    private async Task<VaultSummary> RotateAsync(
        IVaultGrantApi grants,
        Guid vaultId,
        uint keyGeneration,
        CancellationToken cancellationToken)
    {
        var generation = keyGeneration + 1;
        var vaultKey = VaultKeys.Create();
        var now = clock.GetUtcNow();

        try
        {
            var wrapped = VaultKeys.WrapTo(
                vaultKey, bundle.EncryptionPublicKey, vaultId, generation);

            var fingerprint = DshCrypto.ComputeFingerprint(
                bundle.EncryptionPublicKey, bundle.SigningPublicKey);

            var canonical = GrantStatementCodec.Encode(
                vaultId,
                generation,
                GrantPurpose.Member,
                granteeUserId: Profile.UserId,
                granteeKeyFingerprint: fingerprint,
                wrappedKey: wrapped,
                granterUserId: Profile.UserId,
                granterKeyFingerprint: fingerprint,

                // Absent, as in every self-grant: there is no third party whose key could have been
                // substituted when you wrap something to yourself.
                keyLogHead: default,
                grantedAt: now);

            var summary = await grants.RekeyVaultAsync(
                    vaultId,
                    new RekeyVaultRequest(
                        KeyGeneration: generation,
                        WrappedVaultKey: wrapped,
                        GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, canonical),
                        GrantedAt: now),
                    cancellationToken)
                .ConfigureAwait(false);

            var stored = ToStored(summary);

            await Vault.UpsertAsync(stored, cancellationToken).ConfigureAwait(false);

            keyring.Adopt(vaultId, vaultKey, summary.KeyGeneration);

            Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);

            return summary;
        }
        catch
        {
            // Never reached the keyring, so this is the only thing that can release it.
            CryptographicOperations.ZeroMemory(vaultKey);
            throw;
        }
    }

    /// <summary>
    /// Hands every team vault this session can open to one member.
    /// </summary>
    /// <returns>One report per vault, in the order they were attempted.</returns>
    /// <remarks>
    /// What "adding somebody to a team" means in full. Membership is a server-side authorization change
    /// and takes effect at once; a key is a cryptographic act only a machine holding one can perform, so
    /// this is the half that has to happen here. A vault this session cannot open is skipped rather than
    /// failed — somebody else holds its key, and this client has nothing to wrap.
    /// </remarks>
    public async Task<IReadOnlyList<VaultShareReport>> ShareTeamVaultsAsync(
        IVaultGrantApi grants,
        IDirectoryApi directory,
        Guid teamId,
        Guid recipientUserId,
        CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);
        ArgumentNullException.ThrowIfNull(grants);
        ArgumentNullException.ThrowIfNull(directory);

        var reports = new List<VaultShareReport>();

        foreach (var vault in TeamVaults(teamId))
        {
            try
            {
                var outcome = await ShareVaultAsync(
                        grants, directory, vault.VaultId, recipientUserId, cancellationToken)
                    .ConfigureAwait(false);

                reports.Add(new VaultShareReport(vault.VaultId, vault.Name, outcome, null));
            }
            catch (Exception exception) when (exception is not OperationCanceledException)
            {
                reports.Add(new VaultShareReport(vault.VaultId, vault.Name, null, exception));
            }
        }

        return reports;
    }

    /// <summary>
    /// Rotates every team vault this session can open, handing each new key to the members who remain.
    /// </summary>
    /// <returns>One report per vault, in the order they were attempted.</returns>
    /// <remarks>
    /// What "removing somebody from a team" means in full, and the reason it is per vault rather than
    /// per team: a key belongs to a vault, and a client can only rotate the ones it can currently open.
    /// A vault it cannot is left alone and stays flagged for rekey, which is the honest state — somebody
    /// who holds its key has to finish the job.
    /// </remarks>
    public async Task<IReadOnlyList<VaultRekeyReport>> RekeyTeamVaultsAsync(
        IVaultGrantApi grants,
        IDirectoryApi directory,
        ISyncApi sync,
        Guid teamId,
        IReadOnlyList<Guid> recipients,
        CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);
        ArgumentNullException.ThrowIfNull(grants);
        ArgumentNullException.ThrowIfNull(directory);
        ArgumentNullException.ThrowIfNull(sync);
        ArgumentNullException.ThrowIfNull(recipients);

        var reports = new List<VaultRekeyReport>();

        foreach (var vault in TeamVaults(teamId))
        {
            try
            {
                reports.Add(
                    await RekeyVaultAsync(
                            grants, directory, sync, vault.VaultId, recipients, cancellationToken)
                        .ConfigureAwait(false));
            }
            catch (Exception exception) when (exception is not OperationCanceledException)
            {
                reports.Add(new VaultRekeyReport(
                    vault.VaultId, vault.Name, KeyGeneration: 0, [], [], exception));
            }
        }

        return reports;
    }

    /// <summary>The team's vaults this session actually holds a current key for.</summary>
    /// <remarks>
    /// Materialised before the loops above use it, because both of them write to <see cref="Vaults"/>
    /// through the vault store — and a rotation part-way through a lazily evaluated sequence would be
    /// enumerating a list that has been replaced underneath it.
    /// </remarks>
    private List<StoredVault> TeamVaults(Guid teamId) =>
        [.. Vaults.Where(vault => vault.TeamId == teamId && keyring.CanRead(vault.VaultId))];

    /// <summary>
    /// Re-reads which vaults the server says are reachable, and opens any that have become readable.
    /// </summary>
    /// <returns>How many vaults this call made readable that were not before.</returns>
    /// <remarks>
    /// Called after a share and on a periodic pass. A vault somebody shared a minute ago arrives as a
    /// new entry with a wrapped key attached; one whose grant was revoked arrives without one, and is
    /// marked unreadable rather than quietly dropped so the interface can say what happened. Items
    /// already pulled are deliberately left alone — see <see cref="VaultStore.ReplaceAllAsync"/>.
    /// </remarks>
    public async Task<int> RefreshVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);
        ArgumentNullException.ThrowIfNull(api);

        var me = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);

        await Vault.ReplaceAllAsync([.. me.Vaults.Select(ToStored)], cancellationToken)
            .ConfigureAwait(false);

        Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);

        var admitted = 0;

        foreach (var vault in Vaults)
        {
            // Attempted even for a vault that already opens, because the answer can have grown: a
            // rotated vault arrives with a new current generation, and a vault shared by somebody who
            // holds more of its history arrives with wraps this session did not have. Admitting is
            // idempotent, so the only thing an unconditional call costs is the unwrap it skips.
            var readable = keyring.CanRead(vault.VaultId);

            if (keyring.TryAdmit(bundle, vault))
            {
                if (!readable)
                {
                    admitted++;
                }
            }
            else
            {
                keyring.MarkUnreadable(vault.VaultId);
            }
        }

        return admitted;
    }

    /// <summary>Signs and posts one grant.</summary>
    private async Task IssueAsync(
        IVaultGrantApi grants,
        Guid vaultId,
        ReadOnlyMemory<byte> vaultKey,
        uint keyGeneration,
        VerifiedRecipient recipient,
        CancellationToken cancellationToken)
    {
        var now = clock.GetUtcNow();
        var entry = recipient.Entry;

        var wrapped = VaultKeys.WrapTo(
            vaultKey.Span, entry.EncryptionPublicKey, vaultId, keyGeneration);

        var ownFingerprint = DshCrypto.ComputeFingerprint(
            bundle.EncryptionPublicKey, bundle.SigningPublicKey);

        var canonical = GrantStatementCodec.Encode(
            vaultId,
            keyGeneration,
            GrantPurpose.Member,
            granteeUserId: entry.UserId,
            granteeKeyFingerprint: recipient.Fingerprint,
            wrappedKey: wrapped,
            granterUserId: Profile.UserId,
            granterKeyFingerprint: ownFingerprint,

            // Present, unlike a self-grant's. This is the third-party case the head exists for: it
            // records which view of the key log this client held while wrapping, so a server showing
            // two clients different logs has to keep both stories straight for ever after.
            keyLogHead: recipient.KeyLogHead,
            grantedAt: now);

        await grants.IssueVaultGrantAsync(
                vaultId,
                new IssueVaultGrantRequest(
                    RecipientUserId: entry.UserId,
                    RecipientKeyFingerprint: recipient.Fingerprint,
                    KeyGeneration: keyGeneration,
                    WrappedVaultKey: wrapped,
                    KeyLogHead: recipient.KeyLogHead,
                    GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, canonical),
                    GrantedAt: now),
                cancellationToken)
            .ConfigureAwait(false);
    }

    /// <remarks>
    /// The signature covers the vault id, so the id has to be chosen before anything is wrapped — which
    /// is also what makes a create whose response was lost safe to send again.
    /// </remarks>
    private CreateTeamVaultRequest BuildCreateRequest(
        Guid vaultId,
        byte[] vaultKey,
        string name,
        DateTimeOffset now)
    {
        var wrapped = VaultKeys.WrapTo(vaultKey, bundle.EncryptionPublicKey, vaultId, 1);

        var fingerprint = DshCrypto.ComputeFingerprint(
            bundle.EncryptionPublicKey, bundle.SigningPublicKey);

        var canonical = GrantStatementCodec.Encode(
            vaultId,
            keyGeneration: 1,
            GrantPurpose.Member,
            granteeUserId: Profile.UserId,
            granteeKeyFingerprint: fingerprint,
            wrappedKey: wrapped,
            granterUserId: Profile.UserId,
            granterKeyFingerprint: fingerprint,
            keyLogHead: default,
            grantedAt: now);

        return new CreateTeamVaultRequest(
            VaultId: vaultId,
            Name: name,
            WrappedVaultKey: wrapped,
            GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, canonical),
            GrantedAt: now);
    }

    private static StoredVault ToStored(VaultSummary summary) =>
        new(
            summary.VaultId,
            summary.Name,
            summary.IsPersonal,
            summary.TeamId,
            summary.KeyGeneration,
            summary.Permissions,
            summary.WrappedVaultKey,
            summary.RekeyRequired,
            summary.PriorKeyWraps);
}
