<?php

namespace Tests\Feature\Api;

use App\Mail\ApiRateLimitAlert;
use App\Models\ApiRequestLog;
use App\Services\Api\Motivo;
use App\Services\Api\Severidad;
use App\Models\Clients\Client;
use App\Models\Environments\Environment;
use App\Models\Monitoring\ApiRateEvent;
use App\Models\Products\LicenseToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Tests\TestCase;

/**
 * Límite de peticiones del endpoint de la API.
 *
 * Caso real que lo motiva (producción, 2026-08-26): un entorno de desarrollo interno
 * con un token inexistente hizo 437 peticiones `licence` en 17 minutos, todas 401 y
 * todas escritas en `api_request_logs`. Ver known-issues MGR-008.
 */
class ThrottleTest extends TestCase
{
    use RefreshDatabase;

    private const TOKEN_VALIDO = '3IP-TEST-THROTTLE-OK';
    private const DOMINIO = 'https://throttle.test';

    protected function setUp(): void
    {
        parent::setUp();

        RateLimiter::clear('api:fail:127.0.0.1');
        RateLimiter::clear('api:req:127.0.0.1');

        // Los frenos de escritura y el enfriamiento del aviso viven en caché, y con
        // driver `array` sobreviven dentro del mismo proceso: sin limpiarlos, el
        // primer test dejaría muda a los siguientes.
        Cache::flush();

        // Este fichero prueba el CORTE del límite por IP, así que hay que activarlo: de
        // fábrica los límites solo observan. Ver `ObservationModeTest`.
        app(\App\Services\System\Settings::class)->set(\App\Services\System\Settings::LIMITES_CORTAN, '1');

        config([
            'api.throttle.enabled' => true,
            'api.throttle.failures' => 3,
            'api.throttle.failures_decay' => 60,
            'api.throttle.requests' => 100,
            'api.throttle.requests_decay' => 60,
            'api.throttle.allowlist' => [],
        ]);
    }

    private function llamar(string $token): \Illuminate\Testing\TestResponse
    {
        return $this->withHeaders([
            'Authorization' => 'Bearer ' . $token,
            'Accept' => 'application/json',
        ])->postJson('/api/v1', [
            'action' => 'products',
            'host' => self::DOMINIO,
            'plugin' => 'local_tresipunt',
            'version' => '2026080604',
            'site' => ['version' => '2025100601.03', 'release' => '5.1.1+', 'type' => 'moodle', 'env' => 'local'],
        ]);
    }

    private function entornoConLicencia(): void
    {
        $cliente = Client::create(['name' => 'Cliente Throttle', 'shortname' => 'throttle', 'actived' => true]);

        $token = LicenseToken::create([
            'client_id' => $cliente->id,
            'name' => 'Licencia Throttle',
            'token' => self::TOKEN_VALIDO,
            'active' => true,
        ]);

        Environment::create([
            'name' => 'Entorno Throttle',
            'domain' => self::DOMINIO,
            'version' => '5.1.1',
            'env' => 'local',
            'client_id' => $cliente->id,
            'license_token_id' => $token->id,
            'active' => true,
        ]);
    }

    public function test_los_intentos_con_token_invalido_se_cortan(): void
    {
        // Los tres primeros pasan (y fallan con 401); el cuarto ya se rechaza.
        for ($i = 1; $i <= 3; $i++) {
            $this->llamar('3IP-NO-EXISTE')->assertUnauthorized();
        }

        $respuesta = $this->llamar('3IP-NO-EXISTE');

        $respuesta->assertStatus(429)
            ->assertJsonPath('success', false)
            ->assertJsonPath('code', 429);

        $this->assertNotNull($respuesta->headers->get('Retry-After'));
        $this->assertStringContainsString('Límite de peticiones', $respuesta->json('error'));
    }

    public function test_el_corte_deja_una_fila_en_el_registro_de_peticiones(): void
    {
        // **El hueco que esto tapa.** El throttle va delante del logger a propósito, así
        // que un corte no dejaba **ninguna** fila: en el visor de peticiones —la pantalla
        // donde se investiga qué le pasó a un sitio— no había forma de ver que le estábamos
        // cortando. Solo aparecía en `api_blocks`, que es otra pestaña.
        for ($i = 1; $i <= 3; $i++) {
            $this->llamar('3IP-NO-EXISTE');
        }

        $filasAntes = ApiRequestLog::count();

        $this->llamar('3IP-NO-EXISTE')->assertStatus(429);

        $this->assertSame($filasAntes + 1, ApiRequestLog::count(), 'el corte deja su fila');

        $fila = ApiRequestLog::latest('id')->first();

        $this->assertSame(429, $fila->http_status);
        // **Con su motivo**, que es toda la información que aporta la fila: de un 429 a
        // secas no se deduce cuál de los dos límites ha cortado.
        $this->assertSame(Motivo::IP_AL_LIMITE, $fila->reason);
        $this->assertSame(Severidad::ERROR, $fila->severity);
        $this->assertSame(0, (int) $fila->duration_ms, 'la petición no llegó a ejecutarse');
        $this->assertStringContainsString('rechazadas', (string) $fila->error);
    }

    public function test_una_avalancha_cortada_deja_una_sola_fila_y_no_una_por_peticion(): void
    {
        // **Es la propiedad que había que conservar al tapar el hueco de arriba.** 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.
        // Una por **episodio** deja el rastro y no depende del volumen del ataque.
        for ($i = 1; $i <= 3; $i++) {
            $this->llamar('3IP-NO-EXISTE');
        }

        $filasAntes = ApiRequestLog::count();

        for ($i = 1; $i <= 30; $i++) {
            $this->llamar('3IP-NO-EXISTE')->assertStatus(429);
        }

        $this->assertSame(
            $filasAntes + 1,
            ApiRequestLog::count(),
            '30 peticiones cortadas han escrito más de una fila: el episodio no está frenando la escritura'
        );
    }

    public function test_en_observacion_no_se_duplica_la_fila_del_logger(): void
    {
        // En observación la petición **sigue adelante**, así que el logger escribe su fila
        // normal con el resultado de verdad. Añadir aquí otra que dijera «se habría
        // cortado» sería contar dos veces la misma petición.
        app(\App\Services\System\Settings::class)->set(\App\Services\System\Settings::LIMITES_CORTAN, '0');

        for ($i = 1; $i <= 3; $i++) {
            $this->llamar('3IP-NO-EXISTE');
        }

        $filasAntes = ApiRequestLog::count();

        $this->llamar('3IP-NO-EXISTE')->assertUnauthorized();

        $this->assertSame(
            $filasAntes + 1,
            ApiRequestLog::count(),
            'en observación la fila es la del logger y solo la del logger'
        );

        $this->assertNotSame(Motivo::IP_AL_LIMITE, ApiRequestLog::latest('id')->first()->reason);
    }

    public function test_una_ip_bloqueada_a_mano_tambien_deja_rastro_en_el_visor(): void
    {
        \App\Models\Monitoring\ApiBlock::create([
            'subject_type' => \App\Models\Monitoring\ApiBlock::SUBJECT_IP,
            'ip' => '127.0.0.1',
            'reason' => \App\Models\Monitoring\ApiBlock::REASON_MANUAL,
            'state' => \App\Models\Monitoring\ApiBlock::STATE_BLOCKED,
            'enforced' => true,
            'limit_value' => 0,
            'first_blocked_at' => now(),
            'last_blocked_at' => now(),
            'hits' => 1,
        ]);

        $filasAntes = ApiRequestLog::count();

        $this->llamar('3IP-NO-EXISTE')->assertForbidden();

        $this->assertSame($filasAntes + 1, ApiRequestLog::count());
        $this->assertSame(Motivo::BLOQUEO_MANUAL, ApiRequestLog::latest('id')->first()->reason);

        // Y la segunda no repite: una fila por hora, que es lo que se puede acotar aquí sin
        // hacer una consulta por petición rechazada.
        $this->llamar('3IP-NO-EXISTE')->assertForbidden();

        $this->assertSame($filasAntes + 1, ApiRequestLog::count());
    }

    public function test_el_trafico_legitimo_no_acumula_en_el_contador_de_fallos(): void
    {
        $this->entornoConLicencia();

        // Muchas más llamadas correctas que el límite de fallos: no deben cortarse.
        for ($i = 1; $i <= 6; $i++) {
            $this->llamar(self::TOKEN_VALIDO)->assertOk();
        }

        $this->llamar(self::TOKEN_VALIDO)->assertOk();
    }

    public function test_el_techo_total_tambien_corta(): void
    {
        $this->entornoConLicencia();

        config(['api.throttle.requests' => 2]);

        $this->llamar(self::TOKEN_VALIDO)->assertOk();
        $this->llamar(self::TOKEN_VALIDO)->assertOk();

        $this->llamar(self::TOKEN_VALIDO)->assertStatus(429);
    }

    public function test_se_puede_desactivar_por_configuracion(): void
    {
        config(['api.throttle.enabled' => false]);

        for ($i = 1; $i <= 6; $i++) {
            $this->llamar('3IP-NO-EXISTE')->assertUnauthorized();
        }
    }

    public function test_las_ips_de_la_lista_blanca_no_se_limitan(): void
    {
        config(['api.throttle.allowlist' => ['127.0.0.1']]);

        for ($i = 1; $i <= 6; $i++) {
            $this->llamar('3IP-NO-EXISTE')->assertUnauthorized();
        }
    }

    public function test_avisa_antes_de_cortar_al_cruzar_el_margen(): void
    {
        // Margen al 50% de 3 fallos = 2. Al segundo fallo ya debe quedar registrado,
        // sin haber rechazado nada todavía: la idea es enterarse ANTES de cortar.
        config(['api.throttle.warning_ratio' => 0.5]);

        $this->llamar('3IP-NO-EXISTE')->assertUnauthorized();
        $this->llamar('3IP-NO-EXISTE')->assertUnauthorized();

        $evento = ApiRateEvent::where('ip', '127.0.0.1')
            ->where('limit_kind', ApiRateEvent::LIMIT_FAILURES)
            ->first();

        $this->assertNotNull($evento, 'No se ha registrado el aviso anticipado.');
        $this->assertSame(ApiRateEvent::KIND_WARNING, $evento->kind);
        $this->assertSame(3, $evento->limit_value);
    }

    public function test_la_incidencia_queda_registrada_al_cortar(): void
    {
        for ($i = 1; $i <= 4; $i++) {
            $this->llamar('3IP-NO-EXISTE');
        }

        $evento = ApiRateEvent::where('ip', '127.0.0.1')
            ->where('limit_kind', ApiRateEvent::LIMIT_FAILURES)
            ->first();

        $this->assertNotNull($evento);
        $this->assertSame(ApiRateEvent::KIND_BLOCKED, $evento->kind);
    }

    public function test_una_avalancha_no_genera_una_ventana_por_peticion(): void
    {
        // Es el punto clave del diseño: `api_request_logs` se murió por crecer sin
        // freno, así que la tabla de incidencias tiene que quedarse pequeña.
        for ($i = 1; $i <= 40; $i++) {
            $this->llamar('3IP-NO-EXISTE');
        }

        $filas = ApiRateEvent::where('ip', '127.0.0.1')->count();

        $this->assertLessThanOrEqual(
            4,
            $filas,
            "40 peticiones han generado {$filas} filas de incidencia: el freno de escritura no funciona."
        );
    }

    public function test_aparece_en_el_dashboard_como_aviso_critico(): void
    {
        for ($i = 1; $i <= 4; $i++) {
            $this->llamar('3IP-NO-EXISTE');
        }

        $this->assertDatabaseHas('logs', [
            'level' => 'danger',
            'code' => '16003',
            'entity' => 'ApiRateEvent',
        ]);
    }

    public function test_avisa_por_correo_una_sola_vez_por_ip_y_motivo(): void
    {
        Mail::fake();

        config([
            'api.alerts.enabled' => true,
            'api.alerts.to' => ['sistemas@example.test'],
            'api.alerts.notify_on' => 'both',
            'api.alerts.cooldown_minutes' => 60,
        ]);

        for ($i = 1; $i <= 20; $i++) {
            $this->llamar('3IP-NO-EXISTE');
        }

        // Veinte peticiones, DOS correos: uno al cruzar el margen de aviso y otro al
        // empezar a cortar. Son dos avisos distintos a propósito —el primero da
        // margen para arreglarlo, el segundo dice que ya se está rechazando— y el
        // enfriamiento es por IP **y motivo**, así que ninguno se repite.
        Mail::assertSent(ApiRateLimitAlert::class, 2);

        Mail::assertSent(
            ApiRateLimitAlert::class,
            fn (ApiRateLimitAlert $correo) => $correo->evento->kind === ApiRateEvent::KIND_WARNING
        );

        Mail::assertSent(
            ApiRateLimitAlert::class,
            fn (ApiRateLimitAlert $correo) => $correo->evento->kind === ApiRateEvent::KIND_BLOCKED
        );
    }

    public function test_solo_avisa_de_los_cortes_si_se_configura_asi(): void
    {
        Mail::fake();

        config([
            'api.alerts.enabled' => true,
            'api.alerts.to' => ['sistemas@example.test'],
            'api.alerts.notify_on' => ApiRateEvent::KIND_BLOCKED,
        ]);

        for ($i = 1; $i <= 20; $i++) {
            $this->llamar('3IP-NO-EXISTE');
        }

        Mail::assertSent(ApiRateLimitAlert::class, 1);
        Mail::assertSent(
            ApiRateLimitAlert::class,
            fn (ApiRateLimitAlert $correo) => $correo->evento->kind === ApiRateEvent::KIND_BLOCKED
        );
    }

    public function test_no_avisa_por_correo_si_esta_desactivado(): void
    {
        Mail::fake();

        config(['api.alerts.enabled' => false]);

        for ($i = 1; $i <= 10; $i++) {
            $this->llamar('3IP-NO-EXISTE');
        }

        Mail::assertNothingSent();
    }
    /**
     * El tope del panel manda sobre el del fichero.
     *
     * Es el sentido de haberlo hecho configurable: si el middleware siguiera leyendo el
     * fichero, la pantalla mostraría un número y la API cortaría por otro, que es peor
     * que no tener pantalla.
     */
    public function test_el_tope_configurado_en_el_panel_es_el_que_corta(): void
    {
        // El fichero dice 3; el panel dice 2. Manda el panel.
        $ajustes = app(\App\Services\System\Settings::class);
        $ajustes->set(\App\Services\System\Settings::LIMITE_FALLOS, "2");
        $ajustes->olvidar();

        $this->llamar("3IP-NO-EXISTE")->assertStatus(401);
        $this->llamar("3IP-NO-EXISTE")->assertStatus(401);

        // La tercera ya se pasa del tope del panel, que el fichero habría dejado pasar.
        $this->llamar("3IP-NO-EXISTE")->assertStatus(429);
    }

    public function test_la_ventana_configurada_en_el_panel_es_la_que_se_aplica(): void
    {
        $ajustes = app(\App\Services\System\Settings::class);
        $ajustes->set(\App\Services\System\Settings::LIMITE_FALLOS, "2");
        $ajustes->set(\App\Services\System\Settings::LIMITE_FALLOS_VENTANA, "600");
        $ajustes->olvidar();

        $this->llamar("3IP-NO-EXISTE");
        $this->llamar("3IP-NO-EXISTE");
        $respuesta = $this->llamar("3IP-NO-EXISTE")->assertStatus(429);

        // El "reintenta en N segundos" sale de la ventana, así que es la prueba de que
        // se está usando la del panel y no los 60 s del fichero.
        $this->assertGreaterThan(60, $respuesta->json("data.retry_after"));
    }
}