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

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

public sealed class ApiKeyAdminCommandsTests : IAsyncLifetime
{
    private const string Pepper = "test-pepper-value";

    private readonly string _dbPath =
        Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db");

    private AuthSqliteConnectionFactory _factory = null!;
    private SqliteAuthStoreMigrator _migrator = null!;
    private SqliteApiKeyAdminStore _admin = null!;
    private SqliteApiKeyStore _read = null!;
    private SqliteApiKeyAuditStore _audit = null!;
    private ApiKeyOptions _options = null!;

    public Task InitializeAsync()
    {
        _factory = new AuthSqliteConnectionFactory(_dbPath);
        _migrator = new SqliteAuthStoreMigrator(_factory);
        _admin = new SqliteApiKeyAdminStore(_factory);
        _read = new SqliteApiKeyStore(_factory);
        _audit = new SqliteApiKeyAuditStore(_factory);
        _options = new ApiKeyOptions { TokenPrefix = "mxgw", SqlitePath = _dbPath };
        return Task.CompletedTask;
    }

    private ApiKeyAdminCommands BuildCommands(string? pepper = Pepper) => new(
        _options,
        _admin,
        _audit,
        new FakePepperProvider(pepper),
        _migrator);

    // --- init-db ---

    [Fact]
    public async Task InitDb_CreatesTables_AndAppendsAudit()
    {
        ApiKeyAdminCommands commands = BuildCommands();

        await commands.InitDbAsync(remoteAddress: "10.0.0.1", CancellationToken.None);

        // Tables exist: a create after init must succeed.
        Assert.True(await TableExistsAsync("api_keys"));
        Assert.True(await TableExistsAsync("api_key_audit"));

        IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
        Assert.Single(recent, e => e.EventType == "init-db");
    }

    // --- create-key ---

    [Fact]
    public async Task CreateKey_ReturnsAssembledToken_KeyFindable_AndAuditAppended()
    {
        ApiKeyAdminCommands commands = BuildCommands();
        await commands.InitDbAsync(null, CancellationToken.None);

        CreateKeyResult result = await commands.CreateKeyAsync(
            "key-1",
            "Service A",
            new HashSet<string>(["read", "write"], StringComparer.Ordinal),
            constraintsJson: """{"ipAllow":["10.0.0.0/8"]}""",
            remoteAddress: "10.0.0.1",
            CancellationToken.None);

        Assert.Equal("key-1", result.KeyId);
        Assert.StartsWith("mxgw_key-1_", result.Token);

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

        // The returned token's secret matches what is stored (hash of parsed secret == stored hash).
        string secret = ParseSecret(result.Token);
        byte[] expected = ApiKeySecretHasher.Hash(secret, Pepper);
        Assert.True(found!.SecretHash.SequenceEqual(expected));

        // Exactly one create-key audit row.
        IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
        Assert.Single(recent, e => e.EventType == "create-key");
    }

    [Fact]
    public async Task CreateKey_WithExpiresUtc_PersistsExpiryOnRecord()
    {
        // archreview G-2: the expiry overload threads ExpiresUtc onto the stored record.
        ApiKeyAdminCommands commands = BuildCommands();
        await commands.InitDbAsync(null, CancellationToken.None);
        var expiry = new DateTimeOffset(2027, 1, 1, 0, 0, 0, TimeSpan.Zero);

        await commands.CreateKeyAsync(
            "key-exp",
            "Service Exp",
            new HashSet<string>(["read"], StringComparer.Ordinal),
            constraintsJson: null,
            expiresUtc: expiry,
            remoteAddress: null,
            CancellationToken.None);

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

    [Fact]
    public async Task CreateKey_ExpiryLessOverload_PersistsNullExpiry()
    {
        ApiKeyAdminCommands commands = BuildCommands();
        await commands.InitDbAsync(null, CancellationToken.None);

        await commands.CreateKeyAsync(
            "key-1",
            "Service A",
            new HashSet<string>(["read"], StringComparer.Ordinal),
            constraintsJson: null,
            remoteAddress: null,
            CancellationToken.None);

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

    [Fact]
    public async Task CreateKey_PersistsBareTokenPrefix_NotPrefixUnderscoreKeyId()
    {
        // Auth-005: KeyPrefix is the bare token prefix ("mxgw"), NOT "mxgw_key-1". The key id is
        // already its own column; embedding it produced a self-referential value that disagreed with
        // the read/test paths and confused admin tooling.
        ApiKeyAdminCommands commands = BuildCommands();
        await commands.InitDbAsync(null, CancellationToken.None);

        await commands.CreateKeyAsync(
            "key-1",
            "Service A",
            new HashSet<string>(["read"], StringComparer.Ordinal),
            constraintsJson: null,
            remoteAddress: null,
            CancellationToken.None);

        ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
        Assert.NotNull(found);
        Assert.Equal("mxgw", found!.KeyPrefix);

        // The same bare prefix is surfaced by the admin list projection.
        IReadOnlyList<ApiKeyListItem> listed = await commands.ListKeysAsync(CancellationToken.None);
        ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
        Assert.Equal("mxgw", item.KeyPrefix);
    }

    [Fact]
    public async Task CreateKey_PepperUnavailable_ReturnsNoTokenAndAppendsNoAudit()
    {
        ApiKeyAdminCommands commands = BuildCommands(pepper: null);
        await new ApiKeyAdminCommands(_options, _admin, _audit, new FakePepperProvider(Pepper), _migrator)
            .InitDbAsync(null, CancellationToken.None);

        int auditCountBefore = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count;

        await Assert.ThrowsAsync<InvalidOperationException>(() => commands.CreateKeyAsync(
            "key-x",
            "No Pepper",
            new HashSet<string>(StringComparer.Ordinal),
            constraintsJson: null,
            remoteAddress: null,
            CancellationToken.None));

        // No key created, no audit appended.
        Assert.Null(await _read.FindByKeyIdAsync("key-x", CancellationToken.None));
        int auditCountAfter = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count;
        Assert.Equal(auditCountBefore, auditCountAfter);
    }

    [Fact]
    public async Task CreateKey_KeyIdContainsUnderscore_ThrowsArgumentException()
    {
        ApiKeyAdminCommands commands = BuildCommands();
        await commands.InitDbAsync(null, CancellationToken.None);

        await Assert.ThrowsAsync<ArgumentException>(() => commands.CreateKeyAsync(
            "a_b",
            "Service A",
            new HashSet<string>(StringComparer.Ordinal),
            constraintsJson: null,
            remoteAddress: null,
            CancellationToken.None));
    }

    // --- list-keys ---

    [Fact]
    public async Task ListKeys_ReturnsCreatedKey_WithoutSecretMaterial()
    {
        ApiKeyAdminCommands commands = BuildCommands();
        await commands.InitDbAsync(null, CancellationToken.None);
        await commands.CreateKeyAsync(
            "key-1",
            "Service A",
            new HashSet<string>(["read"], StringComparer.Ordinal),
            constraintsJson: null,
            remoteAddress: null,
            CancellationToken.None);

        IReadOnlyList<ApiKeyListItem> keys = await commands.ListKeysAsync(CancellationToken.None);

        ApiKeyListItem item = Assert.Single(keys, k => k.KeyId == "key-1");
        Assert.Equal("Service A", item.DisplayName);
        Assert.Contains("read", item.Scopes);
        Assert.Null(item.RevokedUtc);

        // ApiKeyListItem has NO secret/hash member by construction (compile-time guarantee).
        Assert.DoesNotContain(
            typeof(ApiKeyListItem).GetProperties(),
            p => p.Name.Contains("Hash", StringComparison.OrdinalIgnoreCase)
                 || p.Name.Contains("Secret", StringComparison.OrdinalIgnoreCase));
    }

    // --- revoke-key ---

    [Fact]
    public async Task RevokeKey_DeactivatesKey_AndAppendsAudit()
    {
        ApiKeyAdminCommands commands = BuildCommands();
        await commands.InitDbAsync(null, CancellationToken.None);
        await commands.CreateKeyAsync(
            "key-1",
            "Service A",
            new HashSet<string>(["read"], StringComparer.Ordinal),
            null,
            null,
            CancellationToken.None);

        KeyActionResult result = await commands.RevokeKeyAsync("key-1", "10.0.0.1", CancellationToken.None);

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

        IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
        Assert.Single(recent, e => e.EventType == "revoke-key");
    }

    // --- rotate-key ---

    [Fact]
    public async Task RotateKey_ReturnsNewToken_OldSecretFails_NewSecretWorks_AndAuditAppended()
    {
        ApiKeyAdminCommands commands = BuildCommands();
        await commands.InitDbAsync(null, CancellationToken.None);
        CreateKeyResult created = await commands.CreateKeyAsync(
            "key-1",
            "Service A",
            new HashSet<string>(["read"], StringComparer.Ordinal),
            null,
            null,
            CancellationToken.None);
        string oldSecret = ParseSecret(created.Token);

        CreateKeyResult rotated = await commands.RotateKeyAsync("key-1", "10.0.0.1", CancellationToken.None);

        Assert.Equal("key-1", rotated.KeyId);
        Assert.NotEqual(created.Token, rotated.Token);

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

        // Old secret no longer verifies; new one does.
        Assert.False(ApiKeySecretHasher.Verify(oldSecret, Pepper, found!.SecretHash));
        Assert.True(ApiKeySecretHasher.Verify(ParseSecret(rotated.Token), Pepper, found.SecretHash));

        IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
        Assert.Single(recent, e => e.EventType == "rotate-key");
    }

    [Fact]
    public async Task RotateKey_UnknownKey_ReturnsFailureResult_AndAppendsAudit()
    {
        ApiKeyAdminCommands commands = BuildCommands();
        await commands.InitDbAsync(null, CancellationToken.None);

        int auditCountBefore = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count;

        CreateKeyResult result = await commands.RotateKeyAsync("missing", null, CancellationToken.None);

        Assert.Null(result.Token);

        // Auditing failed/not-found attempts is INTENTIONAL (security trail): exactly one rotate-key row.
        IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
        int newAuditRows = recent.Count - auditCountBefore;
        Assert.Equal(1, newAuditRows);
        ApiKeyAuditEntry auditRow = recent.First(e => e.EventType == "rotate-key");
        Assert.Equal("not-found", auditRow.Details);
    }

    [Fact]
    public async Task RotateKey_PepperUnavailable_Throws_HashUnchanged_AndAppendsNoAudit()
    {
        // Arrange: create a key with a valid pepper.
        ApiKeyAdminCommands setupCommands = BuildCommands(pepper: Pepper);
        await setupCommands.InitDbAsync(null, CancellationToken.None);
        await setupCommands.CreateKeyAsync(
            "key-1",
            "Service A",
            new HashSet<string>(["read"], StringComparer.Ordinal),
            constraintsJson: null,
            remoteAddress: null,
            CancellationToken.None);

        ApiKeyRecord? before = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
        Assert.NotNull(before);
        byte[] hashBefore = before!.SecretHash;

        int auditCountBefore = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count;

        // Act: rotate with no pepper available.
        ApiKeyAdminCommands nopepper = BuildCommands(pepper: null);
        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            nopepper.RotateKeyAsync("key-1", null, CancellationToken.None));

        // Assert: stored hash is unchanged.
        ApiKeyRecord? after = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
        Assert.NotNull(after);
        Assert.True(after!.SecretHash.SequenceEqual(hashBefore));

        // Assert: no rotate-key audit row was appended (RequirePepper fires before any store/audit write).
        int auditCountAfter = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count;
        Assert.Equal(auditCountBefore, auditCountAfter);
    }

    // --- set-scopes / enable-disable ---

    [Fact]
    public async Task SetEnabledAsync_And_SetScopesAsync_AppendAuditEntries()
    {
        ApiKeyAdminCommands commands = BuildCommands();
        await commands.InitDbAsync(null, CancellationToken.None);
        await commands.CreateKeyAsync(
            "key-1",
            "Service A",
            new HashSet<string>(["read"], StringComparer.Ordinal),
            null,
            null,
            CancellationToken.None);

        // Disable, then re-enable, then replace scopes.
        KeyActionResult disabled =
            await commands.SetEnabledAsync("key-1", enabled: false, "10.0.0.1", CancellationToken.None);
        Assert.True(disabled.Succeeded);
        Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));

        KeyActionResult enabled =
            await commands.SetEnabledAsync("key-1", enabled: true, "10.0.0.1", CancellationToken.None);
        Assert.True(enabled.Succeeded);
        Assert.NotNull(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));

        KeyActionResult scoped = await commands.SetScopesAsync(
            "key-1",
            new HashSet<string>(["read", "write"], StringComparer.Ordinal),
            "10.0.0.1",
            CancellationToken.None);
        Assert.True(scoped.Succeeded);

        IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
        Assert.Single(recent, e => e.EventType == "disable-key");
        Assert.Single(recent, e => e.EventType == "enable-key");
        Assert.Single(recent, e => e.EventType == "set-scopes");

        IReadOnlyList<ApiKeyListItem> listed = await commands.ListKeysAsync(CancellationToken.None);
        ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
        Assert.True(item.Scopes.SetEquals(new HashSet<string>(["read", "write"], StringComparer.Ordinal)));
    }

    [Fact]
    public async Task SetScopesAsync_NullScopes_Throws()
    {
        ApiKeyAdminCommands commands = BuildCommands();
        await commands.InitDbAsync(null, CancellationToken.None);

        await Assert.ThrowsAnyAsync<ArgumentException>(() =>
            commands.SetScopesAsync("key-1", null!, null, CancellationToken.None));
    }

    // --- delete-key ---

    [Fact]
    public async Task DeleteKey_OnlyWorksAfterRevoke_AndAppendsAudit()
    {
        ApiKeyAdminCommands commands = BuildCommands();
        await commands.InitDbAsync(null, CancellationToken.None);
        await commands.CreateKeyAsync(
            "key-1",
            "Service A",
            new HashSet<string>(["read"], StringComparer.Ordinal),
            null,
            null,
            CancellationToken.None);

        // Delete before revoke fails.
        KeyActionResult beforeRevoke = await commands.DeleteKeyAsync("key-1", null, CancellationToken.None);
        Assert.False(beforeRevoke.Succeeded);
        Assert.NotNull(await _read.FindByKeyIdAsync("key-1", CancellationToken.None));

        await commands.RevokeKeyAsync("key-1", null, CancellationToken.None);

        KeyActionResult afterRevoke = await commands.DeleteKeyAsync("key-1", null, CancellationToken.None);
        Assert.True(afterRevoke.Succeeded);
        Assert.Null(await _read.FindByKeyIdAsync("key-1", CancellationToken.None));

        // Two delete-key audit rows (one failed attempt, one success) — each verb audits exactly once per call.
        IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
        Assert.Equal(2, recent.Count(e => e.EventType == "delete-key"));
    }

    // --- helpers ---

    private static string ParseSecret(string? token)
    {
        // token = "<prefix>_<keyId>_<secret>"; secret may contain underscores.
        Assert.NotNull(token);
        string[] parts = token!.Split('_', 3);
        return parts[2];
    }

    private async Task<bool> TableExistsAsync(string tableName)
    {
        await using SqliteConnection connection =
            await _factory.OpenConnectionAsync(CancellationToken.None);
        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText =
            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=$name;";
        command.Parameters.AddWithValue("$name", tableName);
        long count = (long)(await command.ExecuteScalarAsync(CancellationToken.None) ?? 0L);
        return count > 0;
    }

    private sealed class FakePepperProvider(string? pepper) : IApiKeyPepperProvider
    {
        public string? GetPepper() => pepper;
    }

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