<?php

namespace App\Livewire\Plugins;

use App\Models\Environments\Environment;
use App\Models\Environments\Plugin;
use App\Models\Products\OwnPlugin;
use App\Models\Products\Product;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Url;
use Livewire\Component;

/**
 * Los datos del parque de plugins, compartidos por las cinco pantallas de la sección.
 *
 * **Antes esto era una sola pantalla con tres pestañas** —«Plugins Tresipunt»,
 * «Inventario» y «Versiones de Moodle»—, más un bloque de «Licenciado y sin instalar»
 * metido dentro de la primera. 1.473 líneas de componente y 899 de vista para responder a
 * cinco preguntas distintas, y se entraba por la más densa de las cinco.
 *
 * Es el mismo problema que tenían Monitorización y Configuración, y se arregla igual: una
 * **portada que enruta y explica**, y cada pregunta en su propia página con su propia URL.
 * Lo que se gana no es estética: una pestaña no se puede enlazar desde un correo, no sale
 * en el menú y **obliga a calcular lo de las otras dos** para pintar sus contadores.
 *
 * ## Por qué una clase base y no cinco copias
 *
 * Casi todo lo que hay aquí lo necesitan varias pantallas: la cobertura del parque, el
 * agregado del inventario, el catálogo de lo nuestro, los avisos. Duplicarlo serían cinco
 * sitios donde arreglar el mismo recuento. Lo que **sí** vive en cada pantalla son sus
 * filtros —que van en la URL y son distintos en cada una— y su `render()`.
 *
 * Los memos privados (`$cacheAgregado` y compañía) siguen siendo por instancia, o sea por
 * render: `render()` corre en cada interacción de Livewire y la cabecera, la tabla y los
 * avisos preguntan por lo mismo.
 *
 * De solo lectura: desde el Manager no se instala ni se actualiza nada.
 */
abstract class PantallaDelParque extends Component
{
    /**
     * El buscador, en la URL.
     *
     * **No es cosmético.** El inventario de un entorno enlaza aquí con «ver este plugin en
     * el resto del parque», y sin el filtro en la URL ese enlace llegaba a la pantalla sin
     * filtrar: caías en la primera de 19 páginas alfabéticas a buscar a mano el plugin del
     * que venías. Es MGR-053.
     *
     * Vive en la base porque lo usan dos pantallas —Inventario y Versiones de Moodle— con
     * el mismo significado: acotar por nombre lo que se está mirando.
     */
    #[Url(as: 'q', except: '')]
    public string $busqueda = '';

    /** Cuántos sitios se nombran en el aviso de ausentes antes de resumir con «y N más». */
    public const SITIOS_EN_EL_AVISO = 4;


    /**
     * Contar también los entornos apagados.
     *
     * **Por defecto no se cuentan, y es el mismo recorte que el del visor de peticiones.**
     * Un sitio dado de baja conserva su último inventario, así que sin el recorte sigue
     * aportando sus 470 componentes a la cobertura y su versión vieja al recuento de
     * versiones distintas: un «3 versiones de local_tresipunt» donde una es de un sitio que
     * ya no existe manda a investigar una divergencia que no hay, y la cobertura promete
     * saber de sitios que no se atienden.
     *
     * **Un interruptor y no más de uno.** Vale para las tres pestañas a la vez porque el
     * recorte es el mismo dato en las tres, y la pantalla dice cuántos entornos está
     * dejando fuera antes de tocarlo: un recorte que no se dice hace que una lista corta se
     * lea como «ya no hay nada».
     *
     * **Y los filtros lo siguen.** Las ramas de Moodle salen de `core()` y los tipos de
     * `agregado()`, así que las opciones que se ofrecen son las que hay en lo que se está
     * viendo — no las del parque entero. Ofrecer una rama que solo tiene sitios apagados
     * lleva a una tabla vacía sin decir por qué.
     */
    #[Url(as: 'apagados', except: false)]
    public bool $incluirApagados = false;

    /** El componente desplegado, si hay uno: se cargan sus entornos bajo la fila. */
    public ?string $abierto = null;

    /**
     * Memo del agregado del parque dentro del mismo render.
     *
     * `render()` corre en cada interacción y la cabecera, el bloque de los nuestros y los
     * avisos preguntan por lo mismo. Sin esto son cuatro `GROUP BY` sobre la tabla entera
     * por pulsación.
     */
    protected ?Collection $cacheAgregado = null;

    protected ?array $cacheNuestros = null;

    protected ?array $cacheNombres = null;

    /**
     * Memos del core y de los nuestros **sin filtrar**.
     *
     * Los necesitan dos consumidores con exigencias opuestas: el resumen de la pestaña y
     * los avisos los quieren completos —un contador que se mueve al filtrar es un contador
     * que miente— y la tabla los quiere filtrados. Se calculan una vez y el filtro se
     * aplica encima.
     */
    protected ?array $cacheCore = null;

    protected ?array $cacheMios = null;

    /** `faltantes()` la llaman los avisos y la pestaña; lleva su propio eager loading. */
    protected ?array $cacheFaltantes = null;


    public function updatedIncluirApagados(): void
    {
        // Cambia el conjunto entero, no solo el orden: la página en la que estabas puede no
        // existir con menos filas.
        //
        // **Solo el inventario pagina**, que es la única lista larga de la sección; en las
        // demás `resetPage()` ni siquiera existe, porque no usan `WithPagination`.
        $this->sinPaginacion() ?: $this->resetPage();
    }

    /** ¿Esta pantalla pagina? Solo el inventario. */
    protected function sinPaginacion(): bool
    {
        return ! method_exists($this, 'resetPage');
    }

    /** ¿Se están dejando fuera los entornos apagados? */
    public function recortaApagados(): bool
    {
        return ! $this->incluirApagados;
    }

    /**
     * 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 su inventario volvería a
     * contarse justo en el caso más claro de sitio que ya no se atiende.
     *
     * Se calcula una vez por petición —propiedad protegida, que Livewire no guarda en la
     * instantánea—: el recorte se aplica en seis sitios distintos por `render()`. Es un
     * array y no una subconsulta porque la tabla de entornos tiene veintiuna filas.
     *
     * @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;

    /**
     * Cuántos entornos apagados se están quedando fuera **y tienen inventario**.
     *
     * Los que no han enviado nada no cambian ninguna cifra de esta pantalla, así que
     * contarlos aquí sería alarmar por algo que no afecta a lo que se está mirando.
     */
    public function apagadosOcultos(): int
    {
        if (! $this->recortaApagados()) {
            return 0;
        }

        $apagados = $this->entornosApagados();

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

        return Plugin::query()
            ->whereIn('environment_id', $apagados)
            ->distinct()
            ->count('environment_id');
    }


    public function cobertura(): array
    {
        $envian = Plugin::query()
            ->when(
                $this->recortaApagados(),
                fn ($q) => $q->whereNotIn('environment_id', $this->entornosApagados())
            )
            ->distinct()
            ->count('environment_id');

        // El denominador tiene que ser del mismo conjunto que el numerador: con el recorte
        // puesto, «3 de 21» contando los apagados en el 21 diría que faltan sitios que no
        // se están mirando.
        $total = Environment::query()
            ->when($this->recortaApagados(), fn ($q) => $q->where('active', true))
            ->count();

        return [
            'envian' => $envian,
            'total' => $total,
            'sin' => max(0, $total - $envian),
            'parcial' => $envian > 0 && $envian < $total,
            'vacio' => $envian === 0,
        ];
    }

    /**
     * De cuándo son las fotos: la más antigua y la más reciente del agregado.
     *
     * El inventario se reescribe entero en cada sincronización, así que `created_at` es la
     * fecha de ese sync. **Este agregado mezcla fotos de fechas distintas**: un entorno
     * sincronizado hoy y otro de hace tres semanas cuentan igual, y eso hay que decirlo.
     *
     * @return array{vieja:?Carbon, nueva:?Carbon, dias:int, dispersa:bool}
     */
    public function frescura(): array
    {
        $fechas = Plugin::query()
            ->when(
                $this->recortaApagados(),
                fn ($q) => $q->whereNotIn('environment_id', $this->entornosApagados())
            )
            ->selectRaw('MIN(created_at) as vieja, MAX(created_at) as nueva')
            ->first();

        $vieja = $fechas?->vieja ? Carbon::parse($fechas->vieja) : null;
        $nueva = $fechas?->nueva ? Carbon::parse($fechas->nueva) : null;

        // `diffInDays` de Carbon 3 devuelve float y con signo: sin el cast, un `=== 0` es
        // falso para `0.0` y el mismo día se lee como «hace dos días» (MGR-062).
        $dias = ($vieja && $nueva) ? (int) abs($nueva->diffInDays($vieja)) : 0;

        return [
            'vieja' => $vieja,
            'nueva' => $nueva,
            'dias' => $dias,
            // Más de una semana entre la foto más vieja y la más nueva: el agregado ya no
            // describe un momento, describe un mes.
            'dispersa' => $dias > 7,
        ];
    }

    /* ======================================================================
       El core de Moodle
       ====================================================================== */

    /**
     * Las versiones de Moodle del parque.
     *
     * **Esto es lo que se perdió del Manager antiguo**, que tenía una tarjeta por versión
     * de core disponible con build, madurez y enlace de descarga. Esos datos no llegan
     * hoy: `data.availableupdatesfetch` no es la lista de versiones —es el timestamp de
     * cuándo lo comprobó el Moodle—, y para tener las tarjetas hace falta o que el plugin
     * las envíe o que el Manager consulte `download.moodle.org`. Es una decisión de
     * producto pendiente.
     *
     * **Pero con lo que hay ya se responde lo que importa.** Cada entorno guarda tres
     * escalares —la instalada, la última mayor y la última de su rama— y con eso se sabe
     * si un sitio está por debajo de su rama (lo urgente: ahí van los parches de
     * seguridad) o por debajo de la mayor (lo planificable).
     *
     * Los estados son excluyentes y en este orden: quien tiene menor pendiente sale como
     * menor pendiente aunque además tenga una mayor esperando, porque **el arreglo es
     * distinto y uno corre más que el otro**.
     *
     * @return array<int, array<string, mixed>>
     */
    public function core(): array
    {
        if ($this->cacheCore !== null) {
            return $this->cacheCore;
        }

        return $this->cacheCore = Environment::query()
            ->when($this->recortaApagados(), fn ($q) => $q->where('active', true))
            ->whereNotNull('version')
            ->with([
                'client:id,name',
                // La telemetría del sitio: de aquí sale la versión **completa** con su
                // build y cuándo comprobó actualizaciones por última vez.
                'data:id,environment_id,moodlerelease,availableupdatesfetch,users,courses',
                // Las versiones de core disponibles. La tabla existe con las columnas del
                // Manager antiguo —`release`, `maturity`, `url`, `download`— y **está
                // vacía**: nadie escribe en ella. Se carga igual para que el día que
                // lleguen los datos la pantalla ya los enseñe.
                'updates',
            ])
            ->get(['id', 'name', 'domain', 'env', 'version', 'lastversion', 'lastminor', 'client_id', 'refresh_at', 'has_support'])
            ->map(function (Environment $entorno) {
                $instalada = (string) $entorno->version;
                $menor = (string) ($entorno->lastminor ?? '');
                $mayor = (string) ($entorno->lastversion ?? '');

                // `version_compare` entiende «5.1.1» y «3.12.3», que es el formato de
                // estas tres columnas. No se le puede pasar `release`, que trae cosas
                // como «1.0.0 (Build: 20230915)».
                $faltaMenor = $menor !== '' && version_compare($instalada, $menor, '<');
                $faltaMayor = $mayor !== '' && version_compare($instalada, $mayor, '<');

                if ($menor === '' && $mayor === '') {
                    $estado = 'sin-datos';
                } elseif ($faltaMenor) {
                    $estado = 'menor';
                } elseif ($faltaMayor) {
                    $estado = 'mayor';
                } else {
                    $estado = 'al-dia';
                }

                return [
                    'entorno' => $entorno,
                    'instalada' => $instalada,
                    'menor' => $menor,
                    'mayor' => $mayor,
                    'estado' => $estado,
                    'rama' => $this->ramaDe($instalada),
                    // **La versión completa, con el build.** `environments.version` guarda
                    // «5.1.1» y la telemetría trae «5.1.1+ (Build: 20251219)»: el `+` dice
                    // que lleva parches posteriores a la etiqueta y el build es la fecha
                    // exacta del código. Es la mitad de lo que enseñaba el Manager antiguo,
                    // y estaba ahí.
                    'completa' => $entorno->data?->moodlerelease,
                    // Cuándo comprobó actualizaciones el propio Moodle. **Explica los «sin
                    // datos»**: si un sitio no lo comprueba, `lastversion` y `lastminor`
                    // nunca se rellenan, y sin esto la pantalla no sabría decir por qué.
                    'comprobado' => $this->fechaDe($entorno->data?->availableupdatesfetch),
                    // El tamaño, para priorizar: actualizar un sitio de 1.648 usuarios no
                    // se planifica igual que uno de 12.
                    'usuarios' => $entorno->data?->users,
                    'cursos' => $entorno->data?->courses,
                    // Las versiones disponibles, cuando lleguen.
                    'disponibles' => $entorno->updates ?? collect(),
                ];
            })
            // Lo que hay que hacer primero, primero. Ordenar por nombre dejaba los 17
            // sitios que no comprueban actualizaciones intercalados entre los 3 que sí, y
            // el bloque se leía como una lista de entornos y no como una lista de trabajo.
            ->sortBy(fn (array $s) => [
                match ($s['estado']) {
                    'menor' => 0,
                    'mayor' => 1,
                    'al-dia' => 2,
                    default => 3,
                },
                $s['entorno']->name,
            ])
            ->values()
            ->all();
    }

    /**
     * Un `timestamp` de Unix como fecha, o null si no hay nada que interpretar.
     *
     * `availableupdatesfetch` llega como entero desde el Moodle del cliente. Un 0 y un
     * null significan lo mismo aquí —nunca lo ha comprobado— y los dos tienen que dar
     * null: `Carbon::createFromTimestamp(0)` devolvería 1970 y la pantalla diría «lo
     * comprobó hace 56 años», que es una respuesta y no un hueco.
     */
    protected function fechaDe(mixed $timestamp): ?Carbon
    {
        $valor = (int) $timestamp;

        return $valor > 0 ? Carbon::createFromTimestamp($valor) : null;
    }

    /** La rama de una versión: «5.1» de «5.1.1». Es el eje por el que se agrupa el parque. */
    protected function ramaDe(string $version): string
    {
        $trozos = explode('.', $version);

        return count($trozos) >= 2 ? $trozos[0] . '.' . $trozos[1] : $version;
    }

    /**
     * El resumen del core por estado, para la cabecera del bloque.
     *
     * @return array{menor:int, mayor:int, aldia:int, sindatos:int}
     */
    public function resumenCore(): array
    {
        $porEstado = collect($this->core())->countBy('estado');

        return [
            'menor' => $porEstado['menor'] ?? 0,
            'mayor' => $porEstado['mayor'] ?? 0,
            'aldia' => $porEstado['al-dia'] ?? 0,
            'sindatos' => $porEstado['sin-datos'] ?? 0,
        ];
    }

    /**
     * En qué ramas de Moodle está repartido el parque.
     *
     * **Es la pregunta que ninguna otra pantalla responde**: «¿cuántos clientes tengo en
     * cada rama?». Decide el trabajo de un trimestre —una rama con dos sitios se sube, una
     * con doce se planifica— y hasta ahora había que contarlo a mano en el listado de
     * entornos.
     *
     * @return array<int, array{rama:string, cuantos:int, sinParches:int}>
     */
    public function ramas(): array
    {
        return collect($this->core())
            ->groupBy('rama')
            ->map(fn (Collection $sitios, string $rama) => [
                'rama' => $rama,
                'cuantos' => $sitios->count(),
                'sinParches' => $sitios->where('estado', 'menor')->count(),
            ])
            // Las ramas viejas primero: son las que hay que mover.
            ->sortBy(fn (array $r) => version_compare($r['rama'], '999.0', '<') ? $r['rama'] : $r['rama'], SORT_NATURAL)
            ->values()
            ->all();
    }

    /**
     * Los sitios cuyo Moodle no comprueba actualizaciones, o dejó de hacerlo.
     *
     * **Es la causa de los «sin datos», no un estado más.** Si el comprobador de
     * actualizaciones de un Moodle está apagado o el sitio no sale a internet,
     * `lastversion` y `lastminor` no se rellenan nunca: la pantalla diría «no lo ha
     * comprobado» de 17 sitios sin poder decir que **eso es lo que hay que arreglar
     * primero**, porque hasta que no lo comprueben no sabemos si están al día.
     *
     * @return array<int, array<string, mixed>>
     */
    public function sinComprobar(): array
    {
        return collect($this->core())
            ->filter(fn (array $s) => $s['comprobado'] === null || $s['comprobado']->diffInDays() > 30)
            ->values()
            ->all();
    }

    /* ======================================================================
       El agregado del parque
       ====================================================================== */

    /**
     * Un registro por componente instalado en el parque.
     *
     * `versiones` es la cuenta de `versiondisk` distintos, no de `release`: la marca
     * `YYYYMMDDXX` es comparable y `release` no tiene formato garantizado —«4.0.0», «1.0.0
     * (Build: 20230915)»— así que dos sitios con la misma versión podrían escribirla
     * distinto y contarían como divergencia falsa.
     *
     * Y `release` **es palabra reservada en MySQL**: cualquier consulta que la nombre hay
     * que escaparla con acentos graves. Aquí no se nombra a propósito.
     */
    protected function agregado(): Collection
    {
        if ($this->cacheAgregado !== null) {
            return $this->cacheAgregado;
        }

        return $this->cacheAgregado = DB::table('plugins')
            ->selectRaw('component, MIN(type) as type, MIN(name) as name')
            ->selectRaw('COUNT(DISTINCT environment_id) as entornos')
            ->selectRaw('COUNT(DISTINCT versiondisk) as versiones')
            ->selectRaw('COUNT(DISTINCT CASE WHEN has_updates = 1 THEN environment_id END) as actualizables')
            ->whereNull('deleted_at')
            // El recorte va aquí y no en cada consumidor: de este agregado salen la tabla
            // del inventario, los nuestros, el resumen, los tipos del desplegable y los
            // avisos. Filtrarlo en un sitio es filtrarlo en los cinco.
            ->when(
                $this->recortaApagados(),
                fn ($q) => $q->whereNotIn('environment_id', $this->entornosApagados())
            )
            ->groupBy('component')
            ->get();
    }

    /** Los `component` que son nuestros, del catálogo de Plugins propios más Productos. */
    protected function nuestrosComponents(): array
    {
        return $this->cacheNuestros ??= OwnPlugin::sonNuestros();
    }

    /**
     * Cómo llamamos **nosotros** a cada plugin nuestro.
     *
     * El agregado trae el nombre que el plugin declara en el inventario de cada cliente, y
     * para los nuestros ese no es el nombre bueno: un sitio con una versión vieja puede
     * declararse con un nombre antiguo, y basta un cliente con el `lang` a medias para que
     * salga el identificador crudo. El catálogo tiene el nombre que hemos puesto nosotros
     * —y si no se puso, ya cogió el del inventario al darlo de alta—, así que manda.
     *
     * @return array<string, string>
     */
    protected function nombresDeCatalogo(): array
    {
        if ($this->cacheNombres !== null) {
            return $this->cacheNombres;
        }

        $delCatalogo = OwnPlugin::query()
            ->whereNotNull('name')
            ->pluck('name', 'component');

        // Los productos también nombran: su ficha es la referencia comercial del plugin.
        $deProductos = Product::query()
            ->whereNotNull('slug')
            ->pluck('name', 'slug');

        return $this->cacheNombres = $deProductos->merge($delCatalogo)->all();
    }

    /**
     * Los plugins **ausentes del disco** del parque, agrupados por entorno.
     *
     * **Es lo que Sistemas tiene que ver aquí.** Un plugin que Moodle tiene registrado y
     * cuyos ficheros no están es una avería silenciosa —sus tablas y sus datos siguen, sus
     * funcionalidades no— y desde el inventario de un solo entorno no se ve que le pasa a
     * varios a la vez, que es cuando huele a despliegue incompleto y no a un borrado a mano.
     *
     * La regla la define `Plugin::scopeAusenteDelDisco()`, compartida con el inventario del
     * entorno y con el listado.
     *
     * @return array{total:int, entornos:\Illuminate\Support\Collection}
     */
    public function ausentesDelDisco(): array
    {
        if ($this->cacheAusentes !== null) {
            return $this->cacheAusentes;
        }

        $filas = Plugin::query()
            ->ausenteDelDisco()
            ->when(
                $this->recortaApagados(),
                fn ($q) => $q->whereNotIn('environment_id', $this->entornosApagados())
            )
            ->with('environment:id,name,domain')
            ->get(['id', 'environment_id', 'component', 'versiondb']);

        return $this->cacheAusentes = [
            'total' => $filas->count(),
            'entornos' => $filas->groupBy('environment_id')->map(fn ($suyos) => [
                'entorno' => $suyos->first()->environment,
                'cuantos' => $suyos->count(),
                'componentes' => $suyos->pluck('component')->all(),
            ])->values(),
        ];
    }

    /** @var array{total:int, entornos:\Illuminate\Support\Collection}|null */
    protected ?array $cacheAusentes = null;

    /**
     * El resumen de arriba: sobre qué se está mirando y qué hay dentro.
     *
     * @return array<string, int>
     */
    public function resumen(): array
    {
        $todo = $this->agregado();
        $nuestros = $this->nuestrosComponents();

        return [
            'componentes' => $todo->count(),
            'tipos' => $todo->pluck('type')->unique()->count(),
            'actualizables' => $todo->where('actualizables', '>', 0)->count(),
            'divergentes' => $todo->where('versiones', '>', 1)->count(),
            'nuestros' => $todo->whereIn('component', $nuestros)->count(),
        ];
    }

    /* ======================================================================
       Los nuestros
       ====================================================================== */

    /**
     * Nuestros plugins en el parque, con el orquestador primero.
     *
     * **`local_tresipunt` no es un plugin nuestro más**: es el que hace todas las llamadas
     * a la API —licencias, contenido, telemetría, inventario—, así que si falta en un sitio
     * o va viejo, **ese sitio deja de recibir licencias y de enviar datos** y ninguna otra
     * pantalla lo explicaría. Por eso encabeza la lista y por eso su desalineación es un
     * aviso crítico y no informativo.
     *
     * @return array<int, array<string, mixed>>
     */
    public function nuestros(): array
    {
        if ($this->cacheMios !== null) {
            return $this->cacheMios;
        }

        $nuestros = $this->nuestrosComponents();

        if ($nuestros === []) {
            return $this->cacheMios = [];
        }

        $filas = $this->agregado()
            ->whereIn('component', $nuestros)
            ->sortBy('component')
            ->values();

        if ($filas->isEmpty()) {
            return $this->cacheMios = [];
        }

        // Las versiones legibles de una sola vez para los nuestros: son diez, no las 470,
        // así que se puede permitir el detalle que la tabla general no puede.
        //
        // **Solo en su pestaña.** Los avisos son transversales y llaman aquí desde
        // cualquiera de las tres, pero solo necesitan las cuentas —cuántas versiones,
        // cuántos actualizables—, que ya vienen del agregado. Traer además en qué entorno
        // está cada una era pagar el detalle en pantallas que no lo pintan.
        $versiones = $this->quiereElDetalleDeVersiones()
            ? $this->versionesDe($filas->pluck('component')->all())
            : [];

        $nombres = $this->nombresDeCatalogo();

        return $filas
            ->map(function (object $fila) use ($versiones, $nombres) {
                $orquestador = $fila->component === OwnPlugin::ORQUESTADOR;

                return [
                    'component' => $fila->component,
                    'name' => $nombres[$fila->component] ?? ($fila->name ?: $fila->component),
                    'type' => $fila->type,
                    'entornos' => (int) $fila->entornos,
                    'versiones' => (int) $fila->versiones,
                    'actualizables' => (int) $fila->actualizables,
                    'orquestador' => $orquestador,
                    'detalle' => $versiones[$fila->component] ?? [],
                ];
            })
            // El orquestador arriba; el resto, los desalineados antes que los homogéneos.
            ->sortBy(fn (array $f) => [
                $f['orquestador'] ? 0 : 1,
                $f['versiones'] > 1 ? 0 : 1,
                $f['actualizables'] > 0 ? 0 : 1,
                $f['name'],
            ])
            ->values()
            ->all();
    }

    /**
     * En qué entornos falta un plugin que debería estar.
     *
     * **La pregunta comercial de esta pantalla**: un cliente con licencia de un producto
     * cuyo plugin no aparece en su inventario. O no lo ha instalado —y está pagando por
     * algo que no usa— o lo tiene y su Moodle no lo está declarando.
     *
     * Solo se pregunta por entornos **que envían inventario**: en los otros 18 la ausencia
     * no significa nada, y listarlos como «falta el plugin» sería el error contrario.
     *
     * @return array<int, array<string, mixed>>
     */
    public function faltantes(): array
    {
        if ($this->cacheFaltantes !== null) {
            return $this->cacheFaltantes;
        }

        $conInventario = Plugin::query()
            ->when(
                $this->recortaApagados(),
                fn ($q) => $q->whereNotIn('environment_id', $this->entornosApagados())
            )
            ->distinct()
            ->pluck('environment_id');

        if ($conInventario->isEmpty()) {
            return $this->cacheFaltantes = [];
        }

        // **Y aquí hacía falta doblemente.** «Este cliente tiene licencia de un producto y
        // su plugin no aparece» es una pregunta comercial, y de un sitio dado de baja la
        // respuesta no interesa: nadie va a llamarle para que instale un plugin.
        return $this->cacheFaltantes = Environment::query()
            ->whereIn('id', $conInventario)
            ->with(['client:id,name', 'licenseToken.products:id,slug,name', 'plugins:id,environment_id,component'])
            ->get(['id', 'name', 'domain', 'env', 'client_id', 'license_token_id'])
            ->map(function (Environment $entorno) {
                $licenciados = $entorno->licenseToken?->products ?? collect();
                $instalados = $entorno->plugins->pluck('component');

                // **Solo lo que puede estar instalado.** Un ecosistema comercial como
                // `fresk_premium` no es un componente de Moodle: no aparece en el
                // inventario de ningún sitio porque no existe como plugin, así que
                // preguntarse por qué «falta» es ruido permanente en todos los clientes
                // que lo tienen contratado. Ver `Product::esPluginDeMoodle()`.
                $faltan = $licenciados
                    ->filter(fn ($producto) => $producto->esPluginDeMoodle())
                    ->filter(fn ($producto) => ! $instalados->contains($producto->slug));

                return $faltan->isEmpty() ? null : [
                    'entorno' => $entorno,
                    'productos' => $faltan->values(),
                ];
            })
            ->filter()
            ->values()
            ->all();
    }

    /**
     * Las versiones instaladas de unos componentes, entorno por entorno.
     *
     * @param  array<int, string>  $components
     * @return array<string, array<int, array<string, mixed>>>
     */
    protected function versionesDe(array $components): array
    {
        if ($components === []) {
            return [];
        }

        return Plugin::query()
            ->whereIn('component', $components)
            ->when(
                $this->recortaApagados(),
                fn ($q) => $q->whereNotIn('environment_id', $this->entornosApagados())
            )
            ->with('environment:id,name,domain,env')
            // Sin lista de columnas a propósito: `release` es palabra reservada en MySQL y
            // nombrarla en el array de un `get()` la deja sin escapar. Son pocas filas
            // —los nuestros, o un componente desplegado—, así que traerlas enteras sale
            // más barato que el riesgo de una consulta que solo falla en producción.
            ->get()
            ->groupBy('component')
            ->map(function (Collection $filas) {
                // La más nueva primero: `versiondisk` es una marca `YYYYMMDDXX` y se
                // compara como cadena porque como entero desborda en 32 bits.
                return $filas
                    ->sortByDesc(fn (Plugin $p) => (string) $p->versiondisk)
                    ->map(fn (Plugin $p) => [
                        'entorno' => $p->environment,
                        // **Y si no hay ninguna de las dos, un guion y no un cero.** Un
                        // plugin ausente del disco tiene `release` vacío y `versiondisk` a
                        // `'0'` —el relleno de «no lo sabemos»—, así que este `?:` pintaba
                        // que ese plugin está en la **versión 0**, que se lee como una
                        // versión y no como un dato que falta.
                        'version' => $p->release ?: ((int) $p->versiondisk !== 0 ? $p->versiondisk : '—'),
                        'versiondisk' => (string) $p->versiondisk,
                        'versiondb' => (string) $p->versiondb,
                        'actualizable' => (bool) $p->has_updates,
                        // Los **dos** desfases, que no son lo mismo y tienen arreglos
                        // opuestos: es MGR-066.
                        'desfase' => $this->desfaseDe($p),
                        // La versión candidata que anuncia el propio Moodle del cliente.
                        'candidata' => $this->candidataDe($p),
                        // Qué versión de Moodle exige el plugin, y si el sitio la cumple.
                        'exige' => (string) $p->versionrequires,
                        'incompatible' => $this->incompatible($p),
                        'dependencias' => $this->dependenciasDe($p),
                    ])
                    ->values()
                    ->all();
            })
            ->all();
    }

    /**
     * En qué situación está la instalación de un plugin en un sitio.
     *
     * **Son dos desfases distintos con arreglos opuestos**, y el aviso que los mezclaba
     * mandaba a la mitad de los casos a hacer lo que no los arregla (MGR-066):
     *
     * - `pendiente` (disco > base de datos): los ficheros se han subido y el `upgrade` no
     *   ha corrido. **Lo arregla el cron de Moodle solo**, o entrar al sitio como admin.
     * - `retrocedido` (disco < base de datos): los ficheros han vuelto a una versión
     *   anterior a la que la base de datos ya migró. **El cron no lo arregla**: hay que
     *   volver a subir la versión buena, y hasta entonces ese sitio puede fallar.
     *
     * Se comparan como cadenas: la marca `YYYYMMDDXX` son diez dígitos y como entero
     * desborda en 32 bits.
     */
    protected function desfaseDe(Plugin $plugin): string
    {
        $disco = (string) $plugin->versiondisk;
        $base = (string) $plugin->versiondb;

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

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

    /**
     * La versión que el Moodle del cliente anuncia como disponible, si anuncia alguna.
     *
     * `availableupdates` es **JSON sin forma garantizada**: puede traer una versión o
     * varias, y las claves cambian entre versiones de Moodle. Se lee a la defensiva y se
     * devuelve lo primero legible; si no hay nada que entender, null, que es mejor que
     * inventarse un número.
     */
    protected function candidataDe(Plugin $plugin): ?string
    {
        $crudo = $plugin->availableupdates;

        if (is_string($crudo)) {
            $crudo = json_decode($crudo, true);
        }

        if (! is_array($crudo) || $crudo === []) {
            return null;
        }

        $primera = is_array(reset($crudo)) ? reset($crudo) : $crudo;

        foreach (['release', 'version', 'versiondisk'] as $clave) {
            if (! empty($primera[$clave])) {
                return (string) $primera[$clave];
            }
        }

        return null;
    }

    /**
     * Si el plugin exige una versión de Moodle más nueva que la que tiene el sitio.
     *
     * `versionrequires` es la versión mínima de Moodle que el plugin declara necesitar, en
     * la misma marca `YYYYMMDDXX`. Se compara contra lo que el sitio informa de sí mismo
     * —no contra `environments.version`, que es un «5.1.1» y no una marca—, y por eso solo
     * se puede responder cuando el inventario trae las dos cosas.
     */
    protected function incompatible(Plugin $plugin): bool
    {
        $exige = (string) $plugin->versionrequires;
        $tiene = (string) $plugin->versiondb;

        return $exige !== '' && $tiene !== '' && $exige > $tiene;
    }

    /**
     * De qué otros plugins depende, y cuáles de esos **no están instalados** en ese sitio.
     *
     * @return array<int, array{component:string, instalado:bool}>
     */
    protected function dependenciasDe(Plugin $plugin): array
    {
        $crudo = $plugin->dependencies;

        if (is_string($crudo)) {
            $crudo = json_decode($crudo, true);
        }

        if (! is_array($crudo) || $crudo === []) {
            return [];
        }

        // Las claves son los `component` cuando viene como objeto, y los valores cuando
        // viene como lista: las dos formas aparecen en los datos.
        $componentes = array_is_list($crudo) ? $crudo : array_keys($crudo);

        $instalados = Plugin::query()
            ->where('environment_id', $plugin->environment_id)
            ->pluck('component')
            ->all();

        return collect($componentes)
            ->filter(fn ($c) => is_string($c) && $c !== '')
            ->map(fn (string $componente) => [
                'component' => $componente,
                'instalado' => in_array($componente, $instalados, true),
            ])
            ->values()
            ->all();
    }

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

    /**
     * Lo que hay que mirar, con nivel y con el arreglo dicho.
     *
     * Mismo criterio que en el inventario de un entorno: **un aviso que no dice qué hacer
     * es ruido**, y uno que manda al arreglo equivocado es peor que no tenerlo (MGR-066).
     *
     * @return array<int, array<string, mixed>>
     */
    public function atencion(): array
    {
        $avisos = [];
        $cobertura = $this->cobertura();
        $nuestros = collect($this->nuestros());
        $core = $this->resumenCore();

        // 1. El orquestador. Si falta donde debería estar, ese sitio está incomunicado.
        $orquestador = $nuestros->firstWhere('orquestador', true);

        if ($cobertura['envian'] > 0 && $orquestador === null) {
            $avisos[] = [
                'nivel' => 'crit',
                'etiqueta' => 'Sin orquestador',
                'texto' => 'Ninguno de los ' . $cobertura['envian'] . ' entornos que envían inventario declara ' . OwnPlugin::ORQUESTADOR . '.',
                'arreglo' => 'Es el plugin que hace todas las llamadas a la API. Si no aparece en ningún inventario, o no está instalado o su versión no lo declara: revisa la instalación antes que cualquier otra cosa de esta pantalla.',
            ];
        } elseif ($orquestador !== null && $orquestador['entornos'] < $cobertura['envian']) {
            $faltan = $cobertura['envian'] - $orquestador['entornos'];
            $avisos[] = [
                'nivel' => 'crit',
                'etiqueta' => 'Orquestador ausente',
                'texto' => $faltan . ($faltan === 1 ? ' entorno envía inventario y no declara ' : ' entornos envían inventario y no declaran ') . OwnPlugin::ORQUESTADOR . '.',
                'arreglo' => 'Ese sitio no recibe licencias ni contenido y no envía telemetría. Instala o actualiza el plugin allí.',
            ];
        }

        if ($orquestador !== null && $orquestador['versiones'] > 1) {
            $avisos[] = [
                'nivel' => 'crit',
                'etiqueta' => 'Orquestador desalineado',
                'texto' => 'El orquestador convive con ' . $orquestador['versiones'] . ' versiones distintas en el parque.',
                'arreglo' => 'Es el plugin del contrato con la API: versiones distintas significan sitios que hablan idiomas distintos con el Manager. Alinéalos antes de tocar el contrato.',
            ];
        }

        // 1 bis. Plugins registrados sin ficheros. **Crítico y por delante del core**: el
        // core por debajo de su rama es trabajo urgente pero el sitio funciona; esto es algo
        // que ya está roto y que no se ve desde ninguna otra pantalla del parque.
        $ausentes = $this->ausentesDelDisco();

        if ($ausentes['total'] > 0) {
            $cuantosSitios = $ausentes['entornos']->count();

            $avisos[] = [
                'nivel' => 'crit',
                'etiqueta' => 'Plugins ausentes del disco',
                'texto' => $ausentes['total'] . ($ausentes['total'] === 1 ? ' plugin está' : ' plugins están')
                    . ' registrados en Moodle y sus ficheros no están en el disco, en '
                    . $cuantosSitios . ($cuantosSitios === 1 ? ' sitio' : ' sitios') . ': '
                    . $ausentes['entornos']
                        ->take(self::SITIOS_EN_EL_AVISO)
                        ->map(fn (array $f) => ($f['entorno']->name ?? 'entorno #?') . ' (' . $f['cuantos'] . ')')
                        ->implode(', ')
                    . ($cuantosSitios > self::SITIOS_EN_EL_AVISO
                        ? ' y ' . ($cuantosSitios - self::SITIOS_EN_EL_AVISO) . ' más'
                        : '') . '.',
                'arreglo' => 'Es el «Missing from disk» de Moodle: 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í. Si pasa en varios sitios a la vez, mira el despliegue '
                    . 'antes que los sitios.',
            ];
        }

        // 2. El core por debajo de su rama: ahí van los parches de seguridad.
        if ($core['menor'] > 0) {
            $avisos[] = [
                'nivel' => 'crit',
                'etiqueta' => 'Core sin parches',
                'texto' => $core['menor'] . ($core['menor'] === 1 ? ' sitio está' : ' sitios están') . ' por debajo de la última versión de su propia rama.',
                'arreglo' => 'Las versiones menores son parches, seguridad incluida, y no cambian de rama: es la actualización que menos riesgo tiene y más urge. Ver el bloque de versiones de Moodle.',
            ];
        }

        if ($core['mayor'] > 0) {
            $avisos[] = [
                'nivel' => 'info',
                'etiqueta' => 'Core con mayor disponible',
                'texto' => $core['mayor'] . ($core['mayor'] === 1 ? ' sitio tiene' : ' sitios tienen') . ' una versión mayor de Moodle disponible.',
                'arreglo' => 'Cambiar de rama se planifica: revisa antes que los plugins del sitio soporten la rama nueva.',
            ];
        }

        // 2b. Los sitios que no comprueban actualizaciones. Va **antes** que los avisos de
        // versiones porque explica por qué de esos sitios no sabemos nada: mientras su
        // Moodle no lo compruebe, un «al día» y un «atrasado» se ven igual.
        $sinComprobar = $this->sinComprobar();

        if ($sinComprobar !== []) {
            $cuantos = count($sinComprobar);
            $avisos[] = [
                'nivel' => 'warn',
                'etiqueta' => 'Sin comprobar',
                'texto' => $cuantos . ($cuantos === 1 ? ' sitio no comprueba' : ' sitios no comprueban') . ' actualizaciones de Moodle, o dejaron de hacerlo hace más de un mes.',
                'arreglo' => 'Lo comprueba cada Moodle contra `download.moodle.org`, y necesita salida a internet y el comprobador activo. Hasta que lo hagan, de esos sitios no sabemos si están al día.',
            ];
        }

        // 3. Los nuestros con versiones distintas entre clientes.
        $nuestrosDivergentes = $nuestros->where('orquestador', false)->where('versiones', '>', 1);

        if ($nuestrosDivergentes->isNotEmpty()) {
            $avisos[] = [
                'nivel' => 'warn',
                'etiqueta' => 'Nuestros desalineados',
                'texto' => $nuestrosDivergentes->count() . ($nuestrosDivergentes->count() === 1 ? ' producto nuestro convive' : ' productos nuestros conviven') . ' con versiones distintas: ' . $nuestrosDivergentes->pluck('name')->implode(', ') . '.',
                'arreglo' => 'Son los que damos soporte nosotros. Mirar en qué cliente está la vieja y si el desfase es intencionado.',
            ];
        }

        // 4. Los nuestros con actualización pendiente.
        $nuestrosActualizables = $nuestros->where('actualizables', '>', 0);

        if ($nuestrosActualizables->isNotEmpty()) {
            $avisos[] = [
                'nivel' => 'warn',
                'etiqueta' => 'Nuestros con actualización',
                'texto' => $nuestrosActualizables->count() . ($nuestrosActualizables->count() === 1 ? ' producto nuestro tiene' : ' productos nuestros tienen') . ' actualización disponible en algún entorno.',
                'arreglo' => 'Lo detecta el propio Moodle del cliente. Si la versión nueva es nuestra, el despliegue lo hacemos nosotros.',
            ];
        }

        // 5. Un plugin licenciado que no está instalado: la pregunta comercial.
        $faltantes = $this->faltantes();

        if ($faltantes !== []) {
            $cuantos = collect($faltantes)->sum(fn (array $f) => $f['productos']->count());
            $avisos[] = [
                'nivel' => 'warn',
                'etiqueta' => 'Licenciado sin instalar',
                'texto' => $cuantos . ($cuantos === 1 ? ' producto licenciado no aparece' : ' productos licenciados no aparecen') . ' en el inventario del entorno que lo tiene contratado.',
                'arreglo' => 'O el cliente no lo ha instalado —está pagando por algo que no usa— o lo tiene y su Moodle no lo declara. Las dos cosas se hablan con el cliente.',
            ];
        }

        // 6. La cobertura. Va al final por nivel, pero es lo que condiciona todo lo de
        // arriba: cada aviso se ha calculado sobre la parte del parque que habla.
        if ($cobertura['vacio']) {
            $avisos[] = [
                'nivel' => 'crit',
                'etiqueta' => 'Sin inventario',
                'texto' => 'Ningún entorno envía inventario de plugins.',
                'arreglo' => 'Lo manda la acción `plugins` de la API desde la tarea programada del plugin. Si no llega de ningún sitio, el problema es del contrato, no de un entorno.',
            ];
        } elseif ($cobertura['parcial']) {
            $avisos[] = [
                'nivel' => 'info',
                'etiqueta' => 'Inventario parcial',
                'texto' => $cobertura['sin'] . ' de ' . $cobertura['total'] . ($cobertura['sin'] === 1 ? ' entorno activo no envía' : ' entornos activos no envían') . ' inventario.',
                'arreglo' => 'Todo lo de esta pantalla se calcula sobre los ' . $cobertura['envian'] . ' que sí lo envían. De los demás no sabemos qué tienen instalado.',
            ];
        }

        // 7. Las fotos de fechas muy distintas.
        $frescura = $this->frescura();

        if ($frescura['dispersa']) {
            $avisos[] = [
                'nivel' => 'info',
                'etiqueta' => 'Fotos dispares',
                'texto' => 'Entre el inventario más antiguo y el más reciente hay ' . $frescura['dias'] . ' días.',
                'arreglo' => 'El agregado mezcla fotos de fechas distintas. Sincroniza los entornos rezagados desde su ficha para comparar cosas comparables.',
            ];
        }

        return $avisos;
    }

    /**
     * Cuántos avisos hay de cada nivel, para el botón del acordeón.
     *
     * @return array{crit:int, warn:int, info:int}
     */
    public function porNivel(): array
    {
        $cuenta = collect($this->atencion())->countBy('nivel');

        return [
            'crit' => $cuenta['crit'] ?? 0,
            'warn' => $cuenta['warn'] ?? 0,
            'info' => $cuenta['info'] ?? 0,
        ];
    }

    /* ======================================================================
       El inventario completo
       ====================================================================== */

    /**
     * Los tipos que hay, con su cuenta, para el filtro.
     *
     * Antes era un `<select>` con seis tipos escritos a mano —mod, block, local, theme,
     * report, format— y en los datos hay **61**. Los que no estaban en la lista no se
     * podían filtrar.
     *
     * @return array<int, array{tipo:string, cuantos:int}>
     */

    public function alternar(string $component): void
    {
        $this->authorize('admin.plugins.environment');

        $this->abierto = $this->abierto === $component ? null : $component;
    }

    /**
     * Los entornos del componente desplegado.
     *
     * @return array<int, array<string, mixed>>
     */
    public function detalleAbierto(): array
    {
        if ($this->abierto === null) {
            return [];
        }

        return $this->versionesDe([$this->abierto])[$this->abierto] ?? [];
    }


    public function resumenNuestros(): array
    {
        $mios = collect($this->nuestros());
        $envian = $this->cobertura()['envian'];

        return [
            'desalineados' => $mios->where('versiones', '>', 1)->count(),
            'actualizables' => $mios->where('actualizables', '>', 0)->count(),
            // «No está en todos los que hablan»: un plugin nuestro que falta en un sitio
            // que sí envía inventario es una ausencia real, no un dato que no tenemos.
            'ausentes' => $envian > 0 ? $mios->where('entornos', '<', $envian)->count() : 0,
        ];
    }

    /**
     * El core, filtrado por la pestaña.
     *
     * @return array<int, array<string, mixed>>
     */

    /**
     * ¿Esta pantalla pinta en qué entorno está cada versión de lo nuestro?
     *
     * Solo «Plugins Tresipunt». Traer el detalle exige una consulta por componente, y las
     * demás pantallas solo necesitan los recuentos —cuántas versiones, cuántos
     * actualizables—, que ya vienen del agregado.
     */
    protected function quiereElDetalleDeVersiones(): bool
    {
        return false;
    }

    /**
     * Deja la pantalla como se abre.
     *
     * Cada sección añade lo suyo; aquí está lo que comparten. **El recorte de apagados
     * también se va**, porque incluirlos es ampliar y no filtrar: si se quedara puesto, la
     * tabla seguiría contando sitios de baja después de haber pedido empezar de cero.
     */
    public function limpiarFiltros(): void
    {
        $this->busqueda = '';
        $this->incluirApagados = false;
    }

    /** Lo que toda la sección necesita en su vista. */
    protected function comunes(): array
    {
        return [
            'cobertura' => $this->cobertura(),
            // Los avisos y su desglose por nivel: los pinta un parcial que va en las cinco
            // pantallas, porque un aviso del core no se vería estando en el inventario.
            'atencion' => $this->atencion(),
            'porNivel' => $this->porNivel(),
            'apagadosOcultos' => $this->apagadosOcultos(),
        ];
    }
}