<?php

namespace App\Services\System;

use App\Models\Monitoring\ApiBlock;
use App\Models\Monitoring\ApiRateEvent;
use Illuminate\Database\Eloquent\Builder;

/**
 * Borrar del registro de cortes y del histórico de ventanas.
 *
 * **Lo que había: nada.** De `api_blocks` solo se podía «revisar» un episodio —marcarlo
 * como visto—, y de `api_rate_events` no se podía hacer absolutamente nada: no la leía
 * ninguna pantalla y nada la borraba. Las dos solo crecían, y un episodio revisado que
 * vuelve a pasar **crea una fila nueva**, así que una IP que aparece cada día deja una fila
 * cada día. En pre son 99 en unos días.
 *
 * ## La regla que no se negocia
 *
 * **Un corte que está cortando no se borra nunca.** Ni a mano ni por la tarea programada.
 * Borrar la fila no levanta el corte —el contador vive en la caché del limitador— así que lo
 * único que se conseguiría es perder de vista algo que sigue rechazando peticiones. Para
 * quitarlo de en medio está «liberar», que sí lo levanta si es manual.
 *
 * **Y ahí acaba la protección.** Un episodio apuntado en observación no corta a nadie: es
 * histórico igual que uno revisado, y se borra igual. La versión anterior pedía
 * `state = cleared` en todas partes —«solo lo revisado»—, y con los límites en observación
 * eso significaba que **nada era borrable**: en pre, 101 filas que no se iban ni a mano ni
 * por retención, y que solo crecían. Ver {@see ApiBlock::scopeBorrable()}.
 *
 * El borrado por sujeto y por grupo **avisa y se salta los que cortan** en lugar de negarse:
 * quien borra los catorce episodios de una IP quiere quitar el histórico, y que uno esté
 * cortando no debería hacer fallar los otros trece.
 */
class LimpiezaDeCortes
{
    /**
     * Cuántas filas se borran por sentencia.
     *
     * Igual que en la purga del registro de peticiones: por lotes para no bloquear la
     * tabla, que la escribe el limitador en caliente.
     */
    public const LOTE = 1000;

    /**
     * Borra un episodio concreto.
     *
     * @return bool Si se ha borrado. `false` si estaba activo, que es la regla de arriba.
     */
    public static function episodio(ApiBlock $episodio): bool
    {
        // Solo se niega si está cortando: en observación la fila es histórico.
        if ($episodio->state === ApiBlock::STATE_BLOCKED && $episodio->enforced) {
            return false;
        }

        $episodio->delete();

        return true;
    }

    /**
     * Borra todos los episodios revisados de un sujeto **y sus ventanas**.
     *
     * **Las ventanas también, y esto faltaba.** Son dos registros del mismo suceso y no uno
     * calculado del otro: el episodio vive en `api_blocks` y las ventanas de un minuto en
     * `api_rate_events`, que es de donde sale la tabla «Cerca del tope». Borrando solo el
     * episodio, el sujeto seguía apareciendo ahí — se revisaba, se borraba, y seguía. El
     * ruido que se venía a quitar no se iba.
     *
     * **Salvo que le quede un corte activo.** Si sigue pasando, sus ventanas son el estado
     * de lo que está pasando ahora, no histórico: borrarlas escondería el problema en vez de
     * cerrarlo.
     *
     * @return array{borrados: int, activos: int, ventanas: int}
     */
    public static function sujeto(?string $ip, ?int $environmentId): array
    {
        $base = fn () => ApiBlock::query()->where(
            fn ($q) => $ip !== null
                ? $q->where('ip', $ip)
                : $q->where('environment_id', $environmentId)
        );

        $activos = (clone $base())->cortandoAhora()->count();

        $borrados = self::porLotes(
            fn () => $base()->borrable()
        );

        $ventanas = 0;

        // Solo se limpian las ventanas si no queda nada abierto, y solo por IP: una ventana
        // del limitador se apunta contra la IP, que es lo que el limitador cuenta.
        if ($activos === 0 && $ip !== null) {
            $ventanas = self::porLotes(fn () => ApiRateEvent::query()->where('ip', $ip));
        }

        return ['borrados' => $borrados, 'activos' => $activos, 'ventanas' => $ventanas];
    }

    /**
     * Borra los episodios revisados de un grupo —motivo, modo y tipo de sujeto— y sus
     * ventanas.
     *
     * Es la acción en lote de la pantalla: si sabe decir «97 IPs con este motivo», tiene
     * que saber borrarlas de una vez. Revisar o borrar 97 a mano no lo va a hacer nadie.
     *
     * **Y con las ventanas**, por lo mismo que en {@see self::sujeto()}: sin ellas el grupo
     * se vacía y los mismos sujetos siguen saliendo en «Cerca del tope». Se respetan las de
     * quien conserve un corte abierto: eso no es ruido resuelto, es algo que sigue pasando.
     *
     * @return array{episodios: int, ventanas: int}
     */
    public static function grupo(string $reason, bool $enforced, string $subjectType): array
    {
        $base = fn () => ApiBlock::query()
            ->where('reason', $reason)
            ->where('enforced', $enforced)
            ->where('subject_type', $subjectType);

        // Las IPs **antes** de borrar: después ya no hay de dónde sacarlas.
        $ips = (clone $base())
            ->borrable()
            ->whereNotNull('ip')
            ->distinct()
            ->pluck('ip')
            ->all();

        $episodios = self::porLotes(fn () => $base()->borrable());

        if ($ips === []) {
            return ['episodios' => $episodios, 'ventanas' => 0];
        }

        // Las que sigan con un corte abierto se quedan con sus ventanas.
        $abiertos = ApiBlock::cortandoAhora()->whereIn('ip', $ips)->pluck('ip')->unique()->all();
        $limpiables = array_values(array_diff($ips, $abiertos));

        if ($limpiables === []) {
            return ['episodios' => $episodios, 'ventanas' => 0];
        }

        $ventanas = self::porLotes(fn () => ApiRateEvent::query()
            ->whereIn('ip', $limpiables)
            ->where('limit_kind', $reason));

        return ['episodios' => $episodios, 'ventanas' => $ventanas];
    }

    /**
     * Borra por rango de fechas, y opcionalmente solo de una IP.
     *
     * El rango se aplica sobre `last_blocked_at` y no sobre `first_blocked_at`: lo que
     * decide si un episodio es viejo es **cuándo dejó de pasar**, no cuándo empezó. Un
     * episodio abierto en junio que siguió recibiendo rechazos en agosto no es de junio.
     *
     * @return array{episodios: int, ventanas: int}
     */
    public static function porRango(
        \DateTimeInterface $desde,
        \DateTimeInterface $hasta,
        ?string $ip = null,
        bool $incluirVentanas = true
    ): array {
        return self::porCriterios([
            'desde' => $desde,
            'hasta' => $hasta,
            'ip' => $ip,
            'ventanas' => $incluirVentanas,
        ]);
    }

    /**
     * Borra lo que encaje con los criterios puestos en la pantalla.
     *
     * **Los criterios van en un array y no en argumentos posicionales** porque ya son seis:
     * `porRango($d, $h, null, true, null, false)` no dice nada de lo que hace, y el orden es
     * lo único que separa «solo esta IP» de «solo este host».
     *
     * Acepta:
     *
     * - `desde` / `hasta` — obligatorios, el rango sobre el que se mira.
     * - `ip` — opcional, una IP exacta.
     * - `host` — opcional, el dominio que declaró el plugin. **Es como se busca a un
     *   cliente de verdad**: nadie se acuerda de la IP de nadie.
     * - `episodios` / `ventanas` — qué tablas se tocan. Por defecto las dos, y se puede
     *   pedir una sola: vaciar la telemetría del limitador es inofensivo, y borrar los
     *   episodios es perder el histórico de incidentes.
     *
     * **Los cortes activos no se van nunca**, ni aquí ni en ningún otro camino de esta
     * clase: si sigue cortando, la fila es el estado y no el histórico.
     *
     * @param  array{desde: \DateTimeInterface, hasta: \DateTimeInterface, ip?: ?string, host?: ?string, episodios?: bool, ventanas?: bool}  $criterios
     * @return array{episodios: int, ventanas: int}
     */
    public static function porCriterios(array $criterios): array
    {
        [$desde, $hasta] = [$criterios['desde'], $criterios['hasta']];
        $ip = $criterios['ip'] ?? null;
        $host = $criterios['host'] ?? null;

        $episodios = ($criterios['episodios'] ?? true)
            ? self::porLotes(fn () => self::episodiosDe($desde, $hasta, $ip, $host))
            : 0;

        $ventanas = ($criterios['ventanas'] ?? true)
            ? self::porLotes(fn () => self::ventanasDe($desde, $hasta, $ip, $host))
            : 0;

        return ['episodios' => $episodios, 'ventanas' => $ventanas];
    }

    /** Cuántas filas se llevarían esos mismos criterios, sin llevárselas. */
    public static function contarPorCriterios(array $criterios): array
    {
        [$desde, $hasta] = [$criterios['desde'], $criterios['hasta']];
        $ip = $criterios['ip'] ?? null;
        $host = $criterios['host'] ?? null;

        return [
            'episodios' => ($criterios['episodios'] ?? true)
                ? self::episodiosDe($desde, $hasta, $ip, $host)->count()
                : 0,
            'ventanas' => ($criterios['ventanas'] ?? true)
                ? self::ventanasDe($desde, $hasta, $ip, $host)->count()
                : 0,
            // Los que se quedan, que es la pregunta siguiente de quien va a confirmar. Son
            // solo los que están cortando: lo demás entra en el recuento de arriba.
            'pendientes' => ApiBlock::cortandoAhora()
                ->whereBetween('last_blocked_at', [$desde, $hasta])
                ->when($ip !== null, fn ($q) => $q->where('ip', $ip))
                ->when($host !== null, fn ($q) => $q->where('host', $host))
                ->count(),
        ];
    }

    /** Los episodios **cerrados** que encajan. Los activos nunca entran. */
    private static function episodiosDe(
        \DateTimeInterface $desde,
        \DateTimeInterface $hasta,
        ?string $ip,
        ?string $host
    ) {
        return ApiBlock::query()
            ->borrable()
            ->whereBetween('last_blocked_at', [$desde, $hasta])
            ->when($ip !== null, fn ($q) => $q->where('ip', $ip))
            ->when($host !== null, fn ($q) => $q->where('host', $host));
    }

    /** Las ventanas del limitador que encajan. */
    private static function ventanasDe(
        \DateTimeInterface $desde,
        \DateTimeInterface $hasta,
        ?string $ip,
        ?string $host
    ) {
        return ApiRateEvent::query()
            ->whereBetween('window_started_at', [$desde, $hasta])
            ->when($ip !== null, fn ($q) => $q->where('ip', $ip))
            ->when($host !== null, fn ($q) => $q->where('host', $host));
    }

    /**
     * Cuántas filas se llevaría un borrado por rango, para decirlo antes de hacerlo.
     *
     * @return array{episodios: int, pendientes: int, ventanas: int}
     */
    public static function contarPorRango(
        \DateTimeInterface $desde,
        \DateTimeInterface $hasta,
        ?string $ip = null
    ): array {
        return self::contarPorCriterios([
            'desde' => $desde,
            'hasta' => $hasta,
            'ip' => $ip,
        ]);
    }

    /**
     * La retención: lo que se lleva la tarea programada.
     *
     * Dos plazos distintos porque son dos cosas distintas: un episodio revisado es un dato
     * de una incidencia que alguien atendió y conviene poder mirarlo semanas después; una
     * ventana del limitador es telemetría de un minuto concreto y a los treinta días no
     * responde ninguna pregunta.
     *
     * Con los días a 0 no se borra nada, igual que en la purga del registro de peticiones:
     * es lo que permite desplegar la tarea antes de decidir el plazo.
     *
     * @return array{episodios: int, ventanas: int}
     */
    public static function porRetencion(int $diasDeEpisodios, int $diasDeVentanas): array
    {
        $episodios = 0;
        $ventanas = 0;

        if ($diasDeEpisodios > 0) {
            $episodios = self::porLotes(fn () => ApiBlock::query()
                ->borrable()
                ->where('last_blocked_at', '<', now()->subDays($diasDeEpisodios)));
        }

        if ($diasDeVentanas > 0) {
            $ventanas = self::porLotes(fn () => ApiRateEvent::query()
                ->where('window_started_at', '<', now()->subDays($diasDeVentanas)));
        }

        return ['episodios' => $episodios, 'ventanas' => $ventanas];
    }

    /**
     * Vacía el registro de cortes entero.
     *
     * **El único camino para dejarlo a cero**, y faltaba: la retención solo sabe borrar «lo
     * anterior a N días», y `N = 0` no borra nada porque 0 significa «sin retención». Sin
     * esto no había forma de vaciar las dos tablas — ni por comando ni desde el panel, donde
     * lo más amplio es un rango de fechas.
     *
     * **Los cortes activos no se van**, tampoco aquí. Si un sujeto sigue cortado, su fila no
     * es histórico: es el estado de lo que está pasando ahora, y borrarla dejaría el corte
     * aplicándose sin nada en pantalla que lo explicara.
     *
     * @return array{episodios: int, ventanas: int, activos: int}
     */
    public static function todo(): array
    {
        $activos = ApiBlock::cortandoAhora()->count();

        $episodios = self::porLotes(fn () => ApiBlock::query()->borrable());

        $ventanas = self::porLotes(fn () => ApiRateEvent::query());

        return ['episodios' => $episodios, 'ventanas' => $ventanas, 'activos' => $activos];
    }

    /** Cuántas filas se llevaría {@see self::todo()}, para decirlo antes de hacerlo. */
    public static function contarTodo(): array
    {
        return [
            'episodios' => ApiBlock::query()->borrable()->count(),
            'ventanas' => ApiRateEvent::query()->count(),
            'activos' => ApiBlock::cortandoAhora()->count(),
        ];
    }

    /**
     * Borra en lotes hasta que no queda nada.
     *
     * La consulta se pasa como *closure* y no como *builder*: un `Builder` con `limit()`
     * ya aplicado no se puede reutilizar en la siguiente vuelta.
     *
     * @param  callable(): Builder  $consulta
     */
    private static function porLotes(callable $consulta): int
    {
        $total = 0;

        do {
            $cuantos = $consulta()->limit(self::LOTE)->delete();
            $total += $cuantos;
        } while ($cuantos > 0);

        return $total;
    }
}
