<?php

namespace App\Services\System;

use App\Models\ApiRequestLog;
use Illuminate\Support\Facades\DB;

/**
 * Quién superó el tope de peticiones en cada tramo: por minuto, por hora y por día.
 *
 * **Es la pestaña «¿Está pasando algo raro?»**, y hasta ahora solo existía el tramo de un
 * minuto —porque es el único que el limitador apunta en `api_rate_events`—. Eso deja fuera lo
 * que más se pregunta: un sitio puede no cruzar nunca el tope de un minuto y estar haciendo
 * cuarenta mil peticiones al día, que es un problema distinto y con otra conversación.
 *
 * Se calcula del registro de peticiones, que tiene la hora exacta de cada una, y no de las
 * ventanas del limitador: de una IP que nunca cruzó el margen no hay ni una ventana, así que
 * no se podría saber si va holgada o rozando.
 *
 * ## Lo que devuelve por cada IP, y por qué esas tres cifras
 *
 * - **El pico**: lo más que llegó a hacer en un tramo. Es el número que decide.
 * - **Los cruces**: en cuántos tramos distintos se pasó. Un pico alto una vez es un
 *   incidente; cruzar el tope en cuarenta minutos distintos es un comportamiento.
 * - **Los dominios**: cuántos sitios salen por esa IP. Es lo que separa un sitio en bucle de
 *   un servidor compartido — y con muchos dominios el arreglo es subir el tope, no cortar.
 *
 * ## El tope por hora y por día todavía no existe
 *
 * Solo hay `limits:requests`, que es por minuto. Para los otros dos tramos esta clase acepta
 * el tope que le den y **no se inventa ninguno**: la pantalla enseña el pico observado y dice
 * que el tope está por decidir. Inventar aquí un «por hora = por minuto × 60» sería fabricar
 * un umbral que nadie ha acordado y presentarlo como si lo hubieran acordado.
 */
final class QuienSePasa
{
    public const MINUTO = 'minuto';

    public const HORA = 'hora';

    public const DIA = 'dia';

    /** Cuántas IPs entran en la lista de un tramo. */
    public const CUANTAS = 15;

    /** Cómo se llama cada tramo en la pantalla. */
    public const NOMBRES = [
        self::MINUTO => ['por minuto', 'en 1 min'],
        self::HORA => ['por hora', 'en 1 h'],
        self::DIA => ['por día', 'en 1 día'],
    ];

    /**
     * Las IPs que superaron el tope en ese tramo, de la que más se pasó a la que menos.
     *
     * Con `$tope` a 0 —que es lo que pasa en hora y día, donde no hay ajuste— **no se filtra
     * por nada**: se devuelven las que más llegaron a hacer, que es lo único honesto que se
     * puede enseñar sin un umbral acordado.
     *
     * @return array<int, array<string, mixed>>
     */
    public static function enElTramo(string $tramo, int $dias, int $tope): array
    {
        $desde = now()->subDays($dias);
        $trozo = self::expresionDelTramo($tramo);

        // Por IP y trozo: cuántas peticiones cayeron en cada minuto, hora o día.
        $porTrozo = ApiRequestLog::query()
            ->selectRaw('ip, ' . $trozo . ' as tramo, count(*) as cuantas')
            ->where('started_at', '>=', $desde)
            ->whereNotNull('ip')
            ->groupBy('ip', DB::raw($trozo));

        // Y de ahí, por IP: su pico, en cuántos trozos se pasó del tope, y cuándo fue el
        // último. Dos agrupaciones y no una: el pico y los cruces son preguntas distintas
        // sobre el mismo recuento.
        $filas = DB::query()
            ->fromSub($porTrozo, 't')
            ->selectRaw('ip, max(cuantas) as pico, count(*) as trozos')
            ->selectRaw('sum(case when cuantas > ? then 1 else 0 end) as cruces', [$tope])
            ->selectRaw('max(tramo) as ultimo')
            ->groupBy('ip')
            // Con tope, solo los que se pasan. Sin tope, los que más hacen.
            ->when($tope > 0, fn ($q) => $q->havingRaw('max(cuantas) > ?', [$tope]))
            ->orderByDesc('pico')
            ->limit(self::CUANTAS)
            ->get();

        if ($filas->isEmpty()) {
            return [];
        }

        $dominios = self::dominiosDe($filas->pluck('ip')->all(), $desde);

        return $filas->map(fn ($fila) => [
            'ip' => $fila->ip,
            'pico' => (int) $fila->pico,
            'tope' => $tope,
            // **Cuántas veces se pasó, no cuántos tramos tuvo.** Sin tope no hay cruces que
            // contar, y decir «0 cruces» al lado de un pico alto se leería como que va bien.
            'cruces' => $tope > 0 ? (int) $fila->cruces : null,
            'dominios' => $dominios[$fila->ip] ?? [],
            'ultimo' => $fila->ultimo,
        ])->all();
    }

    /**
     * Los dominios distintos que han salido por cada IP.
     *
     * Una consulta para todas, no una por fila.
     *
     * @param  array<int, string>  $ips
     * @return array<string, array<int, string>>
     */
    private static function dominiosDe(array $ips, \DateTimeInterface $desde): array
    {
        return ApiRequestLog::query()
            ->select('ip', 'host')
            ->whereIn('ip', $ips)
            ->where('started_at', '>=', $desde)
            ->whereNotNull('host')
            ->distinct()
            ->get()
            ->groupBy('ip')
            ->map(fn ($filas) => $filas->pluck('host')->all())
            ->all();
    }

    /**
     * Cómo se trocea la fecha en cada motor.
     *
     * MySQL y SQLite no formatean igual, y la pantalla corre sobre MySQL mientras los tests
     * corren sobre SQLite: con un solo dialecto, o la consulta se queda sin probar o la
     * pantalla se queda sin funcionar.
     */
    private static function expresionDelTramo(string $tramo): string
    {
        $esSqlite = DB::connection()->getDriverName() === 'sqlite';

        $formatos = [
            self::MINUTO => $esSqlite ? '%Y-%m-%d %H:%M' : '%Y-%m-%d %H:%i',
            self::HORA => '%Y-%m-%d %H',
            self::DIA => '%Y-%m-%d',
        ];

        $formato = $formatos[$tramo] ?? $formatos[self::MINUTO];

        return $esSqlite
            ? "strftime('" . $formato . "', started_at)"
            : "date_format(started_at, '" . $formato . "')";
    }
}
