using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys;

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

public class ApiKeyVerifierTests
{
    private const string TokenPrefix = "mxgw";
    private const string Pepper = "test-pepper";
    private const string KeyId = "abc123";
    private const string Secret = "supersecretvalue";
    private const string DisplayName = "Test Key";
    private const string ConstraintsJson = """{"ipAllow":["10.0.0.0/8"]}""";

    private static readonly IReadOnlySet<string> Scopes =
        new HashSet<string> { "read", "write" };

    private static string Header(string keyId, string secret) =>
        $"{TokenPrefix}_{keyId}_{secret}";

    private static ApiKeyRecord BuildRecord(
        byte[] secretHash,
        DateTimeOffset? revokedUtc = null,
        DateTimeOffset? expiresUtc = null) => new(
        KeyId: KeyId,
        KeyPrefix: TokenPrefix,
        SecretHash: secretHash,
        DisplayName: DisplayName,
        Scopes: Scopes,
        ConstraintsJson: ConstraintsJson,
        CreatedUtc: DateTimeOffset.UnixEpoch,
        LastUsedUtc: null,
        RevokedUtc: revokedUtc,
        ExpiresUtc: expiresUtc);

    private static ApiKeyVerifier BuildVerifier(
        FakeApiKeyStore store,
        FakePepperProvider pepperProvider) =>
        new(new ApiKeyOptions { TokenPrefix = TokenPrefix }, store, pepperProvider);

    // --- MissingOrMalformed ---

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("garbage")]
    [InlineData("wrongprefix_abc123_secret")]
    public async Task VerifyAsync_MissingOrMalformedHeader_ReturnsMissingOrMalformed(string? header)
    {
        var store = new FakeApiKeyStore();
        var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));

        ApiKeyVerification result = await verifier.VerifyAsync(header!, CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Equal(ApiKeyFailure.MissingOrMalformed, result.Failure);
        Assert.Null(result.Identity);
        Assert.False(store.MarkUsedCalled);
    }

    // --- PepperUnavailable ---

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    public async Task VerifyAsync_PepperUnavailable_ReturnsPepperUnavailable(string? pepper)
    {
        var store = new FakeApiKeyStore();
        var verifier = BuildVerifier(store, new FakePepperProvider(pepper));

        ApiKeyVerification result =
            await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Equal(ApiKeyFailure.PepperUnavailable, result.Failure);
        Assert.Null(result.Identity);
        Assert.False(store.MarkUsedCalled);
    }

    [Fact]
    public async Task VerifyAsync_PepperUnavailable_DoesNotQueryStore()
    {
        var store = new FakeApiKeyStore();
        var verifier = BuildVerifier(store, new FakePepperProvider(null));

        await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);

        Assert.False(store.FindByKeyIdCalled);
    }

    // --- KeyNotFound ---

    [Fact]
    public async Task VerifyAsync_KeyNotFound_ReturnsKeyNotFound()
    {
        var store = new FakeApiKeyStore { Record = null };
        var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));

        ApiKeyVerification result =
            await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Equal(ApiKeyFailure.KeyNotFound, result.Failure);
        Assert.Null(result.Identity);
        Assert.False(store.MarkUsedCalled);
    }

    // --- KeyRevoked ---

    [Fact]
    public async Task VerifyAsync_RevokedKey_ReturnsKeyRevoked()
    {
        byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
        var store = new FakeApiKeyStore
        {
            Record = BuildRecord(hash, revokedUtc: DateTimeOffset.UtcNow),
        };
        var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));

        ApiKeyVerification result =
            await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Equal(ApiKeyFailure.KeyRevoked, result.Failure);
        Assert.Null(result.Identity);
        Assert.False(store.MarkUsedCalled);
    }

    // --- KeyExpired (archreview G-2) ---

    private static readonly DateTimeOffset Now = new(2026, 6, 1, 12, 0, 0, TimeSpan.Zero);

    private static ApiKeyVerifier BuildVerifierAt(
        FakeApiKeyStore store, DateTimeOffset now) =>
        new(new ApiKeyOptions { TokenPrefix = TokenPrefix },
            store,
            new FakePepperProvider(Pepper),
            new FakeTimeProvider(now));

    [Fact]
    public async Task VerifyAsync_ExpiredKey_ReturnsKeyExpired()
    {
        byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
        var store = new FakeApiKeyStore
        {
            Record = BuildRecord(hash, expiresUtc: Now - TimeSpan.FromSeconds(1)),
        };
        var verifier = BuildVerifierAt(store, Now);

        ApiKeyVerification result =
            await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Equal(ApiKeyFailure.KeyExpired, result.Failure);
        Assert.Null(result.Identity);
        // Expiry is decided before the secret comparison and usage write.
        Assert.False(store.MarkUsedCalled);
    }

    [Fact]
    public async Task VerifyAsync_ExpiryExactlyNow_IsExpired_Inclusive()
    {
        byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
        var store = new FakeApiKeyStore { Record = BuildRecord(hash, expiresUtc: Now) };
        var verifier = BuildVerifierAt(store, Now);

        ApiKeyVerification result =
            await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Equal(ApiKeyFailure.KeyExpired, result.Failure);
    }

    [Fact]
    public async Task VerifyAsync_NotYetExpiredKey_Succeeds()
    {
        byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
        var store = new FakeApiKeyStore
        {
            Record = BuildRecord(hash, expiresUtc: Now + TimeSpan.FromSeconds(1)),
        };
        var verifier = BuildVerifierAt(store, Now);

        ApiKeyVerification result =
            await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);

        Assert.True(result.Succeeded);
        Assert.Null(result.Failure);
        Assert.True(store.MarkUsedCalled);
    }

    [Fact]
    public async Task VerifyAsync_NullExpiry_NeverExpires()
    {
        byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
        var store = new FakeApiKeyStore { Record = BuildRecord(hash, expiresUtc: null) };
        // A clock far in the future must still accept a never-expiring key.
        var verifier = BuildVerifierAt(store, Now + TimeSpan.FromDays(3650));

        ApiKeyVerification result =
            await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);

        Assert.True(result.Succeeded);
    }

    [Fact]
    public async Task VerifyAsync_RevokedTakesPrecedenceOverExpiry()
    {
        // A key that is both revoked and expired reports KeyRevoked (the earlier check).
        byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
        var store = new FakeApiKeyStore
        {
            Record = BuildRecord(hash, revokedUtc: Now - TimeSpan.FromDays(1), expiresUtc: Now - TimeSpan.FromDays(1)),
        };
        var verifier = BuildVerifierAt(store, Now);

        ApiKeyVerification result =
            await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Equal(ApiKeyFailure.KeyRevoked, result.Failure);
    }

    // --- SecretMismatch ---

    [Fact]
    public async Task VerifyAsync_WrongSecret_ReturnsSecretMismatch()
    {
        // Record's hash is built from a DIFFERENT secret with the test pepper.
        byte[] hash = ApiKeySecretHasher.Hash("a-different-secret", Pepper);
        var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
        var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));

        ApiKeyVerification result =
            await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Equal(ApiKeyFailure.SecretMismatch, result.Failure);
        Assert.Null(result.Identity);
        Assert.False(store.MarkUsedCalled);
    }

    // --- Success ---

    [Fact]
    public async Task VerifyAsync_ValidKey_ReturnsSuccessWithIdentity()
    {
        byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
        var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
        var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));

        ApiKeyVerification result =
            await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);

        Assert.True(result.Succeeded);
        Assert.Null(result.Failure);
        Assert.NotNull(result.Identity);
        Assert.Equal(KeyId, result.Identity!.KeyId);
        Assert.Equal(DisplayName, result.Identity.DisplayName);
        Assert.Equal(Scopes, result.Identity.Scopes);
        Assert.Equal(ConstraintsJson, result.Identity.Constraints);
    }

    [Fact]
    public async Task VerifyAsync_ValidKey_MarksKeyUsed()
    {
        byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
        var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
        var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));

        await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);

        Assert.True(store.MarkUsedCalled);
        Assert.Equal(KeyId, store.MarkUsedKeyId);
    }

    [Fact]
    public async Task VerifyAsync_ValidKey_UsesInjectedTimeProviderForMarkUsed()
    {
        byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
        var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
        var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero));
        var verifier = new ApiKeyVerifier(
            new ApiKeyOptions { TokenPrefix = TokenPrefix },
            store,
            new FakePepperProvider(Pepper),
            fakeTime);

        await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);

        Assert.Equal(fakeTime.Now, store.MarkUsedWhenUtc);
    }

    [Fact]
    public async Task VerifyAsync_ValidKey_DoesNotLeakSecretInIdentity()
    {
        byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
        var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
        var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));

        ApiKeyVerification result =
            await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);

        string identityText = result.Identity!.ToString();
        Assert.DoesNotContain(Secret, identityText, StringComparison.Ordinal);
        Assert.DoesNotContain(Pepper, identityText, StringComparison.Ordinal);
        Assert.DoesNotContain(Convert.ToBase64String(hash), identityText, StringComparison.Ordinal);
    }

    // --- Auth-002: a failed best-effort MarkUsedAsync must NOT fail a valid key ---

    [Fact]
    public async Task VerifyAsync_ValidKey_MarkUsedThrows_StillSucceeds()
    {
        // MarkUsedAsync is best-effort "last used" bookkeeping. A transient storage failure
        // (SQLITE_BUSY, disk full, locked DB) must not turn an otherwise-valid credential into a
        // failed auth: the decision is already made before the usage write. The verifier's contract
        // is "the only exception path is cancellation", so a non-cancellation MarkUsedAsync failure
        // is swallowed and the result is still Succeeded == true.
        byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
        var store = new FakeApiKeyStore
        {
            Record = BuildRecord(hash),
            MarkUsedException = new InvalidOperationException("SQLITE_BUSY"),
        };
        var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));

        ApiKeyVerification result =
            await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);

        Assert.True(result.Succeeded);
        Assert.Null(result.Failure);
        Assert.NotNull(result.Identity);
        Assert.Equal(KeyId, result.Identity!.KeyId);
        Assert.True(store.MarkUsedCalled);
    }

    [Fact]
    public async Task VerifyAsync_MarkUsedThrowsOperationCanceled_Propagates()
    {
        // The ONLY exception path is cancellation: an OperationCanceledException from the usage
        // write (e.g. the request was cancelled mid-write) is honoured and re-thrown, not swallowed.
        byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
        var store = new FakeApiKeyStore
        {
            Record = BuildRecord(hash),
            MarkUsedException = new OperationCanceledException(),
        };
        var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));

        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None));
    }

    // --- Cancellation ---

    [Fact]
    public async Task VerifyAsync_AlreadyCancelled_Throws()
    {
        var store = new FakeApiKeyStore();
        var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
        using var cts = new CancellationTokenSource();
        cts.Cancel();

        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => verifier.VerifyAsync(Header(KeyId, Secret), cts.Token));

        Assert.False(store.MarkUsedCalled);
    }

    // --- Bearer scheme acceptance (sanity) ---

    [Fact]
    public async Task VerifyAsync_BearerPrefixedValidKey_Succeeds()
    {
        byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
        var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
        var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));

        ApiKeyVerification result =
            await verifier.VerifyAsync($"Bearer {Header(KeyId, Secret)}", CancellationToken.None);

        Assert.True(result.Succeeded);
    }

    // --- Fakes ---

    private sealed class FakeApiKeyStore : IApiKeyStore
    {
        public ApiKeyRecord? Record { get; set; }
        public bool FindByKeyIdCalled { get; private set; }
        public bool MarkUsedCalled { get; private set; }
        public string? MarkUsedKeyId { get; private set; }
        public DateTimeOffset? MarkUsedWhenUtc { get; private set; }

        /// <summary>When set, <see cref="MarkUsedAsync"/> throws this exception (after recording the call).</summary>
        public Exception? MarkUsedException { get; set; }

        public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
        {
            FindByKeyIdCalled = true;
            return Task.FromResult(Record);
        }

        public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct) =>
            throw new NotSupportedException("Verifier must use FindByKeyIdAsync to discriminate revoked keys.");

        public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
        {
            MarkUsedCalled = true;
            MarkUsedKeyId = keyId;
            MarkUsedWhenUtc = whenUtc;
            if (MarkUsedException is not null)
            {
                return Task.FromException(MarkUsedException);
            }

            return Task.CompletedTask;
        }
    }

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

    private sealed class FakeTimeProvider(DateTimeOffset now) : TimeProvider
    {
        public DateTimeOffset Now { get; } = now;

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