<?php

namespace App\Services\System;

use App\Models\ApiRequestLog;
use App\Models\Monitoring\ApiBlock;
use App\Services\Api\Motivo;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

/**
 * Los episodios de `api_blocks`, agrupados por lo que son.
 *
 * **Por qué existe.** La pantalla listaba `api_blocks` fila a fila, y en pre eso son **99
 * filas que son una sola cosa**: IPs de salida de AWS —tráfico con NAT rotatorio— que
 * cruzaron el tope de respuestas 401/403, todas con el mismo motivo y todas en observación.
 * Enumerarlas no es información: es la misma información 99 veces, y encima esconde lo
 * único que destaca —había filas de 608 rechazos junto a otras de 6—.
 *
 * Un episodio no es un sujeto: cuando un corte se revisa y el sujeto vuelve a cruzar el
 * umbral, se crea una fila nueva. Así que la misma IP puede tener catorce.
 *
 * ## Qué añade sobre un `group by`
 *
 * **El porqué del rechazo**, que es lo que convierte el grupo en una decisión. `api_blocks`
 * sabe *que* se rechazaron peticiones; el motivo de cada rechazo está en
 * `api_request_logs.reason` desde el 2026-09-09. Cruzando los dos, «4.812 rechazos» pasa a
 * ser «el 96 % con un token que no existe» —un escaneo, no hay nada que arreglar— o «340
 * son licencias caducadas de 12 clientes» —doce tareas—. Ver {@see Motivo}.
 *
 * El cruce es **por IP y ventana de tiempo**, no por clave ajena: `api_blocks` guarda la IP
 * y `api_request_logs` también, y no hay relación entre las tablas. Es una aproximación
 * honesta y se dice como tal en la pantalla: «el 96 %», no «exactamente estas».
 */
class CortesAgrupados
{
    /** Cuántos sujetos se enseñan al desplegar un grupo. El resto va en una línea. */
    public const SUJETOS_VISIBLES = 5;

    /**
     * Los grupos del estado pedido.
     *
     * @param  string  $estado  `blocked` (pendientes), `cleared` (revisados) o `all`.
     * @return Collection<int, array<string, mixed>>
     */
    public static function para(string $estado = ApiBlock::STATE_BLOCKED): Collection
    {
        $grupos = ApiBlock::query()
            ->when($estado !== 'all', fn ($q) => $q->where('state', $estado))
            ->selectRaw('reason, enforced, subject_type,
                count(*) as episodios,
                count(distinct coalesce(ip, cast(environment_id as char))) as sujetos,
                sum(hits) as rechazos,
                max(last_blocked_at) as ultima,
                min(first_blocked_at) as primera')
            ->groupBy('reason', 'enforced', 'subject_type')
            // Por rechazos y no por número de sujetos: 97 IPs con 6 rechazos cada una
            // importan menos que 12 entornos con 340.
            ->orderByDesc('rechazos')
            ->get();

        return $grupos->map(fn ($grupo) => self::describir($grupo, $estado));
    }

    /**
     * Un grupo, con su título, su desglose y los sujetos de dentro.
     *
     * @return array<string, mixed>
     */
    private static function describir(object $grupo, string $estado): array
    {
        $esIp = $grupo->subject_type === ApiBlock::SUBJECT_IP;
        $sujetos = (int) $grupo->sujetos;

        $filas = self::sujetosDe($grupo, $estado);

        return [
            // La clave del grupo, que es la que abre y cierra el desplegable y la que
            // reciben las acciones en lote. No lleva el estado: el grupo se identifica por
            // lo que es, no por el filtro con el que se está mirando.
            'id' => $grupo->reason . ':' . (int) $grupo->enforced . ':' . $grupo->subject_type,
            'reason' => $grupo->reason,
            'enforced' => (bool) $grupo->enforced,
            'subject_type' => $grupo->subject_type,

            'titulo' => $sujetos . ' ' . self::palabraDeSujeto($grupo->subject_type, $sujetos)
                . ' · ' . ApiBlock::textoDeMotivo($grupo->reason),

            'episodios' => (int) $grupo->episodios,
            'sujetos' => $sujetos,
            'rechazos' => (int) $grupo->rechazos,
            'ultima' => $grupo->ultima,

            'modo' => $grupo->enforced ? 'CORTANDO' : 'EN OBSERVACIÓN',

            'desglose' => self::desglose($grupo, $esIp),

            'filas' => $filas['visibles'],
            'cola' => $filas['cola'],
        ];
    }

    /**
     * La frase que dice **por qué** se les rechazó.
     *
     * Es el cruce con `api_request_logs.reason`. Si no hay peticiones que cruzar —porque la
     * retención del registro ya se las llevó— se dice el volumen y nada más: inventar un
     * porcentaje sobre cero sería peor que no darlo.
     */
    private static function desglose(object $grupo, bool $esIp): string
    {
        $rechazos = number_format((int) $grupo->rechazos, 0, ',', '.');
        $base = $rechazos . ' ' . ((int) $grupo->rechazos === 1 ? 'rechazo' : 'rechazos');

        if (! $esIp) {
            // Por entorno no hace falta cruzar nada: el motivo del corte ya lo dice.
            return $base . ' de ' . $grupo->sujetos . ' '
                . self::palabraDeSujeto($grupo->subject_type, (int) $grupo->sujetos) . '.';
        }

        $motivo = self::motivoMasFrecuente($grupo);

        if ($motivo === null) {
            return $base . '. No quedan peticiones en el registro con las que saber por qué: '
                . 'la retención ya se las llevó.';
        }

        return $base . ', el ' . $motivo['porcentaje'] . ' % '
            . self::comoSeDice($motivo['motivo']) . '.';
    }

    /**
     * El motivo de rechazo más frecuente de las IPs de este grupo, con su porcentaje.
     *
     * **Acotado a la ventana del grupo** —de su primer corte a su último— para no contar
     * peticiones de hace meses de la misma IP. Y solo severidades que no son `ok`: una IP
     * que llama bien mil veces y mal veinte no tiene «el 2 % de tokens inválidos», tiene
     * veinte rechazos con un motivo.
     *
     * @return array{motivo: string, porcentaje: int}|null
     */
    private static function motivoMasFrecuente(object $grupo): ?array
    {
        $ips = ApiBlock::query()
            ->where('reason', $grupo->reason)
            ->where('enforced', $grupo->enforced)
            ->where('subject_type', $grupo->subject_type)
            ->whereNotNull('ip')
            ->distinct()
            ->pluck('ip');

        if ($ips->isEmpty()) {
            return null;
        }

        $porMotivo = ApiRequestLog::query()
            ->whereIn('ip', $ips)
            ->whereBetween('started_at', [$grupo->primera, $grupo->ultima])
            ->where('http_status', '>=', 400)
            ->selectRaw('reason, count(*) as c')
            ->groupBy('reason')
            ->orderByDesc('c')
            ->get();

        $total = (int) $porMotivo->sum('c');

        if ($total === 0) {
            return null;
        }

        $primero = $porMotivo->first();

        return [
            'motivo' => (string) $primero->reason,
            'porcentaje' => (int) round(((int) $primero->c / $total) * 100),
        ];
    }

    /** El motivo, dicho como se lee en una frase. */
    private static function comoSeDice(string $motivo): string
    {
        return match ($motivo) {
            Motivo::TOKEN_DESCONOCIDO => 'con un token que no existe',
            Motivo::TOKEN_AUSENTE => 'sin token ninguno',
            Motivo::LICENCIA_CADUCADA => 'con la licencia caducada',
            Motivo::LICENCIA_APAGADA => 'con la licencia apagada',
            Motivo::CLIENTE_DE_BAJA => 'de clientes dados de baja',
            Motivo::ENTORNO_APAGADO => 'de sitios apagados',
            Motivo::HOST_NO_RECONOCIDO => 'desde dominios que no están dados de alta',
            Motivo::PRODUCTO_NO_CONTRATADO => 'pidiendo productos que no tienen contratados',
            default => 'con «' . Motivo::etiqueta($motivo) . '»',
        };
    }

    /**
     * Los sujetos del grupo: los que más rechazan primero, y el resto en una línea.
     *
     * @return array{visibles: Collection<int, array<string, mixed>>, cola: ?string}
     */
    private static function sujetosDe(object $grupo, string $estado): array
    {
        $porSujeto = ApiBlock::query()
            ->with('environment')
            ->when($estado !== 'all', fn ($q) => $q->where('state', $estado))
            ->where('reason', $grupo->reason)
            ->where('enforced', $grupo->enforced)
            ->where('subject_type', $grupo->subject_type)
            ->selectRaw('ip, environment_id,
                count(*) as episodios,
                sum(hits) as rechazos,
                max(last_blocked_at) as ultima,
                max(user_agent) as user_agent,
                min(id) as un_id')
            ->groupBy('ip', 'environment_id')
            ->orderByDesc('rechazos')
            ->get();

        $aLaVista = $porSujeto->take(self::SUJETOS_VISIBLES);
        $borrables = self::revisadosDe($aLaVista);

        $visibles = $aLaVista->map(fn ($fila) => array_merge([
            'id' => (int) $fila->un_id,
            'ip' => $fila->ip,
            'environment_id' => $fila->environment_id,
            'sujeto' => $fila->ip ?? ($fila->environment?->domain ?? ('entorno #' . $fila->environment_id)),
            'user_agent' => $fila->user_agent,
            'episodios' => (int) $fila->episodios,
            'rechazos' => (int) $fila->rechazos,
            'ultima' => $fila->ultima,
            // **Cuántos episodios suyos se podrían borrar.** Es lo que decide si la fila
            // ofrece el botón: borra los *revisados* del sujeto, así que en la vista de
            // pendientes son cero y ofrecerlo es ofrecer algo que no hace nada.
            'borrables' => $borrables[self::claveDe($fila->ip, $fila->environment_id)] ?? 0,
        ], self::traficoDe($fila->ip, $grupo)))->values();

        $resto = $porSujeto->count() - $visibles->count();

        if ($resto <= 0) {
            return ['visibles' => $visibles, 'cola' => null];
        }

        // El rango del resto, no solo cuántos: «y 92 más» no dice si son 6 rechazos o 600.
        $cola = $porSujeto->slice(self::SUJETOS_VISIBLES);
        $min = (int) $cola->min('rechazos');
        $max = (int) $cola->max('rechazos');

        return [
            'visibles' => $visibles,
            'cola' => 'Y ' . $resto . ' ' . self::palabraDeSujeto($grupo->subject_type, $resto)
                . ' más con ' . ($min === $max ? $min . ' rechazos' : 'entre ' . $min . ' y ' . $max . ' rechazos')
                . ' cada ' . ($grupo->subject_type === ApiBlock::SUBJECT_IP ? 'una' : 'uno') . '.',
        ];
    }

    /**
     * El tráfico real de un sujeto: cuántas veces llamó, qué proporción se rechazó y desde
     * cuántos dominios distintos.
     *
     * **Los tres juntos son el diagnóstico, y por separado no dicen nada.** «608 rechazos»
     * puede ser un escaneo o un servidor compartido con mucho tráfico legítimo; lo que los
     * separa es la proporción y el número de dominios:
     *
     * - Muchas rechazadas, **un solo dominio** → un sitio en bucle, o un escaneo.
     * - Pocas rechazadas en proporción y **varios dominios** → un hosting compartido: varios
     *   clientes salen por la misma IP y ahí el tope se sube, no se baja. Cortar esa IP
     *   cortaría a todos.
     *
     * Sale de `api_request_logs`, cruzando por IP dentro de la ventana del grupo. Solo se
     * calcula para los cinco sujetos que se enseñan: es una consulta por sujeto y el resto
     * va en la línea de resumen.
     *
     * @return array{peticiones: int, rechazadas: int, porcentaje: ?int, dominios: int, dominiosTexto: ?string}
     */
    private static function traficoDe(?string $ip, object $grupo): array
    {
        $vacio = [
            'peticiones' => 0,
            'rechazadas' => 0,
            'porcentaje' => null,
            'dominios' => 0,
            'dominiosTexto' => null,
        ];

        if ($ip === null) {
            // Por entorno no hace falta: el sujeto **es** el dominio.
            return $vacio;
        }

        $resumen = ApiRequestLog::query()
            ->where('ip', $ip)
            ->whereBetween('started_at', [$grupo->primera, $grupo->ultima])
            ->selectRaw('count(*) as peticiones,
                sum(case when http_status >= 400 then 1 else 0 end) as rechazadas,
                count(distinct host) as dominios')
            ->first();

        $peticiones = (int) ($resumen->peticiones ?? 0);

        if ($peticiones === 0) {
            return $vacio;
        }

        $rechazadas = (int) $resumen->rechazadas;

        return [
            'peticiones' => $peticiones,
            'rechazadas' => $rechazadas,
            'porcentaje' => (int) round(($rechazadas / $peticiones) * 100),
            'dominios' => (int) $resumen->dominios,
            'dominiosTexto' => self::dominiosDe($ip, $grupo),
        ];
    }

    /**
     * Los dominios que ha usado una IP, para el `title` de la celda.
     *
     * Con tope: una IP de un hosting grande puede haber servido a cincuenta dominios y un
     * `title` de cincuenta líneas no lo lee nadie.
     */
    private static function dominiosDe(string $ip, object $grupo): ?string
    {
        $dominios = ApiRequestLog::query()
            ->where('ip', $ip)
            ->whereBetween('started_at', [$grupo->primera, $grupo->ultima])
            ->whereNotNull('host')
            ->distinct()
            ->limit(self::DOMINIOS_EN_EL_TITULO + 1)
            ->pluck('host');

        if ($dominios->isEmpty()) {
            return null;
        }

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

        return $dominios->take(self::DOMINIOS_EN_EL_TITULO)->implode(' · ')
            . ($sobran > 0 ? ' · y más' : '');
    }

    /**
     * Cuántos episodios **revisados** tiene cada uno de estos sujetos.
     *
     * Se cuentan **todos los del sujeto** y no solo los de este grupo, porque eso es lo que
     * borra el botón de la fila: sus episodios revisados, del motivo que sean.
     *
     * Una consulta por grupo y no una por fila. Son cinco filas visibles, pero esta pantalla
     * ya se paga entera en cada repintado y no hace falta añadirle cinco consultas más.
     *
     * @param  \Illuminate\Support\Collection  $sujetos
     * @return array<string, int>
     */
    private static function revisadosDe($sujetos): array
    {
        if ($sujetos->isEmpty()) {
            return [];
        }

        $ips = $sujetos->pluck('ip')->filter()->unique()->all();
        $entornos = $sujetos->pluck('environment_id')->filter()->unique()->all();

        return ApiBlock::query()
            ->where('state', ApiBlock::STATE_CLEARED)
            ->where(function ($q) use ($ips, $entornos) {
                if ($ips !== []) {
                    $q->orWhereIn('ip', $ips);
                }

                if ($entornos !== []) {
                    $q->orWhereIn('environment_id', $entornos);
                }
            })
            ->selectRaw('ip, environment_id, count(*) as cuantos')
            ->groupBy('ip', 'environment_id')
            ->get()
            ->mapWithKeys(fn ($fila) => [
                self::claveDe($fila->ip, $fila->environment_id) => (int) $fila->cuantos,
            ])
            ->all();
    }

    /** Un sujeto es una IP **o** un entorno, nunca los dos: la clave los distingue. */
    private static function claveDe(?string $ip, $environmentId): string
    {
        return $ip !== null ? 'ip:' . $ip : 'env:' . $environmentId;
    }

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

    private static function palabraDeSujeto(string $tipo, int $cuantos): string
    {
        if ($tipo === ApiBlock::SUBJECT_IP) {
            return $cuantos === 1 ? 'IP' : 'IPs';
        }

        return $cuantos === 1 ? 'entorno' : 'entornos';
    }
}
