<?php

namespace Tests\Feature\Api;

use App\Livewire\ApiLogs\Index as VisorDeLogs;
use App\Models\ApiRequestLog;
use App\Models\Auth\User;
use App\Models\Clients\Client;
use App\Models\Environments\Environment;
use App\Models\Products\LicenseToken;
use App\Services\Api\Motivo;
use App\Services\Api\Severidad;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Tests\TestCase;

/**
 * Un rechazo del middleware se registra sabiendo de quién es.
 *
 * **El hallazgo.** `ApiRequestLogger` saca el cliente, la licencia y el entorno de cada
 * fila de `_license_token` y `_environment`, que `ProductToken` inyectaba en la petición
 * **al final**, después de todos los rechazos. Así que los seis `403` de ese middleware
 * —cliente inactivo, token inactivo, token aún no válido, token expirado, entorno inactivo
 * y host no vinculado— se guardaban con los tres campos a `null`, teniéndolos en la mano.
 *
 * Y son justo las peticiones de las que más falta hace saber de quién son:
 *
 * 1. En el visor no se podían filtrar por cliente ni por sitio.
 * 2. Salían en la lista de hosts marcadas como «no es de ningún entorno», o sea como *un
 *    Moodle llamando que no está dado de alta* — cuando está dado de alta y lo que pasa es
 *    que está apagado, o que su licencia caducó.
 * 3. Y el recorte de entornos apagados del visor no las alcanzaba, porque deja fuera a
 *    propósito las filas sin entorno, que son las de host desconocido de verdad.
 *
 * **Lo que este test fija, y lo que no.** El cliente y la licencia se atribuyen en los
 * seis casos. El entorno, **solo cuando ya se ha resuelto el host**: los cuatro rechazos
 * de licencia y cliente ocurren antes de mirar el host, y adelantar esa resolución
 * cambiaría el orden de los rechazos —un cliente de baja llamando con un host mal escrito
 * recibiría «host no vinculado» en lugar de «cliente inactivo», que es peor y está
 * decidido al contrario a propósito en el middleware—.
 */
class AtribucionDeLosRechazosTest extends TestCase
{
    use RefreshDatabase;

    private const TOKEN = '3IP-RECHAZOS';

    private const DOMINIO = 'https://rechazos.test';

    private Client $cliente;

    private LicenseToken $licencia;

    private Environment $entorno;

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

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

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

        $this->entorno = Environment::create([
            'client_id' => $this->cliente->id,
            'license_token_id' => $this->licencia->id,
            'name' => 'Sitio de rechazos',
            'domain' => self::DOMINIO,
            'version' => '5.1.1',
            'env' => 'pro',
            'active' => true,
        ]);
    }

    /**
     * Una llamada a `licence`.
     *
     * `conSitio` añade el bloque `site`, que la validación exige para que la petición
     * llegue a responderse bien. Los rechazos de este test ocurren **antes** de esa
     * validación, así que para ellos no hace falta y se dejan fuera: cuanto menos payload,
     * más claro es qué está provocando el rechazo.
     */
    private function llamar(
        string $host = self::DOMINIO,
        string $token = self::TOKEN,
        bool $conSitio = false
    ): \Illuminate\Testing\TestResponse {
        $cuerpo = [
            'action' => 'licence',
            'host' => $host,
            'plugin' => 'local_tresipunt',
            'version' => '2026080604',
        ];

        if ($conSitio) {
            $cuerpo['site'] = [
                'version' => '2025100601.03',
                'release' => '5.1.1+ (Build: 20251219)',
                'type' => 'moodle',
                'env' => 'pro',
            ];
        }

        return $this->withHeaders([
            'Authorization' => 'Bearer ' . $token,
            'Accept' => 'application/json',
        ])->postJson('/api/v1', $cuerpo);
    }

    private function ultimaFila(): ApiRequestLog
    {
        return ApiRequestLog::latest('id')->firstOrFail();
    }

    /* ============ Los cuatro de licencia y cliente ============ */

    public function test_un_cliente_de_baja_queda_atribuido(): void
    {
        $this->cliente->update(['actived' => false]);

        $this->llamar()->assertStatus(403);

        $fila = $this->ultimaFila();

        $this->assertSame('Cliente inactivo', $fila->error);
        $this->assertSame($this->cliente->id, $fila->client_id,
            'sin esto, «cliente inactivo» es una fila que no se puede atribuir a ningún cliente');
        $this->assertSame($this->licencia->id, $fila->license_token_id);
    }

    public function test_una_licencia_apagada_queda_atribuida(): void
    {
        $this->licencia->update(['active' => false]);

        $this->llamar()->assertStatus(403);

        $fila = $this->ultimaFila();

        $this->assertSame('Token inactivo', $fila->error);
        $this->assertSame($this->cliente->id, $fila->client_id);
        $this->assertSame($this->licencia->id, $fila->license_token_id);
    }

    public function test_una_licencia_caducada_queda_atribuida(): void
    {
        // Es el caso que Comercial tiene que ver: hay que renovar. Sin cliente en la fila,
        // no había forma de llegar a quién.
        $this->licencia->update(['end_at' => now()->subDay()]);

        $this->llamar()->assertStatus(403);

        $fila = $this->ultimaFila();

        $this->assertStringStartsWith('Token expirado', $fila->error);
        $this->assertSame($this->cliente->id, $fila->client_id);
        $this->assertSame($this->licencia->id, $fila->license_token_id);
    }

    public function test_una_licencia_que_aun_no_empieza_queda_atribuida(): void
    {
        $this->licencia->update(['start_at' => now()->addDay()]);

        $this->llamar()->assertStatus(403);

        $fila = $this->ultimaFila();

        $this->assertStringStartsWith('Token aún no válido', $fila->error);
        $this->assertSame($this->cliente->id, $fila->client_id);
        $this->assertSame($this->licencia->id, $fila->license_token_id);
    }

    /* ============ Los dos que ya conocen el host ============ */

    public function test_un_entorno_apagado_queda_atribuido_a_su_entorno(): void
    {
        // **El más importante de los seis.** Aquí el middleware tiene el entorno en la
        // mano —lo acaba de resolver para comprobar su `active`—, así que la fila puede
        // decir exactamente qué sitio es.
        $this->entorno->update(['active' => false]);

        $this->llamar()->assertStatus(403);

        $fila = $this->ultimaFila();

        $this->assertSame('Entorno inactivo', $fila->error);
        $this->assertSame($this->entorno->id, $fila->environment_id);
        $this->assertSame($this->cliente->id, $fila->client_id);
        $this->assertSame($this->licencia->id, $fila->license_token_id);
    }

    public function test_un_host_que_no_es_de_esta_licencia_sigue_sin_entorno_pero_con_cliente(): void
    {
        // Aquí **no hay entorno que atribuir**, y eso es el dato: el host no es de esta
        // licencia. Pero sí se sabe quién llamó, porque el token es válido.
        $this->llamar(host: 'https://otro-sitio.test')->assertStatus(401);

        $fila = $this->ultimaFila();

        $this->assertNull($fila->environment_id);
        $this->assertSame($this->cliente->id, $fila->client_id);
        $this->assertSame($this->licencia->id, $fila->license_token_id);
    }

    /* ============ Y con qué motivo queda cada uno ============ */

    /**
     * Cada rechazo real, con el motivo que le toca.
     *
     * **Se comprueba contra la API de verdad y no llamando a `Motivo::de()`.** El
     * clasificador ya tiene su test unitario; lo que aquí importa es que el mensaje que
     * escribe el middleware **siga siendo el que el clasificador reconoce**. Mientras el
     * `403` sea plano, el texto es lo único que separa seis situaciones: si alguien
     * reescribe «Token inactivo» sin tocar `Motivo`, esas filas se irían en silencio a
     * «Sin clasificar» y el reparto de la pantalla dejaría de contar lo que dice.
     *
     * @dataProvider losRechazos
     */
    public function test_cada_rechazo_queda_con_su_motivo(string $preparar, string $motivoEsperado): void
    {
        $this->{$preparar}();

        $this->llamar();

        $this->assertSame($motivoEsperado, $this->ultimaFila()->reason);
    }

    /** @return array<string, array{string, string}> */
    public static function losRechazos(): array
    {
        return [
            'cliente de baja' => ['ponerClienteDeBaja', Motivo::CLIENTE_DE_BAJA],
            'licencia apagada' => ['apagarLicencia', Motivo::LICENCIA_APAGADA],
            'licencia caducada' => ['caducarLicencia', Motivo::LICENCIA_CADUCADA],
            'licencia sin empezar' => ['adelantarLicencia', Motivo::LICENCIA_SIN_EMPEZAR],
            'entorno apagado' => ['apagarEntorno', Motivo::ENTORNO_APAGADO],
            'producto no contratado' => ['dejarProductoFueraDeLaLicencia', Motivo::PRODUCTO_NO_CONTRATADO],
            'producto que no existe' => ['noDarDeAltaElProducto', Motivo::PRODUCTO_DESCONOCIDO],
        ];
    }

    private function ponerClienteDeBaja(): void
    {
        $this->cliente->update(['actived' => false]);
    }

    private function apagarLicencia(): void
    {
        $this->licencia->update(['active' => false]);
    }

    private function caducarLicencia(): void
    {
        $this->licencia->update(['end_at' => now()->subDay()]);
    }

    private function adelantarLicencia(): void
    {
        $this->licencia->update(['start_at' => now()->addDay()]);
    }

    private function apagarEntorno(): void
    {
        $this->entorno->update(['active' => false]);
    }

    /** El producto existe en el catálogo, pero la licencia no lo incluye. */
    private function dejarProductoFueraDeLaLicencia(): void
    {
        \App\Models\Products\Product::create([
            'name' => 'Tresipunt', 'slug' => 'local_tresipunt', 'shortname' => 'tresipunt',
        ]);
    }

    /** Y el otro lado de la misma moneda: el catálogo no lo tiene. */
    private function noDarDeAltaElProducto(): void
    {
        // Nada que preparar: el producto no está, y eso es el caso.
    }

    public function test_una_peticion_respondida_no_lleva_motivo(): void
    {
        $this->dejarProductoFueraDeLaLicencia();
        $producto = \App\Models\Products\Product::where('slug', 'local_tresipunt')->firstOrFail();
        $this->licencia->products()->attach($producto->id, ['status' => 'active']);

        $this->llamar(conSitio: true)->assertOk();

        $this->assertSame(Motivo::NINGUNO, $this->ultimaFila()->reason);
    }

    /* ============ Lo que no cambia ============ */

    public function test_un_token_que_no_existe_sigue_sin_poder_atribuirse(): void
    {
        // Y es correcto: no hay a quién atribuirlo. Es la diferencia entre «no sé quién
        // eres» —un fallo de autenticación— y «sé quién eres y no tienes derecho».
        $this->llamar(token: '3IP-NO-EXISTE')->assertStatus(401);

        $fila = $this->ultimaFila();

        $this->assertNull($fila->client_id);
        $this->assertNull($fila->license_token_id);
        $this->assertNull($fila->environment_id);
        $this->assertSame(Severidad::ERROR, $fila->severity,
            'un token desconocido es un error de verdad: alguien está llamando mal');
    }

    public function test_una_peticion_buena_sigue_atribuida_como_antes(): void
    {
        // La red de seguridad del cambio: las inyecciones se han movido hacia arriba, así
        // que hay que comprobar que el camino que ya funcionaba sigue igual.
        //
        // Hace falta que la licencia incluya el producto: sin él, `licence` responde
        // «licence does not include» y esto no probaría el camino bueno.
        $producto = \App\Models\Products\Product::create([
            'name' => 'Tresipunt', 'slug' => 'local_tresipunt', 'shortname' => 'tresipunt',
        ]);
        $this->licencia->products()->attach($producto->id, ['status' => 'active']);

        $this->llamar(conSitio: true)->assertOk();

        $fila = $this->ultimaFila();

        $this->assertSame($this->entorno->id, $fila->environment_id);
        $this->assertSame($this->cliente->id, $fila->client_id);
        $this->assertSame($this->licencia->id, $fila->license_token_id);
    }

    /* ============ Y lo que esto desbloquea en el visor ============ */

    public function test_el_visor_ya_puede_filtrar_esos_rechazos_por_cliente(): void
    {
        $this->licencia->update(['end_at' => now()->subDay()]);
        $this->llamar()->assertStatus(403);

        $componente = $this->visorPara()->set('clientId', $this->cliente->id);

        $this->assertSame(1, $componente->viewData('total'),
            'antes esta fila no tenía cliente, así que filtrar por cliente la escondía');
    }

    public function test_el_rechazo_de_un_entorno_apagado_ya_lo_recorta_el_visor(): void
    {
        // **Las dos mitades encajando.** El visor no cuenta los entornos apagados; hasta
        // ahora este rechazo se le escapaba porque la fila no decía de qué entorno era, y
        // las filas sin entorno se dejan fuera del recorte a propósito —son las de host
        // desconocido, que es justo lo que hay que ver—.
        $this->entorno->update(['active' => false]);
        $this->llamar()->assertStatus(403);

        $componente = $this->visorPara();

        $this->assertSame(0, $componente->viewData('total'));
        $this->assertSame(1, $componente->viewData('apagadosOcultos'),
            'y se dice cuántas se están escondiendo, en lugar de esconderlas en silencio');

        $componente->set('incluirApagados', true);

        $this->assertSame(1, $componente->viewData('total'));
    }

    private function visorPara(): \Livewire\Features\SupportTesting\Testable
    {
        Permission::firstOrCreate(
            ['name' => 'admin.api-logs.index', 'guard_name' => 'web'],
            ['model' => 'api-logs', 'description' => 'ver']
        );

        $usuario = User::factory()->create();
        $usuario->givePermissionTo('admin.api-logs.index');
        $this->actingAs($usuario);

        Cache::flush();

        // **Viéndolo todo**: la pantalla abre por «errores sin revisar» y estos rechazos son
        // de severidad `negocio`, así que sin esto no saldrían y el test estaría midiendo el
        // filtro de severidad en lugar de la atribución.
        return Livewire::test(VisorDeLogs::class)->set('severity', '');
    }
}
