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 SqliteApiKeyAdminStoreTests : IAsyncLifetime
{
    private readonly string _dbPath =
        Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db");

    private AuthSqliteConnectionFactory _factory = null!;
    private SqliteApiKeyAdminStore _admin = null!;
    private SqliteApiKeyStore _read = null!;
    private SqliteApiKeyAuditStore _audit = null!;

    public async Task InitializeAsync()
    {
        _factory = new AuthSqliteConnectionFactory(_dbPath);
        await new SqliteAuthStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
        _admin = new SqliteApiKeyAdminStore(_factory);
        _read = new SqliteApiKeyStore(_factory);
        _audit = new SqliteApiKeyAuditStore(_factory);
    }

    // --- Create ---

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

        await _admin.CreateAsync(record, CancellationToken.None);

        ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
        Assert.NotNull(found);
        Assert.Equal(record.SecretHash, found!.SecretHash);
        Assert.True(record.Scopes.SetEquals(found.Scopes));
        Assert.Equal(record.ConstraintsJson, found.ConstraintsJson);
        Assert.Null(found.LastUsedUtc);
        Assert.Null(found.RevokedUtc);
    }

    [Fact]
    public async Task Create_WithExpiresUtc_RoundTripsThroughFindAndList()
    {
        var expiry = new DateTimeOffset(2026, 12, 31, 23, 59, 59, TimeSpan.Zero);
        ApiKeyRecord record = SampleRecord("key-exp") with { ExpiresUtc = expiry };

        await _admin.CreateAsync(record, CancellationToken.None);

        ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-exp", CancellationToken.None);
        Assert.Equal(expiry, found!.ExpiresUtc);

        IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
        ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-exp");
        Assert.Equal(expiry, item.ExpiresUtc);
    }

    [Fact]
    public async Task Create_WithoutExpiresUtc_ReadsBackAsNull()
    {
        await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);

        ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
        Assert.Null(found!.ExpiresUtc);

        IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
        Assert.Null(Assert.Single(listed, k => k.KeyId == "key-1").ExpiresUtc);
    }

    // --- Revoke ---

    [Fact]
    public async Task Revoke_ActiveKey_SetsRevokedAndFindActiveReturnsNull()
    {
        await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
        var when = new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero);

        bool result = await _admin.RevokeAsync("key-1", when, CancellationToken.None);

        Assert.True(result);
        Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
        ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
        Assert.Equal(when, found!.RevokedUtc);
    }

    [Fact]
    public async Task Revoke_UnknownKey_ReturnsFalse()
    {
        bool result = await _admin.RevokeAsync("missing", DateTimeOffset.UtcNow, CancellationToken.None);

        Assert.False(result);
    }

    [Fact]
    public async Task Revoke_AlreadyRevoked_ReturnsFalse()
    {
        await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
        await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);

        bool result = await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);

        Assert.False(result);
    }

    // --- Rotate ---

    [Fact]
    public async Task Rotate_ChangesHashAndReactivates()
    {
        await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
        await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
        await _read.MarkUsedAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
        byte[] newHash = [9, 9, 9, 9];

        bool result = await _admin.RotateAsync("key-1", newHash, CancellationToken.None);

        Assert.True(result);
        ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
        Assert.Equal(newHash, found!.SecretHash);
        Assert.Null(found.RevokedUtc);
        Assert.Null(found.LastUsedUtc);
        // Reactivated: now visible as active.
        Assert.NotNull(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
    }

    [Fact]
    public async Task Rotate_UnknownKey_ReturnsFalse()
    {
        bool result = await _admin.RotateAsync("missing", [1], CancellationToken.None);

        Assert.False(result);
    }

    // --- SetScopes ---

    [Fact]
    public async Task SetScopesAsync_ReplacesScopes_AndReturnsTrue()
    {
        await _admin.CreateAsync(
            SampleRecord("key-1") with { Scopes = new HashSet<string>(["a"], StringComparer.Ordinal) },
            CancellationToken.None);

        bool result = await _admin.SetScopesAsync(
            "key-1",
            new HashSet<string>(["b", "c"], StringComparer.Ordinal),
            CancellationToken.None);

        Assert.True(result);
        IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
        ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
        Assert.True(item.Scopes.SetEquals(new HashSet<string>(["b", "c"], StringComparer.Ordinal)));
    }

    [Fact]
    public async Task SetScopesAsync_UnknownKey_ReturnsFalse()
    {
        bool result = await _admin.SetScopesAsync(
            "missing",
            new HashSet<string>(["b"], StringComparer.Ordinal),
            CancellationToken.None);

        Assert.False(result);
    }

    // --- SetEnabled ---

    [Fact]
    public async Task SetEnabledAsync_False_DisablesKey()
    {
        await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
        var when = new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero);

        bool result = await _admin.SetEnabledAsync("key-1", enabled: false, when, CancellationToken.None);

        Assert.True(result);
        Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
        ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
        Assert.Equal(when, found!.RevokedUtc);
    }

    [Fact]
    public async Task SetEnabledAsync_True_ReenablesKey_WithoutChangingSecret()
    {
        ApiKeyRecord original = SampleRecord("key-1");
        await _admin.CreateAsync(original, CancellationToken.None);
        // Record some usage so we can prove last_used_utc is left untouched on re-enable.
        var used = new DateTimeOffset(2026, 5, 20, 12, 0, 0, TimeSpan.Zero);
        await _read.MarkUsedAsync("key-1", used, CancellationToken.None);

        // Disable, then re-enable.
        await _admin.SetEnabledAsync(
            "key-1", enabled: false, new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero), CancellationToken.None);
        bool result = await _admin.SetEnabledAsync(
            "key-1", enabled: true, new DateTimeOffset(2026, 6, 1, 9, 0, 0, TimeSpan.Zero), CancellationToken.None);

        Assert.True(result);

        // Active again, and the secret hash + last-used timestamp are unchanged.
        ApiKeyRecord? active = await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None);
        Assert.NotNull(active);
        Assert.True(active!.SecretHash.SequenceEqual(original.SecretHash));
        Assert.Null(active.RevokedUtc);
        Assert.Equal(used, active.LastUsedUtc);
    }

    [Fact]
    public async Task SetEnabledAsync_UnknownKey_ReturnsFalse()
    {
        bool result = await _admin.SetEnabledAsync(
            "missing", enabled: false, DateTimeOffset.UtcNow, CancellationToken.None);

        Assert.False(result);
    }

    // --- Delete ---

    [Fact]
    public async Task Delete_ActiveKey_ReturnsFalseAndKeyStillPresent()
    {
        await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);

        bool result = await _admin.DeleteAsync("key-1", CancellationToken.None);

        Assert.False(result);
        Assert.NotNull(await _read.FindByKeyIdAsync("key-1", CancellationToken.None));
    }

    [Fact]
    public async Task Delete_RevokedKey_ReturnsTrueAndKeyGone()
    {
        await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
        await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);

        bool result = await _admin.DeleteAsync("key-1", CancellationToken.None);

        Assert.True(result);
        Assert.Null(await _read.FindByKeyIdAsync("key-1", CancellationToken.None));
    }

    [Fact]
    public async Task Delete_UnknownKey_ReturnsFalse()
    {
        bool result = await _admin.DeleteAsync("missing", CancellationToken.None);

        Assert.False(result);
    }

    // --- keyId guard tests ---

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    public async Task Revoke_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>(
            () => _admin.RevokeAsync(keyId!, DateTimeOffset.UtcNow, CancellationToken.None));
    }

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    public async Task Rotate_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
    {
        await Assert.ThrowsAnyAsync<ArgumentException>(
            () => _admin.RotateAsync(keyId!, [1, 2, 3], CancellationToken.None));
    }

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

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    public async Task SetScopesAsync_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
    {
        await Assert.ThrowsAnyAsync<ArgumentException>(
            () => _admin.SetScopesAsync(
                keyId!,
                new HashSet<string>(["read"], StringComparer.Ordinal),
                CancellationToken.None));
    }

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    public async Task SetEnabledAsync_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
    {
        await Assert.ThrowsAnyAsync<ArgumentException>(
            () => _admin.SetEnabledAsync(keyId!, enabled: false, DateTimeOffset.UtcNow, CancellationToken.None));
    }

    [Fact]
    public async Task SetScopesAsync_NullScopes_ThrowsArgumentNullException()
    {
        await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);

        await Assert.ThrowsAsync<ArgumentNullException>(
            () => _admin.SetScopesAsync("key-1", null!, CancellationToken.None));
    }

    // --- SetEnabled idempotence ---

    [Fact]
    public async Task SetEnabledAsync_OnAlreadyActiveKey_ReturnsTrue()
    {
        await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);

        bool result = await _admin.SetEnabledAsync(
            "key-1", enabled: true, DateTimeOffset.UtcNow, CancellationToken.None);

        Assert.True(result);
        ApiKeyRecord? active = await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None);
        Assert.NotNull(active);
        Assert.Null(active!.RevokedUtc);
    }

    [Fact]
    public async Task SetEnabledAsync_OnAlreadyDisabledKey_OverwritesTimestamp_ReturnsTrue()
    {
        await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
        var t1 = new DateTimeOffset(2026, 5, 1, 10, 0, 0, TimeSpan.Zero);
        var t2 = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);

        // Disable at t1.
        await _admin.SetEnabledAsync("key-1", enabled: false, t1, CancellationToken.None);

        // Disable again at a later t2 (idempotent overwrite — no guard on revoked_utc).
        bool result = await _admin.SetEnabledAsync("key-1", enabled: false, t2, CancellationToken.None);

        Assert.True(result);
        IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
        ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
        Assert.Equal(t2, item.RevokedUtc);
    }

    // --- Audit ---

    [Fact]
    public async Task Audit_AppendThenListRecent_ReturnsEntry()
    {
        var entry = new ApiKeyAuditEntry(
            KeyId: "key-1",
            EventType: "created",
            RemoteAddress: "10.0.0.1",
            CreatedUtc: new DateTimeOffset(2026, 5, 31, 10, 0, 0, TimeSpan.Zero),
            Details: "by admin");

        await _audit.AppendAsync(entry, CancellationToken.None);

        IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(10, CancellationToken.None);
        Assert.Single(recent);
        Assert.Equal("key-1", recent[0].KeyId);
        Assert.Equal("created", recent[0].EventType);
        Assert.Equal("10.0.0.1", recent[0].RemoteAddress);
        Assert.Equal("by admin", recent[0].Details);
    }

    [Fact]
    public async Task Audit_ListRecent_ReturnsNewestFirst()
    {
        await _audit.AppendAsync(
            new ApiKeyAuditEntry("k", "first", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
        await _audit.AppendAsync(
            new ApiKeyAuditEntry("k", "second", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
        await _audit.AppendAsync(
            new ApiKeyAuditEntry("k", "third", null, DateTimeOffset.UtcNow, null), CancellationToken.None);

        IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(10, CancellationToken.None);

        Assert.Equal(["third", "second", "first"], recent.Select(e => e.EventType));
    }

    [Fact]
    public async Task Audit_ListRecent_RespectsLimit()
    {
        for (int i = 0; i < 5; i++)
        {
            await _audit.AppendAsync(
                new ApiKeyAuditEntry("k", $"e{i}", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
        }

        IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(2, CancellationToken.None);

        Assert.Equal(2, recent.Count);
        Assert.Equal(["e4", "e3"], recent.Select(e => e.EventType));
    }

    [Fact]
    public async Task Audit_NullableFields_RoundTripAsNull()
    {
        await _audit.AppendAsync(
            new ApiKeyAuditEntry(null, "anon", null, DateTimeOffset.UtcNow, null), CancellationToken.None);

        IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(10, CancellationToken.None);
        Assert.Single(recent);
        Assert.Null(recent[0].KeyId);
        Assert.Null(recent[0].RemoteAddress);
        Assert.Null(recent[0].Details);
    }

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

    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.
        }
    }
}
