<?php

namespace App\Services\System;

use App\Models\Monitoring\ApiBlock;
use App\Models\Monitoring\ApiRateEvent;
use Illuminate\Support\Collection;

/**
 * Quién se está acercando al tope de la API.
 *
 * **La tabla existía y no la leía ninguna pantalla.** `api_rate_events` la escribe el
 * limitador desde agosto —una fila por sujeto, ventana de un minuto y tipo de límite, con
 * el máximo observado— y su docblock dice para qué sirve: «responder lo que antes no se
 * podía: qué IPs están cerca del tope». Nadie preguntaba.
 *
 * Y era la respuesta a la segunda pregunta con la que se abre Monitorización: **«¿va a
 * acabar cortado alguien?»**. Los cortes dicen lo que ya ha pasado; esto dice lo que está a
 * punto de pasar, que es cuando aún se puede hacer algo.
 *
 * ## Por qué el máximo y no la suma
 *
 * El límite se cuenta **por ventana**: pasarse es llegar al tope dentro de un minuto, no
 * sumar mil peticiones a lo largo del día. Sumar las ventanas daría un número enorme que no
 * se parece a nada de lo que decide el limitador, y pondría arriba al que llama mucho y
 * repartido —que es justo el que no corre peligro—.
 */
class CercaDelTope
{
    /** Cuántos sujetos se enseñan. Es un aviso, no un listado. */
    public const CUANTOS = 10;

    /** La ventana que se mira. Un día: lo de ayer ya no avisa de nada. */
    public const HORAS = 24;

    /**
     * Los sujetos que han cruzado el margen de aviso, del que más se acercó al que menos.
     *
     * @return Collection<int, array<string, mixed>>
     */
    public static function ultimasHoras(): Collection
    {
        $filas = ApiRateEvent::query()
            ->where('window_started_at', '>=', now()->subHours(self::HORAS))
            ->selectRaw('ip, limit_kind,
                max(observed) as maximo,
                max(limit_value) as tope,
                count(*) as ventanas,
                max(window_started_at) as ultima')
            ->groupBy('ip', 'limit_kind')
            ->orderByDesc('maximo')
            ->limit(self::CUANTOS)
            ->get();

        // **Quién de estos ya está cortado.** Esta tabla contesta «quién va a acabar cortado
        // si sigue así», y el que ya lo está sale en ella igual que los demás: con los
        // números más altos, así que es la fila que más llama y la que menos hay que mirar.
        // Una consulta para todos, no una por fila.
        $cortados = ApiBlock::blocked()
            ->whereIn('ip', $filas->pluck('ip')->filter()->all())
            ->pluck('enforced', 'ip');

        return $filas->map(function ($fila) use ($cortados) {
            $tope = (int) $fila->tope;
            $maximo = (int) $fila->maximo;

            // **El porcentaje se acota a 100.** Un sujeto puede superar el tope dentro de la
            // misma ventana antes de que el limitador lo corte, así que el máximo observado
            // puede pasarse — y una barra al 140 % se sale del carril y no dice nada más que
            // «está pasado», que ya lo dice el número.
            $porcentaje = $tope > 0 ? min(100, (int) round(($maximo / $tope) * 100)) : 0;

            $dominios = self::dominiosDe($fila->ip);

            return [
                'ip' => $fila->ip,
                'sujeto' => $fila->ip,
                // `null` = sin corte. `true` = cortado de verdad; `false` = apuntado en modo
                // observación, que **no corta a nadie** y por eso no se pinta igual: decir
                // «cortada» de algo que sigue pasando sería mentir.
                'cortada' => $cortados->has($fila->ip) ? (bool) $cortados->get($fila->ip) : null,
                'limite' => self::textoDeLimite($fila->limit_kind),
                'limiteExplicado' => self::explicacionDeLimite($fila->limit_kind),
                // **Cuántos dominios distintos salen por esa IP**, que es lo que separa un
                // sitio en bucle de un hosting compartido. Con muchos dominios, subir el
                // tope es la respuesta y cortar la IP cortaría a varios clientes a la vez.
                'dominios' => $dominios['cuantos'],
                'dominiosTexto' => $dominios['texto'],
                'maximo' => $maximo,
                'tope' => $tope,
                'porcentaje' => $porcentaje,
                'ventanas' => (int) $fila->ventanas,
                'ultima' => $fila->ultima,
                // Rojo a partir del 80 %: por debajo es un aviso y por encima es «va a
                // pasar». El margen de aviso del propio limitador es el 50 %, así que todo
                // lo que sale aquí ya lo ha cruzado.
                'apremia' => $porcentaje >= 80,
            ];
        });
    }

    /**
     * Qué cuenta cada límite.
     *
     * **Antes decían «Por IP» y «Rechazadas por IP»**, y con eso no se puede leer una fila:
     * el primero no dice qué cuenta, y el segundo parece el resultado —cuántas se
     * rechazaron— cuando es el criterio. Son dos límites distintos y la diferencia importa:
     * uno vigila el volumen y el otro la autenticación, que son dos problemas con dos
     * respuestas distintas.
     *
     * Se dice entero aunque la columna sea estrecha: una etiqueta corta que hay que ir a
     * buscar al texto de la sección no ahorra nada.
     */
    private static function textoDeLimite(?string $kind): string
    {
        return match ($kind) {
            ApiRateEvent::LIMIT_FAILURES => 'Errores 401/403 por IP',
            ApiRateEvent::LIMIT_REQUESTS => 'Peticiones por IP',
            ApiRateEvent::LIMIT_TOKEN => 'Peticiones por licencia',
            default => (string) $kind,
        };
    }

    /**
     * La explicación larga de cada límite, para el `title` de la celda.
     *
     * Contesta la pregunta que deja el nombre: **qué pasa cuando se cruza**. No es lo mismo
     * pasarse de volumen —que puede ser un cliente legítimo con mucho tráfico— que fallar la
     * autenticación sesenta veces, que no tiene ninguna lectura buena.
     */
    public static function explicacionDeLimite(?string $kind): string
    {
        return match ($kind) {
            ApiRateEvent::LIMIT_FAILURES => 'Cuenta solo las peticiones que acaban en 401 o 403 '
                . '—token que no vale, producto que no está en la licencia—. Es el límite que '
                . 'de verdad corta: fallar la autenticación muchas veces seguidas no tiene '
                . 'ninguna lectura buena.',
            ApiRateEvent::LIMIT_REQUESTS => 'Cuenta todas las peticiones de esa IP, con o sin '
                . 'error. Cruzarlo no significa que algo vaya mal: un hosting con muchos '
                . 'sitios detrás de la misma IP llega aquí sin hacer nada raro.',
            ApiRateEvent::LIMIT_TOKEN => 'Cuenta las peticiones de una misma licencia, sea cual '
                . 'sea la IP desde la que llegan. Se configura en la ficha del token.',
            default => '',
        };
    }

    /**
     * Los dominios que han salido por una IP en la ventana que se mira.
     *
     * @return array{cuantos: int, texto: ?string}
     */
    private static function dominiosDe(?string $ip): array
    {
        if ($ip === null) {
            return ['cuantos' => 0, 'texto' => null];
        }

        $dominios = \App\Models\ApiRequestLog::query()
            ->where('ip', $ip)
            ->where('started_at', '>=', now()->subHours(self::HORAS))
            ->whereNotNull('host')
            ->distinct()
            ->limit(self::DOMINIOS_EN_EL_TITULO + 1)
            ->pluck('host');

        if ($dominios->isEmpty()) {
            return ['cuantos' => 0, 'texto' => null];
        }

        $sobran = $dominios->count() - self::DOMINIOS_EN_EL_TITULO;

        return [
            'cuantos' => $dominios->count(),
            'texto' => $dominios->take(self::DOMINIOS_EN_EL_TITULO)->implode(' · ')
                . ($sobran > 0 ? ' · y más' : ''),
        ];
    }

    /** Cuántos dominios caben en el `title` de la celda antes de ser ilegible. */
    public const DOMINIOS_EN_EL_TITULO = 8;
}
