using DodoSSH.Contracts;
using DodoSSH.Crypto;
using NSec.Cryptography;

namespace DodoSSH.Api.Tests;

/// <summary>
/// Builds a genuine enrollment: real X25519 and Ed25519 keys, a real Ed25519 statement signature,
/// and an ID token whose nonce is the statement's actual canonical hash.
/// </summary>
/// <remarks>
/// Nothing here is stubbed. The point is that the server's two independent checks — the statement
/// self-signature and the identity-provider binding — both run for real, so a change that breaks
/// either shows up here rather than against a live Keycloak.
/// </remarks>
internal sealed class TestEnrollment : IDisposable
{
    private static readonly DateTimeOffset CreatedAt =
        DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123);

    private readonly StubIdentityProvider identityProvider;
    private readonly Key encryptionKey;
    private readonly Key signingKey;

    internal TestEnrollment(StubIdentityProvider identityProvider, string subject, string? email = null)
    {
        this.identityProvider = identityProvider;
        Subject = subject;

        encryptionKey = Key.Create(KeyAgreementAlgorithm.X25519);
        signingKey = Key.Create(SignatureAlgorithm.Ed25519);

        Statement = new KeyStatement(
            Version: 1,
            Issuer: identityProvider.Authority,
            Subject: subject,
            Email: email,
            EncryptionPublicKey: encryptionKey.PublicKey.Export(KeyBlobFormat.RawPublicKey),
            SigningPublicKey: signingKey.PublicKey.Export(KeyBlobFormat.RawPublicKey),
            KeyGeneration: 1,
            CreatedAt: CreatedAt,
            DeviceName: "test-device");
    }

    /// <summary>The OIDC subject this enrollment is for.</summary>
    internal string Subject { get; }

    /// <summary>The client-chosen personal vault id.</summary>
    internal Guid VaultId { get; } = Guid.CreateVersion7();

    /// <summary>The default, well-formed statement.</summary>
    internal KeyStatement Statement { get; }

    /// <summary>The identity fingerprint the server should compute.</summary>
    internal byte[] Fingerprint => DshCrypto.ComputeFingerprint(
        Statement.EncryptionPublicKey,
        Statement.SigningPublicKey);

    /// <summary>Signs a statement with this enrollment's Ed25519 key.</summary>
    internal byte[] Sign(KeyStatement statement) =>
        DshSignatures.SignKeyStatement(signingKey, KeyStatementCodec.Encode(ToFields(statement)));

    /// <summary>Mints an ID token whose nonce is the given statement's binding.</summary>
    internal string MintIdToken(
        KeyStatement statement,
        string? subject = null,
        string? audience = null,
        string? issuer = null,
        DateTime? expires = null,
        bool omitNonce = false) =>
        identityProvider.MintIdToken(
            subject ?? Subject,
            KeyStatementCodec.ComputeNonce(ToFields(statement)),
            audience: audience,
            issuer: issuer,
            expires: expires,
            omitNonce: omitNonce);

    /// <summary>Builds a complete, valid request, with every part overridable for negative tests.</summary>
    internal EnrollmentRequest Build(
        KeyStatement? statement = null,
        byte[]? statementSignature = null,
        string? idToken = null,
        KdfParameters? kdfParameters = null,
        PersonalVaultRequest? personalVault = null,
        bool includeDevice = true,
        bool includeRecovery = true)
    {
        var effective = statement ?? Statement;

        return new EnrollmentRequest(
            Statement: effective,
            StatementSignature: statementSignature ?? Sign(effective),
            IdentityProviderToken: idToken ?? MintIdToken(effective),
            WrappedPrivateKey: Bytes(220, 0x11),
            KdfParameters: kdfParameters ?? DefaultKdf(),
            DevicePublicKey: includeDevice ? Bytes(32, 0x22) : null,
            DeviceWrappedPrivateKey: includeDevice ? Bytes(240, 0x33) : null,
            RecoveryWrappedPrivateKey: includeRecovery ? Bytes(220, 0x44) : null,
            RecoveryKdfParameters: includeRecovery ? RecoveryKdf() : null,
            PersonalVault: personalVault ?? DefaultVault());
    }

    /// <summary>The passphrase KDF profile, matching <c>Argon2Profile.PassphraseDefault</c>.</summary>
    internal static KdfParameters DefaultKdf() =>
        new("argon2id", Bytes(16, 0x55), MemoryKibibytes: 256 * 1024, Passes: 4, Parallelism: 1);

    /// <summary>The recovery KDF profile. Cheaper, because a recovery code carries real entropy.</summary>
    internal static KdfParameters RecoveryKdf() =>
        new("argon2id", Bytes(16, 0x66), MemoryKibibytes: 64 * 1024, Passes: 3, Parallelism: 1);

    /// <remarks>
    /// The grant signature is a real Ed25519 signature of the right length, but not over the §7
    /// grant tuple: that canonical encoding lands with team sharing in M3, and the server stores
    /// grant signatures opaquely rather than verifying them. Shape is what is under test here.
    /// </remarks>
    internal PersonalVaultRequest DefaultVault(Guid? vaultId = null, string name = "Personal") =>
        new(
            VaultId: vaultId ?? VaultId,
            Name: name,
            WrappedVaultKey: Bytes(80, 0x77),
            GrantSignature: SignatureAlgorithm.Ed25519.Sign(signingKey, Bytes(32, 0x88)),
            GrantedAt: CreatedAt);

    /// <summary>Bearer client for this enrollment's subject.</summary>
    internal HttpClient CreateClient(ApiFixture fixture) => fixture.CreateClientFor(Subject);

    /// <inheritdoc />
    public void Dispose()
    {
        encryptionKey.Dispose();
        signingKey.Dispose();
    }

    private static byte[] Bytes(int length, byte seed) =>
        [.. Enumerable.Range(0, length).Select(i => (byte)(seed + i))];

    /// <remarks>
    /// Mapped here rather than reusing the server's mapper, so a change to either side's field list
    /// shows up as a failing enrollment instead of two copies of the same mistake agreeing.
    /// </remarks>
    private static KeyStatementFields ToFields(KeyStatement statement) =>
        new(
            statement.Version,
            statement.Issuer,
            statement.Subject,
            statement.Email,
            statement.EncryptionPublicKey,
            statement.SigningPublicKey,
            statement.KeyGeneration,
            statement.CreatedAt,
            statement.DeviceName);
}
