<?php

namespace Tests\Feature\Api;

use App\Http\Middleware\ProductToken;
use App\Models\Clients\Client;
use App\Models\Environments\Environment;
use App\Models\Products\LicenseToken;
use App\Models\Products\Product;
use App\Services\Api\ActionResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use ReflectionClass;
use Tests\TestCase;

/**
 * Ninguna acción sirve nada a un host que no está dado de alta.
 *
 * **Esto existe por un agujero real que estuvo abierto y que ningún test veía.**
 * `features`, `tutorials` y `resources` no comprobaban el entorno: bastaba un token
 * válido para que cualquier dominio —registrado o no— se llevara el contenido de pago
 * del cliente. Se descubrió mirando una petición de un sitio de Animalaria cuyo dominio
 * no estaba de alta: su `licence` se iba con un 2002 y, veintidós segundos después, su
 * `features` devolvía 200 y nnn KB de contenido.
 *
 * **Y no fue una decisión de nadie.** Las siete ramas de contenido del middleware eran
 * el mismo bloque de cuarenta líneas copiado, y tres de las copias perdieron por el
 * camino el `if (!$environment)` de la primera. El comentario que lo justificaba
 * —«no requiere entorno, similar a setup»— era falso: `setup` sí lo requería.
 *
 * Por eso este test no comprueba tres acciones: **recorre el catálogo entero**. Lo que
 * falló no fue el criterio de nadie, fue que copiar la regla siete veces sale mal tarde
 * o temprano, así que lo que hay que defender es que **una acción nueva no pueda
 * escribirse sin declarar qué comprueba**.
 */
class ReglasDeSeguridadPorAccionTest extends TestCase
{
    use RefreshDatabase;

    private const TOKEN = '3IP-TEST-REGLAS-01';

    /** El dominio que SÍ está dado de alta en la licencia. */
    private const DOMINIO = 'https://dado-de-alta.test';

    /** Uno que no lo está en ninguna. Es el caso del agujero. */
    private const DOMINIO_AJENO = 'https://nadie-lo-dio-de-alta.test';

    private const PLUGIN = 'local_tresipunt';

    protected function setUp(): void
    {
        parent::setUp();

        $cliente = Client::create(['name' => 'Cliente de las reglas', 'shortname' => 'reglas', 'actived' => true]);

        $token = LicenseToken::create([
            'client_id' => $cliente->id,
            'name' => 'Licencia de las reglas',
            'token' => self::TOKEN,
            'active' => true,
        ]);

        Environment::create([
            'name' => 'Entorno dado de alta',
            'domain' => self::DOMINIO,
            'version' => '5.1.1',
            'env' => 'pro',
            'client_id' => $cliente->id,
            'license_token_id' => $token->id,
            'active' => true,
        ]);

        // El producto va contratado y en vigor: así, si algo se rechaza, es por el host
        // y no porque falte la licencia —que es el otro motivo posible y confundiría—.
        $producto = Product::create(['name' => 'Plugin Tresipunt', 'slug' => self::PLUGIN, 'actived' => true]);

        $token->products()->attach($producto->id, ['status' => 'active']);
    }

    /** Las doce acciones que el Manager sabe atender. */
    public static function todasLasAcciones(): array
    {
        $mapa = (new ReflectionClass(ActionResolver::class))->getConstant('ACTION_MAP');

        return array_map(fn (string $accion) => [$accion], array_keys($mapa));
    }

    private function llamar(string $accion, string $host): \Illuminate\Testing\TestResponse
    {
        return $this->withHeaders([
            'Authorization' => 'Bearer ' . self::TOKEN,
            'Accept' => 'application/json',
        ])->postJson('/api/v1', [
            'action' => $accion,
            'host' => $host,
            'plugin' => self::PLUGIN,
            'version' => '2026090301',
            // `data` y `plugins` escriben, así que se les manda un cuerpo mínimo: sin él
            // el rechazo podría venir de la validación y no de la regla que se prueba.
            'data' => ['users' => 1, 'plugins' => []],
            'site' => ['version' => '2025100601', 'release' => '5.1.1', 'type' => 'moodle', 'env' => 'pro'],
        ]);
    }

    /**
     * Cómo rechaza cada acción a un host sin dar de alta: `[http, código]`.
     *
     * **Se escriben aquí a mano y no se leen de `REGLAS`**, que sería dar por buena la
     * tabla comparándola consigo misma. Esto es el contrato con los sitios: si alguien
     * cambia un código, este test tiene que quejarse para que la decisión se tome a
     * propósito y se avise, no de refilón.
     *
     * Que la misma causa devuelva seis códigos distintos es un defecto conocido y
     * heredado; unificarlos rompe a los sitios en producción, así que va aparte.
     *
     * @return array<string, array{int, int}>
     */
    private const RECHAZO_ESPERADO = [
        'licence'   => [401, 2002],
        'products'  => [401, 2002],
        'setup'     => [401, 3000],
        'scss'      => [401, 3000],
        'scss-cdn'  => [401, 3000],
        'js'        => [401, 4000],
        'tutorials' => [401, 4000],
        'features'  => [401, 5000],
        'resources' => [401, 5000],
        'sync'      => [404, 404],
        'data'      => [403, 403],
        'plugins'   => [403, 403],
    ];

    /**
     * @dataProvider todasLasAcciones
     */
    public function test_ninguna_accion_atiende_a_un_host_que_no_esta_dado_de_alta(string $accion): void
    {
        $this->assertArrayHasKey($accion, self::RECHAZO_ESPERADO,
            "«{$accion}» es nueva y no dice cómo rechaza a un host sin dar de alta. Añádela aquí.");

        [$http, $codigo] = self::RECHAZO_ESPERADO[$accion];

        $respuesta = $this->llamar($accion, self::DOMINIO_AJENO);

        // **El rechazo concreto, y no un «no ha dado 200» cualquiera.** La primera
        // versión de este test solo miraba que no fuera 200 y pasaba con el middleware
        // agujereado: `features` respondía «no hay contenido publicado» —que tampoco es
        // 200— y el test se daba por satisfecho sin haber comprobado el host siquiera.
        $respuesta->assertStatus($http);

        $this->assertSame($codigo, $respuesta->json('code'),
            "«{$accion}» tiene que rechazar por host no vinculado, y ha respondido otra cosa");
    }

    /**
     * @dataProvider todasLasAcciones
     */
    public function test_cada_accion_declara_que_comprueba(string $accion): void
    {
        // **La defensa de verdad.** Si alguien añade la acción número trece al
        // `ActionResolver` y no la declara aquí, cae en `REGLA_POR_DEFECTO` —que es
        // estricta— pero nadie se entera de que se la ha dejado sin pensar. Este test
        // obliga a pensarla.
        $reglas = (new ReflectionClass(ProductToken::class))->getConstant('REGLAS');

        $this->assertArrayHasKey($accion, $reglas,
            "«{$accion}» no declara sus reglas en ProductToken::REGLAS. Di qué comprueba: "
            . "si sirve contenido de un producto concreto lleva 'producto'; si escribe el "
            . "estado del sitio, no.");

        $this->assertArrayHasKey('entorno', $reglas[$accion],
            "«{$accion}» no dice cómo rechaza a un host sin dar de alta");
    }

    public function test_las_tres_que_estaban_abiertas_rechazan_con_el_codigo_de_su_familia(): void
    {
        // Las familias están en `api/error-codes.md`: 4xxx para js y tutoriales, 5xxx
        // para funcionalidades y recursos. El `X000` es «entorno no encontrado».
        $esperado = [
            'features' => 5000,
            'resources' => 5000,
            'tutorials' => 4000,
        ];

        foreach ($esperado as $accion => $codigo) {
            $respuesta = $this->llamar($accion, self::DOMINIO_AJENO);

            $respuesta->assertStatus(401);

            $this->assertSame($codigo, $respuesta->json('code'),
                "«{$accion}» debería rechazar con {$codigo}");
        }
    }

    public function test_el_host_dado_de_alta_sigue_recibiendo_servicio(): void
    {
        // La otra mitad: cerrar el agujero no puede cortarle a quien sí está de alta.
        // No se comprueba el cuerpo —puede no haber contenido publicado, que es un
        // `*002` y es una respuesta correcta—, solo que no se le echa por el host.
        foreach (['features', 'tutorials', 'resources'] as $accion) {
            $respuesta = $this->llamar($accion, self::DOMINIO);

            $this->assertNotContains($respuesta->json('code'), [4000, 5000],
                "«{$accion}» está rechazando por host a un entorno que sí está dado de alta");
        }
    }

    public function test_las_cuatro_que_no_piden_un_producto_no_exigen_plugin(): void
    {
        // `products` pide la lista entera, y `sync`, `data` y `plugins` no piden nada:
        // escriben. Exigirles `plugin` sería inventarse un parámetro que el sitio no
        // manda para esas acciones, y romperlas.
        $reglas = (new ReflectionClass(ProductToken::class))->getConstant('REGLAS');

        foreach (['products', 'sync', 'data', 'plugins'] as $accion) {
            $this->assertArrayNotHasKey('producto', $reglas[$accion],
                "«{$accion}» no pide un producto concreto: no debe exigir plugin");
        }
    }

    public function test_las_ocho_que_sirven_contenido_exigen_producto_contratado(): void
    {
        $reglas = (new ReflectionClass(ProductToken::class))->getConstant('REGLAS');

        $deContenido = ['licence', 'setup', 'scss', 'scss-cdn', 'js', 'features', 'tutorials', 'resources'];

        foreach ($deContenido as $accion) {
            $this->assertArrayHasKey('producto', $reglas[$accion],
                "«{$accion}» sirve contenido de un producto y tiene que comprobar que está contratado");
        }
    }
}
