using DodoSSH.Domain;
using Microsoft.EntityFrameworkCore;
using Npgsql;

namespace DodoSSH.Infrastructure.Tests;

/// <summary>
/// Verifies the database enforces the invariants the design depends on.
/// </summary>
/// <remarks>
/// These are not tests of EF. They assert that the constraints hold even if application code has
/// a bug, which is the whole reason for putting them in the schema. The relay-target constraint in
/// particular is a security boundary: see ADR 0004.
/// </remarks>
[Collection(PostgresCollection.Name)]
public sealed class SchemaConstraintTests(PostgresFixture fixture)
{
    private static readonly DateTimeOffset Now = new(2026, 7, 28, 12, 0, 0, TimeSpan.Zero);

    [Fact]
    public async Task Migration_AppliedCleanly_WithNoPendingModelChanges()
    {
        await using var context = fixture.CreateContext();

        var applied = await context.Database.GetAppliedMigrationsAsync();
        applied.ShouldNotBeEmpty();

        (await context.Database.GetPendingMigrationsAsync()).ShouldBeEmpty();
    }

    [Fact]
    public async Task Xmin_IsASystemColumnAndNotDeclaredInTheTable()
    {
        // Npgsql maps xmin as a concurrency token without emitting a column. A negative attnum
        // means PostgreSQL's own system column; a positive one would mean we had created a real
        // column, which PostgreSQL would in fact have rejected.
        await using var context = fixture.CreateContext();

        var attnum = await context.Database
            .SqlQuery<short>($"""
                SELECT a.attnum AS "Value"
                FROM pg_attribute a
                JOIN pg_class c ON c.oid = a.attrelid
                JOIN pg_namespace n ON n.oid = c.relnamespace
                WHERE n.nspname = 'dodo' AND c.relname = 'vault' AND a.attname = 'xmin'
                """)
            .SingleAsync();

        attnum.ShouldBeLessThan((short)0);
    }

    // ---- ADR 0004: the relay must never learn an address it was not granted ----

    [Fact]
    public async Task Host_WithRelayEnabled_RequiresHostnameAndPort()
    {
        await using var context = fixture.CreateContext();
        var vault = await SeedVaultAsync(context);

        context.Hosts.Add(NewHost(vault, relayEnabled: true, hostname: null, port: null));

        var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
        ConstraintName(exception).ShouldBe("ck_host_relay_target");
    }

    [Fact]
    public async Task Host_WithRelayDisabled_MustNotCarryAnAddress()
    {
        // The important direction. If application code could store a plaintext address without
        // the user opting into relay, the server would silently learn infrastructure it was never
        // granted visibility of.
        await using var context = fixture.CreateContext();
        var vault = await SeedVaultAsync(context);

        context.Hosts.Add(NewHost(vault, relayEnabled: false, hostname: "secret.internal", port: 22));

        var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
        ConstraintName(exception).ShouldBe("ck_host_relay_target");
    }

    [Fact]
    public async Task Host_WithRelayEnabledAndAnAddress_IsAccepted()
    {
        await using var context = fixture.CreateContext();
        var vault = await SeedVaultAsync(context);

        context.Hosts.Add(NewHost(vault, relayEnabled: true, hostname: "bastion.internal", port: 22));

        await context.SaveChangesAsync();
    }

    [Fact]
    public async Task Host_WithRelayDisabledAndNoAddress_IsAccepted()
    {
        await using var context = fixture.CreateContext();
        var vault = await SeedVaultAsync(context);

        context.Hosts.Add(NewHost(vault, relayEnabled: false, hostname: null, port: null));

        await context.SaveChangesAsync();
    }

    [Theory]
    [InlineData(0)]
    [InlineData(65536)]
    [InlineData(-1)]
    public async Task Host_RejectsAnOutOfRangePort(int port)
    {
        await using var context = fixture.CreateContext();
        var vault = await SeedVaultAsync(context);

        context.Hosts.Add(NewHost(vault, relayEnabled: true, hostname: "h.internal", port: port));

        var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
        ConstraintName(exception).ShouldBe("ck_host_port_range");
    }

    // ---- Vault ownership ----

    [Fact]
    public async Task Vault_MustHaveExactlyOneOwner()
    {
        await using var context = fixture.CreateContext();
        var user = await SeedUserAsync(context);
        var team = SeedTeam(context, user.Id);
        await context.SaveChangesAsync();

        // Both owners set: permission resolution would have no defined answer.
        context.Vaults.Add(new Vault
        {
            Id = Guid.CreateVersion7(),
            Name = "ambiguous",
            OwnerKind = VaultOwnerKind.Personal,
            OwnerUserId = user.Id,
            TeamId = team.Id,
            CreatedAtUtc = Now,
            UpdatedAtUtc = Now,
        });

        var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
        ConstraintName(exception).ShouldBe("ck_vault_owner");
    }

    [Fact]
    public async Task Vault_PersonalWithoutAnOwnerUser_IsRejected()
    {
        await using var context = fixture.CreateContext();

        context.Vaults.Add(new Vault
        {
            Id = Guid.CreateVersion7(),
            Name = "orphan",
            OwnerKind = VaultOwnerKind.Personal,
            CreatedAtUtc = Now,
            UpdatedAtUtc = Now,
        });

        var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
        ConstraintName(exception).ShouldBe("ck_vault_owner");
    }

    // ---- Grants ----

    [Fact]
    public async Task MemberGrant_RequiresARecipient()
    {
        await using var context = fixture.CreateContext();
        var vault = await SeedVaultAsync(context);

        context.VaultKeyGrants.Add(NewGrant(vault, GrantKind.Member, recipientUserId: null));

        var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
        ConstraintName(exception).ShouldBe("ck_vault_key_grant_recipient");
    }

    [Fact]
    public async Task RecoveryGrant_MustNotNameARecipient()
    {
        // Recovery and escrow grants are wrapped to a key, not to a user. Allowing a recipient
        // would make it ambiguous whether revoking that user revokes recovery.
        await using var context = fixture.CreateContext();
        var vault = await SeedVaultAsync(context);
        var user = await context.Users.FirstAsync(u => u.Id == vault.OwnerUserId);

        context.VaultKeyGrants.Add(NewGrant(vault, GrantKind.Recovery, recipientUserId: user.Id));

        var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
        ConstraintName(exception).ShouldBe("ck_vault_key_grant_recipient");
    }

    [Fact]
    public async Task RecoveryGrant_IsAcceptedWithoutARecipient()
    {
        // Recovery must be expressible in the very first schema, or every vault created before it
        // existed would be permanently unrecoverable.
        await using var context = fixture.CreateContext();
        var vault = await SeedVaultAsync(context);

        context.VaultKeyGrants.Add(NewGrant(vault, GrantKind.Recovery, recipientUserId: null));

        await context.SaveChangesAsync();
    }

    [Fact]
    public async Task MemberGrant_IsUniquePerRecipientPerGeneration()
    {
        await using var context = fixture.CreateContext();
        var vault = await SeedVaultAsync(context);
        var userId = vault.OwnerUserId!.Value;

        context.VaultKeyGrants.Add(NewGrant(vault, GrantKind.Member, userId));
        await context.SaveChangesAsync();

        context.VaultKeyGrants.Add(NewGrant(vault, GrantKind.Member, userId));

        var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
        exception.InnerException.ShouldBeOfType<PostgresException>()
            .SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation);
    }

    [Fact]
    public async Task MemberGrant_MayBeReissuedAfterRevocation()
    {
        // The uniqueness filter is on revocation, not deletion: revoked grants are retained so
        // audit history stays intact, and a rewrap must still be possible.
        await using var context = fixture.CreateContext();
        var vault = await SeedVaultAsync(context);
        var userId = vault.OwnerUserId!.Value;

        var first = NewGrant(vault, GrantKind.Member, userId);
        context.VaultKeyGrants.Add(first);
        await context.SaveChangesAsync();

        first.RevokedAtUtc = Now;
        first.State = GrantState.Revoked;
        await context.SaveChangesAsync();

        context.VaultKeyGrants.Add(NewGrant(vault, GrantKind.Member, userId));
        await context.SaveChangesAsync();
    }

    // ---- Key wraps ----

    [Fact]
    public async Task PassphraseWrap_RequiresKdfParameters()
    {
        // A password-derived wrap without its parameters is permanently unopenable.
        await using var context = fixture.CreateContext();
        var user = await SeedUserAsync(context);

        context.UserKeyWraps.Add(new UserKeyWrap
        {
            Id = Guid.CreateVersion7(),
            UserId = user.Id,
            Kind = UserKeyWrapKind.Passphrase,
            Wrap = [1, 2, 3],
            WrapVersion = 1,
            CreatedAtUtc = Now,
        });

        var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
        ConstraintName(exception).ShouldBe("ck_user_key_wrap_kdf");
    }

    [Fact]
    public async Task DeviceWrap_RequiresADevice()
    {
        await using var context = fixture.CreateContext();
        var user = await SeedUserAsync(context);

        context.UserKeyWraps.Add(new UserKeyWrap
        {
            Id = Guid.CreateVersion7(),
            UserId = user.Id,
            Kind = UserKeyWrapKind.Device,
            Wrap = [1, 2, 3],
            WrapVersion = 1,
            CreatedAtUtc = Now,
        });

        var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
        ConstraintName(exception).ShouldBe("ck_user_key_wrap_device");
    }

    [Fact]
    public async Task PassphraseWrap_WithParameters_IsAccepted()
    {
        await using var context = fixture.CreateContext();
        var user = await SeedUserAsync(context);

        context.UserKeyWraps.Add(new UserKeyWrap
        {
            Id = Guid.CreateVersion7(),
            UserId = user.Id,
            Kind = UserKeyWrapKind.Passphrase,
            Wrap = [1, 2, 3],
            WrapVersion = 1,
            KdfAlgorithm = "argon2id",
            KdfSalt = new byte[16],
            KdfMemoryKibibytes = 262144,
            KdfPasses = 4,
            KdfParallelism = 1,
            CreatedAtUtc = Now,
        });

        await context.SaveChangesAsync();
    }

    // ---- Identity uniqueness ----

    [Fact]
    public async Task User_IsUniquePerIssuerAndSubject()
    {
        await using var context = fixture.CreateContext();
        var subject = Guid.CreateVersion7().ToString();

        context.Users.Add(NewUser("https://idp.example", subject));
        await context.SaveChangesAsync();

        context.Users.Add(NewUser("https://idp.example", subject));

        var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
        exception.InnerException.ShouldBeOfType<PostgresException>()
            .SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation);
    }

    [Fact]
    public async Task User_MayShareASubjectAcrossDifferentIssuers()
    {
        // Multi-issuer from the start: the issuer is part of the identity, not a detail.
        await using var context = fixture.CreateContext();
        var subject = Guid.CreateVersion7().ToString();

        context.Users.Add(NewUser("https://idp-a.example", subject));
        context.Users.Add(NewUser("https://idp-b.example", subject));

        await context.SaveChangesAsync();
    }

    [Fact]
    public async Task Email_IsCaseInsensitivelyUnique()
    {
        // citext: an attacker must not be able to register Alice@x with alice@x already present.
        await using var context = fixture.CreateContext();
        var local = $"user{Guid.CreateVersion7():N}";

        var first = NewUser("https://idp.example", Guid.CreateVersion7().ToString());
        first.Email = $"{local}@example.com";
        context.Users.Add(first);
        await context.SaveChangesAsync();

        var second = NewUser("https://idp.example", Guid.CreateVersion7().ToString());
        second.Email = $"{local.ToUpperInvariant()}@EXAMPLE.COM";
        context.Users.Add(second);

        var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
        exception.InnerException.ShouldBeOfType<PostgresException>()
            .SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation);
    }

    [Fact]
    public async Task UserKey_AllowsOnlyOneCurrentGenerationPerUser()
    {
        // Two current keys would make it ambiguous which one to wrap a vault key to.
        await using var context = fixture.CreateContext();
        var user = await SeedUserAsync(context);

        context.UserKeys.Add(NewUserKey(user.Id, generation: 1, isCurrent: true));
        await context.SaveChangesAsync();

        context.UserKeys.Add(NewUserKey(user.Id, generation: 2, isCurrent: true));

        var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
        exception.InnerException.ShouldBeOfType<PostgresException>()
            .SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation);
    }

    [Fact]
    public async Task UserKey_AllowsManySupersededGenerations()
    {
        await using var context = fixture.CreateContext();
        var user = await SeedUserAsync(context);

        context.UserKeys.Add(NewUserKey(user.Id, generation: 1, isCurrent: false));
        context.UserKeys.Add(NewUserKey(user.Id, generation: 2, isCurrent: false));
        context.UserKeys.Add(NewUserKey(user.Id, generation: 3, isCurrent: true));

        await context.SaveChangesAsync();
    }

    // ---- Sync log ----

    [Fact]
    public async Task SyncChange_SequenceIsDatabaseAssignedAndMonotonic()
    {
        await using var context = fixture.CreateContext();
        var vault = await SeedVaultAsync(context);

        var first = NewChange(vault.Id);
        var second = NewChange(vault.Id);
        context.VaultChanges.AddRange(first, second);
        await context.SaveChangesAsync();

        first.Sequence.ShouldBeGreaterThan(0);
        second.Sequence.ShouldBeGreaterThan(first.Sequence);
    }

    [Fact]
    public async Task SyncChange_RejectsACallerSuppliedSequence()
    {
        // Identity ALWAYS. If anything could choose its own sequence, cursor ordering would stop
        // meaning anything and delta sync would silently skip changes.
        await using var context = fixture.CreateContext();
        var vault = await SeedVaultAsync(context);

        await using var connection = new NpgsqlConnection(fixture.ConnectionString);
        await connection.OpenAsync();
        await using var command = connection.CreateCommand();
        command.CommandText = """
            INSERT INTO dodo.sync_change
                (sequence, vault_id, entity_type, entity_id, operation, revision, actor_user_id, occurred_at_utc)
            VALUES (999999, @vault, 1, @entity, 1, 1, @actor, now())
            """;
        command.Parameters.AddWithValue("vault", vault.Id);
        command.Parameters.AddWithValue("entity", Guid.CreateVersion7());
        command.Parameters.AddWithValue("actor", vault.OwnerUserId!.Value);

        var exception = await Should.ThrowAsync<PostgresException>(() => command.ExecuteNonQueryAsync());

        // 428C9 is generated_always: PostgreSQL refuses a value for a GENERATED ALWAYS column.
        exception.SqlState.ShouldBe("428C9");
    }

    // ---- Concurrency ----

    [Fact]
    public async Task Xmin_DetectsAConcurrentUpdate()
    {
        // Two clients racing a rekey must not silently overwrite one another.
        await using var writer = fixture.CreateContext();
        var vault = await SeedVaultAsync(writer);

        await using var contextA = fixture.CreateContext();
        await using var contextB = fixture.CreateContext();

        var asA = await contextA.Vaults.SingleAsync(v => v.Id == vault.Id);
        var asB = await contextB.Vaults.SingleAsync(v => v.Id == vault.Id);

        asA.KeyGeneration = 2;
        await contextA.SaveChangesAsync();

        asB.KeyGeneration = 3;
        await Should.ThrowAsync<DbUpdateConcurrencyException>(() => contextB.SaveChangesAsync());
    }

    // ---- Helpers ----

    private static string? ConstraintName(DbUpdateException exception) =>
        (exception.InnerException as PostgresException)?.ConstraintName;

    private static UserAccount NewUser(string issuer, string subject) => new()
    {
        Id = Guid.CreateVersion7(),
        Issuer = issuer,
        Subject = subject,
        Status = UserStatus.Active,
        CreatedAtUtc = Now,
        UpdatedAtUtc = Now,
    };

    private static async Task<UserAccount> SeedUserAsync(DodoDbContext context)
    {
        var user = NewUser("https://idp.example", Guid.CreateVersion7().ToString());
        context.Users.Add(user);
        await context.SaveChangesAsync();
        return user;
    }

    private static Team SeedTeam(DodoDbContext context, Guid createdBy)
    {
        var team = new Team
        {
            Id = Guid.CreateVersion7(),
            Name = "Team",
            Slug = $"team-{Guid.CreateVersion7():N}",
            CreatedByUserId = createdBy,
            CreatedAtUtc = Now,
        };

        context.Teams.Add(team);
        return team;
    }

    private static async Task<Vault> SeedVaultAsync(DodoDbContext context)
    {
        var user = await SeedUserAsync(context);

        var vault = new Vault
        {
            Id = Guid.CreateVersion7(),
            Name = "Personal",
            OwnerKind = VaultOwnerKind.Personal,
            OwnerUserId = user.Id,
            KeyGeneration = 1,
            CreatedAtUtc = Now,
            UpdatedAtUtc = Now,
        };

        context.Vaults.Add(vault);
        await context.SaveChangesAsync();
        return vault;
    }

    private static SshHost NewHost(Vault vault, bool relayEnabled, string? hostname, int? port) => new()
    {
        Id = Guid.CreateVersion7(),
        VaultId = vault.Id,
        Payload = [1, 2, 3, 4],
        KeyGeneration = vault.KeyGeneration,
        PayloadAadVersion = 1,
        RelayEnabled = relayEnabled,
        Hostname = hostname,
        Port = port,
        Version = 1,
        CreatedAtUtc = Now,
        UpdatedAtUtc = Now,
        CreatedByUserId = vault.OwnerUserId!.Value,
        UpdatedByUserId = vault.OwnerUserId!.Value,
    };

    private static VaultKeyGrant NewGrant(Vault vault, GrantKind kind, Guid? recipientUserId) => new()
    {
        Id = Guid.CreateVersion7(),
        VaultId = vault.Id,
        KeyGeneration = vault.KeyGeneration,
        Kind = kind,
        RecipientUserId = recipientUserId,
        RecipientKeyFingerprint = new byte[32],
        WrappedKey = [1, 2, 3],
        GranterUserId = vault.OwnerUserId!.Value,
        GranterKeyFingerprint = new byte[32],
        Signature = new byte[64],
        State = GrantState.Active,
        CreatedAtUtc = Now,
    };

    private static UserKey NewUserKey(Guid userId, int generation, bool isCurrent) => new()
    {
        Id = Guid.CreateVersion7(),
        UserId = userId,
        Generation = generation,
        EncryptionPublicKey = new byte[32],
        SigningPublicKey = new byte[32],
        FingerprintSha256 = Guid.CreateVersion7().ToByteArray().Concat(Guid.CreateVersion7().ToByteArray()).ToArray(),
        Statement = "{}",
        StatementSignature = new byte[64],
        IsCurrent = isCurrent,
        CreatedAtUtc = Now,
    };

    private static VaultChange NewChange(Guid vaultId) => new()
    {
        VaultId = vaultId,
        EntityType = ChangeEntityType.SshHost,
        EntityId = Guid.CreateVersion7(),
        Operation = ChangeOperation.Upsert,
        Revision = 1,
        ActorUserId = Guid.CreateVersion7(),
        OccurredAtUtc = Now,
    };
}
