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

namespace ZB.MOM.WW.Auth.ApiKeys;

/// <summary>
/// Verifies presented API-key credentials against the key store, returning a structured,
/// discriminated result. The pipeline is fail-closed: any inability to positively verify a
/// credential yields a failure rather than a success.
/// </summary>
/// <remarks>
/// The failure reason is discriminated for the caller/audit pipeline, but the verifier returns a
/// structured result rather than throwing (the caller decides the opaque client-facing message).
/// The only exception path is cancellation. A successful identity carries the key's scopes and the
/// opaque <c>ConstraintsJson</c> blob (which the verifier does not interpret); it never carries the
/// presented secret, the pepper, or the stored secret hash.
/// </remarks>
public sealed class ApiKeyVerifier(
    ApiKeyOptions options,
    IApiKeyStore store,
    IApiKeyPepperProvider pepperProvider,
    TimeProvider? timeProvider = null) : IApiKeyVerifier
{
    private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;

    /// <inheritdoc />
    public async Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
    {
        ct.ThrowIfCancellationRequested();

        // 1. Parse the header/token. Malformed or wrong-prefix credentials are indistinguishable
        //    from a missing credential and are reported uniformly.
        ParsedApiKey? parsed = ApiKeyParser.TryParse(authorizationHeader, options.TokenPrefix);
        if (parsed is null)
        {
            return Fail(ApiKeyFailure.MissingOrMalformed);
        }

        // 2. Resolve the pepper before touching the store. Without it, no verification is possible,
        //    so we fail closed (and avoid an unnecessary store lookup).
        string? pepper = pepperProvider.GetPepper();
        if (string.IsNullOrWhiteSpace(pepper))
        {
            return Fail(ApiKeyFailure.PepperUnavailable);
        }

        // 3. Look up the record (including revoked ones) so we can discriminate not-found vs revoked.
        ApiKeyRecord? record = await store.FindByKeyIdAsync(parsed.KeyId, ct).ConfigureAwait(false);
        if (record is null)
        {
            return Fail(ApiKeyFailure.KeyNotFound);
        }

        // 4. Reject revoked keys.
        if (record.RevokedUtc is not null)
        {
            return Fail(ApiKeyFailure.KeyRevoked);
        }

        // 4b. Reject expired keys. Expiry is absolute and inclusive: a key whose ExpiresUtc is at or
        //     before "now" is expired. A NULL ExpiresUtc never expires (the pre-expiry default), so
        //     upgrading a store leaves existing keys valid. Uses the injected clock for testability.
        if (record.ExpiresUtc is { } expiresUtc && expiresUtc <= _timeProvider.GetUtcNow())
        {
            return Fail(ApiKeyFailure.KeyExpired);
        }

        // 5. Constant-time secret comparison.
        if (!ApiKeySecretHasher.Verify(parsed.Secret, pepper, record.SecretHash))
        {
            return Fail(ApiKeyFailure.SecretMismatch);
        }

        // 6. The authentication decision is already made (line 60). Recording last-used is
        //    best-effort bookkeeping: a transient storage hiccup (SQLITE_BUSY past the busy-timeout,
        //    disk full, DB locked by a migration) must NOT turn an otherwise-valid credential into a
        //    failed auth. Swallow any non-cancellation failure so the only exception path remains
        //    cancellation, as the class contract promises. Cancellation is honoured (re-thrown).
        try
        {
            await store.MarkUsedAsync(record.KeyId, _timeProvider.GetUtcNow(), ct).ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch
        {
            // Best-effort: the last-used write failed, but the credential is valid. Fail open on the
            // bookkeeping (not the auth decision) rather than denying a legitimate caller.
        }

        return new ApiKeyVerification(
            Succeeded: true,
            Identity: new ApiKeyIdentity(record.KeyId, record.DisplayName, record.Scopes, record.ConstraintsJson),
            Failure: null);
    }

    private static ApiKeyVerification Fail(ApiKeyFailure failure) => new(false, null, failure);
}
