<?php

namespace App\Livewire\Environments;

use App\Models\Environments\Env;
use App\Models\Environments\Environment;
use App\Models\Environments\Plugin;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;

class Index extends Component
{
    use WithPagination;

    /**
     * Los tres órdenes de la lista, con su etiqueta.
     *
     * **Por qué son tres botones y no una flecha en cada cabecera.** La tabla tiene siete
     * columnas y solo tres responden a una pregunta que alguien se hace: qué se ha movido,
     * qué hemos tocado nosotros y dónde está tal sitio. Una flecha en las siete ofrece
     * cuatro ordenaciones que nadie pide y esconde las tres que sí.
     */
    public const ORDENES = [
        'sincro' => 'Sincronización',
        'editado' => 'Última edición',
        'nombre' => 'Nombre',
    ];

    /**
     * En qué orden se pinta la lista.
     *
     * **Por sincronización de entrada, y no alfabético como antes.** El alfabético
     * responde a «dónde está tal sitio», que es lo que se hace con el buscador; lo que se
     * mira al entrar sin buscar nada es qué se ha movido. Los que no han sincronizado
     * nunca van al final en los tres órdenes: son altas sin terminar, no actividad
     * reciente, y colarlos arriba por tener la fecha vacía es lo que hace un `NULL` si no
     * se le dice otra cosa.
     */
    #[Url(as: 'orden', except: 'sincro')]
    public string $orden = 'sincro';

    #[Url(as: 'q', except: '')]
    public string $search = '';

    /**
     * En qué se usa cada sitio —`pro`, `pre`, `develop`, `local`—, **varios a la vez**.
     *
     * Era **uno excluyente**, y la pregunta que se hace de verdad es «pro y pre», que es
     * lo que se mira cuando algo pasa en cliente: producción y su previo. Con un solo
     * valor había que elegir entre ver de más —todos— o ver de menos, y lo que se hacía
     * era mirar dos veces.
     *
     * ## Por qué es una cadena y no un array
     *
     * **Porque `''` significa «el defecto configurado», y así el enlace limpio existe.**
     * El defecto ya no está en el código: sale del catálogo de usos —la casilla «sale
     * marcado en el filtro» de cada fila, en `Configuración › Catálogos`—, y el `except:`
     * del atributo `#[Url]` solo admite expresiones **constantes**, así que no puede
     * comparar contra algo que decide una tabla.
     *
     * Guardando la lista como texto y dejando el vacío para el defecto, el `except: ''`
     * sí es constante: la pantalla recién abierta no arrastra `env` en la URL, y el enlace
     * que se pasa por Slack sigue significando lo que se estaba viendo. Con un array y sin
     * `except` la URL llevaría siempre `env[]=pro&env[]=pre&env[]=develop`.
     *
     * Y tiene un efecto secundario que resulta ser el correcto: si mañana alguien cambia en
     * el catálogo qué usos salen de entrada, **los enlaces limpios ya compartidos siguen
     * significando «lo que sale de entrada»**, no la lista congelada de hoy.
     *
     * ## Nunca se queda sin ninguno
     *
     * Si se pudiera dejar vacía la selección, «ninguno» tendría que significar o una lista
     * vacía —un callejón sin salida— o «todos», y entonces **deseleccionar mostraría más
     * filas**, que es lo contrario de lo que espera cualquiera. Así que lo que está marcado
     * es exactamente lo que se ve, siempre, y quitar el último no hace nada.
     */
    #[Url(as: 'env', except: '')]
    public string $env = '';

    /**
     * Los usos que están marcados ahora mismo.
     *
     * Es lo que antes era la propiedad `$envs`. **Se resuelve en cada lectura** porque
     * `''` no es un valor: es una referencia al catálogo.
     *
     * Y **se saneia contra el catálogo**, porque este valor llega de la URL: un
     * `?env=pro,inventado` filtraría por un uso que no existe y daría una lista vacía sin
     * decir por qué; si no queda ninguno válido, vale el defecto.
     *
     * @return list<string>
     */
    public function envsElegidos(): array
    {
        $pedidos = array_filter(
            array_map('trim', explode(',', $this->env)),
            fn (string $uso) => $uso !== '' && Env::existe($uso)
        );

        return $pedidos === []
            ? Env::visiblesPorDefecto()
            : $this->enOrdenDelCatalogo($pedidos);
    }

    /**
     * La misma selección, siempre en el mismo orden.
     *
     * **El orden es el contrato de la URL.** Sin esto, `env=pro,pre` y `env=pre,pro`
     * serían dos enlaces distintos para la misma vista, y la comparación contra el defecto
     * —la que deja la URL limpia— no reconocería una lista bien ordenada al revés.
     *
     * @param  iterable<string>  $envs
     * @return list<string>
     */
    private function enOrdenDelCatalogo(iterable $envs): array
    {
        return array_values(array_intersect(Env::todos(), (array) $envs));
    }

    /**
     * Deja puesta una selección de usos.
     *
     * Cuando coincide con el defecto configurado se guarda el vacío, y **eso es lo que
     * mantiene limpia la URL**: llegar al defecto por el camino largo —quitar un uso y
     * volver a ponerlo— deja el mismo enlace que abrir la pantalla.
     *
     * @param  iterable<string>  $envs
     */
    private function fijarEnvs(iterable $envs): void
    {
        $canonicos = $this->enOrdenDelCatalogo($envs);

        $this->env = $canonicos === Env::visiblesPorDefecto() ? '' : implode(',', $canonicos);

        $this->resetPage();
    }

    /**
     * Plataforma: Moodle LMS, Workplace, Goodle, Wordpress…
     *
     * Faltaba, y es una pregunta que se hace de verdad —«¿qué sitios son Goodle?»—.
     * La barra solo filtraba por `env` (pro/pre/develop/local), que es otra cosa: uno
     * es en qué tipo de plataforma corre y el otro para qué se usa ese sitio.
     */
    #[Url(as: 'tipo', except: null)]
    public ?int $typeFilter = null;

    /**
     * '' = todos | 'si' = solo activos | 'no' = solo inactivos.
     *
     * Faltaba por completo: un entorno inactivo salía atenuado en la lista y no había
     * forma de aislarlos («¿qué tenemos apagado?») ni de quitarlos de en medio.
     *
     * **Por defecto, solo los activos.** Antes se veían todos, con este motivo escrito
     * aquí: *esconder filas sin decirlo es peor que mostrarlas*. El motivo sigue siendo
     * bueno, así que lo que se ha hecho no es quitarlo: es **decirlo**. La barra dice
     * cuántos locales y cuántos inactivos se están ocultando y ofrece verlos, y los cuatro
     * números de la cabecera siguen contando **todo el parque**, así que no hay ningún
     * sitio donde una lista recortada pueda leerse como si fuera todo.
     */
    #[Url(as: 'estado', except: 'si')]
    public string $activeFilter = 'si';

    /**
     * Entornos que **nunca** han sincronizado.
     *
     * Es un estado distinto de «sin señal» —un alta a medio terminar, no una avería— y
     * no se podía filtrar. En la base real son 20 de 21, así que era justo el grupo más
     * grande y el único sin atajo: de ahí la extrañeza de ver «0 sin señal».
     */
    #[Url(as: 'sin_sync', except: false)]
    public bool $onlyNeverSynced = false;

    /**
     * Entornos sin licencia asignada.
     *
     * En la base real hay 17, y **se quedan a propósito**: pueden ser altas a medio
     * configurar o sitios a la espera. Pero hay que poder encontrarlos, porque un
     * entorno sin licencia **no recibe servicio**: la API busca el host solo entre los
     * entornos de la licencia que llama, así que su Moodle recibe «Environment not
     * found for this host» —el mismo mensaje que un dominio mal escrito—.
     */
    #[Url(as: 'sin_licencia', except: false)]
    public bool $onlyWithoutLicense = false;

    #[Url(as: 'core_mayor', except: false)]
    public bool $onlyCoreMajorUpdates = false;

    #[Url(as: 'core_menor', except: false)]
    public bool $onlyCoreMinorUpdates = false;

    #[Url(as: 'plugins_viejos', except: false)]
    public bool $onlyEnvironmentsWithOutdatedPlugins = false;

    /**
     * Componentes de plugin seleccionados, exactos.
     *
     * **Lo que había era un texto libre** que hacía `LIKE %texto%` sobre `component`, y
     * tenía dos problemas: el nombre («Componente de plugin») no decía lo que hace —esto
     * filtra *entornos que tienen ese plugin*— y con un solo cuadro de texto no se podía
     * pedir «los que tienen el SEPE **y** el ImportGC», que es la pregunta real.
     *
     * Son nombres exactos y no fragmentos: el fragmento se usa para **buscar** en el
     * selector, y lo que queda seleccionado es el componente concreto. Así lo filtrado
     * siempre coincide con lo que se eligió.
     *
     * **Fue el primero en ir a la URL**, y por una razón concreta: «Plugins del parque»
     * enlaza aquí para responder «¿en qué entornos está este plugin?», y sin el filtro en
     * la URL ese enlace llegaba al listado completo, que es no responder. Desde MGR-053
     * van los dieciséis.
     *
     * @var array<int, string>
     */
    #[Url(as: 'plugin', except: [])]
    public array $pluginComponents = [];

    /**
     * Texto del buscador del selector. **No filtra por sí mismo**: sirve para encontrar
     * componentes y añadirlos.
     *
     * **Y por eso es el único que no va a la URL**: lo que se comparte es qué plugins se
     * han elegido, no lo que alguien estaba tecleando para encontrarlos. En un enlace
     * sería ruido que además reabre el selector.
     */
    public string $pluginSearch = '';

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

    /**
     * Componentes que coinciden con lo escrito, con en cuántos entornos están.
     *
     * Hay **469 componentes distintos** en la base de desarrollo y en producción serán
     * más: un desplegable con todos no se puede usar, así que se busca contra el servidor
     * a medida que se escribe.
     *
     * Se piden **dos caracteres como mínimo**: con uno la consulta devuelve casi todo y no
     * ayuda a elegir. Y se ordena por **número de entornos** y no alfabéticamente, porque
     * lo que se busca suele ser un plugin repartido —un producto nuestro— y no el primero
     * por orden de letra.
     *
     * @return array<int, object>
     */
    public function sugerenciasDePlugins(): array
    {
        $termino = trim($this->pluginSearch);

        if (mb_strlen($termino) < 2) {
            return [];
        }

        return Plugin::query()
            ->selectRaw('component, count(distinct environment_id) as entornos')
            ->where('component', 'like', '%' . $termino . '%')
            // Los ya elegidos no se vuelven a ofrecer: elegir dos veces el mismo no
            // cambia el filtro y ocupa un hueco de la lista.
            ->when($this->pluginComponents !== [], fn ($q) => $q->whereNotIn('component', $this->pluginComponents))
            ->groupBy('component')
            ->orderByDesc('entornos')
            ->orderBy('component')
            ->limit(12)
            ->get()
            ->all();
    }

    public function anadirPlugin(string $componente): void
    {
        if ($componente === '' || in_array($componente, $this->pluginComponents, true)) {
            return;
        }

        $this->pluginComponents[] = $componente;
        $this->pluginSearch = '';
        $this->resetPage();
    }

    public function quitarPlugin(string $componente): void
    {
        $this->pluginComponents = array_values(
            array_filter($this->pluginComponents, fn ($c) => $c !== $componente)
        );

        $this->resetPage();
    }

    /**
     * Familia de versión de Moodle: `4.1`, `4.5`, `5.1`…
     *
     * Se filtra por **familia y no por versión exacta** porque la pregunta real de
     * Sistemas es «quién sigue en 4.1», no «quién tiene el 4.1.13». Con versiones
     * exactas la lista de opciones tendría diez entradas para decir lo mismo.
     */
    #[Url(as: 'version', except: null)]
    public ?string $versionFilter = null;

    /**
     * Entornos a los que el Manager **no puede pedirles datos**: no tienen guardado el
     * token de servicios web de su Moodle.
     *
     * Son 20 de 21 en la base de desarrollo. Hasta ahora solo se veía entrando fila a
     * fila, y es la lista de trabajo de «a quién hay que pedirle el token».
     */
    #[Url(as: 'sin_token', except: false)]
    public bool $onlyWithoutWsToken = false;

    public function updatedVersionFilter(): void
    {
        // El desplegable manda '' al elegir «Todas» y el valor por defecto es null:
        // son el mismo estado y hay que dejar uno solo, o la URL se queda con un
        // `version=` vacío colgando que el `except` del atributo no puede limpiar.
        $this->versionFilter = $this->versionFilter === '' ? null : $this->versionFilter;

        $this->resetPage();
    }



    #[Url(as: 'producto', except: null)]
    public ?int $productFilter = null;

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

    /**
     * Marca o desmarca un uso.
     *
     * **Quitar el último no hace nada**, a propósito: ver el motivo en `$env`. Y llega de
     * un `wire:click`, o sea de una petición que cualquiera puede repetir con el valor que
     * quiera, así que un uso que el catálogo no reconoce se ignora: dejarlo entrar no
     * rompería nada —la consulta saldría vacía— pero se quedaría en la URL y en el control,
     * que es peor, porque es una lista vacía sin motivo visible.
     */
    public function alternarEnv(string $uso): void
    {
        if (! Env::existe($uso)) {
            return;
        }

        $puestos = $this->envsElegidos();

        $nuevos = in_array($uso, $puestos, true)
            ? array_diff($puestos, [$uso])
            : array_merge($puestos, [$uso]);

        if ($nuevos === []) {
            return;
        }

        $this->fijarEnvs($nuevos);
    }

    /** Todos los del catálogo. Es el atajo del «Todos» del control segmentado. */
    public function todosLosEnvs(): void
    {
        $this->fijarEnvs(Env::todos());
    }

    /**
     * Quitar el recorte del defecto: se ven los locales **y** los inactivos.
     *
     * Es el botón del aviso de la barra. Va aparte de «Quitar filtros» porque son dos
     * cosas distintas: aquel devuelve la vista **al defecto** —que oculta—, y este
     * enseña todo el parque.
     */
    public function verTodo(): void
    {
        $this->fijarEnvs(Env::todos());
        $this->activeFilter = '';
    }

    /**
     * ¿Se está dejando algo fuera? Para saber si hay que avisar.
     *
     * Se compara contra **el catálogo entero** y no contra la cadena `'local'`: quién queda
     * fuera de entrada lo decide el catálogo, y un uso nuevo marcado como no visible tiene
     * que contar igual.
     */
    public function ocultandoPorDefecto(): bool
    {
        return count($this->envsElegidos()) < count(Env::todos())
            || $this->activeFilter === 'si';
    }

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

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

    /**
     * Alterna un estado de sincronización, apagando el otro.
     *
     * «Nunca sincronizado» y «sin señal» son **excluyentes**: uno pide `refresh_at`
     * nulo y el otro no nulo, así que con los dos puestos la lista sale vacía sin
     * explicación. Es la misma trampa que tenía el filtro de actualización.
     */
    public function alternarSenal(string $cual): void
    {
        // **Los tres se excluyen**: son tres cortes del mismo dato y combinarlos daría
        // listas vacías —un entorno no puede a la vez no haber sincronizado nunca y llevar
        // tres días sin hacerlo—. Encender uno apaga los otros dos.
        $this->onlyNeverSynced = $cual === 'nunca' ? ! $this->onlyNeverSynced : false;
        $this->onlyWithoutSignal = $cual === 'senal' ? ! $this->onlyWithoutSignal : false;
        $this->onlyStale = $cual === 'frescos' ? ! $this->onlyStale : false;

        $this->resetPage();
    }

    /**
     * Cambia el orden de la lista.
     *
     * Vuelve a la página 1: seguir en la 4 después de reordenar enseña un trozo del medio
     * de otra lista, que no es lo que se venía a ver.
     */
    public function ordenarPor(string $cual): void
    {
        $this->orden = $this->ordenValido($cual);

        $this->resetPage();
    }

    /**
     * El orden pedido, o el de por defecto si no existe.
     *
     * Hace falta porque `orden` viaja en la URL y un enlace viejo —o retocado a mano—
     * puede traer cualquier cosa. Sin esto la lista saldría sin ningún orden, y en una
     * lista paginada eso significa que **un mismo entorno puede salir en dos páginas y
     * otro en ninguna**: sin `ORDER BY`, el motor no garantiza que dos `LIMIT/OFFSET`
     * seguidos recorran las filas igual.
     */
    public function ordenValido(?string $cual = null): string
    {
        $cual ??= $this->orden;

        return isset(self::ORDENES[$cual]) ? $cual : 'sincro';
    }

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

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

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

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

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

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

    /**
     * Entornos sin señal: sincronizaron alguna vez y llevan 3 días o más sin hacerlo.
     *
     * **No incluye los que nunca han sincronizado**, y esa distinción es la que pedía el
     * diseño con dos textos distintos: «nunca sincronizado» es un alta sin terminar y
     * «sin señal» es una avería. Mismos umbrales que el Dashboard.
     */
    #[Url(as: 'sin_senal', except: false)]
    public bool $onlyWithoutSignal = false;

    /** Entornos cuyo soporte contratado ya ha caducado. */
    #[Url(as: 'soporte_caducado', except: false)]
    public bool $onlyExpiredSupport = false;

    /**
     * Entornos que **nunca** han tenido soporte contratado.
     *
     * Es distinto de «caducado» y es la pregunta de Comercial: a quién se le puede
     * ofrecer. Un caducado ya fue cliente de soporte; estos no lo han sido nunca.
     */
    #[Url(as: 'sin_soporte', except: false)]
    public bool $onlyWithoutSupport = false;

    /**
     * Entornos con una observación anotada a mano.
     *
     * **Es el único filtro de esta pantalla que no sale de un dato del sistema.** Los demás
     * responden a lo que el parque declara —sin señal, sin licencia, core viejo—; este
     * responde a lo que una persona vio y escribió. Por eso va arriba con los de cabecera y
     * no en el bloque plegable: es una lista de trabajo, no una consulta puntual.
     */
    #[Url(as: 'por_revisar', except: false)]
    public bool $onlyPendingReview = false;

    /**
     * Entornos **sin datos frescos**: los que nunca han sincronizado y los que llevan tres
     * días o más sin hacerlo.
     *
     * **Es el filtro del KPI de «desincronizados», y hacía falta uno propio.** Los dos chips
     * de triaje reparten esos mismos entornos en dos grupos —«nunca sincronizado» es un alta
     * sin terminar y «sin señal» es una avería, y lo que hay que hacer con cada uno no se
     * parece— pero el KPI los cuenta juntos, porque para «¿de qué no sabemos nada?» son lo
     * mismo. Sin este filtro, pulsar el KPI llevaba a una lista que no cuadraba con su
     * número.
     */
    #[Url(as: 'sin_frescos', except: false)]
    public bool $onlyStale = false;

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

    /** Vuelve a contar cuando alguien anota o resuelve algo desde la modal. */
    #[\Livewire\Attributes\On('environmentReviewChanged')]
    public function refrescarRevisiones(): void
    {
        \Illuminate\Support\Facades\Cache::forget('environments:resumen');
        $this->resetPage();
    }

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

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

    /**
     * Filtro de actualización de core: mayor, menor o todas.
     *
     * **En un solo método a propósito.** Son dos banderas excluyentes, y la vista lo
     * hacía con dos `$set` encadenados en el mismo `wire:click`:
     *
     *     wire:click="$set('onlyCoreMajorUpdates', false); $set('onlyCoreMinorUpdates', true)"
     *
     * Eso son **dos peticiones a Livewire**, cada una con su propia instantánea del
     * componente, así que se pisan: al elegir «Menor» el control volvía a «Todas».
     * Un método deja las dos banderas coherentes en una sola petición.
     */
    public function filtrarActualizacion(string $cual): void
    {
        $this->onlyCoreMajorUpdates = $cual === 'mayor';
        $this->onlyCoreMinorUpdates = $cual === 'menor';

        $this->resetPage();
    }

    /**
     * Deja los filtros como al entrar.
     *
     * Con nueve filtros, «quitar lo que haya puesto» a mano es imposible de acertar, y
     * quedarse con uno puesto sin darse cuenta hace leer una lista recortada como si
     * fuera todo el parque.
     */
    /**
     * En qué orden se pinta la lista.
     *
     * El que se haya elegido arriba —sincronización, última edición o nombre—, **salvo que
     * esté puesta la lista de pendientes**: ahí manda el nivel —lo urgente primero, que es
     * para lo que existe el nivel— y el orden elegido pasa a desempatar dentro de cada
     * nivel. Dejar que el orden elegido se comiera al nivel vaciaría de sentido el único
     * filtro de la pantalla que ordena por gravedad.
     *
     * **Va aparte de `aplicarFiltros()` y no dentro**, que es donde estaba: ese constructor
     * lo reutilizan las consultas agregadas de las facetas, y ordenar por una columna que no
     * está en su `GROUP BY` revienta en MySQL con `only_full_group_by`. Filtrar y ordenar son
     * dos cosas distintas, y solo una de las dos vale para un agregado.
     *
     * Y el orden va en SQL y no en PHP porque la lista pagina: ordenar después de paginar
     * ordena la página, no la lista.
     */
    private function ordenDelListado($query)
    {
        if ($this->onlyPendingReview) {
            $query->orderByRaw(\App\Support\RevisionDeEntorno::ordenSql() . ' desc')
                ->orderBy('review_flagged_at');
        }

        // **El `is null` delante de cada fecha, y no un `orderByDesc` a secas.** Un entorno
        // sin sincronizar tiene la fecha vacía, y una fecha vacía no es «lo más reciente»
        // ni «lo más antiguo»: es que no hay dato. Cada motor coloca los nulos donde le
        // parece según la dirección, así que se dice a mano dónde van —al final— y deja de
        // depender del motor.
        //
        // El desempate por nombre cierra los tres: sin él, dos entornos con la misma fecha
        // pueden intercambiarse entre una página y la siguiente.
        return match ($this->ordenValido()) {
            'nombre' => $query->orderBy('name'),
            'editado' => $query
                ->orderByRaw('ultima_edicion is null')
                ->orderByDesc('ultima_edicion')
                ->orderBy('name'),
            default => $query
                ->orderByRaw('refresh_at is null')
                ->orderByDesc('refresh_at')
                ->orderBy('name'),
        };
    }

    /**
     * Cómo se llama cada uso en pantalla.
     *
     * En la base son códigos —`pro`, `develop`— y en un KPI eso se lee como jerga. Lo
     * que no esté aquí sale con su propio nombre en capital: el catálogo de usos puede
     * crecer sin desplegar, así que esto es una traducción y no una lista cerrada.
     */
    /**
     * El color de cada uso: texto y fondo.
     *
     * Los mismos que el badge de la fila, para que `pro` se vea igual en el KPI y en el
     * listado. Lo que no esté aquí sale en gris: el catálogo de usos puede crecer sin
     * desplegar, así que esto es una tabla de colores y no una lista cerrada.
     */
    /** El interruptor de corriente: encendido y apagado son el mismo dibujo en dos colores. */
    private const ICONO_ENCENDIDO = ['M12 2v10', 'M18.4 6.6a9 9 0 1 1-12.77.04'];

    /** La flecha de recargar, tachada: no llega nada de ese sitio. */
    private const ICONO_SIN_SENAL = [
        'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8',
        'M3 3v5h5',
        'm2 2 20 20',
    ];

    private const COLORES_DE_USO = [
        'pro' => ['var(--success-500)', 'var(--success-50)'],
        'pre' => ['var(--info-500)', 'var(--info-50)'],
        'develop' => ['var(--neutral-600)', 'var(--surface-sunken)'],
        'local' => ['var(--neutral-600)', 'var(--surface-sunken)'],
    ];

    public function limpiarFiltros(): void
    {
        $this->reset([
            'search', 'env', 'typeFilter', 'activeFilter', 'onlyNeverSynced',
            'onlyWithoutLicense', 'onlyWithoutSignal', 'onlyWithoutSupport',
            'onlyExpiredSupport', 'onlyCoreMajorUpdates', 'onlyCoreMinorUpdates',
            'onlyEnvironmentsWithOutdatedPlugins', 'pluginComponents', 'pluginSearch',
            'versionFilter', 'onlyWithoutWsToken', 'productFilter', 'onlyPendingReview',
            'onlyStale',
        ]);

        $this->resetPage();
    }

    /**
     * Cuántos filtros del bloque plegable están puestos.
     *
     * La barra llegó a tener trece controles, así que los de uso puntual se agrupan en
     * un bloque que se abre. **Esconder un filtro activo es la peor cosa que puede
     * hacer una pantalla de listado**: se lee una lista recortada como si fuera todo.
     * Con este número, el botón dice cuántos hay puestos ahí dentro y el bloque se
     * abre solo si hay alguno.
     */
    public function filtrosAvanzados(): int
    {
        return collect([
            $this->typeFilter !== null,
            $this->versionFilter !== null && $this->versionFilter !== '',
            $this->productFilter !== null,
            $this->pluginComponents !== [],
            $this->onlyEnvironmentsWithOutdatedPlugins,
            $this->onlyWithoutWsToken,
            $this->onlyWithoutSupport,
            $this->onlyPendingReview,
        ])->filter()->count();
    }

    public function hayFiltros(): bool
    {
        return $this->search !== ''
            // **Se compara con el defecto, no con «vacío».** El defecto de esta
            // pantalla ya recorta —oculta locales e inactivos—, así que si eso contara
            // como «filtro puesto», el botón de quitarlos estaría siempre encendido y
            // dejaría de significar nada.
            || $this->env !== ''
            || $this->typeFilter !== null
            || $this->activeFilter !== 'si'
            || $this->onlyNeverSynced
            || $this->onlyWithoutLicense
            || $this->onlyWithoutSignal
            || $this->onlyExpiredSupport
            || $this->onlyWithoutSupport
            || $this->onlyCoreMajorUpdates
            || $this->onlyCoreMinorUpdates
            || $this->onlyEnvironmentsWithOutdatedPlugins
            || $this->pluginComponents !== []
            || ($this->versionFilter !== null && $this->versionFilter !== '')
            || $this->onlyWithoutWsToken
            || $this->productFilter !== null
            || $this->onlyPendingReview
            || $this->onlyStale;
    }

    /**
     * Los cuatro números de la cabecera, que son los que deciden a qué se mira primero.
     *
     * Se cuentan **sobre todo el parque**, no sobre lo filtrado: son el resumen de qué hay
     * que atender, y si cambiaran al filtrar dejarían de servir para eso.
     *
     * Cacheados 60 s porque en Livewire `render()` se ejecuta en **cada** interacción
     * —cada tecla del buscador— y esto son cuatro agregados. El Dashboard hace lo mismo
     * por el mismo motivo (MGR-027).
     *
     * @return array{sinSenal: int, soporteCaducado: int, coreMayor: int, sinLicencia: int}
     */
    private function resumen(): array
    {
        return Cache::remember('environments:resumen', 60, function () {
            return [
                // Sin señal: han sincronizado y llevan 3 días o más sin hacerlo. El
                // `whereNotNull` es lo que separa la avería del alta sin terminar.
                'sinSenal' => Environment::where('active', true)
                    ->whereNotNull('refresh_at')
                    ->where('refresh_at', '<', now()->subDays(3))
                    ->count(),
                'soporteCaducado' => Environment::where('active', true)
                    ->whereHas('soporteVigente', fn ($q) => $q->whereDate('end_at', '<', now()->format('Y-m-d')))
                    ->count(),
                'coreMayor' => Environment::where('active', true)->whereNotNull('lastversion')->count(),
                'sinLicencia' => Environment::where('active', true)->whereNull('license_token_id')->count(),
                // Los que nunca han dado señal: el grupo más numeroso en la base real, y
                // el que explicaba por qué «sin señal» salía a 0.
                'nuncaSincronizados' => Environment::where('active', true)->whereNull('refresh_at')->count(),
                'sinSoporte' => Environment::where('active', true)->whereDoesntHave('supports')->count(),
                'sinTokenWs' => Environment::where('active', true)
                    ->where(fn ($q) => $q->whereNull('moodletoken')->orWhere('moodletoken', ''))
                    ->count(),
                // **Los apagados también cuentan aquí**, al revés que el resto: una
                // observación puede ser justamente «hay que apagar esto» o «revisar antes
                // de darlo de baja», y esconderla al apagar el sitio la perdería.
                'porRevisar' => Environment::whereNotNull('review_flagged_at')->count(),
                'urgentes' => Environment::where('review_level', \App\Support\RevisionDeEntorno::URGENTE)->count(),
            ];
        });
    }

    /**
     * De qué está hecho el parque: por uso, por plataforma y por estado del servicio.
     *
     * Implementa los KPIs de `Entornos.dc.html` (Claude Design).
     *
     * **Es otra pregunta que los chips de triaje.** Aquellos dicen *qué hay que atender* —sin
     * señal, sin licencia— y estos dicen *qué tenemos*. Las dos se hacían igual de a menudo y
     * solo una tenía respuesta: para saber cuántos sitios de producción hay había que filtrar
     * y leer el «N de M».
     *
     * Cada celda **es un filtro**, no una etiqueta: el número sin el atajo obliga a ir a la
     * barra de abajo a repetir a mano lo que se acaba de leer.
     *
     * **Se cuenta sobre todo el parque, no sobre lo filtrado.** Es la composición, y si se
     * moviera al filtrar dejaría de servir para lo único que sirve: saber de qué se está
     * hablando. Cacheado 60 segundos, como el resto del resumen.
     *
     * @return array<int, array{titulo: string, icono: array, celdas: array}>
     */
    public function composicion(): array
    {
        // **Con versión en la clave.** Esto guarda un array con una forma concreta durante un
        // minuto: al cambiarle las claves, la caché sigue sirviendo la forma vieja y la vista
        // revienta con «Undefined array key». Subir el número es lo que lo evita.
        $datos = Cache::remember('environments:composicion:v6', 60, function () {
            // **Un solo `group by` para cuatro cifras.** El recuento por uso, el total, los
            // encendidos y los apagados son la misma pregunta con otro corte, y sueltos eran
            // cuatro consultas. El listado tiene un test que vigila cuánto cuesta pintarlo
            // —MGR-015— y siete agregados para tres tarjetas se lo comían.
            $porUsoYEstado = Environment::selectRaw('env, active, count(*) as c')
                ->groupBy('env', 'active')
                ->get();

            $usos = [];
            $encendidos = 0;
            $apagados = 0;

            foreach ($porUsoYEstado as $fila) {
                $usos[$fila->env] = ($usos[$fila->env] ?? 0) + (int) $fila->c;

                if ($fila->active) {
                    $encendidos += (int) $fila->c;
                } else {
                    $apagados += (int) $fila->c;
                }
            }

            // Los tipos con su nombre en una consulta y no en dos: contar por `type_id` y
            // después ir a buscar cómo se llaman era ir dos veces a por lo mismo.
            $tipos = \App\Models\Environments\Type::query()
                ->select('types.id', 'types.name', 'types.image')
                ->selectRaw('(select count(*) from environments where environments.type_id = types.id and environments.deleted_at is null) as c')
                ->orderBy('types.name')
                ->get();

            return [
                'usos' => $usos,
                'tipos' => $tipos->pluck('c', 'id')->all(),
                'nombres' => $tipos->pluck('name', 'id')->all(),
                // El logo, el mismo que la fila del listado: el mismo concepto no puede
                // tener dos dibujos según dónde se mire (MGR-029). Se guarda ya resuelto a
                // URL porque el accesor necesita el modelo y aquí solo viaja el array.
                'logos' => $tipos->mapWithKeys(fn ($t) => [$t->id => $t->image_url])->all(),
                'total' => $encendidos + $apagados,
                'encendidos' => $encendidos,
                'apagados' => $apagados,
                // «Desincronizados» junta los que nunca han hablado y los que dejaron de
                // hacerlo: para este KPI son lo mismo —no tenemos datos frescos de ellos— y
                // la distinción, que sí importa, la hacen los chips de abajo.
                'desincronizados' => Environment::where('active', true)
                    ->where(fn ($q) => $q->whereNull('refresh_at')
                        ->orWhere('refresh_at', '<', now()->subDays(3)))
                    ->count(),
            ];
        });

        $usos = [];

        foreach (Environment::entornos() as $uso) {
            if (($datos['usos'][$uso] ?? 0) === 0) {
                continue;
            }

            $puesto = $this->env === $uso;

            $usos[] = [
                'n' => $datos['usos'][$uso],
                'etiqueta' => $uso,
                // **Alterna.** Pulsar la que ya está puesta la quita: un filtro que solo
                // sabe encenderse obliga a bajar a la barra a apagarlo, que es el mismo clic
                // en otro sitio. Así se prueba una composición y se vuelve sin moverse.
                'accion' => "\$set('env', '" . ($puesto ? '' : $uso) . "')",
                'activa' => $puesto,
                // La etiqueta va en cápsula de color, como el badge de la fila: así el mismo
                // concepto se ve igual en los dos sitios.
                'tag' => self::COLORES_DE_USO[$uso] ?? ['var(--neutral-600)', 'var(--surface-sunken)'],
            ];
        }

        $tipos = [];

        foreach ($datos['nombres'] as $id => $nombre) {
            if (($datos['tipos'][$id] ?? 0) === 0) {
                continue;
            }

            $puesto = (int) $this->typeFilter === (int) $id;

            $tipos[] = [
                'n' => $datos['tipos'][$id],
                'etiqueta' => $nombre,
                'accion' => $puesto ? "\$set('typeFilter', null)" : "\$set('typeFilter', " . $id . ')',
                'activa' => $puesto,
                'tag' => null,
                'logo' => $datos['logos'][$id] ?? null,
            ];
        }

        return array_values(array_filter([
            $usos !== [] ? [
                'titulo' => 'Por entorno de trabajo',
                'icono' => ['M6 6.5h.01', 'M6 17.5h.01'],
                'cajas' => [['x' => 2, 'y' => 3], ['x' => 2, 'y' => 14]],
                'celdas' => $usos,
            ] : null,
            $tipos !== [] ? [
                'titulo' => 'Por plataforma',
                'icono' => ['M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20'],
                'cajas' => [],
                'celdas' => $tipos,
            ] : null,
            [
                'titulo' => 'Servicio y señal',
                'icono' => ['M22 12h-4l-3 9L9 3l-3 9H2'],
                'cajas' => [],
                'celdas' => [
                    // **Con icono y sin rótulo**, como el diseño: cuando una celda lleva
                    // icono, la etiqueta se va al `title`. Con tres celdas en 300 px,
                    // «desincronizados» se come la celda entera para decir lo que el icono
                    // ya dice, y lo que importa ahí es el número.
                    [
                        'n' => $datos['encendidos'],
                        'etiqueta' => 'Entornos encendidos',
                        // Al volver a pulsarla se va a «todos», que es lo contrario de
                        // «solo encendidos» en esta pantalla.
                        'accion' => "\$set('activeFilter', '" . ($this->activeFilter === 'si' ? '' : 'si') . "')",
                        'activa' => $this->activeFilter === 'si',
                        'tag' => null,
                        'icono' => [self::ICONO_ENCENDIDO, 'var(--success-500)'],
                    ],
                    [
                        'n' => $datos['apagados'],
                        'etiqueta' => 'Entornos apagados: su Moodle recibe 403 en todas las acciones',
                        'accion' => "\$set('activeFilter', '" . ($this->activeFilter === 'no' ? '' : 'no') . "')",
                        'activa' => $this->activeFilter === 'no',
                        'tag' => null,
                        // Mismo dibujo que el de arriba y otro color: encendido y apagado
                        // son el mismo concepto en dos estados, no dos cosas distintas.
                        'icono' => [self::ICONO_ENCENDIDO, 'var(--danger-500)'],
                    ],
                    [
                        'n' => $datos['desincronizados'],
                        'etiqueta' => 'Sin datos frescos: nunca han sincronizado o llevan 3 días o más sin hacerlo',
                        'accion' => "alternarSenal('frescos')",
                        'activa' => $this->onlyStale,
                        // **El número en negro, como los otros dos.** Estuvo en ámbar y no
                        // funcionaba: la tarjeta es un inventario —cuántos hay de cada
                        // cosa— y una cifra de color ahí se lee como una alarma. Las
                        // alarmas son los chips de triaje de debajo, que existen para eso.
                        // Lo que distingue esta celda es el icono, que sí va en ámbar.
                        'tag' => null,
                        'icono' => [self::ICONO_SIN_SENAL, 'var(--warning-500)'],
                    ],
                ],
            ],
        ]));
    }
    /**
     * Cuántas filas está escondiendo el recorte del defecto.
     *
     * **Es la mitad del cambio, no un adorno.** El defecto de esta pantalla oculta los
     * locales y los inactivos, y el motivo por el que antes no ocultaba nada estaba escrito
     * en el código: *esconder filas sin decirlo es peor que mostrarlas*. Ese motivo sigue
     * siendo bueno. Lo que se ha hecho no es quitarlo: es **decirlo**.
     *
     * **Se cuenta sobre el resto de filtros puestos**, no sobre todo el parque: si dijera
     * «hay 12 locales» mientras se está buscando «reus», el número no se referiría a lo que
     * se tiene delante y no ayudaría a decidir. Así el aviso dice cuántas filas más
     * saldrían **en esta misma búsqueda** al quitar el recorte.
     *
     * **Y es un número, no dos.** Dar «N locales y M inactivos» sería mentir por poco: un
     * local inactivo está en los dos grupos, así que N+M no es lo que aparecería al pulsar
     * «ver todo». La resta contra lo que se está mostrando no puede discrepar de la lista.
     */
    public function ocultos(): int
    {
        if (! $this->ocultandoPorDefecto()) {
            return 0;
        }

        $sinRecorte = $this->aplicarFiltros(Environment::query(), ['env', 'estado'])->count();
        $mostrados = $this->aplicarFiltros(Environment::query())->count();

        return max(0, $sinRecorte - $mostrados);
    }
    /* ==================================================================
     * Las opciones de cada filtro, sacadas de lo que se está viendo
     * ==================================================================
     *
     * **Lo que había**: cada desplegable se llenaba del catálogo entero. Así que con el
     * listado acotado a `pro` se seguían ofreciendo versiones de Moodle y productos que no
     * tenía ninguno de esos entornos, y elegir uno llevaba a una lista vacía — sin forma
     * de saber si el filtro estaba roto o si de verdad no había nada.
     *
     * Ahora cada lista se calcula **sobre lo filtrado, menos su propio filtro** (ver
     * `aplicarFiltros()`). Excluir el suyo no es un detalle: sin eso, al elegir Goodle la
     * única opción ofrecida sería Goodle y no habría forma de cambiar de valor sin limpiar
     * el filtro.
     *
     * Cuestan una consulta agregada cada una, con los filtros ya aplicados y sobre
     * columnas indexadas. Se pagan a propósito: la alternativa es una pantalla que ofrece
     * caminos que no llevan a ninguna parte.
     */

    /** Base común: los filtros puestos menos el que se va a ofrecer. */
    private function paraOfrecer(?string $excepto): \Illuminate\Database\Eloquent\Builder
    {
        return $this->aplicarFiltros(Environment::query(), $excepto);
    }

    /**
     * Las plataformas presentes en lo filtrado, con cuántos entornos.
     *
     * @return array<int, array{id: int, name: string, cuantos: int}>
     */
    public function tiposDisponibles(): array
    {
        $cuantos = $this->paraOfrecer('tipo')
            ->whereNotNull('type_id')
            ->selectRaw('type_id, count(*) as cuantos')
            ->groupBy('type_id')
            ->pluck('cuantos', 'type_id');

        if ($cuantos->isEmpty()) {
            return [];
        }

        return \App\Models\Environments\Type::whereIn('id', $cuantos->keys())
            ->orderBy('name')
            ->get(['id', 'name'])
            ->map(fn ($tipo) => [
                'id' => $tipo->id,
                'name' => $tipo->name,
                'cuantos' => (int) $cuantos[$tipo->id],
            ])
            ->all();
    }

    /**
     * Las familias de versión presentes en lo filtrado, de la más nueva a la más vieja.
     *
     * **Ya no se cachea.** Estaba cacheada 60 s porque era la misma lista para todo el
     * mundo; ahora depende de los filtros puestos, así que una caché global devolvería las
     * familias de otra combinación. Cachear por combinación de filtros sería una clave
     * distinta por cada vista posible: no se amortiza para un `pluck` de una columna.
     *
     * Se parte en PHP y no en SQL porque cortar por el segundo punto no es portable entre
     * MariaDB y sqlite.
     *
     * @return array<string, int>
     */
    public function familiasDisponibles(): array
    {
        return $this->paraOfrecer('version')
            ->whereNotNull('version')
            ->pluck('version')
            ->map(fn ($v) => implode('.', array_slice(explode('.', $v), 0, 2)))
            ->countBy()
            ->sortKeysDesc()
            ->all();
    }

    /**
     * Los productos que llegan a lo filtrado, a través de la licencia de cada entorno.
     *
     * **Sin recuento, y a propósito.** Los otros dos filtros lo llevan porque salen de un
     * `group by` sobre una columna de `environments`. Un entorno no tiene productos: los
     * hereda de su licencia, cruzando `license_token_product` y `license_tokens`, así que
     * «cuántos entornos por producto» son dos pivotes y una consulta por producto. Dar un
     * número aproximado en una pantalla que existe para acotar sería peor que no darlo:
     * quien lo lea decidirá con él.
     *
     * @return array<int, array{id: int, name: string}>
     */
    public function productosDisponibles(): array
    {
        $ids = $this->paraOfrecer('producto')->select('environments.id');

        return \App\Models\Products\Product::query()
            ->whereHas('tokens.environments', fn ($q) => $q->whereIn('environments.id', $ids))
            ->orderBy('name')
            ->get(['id', 'name'])
            ->map(fn ($producto) => ['id' => $producto->id, 'name' => $producto->name])
            ->all();
    }
    /**
     * Aplica los filtros de la barra a una consulta de entornos.
     *
     * **El `$excepto` es lo que hace posible que cada desplegable ofrezca solo lo que hay
     * en lo filtrado.** Las opciones de «plataforma» se calculan con todos los filtros
     * puestos **menos el de plataforma**: si se calcularan con el suyo incluido, al elegir
     * Goodle la única opción ofrecida sería Goodle y no habría forma de cambiar de valor
     * sin limpiar el filtro. Es la regla de cualquier buscador por facetas, y es la
     * diferencia entre acotar y quedarse encerrado.
     *
     * Antes esta cadena vivía suelta dentro de `render()`, así que **no se podía reutilizar
     * para nada más**: las listas de opciones se sacaban del catálogo entero y ofrecían
     * versiones y productos que no tenía ningún entorno de los que se estaban viendo.
     *
     * @param  string|list<string>|null  $excepto  Los filtros que NO se aplican. Son los
     *        mismos nombres que llevan en la URL. Acepta varios porque medir el hueco
     *        que dejan los dos recortes del defecto —locales e inactivos— exige quitar
     *        los dos a la vez; ver `ocultos()`.
     */
    private function aplicarFiltros(\Illuminate\Database\Eloquent\Builder $query, string|array|null $excepto = null): \Illuminate\Database\Eloquent\Builder
    {
        $excepto = $excepto === null ? [] : (array) $excepto;

        return $query
        ->when(! in_array('busqueda', $excepto, true) && $this->search !== '', function ($query) {
            $term = '%' . trim($this->search) . '%';
            $query->where(function ($q) use ($term) {
                $q->where('name', 'like', $term)
                    ->orWhere('domain', 'like', $term)
                    ->orWhereHas('client', function ($subQuery) use ($term) {
                        $subQuery->where('name', 'like', $term);
                    });
            });
        })
        ->when(! in_array('env', $excepto, true), function ($query) {
            $query->whereIn('env', $this->envsElegidos());
        })
        ->when(! in_array('sin_licencia', $excepto, true) && $this->onlyWithoutLicense, function ($query) {
            $query->whereNull('license_token_id');
        })
        ->when(! in_array('estado', $excepto, true) && $this->activeFilter !== '', function ($query) {
            $query->where('active', $this->activeFilter === 'si');
        })
        ->when(! in_array('sin_sync', $excepto, true) && $this->onlyNeverSynced, function ($query) {
            $query->whereNull('refresh_at');
        })
        ->when(! in_array('tipo', $excepto, true) && $this->typeFilter !== null, function ($query) {
            $query->where('type_id', $this->typeFilter);
        })
        ->when(! in_array('sin_senal', $excepto, true) && $this->onlyWithoutSignal, function ($query) {
            // Igual que el resumen: han dado señal y llevan 3 días sin darla.
            $query->whereNotNull('refresh_at')
                ->where('refresh_at', '<', now()->subDays(3));
        })
        ->when(! in_array('sin_frescos', $excepto, true) && $this->onlyStale, function ($query) {
            // Lo mismo que cuenta el KPI: nunca han hablado, o hace tres días o más.
            $query->where(fn ($q) => $q->whereNull('refresh_at')
                ->orWhere('refresh_at', '<', now()->subDays(3)));
        })
        ->when(! in_array('por_revisar', $excepto, true) && $this->onlyPendingReview, function ($query) {
            // **Solo el filtro.** El orden por nivel vive en el listado y no aquí: este
            // constructor lo reutilizan las consultas agregadas de las facetas —`select
            // type_id, count(*) … group by type_id`— y un `ORDER BY` por una columna que no
            // está en el `GROUP BY` revienta en MySQL con `only_full_group_by`. Ver
            // `ordenDelListado()`.
            $query->whereNotNull('review_flagged_at');
        })
        ->when(! in_array('sin_soporte', $excepto, true) && $this->onlyWithoutSupport, function ($query) {
            // Sin ninguna fila en `environment_support`, no solo sin el puntero al
            // vigente: ese estaba a NULL en toda la base antes de MGR-045, así que
            // mirarlo daría por 'sin soporte' a quien sí lo tuvo.
            $query->whereDoesntHave('supports');
        })
        ->when(! in_array('soporte_caducado', $excepto, true) && $this->onlyExpiredSupport, function ($query) {
            $query->whereHas('soporteVigente', fn ($q) => $q->whereDate('end_at', '<', now()->format('Y-m-d')));
        })
        ->when(! in_array('core_mayor', $excepto, true) && $this->onlyCoreMajorUpdates, function ($query) {
            $query->whereNotNull('lastversion');
        })
        ->when(! in_array('core_menor', $excepto, true) && $this->onlyCoreMinorUpdates, function ($query) {
            $query->whereNotNull('lastminor');
        })
        ->when(! in_array('plugins_viejos', $excepto, true) && $this->onlyEnvironmentsWithOutdatedPlugins, function ($query) {
            $query->whereHas('plugins', function ($pluginQuery) {
                $pluginQuery->where('has_updates', true);
            });
        })
        // Un `whereHas` POR COMPONENTE, no un `whereIn`: con varios elegidos se
        // buscan los entornos que tienen TODOS —«los que llevan el SEPE y el
        // ImportGC»—. Un `whereIn` daría los que tienen cualquiera de ellos, que es
        // otra pregunta y además siempre devuelve más filas al añadir criterios, lo
        // contrario de lo que espera quien filtra.
        ->when(! in_array('plugin', $excepto, true) && $this->pluginComponents !== [], function ($query) {
            foreach ($this->pluginComponents as $componente) {
                $query->whereHas('plugins', fn ($q) => $q->where('component', $componente));
            }
        })
        ->when(! in_array('version', $excepto, true) && $this->versionFilter !== null && $this->versionFilter !== '', function ($query) {
            // Familia: el 4.1 incluye 4.1.11, 4.1.13… y también un '4.1' pelado.
            $query->where(function ($q) {
                $q->where('version', 'like', $this->versionFilter . '.%')
                    ->orWhere('version', $this->versionFilter);
            });
        })
        ->when(! in_array('sin_token', $excepto, true) && $this->onlyWithoutWsToken, function ($query) {
            // Cadena vacía además de NULL: el token es una columna cifrada y un
            // guardado en blanco deja '', que tampoco sirve para sincronizar.
            $query->where(fn ($q) => $q->whereNull('moodletoken')->orWhere('moodletoken', ''));
        })
        ->when(! in_array('producto', $excepto, true) && $this->productFilter !== null, function ($query) {
            $query->whereHas('licenseToken.products', function ($productQuery) {
                $productQuery->where('products.id', $this->productFilter);
            });
        });
    }
    public function render()
    {
        // `soporteVigente.support` precargado: sin esto cada tarjeta haría sus dos
        // consultas para pintar el estado del soporte. Ver MGR-015.
        $environments = Environment::with(['client', 'type', 'licenseToken.products', 'soporteVigente.support',
            // `data` la usa la tarjeta para el `+` de la release y el build: sin
            // precargarla son 20 consultas más, una por fila.
            'data',
            // `deactivatedBy` lo pinta la fila de los apagados: sin precargarlo es una
            // consulta por entorno apagado.
            'deactivatedBy',
            // Y quién anotó la observación, que la tarjeta pone en el `title`: mismo
            // motivo, una consulta por entorno marcado.
            'reviewFlaggedBy'])
            ->withCount([
                'plugins as outdated_plugins_count' => function ($pluginQuery) {
                    $pluginQuery->where('has_updates', true);
                },
                // **Los ausentes del disco, en el mismo `withCount`**: son dos subconsultas
                // en la misma pasada, no dos consultas por fila. La regla la define
                // `Plugin::scopeAusenteDelDisco()`, que es la que usan también el inventario
                // del entorno y el parque.
                'plugins as missing_plugins_count' => function ($pluginQuery) {
                    $pluginQuery->ausenteDelDisco();
                },
            ])
            // **Cuándo tocó una persona este entorno**, para el orden «última edición».
            //
            // Sale del registro de acciones del panel y **no de `updated_at`**, que es lo
            // primero que uno piensa y no sirve: `SyncAction` escribe en la fila del
            // entorno en cada sincronización, así que `updated_at` se mueve con cada
            // `sync` y ordenar por él daría casi la misma lista que ordenar por
            // sincronización. Dos botones para lo mismo.
            //
            // Va como subconsulta en el `select` y no como `join`: devuelve una fila por
            // entorno, así que no multiplica el resultado, y sigue siendo **una sola
            // consulta** —esta pantalla tiene un test que las cuenta—.
            //
            // Su límite, que conviene saberlo: el registro del panel **se puede purgar por
            // fecha**, así que una edición anterior a la última limpieza ya no consta y ese
            // entorno se va al final. Es preferible a inventarle una fecha.
            ->addSelect(['ultima_edicion' => \Illuminate\Support\Facades\DB::table('logs')
                ->select('created_at')
                ->whereColumn('entity_id', 'environments.id')
                ->where('entity', 'Environment')
                ->whereNull('deleted_at')
                ->orderByDesc('created_at')
                ->limit(1)])
            ->tap(fn ($q) => $this->aplicarFiltros($q))
            ->tap(fn ($q) => $this->ordenDelListado($q))
            ->paginate(20);

        return view('livewire.environments.index', [
            'environments' => $environments,
            'resumen' => $this->resumen(),
            'composicion' => $this->composicion(),
            // El total del parque, para el "N de M": una lista filtrada se lee como si
            // fuera todo si no se dice de cuántos sale.
            'totalDelParque' => Cache::remember('environments:total', 60, fn () => Environment::count()),
            'hayFiltros' => $this->hayFiltros(),
            // Normalizado, no la propiedad cruda: si la URL trae un orden que no existe,
            // la lista se pinta con el de por defecto y los botones tienen que decir el
            // mismo, o señalarían uno que no es el que se está viendo.
            'ordenActual' => $this->ordenValido(),
            'filtrosAvanzados' => $this->filtrosAvanzados(),
            // **Los usos, del catálogo y no de una lista escrita en la plantilla.** Los
            // botones se pintan con el nombre y el color de cada fila del catálogo, así que
            // un uso nuevo aparece en el filtro sin tocar la vista. Y `envsPuestos` es la
            // selección resuelta: `$env` puede ser `''`, que significa «el defecto».
            'usos' => Env::catalogo(),
            'envsPuestos' => $this->envsElegidos(),
            // **Las tres listas salen de lo filtrado, no del catálogo.** Antes se ofrecían
            // versiones y productos que no tenía ningún entorno de los que se estaban
            // viendo, y elegirlos llevaba a una lista vacía. Ver `paraOfrecer()`.
            'productos' => $this->productosDisponibles(),
            'tipos' => $this->tiposDisponibles(),
            'familias' => $this->familiasDisponibles(),
            // Lo que el defecto está ocultando, para poder decirlo en la barra.
            'ocultos' => $this->ocultos(),
        ])->layout('layouts.app');
    }
}

