using DodoSSH.Contracts;
using DodoSSH.Crypto;
using DodoSSH.Domain;

namespace DodoSSH.Api.Features.Identity;

/// <summary>
/// Structural validation of an enrollment request, before any cryptography or database work.
/// </summary>
/// <remarks>
/// Everything here fails the whole request rather than being recorded and skipped. Enrollment is
/// one indivisible act: a user with a key but no vault, or a vault whose key was never wrapped, is
/// worse than a user who has to try again.
/// </remarks>
internal static class EnrollmentValidation
{
    /// <summary>Validates the request against the caller it claims to describe.</summary>
    /// <exception cref="EnrollmentInvalidException">Any part of the request is unacceptable.</exception>
    internal static void Validate(EnrollmentRequest request, UserAccount caller)
    {
        ValidateStatement(request.Statement, caller);
        ValidateSignature(request.StatementSignature);
        ValidatePassphraseWrap(request);
        ValidateDevice(request);
        ValidateRecovery(request);
        ValidatePersonalVault(request.PersonalVault);
    }

    private static void ValidateStatement(KeyStatement statement, UserAccount caller)
    {
        if (statement is null)
        {
            throw new EnrollmentInvalidException("A key statement is required.");
        }

        ValidateStatementIdentity(statement, caller);
        ValidateStatementKeys(statement);
    }

    private static void ValidateStatementIdentity(KeyStatement statement, UserAccount caller)
    {
        if (statement.Version != KeyStatementCodec.CurrentVersion)
        {
            throw new EnrollmentInvalidException(
                $"Key statement version {statement.Version} is not supported; this server accepts "
                + $"version {KeyStatementCodec.CurrentVersion}.");
        }

        // The first generation only. Rotation is a separate operation with its own predecessor
        // signature, and accepting an arbitrary generation here would let a client skip straight to
        // generation 9 and leave gaps the key log could never explain.
        if (statement.KeyGeneration != 1)
        {
            throw new EnrollmentInvalidException(
                "Enrollment publishes key generation 1. Use key rotation for later generations.");
        }

        // The statement is what the identity provider signs over, so it must describe the caller
        // and not merely be well-formed. Without this a user could enroll keys naming someone else.
        if (!string.Equals(statement.Issuer, caller.Issuer, StringComparison.Ordinal))
        {
            throw new EnrollmentInvalidException("The statement issuer is not the caller's issuer.");
        }

        if (!string.Equals(statement.Subject, caller.Subject, StringComparison.Ordinal))
        {
            throw new EnrollmentInvalidException("The statement subject is not the caller's subject.");
        }

        RequireText(statement.Issuer, EnrollmentLimits.MaximumIssuerLength, "The statement issuer");
        RequireText(statement.Subject, EnrollmentLimits.MaximumSubjectLength, "The statement subject");
        RequireText(statement.DeviceName, EnrollmentLimits.MaximumDeviceNameLength, "The device name");

        if (statement.Email is { Length: > EnrollmentLimits.MaximumEmailLength })
        {
            throw new EnrollmentInvalidException("The statement email is too long.");
        }
    }

    private static void ValidateStatementKeys(KeyStatement statement)
    {
        RequireLength(
            statement.EncryptionPublicKey,
            CryptoSpec.PublicKeySize,
            "The encryption public key");

        RequireLength(
            statement.SigningPublicKey,
            CryptoSpec.PublicKeySize,
            "The signing public key");

        // Distinct key pairs for agreement and signatures. Equal raw bytes here would mean one key
        // used for both roles, which is a standing cryptographic mistake and is also the shape a
        // client bug takes when it exports the wrong key twice.
        if (statement.EncryptionPublicKey.AsSpan().SequenceEqual(statement.SigningPublicKey))
        {
            throw new EnrollmentInvalidException(
                "The encryption and signing public keys must differ.");
        }
    }

    private static void ValidateSignature(byte[] signature) =>
        RequireLength(signature, CryptoSpec.SignatureSize, "The statement signature");

    private static void ValidatePassphraseWrap(EnrollmentRequest request)
    {
        RequireWrap(request.WrappedPrivateKey, "The passphrase wrap");
        ValidateKdf(request.KdfParameters, "passphrase");
    }

    /// <remarks>
    /// Both or neither. A device public key without its wrap registers a device that can never
    /// unlock anything — only the holder of the secret bundle can seal it, so the server could
    /// never fill the gap in.
    /// </remarks>
    private static void ValidateDevice(EnrollmentRequest request)
    {
        if (request.DevicePublicKey is null && request.DeviceWrappedPrivateKey is null)
        {
            return;
        }

        if (request.DevicePublicKey is null || request.DeviceWrappedPrivateKey is null)
        {
            throw new EnrollmentInvalidException(
                "A device key and its wrap must be supplied together.");
        }

        RequireLength(request.DevicePublicKey, CryptoSpec.PublicKeySize, "The device public key");
        RequireWrap(request.DeviceWrappedPrivateKey, "The device wrap");
    }

    /// <remarks>
    /// Recovery is optional at the protocol level and the client should make it very hard to skip.
    /// A user who loses their passphrase and every device has no other path back, and a
    /// zero-knowledge server genuinely cannot help. See ADR 0001.
    /// </remarks>
    private static void ValidateRecovery(EnrollmentRequest request)
    {
        if (request.RecoveryWrappedPrivateKey is null && request.RecoveryKdfParameters is null)
        {
            return;
        }

        if (request.RecoveryWrappedPrivateKey is null || request.RecoveryKdfParameters is null)
        {
            throw new EnrollmentInvalidException(
                "A recovery wrap and its KDF parameters must be supplied together.");
        }

        RequireWrap(request.RecoveryWrappedPrivateKey, "The recovery wrap");
        ValidateKdf(request.RecoveryKdfParameters, "recovery");
    }

    private static void ValidatePersonalVault(PersonalVaultRequest vault)
    {
        if (vault is null)
        {
            throw new EnrollmentInvalidException("A personal vault is required.");
        }

        if (vault.VaultId == Guid.Empty)
        {
            throw new EnrollmentInvalidException("The personal vault id must not be empty.");
        }

        RequireText(vault.Name, EnrollmentLimits.MaximumVaultNameLength, "The vault name");
        RequireWrap(vault.WrappedVaultKey, "The wrapped vault key");
        RequireLength(vault.GrantSignature, CryptoSpec.SignatureSize, "The grant signature");
    }

    private static void ValidateKdf(KdfParameters? parameters, string role)
    {
        // A record's non-nullable parameter is a compile-time promise, not a runtime one: a JSON
        // body that simply omits the property deserialises to null. Without this the next line is a
        // NullReferenceException, so a malformed request would be a 500 instead of a 400.
        if (parameters is null)
        {
            throw new EnrollmentInvalidException($"The {role} KDF parameters are required.");
        }

        if (!string.Equals(parameters.Algorithm, EnrollmentLimits.KdfAlgorithm, StringComparison.Ordinal))
        {
            throw new EnrollmentInvalidException(
                $"The {role} KDF must be {EnrollmentLimits.KdfAlgorithm}.");
        }

        if (parameters.Salt is null
            or { Length: < EnrollmentLimits.MinimumSaltBytes }
            or { Length: > EnrollmentLimits.MaximumSaltBytes })
        {
            throw new EnrollmentInvalidException(
                $"The {role} salt must be between {EnrollmentLimits.MinimumSaltBytes} and "
                + $"{EnrollmentLimits.MaximumSaltBytes} bytes.");
        }

        if (parameters.MemoryKibibytes is < EnrollmentLimits.MinimumKdfMemoryKibibytes
            or > EnrollmentLimits.MaximumKdfMemoryKibibytes)
        {
            // Kibibytes, not bytes. Confusing the two is a factor of 1024 in either direction:
            // unusably slow one way, trivially crackable the other. See docs/crypto.md §2.
            throw new EnrollmentInvalidException(
                $"The {role} KDF memory cost must be between "
                + $"{EnrollmentLimits.MinimumKdfMemoryKibibytes} and "
                + $"{EnrollmentLimits.MaximumKdfMemoryKibibytes} kibibytes.");
        }

        if (parameters.Passes is < EnrollmentLimits.MinimumKdfPasses
            or > EnrollmentLimits.MaximumKdfPasses)
        {
            throw new EnrollmentInvalidException(
                $"The {role} KDF pass count must be between {EnrollmentLimits.MinimumKdfPasses} "
                + $"and {EnrollmentLimits.MaximumKdfPasses}.");
        }

        if (parameters.Parallelism != EnrollmentLimits.RequiredKdfParallelism)
        {
            throw new EnrollmentInvalidException(
                $"The {role} KDF parallelism must be {EnrollmentLimits.RequiredKdfParallelism}; "
                + "libsodium supports no other value.");
        }
    }

    private static void RequireLength(byte[]? value, int expected, string description)
    {
        if (value is null || value.Length != expected)
        {
            throw new EnrollmentInvalidException(
                $"{description} must be exactly {expected} bytes.");
        }
    }

    private static void RequireWrap(byte[]? value, string description)
    {
        if (value is null or { Length: 0 })
        {
            throw new EnrollmentInvalidException($"{description} is required.");
        }

        if (value.Length > EnrollmentLimits.MaximumWrapBytes)
        {
            throw new EnrollmentInvalidException(
                $"{description} exceeds {EnrollmentLimits.MaximumWrapBytes} bytes.");
        }
    }

    private static void RequireText(string? value, int maximumLength, string description)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            throw new EnrollmentInvalidException($"{description} is required.");
        }

        if (value.Length > maximumLength)
        {
            throw new EnrollmentInvalidException(
                $"{description} exceeds {maximumLength} characters.");
        }
    }
}
