<?php

namespace App\Livewire\Environments\Plugins;

use App\Models\Environments\Environment;
use App\Models\Environments\Plugin;
use App\Models\Products\OwnPlugin;
use App\Models\Products\Product;
use App\Services\Moodle\MoodleSyncService;
use Illuminate\Support\Collection;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;

/**
 * Inventario de plugins de un entorno — desde `Plugins.dc.html`.
 *
 * **Qué había.** 470 filas paginadas de 25 en 25, con seis columnas de base de datos
 * —Nombre, `component`, Tipo, «Version disk», «Version db», Estado— y nada que dijera qué
 * hay que mirar. El dato que ordena la pantalla es este: **de los 464 plugins del entorno
 * más grande, requieren algo 6**. El 99 % de las filas no pide nada y ocupaba el mismo
 * espacio que las seis que sí.
 *
 * **Qué se enseña ahora, y por qué en ese orden:**
 *
 * 1. **Requiere atención** — lo que hay que hacer, con su motivo escrito.
 * 2. **Los nuestros** — los 7 plugins de Tresipunt. Es la pregunta más frecuente de la
 *    pantalla («¿qué versión de nuestro plugin tiene este cliente?») y antes había que
 *    buscarla escribiendo «tresipunt» en un filtro de texto.
 * 3. **El inventario completo** — agrupado por tipo, plegado, para cuando hace falta.
 *
 * **Tres cosas que la pantalla anterior decía mal:**
 *
 * - **«Version disk» y «Version db» juntas son el dato más útil y no se explicaba.**
 *   Cuando no coinciden, los ficheros del plugin se han subido pero **el upgrade de Moodle
 *   no se ha ejecutado**: el plugin está a medio actualizar y el sitio puede estar dando
 *   errores. Pasa ahora mismo con `local_tresipunt`. Se enseñaban los dos números en
 *   columnas contiguas sin decir que fueran distintos.
 * - **«Actualizado» era una afirmación demasiado fuerte.** Sale de `has_updates`, que
 *   depende de que ese Moodle tenga el comprobador activado y haya podido salir a
 *   internet. Significa «no nos consta nada nuevo», no «está al día». Con 459 filas en
 *   verde, la diferencia importa.
 * - **La incompatibilidad estaba enterrada en la modal.** Un plugin que declara
 *   incompatibilidad con algo instalado en ese sitio es justo lo que hay que ver sin
 *   abrir nada.
 *
 * **Y lo que esta pantalla no puede decir**: cuándo se instaló cada plugin. El inventario
 * se reescribe entero en cada sincronización (`forceDelete` + insert), así que
 * `created_at` es la fecha del último sync, no la de instalación. Tampoco hay histórico:
 * no se puede responder «qué cambió esta semana».
 */
class Index extends Component
{
    public Environment $environment;

    /**
     * Búsqueda por nombre o por `component`.
     *
     * **En la URL** para poder mandar el enlace: «mira, este cliente tiene el customcert
     * desactualizado» es una conversación que se tiene.
     *
     * Y busca en los dos campos, no solo en `component` como antes: el nombre es lo que
     * se recuerda de un plugin, y el `component` lo que se copia.
     */
    #[Url(as: 'q', except: '')]
    public string $busca = '';

    /** Solo los que requieren algo. Son 6 de 464: sin este filtro hay que rebuscar. */
    #[Url(as: 'atencion', except: false)]
    public bool $soloAtencion = false;

    /** Qué grupo de tipos está desplegado. Uno a la vez: son 62. */
    public ?string $tipoAbierto = null;

    /** Qué plugin tiene el detalle abierto. */
    public ?int $pluginAbierto = null;

    /**
     * Pistas de nombre para **sugerir** un alta, nunca para identificar.
     *
     * Quién es nuestro lo decide el catálogo de Productos (`esNuestro()`). Esta lista
     * solo sirve para avisar de que quizá falte dar de alta un producto, y se usa en
     * `candidatosAAlta()`. Cuando esos cinco plugins estén en el catálogo, se puede
     * borrar sin que la pantalla pierda nada.
     */
    private const PISTAS_DE_NOMBRE = ['tresipunt', 'fresk', 'fundae', '3ip'];

    /** El catálogo de Productos leído en esta petición. */
    private ?array $productos = null;

    /** Los `component` declarados como nuestros, leídos en esta petición. */
    private ?array $declarados = null;

    /** Cuántos tipos se pintan desplegables; el resto se resume al pie. */
    private const TIPOS_VISIBLES = 12;

    /**
     * El inventario leído en esta petición.
     *
     * Privada a propósito: no se serializa en el snapshot de Livewire —serían 470
     * filas viajando al navegador y volviendo— y se vuelve a leer en cada petición,
     * que es lo correcto porque una sincronización puede haberlo cambiado.
     */
    private ?Collection $cache = null;

    public function mount(Environment $environment): void
    {
        $this->environment = $environment;
    }

    public function alternarTipo(string $tipo): void
    {
        $this->tipoAbierto = $this->tipoAbierto === $tipo ? null : $tipo;
        $this->pluginAbierto = null;
    }

    public function alternarPlugin(int $id): void
    {
        $this->pluginAbierto = $this->pluginAbierto === $id ? null : $id;
    }

    /**
     * ¿Se le puede pedir a este Moodle que se sincronice?
     *
     * Hacen falta las dos cosas: el permiso `admin.environments.refresh` y que el sitio
     * nos haya dado su token de servicios web. Sin token no hay forma de llamarle, y es
     * más honesto no ofrecer el botón que ofrecerlo y fallar.
     *
     * La misma regla que en la tarjeta del listado (`EnvironmentCard`).
     */
    public function getPuedeSincronizarProperty(): bool
    {
        return auth()->user()?->can('admin.environments.refresh')
            && trim((string) $this->environment->moodletoken) !== '';
    }

    /**
     * Pide al Moodle que vuelva a enviar su inventario.
     *
     * **Es la acción natural de esta pantalla**: el inventario se reescribe entero en
     * cada sincronización, así que cuando la lista es vieja —o cuando se acaba de
     * actualizar un plugin en el sitio— lo que hace falta es volver a pedirlo, y antes
     * había que irse al listado de entornos a buscar la tarjeta.
     *
     * Al terminar se olvida el inventario memoizado: si no, la pantalla se repintaría
     * con la lista de antes de sincronizar y parecería que el botón no ha hecho nada.
     */
    public function sincronizar(MoodleSyncService $servicio): void
    {
        // El permiso de la ruta no se reaplica en /livewire/update (MGR-005), y el de la
        // ruta es de lectura: sincronizar es otra cosa.
        $this->authorize('admin.environments.refresh');

        $resultado = $servicio->lanzar($this->environment);

        if (!$resultado['ok']) {
            // En modal y no en un flash: el fallo trae un detalle técnico que hay que
            // poder leer y copiar —un error de certificado, un 404—. La misma modal que
            // usa la tarjeta del listado.
            $this->dispatch('openModal',
                component: \App\Livewire\Environments\SyncResultModal::class,
                arguments: [
                    'dominio' => (string) $this->environment->domain,
                    'mensaje' => $resultado['mensaje'],
                    'detalle' => $resultado['detalle'] ?? null,
                ]
            );

            return;
        }

        $this->environment->refresh();
        $this->cache = null;

        session()->flash('success', $resultado['mensaje']);
    }

    /** Si el Moodle nos llama de vuelta, el inventario que hay en memoria ya no vale. */
    #[On('environmentSynced')]
    public function refrescarTrasSincronizar(): void
    {
        $this->environment->refresh();
        $this->cache = null;
    }

    /**
     * El inventario entero, de una consulta.
     *
     * Son 470 filas para el entorno más grande y **se leen todas**: hay que agrupar por
     * tipo, contar cuántos requieren algo y cruzar las dependencias con lo instalado, y
     * ninguna de las tres cosas se puede hacer con una página de 25. Es una consulta por
     * un índice y las filas son pequeñas.
     */
    private function inventario(): Collection
    {
        // **Memoizado dentro de la petición.** `render()` lo necesita cinco veces
        // —atención, los nuestros, los grupos, el resumen y la frescura— y sin esto
        // eran cinco consultas de 470 filas en cada interacción de Livewire.
        if ($this->cache !== null) {
            return $this->cache;
        }

        return $this->cache = Plugin::where('environment_id', $this->environment->id)
            ->orderBy('type')
            ->orderBy('component')
            ->get();
    }

    /**
     * Los `component` instalados, para cruzar dependencias e incompatibilidades.
     *
     * **Es lo que convierte una lista de texto en una respuesta.** `dependencies` y
     * `pluginincompatible` son nombres que declara el plugin, y lo único que interesa
     * saber de ellos es si están en este entorno o no.
     */
    private function instalados(Collection $inventario): array
    {
        return $inventario->pluck('component')->filter()->all();
    }

    /**
     * Los `component` que están en el catálogo de Productos.
     *
     * **El `slug` de un producto ES su component de Moodle** —`theme_fresk`,
     * `block_tresipuntsepe`, `report_fundae`—, que es además cómo lo resuelve la API. Así
     * que el cruce es exacto y no hace falta ninguna columna nueva.
     *
     * @return array<string, \App\Models\Products\Product>
     */
    private function delCatalogo(): array
    {
        if ($this->productos !== null) {
            return $this->productos;
        }

        return $this->productos = Product::query()
            ->select(['id', 'slug', 'name', 'actived', 'enabled'])
            ->get()
            ->keyBy('slug')
            ->all();
    }

    /**
     * ¿Es un plugin nuestro?
     *
     * **Lo decide el Manager, y por declaración explícita**, nunca adivinándolo por el
     * nombre del plugin. Un `component` es nuestro si está en uno de los dos catálogos:
     *
     * 1. **Plugins propios** (Configuración → Catálogos → Plugins propios), que es la
     *    lista de «esto es de Tresipunt» y donde solo hay que poner el identificador.
     * 2. **Productos**, porque un producto es nuestro por definición: tiene ficha,
     *    licencias y contenido versionado detrás.
     *
     * **Antes se miraba el nombre** —si el `component` llevaba «tresipunt», «fresk»…— y
     * fallaba en las dos direcciones: `local_courseai` es nuestro y no lleva ninguna de
     * esas palabras, y cualquier plugin de terceros con «fundae» en el nombre se colaba.
     * Además la lista vivía en el código, así que añadir uno obligaba a desplegar.
     *
     * Las pistas de nombre siguen existiendo pero **solo para sugerir un alta**, y en la
     * pantalla del catálogo, no aquí. Ver `candidatosAAlta()`.
     */
    private function esNuestro(?string $component): bool
    {
        return in_array((string) $component, $this->nuestrosComponents(), true);
    }

    /** Los `component` declarados como nuestros, cacheados por el modelo. */
    private function nuestrosComponents(): array
    {
        if ($this->declarados !== null) {
            return $this->declarados;
        }

        return $this->declarados = OwnPlugin::sonNuestros();
    }

    /** El producto del catálogo de un plugin, si lo hay. */
    private function productoDe(?string $component): ?Product
    {
        return $this->delCatalogo()[(string) $component] ?? null;
    }

    /**
     * ¿Está instalado el plugin que habla con el Manager?
     *
     * **Si no está, lo explica casi todo**: ese entorno no recibe licencias ni
     * contenido, no envía telemetría y no manda inventario. Y sin decirlo, quien mire
     * la pantalla ve un inventario normal y no entiende por qué el resto del panel
     * está vacío para ese cliente.
     *
     * Solo se afirma cuando **hay inventario**: si el entorno no ha enviado nada, no
     * se sabe qué tiene instalado, y decir «no está» sería inventarlo.
     */
    public function faltaElOrquestador(): bool
    {
        $inventario = $this->inventario();

        if ($inventario->isEmpty()) {
            return false;
        }

        return !$inventario->contains('component', OwnPlugin::ORQUESTADOR);
    }

    /**
     * Plugins que **parecen** nuestros y no están en el catálogo.
     *
     * Es una sugerencia, no una identificación: sale de buscar nuestras marcas en el
     * `component`, y con eso no se puede afirmar nada. Lo que sí se puede es avisar de
     * que **quizá falte un producto por dar de alta**, que es justo lo que pasa hoy con
     * cinco plugins del entorno de desarrollo.
     *
     * Sin este aviso, quitar la heurística de la identificación los haría desaparecer de
     * la pantalla y nadie se enteraría de que faltan.
     *
     * @return array<int, Plugin>
     */
    public function candidatosAAlta(): array
    {
        return $this->inventario()
            ->filter(function (Plugin $plugin) {
                if ($this->esNuestro($plugin->component)) {
                    return false;
                }

                $component = mb_strtolower((string) $plugin->component);

                foreach (self::PISTAS_DE_NOMBRE as $pista) {
                    if (str_contains($component, $pista)) {
                        return true;
                    }
                }

                return false;
            })
            ->values()
            ->all();
    }

    /**
     * En qué estado está la versión de un plugin.
     *
     * `versiondisk` es la versión de los ficheros y `versiondb` la que Moodle tiene
     * registrada. **Cuando no coinciden hay tres casos distintos, y confundirlos manda a
     * arreglar lo que no es:**
     *
     * - `pendiente` — **disco más nuevo que la base de datos.** Los ficheros se han subido
     *   y el upgrade de Moodle no se ha ejecutado. Se arregla entrando al sitio como
     *   administrador o pasando el cron: Moodle lanza el upgrade solo.
     * - `retrocedido` — **disco más viejo que la base de datos.** Alguien ha puesto
     *   ficheros de una versión anterior sobre una base de datos ya migrada, normalmente
     *   restaurando una copia a medias. **Moodle no lanza ningún upgrade en este caso**
     *   —no sabe bajar— y el plugin puede fallar contra un esquema que no es el suyo. El
     *   arreglo es subir los ficheros que tocan, no pasar el cron.
     * - `ausente` — **no hay versión de disco y sí de base de datos.** Los ficheros no
     *   están y el plugin sigue registrado: es el «Missing from disk» de la pantalla de
     *   plugins de Moodle. Ni el cron ni subir una versión nueva lo resuelven — hay que
     *   decidir entre restaurar los ficheros o desinstalarlo de verdad, porque mientras
     *   siga registrado sus tablas y sus datos siguen ahí.
     *
     * **El caso `ausente` faltaba y se leía como `ok`**, así que el inventario decía «Sin
     * novedades» de un plugin que Moodle marca en rojo en su propia pantalla.
     *
     * Se comparan como cadenas y no como enteros: son marcas `YYYYMMDDXX` de diez cifras
     * y en 32 bits el `int` se desborda.
     *
     * @return 'ok'|'pendiente'|'retrocedido'|'ausente'
     */
    private function desfase(Plugin $plugin): string
    {
        $faltaElDisco = $this->sinVersion($plugin->versiondisk);
        $faltaLaBd = $this->sinVersion($plugin->versiondb);

        if ($faltaElDisco && ! $faltaLaBd) {
            return 'ausente';
        }

        // Al revés —ficheros sin registrar en la base de datos— es un plugin recién
        // subido que Moodle todavía no ha instalado, y eso ya es `pendiente`: el arreglo
        // es el mismo, dejar que corra el upgrade. No llega por esta vía de todos modos,
        // porque lo que el orquestador enumera es lo que Moodle tiene registrado.
        if ($faltaLaBd && ! $faltaElDisco) {
            return 'pendiente';
        }

        // Sin ninguna de las dos no se puede decir nada, y decir algo sería inventarlo.
        if ($faltaElDisco && $faltaLaBd) {
            return 'ok';
        }

        $disco = (string) $plugin->versiondisk;
        $bd = (string) $plugin->versiondb;

        if ($disco === $bd) {
            return 'ok';
        }

        // Las marcas de versión de Moodle son de longitud fija, así que la comparación de
        // cadenas ordena bien. Si alguna viniera con otra longitud, se compara por
        // longitud primero para no decir que «999» es mayor que «2026090200».
        if (strlen($disco) !== strlen($bd)) {
            return strlen($disco) > strlen($bd) ? 'pendiente' : 'retrocedido';
        }

        return $disco > $bd ? 'pendiente' : 'retrocedido';
    }

    /**
     * ¿Esta versión no la sabemos?
     *
     * Las dos columnas son NOT NULL, así que **la ausencia no llega como `null`**: llega
     * como el `'0'` que ponen `SyncAction` y `PluginsAction` cuando el payload no trae el
     * dato. Se aceptan las tres formas —`null`, `''` y `'0'`— porque hay filas antiguas
     * guardadas de las dos maneras y ninguna marca `YYYYMMDDXX` real es cero.
     */
    private function sinVersion(?string $version): bool
    {
        return $version === null || trim($version) === '' || (int) $version === 0;
    }

    /**
     * La comprobación de arriba y el *scope* `ausenteDelDisco()` del modelo **tienen que
     * decir lo mismo**: esta clasifica una fila ya cargada y aquel filtra en SQL para los
     * recuentos del listado de entornos y del parque. Lo fija
     * `tests/Feature/Environments/PluginsInventarioTest.php`.
     */

    /** ¿La versión de los ficheros y la de la base de datos no cuadran? */
    private function aMedias(Plugin $plugin): bool
    {
        return $this->desfase($plugin) !== 'ok';
    }

    /**
     * Las versiones candidatas de una actualización.
     *
     * `availableupdates` es un JSON del comprobador de Moodle y **no tiene forma
     * garantizada**: puede traer una versión o varias, y las claves cambian —hay
     * actualizaciones con `download` y otras con `downloadurl`, y algunas sin ninguna—.
     * Se lee a la defensiva.
     *
     * @return array<int, array{release: string, madurez: ?string, color: string}>
     */
    private function candidatas(Plugin $plugin): array
    {
        $crudas = $plugin->availableupdates;

        if (!is_array($crudas)) {
            return [];
        }

        $candidatas = [];

        foreach ($crudas as $cruda) {
            if (!is_array($cruda)) {
                continue;
            }

            $version = $cruda['release'] ?? $cruda['version'] ?? null;

            if ($version === null) {
                continue;
            }

            $candidatas[] = [
                'release' => (string) $version,
            ] + $this->madurez($cruda['maturity'] ?? null);
        }

        return $candidatas;
    }

    /**
     * La madurez de una versión candidata, en palabras.
     *
     * **Con cuidado, porque la escala no está confirmada.** Moodle documenta
     * `MATURITY_ALPHA = 50`, `BETA = 100`, `RC = 150` y `STABLE = 200`, y en los datos que
     * tenemos aparecen **200 y 300**. El 300 no está en esa lista, así que no se traduce a
     * una palabra: se enseña el número y se dice que no consta. Inventar «superestable»
     * para un valor que no sabemos qué significa sería peor que no decir nada, porque de
     * esto depende si alguien instala algo en producción.
     *
     * @return array{madurez: ?string, color: string}
     */
    private function madurez($valor): array
    {
        if (!is_numeric($valor)) {
            return ['madurez' => null, 'color' => 'var(--text-subtle)'];
        }

        return match ((int) $valor) {
            50 => ['madurez' => 'alfa — no instalar', 'color' => 'var(--danger-500)'],
            100 => ['madurez' => 'beta — no instalar en pro', 'color' => 'var(--warning-500)'],
            150 => ['madurez' => 'candidata — probar antes', 'color' => 'var(--warning-500)'],
            200 => ['madurez' => 'estable', 'color' => 'var(--success-500)'],
            default => [
                // Ver el docblock: no se traduce lo que no se sabe.
                'madurez' => 'madurez ' . (int) $valor . ' — sin confirmar',
                'color' => 'var(--text-subtle)',
            ],
        };
    }

    /**
     * Lo que requiere algo, con su motivo escrito y ordenado por gravedad.
     *
     * Tres niveles, y el orden no es estético: es el orden en el que hay que atender.
     *
     * - **Crítico** — a medio actualizar. El sitio puede estar roto ahora.
     * - **Aviso** — hay actualización disponible. Hay que planificarlo.
     * - **Informativo** — declara una incompatibilidad. Puede no afectar, y se dice si
     *   afecta o no cruzándolo con lo instalado.
     *
     * @return array<int, array<string, mixed>>
     */
    public function atencion(): array
    {
        $inventario = $this->inventario();
        $instalados = $this->instalados($inventario);
        $filas = [];

        foreach ($inventario as $plugin) {
            $desfase = $this->desfase($plugin);

            if ($desfase === 'pendiente') {
                $filas[] = [
                    'nivel' => 'crit',
                    'plugin' => $plugin,
                    'etiqueta' => 'Falta el upgrade',
                    'texto' => 'Los ficheros están en la versión ' . $plugin->versiondisk
                        . ' y Moodle tiene registrada la ' . $plugin->versiondb . ': el plugin se '
                        . 'ha subido y el upgrade no se ha ejecutado. Hasta que se ejecute, este '
                        . 'plugin puede dar errores. Se arregla entrando al sitio como '
                        . 'administrador o dejando pasar el cron: Moodle lo lanza solo.',
                    'candidatas' => [],
                ];

                continue;
            }

            if ($desfase === 'ausente') {
                $filas[] = [
                    'nivel' => 'crit',
                    'plugin' => $plugin,
                    'etiqueta' => 'Ausente del disco',
                    // **Aquí no se manda a actualizar nada**, que es lo que haría cualquier
                    // texto de desfase: no hay ficheros que actualizar. Y se dice lo que se
                    // queda atrás si se ignora, porque un plugin registrado sin código es
                    // una avería silenciosa: sus tablas siguen, sus datos siguen, y sus
                    // funcionalidades no.
                    'texto' => 'Moodle tiene registrada la versión ' . $plugin->versiondb
                        . ' y sus ficheros no están en el disco. Es el «Missing from disk» de '
                        . 'la pantalla de plugins de Moodle: suele ser un borrado a mano o un '
                        . 'despliegue incompleto. Ni el cron ni subir una versión nueva lo '
                        . 'arreglan: hay que restaurar los ficheros —si el plugin se sigue '
                        . 'usando— o desinstalarlo desde Administración del sitio, porque '
                        . 'mientras siga registrado sus tablas y sus datos siguen ahí.',
                    'candidatas' => [],
                ];

                continue;
            }

            if ($desfase === 'retrocedido') {
                $filas[] = [
                    'nivel' => 'crit',
                    'plugin' => $plugin,
                    'etiqueta' => 'Ficheros retrocedidos',
                    // **No se dice «falta el upgrade», que es lo que parece.** Aquí Moodle
                    // no lanza ningún upgrade —no sabe bajar de versión—, así que pasar el
                    // cron no arregla nada y mandar a hacerlo pierde el tiempo de quien lo
                    // lea.
                    'texto' => 'Los ficheros están en la versión ' . $plugin->versiondisk
                        . ', que es ANTERIOR a la ' . $plugin->versiondb . ' que Moodle tiene '
                        . 'registrada. Suele ser una copia restaurada a medias: ficheros viejos '
                        . 'sobre una base de datos ya migrada. Moodle no lanza ningún upgrade en '
                        . 'este caso, así que el cron no lo arregla: hay que subir los ficheros '
                        . 'de la versión que toca.',
                    'candidatas' => [],
                ];

                continue;
            }

            if ($plugin->has_updates) {
                $candidatas = $this->candidatas($plugin);

                $filas[] = [
                    'nivel' => 'warn',
                    'plugin' => $plugin,
                    'etiqueta' => 'Actualización disponible',
                    'texto' => 'Instalada: ' . ($plugin->release ?: 'versión no declarada') . '.'
                        . (count($candidatas) > 1 ? ' Hay ' . count($candidatas) . ' candidatas.' : ''),
                    'candidatas' => $candidatas,
                ];
            }
        }

        // Las incompatibilidades van después, y **diciendo si afectan**: un plugin que
        // declara incompatibilidad con algo que no está instalado no es un problema, y
        // pintarlo igual que uno que sí lo tiene haría ignorar los dos.
        foreach ($inventario as $plugin) {
            if (!$plugin->pluginincompatible) {
                continue;
            }
            $declarados = $this->componentesDe($plugin->pluginincompatible);
            $presentes = array_values(array_intersect($declarados, $instalados));

            $filas[] = [
                'nivel' => $presentes === [] ? 'info' : 'crit',
                'plugin' => $plugin,
                'etiqueta' => $presentes === [] ? 'Incompatibilidad declarada' : 'Incompatibilidad presente',
                'texto' => $presentes === []
                    ? 'Declara incompatibilidad con ' . implode(', ', $declarados)
                        . ', que no está instalado en este entorno: no afecta.'
                    // Sin `**negritas**`: en Blade se pintarían literales. El énfasis lo
                    // pone el nivel «crit», que ya cambia el color y la marca de la fila.
                    : 'Declara incompatibilidad con ' . implode(', ', $presentes)
                        . ', y está instalado en este entorno. Conviene comprobarlo.',
                'candidatas' => [],
            ];
        }

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

        return array_map(fn (array $fila) => $this->conAdorno($fila), $filas);
    }

    /**
     * Cuántos casos hay de cada nivel, para el resumen del acordeón.
     *
     * **El bloque va plegado por defecto**, así que el botón tiene que decir qué hay
     * dentro sin abrirlo: «1 crítico · 2 avisos» decide si merece la pena abrirlo, y un
     * total suelto no —«3» no distingue tres avisos informativos de tres sitios rotos—.
     * Misma regla que el «Requiere tu atención» del Dashboard.
     *
     * @return array{crit:int, warn:int, info:int}
     */
    public function porNivel(array $atencion): array
    {
        $cuenta = ['crit' => 0, 'warn' => 0, 'info' => 0];

        foreach ($atencion as $caso) {
            $cuenta[$caso['nivel']]++;
        }

        return $cuenta;
    }

    /** Los `component` de un campo de texto libre separado por comas. */
    private function componentesDe(?string $texto): array
    {
        return array_values(array_filter(array_map('trim', explode(',', (string) $texto))));
    }

    /** Los colores y las marcas de un nivel. */
    private function conAdorno(array $fila): array
    {
        $color = match ($fila['nivel']) {
            'crit' => 'var(--danger-500)',
            'warn' => 'var(--warning-500)',
            default => 'var(--info-500)',
        };

        return $fila + [
            'color' => $color,
            'nuestro' => $this->esNuestro($fila['plugin']->component),
        ];
    }

    /**
     * Los plugins de Tresipunt, con lo que se sabe de cada uno.
     *
     * **Y con lo que no se sabe dicho.** Si la versión instalada es la vigente no se puede
     * responder: el Manager no registra «versión vigente» por producto. No es una avería,
     * es una decisión de producto pendiente —el mismo hueco que aparece en
     * `Manager - Stats.dc.html`— y la pantalla lo dice en vez de callarse.
     *
     * @return array<int, array<string, mixed>>
     */
    public function nuestros(): array
    {
        $inventario = $this->inventario();
        $instalados = $this->instalados($inventario);

        return $inventario
            ->filter(fn (Plugin $plugin) => $this->esNuestro($plugin->component))
            ->map(function (Plugin $plugin) use ($instalados) {
                $incompatibles = array_values(array_intersect(
                    $this->componentesDe($plugin->pluginincompatible),
                    $instalados
                ));

                return [
                    'plugin' => $plugin,
                    'desfase' => $this->desfase($plugin),
                    'conActualizacion' => (bool) $plugin->has_updates,
                    'incompatible' => $incompatibles !== [],
                    // El producto del catálogo, **si además es un producto**. Un plugin
                    // declarado a mano es nuestro igual y no tiene ficha: la mayoría de
                    // nuestros plugins no son productos comerciales.
                    'producto' => $this->productoDe($plugin->component),
                    // **El orquestador no es un plugin nuestro más**: es el que habla
                    // con la API. Si falta o está a medias, ese entorno deja de recibir
                    // licencias y contenido y no envía nada.
                    'orquestador' => $plugin->component === OwnPlugin::ORQUESTADOR,
                ];
            })
            // Los que tienen ficha primero —son los productos—, y dentro de cada grupo
            // por nombre. `producto` puede ser null, así que no se ordena por su nombre
            // directamente: eso reventaría.
            ->sortBy(fn (array $fila) => [
                $fila['orquestador'] ? 0 : 1,
                $fila['producto'] === null ? 1 : 0,
                mb_strtolower((string) ($fila['producto']->name ?? $fila['plugin']->name ?? $fila['plugin']->component)),
            ])
            ->values()
            ->all();
    }

    /**
     * El inventario agrupado por tipo.
     *
     * **Agrupado y no en lista plana** porque son 62 tipos con una forma muy desigual:
     * `block` 44, `tool` 41, `mod` 34… y una cola de 30 tipos con uno o dos plugins. Los
     * grandes primero, y la cola se resume al pie.
     *
     * Con búsqueda o con el filtro de atención puestos, **todo se despliega**: si no, hay
     * que abrir a mano el grupo donde está lo que el buscador acaba de encontrar.
     *
     * @return array{grupos: array<int, array<string, mixed>>, cola: ?array{tipos:int, plugins:int}}
     */
    public function grupos(): array
    {
        $inventario = $this->inventario();
        $instalados = $this->instalados($inventario);
        $filtrando = $this->busca !== '' || $this->soloAtencion;
        $busca = mb_strtolower(trim($this->busca));

        $porTipo = $inventario
            ->filter(function (Plugin $plugin) use ($busca) {
                if ($busca === '') {
                    return true;
                }

                // En nombre **y** en component: el nombre es lo que se recuerda, el
                // component lo que se copia. Antes solo se buscaba en component.
                return str_contains(mb_strtolower((string) $plugin->name), $busca)
                    || str_contains(mb_strtolower((string) $plugin->component), $busca);
            })
            ->filter(function (Plugin $plugin) {
                if (!$this->soloAtencion) {
                    return true;
                }

                return $this->aMedias($plugin) || $plugin->has_updates || $plugin->pluginincompatible;
            })
            ->groupBy('type')
            // Los tipos con más plugins primero; a igualdad, por nombre.
            ->sortByDesc(fn (Collection $plugins) => $plugins->count());

        $grupos = [];
        $colaTipos = 0;
        $colaPlugins = 0;

        foreach ($porTipo as $tipo => $plugins) {
            // Con filtro puesto se enseñan todos los grupos que tengan algo; sin filtro,
            // solo los primeros, y el resto se cuenta al pie.
            if (!$filtrando && count($grupos) >= self::TIPOS_VISIBLES) {
                $colaTipos++;
                $colaPlugins += $plugins->count();

                continue;
            }

            $abierto = $filtrando || $this->tipoAbierto === $tipo;

            $grupos[] = [
                'tipo' => (string) ($tipo ?: 'sin tipo'),
                'n' => $plugins->count(),
                'abierto' => $abierto,
                'alerta' => $this->alertaDelGrupo($plugins),
                // Las filas solo se preparan si el grupo está abierto: con 464 plugins,
                // montar el detalle de todos para no pintarlo es trabajo tirado.
                'plugins' => $abierto
                    ? $plugins->map(fn (Plugin $p) => $this->fila($p, $instalados))->values()->all()
                    : [],
            ];
        }

        return [
            'grupos' => $grupos,
            'cola' => $colaTipos > 0 ? ['tipos' => $colaTipos, 'plugins' => $colaPlugins] : null,
        ];
    }

    /** Qué requiere atención dentro de un grupo, resumido en la cabecera plegada. */
    private function alertaDelGrupo(Collection $plugins): ?string
    {
        $medias = $plugins->filter(fn (Plugin $p) => $this->aMedias($p))->count();
        $updates = $plugins->where('has_updates', true)->count();

        if ($medias > 0) {
            return $medias === 1 ? '1 a medio actualizar' : $medias . ' a medio actualizar';
        }

        if ($updates > 0) {
            return $updates === 1 ? '1 actualización' : $updates . ' actualizaciones';
        }

        return null;
    }

    /** Una fila del inventario, con su estado y su detalle. */
    private function fila(Plugin $plugin, array $instalados): array
    {
        return [
            'plugin' => $plugin,
            'nuestro' => $this->esNuestro($plugin->component),
            'desfase' => $this->desfase($plugin),
            'conActualizacion' => (bool) $plugin->has_updates,
            'abierto' => $this->pluginAbierto === $plugin->id,
            'candidatas' => $this->pluginAbierto === $plugin->id ? $this->candidatas($plugin) : [],
            // Las dependencias, **cruzadas con lo instalado**: es lo único que interesa
            // saber de ellas, y antes se listaban como texto sin comprobar nada.
            'dependencias' => $this->pluginAbierto === $plugin->id
                ? $this->dependencias($plugin, $instalados)
                : [],
        ];
    }

    /**
     * Las dependencias de un plugin, diciendo si están instaladas aquí.
     *
     * `dependencies` es un JSON del que no se puede asumir la forma: puede venir como
     * lista de nombres o como mapa `component => versión`.
     *
     * @return array<int, array{nombre: string, instalada: bool}>
     */
    private function dependencias(Plugin $plugin, array $instalados): array
    {
        $crudas = $plugin->dependencies;

        if (!is_array($crudas)) {
            return [];
        }

        $dependencias = [];

        foreach ($crudas as $clave => $valor) {
            // `['mod_forum' => 2022041900]` o `['mod_forum']`: el nombre puede estar en
            // la clave o en el valor.
            $nombre = is_string($clave) ? $clave : (is_string($valor) ? $valor : null);

            if ($nombre === null || $nombre === '') {
                continue;
            }

            $dependencias[] = [
                'nombre' => $nombre,
                'instalada' => in_array($nombre, $instalados, true),
            ];
        }

        return $dependencias;
    }

    /**
     * Cuándo llegó este inventario, y si es viejo.
     *
     * **La fecha es la del último `sync`, no la de instalación de cada plugin**: el
     * inventario se borra y se reescribe entero cada vez. `created_at` de una fila dice
     * cuándo se sincronizó, y presentarlo como «instalado el» sería mentir.
     *
     * @return array{texto: string, nota: string, color: string, punto: string, viejo: bool}
     */
    public function frescura(): array
    {
        $cuando = Plugin::where('environment_id', $this->environment->id)->max('created_at');

        if ($cuando === null) {
            return [
                'texto' => 'nunca',
                'nota' => 'este entorno no envía inventario',
                'color' => 'var(--danger-500)',
                'punto' => 'var(--danger-500)',
                'viejo' => false,
            ];
        }

        $fecha = \Illuminate\Support\Carbon::parse($cuando);
        // `(int)` a propósito: en Carbon 3 `diffInDays` devuelve un float y una
        // comparación estricta con 0 fallaría. Ya pasó en la pantalla de uso.
        $dias = (int) $fecha->startOfDay()->diffInDays(now()->startOfDay());

        if ($dias <= 2) {
            return [
                'texto' => $dias === 0 ? 'hoy, ' . $fecha->format('H:i') : ($dias === 1 ? 'ayer' : 'hace 2 días'),
                'nota' => 'con la última sincronización',
                'color' => 'var(--text-strong)',
                'punto' => 'var(--success-500)',
                'viejo' => false,
            ];
        }

        return [
            'texto' => 'hace ' . $dias . ' días',
            'nota' => 'último envío: ' . $fecha->format('d/m/Y'),
            'color' => 'var(--warning-500)',
            'punto' => 'var(--warning-500)',
            'viejo' => true,
        ];
    }

    /**
     * El resumen de la cabecera.
     *
     * Dice cuántos hay **y cuántos piden algo**, que es la proporción que ordena la
     * pantalla: 3 de 464.
     *
     * **El total se cuenta sobre el conjunto, no sumando los grupos**: un plugin puede
     * tener a la vez el desfase de versión y una actualización disponible, y sumando
     * saldría dos veces. Un recuento que no cuadra con las filas de abajo es un número
     * que no se puede explicar.
     */
    public function resumen(): string
    {
        $inventario = $this->inventario();

        if ($inventario->isEmpty()) {
            return 'Sin inventario todavía.';
        }

        $tipos = $inventario->pluck('type')->unique()->count();

        $pendientes = $inventario->filter(fn (Plugin $p) => $this->desfase($p) === 'pendiente');
        $retrocedidos = $inventario->filter(fn (Plugin $p) => $this->desfase($p) === 'retrocedido');
        $ausentes = $inventario->filter(fn (Plugin $p) => $this->desfase($p) === 'ausente');
        $updates = $inventario->where('has_updates', true);
        $incompat = $inventario->filter(fn (Plugin $p) => (bool) $p->pluginincompatible);

        $resumen = $inventario->count() . ' plugins de ' . $tipos . ' ' . ($tipos === 1 ? 'tipo' : 'tipos') . '.';

        $motivos = [];

        if ($pendientes->isNotEmpty()) {
            $motivos[] = $pendientes->count() . ($pendientes->count() === 1
                ? ' con el upgrade pendiente'
                : ' con el upgrade pendiente');
        }

        if ($retrocedidos->isNotEmpty()) {
            $motivos[] = $retrocedidos->count() . ($retrocedidos->count() === 1
                ? ' con los ficheros retrocedidos'
                : ' con los ficheros retrocedidos');
        }

        if ($ausentes->isNotEmpty()) {
            $motivos[] = $ausentes->count() . ($ausentes->count() === 1
                ? ' ausente del disco'
                : ' ausentes del disco');
        }

        if ($updates->isNotEmpty()) {
            $motivos[] = $updates->count() . ' con actualización disponible';
        }

        if ($incompat->isNotEmpty()) {
            $motivos[] = $incompat->count() . ($incompat->count() === 1
                ? ' con una incompatibilidad declarada'
                : ' con incompatibilidades declaradas');
        }

        if ($motivos === []) {
            return $resumen . ' Ninguno requiere nada.';
        }

        // El conjunto, sin contar dos veces a quien esté en varios grupos.
        $cuantos = $pendientes->merge($retrocedidos)->merge($updates)->merge($incompat)
            ->unique('id')
            ->count();

        return $resumen . ' ' . ($cuantos === 1 ? 'Requiere algo 1' : 'Requieren algo ' . $cuantos)
            . ': ' . implode(', ', $motivos) . '.';
    }

    public function limpiarFiltros(): void
    {
        $this->busca = '';
        $this->soloAtencion = false;
    }

    public function render()
    {
        $inventario = $this->inventario();
        ['grupos' => $grupos, 'cola' => $cola] = $this->grupos();

        return view('livewire.environments.plugins.index', [
            'total' => $inventario->count(),
            'sinInventario' => $inventario->isEmpty(),
            'frescura' => $this->frescura(),
            'resumen' => $this->resumen(),
            'atencion' => $atencion = $this->atencion(),
            'porNivel' => $this->porNivel($atencion),
            'nuestros' => $this->nuestros(),
            'candidatos' => $this->candidatosAAlta(),
            'faltaElOrquestador' => $this->faltaElOrquestador(),
            'grupos' => $grupos,
            'cola' => $cola,
        ])->layout('layouts.app');
    }
}
