<?php

namespace Tests\Feature\Configurations;

use App\Livewire\Configurations\Access;
use App\Livewire\Configurations\Environments\Index as TypesIndex;
use App\Livewire\Configurations\Environments\TypeDeleteModal;
use App\Livewire\Configurations\Index;
use App\Models\Clients\Client;
use App\Models\Auth\User;
use App\Models\Environments\Environment;
use App\Models\Environments\Type;
use App\Services\System\Settings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/**
 * La portada de Configuración y la pantalla de Acceso, desde `Configuración.dc.html`.
 *
 * Lo que se fija: que la sección tenga puerta, que un rol sin permisos se vea desde la
 * portada, que el alcance de un rol no mienta y que los recuentos no vengan de contar a
 * ojo.
 */
class ConfigurationScreensTest extends TestCase
{
    use RefreshDatabase;

    private function permiso(string $nombre, string $modulo = 'configs'): Permission
    {
        return Permission::firstOrCreate(
            ['name' => $nombre, 'guard_name' => 'web'],
            ['model' => $modulo, 'description' => 'Descripción de ' . $nombre]
        );
    }

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

        foreach (['admin.configs.index', 'admin.configs.edit', 'admin.roles.index',
                  'admin.roles.create', 'admin.roles.edit', 'admin.roles.destroy',
                  'admin.permissions.index', 'admin.permissions.edit',
                  'admin.configurations.environments-types.index',
                  'admin.configurations.environments-types.create',
                  'admin.configurations.environments-types.edit',
                  'admin.configurations.environments-types.destroy',
                  'admin.environments.index'] as $nombre) {
            $rol->givePermissionTo($this->permiso($nombre));
        }

        $user = User::factory()->create(['name' => 'Antonio Manzano']);
        $user->assignRole($rol);
        $this->actingAs($user);

        Cache::flush();

        return $user;
    }

    /* ===================== La portada ===================== */

    public function test_la_seccion_de_configuracion_tiene_puerta(): void
    {
        // **Antes no la tenía.** No existía ninguna ruta para `/configurations` y el menú
        // lateral entraba directamente en «Tipos de entornos», la pestaña que menos se
        // toca. El test fija que la ruta existe y responde.
        $this->admin();

        $this->get(route('configurations.index'))
            ->assertOk()
            ->assertSee('Configuración');
    }

    public function test_la_portada_dice_si_los_limites_cortan_o_solo_observan(): void
    {
        // Es la diferencia entre «hay un tope apuntado» y «hay clientes recibiendo 429»,
        // y era la pregunta que había que entrar a cuatro pestañas para responder.
        $this->admin();
        $ajustes = app(Settings::class);

        $ajustes->set(Settings::LIMITES_CORTAN, '0');
        Cache::flush();

        Livewire::test(Index::class)
            ->assertSee('En observación')
            ->assertSee('se anotan los excesos y no se corta a nadie');

        $ajustes->set(Settings::LIMITES_CORTAN, '1');
        Cache::flush();

        Livewire::test(Index::class)
            ->assertSee('Cortando')
            ->assertSee('quien se pasa deja de recibir contenido');
    }

    public function test_la_portada_avisa_de_que_no_se_borra_ningun_log(): void
    {
        // Con la retención en 0 no se borra nada: la tabla más grande del sistema crece
        // sin freno hasta que el visor deja de abrirse. Es un estado válido —es el de
        // fábrica— pero tiene que verse.
        $this->admin();

        app(Settings::class)->set(Settings::LOGS_RETENCION, '0');
        Cache::flush();

        Livewire::test(Index::class)
            ->assertSee('Sin límite')
            ->assertSee('no se borra nada, y el visor se irá volviendo lento');
    }

    public function test_la_portada_marca_un_rol_que_no_ve_ninguna_pantalla(): void
    {
        // **El fallo silencioso de esta sección.** Un rol sin permisos deja a quien lo
        // tenga entrando y sin ver nada, sin ningún error. Era el caso de Support, y no
        // se veía desde ninguna pantalla del panel.
        $this->admin();

        Role::create(['name' => 'Becario', 'guard_name' => 'web']);
        Cache::flush();

        Livewire::test(Index::class)->assertSee('1 rol sin permisos');
    }

    public function test_el_recuento_de_ajustes_sale_del_catalogo_y_no_de_contarlos_a_mano(): void
    {
        // El brief dijo «15 ajustes» contando a ojo y son 17. Con la portada enseñando el
        // número, contarlo a ojo deja de valer: sale de `Settings::CATALOGO`, que es la
        // misma lista que pinta la pantalla de Ajustes.
        $this->admin();

        $delCatalogo = Settings::cuantosAjustes();

        $this->assertSame(
            $delCatalogo,
            collect(Settings::CATALOGO)->sum(fn (array $grupo) => count($grupo['ajustes'])),
            'El recuento no coincide con el catálogo'
        );

        Livewire::test(Index::class)->assertSee($delCatalogo . ' ajustes');
    }

    public function test_las_rutas_a_las_que_apunta_el_catalogo_existen(): void
    {
        // Cada grupo de ajustes enlaza a donde se ve su efecto. Un nombre de ruta mal
        // escrito ahí revienta la pantalla de Ajustes entera al pintar el enlace, y sería
        // en producción: `route()` lanza con un nombre que no existe.
        foreach (Settings::CATALOGO as $clave => $grupo) {
            if ($grupo['ruta'] === null) {
                continue;
            }

            $this->assertTrue(
                app('router')->has($grupo['ruta']),
                'El grupo «' . $clave . '» apunta a una ruta que no existe: ' . $grupo['ruta']
            );
        }
    }

    /* ===================== Acceso ===================== */

    public function test_acceso_dice_cuantos_permisos_y_cuanta_gente_tiene_cada_rol(): void
    {
        // «Roles» tenía tres columnas —Nombre, Alias, Descripción— y ninguna de las dos
        // cosas que se preguntan de un rol.
        $this->admin();

        $componente = Livewire::test(Access::class);
        $roles = collect($componente->viewData('roles'));
        $admin = $roles->firstWhere('nombre', 'Admin');

        $this->assertNotNull($admin);
        $this->assertSame(13, $admin['permisos']);
        $this->assertSame(1, $admin['usuarios']);
        $this->assertFalse($admin['sinPermisos']);
    }

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

        Role::create(['name' => 'Becario', 'guard_name' => 'web']);

        $roles = collect(Livewire::test(Access::class)->viewData('roles'));
        $becario = $roles->firstWhere('nombre', 'Becario');

        $this->assertTrue($becario['sinPermisos']);
        $this->assertSame('No ve ninguna pantalla: el rol no tiene permisos', $becario['alcance']);
    }

    public function test_un_rol_que_nadie_tiene_se_marca_en_acceso(): void
    {
        // Cuatro de los seis roles del panel no tenían ningún usuario y era invisible.
        $this->admin();

        $rol = Role::create(['name' => 'Sobrante', 'guard_name' => 'web']);
        $rol->givePermissionTo($this->permiso('admin.clients.index', 'clients'));

        $roles = collect(Livewire::test(Access::class)->viewData('roles'));
        $sobrante = $roles->firstWhere('nombre', 'Sobrante');

        $this->assertTrue($sobrante['sinUsuarios']);
        $this->assertSame(0, $sobrante['usuarios']);
    }

public function test_el_recuento_de_usuarios_no_cuenta_asignaciones_de_usuarios_que_no_existen(): void
    {
        // **Es el bug que se encontró pintando esta columna.** El rol Support salía con 1
        // persona contándolo por la tabla pivote y con 0 contándolo por la relación, y no
        // podían tener razón los dos: la fila apuntaba a un `model_id` que no existe en
        // `users` —resto de datos de prueba, y `model_has_roles` no tiene clave ajena—.
        //
        // Un número que no se puede explicar es peor que no tener el número: la pantalla
        // decía «lo tiene 1 persona» y quien fuera a buscarla a Usuarios no encontraría a
        // nadie.
        $this->admin();

        $rol = Role::create(['name' => 'Fantasma', 'guard_name' => 'web']);
        $rol->givePermissionTo($this->permiso('admin.clients.index', 'clients'));

        // Una asignación a un usuario inexistente, como la que había en producción.
        DB::table('model_has_roles')->insert([
            'role_id' => $rol->id,
            'model_type' => User::class,
            'model_id' => 999,
        ]);

        $roles = collect(Livewire::test(Access::class)->viewData('roles'));
        $fantasma = $roles->firstWhere('nombre', 'Fantasma');

        $this->assertSame(0, $fantasma['usuarios'], 'Se está contando un usuario que no existe');
        $this->assertTrue($fantasma['sinUsuarios']);
    }

    public function test_un_usuario_dado_de_baja_sigue_contando_en_su_rol(): void
    {
        // **La otra cara, y por eso la limpieza no borra por las bravas.** Un usuario dado
        // de baja conserva sus roles a propósito: se puede recuperar, y entonces tiene que
        // volver con lo que tenía. Solo son basura las asignaciones de ids que no existen
        // en absoluto.
        $this->admin();

        $rol = Role::create(['name' => 'DeBaja', 'guard_name' => 'web']);
        $rol->givePermissionTo($this->permiso('admin.clients.index', 'clients'));

        $persona = User::factory()->create();
        $persona->assignRole($rol);
        $persona->delete();

        $this->assertDatabaseHas('model_has_roles', [
            'role_id' => $rol->id,
            'model_id' => $persona->id,
        ]);
    }

    public function test_el_alcance_de_un_rol_no_dice_sin_usuarios(): void
    {
        // **La frase del alcance se completa con «sin …», y la columna de al lado dice
        // cuánta gente tiene el rol.** Si la sección se llamara «usuarios», «sin
        // usuarios» se leería como «este rol no lo tiene nadie», que es justo lo otro.
        $this->admin();

        $rol = Role::create(['name' => 'Casi', 'guard_name' => 'web']);

        // Todo menos la gestión de usuarios: así el alcance sí enumera lo que falta.
        foreach ([
            'admin.dashboard.index' => 'dashboard',
            'admin.clients.index' => 'clients',
            'admin.environments.index' => 'environments',
            'admin.license-tokens.index' => 'license-tokens',
            'admin.products.index' => 'products',
            'admin.api-logs.index' => 'api-logs',
            'admin.configs.index' => 'configs',
        ] as $nombre => $modulo) {
            $rol->givePermissionTo($this->permiso($nombre, $modulo));
        }

        $roles = collect(Livewire::test(Access::class)->viewData('roles'));
        $alcance = $roles->firstWhere('nombre', 'Casi')['alcance'];

        $this->assertStringContainsString('sin gestión de usuarios', $alcance);
        $this->assertStringNotContainsString('sin usuarios', $alcance);
    }

    public function test_el_rol_de_la_api_no_se_describe_como_si_navegara_el_panel(): void
    {
        // `WebService` tiene permisos de entornos y de configuración, pero existen para
        // que la API funcione. Enumerar sus secciones haría creer que alguien entra al
        // panel con ese rol y ve entornos.
        $this->admin();

        $rol = Role::create(['name' => 'WebService', 'guard_name' => 'web']);
        $rol->givePermissionTo($this->permiso('admin.environments.index', 'environments'));

        $roles = collect(Livewire::test(Access::class)->viewData('roles'));

        $this->assertSame(
            'Rol de la API: no navega el panel',
            $roles->firstWhere('nombre', 'WebService')['alcance']
        );
    }

    public function test_acceso_dice_que_roles_tiene_cada_permiso(): void
    {
        // Es la única pregunta que se hace mirando la pantalla de Permisos, y era la que
        // no se podía responder: la lista plana de 126 filas no lo decía, y Roles tampoco.
        $this->admin();

        $manager = Role::create(['name' => 'Manager', 'guard_name' => 'web']);
        $manager->givePermissionTo($this->permiso('admin.license-tokens.index', 'license-tokens'));

        $modulos = collect(Livewire::test(Access::class)->viewData('modulos'));
        $licencias = $modulos->firstWhere('nombre', 'license-tokens');

        $this->assertNotNull($licencias);

        $permiso = collect($licencias['permisos'])->firstWhere('clave', 'admin.license-tokens.index');

        $this->assertSame(['Manager'], array_column($permiso['roles'], 'nombre'));
        $this->assertFalse($permiso['sinRoles']);
    }

    public function test_un_permiso_que_ningun_rol_tiene_se_marca(): void
    {
        // Un permiso sin ningún rol es código sin dueño: una pantalla que no existe
        // todavía, o una que se quitó y dejó el permiso detrás. Son las pistas del
        // roadmap y hasta ahora no se veían en ninguna parte.
        $this->admin();

        $this->permiso('admin.stats.index', 'stats');

        $modulos = collect(Livewire::test(Access::class)->viewData('modulos'));
        $stats = $modulos->firstWhere('nombre', 'stats');

        $this->assertTrue(collect($stats['permisos'])->firstWhere('clave', 'admin.stats.index')['sinRoles']);
    }

    public function test_la_busqueda_de_permisos_despliega_lo_que_encuentra(): void
    {
        // Sin esto, el buscador filtra pero deja los módulos plegados: hay que desplegar
        // a mano el módulo que acaba de encontrar.
        $this->admin();

        $componente = Livewire::test(Access::class)->set('busca', 'permissions');
        $modulos = collect($componente->viewData('modulos'));

        $this->assertGreaterThan(0, $modulos->count());
        $this->assertTrue($modulos->every(fn (array $m) => $m['abierto']));
        $this->assertTrue($modulos->every(
            fn (array $m) => collect($m['permisos'])->every(
                fn (array $p) => str_contains($p['clave'], 'permissions')
                    || str_contains(mb_strtolower($p['desc']), 'permissions')
            )
        ));
    }

    public function test_acceso_no_consulta_los_roles_permiso_a_permiso(): void
    {
        // Con `$permiso->roles` por fila serían 126 consultas, y lo mismo con
        // `User::role($nombre)->count()` por rol. Es el N+1 de MGR-016 otra vez, y aquí
        // sería peor porque la tabla de permisos es la más larga del panel.
        $this->admin();

        for ($i = 1; $i <= 20; $i++) {
            $this->permiso('admin.cosas' . $i . '.index', 'cosas' . $i);
        }

        for ($i = 1; $i <= 5; $i++) {
            Role::create(['name' => 'Rol ' . $i, 'guard_name' => 'web'])
                ->givePermissionTo(Permission::where('name', 'admin.cosas' . $i . '.index')->first());
        }

        Cache::flush();

        $consultas = 0;
        DB::listen(function () use (&$consultas) {
            $consultas++;
        });

        Livewire::test(Access::class)->viewData('modulos');

        // El margen es generoso a propósito: lo que se fija es el orden de magnitud —una
        // docena, no ciento cincuenta—, no un número exacto que se rompa al añadir una
        // consulta legítima.
        $this->assertLessThan(
            25,
            $consultas,
            'La pantalla de Acceso hace ' . $consultas . ' consultas: ha vuelto el N+1'
        );
    }

    /* ===================== Catálogos: tipos de entorno ===================== */

    public function test_catalogos_dice_cuantos_entornos_cuelgan_de_cada_tipo(): void
    {
        // La tabla anterior tenía icono, nombre, shortname y descripción, y **ni una
        // pista** de que uno de los cuatro tipos tiene 15 entornos colgando.
        $this->admin();

        $cliente = Client::create(['name' => 'Cliente Tipos', 'shortname' => 'tip', 'actived' => true]);
        $moodle = Type::create(['name' => 'Moodle LMS', 'shortname' => 'moodle']);
        Type::create(['name' => 'Canvas', 'shortname' => 'canvas', 'description' => 'Piloto sin estrenar']);

        $this->entorno($cliente, 'uno.test', $moodle);
        $this->entorno($cliente, 'dos.test', $moodle);

        $tipos = collect(Livewire::test(TypesIndex::class)->viewData('types'));

        // Los que más entornos tienen, primero.
        $this->assertSame('Moodle LMS', $tipos->first()->name);
        $this->assertSame(2, $tipos->firstWhere('shortname', 'moodle')->environments_count);
        $this->assertSame(0, $tipos->firstWhere('shortname', 'canvas')->environments_count);
    }

    public function test_un_tipo_con_entornos_no_ofrece_borrarlo(): void
    {
        // **Candado y no botón deshabilitado**: un botón que no hace nada al pulsarlo hace
        // pensar que la pantalla está rota.
        $this->admin();

        $cliente = Client::create(['name' => 'Cliente Tipos', 'shortname' => 'tip', 'actived' => true]);
        $moodle = Type::create(['name' => 'Moodle LMS', 'shortname' => 'moodle']);
        $this->entorno($cliente, 'uno.test', $moodle);

        Livewire::test(TypesIndex::class)
            ->assertDontSee('Borrar…')
            ->assertSee('No se puede borrar: lo declaran 1 entornos');
    }

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

        Type::create(['name' => 'Canvas', 'shortname' => 'canvas']);

        Livewire::test(TypesIndex::class)->assertSee('Borrar…');
    }

    public function test_el_modal_bloquea_el_borrado_de_un_tipo_en_uso(): void
    {
        // Es la regla, y la razón está en el docblock del modal: un entorno cuyo tipo
        // desaparece se queda sin plataforma y deja de emparejar con ningún producto
        // compatible, **sin dar ningún error**.
        $this->admin();

        $cliente = Client::create(['name' => 'Cliente Tipos', 'shortname' => 'tip', 'actived' => true]);
        $moodle = Type::create(['name' => 'Moodle LMS', 'shortname' => 'moodle']);
        $this->entorno($cliente, 'uno.test', $moodle);

        $modal = Livewire::test(TypeDeleteModal::class, ['typeId' => $moodle->id]);

        // `viewData` no funciona en un `ModalComponent`: hay que ir a la instancia.
        $this->assertTrue($modal->instance()->estaEnUso());
        $this->assertSame(1, $modal->instance()->impacto()['entornos']);

        $modal->call('borrar');

        // Sigue ahí. Y la modal **no se cierra**: se queda enseñando el bloqueo, que
        // es lo único que llega al usuario —el flash de una modal que se cierra no se
        // pinta en ninguna parte—.
        $this->assertNotNull(Type::find($moodle->id));
        $modal->assertSee('no se puede borrar')
            ->assertSee('Entornos de este tipo');
    }

    public function test_el_borrado_de_un_tipo_es_logico_y_queda_en_el_log(): void
    {
        // Lógico siempre: un tipo borrado por error se recupera, y los entornos que lo
        // apuntaran lo recuperan con él.
        $this->admin();

        $canvas = Type::create(['name' => 'Canvas', 'shortname' => 'canvas']);

        Livewire::test(TypeDeleteModal::class, ['typeId' => $canvas->id])->call('borrar');

        $this->assertNull(Type::find($canvas->id));
        $this->assertNotNull(Type::withTrashed()->find($canvas->id));
        $this->assertDatabaseHas('logs', [
            'code' => '16025',
            'entity' => 'Type',
            'entity_id' => (string) $canvas->id,
        ]);
    }

    public function test_borrar_un_tipo_exige_su_permiso(): void
    {
        // El permiso `...environments-types.destroy` **no existía**: el seeder crea
        // index, create y edit, y el botón estaba comentado en el blade apuntando a una
        // ruta inexistente. Este test fija que el permiso hace falta de verdad.
        $rol = Role::firstOrCreate(['name' => 'Mirón', 'guard_name' => 'web']);
        $rol->givePermissionTo($this->permiso('admin.configurations.environments-types.index', 'configurations'));

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

        Cache::flush();

        $canvas = Type::create(['name' => 'Canvas', 'shortname' => 'canvas']);

        // Ni se puede abrir la modal…
        Livewire::test(TypesIndex::class)
            ->assertDontSee('Borrar…')
            ->call('abrirBorrado', $canvas->id)
            ->assertForbidden();

        // …ni montarla a mano saltándose la pantalla.
        Livewire::test(TypeDeleteModal::class, ['typeId' => $canvas->id])->assertForbidden();
    }


    /* ===================== Que toda la sección responda ===================== */

    public function test_todas_las_urls_de_configuracion_responden(): void
    {
        // **Test de humo de la sección.** Se han retirado tres pantallas —el listado de
        // roles, el de permisos y la gemela de Productos— y sus URLs se han dejado
        // redirigidas porque están en favoritos y compartidas por chat. Este test fija que
        // ninguna de las catorce rutas de la sección da un error.
        $this->admin();

        $tipo = Type::create(['name' => 'Moodle LMS', 'shortname' => 'moodle']);
        $rol = Role::create(['name' => 'Coordinador', 'guard_name' => 'web']);
        $permiso = $this->permiso('admin.clients.index', 'clients');

        $rutas = [
            // Las que se quedan.
            ['configurations.index', []],
            ['configurations.access', []],
            ['configurations.manager.index', []],
            ['configurations.catalogs', []],
            ['configurations.own-plugins', []],
            ['configurations.environments-types.create', []],
            ['configurations.environments-types.edit', [$tipo->id]],
            ['configurations.roles.create', []],
            ['configurations.roles.edit', [$rol->id]],
            ['configurations.permissions.edit', [$permiso->id]],
            // Las retiradas, que redirigen.
            ['configurations.roles.index', []],
            ['configurations.permissions.index', []],
            ['configurations.products.index', []],
            ['configurations.products.create', []],
        ];

        foreach ($rutas as [$nombre, $parametros]) {
            $respuesta = $this->get(route($nombre, $parametros));

            $this->assertContains(
                $respuesta->getStatusCode(),
                [200, 302],
                'La ruta ' . $nombre . ' responde ' . $respuesta->getStatusCode()
            );
        }
    }

    public function test_las_pantallas_retiradas_llevan_a_donde_vive_ahora_lo_suyo(): void
    {
        // Un enlace guardado no debe morir: tiene que llevar a donde está ahora lo que
        // buscaba. Roles y permisos, a Acceso; el catálogo de producto, a Productos.
        $this->admin();

        $this->get(route('configurations.roles.index'))
            ->assertRedirect('/configurations/access');
        $this->get(route('configurations.permissions.index'))
            ->assertRedirect('/configurations/access');
        $this->get(route('configurations.products.index'))
            ->assertRedirect('/products');
    }

    /** Un entorno de un cliente, con su tipo. */
    private function entorno(Client $cliente, string $dominio, Type $tipo): Environment
    {
        return Environment::create([
            'name' => $dominio,
            'domain' => 'https://' . $dominio,
            'env' => 'pro',
            'version' => '5.1.1',
            'client_id' => $cliente->id,
            'type_id' => $tipo->id,
            'active' => true,
            'refresh_at' => now()->subHour(),
        ]);
    }
}
