<?php

namespace Tests\Feature\Plugins;

use App\Livewire\Layout\Navigation;
use App\Livewire\Plugins\Core;
use App\Livewire\Plugins\Index;
use App\Livewire\Plugins\Inventario;
use App\Livewire\Plugins\Nuestros;
use App\Livewire\Plugins\SinInstalar;
use App\Models\Auth\User;
use App\Models\Clients\Client;
use App\Models\Environments\Environment;
use App\Models\Environments\Plugin;
use App\Models\Products\LicenseToken;
use App\Models\Products\OwnPlugin;
use App\Models\Products\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/**
 * Plugins del parque — el agregado de todos los entornos.
 *
 * Lo que se fija: que **los números digan sobre cuántos entornos se han calculado**, que
 * la divergencia de versiones solo se presente como problema donde lo es, que el core de
 * Moodle distinga «le faltan parches» de «hay un salto de rama» —porque el arreglo y la
 * urgencia son distintos— y que a la pantalla se pueda llegar.
 */
class ParqueTest extends TestCase
{
    use RefreshDatabase;

    private function admin(array $extra = []): User
    {
        $rol = Role::firstOrCreate(['name' => 'Admin', 'guard_name' => 'web']);

        foreach (array_merge([
            'admin.plugins.environment', 'admin.environments.index',
            'admin.clients.index', 'admin.configs.index', 'admin.dashboard.index',
        ], $extra) as $nombre) {
            $rol->givePermissionTo(Permission::firstOrCreate(
                ['name' => $nombre, 'guard_name' => 'web'],
                ['model' => 'plugins', 'description' => $nombre]
            ));
        }

        $user = User::factory()->create();
        $user->assignRole($rol);
        $this->actingAs($user);

        Cache::flush();

        return $user;
    }

    private function entorno(string $nombre, array $atributos = []): Environment
    {
        $cliente = Client::firstOrCreate(
            ['shortname' => 'pq'],
            ['name' => 'Cliente Parque', 'actived' => true]
        );

        return Environment::create(array_merge([
            'name' => $nombre,
            'domain' => 'https://' . str()->slug($nombre) . '.pq.test',
            'env' => 'pro',
            'version' => '5.1.1',
            'client_id' => $cliente->id,
            'active' => true,
            'refresh_at' => now()->subHour(),
        ], $atributos));
    }

    /**
     * La telemetría del sitio: de aquí sale la versión completa con su build y **cuándo
     * comprobó actualizaciones**, que es lo que explica un «sin datos».
     */
    private function telemetria(Environment $entorno, array $atributos = []): \App\Models\Environments\Data
    {
        return \App\Models\Environments\Data::create(array_merge([
            'environment_id' => $entorno->id,
            'moodlerelease' => '5.1.1+ (Build: 20251219)',
            'availableupdatesfetch' => now()->subHours(6)->timestamp,
            'users' => 1648,
            'courses' => 66,
        ], $atributos));
    }

    private function plugin(Environment $entorno, array $atributos): Plugin
    {
        return Plugin::create(array_merge([
            'environment_id' => $entorno->id,
            'name' => 'Un plugin',
            'component' => 'mod_algo',
            'type' => 'mod',
            'versiondisk' => '2026010100',
            'versiondb' => '2026010100',
            // NOT NULL en la tabla.
            'versionrequires' => '2025041400',
            'release' => '1.0.0',
            'has_updates' => false,
            'has_dependencies' => false,
        ], $atributos));
    }

    /* ===================== Los entornos apagados no cuentan ===================== */

    public function test_un_entorno_apagado_no_aporta_su_inventario_al_parque(): void
    {
        // **El sitio dado de baja conserva su último inventario**, así que sin recorte
        // sigue aportando componentes al parque meses después de dejar de existir.
        $this->admin();

        $vivo = $this->entorno('Vivo');
        $apagado = $this->entorno('Apagado', ['active' => false]);

        $this->plugin($vivo, ['component' => 'mod_vivo']);
        $this->plugin($apagado, ['component' => 'mod_muerto']);

        $componentes = collect(Livewire::test(Index::class)->viewData('resumen'));

        $this->assertSame(1, $componentes['componentes'], 'solo el del entorno en servicio');
    }

    public function test_un_entorno_apagado_no_inventa_una_divergencia_de_versiones(): void
    {
        // **Es el caso que más engaña.** Dos sitios con la misma versión y un tercero de
        // baja con la vieja se leen como «hay dos versiones distintas de este plugin en el
        // parque», que es un aviso y manda a investigar una divergencia que no existe.
        $this->admin();

        $uno = $this->entorno('Uno');
        $otro = $this->entorno('Otro');
        $baja = $this->entorno('De baja', ['active' => false]);

        // «Lo nuestro» sale del catálogo de plugins propios, no del nombre del componente.
        OwnPlugin::create(['component' => 'local_tresipunt', 'name' => 'Orquestador 3iPunt']);

        foreach ([$uno, $otro] as $entorno) {
            $this->plugin($entorno, ['component' => 'local_tresipunt', 'versiondisk' => '2026090200']);
        }
        $this->plugin($baja, ['component' => 'local_tresipunt', 'versiondisk' => '2024010100']);

        $fila = collect(Livewire::test(Nuestros::class)->viewData('nuestros'))
            ->firstWhere('component', 'local_tresipunt');

        $this->assertSame(1, $fila['versiones'], 'una sola versión entre los sitios en servicio');
        $this->assertSame(2, $fila['entornos']);
    }

    public function test_la_cobertura_no_cuenta_los_apagados_en_ninguno_de_los_dos_lados(): void
    {
        // El numerador y el denominador tienen que ser del mismo conjunto: «1 de 2»
        // contando el apagado en el 2 diría que falta un sitio que no se está mirando.
        $this->admin();

        $vivo = $this->entorno('Vivo');
        $this->entorno('Apagado', ['active' => false]);

        $this->plugin($vivo, ['component' => 'mod_vivo']);

        $cobertura = Livewire::test(Index::class)->viewData('cobertura');

        $this->assertSame(1, $cobertura['envian']);
        $this->assertSame(1, $cobertura['total']);
        $this->assertFalse($cobertura['parcial'], 'el único sitio en servicio sí envía');
    }

    public function test_un_apagado_y_borrado_sigue_apagado(): void
    {
        // Sin `withTrashed()` en la lista de apagados, el ámbito de `SoftDeletes` lo dejaría
        // fuera del recorte y su inventario volvería a contarse **justo en el caso más
        // claro de sitio que ya no se atiende**.
        $this->admin();

        $vivo = $this->entorno('Vivo');
        $fuera = $this->entorno('Fuera', ['active' => false]);

        $this->plugin($vivo, ['component' => 'mod_vivo']);
        $this->plugin($fuera, ['component' => 'mod_fuera']);

        $fuera->delete();

        $resumen = Livewire::test(Index::class)->viewData('resumen');

        $this->assertSame(1, $resumen['componentes']);
    }

    public function test_la_pantalla_dice_cuantos_apagados_esta_dejando_fuera(): void
    {
        // **Un recorte que no se dice hace que un parque corto se lea como «ya no hay casi
        // nada instalado».** Es la misma norma que en el visor de peticiones.
        $this->admin();

        $vivo = $this->entorno('Vivo');
        $apagado = $this->entorno('Apagado', ['active' => false]);

        $this->plugin($vivo, ['component' => 'mod_vivo']);
        $this->plugin($apagado, ['component' => 'mod_muerto']);

        Livewire::test(Index::class)
            ->assertViewHas('apagadosOcultos', 1)
            ->assertSee('1 entorno apagado con inventario no se está contando');
    }

    public function test_un_apagado_sin_inventario_no_se_cuenta_como_escondido(): void
    {
        // No cambia ninguna cifra de esta pantalla, así que avisar de él sería alarmar por
        // algo que no afecta a lo que se está mirando.
        $this->admin();

        $vivo = $this->entorno('Vivo');
        $this->entorno('Apagado sin nada', ['active' => false]);

        $this->plugin($vivo, ['component' => 'mod_vivo']);

        Livewire::test(Index::class)->assertViewHas('apagadosOcultos', 0);
    }

    public function test_con_el_interruptor_puesto_los_apagados_vuelven_a_contar(): void
    {
        $this->admin();

        $vivo = $this->entorno('Vivo');
        $apagado = $this->entorno('Apagado', ['active' => false]);

        $this->plugin($vivo, ['component' => 'mod_vivo']);
        $this->plugin($apagado, ['component' => 'mod_muerto']);

        $componente = Livewire::test(Index::class)->set('incluirApagados', true);

        $this->assertSame(2, $componente->viewData('resumen')['componentes']);
        // Y se dice que se están contando, que es lo que explica un número más alto.
        $componente->assertSee('Se están contando también los entornos apagados');
    }

    public function test_el_recorte_va_en_la_url_y_no_ensucia_el_enlace_por_defecto(): void
    {
        $this->admin();
        $this->entorno('Vivo');

        Livewire::test(Index::class)
            ->assertSet('incluirApagados', false)
            ->set('incluirApagados', true)
            ->assertSet('incluirApagados', true);
    }

    public function test_quitar_los_filtros_devuelve_el_recorte_a_su_sitio(): void
    {
        // Incluir los apagados es **ampliar**, no filtrar: «quitar los filtros» tiene que
        // dejar la pantalla como se abre.
        $this->admin();
        $this->entorno('Vivo');

        Livewire::test(Index::class)
            ->set('incluirApagados', true)
            ->call('limpiarFiltros')
            ->assertSet('incluirApagados', false);
    }

    public function test_las_ramas_de_moodle_solo_ofrecen_las_que_hay_sin_los_apagados(): void
    {
        // **Es lo que se pidió del filtro de versiones.** Una rama que solo tienen sitios
        // apagados lleva a una tabla vacía sin decir por qué: el desplegable ofrecería algo
        // que ninguna fila visible tiene.
        $this->admin();

        $this->entorno('Vivo', ['version' => '5.1.1']);
        $this->entorno('Antiguo apagado', ['version' => '3.9.2', 'active' => false]);

        $ramas = collect(Livewire::test(Core::class)->viewData('ramas'))->pluck('rama');

        $this->assertContains('5.1', $ramas->all());
        $this->assertNotContains('3.9', $ramas->all(), 'esa rama solo la tiene un sitio apagado');

        // Y con el interruptor puesto vuelve a ofrecerse.
        $conApagados = collect(
            Livewire::test(Core::class)->set('incluirApagados', true)->viewData('ramas')
        )->pluck('rama');

        $this->assertContains('3.9', $conApagados->all());
    }

    public function test_no_se_reclama_un_producto_a_un_sitio_apagado(): void
    {
        // «Este cliente tiene licencia y no ha instalado el plugin» es una pregunta
        // comercial, y de un sitio dado de baja la respuesta no interesa: nadie va a
        // llamarle para que lo instale.
        $this->admin();

        $apagado = $this->entorno('Apagado', ['active' => false]);
        $this->plugin($apagado, ['component' => 'mod_algo']);

        $token = LicenseToken::create([
            'client_id' => $apagado->client_id,
            'name' => 'Licencia',
            'token' => '3IP-PARQUE-APAGADO',
            'active' => true,
        ]);

        $producto = Product::create([
            'name' => 'Fresk',
            'slug' => 'theme_fresk',
            'actived' => true,
        ]);

        $token->products()->attach($producto->id);
        $apagado->update(['license_token_id' => $token->id]);

        $this->assertSame([], Livewire::test(SinInstalar::class)->viewData('faltantes'));
    }

    /* ===================== Que se llegue ===================== */

    public function test_la_pantalla_esta_en_el_menu(): void
    {
        // **El arreglo de fondo.** La pantalla existía, funcionaba y no había un solo
        // enlace en todo el panel que llevara a ella: se llegaba escribiendo la URL. Es el
        // séptimo caso de este release, así que el test va antes que los de contenido.
        $this->admin();

        $rutas = collect(Livewire::test(Navigation::class)->get('menuItems'))->pluck('route');

        $this->assertContains('plugins.index', $rutas);
    }

    public function test_quien_no_tiene_el_permiso_no_ve_la_entrada_ni_la_pantalla(): void
    {
        $rol = Role::firstOrCreate(['name' => 'Pelado', 'guard_name' => 'web']);
        $rol->givePermissionTo(Permission::firstOrCreate(
            ['name' => 'admin.dashboard.index', 'guard_name' => 'web'],
            ['model' => 'dashboard', 'description' => 'ver']
        ));

        $user = User::factory()->create();
        $user->assignRole($rol);
        $this->actingAs($user);
        Cache::flush();

        $rutas = collect(Livewire::test(Navigation::class)->get('menuItems'))->pluck('route');
        $this->assertNotContains('plugins.index', $rutas);

        $this->get(route('plugins.index'))->assertForbidden();
    }

    /* ===================== Sobre cuántos entornos ===================== */

    public function test_la_pantalla_dice_sobre_cuantos_entornos_esta_calculando(): void
    {
        // **Es la primera cosa que cambia cómo se leen los números.** Un «0 con
        // actualización» se lee como «el parque está al día», y con 2 de 3 entornos
        // callados no significa eso.
        $this->admin();

        $conInventario = $this->entorno('Con inventario');
        $this->entorno('Callado uno');
        $this->entorno('Callado dos');
        $this->plugin($conInventario, ['component' => 'mod_forum']);

        $componente = Livewire::test(Index::class);

        $this->assertSame(
            ['envian' => 1, 'total' => 3, 'sin' => 2, 'parcial' => true, 'vacio' => false],
            $componente->viewData('cobertura')
        );

        // **Va donde están los números que condiciona**, y no en la portada: allí eran
        // cuatro líneas de prosa antes de la primera cifra, o sea el ruido del que
        // pretendían proteger. Pegado al recuento de componentes sí cambia cómo se lee.
        Livewire::test(Inventario::class)
            ->assertSee('según el inventario de')
            ->assertSee('1 de 3');

        // Y en la pantalla del inventario, lo mismo pegado a la cuenta de componentes.
        Livewire::test(Inventario::class)
            ->assertSee('según el inventario de')
            ->assertSee('1 de 3');
    }

    public function test_los_entornos_callados_salen_como_aviso_y_no_en_silencio(): void
    {
        $this->admin();

        $con = $this->entorno('Habla');
        $this->entorno('Calla');
        $this->plugin($con, ['component' => 'mod_forum']);

        $avisos = collect(Livewire::test(Index::class)->viewData('atencion'));
        $parcial = $avisos->firstWhere('etiqueta', 'Inventario parcial');

        $this->assertNotNull($parcial);
        $this->assertStringContainsString('1 de 2', $parcial['texto']);
    }

    public function test_si_nadie_envia_inventario_no_se_finge_un_agregado(): void
    {
        // El estado de partida real: 18 de 21 entornos no envían nada. Enseñar «0
        // componentes, 0 con actualización» sería una pantalla que miente en cuatro
        // cifras a la vez.
        $this->admin();
        $this->entorno('Nadie habla');

        $componente = Livewire::test(Index::class);

        $this->assertTrue($componente->viewData('cobertura')['vacio']);
        $componente->assertSee('Todavía no hay inventario de ningún entorno');
        $componente->assertDontSee('Todo el inventario');
    }

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

    public function test_un_sitio_por_debajo_de_su_rama_es_lo_urgente(): void
    {
        // **Es lo que se perdió del Manager antiguo y lo que más se echaba de menos.** Las
        // versiones menores son parches, seguridad incluida, y no cambian de rama: la
        // actualización de menos riesgo y más urgencia.
        $this->admin();

        $entorno = $this->entorno('Sin parches', [
            'version' => '5.1.1',
            'lastminor' => '5.1.6',
            'lastversion' => '5.2.2',
        ]);
        $this->plugin($entorno, ['component' => 'mod_forum']);

        $componente = Livewire::test(Core::class);
        $core = collect($componente->viewData('core'))->firstWhere('entorno.id', $entorno->id);

        $this->assertSame('menor', $core['estado']);
        $this->assertSame(1, $componente->viewData('resumenCore')['menor']);

        // Y sale como crítico, no como informativo: es el criterio de la pantalla.
        $aviso = collect($componente->viewData('atencion'))->firstWhere('etiqueta', 'Core sin parches');
        $this->assertNotNull($aviso);
        $this->assertSame('crit', $aviso['nivel']);
    }

    public function test_un_salto_de_rama_no_se_presenta_como_urgente(): void
    {
        // Cambiar de rama se planifica —hay que comprobar que los plugins del sitio la
        // soporten—, así que mezclarlo con los parches mandaría al arreglo equivocado.
        // Es la lección de MGR-066.
        $this->admin();

        $entorno = $this->entorno('Rama vieja', [
            'version' => '5.1.6',
            'lastminor' => '5.1.6',
            'lastversion' => '5.2.2',
        ]);
        $this->plugin($entorno, ['component' => 'mod_forum']);

        $componente = Livewire::test(Core::class);
        $core = collect($componente->viewData('core'))->firstWhere('entorno.id', $entorno->id);

        $this->assertSame('mayor', $core['estado']);

        $aviso = collect($componente->viewData('atencion'))->firstWhere('etiqueta', 'Core con mayor disponible');
        $this->assertSame('info', $aviso['nivel']);
        $this->assertSame(0, $componente->viewData('resumenCore')['menor']);
    }

    public function test_las_versiones_se_comparan_como_versiones_y_no_como_texto(): void
    {
        // `5.1.10` es posterior a `5.1.9` y como cadena es anterior. Comparar con `<` a
        // secas daría por atrasado un sitio que está al día, y al revés.
        $this->admin();

        $entorno = $this->entorno('Decimal', [
            'version' => '5.1.10',
            'lastminor' => '5.1.9',
            'lastversion' => '5.1.9',
        ]);
        $this->plugin($entorno, ['component' => 'mod_forum']);

        $core = collect(Livewire::test(Core::class)->viewData('core'))->firstWhere('entorno.id', $entorno->id);

        $this->assertSame('al-dia', $core['estado']);
    }

    public function test_un_sitio_que_no_comprueba_actualizaciones_no_se_da_por_al_dia(): void
    {
        // Sin `lastversion` ni `lastminor` no sabemos nada de ese sitio. Pintarlo en verde
        // sería afirmar más de lo que se sabe: 17 de 20 entornos están así.
        $this->admin();

        $entorno = $this->entorno('Mudo', ['version' => '4.5.1', 'lastminor' => null, 'lastversion' => null]);
        $this->plugin($entorno, ['component' => 'mod_forum']);

        $componente = Livewire::test(Core::class);
        $core = collect($componente->viewData('core'))->firstWhere('entorno.id', $entorno->id);

        $this->assertSame('sin-datos', $core['estado']);
        $this->assertSame(0, $componente->viewData('resumenCore')['aldia']);
        $componente->assertSee('No lo ha comprobado');
    }

    public function test_el_core_ordena_por_lo_que_hay_que_hacer_no_por_nombre(): void
    {
        $this->admin();

        $aldia = $this->entorno('Aaa al dia', ['version' => '5.1.6', 'lastminor' => '5.1.6', 'lastversion' => '5.1.6']);
        $sinParches = $this->entorno('Zzz sin parches', ['version' => '5.1.1', 'lastminor' => '5.1.6', 'lastversion' => '5.1.6']);
        $this->plugin($aldia, ['component' => 'mod_forum']);

        $core = collect(Livewire::test(Core::class)->viewData('core'));

        $this->assertSame($sinParches->id, $core->first()['entorno']->id);
    }

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

    public function test_el_orquestador_va_primero_aunque_alfabeticamente_no_toque(): void
    {
        // **No es un plugin nuestro más.** `local_tresipunt` hace todas las llamadas a la
        // API: si falta o va viejo en un sitio, ese sitio deja de recibir licencias y de
        // enviar datos, y ninguna otra pantalla lo explicaría.
        $this->admin();

        $entorno = $this->entorno('Con los nuestros');
        OwnPlugin::create(['component' => 'block_tresipuntsepe', 'name' => 'AAA SEPE']);
        OwnPlugin::create(['component' => OwnPlugin::ORQUESTADOR, 'name' => 'ZZZ 3iPunt']);
        $this->plugin($entorno, ['component' => 'block_tresipuntsepe', 'type' => 'block']);
        $this->plugin($entorno, ['component' => OwnPlugin::ORQUESTADOR, 'type' => 'local']);

        $nuestros = Livewire::test(Nuestros::class)->viewData('nuestros');

        $this->assertSame(OwnPlugin::ORQUESTADOR, $nuestros[0]['component']);
        $this->assertTrue($nuestros[0]['orquestador']);
    }

    public function test_si_el_orquestador_falta_donde_hay_inventario_es_critico(): void
    {
        // Un entorno que envía inventario y no declara el orquestador está incomunicado, y
        // eso es más grave que cualquier plugin desactualizado.
        $this->admin();

        $conOrquestador = $this->entorno('Habla bien');
        $sinOrquestador = $this->entorno('Habla a medias');
        OwnPlugin::create(['component' => OwnPlugin::ORQUESTADOR]);
        $this->plugin($conOrquestador, ['component' => OwnPlugin::ORQUESTADOR, 'type' => 'local']);
        $this->plugin($sinOrquestador, ['component' => 'mod_forum']);

        $aviso = collect(Livewire::test(Index::class)->viewData('atencion'))
            ->firstWhere('etiqueta', 'Orquestador ausente');

        $this->assertNotNull($aviso);
        $this->assertSame('crit', $aviso['nivel']);
        $this->assertStringContainsString('1 entorno', $aviso['texto']);
    }

    public function test_un_producto_del_catalogo_cuenta_como_nuestro_sin_declararlo_aparte(): void
    {
        // Los dos catálogos suman: `own_plugins` y el `slug` de Productos. Es la regla que
        // ya fija `OwnPlugin::sonNuestros()`, y aquí se comprueba que esta pantalla la usa
        // y no reimplementa la suya.
        $this->admin();

        $entorno = $this->entorno('Con producto');
        Product::create(['slug' => 'report_fundae', 'name' => 'Report Fundae', 'tech' => 'moodle', 'actived' => true, 'enabled' => true]);
        $this->plugin($entorno, ['component' => 'report_fundae', 'type' => 'report']);

        $nuestros = collect(Livewire::test(Nuestros::class)->viewData('nuestros'));

        $this->assertTrue($nuestros->contains('component', 'report_fundae'));
    }

    /* ===================== La divergencia de versiones ===================== */

    public function test_un_plugin_nuestro_con_versiones_distintas_es_un_aviso(): void
    {
        // **Es el dato que justifica la pantalla** y que antes no se podía responder: el
        // agregado agrupaba por `component` sin mirar la versión, así que un plugin en la
        // 1.8 en un cliente y en la 2.1 en otro se veía igual que uno homogéneo.
        $this->admin();

        $uno = $this->entorno('Cliente viejo');
        $otro = $this->entorno('Cliente nuevo');
        OwnPlugin::create(['component' => 'theme_fresk', 'name' => 'Fresk']);
        $this->plugin($uno, ['component' => 'theme_fresk', 'type' => 'theme', 'versiondisk' => '2024010100', 'versiondb' => '2024010100']);
        $this->plugin($otro, ['component' => 'theme_fresk', 'type' => 'theme', 'versiondisk' => '2026010100', 'versiondb' => '2026010100']);

        $componente = Livewire::test(Nuestros::class);

        $fila = collect($componente->viewData('nuestros'))->firstWhere('component', 'theme_fresk');
        $this->assertSame(2, $fila['versiones']);
        $this->assertSame(2, $fila['entornos']);

        $aviso = collect($componente->viewData('atencion'))->firstWhere('etiqueta', 'Nuestros desalineados');
        $this->assertNotNull($aviso);
        $this->assertStringContainsString('Fresk', $aviso['texto']);
    }

    public function test_un_plugin_de_moodle_con_versiones_distintas_no_es_un_aviso(): void
    {
        // **El hallazgo que corrige el criterio.** `mod_forum` lleva la versión del core,
        // así que en dos sitios con Moodle 3.12 y 5.1 divergen **por definición**. Tratarlo
        // como desalineación llenaría la pantalla de avisos que nadie puede arreglar, y
        // enterraría los que sí.
        $this->admin();

        $viejo = $this->entorno('Moodle viejo', ['version' => '3.12']);
        $nuevo = $this->entorno('Moodle nuevo', ['version' => '5.1.1']);
        $this->plugin($viejo, ['component' => 'mod_forum', 'versiondisk' => '2024110100', 'versiondb' => '2024110100']);
        $this->plugin($nuevo, ['component' => 'mod_forum', 'versiondisk' => '2025100600', 'versiondb' => '2025100600']);

        $componente = Livewire::test(Index::class);

        // El dato se cuenta —está en el KPI—…
        $this->assertSame(1, $componente->viewData('resumen')['divergentes']);
        // …pero no genera aviso, porque no hay nada que hacer con él.
        $etiquetas = collect($componente->viewData('atencion'))->pluck('etiqueta');
        $this->assertNotContains('Nuestros desalineados', $etiquetas);
    }

    public function test_lo_nuestro_desalineado_se_pone_por_delante_de_la_cola_larga(): void
    {
        $this->admin();

        $uno = $this->entorno('Uno');
        $otro = $this->entorno('Otro');
        OwnPlugin::create(['component' => 'theme_fresk', 'name' => 'Fresk']);
        $this->plugin($uno, ['component' => 'theme_fresk', 'type' => 'theme', 'versiondisk' => '2024010100', 'versiondb' => '2024010100']);
        $this->plugin($otro, ['component' => 'theme_fresk', 'type' => 'theme', 'versiondisk' => '2026010100', 'versiondb' => '2026010100']);
        // Un plugin de core que también diverge: no debe adelantar al nuestro.
        $this->plugin($uno, ['component' => 'mod_forum', 'versiondisk' => '2024110100', 'versiondb' => '2024110100']);
        $this->plugin($otro, ['component' => 'mod_forum', 'versiondisk' => '2025100600', 'versiondb' => '2025100600']);

        $filas = collect(Livewire::test(Inventario::class)->viewData('filas'))->values();

        $this->assertSame('theme_fresk', $filas[0]['component']);
    }

    /* ===================== La pregunta comercial ===================== */

    public function test_un_producto_licenciado_que_no_esta_instalado_se_señala(): void
    {
        // O el cliente no lo ha instalado —está pagando por algo que no usa— o lo tiene y
        // su Moodle no lo declara. Las dos cosas se hablan con el cliente.
        $this->admin();

        $cliente = Client::firstOrCreate(['shortname' => 'pq'], ['name' => 'Cliente Parque', 'actived' => true]);
        $producto = Product::create(['slug' => 'local_tresipuntmarket', 'name' => 'Marketplace', 'tech' => 'moodle', 'actived' => true, 'enabled' => true]);

        $token = LicenseToken::create([
            'token' => '3IP-AAAA-BBBB-CCCC-DDDD',
            'client_id' => $cliente->id,
            'actived' => true,
        ]);
        $token->products()->attach($producto->id);

        $entorno = $this->entorno('Con licencia', ['license_token_id' => $token->id]);
        // Envía inventario, pero el plugin licenciado no está en él.
        $this->plugin($entorno, ['component' => 'mod_forum']);

        $componente = Livewire::test(SinInstalar::class);
        $faltantes = $componente->viewData('faltantes');

        $this->assertCount(1, $faltantes);
        $this->assertSame('Marketplace', $faltantes[0]['productos']->first()->name);
        $componente->assertSee('Licenciado y sin instalar');
    }

    public function test_un_ecosistema_no_es_un_plugin_que_falte(): void
    {
        // **Fresk Premium no es un plugin: es un agrupamiento comercial.** Su slug
        // (`fresk_premium`) no empieza por ningún tipo de plugin de Moodle, así que no
        // puede aparecer en el inventario de ningún sitio — no existe como componente.
        // Listarlo aquí es ruido permanente: sale como «sin instalar» en todos los
        // clientes que lo tienen contratado, para siempre, y no hay nada que instalar.
        $this->admin();

        $cliente = Client::firstOrCreate(['shortname' => 'pq'], ['name' => 'Cliente Parque', 'actived' => true]);
        $ecosistema = Product::create(['slug' => 'fresk_premium', 'name' => 'Fresk Premium', 'tech' => 'moodle', 'type' => 'ecosistema', 'actived' => true, 'enabled' => true]);
        $plugin = Product::create(['slug' => 'local_tresipuntmarket', 'name' => 'Marketplace', 'tech' => 'moodle', 'actived' => true, 'enabled' => true]);

        $token = LicenseToken::create(['token' => '3IP-ECO1-ECO2-ECO3-ECO4', 'client_id' => $cliente->id, 'actived' => true]);
        $token->products()->attach([$ecosistema->id, $plugin->id]);

        $entorno = $this->entorno('Con las dos cosas', ['license_token_id' => $token->id]);
        $this->plugin($entorno, ['component' => 'mod_forum']);

        $faltantes = Livewire::test(SinInstalar::class)->viewData('faltantes');

        // El plugin de verdad sí falta y se dice; el ecosistema no aparece.
        $nombres = collect($faltantes)->flatMap(fn (array $f) => $f['productos']->pluck('name'))->all();

        $this->assertContains('Marketplace', $nombres);
        $this->assertNotContains('Fresk Premium', $nombres);
    }

    public function test_solo_cuentan_como_plugin_los_slugs_que_moodle_puede_declarar(): void
    {
        // La regla, aislada: el inventario de un sitio lo rellena Moodle con `tipo_nombre`.
        $plugin = fn (string $slug) => (new Product(['slug' => $slug]))->esPluginDeMoodle();

        $this->assertTrue($plugin('theme_fresk'));
        $this->assertTrue($plugin('local_courseai'));
        $this->assertTrue($plugin('report_fundae'));

        $this->assertFalse($plugin('fresk_premium'), 'un ecosistema comercial');
        $this->assertFalse($plugin('premium'), 'sin prefijo no hay componente');
        $this->assertFalse($plugin(''), 'ni con el slug vacío');
    }

    public function test_no_se_acusa_de_falta_a_un_entorno_que_no_envia_inventario(): void
    {
        // En un entorno que no envía inventario la ausencia no significa nada. Listarlo
        // como «falta el plugin» sería el error contrario, y afecta a 17 de 20 entornos.
        $this->admin();

        $cliente = Client::firstOrCreate(['shortname' => 'pq'], ['name' => 'Cliente Parque', 'actived' => true]);
        $producto = Product::create(['slug' => 'local_tresipuntmarket', 'name' => 'Marketplace', 'tech' => 'moodle', 'actived' => true, 'enabled' => true]);
        $token = LicenseToken::create(['token' => '3IP-EEEE-FFFF-GGGG-HHHH', 'client_id' => $cliente->id, 'actived' => true]);
        $token->products()->attach($producto->id);

        // Con licencia y sin inventario.
        $this->entorno('Callado con licencia', ['license_token_id' => $token->id]);
        // Otro que sí envía, para que la pantalla no esté en el estado vacío.
        $this->plugin($this->entorno('Habla'), ['component' => 'mod_forum']);

        $this->assertSame([], Livewire::test(SinInstalar::class)->viewData('faltantes'));
    }

    /* ===================== Los filtros ===================== */

    public function test_los_filtros_van_en_la_url_para_que_el_enlace_llegue_filtrado(): void
    {
        // MGR-053. El inventario de un entorno enlaza aquí con «ver este plugin en el resto
        // del parque»; sin los filtros en la URL, ese enlace caía en la primera de 16
        // páginas alfabéticas a buscar a mano el plugin del que venías.
        $this->admin();

        $entorno = $this->entorno('Con varios');
        $this->plugin($entorno, ['component' => 'mod_forum']);
        $this->plugin($entorno, ['component' => 'mod_quiz']);
        $this->plugin($entorno, ['component' => 'block_html', 'type' => 'block']);

        // Ahora el enlace lleva directamente a la pantalla del inventario, sin `seccion`:
        // la sección es la URL.
        Livewire::withQueryParams(['q' => 'quiz'])
            ->test(Inventario::class)
            ->assertSet('busqueda', 'quiz')
            ->assertSee('mod_quiz')
            ->assertDontSee('mod_forum');
    }

    public function test_el_filtro_de_tipos_ofrece_los_que_hay_y_no_una_lista_escrita_a_mano(): void
    {
        // Antes era un `<select>` con seis tipos escritos en la plantilla —mod, block,
        // local, theme, report, format— y en los datos hay 61: los que no estaban en la
        // lista no se podían filtrar.
        $this->admin();

        $entorno = $this->entorno('Tipos raros');
        $this->plugin($entorno, ['component' => 'qbank_editquestion', 'type' => 'qbank']);
        $this->plugin($entorno, ['component' => 'customfield_date', 'type' => 'customfield']);

        $tipos = collect(Livewire::test(Inventario::class)->viewData('tipos'))->pluck('tipo');

        $this->assertContains('qbank', $tipos);
        $this->assertContains('customfield', $tipos);
    }

    public function test_el_foco_filtra_a_los_nuestros(): void
    {
        $this->admin();

        $entorno = $this->entorno('Mezclado');
        OwnPlugin::create(['component' => 'theme_fresk']);
        $this->plugin($entorno, ['component' => 'theme_fresk', 'type' => 'theme']);
        $this->plugin($entorno, ['component' => 'mod_forum']);

        Livewire::test(Inventario::class)
            ->set('foco', 'nuestros')
            ->assertSee('theme_fresk')
            ->assertDontSee('mod_forum');
    }

    public function test_el_buscador_sugiere_en_vez_de_hacer_escribir_a_ciegas(): void
    {
        // Con 470 componentes, un campo de texto libre es acertar el identificador exacto
        // o no encontrar nada.
        $this->admin();

        $entorno = $this->entorno('Muchos');
        foreach (['mod_forum', 'mod_forumreport', 'block_forumsearch'] as $componente) {
            $this->plugin($entorno, ['component' => $componente, 'type' => explode('_', $componente)[0]]);
        }

        $sugerencias = collect(Livewire::test(Inventario::class)->set('busqueda', 'forum')->viewData('sugerencias'));

        $this->assertCount(3, $sugerencias);
        $this->assertContains('mod_forumreport', $sugerencias->pluck('component'));
    }

    public function test_no_se_sugiere_nada_con_una_sola_letra(): void
    {
        $this->admin();
        $this->plugin($this->entorno('Uno'), ['component' => 'mod_forum']);

        $this->assertSame([], Livewire::test(Inventario::class)->set('busqueda', 'm')->viewData('sugerencias'));
    }

    /* ===================== Permisos ===================== */

    public function test_desplegar_un_componente_exige_permiso(): void
    {
        // MGR-005: el permiso de la ruta **no se vuelve a aplicar** en `/livewire/update`,
        // y este método lee el inventario de un `component` que llega del cliente. Antes
        // eran dos mutadores —`openEnvironmentsModal` y `closeEnvironmentsModal`— y
        // ninguno comprobaba nada.
        $rol = Role::firstOrCreate(['name' => 'Curioso', 'guard_name' => 'web']);
        $rol->givePermissionTo(Permission::firstOrCreate(
            ['name' => 'admin.dashboard.index', 'guard_name' => 'web'],
            ['model' => 'dashboard', 'description' => 'ver']
        ));

        $user = User::factory()->create();
        $user->assignRole($rol);
        $this->actingAs($user);
        Cache::flush();

        Livewire::test(Inventario::class)
            ->call('alternar', 'mod_forum')
            ->assertForbidden();
    }

    public function test_desplegar_ensena_en_que_entornos_esta_y_con_que_version(): void
    {
        $this->admin();

        $uno = $this->entorno('Sitio uno');
        $otro = $this->entorno('Sitio dos');
        $this->plugin($uno, ['component' => 'mod_forum', 'release' => '4.0.0']);
        $this->plugin($otro, ['component' => 'mod_forum', 'release' => '4.5.1']);

        $componente = Livewire::test(Inventario::class)
            ->call('alternar', 'mod_forum');

        $this->assertCount(2, $componente->viewData('detalle'));
        $componente->assertSee('Sitio uno');
        $componente->assertSee('4.5.1');
    }

    /* ===================== Las fotos ===================== */

    public function test_se_avisa_cuando_el_agregado_mezcla_fotos_de_fechas_lejanas(): void
    {
        // El inventario se reescribe entero en cada sincronización, así que este agregado
        // mezcla fotos de fechas distintas: un entorno sincronizado hoy y otro de hace tres
        // semanas cuentan igual, y eso cambia lo que significan los números.
        $this->admin();

        $viejo = $this->entorno('Rezagado');
        $nuevo = $this->entorno('Al dia');
        $this->plugin($viejo, ['component' => 'mod_forum'])->forceFill(['created_at' => now()->subDays(40)])->save();
        $this->plugin($nuevo, ['component' => 'mod_quiz']);

        $componente = Livewire::test(Index::class);

        $this->assertTrue($componente->viewData('frescura')['dispersa']);
        $this->assertNotNull(collect($componente->viewData('atencion'))->firstWhere('etiqueta', 'Fotos dispares'));
    }

    public function test_el_mismo_dia_no_se_lee_como_fotos_dispares(): void
    {
        // Carbon 3 devuelve `diffInDays` como **float con signo**: sin el cast, `=== 0` es
        // falso para `0.0` y todo el parque saldría como disperso (MGR-062).
        $this->admin();

        $entorno = $this->entorno('Hoy mismo');
        $this->plugin($entorno, ['component' => 'mod_forum']);
        $this->plugin($entorno, ['component' => 'mod_quiz']);

        $frescura = Livewire::test(Index::class)->viewData('frescura');

        $this->assertSame(0, $frescura['dias']);
        $this->assertFalse($frescura['dispersa']);
    }

    /* ===================== Nada que hacer ===================== */

    public function test_cuando_no_hay_nada_que_hacer_se_dice_y_no_se_inventan_avisos(): void
    {
        $this->admin();

        $entorno = $this->entorno('Impecable', [
            'version' => '5.1.6',
            'lastminor' => '5.1.6',
            'lastversion' => '5.1.6',
        ]);
        OwnPlugin::create(['component' => OwnPlugin::ORQUESTADOR]);
        $this->plugin($entorno, ['component' => OwnPlugin::ORQUESTADOR, 'type' => 'local']);
        $this->plugin($entorno, ['component' => 'mod_forum']);
        // Y su Moodle comprueba actualizaciones: si no lo hiciera, «está al día» no se
        // podría afirmar, y eso también es algo que hacer.
        $this->telemetria($entorno);

        $componente = Livewire::test(Index::class);

        $this->assertSame([], $componente->viewData('atencion'));
        $componente->assertSee('Nada que hacer');
    }

    /* ===================== La portada y el reparto en páginas ===================== */

    public function test_la_portada_enruta_y_trae_el_numero_de_cada_pantalla(): void
    {
        // **Lo que había: `/plugins` era «Plugins Tresipunt» con pestañas encima.** La
        // puerta de la sección era la pantalla más densa, y dentro vivía además un bloque
        // de «Licenciado y sin instalar» que responde a otra pregunta.
        //
        // Una tarjeta sin número obliga a entrar para saber si hay algo que mirar, que es
        // lo que hacía inútil el menú de pestañas.
        $this->admin();

        $entorno = $this->entorno('Uno');
        $this->plugin($entorno, ['component' => 'mod_forum']);
        $this->plugin($entorno, ['component' => 'block_html', 'type' => 'block']);

        $tarjetas = collect(Livewire::test(Index::class)->viewData('tarjetas'))->keyBy('clave');

        foreach (['nuestros', 'sin-instalar', 'inventario', 'core'] as $clave) {
            $this->assertTrue($tarjetas->has($clave), "falta la tarjeta $clave");
            $this->assertArrayHasKey('cuantos', $tarjetas[$clave]);
            $this->assertArrayHasKey('alerta', $tarjetas[$clave]);
        }

        $this->assertSame(2, $tarjetas['inventario']['cuantos'], 'los dos componentes del parque');
    }

    public function test_cada_pantalla_tiene_sus_propios_filtros_y_no_se_arrastran(): void
    {
        // **El problema que esto resuelve de raíz.** Con pestañas, filtrar el inventario
        // por «mod» y saltar a versiones de Moodle dejaba un filtro activo que no se veía
        // en ninguna parte: la pantalla salía recortada sin que nada lo indicara, y hacía
        // falta un `irA()` que limpiara a mano.
        //
        // Con una URL por pantalla eso no puede pasar: `tipo` y `foco` solo existen en el
        // inventario, y `estado` solo en versiones de Moodle.
        $this->admin();

        $entorno = $this->entorno('Uno');
        $this->plugin($entorno, ['component' => 'mod_forum']);

        $inventario = Livewire::test(Inventario::class)
            ->set('busqueda', 'forum')
            ->set('tipo', 'mod')
            ->set('foco', 'nuestros');

        $inventario->assertSet('tipo', 'mod');

        // Y la otra pantalla nace limpia, sin saber nada de aquello.
        Livewire::test(Core::class)->assertSet('busqueda', '')->assertSet('focoCore', '');
    }

    public function test_quitar_los_filtros_deja_la_pantalla_como_se_abre(): void
    {
        // Incluidos los apagados, porque incluirlos es **ampliar** y no filtrar: si se
        // quedara puesto, la tabla seguiría contando sitios de baja después de haber
        // pedido explícitamente empezar de cero.
        $this->admin();
        $this->plugin($this->entorno('Uno'), ['component' => 'mod_forum']);

        Livewire::test(Inventario::class)
            ->set('busqueda', 'forum')
            ->set('tipo', 'mod')
            ->set('incluirApagados', true)
            ->call('limpiarFiltros')
            ->assertSet('busqueda', '')
            ->assertSet('tipo', '')
            ->assertSet('foco', '')
            ->assertSet('incluirApagados', false);
    }

    public function test_solo_la_pantalla_que_lo_pinta_paga_el_detalle_de_versiones(): void
    {
        // Traer **en qué entorno está cada versión** cuesta una consulta por componente, y
        // solo lo pinta «Plugins Tresipunt». Las demás pantallas necesitan los recuentos
        // —cuántas versiones, cuántos actualizables— y esos ya vienen del agregado.
        //
        // Con pestañas esto lo decidía `$this->seccion`; ahora lo decide cada pantalla, en
        // `quiereElDetalleDeVersiones()`. Lo observable es lo mismo.
        $this->admin();

        $entorno = $this->entorno('Uno');
        OwnPlugin::create(['component' => 'theme_fresk', 'name' => 'Fresk']);
        $this->plugin($entorno, ['component' => 'theme_fresk', 'type' => 'theme']);
        $this->plugin($entorno, ['component' => 'mod_forum']);

        $conDetalle = collect(Livewire::test(Nuestros::class)->viewData('nuestros'));

        // La portada usa los mismos datos para su tarjeta y **no** pide el detalle.
        $portada = Livewire::test(Index::class);
        $sinDetalle = collect($portada->instance()->nuestros());

        $this->assertNotEmpty($conDetalle->firstWhere('component', 'theme_fresk')['detalle']);
        $this->assertSame([], $sinDetalle->firstWhere('component', 'theme_fresk')['detalle']);
        // Y las cuentas siguen estando, que es lo que la tarjeta y los avisos necesitan.
        $this->assertSame(1, $sinDetalle->firstWhere('component', 'theme_fresk')['entornos']);
    }

    /* ===================== Los filtros de cada pestaña ===================== */

    public function test_la_pestana_de_lo_nuestro_filtra_por_lo_que_esta_mal(): void
    {
        $this->admin();

        $uno = $this->entorno('Cliente viejo');
        $otro = $this->entorno('Cliente nuevo');
        OwnPlugin::create(['component' => 'theme_fresk', 'name' => 'Fresk']);
        OwnPlugin::create(['component' => 'format_fresk', 'name' => 'Formato']);
        // Uno desalineado…
        $this->plugin($uno, ['component' => 'theme_fresk', 'type' => 'theme', 'versiondisk' => '2024010100', 'versiondb' => '2024010100']);
        $this->plugin($otro, ['component' => 'theme_fresk', 'type' => 'theme', 'versiondisk' => '2026010100', 'versiondb' => '2026010100']);
        // …y otro igual en los dos.
        $this->plugin($uno, ['component' => 'format_fresk', 'type' => 'format']);
        $this->plugin($otro, ['component' => 'format_fresk', 'type' => 'format']);

        $componente = Livewire::test(Nuestros::class);

        $this->assertSame(1, $componente->viewData('resumenNuestros')['desalineados']);
        $this->assertCount(2, $componente->viewData('nuestros'));

        $componente->set('focoNuestros', 'desalineados');
        $filtrados = collect($componente->viewData('nuestros'));

        $this->assertCount(1, $filtrados);
        $this->assertSame('theme_fresk', $filtrados->first()['component']);
    }

    public function test_la_pestana_del_core_filtra_por_lo_que_le_falta_a_cada_sitio(): void
    {
        $this->admin();

        $sinParches = $this->entorno('Sin parches', ['version' => '5.1.1', 'lastminor' => '5.1.6', 'lastversion' => '5.1.6']);
        $this->entorno('Al dia', ['version' => '5.1.6', 'lastminor' => '5.1.6', 'lastversion' => '5.1.6']);
        $this->plugin($sinParches, ['component' => 'mod_forum']);

        $componente = Livewire::test(Core::class);
        $this->assertCount(2, $componente->viewData('core'));

        $componente->set('focoCore', 'menor');
        $filtrado = collect($componente->viewData('core'));

        $this->assertCount(1, $filtrado);
        $this->assertSame($sinParches->id, $filtrado->first()['entorno']->id);
        // Y el resumen sigue contando los dos: es lo que hay, no lo que se está viendo.
        $this->assertSame(1, $componente->viewData('resumenCore')['aldia']);
    }

    public function test_el_core_se_puede_buscar_por_nombre_de_entorno(): void
    {
        $this->admin();

        $this->entorno('Campus Norte');
        $this->entorno('Campus Sur');
        $this->plugin($this->entorno('Otro'), ['component' => 'mod_forum']);

        $encontrados = collect(
            Livewire::test(Core::class)->set('busqueda', 'norte')->viewData('core')
        );

        $this->assertCount(1, $encontrados);
        $this->assertSame('Campus Norte', $encontrados->first()['entorno']->name);
    }

    /* ===================== El enlace a Entornos ===================== */

    public function test_el_enlace_a_entornos_lleva_el_plugin_puesto(): void
    {
        // La pregunta «¿en qué entornos está este plugin?» se responde en el listado de
        // Entornos, donde están el cliente, la licencia y el soporte. El filtro
        // `pluginComponents` existía y **no vivía en la URL**, así que el enlace llegaba al
        // listado completo: es MGR-053, y se ha abierto solo ese filtro.
        $this->admin();

        $entorno = $this->entorno('Con el nuestro');
        OwnPlugin::create(['component' => 'theme_fresk', 'name' => 'Fresk']);
        $this->plugin($entorno, ['component' => 'theme_fresk', 'type' => 'theme']);

        // El enlace vive en la pantalla que lista lo nuestro, que es donde se hace esa
        // pregunta: «este plugin, ¿en qué clientes está?».
        Livewire::test(Nuestros::class)
            ->assertSee(route('environments.index', ['plugin' => ['theme_fresk']]), false);
    }

    public function test_entornos_recoge_el_plugin_de_la_url_y_filtra(): void
    {
        // El otro lado del enlace: sin esto, el enlace de arriba sería una promesa.
        $this->admin();

        $conElPlugin = $this->entorno('Lo tiene');
        $sinElPlugin = $this->entorno('No lo tiene');
        $this->plugin($conElPlugin, ['component' => 'theme_fresk', 'type' => 'theme']);
        $this->plugin($sinElPlugin, ['component' => 'mod_forum']);

        Livewire::withQueryParams(['plugin' => ['theme_fresk']])
            ->test(\App\Livewire\Environments\Index::class)
            ->assertSet('pluginComponents', ['theme_fresk'])
            ->assertSee('Lo tiene')
            ->assertDontSee('No lo tiene');
    }

    /* ===================== El detalle que sí teníamos y no se usaba ===================== */

    public function test_se_ensena_la_version_completa_con_su_build(): void
    {
        // **Corrige lo que este brief daba por perdido.** `environments.version` guarda
        // «5.1.1» y la telemetría del sitio trae «5.1.1+ (Build: 20251219)»: el build es la
        // mitad de lo que el Manager antiguo enseñaba en sus tarjetas, y estaba ahí sin que
        // ninguna pantalla lo usara. El `+` dice que lleva parches posteriores a la
        // etiqueta.
        $this->admin();

        $entorno = $this->entorno('Con build', ['version' => '5.1.1', 'lastminor' => '5.1.1', 'lastversion' => '5.1.1']);
        $this->telemetria($entorno);
        $this->plugin($entorno, ['component' => 'mod_forum']);

        $componente = Livewire::test(Core::class);
        $sitio = collect($componente->viewData('core'))->firstWhere('entorno.id', $entorno->id);

        $this->assertSame('5.1.1+ (Build: 20251219)', $sitio['completa']);
        $componente->assertSee('(Build: 20251219)');
    }

    public function test_se_dice_cuando_comprobo_actualizaciones_cada_sitio(): void
    {
        // **Es la causa de los «sin datos», no un estado más.** Si el Moodle de un cliente
        // no comprueba actualizaciones, `lastversion` y `lastminor` no se rellenan nunca y
        // un sitio al día y uno atrasado se ven igual.
        $this->admin();

        $comprueba = $this->entorno('Comprueba');
        $noComprueba = $this->entorno('No comprueba');
        $this->telemetria($comprueba);
        $this->plugin($comprueba, ['component' => 'mod_forum']);

        $componente = Livewire::test(Core::class);
        $core = collect($componente->viewData('core'));

        $this->assertNotNull($core->firstWhere('entorno.id', $comprueba->id)['comprobado']);
        $this->assertNull($core->firstWhere('entorno.id', $noComprueba->id)['comprobado']);

        $aviso = collect($componente->viewData('atencion'))->firstWhere('etiqueta', 'Sin comprobar');
        $this->assertNotNull($aviso);
        $this->assertStringContainsString('1 sitio', $aviso['texto']);
    }

    public function test_un_timestamp_a_cero_no_es_1970(): void
    {
        // `availableupdatesfetch` llega como entero. Un 0 significa «nunca», y
        // `Carbon::createFromTimestamp(0)` daría 1970: la pantalla diría «lo comprobó hace
        // 56 años», que es una respuesta y no un hueco.
        $this->admin();

        $entorno = $this->entorno('Cero');
        $this->telemetria($entorno, ['availableupdatesfetch' => 0]);
        $this->plugin($entorno, ['component' => 'mod_forum']);

        $sitio = collect(Livewire::test(Core::class)->viewData('core'))
            ->firstWhere('entorno.id', $entorno->id);

        $this->assertNull($sitio['comprobado']);
    }

    public function test_se_dice_en_que_ramas_esta_repartido_el_parque(): void
    {
        // La pregunta que ninguna otra pantalla responde, y la que decide el trabajo de un
        // trimestre: una rama con dos sitios se sube, una con doce se planifica.
        $this->admin();

        $this->entorno('Uno 5.1', ['version' => '5.1.1']);
        $this->entorno('Dos 5.1', ['version' => '5.1.6']);
        $this->entorno('Viejo', ['version' => '3.11']);
        $this->plugin($this->entorno('Habla', ['version' => '5.1.1']), ['component' => 'mod_forum']);

        $ramas = collect(Livewire::test(Core::class)->viewData('ramas'));

        $this->assertSame(3, $ramas->firstWhere('rama', '5.1')['cuantos']);
        $this->assertSame(1, $ramas->firstWhere('rama', '3.11')['cuantos']);
    }

    public function test_el_desplegable_distingue_los_dos_desfases_de_version(): void
    {
        // MGR-066. Ficheros por delante de la base de datos: **lo arregla el cron**.
        // Ficheros por detrás: el cron NO lo arregla y hay que volver a subir el código.
        // El desplegable lo dice con esas palabras, porque el arreglo es opuesto.
        $this->admin();

        $pendiente = $this->entorno('Upgrade pendiente');
        $retrocedido = $this->entorno('Codigo retrocedido');
        OwnPlugin::create(['component' => 'theme_fresk', 'name' => 'Fresk']);
        $this->plugin($pendiente, ['component' => 'theme_fresk', 'type' => 'theme', 'versiondisk' => '2026010100', 'versiondb' => '2025010100']);
        $this->plugin($retrocedido, ['component' => 'theme_fresk', 'type' => 'theme', 'versiondisk' => '2025010100', 'versiondb' => '2026010100']);

        $componente = Livewire::test(Inventario::class)->call('alternar', 'theme_fresk');
        $detalle = collect($componente->viewData('detalle'));

        $this->assertSame('pendiente', $detalle->firstWhere('entorno.id', $pendiente->id)['desfase']);
        $this->assertSame('retrocedido', $detalle->firstWhere('entorno.id', $retrocedido->id)['desfase']);

        $componente->assertSee('el cron no lo arregla', false);
        $componente->assertSee('Ficheros retrocedidos');
    }

    public function test_el_desplegable_avisa_de_una_dependencia_que_no_esta(): void
    {
        // Un plugin nuestro que depende de otro que no está instalado en ese sitio: es la
        // causa de que «esté instalado y no funcione», y no se veía en ninguna pantalla.
        $this->admin();

        $entorno = $this->entorno('Sin la dependencia');
        OwnPlugin::create(['component' => 'local_tresipuntsepelite', 'name' => 'SEPE Lite']);
        $this->plugin($entorno, [
            'component' => 'local_tresipuntsepelite',
            'type' => 'local',
            'has_dependencies' => true,
            'dependencies' => json_encode(['block_tresipuntsepe' => '2025010100']),
        ]);

        $componente = Livewire::test(Inventario::class)->call('alternar', 'local_tresipuntsepelite');
        $dependencias = collect($componente->viewData('detalle'))->first()['dependencias'];

        $this->assertSame('block_tresipuntsepe', $dependencias[0]['component']);
        $this->assertFalse($dependencias[0]['instalado']);
        $componente->assertSee('Le falta la dependencia');
    }

    public function test_una_dependencia_instalada_no_se_presenta_como_problema(): void
    {
        $this->admin();

        $entorno = $this->entorno('Con todo');
        OwnPlugin::create(['component' => 'local_tresipuntsepelite', 'name' => 'SEPE Lite']);
        $this->plugin($entorno, ['component' => 'block_tresipuntsepe', 'type' => 'block']);
        $this->plugin($entorno, [
            'component' => 'local_tresipuntsepelite',
            'type' => 'local',
            'has_dependencies' => true,
            'dependencies' => json_encode(['block_tresipuntsepe' => '2025010100']),
        ]);

        $componente = Livewire::test(Inventario::class)->call('alternar', 'local_tresipuntsepelite');
        $dependencias = collect($componente->viewData('detalle'))->first()['dependencias'];

        $this->assertTrue($dependencias[0]['instalado']);
        $componente->assertDontSee('Le falta la dependencia');
    }

    public function test_availableupdates_con_forma_rara_no_rompe_la_pantalla(): void
    {
        // Es **JSON sin forma garantizada**: una versión o varias, y las claves cambian
        // entre versiones de Moodle. Leerlo a la defensiva es la diferencia entre no decir
        // nada y que la pantalla se caiga.
        $this->admin();

        $entorno = $this->entorno('Con json raro');
        OwnPlugin::create(['component' => 'theme_fresk', 'name' => 'Fresk']);
        $this->plugin($entorno, [
            'component' => 'theme_fresk',
            'type' => 'theme',
            'has_updates' => true,
            'availableupdates' => json_encode([['release' => '2.5.0', 'maturity' => 200]]),
        ]);

        $detalle = collect(Livewire::test(Inventario::class)->call('alternar', 'theme_fresk')->viewData('detalle'));

        $this->assertSame('2.5.0', $detalle->first()['candidata']);
    }
}
