<?php

namespace Tests\Feature\Api;

use App\Models\ApiRequestLog;
use App\Models\Clients\Client;
use App\Models\Environments\Environment;
use App\Models\Products\LicenseToken;
use App\Services\Api\ActionResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;

/**
 * La API no cuenta lo que ha fallado por dentro (MGR-007).
 *
 * El controlador devolvía `$e->getMessage()` tal cual, así que cualquiera con un
 * token —o sin él, según el camino— podía provocar un error y leer nombres de clase,
 * errores de MySQL con tabla y columna, o rutas del servidor. A cambio de quitar el
 * detalle se devuelve el `request_uuid`, que es lo que soporte necesita para buscar
 * la petición en `/api-logs`.
 */
class ErrorDetailLeakTest extends TestCase
{
    use RefreshDatabase;

    private const TOKEN = '3IP-TEST-LEAK-0001';
    private const DOMAIN = 'https://leak.test';

    /** Detalle que NUNCA debe salir por la red. */
    private const DETALLE_INTERNO = "SQLSTATE[42S02]: Base table or view not found: 1146 Table 'laravel.secretos' doesn't exist";

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

        $client = Client::create([
            'name' => 'Cliente Fuga',
            'shortname' => 'fuga',
            'actived' => true,
        ]);

        $token = LicenseToken::create([
            'client_id' => $client->id,
            'name' => 'Licencia Fuga',
            'token' => self::TOKEN,
            'active' => true,
        ]);

        Environment::create([
            'name' => 'Entorno Fuga',
            'domain' => self::DOMAIN,
            'version' => '5.1.1',
            'env' => 'local',
            'client_id' => $client->id,
            'license_token_id' => $token->id,
            'active' => true,
        ]);
    }

    private function llamar(string $action = 'products'): \Illuminate\Testing\TestResponse
    {
        return $this->withHeaders([
            'Authorization' => 'Bearer ' . self::TOKEN,
            'Accept' => 'application/json',
        ])->postJson('/api/v1', [
            'action' => $action,
            'host' => self::DOMAIN,
            'plugin' => 'local_tresipunt',
            'version' => '2026080604',
            'site' => [
                'version' => '2025100601.03',
                'release' => '5.1.1+ (Build: 20251219)',
                'type' => 'moodle',
                'env' => 'local',
            ],
        ]);
    }

    /** Fuerza la excepción que se quiera desde dentro de la acción. */
    private function haciendoQueFalle(\Throwable $e): void
    {
        $this->mock(ActionResolver::class, function ($mock) use ($e) {
            $mock->shouldReceive('resolve')->andThrow($e);
        });
    }

    public function test_un_error_interno_no_devuelve_el_detalle(): void
    {
        $this->haciendoQueFalle(new \RuntimeException(self::DETALLE_INTERNO));

        $respuesta = $this->llamar();

        $respuesta->assertStatus(500)
            ->assertJsonPath('success', false)
            ->assertJsonPath('code', 500);

        $this->assertStringNotContainsString('SQLSTATE', $respuesta->getContent());
        $this->assertStringNotContainsString('secretos', $respuesta->getContent());
        $this->assertStringNotContainsString("doesn't exist", $respuesta->getContent());
    }

    public function test_tampoco_delata_los_nombres_de_clase_internos(): void
    {
        // El caso real: ActionResolver lanza \RuntimeException("La clase de acción
        // 'App\Services\Api\Actions\XAction' no existe").
        $this->haciendoQueFalle(new \RuntimeException(
            "La clase de acción 'App\Services\Api\Actions\ProductsAction' no existe."
        ));

        $respuesta = $this->llamar();

        $this->assertStringNotContainsString('App\\Services', $respuesta->getContent());
        $this->assertStringNotContainsString('ProductsAction', $respuesta->getContent());
    }

    public function test_devuelve_una_referencia_para_poder_buscar_la_peticion(): void
    {
        $this->haciendoQueFalle(new \RuntimeException(self::DETALLE_INTERNO));

        $respuesta = $this->llamar();

        $uuid = $respuesta->json('data.request_uuid');

        $this->assertNotEmpty($uuid, 'La respuesta tiene que traer el request_uuid.');
        $this->assertStringContainsString('Referencia: ' . $uuid, $respuesta->json('error'));

        // Y esa referencia tiene que encontrar la petición en el visor de logs, que
        // es de lo que sirve: filtra por `request_uuid`.
        $this->assertDatabaseHas('api_request_logs', ['request_uuid' => $uuid]);
    }

    public function test_el_detalle_completo_queda_en_el_log(): void
    {
        Log::spy();

        $this->haciendoQueFalle(new \RuntimeException(self::DETALLE_INTERNO));

        $this->llamar();

        // Quitar el detalle de la respuesta no vale nada si además se pierde: tiene
        // que estar en el canal `api` con el contexto de la petición.
        Log::shouldHaveReceived('channel')->with('api');
    }

    public function test_un_error_de_php_tambien_sale_en_el_sobre_de_la_api(): void
    {
        // `\TypeError` no es `\Exception`: antes se escapaba al handler de Laravel y
        // el plugin recibía una página de error en vez del sobre JSON.
        $this->haciendoQueFalle(new \TypeError('Argument #1 must be of type int, string given'));

        $this->llamar()
            ->assertStatus(500)
            ->assertJsonPath('success', false)
            ->assertJsonStructure(['success', 'error', 'code', 'action', 'data']);
    }

    public function test_el_error_del_cliente_si_dice_que_ha_enviado_mal(): void
    {
        // Contrapartida: cuando la culpa es de quien llama, el mensaje se mantiene.
        // Sin él, el plugin no sabe qué corregir. Es el único mensaje de excepción
        // genérica que sigue viajando, y es deliberado.
        $this->haciendoQueFalle(new \InvalidArgumentException(
            'El plugin debe tener al menos name o component'
        ));

        $respuesta = $this->llamar();

        $respuesta->assertStatus(400)
            ->assertJsonPath('success', false)
            ->assertJsonPath('code', 400);

        $this->assertStringContainsString(
            'al menos name o component',
            $respuesta->json('error')
        );
    }

    public function test_el_entorno_no_encontrado_conserva_su_mensaje_y_su_codigo(): void
    {
        // Es un error de DOMINIO, no una avería: el Moodle tiene que poder leer que su
        // dominio no está dado de alta. Antes viajaba como `NotFoundHttpException` y
        // al ocultar los mensajes del framework se quedó sin texto; de ahí la
        // excepción propia.
        $this->haciendoQueFalle(new \App\Services\Api\Exceptions\EnvironmentNotFoundException());

        $respuesta = $this->llamar();

        $respuesta->assertStatus(404)
            // 404/404 es lo que ya devolvía el middleware para `sync`: no se toca,
            // el plugin ramifica por `code`.
            ->assertJsonPath('code', 404);

        $this->assertStringContainsString('Environment not found', $respuesta->json('error'));
        $this->assertStringNotContainsString('Referencia:', $respuesta->json('error'));
    }

    public function test_una_accion_inexistente_la_corta_la_validacion_antes(): void
    {
        // Ojo al camino real: el `in:` del FormRequest rechaza la acción con 422
        // ANTES de llegar al controlador, así que el "Acción no reconocida" del
        // ActionResolver no se ve por HTTP. Queda escrito para que nadie lo busque
        // dos veces.
        $this->llamar('accion-que-no-existe')->assertStatus(422);
    }

    public function test_la_accion_se_mantiene_en_la_respuesta_de_error(): void
    {
        // Retrocompatibilidad: el sobre no cambia de forma. `action` sigue viniendo.
        $this->haciendoQueFalle(new \RuntimeException(self::DETALLE_INTERNO));

        $this->llamar()
            ->assertJsonPath('action', 'products')
            ->assertJsonStructure(['success', 'error', 'code', 'action', 'data']);
    }
}
