<?php

use App\Services\Api\Severidad;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

/**
 * La severidad de cada respuesta de la API.
 *
 * **El problema que resuelve.** El panel contaba como error todo `http_status >= 400`.
 * Midiendo los 14 errores reales de la base de desarrollo salió que **siete no son
 * errores**: cinco son «no hay contenido publicado para esa versión» y dos son «el
 * cliente no tiene ese producto contratado», que es la respuesta correcta y la que el
 * plugin usa para enseñar la promoción.
 *
 * Un panel con un 35 % de error permanente **enseña a la gente a no mirarlo**, y eso es lo
 * contrario de para qué existe Monitorización. Ver `norma-cero-errores.md`.
 *
 * **Se clasifica al escribir y no al leer** por dos razones: `api_request_logs` es la
 * tabla que hizo inusable el visor (MGR-027) y clasificar en la consulta obligaría a
 * repetir la lógica en cada pantalla; y la severidad depende del texto del error, que es
 * caro de filtrar con `like` sobre millones de filas.
 *
 * **El índice compuesto** `(started_at, severity)` es el que hace baratos los dos usos
 * reales: el porcentaje de error de un rango y el ranking de IPs con errores.
 *
 * ---
 *
 * **Por qué el relleno es SQL y no PHP.** La primera versión recorría las filas con
 * `chunkById(500)` y lanzaba **un `UPDATE` por fila** llamando a `Severidad::de()`, con
 * este razonamiento escrito al lado: «en producción son millones de filas en total pero
 * una fracción pequeña con `http_status >= 400`, así que se recorren solo esas».
 *
 * **Esa premisa era falsa.** Medido en pre el 04/09/2026: 978 595 filas, de las cuales
 * **949 036 tienen `http_status >= 400`** —el 97 %—. No es una fracción pequeña: es
 * casi toda la tabla. Un sitio con el token mal puesto llamando en bucle llena el log
 * de 401 y de ahí no se sale, que es justo el problema que motivó el límite de fallos
 * por IP (MGR-008). Así que el relleno no eran unos miles de `UPDATE`, eran casi un
 * millón, uno por viaje a la base: por eso se quedó colgada.
 *
 * Ahora es un `UPDATE ... CASE` por bloques de ids: unas 50 consultas en total en vez
 * de 949 036. El bloque acota además el bloqueo de cada escritura, porque una migración
 * que bloquee la tabla de logs entera deja a los Moodle de los clientes sin poder
 * registrar sus peticiones mientras dura.
 *
 * **El índice se crea después del relleno**, y no antes como estaba. Con el 97 % de las
 * filas por actualizar, tener ya el índice `(started_at, severity)` puesto significa
 * mantenerlo fila a fila durante todo el relleno para luego dejarlo igual que si se
 * hubiera construido de una vez al final.
 *
 * **El precio es que la clasificación está escrita dos veces**, así que el `case` vive
 * en `Severidad::comoCase()`, en el mismo archivo que `Severidad::de()` —la que manda
 * en caliente—, y `SeveridadEnSqlTest` compara las dos sobre todos los códigos reales.
 * Separarlas sin darse cuenta es el único riesgo de haber bajado esto a SQL.
 */
return new class extends Migration
{
    /** Ids por bloque. Ni tan pocos que sean miles de consultas ni tanto que bloquee. */
    private const BLOQUE = 20000;

    public function up(): void
    {
        // Idempotente a propósito: si un intento anterior se quedó colgado en el relleno
        // y se mató el proceso, la columna **ya está** —el DDL de MySQL no se deshace
        // con la transacción— pero la migración no quedó registrada. Sin esta
        // comprobación el segundo intento muere con «duplicate column» y hay que
        // arreglarlo a mano en el servidor, que es lo último que se quiere hacer a mitad
        // de un despliegue.
        if (! Schema::hasColumn('api_request_logs', 'severity')) {
            Schema::table('api_request_logs', function (Blueprint $table) {
                // `ok` por defecto para que las filas de `http_status < 400` queden ya
                // bien sin tocarlas: es exactamente lo que devuelve `Severidad::de()`.
                $table->string('severity', 20)->default(Severidad::OK)->after('http_status');
            });
        }

        // El relleno **antes** del índice: ver el porqué en la cabecera.
        $this->rellenar();

        if (! Schema::hasIndex('api_request_logs', 'api_logs_started_severity_idx')) {
            Schema::table('api_request_logs', function (Blueprint $table) {
                $table->index(['started_at', 'severity'], 'api_logs_started_severity_idx');
            });
        }
    }

    /**
     * Clasifica las filas que ya estaban.
     *
     * Se reescriben **todas** las de error, incluidas las que un intento anterior ya
     * hubiera clasificado. Filtrar por `severity = 'ok'` para hacer menos trabajo sería
     * correcto —una fila de 400 o más nunca se queda en `ok`— pero dejaría la tabla con
     * una parte escrita por el recorrido en PHP y otra por el `case` de SQL, y eso es
     * precisamente lo que no se quiere poder dudar cuando alguien mire el porcentaje de
     * error. Reescribirlas cuesta unas 50 consultas.
     */
    private function rellenar(): void
    {
        $limites = DB::table('api_request_logs')
            ->where('http_status', '>=', 400)
            ->selectRaw('min(id) as desde, max(id) as hasta')
            ->first();

        if ($limites === null || $limites->desde === null) {
            return;
        }

        $sql = 'update api_request_logs set severity = ' . Severidad::comoCase()
            . ' where http_status >= 400 and id between ? and ?';

        for ($desde = (int) $limites->desde; $desde <= (int) $limites->hasta; $desde += self::BLOQUE) {
            DB::update($sql, [$desde, $desde + self::BLOQUE - 1]);
        }
    }

    public function down(): void
    {
        Schema::table('api_request_logs', function (Blueprint $table) {
            $table->dropIndex('api_logs_started_severity_idx');
            $table->dropColumn('severity');
        });
    }
};
