using System.Security.Cryptography;
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,
    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>Lists who can open a vault.</summary>
    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);

        return new VaultGrantsResponse(
            VaultId: vault.Id,
            KeyGeneration: (uint)vault.KeyGeneration,
            RekeyRequired: vault.RekeyRequired,
            Grants:
            [
                .. grants.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>
    /// Re-issuing to a recipient who already holds a live grant 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 rotation or a botched first attempt.
    /// </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 existing = await database.VaultKeyGrants
            .SingleOrDefaultAsync(
                g => g.VaultId == vault.Id
                    && g.KeyGeneration == vault.KeyGeneration
                    && g.RecipientUserId == request.RecipientUserId
                    && g.RevokedAtUtc == null,
                cancellationToken)
            .ConfigureAwait(false);

        var grant = existing ?? new VaultKeyGrant
        {
            Id = Guid.CreateVersion7(),
            VaultId = vault.Id,
            KeyGeneration = vault.KeyGeneration,
            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, vault.KeyGeneration, request.RecipientUserId, actor.Id);
    }

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

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

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