<?php

namespace App\Services\System;

use App\Models\Monitoring\Log;
use App\Models\Monitoring\Notice;

/**
 * Borrar del registro de acciones del panel (`logs`) y del histórico de avisos (`notices`).
 *
 * **Las dos únicas tablas del Manager que crecían sin ninguna salida.** No las toca ninguna
 * tarea programada, no tienen retención y hasta ahora no había forma de borrar una fila: la
 * pantalla de tamaño las marcaba «Ninguna. Crece con los incidentes» y ahí se acababa la
 * conversación. `logs` además lleva borrado lógico, así que una fila «borrada» seguía
 * ocupando sitio — por eso aquí se borra **de verdad**.
 *
 * ## Por qué no hay tarea programada, y sí una modal
 *
 * Decisión de Producto del 2026-09-15. No son telemetría: una acción del panel es el rastro
 * de quién hizo qué, y un aviso es la prueba de que se avisó. Caducarlos por un número de
 * días es perder pruebas por un plazo que nadie ha pensado. Se borran a mano, con criterios
 * a la vista y el impacto delante.
 *
 * ## Los dos impactos, que no son el mismo
 *
 * - **Acciones**: es la auditoría. Lo que se borra no vuelve, y el propio borrado se apunta
 *   —código 16041— para que quede quién vació qué. Esa fila no se borra a sí misma.
 * - **Avisos**: borrar uno **rearma el aviso**. La tabla existe para no repetir correos: se
 *   reconoce por su `reference`, y sin la fila el sistema no sabe que ya se mandó. Con un
 *   aviso de una licencia que sigue viva, el siguiente paso de la tarea diaria es mandarlo
 *   otra vez. Ver {@see Notice}.
 */
class LimpiezaDelPanel
{
    /** Cuántas filas se borran por sentencia, igual que en el resto de limpiezas. */
    public const LOTE = 1000;

    /* ===================== Acciones del panel ===================== */

    /**
     * Las acciones que encajan con los criterios.
     *
     * **Con las borradas lógicamente dentro** (`withTrashed`): `logs` usa `SoftDeletes`, así
     * que lo que alguien «borró» en su día sigue en la tabla ocupando sitio. Si esta pantalla
     * dice que hay 43 filas, tienen que poder irse las 43.
     *
     * @param  array{desde: \DateTimeInterface, hasta: \DateTimeInterface, nivel?: ?string, codigo?: ?string, usuario?: ?int}  $criterios
     */
    public static function acciones(array $criterios)
    {
        return Log::withTrashed()
            ->whereBetween('created_at', [$criterios['desde'], $criterios['hasta']])
            ->when(($criterios['nivel'] ?? null) !== null, fn ($q) => $q->where('level', $criterios['nivel']))
            ->when(($criterios['codigo'] ?? null) !== null, fn ($q) => $q->where('code', $criterios['codigo']))
            ->when(($criterios['usuario'] ?? null) !== null, fn ($q) => $q->where('user_id', $criterios['usuario']));
    }

    /** Cuántas acciones se llevarían esos criterios, sin llevárselas. */
    public static function contarAcciones(array $criterios): int
    {
        return self::acciones($criterios)->count();
    }

    /**
     * Borra las acciones que encajan, **de verdad y no en lógico**.
     *
     * Un `delete()` normal sobre un modelo con `SoftDeletes` solo rellena `deleted_at`: la
     * fila se queda y la pantalla de tamaño sigue contándola. Quien abre esta modal quiere
     * que la tabla baje.
     */
    public static function borrarAcciones(array $criterios): int
    {
        return self::porLotes(
            fn () => self::acciones($criterios),
            fn ($ids) => Log::withTrashed()->whereIn('id', $ids)->forceDelete()
        );
    }

    /* ===================== Avisos enviados ===================== */

    /**
     * Los avisos que encajan con los criterios.
     *
     * `estado` separa los que salieron de los que fallaron, que es la distinción de la
     * pantalla: un aviso sin `sent_at` y con `error` **no llegó a nadie**, y esos son los que
     * de verdad sobran cuando se ha arreglado el buzón de destino.
     *
     * @param  array{desde: \DateTimeInterface, hasta: \DateTimeInterface, tipo?: ?string, estado?: ?string}  $criterios
     */
    public static function avisos(array $criterios)
    {
        $estado = $criterios['estado'] ?? null;

        return Notice::query()
            ->whereBetween('created_at', [$criterios['desde'], $criterios['hasta']])
            ->when(($criterios['tipo'] ?? null) !== null, fn ($q) => $q->where('kind', $criterios['tipo']))
            ->when($estado === 'sent', fn ($q) => $q->whereNotNull('sent_at'))
            ->when($estado === 'failed', fn ($q) => $q->whereNull('sent_at'));
    }

    /** Cuántos avisos se llevarían esos criterios. */
    public static function contarAvisos(array $criterios): int
    {
        return self::avisos($criterios)->count();
    }

    /**
     * Los avisos que la tarea diaria **volvería a mandar** si se borran.
     *
     * Es la mitad del impacto que no se ve en el recuento de filas, y la razón de que esta
     * modal no sea como la de cortes: allí borrar pierde histórico, aquí borrar **provoca
     * correos**.
     *
     * Se cuenta por `kind` y no mirando si el sujeto sigue vivo, que sería más preciso y
     * mentira: la condición que disparó el aviso —una licencia a treinta días de caducar—
     * puede seguir cumpliéndose o no, y averiguarlo es rehacer la tarea entera. Los tres
     * avisos recurrentes los revisa una tarea cada día; el de bloqueo lo dispara el tráfico.
     */
    public static function contarAvisosQueSeRearman(array $criterios): int
    {
        return (clone self::avisos($criterios))->whereIn('kind', [
            Notice::KIND_LICENCE_EXPIRING,
            Notice::KIND_LICENCE_EXPIRED,
            Notice::KIND_SUPPORT_EXPIRING,
        ])->count();
    }

    /** Borra los avisos que encajan. `notices` no tiene papelera: se van del todo. */
    public static function borrarAvisos(array $criterios): int
    {
        return self::porLotes(
            fn () => self::avisos($criterios),
            fn ($ids) => Notice::whereIn('id', $ids)->delete()
        );
    }

    /**
     * Borra por lotes para no bloquear la tabla.
     *
     * @param  callable(): \Illuminate\Database\Eloquent\Builder  $consulta
     * @param  callable(\Illuminate\Support\Collection): int  $borrar
     */
    private static function porLotes(callable $consulta, callable $borrar): int
    {
        $total = 0;

        do {
            $ids = $consulta()->limit(self::LOTE)->pluck('id');

            if ($ids->isEmpty()) {
                break;
            }

            $borrados = $borrar($ids);
            $total += $borrados;
        } while ($borrados > 0);

        return $total;
    }
}
