using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.RateLimiting;
using StellaOps.Cryptography.Audit;

namespace StellaOps.Authority.OpenIddict.Handlers;

internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenRequestContext>
{
    private readonly IAuthorityClientStore clientStore;
    private readonly IAuthorityIdentityProviderRegistry registry;
    private readonly ActivitySource activitySource;
    private readonly IAuthEventSink auditSink;
    private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
    private readonly TimeProvider timeProvider;
    private readonly ILogger<ValidateClientCredentialsHandler> logger;

    public ValidateClientCredentialsHandler(
        IAuthorityClientStore clientStore,
        IAuthorityIdentityProviderRegistry registry,
        ActivitySource activitySource,
        IAuthEventSink auditSink,
        IAuthorityRateLimiterMetadataAccessor metadataAccessor,
        TimeProvider timeProvider,
        ILogger<ValidateClientCredentialsHandler> logger)
    {
        this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
        this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
        this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
        this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
        this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
        this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
    {
        ArgumentNullException.ThrowIfNull(context);

        if (!context.Request.IsClientCredentialsGrantType())
        {
            return;
        }

        using var activity = activitySource.StartActivity("authority.token.validate_client_credentials", ActivityKind.Internal);
        activity?.SetTag("authority.endpoint", "/token");
        activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.ClientCredentials);
        activity?.SetTag("authority.client_id", context.ClientId ?? string.Empty);

        ClientCredentialsAuditHelper.EnsureCorrelationId(context.Transaction);

        var metadata = metadataAccessor.GetMetadata();
        var clientId = context.ClientId ?? context.Request.ClientId;
        context.Transaction.Properties[AuthorityOpenIddictConstants.AuditClientIdProperty] = clientId;
        if (!string.IsNullOrWhiteSpace(clientId))
        {
            metadataAccessor.SetClientId(clientId);
        }

        var requestedScopeInput = context.Request.GetScopes();
        var requestedScopes = requestedScopeInput.IsDefaultOrEmpty ? Array.Empty<string>() : requestedScopeInput.ToArray();
        context.Transaction.Properties[AuthorityOpenIddictConstants.AuditRequestedScopesProperty] = requestedScopes;

        var unexpectedParameters = TokenRequestTamperInspector.GetUnexpectedClientCredentialsParameters(context.Request);
        if (unexpectedParameters.Count > 0)
        {
            var providerHint = context.Request.GetParameter(AuthorityOpenIddictConstants.ProviderParameterName)?.Value?.ToString();
            var tamperRecord = ClientCredentialsAuditHelper.CreateTamperRecord(
                timeProvider,
                context.Transaction,
                metadata,
                clientId,
                providerHint,
                confidential: null,
                unexpectedParameters);

            await auditSink.WriteAsync(tamperRecord, context.CancellationToken).ConfigureAwait(false);
        }

        try
        {
            if (string.IsNullOrWhiteSpace(context.ClientId))
            {
                context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client identifier is required.");
                logger.LogWarning("Client credentials validation failed: missing client identifier.");
                return;
            }

        var document = await clientStore.FindByClientIdAsync(context.ClientId, context.CancellationToken).ConfigureAwait(false);
        if (document is null || document.Disabled)
        {
            context.Reject(OpenIddictConstants.Errors.InvalidClient, "Unknown or disabled client identifier.");
            logger.LogWarning("Client credentials validation failed for {ClientId}: client not found or disabled.", context.ClientId);
            return;
        }

        context.Transaction.Properties[AuthorityOpenIddictConstants.AuditConfidentialProperty] = string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase);

        IIdentityProviderPlugin? provider = null;
        if (!string.IsNullOrWhiteSpace(document.Plugin))
        {
            if (!registry.TryGet(document.Plugin, out provider))
            {
                context.Reject(OpenIddictConstants.Errors.InvalidClient, "Configured identity provider is unavailable.");
                logger.LogWarning("Client credentials validation failed for {ClientId}: provider {Provider} unavailable.", context.ClientId, document.Plugin);
                return;
            }

            context.Transaction.Properties[AuthorityOpenIddictConstants.AuditProviderProperty] = provider.Name;

            if (!provider.Capabilities.SupportsClientProvisioning || provider.ClientProvisioning is null)
            {
                context.Reject(OpenIddictConstants.Errors.UnauthorizedClient, "Associated identity provider does not support client provisioning.");
                logger.LogWarning("Client credentials validation failed for {ClientId}: provider {Provider} lacks client provisioning capabilities.", context.ClientId, provider.Name);
                return;
            }
        }

        var allowedGrantTypes = ClientCredentialHandlerHelpers.Split(document.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes);
        if (allowedGrantTypes.Count > 0 &&
            !allowedGrantTypes.Any(static grant => string.Equals(grant, OpenIddictConstants.GrantTypes.ClientCredentials, StringComparison.Ordinal)))
        {
            context.Reject(OpenIddictConstants.Errors.UnauthorizedClient, "Client credentials grant is not permitted for this client.");
            logger.LogWarning("Client credentials validation failed for {ClientId}: grant type not allowed.", document.ClientId);
            return;
        }

        var requiresSecret = string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase);
        if (requiresSecret)
        {
            if (string.IsNullOrWhiteSpace(document.SecretHash))
            {
                context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client secret is not configured.");
                logger.LogWarning("Client credentials validation failed for {ClientId}: secret not configured.", document.ClientId);
                return;
            }

            if (string.IsNullOrWhiteSpace(context.ClientSecret) ||
                !ClientCredentialHandlerHelpers.VerifySecret(context.ClientSecret, document.SecretHash))
            {
                context.Reject(OpenIddictConstants.Errors.InvalidClient, "Invalid client credentials.");
                logger.LogWarning("Client credentials validation failed for {ClientId}: secret verification failed.", document.ClientId);
                return;
            }
        }
        else if (!string.IsNullOrWhiteSpace(context.ClientSecret) && !string.IsNullOrWhiteSpace(document.SecretHash) &&
                 !ClientCredentialHandlerHelpers.VerifySecret(context.ClientSecret, document.SecretHash))
        {
            context.Reject(OpenIddictConstants.Errors.InvalidClient, "Invalid client credentials.");
            logger.LogWarning("Client credentials validation failed for {ClientId}: secret verification failed.", document.ClientId);
            return;
        }

        var allowedScopes = ClientCredentialHandlerHelpers.Split(document.Properties, AuthorityClientMetadataKeys.AllowedScopes);
        var resolvedScopes = ClientCredentialHandlerHelpers.ResolveGrantedScopes(
            allowedScopes,
            context.Request.GetScopes());

        if (resolvedScopes.InvalidScope is not null)
        {
            context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = resolvedScopes.InvalidScope;
            context.Reject(OpenIddictConstants.Errors.InvalidScope, $"Scope '{resolvedScopes.InvalidScope}' is not allowed for this client.");
            logger.LogWarning("Client credentials validation failed for {ClientId}: scope {Scope} not permitted.", document.ClientId, resolvedScopes.InvalidScope);
            return;
        }

        context.Transaction.Properties[AuthorityOpenIddictConstants.AuditGrantedScopesProperty] = resolvedScopes.Scopes;

        context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = document;
        if (provider is not null)
        {
            context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty] = provider.Name;
            activity?.SetTag("authority.identity_provider", provider.Name);
        }

        context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty] = resolvedScopes.Scopes;
        logger.LogInformation("Client credentials validated for {ClientId}.", document.ClientId);
    }
        finally
        {
            var outcome = context.IsRejected ? AuthEventOutcome.Failure : AuthEventOutcome.Success;
            var reason = context.IsRejected ? context.ErrorDescription : null;
            var auditClientId = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditClientIdProperty, out var clientValue)
                ? clientValue as string
                : clientId;
            var providerName = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditProviderProperty, out var providerValue)
                ? providerValue as string
                : null;
            var confidentialValue = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditConfidentialProperty, out var confidentialValueObj) && confidentialValueObj is bool conf
                ? (bool?)conf
                : null;
            var requested = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditRequestedScopesProperty, out var requestedValue) && requestedValue is string[] requestedArray
                ? (IReadOnlyList<string>)requestedArray
                : requestedScopes;
            var granted = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditGrantedScopesProperty, out var grantedValue) && grantedValue is string[] grantedArray
                ? (IReadOnlyList<string>)grantedArray
                : Array.Empty<string>();
            var invalidScope = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditInvalidScopeProperty, out var invalidValue)
                ? invalidValue as string
                : null;

            var record = ClientCredentialsAuditHelper.CreateRecord(
                timeProvider,
                context.Transaction,
                metadata,
                null,
                outcome,
                reason,
                auditClientId,
                providerName,
                confidentialValue,
                requested,
                granted,
                invalidScope);

            await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
        }
    }
}

internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleTokenRequestContext>
{
    private readonly IAuthorityIdentityProviderRegistry registry;
    private readonly IAuthorityTokenStore tokenStore;
    private readonly TimeProvider clock;
    private readonly ActivitySource activitySource;
    private readonly ILogger<HandleClientCredentialsHandler> logger;

    public HandleClientCredentialsHandler(
        IAuthorityIdentityProviderRegistry registry,
        IAuthorityTokenStore tokenStore,
        TimeProvider clock,
        ActivitySource activitySource,
        ILogger<HandleClientCredentialsHandler> logger)
    {
        this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
        this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
        this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
        this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public async ValueTask HandleAsync(OpenIddictServerEvents.HandleTokenRequestContext context)
    {
        ArgumentNullException.ThrowIfNull(context);

        if (!context.Request.IsClientCredentialsGrantType())
        {
            return;
        }

        using var activity = activitySource.StartActivity("authority.token.handle_client_credentials", ActivityKind.Internal);

        if (!context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTransactionProperty, out var value) ||
            value is not AuthorityClientDocument document)
        {
            context.Reject(OpenIddictConstants.Errors.ServerError, "Client metadata not available.");
            return;
        }

        var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
        identity.AddClaim(new Claim(OpenIddictConstants.Claims.Subject, document.ClientId));
        identity.AddClaim(new Claim(OpenIddictConstants.Claims.ClientId, document.ClientId));
        activity?.SetTag("authority.client_id", document.ClientId);
        activity?.SetTag("authority.endpoint", "/token");
        activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.ClientCredentials);

        var tokenId = identity.GetClaim(OpenIddictConstants.Claims.JwtId);
        if (string.IsNullOrEmpty(tokenId))
        {
            tokenId = Guid.NewGuid().ToString("N");
            identity.SetClaim(OpenIddictConstants.Claims.JwtId, tokenId);
        }

        identity.SetDestinations(static claim => claim.Type switch
        {
            OpenIddictConstants.Claims.Subject => new[] { OpenIddictConstants.Destinations.AccessToken },
            OpenIddictConstants.Claims.ClientId => new[] { OpenIddictConstants.Destinations.AccessToken },
            OpenIddictConstants.Claims.JwtId => new[] { OpenIddictConstants.Destinations.AccessToken },
            StellaOpsClaimTypes.IdentityProvider => new[] { OpenIddictConstants.Destinations.AccessToken },
            _ => new[] { OpenIddictConstants.Destinations.AccessToken }
        });

        var (provider, descriptor) = await ResolveProviderAsync(context, document).ConfigureAwait(false);
        if (context.IsRejected)
        {
            logger.LogWarning("Client credentials request rejected for {ClientId} during provider resolution.", document.ClientId);
            return;
        }

        if (provider is null)
        {
            if (!string.IsNullOrWhiteSpace(document.Plugin))
            {
                identity.SetClaim(StellaOpsClaimTypes.IdentityProvider, document.Plugin);
                activity?.SetTag("authority.identity_provider", document.Plugin);
            }
        }
        else
        {
            identity.SetClaim(StellaOpsClaimTypes.IdentityProvider, provider.Name);
            activity?.SetTag("authority.identity_provider", provider.Name);
        }

        var principal = new ClaimsPrincipal(identity);

        var grantedScopes = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientGrantedScopesProperty, out var scopesValue) &&
                            scopesValue is IReadOnlyList<string> resolvedScopes
            ? resolvedScopes
            : ClientCredentialHandlerHelpers.Split(document.Properties, AuthorityClientMetadataKeys.AllowedScopes);

        if (grantedScopes.Count > 0)
        {
            principal.SetScopes(grantedScopes);
        }
        else
        {
            principal.SetScopes(Array.Empty<string>());
        }

        if (provider is not null && descriptor is not null)
        {
            var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, user: null, descriptor);
            await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false);
        }

        await PersistTokenAsync(context, document, tokenId, grantedScopes, activity).ConfigureAwait(false);

        context.Principal = principal;
        context.HandleRequest();
        logger.LogInformation("Issued client credentials access token for {ClientId} with scopes {Scopes}.", document.ClientId, grantedScopes);
    }

    private async ValueTask<(IIdentityProviderPlugin? Provider, AuthorityClientDescriptor? Descriptor)> ResolveProviderAsync(
        OpenIddictServerEvents.HandleTokenRequestContext context,
        AuthorityClientDocument document)
    {
        string? providerName = null;
        if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientProviderTransactionProperty, out var providerValue) &&
            providerValue is string storedProvider)
        {
            providerName = storedProvider;
        }
        else if (!string.IsNullOrWhiteSpace(document.Plugin))
        {
            providerName = document.Plugin;
        }

        if (string.IsNullOrWhiteSpace(providerName))
        {
            return (null, null);
        }

        if (!registry.TryGet(providerName, out var provider) || provider.ClientProvisioning is null)
        {
            context.Reject(OpenIddictConstants.Errors.InvalidClient, "Configured identity provider is unavailable.");
            return (null, null);
        }

        var descriptor = await provider.ClientProvisioning.FindByClientIdAsync(document.ClientId, context.CancellationToken).ConfigureAwait(false);

        if (descriptor is null)
        {
            context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client registration was not found.");
            return (null, null);
        }

        return (provider, descriptor);
    }

    private async ValueTask PersistTokenAsync(
        OpenIddictServerEvents.HandleTokenRequestContext context,
        AuthorityClientDocument document,
        string tokenId,
        IReadOnlyCollection<string> scopes,
        Activity? activity)
    {
        if (context.IsRejected)
        {
            return;
        }

        var issuedAt = clock.GetUtcNow();
        var lifetime = context.Options?.AccessTokenLifetime;
        var expiresAt = lifetime.HasValue && lifetime.Value > TimeSpan.Zero
            ? issuedAt + lifetime.Value
            : (DateTimeOffset?)null;

        var record = new AuthorityTokenDocument
        {
            TokenId = tokenId,
            Type = OpenIddictConstants.TokenTypeHints.AccessToken,
            SubjectId = document.ClientId,
            ClientId = document.ClientId,
            Scope = scopes.Count > 0 ? scopes.ToList() : new List<string>(),
            Status = "valid",
            CreatedAt = issuedAt,
            ExpiresAt = expiresAt
        };

        await tokenStore.InsertAsync(record, context.CancellationToken).ConfigureAwait(false);
        context.Transaction.Properties[AuthorityOpenIddictConstants.TokenTransactionProperty] = record;
        activity?.SetTag("authority.token_id", tokenId);
    }
}

internal static class ClientCredentialHandlerHelpers
{
    public static IReadOnlyList<string> Split(IReadOnlyDictionary<string, string?> properties, string key)
    {
        if (!properties.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
        {
            return Array.Empty<string>();
        }

        return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
    }

    public static (string[] Scopes, string? InvalidScope) ResolveGrantedScopes(
        IReadOnlyCollection<string> allowedScopes,
        IReadOnlyList<string> requestedScopes)
    {
        if (allowedScopes.Count == 0)
        {
            return (requestedScopes.Count == 0 ? Array.Empty<string>() : requestedScopes.ToArray(), null);
        }

        var allowed = new HashSet<string>(allowedScopes, StringComparer.Ordinal);

        if (requestedScopes.Count == 0)
        {
            return (allowedScopes.ToArray(), null);
        }

        foreach (var scope in requestedScopes)
        {
            if (!allowed.Contains(scope))
            {
                return (Array.Empty<string>(), scope);
            }
        }

        return (requestedScopes.ToArray(), null);
    }

    public static bool VerifySecret(string secret, string storedHash)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(secret);

        if (string.IsNullOrWhiteSpace(storedHash))
        {
            return false;
        }

        try
        {
            var computed = Convert.FromBase64String(AuthoritySecretHasher.ComputeHash(secret));
            var expected = Convert.FromBase64String(storedHash);

            if (computed.Length != expected.Length)
            {
                return false;
            }

            return CryptographicOperations.FixedTimeEquals(computed, expected);
        }
        catch (FormatException)
        {
            return false;
        }
    }
}
