using DodoSSH.Client.Api;
using DodoSSH.Contracts;
using DodoSSH.Crypto;

namespace DodoSSH.Client.App.Tests;

/// <summary>
/// The team, directory and grant half of the fake server.
/// </summary>
/// <remarks>
/// <para>
/// <b>The key log is real.</b> Entries are chained with <see cref="KeyLogChain.ComputeEntryHash"/> exactly
/// as the server chains them, because the client refuses to wrap a vault key to a directory answer that
/// does not appear in a log whose chain verifies — so a fake that returned a plausible-looking log would
/// make every sharing test pass against a check that was never exercised. It also means a test can break
/// the chain deliberately and watch the client refuse.
/// </para>
/// <para>
/// Everything else is deliberately thin. Roles, slugs and idempotency are the server's rules and are
/// tested against the real one in <c>DodoSSH.Api.Tests</c>; what the shell needs from here is that a team
/// can be created, a member added, and a vault key wrapped and recorded.
/// </para>
/// </remarks>
internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultGrantApi
{
    private readonly List<TeamSummary> teams = [];
    private readonly Dictionary<Guid, List<TeamMemberSummary>> members = [];
    private readonly Dictionary<Guid, VaultSummary> teamVaults = [];
    /// <remarks>
    /// Keyed by generation as well as by recipient, because the real table is: a rotation leaves a
    /// member holding one grant per generation, and a fake that kept one per person would quietly model
    /// sharing the history as overwriting it — which is the bug this half of the feature exists to
    /// avoid.
    /// </remarks>
    private readonly Dictionary<(Guid VaultId, Guid UserId, uint KeyGeneration), IssueVaultGrantRequest>
        grants = [];
    private readonly List<KeyLogRecord> keyLog = [];
    private readonly List<DirectoryEntry> directory = [];

    /// <summary>
    /// Every account on this fake server, enrolled or not.
    /// </summary>
    /// <remarks>
    /// Kept apart from <see cref="directory"/> because the real server keeps them apart, and the gap
    /// between the two is where a real bug lived: the directory omits anybody who has not published a
    /// key, so a fake that had only one list could not tell an account that does not exist from one
    /// that exists and has not enrolled — which is exactly the distinction the add path turns on.
    /// </remarks>
    private readonly List<(Guid UserId, string Email, string DisplayName)> accounts = [];

    /// <inheritdoc />
    public ITeamApi Teams => this;

    /// <inheritdoc />
    public IDirectoryApi Directory => this;

    /// <inheritdoc />
    public IVaultGrantApi Grants => this;

    /// <summary>
    /// Grants this fake has been asked to record, newest generation per recipient.
    /// </summary>
    /// <remarks>
    /// Flattened to one entry per recipient because that is the question most tests are asking — can
    /// this person open the vault as it stands. <see cref="GenerationsGranted"/> is for the ones asking
    /// whether they were also given its history.
    /// </remarks>
    internal IReadOnlyDictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> IssuedGrants =>
        grants
            .GroupBy(entry => (entry.Key.VaultId, entry.Key.UserId))
            .ToDictionary(
                group => group.Key,
                group => group.OrderByDescending(entry => entry.Key.KeyGeneration).First().Value);

    /// <summary>Which generations of one vault's key a recipient has been wrapped, oldest first.</summary>
    internal IReadOnlyList<uint> GenerationsGranted(Guid vaultId, Guid userId) =>
        [
            .. grants.Keys
                .Where(key => key.VaultId == vaultId && key.UserId == userId)
                .Select(key => key.KeyGeneration)
                .Order(),
        ];

    /// <summary>
    /// When true, the log served omits its last entry's link, so its chain no longer verifies.
    /// </summary>
    /// <remarks>
    /// The switch a test flips to prove the client refuses rather than shares. A fake with no way to be
    /// wrong can only ever confirm the happy path.
    /// </remarks>
    internal bool CorruptKeyLog { get; set; }

    /// <summary>Slugs this fake refuses, as the real server refuses one already in use.</summary>
    /// <remarks>
    /// A vault's slug is derived from its name rather than typed, so a collision is something the client
    /// has to get out of on its own — and a fake that accepted every slug could not tell whether it does.
    /// </remarks>
    internal HashSet<string> TakenSlugs { get; } = new(StringComparer.Ordinal);

    /// <summary>How many vault creates to refuse before answering normally.</summary>
    /// <remarks>
    /// Creating a vault of its own is two calls, and the failure worth testing is the one between them:
    /// the team is made and the vault is not. One refusal is enough to leave the client in that state and
    /// let the test press CREATE again.
    /// </remarks>
    internal int VaultCreateFailures { get; set; }

    /// <summary>How many team creates have been asked for, for a test to assert on.</summary>
    internal int TeamCreates { get; private set; }

    /// <summary>Registers another account, as though they had signed in and enrolled here.</summary>
    /// <returns>Their user id.</returns>
    internal Guid AddAccount(string email, string displayName)
    {
        var userId = Guid.CreateVersion7();

        // Real keys rather than filler: the client recomputes the fingerprint over both halves and refuses
        // an entry whose fingerprint does not match, so random bytes would fail for the wrong reason.
        using var bundle = UserSecretBundle.Create(DateTimeOffset.UnixEpoch);

        var sequence = AppendKeyLog(
            userId, bundle.EncryptionPublicKey, bundle.SigningPublicKey, new byte[64]);

        directory.Add(new DirectoryEntry(
            userId,
            email,
            displayName,
            bundle.EncryptionPublicKey,
            bundle.SigningPublicKey,
            DshCrypto.ComputeFingerprint(bundle.EncryptionPublicKey, bundle.SigningPublicKey),
            KeyGeneration: 1,
            KeyLogSequence: sequence));

        accounts.Add((userId, email, displayName));

        return userId;
    }

    /// <summary>
    /// Registers an account that has signed in here but has not enrolled a key.
    /// </summary>
    /// <remarks>
    /// Normal rather than exotic: an account exists from its owner's first authenticated request and
    /// stays keyless until they choose a passphrase on their own machine. It is absent from the
    /// directory throughout, because a directory entry exists to be wrapped to and this one has nothing
    /// to wrap. It can still be made a member — membership grants nothing readable.
    /// </remarks>
    /// <returns>Their user id.</returns>
    internal Guid AddUnenrolledAccount(string email, string displayName)
    {
        var userId = Guid.CreateVersion7();

        accounts.Add((userId, email, displayName));

        return userId;
    }

    /// <summary>
    /// Puts a vault somebody else made, and shared with this account, on the server.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The other half of sharing, which no test can otherwise reach: every vault in this suite is one
    /// this client made, and a vault this client made is one it already holds the key to. What arrives
    /// on the machine somebody shared <em>with</em> is different — a vault that appears in <c>/me</c>
    /// out of nowhere, with a key wrapped to this account by a client this one never spoke to.
    /// </para>
    /// <para>
    /// The wrap is real, made against the encryption key this account enrolled, so the keyring opens it
    /// exactly as it opens one from a real colleague. A helper that filled the field with bytes would
    /// let a vault appear in the list and never prove it could be read.
    /// </para>
    /// </remarks>
    /// <param name="name">What the vault is called.</param>
    /// <param name="sharedBy">The account that made it, from <see cref="AddAccount"/>.</param>
    /// <returns>The vault's id.</returns>
    internal Guid ShareVaultWithMe(string name, Guid sharedBy)
    {
        if (statement is not { } enrolled)
        {
            throw new InvalidOperationException(
                "Nothing can be wrapped to this account until it has enrolled a key.");
        }

        var teamId = Guid.CreateVersion7();
        var vaultId = Guid.CreateVersion7();
        var vaultKey = VaultKeys.Create();

        var wrapped = VaultKeys.WrapTo(vaultKey, enrolled.EncryptionPublicKey, vaultId, 1);

        teams.Add(new TeamSummary(
            teamId,
            name,
            name.ToLowerInvariant().Replace(' ', '-'),
            Description: null,

            // A member rather than an owner: somebody else made this and this account was added to it,
            // which is what decides whether the screen offers to rename or remove it.
            TeamMemberRole.Member,
            MemberCount: 2,
            VaultCount: 1,
            DateTimeOffset.UnixEpoch));

        var sharer = accounts.Find(account => account.UserId == sharedBy);

        members[teamId] =
        [
            Member(sharedBy, sharer.Email, sharer.DisplayName, TeamMemberRole.Owner),
            Member(UserId, "alice@example.com", "Alice Example", TeamMemberRole.Member),
        ];

        teamVaults[vaultId] = new VaultSummary(
            vaultId,
            name,
            IsPersonal: false,
            TeamId: teamId,
            KeyGeneration: 1,
            Permissions: 31,
            wrapped,
            RekeyRequired: false);

        return vaultId;
    }

    /// <summary>One active, enrolled member, which is the only kind this helper makes.</summary>
    private static TeamMemberSummary Member(
        Guid userId,
        string email,
        string displayName,
        TeamMemberRole role) =>
        new(
            userId,
            email,
            displayName,
            role,
            TeamMemberStatus.Active,
            IsEnrolled: true,
            DateTimeOffset.UnixEpoch,
            DateTimeOffset.UnixEpoch);

    /// <inheritdoc />
    public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
        Task.FromResult<IReadOnlyList<TeamSummary>>([.. teams]);

    /// <inheritdoc />
    public Task<TeamSummary> CreateTeamAsync(
        CreateTeamRequest request,
        CancellationToken cancellationToken)
    {
        TeamCreates++;

        // Idempotent on the client-chosen id, as the real one is. That is the whole of how a create whose
        // second half failed is retried without leaving a second team behind, so a fake that made one
        // anyway would let the bug through.
        if (teams.Find(row => row.TeamId == request.TeamId) is { } existing)
        {
            return Task.FromResult(existing);
        }

        if (TakenSlugs.Contains(request.Slug))
        {
            throw new DodoSshApiException(
                System.Net.HttpStatusCode.Conflict,
                ProblemCodes.TeamSlugTaken,
                $"The slug '{request.Slug}' is already in use.");
        }

        var team = new TeamSummary(
            request.TeamId,
            request.Name,
            request.Slug,
            request.Description,
            TeamMemberRole.Owner,
            MemberCount: 1,
            VaultCount: 0,
            DateTimeOffset.UnixEpoch);

        teams.Add(team);

        members[team.TeamId] =
        [
            new TeamMemberSummary(
                UserId,
                "alice@example.com",
                "Alice Example",
                TeamMemberRole.Owner,
                TeamMemberStatus.Active,
                IsEnrolled: true,
                DateTimeOffset.UnixEpoch,
                DateTimeOffset.UnixEpoch),
        ];

        return Task.FromResult(team);
    }

    /// <inheritdoc />
    public Task<TeamSummary> UpdateTeamAsync(
        Guid teamId,
        UpdateTeamRequest request,
        CancellationToken cancellationToken)
    {
        var index = teams.FindIndex(team => team.TeamId == teamId);

        if (index < 0)
        {
            throw new DodoSshApiException(
                System.Net.HttpStatusCode.NotFound, ProblemCodes.InvalidTeam, "No such team.");
        }

        // The slug is deliberately not touched, matching the server: a rename changes the display
        // name only. A fake that also moved the slug would let a test assert behaviour nothing has.
        teams[index] = teams[index] with
        {
            Name = request.Name,
            Description = request.Description,
        };

        return Task.FromResult(teams[index]);
    }

    /// <inheritdoc />
    /// <remarks>
    /// The vault refusal is reproduced rather than skipped, unlike the other server rules here. It is
    /// the one whose consequence the shell has to render — a status line explaining why nothing
    /// happened — so a fake that always succeeded would leave that path untested.
    /// </remarks>
    public Task<bool> ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken)
    {
        var index = teams.FindIndex(team => team.TeamId == teamId);

        if (index < 0)
        {
            return Task.FromResult(false);
        }

        if (teamVaults.Values.Any(vault => vault.TeamId == teamId))
        {
            throw new DodoSshApiException(
                System.Net.HttpStatusCode.Conflict,
                ProblemCodes.TeamNotEmpty,
                "This team still owns vaults, and archiving it would take them away from everybody "
                + "holding a key — including you.");
        }

        teams.RemoveAt(index);
        members.Remove(teamId);

        return Task.FromResult(true);
    }

    /// <inheritdoc />
    /// <remarks>
    /// Both rows move, because a fake that only promoted the recipient would let a test pass while
    /// the team was owned twice — which is the exact failure the real service uses a transaction to
    /// make impossible.
    /// </remarks>
    public Task TransferTeamOwnershipAsync(
        Guid teamId,
        TransferTeamOwnershipRequest request,
        CancellationToken cancellationToken)
    {
        var list = members.GetValueOrDefault(teamId, []);
        var incoming = list.FindIndex(member => member.UserId == request.UserId);

        if (incoming < 0)
        {
            throw new DodoSshApiException(
                System.Net.HttpStatusCode.BadRequest,
                ProblemCodes.InvalidTeam,
                "That account is not an active member of this team.");
        }

        var outgoing = list.FindIndex(member => member.Role == TeamMemberRole.Owner);

        list[incoming] = list[incoming] with { Role = TeamMemberRole.Owner };

        if (outgoing >= 0)
        {
            list[outgoing] = list[outgoing] with { Role = TeamMemberRole.Admin };
        }

        var index = teams.FindIndex(team => team.TeamId == teamId);

        if (index >= 0)
        {
            teams[index] = teams[index] with { Role = TeamMemberRole.Admin };
        }

        return Task.CompletedTask;
    }

    /// <summary>
    /// When set, a member read waits on it before answering.
    /// </summary>
    /// <remarks>
    /// Every other method here answers from memory and therefore completes before its caller's await
    /// ever suspends, which hides anything the screen only gets wrong while a read is in flight — the
    /// state a real server leaves it in for the length of a round trip. A test that wants that state
    /// holds the gate.
    /// </remarks>
    internal TaskCompletionSource? MemberReadGate { get; set; }

    /// <summary>How many member reads have been asked for, for a test to assert on.</summary>
    internal int MemberReads { get; private set; }

    /// <inheritdoc />
    public async Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
        Guid teamId,
        CancellationToken cancellationToken)
    {
        MemberReads++;

        if (MemberReadGate is { } gate)
        {
            await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
        }

        return members.TryGetValue(teamId, out var list) ? [.. list] : [];
    }

    /// <summary>Adds a member, resolved by id when the caller has one and by address otherwise.</summary>
    /// <remarks>
    /// Resolved against <see cref="accounts"/> rather than <see cref="directory"/>, which is the whole
    /// point of the two being separate here: an account with no published key is missing from the
    /// directory and is still perfectly addable. <c>IsEnrolled</c> is reported from whether the
    /// directory has them rather than hardcoded, so a member row can say it holds no key.
    /// </remarks>
    public Task<TeamMemberSummary> AddTeamMemberAsync(
        Guid teamId,
        AddTeamMemberRequest request,
        CancellationToken cancellationToken)
    {
        var account = request.UserId != Guid.Empty
            ? accounts.Find(candidate => candidate.UserId == request.UserId)
            : accounts.Find(candidate => string.Equals(
                candidate.Email, request.Email, StringComparison.OrdinalIgnoreCase));

        if (account.UserId == Guid.Empty)
        {
            throw new DodoSshApiException(
                System.Net.HttpStatusCode.NotFound,
                ProblemCodes.NoSuchAccount,
                "No such account on this server.");
        }

        // LastActiveAt is left null: this account has been added, not seen. The owner's row carries a
        // real one, so both branches of the interface's "last active / never" split are exercised.
        var member = new TeamMemberSummary(
            account.UserId,
            account.Email,
            account.DisplayName,
            request.Role,
            TeamMemberStatus.Active,
            IsEnrolled: directory.Exists(entry => entry.UserId == account.UserId),
            DateTimeOffset.UnixEpoch,
            LastActiveAt: null);

        members[teamId] = [.. members.GetValueOrDefault(teamId, []), member];

        Recount(teamId);

        return Task.FromResult(member);
    }

    /// <inheritdoc />
    public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
        Guid teamId,
        Guid userId,
        ChangeTeamMemberRoleRequest request,
        CancellationToken cancellationToken)
    {
        var list = members.GetValueOrDefault(teamId, []);
        var index = list.FindIndex(member => member.UserId == userId);

        if (index < 0)
        {
            throw new DodoSshApiException(
                System.Net.HttpStatusCode.BadRequest,
                ProblemCodes.InvalidTeam,
                "That account is not an active member of this team.");
        }

        list[index] = list[index] with { Role = request.Role };

        return Task.FromResult(list[index]);
    }

    /// <inheritdoc />
    public Task<bool> RemoveTeamMemberAsync(
        Guid teamId,
        Guid userId,
        CancellationToken cancellationToken)
    {
        var list = members.GetValueOrDefault(teamId, []);
        var removed = list.RemoveAll(member => member.UserId == userId) > 0;

        // Every grant they held from this team goes with them, as the real service revokes them in the
        // same transaction. A fake that removed the membership and left the grants would let a test
        // "prove" a revocation that had not happened.
        var theirs = grants.Keys
            .Where(key => key.UserId == userId
                && teamVaults.TryGetValue(key.VaultId, out var vault)
                && vault.TeamId == teamId)
            .ToList();

        // Every generation, not only the newest. A revocation that left the history behind would let
        // them go on reading everything written before the rotation that follows.
        foreach (var key in theirs)
        {
            grants.Remove(key);
        }

        Recount(teamId);

        return Task.FromResult(removed);
    }

    /// <inheritdoc />
    public Task<VaultSummary> CreateTeamVaultAsync(
        Guid teamId,
        CreateTeamVaultRequest request,
        CancellationToken cancellationToken)
    {
        if (VaultCreateFailures > 0)
        {
            VaultCreateFailures--;

            throw new DodoSshApiException(
                System.Net.HttpStatusCode.ServiceUnavailable,
                code: null,
                "The server is not answering.");
        }

        var vault = new VaultSummary(
            request.VaultId,
            request.Name,
            IsPersonal: false,
            TeamId: teamId,
            KeyGeneration: 1,
            Permissions: 31,
            request.WrappedVaultKey,
            RekeyRequired: false);

        teamVaults[vault.VaultId] = vault;

        // The creator's own grant, as the real create records it in the same transaction. Without it a
        // rotation here would report no earlier wraps and the vault's first generation would vanish.
        grants[(vault.VaultId, UserId, 1)] = new IssueVaultGrantRequest(
            UserId,
            RecipientKeyFingerprint: new byte[32],
            KeyGeneration: 1,
            request.WrappedVaultKey,
            KeyLogHead: new byte[32],
            request.GrantSignature,
            request.GrantedAt);

        Recount(teamId);

        return Task.FromResult(vault);
    }

    /// <inheritdoc />
    /// <remarks>
    /// The owning team is renamed with the vault when it owns nothing else, exactly as the real service
    /// does it — a fake that moved only the vault would let a test pass while the two names disagreed,
    /// which is the state the server code goes out of its way to avoid.
    /// </remarks>
    public Task<VaultSummary> RenameVaultAsync(
        Guid vaultId,
        UpdateVaultRequest request,
        CancellationToken cancellationToken)
    {
        if (personalVault is { } personal && personal.VaultId == vaultId)
        {
            personalVault = personal with { Name = request.Name };

            return Task.FromResult(personalVault);
        }

        if (!teamVaults.TryGetValue(vaultId, out var vault))
        {
            throw new DodoSshApiException(
                System.Net.HttpStatusCode.NotFound, ProblemCodes.InvalidTeam, "No such vault.");
        }

        var renamed = vault with { Name = request.Name };

        teamVaults[vaultId] = renamed;

        if (renamed.TeamId is { } teamId
            && !teamVaults.Values.Any(other => other.TeamId == teamId && other.VaultId != vaultId))
        {
            var index = teams.FindIndex(team => team.TeamId == teamId);

            if (index >= 0)
            {
                teams[index] = teams[index] with { Name = request.Name };
            }
        }

        return Task.FromResult(renamed);
    }

    /// <inheritdoc />
    /// <remarks>
    /// Every grant to the vault goes with it, as the real service withdraws them in the same write, and the
    /// team behind it is archived when it owns nothing else — the second half of what the endpoint does.
    /// A fake that kept either would let a test assert a deletion that had left the vault readable, or
    /// leave the vaults screen listing a membership list with no vault under it.
    /// </remarks>
    public Task<bool> DeleteVaultAsync(Guid vaultId, CancellationToken cancellationToken)
    {
        if (personalVault is { } personal && personal.VaultId == vaultId)
        {
            throw new DodoSshApiException(
                System.Net.HttpStatusCode.BadRequest,
                ProblemCodes.InvalidVaultGrant,
                "A personal vault cannot be deleted.");
        }

        if (!teamVaults.Remove(vaultId, out var vault))
        {
            return Task.FromResult(false);
        }

        foreach (var key in grants.Keys.Where(key => key.VaultId == vaultId).ToList())
        {
            grants.Remove(key);
        }

        if (vault.TeamId is { } teamId && !teamVaults.Values.Any(other => other.TeamId == teamId))
        {
            teams.RemoveAll(team => team.TeamId == teamId);
            members.Remove(teamId);
        }

        return Task.FromResult(true);
    }

    /// <inheritdoc />
    public Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
        string email,
        CancellationToken cancellationToken) =>
        Task.FromResult<IReadOnlyList<DirectoryEntry>>(
        [
            .. directory.Where(entry =>
                string.Equals(entry.Email, email, StringComparison.OrdinalIgnoreCase)),
        ]);

    /// <inheritdoc />
    public Task<DirectoryEntry?> LookupByIdAsync(Guid userId, CancellationToken cancellationToken) =>
        Task.FromResult(directory.Find(entry => entry.UserId == userId));

    /// <inheritdoc />
    public Task<KeyLogPage> ReadKeyLogAsync(
        long afterSequence,
        int? limit,
        CancellationToken cancellationToken)
    {
        var page = keyLog.Where(entry => entry.Sequence > afterSequence).ToList();

        if (CorruptKeyLog && page.Count > 0)
        {
            // One byte, in the field the chain is built from. Enough to break the link and nothing else,
            // which is what a tampered log would look like.
            var last = page[^1];
            page[^1] = last with { EncryptionPublicKey = [.. last.EncryptionPublicKey.Reverse()] };
        }

        var head = keyLog.Count == 0
            ? KeyLogChain.CreateGenesisPreviousHash()
            : keyLog[^1].Hash;

        return Task.FromResult(new KeyLogPage(page, keyLog.Count, head, HasMore: false));
    }

    /// <inheritdoc />
    public Task<VaultGrantsResponse> ListVaultGrantsAsync(
        Guid vaultId,
        CancellationToken cancellationToken) =>
        Task.FromResult(new VaultGrantsResponse(
            vaultId,
            KeyGeneration: Generation(vaultId),
            RekeyRequired: false,
            Grants:
            [
                // One row per holder rather than per grant, as the real listing shows a member once
                // and lets the generation say whether their key is current.
                .. grants
                    .Where(entry => entry.Key.VaultId == vaultId)
                    .GroupBy(entry => entry.Key.UserId)
                    .Select(group => new VaultGrantSummary(
                        group.Key,
                        directory.Find(candidate => candidate.UserId == group.Key)?.Email,
                        null,
                        KeyGeneration: group.Max(entry => entry.Key.KeyGeneration),
                        VaultGrantState.Active,
                        UserId,
                        DateTimeOffset.UnixEpoch,
                        null)),
            ]));

    /// <inheritdoc />
    public Task IssueVaultGrantAsync(
        Guid vaultId,
        IssueVaultGrantRequest request,
        CancellationToken cancellationToken)
    {
        grants[(vaultId, request.RecipientUserId, request.KeyGeneration)] = request;

        return Task.CompletedTask;
    }

    /// <inheritdoc />
    /// <remarks>
    /// Models the one part of a rotation that is the server's: the generation advances, the caller's own
    /// grant for it is recorded, and everything older is left standing so the vault's stored items go on
    /// opening. What comes back is what the real endpoint returns — the vault at its new generation,
    /// with the caller's earlier wraps attached.
    /// </remarks>
    public Task<VaultSummary> RekeyVaultAsync(
        Guid vaultId,
        RekeyVaultRequest request,
        CancellationToken cancellationToken)
    {
        if (!teamVaults.TryGetValue(vaultId, out var vault))
        {
            throw new DodoSshApiException(
                System.Net.HttpStatusCode.NotFound, code: null, "No such vault.");
        }

        if (request.KeyGeneration != vault.KeyGeneration + 1)
        {
            throw new DodoSshApiException(
                System.Net.HttpStatusCode.BadRequest,
                ProblemCodes.InvalidVaultGrant,
                $"This vault is at key generation {vault.KeyGeneration}.");
        }

        grants[(vaultId, UserId, request.KeyGeneration)] = new IssueVaultGrantRequest(
            UserId,
            RecipientKeyFingerprint: new byte[32],
            request.KeyGeneration,
            request.WrappedVaultKey,
            KeyLogHead: new byte[32],
            request.GrantSignature,
            request.GrantedAt);

        var prior = grants
            .Where(entry => entry.Key.VaultId == vaultId
                && entry.Key.UserId == UserId
                && entry.Key.KeyGeneration < request.KeyGeneration)
            .OrderBy(entry => entry.Key.KeyGeneration)
            .Select(entry => new VaultKeyWrap(entry.Key.KeyGeneration, entry.Value.WrappedVaultKey))
            .ToList();

        var rotated = vault with
        {
            KeyGeneration = request.KeyGeneration,
            WrappedVaultKey = request.WrappedVaultKey,
            RekeyRequired = false,
            PriorKeyWraps = prior,
        };

        teamVaults[vaultId] = rotated;

        return Task.FromResult(rotated);
    }

    /// <inheritdoc />
    public Task<bool> RevokeVaultGrantAsync(
        Guid vaultId,
        Guid userId,
        CancellationToken cancellationToken)
    {
        var theirs = grants.Keys
            .Where(key => key.VaultId == vaultId && key.UserId == userId)
            .ToList();

        foreach (var key in theirs)
        {
            grants.Remove(key);
        }

        return Task.FromResult(theirs.Count > 0);
    }

    /// <summary>The generation a vault currently stands at.</summary>
    private uint Generation(Guid vaultId) =>
        teamVaults.TryGetValue(vaultId, out var vault) ? vault.KeyGeneration : 1;

    /// <summary>Publishes the enrolling account's own key, in the directory and the key log.</summary>
    private void RegisterSelf(KeyStatement statement, byte[] statementSignature)
    {
        if (directory.Exists(entry => entry.UserId == UserId))
        {
            return;
        }

        var sequence = AppendKeyLog(
            UserId, statement.EncryptionPublicKey, statement.SigningPublicKey, statementSignature);

        directory.Add(new DirectoryEntry(
            UserId,
            "alice@example.com",
            "Alice Example",
            statement.EncryptionPublicKey,
            statement.SigningPublicKey,
            DshCrypto.ComputeFingerprint(statement.EncryptionPublicKey, statement.SigningPublicKey),
            statement.KeyGeneration,
            sequence));
    }

    /// <summary>Appends a key log entry, chained as the real log chains it.</summary>
    private long AppendKeyLog(
        Guid userId,
        byte[] encryptionPublicKey,
        byte[] signingPublicKey,
        byte[] statementSignature)
    {
        var previous = keyLog.Count == 0
            ? KeyLogChain.CreateGenesisPreviousHash()
            : keyLog[^1].Hash;

        var createdAt = KeyLogChain.TruncateTimestamp(DateTimeOffset.UnixEpoch);
        var sequence = keyLog.Count + 1;

        var hash = KeyLogChain.ComputeEntryHash(
            previous, userId, 1, encryptionPublicKey, signingPublicKey, statementSignature, createdAt);

        keyLog.Add(new KeyLogRecord(
            sequence,
            userId,
            Generation: 1,
            encryptionPublicKey,
            signingPublicKey,
            statementSignature,
            previous,
            hash,
            createdAt));

        return sequence;
    }

    private void Recount(Guid teamId)
    {
        var index = teams.FindIndex(team => team.TeamId == teamId);

        if (index < 0)
        {
            return;
        }

        teams[index] = teams[index] with
        {
            MemberCount = members.GetValueOrDefault(teamId, []).Count,
            VaultCount = teamVaults.Values.Count(vault => vault.TeamId == teamId),
        };
    }
}
