using System.Security.Cryptography;
using DodoSSH.Api.Features.Events;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Npgsql;

namespace DodoSSH.Api.Features.Teams;

/// <summary>
/// Team vaults and the key grants that make them readable.
/// </summary>
/// <remarks>
/// <para>
/// Everything here stores bytes it cannot interpret. A wrapped vault key is sealed to a recipient's
/// X25519 key, and a grant signature is Ed25519 over a tuple this server never verifies — the two
/// together are what let a client detect a fabricated grant, and moving either check onto the server
/// would make it a convenience rather than the boundary. See docs/crypto.md §6 and §7.
/// </para>
/// <para>
/// What the server <em>can</em> check is that a grant is not obviously useless: that the recipient is
/// enrolled, that the fingerprint names their current key, and that the generation is the vault's
/// current one. Each of those would otherwise surface at the far end as a tag failure the recipient
/// reads as data corruption, days later, with nothing pointing at the grant that caused it.
/// </para>
/// </remarks>
internal sealed class VaultGrantService(
    DodoDbContext database,
    TimeProvider clock,
    IVaultEventPublisher events,
    ILogger<VaultGrantService> logger)
{
    /// <summary>
    /// Largest wrapped vault key accepted.
    /// </summary>
    /// <remarks>
    /// A <c>SealTo</c> envelope over a 32-byte key is 6 + 32 + 24 + 48 = 110 bytes. The cap is loose
    /// enough to survive a future envelope — the reserved hybrid seal in docs/crypto.md §8 is far
    /// larger — and tight enough that this column cannot be used as free storage on a server that
    /// stores it without being able to read it.
    /// </remarks>
    private const int MaxWrappedKeyBytes = 4096;

    /// <summary>Creates a vault owned by a team, with the creator's own grant.</summary>
    /// <remarks>
    /// The vault and its first grant are written together, for the reason enrollment gives about a
    /// personal vault: a vault with no grant is a container nobody can ever open, including whoever
    /// created it, because only a client can wrap the key and it has already moved on.
    /// </remarks>
    internal async Task<VaultSummary> CreateTeamVaultAsync(
        UserAccount user,
        Team team,
        CreateTeamVaultRequest request,
        CancellationToken cancellationToken)
    {
        var name = RequireVaultName(request.Name);

        if (request.VaultId == Guid.Empty)
        {
            throw new TeamInvalidException("A vault id is required. Generate a UUIDv7 on the client.");
        }

        RequireWrappedKey(request.WrappedVaultKey);
        RequireSignature(request.GrantSignature);

        var key = await RequireCurrentKeyAsync(user.Id, cancellationToken).ConfigureAwait(false);

        var taken = await database.Vaults
            .AnyAsync(v => v.Id == request.VaultId, cancellationToken)
            .ConfigureAwait(false);

        if (taken)
        {
            throw new TeamInvalidException(
                "That vault id is already in use. Generate a new UUIDv7 and retry.");
        }

        AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());

        try
        {
            await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
        }
        catch (DbUpdateException exception) when (IsUniqueViolation(exception))
        {
            // The pre-check covers the ordinary case; this is the race between two creates choosing
            // the same id, which must not surface as a 500 about a constraint.
            throw new TeamInvalidException(
                "That vault id is already in use. Generate a new UUIDv7 and retry.");
        }

        TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);

        return new VaultSummary(
            VaultId: request.VaultId,
            Name: name,
            IsPersonal: false,
            TeamId: team.Id,
            KeyGeneration: 1,
            Permissions: 0,
            WrappedVaultKey: request.WrappedVaultKey,
            RekeyRequired: false);
    }

    /// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
    private void AddVaultWithSelfGrant(
        UserAccount user,
        Team team,
        CreateTeamVaultRequest request,
        string name,
        UserKey key,
        DateTimeOffset now)
    {
        database.Vaults.Add(new Vault
        {
            Id = request.VaultId,
            Name = name,
            OwnerKind = VaultOwnerKind.Team,
            TeamId = team.Id,
            KeyGeneration = 1,
            CreatedAtUtc = now,
            UpdatedAtUtc = now,
        });

        database.VaultKeyGrants.Add(new VaultKeyGrant
        {
            Id = Guid.CreateVersion7(),
            VaultId = request.VaultId,
            KeyGeneration = 1,
            Kind = GrantKind.Member,
            RecipientUserId = user.Id,
            RecipientKeyFingerprint = key.FingerprintSha256,
            WrappedKey = request.WrappedVaultKey,
            GranterUserId = user.Id,
            GranterKeyFingerprint = key.FingerprintSha256,

            // No key log head, exactly as a personal vault's self-grant carries none: there is no
            // third party whose key could have been substituted here.
            KeyLogHead = null,
            Signature = request.GrantSignature,
            State = GrantState.Active,
            CreatedAtUtc = now,
        });
    }

    private static string RequireVaultName(string? value)
    {
        var name = (value ?? string.Empty).Trim();

        return name.Length is 0 or > 256
            ? throw new TeamInvalidException("A vault name of 1 to 256 characters is required.")
            : name;
    }

    /// <summary>
    /// Renames a vault, and the team behind it where that team exists to carry this vault alone.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>The team is renamed with it, and only when it owns nothing else.</b> A vault made from the
    /// vaults screen gets a team of its own named after it, and that team is not a thing the person who
    /// made it was ever shown — so a rename that moved the vault's name and left the team's would leave
    /// the operator, the logs and the database naming it something nobody uses. A team owning several
    /// vaults is a different situation: it has a name of its own that somebody chose, and renaming one of
    /// its vaults must not take it.
    /// </para>
    /// <para>
    /// The slug never moves, exactly as <c>UpdateTeamRequest</c> records: it is unique only among live
    /// teams, so a rename that changed it could take one an archived team is still holding.
    /// </para>
    /// </remarks>
    internal async Task<VaultSummary> RenameVaultAsync(
        Vault vault,
        UpdateVaultRequest request,
        CancellationToken cancellationToken)
    {
        var name = RequireVaultName(request.Name);

        vault.Name = name;
        vault.UpdatedAtUtc = clock.GetUtcNow();

        if (vault.TeamId is { } teamId)
        {
            var alone = !await database.Vaults
                .AnyAsync(
                    other => other.TeamId == teamId
                        && other.Id != vault.Id
                        && other.DeletedAtUtc == null,
                    cancellationToken)
                .ConfigureAwait(false);

            if (alone)
            {
                var team = await database.Teams
                    .SingleOrDefaultAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
                    .ConfigureAwait(false);

                if (team is not null)
                {
                    team.Name = name;
                }
            }
        }

        await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

        TeamLog.VaultRenamed(logger, vault.Id, vault.TeamId);

        return new VaultSummary(
            VaultId: vault.Id,
            Name: name,
            IsPersonal: vault.OwnerKind == VaultOwnerKind.Personal,
            TeamId: vault.TeamId,
            KeyGeneration: (uint)vault.KeyGeneration,
            Permissions: 0,
            WrappedVaultKey: null,
            RekeyRequired: vault.RekeyRequired);
    }

    /// <summary>
    /// Deletes a vault, and the team behind it where that team exists to carry this vault alone.
    /// </summary>
    /// <returns>
    /// The team that has been left owning nothing by this, or null where there is none. The caller
    /// archives it; see <see cref="DeleteVaultEndpoint"/> for why that is a second step.
    /// </returns>
    /// <remarks>
    /// <para>
    /// <b>Soft, like every other removal here.</b> The row is stamped rather than dropped, so an operator
    /// can see that a vault existed and what became of it — and so the foreign keys from its items and its
    /// grants stay valid. <c>VaultAccessService</c> filters on the stamp at both ends, so from the moment
    /// this commits the vault is absent from every member's <c>/me</c> and every call naming it answers 404.
    /// </para>
    /// <para>
    /// <b>The items are deliberately left alone.</b> They are ciphertext behind a vault nothing will now
    /// resolve, so deleting them buys no confidentiality — and it would destroy the one thing an operator
    /// restoring a vault deleted by mistake would need. What this is not is a promise about other people's
    /// machines: a member who synced yesterday still holds their copy, exactly as ADR 0001 says about
    /// revocation, and the deletion message says so.
    /// </para>
    /// <para>
    /// <b>Every grant is withdrawn in the same write.</b> Nothing reads them once the vault is gone, but a
    /// live grant on a deleted vault is a row that says somebody holds a key to something that no longer
    /// exists — and the grant list is the thing an operator reads to answer "who could open this".
    /// </para>
    /// </remarks>
    internal async Task<Team?> DeleteVaultAsync(
        UserAccount actor,
        Vault vault,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(vault);

        // A personal vault is where everything filed nowhere else lives, and it is created by enrollment
        // rather than by anybody choosing to make it. Deleting one would leave an enrolled account with a
        // key, no vault, and no way to make the vault it is supposed to have.
        if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is not { } teamId)
        {
            throw new VaultGrantInvalidException(
                "A personal vault cannot be deleted. It is where everything filed nowhere else lives, and "
                + "the account has no way to make another. Move what you want to keep into a shared vault "
                + "and delete that instead.");
        }

        var now = clock.GetUtcNow();

        var grants = await database.VaultKeyGrants
            .Where(g => g.VaultId == vault.Id && g.RevokedAtUtc == null)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        foreach (var grant in grants)
        {
            grant.State = GrantState.Revoked;
            grant.RevokedAtUtc = now;
        }

        vault.DeletedAtUtc = now;
        vault.UpdatedAtUtc = now;

        await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

        TeamLog.VaultDeleted(logger, vault.Id, teamId, actor.Id, grants.Count);

        // Read after the save, so "owns nothing else" is asked of a database that already knows this one
        // is gone. The team is returned rather than archived here for the reason the rename gives about
        // renaming it: a team carrying several vaults has a name and a membership list somebody chose, and
        // one made to carry this vault alone is a thing its creator was never shown.
        var alone = !await database.Vaults
            .AnyAsync(other => other.TeamId == teamId && other.DeletedAtUtc == null, cancellationToken)
            .ConfigureAwait(false);

        return alone
            ? await database.Teams
                .SingleOrDefaultAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
                .ConfigureAwait(false)
            : null;
    }

    /// <summary>Lists who can open a vault.</summary>
    /// <remarks>
    /// One row per holder, not one per grant. A rotated vault holds several grants per member — one per
    /// generation, which is what lets them read its history — and a listing that showed each of them
    /// would answer "who can open this" with the same person three times. The row carries the best key
    /// they hold: the live grant at the highest generation, or, for somebody whose access has been
    /// withdrawn, the most recent grant they had, so the withdrawal is still visible.
    /// </remarks>
    internal async Task<VaultGrantsResponse> ListGrantsAsync(
        Vault vault,
        CancellationToken cancellationToken)
    {
        var grants = await database.VaultKeyGrants
            .Where(g => g.VaultId == vault.Id && g.Kind == GrantKind.Member)
            .Include(g => g.RecipientUser)
            .OrderBy(g => g.CreatedAtUtc)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        var holders = grants
            .GroupBy(g => g.RecipientUserId!.Value)
            .Select(group => group
                .OrderByDescending(g => g.RevokedAtUtc is null)
                .ThenByDescending(g => g.KeyGeneration)
                .First())

            // The order the first grant of each holder was made in, so the list reads as the vault was
            // shared rather than reshuffling itself every time somebody is re-wrapped.
            .OrderBy(g => grants.Find(first => first.RecipientUserId == g.RecipientUserId)!.CreatedAtUtc)
            .ToList();

        return new VaultGrantsResponse(
            VaultId: vault.Id,
            KeyGeneration: (uint)vault.KeyGeneration,
            RekeyRequired: vault.RekeyRequired,
            Grants:
            [
                .. holders.Select(g => new VaultGrantSummary(
                    g.RecipientUserId!.Value,
                    g.RecipientUser?.Email,
                    g.RecipientUser?.DisplayName,
                    (uint)g.KeyGeneration,
                    ToContract(g.State),
                    g.GranterUserId,
                    g.CreatedAtUtc,
                    g.RevokedAtUtc)),
            ]);
    }

    /// <summary>Wraps a vault key to another member.</summary>
    /// <remarks>
    /// <para>
    /// Re-issuing to a recipient who already holds a live grant <em>for that generation</em> replaces it
    /// in place rather than inserting a second row, because the unique index permits exactly one live
    /// grant per recipient per generation — and because the operation somebody is actually performing
    /// when they do this is "wrap it again", after a botched first attempt.
    /// </para>
    /// <para>
    /// A recipient may hold one grant per generation at once, and after a rotation they need to: an item
    /// is sealed under whatever generation was current when it was written, so somebody given only the
    /// newest key would find everything older unreadable. Which generations get wrapped is the sharing
    /// client's decision — it is the only party that can tell which ones it holds.
    /// </para>
    /// </remarks>
    internal async Task IssueGrantAsync(
        UserAccount actor,
        Vault vault,
        IssueVaultGrantRequest request,
        CancellationToken cancellationToken)
    {
        await RequireIssuableAsync(vault, request, cancellationToken).ConfigureAwait(false);

        var granterKey = await RequireCurrentKeyAsync(actor.Id, cancellationToken)
            .ConfigureAwait(false);

        var generation = (int)request.KeyGeneration;

        var existing = await database.VaultKeyGrants
            .SingleOrDefaultAsync(
                g => g.VaultId == vault.Id
                    && g.KeyGeneration == generation
                    && g.RecipientUserId == request.RecipientUserId
                    && g.RevokedAtUtc == null,
                cancellationToken)
            .ConfigureAwait(false);

        var grant = existing ?? new VaultKeyGrant
        {
            Id = Guid.CreateVersion7(),
            VaultId = vault.Id,
            KeyGeneration = generation,
            Kind = GrantKind.Member,
            RecipientUserId = request.RecipientUserId,
            CreatedAtUtc = clock.GetUtcNow(),
        };

        grant.RecipientKeyFingerprint = request.RecipientKeyFingerprint;
        grant.WrappedKey = request.WrappedVaultKey;
        grant.GranterUserId = actor.Id;

        // Taken from the server's own view of the caller's key rather than from the request. The
        // client signed over the same value, so an honest client is unaffected; a field that could
        // disagree with reality is one a reader would have to decide which copy to believe.
        grant.GranterKeyFingerprint = granterKey.FingerprintSha256;

        grant.KeyLogHead = request.KeyLogHead;
        grant.Signature = request.GrantSignature;
        grant.State = GrantState.Active;

        if (existing is null)
        {
            database.VaultKeyGrants.Add(grant);
        }

        await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

        TeamLog.GrantIssued(
            logger, vault.Id, generation, request.RecipientUserId, actor.Id);

        // The recipient, never the actor. This is the whole of what makes a shared vault arrive at
        // once rather than on the recipient's next pass — and it is the case the README has had to
        // apologise for since sharing shipped. See ADR 0012.
        events.VaultAccessChanged(request.RecipientUserId);
    }

    /// <summary>
    /// Everything that can be checked about a grant without holding the vault key.
    /// </summary>
    /// <remarks>
    /// None of this verifies that the wrap contains the right key — nothing on this machine can. Each
    /// check exists because failing it would otherwise surface at the recipient as a tag failure they
    /// read as data corruption, long after the request that caused it.
    /// </remarks>
    private async Task RequireIssuableAsync(
        Vault vault,
        IssueVaultGrantRequest request,
        CancellationToken cancellationToken)
    {
        // Team vaults only. A personal vault has exactly one subject who may reach it, so a grant on
        // one would seal a key to somebody the access check will go on refusing — a row that looks
        // like sharing and is not. Moving the items into a team vault is the operation that shares.
        if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is not { } teamId)
        {
            throw new VaultGrantInvalidException(
                "Only a team vault can be shared. A personal vault is reachable by its owner alone, "
                + "so a grant on one would seal a key to somebody who still could not fetch it.");
        }

        RequireWrappedKey(request.WrappedVaultKey);
        RequireSignature(request.GrantSignature);
        RequireDigest(request.RecipientKeyFingerprint, "recipient key fingerprint");
        RequireDigest(request.KeyLogHead, "key log head");

        // Any generation the vault has actually reached, not only the current one — sharing a rotated
        // vault means handing over its history as well as its present. A generation ahead of the
        // current one is refused: nothing is sealed under it, so the grant would open nothing, and
        // accepting it would let a client move the vault forward without the transaction that does so.
        if (request.KeyGeneration is 0 || request.KeyGeneration > (uint)vault.KeyGeneration)
        {
            throw new VaultGrantInvalidException(
                $"This vault is at key generation {vault.KeyGeneration}. A grant for generation "
                + $"{request.KeyGeneration} would open nothing.");
        }

        var member = await database.TeamMemberships
            .AnyAsync(
                m => m.TeamId == teamId
                    && m.UserId == request.RecipientUserId
                    && m.Status == MembershipStatus.Active
                    && m.DeletedAtUtc == null,
                cancellationToken)
            .ConfigureAwait(false);

        if (!member)
        {
            throw new VaultGrantInvalidException(
                "That account is not an active member of the team that owns this vault. Add them to "
                + "the team first — a key wrapped to somebody the server will refuse to serve is a "
                + "grant that does nothing.");
        }

        var recipientKey = await RequireCurrentKeyAsync(request.RecipientUserId, cancellationToken)
            .ConfigureAwait(false);

        // The fingerprint the client signed over must be the key the recipient actually holds.
        // Otherwise the grant is sealed to a superseded key, opens nothing, and surfaces at the far
        // end as an unexplained decryption failure rather than as the mistake it is.
        if (!CryptographicOperations.FixedTimeEquals(
                recipientKey.FingerprintSha256, request.RecipientKeyFingerprint))
        {
            throw new VaultGrantInvalidException(
                "The fingerprint does not name the recipient's current identity key. Re-read the "
                + "directory and wrap the key again — theirs has been rotated since you fetched it.");
        }
    }

    /// <summary>
    /// Moves a vault to a fresh key generation, wrapped to the caller.
    /// </summary>
    /// <returns>The vault as the caller now sees it, at the generation this call created.</returns>
    /// <remarks>
    /// <para>
    /// <b>What the server contributes is the moment, not the key.</b> It cannot generate a vault key, tell
    /// that the one it is handed differs from the old one, or check that the caller held the old one at
    /// all. What it can do — and what nothing else can — is advance the generation exactly once, so two
    /// admins rotating the same vault at the same time do not both walk away believing they succeeded.
    /// The stale one's generation is no longer one past the current, and it is refused.
    /// </para>
    /// <para>
    /// <b>Earlier grants are left standing.</b> They are what the remaining members read the vault's
    /// history with: an item carries the generation it was sealed under, and nothing here re-encrypts
    /// items — only a client holding both keys could. The departed member is cut off by the revocation
    /// that removal already performed, which takes every generation they held.
    /// </para>
    /// <para>
    /// The rekey flag is cleared here rather than when the last member is re-wrapped, because it records
    /// that a membership change left the vault owing a rotation, and the rotation is this. Who still
    /// needs the new key is a different question, and the grant list answers it by generation.
    /// </para>
    /// </remarks>
    internal async Task<VaultSummary> RekeyAsync(
        UserAccount actor,
        Vault vault,
        int permissions,
        RekeyVaultRequest request,
        CancellationToken cancellationToken)
    {
        var key = await RequireRotatableAsync(actor, vault, request, cancellationToken)
            .ConfigureAwait(false);

        var now = clock.GetUtcNow();
        var generation = (int)request.KeyGeneration;

        AddSelfGrant(actor, vault, key, generation, request, now);

        vault.KeyGeneration = generation;
        vault.RekeyRequired = false;
        vault.RekeyReason = RekeyReason.None;
        vault.UpdatedAtUtc = now;

        try
        {
            // One SaveChanges, so the row and the grant land together. The vault's xmin concurrency
            // token is what makes the generation check above binding rather than advisory: a second
            // rotation that read the same generation fails here instead of overwriting this one.
            await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
        }
        catch (DbUpdateConcurrencyException)
        {
            // Reported as the same refusal the pre-check gives, because it is the same situation seen a
            // moment later — and a 500 about a concurrency token would tell the user nothing they could
            // act on. Retrying is safe: the caller generates a fresh key and reads the generation again.
            throw new VaultGrantInvalidException(
                "Somebody else rotated this vault while this rotation was being recorded. Read it again "
                + "and rotate from the generation they left behind.");
        }

        TeamLog.VaultRekeyed(logger, vault.Id, generation, actor.Id);

        var prior = await database.VaultKeyGrants
            .Where(g => g.VaultId == vault.Id
                && g.RecipientUserId == actor.Id
                && g.KeyGeneration < generation
                && g.State == GrantState.Active
                && g.RevokedAtUtc == null)
            .OrderBy(g => g.KeyGeneration)
            .Select(g => new VaultKeyWrap((uint)g.KeyGeneration, g.WrappedKey))
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        return new VaultSummary(
            VaultId: vault.Id,
            Name: vault.Name,
            IsPersonal: false,
            TeamId: vault.TeamId,
            KeyGeneration: request.KeyGeneration,
            Permissions: permissions,
            WrappedVaultKey: request.WrappedVaultKey,
            RekeyRequired: false,
            PriorKeyWraps: prior);
    }

    /// <summary>Records the rotating client's grant for the generation it has just created.</summary>
    private void AddSelfGrant(
        UserAccount actor,
        Vault vault,
        UserKey key,
        int generation,
        RekeyVaultRequest request,
        DateTimeOffset now) =>
        database.VaultKeyGrants.Add(new VaultKeyGrant
        {
            Id = Guid.CreateVersion7(),
            VaultId = vault.Id,
            KeyGeneration = generation,
            Kind = GrantKind.Member,
            RecipientUserId = actor.Id,
            RecipientKeyFingerprint = key.FingerprintSha256,
            WrappedKey = request.WrappedVaultKey,
            GranterUserId = actor.Id,
            GranterKeyFingerprint = key.FingerprintSha256,

            // No key log head, as every self-grant carries none: there is no third party whose key
            // could have been substituted when you wrap something to yourself.
            KeyLogHead = null,
            Signature = request.GrantSignature,
            State = GrantState.Active,
            CreatedAtUtc = now,
        });

    /// <summary>
    /// Everything that can be checked about a rotation before it is recorded.
    /// </summary>
    /// <returns>The caller's current identity key, which the new grant is filed against.</returns>
    private async Task<UserKey> RequireRotatableAsync(
        UserAccount actor,
        Vault vault,
        RekeyVaultRequest request,
        CancellationToken cancellationToken)
    {
        if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is null)
        {
            throw new VaultGrantInvalidException(
                "Only a team vault can be rotated. A personal vault has one reader, so a rotation "
                + "would re-wrap a key to the same person and change nothing about who can read it.");
        }

        RequireWrappedKey(request.WrappedVaultKey);
        RequireSignature(request.GrantSignature);

        if (request.KeyGeneration != (uint)vault.KeyGeneration + 1)
        {
            throw new VaultGrantInvalidException(
                $"This vault is at key generation {vault.KeyGeneration}, so the next one is "
                + $"{vault.KeyGeneration + 1} and not {request.KeyGeneration}. Read the vault again — "
                + "somebody else has rotated it since you last looked.");
        }

        var key = await RequireCurrentKeyAsync(actor.Id, cancellationToken).ConfigureAwait(false);

        // Held now, not merely permitted. The new key has to be wrapped from the old one, and an
        // account that cannot open the current generation cannot have done that — so a request from
        // one is either a mistake or an attempt to strand every other member behind a key nobody has.
        var holdsCurrent = await database.VaultKeyGrants
            .AnyAsync(
                g => g.VaultId == vault.Id
                    && g.KeyGeneration == vault.KeyGeneration
                    && g.RecipientUserId == actor.Id
                    && g.State == GrantState.Active
                    && g.RevokedAtUtc == null,
                cancellationToken)
            .ConfigureAwait(false);

        return holdsCurrent
            ? key
            : throw new VaultGrantInvalidException(
                "You hold no key to this vault at its current generation, so you cannot rotate it. Ask "
                + "a member who does.");
    }

    /// <summary>
    /// Withdraws a member's key grant.
    /// </summary>
    /// <returns>Whether there was a live grant to withdraw.</returns>
    /// <remarks>
    /// Blocks future reads and nothing else. Anything the recipient has already pulled is on their
    /// machine and stays there, which is why the vault is flagged for rekey and why the honest
    /// remediation for a departure is rotating the SSH credential itself. See ADR 0001.
    /// </remarks>
    internal async Task<bool> RevokeGrantAsync(
        UserAccount actor,
        Vault vault,
        Guid recipientUserId,
        CancellationToken cancellationToken)
    {
        if (recipientUserId == actor.Id)
        {
            throw new VaultGrantInvalidException(
                "You cannot withdraw your own key. It would leave you unable to read a vault you can "
                + "still write to, and nothing here can hand it back.");
        }

        var grants = await database.VaultKeyGrants
            .Where(g => g.VaultId == vault.Id
                && g.RecipientUserId == recipientUserId
                && g.RevokedAtUtc == null)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        if (grants.Count == 0)
        {
            return false;
        }

        var now = clock.GetUtcNow();

        foreach (var grant in grants)
        {
            grant.State = GrantState.Revoked;
            grant.RevokedAtUtc = now;
        }

        vault.RekeyRequired = true;
        vault.RekeyReason = RekeyReason.Requested;
        vault.UpdatedAtUtc = now;

        await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

        TeamLog.GrantRevoked(logger, vault.Id, recipientUserId, actor.Id);

        // Told so their client stops showing a vault it can no longer open, rather than leaving it
        // listed until the next pass. It does not reach what they already pulled — nothing can, see
        // ADR 0001 — and the server-side effect is immediate regardless of whether this arrives.
        events.VaultAccessChanged(recipientUserId);

        return true;
    }

    private async Task<UserKey> RequireCurrentKeyAsync(Guid userId, CancellationToken cancellationToken)
    {
        var key = await database.UserKeys
            .SingleOrDefaultAsync(k => k.UserId == userId && k.IsCurrent, cancellationToken)
            .ConfigureAwait(false);

        return key
            ?? throw new VaultGrantInvalidException(
                "That account has not published an identity key yet, so there is nothing to wrap a "
                + "vault key to. They have to sign in and set up their vault first.");
    }

    private static void RequireWrappedKey(byte[]? value)
    {
        if (value is null || value.Length == 0 || value.Length > MaxWrappedKeyBytes)
        {
            throw new VaultGrantInvalidException(
                $"A wrapped vault key of 1 to {MaxWrappedKeyBytes} bytes is required.");
        }
    }

    private static void RequireSignature(byte[]? value)
    {
        if (value is null || value.Length != 64)
        {
            throw new VaultGrantInvalidException("An Ed25519 grant signature is 64 bytes.");
        }
    }

    private static void RequireDigest(byte[]? value, string field)
    {
        if (value is null || value.Length != 32)
        {
            throw new VaultGrantInvalidException($"A {field} is 32 bytes.");
        }
    }

    private static VaultGrantState ToContract(GrantState state) => state switch
    {
        GrantState.Active => VaultGrantState.Active,
        GrantState.AwaitingRewrap => VaultGrantState.AwaitingRewrap,
        GrantState.Revoked => VaultGrantState.Revoked,
        _ => VaultGrantState.Unspecified,
    };

    private static bool IsUniqueViolation(DbUpdateException exception) =>
        string.Equals(
            (exception.InnerException as PostgresException)?.SqlState,
            PostgresErrorCodes.UniqueViolation,
            StringComparison.Ordinal);
}
