using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;

namespace foodmarket.Api.Infrastructure.RateLimiting;

/// <summary>Anti-brute-force на чувствительных auth-эндпоинтах: подбор пароля
/// на <c>/connect/token</c> и спам-регистрация на <c>/api/auth/signup</c>.
///
/// Два sliding-window лимита на IP, оба должны пропустить запрос (chained):
/// 5/мин (резкий всплеск) и 20/час (медленный перебор). Применяется
/// глобально, но no-op для всех путей кроме двух auth-эндпоинтов — так
/// нет нужды цеплять policy на каждый minimal-API/контроллер по отдельности,
/// а двойное окно (которое per-endpoint policy выразить не может) работает
/// через <see cref="PartitionedRateLimiter.CreateChained{TResource}"/>.</summary>
public static class AuthRateLimiterExtensions
{
    // Дефолты. Переопределяются конфигом RateLimiting:* (см. ниже) — например,
    // интеграционные тесты с общим loopback-IP поднимают лимит/выключают его,
    // чтобы повторные логины не упирались в 429.
    public const int DefaultPerMinutePermitLimit = 5;
    public const int DefaultPerHourPermitLimit = 20;

    private const string NoLimitPartition = "__not-an-auth-endpoint";

    public static IServiceCollection AddAuthRateLimiting(this IServiceCollection services, IConfiguration config)
    {
        var section = config.GetSection("RateLimiting");
        var enabled = section.GetValue("Enabled", true);
        var perMinute = section.GetValue("PerMinute", DefaultPerMinutePermitLimit);
        var perHour = section.GetValue("PerHour", DefaultPerHourPermitLimit);

        services.AddRateLimiter(options =>
        {
            // По умолчанию RateLimiter отдаёт 503 — нам нужен честный 429.
            options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

            options.GlobalLimiter = enabled
                ? PartitionedRateLimiter.CreateChained(
                    BuildWindow(perMinute, TimeSpan.FromMinutes(1)),
                    BuildWindow(perHour, TimeSpan.FromHours(1)))
                : PartitionedRateLimiter.Create<HttpContext, string>(
                    _ => RateLimitPartition.GetNoLimiter(NoLimitPartition));

            options.OnRejected = async (context, token) =>
            {
                if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
                {
                    context.HttpContext.Response.Headers.RetryAfter =
                        ((int)retryAfter.TotalSeconds).ToString();
                }

                context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
                context.HttpContext.Response.ContentType = "application/json";
                await context.HttpContext.Response.WriteAsJsonAsync(new
                {
                    error = "too_many_requests",
                    error_description = "Слишком много попыток. Повторите позже.",
                }, token);
            };
        });

        return services;
    }

    private static PartitionedRateLimiter<HttpContext> BuildWindow(int permitLimit, TimeSpan window) =>
        PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
        {
            var bucket = ResolveAuthBucket(ctx);
            if (bucket is null)
            {
                return RateLimitPartition.GetNoLimiter(NoLimitPartition);
            }

            // Отдельный бакет на каждый эндпоинт: успешная регистрация не должна
            // съедать лимит логинов (и наоборот). Ключ = "<эндпоинт>:<IP>".
            return RateLimitPartition.GetSlidingWindowLimiter(
                $"{bucket}:{ResolveClientKey(ctx)}",
                _ => new SlidingWindowRateLimiterOptions
                {
                    PermitLimit = permitLimit,
                    Window = window,
                    SegmentsPerWindow = 6,
                    QueueLimit = 0,
                    AutoReplenishment = true,
                });
        });

    /// <summary>Возвращает имя бакета для лимитируемого auth-эндпоинта либо
    /// null, если запрос не подлежит лимиту.</summary>
    private static string? ResolveAuthBucket(HttpContext ctx)
    {
        if (!HttpMethods.IsPost(ctx.Request.Method)) return null;
        var path = ctx.Request.Path;
        if (path.StartsWithSegments("/connect/token", StringComparison.OrdinalIgnoreCase)) return "token";
        if (path.StartsWithSegments("/api/auth/signup", StringComparison.OrdinalIgnoreCase)) return "signup";
        return null;
    }

    /// <summary>Ключ партиции — IP клиента. За nginx-проксей реальный адрес
    /// в X-Forwarded-For (берём левый — самый дальний клиент); напрямую —
    /// RemoteIpAddress. Без идентификации IP лимит общий на всех (fallback
    /// «unknown»), что безопаснее, чем не лимитировать вовсе.</summary>
    private static string ResolveClientKey(HttpContext ctx)
    {
        var forwarded = ctx.Request.Headers["X-Forwarded-For"].ToString();
        if (!string.IsNullOrWhiteSpace(forwarded))
        {
            var first = forwarded.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
            if (first.Length > 0) return first[0];
        }
        return ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
    }
}
