using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.RateLimiting;
using Tiku.Api.Options;
using Tiku.Application.Security;
using Tiku.Infrastructure.Security;

namespace Tiku.Api.Middleware;

public sealed class AuthRateLimitPartitionMiddleware(
    RequestDelegate next,
    IRedisSecurityStore redisSecurityStore,
    Microsoft.Extensions.Options.IOptions<AuthRateLimitOptions> options)
{
    public AuthRateLimitPartitionMiddleware(RequestDelegate next)
        : this(
            next,
            new NullRedisSecurityStore(),
            Microsoft.Extensions.Options.Options.Create(new AuthRateLimitOptions()))
    {
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var policy = context.GetEndpoint()?
            .Metadata
            .GetMetadata<EnableRateLimitingAttribute>()?
            .PolicyName;
        var propertyName = policy switch
        {
            AuthRateLimitPolicies.Password => "identifier",
            AuthRateLimitPolicies.Sms => "phone",
            _ => null
        };

        if (HttpMethods.IsPost(context.Request.Method) && propertyName is not null)
        {
            await CaptureAccountHashAsync(context, propertyName);
            if (redisSecurityStore.IsConfigured)
            {
                await ConsumeDistributedLimitAsync(context, policy!);
                if (context.Response.HasStarted)
                {
                    return;
                }
            }
        }

        await next(context);
    }

    private async Task ConsumeDistributedLimitAsync(HttpContext context, string policyName)
    {
        var ip = Hash(context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip");
        var account = context.Items.TryGetValue(AuthRateLimitPartitionKey.AccountHashItemKey, out var value)
            ? value as string ?? "unknown-account"
            : "unknown-account";
        var isPassword = policyName == AuthRateLimitPolicies.Password;
        var limit = isPassword ? options.Value.PasswordPermitLimit : options.Value.SmsPermitLimit;
        var window = TimeSpan.FromSeconds(isPassword
            ? options.Value.PasswordWindowSeconds
            : options.Value.SmsWindowSeconds);

        DistributedRateLimitResult result;
        try
        {
            result = await redisSecurityStore.ConsumeAsync(
            [
                new DistributedRateLimitBucket($"{policyName}:ip:{ip}", limit * 4, window),
                new DistributedRateLimitBucket($"{policyName}:ip-account:{ip}:{account}", limit, window)
            ], context.RequestAborted);
        }
        catch (RedisSecurityUnavailableException)
        {
            context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
            await context.Response.WriteAsJsonAsync(new
            {
                title = "Authentication security service is unavailable.",
                status = StatusCodes.Status503ServiceUnavailable,
                code = "auth_security_unavailable",
                traceId = context.TraceIdentifier
            }, context.RequestAborted);
            return;
        }

        if (!result.Allowed)
        {
            if (result.RetryAfter is { } retryAfter)
            {
                context.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
            }
            context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
            await context.Response.WriteAsJsonAsync(new
            {
                title = "Too many requests.",
                status = StatusCodes.Status429TooManyRequests,
                code = "rate_limited",
                traceId = context.TraceIdentifier
            }, context.RequestAborted);
        }
    }

    private static async Task CaptureAccountHashAsync(HttpContext context, string propertyName)
    {
        context.Request.EnableBuffering(bufferThreshold: 4096, bufferLimit: 16_384);
        try
        {
            using var document = await JsonDocument.ParseAsync(
                context.Request.Body,
                cancellationToken: context.RequestAborted);
            var captured = TryGetStringProperty(document.RootElement, propertyName) ??
                           (propertyName == "identifier" ? TryGetStringProperty(document.RootElement, "phone") : null);
            if (captured is { } value &&
                !string.IsNullOrWhiteSpace(value))
            {
                context.Items[AuthRateLimitPartitionKey.AccountHashItemKey] = Hash(value.Trim());
            }
        }
        catch (JsonException)
        {
            // MVC will produce the canonical malformed JSON response.
        }
        catch (IOException)
        {
            // Oversized or unreadable bodies share the IP-only fallback partition.
        }
        finally
        {
            if (context.Request.Body.CanSeek)
            {
                context.Request.Body.Position = 0;
            }
        }
    }

    private static string? TryGetStringProperty(JsonElement element, string propertyName)
    {
        if (element.ValueKind != JsonValueKind.Object)
        {
            return null;
        }

        foreach (var property in element.EnumerateObject())
        {
            if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase) &&
                property.Value.ValueKind == JsonValueKind.String)
            {
                return property.Value.GetString();
            }
        }

        return null;
    }

    private static string Hash(string value)
    {
        return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))
            .ToLowerInvariant();
    }
}

public static class AuthRateLimitPartitionKey
{
    internal const string AccountHashItemKey = "tiku.auth_rate_limit.account_hash";

    public static string Resolve(HttpContext context, string policyName)
    {
        var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";
        var accountHash = context.Items.TryGetValue(AccountHashItemKey, out var value) &&
                          value is string hash &&
                          !string.IsNullOrWhiteSpace(hash)
            ? hash
            : "unknown-account";
        return $"{policyName}:{ipAddress}:{accountHash}";
    }
}
