<?php

namespace Tests\Feature\Configurations;

use App\Livewire\Configurations\Environments\Index as CatalogosIndex;
use App\Livewire\Configurations\Envs\BorrarUsoModal;
use App\Livewire\Configurations\Envs\Create as CrearUso;
use App\Livewire\Configurations\Envs\Edit as EditarUso;
use App\Models\Auth\User;
use App\Models\Clients\Client;
use App\Models\Environments\Env;
use App\Models\Environments\Environment;
use App\Models\Products\LicenseToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Tests\TestCase;

/**
 * El catálogo de usos del entorno: `pro`, `pre`, `develop`, `local`.
 *
 * **El hueco que cierra:** eran cuatro cadenas en una constante de PHP. Añadir un uso
 * —«staging», «formación»— era tocar código y desplegar, y decidir cuáles se ven de entrada
 * en el listado de entornos no tenía pantalla ninguna. Es la novena aparición del patrón en
 * este release: *funcionalidad que parecía existir y no tenía puerta*.
 *
 * **Y lo delicado no es el CRUD, son las dos ataduras que tiene ese valor:**
 *
 * 1. `environments.env` guarda la **cadena**, no una clave ajena, porque ese valor lo manda
 *    cada Moodle en el payload de la API. Así que borrar o renombrar un uso en uso no da
 *    ningún error de integridad: deja sitios con un valor que ya no significa nada y cuyo
 *    `sync` empieza a rechazarse.
 * 2. `environments.domain_unique` es una columna generada que compara `env` con la cadena
 *    literal `'local'`, y de ella cuelga el índice único del dominio. Renombrar ese uso
 *    dejaría a los locales nuevos sin la exención que permite que varios desarrolladores
 *    compartan `moodle.test`.
 *
 * Esos dos son los que se prueban aquí de verdad. El resto es que la puerta exista.
 */
class CatalogoDeUsosTest extends TestCase
{
    use RefreshDatabase;

    /** @param  list<string>  $permisos */
    private function usuario(array $permisos): User
    {
        foreach ($permisos as $permiso) {
            // La tabla `permissions` de este proyecto tiene `model` NOT NULL.
            Permission::firstOrCreate(
                ['name' => $permiso, 'guard_name' => 'web'],
                ['model' => 'configurations', 'description' => $permiso]
            );
        }

        $usuario = User::factory()->create();
        $usuario->givePermissionTo($permisos);
        $this->actingAs($usuario);

        Cache::flush();
        Env::olvidarCatalogo();

        return $usuario;
    }

    private function entornoCon(string $env): Environment
    {
        $cliente = Client::create([
            'name' => 'Cliente ' . $env,
            'shortname' => 'c' . uniqid(),
            'actived' => true,
        ]);

        return Environment::create([
            'client_id' => $cliente->id,
            'name' => 'Sitio de ' . $env,
            'domain' => 'https://' . uniqid() . '.test',
            'version' => '5.1.1',
            'env' => $env,
            'active' => true,
        ]);
    }

    private function uso(string $shortname): Env
    {
        return Env::query()->where('shortname', $shortname)->firstOrFail();
    }

    /* ===================== La semilla ===================== */

    public function test_la_migracion_deja_los_cuatro_de_siempre(): void
    {
        // La semilla no es un detalle: es lo que garantiza que el cambio de constante a
        // catálogo **no cambia nada** para quien ya usaba el panel.
        $this->assertSame(['pro', 'pre', 'develop', 'local'], Env::todos(),
            'el orden es el contrato: es el de los botones del filtro y el de la URL');

        $this->assertSame(['pro', 'pre', 'develop'], Env::visiblesPorDefecto(),
            'lo que hacía la constante ENVS_POR_DEFECTO: todos menos local');

        $this->assertSame(
            ['var(--teal-700)', 'var(--teal-100)'],
            Env::colorDe('pro'),
            'los colores son los que ya usaba la fila del listado'
        );
    }

    public function test_un_uso_que_no_esta_en_el_catalogo_se_pinta_en_gris_y_no_revienta(): void
    {
        // `environments.env` es una cadena, así que puede haber un valor guardado cuyo uso
        // ya no exista. Pintarlo neutro es correcto; reventar la ficha del entorno, no.
        $this->assertSame(
            ['var(--neutral-600)', 'var(--surface-sunken)'],
            Env::colorDe('lo-que-sea')
        );
        $this->assertSame(['var(--neutral-600)', 'var(--surface-sunken)'], Env::colorDe(null));
    }

    /* ===================== Alta ===================== */

    public function test_se_puede_crear_un_uso_y_aparece_en_todas_partes(): void
    {
        $this->usuario(['admin.configurations.envs.create']);

        Livewire::test(CrearUso::class)
            ->set('shortname', 'staging')
            ->set('name', 'Staging')
            ->set('description', 'El previo del previo')
            ->set('color', 'ambar')
            ->set('visible_por_defecto', false)
            ->call('store')
            ->assertHasNoErrors();

        $uso = $this->uso('staging');

        $this->assertSame('Staging', $uso->name);
        $this->assertFalse($uso->visible_por_defecto);
        $this->assertSame(Env::PALETA['ambar']['texto'], $uso->color_text);

        // **Y llega a los tres sitios que importan, sin tocar código:**
        // el vocabulario de los formularios…
        $this->assertArrayHasKey('staging', Environment::envs());
        // …la lista de valores válidos…
        $this->assertContains('staging', Environment::entornos());
        // …y por tanto la regla que valida las cinco puertas y la API.
        $this->assertStringContainsString('staging', Environment::reglaDeEnv());
    }

    public function test_el_uso_nuevo_va_al_final_del_orden(): void
    {
        // Meterlo en medio movería los botones que la gente ya tiene aprendidos.
        $this->usuario(['admin.configurations.envs.create']);

        Livewire::test(CrearUso::class)
            ->set('shortname', 'staging')->set('name', 'Staging')
            ->call('store');

        $this->assertSame(['pro', 'pre', 'develop', 'local', 'staging'], Env::todos());
    }

    public function test_el_shortname_con_mayusculas_se_rechaza(): void
    {
        // **No es cosmética.** Este valor se compara tal cual con lo que manda el plugin
        // del cliente, así que `Pro` y `pro` serían dos usos distintos para la base y el
        // mismo para quien los lee. Y para la columna generada del dominio, `'Local'` no
        // es `'local'`: un entorno así perdería la exención del índice sin que nada avise.
        $this->usuario(['admin.configurations.envs.create']);

        Livewire::test(CrearUso::class)
            ->set('shortname', 'Staging')->set('name', 'Staging')
            ->call('store')
            ->assertHasErrors(['shortname' => 'regex']);

        $this->assertSame(4, Env::query()->count());
    }

    public function test_un_shortname_repetido_se_rechaza_aunque_el_uso_este_borrado(): void
    {
        // El índice de la base no distingue borrados, así que excluirlos en la validación
        // solo movería el fallo a una excepción de la base. Lo que se arregla es el mensaje.
        $this->usuario(['admin.configurations.envs.create', 'admin.configurations.envs.destroy']);

        $borrado = Env::create([
            'shortname' => 'staging', 'name' => 'Staging', 'orden' => 50,
            'color_text' => 'var(--warning-500)', 'color_bg' => 'var(--warning-50)',
        ]);
        $borrado->delete();

        Livewire::test(CrearUso::class)
            ->set('shortname', 'staging')->set('name', 'Otro staging')
            ->call('store')
            ->assertHasErrors(['shortname' => 'unique']);
    }

    public function test_sin_permiso_no_se_puede_crear(): void
    {
        // El permiso de la ruta no se reaplica en /livewire/update (MGR-005), así que se
        // comprueba en el componente — y al entrar, no solo al guardar: abrir un
        // formulario que no se va a poder enviar es una pantalla que engaña.
        $this->usuario(['admin.configurations.envs.edit']);

        Livewire::test(CrearUso::class)->assertForbidden();
    }

    /* ===================== Edición ===================== */

    public function test_el_shortname_no_se_puede_cambiar_si_hay_entornos_usandolo(): void
    {
        // **Cambiarlo aquí no renombra nada**: `environments.env` guarda la cadena. Esos
        // entornos se quedarían con un uso que el catálogo no reconoce —el filtro no los
        // encontraría— y su siguiente `sync` se rechazaría con un 422 que habla de un campo
        // que su configuración lleva bien puesto.
        $this->usuario(['admin.configurations.envs.edit']);

        $this->entornoCon('pro');
        $pro = $this->uso('pro');

        $componente = Livewire::test(EditarUso::class, ['env' => $pro]);

        $this->assertTrue($componente->instance()->shortnameEsFijo());
        $this->assertSame(1, $componente->instance()->cuantosEntornos());

        // Y no basta con deshabilitar el campo en la vista: esconder un control no es
        // protegerlo, así que se manda el valor cambiado a mano.
        $componente->set('shortname', 'produccion')->call('update')->assertHasErrors('shortname');

        $this->assertSame('pro', $pro->fresh()->shortname);
    }

    public function test_lo_demas_si_se_puede_cambiar_con_entornos_usandolo(): void
    {
        // El bloqueo es del `shortname` y de nada más: el nombre, el color, la descripción
        // y la marca de «sale de entrada» no viajan a la base de los entornos.
        $this->usuario(['admin.configurations.envs.edit']);

        $this->entornoCon('pro');
        $pro = $this->uso('pro');

        Livewire::test(EditarUso::class, ['env' => $pro])
            ->set('name', 'Producción del cliente')
            ->set('color', 'naranja')
            ->set('visible_por_defecto', false)
            ->call('update')
            ->assertHasNoErrors();

        $pro->refresh();

        $this->assertSame('Producción del cliente', $pro->name);
        $this->assertSame(Env::PALETA['naranja']['texto'], $pro->color_text);
        $this->assertFalse($pro->visible_por_defecto);
    }

    public function test_el_shortname_de_local_no_se_cambia_ni_sin_entornos(): void
    {
        // **La atadura con la base.** `domain_unique` es una columna generada que compara
        // `env` con la cadena literal `local`, y de ella cuelga el índice único del
        // dominio: es lo que exime a los sitios locales para que varios desarrolladores
        // puedan compartir `moodle.test`. Renombrarlo daría un error de clave duplicada que
        // no menciona `env` en ninguna parte.
        $this->usuario(['admin.configurations.envs.edit']);

        $local = $this->uso('local');

        $this->assertSame(0, $local->cuantosEntornos(), 'sin entornos, y aun así fijo');
        $this->assertTrue($local->esExentoDelIndice());

        Livewire::test(EditarUso::class, ['env' => $local])
            ->set('shortname', 'localhost')
            ->call('update')
            ->assertHasErrors('shortname');

        $this->assertSame('local', $local->fresh()->shortname);
    }

    public function test_a_local_si_se_le_puede_quitar_la_marca_de_visible(): void
    {
        // Es la salida que ofrece la modal de borrado cuando bloquea: si ya no se quieren
        // ver, se les quita la marca y desaparecen del listado sin romper nada.
        $this->usuario(['admin.configurations.envs.edit']);

        $local = $this->uso('local');

        Livewire::test(EditarUso::class, ['env' => $local])
            ->set('visible_por_defecto', true)
            ->call('update')
            ->assertHasNoErrors();

        $this->assertSame(['pro', 'pre', 'develop', 'local'], Env::visiblesPorDefecto());
    }

    /* ===================== Borrado ===================== */

    public function test_un_uso_con_entornos_no_se_borra(): void
    {
        $this->usuario(['admin.configurations.envs.destroy']);

        $this->entornoCon('pro');
        $pro = $this->uso('pro');

        $componente = Livewire::test(BorrarUsoModal::class, ['usoId' => $pro->id]);

        $this->assertTrue($componente->instance()->estaBloqueado());
        $this->assertSame(1, $componente->instance()->impacto()['entornos']);

        // Se pulsa igual: esconder el botón no es protegerlo.
        $componente->call('borrar');

        $this->assertNotNull($pro->fresh(), 'el uso sigue en el catálogo');
        $this->assertContains('pro', Env::todos());
    }

    public function test_un_uso_con_entornos_apagados_tampoco_se_borra(): void
    {
        // Un entorno apagado se puede volver a encender, y entonces su uso tendría que
        // seguir existiendo. Cuentan todos, no solo los activos.
        $this->usuario(['admin.configurations.envs.destroy']);

        $this->entornoCon('pre')->update(['active' => false]);
        $pre = $this->uso('pre');

        $componente = Livewire::test(BorrarUsoModal::class, ['usoId' => $pre->id]);

        $this->assertTrue($componente->instance()->estaBloqueado());
        $this->assertSame(0, $componente->instance()->impacto()['activos'],
            'y la modal dice cuántos están en servicio, que es otra cosa');
    }

    public function test_local_no_se_borra_nunca(): void
    {
        $this->usuario(['admin.configurations.envs.destroy']);

        $local = $this->uso('local');

        $componente = Livewire::test(BorrarUsoModal::class, ['usoId' => $local->id]);

        $this->assertTrue($componente->instance()->estaBloqueado());
        $this->assertSame(0, $componente->instance()->impacto()['entornos'],
            'cero entornos y aun así bloqueado: es la fila de la que depende un índice');

        $componente->call('borrar');

        $this->assertNotNull($local->fresh());
    }

    public function test_un_uso_sin_entornos_se_borra_y_sale_de_todas_partes(): void
    {
        $this->usuario(['admin.configurations.envs.destroy']);

        $staging = Env::create([
            'shortname' => 'staging', 'name' => 'Staging', 'orden' => 50,
            'color_text' => 'var(--warning-500)', 'color_bg' => 'var(--warning-50)',
        ]);

        Livewire::test(BorrarUsoModal::class, ['usoId' => $staging->id])->call('borrar');

        // Lógico: se recupera. Y fuera del vocabulario, del filtro y de la validación.
        $this->assertSoftDeleted('envs', ['id' => $staging->id]);
        $this->assertNotContains('staging', Env::todos());
        $this->assertArrayNotHasKey('staging', Environment::envs());
        $this->assertStringNotContainsString('staging', Environment::reglaDeEnv());
    }

    public function test_sin_permiso_no_se_puede_borrar(): void
    {
        $this->usuario(['admin.configurations.envs.edit']);

        $local = $this->uso('local');

        Livewire::test(BorrarUsoModal::class, ['usoId' => $local->id])->assertForbidden();
    }

    /* ===================== La caché ===================== */

    public function test_el_catalogo_se_refresca_al_guardar_sin_que_nadie_lo_pida(): void
    {
        // El catálogo se cachea **para siempre** —lo lee cada `render()` de media docena de
        // pantallas y cambia tres veces al año—, así que la invalidación no puede depender
        // de que cada formulario se acuerde: la primera pantalla que no lo hiciera dejaría
        // el panel entero enseñando un catálogo viejo sin ninguna pista del motivo. Se tira
        // desde los eventos del modelo.
        $this->assertCount(4, Env::catalogo());

        Env::create([
            'shortname' => 'staging', 'name' => 'Staging', 'orden' => 50,
            'color_text' => 'var(--warning-500)', 'color_bg' => 'var(--warning-50)',
        ]);

        $this->assertCount(5, Env::catalogo(), 'el alta tira la caché');

        Env::query()->where('shortname', 'staging')->first()->delete();

        $this->assertCount(4, Env::catalogo(), 'y el borrado también');
    }

    /* ===================== La hoja de Catálogos ===================== */

    public function test_la_hoja_de_catalogos_lista_los_usos_con_sus_entornos(): void
    {
        $this->usuario(['admin.configurations.environments-types.index', 'admin.environments.index']);

        $this->entornoCon('pro');
        $this->entornoCon('pro');
        $this->entornoCon('local');

        $usos = Livewire::test(CatalogosIndex::class)->viewData('usos')->keyBy('shortname');

        $this->assertSame(2, $usos['pro']['entornos']);
        $this->assertSame(1, $usos['local']['entornos']);
        $this->assertSame(0, $usos['pre']['entornos']);
        $this->assertTrue($usos['local']['protegido']);
        $this->assertFalse($usos['pro']['protegido']);
    }

    public function test_la_hoja_avisa_de_los_entornos_con_un_uso_que_no_existe(): void
    {
        // No debería pasar —la migración dio de alta los que encontró—, pero si alguien
        // borra un uso que luego vuelve a llegar por la API, esos sitios se quedan
        // descolgados: el filtro no los encuentra y su sync se rechaza. Esta es la única
        // pantalla donde eso se puede ver.
        $this->usuario(['admin.configurations.environments-types.index']);

        $entorno = $this->entornoCon('pro');
        // A pelo: los formularios no lo permiten, y es justo el estado que hay que detectar.
        Environment::query()->where('id', $entorno->id)->update(['env' => 'fantasma']);

        $huerfanos = Livewire::test(CatalogosIndex::class)->viewData('usosHuerfanos');

        $this->assertSame(['fantasma'], $huerfanos);
    }

    /* ===================== La API pública ===================== */

    public function test_la_api_acepta_un_uso_nuevo_del_catalogo(): void
    {
        // **La atadura con el exterior.** La regla que valida `site.env` tenía la lista
        // escrita a mano, así que un uso nuevo dado de alta en el panel se rechazaba aquí
        // con un 422 que hablaba de un campo válido.
        Env::create([
            'shortname' => 'staging', 'name' => 'Staging', 'orden' => 50,
            'color_text' => 'var(--warning-500)', 'color_bg' => 'var(--warning-50)',
        ]);

        $respuesta = $this->llamarAlSync('staging');

        $respuesta->assertOk();
        $this->assertSame('staging', Environment::query()->where('domain', 'https://api-uso.test')->value('env'));
    }

    public function test_la_api_sigue_rechazando_un_uso_que_no_esta_en_el_catalogo(): void
    {
        $respuesta = $this->llamarAlSync('inventado');

        $respuesta->assertStatus(422);

        // **Y que el rechazo sea por el `env`.** La primera versión de este test daba por
        // bueno cualquier 422, y el que llegaba era «el campo data.plugins es obligatorio»:
        // el payload iba sin inventario. O sea que pasaba sin comprobar nada de lo que
        // dice comprobar, y su hermano de arriba fallaba por lo mismo.
        $this->assertStringContainsString('env', $respuesta->json('error'));
    }

    private function llamarAlSync(string $env): \Illuminate\Testing\TestResponse
    {
        $cliente = Client::create(['name' => 'Cliente API', 'shortname' => 'api', 'actived' => true]);

        $token = LicenseToken::create([
            'client_id' => $cliente->id,
            'name' => 'Licencia API',
            'token' => '3IP-TEST-USOS',
            'active' => true,
        ]);

        Environment::create([
            'client_id' => $cliente->id,
            'license_token_id' => $token->id,
            'name' => 'Sitio API',
            'domain' => 'https://api-uso.test',
            'version' => '5.1.1',
            'env' => 'pro',
            'active' => true,
        ]);

        return $this->withHeaders([
            'Authorization' => 'Bearer 3IP-TEST-USOS',
            'Accept' => 'application/json',
        ])->postJson('/api/v1', [
            'action' => 'sync',
            'host' => 'https://api-uso.test',
            'plugin' => 'local_tresipunt',
            'version' => '2026080604',
            'site' => [
                'version' => '2025100601.03',
                'release' => '5.1.1+ (Build: 20251219)',
                'type' => 'moodle',
                'env' => $env,
            ],
            // **Con un plugin dentro y no con la lista vacía**: `data.plugins` es
            // obligatorio en el `sync`, así que un inventario vacío se rechaza antes de
            // llegar a validar el `env` — y este test dejaría de probar lo que dice.
            'data' => ['plugins' => [[
                'name' => 'Tresipunt', 'component' => 'local_tresipunt', 'type' => 'local',
                'versiondisk' => '2026080604',
            ]]],
        ]);
    }
}
