<?php

namespace App\Services\Api;

/**
 * Por qué se rechazó una petición, como **dato** y no como texto libre.
 *
 * ## El problema
 *
 * La severidad ({@see Severidad}) responde «¿hay que arreglarlo?» y con eso se construyó
 * la norma de cero errores. Pero deja una pregunta sin contestar, y es la que se hace a
 * diario: **¿por qué ha fallado?**
 *
 * Hoy eso solo está en el texto del mensaje, y el resultado es que **ocho situaciones
 * distintas comparten la etiqueta «No contratado»**:
 *
 * - El `403` del middleware es plano —`code: 403` para todo— y cubre seis cosas: cliente
 *   de baja, licencia apagada, licencia sin empezar, licencia caducada, entorno apagado y
 *   host que no es de esa licencia.
 * - Y encima de esas seis van las dos de producto: `X001` con «does not include» —el
 *   cliente no tiene ese producto— y los `X000` genéricos.
 *
 * Las seis primeras no son «no contratado» y no piden lo mismo: una es una renovación
 * pendiente, otra es reactivar algo que apagamos, y otra es **un alta sin terminar** que
 * estaba contando como respuesta correcta.
 *
 * ## Qué hace esta clase
 *
 * Traduce `(http_status, error_code, mensaje)` a **un motivo de una lista cerrada**, con su
 * etiqueta y una explicación de qué ha pasado. Con eso el visor puede agrupar —«cuántos
 * sitios están llamando con la licencia caducada» no se podía contestar— sin que nadie
 * tenga que leer textos uno a uno.
 *
 * **Lo que no dice es de quién es el trabajo.** Una primera versión traía un dueño por
 * motivo; se quitó porque ese reparto no está decidido, y una pantalla que lo afirma
 * convierte una suposición en una asignación.
 *
 * ## Por qué se clasifica aquí y no se cambia la respuesta de la API
 *
 * **Es la misma decisión que se tomó con `severity`, y por el mismo motivo**: clasificar en
 * el Manager no rompe nada, se puede hacer solo aquí y es reversible. Cambiar la respuesta
 * —dar un código propio a cada situación— es lo que §7.6 de `norma-cero-errores.md` tiene
 * planificado, y exige tocar `local_tresipunt` primero y coordinar versiones, porque los
 * Moodles desplegados siguen con la versión antigua durante semanas.
 *
 * O sea: esto es el instrumento de medida. Cuando cada situación tenga su propio código, la
 * dependencia del texto de aquí desaparece y esta clase se queda solo con el código.
 *
 * **La dependencia del mensaje es un defecto conocido, no un descuido.** Ver §7.2 de la
 * norma: `X001` significa dos cosas y `X003` otras dos.
 */
class Motivo
{
    /* ============ Autenticación: no sé quién eres ============ */

    /** No venía token en la cabecera. */
    public const TOKEN_AUSENTE = 'token_ausente';

    /** El token no existe en la base. */
    public const TOKEN_DESCONOCIDO = 'token_desconocido';

    /* ============ Estado del contrato: sé quién eres ============ */

    /** El cliente está dado de baja en el panel. */
    public const CLIENTE_DE_BAJA = 'cliente_de_baja';

    /** La licencia está apagada. */
    public const LICENCIA_APAGADA = 'licencia_apagada';

    /** La licencia tiene fecha de inicio futura. */
    public const LICENCIA_SIN_EMPEZAR = 'licencia_sin_empezar';

    /** La licencia terminó y el sitio sigue pidiendo. */
    public const LICENCIA_CADUCADA = 'licencia_caducada';

    /** La licencia se ha pasado de su tope de peticiones por minuto. */
    public const LICENCIA_AL_LIMITE = 'licencia_al_limite';

    /* ============ Estado del sitio ============ */

    /** El entorno está apagado en el panel. */
    /**
     * El limitador por IP ha cortado.
     *
     * **No es lo mismo que `licencia_al_limite` y por eso son dos.** Aquel es el tope de la
     * licencia y se ajusta en su ficha; este es el tope por IP de los ajustes del Manager, y
     * puede cortar a un tercero que comparta salida —un hosting compartido— sin que su
     * licencia tenga nada que ver.
     */
    public const IP_AL_LIMITE = 'ip_al_limite';

    /** Alguien bloqueó esta IP a mano desde el panel. */
    public const BLOQUEO_MANUAL = 'bloqueo_manual';

    public const ENTORNO_APAGADO = 'entorno_apagado';

    /** El host que llama no está dado de alta en ningún sitio. */
    public const HOST_NO_RECONOCIDO = 'host_no_reconocido';

    /**
     * El host existe como entorno del mismo cliente, pero cuelga de **otra licencia**.
     *
     * Aparte de {@see self::HOST_NO_RECONOCIDO} porque es otra tarea: el alta está hecha y
     * lo que hay que corregir es la vinculación. Mezclados, la pantalla decía «alta sin
     * terminar» de un entorno que ya existe y mandaba a soporte a crearlo otra vez.
     */
    public const HOST_DE_OTRA_LICENCIA = 'host_de_otra_licencia';

    /** El host existe como entorno del mismo cliente y **no tiene licencia asignada**. */
    public const HOST_SIN_LICENCIA = 'host_sin_licencia';

    /* ============ Contrato de la petición ============ */

    /** No mandó el parámetro `host`. */
    public const HOST_AUSENTE = 'host_ausente';

    /** No mandó el parámetro `plugin`. */
    public const PLUGIN_AUSENTE = 'plugin_ausente';

    /** Falta un campo obligatorio o llega con el tipo mal: el `422` de la validación. */
    public const PETICION_INVALIDA = 'peticion_invalida';

    /** El plugin mandó una versión que no se puede interpretar. */
    public const VERSION_MAL_FORMADA = 'version_mal_formada';

    /* ============ Producto ============ */

    /** La licencia no incluye ese producto: la respuesta correcta. */
    public const PRODUCTO_NO_CONTRATADO = 'producto_no_contratado';

    /** El plugin pide un producto que no está en el catálogo. */
    public const PRODUCTO_DESCONOCIDO = 'producto_desconocido';

    /** `X000`: «Product not validated», que no dice cuál de las dos anteriores es. */
    public const PRODUCTO_SIN_VALIDAR = 'producto_sin_validar';

    /* ============ Contenido ============ */

    /** No hay nada publicado para esa petición. No es un fallo. */
    public const SIN_CONTENIDO = 'sin_contenido';

    /* ============ Nuestro ============ */

    /** Un fichero publicado que no se puede leer. */
    public const FICHERO_ILEGIBLE = 'fichero_ilegible';

    /** Un `5xx`: el fallo es del propio Manager. */
    public const FALLO_DEL_MANAGER = 'fallo_del_manager';

    /* ============ Y el que evita fingir precisión ============ */

    /**
     * No encaja en ninguno.
     *
     * **Existe a propósito y hay que mirarlo.** Un motivo «otro» que crece es la señal de
     * que hay una situación nueva sin clasificar; inventarle un motivo aproximado sería
     * peor, porque el panel diría algo concreto y falso.
     */
    public const OTRO = 'otro';

    /** La petición se respondió: no hay motivo que dar. */
    public const NINGUNO = 'ninguno';

    /**
     * Cómo se llama cada motivo en pantalla, y qué significa.
     *
     * **Sin dueño.** La primera versión de esto llevaba un campo `dueno` —Comercial,
     * nosotros, el plugin— y se ha quitado: **quién atiende cada motivo no está decidido**,
     * y una pantalla que lo afirma convierte una suposición en un reparto de trabajo. El
     * dato que sí es cierto es *qué ha pasado*, y eso es lo que se dice.
     *
     * El `detalle` es lo que sale al pasar por encima: describe la situación y qué implica,
     * que es lo que hace falta para decidir qué hacer con ella.
     *
     * @var array<string, array{etiqueta: string, detalle: string}>
     */
    public const CATALOGO = [
        self::TOKEN_AUSENTE => [
            'etiqueta' => 'Sin token',
            'detalle' => 'La petición no llevaba cabecera de autorización. O es un sondeo de fuera, o un plugin sin configurar.',
        ],
        self::TOKEN_DESCONOCIDO => [
            'etiqueta' => 'Token que no existe',
            'detalle' => 'El token no está en la base: alguien llama con una licencia que nunca existió o que se borró.',
        ],
        self::CLIENTE_DE_BAJA => [
            'etiqueta' => 'Cliente de baja',
            'detalle' => 'El cliente está dado de baja en el panel y su Moodle sigue llamando.',
        ],
        self::LICENCIA_APAGADA => [
            'etiqueta' => 'Licencia apagada',
            'detalle' => 'La licencia está desactivada en el panel, así que no se sirve nada con ella.',
        ],
        self::LICENCIA_SIN_EMPEZAR => [
            'etiqueta' => 'Licencia sin empezar',
            'detalle' => 'Su fecha de inicio es futura. Es correcto, y deja de pasar solo al llegar la fecha.',
        ],
        self::LICENCIA_CADUCADA => [
            'etiqueta' => 'Licencia caducada',
            'detalle' => 'El contrato terminó y el sitio sigue pidiendo. Es una renovación pendiente, no una avería.',
        ],
        self::LICENCIA_AL_LIMITE => [
            'etiqueta' => 'Licencia al límite',
            'detalle' => 'Se ha pasado del tope de peticiones por minuto de su licencia: o llama de más, o el tope está corto.',
        ],
        self::IP_AL_LIMITE => [
            'etiqueta' => 'IP al límite',
            'detalle' => 'El limitador por IP la ha cortado: demasiadas peticiones o demasiados rechazos en un minuto. El tope se ajusta en Ajustes del Manager; si un sitio llama en bucle, el arreglo está en su Moodle.',
        ],
        self::BLOQUEO_MANUAL => [
            'etiqueta' => 'Bloqueo manual',
            'detalle' => 'Alguien bloqueó esta IP desde el panel, así que se rechaza todo lo que envíe hasta que se libere en Monitorización → Tráfico y cortes.',
        ],
        self::ENTORNO_APAGADO => [
            'etiqueta' => 'Sitio apagado',
            'detalle' => 'El entorno está apagado en el panel y su Moodle sigue llamando. Por defecto estas peticiones no se cuentan.',
        ],
        self::HOST_NO_RECONOCIDO => [
            'etiqueta' => 'Sitio no reconocido',
            'detalle' => 'El dominio que llama no está dado de alta en ningún sitio: un alta sin terminar, o un dominio que cambió y no se actualizó.',
        ],
        self::HOST_DE_OTRA_LICENCIA => [
            'etiqueta' => 'Sitio en otra licencia',
            'detalle' => 'El dominio SÍ está dado de alta, pero su entorno cuelga de otra licencia del mismo cliente. No hay que crear nada: hay que corregir la vinculación, o cambiar el token del sitio.',
        ],
        self::HOST_SIN_LICENCIA => [
            'etiqueta' => 'Sitio sin licencia',
            'detalle' => 'El dominio SÍ está dado de alta, pero su entorno no tiene ninguna licencia asignada todavía. Es un alta a medio terminar.',
        ],
        self::HOST_AUSENTE => [
            'etiqueta' => 'Sin host',
            'detalle' => 'La petición no mandó el parámetro `host`, así que no hay sitio al que responder.',
        ],
        self::PLUGIN_AUSENTE => [
            'etiqueta' => 'Sin plugin',
            'detalle' => 'La petición no mandó el parámetro `plugin`, que es lo que dice qué producto se pide.',
        ],
        self::PETICION_INVALIDA => [
            'etiqueta' => 'Petición mal formada',
            'detalle' => 'Falta un campo obligatorio o llega con un valor que no vale: es el contrato de la API incumplido.',
        ],
        self::VERSION_MAL_FORMADA => [
            'etiqueta' => 'Versión mal formada',
            'detalle' => 'La versión que manda el sitio no se puede interpretar, así que no hay con qué comparar lo publicado.',
        ],
        self::PRODUCTO_NO_CONTRATADO => [
            'etiqueta' => 'Producto no contratado',
            'detalle' => 'La licencia no incluye ese producto. Es la respuesta correcta, y el plugin la usa para enseñar la promoción.',
        ],
        self::PRODUCTO_DESCONOCIDO => [
            'etiqueta' => 'Producto que no existe',
            'detalle' => 'El plugin pide un producto que no está en el catálogo: o falta darlo de alta, o su slug no coincide con lo que envía.',
        ],
        self::PRODUCTO_SIN_VALIDAR => [
            'etiqueta' => 'Producto sin validar',
            'detalle' => 'Un `X000` genérico: no dice si el producto no existe o si no está contratado. Es una de las razones para partir los códigos.',
        ],
        self::SIN_CONTENIDO => [
            'etiqueta' => 'Sin contenido publicado',
            'detalle' => 'No hay nada publicado para esa petición. Es una respuesta correcta y la más numerosa del registro.',
        ],
        self::FICHERO_ILEGIBLE => [
            'etiqueta' => 'Fichero publicado ilegible',
            'detalle' => 'Hay contenido publicado que no se puede leer o servir.',
        ],
        self::FALLO_DEL_MANAGER => [
            'etiqueta' => 'Fallo del Manager',
            'detalle' => 'Un 5xx: el error es del propio Manager.',
        ],
        self::OTRO => [
            'etiqueta' => 'Sin clasificar',
            'detalle' => 'No encaja en ningún motivo conocido. Si este número crece, hay una situación nueva que merece su propio nombre.',
        ],
        self::NINGUNO => [
            'etiqueta' => 'Correcta',
            'detalle' => 'La petición se respondió.',
        ],
    ];
    /** Todos, para validar el filtro de la pantalla. */
    public const TODOS = [
        self::TOKEN_AUSENTE, self::TOKEN_DESCONOCIDO, self::CLIENTE_DE_BAJA,
        self::LICENCIA_APAGADA, self::LICENCIA_SIN_EMPEZAR, self::LICENCIA_CADUCADA,
        self::LICENCIA_AL_LIMITE, self::IP_AL_LIMITE, self::BLOQUEO_MANUAL,
        self::ENTORNO_APAGADO, self::HOST_NO_RECONOCIDO,
        self::HOST_DE_OTRA_LICENCIA, self::HOST_SIN_LICENCIA,
        self::HOST_AUSENTE, self::PLUGIN_AUSENTE, self::PETICION_INVALIDA,
        self::VERSION_MAL_FORMADA, self::PRODUCTO_NO_CONTRATADO, self::PRODUCTO_DESCONOCIDO,
        self::PRODUCTO_SIN_VALIDAR, self::SIN_CONTENIDO, self::FICHERO_ILEGIBLE,
        self::FALLO_DEL_MANAGER, self::OTRO, self::NINGUNO,
    ];

    /** La etiqueta de un motivo, con una reserva por si llega uno que no conocemos. */
    public static function etiqueta(?string $motivo): string
    {
        return self::CATALOGO[$motivo]['etiqueta'] ?? 'Sin clasificar';
    }


    public static function detalle(?string $motivo): string
    {
        return self::CATALOGO[$motivo]['detalle'] ?? self::CATALOGO[self::OTRO]['detalle'];
    }

    /**
     * El motivo de una respuesta.
     *
     * **El orden de las comprobaciones importa** y va de lo más específico a lo más
     * general: los textos del middleware son inequívocos, los códigos de familia comparten
     * significado, y el código HTTP a secas es lo último que queda.
     *
     * @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.
     */
    public static function de(int $httpStatus, ?int $errorCode = null, ?string $mensaje = null): string
    {
        if ($httpStatus < 400) {
            return self::NINGUNO;
        }

        $codigo = $errorCode ?? $httpStatus;
        $texto = mb_strtolower((string) $mensaje);
        $familia = $codigo >= 2000 && $codigo <= 5999;

        // ============ Por texto: los seis del middleware ============
        //
        // **Son textos fijos escritos en `ProductToken`**, no mensajes que venga nadie de
        // fuera, así que compararlos es fiable mientras no se traduzcan. Cuando cada
        // situación tenga su código propio (§7.6 de la norma), este bloque desaparece.
        foreach (self::PORTEXTO as $fragmento => $motivo) {
            if (str_contains($texto, $fragmento)) {
                return $motivo;
            }
        }

        // ============ Por código de familia ============
        if ($familia) {
            // `X002` en 3, 4 y 5: no hay versión compatible, no se encontraron ficheros.
            // En `2xxx` el `2` significa otra cosa —entorno no encontrado—, y ese caso ya
            // lo ha cogido el bloque de texto de arriba.
            if ($codigo >= 3000 && $codigo % 1000 === 2) {
                return self::SIN_CONTENIDO;
            }

            // `X003`: o la versión del cliente está mal formada —ya cazado por texto— o
            // tenemos un fichero que no se puede leer.
            if ($codigo % 1000 === 3) {
                return self::FICHERO_ILEGIBLE;
            }

            // `X000` genérico. Se queda sin partir a propósito: ver el detalle del motivo.
            if ($codigo % 1000 === 0) {
                return self::PRODUCTO_SIN_VALIDAR;
            }

            // `X001` sin ninguno de los textos de arriba: es «no incluye», que es lo que
            // significa en la práctica.
            if ($codigo % 1000 === 1) {
                return self::PRODUCTO_NO_CONTRATADO;
            }
        }

        // ============ Por código HTTP ============
        return match (true) {
            $httpStatus === 429 => self::LICENCIA_AL_LIMITE,
            $httpStatus === 422 => self::PETICION_INVALIDA,
            $httpStatus >= 500 => self::FALLO_DEL_MANAGER,
            default => self::OTRO,
        };
    }

    /**
     * Fragmentos de mensaje que identifican un motivo sin ambigüedad.
     *
     * **En este orden**, y el orden importa: «token expirado» y «token inactivo» empiezan
     * igual, y «environment not found» aparece con tres códigos distintos según la acción.
     *
     * En minúsculas, porque la comparación se hace en minúsculas.
     *
     * @var array<string, string>
     */
    private const PORTEXTO = [
        'token no proporcionado' => self::TOKEN_AUSENTE,
        'token no válido' => self::TOKEN_DESCONOCIDO,
        'cliente inactivo' => self::CLIENTE_DE_BAJA,
        'token inactivo' => self::LICENCIA_APAGADA,
        'token aún no válido' => self::LICENCIA_SIN_EMPEZAR,
        'token expirado' => self::LICENCIA_CADUCADA,
        'límite de peticiones' => self::LICENCIA_AL_LIMITE,
        'entorno inactivo' => self::ENTORNO_APAGADO,
        // Los dos específicos van ANTES del genérico: los tres mensajes empiezan por
        // «Environment not found», y el bucle devuelve la primera coincidencia.
        'belongs to a different licence' => self::HOST_DE_OTRA_LICENCIA,
        'has no licence assigned yet' => self::HOST_SIN_LICENCIA,
        'environment not found' => self::HOST_NO_RECONOCIDO,
        'no está vinculado a este token' => self::HOST_NO_RECONOCIDO,
        'parámetro host no proporcionado' => self::HOST_AUSENTE,
        'plugin parameter is required' => self::PLUGIN_AUSENTE,
        'does not include' => self::PRODUCTO_NO_CONTRATADO,
        'plugin not found' => self::PRODUCTO_DESCONOCIDO,
        'invalid version format' => self::VERSION_MAL_FORMADA,
    ];

    /**
     * La misma decisión, escrita como un `case` de SQL.
     *
     * **Por qué existe una segunda copia**: para rellenar en masa las filas que ya están,
     * sin recorrer en PHP la tabla que más crece del sistema. Es el mismo motivo por el que
     * `Severidad::comoCase()` existe, y el mismo riesgo: que las dos digan lo mismo lo fija
     * `MotivoEnSqlTest`, que las compara sobre todos los códigos y textos reales.
     *
     * `de()` es la que manda: es la que corre al escribir cada fila.
     *
     * Se usa `%` y `lower(coalesce(...))` y no `mod()` ni `like` a secas para que valga
     * igual en MySQL y en sqlite —donde corren los tests—, y para que un `null` en `error`
     * no tumbe la comparación.
     *
     * @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)";
        $texto = "lower(coalesce($msg, ''))";

        $sql = "case\n                when $status < 400 then '" . self::NINGUNO . "'\n";

        foreach (self::PORTEXTO as $fragmento => $motivo) {
            // El fragmento se interpola: son literales de esta clase, no entrada de nadie.
            $sql .= "                when $texto like '%" . $fragmento . "%' then '$motivo'\n";
        }

        $sql .= <<<SQL
                when $codigo between 3000 and 5999 and $codigo % 1000 = 2 then '@sinContenido'
                when $codigo between 2000 and 5999 and $codigo % 1000 = 3 then '@ficheroIlegible'
                when $codigo between 2000 and 5999 and $codigo % 1000 = 0 then '@productoSinValidar'
                when $codigo between 2000 and 5999 and $codigo % 1000 = 1 then '@productoNoContratado'
                when $status = 429 then '@licenciaAlLimite'
                when $status = 422 then '@peticionInvalida'
                when $status >= 500 then '@falloDelManager'
                else '@otro'
            end
            SQL;

        return str_replace(
            ['@sinContenido', '@ficheroIlegible', '@productoSinValidar', '@productoNoContratado',
                '@licenciaAlLimite', '@peticionInvalida', '@falloDelManager', '@otro'],
            [self::SIN_CONTENIDO, self::FICHERO_ILEGIBLE, self::PRODUCTO_SIN_VALIDAR,
                self::PRODUCTO_NO_CONTRATADO, self::LICENCIA_AL_LIMITE, self::PETICION_INVALIDA,
                self::FALLO_DEL_MANAGER, self::OTRO],
            $sql
        );
    }
}
