<?php

namespace App\Services\Api;

/**
 * Clasifica una respuesta de la API en cuatro severidades.
 *
 * **Por qué existe.** El panel contaba como error todo `http_status >= 400`, y midiendo
 * los errores reales resultó que **la mitad no son errores**: son respuestas correctas a
 * preguntas legítimas. Un panel con un 35 % de error permanente enseña a no mirarlo.
 * Ver `norma-cero-errores.md` en la documentación.
 *
 * Las cuatro severidades:
 *
 * - **`ok`** — la petición se respondió.
 * - **`error`** — algo está mal y **hay alguien que tiene que arreglarlo**: el Manager, el
 *   plugin o el cliente. Es lo único que debe contar en el porcentaje de error, y el
 *   objetivo es que sea cero.
 * - **`sin_contenido`** — no hay nada publicado para esa petición. **No es un fallo**: un
 *   producto sin features nunca va a tener features y su plugin va a preguntar cada hora.
 * - **`negocio`** — el cliente no tiene eso contratado, o su contrato terminó. Es la
 *   respuesta correcta, y el plugin la usa para enseñar la promoción (MGR-033).
 *
 * **La dependencia del mensaje es un defecto conocido, no un descuido.** `X001` significa
 * dos cosas distintas —«el plugin no existe» y «la licencia no lo incluye»— y `X003`
 * también —«me has mandado la versión mal» y «tengo un fichero roto»—. Con el código a
 * secas no se pueden separar, así que se mira el texto. Cuando cada situación tenga su
 * propio código, estas dos comprobaciones desaparecen: ver §4 de la norma.
 */
class Severidad
{
    public const OK = 'ok';
    public const ERROR = 'error';
    public const SIN_CONTENIDO = 'sin_contenido';
    public const NEGOCIO = 'negocio';

    /** Todas, para validaciones y filtros. */
    public const TODAS = [self::OK, self::ERROR, self::SIN_CONTENIDO, self::NEGOCIO];

    /** Cómo se llama cada una en pantalla. */
    public const ETIQUETAS = [
        self::OK => 'Correcta',
        self::ERROR => 'Error',
        self::SIN_CONTENIDO => 'Sin contenido',
        self::NEGOCIO => 'No contratado',
    ];

    /**
     * La severidad de una respuesta.
     *
     * @param  int  $httpStatus  El código HTTP con el que se respondió.
     * @param  int|null  $errorCode  El código de la familia (`2xxx`–`5xxx`) o el HTTP.
     * @param  string|null  $mensaje  El texto del error. Necesario mientras dos
     *                                situaciones distintas compartan código.
     */
    public static function de(int $httpStatus, ?int $errorCode = null, ?string $mensaje = null): string
    {
        if ($httpStatus < 400) {
            return self::OK;
        }

        $codigo = $errorCode ?? $httpStatus;
        $familia = self::familia($codigo);
        $texto = mb_strtolower((string) $mensaje);

        // ============ La familia 2xxx va aparte ============
        // **`2xxx` no sigue la convención de las otras tres.** En `3xxx`, `4xxx` y
        // `5xxx` el último dígito significa lo mismo en todas —0 producto sin validar,
        // 1 licencia/plugin, 2 sin contenido, 3 formato inválido—, pero en `2xxx`
        // (`licence`) los números quieren decir otra cosa:
        //
        // - `2001` → la licencia no incluye el plugin → **negocio**
        // - `2002` → entorno no encontrado, o plugin inexistente → **error**
        //
        // Aplicar la regla general aquí clasificaba «Environment not found» como «sin
        // contenido», que es exactamente al revés: es un alta sin terminar y hay que
        // arreglarla. Es otra razón para renumerar los códigos (§4 de la norma).
        if ($familia === 2) {
            return $codigo === 2001 ? self::NEGOCIO : self::ERROR;
        }

        // ============ Sin contenido ============
        // `X002` en las familias 3, 4 y 5: «no hay versión compatible» y «no se
        // encontraron ficheros». Es el caso más numeroso del log y el que más
        // claramente no es un fallo.
        if ($familia !== null && $codigo % 1000 === 2) {
            return self::SIN_CONTENIDO;
        }

        // ============ Negocio ============
        // El contrato terminó o nunca incluyó eso. El 403 del middleware cubre token
        // inactivo, token expirado, cliente de baja y entorno inactivo: en los cuatro
        // casos el Manager está respondiendo bien.
        if ($httpStatus === 403 && $errorCode === 403) {
            return self::NEGOCIO;
        }

        // `X001` con «licence does not include»: el cliente no tiene ese producto. El
        // mismo código con «plugin not found» es otra cosa —ver más abajo—.
        if ($familia !== null && str_contains($texto, 'does not include')) {
            return self::NEGOCIO;
        }

        // `X000` («Product not validated») es genérico: no dice si el producto no existe
        // o si no está contratado. Se cuenta como negocio porque en la práctica es lo
        // segundo, y queda anotado como una de las razones para partir el código.
        if ($familia !== null && $codigo % 1000 === 0) {
            return self::NEGOCIO;
        }

        // ============ Error ============
        // Todo lo demás tiene dueño y hay que arreglarlo:
        //
        // - `401` sin token o con un token que no existe → alguien llama mal.
        // - `404` «Environment not found» → un alta sin terminar.
        // - `X001` con «plugin not found» → el plugin pide un producto inexistente.
        // - `X003` → o el plugin manda la versión mal formada, o tenemos un fichero
        //   roto. Las dos son errores, aunque de dueños distintos.
        // - `400` / `422` → contrato incumplido.
        // - `429` → algo está llamando de más.
        // - `5xx` → el Manager.
        return self::ERROR;
    }

    /**
     * La misma decisión que `de()`, escrita como un `case` de SQL.
     *
     * **Por qué existe una segunda copia.** El relleno de la migración que creó la
     * columna recorría las filas en PHP y lanzaba un `UPDATE` por cada una. En
     * desarrollo eran 14 filas; en pre se quedó colgada, porque el coste no depende de
     * cuántos errores haya sino de cuántas filas hay que **mirar**, y `api_request_logs`
     * es la tabla que más crece del sistema. Clasificar en SQL lo convierte en una
     * consulta por bloque de ids en vez de una por fila.
     *
     * **`de()` es la que manda**: es la que se ejecuta en caliente al escribir cada
     * petición. Esto solo sirve para rellenar en masa lo que ya estaba. Que las dos
     * digan lo mismo lo fija `SeveridadEnSqlTest`, que las compara sobre todos los
     * códigos y mensajes reales.
     *
     * Se usa `%` y no `mod()`, y `lower(coalesce(error, ''))` y no `error like`, porque
     * tiene que valer igual en MySQL y en sqlite —donde corren los tests— y porque un
     * `null` en `error` no debe tumbar la comparación de texto.
     *
     * @param  string  $status  Columna con el código HTTP.
     * @param  string  $code  Columna con el código de la familia.
     * @param  string  $msg  Columna con el texto del error.
     */
    public static function comoCase(
        string $status = 'http_status',
        string $code = 'error_code',
        string $msg = 'error'
    ): string {
        $codigo = "coalesce($code, $status)";
        $familia = "$codigo between 3000 and 5999";

        $ok = self::OK;
        $error = self::ERROR;
        $sinContenido = self::SIN_CONTENIDO;
        $negocio = self::NEGOCIO;

        return <<<SQL
            case
                when $status < 400 then '$ok'
                when $codigo between 2000 and 2999
                    then case when $codigo = 2001 then '$negocio' else '$error' end
                when $familia and $codigo % 1000 = 2 then '$sinContenido'
                when $status = 403 and $code = 403 then '$negocio'
                when $familia and lower(coalesce($msg, '')) like '%does not include%'
                    then '$negocio'
                when $familia and $codigo % 1000 = 0 then '$negocio'
                else '$error'
            end
            SQL;
    }

    /**
     * La familia del código (`2`–`5`), o null si es un código HTTP a secas.
     *
     * Los códigos de familia son de cuatro dígitos y empiezan por 2, 3, 4 o 5. Un `404`
     * también empieza por 4 pero tiene tres dígitos, así que la longitud es lo que los
     * separa.
     */
    private static function familia(int $codigo): ?int
    {
        if ($codigo < 2000 || $codigo > 5999) {
            return null;
        }

        return (int) floor($codigo / 1000);
    }
}
