<?php

namespace App\Services\System;

use App\Models\System\Config;
use Illuminate\Support\Facades\Cache;

/**
 * Ajustes del Manager editables desde el panel.
 *
 * La tabla `configs` y su modelo venían del Manager antiguo, migrados con sus cinco
 * filas dentro y **sin ninguna pantalla que los leyera ni los escribiera**: cambiar el
 * correo al que van los avisos exigía tocar la base a mano. Los permisos
 * `admin.configs.index` y `admin.configs.edit` también estaban reservados en el
 * seeder desde el principio, sin usarse.
 *
 * **Qué va aquí y qué no.** Aquí van los ajustes que Soporte tiene que poder cambiar
 * solo: a quién se avisa, cada cuánto, a qué hora sale el resumen. **Las credenciales
 * siguen en el `.env`** —el SMTP, entre otras—: una credencial en base de datos es un
 * secreto en un backup, y un SMTP mal guardado desde el panel dejaría al Manager sin
 * poder enviar nada, incluidos los correos de recuperación de contraseña. El Manager
 * antiguo guardaba el token de Jira en `configs.value` en claro, y no vamos a repetirlo.
 *
 * Los valores se cachean porque se leen en cada petición de la API que acaba en un
 * bloqueo; `olvidar()` limpia al guardar.
 */
class Settings
{
    /** Ámbito de los ajustes globales del Manager en la columna `model`. */
    public const AMBITO = 'manager';

    private const CACHE_KEY = 'settings:manager';

    private const CACHE_SEGUNDOS = 300;

    /* ==========================================================
       Claves. Se declaran como constantes para que no haya cadenas
       sueltas repartidas por el código.
       ========================================================== */

    /** Destinatarios de los avisos de bloqueo, separados por coma. */
    public const AVISOS_EMAIL = 'alerts:blocks:email';

    /** Interruptor del aviso al bloquear. */
    public const AVISOS_ACTIVOS = 'alerts:blocks:enabled';

    /** Interruptor del resumen diario. */
    public const RESUMEN_ACTIVO = 'alerts:digest:enabled';

    /** Hora a la que sale el resumen diario (0-23). */
    public const RESUMEN_HORA = 'alerts:digest:hour';

    /**
     * Destinatarios de los avisos de licencias que caducan. **Separados de los de
     * bloqueo a propósito**: un bloqueo de la API es cosa de Sistemas y una licencia
     * que caduca es cosa de Comercial y Soporte.
     */
    public const LICENCIAS_EMAIL = 'alerts:licence:email';

    /** Interruptor del aviso de licencias que caducan. */
    public const LICENCIAS_ACTIVO = 'alerts:licence:enabled';

    /** Cuántos días antes de caducar se avisa. */
    public const LICENCIAS_DIAS = 'alerts:licence:days';

    /**
     * Destinatarios de los avisos de soportes que caducan.
     *
     * **Si se deja vacío se usan los de licencias**, y es deliberado: en el Manager
     * antiguo esto iba a `help@tresipunt.com`, pero un ajuste más que empieza vacío es un
     * aviso más que parece configurado y no llega a nadie. Quien quiera separarlos —el
     * soporte lo lleva Soporte y las licencias Comercial— pone aquí sus direcciones.
     */
    public const SOPORTES_EMAIL = 'alerts:support:email';

    /** Interruptor del aviso de soportes que caducan. */
    public const SOPORTES_ACTIVO = 'alerts:support:enabled';

    /**
     * Días de antelación a los que se avisa, separados por comas.
     *
     * **Son varios, no uno**, al contrario que en las licencias: el Manager antiguo
     * avisaba a 30, 15, 7 y 0 días, y tiene sentido —un soporte se renueva con
     * negociación, así que un solo aviso a diez días llega tarde—. El 0 es el día de la
     * caducidad.
     */
    public const SOPORTES_DIAS = 'alerts:support:days';

    /**
     * ¿Los límites de la API CORTAN, o solo observan?
     *
     * Viene apagado a propósito: se despliega midiendo. Ver la migración
     * `add_enforced_to_api_blocks` y `alerts-and-settings.md`.
     */
    public const LIMITES_CORTAN = 'limits:enforce';

    /*
    |--------------------------------------------------------------------------
    | Umbrales de los límites de la API
    |--------------------------------------------------------------------------
    |
    | Estaban SOLO en el `.env`. Eso significaba que ajustar un tope —lo que hay que
    | hacer precisamente cuando se está midiendo con tráfico real— pedía entrar al
    | servidor, editar un fichero y limpiar la caché de configuración. Con el modo
    | observación activado, esa fricción es justo la que impide que el modo sirva de
    | algo: se observa para afinar, y afinar tenía que poder hacerse desde el panel.
    |
    | Cada clave **cae al valor de `config/api.php`** si no está puesta, y ese cae al
    | `.env`. Así el `.env` sigue siendo el valor de arranque de una instalación nueva
    | y el panel manda cuando alguien decide otra cosa.
    */

    /** Respuestas 401/403 por IP antes de considerarla bloqueada. */
    public const LIMITE_FALLOS = 'limits:failures';

    /** Ventana del contador de fallos, en segundos. */
    public const LIMITE_FALLOS_VENTANA = 'limits:failures_window';

    /** Techo total de peticiones por IP. */
    public const LIMITE_PETICIONES = 'limits:requests';

    /** Ventana del techo de peticiones, en segundos. */
    public const LIMITE_PETICIONES_VENTANA = 'limits:requests_window';

    /**
     * Margen de aviso anticipado, en **porcentaje** del tope (50 = a la mitad).
     *
     * Se guarda en porcentaje y no en fracción porque es lo que se escribe en un
     * formulario sin equivocarse: un 0,5 mal tecleado como 5 multiplica el margen por
     * diez sin que salte nada.
     */
    public const LIMITE_MARGEN = 'limits:warning_percent';

    /**
     * Días que se guardan los logs de la API; 0 = no se borra nada automáticamente.
     *
     * Viene en 0 por el mismo motivo que los límites vienen sin cortar: una tarea que
     * borra rastro de auditoría no debe empezar a funcionar sola el día del despliegue.
     * Se activa cuando alguien decide cuánto histórico quiere conservar.
     */
    public const LOGS_RETENCION = 'logs:retention_days';

    /**
     * Días que se conservan los **episodios de corte revisados**.
     *
     * `api_blocks` no tenía retención ninguna: solo crecía. Y crece más rápido de lo que
     * parece, porque un episodio revisado que vuelve a pasar **abre fila nueva** — una IP
     * que aparece cada día deja una fila cada día.
     *
     * Los pendientes **no caducan nunca**: un episodio sin revisar es algo que nadie ha
     * mirado, y borrarlo por antigüedad sería perder justo lo que quedaba por atender.
     */
    public const CORTES_RETENCION = 'blocks:retention_days';

    /**
     * Días que se conservan las **ventanas del limitador** (`api_rate_events`).
     *
     * Plazo más corto que el de los episodios, y a propósito: un episodio revisado es una
     * incidencia que alguien atendió y conviene poder mirarla semanas después; una ventana
     * es telemetría de un minuto concreto y a los treinta días no responde ninguna
     * pregunta. Es además la tabla que más filas mete por unidad de tráfico.
     */
    public const VENTANAS_RETENCION = 'rate_events:retention_days';

    /* ==========================================================
       Los parámetros del contenido de producto que estaban
       escritos en el código.
       ========================================================== */

    /**
     * Tope del ZIP de un bundle SCSS-CDN, en megabytes.
     *
     * **Alimenta las dos comprobaciones**: la validación del formulario y la del
     * servicio que abre el ZIP. Estaban escritas por separado —`ZIP_KB` y
     * `MAX_FILE_SIZE`—, con el mismo número dos veces, y ese es el patrón que ya se
     * pagó con los límites de subida: uno cambia y el otro no, y la pantalla anuncia
     * un tope que el servidor no respeta.
     *
     * Sigue **por debajo de lo que permita el PHP de la máquina**: `LimiteDeSubida`
     * coge el menor de los tres, así que subir este número no puede saltarse el
     * `upload_max_filesize`.
     */
    public const SCSSCDN_ZIP_MB = 'scsscdn:zip_max_mb';

    /**
     * Cuánto vive la URL firmada que se le manda al plugin, **en segundos**.
     *
     * En segundos y no en horas porque la única alternativa que se ha planteado son
     * **30 segundos** —lo que el autor del theme recordaba haber acordado— y en horas
     * no se puede escribir. El valor de siempre son 3600.
     *
     * **Cuidado al bajarlo**: esa URL viaja dentro del SCSS que Moodle compila, así
     * que la ventana tiene que cubrir el tiempo entre que se sirve el punto de entrada
     * y que Moodle sigue cada `@import`. Si caduca en medio, el cliente se queda sin
     * parte de los estilos y **no falla nada visible**: el theme tira de su caché.
     */
    public const SCSSCDN_URL_API_SEGUNDOS = 'scsscdn:api_url_seconds';

    /**
     * Cuánto vive la URL firmada que se **pinta en el panel**, en días.
     *
     * Es la del enlace de la ficha del bundle y del editor de un fichero, que están
     * para mirar el contenido. Valía **365 días**: un enlace copiado de un pantallazo
     * da acceso al fichero durante un año sin sesión. Se deja en 365 para no cambiar
     * comportamiento al desplegar, pero es el candidato obvio a bajar.
     */
    public const SCSSCDN_URL_PANEL_DIAS = 'scsscdn:panel_url_days';

    /**
     * El catálogo de lo que se puede configurar, agrupado como se pinta.
     *
     * **Existe porque hasta ahora no había ninguna lista.** Los ajustes vivían repartidos
     * entre las constantes de esta clase, los campos del componente de Ajustes y el blade
     * de 802 líneas, y **nadie podía decir cuántos hay**: el brief dijo «15» contando a
     * ojo, y son 17. Con la portada de Configuración enseñando el recuento, contarlo a
     * ojo deja de valer.
     *
     * Cada entrada dice **qué es y qué pasa si se cambia**, porque en esta sección todo
     * lo que se toca tiene consecuencias en otro lado y esa frase es la que la pantalla
     * enseña.
     *
     * `tipo`:
     * - `bool` — activado / desactivado.
     * - `modo` — el caso especial de los límites: observar o cortar. Va con confirmación.
     * - `correos` — lista separada por comas; vacío significa «no se avisa a nadie».
     * - `dias` — lista de días de antelación.
     * - `entero` — un número; **con `heredable`, el 0 significa «usa el del `.env`»**.
     *
     * @var array<string, array{nombre: string, nota: string, efecto: string, ruta: ?string, ajustes: array<string, array{desc: string, tipo: string, heredable?: bool}>}>
     */
    public const CATALOGO = [
        'limites' => [
            'nombre' => 'Límites de la API',
            'nota' => 'Cada tope se cuenta por IP y dentro de una ventana de tiempo. Bajarlo puede empezar a cortarle el servicio a clientes reales.',
            'efecto' => 'Ver el efecto en Monitorización',
            'ruta' => 'monitoring.traffic',
            'ajustes' => [
                self::LIMITES_CORTAN => [
                    'desc' => 'Qué pasa cuando alguien supera un límite',
                    'tipo' => 'modo',
                ],
                self::LIMITE_PETICIONES => [
                    'unidad' => ['petición', 'peticiones'],
                    'desc' => 'Cuántas peticiones puede hacer una IP antes de limitarla',
                    'tipo' => 'entero',
                    'heredable' => true,
                    'min' => 0,
                    'max' => 1000000,
                ],
                self::LIMITE_PETICIONES_VENTANA => [
                    'unidad' => ['segundo', 'segundos'],
                    'desc' => 'En cuánto tiempo se cuentan esas peticiones',
                    'tipo' => 'entero',
                    'heredable' => true,
                    'min' => 0,
                    'max' => 86400,
                ],
                self::LIMITE_FALLOS => [
                    'unidad' => ['fallo', 'fallos'],
                    'desc' => 'Cuántas peticiones con error puede hacer una IP antes de bloquearla',
                    'tipo' => 'entero',
                    'heredable' => true,
                    'min' => 0,
                    'max' => 100000,
                ],
                self::LIMITE_FALLOS_VENTANA => [
                    'unidad' => ['segundo', 'segundos'],
                    'desc' => 'En cuánto tiempo se cuentan esos errores',
                    'tipo' => 'entero',
                    'heredable' => true,
                    'min' => 0,
                    'max' => 86400,
                ],
                self::LIMITE_MARGEN => [
                    'unidad' => ['%', '%'],
                    'desc' => 'A qué porcentaje del tope se empieza a avisar, antes de cortar',
                    'tipo' => 'entero',
                    'heredable' => true,
                    'min' => 0,
                    'max' => 99,
                ],
            ],
        ],
        'avisos' => [
            'nombre' => 'Avisos por correo',
            'nota' => 'Si se queda sin destinatarios, se deja de avisar y no salta ningún error.',
            'efecto' => 'Ver los avisos enviados',
            'ruta' => 'monitoring.notices',
            'ajustes' => [
                self::LICENCIAS_ACTIVO => [
                    'desc' => 'Avisar cuando una licencia va a caducar',
                    'tipo' => 'bool',
                ],
                self::LICENCIAS_EMAIL => [
                    'desc' => 'A quién se avisa de las licencias que caducan',
                    'tipo' => 'correos',
                ],
                self::LICENCIAS_DIAS => [
                    'unidad' => ['día', 'días'],
                    'desc' => 'Cuántos días antes se avisa de una licencia',
                    'tipo' => 'entero',
                    'min' => 1,
                    'max' => 365,
                ],
                self::SOPORTES_ACTIVO => [
                    'desc' => 'Avisar cuando un contrato de soporte va a caducar',
                    'tipo' => 'bool',
                ],
                self::SOPORTES_EMAIL => [
                    'desc' => 'A quién se avisa de los soportes que caducan',
                    'tipo' => 'correos',
                ],
                self::SOPORTES_DIAS => [
                    'desc' => 'Cuántos días antes se avisa de un soporte',
                    'tipo' => 'dias',
                ],
                self::AVISOS_ACTIVOS => [
                    'desc' => 'Avisar cuando se bloquea una IP o una licencia',
                    'tipo' => 'bool',
                ],
                self::AVISOS_EMAIL => [
                    'desc' => 'A quién se avisa de los bloqueos',
                    'tipo' => 'correos',
                ],
                self::RESUMEN_ACTIVO => [
                    'desc' => 'Enviar un resumen diario de los bloqueos',
                    'tipo' => 'bool',
                ],
                self::RESUMEN_HORA => [
                    'desc' => 'A qué hora sale el resumen diario',
                    'tipo' => 'entero',
                    'min' => 0,
                    'max' => 23,
                ],
            ],
        ],
        'contenido' => [
            'nombre' => 'Contenido de producto: SCSS-CDN',
            'nota' => 'Números que estaban escritos en el código. Los de fábrica son los que llevaba funcionando: cámbialos solo con un motivo, y sabiendo que un SCSS que no llega no rompe nada visible —el theme del cliente tira de su caché—.',
            'efecto' => 'Ver los productos',
            'ruta' => 'products.index',
            'ajustes' => [
                self::SCSSCDN_ZIP_MB => [
                    'unidad' => ['MB', 'MB'],
                    'desc' => 'Tamaño máximo del ZIP de un bundle, en MB',
                    'tipo' => 'entero',
                    'min' => 1,
                    'max' => 500,
                ],
                self::SCSSCDN_URL_API_SEGUNDOS => [
                    'unidad' => ['segundo', 'segundos'],
                    'desc' => 'Cuántos segundos vive la dirección firmada que recibe el plugin',
                    'tipo' => 'entero',
                    'min' => 30,
                    'max' => 86400,
                ],
                self::SCSSCDN_URL_PANEL_DIAS => [
                    'unidad' => ['día', 'días'],
                    'desc' => 'Cuántos días vive la dirección firmada que se enseña en el panel',
                    'tipo' => 'entero',
                    'min' => 1,
                    'max' => 3650,
                ],
            ],
        ],
        'retencion' => [
            'nombre' => 'Retención del log de la API',
            'nota' => 'La tarea de las 03:30 borra las peticiones más antiguas que el plazo. '
                . 'Con 0 se ejecuta igual y no borra nada, que es como viene: este registro es '
                . 'el rastro de auditoría de la API —qué pidió cada sitio y qué se le respondió— '
                . 'y cuánto se conserva es una decisión que tiene que tomar alguien. Es la tabla '
                . 'que más crece, una fila por petición. Lo que se va no vuelve.',
            'efecto' => 'Ver el visor de peticiones',
            'ruta' => 'api-logs.index',
            'ajustes' => [
                self::LOGS_RETENCION => [
                    'unidad' => ['día', 'días'],
                    'desc' => 'Cuántos días de peticiones se conservan. 0 = no se borra nada',
                    'tipo' => 'entero',
                    'min' => 0,
                    'max' => 3650,
                ],
            ],
        ],
        // **Las dos tablas de Monitorización, que no tenían retención ninguna.**
        //
        // Y el matiz que costó horas averiguar: estas dos claves estaban declaradas aquí
        // pero **ninguna migración las sembraba**, así que ni siquiera existían como fila en
        // `configs`. `get($clave, 0)` devolvía 0, y 0 significa «no borrar nada», de modo que
        // la tarea de las 03:45 se ejecutaba cada noche sin borrar nunca. La limpieza parecía
        // montada y no lo estaba, y la pantalla de Tráfico iba acumulando ruido sin final.
        //
        // Desde `seed_blocks_retention_settings` tienen valor. Ver `LimpiezaDeCortes` y
        // `briefs/trafico-y-cortes.md`.
        'retencion_cortes' => [
            'nombre' => 'Retención de Tráfico y cortes',
            'nota' => 'La tarea de las 03:45 borra lo más antiguo que el plazo de cada tabla. '
                . 'Con 0 se ejecuta igual y no borra nada. Los episodios son incidentes —uno por '
                . 'sujeto y motivo, con un contador de rechazos dentro—, y las ventanas son '
                . 'telemetría de un minuto: ninguna de las dos crece con el tráfico, crecen con '
                . 'los incidentes. **Un corte pendiente no caduca nunca**, ni por aquí ni a '
                . 'mano: sin revisar es algo que nadie ha mirado todavía, y se quita revisándolo '
                . 'y borrándolo desde la propia pantalla.',
            'efecto' => 'Ver Tráfico y cortes',
            'ruta' => 'monitoring.traffic',
            'ajustes' => [
                self::CORTES_RETENCION => [
                    'unidad' => ['día', 'días'],
                    'desc' => 'Cuántos días se conservan los episodios ya revisados. Los '
                        . 'pendientes no se borran nunca. 0 = no se borra nada',
                    'tipo' => 'entero',
                    'min' => 0,
                    'max' => 3650,
                ],
                self::VENTANAS_RETENCION => [
                    'unidad' => ['día', 'días'],
                    'desc' => 'Cuántos días se conservan las ventanas de un minuto que cruzaron '
                        . 'el margen de aviso. Es telemetría, no auditoría. 0 = no se borra nada',
                    'tipo' => 'entero',
                    'min' => 0,
                    'max' => 3650,
                ],
            ],
        ],
    ];

    /**
     * La ficha de un ajuste: qué vale, si es suyo o heredado, y qué se aplica de verdad.
     *
     * **Es la respuesta a «qué está en su valor por defecto y qué se ha tocado»**, que
     * era invisible: varios umbrales están a `0`, que significa «usa el del `.env`», y
     * eso no se distinguía de «lo he puesto a cero». La pantalla de Ajustes lo enseña con
     * dos etiquetas distintas, y la modal de edición lo dice antes de guardar.
     *
     * @return array{clave: string, desc: string, tipo: string, crudo: ?string, valor: string, heredado: bool, efectivo: ?string}
     */
    public function fichaDe(string $clave): array
    {
        $meta = $this->metaDe($clave);
        $crudo = $this->get($clave);
        $crudo = $crudo === null ? null : (string) $crudo;

        // «Heredado» es solo para los umbrales: en ellos el 0 es la señal de «no
        // configurado» y la cascada es panel → config/api.php → .env. En un booleano o en
        // una lista de correos, vacío significa vacío, no heredado.
        $heredado = ($meta['heredable'] ?? false)
            && ($crudo === null || trim($crudo) === '' || (int) $crudo === 0);

        return [
            'clave' => $clave,
            'desc' => $meta['desc'],
            'tipo' => $meta['tipo'],
            'crudo' => $crudo,
            'valor' => $this->comoSeLee($clave, $meta['tipo'], $crudo, $heredado),
            // Vacía cuando el ajuste no es un número con unidad —un booleano, una lista de
            // correos— o cuando está heredado, que entonces el valor que se pinta es un 0
            // que significa «no configurado», no cero segundos.
            'unidad' => $heredado ? '' : $this->unidadDe($clave, $crudo),
            'heredado' => $heredado,
            'efectivo' => $heredado ? $this->efectivoDe($clave) : null,
        ];
    }

    /**
     * Los grupos del catálogo, cada uno con las fichas de sus ajustes.
     *
     * Se calcula de una vez porque `todos()` está cacheado: las 17 fichas salen de un
     * único array en memoria, no de 17 consultas.
     *
     * @return array<string, array<string, mixed>>
     */
    public function grupos(): array
    {
        $grupos = [];

        foreach (self::CATALOGO as $clave => $grupo) {
            $grupos[$clave] = [
                'nombre' => $grupo['nombre'],
                'nota' => $grupo['nota'],
                'efecto' => $grupo['efecto'],
                'ruta' => $grupo['ruta'],
                'ajustes' => array_map(
                    fn (string $ajuste) => $this->fichaDe($ajuste),
                    array_keys($grupo['ajustes'])
                ),
            ];
        }

        return $grupos;
    }

    /**
     * La unidad de un ajuste, ya en singular o plural según el número.
     *
     * **Va pegada al valor y no solo en la descripción.** En una lista de ajustes, `50`,
     * `3600` y `365` seguidos —megas, segundos y días— tienen la misma pinta, y para saber
     * cuál es cuál hay que volver a leer el texto de arriba. Donde más importa es en la
     * modal de edición: escribir `3600` pensando en días es un error silencioso que no se
     * nota hasta mucho después.
     */
    public function unidadDe(string $clave, ?string $valor): string
    {
        $meta = $this->metaDe($clave);

        if (! isset($meta['unidad'])) {
            return '';
        }

        [$singular, $plural] = $meta['unidad'];

        return abs((int) $valor) === 1 ? $singular : $plural;
    }

    /** Los metadatos de un ajuste del catálogo. */
    public function metaDe(string $clave): array
    {
        foreach (self::CATALOGO as $grupo) {
            if (isset($grupo['ajustes'][$clave])) {
                return $grupo['ajustes'][$clave];
            }
        }

        // Una clave que no está en el catálogo no se pinta ni se edita: es un ajuste que
        // alguien metió en la tabla sin declararlo, y la pantalla no debe inventárselo.
        throw new \InvalidArgumentException('El ajuste «' . $clave . '» no está en el catálogo.');
    }

    /** ¿Está declarado? La modal de edición lo comprueba antes de tocar nada. */
    public function estaEnElCatalogo(string $clave): bool
    {
        foreach (self::CATALOGO as $grupo) {
            if (isset($grupo['ajustes'][$clave])) {
                return true;
            }
        }

        return false;
    }

    /**
     * Cómo se lee un valor en pantalla.
     *
     * Un `1` no se le enseña a nadie: se dice «Activado». Y una lista de días se escribe
     * separada por puntos —`30 · 15 · 7 · 0`— porque las comas se confunden con el
     * separador de miles.
     */
    private function comoSeLee(string $clave, string $tipo, ?string $crudo, bool $heredado): string
    {
        if ($heredado) {
            return '0';
        }

        return match ($tipo) {
            'bool' => $this->activo($clave) ? 'Activado' : 'Desactivado',
            'modo' => $this->losLimitesCortan() ? 'Cortar' : 'Observar',
            'correos' => ($correos = $this->correos($clave)) === []
                ? 'Sin destinatarios'
                : implode(', ', $correos),
            'dias' => implode(' · ', $this->diasDeAvisoDeSoportes()),
            default => $crudo === null || trim($crudo) === '' ? '0' : trim($crudo),
        };
    }

    /**
     * Qué se aplica de verdad cuando el ajuste está heredado.
     *
     * Sale de `umbralesDeLaApi()`, que es quien resuelve la cascada, para que no haya dos
     * sitios donde decidir el valor efectivo.
     */
    private function efectivoDe(string $clave): ?string
    {
        $umbrales = $this->umbralesDeLaApi();

        return match ($clave) {
            self::LIMITE_PETICIONES => number_format($umbrales['requests'], 0, ',', '.') . ' peticiones',
            self::LIMITE_PETICIONES_VENTANA => $umbrales['requests_decay'] . ' s',
            self::LIMITE_FALLOS => number_format($umbrales['failures'], 0, ',', '.') . ' fallos',
            self::LIMITE_FALLOS_VENTANA => $umbrales['failures_decay'] . ' s',
            self::LIMITE_MARGEN => ((int) round($umbrales['warning_ratio'] * 100)) . ' %',
            default => null,
        };
    }

    /** Cuántos ajustes hay, de verdad. La portada lo enseña. */
    public static function cuantosAjustes(): int
    {
        return array_sum(array_map(
            fn (array $grupo) => count($grupo['ajustes']),
            self::CATALOGO
        ));
    }

    /**
     * Todos los ajustes del ámbito, indexados por su clave corta.
     *
     * @return array<string, string>
     */
    public function todos(): array
    {
        return Cache::remember(self::CACHE_KEY, self::CACHE_SEGUNDOS, function () {
            return Config::where('model', self::AMBITO)
                ->pluck('value', 'shortname')
                ->all();
        });
    }

    public function get(string $clave, string|int|null $porDefecto = null): string|int|null
    {
        return $this->todos()[$clave] ?? $porDefecto;
    }

    public function activo(string $clave, bool $porDefecto = false): bool
    {
        $valor = $this->get($clave);

        if ($valor === null) {
            return $porDefecto;
        }

        return $valor === '1';
    }

    /**
     * Direcciones de correo de un ajuste, ya limpias y validadas.
     *
     * Se devuelve una lista para que quien la use no tenga que partir la cadena ni
     * preocuparse por espacios, comas de más o direcciones mal escritas: una dirección
     * inválida guardada por error no debe tumbar el envío al resto.
     *
     * @return array<int, string>
     */
    public function correos(string $clave): array
    {
        return $this->correosDeUnTexto((string) $this->get($clave, ''));
    }

    /**
     * Las direcciones válidas de un texto separado por comas.
     *
     * Aparte de `correos()` porque la modal de edición tiene que poder decir «esto se va
     * a quedar sin destinatarios» **antes** de guardar, y para eso hay que limpiar el
     * texto del campo, no el de la base de datos.
     *
     * @return array<int, string>
     */
    public function correosDeUnTexto(string $texto): array
    {
        return array_values(array_filter(
            array_map('trim', explode(',', $texto)),
            fn ($correo) => $correo !== '' && filter_var($correo, FILTER_VALIDATE_EMAIL)
        ));
    }

    /**
     * Guarda un ajuste. Crea la fila si no existe, para que un despliegue que añada
     * una clave nueva no dependa de haber pasado el seeder.
     */
    public function set(string $clave, string|int|null $valor): void
    {
        Config::updateOrCreate(
            [
                'model' => self::AMBITO,
                'model_id' => '0',
                'shortname' => $clave,
            ],
            [
                'value' => (string) $valor,
                // `name`, `type` y `desc` los pone el seeder con el texto bueno; aquí
                // solo se rellenan si la fila nace de un guardado.
                'name' => Config::where('model', self::AMBITO)->where('shortname', $clave)->value('name') ?? $clave,
                'type' => Config::where('model', self::AMBITO)->where('shortname', $clave)->value('type') ?? 'text',
                'mode' => 'wr',
            ]
        );

        $this->olvidar();
    }

    public function olvidar(): void
    {
        Cache::forget(self::CACHE_KEY);
    }

    /* ==========================================================
       Accesos con nombre, para no repetir valores por defecto
       ========================================================== */

    /** @return array<int, string> */
    public function destinatariosDeAvisos(): array
    {
        return $this->correos(self::AVISOS_EMAIL);
    }

    public function avisaDeBloqueos(): bool
    {
        // Por defecto SÍ: la petición fue explícita —"es importante mandar un email
        // cuando hay un bloqueo SIEMPRE"—. Antes venía desactivado
        // (`API_ALERTS_ENABLED=false`) y por eso nunca llegó ninguno.
        return $this->activo(self::AVISOS_ACTIVOS, true);
    }

    public function mandaResumenDiario(): bool
    {
        return $this->activo(self::RESUMEN_ACTIVO, true);
    }

    public function horaDelResumen(): int
    {
        $hora = (int) $this->get(self::RESUMEN_HORA, 8);

        return max(0, min(23, $hora));
    }

    /** @return array<int, string> */
    public function destinatariosDeLicencias(): array
    {
        return $this->correos(self::LICENCIAS_EMAIL);
    }

    public function avisaDeLicencias(): bool
    {
        return $this->activo(self::LICENCIAS_ACTIVO, true);
    }

    /**
     * Días antes de caducar a los que se avisa.
     *
     * Se acota entre 1 y 365: un 0 dejaría solo el aviso del día de la caducidad, que
     * es tarde para renovar, y un número enorme avisaría de todo desde el primer día.
     */
    /* ==========================================================
       Los del SCSS-CDN. Cada uno acota igual que su ajuste, porque
       un valor puesto a mano en la base no pasa por la pantalla.
       ========================================================== */

    /** El tope del ZIP en kilobytes, que es la unidad con la que valida Laravel. */
    public function zipDeBundleEnKb(): int
    {
        $mb = (int) $this->get(self::SCSSCDN_ZIP_MB, 50);

        return max(1, min(500, $mb)) * 1024;
    }

    /** Y en bytes, que es lo que compara el servicio que abre el ZIP. */
    public function zipDeBundleEnBytes(): int
    {
        return $this->zipDeBundleEnKb() * 1024;
    }

    public function segundosDeUrlDeApi(): int
    {
        $segundos = (int) $this->get(self::SCSSCDN_URL_API_SEGUNDOS, 3600);

        return max(30, min(86400, $segundos));
    }

    public function diasDeUrlDelPanel(): int
    {
        $dias = (int) $this->get(self::SCSSCDN_URL_PANEL_DIAS, 365);

        return max(1, min(3650, $dias));
    }

    public function diasDeAvisoDeLicencias(): int
    {
        $dias = (int) $this->get(self::LICENCIAS_DIAS, 10);

        return max(1, min(365, $dias));
    }

    /**
     * Destinatarios de los avisos de soportes.
     *
     * Cae a los de licencias si no hay propios: ver `SOPORTES_EMAIL`.
     *
     * @return array<int, string>
     */
    public function destinatariosDeSoportes(): array
    {
        $propios = $this->correos(self::SOPORTES_EMAIL);

        return $propios !== [] ? $propios : $this->destinatariosDeLicencias();
    }

    public function avisaDeSoportes(): bool
    {
        return $this->activo(self::SOPORTES_ACTIVO, true);
    }

    /**
     * Días de antelación de los avisos de soporte, ordenados de mayor a menor.
     *
     * Se acepta cualquier cosa por la entrada y se limpia aquí: duplicados fuera,
     * negativos fuera, un tope de 365 y un máximo de seis avisos. Sin el tope, un
     * "0,1,2,3,…,300" mandaría trescientos correos por soporte, que es la forma más
     * rápida de que nadie vuelva a leer un aviso del Manager.
     *
     * El orden descendente importa: se avisa primero del umbral más lejano, y así el
     * correo que llega dice "quedan 30 días" y no "quedan 0".
     *
     * @return array<int, int>
     */
    public function diasDeAvisoDeSoportes(): array
    {
        $crudo = trim((string) $this->get(self::SOPORTES_DIAS, '30,15,7,0'));

        // Los trozos vacíos se descartan ANTES de convertir a entero: `intval('')` es 0,
        // que es un umbral válido, así que vaciar el campo desde el panel dejaba la lista
        // en [0] —avisar solo el día de la caducidad— en vez de volver a los de siempre.
        $trozos = array_filter(array_map('trim', explode(',', $crudo)), fn ($t) => $t !== '');

        $dias = array_filter(
            array_map('intval', $trozos),
            fn ($dia) => $dia >= 0 && $dia <= 365
        );

        $dias = array_values(array_unique($dias));
        rsort($dias);

        // Si la configuración se queda en nada, se vuelve al valor del Manager antiguo:
        // un aviso sin umbrales no avisaría nunca, y en silencio.
        return $dias !== [] ? array_slice($dias, 0, 6) : [30, 15, 7, 0];
    }

    /**
     * ¿Se corta de verdad al pasarse del límite?
     *
     * **Por defecto NO.** Los umbrales se eligieron a partir de un único incidente y
     * el tráfico real de 200 entornos no se conoce hasta medirlo. Cortar con un número
     * inventado deja sin servicio a un cliente legítimo, así que primero se observa —se
     * cuenta, se registra y se avisa, pero la petición pasa— y cuando hay datos se
     * activa desde el panel.
     */
    public function losLimitesCortan(): bool
    {
        return $this->activo(self::LIMITES_CORTAN, false);
    }

    /**
     * Los umbrales de los límites de la API, ya resueltos.
     *
     * **Un solo método y una sola lectura.** Esto se llama en cada petición de la API, y
     * los ajustes vienen de un array ya cacheado, así que resolver los cinco valores de
     * golpe no cuesta más que resolver uno. Devolver un array evita además que el
     * middleware mezcle dos orígenes distintos y acabe con el tope del panel y la
     * ventana del `.env`.
     *
     * **Cada valor cae al `config/api.php`** cuando el panel no lo tiene puesto, y un
     * valor guardado a 0 o vacío se trata como "no puesto": un tope de 0 cortaría
     * absolutamente todo el tráfico de la API, y no hay ninguna razón para poder
     * configurar eso desde un formulario. Para no cortar está el modo observación.
     *
     * @return array{failures: int, failures_decay: int, requests: int, requests_decay: int, warning_ratio: float}
     */
    public function umbralesDeLaApi(): array
    {
        $config = config('api.throttle');

        $numero = function (string $clave, $porDefecto): int {
            $valor = (int) $this->get($clave, 0);

            return $valor > 0 ? $valor : (int) $porDefecto;
        };

        // El margen se guarda en porcentaje (50) y el middleware lo usa como fracción
        // (0.5). La conversión vive aquí para que solo haya un sitio donde equivocarse.
        $margen = (int) $this->get(self::LIMITE_MARGEN, 0);
        $ratio = $margen > 0 && $margen < 100
            ? $margen / 100
            : (float) ($config['warning_ratio'] ?? 0.5);

        return [
            'failures' => $numero(self::LIMITE_FALLOS, $config['failures'] ?? 20),
            'failures_decay' => $numero(self::LIMITE_FALLOS_VENTANA, $config['failures_decay'] ?? 60),
            'requests' => $numero(self::LIMITE_PETICIONES, $config['requests'] ?? 600),
            'requests_decay' => $numero(self::LIMITE_PETICIONES_VENTANA, $config['requests_decay'] ?? 60),
            'warning_ratio' => $ratio,
        ];
    }

    /**
     * Días de histórico que se conservan en `api_request_logs`; 0 = sin límite.
     *
     * Se acota a 3650 días para que un dedazo no deje una retención de un siglo, y el
     * mínimo real es 1: una retención de menos de un día borraría el rastro del
     * incidente que se está investigando.
     */
    public function diasDeRetencionDeLogs(): int
    {
        $dias = (int) $this->get(self::LOGS_RETENCION, 0);

        return $dias <= 0 ? 0 : min(3650, $dias);
    }

    /**
     * Días que se conservan los episodios de corte revisados; 0 = sin límite.
     *
     * Mismo criterio que la retención del log: `0` es «no borres nada», que es lo que
     * permite desplegar la tarea antes de decidir el plazo.
     */
    public function diasDeRetencionDeCortes(): int
    {
        $dias = (int) $this->get(self::CORTES_RETENCION, 0);

        return $dias <= 0 ? 0 : min(3650, $dias);
    }

    /** Días que se conservan las ventanas del limitador; 0 = sin límite. */
    public function diasDeRetencionDeVentanas(): int
    {
        $dias = (int) $this->get(self::VENTANAS_RETENCION, 0);

        return $dias <= 0 ? 0 : min(3650, $dias);
    }
}
