<?php

namespace Tests\Feature\ApiLogs;

use App\Livewire\ApiLogs\Index;
use App\Models\ApiRequestLog;
use App\Models\Auth\User;
use App\Support\ConsultaAcotada;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
use PDOException;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/**
 * El tope de tiempo de las consultas del visor de peticiones.
 *
 * Lo que se fija: que **la pantalla se pinte igual** cuando una consulta no llega a
 * tiempo. `api_request_logs` es la tabla que más crece del sistema, y hasta ahora un
 * rango amplio o un filtro sin índice dejaba la página en blanco esperando —sin cabecera,
 * sin filtros y sin forma de corregir el filtro que la había colgado, porque los filtros
 * viven dentro de la página que no llegaba a pintarse—.
 */
class ConsultaAcotadaTest extends TestCase
{
    use RefreshDatabase;

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

        foreach (['admin.api-logs.index', 'admin.dashboard.index'] as $nombre) {
            $rol->givePermissionTo(Permission::firstOrCreate(
                ['name' => $nombre, 'guard_name' => 'web'],
                ['model' => 'api-logs', 'description' => $nombre]
            ));
        }

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

        Cache::flush();

        return $user;
    }

    /** La excepción que lanza MySQL 8 al saltar `max_execution_time`. */
    private function tiempoAgotadoDeMysql(): QueryException
    {
        $pdo = new PDOException('Query execution was interrupted, maximum statement execution time exceeded');
        $pdo->errorInfo = ['HY000', 3024, 'Query execution was interrupted'];

        return new QueryException('mysql', 'select * from api_request_logs', [], $pdo);
    }

    /* ===================== El helper ===================== */

    public function test_una_consulta_que_no_llega_a_tiempo_devuelve_el_valor_de_reserva(): void
    {
        // **No lanza.** Quien llama necesita seguir pintando la pantalla: es el objetivo
        // de todo esto.
        $resultado = ConsultaAcotada::ejecutar(
            fn () => throw $this->tiempoAgotadoDeMysql(),
            siSeAgota: 'nada',
            donde: 'un test'
        );

        $this->assertSame('nada', $resultado);
    }

    public function test_cualquier_otro_error_de_sql_si_sube(): void
    {
        // Un tope de tiempo no es un `try/catch` general. Una tabla que no existe o una
        // columna mal escrita son fallos de verdad, y disimularlos convertiría un error
        // con causa clara en una pantalla vacía sin explicación.
        $pdo = new PDOException('Unknown column');
        $pdo->errorInfo = ['42S22', 1054, 'Unknown column'];
        $otroError = new QueryException('mysql', 'select inventado from api_request_logs', [], $pdo);

        $this->expectException(QueryException::class);

        ConsultaAcotada::ejecutar(fn () => throw $otroError, siSeAgota: 'nada');
    }

    public function test_con_el_tope_a_cero_no_se_acota(): void
    {
        // La vía de escape para un entorno donde haga falta dejar correr una consulta
        // larga a propósito.
        config(['api.logs_viewer.timeout_seconds' => 0]);

        $this->expectException(QueryException::class);

        ConsultaAcotada::ejecutar(fn () => throw $this->tiempoAgotadoDeMysql(), siSeAgota: 'nada');
    }

    public function test_una_consulta_normal_devuelve_lo_suyo_sin_tocar_nada(): void
    {
        $this->assertSame(42, ConsultaAcotada::ejecutar(fn () => 42, siSeAgota: 0));
    }

    /* ===================== La pantalla ===================== */

    public function test_los_indicadores_vacios_tienen_la_misma_forma_que_los_de_verdad(): void
    {
        // **Es el test que evita que la red de seguridad falle el día que hace falta.** La
        // plantilla accede a las trece claves sin comprobar si existen, así que si las dos
        // formas se separan —al añadir un indicador nuevo y olvidar el hueco—, un corte
        // daría «undefined array key» en vez del aviso: un fallo que parece del código y
        // no un filtro demasiado ancho.
        $this->admin();

        ApiRequestLog::create([
            'request_uuid' => (string) \Illuminate\Support\Str::uuid(),
            'method' => 'POST',
            'path' => '/api/v1',
            'auth_type' => 'token',
            'http_status' => 200,
            'duration_ms' => 120,
            'started_at' => now()->subMinutes(5),
            'ended_at' => now()->subMinutes(5),
            'action' => 'sync',
        ]);

        $componente = Livewire::test(Index::class);

        // Las claves cambiaron al pasar los indicadores a contar por severidad y no por
        // `http_status` (MGR-078): `errorPct` y `sitesErrorRate` ya no existen.
        //
        // `apagadosOcultos` entró con el recorte de los entornos apagados y `porMotivo` con
        // el reparto por motivos: son agregados más, así que viven con los otros doce
        // —misma caché y mismo tope de tiempo— y por eso tienen que estar también en el
        // hueco que se devuelve cuando la consulta se corta.
        $claves = ['total', 'errores', 'apagadosOcultos', 'erroresSinRevisar', 'porSeveridad',
            'porMotivo', 'count4xx',
            'count5xx', 'prevTotal', 'prevErrores', 'p50', 'p95', 'prevP50', 'prevP95',
            'topActions', 'topClients', 'sitiosConErrores'];

        // Los de verdad llegan a la vista. Se comprueba que **la clave está**, no que
        // tenga valor: `p50` y `p95` son null cuando no hay ninguna petición en el rango,
        // y eso es correcto —no hay percentil de nada—.
        foreach ($claves as $clave) {
            $componente->assertViewHas($clave);
        }

        // …y el hueco tiene exactamente las mismas claves.
        $metodo = (new \ReflectionClass(Index::class))->getMethod('statsVacios');
        $vacios = $metodo->invoke($componente->instance());

        $this->assertSame(
            collect($claves)->sort()->values()->all(),
            collect($vacios)->keys()->sort()->values()->all()
        );
    }

    public function test_la_pantalla_se_pinta_entera_cuando_la_consulta_se_corta(): void
    {
        // **Es el punto de todo esto.** Antes esta pantalla se quedaba en blanco esperando
        // y los filtros —que son lo que hay que tocar para arreglarlo— viven dentro de la
        // página que no llegaba a pintarse.
        $this->admin();
        ConsultaAcotada::fingirCorte();

        $componente = Livewire::test(Index::class);

        $componente->assertOk()
            // Los filtros están ahí, que es lo que hacía falta.
            ->assertSee('Filtros')
            ->assertSee('Limpiar filtros')
            // Y el aviso dice **qué hacer**, no solo que ha fallado: un aviso sin arreglo
            // es ruido.
            ->assertSee('tardaba demasiado y se ha cortado')
            ->assertSee('acota el rango de fechas', false)
            // **No es lo mismo «no hay» que «no se pudo saber».** Decir «no hay registros
            // en el rango» cuando la consulta se cortó es afirmar algo que no se ha
            // comprobado, y manda a buscar en otro sitio un problema que está aquí.
            ->assertDontSee('No hay registros en el rango seleccionado')
            ->assertSee('No se ha podido leer el listado a tiempo');

        $this->assertTrue($componente->get('listadoAgotado'));
        $this->assertTrue($componente->get('indicadoresAgotados'));
    }

    public function test_un_hueco_por_corte_no_se_queda_cacheado_cinco_minutos(): void
    {
        // Los indicadores se cachean para no repetir doce agregados en cada tecla. Pero
        // cachear el **hueco** convertiría un problema pasajero —un pico de carga— en una
        // pantalla sin datos durante cinco minutos, sin que nada lo explique.
        $this->admin();

        ConsultaAcotada::fingirCorte();
        Livewire::test(Index::class);

        ConsultaAcotada::fingirCorte(false);

        // La segunda vez, sin corte, los indicadores se calculan de verdad: si el hueco se
        // hubiera cacheado, seguirían vacíos.
        Livewire::test(Index::class)->assertSet('indicadoresAgotados', false);
    }

    protected function tearDown(): void
    {
        // Es un estado estático: sin esto se filtra al siguiente test del proceso y lo
        // rompe por una razón que no está en su código.
        ConsultaAcotada::fingirCorte(false);

        parent::tearDown();
    }
}
