<?php

namespace App\Models\Monitoring;

use App\Models\Auth\User;
use App\Models\Environments\Environment;
use App\Models\Products\LicenseToken;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
 * Un bloqueo de la API: quién está bloqueado, desde cuándo y por qué.
 *
 * A diferencia de `ApiRateEvent` —que registra ventanas de un minuto para el
 * histórico— esta tabla guarda **estado**: responde a "¿esto está bloqueado ahora?" y
 * a "¿es la primera vez?". Sin esa segunda respuesta no se puede avisar por correo una
 * sola vez, que es el comportamiento pedido.
 *
 * El aviso sale en la **transición** a bloqueado. Los rechazos siguientes suben el
 * contador y la fecha, y no vuelven a avisar hasta que alguien libera el bloqueo y se
 * repite.
 */
class ApiBlock extends Model
{
    protected $table = 'api_blocks';

    protected $fillable = [
        'subject_type',
        'ip',
        'environment_id',
        'license_token_id',
        'reason',
        'state',
        'enforced',
        'first_blocked_at',
        'last_blocked_at',
        'hits',
        'limit_value',
        'notified_at',
        'cleared_at',
        'cleared_by',
        'cleared_note',
        'host',
        'action',
        'plugin',
        'user_agent',
    ];

    protected $casts = [
        'first_blocked_at' => 'datetime',
        'last_blocked_at' => 'datetime',
        'notified_at' => 'datetime',
        'cleared_at' => 'datetime',
        'enforced' => 'boolean',
        'hits' => 'integer',
        'limit_value' => 'integer',
    ];

    public const SUBJECT_IP = 'ip';
    public const SUBJECT_ENVIRONMENT = 'environment';

    public const STATE_BLOCKED = 'blocked';
    public const STATE_CLEARED = 'cleared';

    /** Mismos valores que `api_rate_events.limit_kind`, para poder cruzarlos. */
    public const REASON_FAILURES = 'failures';
    public const REASON_REQUESTS = 'requests';
    public const REASON_TOKEN = 'token';

    /**
     * Bloqueo puesto **a mano** por una persona desde la pantalla de monitorización.
     *
     * Es el único que corta también en modo observación: lo ha decidido alguien mirando
     * los datos, no un umbral puesto a ojo. Y no caduca solo —se levanta marcándolo como
     * revisado—, porque se pone por un motivo concreto y quien lo pone decide cuándo
     * sobra.
     */
    public const REASON_MANUAL = 'manual';

    public function environment(): BelongsTo
    {
        return $this->belongsTo(Environment::class);
    }

    public function licenseToken(): BelongsTo
    {
        return $this->belongsTo(LicenseToken::class);
    }

    public function clearedBy(): BelongsTo
    {
        return $this->belongsTo(User::class, 'cleared_by');
    }

    public function scopeBlocked($query)
    {
        return $query->where('state', self::STATE_BLOCKED);
    }

    /**
     * Los que **están rechazando peticiones ahora mismo**.
     *
     * No es lo mismo que {@see self::scopeBlocked()}, y confundirlos costó caro: `blocked` es
     * «el episodio sigue abierto», que en modo observación describe a todo el mundo y no
     * corta a nadie. Cortar de verdad es el episodio abierto **y** aplicándose.
     */
    public function scopeCortandoAhora($query)
    {
        return $query->where('state', self::STATE_BLOCKED)->where('enforced', true);
    }

    /**
     * Los que se pueden borrar: todo menos lo que está cortando.
     *
     * **La regla, dicha del derecho.** Antes el borrado pedía `state = cleared` —«solo lo
     * revisado»—, y eso dejaba fuera los episodios apuntados en observación: no cortan a
     * nadie, nadie los va a revisar de uno en uno, y ni la limpieza a mano ni la retención
     * los tocaban. Se acumulaban sin salida. Lo que hay que proteger es lo que rompe algo si
     * desaparece de la pantalla, y eso es un corte activo: borrar su fila no lo levanta
     * —el contador vive en la caché del limitador—, solo lo deja aplicándose a ciegas.
     */
    public function scopeBorrable($query)
    {
        return $query->whereNot(
            fn ($q) => $q->where('state', self::STATE_BLOCKED)->where('enforced', true)
        );
    }

    /**
     * Registra un rechazo y devuelve el bloqueo, diciendo si es NUEVO.
     *
     * Es el único punto de entrada, y devuelve `esNuevo` en vez de mandar el correo
     * por su cuenta a propósito: un modelo que envía correos es un modelo que no se
     * puede probar sin fingir el correo, y aquí interesa poder comprobar el estado por
     * separado del aviso.
     *
     * @param  array{ip?: ?string, environment_id?: ?int, license_token_id?: ?int, host?: ?string, action?: ?string, plugin?: ?string, user_agent?: ?string}  $contexto
     * @return array{bloqueo: self, esNuevo: bool}
     */
    public static function registrar(string $subjectType, string $reason, int $limite, array $contexto): array
    {
        $ahora = now();

        $existente = self::query()
            ->blocked()
            ->where('subject_type', $subjectType)
            ->where('reason', $reason)
            ->when(
                $subjectType === self::SUBJECT_IP,
                fn ($q) => $q->where('ip', $contexto['ip'] ?? null),
                fn ($q) => $q->where('environment_id', $contexto['environment_id'] ?? null)
            )
            ->first();

        if ($existente) {
            // Ya estaba bloqueado: se actualiza y NO se vuelve a avisar.
            $existente->increment('hits');
            $existente->update(array_merge($contexto, [
                'last_blocked_at' => $ahora,
                'limit_value' => $limite,
            ]));

            return ['bloqueo' => $existente->refresh(), 'esNuevo' => false];
        }

        $bloqueo = self::create(array_merge($contexto, [
            'subject_type' => $subjectType,
            'reason' => $reason,
            'state' => self::STATE_BLOCKED,
            'first_blocked_at' => $ahora,
            'last_blocked_at' => $ahora,
            'hits' => 1,
            'limit_value' => $limite,
        ]));

        return ['bloqueo' => $bloqueo, 'esNuevo' => true];
    }

    /**
     * Marca el bloqueo como revisado.
     *
     * No desbloquea nada por sí mismo —el contador del límite vive en la caché y se
     * vacía solo al pasar el minuto—: lo que hace es cerrar la incidencia para que
     * desaparezca de la lista de pendientes y para que, si vuelve a pasar, se avise
     * otra vez.
     */
    public function liberar(?int $usuarioId, ?string $nota = null): void
    {
        // Sin esto, un bloqueo manual sobrevive 30 segundos a su propio levantamiento y
        // parece que el botón no ha funcionado.
        if ($this->ip) {
            \Illuminate\Support\Facades\Cache::forget('api:block:manual:' . $this->ip);
        }

        $this->update([
            'state' => self::STATE_CLEARED,
            'cleared_at' => now(),
            'cleared_by' => $usuarioId,
            'cleared_note' => $nota,
        ]);
    }

    /**
     * ¿Hay un bloqueo MANUAL vivo para esta IP?
     *
     * Lo consulta el middleware en cada petición, así que va cacheado: sin caché sería
     * una consulta más por petición a la API, y este middleware existe precisamente
     * para no amplificar el coste del tráfico malo. La caché es corta —30 segundos—
     * para que bloquear desde el panel surta efecto casi al instante.
     */
    public static function olvidarCacheDe(string $ip): void
    {
        \Illuminate\Support\Facades\Cache::forget('api:block:manual:' . $ip);
    }

    public static function ipBloqueadaAMano(string $ip): bool
    {
        return \Illuminate\Support\Facades\Cache::remember(
            'api:block:manual:' . $ip,
            30,
            fn () => self::blocked()
                ->where('subject_type', self::SUBJECT_IP)
                ->where('reason', self::REASON_MANUAL)
                ->where('ip', $ip)
                ->exists()
        );
    }

    /** Texto del motivo, para pantallas y correos. */
    public function getMotivoTextoAttribute(): string
    {
        return self::textoDeMotivo($this->reason);
    }

    /**
     * El mismo texto, sin necesitar una fila.
     *
     * Lo pide el agrupado de la pantalla, que trabaja con el resultado de un `group by`
     * y no con modelos: un `select reason, count(*)` no es un `ApiBlock` y no debe
     * fingir que lo es.
     */
    public static function textoDeMotivo(?string $reason): string
    {
        return match ($reason) {
            // «demasiados» va dentro del texto y no en la frase que lo usa: los correos
            // dicen «ha quedado bloqueado por …» y el agrupado de la pantalla lo pone tras
            // el número de sujetos. Un solo texto para los dos sitios.
            self::REASON_FAILURES => 'demasiados intentos rechazados (401/403)',
            self::REASON_REQUESTS => 'techo de peticiones de la API',
            self::REASON_TOKEN => 'límite de peticiones de su licencia',
            self::REASON_MANUAL => 'bloqueo manual',
            default => (string) $reason,
        };
    }

    /**
     * Cómo se lee el estado en pantalla y en los correos.
     *
     * Son TRES estados visibles, no dos: en modo observación hay filas que dicen "se
     * habría bloqueado" de sitios que están funcionando con normalidad, y confundirlas
     * con un corte real haría que alguien saliera corriendo a arreglar algo que no está
     * roto.
     */
    public function getEstadoTextoAttribute(): string
    {
        if ($this->state === self::STATE_CLEARED) {
            return 'Revisado';
        }

        return $this->enforced ? 'Bloqueado' : 'Se habría bloqueado';
    }

    /** Cómo se identifica el sujeto en una frase. */
    public function getSujetoTextoAttribute(): string
    {
        if ($this->subject_type === self::SUBJECT_ENVIRONMENT) {
            return $this->environment?->domain
                ?? $this->host
                ?? ('entorno #' . $this->environment_id);
        }

        return 'IP ' . $this->ip;
    }
}
