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;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security;
using ZB.MOM.WW.ScadaBridge.Security;

namespace ZB.MOM.WW.ScadaBridge.Security.Tests;

/// <summary>
/// Inbound-API key re-arch (C1): unit tests for <see cref="LibraryInboundApiKeyAdmin"/>, the
/// app-side management seam that maps the Commons <see cref="IInboundApiKeyAdmin"/> contract
/// onto the shared <c>ZB.MOM.WW.Auth.ApiKeys</c> admin facade (<see cref="ApiKeyAdminCommands"/>).
/// These run the REAL library against a per-test temp SQLite database (mirroring the library's
/// own <c>ApiKeyAdminCommandsTests</c>), so the mapping — token assembly, enabled flag from
/// <c>RevokedUtc</c>, scope ↔ method translation, revoke-then-delete — is verified end-to-end.
/// Tokens are only ever minted via <see cref="IInboundApiKeyAdmin.CreateAsync"/>.
/// </summary>
public sealed class LibraryInboundApiKeyAdminTests : IAsyncLifetime
{
    private const string Pepper = "test-pepper-at-least-16-chars-long";
    private const string TokenPrefix = "sbk";

    private readonly string _dbPath =
        Path.Combine(Path.GetTempPath(), $"inbound-key-admin-{Guid.NewGuid():N}.sqlite");

    private AuthSqliteConnectionFactory _factory = null!;
    private SqliteAuthStoreMigrator _migrator = null!;
    private SqliteApiKeyAdminStore _adminStore = null!;
    private SqliteApiKeyAuditStore _auditStore = null!;
    private ApiKeyOptions _options = null!;
    private IInboundApiKeyAdmin _sut = null!;

    public async Task InitializeAsync()
    {
        _factory = new AuthSqliteConnectionFactory(_dbPath);
        _migrator = new SqliteAuthStoreMigrator(_factory);
        _adminStore = new SqliteApiKeyAdminStore(_factory);
        _auditStore = new SqliteApiKeyAuditStore(_factory);
        _options = new ApiKeyOptions { TokenPrefix = TokenPrefix, SqlitePath = _dbPath };

        // Create the schema once up front so every command runs against a ready store.
        await _migrator.MigrateAsync(CancellationToken.None);

        var commands = new ApiKeyAdminCommands(
            _options,
            _adminStore,
            _auditStore,
            new FakePepperProvider(Pepper),
            _migrator,
            new FixedTimeProvider(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero)));

        _sut = new LibraryInboundApiKeyAdmin(commands);
    }

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    public async Task CreateAsync_NullOrWhitespaceName_Throws(string? name)
    {
        // ThrowIfNullOrWhiteSpace throws ArgumentNullException for null and ArgumentException
        // for empty/whitespace; both are ArgumentException subtypes, so ThrowsAnyAsync covers all.
        await Assert.ThrowsAnyAsync<ArgumentException>(
            () => _sut.CreateAsync(name!, new[] { "MethodA" }, CancellationToken.None));
    }

    [Fact]
    public async Task CreateAsync_ReturnsKeyIdAndToken_TokenStartsWith_sbk()
    {
        InboundApiKeyCreated created = await _sut.CreateAsync(
            "Service A", new[] { "MethodA", "MethodB" }, CancellationToken.None);

        Assert.False(string.IsNullOrWhiteSpace(created.KeyId));
        Assert.StartsWith($"{TokenPrefix}_{created.KeyId}_", created.Token);

        // The created key then appears in ListAsync, enabled, with the given methods.
        IReadOnlyList<InboundApiKeyInfo> keys = await _sut.ListAsync(CancellationToken.None);
        InboundApiKeyInfo info = Assert.Single(keys, k => k.KeyId == created.KeyId);
        Assert.Equal("Service A", info.Name);
        Assert.True(info.Enabled);
        Assert.Equal(new[] { "MethodA", "MethodB" }, info.Methods);
    }

    [Fact]
    public async Task SetEnabledAsync_False_ThenTrue_Toggles_ListedEnabledFlag()
    {
        InboundApiKeyCreated created = await _sut.CreateAsync(
            "Toggle Key", new[] { "MethodA" }, CancellationToken.None);

        Assert.True((await SingleAsync(created.KeyId)).Enabled);

        Assert.True(await _sut.SetEnabledAsync(created.KeyId, enabled: false, CancellationToken.None));
        Assert.False((await SingleAsync(created.KeyId)).Enabled);

        Assert.True(await _sut.SetEnabledAsync(created.KeyId, enabled: true, CancellationToken.None));
        Assert.True((await SingleAsync(created.KeyId)).Enabled);
    }

    [Fact]
    public async Task SetEnabledAsync_UnknownKey_ReturnsFalse()
    {
        Assert.False(await _sut.SetEnabledAsync("does-not-exist", enabled: false, CancellationToken.None));
    }

    [Fact]
    public async Task SetMethodsAsync_ReplacesMethods()
    {
        InboundApiKeyCreated created = await _sut.CreateAsync(
            "Scope Key", new[] { "Old1", "Old2" }, CancellationToken.None);

        Assert.True(await _sut.SetMethodsAsync(
            created.KeyId, new[] { "New1", "New2", "New3" }, CancellationToken.None));

        // ListAsync reflects the new (ordinally-sorted) methods.
        InboundApiKeyInfo info = await SingleAsync(created.KeyId);
        Assert.Equal(new[] { "New1", "New2", "New3" }, info.Methods);

        // GetMethodsForKeyAsync returns them.
        IReadOnlyList<string> methods = await _sut.GetMethodsForKeyAsync(created.KeyId, CancellationToken.None);
        Assert.Equal(new[] { "New1", "New2", "New3" }, methods);

        // GetKeysForMethodAsync finds the key for an in-scope method, not for an out-of-scope one.
        IReadOnlyList<string> inScope = await _sut.GetKeysForMethodAsync("New2", CancellationToken.None);
        Assert.Contains(created.KeyId, inScope);

        IReadOnlyList<string> outOfScope = await _sut.GetKeysForMethodAsync("Old1", CancellationToken.None);
        Assert.DoesNotContain(created.KeyId, outOfScope);
    }

    [Fact]
    public async Task DeleteAsync_RemovesKey()
    {
        InboundApiKeyCreated created = await _sut.CreateAsync(
            "Delete Key", new[] { "MethodA" }, CancellationToken.None);

        // Sanity: present before delete.
        Assert.Contains(
            await _sut.ListAsync(CancellationToken.None),
            k => k.KeyId == created.KeyId);

        Assert.True(await _sut.DeleteAsync(created.KeyId, CancellationToken.None));

        // After delete, ListAsync no longer contains it.
        Assert.DoesNotContain(
            await _sut.ListAsync(CancellationToken.None),
            k => k.KeyId == created.KeyId);
    }

    [Fact]
    public async Task GetKeysForMethodAsync_ReturnsOnlyKeysScopedToThatMethod()
    {
        InboundApiKeyCreated a = await _sut.CreateAsync(
            "Key A", new[] { "Shared", "OnlyA" }, CancellationToken.None);
        InboundApiKeyCreated b = await _sut.CreateAsync(
            "Key B", new[] { "Shared", "OnlyB" }, CancellationToken.None);
        InboundApiKeyCreated c = await _sut.CreateAsync(
            "Key C", new[] { "Unrelated" }, CancellationToken.None);

        IReadOnlyList<string> sharedKeys = await _sut.GetKeysForMethodAsync("Shared", CancellationToken.None);
        Assert.Equal(
            new[] { a.KeyId, b.KeyId }.OrderBy(x => x, StringComparer.Ordinal),
            sharedKeys.OrderBy(x => x, StringComparer.Ordinal));
        Assert.DoesNotContain(c.KeyId, sharedKeys);

        IReadOnlyList<string> onlyAKeys = await _sut.GetKeysForMethodAsync("OnlyA", CancellationToken.None);
        Assert.Equal(new[] { a.KeyId }, onlyAKeys);

        // A method nobody is scoped to yields no keys.
        Assert.Empty(await _sut.GetKeysForMethodAsync("Nobody", CancellationToken.None));
    }

    [Fact]
    public async Task GetMethodsForKeyAsync_UnknownKey_ReturnsEmpty()
    {
        Assert.Empty(await _sut.GetMethodsForKeyAsync("does-not-exist", CancellationToken.None));
    }

    private async Task<InboundApiKeyInfo> SingleAsync(string keyId)
    {
        IReadOnlyList<InboundApiKeyInfo> keys = await _sut.ListAsync(CancellationToken.None);
        return Assert.Single(keys, k => k.KeyId == keyId);
    }

    public Task DisposeAsync()
    {
        // SqliteConnection pooling can hold the file open; clear pools before deleting
        // so the temp DB (and its -wal/-shm sidecars) are removed.
        SqliteConnection.ClearAllPools();
        foreach (var suffix in new[] { "", "-wal", "-shm" })
        {
            var path = _dbPath + suffix;
            if (File.Exists(path))
            {
                File.Delete(path);
            }
        }

        return Task.CompletedTask;
    }

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

    private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
    {
        private readonly DateTimeOffset _now = now;

        public override DateTimeOffset GetUtcNow() => _now;
    }
}
