<?php

namespace App\Livewire\ApiLogs;

use App\Models\ApiRequestLog;
use App\Models\Clients\Client;
use App\Services\Api\Motivo;
use App\Services\Api\Severidad;
use App\Models\Environments\Environment;
use App\Models\Products\LicenseToken;
use App\Services\System\ApiLogPurger;
use App\Support\ConsultaAcotada;
use App\Support\SugerenciasDeClientes;
use App\Support\SugerenciasDeEntornos;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithPagination;

class Index extends Component
{
    use WithPagination;

    /**
     * Si la última consulta no llegó a tiempo y se cortó.
     *
     * Propiedades del componente y no variables de la vista: la plantilla las lee, el
     * aviso depende de ellas y **se pueden probar**. Se recalculan en cada `render()`, así
     * que no arrastran el estado de la petición anterior.
     */
    public bool $indicadoresAgotados = false;

    public bool $listadoAgotado = false;

    public string $rangePreset = '30d'; // 24h, 7d, 30d, custom
    public ?string $dateFrom = null;
    public ?string $dateTo = null;

    /**
     * Por qué severidad se está mirando. Vacío es **todo**, y es como se abre.
     *
     * Una sola severidad y no varias a la vez: las cuatro no son grados de lo mismo —«sin
     * contenido» y «no contratado» son respuestas correctas—, así que combinarlas no
     * responde ninguna pregunta real.
     *
     * **Se abre en «todo» a petición del usuario (2026-09-10).** Estuvo un día abriéndose
     * en `error`, con el argumento de que la pantalla existe para arreglar cosas; el
     * argumento contrario pesa más y es el que manda: **el registro es un registro**, y una
     * pantalla que se abre recortando obliga a acordarse del recorte para interpretar
     * cualquier cifra —«no hay peticiones de este sitio» puede querer decir «no hay errores
     * de este sitio»—. Llegar a los errores es un clic; darse cuenta de que faltaba tráfico
     * no lo es.
     *
     * Los chips de arriba siguen diciendo cuántas hay de cada severidad, así que «solo
     * errores» está igual de cerca que antes lo estaba «todo».
     */
    public ?string $severity = self::SEVERIDAD_POR_DEFECTO;

    /** Con qué severidad se abre la pantalla: con ninguna, o sea con todas. */
    public const SEVERIDAD_POR_DEFECTO = '';

    /**
     * **Por qué** falló, de una lista cerrada. Null es «cualquier motivo».
     *
     * La severidad dice si hay que arreglarlo; el motivo dice de quién es el trabajo, y esa
     * era la pregunta que no se podía contestar: el `403` del middleware es plano, así que
     * **ocho situaciones distintas salían como «No contratado»** —licencia caducada, que
     * renueva Comercial; entorno apagado, que reactivamos nosotros; y host no reconocido,
     * que es un alta mal hecha y contaba como respuesta correcta—. Ver {@see Motivo}.
     *
     * Uno solo y no varios, igual que la severidad: son excluyentes por definición —cada
     * petición falló por una cosa— y combinarlos no responde ninguna pregunta.
     */
    public ?string $reason = null;

    /**
     * Enseñar solo lo que nadie ha mirado todavía.
     *
     * Es la mitad del sentido de marcar como visto: la otra mitad es poder sacar de
     * la lista lo que ya se ha revisado, para que la lista se pueda vaciar.
     *
     * **Y arranca puesto.** Marcar como visto solo sirve para algo si lo visto se va de
     * en medio: con el defecto en «se ven todas», revisar una petición no cambiaba nada
     * de lo que se tenía delante y la lista no se podía vaciar nunca.
     *
     * Se quita con el chip «Sin revisar» de la barra, que dice si está puesto — y el
     * aviso de debajo cuenta cuántas está escondiendo, para que no sea un recorte
     * invisible. Ver `vistosOcultos()`.
     */
    public bool $onlyUnreviewed = true;

    public ?int $clientId = null;
    public ?int $environmentId = null;
    public bool $onlyWithoutEnvironment = false;

    /**
     * Contar también las peticiones de los entornos apagados.
     *
     * **Por defecto no se cuentan.** Un entorno apagado es un sitio que ya no está en
     * servicio —una baja, una migración, un alta que no llegó a nada—, y su Moodle puede
     * seguir llamando durante meses. Todas esas llamadas se llevan un error, así que
     * salían en el visor como averías por arreglar y en «sitios con errores» por delante
     * de los sitios de verdad. **La lista de trabajo apuntaba a sitios que ya no se
     * atienden.**
     *
     * Esto no es esconder datos: es que el histórico de un sitio dado de baja no responde
     * a la pregunta con la que se abre esta pantalla. Y sigue estando entero —nada se
     * borra—, así que hay tres formas de verlo y todas explícitas:
     *
     * 1. Este interruptor, que dice cuántas filas está escondiendo antes de tocarlo.
     * 2. Elegir ese sitio en el selector: **pedir un sitio concreto manda sobre el
     *    recorte**, incluso si está apagado. Si no, entrar desde la ficha de un entorno de
     *    baja daría una pantalla vacía sin decir por qué, que es el peor resultado posible.
     * 3. La limpieza del histórico, que borra «lo que se está viendo» y por tanto respeta
     *    el recorte: no se lleva por delante lo que no se está mirando.
     */
    public bool $incluirApagados = false;
    public ?int $licenseTokenId = null;
    public ?string $action = null;
    public array $httpStatuses = [];
    public ?int $durationMin = null;
    public ?int $durationMax = null;
    public ?int $responseSizeMin = null;
    public ?int $responseSizeMax = null;

    public ?string $ip = null;
    public ?string $requestUuid = null;
    public ?string $host = null;

    /**
     * Lo que se está escribiendo en los tres selectores buscables.
     *
     * **No van a la URL**, igual que el buscador de plugins del listado de entornos: lo
     * que se comparte es qué cliente o qué host se ha elegido, no lo que alguien estaba
     * teclando para encontrarlo. En un enlace sería ruido que además reabre el desplegable.
     */
    public string $clientSearch = '';

    public string $environmentSearch = '';

    public string $hostSearch = '';

    /** Cuántas sugerencias se ofrecen a la vez. Doce entran en pantalla sin desplazar. */
    public const SUGERENCIAS = 12;

    protected $queryString = [
        'rangePreset' => ['except' => '30d'],
        // **Con `except`, que ahora hace falta**: los dos tienen defecto distinto de vacío,
        // así que sin esto la pantalla recién abierta arrastraría `severity=error` y
        // `onlyUnreviewed=1` en cada enlace que se comparta.
        'severity' => ['except' => self::SEVERIDAD_POR_DEFECTO],
        'reason',
        'onlyUnreviewed' => ['except' => true],
        'dateFrom',
        'dateTo',
        'clientId',
        'environmentId',
        'onlyWithoutEnvironment',
        'incluirApagados',
        'licenseTokenId',
        'action',
        'durationMin',
        'durationMax',
        'responseSizeMin',
        'responseSizeMax',
        'ip',
        'requestUuid',
        'host',
    ];

    public function mount(): void
    {
        $this->authorize('admin.api-logs.index');
        $this->applyDefaultRange();
    }

    protected function applyDefaultRange(): void
    {
        if ($this->rangePreset === 'custom' && $this->dateFrom && $this->dateTo) {
            return;
        }
        $end = Carbon::now();
        $start = match ($this->rangePreset) {
            '24h' => $end->copy()->subHours(24),
            '7d' => $end->copy()->subDays(7),
            '30d' => $end->copy()->subDays(30),
            default => $end->copy()->subDays(30),
        };
        $this->dateFrom = $start->format('Y-m-d\TH:i');
        $this->dateTo = $end->format('Y-m-d\TH:i');
    }

    #[Computed]
    public function appliedFiltersCounts(): array
    {
        $clienteEntorno = 0;
        if ($this->clientId !== null && $this->clientId !== '') {
            $clienteEntorno++;
        }
        if ($this->environmentId !== null && $this->environmentId !== '') {
            $clienteEntorno++;
        }
        if ($this->licenseTokenId !== null && $this->licenseTokenId !== '') {
            $clienteEntorno++;
        }
        if ($this->onlyWithoutEnvironment) {
            $clienteEntorno++;
        }
        // **Cuenta puesto solo cuando amplía**, no cuando recorta: el recorte es el
        // defecto de la pantalla, y si contara, el botón de quitar filtros estaría siempre
        // encendido y dejaría de significar nada.
        if ($this->incluirApagados) {
            $clienteEntorno++;
        }
        if ($this->host !== null && $this->host !== '') {
            $clienteEntorno++;
        }

        // **Se comparan con el defecto, no con «vacío».** La pantalla abre con «errores sin
        // revisar», y si eso contara como dos filtros puestos, el aviso de «estás viendo un
        // recorte» estaría encendido siempre y dejaría de significar nada — que es
        // exactamente lo que pasa con el recorte de los apagados y con el del listado de
        // entornos.
        $peticionRespuesta = 0;
        if ($this->severity !== self::SEVERIDAD_POR_DEFECTO) {
            $peticionRespuesta++;
        }
        if ($this->reason !== null && $this->reason !== '') {
            $peticionRespuesta++;
        }
        if (! $this->onlyUnreviewed) {
            $peticionRespuesta++;
        }
        if ($this->action !== null && $this->action !== '') {
            $peticionRespuesta++;
        }
        if (! empty($this->httpStatuses)) {
            $peticionRespuesta++;
        }

        $avanzados = 0;
        if (is_string($this->ip) && trim($this->ip) !== '') {
            $avanzados++;
        }
        if (is_string($this->requestUuid) && trim($this->requestUuid) !== '') {
            $avanzados++;
        }
        if ($this->durationMin !== null) {
            $avanzados++;
        }
        if ($this->durationMax !== null) {
            $avanzados++;
        }
        if ($this->responseSizeMin !== null) {
            $avanzados++;
        }
        if ($this->responseSizeMax !== null) {
            $avanzados++;
        }

        return [
            'total' => $clienteEntorno + $peticionRespuesta + $avanzados,
            'cliente_entorno' => $clienteEntorno,
            'peticion_respuesta' => $peticionRespuesta,
            'avanzados' => $avanzados,
        ];
    }

    /**
     * Cuántas filas está escondiendo el recorte de los vistos.
     *
     * **El mismo principio que con los entornos apagados**: el defecto recorta, así que el
     * recorte se dice. Si no, una lista corta se lee como «ya casi no hay nada» cuando lo
     * que pasa es que alguien marcó treinta como vistas.
     *
     * Se cuenta **sobre el resto de los filtros puestos**, severidad incluida: la pregunta
     * es «cuántas más saldrían aquí mismo», no cuántas revisadas hay en el rango.
     */
    public function vistosOcultos(): int
    {
        if (! $this->onlyUnreviewed) {
            return 0;
        }

        // **Sin el filtro de revisión y con lo contrario puesto a mano**: `baseQuery()` no
        // se puede «deshacer» un `where`, así que se pide la consulta sin ese filtro y se
        // acota a las revisadas. Es el mismo mecanismo que el `$conSeveridad` de los chips.
        return (int) $this->baseQuery(conFiltroDeVistos: false)
            ->whereNotNull('reviewed_at')
            ->count();
    }

    /**
     * ¿Está puesto el recorte de los apagados?
     *
     * **Pedir un sitio concreto manda sobre el recorte.** Si se ha elegido un entorno o un
     * host, la pregunta ya no es «qué hay que arreglar en el parque» sino «qué le pasa a
     * este», y responder con una pantalla vacía porque el sitio está de baja sería el peor
     * resultado posible: no dice que esté apagado, dice que no ha llamado nunca.
     */
    public function recortaApagados(): bool
    {
        if ($this->incluirApagados) {
            return false;
        }

        return $this->environmentId === null
            && ($this->host === null || $this->host === '');
    }

    /**
     * Los ids de los entornos apagados.
     *
     * `withTrashed()` porque **un entorno apagado y borrado sigue apagado**: sin él, el
     * ámbito de `SoftDeletes` lo dejaría fuera de esta lista y sus peticiones volverían a
     * contarse justo en el caso más claro de sitio que ya no se atiende.
     *
     * Borrado y activo, en cambio, **sí cuenta**: el recorte es el que se ha pedido —los
     * apagados—, y borrar la ficha de un entorno no es lo mismo que dar el sitio de baja.
     * Si algún día hace falta, se añade aquí y se dice en la pantalla.
     *
     * Se calculan **una vez por petición** —propiedad protegida, que Livewire no guarda en
     * la instantánea—: `baseQuery()` se llama una docena de veces por `render()` entre los
     * indicadores y el listado. Es un array y no una subconsulta porque la tabla de
     * entornos tiene veintiuna filas: el `IN` sale con los ids dentro y el plan de la
     * consulta grande no depende de una correlación.
     *
     * @return list<int>
     */
    protected function entornosApagados(): array
    {
        return $this->entornosApagados ??= Environment::withTrashed()
            ->where('active', false)
            ->pluck('id')
            ->map(fn ($id) => (int) $id)
            ->all();
    }

    /** @var list<int>|null */
    protected ?array $entornosApagados = null;

    /**
     * El rango activo, `[desde, hasta]`.
     *
     * **El `endOfMinute` no es un detalle: sin él, lo que acaba de pasar no se ve.** Los
     * dos extremos se guardan como `Y-m-d\TH:i` —es lo que admite un `datetime-local`, con
     * precisión de minuto—, así que `Carbon::parse('…14:32')` da las 14:32:**00**. Con el
     * preset «últimos 30 días», `applyDefaultRange()` pone el final en `now()`, que se
     * escribe truncado: el rango terminaba al principio del minuto en curso y **las
     * peticiones de los últimos 0-59 segundos quedaban fuera**.
     *
     * Se veía como «he arreglado el sitio, su Moodle ya ha llamado y el visor no lo
     * enseña» — y al minuto siguiente aparecía sola, que es la clase de comportamiento que
     * hace desconfiar de la pantalla entera. Se cazó al comprobar que un rechazo del
     * middleware quedaba atribuido: la fila estaba escrita y el listado no la traía.
     *
     * Y vale igual para un rango escrito a mano: quien pone «hasta las 14:32» quiere las
     * peticiones de las 14:32, no las de hasta las 14:32:00.
     */
    public function getDateRange(): array
    {
        $this->applyDefaultRange();
        $from = Carbon::parse($this->dateFrom);
        $to = Carbon::parse($this->dateTo)->endOfMinute();

        return [$from, $to];
    }

    public function getPreviousDateRange(): array
    {
        [$from, $to] = $this->getDateRange();
        $length = $from->diffInSeconds($to);
        $prevEnd = $from->copy()->subSecond();
        $prevStart = $prevEnd->copy()->subSeconds($length);
        return [$prevStart, $prevEnd];
    }

    /**
     * @param  bool  $conSeveridad  A false, ignora el filtro de severidad.
     *                              **Lo necesitan los contadores de los chips**: si se
     *                              calcularan con el filtro puesto, al pulsar «Errores»
     *                              el chip «Todo» pasaría a decir el número de errores.
     */
    protected function baseQuery(
        bool $conSeveridad = true,
        bool $conRecorteDeApagados = true,
        bool $conMotivo = true,
        bool $conFiltroDeVistos = true
    )
    {
        [$from, $to] = $this->getDateRange();
        $q = ApiRequestLog::query()->whereBetween('started_at', [$from, $to]);

        // **Fuera los entornos apagados**, salvo que se pidan o se pida uno concreto. Ver
        // `$incluirApagados` y `recortaApagados()`. Va aquí arriba a propósito: por debajo
        // hay quince filtros y este es el único que recorta sin que nadie lo haya pulsado,
        // así que tiene que leerse antes que ellos.
        if ($conRecorteDeApagados && $this->recortaApagados()) {
            $apagados = $this->entornosApagados();

            if ($apagados !== []) {
                // El paréntesis y el `whereNull` no son adorno: `environment_id NOT IN (…)`
                // con `environment_id` nulo evalúa a NULL, no a cierto, así que sin esto se
                // llevaría por delante **las peticiones de host desconocido** — que son
                // justo las que más importan y no son de ningún entorno apagado.
                $q->where(fn ($sub) => $sub
                    ->whereNull('environment_id')
                    ->orWhereNotIn('environment_id', $apagados));
            }
        }

        if ($this->clientId) {
            $q->where('client_id', $this->clientId);
        }
        if ($this->environmentId) {
            $q->where('environment_id', $this->environmentId);
        }
        if ($this->onlyWithoutEnvironment) {
            $q->whereNull('environment_id');
        }
        if ($this->licenseTokenId) {
            $q->where('license_token_id', $this->licenseTokenId);
        }
        if ($conFiltroDeVistos && $this->onlyUnreviewed) {
            $q->whereNull('reviewed_at');
        }
        // La severidad la guarda cada fila al insertarse: filtrar por ella es un
        // `where` sobre una columna, no un cálculo.
        if ($conSeveridad && $this->severity !== null && $this->severity !== '') {
            $q->where('severity', $this->severity);
        }
        // El `$conMotivo` es lo mismo que el `$conSeveridad` y por el mismo motivo: el
        // reparto por motivos se calcula **sin** este filtro, o al elegir uno los demás
        // pasarían a cero y el reparto dejaría de ser un reparto.
        if ($conMotivo && $this->reason !== null && $this->reason !== '') {
            $q->where('reason', $this->reason);
        }
        if ($this->action !== null && $this->action !== '') {
            $q->where('action', $this->action);
        }
        if ($this->httpStatuses !== []) {
            $q->whereIn('http_status', array_map('intval', $this->httpStatuses));
        }
        if ($this->durationMin !== null && $this->durationMin !== '') {
            $q->where('duration_ms', '>=', (int) $this->durationMin);
        }
        if ($this->durationMax !== null && $this->durationMax !== '') {
            $q->where('duration_ms', '<=', (int) $this->durationMax);
        }
        if ($this->responseSizeMin !== null && $this->responseSizeMin !== '') {
            $q->where('response_size', '>=', (int) $this->responseSizeMin);
        }
        if ($this->responseSizeMax !== null && $this->responseSizeMax !== '') {
            $q->where('response_size', '<=', (int) $this->responseSizeMax);
        }
        if ($this->ip !== null && $this->ip !== '') {
            $q->where('ip', 'like', '%' . $this->ip . '%');
        }
        if ($this->requestUuid !== null && $this->requestUuid !== '') {
            $q->where('request_uuid', 'like', '%' . $this->requestUuid . '%');
        }
        if ($this->host !== null && $this->host !== '') {
            $q->where('host', $this->host);
        }

        return $q;
    }

    public function resetFilters(): void
    {
        $this->rangePreset = '30d';
        $this->applyDefaultRange();
        $this->clientId = null;
        $this->environmentId = null;
        $this->licenseTokenId = null;
        $this->action = null;
        $this->onlyWithoutEnvironment = false;
        $this->incluirApagados = false;
        $this->httpStatuses = [];
        $this->durationMin = null;
        $this->durationMax = null;
        $this->responseSizeMin = null;
        $this->responseSizeMax = null;
        $this->ip = null;
        $this->requestUuid = null;
        $this->host = null;
        // **Al defecto de la pantalla, no a vacío**: al entrar se ven los errores sin
        // revisar, y «quitar filtros» tiene que dejar eso mismo. Para verlo todo están
        // los chips, que dicen cuántas hay de cada cosa.
        $this->severity = self::SEVERIDAD_POR_DEFECTO;
        $this->reason = null;
        $this->onlyUnreviewed = true;
        // Los buscadores también: dejar escrito «acme» con el filtro ya quitado hace
        // pensar que sigue puesto.
        $this->clientSearch = '';
        $this->environmentSearch = '';
        $this->hostSearch = '';
        $this->resetPage();
    }

    /**
     * Filtra por severidad desde los chips de la tabla.
     *
     * Pulsar el que ya está puesto lo quita: es lo que se espera de un chip, y evita
     * dejar al usuario encerrado en «solo errores» sin ver cómo salir.
     *
     * **«Todo» es la cadena vacía y no `null`, y no es indiferente.** Desde que la pantalla
     * abre en `error`, el atributo de la URL lleva `except: 'error'`, y con eso **un `null`
     * no sobrevive al viaje**: al no viajar en la URL, la propiedad vuelve a su valor
     * inicial en la petición siguiente, así que apagar el chip duraba hasta el clic
     * siguiente y luego se encendía solo. Se cazó en el test del chip.
     *
     * El vacío sí es un valor: viaja, se distingue del defecto y ya era lo que manda un
     * `<select>` al elegir «Todos». Los quince `where` de `baseQuery()` tratan `''` y `null`
     * igual desde el principio, así que no hay nada más que cambiar.
     */
    public function filtrarPorSeveridad(?string $severidad): void
    {
        $this->severity = ($severidad === null || $severidad === '' || $this->severity === $severidad)
            ? ''
            : $severidad;

        $this->resetPage();
    }

    /**
     * Marca o desmarca una petición como vista.
     *
     * **Escribe, así que lleva su `authorize()`**: en `/livewire/update` el `permission:`
     * de la ruta no se reaplica (MGR-005). El permiso es el de lectura del visor, porque
     * marcar que has mirado algo es parte de mirarlo: si esto pidiera el permiso de
     * configuración, Soporte —que es quien revisa— no podría usarlo.
     *
     * Es un interruptor: se puede desmarcar. Un «visto» que no se puede quitar convierte
     * un clic de más en un dato falso para siempre.
     */
    public function alternarRevision(int $logId): void
    {
        $this->authorize('admin.api-logs.index');

        $peticion = ApiRequestLog::findOrFail($logId);

        $peticion->update($peticion->estaRevisada()
            ? ['reviewed_at' => null, 'reviewed_by' => null]
            : ['reviewed_at' => now(), 'reviewed_by' => auth()->id()]);

        // Los indicadores se cachean cinco minutos y uno de ellos cuenta los errores sin
        // revisar: sin olvidarla, marcar como visto no cambiaría el número hasta cinco
        // minutos después. Es la misma lección que MGR-073.
        Cache::forget($this->statsCacheKey());
    }

    /**
     * La modal ha marcado —o desmarcado— una petición.
     *
     * Solo hay que olvidar la caché de indicadores: el listado se relee en cada
     * `render()`, pero «errores sin revisar» está cacheado cinco minutos y se habría
     * quedado con el número de antes.
     */
    #[On('api-logs-revision-cambiada')]
    public function olvidarIndicadores(): void
    {
        Cache::forget($this->statsCacheKey());
    }

    public function openLogModal(int $logId): void
    {
        $this->dispatch('openModal',
            component: \App\Livewire\ApiLogs\LogDetailModal::class,
            arguments: ['logId' => $logId]
        );
    }

    /**
     * Filtra por el mismo sitio (si el log tiene entorno reconocido) o por el mismo host (si no).
     * - Sitio reconocido: aplica filtro "Sitio" (environmentId).
     * - Host no reconocido: aplica filtro por columna host (sin selector en filtros; se muestra como badge).
     */
    public function filterBySiteOrHost(?int $environmentId = null, ?string $host = null): void
    {
        if ($environmentId !== null && $environmentId !== '') {
            $this->environmentId = $environmentId;
            $this->host = null;
            $this->onlyWithoutEnvironment = false;
        } elseif ($host !== null && trim($host) !== '') {
            $this->host = trim($host);
            $this->environmentId = null;
            $this->onlyWithoutEnvironment = false;
        }
        $this->resetPage();
    }

    public function clearHostFilter(): void
    {
        $this->host = null;
        $this->hostSearch = '';
        $this->resetPage();
    }

    /* ==================================================================
     * Los tres selectores buscables
     * ==================================================================
     *
     * **Lo que había**: `Cliente` y `Sitio` eran dos `<select>` que se llenaban con una
     * consulta escrita dentro de la plantilla —`Client::orderBy('name')->get()` y
     * `Environment::orderBy('name')->get()`—. O sea que **las dos tablas enteras se leían
     * y se serializaban en cada `render()`**, y `render()` ocurre en cada tecleo de
     * cualquier campo con `debounce`. Con 20 clientes no se nota; con mil, cada pulsación
     * arrastra mil `<option>` de ida y vuelta.
     *
     * Y `host` no tenía **ningún** campo: solo se podía llegar a él pulsando «mismo host»
     * en una fila que ya estuviera en pantalla. Así que **un host que no es de ningún
     * entorno —un alta sin terminar, un dominio cambiado— no se podía buscar**: había que
     * encontrarlo antes por casualidad.
     *
     * De dónde sale cada lista, y no es lo mismo:
     *
     * - **Cliente y sitio, del catálogo.** Se quiere poder filtrar por un cliente que no
     *   ha llamado nunca: cero resultados es una respuesta útil («este cliente no ha
     *   llamado»), y no se puede dar si el cliente no aparece para elegirlo.
     * - **Host, del propio registro** y **dentro del rango de fechas activo**. Es la única
     *   fuente que tiene hosts que no son de nadie, y acotarlo al rango hace que el número
     *   que se ofrece sea el que se va a ver al pulsar.
     */

    /**
     * Clientes que coinciden con lo escrito.
     *
     * @return array{filas: \Illuminate\Support\Collection, total: int}
     */
    public function sugerenciasDeClientes(): array
    {
        // La consulta vive en `SugerenciasDeClientes` porque el mismo selector está en el
        // formulario de un entorno: lo que no puede divergir es por qué campos busca y
        // cuántas devuelve.
        return SugerenciasDeClientes::para($this->clientSearch);
    }

    /**
     * Entornos que coinciden, por nombre **o por dominio**.
     *
     * El dominio es lo que se tiene en la mano leyendo un log, y antes no se podía buscar
     * por él: el `<select>` lo pintaba entre paréntesis pero solo se podía ir a ojo.
     *
     * **Y se acotan al cliente elegido**, si hay uno. No cambia lo que filtra la consulta
     * —elegir el entorno de otro cliente ya daba cero—, pero ofrecerlo era ofrecer una
     * combinación que no puede devolver nada.
     *
     * @return array{filas: \Illuminate\Support\Collection, total: int}
     */
    public function sugerenciasDeEntornos(): array
    {
        // La consulta vive en `SugerenciasDeEntornos` porque el mismo selector está en
        // la limpieza del histórico, ahí sin acotar por cliente.
        return SugerenciasDeEntornos::para($this->environmentSearch, $this->clientId);
    }

    /**
     * Hosts que han llamado en el rango activo, con cuántas veces y si alguien responde
     * por ellos.
     *
     * **`sin_entorno` es el dato que se venía a buscar.** Un host sin entorno no es un
     * hueco: es un Moodle llamando a nuestra API que no está dado de alta con ese dominio
     * —o cuyo dominio cambió y nadie lo actualizó—. Su petición se lleva un error y el
     * sitio no recibe nada.
     *
     * @return array{filas: \Illuminate\Support\Collection, total: int}
     */
    public function sugerenciasDeHosts(): array
    {
        [$from, $to] = $this->getDateRange();

        $termino = trim($this->hostSearch);

        $consulta = ApiRequestLog::query()
            ->whereBetween('started_at', [$from, $to])
            ->whereNotNull('host')
            ->when($termino !== '', fn ($q) => $q->where('host', 'like', '%' . $termino . '%'));

        $filas = (clone $consulta)
            ->selectRaw('host,
                count(*) as peticiones,
                sum(case when environment_id is null then 1 else 0 end) as sin_entorno')
            ->groupBy('host')
            // Por volumen y no alfabéticamente: lo que se busca es quién está llamando
            // más, no la primera letra.
            ->orderByDesc('peticiones')
            ->limit(self::SUGERENCIAS)
            ->get();

        return [
            'filas' => $filas,
            'total' => (clone $consulta)->distinct()->count('host'),
        ];
    }

    /**
     * El cliente y el entorno elegidos, para poder pintarlos como etiqueta quitable.
     *
     * Van aquí y no en la plantilla porque son **una consulta por clave primaria**, y la
     * plantilla no debería tener ninguna: es justo lo que se está arreglando.
     */
    public function clienteElegido(): ?Client
    {
        return $this->clientId ? Client::find($this->clientId) : null;
    }

    public function entornoElegido(): ?Environment
    {
        return $this->environmentId ? Environment::find($this->environmentId) : null;
    }

    /** Elegir un cliente de la lista: se aplica y se cierra el buscador. */
    public function elegirCliente(?int $clienteId): void
    {
        $this->clientId = $clienteId;
        $this->clientSearch = '';
        $this->resetPage();
    }

    public function elegirEntorno(?int $entornoId): void
    {
        $this->environmentId = $entornoId;
        $this->environmentSearch = '';
        // Elegir un sitio concreto y «solo sin sitio reconocido» se contradicen: juntos
        // no devuelven nada nunca. Manda lo último que ha pulsado la persona.
        $this->onlyWithoutEnvironment = false;
        $this->resetPage();
    }

    /**
     * Elegir un host de la lista.
     *
     * **Coincidencia exacta**, no `like`: el valor sale de la propia lista, así que existe
     * tal cual. Filtrar por lo escrito a medias haría que el número de la sugerencia y el
     * del listado no cuadraran.
     */
    public function elegirHost(string $host): void
    {
        $this->host = $host;
        $this->hostSearch = '';
        $this->environmentId = null;
        $this->onlyWithoutEnvironment = false;
        $this->resetPage();
    }

    #[On('api-logs-filter-by-site-or-host')]
    public function onFilterBySiteOrHost(?int $environmentId = null, ?string $host = null): void
    {
        $this->filterBySiteOrHost($environmentId, $host);
    }

    public function copyUuid(string $uuid): void
    {
        $this->dispatch('copy-to-clipboard', text: $uuid);
    }

    public function updatedOnlyErrors(): void
    {
        $this->resetPage();
    }

    public function updatedSeverity(): void
    {
        $this->resetPage();
    }

    public function updatedOnlyUnreviewed(): void
    {
        $this->resetPage();
    }

    public function updatedClientId(): void
    {
        $this->resetPage();
    }

    public function updatedEnvironmentId(): void
    {
        $this->resetPage();
    }

    public function updatedIncluirApagados(): void
    {
        $this->resetPage();
    }

    /**
     * Filtra por motivo desde el reparto.
     *
     * Pulsar el que ya está puesto lo quita: es lo que se espera de un chip y evita dejar
     * a nadie encerrado en un motivo sin ver cómo salir. Mismo comportamiento que los
     * chips de severidad.
     */
    public function filtrarPorMotivo(?string $motivo): void
    {
        $this->reason = ($motivo === null || $motivo === '' || $this->reason === $motivo)
            ? null
            : $motivo;

        $this->resetPage();
    }

    public function updatedReason(): void
    {
        $this->resetPage();
    }

    public function updatedLicenseTokenId(): void
    {
        $this->resetPage();
    }

    public function updatedAction(): void
    {
        $this->resetPage();
    }

    public function updatedHost(): void
    {
        $this->resetPage();
    }

    public function updatedHttpStatuses(): void
    {
        $this->resetPage();
    }

    public function updatedDurationMin(): void
    {
        $this->resetPage();
    }

    public function updatedDurationMax(): void
    {
        $this->resetPage();
    }

    public function updatedResponseSizeMin(): void
    {
        $this->resetPage();
    }

    public function updatedResponseSizeMax(): void
    {
        $this->resetPage();
    }

    public function updatedRangePreset(): void
    {
        $this->applyDefaultRange();
        $this->resetPage();
    }

    public function updatedDateFrom(): void
    {
        $this->resetPage();
    }

    public function updatedDateTo(): void
    {
        $this->resetPage();
    }

    public static function actionOptions(): array
    {
        return [
            'sync' => 'sync',
            'licence' => 'licence',
            'products' => 'products',
            'scss' => 'scss',
            'scss-cdn' => 'scss-cdn',
            'js' => 'js',
            'setup' => 'setup',
            'features' => 'features',
            'tutorials' => 'tutorials',
            'resources' => 'resources',
            'data' => 'data',
            'plugins' => 'plugins',
            'stats' => 'stats',
        ];
    }

    /*
    |--------------------------------------------------------------------------
    | Borrado de registros
    |--------------------------------------------------------------------------
    |
    | Se borra exactamente lo que muestra el filtro actual, reutilizando
    | baseQuery(). Así el usuario ve en pantalla cuántos registros va a eliminar
    | antes de confirmar, y sirve tanto para "los de los últimos 7 días" como para
    | "los anteriores a 90 días" o "solo los 429 de esta IP".
    |
    | Es una acción destructiva sobre un rastro de auditoría, así que: permiso
    | propio (`admin.api-logs.destroy`, solo Admin), confirmación previa, y el
    | borrado se registra a su vez en la tabla `logs` —la que alimenta el
    | Dashboard— con quién, cuántos y con qué filtro. Un borrado de logs que no
    | deja rastro es exactamente lo que haría alguien tapando huellas.
    */

    /**
     * Qué se ha pedido borrar desde la modal de limpieza.
     *
     * **Una propiedad y no tres.** Eran `purgeOlderThanDays`, `purgeFrom` y `purgeTo`, y
     * cada acción tenía que acordarse de anular las otras dos para que el criterio no se
     * mezclara — en un borrado sin vuelta atrás, olvidarse de una es borrar otra cosa.
     * Ahora el criterio entra entero o no entra.
     *
     * La forma es la que entiende `ApiLogPurger::consultaDe()`, que es **quien define la
     * consulta**: la modal cuenta con ella lo que va a enseñar y aquí se borra con ella
     * misma, así que el número del aviso y las filas que se van no pueden discrepar.
     *
     * @var array{criterio?: string, dias?: int|null, desde?: string|null, hasta?: string|null}
     */
    public array $ordenDeLimpieza = [];

    /**
     * Borrar los errores de **un sitio concreto**, conocido o no.
     *
     * **El hueco que tapa.** Se podía borrar por antigüedad, por rango de fechas o «lo que
     * coincida con el filtro actual» — y esa última vía servía, pero había que armar el
     * filtro a mano y no la encuentra nadie. Lo que se quiere después de arreglar una
     * avería es puntual: *este sitio ya está resuelto, quítame sus errores de en medio*.
     *
     * **Y «no conocido» no es un caso raro, es la mitad del caso.** Un host que no resuelve
     * a ningún entorno —un alta sin terminar, un dominio que cambió— acumula errores que no
     * son de nadie, y son justo los que más ensucian: no tienen ficha a la que ir ni
     * responsable que los mire. Así que los dos se borran igual.
     *
     * Son dos propiedades y no una porque son dos consultas distintas: por entorno es
     * `environment_id = X`; por host es `environment_id IS NULL AND host = X`. Un host
     * puede tener filas de las dos clases, y borrar por host las de un entorno reconocido
     * borraría más de lo que decía la lista donde se ha pulsado.
     */
    public ?int $purgeErroresDeEntorno = null;

    public ?string $purgeErroresDeHost = null;

    /**
     * Abre la limpieza del histórico.
     *
     * **El formulario está en una modal y no en la pantalla.** Era una tira desplegable al
     * final del visor con cuatro bloques dentro: cerrada no decía nada y abierta ocupaba
     * más que el listado, en una pantalla que existe para mirar peticiones y no para
     * borrarlas. Ver `LimpiezaDelHistoricoModal`.
     *
     * Lo que se le pasa es **lo que solo se sabe aquí**: cuántas filas coinciden con los
     * filtros puestos ahora mismo. La modal no puede contarlo sin duplicar `baseQuery()`
     * con sus veinte filtros, que es la forma segura de que algún día el aviso y el borrado
     * dejen de coincidir.
     */
    public function abrirLimpieza(): void
    {
        $this->authorize('admin.api-logs.destroy');

        [$from, $to] = $this->getDateRange();

        $this->dispatch('openModal',
            component: LimpiezaDelHistoricoModal::class,
            arguments: [
                'filtroTotal' => (int) $this->baseQuery()->count(),
                'filtrosPuestos' => $this->appliedFiltersCounts()['total'],
                'rangoTexto' => $from->format('d/m/Y') . ' – ' . $to->format('d/m/Y'),
            ]
        );
    }

    /**
     * Ejecuta la orden que manda la modal de limpieza.
     *
     * **La modal ya ha sido la confirmación**: ha enseñado el número exacto, de qué fechas
     * y cuántos errores sin revisar se llevaba, y el botón que se pulsó decía la cifra. Un
     * segundo «¿seguro?» encima de eso no añade información, y las confirmaciones que no
     * informan se pulsan sin leer.
     */
    #[On('apiLogsLimpiar')]
    public function limpiar(array $orden): void
    {
        // El permiso de la ruta no se reaplica en /livewire/update (MGR-005), y un evento
        // del navegador se puede provocar a mano.
        $this->authorize('admin.api-logs.destroy');

        $this->ordenDeLimpieza = $orden;

        // Los criterios son excluyentes: el de un sitio con errores tiene su propia vía.
        $this->purgeErroresDeEntorno = null;
        $this->purgeErroresDeHost = null;

        $this->purge();
    }

    /** La modal avisa al terminar, para que el listado y los indicadores se repinten. */
    #[On('apiLogsPurgados')]
    public function repintarTrasLimpiar(): void
    {
        // Los indicadores están cacheados por clave de filtros: sin esto el resumen
        // seguiría contando filas que ya no existen.
        Cache::forget($this->statsCacheKey());
        $this->resetPage();
    }

    /**
     * Borrar los errores de un sitio o de un host.
     *
     * **Dentro del rango de fechas visible**, y la confirmación lo dice con las dos
     * fechas. Podría borrar todo el histórico de ese sitio, pero entonces el número de la
     * confirmación no sería el que la persona tiene delante en la pantalla — y en un
     * borrado sin vuelta atrás, que el número no cuadre es lo último que se quiere. Para
     * llevarse más, se amplía el rango, que está en la misma pantalla.
     */
    public function openPurgeErroresDeSitioModal(?int $environmentId = null, ?string $host = null): void
    {
        $this->authorize('admin.api-logs.destroy');

        // Los criterios son excluyentes: manda el último que se ha pulsado.
        $this->ordenDeLimpieza = [];
        $this->purgeErroresDeEntorno = null;
        $this->purgeErroresDeHost = null;

        if ($environmentId !== null) {
            $this->purgeErroresDeEntorno = $environmentId;
            $donde = Environment::find($environmentId)?->name ?? 'el entorno #' . $environmentId;
        } elseif ($host !== null && trim($host) !== '') {
            $this->purgeErroresDeHost = trim($host);
            $donde = $this->purgeErroresDeHost . ' (ningún entorno responde a ese host)';
        } else {
            session()->flash('info', 'No se ha indicado de qué sitio borrar los errores.');

            return;
        }

        [$from, $to] = $this->getDateRange();

        $this->abrirConfirmacion(
            'los errores de ' . $donde . ' registrados entre el '
            . $from->format('d/m/Y') . ' y el ' . $to->format('d/m/Y')
        );
    }

    /**
     * Cuenta lo que se va a borrar y pide confirmación.
     *
     * La cuenta va ANTES de la confirmación a propósito: un "¿seguro?" sin número no
     * ayuda a decidir, y aquí la diferencia entre borrar 40 filas y borrar 2 millones
     * está solo en el filtro que quedara puesto.
     */
    private function abrirConfirmacion(string $descripcion): void
    {
        $total = $this->purgeQuery()->count();

        if ($total === 0) {
            session()->flash('info', 'No hay registros que coincidan: no se borra nada.');

            return;
        }

        $this->dispatch(
            'openModal',
            component: \App\Livewire\Components\ConfirmModal::class,
            arguments: [
                'itemId' => 0,
                'itemName' => number_format($total, 0, ',', '.') . ' registro(s)',
                'title' => 'Borrar registros del log',
                'message' => "Se van a borrar {$descripcion}: "
                    . number_format($total, 0, ',', '.')
                    . ' registro(s). Esta acción no se puede deshacer.',
                'confirmText' => 'Borrar',
                'cancelText' => 'Cancelar',
                'eventName' => 'apiLogsPurgeConfirmed',
                'context' => 'api-logs',
                'confirmButtonColor' => 'red',
            ]
        );
    }

    #[On('apiLogsPurgeConfirmed')]
    public function purge(): void
    {
        $this->authorize('admin.api-logs.destroy');

        $criterio = match (true) {
            ($this->ordenDeLimpieza['criterio'] ?? '') === 'antiguedad'
                => 'anteriores a ' . $this->ordenDeLimpieza['dias'] . ' días',
            ($this->ordenDeLimpieza['criterio'] ?? '') === 'rango'
                => $this->ordenDeLimpieza['desde'] . ' a ' . $this->ordenDeLimpieza['hasta'],
            $this->purgeErroresDeEntorno !== null => 'errores del entorno #' . $this->purgeErroresDeEntorno,
            // El host va entre comillas en el rastro: es un dato que viene de fuera y
            // conviene que se lea como tal cuando alguien audite el borrado.
            $this->purgeErroresDeHost !== null => 'errores del host «' . $this->purgeErroresDeHost . '» sin entorno reconocido',
            default => 'filtro del visor',
        };

        $borrados = app(ApiLogPurger::class)->borrar(
            fn () => $this->purgeQuery(),
            $criterio,
            'el visor'
        );

        $this->reset(['ordenDeLimpieza', 'purgeErroresDeEntorno', 'purgeErroresDeHost']);
        $this->resetPage();

        // **Los indicadores están cacheados por clave de filtros.** Sin esto, la cabecera
        // seguiría contando filas que ya no existen hasta que caducara la caché — y el
        // primer sitio donde se mira después de borrar es justo la cabecera.
        Cache::forget($this->statsCacheKey());

        session()->flash('success', number_format($borrados, 0, ',', '.') . ' registro(s) borrados.');
    }

    /**
     * Consulta de lo que se va a borrar: el filtro del visor, o solo la
     * antigüedad si se usó uno de los atajos de limpieza.
     */
    protected function purgeQuery()
    {
        // Antigüedad y rango los define el purgador, que es también con quien la modal
        // cuenta lo que enseña: **una sola consulta para el número y para el borrado**.
        $propia = ApiLogPurger::consultaDe($this->ordenDeLimpieza);

        if ($propia !== null) {
            return $propia;
        }

        // Los errores de un sitio, conocido o no. **Solo severidad `error`**: los `*002`
        // son respuestas correctas y no son basura que limpiar, así que borrarlos con esto
        // se llevaría el histórico de lo que funciona bien.
        if ($this->purgeErroresDeEntorno !== null || $this->purgeErroresDeHost !== null) {
            [$from, $to] = $this->getDateRange();

            $q = ApiRequestLog::query()
                ->whereBetween('started_at', [$from, $to])
                ->where('severity', Severidad::ERROR);

            if ($this->purgeErroresDeEntorno !== null) {
                return $q->where('environment_id', $this->purgeErroresDeEntorno);
            }

            // Con el `whereNull`: es el grupo exacto que enseña la lista de «sitios con
            // errores». Sin él se llevaría también las filas del mismo host que sí tienen
            // entorno reconocido, que es más de lo que decía el número pulsado.
            return $q->whereNull('environment_id')->where('host', $this->purgeErroresDeHost);
        }


        return $this->baseQuery();
    }

    public function render()
    {
        $this->applyDefaultRange();
        [$from, $to] = $this->getDateRange();

        // Los indicadores son caros y NO necesitan ser exactos al segundo, así que se
        // calculan una vez y se reutilizan. En Livewire `render()` se ejecuta en cada
        // interacción —cada tecla del buscador, cada cambio de filtro—, y antes eso
        // repetía doce consultas sobre millones de filas. Ver known-issues MGR-027.
        // **Con tope de tiempo, y la pantalla se pinta igual si se agota.** Esta tabla es
        // la que más crece del sistema y los indicadores hacen doce agregados sobre el
        // rango; con un rango amplio o un filtro sin índice la consulta puede tardar
        // minutos, y hasta ahora eso dejaba la pantalla en blanco esperando: sin
        // cabecera, sin filtros y **sin forma de corregir el filtro que la había
        // colgado**, porque los filtros viven dentro de la página que no llegaba a
        // pintarse. Ver `ConsultaAcotada`.
        $stats = Cache::remember(
            $this->statsCacheKey(),
            max(0, (int) config('api.logs_viewer.stats_cache_seconds', 300)),
            fn () => ConsultaAcotada::ejecutar(
                fn () => $this->stats(),
                donde: 'api-logs: indicadores'
            )
        );

        // Si se agotó, no se cachean unos indicadores vacíos: se recalcularían igual de
        // lentos la próxima vez, pero cachear el hueco cinco minutos convertiría un
        // problema pasajero en una pantalla sin datos que nadie entiende.
        $this->indicadoresAgotados = $stats === null;

        if ($this->indicadoresAgotados) {
            Cache::forget($this->statsCacheKey());
            $stats = $this->statsVacios();
        }

        // El listado sí va en vivo: es una consulta paginada que aprovecha el índice
        // de `started_at`, así que es barata y conviene que esté al día. Aun así lleva
        // tope: un filtro por texto sobre el cuerpo de la petición no usa ningún índice.
        $logs = ConsultaAcotada::ejecutar(
            fn () => $this->baseQuery()
                ->with(['client', 'site', 'licenseToken'])
                ->orderByDesc('started_at')
                ->paginate(20),
            donde: 'api-logs: listado'
        );

        $this->listadoAgotado = $logs === null;

        return view('livewire.api-logs.index', array_merge($stats, [
            'logs' => $logs,

            'veredicto' => $this->veredicto($stats),
            // Lo que esconde el recorte de los vistos, para poder decirlo en la barra.
            'vistosOcultos' => $this->vistosOcultos(),
            'topeSegundos' => (int) config('api.logs_viewer.timeout_seconds', 8),
            'actionOptions' => self::actionOptions(),
            // El catálogo de motivos, para que la vista pinte la etiqueta y el dueño sin
            // saber nada de la lista.
            'catalogoDeMotivos' => Motivo::CATALOGO,
            'dateFrom' => $from,
            'dateTo' => $to,
        ]))->layout('layouts.app');
    }

    /**
     * La frase que abre la pantalla.
     *
     * **Antes lo primero que se leía era «Requests: 108».** Ese número no responde a
     * ninguna de las preguntas con las que se abre este visor, y el que iba al lado
     * —«% errores»— era falso. Aquí se dice, en una frase, si hay algo que arreglar y
     * dónde, que es lo único que hace falta saber antes de tocar un filtro.
     *
     * Los cuatro estados son los del diseño y salen de los datos, no de un interruptor:
     * hay errores · no hay ninguno · la consulta se cortó · los filtros no encuentran
     * nada.
     *
     * @param  array<string,mixed>  $stats
     * @return array{titulo: string, detalle: string, tono: string}
     */
    protected function veredicto(array $stats): array
    {
        // El corte manda sobre todo lo demás: los contadores pueden estar a medias, así
        // que afirmar «nada que arreglar» con ellos sería mentir.
        if ($this->indicadoresAgotados) {
            return [
                'titulo' => 'Resultados incompletos',
                'detalle' => 'La consulta se cortó antes de terminar, así que los contadores '
                    . 'pueden quedarse cortos. Acota el rango o filtra por sitio.',
                'tono' => 'aviso',
            ];
        }

        if (($stats['total'] ?? 0) === 0) {
            $conFiltros = $this->appliedFiltersCounts()['total'] > 0;

            return [
                'titulo' => $conFiltros ? 'Nada que enseñar con estos filtros' : 'Ninguna petición en este rango',
                'detalle' => $conFiltros
                    ? 'Con los filtros puestos no hay ninguna petición. Si buscas un sitio que '
                        . 'no aparece nunca, puede que no haya llamado: su ficha lo dice.'
                    : 'Ningún Moodle ha llamado en el rango elegido. Prueba a ampliarlo.',
                'tono' => 'aviso',
            ];
        }

        $errores = (int) ($stats['errores'] ?? 0);

        if ($errores === 0) {
            $sinContenido = (int) (($stats['porSeveridad'][Severidad::SIN_CONTENIDO] ?? 0));
            $negocio = (int) (($stats['porSeveridad'][Severidad::NEGOCIO] ?? 0));

            $detalle = 'Cero errores en ' . number_format($stats['total'], 0, ',', '.') . ' peticiones.';

            // Se nombran para que nadie las descubra en la tabla y abra una incidencia por
            // algo que funciona.
            if ($sinContenido > 0 || $negocio > 0) {
                $detalle .= ' Hay ' . implode(' y ', array_filter([
                    $sinContenido > 0 ? $sinContenido . ' sin contenido publicado' : null,
                    $negocio > 0 ? $negocio . ' no contratado' : null,
                ])) . ', que son respuestas correctas.';
            }

            return ['titulo' => 'Nada que arreglar', 'detalle' => $detalle, 'tono' => 'ok'];
        }

        $peor = $stats['sitiosConErrores']->first();
        $donde = $peor['entorno']?->name ?? $peor['host'] ?? null;

        $sinRevisar = (int) ($stats['erroresSinRevisar'] ?? $errores);

        // **Todos vistos no es «nada que arreglar»**, es «nadie tiene que enterarse de
        // esto ahora»: los errores siguen ahí y alguien dijo que ya los conocía.
        if ($sinRevisar === 0) {
            return [
                'titulo' => 'Nada nuevo: los ' . $errores . ' errores están vistos',
                'detalle' => 'Alguien ha marcado como revisadas todas las peticiones con '
                    . 'error del rango. Siguen siendo errores: revisar no arregla, solo dice '
                    . 'que ya se conocen.',
                'tono' => 'aviso',
            ];
        }

        return [
            'titulo' => $sinRevisar . ($sinRevisar === 1 ? ' error por arreglar' : ' errores por arreglar')
                . ($donde !== null && $stats['sitiosConErrores']->count() === 1 ? ' en ' . $donde : ''),
            'detalle' => $stats['sitiosConErrores']->count() > 1
                ? 'Repartidos en ' . $stats['sitiosConErrores']->count() . ' sitios. '
                    . 'El que más: ' . $donde . ' con ' . $peor['errores'] . '.'
                : 'Son los que tienen dueño y hay que arreglar; el objetivo de este número es cero.',
            'tono' => 'malo',
        ];
    }

    /**
     * Clave de caché de los indicadores: depende de TODOS los filtros, para que dos
     * combinaciones distintas no se pisen entre sí.
     */
    protected function statsCacheKey(): string
    {
        [$from, $to] = $this->getDateRange();

        // ============ Por qué el rango no entra por sus fechas exactas ============
        //
        // **La caché duraba un minuto, no cinco.** Con un preset —«últimos 30 días»— las
        // fechas las recalcula `applyDefaultRange()` desde `now()` con precisión de
        // minuto, así que la clave cambiaba al cambiar el minuto y los doce agregados
        // volvían a correr sobre la tabla que más crece del sistema. El
        // `stats_cache_seconds` de 300 no llegaba a aplicarse nunca.
        //
        // Con un preset, la clave lleva **el nombre del preset y un cubo de tiempo del
        // tamaño del TTL**: es la misma durante los cinco minutos enteros. Con un rango a
        // medida sí van las fechas exactas, porque ahí no se mueven solas: las ha escrito
        // una persona.
        $ttl = max(1, (int) config('api.logs_viewer.stats_cache_seconds', 300));

        $rango = $this->rangePreset === 'custom'
            ? [$from->toDateTimeString(), $to->toDateTimeString()]
            : [$this->rangePreset, intdiv(now()->getTimestamp(), $ttl)];

        return 'api-logs:stats:' . md5(serialize([
            $rango,
            $this->clientId,
            $this->environmentId,
            $this->onlyWithoutEnvironment,
            $this->licenseTokenId,
            $this->action,
            $this->httpStatuses,
            $this->durationMin,
            $this->durationMax,
            $this->responseSizeMin,
            $this->responseSizeMax,
            $this->ip,
            $this->requestUuid,
            $this->host,
            // **Los dos nuevos, y por qué importa**: sin ellos, cambiar de chip no
            // cambiaba la clave, así que los indicadores se servían de la caché de otra
            // combinación de filtros. Se veía como «7 peticiones» con treinta filas
            // debajo.
            $this->severity,
            $this->reason,
            $this->onlyUnreviewed,
            // El recorte de los apagados, por lo mismo: sin él, encender el interruptor
            // dejaría la cabecera diciendo los números de antes.
            $this->recortaApagados(),
        ]));
    }

    /**
     * Indicadores de la cabecera. Todo lo caro vive aquí y se cachea entero.
     */
    protected function stats(): array
    {
        [$prevFrom, $prevTo] = $this->getPreviousDateRange();

        // **Sin el filtro de severidad y sin el de vistos**: este bloque describe **el
        // rango**, no lo que se está mirando ahora. Los contadores de los chips y el número
        // de errores de verdad tienen que seguir diciendo lo mismo cuando se pulsa uno, o el
        // propio filtro se contradice.
        //
        // El de vistos entró en la misma exención cuando pasó a venir puesto de entrada, y
        // por un motivo que se vio enseguida: con él dentro, marcar un error como visto
        // bajaba también el total —«2 errores · 1 visto» pasaba a «1 error»—, o sea que
        // marcar parecía arreglar, que es justo lo contrario de lo que dice esa pantalla.
        //
        // Y `$base` va igual porque de ahí salen el p50, el p95 y las acciones más usadas:
        // con el defecto en «solo errores», el p50 habría pasado a ser la latencia de los
        // errores, que no describe la salud de nada.
        $base = $this->baseQuery(conSeveridad: false, conFiltroDeVistos: false);
        $sinFiltroDeSeveridad = $this->baseQuery(conSeveridad: false, conFiltroDeVistos: false);

        // ============ Por severidad, no por `http_status` ============
        //
        // **Contando `>= 400` la mitad de los «errores» no lo son.** `3002`, `4002` y
        // `5002` significan «no hay contenido publicado para esa versión» y un `403` con
        // token caducado significa «no lo tiene contratado»: las tres son respuestas
        // correctas, y esta pantalla las contaba como averías. Con los datos de
        // desarrollo eso da 13 % de error donde el real es 6,5 %.
        //
        // La severidad la calcula el modelo al insertar cada fila, así que aquí es un
        // `group by` sobre una columna. Ver `norma-cero-errores.md` y MGR-078.
        $porSeveridad = (clone $sinFiltroDeSeveridad)
            ->selectRaw('severity, count(*) as c')
            ->groupBy('severity')
            ->pluck('c', 'severity');

        $total = (int) $porSeveridad->sum();
        $errores = (int) ($porSeveridad[Severidad::ERROR] ?? 0);

        // Los que nadie ha mirado todavía. **No sustituye al total**: se marca la
        // petición, no el tipo de error, así que el mismo fallo repetido mañana vuelve
        // a contar. El indicador enseña los dos números y no finge que uno arregla el
        // otro.
        $erroresSinRevisar = $errores > 0
            ? (clone $sinFiltroDeSeveridad)->where('severity', Severidad::ERROR)->whereNull('reviewed_at')->count()
            : 0;

        // Se conserva el reparto por HTTP para el detalle técnico: sigue siendo cierto,
        // solo deja de ser la cifra de cabecera.
        $count4xx = (clone $sinFiltroDeSeveridad)->whereBetween('http_status', [400, 499])->count();
        $count5xx = (clone $sinFiltroDeSeveridad)->where('http_status', '>=', 500)->count();

        // El periodo anterior solo se compara por los filtros "gruesos": añadir todos
        // los demás multiplicaría el coste para un dato que solo sirve de referencia.
        $prevBase = ApiRequestLog::query()->whereBetween('started_at', [$prevFrom, $prevTo]);
        if ($this->clientId) {
            $prevBase->where('client_id', $this->clientId);
        }
        if ($this->environmentId) {
            $prevBase->where('environment_id', $this->environmentId);
        }
        if ($this->licenseTokenId) {
            $prevBase->where('license_token_id', $this->licenseTokenId);
        }
        if ($this->action !== null && $this->action !== '') {
            $prevBase->where('action', $this->action);
        }

        $prevTotal = (clone $prevBase)->count();
        // Mismo criterio que el periodo actual: si no, la comparación compara dos
        // cosas distintas.
        $prevErrores = (clone $prevBase)->where('severity', Severidad::ERROR)->count();

        $topActions = (clone $base)
            ->select('action', DB::raw('count(*) as c'))
            ->whereNotNull('action')
            ->groupBy('action')
            ->orderByDesc('c')
            ->limit(5)
            ->get();

        $topClients = (clone $sinFiltroDeSeveridad)
            ->select('client_id', DB::raw('count(*) as c'))
            ->whereNotNull('client_id')
            ->groupBy('client_id')
            ->orderByDesc('c')
            ->limit(3)
            ->get();

        foreach ($topClients as $row) {
            $row->client = Client::find($row->client_id);
        }

        // ============ Los sitios con errores de verdad ============
        //
        // **Antes ordenaba por tasa de `http_status >= 400`**, así que podía poner primero
        // al sitio más sano del parque —el que solo pide contenido que aún no está
        // publicado— y mandar a Soporte justo donde no hay nada que hacer.
        //
        // Ahora solo salen los que tienen errores de verdad, ordenados por **cuántos**:
        // un sitio con 7 errores importa más que uno con 1, y la tasa relativa premiaba
        // al que llama poco. Y **si no hay ninguno, la lista está vacía**, que es la
        // respuesta buena y no un top 3 de lo menos malo.
        //
        // El recorte y el orden se hacen en SQL: con muchos entornos, traerse todos los
        // grupos para enseñar tres es traerse la tabla agrupada entera.
        $sitiosConErrores = (clone $sinFiltroDeSeveridad)
            ->select('environment_id', 'host', DB::raw('count(*) as errores'))
            ->where('severity', Severidad::ERROR)
            // Esta lista es «dónde ir a arreglar», así que no manda a un sitio cuyos
            // errores ya están todos revisados.
            ->whereNull('reviewed_at')
            ->groupBy('environment_id', 'host')
            ->orderByDesc('errores')
            ->limit(5)
            ->get()
            // **Un array y no el propio modelo.** Estas filas son instancias de
            // `ApiRequestLog`, y ese modelo castea el atributo `environment` a array —es
            // una columna de verdad: el bloque `environment` del payload—. Colgarle ahí
            // un modelo Eloquent lo pasa por el cast y sale un valor truthy que no es un
            // modelo, así que `route('environments.show', …)` revienta. Una fila de un
            // `group by` no es una petición y no debe fingir que lo es.
            ->map(fn ($row) => [
                // `host` cuando no hay entorno reconocido: «alguien está llamando desde
                // ahí y no sabemos quién» también hay que poder verlo.
                'entorno' => $row->environment_id !== null
                    ? Environment::find($row->environment_id)
                    : null,
                'host' => $row->host,
                'errores' => (int) $row->errores,
            ]);

        // ============ El reparto por motivos ============
        //
        // **Es lo que convierte «14 errores» en una lista de trabajo.** Sin el motivo,
        // saber por qué falló cada una exigía abrir filas de una en una y leer el texto;
        // y las que compartían el `403` plano del middleware no se podían separar ni así
        // sin mirarlas todas.
        //
        // **Sin el filtro de motivo puesto**, igual que el reparto por severidad: si se
        // calculara con él, al elegir «licencia caducada» los demás motivos pasarían a
        // cero y dejaría de haber reparto que mirar.
        //
        // Solo lo que ha fallado: las respondidas llevan motivo `ninguno` y ocupan el
        // primer puesto de cualquier orden sin decir nada.
        $porMotivo = $this->baseQuery(conSeveridad: false, conMotivo: false, conFiltroDeVistos: false)
            ->where('http_status', '>=', 400)
            ->selectRaw('reason, count(*) as c')
            ->groupBy('reason')
            ->orderByDesc('c')
            ->pluck('c', 'reason');

        // ============ Cuántas filas esconde el recorte de los apagados ============
        //
        // Se dice, y se cuenta **sobre el resto de los filtros puestos**: si dijera «hay
        // 40.000 peticiones de sitios apagados» mientras se está mirando un rango de 24
        // horas y una acción concreta, el número no se referiría a lo que se tiene delante.
        //
        // Va dentro de los indicadores y no aparte porque es un `count` sobre la tabla que
        // más crece del sistema: así entra en la misma caché y en el mismo tope de tiempo
        // que los otros doce agregados, en vez de ser el agregado sin vigilar que vuelve a
        // colgar la pantalla.
        $apagadosOcultos = $this->recortaApagados()
            ? (int) $this->baseQuery(conSeveridad: false, conRecorteDeApagados: false)
                ->whereIn('environment_id', $this->entornosApagados())
                ->count()
            : 0;

        return [
            'total' => $total,
            'errores' => $errores,
            'apagadosOcultos' => $apagadosOcultos,
            'erroresSinRevisar' => $erroresSinRevisar,
            'porSeveridad' => $porSeveridad,
            'porMotivo' => $porMotivo,
            'count4xx' => $count4xx,
            'count5xx' => $count5xx,
            'prevTotal' => $prevTotal,
            'prevErrores' => $prevErrores,
            'p50' => $this->percentile($base, 50),
            'p95' => $this->percentile($base, 95),
            'prevP50' => $this->percentile($prevBase, 50),
            'prevP95' => $this->percentile($prevBase, 95),
            'topActions' => $topActions,
            'topClients' => $topClients,
            'sitiosConErrores' => $sitiosConErrores,
        ];
    }

    /**
     * Los indicadores cuando la consulta no llegó a tiempo.
     *
     * **La misma forma exacta que `stats()`**, con los valores a cero y las colecciones
     * vacías: la plantilla accede a las trece claves sin comprobar si existen, y devolver
     * un array a medias cambiaría un «tarda mucho» por un «undefined array key» —que es
     * peor, porque parece un fallo del código y no un filtro demasiado ancho—.
     *
     * Los ceros no se pintan como datos: la vista enseña el aviso y los deja en blanco.
     */
    protected function statsVacios(): array
    {
        return [
            'total' => 0,
            'errores' => 0,
            'apagadosOcultos' => 0,
            'erroresSinRevisar' => 0,
            'porSeveridad' => collect(),
            'porMotivo' => collect(),
            'count4xx' => 0,
            'count5xx' => 0,
            'prevTotal' => 0,
            'prevErrores' => 0,
            'p50' => 0,
            'p95' => 0,
            'prevP50' => 0,
            'prevP95' => 0,
            'topActions' => collect(),
            'topClients' => collect(),
            'sitiosConErrores' => collect(),
        ];
    }

    /**
     * Percentil de duración, calculado en la base de datos.
     *
     * Antes se hacía `pluck('duration_ms')` sobre TODO el rango y se calculaba en
     * PHP: eso cargaba en memoria una columna entera —cientos de miles de enteros con
     * 30 días de tráfico— y era lo que hacía imposible abrir la pantalla. Aquí son
     * dos consultas: contar, y saltar hasta la posición que toca.
     *
     * Se usa OFFSET y no PERCENTILE_CONT a propósito: producción es MariaDB, local es
     * MySQL y los tests corren sobre sqlite, y esta forma funciona en las tres.
     *
     * Diferencia con la versión anterior: no interpola entre los dos valores vecinos,
     * coge el de la posición. Para un p50/p95 de latencia es irrelevante.
     */
    protected function percentile($query, float $p): ?int
    {
        $q = (clone $query)->whereNotNull('duration_ms');

        $n = (clone $q)->count();

        if ($n === 0) {
            return null;
        }

        $offset = (int) floor(($p / 100) * ($n - 1));

        $row = (clone $q)
            ->orderBy('duration_ms')
            ->offset($offset)
            ->limit(1)
            ->first(['duration_ms']);

        return $row ? (int) $row->duration_ms : null;
    }
}
