<?php

namespace App\Services\System;

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

/**
 * Cuánto llegó a llamar cada IP en su peor minuto, su peor hora y su peor día.
 *
 * **La pregunta que la pantalla no sabía responder.** El ranking decía «esta IP hizo 720
 * peticiones en 7 días», y con eso no se puede decidir nada: 720 repartidas en una semana son
 * cuatro por hora, y 720 en un minuto es un problema. El total no distingue un cliente con
 * mucho tráfico de uno en bucle, y **es justo esa diferencia la que decide si hay que hacer
 * algo**.
 *
 * El otro sitio donde había picos —`api_rate_events`— solo apunta las ventanas que **cruzaron
 * el margen de aviso**. Sirve para vigilar a quien ya se acerca al tope, y no sirve para lo
 * que se pregunta aquí: de una IP que nunca lo cruzó no hay ni una fila, así que no se puede
 * saber si va holgada o rozando.
 *
 * Esto se calcula del registro de peticiones, que tiene la hora exacta de cada una.
 *
 * ## El coste, que es lo que decide cómo está escrito
 *
 * Son tres agregados sobre `api_request_logs`, que es la tabla que más crece. Van así:
 *
 * - **Acotados a la ventana que se está mirando** (24 h, 7 o 30 días), nunca a toda la tabla.
 * - **Solo de las IPs que ya salen en el ranking** —quince—, no de todas: el ranking ya
 *   decidió quiénes importan, y agrupar por minuto sobre el parque entero para tirar el 99 %
 *   sería pagar por lo que no se enseña.
 * - Con `started_at` indexado, que ya lo estaba.
 *
 * ## Y por qué el troceado se escribe dos veces
 *
 * MySQL y SQLite no formatean fechas igual —`DATE_FORMAT` frente a `strftime`— y la pantalla
 * corre sobre MySQL mientras los tests corren sobre SQLite. Un solo dialecto dejaría la
 * consulta sin probar o la pantalla sin funcionar.
 */
final class PicosDeTrafico
{
    /** Los tres tramos, con su formato en cada motor. */
    private const TRAMOS = [
        'minuto' => ['mysql' => '%Y-%m-%d %H:%i', 'sqlite' => '%Y-%m-%d %H:%M'],
        'hora' => ['mysql' => '%Y-%m-%d %H', 'sqlite' => '%Y-%m-%d %H'],
        'dia' => ['mysql' => '%Y-%m-%d', 'sqlite' => '%Y-%m-%d'],
    ];

    /**
     * El pico de cada IP en cada tramo.
     *
     * @param  array<int, string>  $ips  las del ranking; fuera de ahí no se calcula nada
     * @return array<string, array{minuto: int, hora: int, dia: int}>
     */
    public static function de(array $ips, int $dias): array
    {
        if ($ips === []) {
            return [];
        }

        $desde = now()->subDays($dias);
        $picos = [];

        foreach (array_keys(self::TRAMOS) as $tramo) {
            foreach (self::maximosPorTramo($ips, $desde, $tramo) as $ip => $maximo) {
                $picos[$ip][$tramo] = $maximo;
            }
        }

        // Una IP puede no tener filas en la ventana —el ranking mira lo mismo, pero por si
        // acaso—: mejor un cero que un aviso de índice indefinido en la vista.
        foreach ($ips as $ip) {
            $picos[$ip] = array_merge(['minuto' => 0, 'hora' => 0, 'dia' => 0], $picos[$ip] ?? []);
        }

        return $picos;
    }

    /**
     * El máximo de peticiones por IP en un tramo.
     *
     * Se agrupa dos veces: primero por IP y tramo para contar cada trocito, y después por IP
     * para quedarse con el mayor. En una subconsulta y no en PHP: traer un millón de trocitos
     * para quedarse con quince números no cabe en memoria ni tiene por qué salir de la base.
     *
     * @param  array<int, string>  $ips
     * @return array<string, int>
     */
    private static function maximosPorTramo(array $ips, \DateTimeInterface $desde, string $tramo): array
    {
        $driver = DB::connection()->getDriverName();
        $formato = self::TRAMOS[$tramo][$driver === 'sqlite' ? 'sqlite' : 'mysql'];

        $trozo = $driver === 'sqlite'
            ? "strftime('" . $formato . "', started_at)"
            : "date_format(started_at, '" . $formato . "')";

        $porTrozo = ApiRequestLog::query()
            ->selectRaw('ip, ' . $trozo . ' as tramo, count(*) as cuantas')
            ->where('started_at', '>=', $desde)
            ->whereIn('ip', $ips)
            ->groupBy('ip', DB::raw($trozo));

        return DB::query()
            ->fromSub($porTrozo, 't')
            ->selectRaw('ip, max(cuantas) as pico')
            ->groupBy('ip')
            ->pluck('pico', 'ip')
            ->map(fn ($v) => (int) $v)
            ->all();
    }

    /**
     * ¿Este pico por minuto pasó del tope configurado?
     *
     * **No es lo mismo que estar cortado**, y la diferencia importa: el tope se aplica por
     * ventana de un minuto del limitador, que empieza cuando llega la primera petición, y
     * este pico se mide sobre minutos de reloj. Un mismo tráfico puede cruzar uno y no el
     * otro. Aquí se dice «llegó a hacer más peticiones en un minuto que el tope», que es un
     * dato de vigilancia, no un veredicto de corte.
     */
    public static function pasoElTope(int $picoPorMinuto, int $tope): bool
    {
        return $tope > 0 && $picoPorMinuto > $tope;
    }
}
