<?php

namespace App\Http\Middleware;

use App\Mail\ApiRateLimitAlert;
use App\Models\ApiRequestLog;
use App\Models\Environments\Environment;
use App\Models\Monitoring\ApiBlock;
use App\Services\Api\HostNormalizer;
use App\Services\Api\Motivo;
use App\Models\Monitoring\ApiRateEvent;
use App\Models\Monitoring\Log as MonitoringLog;
use App\Services\Api\ApiResponse;
use App\Services\System\BlockNotifier;
use App\Services\System\Settings;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;

/**
 * Límite de peticiones del endpoint de la API de plugins, con visibilidad.
 *
 * Va DELANTE de `api_request_logger` a propósito: si fuera detrás, cada petición
 * rechazada seguiría escribiendo una fila en `api_request_logs`, que es justo la
 * amplificación que este middleware existe para cortar.
 *
 * Dos contadores por IP, los dos configurables **desde el panel** (Configuraciones
 * del Manager → Bloqueos), con caída a `config/api.php` y de ahí al `.env`:
 *
 *   - Fallos: solo respuestas 401/403. El tráfico legítimo lleva token válido y no
 *     acumula, así que puede ser estricto aunque varios Moodle compartan IP.
 *   - Techo: todas las peticiones. Holgado, solo como red de seguridad.
 *
 * Y cortar en silencio no vale, así que cada incidencia deja rastro en cuatro
 * sitios, todos con freno para que una avalancha no se convierta en una avalancha
 * de avisos:
 *
 *   1. Tabla `api_rate_events` — una fila por IP, ventana y tipo de límite, con el
 *      máximo observado. Es la que responde "qué IPs están cerca del tope".
 *   2. Canal de log `api` — el detalle, para un `tail -f`.
 *   3. Tabla `logs` — para que salga en el Dashboard.
 *   4. Correo a Sistemas — con enfriamiento de una hora por IP y motivo.
 *
 * Hay además un **margen de aviso** (`warning_ratio`, también configurable): al llegar
 * a la mitad del tope ya se registra y se avisa, para poder arreglarlo antes de cortar a
 * nadie.
 *
 * Ver known-issues MGR-008.
 */
class ThrottleApiRequests
{
    public function handle(Request $request, Closure $next): Response
    {
        $config = config('api.throttle');
        $ajustes = app(Settings::class);

        if (!($config['enabled'] ?? true)) {
            return $next($request);
        }

        $ip = (string) $request->ip();

        if (in_array($ip, $config['allowlist'] ?? [], true)) {
            return $next($request);
        }

        // Lista de DENEGACIÓN, puesta a mano desde Monitorización → IPs. Antes solo
        // existía la lista blanca: no había forma de decir "esta IP no, y punto" sin
        // esperar a que cruzara un umbral ni tocar el `.env`.
        //
        // Corta también en modo observación, y es deliberado: lo ha decidido una persona
        // mirando los datos, no un umbral puesto a ojo.
        if (ApiBlock::ipBloqueadaAMano($ip)) {
            $this->registrarCorte(
                $request,
                $ip,
                Motivo::BLOQUEO_MANUAL,
                403,
                'Acceso bloqueado a mano desde el panel.',
                // **Una cada hora y no una por episodio**, que es lo que se puede hacer
                // aquí sin pagarlo: saber si es el primer rechazo de este episodio exigiría
                // leer la fila del bloqueo, y esa comprobación va cacheada justo para no
                // hacer una consulta por petición rechazada. Una fila por hora deja el
                // rastro y mantiene el coste acotado.
                clave: 'api:log:manual:' . $ip
            );

            return ApiResponse::forbidden('Acceso bloqueado. Contacta con soporte de Tresipunt.');
        }

        // Los topes y las ventanas se configuran en el panel (Configuraciones del
        // Manager → Bloqueos) y caen a `config/api.php` si nadie los ha tocado. Antes
        // estaban solo en el `.env`: afinar un umbral —lo que hay que hacer justo
        // mientras se mide en observación— pedía entrar al servidor.
        $umbrales = $ajustes->umbralesDeLaApi();

        $limites = [
            ApiRateEvent::LIMIT_FAILURES => [
                'clave' => 'api:fail:' . $ip,
                'tope' => $umbrales['failures'],
                'ventana' => $umbrales['failures_decay'],
            ],
            ApiRateEvent::LIMIT_REQUESTS => [
                'clave' => 'api:req:' . $ip,
                'tope' => $umbrales['requests'],
                'ventana' => $umbrales['requests_decay'],
            ],
        ];

        // Primero el contador de fallos: es el que corta de verdad.
        //
        // `losLimitesCortan()` viene APAGADO por defecto: en modo observación se
        // registra la incidencia y se avisa, pero la petición **sigue adelante**. Es
        // como se despliega esto en producción —midiendo con tráfico real antes de
        // cortar a nadie con un umbral puesto a ojo— y se activa desde el panel cuando
        // hay datos. Ver `alerts-and-settings.md`.
        $corta = $ajustes->losLimitesCortan();

        foreach ($limites as $tipo => $limite) {
            if (RateLimiter::tooManyAttempts($limite['clave'], $limite['tope'])) {
                if ($corta) {
                    return $this->rechazar($request, $ip, $tipo, $limite);
                }

                // Observación: se deja constancia de que se HABRÍA cortado y se
                // continúa. Solo del primer límite que se pase, para no duplicar.
                $this->registrar(
                    $request,
                    $ip,
                    $tipo,
                    $limite,
                    ApiRateEvent::KIND_BLOCKED,
                    RateLimiter::attempts($limite['clave'])
                );

                $this->marcarBloqueo($request, $ip, $tipo, $limite, enforced: false);

                break;
            }
        }

        RateLimiter::hit(
            $limites[ApiRateEvent::LIMIT_REQUESTS]['clave'],
            $limites[ApiRateEvent::LIMIT_REQUESTS]['ventana']
        );

        $response = $next($request);

        // Un 401 o un 403 significan credenciales o permisos: es lo que se cuenta
        // para detectar enumeración de tokens y sitios mal configurados en bucle.
        if (in_array($response->getStatusCode(), [401, 403], true)) {
            RateLimiter::hit(
                $limites[ApiRateEvent::LIMIT_FAILURES]['clave'],
                $limites[ApiRateEvent::LIMIT_FAILURES]['ventana']
            );
        }

        $this->comprobarMargen($request, $ip, $limites, $umbrales['warning_ratio']);

        return $response;
    }

    /**
     * Aviso anticipado: si algún contador ha cruzado el margen configurado, se
     * registra (y se avisa) ANTES de llegar al tope.
     */
    private function comprobarMargen(Request $request, string $ip, array $limites, float $ratio): void
    {

        if ($ratio <= 0 || $ratio >= 1) {
            return;
        }

        foreach ($limites as $tipo => $limite) {
            $usado = RateLimiter::attempts($limite['clave']);

            if ($usado < (int) ceil($limite['tope'] * $ratio)) {
                continue;
            }

            $this->registrar($request, $ip, $tipo, $limite, ApiRateEvent::KIND_WARNING, $usado);
        }
    }

    private function rechazar(Request $request, string $ip, string $tipo, array $limite): Response
    {
        $segundos = RateLimiter::availableIn($limite['clave']);

        $this->registrar(
            $request,
            $ip,
            $tipo,
            $limite,
            ApiRateEvent::KIND_BLOCKED,
            RateLimiter::attempts($limite['clave'])
        );

        // Estado del bloqueo, que es distinto del histórico: `api_rate_events` guarda
        // ventanas de un minuto, y esto guarda "esta IP está bloqueada" hasta que
        // alguien lo revise. De aquí sale el aviso por correo, una sola vez por
        // bloqueo. Ver known-issues MGR-013 y la pantalla de ajustes.
        $this->marcarBloqueo($request, $ip, $tipo, $limite);

        // Mismo envoltorio que el resto de la API: `local_tresipunt` busca la clave
        // `error` y, si no la encuentra, registra "No error message" y se pierde el
        // motivo. Ver api-contract.md §NORMA.
        return ApiResponse::error(
            $request->input('action'),
            // Dice qué límite ha cortado: hay otro por licencia y sus mensajes eran
            // casi idénticos, así que era imposible saber cuál actuaba.
            'Límite de peticiones de la API excedido. Reintenta en ' . $segundos . ' segundo(s).',
            429,
            ['retry_after' => $segundos],
            429
        )->header('Retry-After', (string) $segundos);
    }

    /**
     * Deja rastro de la incidencia, con freno de escritura para que una avalancha no
     * genere una fila —ni un correo— por petición.
     */
    private function registrar(
        Request $request,
        string $ip,
        string $tipo,
        array $limite,
        string $kind,
        int $observado
    ): void {
        $ventanaInicio = now()->subSeconds(
            $limite['ventana'] - RateLimiter::availableIn($limite['clave'])
        )->startOfMinute();

        if (config('api.throttle.log_rejections')) {
            Log::channel('api')->warning('Límite de peticiones de la API: ' . $kind, [
                'ip' => $ip,
                'limite' => $tipo,
                'observado' => $observado,
                'tope' => $limite['tope'],
                'host' => $request->input('host'),
                'action' => $request->input('action'),
                'plugin' => $request->input('plugin'),
            ]);
        }

        if (!config('api.throttle.record_events')) {
            return;
        }

        // Una escritura por IP cada N segundos como máximo.
        $freno = 'api:rate:escrito:' . $ip . ':' . $tipo . ':' . $kind;

        if (!Cache::add($freno, true, max(1, (int) config('api.throttle.record_throttle_seconds')))) {
            return;
        }

        try {
            $evento = ApiRateEvent::updateOrCreate(
                [
                    'ip' => $ip,
                    'window_started_at' => $ventanaInicio,
                    'limit_kind' => $tipo,
                ],
                [
                    'kind' => $kind,
                    'observed' => $observado,
                    'limit_value' => $limite['tope'],
                    'host' => $request->input('host'),
                    'action' => $request->input('action'),
                    'plugin' => $request->input('plugin'),
                    'user_agent' => substr((string) $request->userAgent(), 0, 255),
                ]
            );

            if ($kind === ApiRateEvent::KIND_BLOCKED) {
                $evento->increment('rejected');
            }
        } catch (\Throwable $e) {
            // Registrar la incidencia no puede tumbar la API.
            Log::channel('api')->error('No se pudo registrar la incidencia de límite', [
                'ip' => $ip,
                'error' => $e->getMessage(),
            ]);

            return;
        }

        $this->avisar($evento, $kind);
    }

    /**
     * Deja el aviso en el Dashboard y manda el correo a Sistemas, con enfriamiento
     * por IP y motivo: sin él, una avalancha de peticiones sería una avalancha de
     * correos, que es el mismo problema cambiando de sitio.
     */
    private function avisar(ApiRateEvent $evento, string $kind): void
    {
        $minutos = max(1, (int) config('api.alerts.cooldown_minutes'));
        $enfriamiento = 'api:rate:avisado:' . $evento->ip . ':' . $kind;

        if (!Cache::add($enfriamiento, true, $minutos * 60)) {
            return;
        }

        $critico = $kind === ApiRateEvent::KIND_BLOCKED;

        // En el Dashboard: `danger` cuando ya se está cortando, `warning` cuando solo
        // se ha cruzado el margen.
        MonitoringLog::db(
            $critico ? 'danger' : 'warning',
            $critico ? '16003' : '16004',
            ($critico
                ? 'Tope de peticiones de la API alcanzado por la IP '
                : 'La IP se acerca al tope de peticiones de la API: ')
                . $evento->ip
                . ' (' . $evento->observed . '/' . $evento->limit_value
                . ', límite de ' . $evento->limit_kind . ')'
                . ($evento->host ? ' — host: ' . $evento->host : ''),
            'ApiRateEvent',
            (string) $evento->id
        );

        $notificar = config('api.alerts.notify_on', 'both');

        if (!config('api.alerts.enabled') || config('api.alerts.to') === []) {
            return;
        }

        if ($notificar !== 'both' && $notificar !== $kind) {
            return;
        }

        try {
            $mailable = new ApiRateLimitAlert($evento, (string) config('app.env'));
            $envio = Mail::to(config('api.alerts.to'));

            config('api.alerts.queue') ? $envio->queue($mailable) : $envio->send($mailable);
        } catch (\Throwable $e) {
            // Un fallo de correo nunca puede romper la respuesta de la API.
            Log::channel('api')->error('No se pudo enviar el aviso de límite de peticiones', [
                'ip' => $evento->ip,
                'error' => $e->getMessage(),
            ]);
        }
    }

    /**
     * Deja el bloqueo de una IP con estado, y avisa si es nuevo.
     *
     * El freno de escritura de `registrar()` agrupa el HISTÓRICO por ventanas de un
     * minuto. Esto es otra cosa: el estado "esta IP está bloqueada", que dura hasta que
     * alguien la revisa en la pantalla de ajustes. De aquí sale el correo, **una sola
     * vez por bloqueo** —el caso real de producción fueron 437 rechazos en 17 minutos,
     * y 437 correos habrían convertido el aviso en algo que se filtra—.
     *
     * Si la IP corresponde a un entorno conocido se guarda también, porque un dominio
     * dice mucho más que una IP en un correo a las tres de la mañana.
     */
    private function marcarBloqueo(
        Request $request,
        string $ip,
        string $tipo,
        array $limite,
        bool $enforced = true
    ): void {
        try {
            $entorno = $this->entornoDelHost((string) $request->input('host'));

            $resultado = ApiBlock::registrar(
                ApiBlock::SUBJECT_IP,
                $tipo,
                (int) ($limite['tope'] ?? 0),
                [
                    'ip' => $ip,
                    'environment_id' => $entorno?->id,
                    'license_token_id' => $entorno?->license_token_id,
                    'host' => $request->input('host'),
                    'action' => $request->input('action'),
                    'plugin' => $request->input('plugin'),
                    'user_agent' => substr((string) $request->userAgent(), 0, 255),
                    'enforced' => $enforced,
                ]
            );

            if ($resultado['esNuevo']) {
                app(BlockNotifier::class)->avisar($resultado['bloqueo']);

                // **Y una fila en el registro de peticiones, una sola por episodio.** El
                // limitador va delante del logger a propósito, así que un corte no dejaba
                // ninguna fila en el visor: la pantalla donde se investiga «qué le pasó a
                // este sitio» no decía que le estábamos cortando. Ver el docblock de
                // `registrarCorte()`.
                //
                // Solo cuando corta de verdad: en observación la petición sigue adelante y
                // el logger escribe su fila normal, así que aquí sería una duplicada.
                if ($enforced) {
                    $this->registrarCorte(
                        $request,
                        $ip,
                        Motivo::IP_AL_LIMITE,
                        429,
                        // El mensaje dice **cuál** de los dos contadores ha cortado: uno
                        // es volumen y el otro son rechazos, y el arreglo no es el mismo.
                        $tipo === ApiRateEvent::LIMIT_FAILURES
                            ? 'Cortada por demasiadas peticiones rechazadas desde esta IP: más de '
                                . (int) ($limite['tope'] ?? 0) . ' en la ventana del límite.'
                            : 'Cortada por demasiadas peticiones desde esta IP: más de '
                                . (int) ($limite['tope'] ?? 0) . ' en la ventana del límite.',
                        entorno: $entorno
                    );
                }
            }
        } catch (\Throwable $e) {
            // Igual que el resto de la instrumentación: no puede tumbar la API.
            Log::channel('api')->error('No se pudo marcar el bloqueo de la IP', [
                'ip' => $ip,
                'error' => $e->getMessage(),
            ]);
        }
    }

    /**
     * Entorno que corresponde a un host, si lo hay.
     *
     * Devuelve null sin ruido cuando no lo hay, que es el caso más frecuente en un
     * bloqueo por fallos: quien llama con un token inexistente suele ser un sitio que
     * no está dado de alta.
     */
    private function entornoDelHost(string $host): ?Environment
    {
        if ($host === '') {
            return null;
        }

        $normalizado = HostNormalizer::normalize($host);

        return Environment::all()->first(
            fn (Environment $entorno) => HostNormalizer::normalize($entorno->domain) === $normalizado
        );
    }

    /**
     * Deja **una** fila en el registro de peticiones por episodio de corte.
     *
     * **Por qué una y no todas.** `api_throttle` va delante de `api_request_logger` a
     * propósito (MGR-008): una fila por petición rechazada es la amplificación que el límite
     * existe para cortar —una IP con 720 rechazos escribiría 720 filas en la tabla que más
     * crece del sistema—. Pero el efecto secundario era que un corte **no dejaba ni una
     * sola fila**, así que en el visor de peticiones no había forma de ver que a un sitio se
     * le estaba cortando: solo aparecía en `api_blocks`, que es otra pantalla.
     *
     * Con una fila por episodio el coste queda acotado por el número de episodios —decenas
     * al día en el peor caso— y no por el volumen del ataque, que es la propiedad que había
     * que conservar. Y la fila lleva su `reason`, así que el visor la clasifica y se puede
     * filtrar por «IP al límite» o «Bloqueo manual».
     *
     * `duration_ms` es 0 y es correcto: la petición no llegó a ejecutarse.
     *
     * @param  string|null  $clave  Si se pasa, la fila se escribe solo si esa clave no está
     *                              ya en la caché —una por hora—. Es para los cortes que no
     *                              traen la señal de «episodio nuevo».
     */
    private function registrarCorte(
        Request $request,
        string $ip,
        string $motivo,
        int $status,
        string $mensaje,
        ?Environment $entorno = null,
        ?string $clave = null
    ): void {
        try {
            // `add()` y no `has()`+`put()`: es atómico, así que dos peticiones simultáneas
            // no escriben dos filas.
            if ($clave !== null && ! Cache::add($clave, true, self::UNA_FILA_CADA)) {
                return;
            }

            $ahora = now();

            ApiRequestLog::create([
                'request_uuid' => (string) Str::uuid(),
                'method' => $request->getMethod(),
                'path' => $request->path(),
                'ip' => $ip,
                'user_agent' => substr((string) $request->userAgent(), 0, 255),
                // Sin cabeceras ni cuerpo: de una petición cortada no hace falta el detalle
                // —no se ejecutó— y guardarlo sería guardar lo que manda quien nos ataca.
                'auth_type' => $request->bearerToken() !== null ? 'bearer' : 'none',
                'license_token_id' => $entorno?->license_token_id,
                'environment_id' => $entorno?->id,
                'client_id' => $entorno?->client_id,
                'action' => $request->input('action') ? (string) $request->input('action') : null,
                'plugin' => $request->input('plugin') ? (string) $request->input('plugin') : null,
                'host' => $request->input('host') ? (string) $request->input('host') : null,
                'version' => $request->input('version') ? (string) $request->input('version') : null,
                'http_status' => $status,
                'success' => false,
                'error' => $mensaje,
                // Explícito, porque de un 429 o un 403 a secas no se puede deducir cuál de
                // los límites ha cortado — y esa es toda la información que aporta la fila.
                'reason' => $motivo,
                'duration_ms' => 0,
                'started_at' => $ahora,
                'ended_at' => $ahora,
            ]);
        } catch (\Throwable $e) {
            // Igual que el resto de la instrumentación: no puede tumbar la API.
            Log::channel('api')->error('No se pudo registrar la petición cortada', [
                'ip' => $ip,
                'motivo' => $motivo,
                'error' => $e->getMessage(),
            ]);
        }
    }

    /** Cada cuánto se repite la fila de un corte que no trae señal de episodio nuevo. */
    private const UNA_FILA_CADA = 3600;
}
