<?php

namespace App\Livewire\Dashboard;

use App\Models\ApiRequestLog;
use App\Support\VersionDeMoodle;
use App\Services\Api\Severidad;
use App\Models\Clients\Client;
use App\Models\Environments\Environment;
use App\Models\Environments\Type;
use App\Models\Features\FeatureVersion;
use App\Models\Environments\Data;
use App\Models\Monitoring\ApiRateEvent;
use App\Models\Monitoring\Log as MonitoringLog;
use App\Models\Products\LicenseToken;
use App\Models\Products\Product;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Livewire\Component;

/**
 * Dashboard: página de control diaria.
 *
 * Implementa el diseño de Claude Design (proyecto "Laravel Manager Tresipunt",
 * pantalla `Dashboard Manager.dc.html`) y el brief de `briefs/dashboard.md`.
 *
 * Dos reglas que condicionan todo el componente:
 *
 *  1. **Lo caro va cacheado.** `render()` se ejecuta en cada interacción de
 *     Livewire, y aquí hay consultas sobre `api_request_logs`, que en producción
 *     tiene millones de filas. Es exactamente lo que dejó inusable el visor de
 *     logs (known-issues MGR-027).
 *  2. **Lo que un rol no puede ver, no se consulta.** No basta con ocultarlo en la
 *     plantilla.
 */
class Index extends Component
{
    /** Buscador de dominio: se resuelve en vivo porque es una consulta indexada. */
    public string $domainQuery = '';

    /** all | crit | warn | info */
    public string $attentionFilter = 'all';

    public function setAttentionFilter(string $filter): void
    {
        $this->attentionFilter = in_array($filter, ['all', 'crit', 'warn', 'info'], true)
            ? $filter
            : 'all';
    }

    /** Cuántos sitios entran en cada lista de «los más grandes». */
    private const CUANTOS_GRANDES = 5;

    public function render()
    {
        $user = auth()->user();

        // Qué puede ver quien mira. Se calcula una vez y decide qué se consulta.
        $puede = [
            'environments' => (bool) $user?->can('admin.environments.index'),
            'clients' => (bool) $user?->can('admin.clients.index'),
            'tokens' => (bool) $user?->can('admin.license-tokens.index'),
            'products' => (bool) $user?->can('admin.products.index'),
            'apiLogs' => (bool) $user?->can('admin.api-logs.index'),
        ];

        $atencion = $this->atencion($puede);

        $porNivel = [
            'all' => count($atencion),
            'crit' => count(array_filter($atencion, fn ($i) => $i['level'] === 'crit')),
            'warn' => count(array_filter($atencion, fn ($i) => $i['level'] === 'warn')),
            'info' => count(array_filter($atencion, fn ($i) => $i['level'] === 'info')),
        ];

        return view('livewire.dashboard.index', [
            'puede' => $puede,
            'bienvenida' => $this->bienvenida($user, $puede, $porNivel),
            'kpis' => $this->kpis($puede),
            'atencion' => $this->filtrar($atencion),
            'hayAtencion' => $atencion !== [],
            // Cuántas hay en cada categoría, para que los filtros digan el número sin
            // tener que pulsarlos y para el contador de la cabecera.
            'atencionPorNivel' => $porNivel,
            'parque' => $puede['environments'] ? $this->parque() : null,
            'actividad' => $puede['apiLogs'] ? $this->actividad() : null,
            'contenido' => $puede['products'] ? $this->contenido() : null,
            'dominio' => $this->buscarDominio($puede),
            'masGrandes' => $puede['environments'] ? $this->masGrandes() : null,
        ])->layout('layouts.app');
    }

    /**
     * Los cinco sitios más grandes, por tres medidas.
     *
     * **La pregunta que no tenía respuesta en ningún sitio**: «¿cuáles son nuestros clientes
     * gordos?». El dato estaba —cada Moodle lo manda en su telemetría diaria— y solo se podía
     * ver entrando entorno por entorno.
     *
     * Tres listas y no una porque no miden lo mismo y el orden cambia: un campus con muchos
     * usuarios y pocos cursos es un sitio de acceso masivo a poco contenido, y uno con muchos
     * cursos y pocas matrículas es un catálogo que nadie usa todavía. Juntar las tres en un
     * ranking único obligaría a inventarse una fórmula que pondere, y no hay ninguna que
     * signifique algo.
     *
     * **Solo entornos que han mandado telemetría.** Un sitio sin fila en `data` no es un
     * sitio con cero usuarios: es uno del que no sabemos nada, y colarlo al final de la lista
     * con un 0 sería afirmar algo que nadie ha medido.
     *
     * Cacheado cinco minutos, como el resto de bloques del panel: son tres consultas
     * ordenadas y el dato se actualiza una vez al día.
     *
     * @return array<string, array{titulo: string, unidad: string, filas: array}>
     */
    private function masGrandes(): array
    {
        // **La clave lleva versión, y no es manía.** Esto guarda un array con una forma
        // concreta durante cinco minutos. Al añadirle una clave —`icono`— la caché siguió
        // sirviendo la forma vieja y la vista reventó con «Undefined array key». Pasa
        // igual en cada despliegue que cambie lo que se guarda: subir el número es lo que
        // evita que la pantalla se caiga durante los primeros cinco minutos.
        return Cache::remember('dashboard:mas-grandes:v2', 300, function () {
            // El rótulo corto: el título común «Los sitios más grandes» ya dice de qué van
            // las tres, así que repetir «Sitios con…» en cada una es decirlo cuatro veces.
            // Los iconos son los del diseño y van en línea: son tres y se pintan una vez.
            // El `d` de cada trazo separado por `|` para no repetir el envoltorio del SVG.
            $medidas = [
                'users' => ['Más usuarios', 'usuarios',
                    'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|M22 21v-2a4 4 0 0 0-3-3.87|M16 3.13a4 4 0 0 1 0 7.75'],
                'courses' => ['Más cursos', 'cursos',
                    'M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20'],
                'enrolments' => ['Más matriculaciones', 'matriculaciones',
                    'M16 11l2 2 4-4|M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z'],
            ];

            $listas = [];

            foreach ($medidas as $columna => [$titulo, $unidad, $trazos]) {
                $listas[$columna] = [
                    'titulo' => $titulo,
                    'unidad' => $unidad,
                    'icono' => explode('|', $trazos),
                    'filas' => Data::query()
                        ->with('environment:id,name,domain,client_id', 'environment.client:id,name')
                        // El entorno tiene que existir: `data` sobrevive al borrado en blando
                        // de su entorno y una fila sin nombre no dice nada.
                        ->whereHas('environment')
                        ->where($columna, '>', 0)
                        ->orderByDesc($columna)
                        ->limit(self::CUANTOS_GRANDES)
                        ->get()
                        ->map(fn (Data $d) => [
                            // **El dominio y no el nombre**, como el diseño: es lo que
                            // identifica un sitio de un vistazo y lo que llega en una
                            // incidencia. El nombre y el cliente van al `title`, para quien
                            // no reconozca el dominio.
                            'dominio' => self::soloElHost($d->environment?->domain) ?: ($d->environment?->name ?? '—'),
                            'nombre' => $d->environment?->name,
                            'cliente' => $d->environment?->client?->name,
                            'url' => $d->environment ? route('environments.show', $d->environment) : null,
                            'cuantos' => (int) $d->{$columna},
                        ])
                        ->all(),
                ];
            }

            return $listas;
        });
    }

    /**
     * El dominio sin el `https://`, que en una lista estrecha son ocho caracteres de nada.
     *
     * Con la barra final fuera también: los dominios se guardan normalizados desde el
     * formulario, pero quedan filas anteriores a esa normalización.
     */
    private static function soloElHost(?string $dominio): string
    {
        if (! $dominio) {
            return '';
        }

        return rtrim(preg_replace('#^https?://#i', '', trim($dominio)), '/');
    }

    /* ==========================================================
       KPIs
       ==========================================================
       Cifras de cabecera, una celda por área. Cada celda enlaza a su listado: el
       KPI no es el destino, es la puerta.

       Solo aparecen las áreas que el usuario puede ver, y las que no puede ni se
       consultan. Si no puede ver ninguna, el bloque entero desaparece — no se
       queda una tira vacía.
    */

    private function kpis(array $puede): ?array
    {
        return Cache::remember(
            'dashboard:kpis:' . md5(serialize($puede)),
            300,
            function () use ($puede) {
                $celdas = [];

                if ($puede['clients']) {
                    $celdas[] = [
                        'label' => 'Clientes',
                        'valor' => Client::count(),
                        'detalle' => '+' . Client::whereMonth('created_at', now()->month)
                            ->whereYear('created_at', now()->year)
                            ->count() . ' este mes',
                        'alerta' => null,
                        'url' => route('clients.index'),
                    ];
                }

                if ($puede['environments']) {
                    $activos = Environment::where('active', true)->count();
                    $sinLicencia = Environment::where('active', true)->whereNull('license_token_id')->count();

                    $celdas[] = [
                        'label' => 'Entornos',
                        'valor' => Environment::count(),
                        'detalle' => $activos . ' activos',
                        'alerta' => $sinLicencia > 0 ? $sinLicencia . ' sin licencia' : null,
                        'url' => route('environments.index'),
                    ];
                }

                if ($puede['tokens']) {
                    // Rango CERRADO para "vence pronto" y comparación estricta para
                    // "vencida": son dos conjuntos que NO se solapan. Contar la misma
                    // licencia en los dos era el error de MGR-025.
                    $porVencer = LicenseToken::whereNotNull('end_at')
                        ->whereBetween('end_at', [now(), now()->addDays(30)])
                        ->count();

                    $vencidas = LicenseToken::whereNotNull('end_at')
                        ->where('end_at', '<', now())
                        ->count();

                    $celdas[] = [
                        'label' => 'Licencias',
                        'valor' => LicenseToken::count(),
                        'detalle' => $porVencer . ' vence(n) en ≤30 días',
                        'alerta' => $vencidas > 0 ? $vencidas . ' vencida(s)' : null,
                        'url' => route('license_tokens.index'),
                    ];
                }

                if ($puede['products']) {
                    $total = Product::count();

                    // "Sin publicar" = sin ninguna versión de funcionalidades. Una
                    // consulta, no una por producto.
                    $conVersiones = FeatureVersion::distinct()->count('product_id');

                    $celdas[] = [
                        'label' => 'Productos',
                        'valor' => $total,
                        'detalle' => $conVersiones . ' con contenido',
                        'alerta' => ($total - $conVersiones) > 0
                            ? ($total - $conVersiones) . ' sin publicar'
                            : null,
                        'url' => route('products.index'),
                    ];
                }

                return $celdas === [] ? null : $celdas;
            }
        );
    }

    /* ==========================================================
       Requiere tu atención
       ========================================================== */

    /**
     * @return array<int, array<string, mixed>>
     */
    private function atencion(array $puede): array
    {
        return Cache::remember(
            'dashboard:atencion:' . md5(serialize($puede)),
            60,
            function () use ($puede) {
                $items = [];

                if ($puede['tokens']) {
                    $items = array_merge($items, $this->licenciasQueCaducan());
                }

                if ($puede['environments']) {
                    $items = array_merge($items, $this->entornosSinSincronizar());
                    $items = array_merge($items, $this->entornosSinLicencia());
                }

                if ($puede['apiLogs']) {
                    $items = array_merge($items, $this->entornosConErrores());
                    $items = array_merge($items, $this->ipsEnElLimite());
                }

                $items = array_merge($items, $this->avisosDelSistema());

                // Críticos primero, luego avisos, luego informativos.
                $orden = ['crit' => 0, 'warn' => 1, 'info' => 2];
                usort($items, fn ($a, $b) => $orden[$a['level']] <=> $orden[$b['level']]);

                return $items;
            }
        );
    }

    /**
     * Saludo y frase de resumen de la cabecera.
     *
     * El diseño ("Dashboard Manager", Claude Design) sustituye el título "Dashboard"
     * —que no dice nada que el usuario no sepa ya— por un saludo y UNA frase que
     * responde a "¿tengo algo que hacer?" antes de que haya que leer nada más. Las
     * cuatro variantes del prototipo salen de datos reales, no de un escenario:
     *
     *   sin datos     → invita a empezar (instalación nueva)
     *   con críticos  → cuántos y que son de hoy
     *   con avisos    → cuántos, sin dramatizar
     *   sin nada      → lo dice explícitamente, que es la mitad del valor
     *
     * El saludo usa sólo el nombre de pila. `users` tiene una única columna `name`
     * —no hay `last_name`, aunque el menú lateral lo imprima (referencia muerta,
     * anotada como MGR-031)— así que se corta por el primer espacio: si alguien
     * guardó "María José Pérez", saluda "Hola, María".
     */
    private function bienvenida(?object $user, array $puede, array $porNivel): array
    {
        $nombre = trim((string) ($user->name ?? ''));
        $nombre = $nombre === '' ? null : explode(' ', $nombre)[0];

        return [
            'saludo' => $nombre ? 'Hola, ' . $nombre : 'Hola',
            'resumen' => $this->resumenBienvenida($puede, $porNivel),
        ];
    }

    private function resumenBienvenida(array $puede, array $porNivel): string
    {
        // Instalación vacía: no hay nada que resumir, hay algo que empezar. Se
        // pregunta sólo si el rol puede ver clientes o entornos —si no puede, "no
        // hay datos" no es una conclusión que le corresponda.
        if ($puede['clients'] || $puede['environments']) {
            $vacio = Cache::remember('dash:vacio', 300, fn () => Client::count() === 0 && Environment::count() === 0);

            if ($vacio) {
                return $puede['clients']
                    ? 'Bienvenido. Crea tu primer cliente para empezar.'
                    : 'Bienvenido. Todavía no hay clientes ni entornos registrados.';
            }
        }

        if ($porNivel['crit'] > 0) {
            return $porNivel['crit'] === 1
                ? '1 incidencia crítica requiere tu atención.'
                : $porNivel['crit'] . ' incidencias críticas requieren tu atención.';
        }

        if ($porNivel['warn'] > 0) {
            return $porNivel['warn'] === 1
                ? 'Tienes 1 aviso pendiente de revisar.'
                : 'Tienes ' . $porNivel['warn'] . ' avisos pendientes de revisar.';
        }

        if ($porNivel['info'] > 0) {
            return 'Nada urgente. Hay ' . $porNivel['info'] . ' punto(s) informativo(s) más abajo.';
        }

        return 'Todo en orden. Nada pendiente de tu parte.';
    }

    private function filtrar(array $items): array
    {
        if ($this->attentionFilter === 'all') {
            return $items;
        }

        return array_values(array_filter(
            $items,
            fn ($item) => $item['level'] === $this->attentionFilter
        ));
    }

    /**
     * Cuántas filas se pintan como máximo de cada bloque de "Requiere tu atención".
     *
     * El bloque NO pagina a propósito: es un panel de "qué hago ahora", no un
     * listado. Con 1.000 licencias caducando, mil filas taparían el Dashboard y
     * seguirían sin decirte por dónde empezar. Se muestran las N más urgentes
     * (ordenadas por urgencia) y el resto se resume en una sola fila con enlace al
     * listado completo, que es donde sí hay filtros y paginado de verdad.
     */
    private const FILAS_POR_BLOQUE = 5;

    private const FILAS_POR_BLOQUE_MENOR = 3;

    /**
     * Fila de cierre "y N más" para un bloque que se ha quedado corto.
     *
     * Truncar sin decirlo es peor que no mostrar nada: quien mira el Dashboard
     * asume que lo que ve es todo lo que hay. Esta fila existe para que 5 de 1.000
     * no se lean nunca como 5 de 5.
     *
     * `$total` se calcula sólo cuando el bloque ha llenado su cupo —si volvieron
     * menos filas que el límite, ya sabemos que no hay más y nos ahorramos el COUNT.
     */
    private function filaRestantes(int $mostradas, int $limite, callable $contar, string $level, string $queSon, string $url, string $cta): array
    {
        if ($mostradas < $limite) {
            return [];
        }

        $total = (int) $contar();
        $ocultas = $total - $mostradas;

        if ($ocultas <= 0) {
            return [];
        }

        return [[
            'level' => $level,
            'subject' => '+' . number_format($ocultas, 0, ',', '.') . ' más',
            'rest' => $queSon . ' (se muestran las ' . $mostradas . ' más urgentes de ' . number_format($total, 0, ',', '.') . ')',
            'meta' => 'El listado completo tiene filtros y paginado',
            'cta' => $cta,
            'url' => $url,
        ]];
    }

    private function licenciasQueCaducan(): array
    {
        // Rango CERRADO a propósito: sin la cota inferior se cuentan también las ya
        // vencidas, que es el error de MGR-025.
        $base = fn () => LicenseToken::query()
            ->whereNotNull('end_at')
            ->whereBetween('end_at', [now(), now()->addDays(30)])
            ->where('active', true);

        $tokens = $base()
            ->with('client')
            ->withCount('environments')
            ->orderBy('end_at')
            ->limit(self::FILAS_POR_BLOQUE)
            ->get();

        $filas = $tokens->map(function (LicenseToken $token) {
            $dias = (int) ceil(now()->diffInDays($token->end_at, false));

            return [
                'level' => $dias <= 7 ? 'crit' : 'warn',
                'subject' => $token->name ?: 'Licencia ' . $token->token,
                'rest' => 'vence en ' . $dias . ' día(s) — afecta a ' . $token->environments_count . ' entorno(s)',
                'meta' => ($token->client?->name ?? 'Sin cliente') . ' · ' . $token->end_at->format('d/m/Y'),
                'cta' => 'Renovar',
                'url' => route('license_tokens.show', $token),
            ];
        })->all();

        return array_merge($filas, $this->filaRestantes(
            count($filas),
            self::FILAS_POR_BLOQUE,
            fn () => $base()->count(),
            'warn',
            'licencias más vencen en los próximos 30 días',
            route('license_tokens.index'),
            'Ver licencias',
        ));
    }

    private function entornosSinSincronizar(): array
    {
        // Solo entornos que SÍ sincronizaron alguna vez y han dejado de hacerlo: un
        // `refresh_at` nulo es un entorno que nunca ha dado señal, y eso no es una
        // incidencia nueva sino una configuración pendiente.
        $base = fn () => Environment::query()
            ->where('active', true)
            ->whereNotNull('refresh_at')
            ->where('refresh_at', '<', now()->subDays(3));

        $entornos = $base()
            ->with('client')
            ->orderBy('refresh_at')
            ->limit(self::FILAS_POR_BLOQUE)
            ->get();

        $filas = $entornos->map(function (Environment $entorno) {
            // El orden importa: en Carbon 3 `diffInDays` va CON signo, así que
            // `now()->diffInDays($pasado)` da negativo —la fila decía "hace -20
            // día(s)" y ningún entorno llegaba nunca a crítico. Se cuenta desde la
            // última señal hasta ahora, que es lo que se quiere decir.
            $dias = (int) $entorno->refresh_at->diffInDays(now());

            return [
                'level' => $dias >= 7 ? 'crit' : 'warn',
                'subject' => $entorno->domain,
                'rest' => 'sin sincronizar desde hace ' . $dias . ' día(s)',
                'meta' => ($entorno->client?->name ?? 'Sin cliente')
                    . ' · última señal ' . $entorno->refresh_at->format('d/m/Y H:i'),
                'cta' => 'Ver entorno',
                'url' => route('environments.edit', $entorno),
            ];
        })->all();

        return array_merge($filas, $this->filaRestantes(
            count($filas),
            self::FILAS_POR_BLOQUE,
            fn () => $base()->count(),
            'warn',
            'entornos más llevan más de 3 días sin sincronizar',
            route('environments.index'),
            'Ver entornos',
        ));
    }

    private function entornosSinLicencia(): array
    {
        // Una sola línea agregada: con veinte entornos sin licencia, veinte filas
        // ahogarían lo que de verdad requiere acción hoy.
        $cuantos = Environment::query()
            ->where('active', true)
            ->whereNull('license_token_id')
            ->count();

        if ($cuantos === 0) {
            return [];
        }

        return [[
            'level' => 'info',
            'subject' => $cuantos . ' entorno(s)',
            'rest' => 'sin licencia asignada: no reciben contenido ni configuración',
            'meta' => 'Revisar si es intencionado o falta vincularlos a un token',
            'cta' => 'Ver entornos',
            'url' => route('environments.index'),
        ]];
    }

    private function entornosConErrores(): array
    {
        // Sobre `api_request_logs`: se limita a 24 h y a 5 filas, y se apoya en el
        // índice compuesto (started_at, http_status) añadido en MGR-027.
        $base = fn () => ApiRequestLog::query()
            ->where('started_at', '>=', now()->subDay())
            ->whereIn('http_status', [401, 403])
            ->whereNotNull('host');

        $hosts = $base()
            ->select('host', DB::raw('count(*) as c'))
            ->groupBy('host')
            ->orderByDesc('c')
            ->limit(self::FILAS_POR_BLOQUE)
            ->get();

        $filas = $hosts->map(fn ($fila) => [
            'level' => 'crit',
            'subject' => $fila->host,
            'rest' => 'licencia rechazada — ' . number_format($fila->c, 0, ',', '.') . ' petición(es) en 24 h',
            'meta' => 'El sitio está pidiendo licencia y no la obtiene',
            'cta' => 'Ver logs',
            'url' => route('api-logs.index', ['host' => $fila->host]),
        ])->all();

        return array_merge($filas, $this->filaRestantes(
            count($filas),
            self::FILAS_POR_BLOQUE,
            // Dominios distintos, no peticiones: el COUNT sólo se lanza si el bloque
            // se llenó, y va sobre la ventana de 24 h con el índice compuesto.
            fn () => $base()->distinct()->count('host'),
            'crit',
            'dominios más tienen la licencia rechazada en 24 h',
            route('api-logs.index', ['status' => '401']),
            'Ver logs',
        ));
    }

    private function ipsEnElLimite(): array
    {
        $base = fn () => ApiRateEvent::query()
            ->where('window_started_at', '>=', now()->subDay());

        $eventos = $base()
            ->with('licenseToken.client')
            ->orderByDesc('window_started_at')
            ->limit(self::FILAS_POR_BLOQUE_MENOR)
            ->get();

        // Dos límites distintos escriben en esta tabla y la fila tiene que decir cuál
        // ha cortado, porque la acción a tomar no es la misma: el de IP se ajusta en
        // `config/api.php` y suele ser un sitio en bucle; el de la licencia se ajusta
        // en la ficha del token y suele ser un cliente que ha crecido.
        // Ver known-issues MGR-008 y MGR-013.
        $filas = $eventos->map(function (ApiRateEvent $evento) {
            $bloqueado = $evento->kind === ApiRateEvent::KIND_BLOCKED;

            if ($evento->es_de_token) {
                $licencia = $evento->licenseToken;

                return [
                    'level' => 'warn',
                    'subject' => 'Licencia ' . ($licencia?->name ?? '#' . $evento->license_token_id),
                    'rest' => 'alcanzó su límite de ' . $evento->limit_value . ' peticiones/minuto'
                        . ($evento->rejected > 1 ? ' (' . $evento->rejected . ' rechazadas)' : ''),
                    'meta' => ($licencia?->client?->name ?? 'Sin cliente')
                        . ' · ' . $evento->window_started_at->format('d/m H:i')
                        . ($evento->host ? ' · ' . $evento->host : ''),
                    'cta' => $licencia ? 'Ver licencia' : 'Ver registro',
                    'url' => $licencia
                        ? route('license_tokens.show', $licencia)
                        : route('api-logs.index', ['ip' => $evento->ip]),
                ];
            }

            return [
                'level' => $bloqueado ? 'warn' : 'info',
                'subject' => 'IP ' . $evento->ip,
                'rest' => $bloqueado
                    ? 'alcanzó el límite de peticiones (' . $evento->observed . '/' . $evento->limit_value . ')'
                    : 'se acerca al límite de peticiones (' . $evento->observed . '/' . $evento->limit_value . ')',
                'meta' => 'api_rate_events · ' . $evento->window_started_at->format('d/m H:i')
                    . ($evento->host ? ' · ' . $evento->host : ''),
                'cta' => 'Ver registro',
                'url' => route('api-logs.index', ['ip' => $evento->ip]),
            ];
        })->all();

        return array_merge($filas, $this->filaRestantes(
            count($filas),
            self::FILAS_POR_BLOQUE_MENOR,
            fn () => $base()->count(),
            'warn',
            'episodios más de IPs en el límite en 24 h',
            route('api-logs.index'),
            'Ver registro',
        ));
    }

    /**
     * Avisos que escribe la propia aplicación en la tabla `logs` con nivel `danger`
     * o `warning`: límite de peticiones alcanzado, borrados de log, errores de
     * procesos internos.
     *
     * El diseño no traía un bloque para esto, pero el rediseño no puede hacer
     * desaparecer una visibilidad que ya existía: el middleware del límite escribe
     * aquí precisamente para que se vea en el Dashboard (MGR-008), y el bloque
     * anterior llevaba tiempo pintándose en blanco (MGR-028). Van al mismo sitio que
     * el resto de lo que requiere atención, que es donde se miran.
     */
    private function avisosDelSistema(): array
    {
        $base = fn () => MonitoringLog::query()
            ->whereIn('level', ['danger', 'warning'])
            ->where('created_at', '>=', now()->subDays(7));

        $avisos = $base()
            ->orderByDesc('created_at')
            ->limit(self::FILAS_POR_BLOQUE_MENOR)
            ->get();

        $filas = $avisos->map(fn (MonitoringLog $aviso) => [
            'level' => $aviso->level === 'danger' ? 'crit' : 'warn',
            'subject' => 'Sistema',
            'rest' => $aviso->msg,
            'meta' => 'Código ' . $aviso->code . ' · ' . $aviso->created_at->diffForHumans(),
            'cta' => 'Ver',
            'url' => route('dashboard'),
        ])->all();

        // Aquí el "ver todo" no tiene a dónde ir: la tabla `logs` no tiene pantalla
        // propia todavía (queda anotado como pendiente). Aun así hay que decir el
        // número, que es lo que evita leer 3 de 400 como 3 de 3.
        return array_merge($filas, $this->filaRestantes(
            count($filas),
            self::FILAS_POR_BLOQUE_MENOR,
            fn () => $base()->count(),
            'warn',
            'avisos más del sistema en los últimos 7 días',
            route('dashboard'),
            'Sin listado',
        ));
    }

    /* ==========================================================
       Estado del parque
       ========================================================== */

    private function parque(): array
    {
        return Cache::remember('dashboard:parque', 300, function () {
            $tiposMoodle = Type::where('name', 'like', '%Moodle%')->pluck('id');

            // **La columna `version` no guarda siempre lo mismo.** Guarda la release
            // —`5.1.1`— cuando el entorno ha sincronizado con el Manager actual, y el
            // versiondb que dejó el Manager antiguo —`2025100603.08`— cuando no.
            //
            // Antes se partía por el punto y se tomaban los dos primeros trozos como
            // «rama», así que los versiondb producían filas como `Moodle 2025100606` y
            // `Moodle 2025100605.02`: cinco ramas inventadas, una por sitio sin
            // actualizar, que además desplazaban del top 5 a las ramas de verdad.
            //
            // Ahora `VersionDeMoodle` dice si un valor es una release o no, y los que no
            // lo son van juntos a un grupo que **dice que no se sabe** en lugar de
            // fingir una rama. Ver `VersionDeMoodle` para por qué la misma columna
            // contiene dos cosas.
            $sinRelease = 0;

            $porVersion = Environment::query()
                ->select('version', DB::raw('count(*) as c'))
                ->where('active', true)
                ->whereIn('type_id', $tiposMoodle)
                ->whereNotNull('version')
                ->groupBy('version')
                ->get()
                ->reduce(function (array $acc, $fila) use (&$sinRelease) {
                    $rama = VersionDeMoodle::rama((string) $fila->version);

                    if ($rama === null) {
                        $sinRelease += (int) $fila->c;

                        return $acc;
                    }

                    $acc[$rama] = ($acc[$rama] ?? 0) + (int) $fila->c;

                    return $acc;
                }, []);

            krsort($porVersion);

            $totalMoodle = array_sum($porVersion) + $sinRelease;

            $versiones = [];
            foreach ($porVersion as $rama => $n) {
                $versiones[] = [
                    'nombre' => 'Moodle ' . $rama,
                    'n' => $n,
                    // Entero y no float: `round()` devuelve float y el ancho de la barra salia
                    // como `50.0%` en el `style`. Funciona, pero un porcentaje es un entero.
                    'pct' => $totalMoodle > 0 ? (int) round(($n / $totalMoodle) * 100) : 0,
                    'conocida' => true,
                ];
            }

            // Se corta a 5 ramas **antes** de añadir el grupo de los desconocidos, para
            // que el aviso no se quede fuera del corte: es justo el que hay que ver, y
            // es la lista de sitios que llevan tiempo sin dar señal.
            $versiones = array_slice($versiones, 0, 5);

            if ($sinRelease > 0) {
                $versiones[] = [
                    'nombre' => 'Sin release conocida',
                    'n' => $sinRelease,
                    'pct' => $totalMoodle > 0 ? (int) round(($sinRelease / $totalMoodle) * 100) : 0,
                    'conocida' => false,
                ];
            }

            $total = Environment::where('active', true)->count();
            $hoy = now()->format('Y-m-d');

            // **Contratos y no el flag `has_support`.** Este contador decía
            // «N con soporte activo» leyendo una casilla que nadie recalcula: en la
            // base local había 5 entornos marcados y sin una sola fila en
            // `environment_support`, y el último contrato real acabó en marzo de 2023.
            // La cuenta se hace igual que en el listado de Entornos —`soporteVigente`
            // con `end_at` desde hoy— para que las dos pantallas no se contradigan.
            $conSoporte = Environment::where('active', true)
                ->whereHas('soporteVigente', fn ($q) => $q->whereDate('end_at', '>=', $hoy))
                ->count();

            // Los que llevan el flag puesto y no tienen contrato que lo respalde. No es
            // un aviso de negocio, es **el dato descuadrado**: mientras exista hay que
            // decir que existe, o el 0 de arriba se lee como «nadie tiene soporte».
            $flagSinContrato = Environment::where('active', true)
                ->where('has_support', true)
                ->whereDoesntHave('supports')
                ->count();

            return [
                'versiones' => $versiones,
                'total' => $total,
                'conSoporte' => $conSoporte,
                'flagSinContrato' => $flagSinContrato,
                'sinLicencia' => Environment::where('active', true)->whereNull('license_token_id')->count(),
            ];
        });
    }

    /* ==========================================================
       Actividad de la API
       ========================================================== */

    private function actividad(): array
    {
        return Cache::remember('dashboard:actividad', 300, function () {
            $hoyDesde = now()->startOfDay();
            $ayerDesde = now()->subDay()->startOfDay();

            $hoy = ApiRequestLog::where('started_at', '>=', $hoyDesde)->count();

            if ($hoy === 0 && ApiRequestLog::where('started_at', '>=', $ayerDesde)->doesntExist()) {
                // Ni hoy ni ayer: no hay nada que comparar. Se dice, en lugar de
                // pintar ceros que se confunden con "todo va bien".
                return ['disponible' => false];
            }

            $ayer = ApiRequestLog::whereBetween('started_at', [$ayerDesde, $hoyDesde])->count();

            // **Solo los errores de verdad.** Antes contaba todo `>= 400`, y la mitad
            // de eso son respuestas correctas: «no hay contenido publicado» y «el
            // cliente no tiene eso contratado». Con el criterio antiguo este panel
            // marcaba un 35 % de error permanente, que es la forma más rápida de que
            // nadie vuelva a mirarlo. Ver `norma-cero-errores.md`.
            // **Y solo los que nadie ha revisado.** Este número es una alerta: si un
            // error ya se ha mirado y marcado, seguir contándolo aquí hace que la
            // portada pida atención sobre algo que alguien ya está atendiendo. La
            // regla del panel: una alerta cuenta lo no revisado, un histórico cuenta
            // todo.
            $erroresHoy = ApiRequestLog::where('started_at', '>=', $hoyDesde)
                ->where('severity', Severidad::ERROR)
                ->whereNull('reviewed_at')
                ->count();
            $erroresAyer = ApiRequestLog::whereBetween('started_at', [$ayerDesde, $hoyDesde])
                ->where('severity', Severidad::ERROR)
                ->whereNull('reviewed_at')
                ->count();

            // Serie de 7 días para las dos sparklines: **una sola consulta agrupada**,
            // no siete ni catorce. El total y los errores salen de la misma pasada con un
            // `sum` condicional, que es portable entre MariaDB y sqlite —un `sum(case
            // when ...)` funciona en los dos, un `countIf` no—.
            $serie = ApiRequestLog::query()
                ->select(
                    DB::raw('date(started_at) as dia'),
                    DB::raw('count(*) as total'),
                    // La línea de la sparkline acompaña al número de arriba, así que
                    // sigue el mismo criterio: lo revisado no pinta pico.
                    DB::raw("sum(case when severity = '" . Severidad::ERROR . "' and reviewed_at is null then 1 else 0 end) as errores")
                )
                ->where('started_at', '>=', now()->subDays(6)->startOfDay())
                ->groupBy('dia')
                ->orderBy('dia')
                ->get()
                ->keyBy('dia');

            $dias = [];
            $diasError = [];

            for ($i = 6; $i >= 0; $i--) {
                $clave = now()->subDays($i)->toDateString();
                $fila = $serie[$clave] ?? null;
                $total = (int) ($fila->total ?? 0);
                $errores = (int) ($fila->errores ?? 0);

                $dias[] = $total;
                // **Porcentaje y no número absoluto**: un día con 10.000 peticiones y 50
                // errores está mejor que uno con 100 y 20, y una sparkline de absolutos
                // diría lo contrario.
                $diasError[] = $total > 0 ? round(($errores / $total) * 100, 2) : 0.0;
            }

            return [
                'disponible' => true,
                'hoy' => $hoy,
                'ayer' => $ayer,
                'variacion' => $ayer > 0 ? (int) round((($hoy - $ayer) / $ayer) * 100) : null,
                'errorPctHoy' => $hoy > 0 ? round(($erroresHoy / $hoy) * 100, 1) : 0,
                'errorPctAyer' => $ayer > 0 ? round(($erroresAyer / $ayer) * 100, 1) : 0,
                'serie' => $dias,
                'serieError' => $diasError,
            ];
        });
    }

    /* ==========================================================
       Contenido publicado
       ========================================================== */

    private function contenido(): array
    {
        return Cache::remember('dashboard:contenido', 300, function () {
            // Última versión de funcionalidades por producto. Una consulta agrupada.
            $ultimas = FeatureVersion::query()
                ->select('product_id', DB::raw('max(version) as version'), DB::raw('max(created_at) as creada'))
                ->groupBy('product_id')
                ->get()
                ->keyBy('product_id');

            return Product::query()
                ->orderBy('name')
                ->limit(6)
                ->get()
                ->map(function (Product $producto) use ($ultimas) {
                    $ultima = $ultimas->get($producto->id);

                    return [
                        'nombre' => $producto->name,
                        'url' => route('products.show', $producto),
                        'version' => $ultima?->version ?? '—',
                        'fecha' => $ultima?->creada
                            ? \Carbon\Carbon::parse($ultima->creada)->diffForHumans()
                            : 'Sin versiones',
                        'publicado' => $ultima !== null,
                    ];
                })
                ->all();
        });
    }

    /* ==========================================================
       Comprobar un dominio
       ========================================================== */

    private function buscarDominio(array $puede): ?array
    {
        $q = trim($this->domainQuery);

        if (!$puede['environments'] || mb_strlen($q) < 3) {
            return null;
        }

        $entorno = Environment::query()
            ->where('domain', 'like', '%' . $q . '%')
            ->orWhereHas('client', fn ($query) => $query->where('name', 'like', '%' . $q . '%'))
            ->with(['client', 'licenseToken', 'type'])
            ->first();

        if (!$entorno) {
            return ['encontrado' => false];
        }

        $token = $entorno->licenseToken;

        return [
            'encontrado' => true,
            'dominio' => $entorno->domain,
            'cliente' => $entorno->client?->name ?? 'Sin cliente',
            'licencia' => $token
                ? ($token->end_at
                    ? 'Activa hasta ' . $token->end_at->format('m/Y')
                    : 'Activa sin caducidad')
                : 'Sin licencia asignada',
            'licenciaOk' => $token !== null && $token->active,
            'version' => ($entorno->type?->name ?? 'Sin tipo') . ' ' . ($entorno->version ?? '—'),
            'sync' => $entorno->refresh_at ? $entorno->refresh_at->diffForHumans() : 'Nunca',
            'url' => route('environments.edit', $entorno),
            'urlLogs' => route('api-logs.index', ['host' => $entorno->domain]),
        ];
    }
}
