<?php

namespace App\Livewire\Monitoring;

use App\Models\Monitoring\ApiBlock;
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\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;

/**
 * Tráfico y cortes: quién llama más, quién se pasa de los topes y quién falla al entrar.
 *
 * Implementa `Gestión IPs.dc.html` (Claude Design). **Tercera versión**, y las dos primeras
 * explican esta. La original enseñaba 99 filas que eran una sola cosa, paginadas de 25 en
 * 25. La segunda arregló eso agrupándolas, pero seguía contestando la pregunta de un panel
 * de emergencia —«¿hay alguien sin servicio?»—, y con los límites en observación esa
 * respuesta es siempre que no: una pantalla cuya respuesta no cambia nunca no se mira.
 *
 * Con la decisión de Producto del 2026-09-14 —«al principio queremos observación solo, la
 * idea es no bloquear a nadie»— esto es un panel de **vigilancia**, y lo que se viene a
 * hacer aquí es ser policía. Cinco bloques, de arriba abajo:
 *
 * 1. **El modo**, arriba del todo, porque cambia el significado de todo lo de abajo.
 * 2. **Quién está —o estaría— cortado.** La misma tabla con la pregunta en condicional: en
 *    observación ese número es la medida que autoriza a encender el corte algún día.
 * 3. **¿Está pasando algo raro?**, con pestañas por minuto, hora y día. Sale de
 *    `api_request_logs` y no de las ventanas del limitador: de una IP que nunca rozó el
 *    margen no hay ni una ventana, así que no se podía saber si iba holgada o al borde.
 *    Ver {@see QuienSePasa}.
 * 4. **Los fallos por lo que significan** —autenticación, avería, 200 y sin contenido—,
 *    cada uno con su enlace al visor. Ver {@see FamiliasDeFallo}.
 * 5. **Quién llama más**, con filtros por host y por código que van en la URL.
 *
 * ## Lo que ya no está, y por qué
 *
 * La tabla de grupos plegables con «Revisar los 97…» y «Borrar el grupo…». Revisar era el
 * paso obligatorio para poder borrar, y eso resultó ser un error de fondo: el borrado pedía
 * `state = cleared` cuando lo que hay que proteger es lo que **está cortando**. En pre eso
 * dejaba 101 filas sin salida —ni a mano ni por retención—. Corregida la regla
 * ({@see ApiBlock::scopeBorrable()}), revisar dejó de ser el paso previo de nada.
 *
 * **Permisos, con dos niveles**: ver exige `admin.api-logs.index` —Admin, Support y Manager,
 * porque vigilar es el trabajo de Soporte— y tocar exige `admin.configs.edit`, que es solo
 * de Admin. Liberar un corte rearma un aviso, bloquear a mano corta servicio y borrar no
 * tiene papelera: no es lo mismo mirar que tocar.
 */
class Traffic extends Component
{

    /**
     * Días que abarca el ranking de IPs.
     *
     * **El ranking volvió a propósito.** El rediseño lo había quitado y puesto «Cerca del
     * tope» en su lugar, pero no contestan la misma pregunta: aquel dice *quién va a acabar
     * cortado* —ventanas de un minuto que cruzaron el margen— y este dice **quién está
     * llamando**, cruzado o no. Sin él no quedaba ninguna pantalla que contestara «¿quién
     * nos está dando el tráfico?».
     *
     * Va **plegado**: es contexto, no una alerta. La pantalla abre por lo que hay que
     * atender, y esto se despliega cuando alguien viene con una pregunta de volumen.
     */
    #[Url(as: 'dias', except: 7)]
    public int $dias = 7;

    /** Solo las IPs con peticiones rechazadas, que es donde suele estar el problema. */
    #[Url(as: 'con_errores', except: false)]
    public bool $soloConErrores = false;

    /**
     * El tramo de «¿Está pasando algo raro?»: por minuto, por hora o por día.
     *
     * **Hasta ahora solo existía el minuto**, porque es el único que el limitador apunta. Eso
     * dejaba fuera lo que más se pregunta: un sitio puede no cruzar nunca el tope de un
     * minuto y estar haciendo cuarenta mil peticiones al día, que es otro problema y otra
     * conversación.
     */
    #[Url(as: 'tramo', except: QuienSePasa::MINUTO)]
    public string $tramo = QuienSePasa::MINUTO;

    /**
     * Filtro del ranking por dominio.
     *
     * **Es como se busca a un cliente de verdad**: nadie se acuerda de la IP de nadie, y una
     * incidencia llega como «el sitio de tal está raro».
     */
    #[Url(as: 'host', except: '')]
    public string $host = '';

    /**
     * Filtro del ranking por familia de código: `ok`, `auth`, `err` o todos.
     *
     * **Separa dos preguntas que se hacían con la misma tabla**: quién falla la
     * autenticación —lo que puede ser un tercero probando— y quién llama mucho con todo
     * correcto, que es configuración del cliente.
     */
    #[Url(as: 'codigo', except: '')]
    public string $codigo = '';

    /** ¿Quien mira puede tocar? Lo pregunta la vista en cada botón. */
    public function puedeEditar(): bool
    {
        return Auth::user()?->can('admin.configs.edit') ?? false;
    }

    /* ==================================================================
     * Lo que se ve
     * ================================================================== */

    /**
     * Qué episodios alimentan «¿Quién estaría cortado?»: los abiertos.
     *
     * **Sin filtro, y es a propósito.** La pantalla anterior dejaba elegir entre pendientes,
     * revisados y todos, porque su tabla era el registro entero. Esta contesta una sola
     * pregunta —quién está o estaría sin servicio— y un revisado no entra en ella: la lista
     * completa se mira en el visor de peticiones, que es donde están los filtros.
     */
    private function estado(): string
    {
        return ApiBlock::STATE_BLOCKED;
    }

    /**
     * Los cuatro números con los que se decide todo lo demás.
     *
     * Cacheados 60 segundos: `render()` corre en cada interacción y esto son cuatro
     * agregados. Es el mismo plazo que usan las pestañas de la sección.
     *
     * @return array{cortando: int, observando: int, sujetosObservados: int, pendientes: int}
     */
    public function resumen(): array
    {
        return Cache::remember('monitoring:trafico:resumen', 60, fn () => [
            'cortando' => ApiBlock::blocked()->where('enforced', true)->count(),
            'observando' => ApiBlock::blocked()->where('enforced', false)->count(),
            'sujetosObservados' => ApiBlock::blocked()
                ->where('enforced', false)
                ->distinct()
                ->count(\Illuminate\Support\Facades\DB::raw('coalesce(ip, cast(environment_id as char))')),
            'pendientes' => ApiBlock::blocked()->count(),
        ]);
    }

    /**
     * La frase que abre la pantalla.
     *
     * **Lo primero que se lee tiene que contestar la pregunta con la que se entra**, y esa
     * pregunta es «¿hay alguien sin servicio por nuestra culpa?». Antes lo primero era una
     * tabla de 99 filas.
     *
     * @param  array<string, int>  $resumen
     * @return array{titulo: string, detalle: string, tono: string}
     */
    public function veredicto(array $resumen): array
    {
        if ($resumen['cortando'] > 0) {
            return [
                'titulo' => $resumen['cortando'] === 1
                    ? 'Hay 1 sujeto cortado — un cliente puede estar sin servicio'
                    : 'Hay ' . $resumen['cortando'] . ' sujetos cortados — puede haber clientes sin servicio',
                'detalle' => 'El resto del registro está en observación: se apunta y las peticiones pasan.',
                'tono' => 'malo',
            ];
        }

        if ($resumen['pendientes'] === 0) {
            return [
                'titulo' => 'Nada cortado y nada registrado',
                'detalle' => 'Ningún sujeto ha cruzado un límite. Los umbrales están en '
                    . 'Ajustes del Manager → Bloqueos.',
                'tono' => 'ok',
            ];
        }

        return [
            'titulo' => 'Nada cortado — ' . $resumen['sujetosObservados'] . ' '
                . ($resumen['sujetosObservados'] === 1 ? 'sujeto apuntado' : 'sujetos apuntados')
                . ' en observación',
            'detalle' => 'Los límites están en modo observación: se apunta lo que se habría '
                . 'cortado y todas las peticiones pasan. Nadie está sin servicio por nuestra parte.',
            'tono' => 'aviso',
        ];
    }

    /**
     * «Qué tienes que hacer», que es la mitad del rediseño.
     *
     * **Un veredicto sin tareas obliga a deducir el trabajo de una tabla.** Las tareas se
     * calculan de los mismos datos y dicen, en orden, qué hay que atender y qué no — porque
     * decir «esto no es nada» es tan útil como decir «esto sí»: es lo que evita que alguien
     * dedique la mañana a 97 IPs que no afectan a ningún cliente.
     *
     * @param  array<string, int>  $resumen
     * @param  \Illuminate\Support\Collection<int, array<string, mixed>>  $grupos
     * @return list<array{num: string, tono: string, texto: string, enlace: ?string, cta: ?string}>
     */
    public function tareas(array $resumen, $grupos): array
    {
        if ($resumen['cortando'] === 0 && $resumen['pendientes'] === 0) {
            return [[
                'num' => '✓',
                'tono' => 'ok',
                'texto' => 'Nada. No hay cortes, ni nada apuntado, ni nadie cerca del tope. '
                    . 'Esta pantalla se mira cuando un cliente dice que no recibe servicio o '
                    . 'cuando salta un aviso.',
                'enlace' => null,
                'cta' => null,
            ]];
        }

        $tareas = [];
        $n = 1;

        if ($resumen['cortando'] > 0) {
            $tareas[] = [
                'num' => (string) $n++,
                'tono' => 'malo',
                'texto' => 'Hay ' . $resumen['cortando'] . ' sujeto(s) cortado(s). Si un cliente '
                    . 'dice que no recibe nada, comprueba primero si sale por ahí — y libéralo si '
                    . 'el corte ya no hace falta.',
                'enlace' => null,
                'cta' => null,
            ];
        } else {
            $tareas[] = [
                'num' => (string) $n++,
                'tono' => 'ok',
                'texto' => 'Nadie está cortado: si un cliente dice que no recibe nada, no es por '
                    . 'los límites. Mira sus peticiones en el visor.',
                'enlace' => route('api-logs.index'),
                'cta' => 'Abrir el visor',
            ];
        }

        // **Los grupos que son trabajo van antes que los que no.** Un grupo de licencias
        // caducadas son tareas con nombre; uno de tokens inventados es ruido, y ponerlos al
        // mismo nivel es lo que hacía que el ruido tapara el trabajo.
        foreach ($grupos as $grupo) {
            $tareas[] = [
                'num' => (string) $n++,
                'tono' => $grupo['esTrabajo'] ? 'trabajo' : 'ruido',
                'texto' => $grupo['esTrabajo']
                    ? 'Lo que es trabajo: ' . $grupo['titulo'] . ' — ' . $grupo['queHacer']
                    : $grupo['titulo'] . '. ' . $grupo['queHacer'],
                'enlace' => null,
                'cta' => null,
            ];
        }

        return $tareas;
    }

    /**
     * El ranking de IPs: quién está llamando, y cuánto de eso se rechaza.
     *
     * **Agregado y cacheado 60 segundos.** Sale de `api_request_logs`, que es la tabla que
     * hizo inusable el visor (MGR-027): se apoya en el índice de `started_at`, se limita a
     * 50 filas porque es un ranking y no un listado, y no se recalcula en cada interacción.
     *
     * **Los errores son solo los de verdad**, no todo `>= 400`: un 404 de «no hay contenido
     * publicado» no es motivo para bloquear a nadie, y con el criterio antiguo inflaba el
     * porcentaje de la IP que más llama —que suele ser nuestro propio sitio de pruebas—.
     *
     * Y lleva **dominios distintos**, como las otras dos tablas de la pantalla: es lo que
     * separa un sitio en bucle de un hosting compartido, y sin ese dato el volumen solo no
     * dice si cortar esa IP cortaría a un cliente o a diez.
     *
     * @return \Illuminate\Support\Collection<int, object>
     */
    public function ranking()
    {
        // **Los filtros van en la clave.** Sin ellos, pedir el ranking de un host
        // devolvería el que se cacheó sin filtrar, que es peor que no cachear.
        //
        // Y delante va una versión, porque con cuatro filtros las claves ya no se pueden
        // enumerar para borrarlas: `olvidarResumen()` sube el número y todas las variantes
        // quedan obsoletas de golpe. Sin eso, borrar algo y seguir viéndolo **solo con un
        // filtro puesto** es un fallo que no se reproduce a la primera.
        $clave = 'monitoring:ips:' . self::versionDelRanking() . ':'
            . $this->dias . ':' . ($this->soloConErrores ? '1' : '0')
            . ':' . md5($this->host . '|' . $this->codigo);

        return Cache::remember($clave, 60, fn () => $this->calcularRanking());
    }

    /** Cuántas IPs entran en el ranking. */
    public const CUANTAS_IPS = 15;

    /**
     * Los filtros con los que hay que abrir el visor para ver **lo que esta pantalla está
     * contando**.
     *
     * **Un enlace que promete 720 peticiones y abre una lista vacía es peor que no tener
     * enlace**: lo que uno concluye es que los datos no están, no que hay un filtro puesto.
     * Y el visor trae dos puestos de fábrica que aquí sobran:
     *
     * - **La ventana**: él se abre en 30 días y esta pantalla se mira en 24 h, 7 o 30. Pedir
     *   «las de esta IP en las últimas 24 h» y recibir las de un mes no es lo mismo.
     * - **«Solo sin revisar»**: tiene todo el sentido en su pantalla —es lo que permite
     *   vaciar la lista de trabajo— y ninguno viniendo de aquí, donde se cuenta todo.
     * - **El recorte de entornos apagados**: el visor los esconde salvo que se pida un
     *   entorno concreto, y **esta es la causa más difícil de adivinar**. Una IP que llama
     *   con el token de un entorno dado de baja aparece aquí con sus 720 peticiones y en el
     *   visor con ninguna, sin que nada diga por qué. Este ranking cuenta por IP y no por
     *   entorno, así que aquí el recorte no aplica.
     *
     * @return array<string, string>
     */
    public function filtrosDelVisor(): array
    {
        return [
            'rangePreset' => match ($this->dias) {
                1 => '24h',
                30 => '30d',
                default => '7d',
            },
            // A cero y explícito: el defecto del visor es `true`.
            'onlyUnreviewed' => '0',
            // Y con los entornos apagados dentro, por lo mismo.
            'incluirApagados' => '1',
        ];
    }

    /**
     * El ranking, con lo mismo que enseñan las otras dos tablas.
     *
     * **Tres consultas y no una por fila.** El recuento por IP sale de un `group by`; el
     * último user-agent, de un solo `where id in (max(id) por ip)` —`id` es autoincremental,
     * así que el mayor es el más reciente—; y los dominios, de un `distinct ip, host` que se
     * agrupa en PHP. Con quince filas, pedir el user-agent y los dominios de cada una serían
     * treinta consultas por repintado sobre la tabla que más crece del sistema.
     */
    private function calcularRanking(): \Illuminate\Support\Collection
    {
        $esError = "sum(case when severity = '" . \App\Services\Api\Severidad::ERROR . "' then 1 else 0 end)";

        $filas = \App\Models\ApiRequestLog::query()
            ->select([
                'ip',
                \Illuminate\Support\Facades\DB::raw('count(*) as peticiones'),
                \Illuminate\Support\Facades\DB::raw($esError . ' as errores'),
                \Illuminate\Support\Facades\DB::raw('count(distinct host) as dominios'),
                \Illuminate\Support\Facades\DB::raw('max(started_at) as ultima'),
            ])
            ->where('started_at', '>=', now()->subDays($this->dias))
            ->whereNotNull('ip')
            // El host tal cual lo declara el plugin. Se compara por coincidencia parcial
            // porque nadie escribe el dominio entero de memoria.
            ->when($this->host !== '', fn ($q) => $q->where('host', 'like', '%' . $this->host . '%'))
            ->when($this->codigo !== '', fn ($q) => match ($this->codigo) {
                'ok' => $q->whereBetween('http_status', [200, 299]),
                'auth' => $q->whereIn('http_status', [401, 403]),
                'err' => $q->where('http_status', '>=', 500),
                default => $q,
            })
            ->groupBy('ip')
            ->when($this->soloConErrores, fn ($q) => $q->havingRaw($esError . ' > 0'))
            ->orderByDesc('peticiones')
            ->limit(self::CUANTAS_IPS)
            ->get();

        if ($filas->isEmpty()) {
            return collect();
        }

        $ips = $filas->pluck('ip')->all();
        $desde = now()->subDays($this->dias);

        // Ya tienen episodios apuntados, del estado que sea. **No es lo mismo que «con
        // corte»**: la mayoría de los episodios son de observación y no cortan nada, pero
        // que una IP del ranking ya esté en el registro de arriba es justo lo que hay que
        // ver antes de decidir si su volumen es normal.
        $apuntadas = ApiBlock::query()
            ->where('subject_type', ApiBlock::SUBJECT_IP)
            ->whereIn('ip', $ips)
            ->distinct()
            ->pluck('ip')
            ->all();

        // **Los picos, solo de estas quince.** El ranking ya ha decidido quiénes importan;
        // agrupar por minuto sobre el parque entero para tirar el 99 % sería pagar por lo
        // que no se enseña. Ver `PicosDeTrafico`.
        $picos = PicosDeTrafico::de($ips, $this->dias);
        $topePorMinuto = (int) app(\App\Services\System\Settings::class)->umbralesDeLaApi()['requests'];

        $agentes = \App\Models\ApiRequestLog::query()
            ->whereIn('id', \App\Models\ApiRequestLog::query()
                ->selectRaw('max(id)')
                ->whereIn('ip', $ips)
                ->where('started_at', '>=', $desde)
                ->groupBy('ip'))
            ->pluck('user_agent', 'ip');

        $dominios = \App\Models\ApiRequestLog::query()
            ->select('ip', 'host')
            ->whereIn('ip', $ips)
            ->where('started_at', '>=', $desde)
            ->whereNotNull('host')
            ->distinct()
            ->get()
            ->groupBy('ip');

        return $filas->values()->map(function ($fila, $i) use ($apuntadas, $agentes, $dominios, $picos, $topePorMinuto) {
            $peticiones = (int) $fila->peticiones;
            $errores = (int) $fila->errores;

            $suyos = $dominios->get($fila->ip, collect())->pluck('host');
            $sobran = $suyos->count() - CortesAgrupados::DOMINIOS_EN_EL_TITULO;

            return [
                'pos' => $i + 1,
                'ip' => $fila->ip,
                'peticiones' => $peticiones,
                'errores' => $errores,
                'porcentaje' => $peticiones > 0 ? (int) round(($errores / $peticiones) * 100) : 0,
                'dominios' => (int) $fila->dominios,
                'dominiosTexto' => $suyos->isEmpty()
                    ? null
                    : $suyos->take(CortesAgrupados::DOMINIOS_EN_EL_TITULO)->implode(' · ')
                        . ($sobran > 0 ? ' · y más' : ''),
                'userAgent' => $agentes->get($fila->ip),
                'apuntada' => in_array($fila->ip, $apuntadas, true),
                // **El pico, que es lo que distingue mucho tráfico de un bucle.** El
                // total no lo dice: 720 en una semana son cuatro por hora y 720 en un
                // minuto es otra cosa.
                'picoMinuto' => $picos[$fila->ip]['minuto'] ?? 0,
                'picoHora' => $picos[$fila->ip]['hora'] ?? 0,
                'picoDia' => $picos[$fila->ip]['dia'] ?? 0,
                // Llegó a hacer más peticiones en un minuto de reloj que el tope. **No
                // es lo mismo que estar cortada**: el limitador cuenta sus propias
                // ventanas, que empiezan con la primera petición. Esto es vigilancia.
                'pasoElTope' => PicosDeTrafico::pasoElTope($picos[$fila->ip]['minuto'] ?? 0, $topePorMinuto),
                'topePorMinuto' => $topePorMinuto,
                'ultima' => $fila->ultima,
            ];
        });
    }

    public function updatedDias(): void
    {
        $this->dias = in_array((int) $this->dias, [1, 7, 30], true) ? (int) $this->dias : 7;
    }

    /* ==================================================================
     * Acciones
     * ================================================================== */


    /** La limpieza del registro: por tabla, fechas, host e IP. */
    public function abrirLimpieza(): void
    {
        $this->authorize('admin.configs.edit');

        $this->dispatch('openModal', component: LimpiezaDeCortesModal::class);
    }

    /** Bloquear una IP a mano, o liberar un corte: las dos ya existían. */
    public function abrirBloqueo(string $ip = ''): void
    {
        $this->authorize('admin.configs.edit');

        $this->dispatch('openModal',
            component: IpBlockModal::class,
            arguments: ['ip' => $ip]
        );
    }

    public function pedirLiberar(int $bloqueoId): void
    {
        $this->authorize('admin.configs.edit');

        $bloqueo = ApiBlock::findOrFail($bloqueoId);

        $this->dispatch('openModal',
            component: \App\Livewire\Components\ConfirmModal::class,
            arguments: [
                'itemId' => $bloqueoId,
                'itemName' => $bloqueo->sujeto_texto,
                'title' => 'Liberar ' . $bloqueo->sujeto_texto,
                'message' => 'Cierra la incidencia de «' . $bloqueo->sujeto_texto . '».' . "\n\n"
                    . ($bloqueo->reason === ApiBlock::REASON_MANUAL
                        ? 'Este bloqueo es **manual**, así que liberarlo **levanta el corte**: '
                          . 'esa IP volverá a poder llamar a la API desde ya.'
                        : 'Este bloqueo es **automático**: el corte se levanta solo al pasar la '
                          . 'ventana del límite, así que esto **no** cambia lo que recibe ahora '
                          . 'mismo — cierra la incidencia.') . "\n\n"
                    . 'Si vuelve a cruzar el umbral se registrará un episodio nuevo y se avisará '
                    . 'de nuevo.',
                'confirmText' => 'Liberar',
                'confirmButtonColor' => 'blue',
                'eventName' => 'apiBlockClearConfirmed',
            ]
        );
    }

    /**
     * El receptor de la confirmación de liberar.
     *
     * **Sin parámetros tipados**: `ConfirmModal` despacha un único argumento array y
     * Livewire intentaría inyectar por tipo, con un TypeError. Ver MGR-042.
     */
    #[On('apiBlockClearConfirmed')]
    public function liberarConfirmado($datos = null): void
    {
        $this->authorize('admin.configs.edit');

        $id = is_array($datos) ? ($datos['itemId'] ?? null) : null;

        if ($id === null) {
            return;
        }

        $bloqueo = ApiBlock::findOrFail((int) $id);
        $bloqueo->liberar(Auth::id());

        $this->olvidarResumen();

        session()->flash('success', 'Corte de ' . $bloqueo->sujeto_texto . ' liberado.'
            . ($bloqueo->reason === ApiBlock::REASON_MANUAL
                ? ' Esa IP vuelve a poder llamar a la API.'
                : ' El corte automático se levanta solo al pasar la ventana del límite.'));
    }

    /**
     * Tras bloquear, revisar o borrar desde una modal, la pantalla se vuelve a pintar.
     *
     * **Solo lo llaman las modales.** Hubo un botón de «Recalcular» en la cabecera y se
     * quitó: se puso creyendo que el ranking enseñaba datos rancios, y lo que pasaba era
     * otra cosa —aquellas peticiones nunca llegaron al registro, se rechazaron antes—. Lo
     * que quedaba del botón era tirar una caché de sesenta segundos que se pasa sola.
     */
    #[On('cortesCambiados')]
    #[On('apiBlockCreated')]
    public function refrescar(): void
    {
        $this->olvidarResumen();
    }

    /* ==================================================================
     * Apoyo
     * ================================================================== */


    /**
     * Tira las cachés de la pantalla después de tocar datos.
     *
     * **El ranking también, y faltaba.** Se guarda un minuto por combinación de ventana y
     * filtro (`monitoring:ips:…`), así que después de borrar peticiones la tabla seguía
     * enseñando IPs y recuentos de filas que ya no existían. El síntoma es desconcertante:
     * el ranking dice 720 y el visor no encuentra ninguna.
     */

    private function olvidarResumen(): void
    {
        Cache::forget('monitoring:trafico:resumen');
        Cache::forget('monitoring:estado');
        Cache::forget('monitoring:portada');

        // El ranking, por versión: con los filtros dentro de la clave ya no hay una lista
        // finita que recorrer.
        Cache::forever(self::CLAVE_VERSION, self::versionDelRanking() + 1);
    }

    /** La clave donde vive el número de versión del ranking. */
    private const CLAVE_VERSION = 'monitoring:ips:version';

    /**
     * La versión actual del ranking cacheado.
     *
     * `forever` y no un plazo: si caducara, dos pantallas podrían leer versiones distintas y
     * una de ellas volvería a servir lo viejo.
     */
    private static function versionDelRanking(): int
    {
        return (int) Cache::get(self::CLAVE_VERSION, 1);
    }

    /** Rastro de lo que se borra: sin esto, un borrado definitivo no deja constancia. */
    private function registrar(string $codigo, string $mensaje): void
    {
        \App\Models\Monitoring\Log::db(
            'warning',
            $codigo,
            $mensaje . ' (por ' . (Auth::user()?->name ?? 'desconocido') . ')',
            'ApiBlock',
            null
        );
    }

    /**
     * Quién se pasó del tope en el tramo elegido.
     *
     * **El tope de hora y de día todavía no existe**: solo hay `limits:requests`, que es por
     * minuto. Para los otros dos se pasa 0, y entonces no se filtra por nada: se enseña quién
     * más llega, que es lo único honesto que se puede decir sin un umbral acordado. La
     * pantalla lo dice con todas las letras en vez de fabricar un «por hora = por minuto ×
     * 60» y presentarlo como si alguien lo hubiera decidido.
     */
    public function quienSePasa(Settings $ajustes): array
    {
        return QuienSePasa::enElTramo($this->tramo, $this->dias, $this->topeDelTramo($ajustes));
    }

    /** El tope configurado del tramo que se está mirando; 0 si no hay ninguno. */
    public function topeDelTramo(Settings $ajustes): int
    {
        return $this->tramo === QuienSePasa::MINUTO
            ? (int) $ajustes->umbralesDeLaApi()['requests']
            : 0;
    }

    /** Cambia de tramo sin recargar la pantalla entera. */
    public function verTramo(string $tramo): void
    {
        if (isset(QuienSePasa::NOMBRES[$tramo])) {
            $this->tramo = $tramo;
        }
    }

    /** Quita los filtros del ranking. */
    public function limpiarFiltros(): void
    {
        $this->host = '';
        $this->codigo = '';
    }

    public function render(Settings $ajustes)
    {
        $resumen = $this->resumen();

        // Los grupos ya no se pintan como tales: de ellos salen los sujetos de la tabla de
        // arriba y las tareas de «qué tienes que hacer».
        $grupos = CortesAgrupados::para($this->estado())
            ->map(fn ($grupo) => array_merge($grupo, $this->comoSeAtiende($grupo)));

        return view('livewire.monitoring.traffic', [
            'resumen' => $resumen,
            'veredicto' => $this->veredicto($resumen),
            'grupos' => $grupos,
            'tareas' => $this->tareas($resumen, $grupos),
            // Los que cortan de verdad van aparte y sin agrupar: son pocos y cada uno es
            // una decisión.
            'cortando' => ApiBlock::blocked()
                ->with(['environment.client', 'clearedBy'])
                ->where('enforced', true)
                ->orderByDesc('last_blocked_at')
                ->get(),
            // **El margen de aviso, con el porcentaje de verdad.** La pantalla decía «la
            // mitad del tope», y eso solo es cierto mientras nadie toque el ajuste: es
            // `warning_ratio` y se configura. Un texto que se vuelve falso al cambiar una
            // opción es peor que no tenerlo.
            'margen' => (int) round($ajustes->umbralesDeLaApi()['warning_ratio'] * 100),
            // Las tres piezas del diseño nuevo.
            'sePasan' => $this->quienSePasa($ajustes),
            'topeDelTramo' => $this->topeDelTramo($ajustes),
            'familias' => FamiliasDeFallo::de($this->dias),
            // El ranking va plegado: es contexto, no una alerta. Ver `ranking()`.
            'ranking' => $ranking = $this->ranking(),
            // Qué IPs del ranking tienen un corte **activo**, para no ofrecer bloquear dos
            // veces. Es distinto de `apuntada`, que solo dice que hay episodios suyos.
            'yaBloqueadas' => $ranking->isEmpty() ? [] : ApiBlock::blocked()
                ->where('subject_type', ApiBlock::SUBJECT_IP)
                ->whereIn('ip', $ranking->pluck('ip'))
                ->pluck('ip')
                ->all(),
            'corta' => $ajustes->losLimitesCortan(),
            'puedeEditar' => $this->puedeEditar(),
            'retencion' => [
                'episodios' => $ajustes->diasDeRetencionDeCortes(),
                'ventanas' => $ajustes->diasDeRetencionDeVentanas(),
            ],
        ])->layout('layouts.app');
    }

    /**
     * Si un grupo es trabajo o es ruido, y qué hacer con él.
     *
     * **Es el juicio que la pantalla tiene que dar y antes no daba.** Un grupo de tokens
     * inventados y uno de licencias caducadas se pintaban igual, y el primero —que no afecta
     * a ningún cliente— tapaba al segundo, que son clientes a los que hay que llamar.
     *
     * El juicio sale del motivo del corte, no de su tamaño: 97 IPs escaneando siguen sin ser
     * trabajo, y un solo entorno con la licencia caducada sí lo es.
     *
     * @param  array<string, mixed>  $grupo
     * @return array{esTrabajo: bool, queHacer: string}
     */
    private function comoSeAtiende(array $grupo): array
    {
        // Por licencia: el sujeto es un entorno nuestro, con cliente y contrato detrás.
        if ($grupo['subject_type'] === ApiBlock::SUBJECT_ENVIRONMENT) {
            return [
                'esTrabajo' => true,
                'queHacer' => 'Cada sujeto es un cliente con algo pendiente de decidir: '
                    . 'despliega y mira quiénes son. No los borres — desaparecen solos cuando se '
                    . 'resuelva.',
            ];
        }

        if ($grupo['reason'] === ApiBlock::REASON_MANUAL) {
            return [
                'esTrabajo' => true,
                'queHacer' => 'Alguien lo bloqueó a propósito. Compruébalo antes de liberarlo: '
                    . 'el motivo está en la nota del corte.',
            ];
        }

        // Y las IPs: el porqué está en el desglose, y lo que se puede hacer es archivar.
        return [
            'esTrabajo' => false,
            'queHacer' => 'La API las rechaza bien y ningún cliente se queda sin servicio. '
                . '«Revisar» los archiva; «Borrar» los quita del registro.',
        ];
    }
}
