<?php

namespace App\Support;

use App\Models\ApiRequestLog;
use App\Models\Environments\Environment;
use App\Services\Api\Severidad;

/**
 * El veredicto de un entorno: una frase que dice si ese sitio está recibiendo lo que su
 * cliente tiene contratado, y si no, por qué.
 *
 * **Por qué hace falta una clase para esto.** El dato no está en ninguna columna: sale de
 * cruzar cuatro cosas —si está encendido, cuándo llamó por última vez, si tiene licencia
 * y qué dice el registro de la API—. Antes había que hacer ese cruce a mano abriendo
 * cuatro pantallas, y cada persona lo hacía a su manera.
 *
 * **La regla que manda aquí: `*002` no es un error.** Los códigos `3002`, `4002` y `5002`
 * significan «no hay contenido publicado para esa versión», y son la respuesta correcta a
 * una pregunta legítima —un producto sin features nunca va a tener features, y su plugin
 * va a preguntar cada hora—. Contarlos como avería es lo que producía el 35 % de error
 * permanente que enseñó a no mirar el panel. Aquí se cuenta lo que ya clasifica
 * {@see Severidad} a partir de la columna `severity` del log, que es la misma fuente que
 * usa Monitorización: **error** es solo lo que tiene dueño y hay que arreglar.
 *
 * Los cinco veredictos, en orden de precedencia:
 *
 * | Clave | Cuándo | Por qué antes que los siguientes |
 * |---|---|---|
 * | `apagado` | `active = false` | Lo explica todo lo demás: recibe `403` en todo |
 * | `errores` | Alguna petición con severidad `error` en 7 días | Es lo único que hay que arreglar ya |
 * | `sin_sync` | Nunca ha hecho `sync` | No sabemos su versión, su tamaño ni sus plugins |
 * | `sin_senal` | Su último `sync` es de hace más de 7 días | Puede estar caído sin que nadie lo sepa |
 * | `sin_licencia` | Llama, pero no tiene licencia asociada | Llama y no recibe nada |
 * | `normal` | Todo lo anterior en orden | — |
 */
class SaludDelEntorno
{
    /** Días sin `sync` a partir de los cuales un entorno se considera sin señal. */
    public const DIAS_SIN_SENAL = 7;

    /** Ventana en la que se buscan errores recientes. */
    public const DIAS_DE_ERRORES = 7;

    /** La ventana del resumen, la misma que usa Monitorización. */
    public const DIAS_DE_RESUMEN = 30;

    /**
     * @return array{clave: string, titulo: string, detalle: string, tono: string}
     *         `tono` es `ok`, `aviso` o `malo`, y es lo único que decide el color.
     */
    public static function de(Environment $entorno): array
    {
        $peticiones = ApiRequestLog::where('environment_id', $entorno->id);

        $ultima = (clone $peticiones)->max('started_at');
        $totalPeticiones = (clone $peticiones)->count();

        // La rellena la comprobación de errores, más abajo; aquí se declara para que los
        // veredictos que salen antes no tropiecen con ella.
        $erroresVistos = 0;

        // ============ Apagado ============
        if (! $entorno->active) {
            $quien = $entorno->deactivatedBy?->name;
            $motivo = $entorno->deactivation_reason;
            $desde = $entorno->deactivated_at;

            // **Quién y por qué son dos huecos distintos.** El motivo solo lo pide la
            // modal; apagar desde la casilla del formulario de edición guarda el usuario
            // y la fecha pero deja el motivo vacío, y eso hay que decirlo —un hueco en
            // blanco se lee como que no falta nada—.
            $frases = [];

            $frases[] = match (true) {
                $quien !== null && (bool) $motivo => 'Lo apagó ' . $quien . ' — «' . $motivo . '».',
                $quien !== null => 'Lo apagó ' . $quien . ', sin motivo anotado.',
                (bool) $motivo => 'Motivo: «' . $motivo . '».',
                default => 'No quedó anotado quién lo apagó ni por qué.',
            };

            $frases[] = 'Su Moodle recibe «Entorno inactivo» (403) en todas las acciones.';

            // **Un entorno apagado que sigue llamando es una situación distinta.** El
            // plugin no se enteró de nada: seguirá pidiendo cada día y llevándose un 403
            // hasta que alguien lo desinstale de ese Moodle.
            $desdeApagado = $desde !== null
                ? (clone $peticiones)->where('started_at', '>=', $desde)->count()
                : 0;

            if ($desdeApagado > 0) {
                $frases[] = 'Y sigue llamando: ' . $desdeApagado . ' petición(es) desde '
                    . 'entonces se han llevado un 403. Si el apagado es definitivo, '
                    . 'conviene desinstalar el plugin de su Moodle.';
            }

            return [
                'clave' => 'apagado',
                'titulo' => $desde !== null
                    ? 'Apagado desde el ' . $desde->format('d/m/Y')
                    : 'Apagado',
                'detalle' => implode(' ', $frases),
                'tono' => 'malo',
            ];
        }

        // ============ Llama y recibe errores ============
        //
        // **Solo los que nadie ha revisado.** Un error marcado como visto sigue siendo un
        // error, pero alguien ha dicho ya que lo conoce: mantener la alarma después de eso
        // convierte el «visto» en un botón que no hace nada, y la ficha en una pantalla
        // que grita lo mismo cada día. Los revisados no desaparecen: se cuentan abajo.
        $errores = (clone $peticiones)
            ->where('severity', Severidad::ERROR)
            ->where('started_at', '>=', now()->subDays(self::DIAS_DE_ERRORES))
            ->orderByDesc('started_at')
            ->get(['id', 'action', 'plugin', 'error_code', 'error', 'http_status', 'started_at', 'reviewed_at']);

        $sinRevisar = $errores->whereNull('reviewed_at');

        if ($sinRevisar->isNotEmpty()) {
            $primero = $sinRevisar->first();
            $vistos = $errores->count() - $sinRevisar->count();

            return [
                'clave' => 'errores',
                'titulo' => 'Llama y recibe errores',
                'detalle' => $sinRevisar->count() . ' petición(es) con error sin revisar en los últimos '
                    . self::DIAS_DE_ERRORES . ' días'
                    . ($vistos > 0 ? ' (y ' . $vistos . ' ya vista(s))' : '')
                    . '. La última, `' . $primero->action . '`'
                    . ($primero->plugin ? ' de ' . $primero->plugin : '')
                    . ': ' . self::explicar($primero) . '.',
                'tono' => 'malo',
            ];
        }

        // Hubo errores y están todos vistos: **no es una alerta, pero tampoco es nada**.
        // Se guarda para decirlo en el veredicto que acabe saliendo, en vez de callarlo.
        $erroresVistos = $errores->count();

        // ============ Nunca ha sincronizado ============
        // `refresh_at` lo escribe **solo** la acción `sync`. Que esté a null no quiere
        // decir que el sitio no llame: puede estar pidiendo licencia y contenido sin
        // haber mandado nunca un sync, y entonces lo que falta es todo lo que llega en
        // ese payload —versión, tamaño, inventario—. Son dos situaciones distintas y se
        // cuentan como tales.
        if ($entorno->refresh_at === null) {
            if ($totalPeticiones > 0) {
                return [
                    'clave' => 'sin_sync',
                    'titulo' => 'Llama, pero nunca ha sincronizado',
                    'detalle' => 'Ha hecho ' . $totalPeticiones . ' petición(es) —la última '
                        . self::hace($ultima) . '—, pero su plugin no ha mandado nunca un '
                        . '`sync`. Por eso no sabemos su versión de Moodle, ni su tamaño, '
                        . 'ni su inventario de plugins: todo eso llega en ese payload.',
                    'tono' => 'aviso',
                ];
            }

            return [
                'clave' => 'nunca',
                'titulo' => 'Nunca ha sincronizado',
                'detalle' => 'Creado el ' . $entorno->created_at?->format('d/m/Y')
                    . ' y su Moodle no ha llamado todavía. Comprueba que el plugin está '
                    . 'instalado y configurado con este dominio exacto y con el token de '
                    . 'su licencia.',
                'tono' => 'aviso',
            ];
        }

        // ============ Sin señal ============
        $dias = (int) $entorno->refresh_at->diffInDays(now());

        if ($dias > self::DIAS_SIN_SENAL) {
            return [
                'clave' => 'sin_senal',
                'titulo' => 'Sin señal desde hace ' . $dias . ' días',
                'detalle' => 'Su último sync es del ' . $entorno->refresh_at->format('d/m/Y')
                    . '. Qué mirar: si el sitio sigue en pie, si la tarea programada del '
                    . 'plugin sigue corriendo, y si el dominio ha cambiado —es la llave con '
                    . 'la que la API lo encuentra—.',
                'tono' => 'aviso',
            ];
        }

        // ============ Llama y no recibe nada ============
        if ($entorno->license_token_id === null) {
            return [
                'clave' => 'sin_licencia',
                'titulo' => 'Llama y no recibe nada',
                'detalle' => 'Sincroniza con normalidad —última señal ' . self::hace($entorno->refresh_at)
                    . '— pero no tiene ninguna licencia asociada, así que no recibe ningún '
                    . 'producto por muchas veces que llame.',
                'tono' => 'aviso',
            ];
        }

        // ============ Normal ============
        $desde = now()->subDays(self::DIAS_DE_RESUMEN);
        $ok = (clone $peticiones)->where('started_at', '>=', $desde)
            ->where('severity', Severidad::OK)->count();
        $sinContenido = (clone $peticiones)->where('started_at', '>=', $desde)
            ->where('severity', Severidad::SIN_CONTENIDO)->count();

        $detalle = 'Última señal ' . self::hace($entorno->refresh_at) . '. '
            . $ok . ' petición(es) correctas en ' . self::DIAS_DE_RESUMEN . ' días'
            . ($erroresVistos > 0 ? '.' : ', ninguna con error.');

        if ($sinContenido > 0) {
            // Se dice, y se dice que no es un fallo. Si no se nombra, alguien lo verá en
            // el visor y abrirá una incidencia por algo que funciona.
            $detalle .= ' ' . $sinContenido . ' pidieron contenido que aún no está '
                . 'publicado, que no es un fallo.';
        }

        // **Los errores revisados se nombran.** Quitar la alarma no es lo mismo que
        // borrarlos: si no se dicen, alguien los descubre en el visor y abre una
        // incidencia por algo que ya se estaba mirando.
        if ($erroresVistos > 0) {
            $detalle .= ' Hay ' . $erroresVistos . ' con error, ya revisada(s).';
        }

        return [
            'clave' => 'normal',
            'titulo' => 'Recibe con normalidad',
            'detalle' => $detalle,
            'tono' => 'ok',
        ];
    }

    /**
     * Qué significa una petición fallida, en una frase.
     *
     * El código a secas no vale para nadie que no se sepa las cuatro familias de memoria,
     * y el mensaje técnico del log está en inglés y a veces dice dos cosas distintas con
     * el mismo código. Aquí se traduce lo que se puede afirmar con certeza; el resto se
     * deja en el propio mensaje, que es más honesto que inventar una causa.
     */
    public static function explicar(ApiRequestLog $peticion): string
    {
        $codigo = (int) $peticion->error_code;
        $mensaje = (string) $peticion->error;

        return match (true) {
            // **Los tres casos del host, separados.** El middleware ya los distingue
            // —`ProductToken::motivoDelHostNoEncontrado()` devuelve tres mensajes— y aquí
            // se juntaban todos en «el alta está sin terminar». Eso **contradecía al
            // mensaje del servidor en la misma ficha**: arriba ponía «the environment
            // exists» y abajo que faltaba darlo de alta, y mandaba a soporte a crear un
            // entorno que ya existe. Se mira el mensaje antes que el código porque es el
            // mensaje el que lleva la distinción.
            str_contains($mensaje, 'belongs to a different licence') =>
                'el dominio sí está dado de alta, pero su entorno cuelga de otra licencia '
                . 'de este mismo cliente: hay que corregir la vinculación, no dar de alta nada',
            str_contains($mensaje, 'has no licence assigned yet') =>
                'el dominio sí está dado de alta, pero su entorno no tiene ninguna licencia '
                . 'asignada todavía',
            $codigo === 2002 || str_contains($mensaje, 'Environment not found') =>
                'la API no reconoce el dominio desde el que llama, así que el alta de este '
                . 'entorno está sin terminar',
            $codigo === 2001 =>
                'su licencia no incluye ese producto',
            $codigo % 1000 === 3 && $codigo > 1000 =>
                'o el plugin manda la versión mal formada, o hay un fichero roto en el '
                . 'contenido publicado',
            $peticion->http_status === 429 =>
                'ha superado el límite de peticiones de su licencia',
            $peticion->http_status >= 500 =>
                'ha fallado el Manager, no el cliente',
            default => $mensaje !== '' ? $mensaje : 'código ' . $codigo,
        };
    }

    /** «hace 25 minutos», y «nunca» sin fecha. */
    private static function hace(mixed $fecha): string
    {
        if ($fecha === null) {
            return 'nunca';
        }

        return \Illuminate\Support\Carbon::parse($fecha)->diffForHumans();
    }
}
