using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;

namespace ZB.MOM.WW.Auth.ApiKeys.Tests;

public sealed class SqliteApiKeyStoreTests : IAsyncLifetime
{
    private readonly string _dbPath =
        Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db");

    private AuthSqliteConnectionFactory _factory = null!;
    private SqliteApiKeyStore _store = null!;

    public async Task InitializeAsync()
    {
        _factory = new AuthSqliteConnectionFactory(_dbPath);
        await new SqliteAuthStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
        _store = new SqliteApiKeyStore(_factory);
    }

    [Fact]
    public async Task FindByKeyId_AfterInsert_ReturnsEqualRecord()
    {
        ApiKeyRecord record = SampleRecord("key-1");
        await InsertAsync(record);

        ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-1", CancellationToken.None);

        Assert.NotNull(found);
        AssertRecordEqual(record, found!);
    }

    [Fact]
    public async Task FindByKeyId_ReturnsRevokedRecord()
    {
        ApiKeyRecord record = SampleRecord("key-revoked") with
        {
            RevokedUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero),
        };
        await InsertAsync(record);

        ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-revoked", CancellationToken.None);

        Assert.NotNull(found);
        Assert.NotNull(found!.RevokedUtc);
    }

    [Fact]
    public async Task FindActiveByKeyId_RevokedKey_ReturnsNull()
    {
        ApiKeyRecord record = SampleRecord("key-revoked") with
        {
            RevokedUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero),
        };
        await InsertAsync(record);

        ApiKeyRecord? found = await _store.FindActiveByKeyIdAsync("key-revoked", CancellationToken.None);

        Assert.Null(found);
    }

    [Fact]
    public async Task FindActiveByKeyId_ActiveKey_ReturnsRecord()
    {
        await InsertAsync(SampleRecord("key-active"));

        ApiKeyRecord? found = await _store.FindActiveByKeyIdAsync("key-active", CancellationToken.None);

        Assert.NotNull(found);
    }

    [Fact]
    public async Task FindByKeyId_UnknownKey_ReturnsNull()
    {
        ApiKeyRecord? found = await _store.FindByKeyIdAsync("missing", CancellationToken.None);

        Assert.Null(found);
    }

    [Fact]
    public async Task MarkUsed_ActiveKey_UpdatesLastUsed()
    {
        await InsertAsync(SampleRecord("key-active"));
        var when = new DateTimeOffset(2026, 5, 31, 12, 0, 0, TimeSpan.Zero);

        await _store.MarkUsedAsync("key-active", when, CancellationToken.None);

        ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-active", CancellationToken.None);
        Assert.Equal(when, found!.LastUsedUtc);
    }

    [Fact]
    public async Task MarkUsed_RevokedKey_DoesNotUpdateLastUsed()
    {
        ApiKeyRecord record = SampleRecord("key-revoked") with
        {
            RevokedUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero),
        };
        await InsertAsync(record);
        var when = new DateTimeOffset(2026, 5, 31, 12, 0, 0, TimeSpan.Zero);

        await _store.MarkUsedAsync("key-revoked", when, CancellationToken.None);

        ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-revoked", CancellationToken.None);
        Assert.Null(found!.LastUsedUtc);
    }

    // --- keyId guard tests ---

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    public async Task FindByKeyId_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
    {
        // ArgumentNullException (null) and ArgumentException (empty/whitespace) are both acceptable;
        // ThrowIfNullOrWhiteSpace throws ArgumentNullException for null, ArgumentException for whitespace.
        await Assert.ThrowsAnyAsync<ArgumentException>(
            () => _store.FindByKeyIdAsync(keyId!, CancellationToken.None));
    }

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    public async Task FindActiveByKeyId_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
    {
        await Assert.ThrowsAnyAsync<ArgumentException>(
            () => _store.FindActiveByKeyIdAsync(keyId!, CancellationToken.None));
    }

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    public async Task MarkUsed_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
    {
        await Assert.ThrowsAnyAsync<ArgumentException>(
            () => _store.MarkUsedAsync(keyId!, DateTimeOffset.UtcNow, CancellationToken.None));
    }

    [Fact]
    public void ScopeSerializer_RoundTripsAndSortsOrdinally()
    {
        var unsorted = new HashSet<string>(["zeta", "alpha", "mike"], StringComparer.Ordinal);
        var differentOrder = new HashSet<string>(["mike", "zeta", "alpha"], StringComparer.Ordinal);

        string a = ScopeSerializer.Serialize(unsorted);
        string b = ScopeSerializer.Serialize(differentOrder);

        // Equal sets must produce identical column text regardless of insertion order.
        Assert.Equal(a, b);
        Assert.Equal("""["alpha","mike","zeta"]""", a);

        IReadOnlySet<string> roundTripped = ScopeSerializer.Deserialize(a);
        Assert.True(roundTripped.SetEquals(unsorted));
    }

    [Fact]
    public void ScopeSerializer_DeserializeNullOrEmpty_ReturnsEmptySet()
    {
        Assert.Empty(ScopeSerializer.Deserialize(null));
        Assert.Empty(ScopeSerializer.Deserialize(""));
    }

    // --- Auth-003: corrupt scopes JSON must fail closed (empty set), never throw JsonException ---

    [Theory]
    [InlineData("not json at all")]
    [InlineData("{")]
    [InlineData("{\"a\":1}")]   // valid JSON, but an object, not a string[]
    [InlineData("42")]          // valid JSON, but a number
    [InlineData("[\"read\",")]  // truncated/partial write
    public void ScopeSerializer_DeserializeMalformed_ReturnsEmptySet_DoesNotThrow(string value)
    {
        // A poisoned scopes column (tampering, partial write, format change, buggy writer) must
        // degrade to a zero-scope set rather than throwing on the verification hot path.
        IReadOnlySet<string> scopes = ScopeSerializer.Deserialize(value);
        Assert.Empty(scopes);
    }

    [Fact]
    public async Task FindByKeyId_CorruptScopesColumn_ReturnsRecordWithEmptyScopes_DoesNotThrow()
    {
        // Insert a row whose scopes column holds malformed (non-array) JSON, then read it through
        // the store. The store must NOT propagate a JsonException out of FindByKeyIdAsync (which the
        // verifier relies on for its "only exception path is cancellation" contract).
        await InsertWithRawScopesAsync("key-corrupt", scopesJson: "{ this is not valid json");

        ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-corrupt", CancellationToken.None);

        Assert.NotNull(found);
        Assert.Empty(found!.Scopes);
    }

    private static ApiKeyRecord SampleRecord(string keyId) => new(
        KeyId: keyId,
        KeyPrefix: "mxgw_ab12",
        SecretHash: [1, 2, 3, 4, 5, 6, 7, 8],
        DisplayName: "Test Key " + keyId,
        Scopes: new HashSet<string>(["read", "write"], StringComparer.Ordinal),
        ConstraintsJson: """{"ipAllow":["10.0.0.0/8"]}""",
        CreatedUtc: new DateTimeOffset(2026, 5, 1, 8, 30, 0, TimeSpan.Zero),
        LastUsedUtc: null,
        RevokedUtc: null);

    private static void AssertRecordEqual(ApiKeyRecord expected, ApiKeyRecord actual)
    {
        Assert.Equal(expected.KeyId, actual.KeyId);
        Assert.Equal(expected.KeyPrefix, actual.KeyPrefix);
        Assert.Equal(expected.SecretHash, actual.SecretHash);
        Assert.Equal(expected.DisplayName, actual.DisplayName);
        Assert.True(expected.Scopes.SetEquals(actual.Scopes));
        Assert.Equal(expected.ConstraintsJson, actual.ConstraintsJson);
        Assert.Equal(expected.CreatedUtc, actual.CreatedUtc);
        Assert.Equal(expected.LastUsedUtc, actual.LastUsedUtc);
        Assert.Equal(expected.RevokedUtc, actual.RevokedUtc);
    }

    private async Task InsertAsync(ApiKeyRecord record)
    {
        await using SqliteConnection connection =
            await _factory.OpenConnectionAsync(CancellationToken.None);
        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = """
            INSERT INTO api_keys (
                key_id, key_prefix, secret_hash, display_name, scopes,
                constraints, created_utc, last_used_utc, revoked_utc)
            VALUES (
                $key_id, $key_prefix, $secret_hash, $display_name, $scopes,
                $constraints, $created_utc, $last_used_utc, $revoked_utc);
            """;
        command.Parameters.AddWithValue("$key_id", record.KeyId);
        command.Parameters.AddWithValue("$key_prefix", record.KeyPrefix);
        command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = record.SecretHash;
        command.Parameters.AddWithValue("$display_name", record.DisplayName);
        command.Parameters.AddWithValue("$scopes", ScopeSerializer.Serialize(record.Scopes));
        command.Parameters.AddWithValue("$constraints", (object?)record.ConstraintsJson ?? DBNull.Value);
        command.Parameters.AddWithValue("$created_utc", record.CreatedUtc.ToString("O"));
        command.Parameters.AddWithValue("$last_used_utc", (object?)record.LastUsedUtc?.ToString("O") ?? DBNull.Value);
        command.Parameters.AddWithValue("$revoked_utc", (object?)record.RevokedUtc?.ToString("O") ?? DBNull.Value);
        await command.ExecuteNonQueryAsync(CancellationToken.None);
    }

    private async Task InsertWithRawScopesAsync(string keyId, string scopesJson)
    {
        // Writes the scopes column verbatim (NOT via ScopeSerializer.Serialize) so a malformed
        // value can be persisted to simulate tampering / a partial or buggy write.
        await using SqliteConnection connection =
            await _factory.OpenConnectionAsync(CancellationToken.None);
        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = """
            INSERT INTO api_keys (
                key_id, key_prefix, secret_hash, display_name, scopes,
                constraints, created_utc, last_used_utc, revoked_utc)
            VALUES (
                $key_id, $key_prefix, $secret_hash, $display_name, $scopes,
                $constraints, $created_utc, $last_used_utc, $revoked_utc);
            """;
        command.Parameters.AddWithValue("$key_id", keyId);
        command.Parameters.AddWithValue("$key_prefix", "mxgw");
        command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = new byte[] { 1, 2, 3 };
        command.Parameters.AddWithValue("$display_name", "Corrupt Key");
        command.Parameters.AddWithValue("$scopes", scopesJson);
        command.Parameters.AddWithValue("$constraints", DBNull.Value);
        command.Parameters.AddWithValue("$created_utc", DateTimeOffset.UnixEpoch.ToString("O"));
        command.Parameters.AddWithValue("$last_used_utc", DBNull.Value);
        command.Parameters.AddWithValue("$revoked_utc", DBNull.Value);
        await command.ExecuteNonQueryAsync(CancellationToken.None);
    }

    public Task DisposeAsync()
    {
        SqliteConnection.ClearAllPools();
        TryDelete(_dbPath);
        TryDelete(_dbPath + "-wal");
        TryDelete(_dbPath + "-shm");
        return Task.CompletedTask;
    }

    private static void TryDelete(string path)
    {
        try
        {
            if (File.Exists(path))
            {
                File.Delete(path);
            }
        }
        catch (IOException)
        {
            // Best-effort cleanup of the per-test temp database.
        }
    }
}
