<?php

namespace Tests\Feature\Monitoring;

use App\Models\ApiRequestLog;
use App\Models\Clients\Client;
use App\Models\Environments\Environment;
use App\Models\Monitoring\ApiBlock;
use App\Models\Monitoring\ApiRateEvent;
use App\Services\Api\Motivo;
use App\Services\System\CercaDelTope;
use App\Services\System\CortesAgrupados;
use App\Services\System\FamiliasDeFallo;
use App\Services\System\PicosDeTrafico;
use App\Services\System\QuienSePasa;
use App\Services\System\LimpiezaDeCortes;
use App\Services\System\Settings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

/**
 * Los datos de la pantalla de tráfico: agrupar, avisar y borrar.
 *
 * **El problema que resuelven.** La pantalla listaba `api_blocks` fila a fila y en pre eso
 * son 99 filas que son una sola cosa: IPs de salida de AWS que cruzaron el tope de
 * respuestas 401/403, todas con el mismo motivo y todas en observación. Y de esa tabla solo
 * se podía «revisar» un episodio: nada la borraba, nada la caducaba, y un episodio revisado
 * que vuelve a pasar **crea una fila nueva**.
 *
 * Lo que se fija aquí:
 *
 * 1. Que agrupar cuente lo que dice —episodios y sujetos son cosas distintas—.
 * 2. Que el grupo diga **por qué** se les rechazó, cruzando con el registro de peticiones.
 * 3. Que borrar **nunca** se lleve un corte activo.
 */
class CortesAgrupadosTest extends TestCase
{
    use RefreshDatabase;

    /* ===================== Quién se pasa, por tramo ===================== */

    public function test_con_tope_solo_salen_los_que_se_pasan(): void
    {
        // Cinco peticiones en el mismo minuto contra un tope de 3: se pasa. Otra IP con una
        // sola, no.
        for ($i = 0; $i < 5; $i++) {
            $this->peticionEn('203.0.113.41', now()->subMinutes(5));
        }

        $this->peticionEn('203.0.113.42', now()->subMinutes(5));

        $filas = QuienSePasa::enElTramo(QuienSePasa::MINUTO, 7, 3);

        $this->assertCount(1, $filas);
        $this->assertSame('203.0.113.41', $filas[0]['ip']);
        $this->assertSame(5, $filas[0]['pico']);
        $this->assertSame(1, $filas[0]['cruces'], 'Un solo minuto por encima.');
    }

    public function test_los_cruces_cuentan_tramos_distintos_no_peticiones(): void
    {
        // **La diferencia que importa**: un pico alto una vez es un incidente; pasarse en
        // tres minutos distintos es un comportamiento.
        foreach ([5, 30, 90] as $haceMinutos) {
            for ($i = 0; $i < 4; $i++) {
                $this->peticionEn('203.0.113.43', now()->subMinutes($haceMinutos));
            }
        }

        $filas = QuienSePasa::enElTramo(QuienSePasa::MINUTO, 7, 3);

        $this->assertSame(4, $filas[0]['pico'], 'El pico sigue siendo cuatro.');
        $this->assertSame(3, $filas[0]['cruces'], 'Pero se pasó en tres minutos distintos.');
    }

    public function test_sin_tope_no_se_filtra_y_no_se_inventan_cruces(): void
    {
        // **Es el caso de «por hora» y «por día»**, donde no hay ajuste. Se enseña lo que
        // más hace cada uno y se dice que no hay tope, en vez de fabricar un umbral que
        // nadie ha acordado y presentarlo como si lo hubieran acordado.
        $this->peticionEn('203.0.113.44', now()->subHours(2));

        $filas = QuienSePasa::enElTramo(QuienSePasa::HORA, 7, 0);

        $this->assertCount(1, $filas, 'Sale aunque no se pase de nada.');
        $this->assertNull($filas[0]['cruces'], 'Sin tope no hay cruces que contar.');
    }

    public function test_los_dominios_separan_un_bucle_de_un_hosting(): void
    {
        // Con muchos dominios detrás de una IP, el arreglo es subir el tope: cortarla
        // cortaría a varios clientes a la vez.
        foreach (['uno.example', 'dos.example', 'tres.example'] as $i => $host) {
            $this->peticionEn('203.0.113.45', now()->subMinutes(5 + $i), $host);
        }

        $filas = QuienSePasa::enElTramo(QuienSePasa::MINUTO, 7, 0);

        $this->assertCount(3, $filas[0]['dominios']);
    }

    /* ===================== Las familias de fallo ===================== */

    public function test_lo_que_no_es_un_error_se_cuenta_aparte(): void
    {
        // **Es la familia que más daño hace mezclada**: «sin contenido» es una respuesta
        // correcta, y sumada al montón infla el porcentaje de fallos de cualquier sitio que
        // todavía no tiene contenido publicado.
        $this->peticionEn('203.0.113.46', now()->subHour());
        $this->peticionEn('203.0.113.46', now()->subHour(), null, 401, \App\Services\Api\Severidad::ERROR);
        $this->peticionEn('203.0.113.46', now()->subHour(), null, 404, \App\Services\Api\Severidad::SIN_CONTENIDO);

        $familias = collect(FamiliasDeFallo::de(7))->keyBy('clave');

        $this->assertSame(1, $familias['auth']['cuantas']);
        $this->assertSame(1, $familias['ok']['cuantas']);
        $this->assertSame(1, $familias['sincontenido']['cuantas']);
        $this->assertSame(0, $familias['averia']['cuantas']);
    }

    /* ===================== Los picos: quién va más deprisa ===================== */

    public function test_el_pico_por_minuto_distingue_mucho_trafico_de_un_bucle(): void
    {
        // **Es la razón de la tabla.** Dos IPs con el mismo total: una repartida por la
        // semana y otra concentrada en un minuto. El total las pinta iguales y no lo son.
        $repartida = '203.0.113.31';
        $enBucle = '203.0.113.32';

        for ($i = 0; $i < 10; $i++) {
            $this->peticionEn($repartida, now()->subHours($i * 5));
            $this->peticionEn($enBucle, now()->subMinutes(3));
        }

        $picos = PicosDeTrafico::de([$repartida, $enBucle], 7);

        $this->assertSame(1, $picos[$repartida]['minuto'], 'Una por minuto: no pasa nada.');
        $this->assertSame(10, $picos[$enBucle]['minuto'], 'Diez en el mismo minuto: eso sí.');
    }

    public function test_los_tres_tramos_se_cuentan_por_separado(): void
    {
        $ip = '203.0.113.33';

        // Cuatro en un minuto, y dos más en otra hora del mismo día.
        for ($i = 0; $i < 4; $i++) {
            $this->peticionEn($ip, now()->subMinutes(10));
        }

        $this->peticionEn($ip, now()->subHours(3));
        $this->peticionEn($ip, now()->subHours(3));

        $picos = PicosDeTrafico::de([$ip], 7);

        $this->assertSame(4, $picos[$ip]['minuto']);
        $this->assertSame(4, $picos[$ip]['hora'], 'La mejor hora contiene ese minuto.');
        $this->assertSame(6, $picos[$ip]['dia'], 'Y el día, las seis.');
    }

    public function test_fuera_de_la_ventana_no_cuenta(): void
    {
        $ip = '203.0.113.34';

        $this->peticionEn($ip, now()->subDays(20));

        $this->assertSame(0, PicosDeTrafico::de([$ip], 7)[$ip]['minuto']);
        $this->assertSame(1, PicosDeTrafico::de([$ip], 30)[$ip]['minuto']);
    }

    public function test_se_marca_quien_paso_del_tope_por_minuto(): void
    {
        // **No es lo mismo que estar cortado**, y por eso es un aviso y no un veredicto: el
        // limitador cuenta sus propias ventanas, que empiezan con la primera petición.
        $this->assertTrue(PicosDeTrafico::pasoElTope(601, 600));
        $this->assertFalse(PicosDeTrafico::pasoElTope(600, 600));
        $this->assertFalse(PicosDeTrafico::pasoElTope(999, 0), 'Sin tope no se marca nada.');
    }

    /**
     * Una petición del registro **en un momento concreto**.
     *
     * Con su propio nombre: el `peticion()` de más abajo escribe siempre a la misma hora, que
     * le vale para lo suyo y no sirve aquí — la hora exacta es justo lo que miden los picos.
     */
    private function peticionEn(
        string $ip,
        \DateTimeInterface $cuando,
        ?string $host = null,
        int $estado = 200,
        string $severidad = \App\Services\Api\Severidad::OK
    ): void
    {
        ApiRequestLog::insert([
            'request_uuid' => (string) \Illuminate\Support\Str::uuid(),
            'method' => 'POST',
            'path' => 'api/v1',
            'auth_type' => 'bearer',
            'ip' => $ip,
            'action' => 'licence',
            'host' => $host,
            'http_status' => $estado,
            'severity' => $severidad,
            'success' => $estado < 400,
            'started_at' => $cuando,
            'ended_at' => $cuando,
            // No admite nulo: toda petición registrada tardó algo.
            'duration_ms' => 20,
        ]);
    }

    /* ===================== Vaciar el registro entero ===================== */

    public function test_vaciar_el_registro_se_lleva_todo_lo_cerrado_y_las_ventanas(): void
    {
        // **Era el hueco.** La retención solo sabe borrar «lo anterior a N días», y N=0 no
        // borra nada porque 0 significa «sin retención»: no había forma de dejar las dos
        // tablas a cero, ni por comando ni desde el panel.
        $this->episodio(['ip' => '203.0.113.20', 'last_blocked_at' => now()->subMinutes(5)]);
        $this->episodio(['ip' => '203.0.113.21', 'last_blocked_at' => now()->subDays(400)]);
        $this->ventana(['ip' => '203.0.113.20']);
        $this->ventana(['ip' => '203.0.113.21']);

        $borrados = LimpiezaDeCortes::todo();

        $this->assertSame(2, $borrados['episodios'], 'Sin mirar la antigüedad.');
        $this->assertSame(2, $borrados['ventanas']);
        $this->assertSame(0, ApiBlock::count());
        $this->assertSame(0, ApiRateEvent::count());
    }

    public function test_vaciar_no_levanta_un_corte_activo(): void
    {
        // **La regla que no se negocia.** Si sigue cortando, su fila no es histórico: es el
        // estado de lo que está pasando, y borrarla dejaría el corte aplicándose sin nada
        // en pantalla que lo explicara.
        $this->episodio(['ip' => '203.0.113.22', 'state' => ApiBlock::STATE_BLOCKED, 'enforced' => true]);
        $this->episodio(['ip' => '203.0.113.23', 'state' => ApiBlock::STATE_CLEARED]);

        $borrados = LimpiezaDeCortes::todo();

        $this->assertSame(1, $borrados['episodios']);
        $this->assertSame(1, $borrados['activos']);
        $this->assertSame(1, ApiBlock::count(), 'El activo se queda.');
        $this->assertSame(ApiBlock::STATE_BLOCKED, ApiBlock::first()->state);
    }

    public function test_el_comando_de_vaciar_deja_rastro_y_no_se_combina(): void
    {
        $this->episodio(['ip' => '203.0.113.24']);

        // Vaciar a mano es una decisión, no una tarea: tiene que quedar escrito.
        $this->artisan('api-blocks:purge --all')->assertSuccessful();

        $this->assertSame(0, ApiBlock::count());
        $this->assertSame(
            1,
            \App\Models\Monitoring\Log::where('code', '16040')->count(),
            'Sin rastro, el día que falte un episodio no hay forma de saber quién se lo llevó.'
        );

        $this->artisan('api-blocks:purge --all --episodios=30')->assertFailed();
    }

    /* ===================== Limpiar el ruido de un sujeto ya resuelto ===================== */

    public function test_limpiar_un_sujeto_se_lleva_tambien_sus_ventanas(): void
    {
        // **Era el ruido que no se iba.** El episodio y las ventanas son dos registros del
        // mismo suceso, no uno calculado del otro: borrando solo el episodio, el sujeto
        // seguía saliendo en «Cerca del tope», que lee las ventanas. Se revisaba, se
        // borraba, y seguía ahí.
        $this->episodio(['ip' => '203.0.113.9']);
        // Minutos distintos: la tabla tiene un único por (ip, ventana, límite), y con
        // razón — una ventana de un minuto de un límite es una, no dos.
        $this->ventana(['ip' => '203.0.113.9']);
        $this->ventana(['ip' => '203.0.113.9', 'window_started_at' => now()->subHours(2)->startOfMinute()]);

        // De otra IP, que no se toca.
        $this->ventana(['ip' => '203.0.113.8']);

        $resultado = LimpiezaDeCortes::sujeto('203.0.113.9', null);

        $this->assertSame(1, $resultado['borrados']);
        $this->assertSame(2, $resultado['ventanas']);
        $this->assertSame(0, ApiRateEvent::where('ip', '203.0.113.9')->count());
        $this->assertSame(1, ApiRateEvent::where('ip', '203.0.113.8')->count(), 'la otra IP no se toca');
    }

    public function test_si_le_queda_un_corte_abierto_sus_ventanas_se_quedan(): void
    {
        // **Lo que sigue pasando no es histórico.** Borrar las ventanas de un sujeto con el
        // corte todavía abierto escondería el problema en la pantalla que existe para
        // verlo.
        $this->episodio(['ip' => '203.0.113.7', 'state' => ApiBlock::STATE_CLEARED]);
        $this->episodio(['ip' => '203.0.113.7', 'state' => ApiBlock::STATE_BLOCKED, 'enforced' => true]);
        $this->ventana(['ip' => '203.0.113.7']);

        $resultado = LimpiezaDeCortes::sujeto('203.0.113.7', null);

        $this->assertSame(1, $resultado['borrados']);
        $this->assertSame(1, $resultado['activos']);
        $this->assertSame(0, $resultado['ventanas']);
        $this->assertSame(1, ApiRateEvent::where('ip', '203.0.113.7')->count());
    }

    public function test_limpiar_un_grupo_se_lleva_las_ventanas_de_su_motivo(): void
    {
        // Solo las del motivo del grupo: una IP puede estar cruzando dos límites a la vez y
        // limpiar uno no resuelve el otro.
        $this->episodio(['ip' => '203.0.113.6']);
        $this->ventana(['ip' => '203.0.113.6', 'limit_kind' => ApiRateEvent::LIMIT_FAILURES]);
        $this->ventana(['ip' => '203.0.113.6', 'limit_kind' => ApiRateEvent::LIMIT_REQUESTS]);

        $resultado = LimpiezaDeCortes::grupo(ApiBlock::REASON_FAILURES, false, ApiBlock::SUBJECT_IP);

        $this->assertSame(1, $resultado['episodios']);
        $this->assertSame(1, $resultado['ventanas']);
        $this->assertSame(
            1,
            ApiRateEvent::where('ip', '203.0.113.6')->where('limit_kind', ApiRateEvent::LIMIT_REQUESTS)->count(),
            'la ventana del otro límite se queda'
        );
    }

    /** Una ventana del limitador, para los tests de limpieza. */
    private function ventana(array $datos = []): ApiRateEvent
    {
        return ApiRateEvent::create(array_merge([
            'ip' => '203.0.113.1',
            'window_started_at' => now()->subHour()->startOfMinute(),
            'limit_kind' => ApiRateEvent::LIMIT_FAILURES,
            'kind' => ApiRateEvent::KIND_WARNING,
            'observed' => 15,
            'limit_value' => 20,
            'rejected' => 0,
        ], $datos));
    }

    private function episodio(array $datos = []): ApiBlock
    {
        return ApiBlock::create(array_merge([
            'subject_type' => ApiBlock::SUBJECT_IP,
            'ip' => '203.0.113.1',
            'reason' => ApiBlock::REASON_FAILURES,
            'state' => ApiBlock::STATE_CLEARED,
            'enforced' => false,
            'first_blocked_at' => now()->subHours(3),
            'last_blocked_at' => now()->subHour(),
            'hits' => 10,
            'limit_value' => 20,
        ], $datos));
    }

    /**
     * Peticiones de una IP, unas buenas y otras rechazadas.
     *
     * Hacen falta las dos: el diagnóstico de un sujeto es **la proporción**, así que un
     * helper que solo cree rechazos no podría probarlo.
     */
    private function peticionesDe(string $ip, int $buenas, int $malas, string $host): void
    {
        foreach (range(1, max(0, $buenas)) as $i) {
            if ($buenas === 0) {
                break;
            }

            $this->fila($ip, $host, 200, Motivo::NINGUNO);
        }

        foreach (range(1, max(0, $malas)) as $i) {
            if ($malas === 0) {
                break;
            }

            $this->fila($ip, $host, 401, Motivo::TOKEN_DESCONOCIDO);
        }
    }

    private function fila(string $ip, ?string $host, int $status, string $motivo): void
    {
        ApiRequestLog::create([
            'request_uuid' => \Illuminate\Support\Str::uuid()->toString(),
            'method' => 'POST',
            'path' => '/api/v1',
            'auth_type' => 'bearer',
            'ip' => $ip,
            'host' => $host,
            'started_at' => now()->subHours(2),
            'ended_at' => now()->subHours(2),
            'http_status' => $status,
            'error_code' => $status,
            'reason' => $motivo,
            'duration_ms' => 20,
        ]);
    }

    private function peticion(string $ip, string $motivo, int $cuantas = 1): void
    {
        foreach (range(1, $cuantas) as $i) {
            ApiRequestLog::create([
                'request_uuid' => \Illuminate\Support\Str::uuid()->toString(),
                'method' => 'POST',
                'path' => '/api/v1',
                'auth_type' => 'bearer',
                'ip' => $ip,
                'started_at' => now()->subHours(2),
                'ended_at' => now()->subHours(2),
                'http_status' => 401,
                'error_code' => 401,
                'error' => 'Token no válido',
                'reason' => $motivo,
                'duration_ms' => 20,
            ]);
        }
    }

    /* ===================== Agrupar ===================== */

    public function test_noventa_y_nueve_ips_del_mismo_motivo_son_un_grupo(): void
    {
        // El caso literal de pre. Antes eran 99 filas en cuatro páginas.
        foreach (range(1, 99) as $i) {
            $this->episodio(['ip' => '203.0.113.' . $i, 'hits' => $i]);
        }

        $grupos = CortesAgrupados::para(ApiBlock::STATE_CLEARED);

        $this->assertCount(1, $grupos);
        $this->assertSame(99, $grupos->first()['sujetos']);
        $this->assertSame(99, $grupos->first()['episodios']);
        $this->assertStringContainsString('99 IPs', $grupos->first()['titulo']);
    }

    public function test_los_episodios_no_son_los_sujetos(): void
    {
        // **La distinción que hace que el número no engañe.** Un corte revisado que vuelve
        // a pasar deja una fila nueva, así que la misma IP puede tener varias.
        $this->episodio(['ip' => '203.0.113.1']);
        $this->episodio(['ip' => '203.0.113.1']);
        $this->episodio(['ip' => '203.0.113.2']);

        $grupo = CortesAgrupados::para(ApiBlock::STATE_CLEARED)->first();

        $this->assertSame(3, $grupo['episodios']);
        $this->assertSame(2, $grupo['sujetos'], 'tres episodios de dos IPs');
    }

    public function test_lo_que_corta_y_lo_que_solo_observa_son_grupos_distintos(): void
    {
        // No son grados de lo mismo: cortando significa que hay clientes sin servicio.
        $this->episodio(['ip' => '203.0.113.1', 'enforced' => true]);
        $this->episodio(['ip' => '203.0.113.2', 'enforced' => false]);

        $grupos = CortesAgrupados::para(ApiBlock::STATE_CLEARED);

        $this->assertCount(2, $grupos);
        $this->assertSame(['CORTANDO', 'EN OBSERVACIÓN'], $grupos->pluck('modo')->sort()->values()->all());
    }

    public function test_los_grupos_salen_por_rechazos_y_no_por_numero_de_sujetos(): void
    {
        // 97 IPs con 6 rechazos importan menos que 12 entornos con 340.
        foreach (range(1, 20) as $i) {
            $this->episodio(['ip' => '203.0.113.' . $i, 'hits' => 1]);
        }

        $this->episodio([
            'subject_type' => ApiBlock::SUBJECT_ENVIRONMENT,
            'ip' => null,
            'environment_id' => $this->entorno()->id,
            'reason' => ApiBlock::REASON_TOKEN,
            'hits' => 500,
        ]);

        $grupos = CortesAgrupados::para(ApiBlock::STATE_CLEARED);

        $this->assertSame(500, $grupos->first()['rechazos'], 'primero el que más rechaza');
    }

    public function test_solo_se_enseñan_cinco_sujetos_y_el_resto_se_cuenta_con_su_rango(): void
    {
        // «Y 92 más» no dice si son 6 rechazos o 600, y eso es justo lo que hay que saber.
        foreach (range(1, 10) as $i) {
            $this->episodio(['ip' => '203.0.113.' . $i, 'hits' => $i * 10]);
        }

        $grupo = CortesAgrupados::para(ApiBlock::STATE_CLEARED)->first();

        $this->assertCount(CortesAgrupados::SUJETOS_VISIBLES, $grupo['filas']);
        $this->assertSame(100, $grupo['filas']->first()['rechazos'], 'el que más, primero');
        $this->assertStringContainsString('Y 5 IPs más', $grupo['cola']);
        $this->assertStringContainsString('entre 10 y 50 rechazos', $grupo['cola']);
    }

    /* ===================== El porqué ===================== */

    public function test_el_grupo_dice_por_que_se_les_rechazo(): void
    {
        // **Es lo que convierte el grupo en una decisión.** «4.812 rechazos» no dice si es
        // un escaneo o son clientes; «el 96 % con un token que no existe» sí.
        $this->episodio(['ip' => '203.0.113.1', 'hits' => 100]);

        $this->peticion('203.0.113.1', Motivo::TOKEN_DESCONOCIDO, 24);
        $this->peticion('203.0.113.1', Motivo::LICENCIA_CADUCADA, 1);

        $grupo = CortesAgrupados::para(ApiBlock::STATE_CLEARED)->first();

        $this->assertStringContainsString('96 %', $grupo['desglose']);
        $this->assertStringContainsString('token que no existe', $grupo['desglose']);
    }

    public function test_si_el_registro_ya_no_tiene_esas_peticiones_no_se_inventa_un_porcentaje(): void
    {
        // La retención del registro de peticiones es más corta que la de los cortes, así
        // que llega un día en que no hay con qué cruzar. Decir «el 0 %» sería peor.
        $this->episodio(['ip' => '203.0.113.1', 'hits' => 100]);

        $grupo = CortesAgrupados::para(ApiBlock::STATE_CLEARED)->first();

        $this->assertStringContainsString('No quedan peticiones en el registro', $grupo['desglose']);
    }

    /* ===================== El diagnóstico de cada sujeto ===================== */

    public function test_cada_sujeto_dice_cuantas_llamo_y_que_proporcion_se_rechazo(): void
    {
        // **Los dos números juntos son el diagnóstico.** «608 rechazos» puede ser un
        // escaneo o un hosting con mucho tráfico legítimo; lo que los separa es la
        // proporción.
        $this->episodio(['ip' => '203.0.113.1', 'hits' => 100]);

        // Cien llamadas, noventa rechazadas: un sitio en bucle.
        $this->peticionesDe('203.0.113.1', buenas: 10, malas: 90, host: 'https://uno.test');

        $fila = CortesAgrupados::para(ApiBlock::STATE_CLEARED)->first()['filas']->first();

        $this->assertSame(100, $fila['peticiones']);
        $this->assertSame(90, $fila['rechazadas']);
        $this->assertSame(90, $fila['porcentaje']);
    }

    public function test_cada_sujeto_dice_cuantos_dominios_distintos_ha_usado(): void
    {
        // **Es lo que separa el escaneo del hosting compartido.** Un dominio y muchas
        // rechazadas es un sitio en bucle; cuatro dominios y pocas rechazadas es un
        // servidor compartido, y ahí cortar la IP cortaría a varios clientes.
        $this->episodio(['ip' => '203.0.113.2', 'hits' => 10]);

        foreach (['https://a.test', 'https://b.test', 'https://c.test'] as $host) {
            $this->peticionesDe('203.0.113.2', buenas: 5, malas: 1, host: $host);
        }

        $fila = CortesAgrupados::para(ApiBlock::STATE_CLEARED)->first()['filas']->first();

        $this->assertSame(3, $fila['dominios']);
        $this->assertStringContainsString('a.test', $fila['dominiosTexto']);
        $this->assertStringContainsString('c.test', $fila['dominiosTexto']);
    }

    public function test_un_sujeto_sin_peticiones_en_el_registro_no_finge_porcentaje(): void
    {
        // La retención del registro es más corta que la de los cortes: llega el día en que
        // el episodio sigue y sus peticiones ya no están.
        $this->episodio(['ip' => '203.0.113.3', 'hits' => 50]);

        $fila = CortesAgrupados::para(ApiBlock::STATE_CLEARED)->first()['filas']->first();

        $this->assertSame(0, $fila['peticiones']);
        $this->assertNull($fila['porcentaje'], 'null y no 0: no es «el 0 %», es «no se sabe»');
        $this->assertNull($fila['dominiosTexto']);
    }

    /* ===================== Cerca del tope ===================== */

    public function test_cerca_del_tope_sale_del_maximo_de_la_ventana_y_no_de_la_suma(): void
    {
        // El límite se cuenta por ventana de un minuto: sumarlas daría un número que no se
        // parece a nada de lo que decide el limitador.
        foreach ([40, 86, 12] as $i => $observado) {
            ApiRateEvent::create([
                'ip' => '203.0.113.7',
                'window_started_at' => now()->subHours($i + 1),
                'limit_kind' => ApiRateEvent::LIMIT_REQUESTS,
                'kind' => ApiRateEvent::KIND_WARNING,
                'observed' => $observado,
                'limit_value' => 100,
                'rejected' => 0,
            ]);
        }

        $this->fila('203.0.113.7', 'https://x.test', 401, Motivo::TOKEN_DESCONOCIDO);

        $fila = CercaDelTope::ultimasHoras()->first();

        $this->assertSame(1, $fila['dominios'], 'y de cuántos dominios viene ese tráfico');
        $this->assertSame(86, $fila['maximo']);
        $this->assertSame(3, $fila['ventanas']);
        $this->assertSame(86, $fila['porcentaje']);
        $this->assertTrue($fila['apremia']);
    }

    public function test_un_maximo_por_encima_del_tope_no_desborda_la_barra(): void
    {
        ApiRateEvent::create([
            'ip' => '203.0.113.8',
            'window_started_at' => now()->subMinutes(10),
            'limit_kind' => ApiRateEvent::LIMIT_REQUESTS,
            'kind' => ApiRateEvent::KIND_BLOCKED,
            'observed' => 140,
            'limit_value' => 100,
            'rejected' => 40,
        ]);

        $this->assertSame(100, CercaDelTope::ultimasHoras()->first()['porcentaje']);
    }

    public function test_lo_de_hace_dos_dias_ya_no_avisa(): void
    {
        ApiRateEvent::create([
            'ip' => '203.0.113.9',
            'window_started_at' => now()->subDays(2),
            'limit_kind' => ApiRateEvent::LIMIT_REQUESTS,
            'kind' => ApiRateEvent::KIND_WARNING,
            'observed' => 90,
            'limit_value' => 100,
            'rejected' => 0,
        ]);

        $this->assertCount(0, CercaDelTope::ultimasHoras());
    }

    /* ===================== Borrar ===================== */

    public function test_un_corte_activo_no_se_borra(): void
    {
        // **La regla que no se negocia.** Borrar la fila no levanta el corte —el contador
        // vive en la caché del limitador—, así que lo único que se conseguiría es perder de
        // vista algo que sigue cortando.
        $activo = $this->episodio(['state' => ApiBlock::STATE_BLOCKED, 'enforced' => true]);

        $this->assertFalse(LimpiezaDeCortes::episodio($activo));
        $this->assertNotNull($activo->fresh());
    }

    public function test_un_episodio_revisado_se_borra_y_no_hay_papelera(): void
    {
        $revisado = $this->episodio();

        $this->assertTrue(LimpiezaDeCortes::episodio($revisado));
        $this->assertDatabaseMissing('api_blocks', ['id' => $revisado->id]);
    }

    public function test_borrar_por_sujeto_respeta_los_activos_y_lo_dice(): void
    {
        // Quien borra los episodios de una IP quiere quitar el histórico; que uno esté
        // activo no debería hacer fallar los otros.
        $this->episodio(['ip' => '203.0.113.5']);
        $this->episodio(['ip' => '203.0.113.5']);
        $this->episodio(['ip' => '203.0.113.5', 'state' => ApiBlock::STATE_BLOCKED, 'enforced' => true]);
        $this->episodio(['ip' => '203.0.113.6']);

        $resultado = LimpiezaDeCortes::sujeto('203.0.113.5', null);

        $this->assertSame(2, $resultado['borrados']);
        $this->assertSame(1, $resultado['activos']);
        $this->assertSame(1, ApiBlock::where('ip', '203.0.113.5')->count(), 'queda el activo');
        $this->assertSame(1, ApiBlock::where('ip', '203.0.113.6')->count(), 'la otra IP no se toca');
    }

    public function test_borrar_un_grupo_se_lleva_solo_los_revisados_de_ese_grupo(): void
    {
        $this->episodio(['ip' => '203.0.113.1']);
        $this->episodio(['ip' => '203.0.113.2']);
        $this->episodio(['ip' => '203.0.113.3', 'state' => ApiBlock::STATE_BLOCKED, 'enforced' => true]);
        $this->episodio(['ip' => '203.0.113.4', 'reason' => ApiBlock::REASON_REQUESTS]);

        $resultado = LimpiezaDeCortes::grupo(ApiBlock::REASON_FAILURES, false, ApiBlock::SUBJECT_IP);

        // Devuelve las dos cifras: los episodios y las ventanas de esos mismos sujetos,
        // que son las que dejaban al sujeto saliendo en «Cerca del tope» después de
        // haberlo dado por resuelto.
        $this->assertSame(2, $resultado['episodios']);
        $this->assertSame(2, ApiBlock::count(), 'el activo y el de otro motivo se quedan');
    }

    public function test_borrar_por_rango_se_lleva_tambien_las_ventanas(): void
    {
        $this->episodio(['last_blocked_at' => now()->subDays(40)]);
        $this->episodio(['last_blocked_at' => now()->subDays(2)]);

        ApiRateEvent::create([
            'ip' => '203.0.113.1', 'window_started_at' => now()->subDays(40),
            'limit_kind' => ApiRateEvent::LIMIT_REQUESTS, 'kind' => ApiRateEvent::KIND_WARNING,
            'observed' => 60, 'limit_value' => 100, 'rejected' => 0,
        ]);

        $resultado = LimpiezaDeCortes::porRango(now()->subDays(60), now()->subDays(30));

        $this->assertSame(1, $resultado['episodios']);
        $this->assertSame(1, $resultado['ventanas']);
        $this->assertSame(1, ApiBlock::count(), 'el de hace dos días se queda');
    }

    public function test_el_rango_se_mide_por_cuando_dejo_de_pasar(): void
    {
        // Un episodio abierto en junio que siguió recibiendo rechazos en agosto no es de
        // junio: lo que decide si es viejo es cuándo dejó de pasar.
        $this->episodio([
            'first_blocked_at' => now()->subDays(90),
            'last_blocked_at' => now()->subDays(2),
        ]);

        $resultado = LimpiezaDeCortes::porRango(now()->subDays(120), now()->subDays(30));

        $this->assertSame(0, $resultado['episodios']);
    }

    public function test_se_puede_contar_antes_de_borrar_y_dice_lo_que_no_se_borrara(): void
    {
        $this->episodio(['last_blocked_at' => now()->subDays(40)]);
        $this->episodio(['last_blocked_at' => now()->subDays(40)]);
        $this->episodio(['last_blocked_at' => now()->subDays(40), 'state' => ApiBlock::STATE_BLOCKED, 'enforced' => true]);

        $cuenta = LimpiezaDeCortes::contarPorRango(now()->subDays(60), now()->subDays(30));

        $this->assertSame(2, $cuenta['episodios']);
        $this->assertSame(1, $cuenta['pendientes'], 'se dice: son los que se quedan');
    }

    public function test_la_retencion_a_cero_no_borra_nada(): void
    {
        // Es lo que permite desplegar la tarea antes de decidir el plazo, igual que en la
        // purga del registro de peticiones.
        $this->episodio(['last_blocked_at' => now()->subDays(400)]);

        $resultado = LimpiezaDeCortes::porRetencion(0, 0);

        $this->assertSame(['episodios' => 0, 'ventanas' => 0], $resultado);
        $this->assertSame(1, ApiBlock::count());
    }

    public function test_la_retencion_tiene_dos_plazos_distintos(): void
    {
        // Un episodio revisado es una incidencia que alguien atendió; una ventana del
        // limitador es telemetría de un minuto. No caducan igual.
        $this->episodio(['last_blocked_at' => now()->subDays(45)]);

        ApiRateEvent::create([
            'ip' => '203.0.113.1', 'window_started_at' => now()->subDays(45),
            'limit_kind' => ApiRateEvent::LIMIT_REQUESTS, 'kind' => ApiRateEvent::KIND_WARNING,
            'observed' => 60, 'limit_value' => 100, 'rejected' => 0,
        ]);

        $resultado = LimpiezaDeCortes::porRetencion(diasDeEpisodios: 90, diasDeVentanas: 30);

        $this->assertSame(0, $resultado['episodios'], 'a los 45 días todavía no toca');
        $this->assertSame(1, $resultado['ventanas'], 'pero la ventana sí');
    }

    /* ============ La tarea programada que aplica esa retención ============ */

    public function test_la_tarea_esta_registrada_en_el_planificador(): void
    {
        // **El fallo de esta clase de trabajo es dejar el ajuste sin la tarea**: la
        // retención se puede configurar en el panel y no la aplica nadie, así que las dos
        // tablas siguen creciendo mientras la pantalla dice que hay un plazo puesto.
        $comandos = collect(app(\Illuminate\Console\Scheduling\Schedule::class)->events())
            ->map(fn ($evento) => $evento->command)
            ->implode(' | ');

        $this->assertStringContainsString('api-blocks:purge', $comandos);
        $this->assertStringContainsString('--retention', $comandos);
    }

    public function test_sin_retencion_configurada_la_tarea_no_borra_nada(): void
    {
        // Con la retención a 0 la tarea corre cada noche y no toca nada. **Se pone a mano
        // porque ya no es el valor de fábrica**: desde la migración de los ajustes de
        // retención, los episodios cerrados caducan a los 180 días y las ventanas a los 30.
        // El 0 sigue existiendo y sigue significando «no borrar», que es lo que se comprueba.
        app(Settings::class)->set(Settings::CORTES_RETENCION, '0');
        app(Settings::class)->set(Settings::VENTANAS_RETENCION, '0');

        $this->episodio(['last_blocked_at' => now()->subDays(400)]);

        $this->artisan('api-blocks:purge --retention')
            ->expectsOutputToContain('No hay retención configurada')
            ->assertSuccessful();

        $this->assertSame(1, ApiBlock::count());
    }

    public function test_con_retencion_configurada_la_tarea_borra_y_deja_rastro(): void
    {
        app(Settings::class)->set(Settings::CORTES_RETENCION, '90');

        $viejo = $this->episodio(['last_blocked_at' => now()->subDays(400)]);
        $reciente = $this->episodio(['last_blocked_at' => now()->subDays(10)]);

        $this->artisan('api-blocks:purge --retention')->assertSuccessful();

        $this->assertNull(ApiBlock::find($viejo->id));
        $this->assertNotNull($reciente->fresh());

        // Nadie está delante cuando corre, así que si no queda escrito no hay forma de
        // saber si un episodio lo borró la retención o alguien a mano.
        $this->assertDatabaseHas('logs', ['code' => '16040']);
    }

    public function test_lo_apuntado_en_observacion_se_puede_borrar(): void
    {
        // **El fallo que este test fija, y que llegó a pre.** Todos los caminos de borrado
        // pedían `state = cleared` —«solo lo revisado»—, pero un episodio en observación
        // nace en `blocked` y ahí se queda: no corta a nadie y nadie iba a revisar 101 a
        // mano. Resultado: la modal decía «0 se borran, 101 no» y no había ninguna salida,
        // ni a mano ni por retención. La tabla solo podía crecer.
        $this->episodio([
            'ip' => '203.0.113.91',
            'state' => ApiBlock::STATE_BLOCKED,
            'enforced' => false,
        ]);

        $impacto = LimpiezaDeCortes::contarPorCriterios([
            'desde' => now()->subDays(7),
            'hasta' => now(),
        ]);

        $this->assertSame(1, $impacto['episodios'], 'apuntado en observación: es histórico');
        $this->assertSame(0, $impacto['pendientes'], 'y no se queda fuera, porque no corta a nadie');

        LimpiezaDeCortes::porCriterios(['desde' => now()->subDays(7), 'hasta' => now()]);

        $this->assertSame(0, ApiBlock::count());
    }

    public function test_pero_el_que_esta_cortando_sigue_sin_borrarse(): void
    {
        // La otra mitad de la misma regla: lo que se protege es lo que rompe algo al
        // desaparecer de la pantalla, y eso es un corte aplicándose.
        $this->episodio([
            'ip' => '203.0.113.92',
            'state' => ApiBlock::STATE_BLOCKED,
            'enforced' => true,
        ]);

        $impacto = LimpiezaDeCortes::contarPorCriterios([
            'desde' => now()->subDays(7),
            'hasta' => now(),
        ]);

        $this->assertSame(0, $impacto['episodios']);
        $this->assertSame(1, $impacto['pendientes'], 'y se dice, para que no parezca que se lo lleva todo');

        LimpiezaDeCortes::porCriterios(['desde' => now()->subDays(7), 'hasta' => now()]);

        $this->assertSame(1, ApiBlock::count());
    }

    public function test_la_retencion_tambien_se_lleva_lo_apuntado_en_observacion(): void
    {
        // Si no, la tarea diaria no serviría de nada en modo observación: pasaría cada noche
        // sobre una tabla que no puede tocar.
        app(Settings::class)->set(Settings::CORTES_RETENCION, '30');

        $this->episodio([
            'ip' => '203.0.113.93',
            'state' => ApiBlock::STATE_BLOCKED,
            'enforced' => false,
            'last_blocked_at' => now()->subDays(400),
        ]);

        $this->artisan('api-blocks:purge --retention')->assertSuccessful();

        $this->assertSame(0, ApiBlock::count());
    }

    public function test_la_tarea_no_borra_un_corte_activo_por_viejo_que_sea(): void
    {
        // **Mientras esté cortando, esa fila es la que explica por qué un cliente no recibe
        // servicio.** Borrarla dejaría el corte en pie sin nada que lo justifique.
        app(Settings::class)->set(Settings::CORTES_RETENCION, '30');

        $activo = $this->episodio([
            'state' => ApiBlock::STATE_BLOCKED,
            // Cortando de verdad: en observacion la fila es historico y si se borra.
            'enforced' => true,
            'last_blocked_at' => now()->subDays(400),
        ]);

        $this->artisan('api-blocks:purge --retention')->assertSuccessful();

        $this->assertNotNull($activo->fresh(), 'un corte activo no se borra nunca');
    }

    public function test_la_tarea_puede_decir_que_borraria_sin_borrarlo(): void
    {
        $this->episodio(['last_blocked_at' => now()->subDays(400)]);
        $this->episodio(['state' => ApiBlock::STATE_BLOCKED, 'enforced' => true, 'last_blocked_at' => now()->subDays(400)]);

        $this->artisan('api-blocks:purge --episodios=90 --dry-run')
            ->expectsOutputToContain('Nada se ha borrado')
            ->expectsOutputToContain('no se tocan')
            ->assertSuccessful();

        $this->assertSame(2, ApiBlock::count());
    }

    public function test_la_tarea_exige_saber_que_borrar(): void
    {
        // Sin plazo no hay consulta: seguir adelante dejaría un `delete` sin `where`.
        $this->artisan('api-blocks:purge')->assertFailed();
    }

    private function entorno(): Environment
    {
        $cliente = Client::create(['name' => 'C', 'shortname' => 'c' . uniqid(), 'actived' => true]);

        return Environment::create([
            'client_id' => $cliente->id,
            'name' => 'Sitio',
            'domain' => 'https://' . uniqid() . '.test',
            'version' => '5.1.1',
            'env' => 'pro',
            'active' => true,
        ]);
    }
}
