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

namespace StellaOps.Authority.OpenIddict.Handlers;

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

    public ValidatePasswordGrantHandler(
        IAuthorityIdentityProviderRegistry registry,
        ActivitySource activitySource,
        IAuthEventSink auditSink,
        IAuthorityRateLimiterMetadataAccessor metadataAccessor,
        TimeProvider timeProvider,
        ILogger<ValidatePasswordGrantHandler> logger)
    {
        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.IsPasswordGrantType())
        {
            return;
        }

        using var activity = activitySource.StartActivity("authority.token.validate_password_grant", ActivityKind.Internal);
        activity?.SetTag("authority.endpoint", "/token");
        activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.Password);
        activity?.SetTag("authority.username", context.Request.Username ?? string.Empty);

        PasswordGrantAuditHelper.EnsureCorrelationId(context.Transaction);

        var metadata = metadataAccessor.GetMetadata();
        var clientId = context.ClientId ?? context.Request.ClientId;
        if (!string.IsNullOrWhiteSpace(clientId))
        {
            metadataAccessor.SetClientId(clientId);
        }

        var requestedScopesInput = context.Request.GetScopes();
        var requestedScopes = requestedScopesInput.IsDefaultOrEmpty ? Array.Empty<string>() : requestedScopesInput.ToArray();

        var unexpectedParameters = TokenRequestTamperInspector.GetUnexpectedPasswordGrantParameters(context.Request);
        if (unexpectedParameters.Count > 0)
        {
            var providerHint = context.Request.GetParameter(AuthorityOpenIddictConstants.ProviderParameterName)?.Value?.ToString();
            var tamperRecord = PasswordGrantAuditHelper.CreateTamperRecord(
                timeProvider,
                context.Transaction,
                metadata,
                clientId,
                providerHint,
                context.Request.Username,
                requestedScopes,
                unexpectedParameters);

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

        var selection = AuthorityIdentityProviderSelector.ResolvePasswordProvider(context.Request, registry);
        if (!selection.Succeeded)
        {
            var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
                timeProvider,
                context.Transaction,
                metadata,
                AuthEventOutcome.Failure,
                selection.Description,
                clientId,
                providerName: null,
                user: null,
                username: context.Request.Username,
                scopes: requestedScopes,
                retryAfter: null,
                failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
                extraProperties: null);

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

            context.Reject(selection.Error!, selection.Description);
            logger.LogWarning("Password grant validation failed for {Username}: {Reason}.", context.Request.Username, selection.Description);
            return;
        }

        if (string.IsNullOrWhiteSpace(context.Request.Username) || string.IsNullOrEmpty(context.Request.Password))
        {
            var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
                timeProvider,
                context.Transaction,
                metadata,
                AuthEventOutcome.Failure,
                "Both username and password must be provided.",
                clientId,
                providerName: selection.Provider?.Name,
                user: null,
                username: context.Request.Username,
                scopes: requestedScopes,
                retryAfter: null,
                failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
                extraProperties: null);

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

            context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Both username and password must be provided.");
            logger.LogWarning("Password grant validation failed: missing credentials for {Username}.", context.Request.Username);
            return;
        }

        context.Transaction.Properties[AuthorityOpenIddictConstants.ProviderTransactionProperty] = selection.Provider!.Name;
        activity?.SetTag("authority.identity_provider", selection.Provider.Name);
        logger.LogInformation("Password grant validation succeeded for {Username} using provider {Provider}.", context.Request.Username, selection.Provider.Name);
    }
}

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

    public HandlePasswordGrantHandler(
        IAuthorityIdentityProviderRegistry registry,
        ActivitySource activitySource,
        IAuthEventSink auditSink,
        IAuthorityRateLimiterMetadataAccessor metadataAccessor,
        TimeProvider timeProvider,
        ILogger<HandlePasswordGrantHandler> logger)
    {
        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.HandleTokenRequestContext context)
    {
        ArgumentNullException.ThrowIfNull(context);

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

        using var activity = activitySource.StartActivity("authority.token.handle_password_grant", ActivityKind.Internal);
        activity?.SetTag("authority.endpoint", "/token");
        activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.Password);
        activity?.SetTag("authority.username", context.Request.Username ?? string.Empty);

        PasswordGrantAuditHelper.EnsureCorrelationId(context.Transaction);

        var metadata = metadataAccessor.GetMetadata();
        var clientId = context.ClientId ?? context.Request.ClientId;
        if (!string.IsNullOrWhiteSpace(clientId))
        {
            metadataAccessor.SetClientId(clientId);
        }

        var requestedScopesInput = context.Request.GetScopes();
        var requestedScopes = requestedScopesInput.IsDefaultOrEmpty ? Array.Empty<string>() : requestedScopesInput.ToArray();

        var providerName = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ProviderTransactionProperty, out var value)
            ? value as string
            : null;

        IIdentityProviderPlugin? resolvedProvider;
        if (!string.IsNullOrWhiteSpace(providerName))
        {
            if (!registry.TryGet(providerName!, out var explicitProvider))
            {
                var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
                    timeProvider,
                    context.Transaction,
                    metadata,
                    AuthEventOutcome.Failure,
                    "Unable to resolve the requested identity provider.",
                    clientId,
                    providerName,
                    user: null,
                    username: context.Request.Username,
                    scopes: requestedScopes,
                    retryAfter: null,
                    failureCode: AuthorityCredentialFailureCode.UnknownError,
                    extraProperties: null);

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

                context.Reject(OpenIddictConstants.Errors.ServerError, "Unable to resolve the requested identity provider.");
                logger.LogError("Password grant handling failed: provider {Provider} not found for user {Username}.", providerName, context.Request.Username);
                return;
            }

            resolvedProvider = explicitProvider;
        }
        else
        {
            var selection = AuthorityIdentityProviderSelector.ResolvePasswordProvider(context.Request, registry);
            if (!selection.Succeeded)
            {
                var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
                    timeProvider,
                    context.Transaction,
                    metadata,
                    AuthEventOutcome.Failure,
                    selection.Description,
                    clientId,
                    providerName: null,
                    user: null,
                    username: context.Request.Username,
                    scopes: requestedScopes,
                    retryAfter: null,
                    failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
                    extraProperties: null);

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

                context.Reject(selection.Error!, selection.Description);
                logger.LogWarning("Password grant handling rejected {Username}: {Reason}.", context.Request.Username, selection.Description);
                return;
            }

            resolvedProvider = selection.Provider;
            providerName = selection.Provider?.Name;
        }

        var provider = resolvedProvider ?? throw new InvalidOperationException("No identity provider resolved for password grant.");

        var username = context.Request.Username;
        var password = context.Request.Password;
        if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password))
        {
            var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
                timeProvider,
                context.Transaction,
                metadata,
                AuthEventOutcome.Failure,
                "Both username and password must be provided.",
                clientId,
                provider.Name,
                user: null,
                username: username,
                scopes: requestedScopes,
                retryAfter: null,
                failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
                extraProperties: null);

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

            context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Both username and password must be provided.");
            logger.LogWarning("Password grant handling rejected: missing credentials for {Username}.", username);
            return;
        }

        var verification = await provider.Credentials.VerifyPasswordAsync(
            username,
            password,
            context.CancellationToken).ConfigureAwait(false);

        if (!verification.Succeeded || verification.User is null)
        {
            var outcome = verification.FailureCode == AuthorityCredentialFailureCode.LockedOut
                ? AuthEventOutcome.LockedOut
                : AuthEventOutcome.Failure;

            var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
                timeProvider,
                context.Transaction,
                metadata,
                outcome,
                verification.Message,
                clientId,
                provider.Name,
                verification.User,
                username,
                scopes: requestedScopes,
                retryAfter: verification.RetryAfter,
                failureCode: verification.FailureCode,
                extraProperties: verification.AuditProperties);

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

            context.Reject(
                OpenIddictConstants.Errors.InvalidGrant,
                verification.Message ?? "Invalid username or password.");
            logger.LogWarning("Password verification failed for {Username}: {Message}.", username, verification.Message);
            return;
        }

        metadataAccessor.SetSubjectId(verification.User.SubjectId);

        var identity = new ClaimsIdentity(
            OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
            OpenIddictConstants.Claims.Name,
            OpenIddictConstants.Claims.Role);

        identity.AddClaim(new Claim(OpenIddictConstants.Claims.Subject, verification.User.SubjectId));
        identity.AddClaim(new Claim(OpenIddictConstants.Claims.PreferredUsername, verification.User.Username));

        if (!string.IsNullOrWhiteSpace(verification.User.DisplayName))
        {
            identity.AddClaim(new Claim(OpenIddictConstants.Claims.Name, verification.User.DisplayName!));
        }

        foreach (var role in verification.User.Roles)
        {
            identity.AddClaim(new Claim(OpenIddictConstants.Claims.Role, role));
        }

        identity.SetDestinations(static claim => claim.Type switch
        {
            OpenIddictConstants.Claims.Subject => new[] { OpenIddictConstants.Destinations.AccessToken, OpenIddictConstants.Destinations.IdentityToken },
            OpenIddictConstants.Claims.Name => new[] { OpenIddictConstants.Destinations.AccessToken, OpenIddictConstants.Destinations.IdentityToken },
            OpenIddictConstants.Claims.PreferredUsername => new[] { OpenIddictConstants.Destinations.AccessToken },
            OpenIddictConstants.Claims.Role => new[] { OpenIddictConstants.Destinations.AccessToken },
            _ => new[] { OpenIddictConstants.Destinations.AccessToken }
        });

        var principal = new ClaimsPrincipal(identity);
        principal.SetScopes(context.Request.GetScopes());

        var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, verification.User, null);
        await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false);

        var successRecord = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
            timeProvider,
            context.Transaction,
            metadata,
            AuthEventOutcome.Success,
            verification.Message,
            clientId,
            provider.Name,
            verification.User,
            username,
            scopes: requestedScopes,
            retryAfter: null,
            failureCode: null,
            extraProperties: verification.AuditProperties);

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

        context.Principal = principal;
        context.HandleRequest();
        activity?.SetTag("authority.subject_id", verification.User.SubjectId);
        logger.LogInformation("Password grant issued for {Username} with subject {SubjectId}.", verification.User.Username, verification.User.SubjectId);
    }
}

internal static class PasswordGrantAuditHelper
{
    internal static string EnsureCorrelationId(OpenIddictServerTransaction transaction)
    {
        ArgumentNullException.ThrowIfNull(transaction);

        if (transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditCorrelationProperty, out var value) &&
            value is string existing && !string.IsNullOrWhiteSpace(existing))
        {
            return existing;
        }

        var correlation = Activity.Current?.TraceId.ToString() ??
                          Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);

        transaction.Properties[AuthorityOpenIddictConstants.AuditCorrelationProperty] = correlation;
        return correlation;
    }

    internal static AuthEventRecord CreatePasswordGrantRecord(
        TimeProvider timeProvider,
        OpenIddictServerTransaction transaction,
        AuthorityRateLimiterMetadata? metadata,
        AuthEventOutcome outcome,
        string? reason,
        string? clientId,
        string? providerName,
        AuthorityUserDescriptor? user,
        string? username,
        IEnumerable<string>? scopes,
        TimeSpan? retryAfter,
        AuthorityCredentialFailureCode? failureCode,
        IEnumerable<AuthEventProperty>? extraProperties,
        string? eventType = null)
    {
        ArgumentNullException.ThrowIfNull(timeProvider);
        ArgumentNullException.ThrowIfNull(transaction);

        var correlationId = EnsureCorrelationId(transaction);
        var normalizedScopes = NormalizeScopes(scopes);
        var subject = BuildSubject(user, username, providerName);
        var client = BuildClient(clientId, providerName);
        var network = BuildNetwork(metadata);
        var properties = BuildProperties(user, retryAfter, failureCode, extraProperties);

        return new AuthEventRecord
        {
            EventType = string.IsNullOrWhiteSpace(eventType) ? "authority.password.grant" : eventType,
            OccurredAt = timeProvider.GetUtcNow(),
            CorrelationId = correlationId,
            Outcome = outcome,
            Reason = Normalize(reason),
            Subject = subject,
            Client = client,
            Scopes = normalizedScopes,
            Network = network,
            Properties = properties
        };
    }

    private static AuthEventSubject? BuildSubject(AuthorityUserDescriptor? user, string? username, string? providerName)
    {
        var attributes = user?.Attributes;
        var normalizedUsername = Normalize(username) ?? Normalize(user?.Username);
        var subjectId = Normalize(user?.SubjectId);
        var displayName = Normalize(user?.DisplayName);
        var attributeProperties = BuildSubjectAttributes(attributes);

        if (string.IsNullOrWhiteSpace(subjectId) &&
            string.IsNullOrWhiteSpace(normalizedUsername) &&
            string.IsNullOrWhiteSpace(displayName) &&
            attributeProperties.Count == 0 &&
            string.IsNullOrWhiteSpace(providerName))
        {
            return null;
        }

        return new AuthEventSubject
        {
            SubjectId = ClassifiedString.Personal(subjectId),
            Username = ClassifiedString.Personal(normalizedUsername),
            DisplayName = ClassifiedString.Personal(displayName),
            Realm = ClassifiedString.Public(Normalize(providerName)),
            Attributes = attributeProperties
        };
    }

    private static IReadOnlyList<AuthEventProperty> BuildSubjectAttributes(IReadOnlyDictionary<string, string?>? attributes)
    {
        if (attributes is null || attributes.Count == 0)
        {
            return Array.Empty<AuthEventProperty>();
        }

        var items = new List<AuthEventProperty>(attributes.Count);
        foreach (var pair in attributes)
        {
            if (string.IsNullOrWhiteSpace(pair.Key))
            {
                continue;
            }

            items.Add(new AuthEventProperty
            {
                Name = pair.Key,
                Value = ClassifiedString.Personal(Normalize(pair.Value))
            });
        }

        return items.Count == 0 ? Array.Empty<AuthEventProperty>() : items;
    }

    private static AuthEventClient? BuildClient(string? clientId, string? providerName)
    {
        var normalizedClientId = Normalize(clientId);
        var provider = Normalize(providerName);

        if (string.IsNullOrWhiteSpace(normalizedClientId) && string.IsNullOrWhiteSpace(provider))
        {
            return null;
        }

        return new AuthEventClient
        {
            ClientId = ClassifiedString.Personal(normalizedClientId),
            Name = ClassifiedString.Empty,
            Provider = ClassifiedString.Public(provider)
        };
    }

    private static AuthEventNetwork? BuildNetwork(AuthorityRateLimiterMetadata? metadata)
    {
        var remote = Normalize(metadata?.RemoteIp);
        var forwarded = Normalize(metadata?.ForwardedFor);

        if (string.IsNullOrWhiteSpace(remote) && string.IsNullOrWhiteSpace(forwarded))
        {
            return null;
        }

        return new AuthEventNetwork
        {
            RemoteAddress = ClassifiedString.Personal(remote),
            ForwardedFor = ClassifiedString.Personal(forwarded)
        };
    }

    private static IReadOnlyList<AuthEventProperty> BuildProperties(
        AuthorityUserDescriptor? user,
        TimeSpan? retryAfter,
        AuthorityCredentialFailureCode? failureCode,
        IEnumerable<AuthEventProperty>? extraProperties)
    {
        var properties = new List<AuthEventProperty>();

        if (failureCode is { } code)
        {
            properties.Add(new AuthEventProperty
            {
                Name = "failure.code",
                Value = ClassifiedString.Public(code.ToString())
            });
        }

        if (retryAfter is { } retry && retry > TimeSpan.Zero)
        {
            var seconds = Math.Ceiling(retry.TotalSeconds).ToString(CultureInfo.InvariantCulture);
            properties.Add(new AuthEventProperty
            {
                Name = "policy.retry_after_seconds",
                Value = ClassifiedString.Public(seconds)
            });
        }

        if (user is not null)
        {
            properties.Add(new AuthEventProperty
            {
                Name = "subject.requires_password_reset",
                Value = ClassifiedString.Public(user.RequiresPasswordReset ? "true" : "false")
            });
        }

        if (extraProperties is not null)
        {
            foreach (var property in extraProperties)
            {
                if (property is null || string.IsNullOrWhiteSpace(property.Name))
                {
                    continue;
                }

                properties.Add(property);
            }
        }

        return properties.Count == 0 ? Array.Empty<AuthEventProperty>() : properties;
    }

    private static IReadOnlyList<string> NormalizeScopes(IEnumerable<string>? scopes)
    {
        if (scopes is null)
        {
            return Array.Empty<string>();
        }

        var normalized = scopes
            .Where(static scope => !string.IsNullOrWhiteSpace(scope))
            .Select(static scope => scope.Trim())
            .Where(static scope => scope.Length > 0)
            .Distinct(StringComparer.Ordinal)
            .OrderBy(static scope => scope, StringComparer.Ordinal)
            .ToArray();

        return normalized.Length == 0 ? Array.Empty<string>() : normalized;
    }

    private static string? Normalize(string? value)
        => string.IsNullOrWhiteSpace(value) ? null : value.Trim();

    internal static AuthEventRecord CreateTamperRecord(
        TimeProvider timeProvider,
        OpenIddictServerTransaction transaction,
        AuthorityRateLimiterMetadata? metadata,
        string? clientId,
        string? providerName,
        string? username,
        IEnumerable<string>? scopes,
        IEnumerable<string> unexpectedParameters)
    {
        var properties = new List<AuthEventProperty>
        {
            new()
            {
                Name = "request.tampered",
                Value = ClassifiedString.Public("true")
            }
        };

        if (unexpectedParameters is not null)
        {
            foreach (var parameter in unexpectedParameters)
            {
                if (string.IsNullOrWhiteSpace(parameter))
                {
                    continue;
                }

                properties.Add(new AuthEventProperty
                {
                    Name = "request.unexpected_parameter",
                    Value = ClassifiedString.Public(parameter)
                });
            }
        }

        var reason = unexpectedParameters is null
            ? "Unexpected parameters supplied to password grant request."
            : $"Unexpected parameters supplied to password grant request: {string.Join(", ", unexpectedParameters)}.";

        return CreatePasswordGrantRecord(
            timeProvider,
            transaction,
            metadata,
            AuthEventOutcome.Failure,
            reason,
            clientId,
            providerName,
            user: null,
            username,
            scopes,
            retryAfter: null,
            failureCode: null,
            extraProperties: properties,
            eventType: "authority.token.tamper");
    }
}
