<?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;

    /**
     * La clave del proyecto en Jira (PRODSECU-182). Exacta: es un código, no un texto que
     * se busque a trozos —para eso está el buscador, que también la mira—.
     */
    #[Url(as: 'proyecto', except: '')]
    public string $proyecto = '';

    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();
    }

    public function updatedProyecto(): 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', 'proyecto',
        ]);

        $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->proyecto !== '',
            $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->proyecto !== ''
            || $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}>
     */
    /**
     * Las claves de proyecto de Jira presentes en lo filtrado, con cuántos entornos
     * (PRODSECU-182). Sale del mismo `group by` que las plataformas: es una columna del
     * entorno.
     *
     * @return array<string, int>
     */
    public function proyectosDisponibles(): array
    {
        return $this->paraOfrecer('proyecto')
            ->whereNotNull('key')
            ->where('key', '!=', '')
            // `key` es palabra reservada en MySQL: `select()` y `groupBy()` la entrecomillan
            // con la gramática de cada motor, un `selectRaw` no.
            ->select('key')
            ->selectRaw('count(*) as cuantos')
            ->groupBy('key')
            ->orderBy('key')
            ->pluck('cuantos', 'key')
            ->map(fn ($n) => (int) $n)
            ->all();
    }

    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)
                    // Por clave de proyecto de Jira (PRODSECU-182).
                    ->orWhere('key', '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);
            });
        })
        ->when(! in_array('proyecto', $excepto, true) && $this->proyecto !== '', function ($query) {
            $query->where('key', $this->proyecto);
        });
    }
    /**
     * La consulta del listado, con sus filtros y su orden.
     *
     * **Una sola, para la pantalla y para la descarga** (PRODSECU-153): lo que se descarga es
     * exactamente lo que se ve, filtros incluidos, y no puede desincronizarse.
     */
    private function consultaDelListado(): \Illuminate\Database\Eloquent\Builder
    {
        // `soporteVigente.support` precargado: sin esto cada tarjeta haría sus dos
        // consultas para pintar el estado del soporte. Ver MGR-015.
        return 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)])
            // **Si se puede borrar**, para el candado del menú de cada fila: una columna más en
            // esta misma consulta, no nueve consultas por entorno. Ver `EntornoBorrable`.
            ->addSelect(['esta_limpio' => \App\Support\EntornoBorrable::subconsultaEstaLimpio()])
            ->tap(fn ($q) => $this->aplicarFiltros($q))
            ->tap(fn ($q) => $this->ordenDelListado($q));
    }

    /**
     * El listado en CSV, con los filtros que haya puestos (PRODSECU-153).
     *
     * **Lo que no sale, y por qué**:
     *
     * - `moodletoken`, el token de servicios web del Moodle del cliente: es una credencial suya
     *   y no se enseña ni en el panel (MGR-038).
     * - El **valor** del token de licencia: con él cualquiera consume la API como ese cliente.
     *   Sale el nombre de la licencia, que es con lo que se la reconoce.
     * - `login_url`, la entrada de soporte de cada sitio (`?saml=off` y similares).
     * - Las notas de texto libre: la observación y el motivo del apagado.
     */
    public function descargar(): \Symfony\Component\HttpFoundation\StreamedResponse
    {
        $this->authorize('admin.environments.index');

        $cabeceras = [
            'ID', 'Nombre', 'Dominio', 'Cliente', 'Uso', 'Tipo', 'Activo', 'Apagado el',
            'Versión de Moodle', 'versiondb', 'Última versión disponible', 'Última de su rama',
            'Clave Jira', 'Licencia', 'Productos', 'Soporte vigente', 'Soporte hasta',
            'Plugins con actualización', 'Plugins ausentes del disco', 'Última sincronización',
            'Alta en el Manager',
        ];

        $filas = $this->consultaDelListado()->get()->map(fn (Environment $e) => [
            $e->id,
            $e->name,
            $e->domain,
            $e->client?->name,
            $e->env,
            $e->type?->name,
            (bool) $e->active,
            $e->deactivated_at?->format('Y-m-d H:i'),
            $e->version,
            $e->versiondb,
            $e->lastversion,
            $e->lastminor,
            $e->key,
            $e->licenseToken?->name,
            $e->licenseToken?->products->pluck('name')->unique()->implode(', '),
            $e->soporteVigente?->support?->name,
            $e->soporteVigente?->end_at?->format('Y-m-d'),
            (int) $e->outdated_plugins_count,
            (int) $e->missing_plugins_count,
            $e->refresh_at?->format('Y-m-d H:i'),
            $e->created_at?->format('Y-m-d'),
        ]);

        return \App\Support\ExportacionCsv::descargar('entornos', $cabeceras, $filas, $this->filtrosParaElRastro());
    }

    /**
     * Los filtros puestos, para el rastro de la descarga. Solo las propiedades públicas que
     * van en la URL, que son las que deciden qué se ve.
     *
     * @return array<string, mixed>
     */
    private function filtrosParaElRastro(): array
    {
        $filtros = [];

        foreach ((new \ReflectionClass($this))->getProperties(\ReflectionProperty::IS_PUBLIC) as $propiedad) {
            if ($propiedad->getAttributes(\Livewire\Attributes\Url::class) !== []) {
                $filtros[$propiedad->getName()] = $propiedad->getValue($this);
            }
        }

        return $filtros;
    }

    public function render()
    {
        $environments = $this->consultaDelListado()->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(),
            'proyectos' => $this->proyectosDisponibles(),
            'tipos' => $this->tiposDisponibles(),
            'familias' => $this->familiasDisponibles(),
            // Lo que el defecto está ocultando, para poder decirlo en la barra.
            'ocultos' => $this->ocultos(),
        ])->layout('layouts.app');
    }
}

