using System.Net;
using System.Net.Http.Json;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace DodoSSH.Api.Tests;

/// <summary>
/// Just-in-time provisioning, <c>/me</c>, and enrollment end to end over HTTP.
/// </summary>
/// <remarks>
/// The rejection tests are the important ones. Enrollment is where a user's public keys enter the
/// system, and every confidentiality guarantee in the product is downstream of those keys being
/// genuinely theirs. A hole here is not a bug in one endpoint; it is the whole vault.
/// </remarks>
[Collection(ApiCollection.Name)]
public sealed class IdentityEndpointTests(ApiFixture fixture)
{
    private const string MeUrl = "/api/v1/me";
    private const string EnrollUrl = "/api/v1/me/enrollment";
    private const string DevicesUrl = "/api/v1/me/devices";

    // ---- Provisioning and /me ----

    [Fact]
    public async Task GetMe_WithoutAToken_Is401()
    {
        var response = await fixture.CreateClient().GetAsync(new Uri(MeUrl, UriKind.Relative));

        response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
    }

    [Fact]
    public async Task GetMe_OnAFirstRequest_ProvisionsTheUserAndAsksForEnrollment()
    {
        var subject = NewSubject();
        var email = $"{subject}@example.com";
        var client = fixture.CreateClientFor(subject, email);

        var me = await ReadMeAsync(client);

        me.Issuer.ShouldBe(fixture.IdentityProvider.Authority);
        me.Subject.ShouldBe(subject);
        me.Email.ShouldBe(email);

        // The single flag a client branches on at startup.
        me.EnrollmentRequired.ShouldBeTrue();
        me.KeyGeneration.ShouldBeNull();
        me.WrappedPrivateKey.ShouldBeNull();
        me.KdfParameters.ShouldBeNull();
        me.Vaults.ShouldBeEmpty();

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        var user = await database.Users.SingleOrDefaultAsync(u => u.Subject == subject);
        user.ShouldNotBeNull();
        user.Status.ShouldBe(UserStatus.Active);
        user.EnrolledAtUtc.ShouldBeNull();
    }

    [Fact]
    public async Task GetMe_RepeatedRequests_ProvisionOnlyOnce()
    {
        var subject = NewSubject();
        var client = fixture.CreateClientFor(subject);

        for (var i = 0; i < 3; i++)
        {
            (await client.GetAsync(new Uri(MeUrl, UriKind.Relative))).EnsureSuccessStatusCode();
        }

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        (await database.Users.CountAsync(u => u.Subject == subject)).ShouldBe(1);
    }

    [Fact]
    public async Task GetMe_AfterEnrolling_ReturnsEverythingNeededToUnlockOffline()
    {
        using var enrollment = NewEnrollment();
        var client = enrollment.CreateClient(fixture);

        var enrolled = await EnrollAsync(client, enrollment.Build());

        var me = await ReadMeAsync(client);

        me.EnrollmentRequired.ShouldBeFalse();
        me.KeyGeneration.ShouldBe(1);

        // Wrap and KDF parameters together, because unlock has to work with no network at all. If
        // the salt were fetched at unlock time, an offline launch could not open the vault.
        me.WrappedPrivateKey.ShouldNotBeNull();
        me.KdfParameters.ShouldNotBeNull();
        me.KdfParameters.Algorithm.ShouldBe("argon2id");
        me.KdfParameters.MemoryKibibytes.ShouldBe(256 * 1024);
        me.KdfParameters.Passes.ShouldBe(4);
        me.KdfParameters.Parallelism.ShouldBe(1);

        var vault = me.Vaults.ShouldHaveSingleItem();
        vault.VaultId.ShouldBe(enrolled.PersonalVaultId);
        vault.IsPersonal.ShouldBeTrue();
        vault.TeamId.ShouldBeNull();
        vault.KeyGeneration.ShouldBe(1u);
        vault.RekeyRequired.ShouldBeFalse();

        // Without the wrapped vault key the vault is unopenable, so this being present is the
        // difference between enrollment having worked and having half worked.
        vault.WrappedVaultKey.ShouldNotBeNull();
    }

    [Fact]
    public async Task GetMe_DoesNotListAnotherUsersVault()
    {
        using var owner = NewEnrollment();
        await EnrollAsync(owner.CreateClient(fixture), owner.Build());

        using var other = NewEnrollment();
        var otherClient = other.CreateClient(fixture);
        await EnrollAsync(otherClient, other.Build());

        var me = await ReadMeAsync(otherClient);

        me.Vaults.ShouldHaveSingleItem().VaultId.ShouldBe(other.VaultId);
    }

    // ---- Enrollment: the happy path ----

    [Fact]
    public async Task Enroll_WritesTheKeyItsWrapsTheDeviceTheLogEntryAndTheVault()
    {
        using var enrollment = NewEnrollment();
        var client = enrollment.CreateClient(fixture);

        var response = await EnrollAsync(client, enrollment.Build());

        response.KeyGeneration.ShouldBe(1);
        response.PersonalVaultId.ShouldBe(enrollment.VaultId);
        response.DeviceId.ShouldNotBeNull();
        response.KeyLogSequence.ShouldBeGreaterThan(0);

        // The fingerprint is computed by the server from the statement, never taken from the client.
        response.Fingerprint.ShouldBe(enrollment.Fingerprint);

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        var key = await database.UserKeys.SingleAsync(k => k.UserId == response.UserId);
        key.IsCurrent.ShouldBeTrue();
        key.Generation.ShouldBe(1);
        key.FingerprintSha256.ShouldBe(enrollment.Fingerprint);
        key.EncryptionPublicKey.ShouldBe(enrollment.Statement.EncryptionPublicKey);
        key.SigningPublicKey.ShouldBe(enrollment.Statement.SigningPublicKey);

        var wraps = await database.UserKeyWraps
            .Where(w => w.UserId == response.UserId)
            .ToListAsync();

        // One bundle, three wraps of it. That shape is what makes a passphrase change a single-row
        // update rather than a re-encryption of the vault.
        wraps.Count.ShouldBe(3);
        wraps.Select(w => w.Kind).Order().ShouldBe(
            [UserKeyWrapKind.Passphrase, UserKeyWrapKind.Device, UserKeyWrapKind.Recovery]);

        var device = wraps.Single(w => w.Kind == UserKeyWrapKind.Device);
        device.DeviceId.ShouldBe(response.DeviceId);
        device.KdfAlgorithm.ShouldBeNull();
        device.KdfSalt.ShouldBeNull();

        var vault = await database.Vaults.SingleAsync(v => v.Id == enrollment.VaultId);
        vault.OwnerKind.ShouldBe(VaultOwnerKind.Personal);
        vault.OwnerUserId.ShouldBe(response.UserId);
        vault.KeyGeneration.ShouldBe(1);

        var grant = await database.VaultKeyGrants.SingleAsync(g => g.VaultId == enrollment.VaultId);
        grant.Kind.ShouldBe(GrantKind.Member);
        grant.State.ShouldBe(GrantState.Active);
        grant.RecipientUserId.ShouldBe(response.UserId);
        grant.RecipientKeyFingerprint.ShouldBe(enrollment.Fingerprint);
        grant.GranterKeyFingerprint.ShouldBe(enrollment.Fingerprint);

        // A self-grant records no key log head: there is no third party whose key could have been
        // substituted, and the client could not have signed over an entry written alongside it.
        grant.KeyLogHead.ShouldBeNull();

        var user = await database.Users.SingleAsync(u => u.Id == response.UserId);
        user.EnrolledAtUtc.ShouldNotBeNull();
    }

    [Fact]
    public async Task Enroll_AppendsToTheKeyLogAndTheEntryReproducesItsOwnHash()
    {
        using var enrollment = NewEnrollment();
        var response = await EnrollAsync(enrollment.CreateClient(fixture), enrollment.Build());

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        var entry = await database.KeyLog.SingleAsync(e => e.UserId == response.UserId);

        // The stored row must reproduce its own hash after a round trip. It only does so if the
        // timestamp was truncated to the precision the chain hashes before being written — the
        // column holds microseconds and the hash covers milliseconds.
        var recomputed = KeyLogChain.ComputeEntryHash(
            entry.PreviousHash,
            entry.UserId,
            entry.Generation,
            entry.EncryptionPublicKey,
            entry.SigningPublicKey,
            entry.StatementSignature,
            entry.CreatedAtUtc);

        entry.Hash.ShouldBe(recomputed);
        entry.Sequence.ShouldBe(response.KeyLogSequence);
    }

    [Fact]
    public async Task Enroll_LinksEachLogEntryToItsPredecessor()
    {
        // The chain is what turns key substitution from undetectable into detectable: a server
        // serving divergent views has to keep both forks consistent forever.
        using var first = NewEnrollment();
        var firstResponse = await EnrollAsync(first.CreateClient(fixture), first.Build());

        using var second = NewEnrollment();
        var secondResponse = await EnrollAsync(second.CreateClient(fixture), second.Build());

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        var firstEntry = await database.KeyLog.SingleAsync(e => e.UserId == firstResponse.UserId);
        var secondEntry = await database.KeyLog.SingleAsync(e => e.UserId == secondResponse.UserId);

        secondEntry.Sequence.ShouldBeGreaterThan(firstEntry.Sequence);

        // Not necessarily the immediate successor — the shared database has other tests' entries —
        // so walk back rather than assuming adjacency.
        var predecessor = await database.KeyLog
            .Where(e => e.Sequence < secondEntry.Sequence)
            .OrderByDescending(e => e.Sequence)
            .FirstAsync();

        secondEntry.PreviousHash.ShouldBe(predecessor.Hash);
    }

    [Fact]
    public async Task Enroll_RetainsTheProviderTokenSoClientsNeedNotTrustTheServer()
    {
        using var enrollment = NewEnrollment();
        var request = enrollment.Build();

        var response = await EnrollAsync(enrollment.CreateClient(fixture), request);

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        var key = await database.UserKeys.SingleAsync(k => k.UserId == response.UserId);

        // Storing only our own summary would ask clients to trust the server about the single
        // question the design exists to avoid trusting it about.
        key.IdentityProviderBinding.ShouldNotBeNull();
        key.IdentityProviderBinding.ShouldContain(request.IdentityProviderToken);
        key.IdentityProviderBinding.ShouldContain(
            KeyStatementCodec.ComputeNonce(Fields(enrollment.Statement)));
    }

    [Fact]
    public async Task Enroll_ThenPush_CompletesTheVerticalSlice()
    {
        // Login, enroll, store an item. The whole point of M1's backend.
        using var enrollment = NewEnrollment();
        var client = enrollment.CreateClient(fixture);

        await EnrollAsync(client, enrollment.Build());

        var push = await client.PostContractAsync(
            $"/api/v1/vaults/{enrollment.VaultId}/sync/push",
            new SyncPushRequest(
            [
                new SyncPushOperation(
                    Guid.CreateVersion7(),
                    SyncEntityType.Host,
                    Guid.CreateVersion7(),
                    SyncOperation.Upsert,
                    null,
                    new EncryptedPayload([1, 2, 3, 4], [5, 6], Guid.CreateVersion7(), 1, 1),
                    new SyncPlaintextFields()),
            ]));

        push.EnsureSuccessStatusCode();

        var body = await push.Content.ReadContractAsync<SyncPushResponse>();
        body.ShouldNotBeNull();
        body.Results[0].Status.ShouldBe(SyncOperationStatus.Applied);
    }

    [Fact]
    public async Task Enroll_WithoutADeviceOrRecoveryWrap_StillSucceeds()
    {
        using var enrollment = NewEnrollment();

        var response = await EnrollAsync(
            enrollment.CreateClient(fixture),
            enrollment.Build(includeDevice: false, includeRecovery: false));

        response.DeviceId.ShouldBeNull();

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        var wraps = await database.UserKeyWraps.Where(w => w.UserId == response.UserId).ToListAsync();
        wraps.ShouldHaveSingleItem().Kind.ShouldBe(UserKeyWrapKind.Passphrase);
    }

    // ---- Enrollment: retries and conflicts ----

    [Fact]
    public async Task Enroll_ReplayingTheIdenticalRequest_IsIdempotent()
    {
        // A client whose response was lost re-sends the same body. Creating a second vault instead
        // would strand the first one with no way for the client to discover it.
        using var enrollment = NewEnrollment();
        var client = enrollment.CreateClient(fixture);
        var request = enrollment.Build();

        var first = await EnrollAsync(client, request);
        var replay = await EnrollAsync(client, request);

        replay.UserId.ShouldBe(first.UserId);
        replay.KeyGeneration.ShouldBe(first.KeyGeneration);
        replay.PersonalVaultId.ShouldBe(first.PersonalVaultId);
        replay.KeyLogSequence.ShouldBe(first.KeyLogSequence);
        replay.Fingerprint.ShouldBe(first.Fingerprint);
        replay.DeviceId.ShouldBe(first.DeviceId);

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        (await database.UserKeys.CountAsync(k => k.UserId == first.UserId)).ShouldBe(1);
        (await database.KeyLog.CountAsync(e => e.UserId == first.UserId)).ShouldBe(1);
        (await database.Vaults.CountAsync(v => v.OwnerUserId == first.UserId)).ShouldBe(1);
    }

    [Fact]
    public async Task Enroll_ConcurrentEnrollmentsByDifferentUsers_LeaveAnUnbrokenChain()
    {
        // The real hazard the deployment-wide advisory lock exists for. Different users trip no
        // unique index, so without serialised appends they all read the same head and write entries
        // that each claim the same predecessor. That is a forked chain — indistinguishable from the
        // key-substitution attack the log exists to make detectable — and it is permanent, because
        // the log is append-only and no server-side repair is possible.
        const int Concurrency = 6;

        var enrollments = Enumerable.Range(0, Concurrency).Select(_ => NewEnrollment()).ToArray();

        try
        {
            var responses = await Task.WhenAll(enrollments.Select(e =>
                e.CreateClient(fixture).PostContractAsync(EnrollUrl, e.Build())));

            foreach (var response in responses)
            {
                response.EnsureSuccessStatusCode();
            }

            await using var scope = fixture.CreateScope();
            var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

            // The whole log, including entries earlier tests appended. Every link and every hash.
            var entries = await database.KeyLog.OrderBy(e => e.Sequence).ToListAsync();

            entries.Count.ShouldBeGreaterThanOrEqualTo(Concurrency);

            var expectedPrevious = KeyLogChain.CreateGenesisPreviousHash();

            foreach (var entry in entries)
            {
                entry.PreviousHash.ShouldBe(
                    expectedPrevious,
                    $"Key log entry {entry.Sequence} does not link to its predecessor. The chain "
                    + "has forked, which is exactly what an undetectable key substitution looks like.");

                entry.Hash.ShouldBe(KeyLogChain.ComputeEntryHash(
                    entry.PreviousHash,
                    entry.UserId,
                    entry.Generation,
                    entry.EncryptionPublicKey,
                    entry.SigningPublicKey,
                    entry.StatementSignature,
                    entry.CreatedAtUtc));

                expectedPrevious = entry.Hash;
            }
        }
        finally
        {
            foreach (var enrollment in enrollments)
            {
                enrollment.Dispose();
            }
        }
    }

    [Fact]
    public async Task Enroll_ConcurrentIdenticalRequests_ProduceExactlyOneEnrollment()
    {
        // A client on a flaky connection genuinely does this. Four requests race just-in-time
        // provisioning, the unique index on the current key, and the deployment-wide key log lock at
        // once. Every one must end in the same answer, and the append-only log must gain one entry —
        // two entries claiming the same predecessor would look exactly like the fork the chain
        // exists to detect.
        using var enrollment = NewEnrollment();
        var request = enrollment.Build();

        var responses = await Task.WhenAll(
            Enumerable.Range(0, 4).Select(_ =>
                enrollment.CreateClient(fixture).PostContractAsync(EnrollUrl, request)));

        var bodies = new List<EnrollmentResponse>(responses.Length);
        foreach (var response in responses)
        {
            response.EnsureSuccessStatusCode();

            var body = await response.Content.ReadContractAsync<EnrollmentResponse>();
            body.ShouldNotBeNull();
            bodies.Add(body);
        }

        bodies.Select(b => b.UserId).Distinct().ShouldHaveSingleItem();
        bodies.Select(b => b.KeyLogSequence).Distinct().ShouldHaveSingleItem();
        bodies.Select(b => b.DeviceId).Distinct().ShouldHaveSingleItem();

        var userId = bodies[0].UserId;

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        (await database.Users.CountAsync(u => u.Subject == enrollment.Subject)).ShouldBe(1);
        (await database.UserKeys.CountAsync(k => k.UserId == userId)).ShouldBe(1);
        (await database.KeyLog.CountAsync(e => e.UserId == userId)).ShouldBe(1);
        (await database.Vaults.CountAsync(v => v.OwnerUserId == userId)).ShouldBe(1);
        (await database.Devices.CountAsync(d => d.UserId == userId)).ShouldBe(1);
        (await database.UserKeyWraps.CountAsync(w => w.UserId == userId)).ShouldBe(3);
    }

    [Fact]
    public async Task Enroll_ASecondDifferentKey_Is409()
    {
        // Accepting it would orphan every vault key already wrapped to the first one.
        using var first = NewEnrollment();
        var client = first.CreateClient(fixture);
        await EnrollAsync(client, first.Build());

        using var second = new TestEnrollment(fixture.IdentityProvider, first.Subject);

        var response = await client.PostContractAsync(EnrollUrl, second.Build());

        await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.AlreadyEnrolled);
    }

    [Fact]
    public async Task Enroll_TheSameKeysAgainstADifferentVaultId_Is409()
    {
        using var enrollment = NewEnrollment();
        var client = enrollment.CreateClient(fixture);
        await EnrollAsync(client, enrollment.Build());

        var response = await client.PostContractAsync(
            EnrollUrl,
            enrollment.Build(personalVault: enrollment.DefaultVault(vaultId: Guid.CreateVersion7())));

        await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.AlreadyEnrolled);
    }

    [Fact]
    public async Task Enroll_WithAVaultIdAnotherUserAlreadyHas_Is400()
    {
        using var owner = NewEnrollment();
        await EnrollAsync(owner.CreateClient(fixture), owner.Build());

        using var second = NewEnrollment();

        var response = await second.CreateClient(fixture).PostContractAsync(
            EnrollUrl,
            second.Build(personalVault: second.DefaultVault(vaultId: owner.VaultId)));

        await ShouldBeProblemAsync(
            response,
            HttpStatusCode.BadRequest,
            ProblemCodes.InvalidEnrollment);
    }

    // ---- Enrollment: the identity-provider binding ----

    [Fact]
    public async Task Enroll_WithANonceForADifferentStatement_IsRejected()
    {
        // The heart of it. A token that does not hash to *this* statement binds nothing, so
        // accepting it would let anyone publish keys under any account the provider knows.
        using var enrollment = NewEnrollment();
        var other = enrollment.Statement with { DeviceName = "some-other-device" };

        var response = await enrollment.CreateClient(fixture).PostContractAsync(
            EnrollUrl,
            enrollment.Build(idToken: enrollment.MintIdToken(other)));

        await ShouldBeBindingRejectedAsync(response);
    }

    [Fact]
    public async Task Enroll_WithATokenForAnotherSubject_IsRejected()
    {
        using var enrollment = NewEnrollment();

        var response = await enrollment.CreateClient(fixture).PostContractAsync(
            EnrollUrl,
            enrollment.Build(
                idToken: enrollment.MintIdToken(enrollment.Statement, subject: NewSubject())));

        await ShouldBeBindingRejectedAsync(response);
    }

    [Fact]
    public async Task Enroll_WithATokenAudiencedToTheApiRatherThanTheClient_IsRejected()
    {
        // An ID token is audienced to the client. An access token presented here would be a
        // different kind of assertion entirely, and must not be interchangeable with one.
        using var enrollment = NewEnrollment();

        var response = await enrollment.CreateClient(fixture).PostContractAsync(
            EnrollUrl,
            enrollment.Build(
                idToken: enrollment.MintIdToken(
                    enrollment.Statement,
                    audience: StubIdentityProvider.Audience)));

        await ShouldBeBindingRejectedAsync(response);
    }

    [Fact]
    public async Task Enroll_WithATokenSignedByAKeyTheProviderDoesNotPublish_IsRejected()
    {
        // Proves the JWKS check genuinely runs rather than the token being parsed and trusted.
        using var enrollment = NewEnrollment();

        var foreign = fixture.IdentityProvider.MintIdTokenWithForeignKey(
            enrollment.Subject,
            KeyStatementCodec.ComputeNonce(Fields(enrollment.Statement)));

        var response = await enrollment.CreateClient(fixture).PostContractAsync(
            EnrollUrl,
            enrollment.Build(idToken: foreign));

        await ShouldBeBindingRejectedAsync(response);
    }

    [Fact]
    public async Task Enroll_WithAnExpiredToken_IsRejected()
    {
        using var enrollment = NewEnrollment();

        var response = await enrollment.CreateClient(fixture).PostContractAsync(
            EnrollUrl,
            enrollment.Build(
                idToken: enrollment.MintIdToken(
                    enrollment.Statement,
                    expires: TimeProvider.System.GetUtcNow().UtcDateTime.AddMinutes(-10))));

        await ShouldBeBindingRejectedAsync(response);
    }

    [Fact]
    public async Task Enroll_WithATokenFromAnotherIssuer_IsRejected()
    {
        using var enrollment = NewEnrollment();

        var response = await enrollment.CreateClient(fixture).PostContractAsync(
            EnrollUrl,
            enrollment.Build(
                idToken: enrollment.MintIdToken(enrollment.Statement, issuer: "https://evil.example")));

        await ShouldBeBindingRejectedAsync(response);
    }

    [Fact]
    public async Task Enroll_WithNoNonceAtAll_IsRejected()
    {
        using var enrollment = NewEnrollment();

        var response = await enrollment.CreateClient(fixture).PostContractAsync(
            EnrollUrl,
            enrollment.Build(
                idToken: enrollment.MintIdToken(enrollment.Statement, omitNonce: true)));

        await ShouldBeBindingRejectedAsync(response);
    }

    [Fact]
    public async Task Enroll_WhenTheBindingFails_WritesNothing()
    {
        // A rejection that still wrote a key log entry would poison the chain permanently, because
        // the log is append-only.
        using var enrollment = NewEnrollment();
        var client = enrollment.CreateClient(fixture);

        var response = await client.PostContractAsync(
            EnrollUrl,
            enrollment.Build(idToken: enrollment.MintIdToken(enrollment.Statement, omitNonce: true)));

        response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        var user = await database.Users.SingleAsync(u => u.Subject == enrollment.Subject);

        (await database.UserKeys.AnyAsync(k => k.UserId == user.Id)).ShouldBeFalse();
        (await database.UserKeyWraps.AnyAsync(w => w.UserId == user.Id)).ShouldBeFalse();
        (await database.KeyLog.AnyAsync(e => e.UserId == user.Id)).ShouldBeFalse();
        (await database.Vaults.AnyAsync(v => v.Id == enrollment.VaultId)).ShouldBeFalse();
        user.EnrolledAtUtc.ShouldBeNull();
    }

    // ---- Enrollment: statement and shape validation ----

    [Fact]
    public async Task Enroll_WithASignatureFromAnotherKey_IsRejected()
    {
        using var enrollment = NewEnrollment();
        using var other = NewEnrollment();

        var response = await enrollment.CreateClient(fixture).PostContractAsync(
            EnrollUrl,
            enrollment.Build(statementSignature: other.Sign(enrollment.Statement)));

        await ShouldBeProblemAsync(
            response,
            HttpStatusCode.BadRequest,
            ProblemCodes.InvalidEnrollment);
    }

    [Fact]
    public async Task Enroll_WithAStatementNamingAnotherSubject_IsRejected()
    {
        // Caught by shape validation before any cryptography, so the code says "invalid" rather
        // than blaming the binding.
        using var enrollment = NewEnrollment();
        var impersonating = enrollment.Statement with { Subject = NewSubject() };

        var response = await enrollment.CreateClient(fixture).PostContractAsync(
            EnrollUrl,
            enrollment.Build(statement: impersonating));

        await ShouldBeProblemAsync(
            response,
            HttpStatusCode.BadRequest,
            ProblemCodes.InvalidEnrollment);
    }

    [Theory]
    [InlineData(1024, 4, "memory far below the floor")]
    [InlineData(256 * 1024, 1, "too few passes")]
    [InlineData(8 * 1024 * 1024, 4, "memory above the ceiling")]
    public async Task Enroll_WithUnacceptableKdfParameters_IsRejected(
        int memoryKibibytes,
        int passes,
        string reason)
    {
        // A weak wrap here is a permanent liability: it is exactly what an attacker who ever
        // obtains a database dump grinds against offline. An absurd one locks the user out instead.
        reason.ShouldNotBeEmpty();

        using var enrollment = NewEnrollment();

        var response = await enrollment.CreateClient(fixture).PostContractAsync(
            EnrollUrl,
            enrollment.Build(
                kdfParameters: new KdfParameters("argon2id", new byte[16], memoryKibibytes, passes, 1)));

        await ShouldBeProblemAsync(
            response,
            HttpStatusCode.BadRequest,
            ProblemCodes.InvalidEnrollment);
    }

    [Fact]
    public async Task Enroll_WithParallelismOtherThanOne_IsRejected()
    {
        using var enrollment = NewEnrollment();

        var response = await enrollment.CreateClient(fixture).PostContractAsync(
            EnrollUrl,
            enrollment.Build(
                kdfParameters: new KdfParameters("argon2id", new byte[16], 256 * 1024, 4, 4)));

        await ShouldBeProblemAsync(
            response,
            HttpStatusCode.BadRequest,
            ProblemCodes.InvalidEnrollment);
    }

    [Fact]
    public async Task Enroll_WithAWrongLengthPublicKey_IsRejected()
    {
        using var enrollment = NewEnrollment();
        var truncated = enrollment.Statement with { EncryptionPublicKey = new byte[31] };

        // The signature and token are supplied ready-made: a malformed statement cannot be
        // canonically encoded at all, so neither can be derived from it. Shape validation runs
        // before any cryptography, so the server never gets that far either.
        var response = await enrollment.CreateClient(fixture).PostContractAsync(
            EnrollUrl,
            enrollment.Build(
                statement: truncated,
                statementSignature: new byte[64],
                idToken: enrollment.MintIdToken(enrollment.Statement)));

        await ShouldBeProblemAsync(
            response,
            HttpStatusCode.BadRequest,
            ProblemCodes.InvalidEnrollment);
    }

    [Fact]
    public async Task Enroll_ReusingOneKeyForBothRoles_IsRejected()
    {
        // Using one key for agreement and signatures is a standing cryptographic mistake, and is
        // also the shape a client bug takes when it exports the wrong key twice.
        using var enrollment = NewEnrollment();
        var reused = enrollment.Statement with
        {
            EncryptionPublicKey = enrollment.Statement.SigningPublicKey,
        };

        var response = await enrollment.CreateClient(fixture).PostContractAsync(
            EnrollUrl,
            enrollment.Build(statement: reused));

        await ShouldBeProblemAsync(
            response,
            HttpStatusCode.BadRequest,
            ProblemCodes.InvalidEnrollment);
    }

    [Fact]
    public async Task Enroll_WithAGenerationOtherThanOne_IsRejected()
    {
        using var enrollment = NewEnrollment();
        var later = enrollment.Statement with { KeyGeneration = 2 };

        var response = await enrollment.CreateClient(fixture).PostContractAsync(
            EnrollUrl,
            enrollment.Build(statement: later));

        await ShouldBeProblemAsync(
            response,
            HttpStatusCode.BadRequest,
            ProblemCodes.InvalidEnrollment);
    }

    [Fact]
    public async Task Enroll_WithADeviceKeyButNoDeviceWrap_IsRejected()
    {
        // Only the holder of the bundle can seal it, so a device key with no wrap would register a
        // device that can never unlock anything and that the server could never fix.
        using var enrollment = NewEnrollment();

        var response = await enrollment.CreateClient(fixture).PostContractAsync(
            EnrollUrl,
            enrollment.Build() with { DeviceWrappedPrivateKey = null });

        await ShouldBeProblemAsync(
            response,
            HttpStatusCode.BadRequest,
            ProblemCodes.InvalidEnrollment);
    }

    [Fact]
    public async Task Enroll_WithARecoveryWrapButNoKdfParameters_IsRejected()
    {
        using var enrollment = NewEnrollment();

        var response = await enrollment.CreateClient(fixture).PostContractAsync(
            EnrollUrl,
            enrollment.Build() with { RecoveryKdfParameters = null });

        await ShouldBeProblemAsync(
            response,
            HttpStatusCode.BadRequest,
            ProblemCodes.InvalidEnrollment);
    }

    [Fact]
    public async Task Enroll_WithAJsonBodyMissingRequiredFields_Is400NotAServerError()
    {
        // A record's non-nullable parameters are a compile-time promise only — JSON that omits a
        // property deserialises to null regardless. The distinction that matters is 400 versus 500:
        // one tells a client its request was wrong, the other looks like the server broke.
        using var enrollment = NewEnrollment();

        using var content = new StringContent(
            """{"statement":null,"statementSignature":null,"identityProviderToken":null}""",
            System.Text.Encoding.UTF8,
            "application/json");

        var response = await enrollment.CreateClient(fixture).PostAsync(
            new Uri(EnrollUrl, UriKind.Relative),
            content);

        response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
    }

    [Fact]
    public async Task Enroll_WithoutKdfParameters_Is400NotAServerError()
    {
        using var enrollment = NewEnrollment();

        var response = await enrollment.CreateClient(fixture).PostContractAsync(
            EnrollUrl,
            enrollment.Build() with { KdfParameters = null! });

        await ShouldBeProblemAsync(
            response,
            HttpStatusCode.BadRequest,
            ProblemCodes.InvalidEnrollment);
    }

    [Fact]
    public async Task Enroll_WithoutAToken_Is401()
    {
        using var enrollment = NewEnrollment();

        var response = await fixture.CreateClient().PostContractAsync(EnrollUrl, enrollment.Build());

        response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
    }

    // ---- Registering a device after enrollment ----

    [Fact]
    public async Task RegisterDevice_AddsADeviceAndAWrapThatOnlyThatDeviceCanOpen()
    {
        // The gap this endpoint closes. EnrollmentService.AddDevice only ever runs during enrollment, so
        // without this every account that existed before a keystore was wired up could never turn the
        // feature on — which is all of them.
        using var enrollment = NewEnrollment();
        var client = enrollment.CreateClient(fixture);
        var enrolled = await EnrollAsync(client, enrollment.Build());

        var publicKey = DeviceKey();

        var registered = await RegisterDeviceAsync(
            client,
            new RegisterDeviceRequest("a second laptop", publicKey, Wrap()));

        registered.DeviceId.ShouldNotBe(Guid.Empty);

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        var device = await database.Devices.SingleAsync(d => d.Id == registered.DeviceId);
        device.UserId.ShouldBe(enrolled.UserId);
        device.Name.ShouldBe("a second laptop");
        device.PublicKey.ShouldBe(publicKey);

        var wrap = await database.UserKeyWraps.SingleAsync(w => w.DeviceId == registered.DeviceId);
        wrap.Kind.ShouldBe(UserKeyWrapKind.Device);
        wrap.UserId.ShouldBe(enrolled.UserId);
    }

    [Fact]
    public async Task RegisterDevice_BeforeEnrolling_AsksForEnrollmentRatherThanRejectingTheShape()
    {
        // You cannot wrap a bundle to a device before you have a bundle. The distinction that matters is
        // which answer the client gets: "enroll first" is actionable, and a 400 about the request's shape
        // would send somebody looking at their key length.
        var subject = NewSubject();
        var client = fixture.CreateClientFor(subject, $"{subject}@example.com");

        var response = await client.PostContractAsync(
            DevicesUrl,
            new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap()));

        await ShouldBeProblemAsync(
            response,
            HttpStatusCode.Forbidden,
            ProblemCodes.EnrollmentRequired);
    }

    [Fact]
    public async Task RegisterDevice_TwiceWithTheSameKey_ReturnsTheSameDeviceAndAddsNoSecondWrap()
    {
        // Idempotent, as enrollment is. A dropped response must leave a retry safe, and a second row for
        // one key would mean a device list with a duplicate and two wraps to revoke instead of one.
        using var enrollment = NewEnrollment();
        var client = enrollment.CreateClient(fixture);
        await EnrollAsync(client, enrollment.Build());

        var publicKey = DeviceKey();
        var request = new RegisterDeviceRequest("a laptop", publicKey, Wrap());

        var first = await RegisterDeviceAsync(client, request);
        var second = await RegisterDeviceAsync(client, request);

        second.DeviceId.ShouldBe(first.DeviceId);
        second.EnrolledAt.ShouldBe(first.EnrolledAt);

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        (await database.Devices.CountAsync(d => d.PublicKey == publicKey)).ShouldBe(1);
        (await database.UserKeyWraps.CountAsync(w => w.DeviceId == first.DeviceId)).ShouldBe(1);
    }

    [Theory]
    [InlineData(31)]
    [InlineData(33)]
    public async Task RegisterDevice_WithAPublicKeyOfTheWrongLength_IsRejected(int length)
    {
        // The same rule enrollment applies. A key that is not an X25519 public key is one nothing can ever
        // seal to, so the row would be permanently useless.
        using var enrollment = NewEnrollment();
        var client = enrollment.CreateClient(fixture);
        await EnrollAsync(client, enrollment.Build());

        var response = await client.PostContractAsync(
            DevicesUrl,
            new RegisterDeviceRequest("a laptop", new byte[length], Wrap()));

        await ShouldBeProblemAsync(
            response,
            HttpStatusCode.BadRequest,
            ProblemCodes.InvalidDeviceRegistration);
    }

    [Fact]
    public async Task RegisterDevice_WithNoWrap_IsRejected()
    {
        // A device key with no wrap registers a device that can never unlock anything, and the server
        // cannot fill the gap in — only the holder of the bundle can seal it.
        using var enrollment = NewEnrollment();
        var client = enrollment.CreateClient(fixture);
        await EnrollAsync(client, enrollment.Build());

        var response = await client.PostContractAsync(
            DevicesUrl,
            new RegisterDeviceRequest("a laptop", DeviceKey(), []));

        await ShouldBeProblemAsync(
            response,
            HttpStatusCode.BadRequest,
            ProblemCodes.InvalidDeviceRegistration);
    }

    /// <remarks>
    /// The wrap is the half that matters. A device row nobody can use is untidy; a <c>kind=device</c> wrap
    /// left behind is the user's identity bundle still sealed to a key somebody may be holding. This asserts
    /// the foreign key's cascade rather than trusting the configuration to go on saying so.
    /// </remarks>
    [Fact]
    public async Task RevokeDevice_TakesItsWrapWithIt()
    {
        using var enrollment = NewEnrollment();
        var client = enrollment.CreateClient(fixture);
        await EnrollAsync(client, enrollment.Build());

        var registered = await RegisterDeviceAsync(
            client, new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap()));

        var response = await client.DeleteAsync(
            new Uri($"{DevicesUrl}/{registered.DeviceId}", UriKind.Relative));

        response.StatusCode.ShouldBe(HttpStatusCode.NoContent);

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        (await database.Devices.CountAsync(d => d.Id == registered.DeviceId)).ShouldBe(0);
        (await database.UserKeyWraps.CountAsync(w => w.DeviceId == registered.DeviceId)).ShouldBe(0);
    }

    /// <remarks>
    /// The passphrase wrap is what makes revocation safe to offer at all: withdrawing every device must
    /// never be able to lock somebody out of their own vault, so this checks the one row that guarantees it
    /// is still there afterwards.
    /// </remarks>
    [Fact]
    public async Task RevokeDevice_LeavesThePassphraseWrapAlone()
    {
        using var enrollment = NewEnrollment();
        var client = enrollment.CreateClient(fixture);
        await EnrollAsync(client, enrollment.Build());

        var registered = await RegisterDeviceAsync(
            client, new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap()));

        await client.DeleteAsync(new Uri($"{DevicesUrl}/{registered.DeviceId}", UriKind.Relative));

        var me = await ReadMeAsync(client);

        me.EnrollmentRequired.ShouldBeFalse();
        me.WrappedPrivateKey.ShouldNotBeNull();
    }

    [Fact]
    public async Task RevokeDevice_ThatIsNotThere_Is404()
    {
        // Rather than a bland 204. "Revoked" is what the user reads, and reading it about the wrong id is
        // worse than being told to look again — a client driving towards "this machine cannot unlock" can
        // treat 404 as having arrived, and the desktop one does.
        using var enrollment = NewEnrollment();
        var client = enrollment.CreateClient(fixture);
        await EnrollAsync(client, enrollment.Build());

        var response = await client.DeleteAsync(
            new Uri($"{DevicesUrl}/{Guid.CreateVersion7()}", UriKind.Relative));

        response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
    }

    /// <remarks>
    /// The authorisation check, and the only one there is: the id is an unguessable v7 GUID, but unguessable
    /// is not a permission. Without the user scope on the query, one account could withdraw another's device
    /// key by pasting an id it saw once — and the victim's next launch would ask for a passphrase with no
    /// explanation.
    /// </remarks>
    [Fact]
    public async Task RevokeDevice_BelongingToSomebodyElse_Is404AndChangesNothing()
    {
        using var mine = NewEnrollment();
        var myClient = mine.CreateClient(fixture);
        await EnrollAsync(myClient, mine.Build());

        var registered = await RegisterDeviceAsync(
            myClient, new RegisterDeviceRequest("my laptop", DeviceKey(), Wrap()));

        using var theirs = NewEnrollment();
        var theirClient = theirs.CreateClient(fixture);
        await EnrollAsync(theirClient, theirs.Build());

        var response = await theirClient.DeleteAsync(
            new Uri($"{DevicesUrl}/{registered.DeviceId}", UriKind.Relative));

        // 404 rather than 403, because a stranger must not learn that the id exists.
        response.StatusCode.ShouldBe(HttpStatusCode.NotFound);

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        (await database.Devices.CountAsync(d => d.Id == registered.DeviceId)).ShouldBe(1);
        (await database.UserKeyWraps.CountAsync(w => w.DeviceId == registered.DeviceId)).ShouldBe(1);
    }

    [Fact]
    public async Task RevokeDevice_WithoutAToken_Is401()
    {
        var response = await fixture.CreateClient().DeleteAsync(
            new Uri($"{DevicesUrl}/{Guid.CreateVersion7()}", UriKind.Relative));

        response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
    }

    [Fact]
    public async Task RegisterDevice_WithABlankName_IsRejected()
    {
        using var enrollment = NewEnrollment();
        var client = enrollment.CreateClient(fixture);
        await EnrollAsync(client, enrollment.Build());

        var response = await client.PostContractAsync(
            DevicesUrl,
            new RegisterDeviceRequest("   ", DeviceKey(), Wrap()));

        await ShouldBeProblemAsync(
            response,
            HttpStatusCode.BadRequest,
            ProblemCodes.InvalidDeviceRegistration);
    }

    [Fact]
    public async Task RegisterDevice_WithoutAToken_Is401()
    {
        var response = await fixture.CreateClient().PostContractAsync(
            DevicesUrl,
            new RegisterDeviceRequest("a laptop", DeviceKey(), Wrap()));

        response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
    }

    // ---- Helpers ----

    private static string NewSubject() => $"user-{Guid.CreateVersion7():N}";

    private TestEnrollment NewEnrollment()
    {
        var subject = NewSubject();
        return new TestEnrollment(fixture.IdentityProvider, subject, $"{subject}@example.com");
    }

    private static async Task<MeResponse> ReadMeAsync(HttpClient client)
    {
        var response = await client.GetAsync(new Uri(MeUrl, UriKind.Relative));
        response.EnsureSuccessStatusCode();

        var me = await response.Content.ReadContractAsync<MeResponse>();
        me.ShouldNotBeNull();
        return me;
    }

    private static async Task<EnrollmentResponse> EnrollAsync(
        HttpClient client,
        EnrollmentRequest request)
    {
        var response = await client.PostContractAsync(EnrollUrl, request);
        response.EnsureSuccessStatusCode();

        var body = await response.Content.ReadContractAsync<EnrollmentResponse>();
        body.ShouldNotBeNull();
        return body;
    }

    private static async Task<RegisterDeviceResponse> RegisterDeviceAsync(
        HttpClient client,
        RegisterDeviceRequest request)
    {
        var response = await client.PostContractAsync(DevicesUrl, request);
        response.EnsureSuccessStatusCode();

        var body = await response.Content.ReadContractAsync<RegisterDeviceResponse>();
        body.ShouldNotBeNull();
        return body;
    }

    /// <remarks>
    /// A distinct 32 bytes per call, because the endpoint is idempotent on the public key and two tests
    /// sharing one would silently be asserting about each other's device.
    /// </remarks>
    private static byte[] DeviceKey() => Guid.CreateVersion7().ToByteArray().Concat(
        Guid.CreateVersion7().ToByteArray()).ToArray();

    /// <remarks>
    /// Not a real envelope. The server treats a wrap as opaque bytes and validates only that it is present
    /// and bounded, which is the whole of what it is allowed to know.
    /// </remarks>
    private static byte[] Wrap() => [1, 2, 3, 4];

    private static Task ShouldBeBindingRejectedAsync(HttpResponseMessage response) =>
        ShouldBeProblemAsync(
            response,
            HttpStatusCode.BadRequest,
            ProblemCodes.IdentityBindingInvalid);

    private static async Task ShouldBeProblemAsync(
        HttpResponseMessage response,
        HttpStatusCode expectedStatus,
        string expectedCode)
    {
        response.StatusCode.ShouldBe(expectedStatus);

        var problem = await response.Content.ReadProblemAsync();
        problem.ShouldNotBeNull();
        problem.Code.ShouldBe(expectedCode);
    }

    private static KeyStatementFields Fields(KeyStatement statement) =>
        new(
            statement.Version,
            statement.Issuer,
            statement.Subject,
            statement.Email,
            statement.EncryptionPublicKey,
            statement.SigningPublicKey,
            statement.KeyGeneration,
            statement.CreatedAt,
            statement.DeviceName);
}
