<?php

namespace App\Support;

use Closure;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
 * Ejecuta una consulta con un tope de tiempo: si tarda más, se corta y se sigue.
 *
 * **Por qué existe.** El visor de peticiones de la API lee `api_request_logs`, que es la
 * tabla que más crece del sistema —una fila por llamada de cada Moodle del parque— y sus
 * indicadores hacen doce agregados sobre un rango de fechas. Con un rango amplio, un
 * filtro sin índice o la tabla llena, la consulta puede tardar minutos, y mientras tanto
 * **la pantalla se queda en blanco pensando**: sin cabecera, sin filtros y sin forma de
 * corregir el filtro que la ha colgado, porque los filtros están dentro de la página que
 * no llega a pintarse.
 *
 * Con un tope, el peor caso deja de ser «esperar sin saber» y pasa a ser «la página está
 * ahí, con un aviso y los filtros a mano».
 *
 * **No es un `set_time_limit` de PHP.** El límite se le pide a la base de datos, que es
 * quien está trabajando: PHP sigue esperando a que el servidor conteste, y matar el
 * proceso de PHP deja la consulta corriendo en MySQL, consumiendo lo mismo y sin nadie
 * que recoja el resultado.
 */
class ConsultaAcotada
{
    /**
     * Error de MySQL 8 cuando salta `max_execution_time`.
     *
     * «Query execution was interrupted, maximum statement execution time exceeded». Se
     * comprueba por código y no por el texto, que cambia entre versiones e idiomas.
     */
    private const MYSQL_TIEMPO_AGOTADO = 3024;

    /** El de MariaDB para `max_statement_time`, que es un ajuste distinto. */
    private const MARIADB_TIEMPO_AGOTADO = 1969;

    /**
     * Si se finge que toda consulta se agota. **Solo para tests**, como `Http::fake()`.
     *
     * Sin esto, el camino de emergencia no se puede probar de punta a punta: los tests
     * corren sobre sqlite, que no tiene tope de tiempo, así que no hay forma de provocar
     * un corte de verdad —y una pantalla de emergencia que nadie ha visto funcionar no es
     * una red de seguridad, es una suposición—.
     */
    private static bool $fingirCorte = false;

    public static function fingirCorte(bool $activo = true): void
    {
        self::$fingirCorte = $activo;
    }

    /**
     * Ejecuta `$consulta` con un tope de segundos.
     *
     * Devuelve lo que devuelva la consulta, o `$siSeAgota` si no llegó a tiempo. **No
     * lanza**: quien llama necesita seguir pintando la pantalla, que es el objetivo.
     *
     * @template T
     *
     * @param  Closure():T  $consulta
     * @param  T  $siSeAgota  qué devolver si no llega a tiempo
     * @param  string  $donde  para el log: qué se estaba consultando
     * @return T
     */
    public static function ejecutar(Closure $consulta, mixed $siSeAgota = null, string $donde = '', ?int $segundos = null): mixed
    {
        if (self::$fingirCorte) {
            return $siSeAgota;
        }

        $tope = $segundos ?? (int) config('api.logs_viewer.timeout_seconds', 8);

        // Con el tope a 0 o menos, sin límite: es la vía de escape para un entorno donde
        // haga falta dejar correr una consulta larga a propósito.
        if ($tope <= 0) {
            return $consulta();
        }

        $restaurar = self::aplicarTope($tope);

        try {
            return $consulta();
        } catch (QueryException $e) {
            if (! self::esTiempoAgotado($e)) {
                // Cualquier otro error de SQL es un fallo de verdad y no se disimula: se
                // deja subir para que lo vea quien deba verlo.
                throw $e;
            }

            // Se registra con lo que hace falta para arreglarlo —dónde y con qué tope—,
            // porque si esto pasa a menudo el problema es un índice que falta, no el tope.
            Log::warning('Consulta cortada por tiempo', [
                'donde' => $donde,
                'segundos' => $tope,
            ]);

            return $siSeAgota;
        } finally {
            $restaurar();
        }
    }

    /**
     * Le pide a la base de datos el tope, y devuelve la función que lo deshace.
     *
     * Se restaura siempre —en el `finally` de arriba— porque la conexión se reutiliza
     * dentro de la misma petición: dejar el tope puesto se lo aplicaría a todo lo que
     * venga después, incluidas las escrituras del final del ciclo.
     */
    private static function aplicarTope(int $segundos): Closure
    {
        $conexion = DB::connection();
        $sinNada = fn () => null;

        if ($conexion->getDriverName() !== 'mysql') {
            // sqlite —los tests— y el resto no tienen este ajuste. No poner tope es
            // preferible a fingirlo: el código de arriba se comporta igual, solo que sin
            // corte, y así los tests prueban el camino normal.
            return $sinNada;
        }

        try {
            $version = (string) $conexion->getPdo()->getAttribute(\PDO::ATTR_SERVER_VERSION);

            // **Son dos ajustes con nombres y unidades distintas**, y poner el que no toca
            // falla con «Unknown system variable». MariaDB: segundos, y afecta a cualquier
            // sentencia. MySQL: milisegundos, y solo a los `SELECT` de solo lectura —que
            // es justo lo que se acota aquí—.
            if (stripos($version, 'mariadb') !== false) {
                $conexion->statement('SET SESSION max_statement_time = ' . $segundos);

                return fn () => $conexion->statement('SET SESSION max_statement_time = DEFAULT');
            }

            $conexion->statement('SET SESSION max_execution_time = ' . ($segundos * 1000));

            return fn () => $conexion->statement('SET SESSION max_execution_time = DEFAULT');
        } catch (Throwable $e) {
            // Si el ajuste no se puede poner —permisos, un motor que no lo trae—, la
            // pantalla tiene que seguir funcionando sin tope. Es peor no pintar nada.
            Log::debug('No se pudo poner tope de tiempo a la consulta', ['error' => $e->getMessage()]);

            return $sinNada;
        }
    }

    private static function esTiempoAgotado(QueryException $e): bool
    {
        $codigo = (int) ($e->errorInfo[1] ?? 0);

        return in_array($codigo, [self::MYSQL_TIEMPO_AGOTADO, self::MARIADB_TIEMPO_AGOTADO], true);
    }
}
