using System.Net;
using System.Text.Json;
using DodoSSH.Client.Auth;
using DodoSSH.Contracts;
using DodoSSH.Crypto;

namespace DodoSSH.Client.Api.Tests;

/// <summary>
/// The client half of enrollment: what it sends, and what it keeps to itself.
/// </summary>
/// <remarks>
/// The most valuable assertions here are the negative ones about the request body. The server is
/// supposed to be unable to read anything it stores, and this is where that either holds or quietly
/// stops holding — a refactor that put a passphrase or a private key into the request would be
/// invisible to every other test in the repository.
/// </remarks>
public sealed class ClientEnrollmentTests : IDisposable
{
    private const string Passphrase = "correct horse battery staple";
    private const string EnrollmentPath = "/api/v1/me/enrollment";

    private static readonly Guid UserId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");

    private readonly StubServer server = new();
    private readonly HttpClient http = new();

    public ClientEnrollmentTests() => http.BaseAddress = server.BaseUrl;

    /// <inheritdoc />
    public void Dispose()
    {
        http.Dispose();
        server.Dispose();
    }

    [Fact]
    public async Task Enroll_SendsAStatementTheServerCanVerify()
    {
        var binding = new CapturingKeyBinding();
        var outcome = await EnrollAsync(binding);

        using var bundle = outcome.Bundle;

        var request = ReadRequest();

        // The statement must describe the caller, or the server rejects it — and the signature must
        // verify against the statement's own signing key, which is what proves possession.
        request.Statement.Issuer.ShouldBe("https://idp.example/realms/dodossh");
        request.Statement.Subject.ShouldBe("alice");
        request.Statement.KeyGeneration.ShouldBe(1);
        request.Statement.EncryptionPublicKey.ShouldBe(bundle.EncryptionPublicKey);
        request.Statement.SigningPublicKey.ShouldBe(bundle.SigningPublicKey);

        DshSignatures.VerifyKeyStatement(
            request.Statement.SigningPublicKey,
            KeyStatementCodec.Encode(ToFields(request.Statement)),
            request.StatementSignature)
            .ShouldBeTrue();
    }

    [Fact]
    public async Task Enroll_BindsTheKeysWithTheStatementsOwnHash()
    {
        // The nonce handed to the identity provider must be this statement's hash and no other,
        // otherwise the token binds keys nobody is publishing.
        var binding = new CapturingKeyBinding();
        var outcome = await EnrollAsync(binding);
        outcome.Bundle.Dispose();

        var request = ReadRequest();

        binding.RequestedNonce.ShouldBe(
            KeyStatementCodec.ComputeNonce(ToFields(request.Statement)));
    }

    [Fact]
    public async Task Enroll_SendsNothingTheServerCouldUseToOpenTheVault()
    {
        var outcome = await EnrollAsync(new CapturingKeyBinding());

        using var bundle = outcome.Bundle;
        var body = server.LastBody(EnrollmentPath);

        // The passphrase and the recovery code exist only on this machine.
        body.ShouldNotContain(Passphrase);
        body.ShouldNotContain(outcome.RecoveryCode);

        // Nor may any private key appear, in any encoding the serialiser might have chosen.
        body.ShouldNotContain(Convert.ToBase64String(outcome.PersonalVaultKey));
        outcome.DevicePrivateKey.ShouldNotBeNull("this enrollment did bind a device");
        body.ShouldNotContain(Convert.ToBase64String(outcome.DevicePrivateKey));
        body.ShouldNotContain(Convert.ToHexString(outcome.PersonalVaultKey));
    }

    [Fact]
    public async Task Enroll_WrapsTheSameBundleThreeWays()
    {
        // One bundle, several wraps, which is what makes a passphrase change a single-row update
        // rather than a re-encryption of the vault.
        var outcome = await EnrollAsync(new CapturingKeyBinding());

        using var bundle = outcome.Bundle;
        var request = ReadRequest();
        var descriptor = DshAad.UserSecretBundle(UserId);

        using var viaPassphrase = OpenWithPassphrase(request, Passphrase, descriptor);
        viaPassphrase.ShouldNotBeNull();
        viaPassphrase.SigningPublicKey.ShouldBe(bundle.SigningPublicKey);

        request.RecoveryWrappedPrivateKey.ShouldNotBeNull();
        request.RecoveryKdfParameters.ShouldNotBeNull();

        using var viaRecovery = OpenWithRecovery(request, outcome.RecoveryCode, descriptor);
        viaRecovery.ShouldNotBeNull();
        viaRecovery.SigningPublicKey.ShouldBe(bundle.SigningPublicKey);

        // The device wrap is sealed to the device key rather than derived, so it opens with the
        // private half the caller was handed to put in the OS keystore.
        request.DevicePublicKey.ShouldNotBeNull();
        request.DeviceWrappedPrivateKey.ShouldNotBeNull();

        using var deviceKey = NSec.Cryptography.Key.Import(
            NSec.Cryptography.KeyAgreementAlgorithm.X25519,
            outcome.DevicePrivateKey,
            NSec.Cryptography.KeyBlobFormat.RawPrivateKey);

        using var viaDevice = UserSecretBundle.TryOpenSealed(
            deviceKey, request.DeviceWrappedPrivateKey, descriptor);

        viaDevice.ShouldNotBeNull();
        viaDevice.SigningPublicKey.ShouldBe(bundle.SigningPublicKey);
    }

    [Fact]
    public async Task Enroll_SendsKdfParametersStrongEnoughForTheServerToAccept()
    {
        // The server enforces a floor. Sending anything below it fails enrollment against a real
        // server while passing every stub, so the values are asserted here rather than discovered
        // later.
        var outcome = await EnrollAsync(new CapturingKeyBinding());
        outcome.Bundle.Dispose();

        var request = ReadRequest();

        request.KdfParameters.Algorithm.ShouldBe("argon2id");
        request.KdfParameters.MemoryKibibytes.ShouldBe(Argon2Profile.PassphraseDefault.MemoryKibibytes);
        request.KdfParameters.Passes.ShouldBe(Argon2Profile.PassphraseDefault.Passes);
        request.KdfParameters.Parallelism.ShouldBe(1);
        request.KdfParameters.Salt.Length.ShouldBe(CryptoSpec.SaltSize);

        // A distinct salt per wrap. Reusing one would let a single cracking effort cover both.
        request.RecoveryKdfParameters!.Salt.ShouldNotBe(request.KdfParameters.Salt);
    }

    [Fact]
    public async Task Enroll_SignsThePersonalVaultGrantOverTheCanonicalTuple()
    {
        var outcome = await EnrollAsync(new CapturingKeyBinding());

        using var bundle = outcome.Bundle;
        var request = ReadRequest();
        var vault = request.PersonalVault;

        var fingerprint = DshCrypto.ComputeFingerprint(
            bundle.EncryptionPublicKey, bundle.SigningPublicKey);

        var grant = GrantStatementCodec.Encode(
            vault.VaultId,
            keyGeneration: 1,
            GrantPurpose.Member,
            granteeUserId: UserId,
            granteeKeyFingerprint: fingerprint,
            wrappedKey: vault.WrappedVaultKey,
            granterUserId: UserId,
            granterKeyFingerprint: fingerprint,
            keyLogHead: default,
            grantedAt: vault.GrantedAt);

        GrantStatementCodec.Verify(bundle.SigningPublicKey, grant, vault.GrantSignature)
            .ShouldBeTrue("A grant the granter's own key cannot verify is one no client will accept.");
    }

    [Fact]
    public async Task Enroll_SealsThePersonalVaultKeyToTheEnrollingIdentity()
    {
        var outcome = await EnrollAsync(new CapturingKeyBinding());

        using var bundle = outcome.Bundle;
        var vault = ReadRequest().PersonalVault;

        VaultKeys.TryUnwrap(bundle.EncryptionKey, vault.WrappedVaultKey, vault.VaultId, 1)
            .ShouldBe(outcome.PersonalVaultKey);
    }

    [Fact]
    public async Task Enroll_ProducesAReadableRecoveryCode()
    {
        // Read aloud or copied off a screen, so the alphabet omits the characters that get
        // mistranscribed.
        var outcome = await EnrollAsync(new CapturingKeyBinding());
        outcome.Bundle.Dispose();

        outcome.RecoveryCode.ShouldNotBeNullOrWhiteSpace();
        outcome.RecoveryCode.ShouldContain("-");

        foreach (var character in outcome.RecoveryCode.Replace("-", string.Empty, StringComparison.Ordinal))
        {
            "0123456789ABCDEFGHJKMNPQRSTVWXYZ".ShouldContain(character);
        }
    }

    [Fact]
    public async Task Enroll_DisposesTheBundleWhenTheServerRefuses()
    {
        // The caller never receives the bundle on failure, so nothing else could release its guarded
        // memory.
        server.StubProblem(
            EnrollmentPath, "POST", 409, ProblemCodes.AlreadyEnrolled, "Already enrolled.");

        var enrollment = new ClientEnrollment(
            new DodoSshApiClient(http, new StubTokenProvider()),
            new CapturingKeyBinding(),
            TimeProvider.System);

        var exception = await Should.ThrowAsync<DodoSshApiException>(async () =>
            await enrollment.EnrollAsync(
                Me(), Passphrase, "laptop", "Personal", true, TestContext.Current.CancellationToken));

        exception.Code.ShouldBe(ProblemCodes.AlreadyEnrolled);
        exception.StatusCode.ShouldBe(HttpStatusCode.Conflict);
    }

    [Fact]
    public async Task Enroll_RejectsAnEmptyPassphraseBeforeTouchingTheNetwork()
    {
        var binding = new CapturingKeyBinding();

        var enrollment = new ClientEnrollment(
            new DodoSshApiClient(http, new StubTokenProvider()),
            binding,
            TimeProvider.System);

        await Should.ThrowAsync<ArgumentException>(async () =>
            await enrollment.EnrollAsync(
                Me(), string.Empty, "laptop", "Personal", true, TestContext.Current.CancellationToken));

        binding.RequestedNonce.ShouldBeNull("Nothing should reach the identity provider.");
    }

    // ---- Helpers ----

    private async Task<EnrollmentOutcome> EnrollAsync(CapturingKeyBinding binding)
    {
        server.StubEnrollment(new EnrollmentResponse(
            UserId: UserId,
            KeyGeneration: 1,
            Fingerprint: new byte[32],
            PersonalVaultId: Guid.CreateVersion7(),
            DeviceId: Guid.CreateVersion7(),
            KeyLogSequence: 1));

        var enrollment = new ClientEnrollment(
            new DodoSshApiClient(http, new StubTokenProvider()),
            binding,
            TimeProvider.System);

        return await enrollment.EnrollAsync(
            Me(),
            Passphrase,
            "laptop",
            "Personal",
            bindThisDevice: true,
            TestContext.Current.CancellationToken);
    }

    private EnrollmentRequest ReadRequest()
    {
        var request = JsonSerializer.Deserialize(
            server.LastBody(EnrollmentPath),
            DodoSshJsonContext.Default.EnrollmentRequest);

        request.ShouldNotBeNull();
        return request;
    }

    private static MeResponse Me() =>
        new(
            UserId: UserId,
            Issuer: "https://idp.example/realms/dodossh",
            Subject: "alice",
            Email: "alice@example.com",
            DisplayName: "Alice",
            EnrollmentRequired: true,
            KeyGeneration: null,
            WrappedPrivateKey: null,
            KdfParameters: null,
            Vaults: []);

    private static UserSecretBundle? OpenWithPassphrase(
        EnrollmentRequest request,
        string passphrase,
        AadDescriptor descriptor)
    {
        // Reconstructed from the stored parameters, exactly as a client unlocking on another device
        // would do after reading them from /me.
        using var master = MasterKey.Derive(
            passphrase,
            request.KdfParameters.Salt,
            Argon2Profile.FromStoredParameters(
                request.KdfParameters.MemoryKibibytes,
                request.KdfParameters.Passes,
                request.KdfParameters.Parallelism));

        return master.TryOpenBundle(request.WrappedPrivateKey, descriptor);
    }

    private static UserSecretBundle? OpenWithRecovery(
        EnrollmentRequest request,
        string recoveryCode,
        AadDescriptor descriptor)
    {
        var parameters = request.RecoveryKdfParameters!;

        using var master = MasterKey.Derive(
            recoveryCode,
            parameters.Salt,
            Argon2Profile.FromStoredParameters(
                parameters.MemoryKibibytes, parameters.Passes, parameters.Parallelism));

        return master.TryOpenBundle(request.RecoveryWrappedPrivateKey!, descriptor);
    }

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

    /// <summary>Records the nonce it was asked to bind, and returns a token carrying it.</summary>
    private sealed class CapturingKeyBinding : IKeyBindingAuthorizer
    {
        internal string? RequestedNonce { get; private set; }

        public Task<string> AuthorizeKeyBindingAsync(
            string bindingNonce,
            CancellationToken cancellationToken)
        {
            RequestedNonce = bindingNonce;

            // Shape only. The real token's signature and nonce are the server's to verify, and doing
            // it here would just be testing the stub.
            return Task.FromResult($"header.{bindingNonce}.signature");
        }
    }
}
