<?php

namespace App\Http\Middleware;

use App\Models\Environments\Environment;
use App\Models\Monitoring\ApiBlock;
use App\Models\Monitoring\ApiRateEvent;
use App\Models\Products\LicenseToken;
use App\Models\Products\Product;
use App\Services\Api\ApiResponse;
use App\Services\Api\HostNormalizer;
use App\Services\System\BlockNotifier;
use App\Services\System\Settings;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Symfony\Component\HttpFoundation\Response;

class ProductToken
{
    /* ==========================================================
       Las reglas de seguridad, declaradas y en un solo sitio.

       **Por qué existe esta tabla.** Antes cada acción traía su propia copia del mismo
       bloque de cuarenta líneas —buscar el plugin, buscar el producto, comprobar la
       licencia con sus dos ventanas de fechas—, siete veces. Y a tres de esas copias
       —`features`, `tutorials` y `resources`— les faltaba la primera comprobación de
       todas, la del entorno: servían contenido de pago a cualquier dominio que llevara
       un token válido, estuviera dado de alta o no. El comentario que lo justificaba
       («no requiere entorno, similar a setup») era falso: `setup` sí lo requería,
       veinte líneas más arriba.

       No fue una decisión: fue una copia que perdió una línea. Y lo que lo hizo posible
       es que la regla viviera repetida en vez de declarada, así que el arreglo no es
       añadir el `if` que falta tres veces —eso deja el problema vivo para la acción
       número trece—, es que **no se pueda escribir una acción sin decir qué comprueba**.

       Una acción que no esté aquí recibe el trato más estricto (`REGLA_POR_DEFECTO`).
       Falla cerrado.
       ========================================================== */

    /** Rechazo de contenido: 401 con el código de su familia y el motivo detallado. */
    private const SIN_ENTORNO_CONTENIDO = 'contenido';

    /** El 404 propio de `sync`, que se mantiene por contrato. */
    private const SIN_ENTORNO_NO_ENCONTRADO = 'no_encontrado';

    /** El 403 seco de las acciones de escritura. */
    private const SIN_ENTORNO_PROHIBIDO = 'prohibido';

    /**
     * Qué se le exige a cada acción, y con qué código se rechaza.
     *
     * - `entorno`: la forma del rechazo cuando el host no está dado de alta en ese token.
     * - `producto`: `[código si el plugin no existe, código si la licencia no lo cubre]`.
     *   Su ausencia **no es un olvido**, es que esa acción no pide un producto concreto:
     *   `products` pide la lista entera, y `sync`, `data` y `plugins` no piden nada —
     *   escriben el estado del sitio—. El `plugin` que traen en el payload es quién
     *   llama, no qué se pide.
     *
     * **Los códigos son los de siempre, uno por uno.** Que la misma causa devuelva hoy
     * `2002`, `3000`, `4000`, `404` y `403` según quién la diga es un defecto real, pero
     * unificarlos cambia el contrato con los sitios en producción: eso es una mayor y va
     * aparte. Aquí solo se centraliza la lógica. Las familias están en
     * `api/error-codes.md`: 2xxx licencia, 3xxx setup y estilos, 4xxx js y tutoriales,
     * 5xxx funcionalidades y recursos.
     *
     * @var array<string, array{entorno: array, producto?: array{int, int}}>
     */
    private const REGLAS = [
        'licence'   => ['entorno' => [self::SIN_ENTORNO_CONTENIDO, 2002], 'producto' => [2002, 2001]],
        'products'  => ['entorno' => [self::SIN_ENTORNO_CONTENIDO, 2002]],
        'setup'     => ['entorno' => [self::SIN_ENTORNO_CONTENIDO, 3000], 'producto' => [3001, 3001]],
        'scss'      => ['entorno' => [self::SIN_ENTORNO_CONTENIDO, 3000], 'producto' => [3001, 3001]],
        'scss-cdn'  => ['entorno' => [self::SIN_ENTORNO_CONTENIDO, 3000], 'producto' => [3001, 3001]],
        'js'        => ['entorno' => [self::SIN_ENTORNO_CONTENIDO, 4000], 'producto' => [4001, 4001]],
        // Las tres que no comprobaban el entorno. El código de «entorno no encontrado»
        // es nuevo en ellas y sigue la familia que ya usaban para sus otros rechazos:
        // 4000 para tutoriales, 5000 para funcionalidades y recursos.
        'tutorials' => ['entorno' => [self::SIN_ENTORNO_CONTENIDO, 4000], 'producto' => [4001, 4001]],
        'features'  => ['entorno' => [self::SIN_ENTORNO_CONTENIDO, 5000], 'producto' => [5001, 5001]],
        'resources' => ['entorno' => [self::SIN_ENTORNO_CONTENIDO, 5000], 'producto' => [5001, 5001]],
        'sync'      => ['entorno' => [self::SIN_ENTORNO_NO_ENCONTRADO]],
        'data'      => ['entorno' => [self::SIN_ENTORNO_PROHIBIDO]],
        'plugins'   => ['entorno' => [self::SIN_ENTORNO_PROHIBIDO]],
    ];

    /**
     * Lo que se aplica a una acción no declarada: entorno obligatorio y nada servido.
     *
     * Es el mismo trato que daba el `else` final de antes, así que una acción
     * desconocida se comporta igual que siempre —el controlador la rechaza con un 400
     * si el host es válido—. Lo que cambia es que **una acción nueva que alguien añada
     * al `ActionResolver` y olvide declarar aquí no queda abierta**, que es exactamente
     * lo que pasó con las tres de arriba.
     */
    private const REGLA_POR_DEFECTO = ['entorno' => [self::SIN_ENTORNO_PROHIBIDO]];

    /**
     * Handle an incoming request.
     *
     * @param  \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response)  $next
     */
    public function handle(Request $request, Closure $next): Response
    {
        // Extraer el bearer token de la petición
        $token = $request->bearerToken();

        // Validar que el token exista
        if (!$token) {
            return ApiResponse::unauthorized('Token no proporcionado');
        }

        // Buscar el token en la base de datos
        $licenseToken = LicenseToken::where('token', $token)->first();

        // Validar que el token exista en la base de datos
        if (!$licenseToken) {
            return ApiResponse::unauthorized('Token no válido');
        }

        // ============ La licencia se inyecta AQUÍ, no al final ============
        //
        // **`ApiRequestLogger` lee `_license_token` y `_environment` de la petición para
        // saber de quién es cada fila del registro**, y hasta ahora los dos se inyectaban
        // al final del middleware, después de todos los rechazos. Consecuencia: **los seis
        // `403` de este middleware se registraban con cliente, licencia y entorno a
        // `null`** —cliente inactivo, token inactivo, token aún no válido, token expirado,
        // entorno inactivo y host no vinculado—, teniéndolos aquí en la mano.
        //
        // Y eso son justo las peticiones de las que más falta hace saber de quién son:
        //
        // - En el visor no se podían filtrar por cliente ni por sitio.
        // - Salían en la lista de hosts como «no es de ningún entorno», o sea etiquetadas
        //   como *un Moodle llamando que no está dado de alta*, cuando sí lo está.
        // - Y el recorte de entornos apagados no las alcanzaba, porque deja fuera a
        //   propósito las filas sin entorno —que son las de host desconocido de verdad—.
        //
        // Inyectarlo aquí no cambia ninguna respuesta: es un dato de la petición que solo
        // leen el registro y las acciones. No viaja al cuerpo del log, porque el `payload`
        // que se guarda sale del **cuerpo crudo** (`$request->getContent()`) y no de la
        // entrada combinada.
        $request->merge(['_license_token' => $licenseToken]);

        // EL CLIENTE PRIMERO, y el orden importa.
        //
        // Dar de baja a un cliente desactiva también sus licencias —para que el panel
        // no diga "activa" de algo cortado—, así que si se mirara antes el `active` del
        // token, la API respondería "Token inactivo" a un cliente de baja: un motivo
        // que manda a soporte a revisar la licencia cuando el problema es el cliente.
        //
        // Antes la API no miraba al cliente en ningún momento: dar de baja un cliente
        // en el panel no cortaba nada, y sus Moodles seguían recibiendo licencias,
        // setups, SCSS y JS. Lo engañoso era que el cliente desaparecía de los
        // listados, así que parecía cortado. Ver known-issues MGR-021.
        //
        // La comprobación va sobre la relación, así que cubre también el borrado:
        // `Client` usa SoftDeletes y un cliente borrado devuelve null aquí.
        $client = $licenseToken->client;

        if (!$client || !$client->actived) {
            return ApiResponse::forbidden('Cliente inactivo');
        }

        // Y después el token: aquí ya se sabe que el cliente está de alta, así que
        // "Token inactivo" significa lo que dice —esta licencia concreta— y no un
        // efecto colateral de la baja del cliente.
        if (!$licenseToken->active) {
            return ApiResponse::forbidden('Token inactivo');
        }

        // Validar rango de fechas
        $now = now();

        // Si start_at está definida, verificar que now() >= start_at
        if ($licenseToken->start_at && $now->lessThan($licenseToken->start_at)) {
            return ApiResponse::forbidden('Token aún no válido. Fecha de inicio: ' . $licenseToken->start_at->format('Y-m-d H:i:s'));
        }

        // Si end_at está definida, verificar que now() <= end_at
        if ($licenseToken->end_at && $now->greaterThan($licenseToken->end_at)) {
            return ApiResponse::forbidden('Token expirado. Fecha de expiración: ' . $licenseToken->end_at->format('Y-m-d H:i:s'));
        }

        // Límite de peticiones POR MINUTO de este token.
        //
        // Hay dos mecanismos de límite en la API y conviene no confundirlos:
        //
        //   este                     por TOKEN, configurable por licencia en el panel
        //   ThrottleApiRequests      por IP, configurable en `config/api.php` (MGR-008)
        //
        // El campo se llamaba `usage_limit` y la interfaz decía "Límite de uso", pero
        // la ventana siempre ha sido de 60 segundos: nunca fue una cuota total. Se
        // renombró a `rate_limit_per_minute` para que el nombre diga lo que hace.
        // Ver known-issues MGR-013.
        $key = 'rate_limit:token:' . $token;

        // Sin valor, un techo alto que en la práctica es "sin límite" (10.000/minuto
        // son 166 por segundo desde un solo token). Es deliberado: el corte real de
        // abuso lo hace el límite por IP; este campo sirve para poner un techo a un
        // cliente concreto cuando hace falta.
        $limit = $licenseToken->rate_limit_per_minute ?? 10000;

        if (RateLimiter::tooManyAttempts($key, $limit)) {
            $seconds = RateLimiter::availableIn($key);

            // Modo observación: por defecto los límites NO cortan. Se registra que se
            // habría cortado, se avisa, y la petición sigue. Se activa el corte desde
            // el panel cuando hay datos reales para ajustar los topes. Ver
            // `alerts-and-settings.md`.
            $corta = app(Settings::class)->losLimitesCortan();

            // Sin esto el corte no dejaba rastro en ningún sitio: la única forma de
            // enterarse de que una licencia está topando era que el cliente llamara.
            // Va a la misma tabla que el límite por IP para que se vean en el mismo
            // sitio del Dashboard y del visor de logs.
            $this->registrarLimiteDeLicencia($request, $licenseToken, $limit);

            // Fuera del método de arriba a propósito: ese lleva un freno de caché que
            // agrupa las escrituras del HISTÓRICO, y si el estado del bloqueo colgara
            // de él, el freno se llevaría por delante el contador de rechazos y el
            // aviso. Son dos cosas con ritmos distintos: el histórico se agrupa por
            // ventanas, el estado se actualiza en cada rechazo.
            $this->marcarBloqueo($request, $licenseToken, $limit, $corta);

            // En observación **no se ataja**: se deja caer el flujo para que siga el
            // resto del middleware. Un `return $next($request)` aquí se saltaría la
            // resolución del host y la inyección de `_license_token` y `_environment`,
            // y las acciones recibirían null.
            if ($corta) {
                return ApiResponse::error(
                    null,
                    // El mensaje dice QUÉ límite ha cortado y CUÁL era. Antes los dos
                    // mecanismos devolvían textos casi idénticos y era imposible saber
                    // cuál había actuado: el plugin escribe este texto en su log y es
                    // lo único que ve soporte.
                    'Límite de peticiones de la licencia excedido (' . $limit . '/minuto). '
                    . 'Reintenta en ' . $seconds . ' segundo(s).',
                    429,
                    [],
                    429
                );
            }
        }

        // Ventana de 60 segundos: de aquí viene el "por minuto".
        RateLimiter::hit($key, 60);

        // Validar el parámetro host del payload
        $host = $request->input('host');

        if (!$host) {
            return ApiResponse::error(
                null,
                'Parámetro host no proporcionado',
                400,
                [],
                400
            );
        }

        // Obtener la acción (si está presente)
        $action = $request->input('action');

        // Normalizar el host para comparación (mantener host completo, no solo dominio)
        $normalizedHost = HostNormalizer::normalize($host);

        // Búsqueda unificada: el entorno debe estar vinculado al token actual.
        // Antes 'sync' buscaba en Environment::all() para poder dar un mensaje
        // diagnóstico "host vinculado a otro token", pero esa asimetría producía
        // falsos 401 cuando dos entornos legítimos tenían dominios que normalizaban
        // al mismo valor (ej. con y sin trailing slash) en tokens distintos.
        // Ver fixes/PRODSECU-192.
        $environment = $licenseToken->environments()->get()->first(function ($environment) use ($normalizedHost) {
            return HostNormalizer::normalize($environment->domain) === $normalizedHost;
        });

        // Y el entorno, en cuanto se sabe cuál es y **antes de cualquier rechazo**. Puede
        // ser null —host que no es de esta licencia—, y eso también es correcto: el
        // registro comprueba el tipo, así que null se guarda como «sin entorno», que es lo
        // que significa. Ver el bloque de arriba.
        $request->merge(['_environment' => $environment]);

        // Si el entorno existe pero está desactivado, no se sirve nada. Antes el flag
        // `active` del entorno no lo miraba nadie: la tarjeta salía atenuada en el
        // panel y el Moodle seguía recibiendo licencias con normalidad.
        // Ver known-issues MGR-021.
        //
        // Se comprueba aquí, antes del reparto por acción, para que valga igual para
        // todas: las que exigen entorno y las que solo lo usan si aparece.
        if ($environment && !$environment->active) {
            return ApiResponse::forbidden('Entorno inactivo');
        }

        // ============ El reparto por acción, con la tabla de arriba ============
        //
        // Antes esto eran 555 líneas con siete copias del mismo bloque. Lo que queda es
        // leer qué exige la acción y aplicarlo.
        $reglas = self::REGLAS[$action] ?? self::REGLA_POR_DEFECTO;

        if (!$environment) {
            return $this->rechazarPorHostNoVinculado(
                $reglas['entorno'],
                $action,
                $licenseToken,
                $normalizedHost,
                $host
            );
        }

        // El producto solo lo piden las ocho acciones que sirven contenido de uno
        // concreto. El porqué de las otras cuatro, en el docblock de `REGLAS`.
        if (isset($reglas['producto'])) {
            [$codigoSinPlugin, $codigoSinLicencia] = $reglas['producto'];

            $plugin = $request->input('plugin');

            if (!$plugin) {
                return ApiResponse::error($action, 'Plugin parameter is required', 400, [], 400);
            }

            $product = Product::where('slug', $plugin)->first();

            if (!$product) {
                return ApiResponse::error($action, 'Plugin not found', $codigoSinPlugin, [], 401);
            }

            if (!$this->laLicenciaCubre($licenseToken, $product, $now)) {
                return ApiResponse::error($action, 'Licence does not include this plugin', $codigoSinLicencia, [], 401);
            }

            // Inyectar el producto validado en el request
            $request->merge(['_validated_product' => $product]);
        }

        // El host normalizado, que solo tiene sentido cuando hay entorno. El token y el
        // entorno se inyectaron arriba, en cuanto se conocieron, para que los rechazos
        // también queden atribuidos en el registro.
        $request->merge([
            '_normalized_host' => $environment ? $normalizedHost : null,
        ]);

        return $next($request);
    }

    /**
     * El rechazo cuando el host no es un entorno de esa licencia.
     *
     * **El log va siempre**, sea cual sea la forma del rechazo. Antes solo lo escribían
     * seis de las doce acciones: un `sync` o un `data` de un host desconocido no dejaba
     * en `api.log` ni el dominio buscado ni si ese dominio existe en otra licencia, que
     * es justo lo que hace falta para diagnosticarlo.
     *
     * La forma de la respuesta sí es la de siempre para cada acción, porque es contrato.
     */
    private function rechazarPorHostNoVinculado(
        array $regla,
        ?string $action,
        LicenseToken $licenseToken,
        string $normalizedHost,
        ?string $host
    ): Response {
        // Los dominios del token van al LOG, no a la respuesta: enumerarlos le daba a
        // cualquier sitio que llamara la lista de dominios de su cliente. Ver MGR-009.
        $this->registrarHostNoEncontrado($licenseToken, $normalizedHost, $host, $action);

        $donde = [
            'searched_host' => $normalizedHost,
            'original_host' => $host,
        ];

        return match ($regla[0]) {
            self::SIN_ENTORNO_PROHIBIDO => ApiResponse::forbidden('El host proporcionado no está vinculado a este token'),
            self::SIN_ENTORNO_NO_ENCONTRADO => ApiResponse::error($action, 'Environment not found for this host', 404, $donde, 404),
            default => ApiResponse::error(
                $action,
                $this->motivoDelHostNoEncontrado($licenseToken, $normalizedHost),
                $regla[1],
                $donde,
                401
            ),
        };
    }

    /**
     * Si la licencia trae ese producto contratado y dentro de sus fechas.
     *
     * Las dos ventanas van con `whereNull` delante porque **una fecha sin poner
     * significa «sin límite»**, no «fuera de plazo»: la mayoría de los productos no
     * llevan ni inicio ni fin, y tratarlos como caducados dejaría sin servicio a casi
     * todo el parque.
     */
    private function laLicenciaCubre(LicenseToken $licenseToken, Product $product, \Illuminate\Support\Carbon $now): bool
    {
        return $licenseToken->products()
            ->where('products.id', $product->id)
            ->wherePivot('status', 'active')
            ->where(function ($query) use ($now) {
                $query->whereNull('license_token_product.start_at')
                    ->orWhere('license_token_product.start_at', '<=', $now);
            })
            ->where(function ($query) use ($now) {
                $query->whereNull('license_token_product.end_at')
                    ->orWhere('license_token_product.end_at', '>=', $now);
            })
            ->exists();
    }

    /**
     * Deja en el log del servidor por qué no se ha encontrado el entorno.
     *
     * Antes esta información viajaba en la respuesta: el mensaje enumeraba TODOS los
     * dominios del token ("Available hosts: a.com, b.com, c.com") y `data` los
     * repetía en `available_hosts`. Cualquiera que consiguiera un token —o cualquier
     * sitio del cliente que estuviera comprometido— obtenía el listado de dominios de
     * ese cliente. Y el plugin escribe el mensaje de error en su propio log, así que
     * la lista acababa también en el Moodle. Ver known-issues MGR-009.
     *
     * Quien necesita este dato es soporte, y soporte tiene acceso al Manager: va al
     * canal `api` con el host buscado, para poder comparar de un vistazo. En la
     * respuesta se queda `searched_host`, que es el dominio del propio llamante.
     */
    /**
     * Explica POR QUÉ no se ha encontrado el entorno, cuando se puede decir sin filtrar.
     *
     * El middleware busca el host **solo entre los entornos de esa licencia**, así que un
     * entorno que existe y no está vinculado a ella recibía exactamente el mismo mensaje
     * que un dominio mal escrito: «Environment not found for this host». Quien lo lee se
     * va a revisar el dominio, que está perfecto, y el problema es la vinculación.
     *
     * No es un caso raro: en la base real hay 17 entornos **sin ninguna licencia**, que
     * pueden ser altas a medio configurar o sitios a la espera. Cualquiera de ellos que
     * empiece a llamar recibe este error.
     *
     * **Solo se dice si el entorno es del MISMO cliente que la licencia.** Si es de otro,
     * el motivo se queda en el log: confirmar a quien llama que un dominio cualquiera está
     * registrado en el Manager convierte esta respuesta en un comprobador de dominios de
     * otros clientes. Es la misma norma que MGR-009 (los dominios del token van al log, no
     * a la respuesta) y MGR-034 (el detalle técnico no sale a la API).
     */
    private function motivoDelHostNoEncontrado(LicenseToken $licenseToken, string $normalizedHost): string
    {
        $base = 'Environment not found for this host. Host searched: ' . $normalizedHost;

        // Se compara normalizado porque es como decide la búsqueda de arriba: si se
        // comparara la columna en crudo, un dominio guardado con otra caja no casaría
        // aquí y el mensaje volvería a ser el genérico justo cuando más ayuda.
        $entorno = Environment::where('client_id', $licenseToken->client_id)
            ->get()
            ->first(fn (Environment $candidato) => HostNormalizer::normalize((string) $candidato->domain) === $normalizedHost);

        if (!$entorno) {
            return $base;
        }

        if ($entorno->license_token_id === null) {
            return $base . '. The environment exists for this client but has no licence assigned yet.';
        }

        // Existe y cuelga de OTRA licencia del mismo cliente. No se dice de cuál: el id
        // de otra licencia no le hace falta a quien llama y es dato de negocio.
        return $base . '. The environment exists for this client but belongs to a different licence.';
    }

    private function registrarHostNoEncontrado(
        LicenseToken $licenseToken,
        string $normalizedHost,
        ?string $originalHost,
        ?string $action
    ): void {
        Log::channel('api')->info('Host no vinculado al token', [
            'action' => $action,
            'token_id' => $licenseToken->id,
            'client_id' => $licenseToken->client_id,
            'searched_host' => $normalizedHost,
            'original_host' => $originalHost,
            'available_hosts' => $licenseToken->environments()->pluck('domain')->toArray(),
            // El motivo completo, incluido el caso en que NO se le puede decir a quien
            // llama: si el dominio es de otro cliente, la respuesta se queda genérica y
            // aquí queda por qué. Sin esto, un "no encontrado" de un dominio cruzado
            // sería indistinguible de un dominio inexistente al mirar el log.
            'entorno_con_ese_dominio' => Environment::withTrashed()
                ->get()
                ->first(fn (Environment $c) => HostNormalizer::normalize((string) $c->domain) === $normalizedHost)
                ?->only(['id', 'client_id', 'license_token_id', 'deleted_at']),
        ]);
    }

    /**
     * Deja constancia de que una licencia ha alcanzado su límite por minuto.
     *
     * El límite por token cortaba con un 429 y **no registraba nada**, al contrario que
     * el límite por IP (MGR-008). Escriben los dos en `api_rate_events` para que
     * aparezcan en el mismo bloque del Dashboard y en el mismo visor: quien mira no
     * tiene que saber que hay dos mecanismos para encontrar el problema.
     *
     * Una fila por licencia y minuto, no una por petición rechazada: un cliente que se
     * pase mil veces en un minuto genera una fila con el máximo observado. Y con un
     * freno en caché encima, para que ni siquiera se intente escribir en cada rechazo.
     *
     * Ver known-issues MGR-013.
     */
    private function registrarLimiteDeLicencia(Request $request, LicenseToken $licenseToken, int $limite): void
    {
        if (!config('api.throttle.record_events', true)) {
            return;
        }

        $ip = (string) $request->ip();
        $ventana = now()->startOfMinute();

        $freno = 'api:rate:token:escrito:' . $licenseToken->id . ':' . $ventana->timestamp;

        if (!Cache::add($freno, true, max(1, (int) config('api.throttle.record_throttle_seconds', 60)))) {
            return;
        }

        try {
            $evento = ApiRateEvent::updateOrCreate(
                [
                    'ip' => $ip,
                    'window_started_at' => $ventana,
                    'limit_kind' => ApiRateEvent::LIMIT_TOKEN,
                ],
                [
                    'license_token_id' => $licenseToken->id,
                    'kind' => ApiRateEvent::KIND_BLOCKED,
                    // Lo observado es "al menos el tope": el RateLimiter no dice cuántas
                    // ha rechazado, solo que se ha pasado.
                    'observed' => $limite,
                    'limit_value' => $limite,
                    'host' => $request->input('host'),
                    'action' => $request->input('action'),
                    'plugin' => $request->input('plugin'),
                    'user_agent' => substr((string) $request->userAgent(), 0, 255),
                ]
            );

            $evento->increment('rejected');
        } catch (\Throwable $e) {
            // Registrar la incidencia no puede tumbar la API.
            Log::channel('api')->error('No se pudo registrar el límite de la licencia', [
                'license_token_id' => $licenseToken->id,
                'error' => $e->getMessage(),
            ]);
        }
    }

    /**
     * Deja el bloqueo con estado y avisa por correo si es nuevo.
     *
     * El freno en caché de arriba agrupa las ESCRITURAS del histórico; esto es otra
     * cosa: el estado "esta licencia está bloqueada", que dura hasta que alguien lo
     * revisa. De ahí sale el aviso, una sola vez por bloqueo.
     *
     * El sujeto es el ENTORNO y no la licencia: es lo que un humano reconoce en un
     * correo, y una licencia puede tener varios entornos con problemas distintos.
     */
    private function marcarBloqueo(
        Request $request,
        LicenseToken $licenseToken,
        int $limite,
        bool $enforced = true
    ): void {
        try {
            $entorno = $licenseToken->environments()->get()->first(function ($environment) use ($request) {
                return HostNormalizer::normalize($environment->domain)
                    === HostNormalizer::normalize((string) $request->input('host'));
            });

            $resultado = ApiBlock::registrar(
                ApiBlock::SUBJECT_ENVIRONMENT,
                ApiBlock::REASON_TOKEN,
                $limite,
                [
                    'ip' => $request->ip(),
                    'environment_id' => $entorno?->id,
                    'license_token_id' => $licenseToken->id,
                    'host' => $request->input('host'),
                    'action' => $request->input('action'),
                    'plugin' => $request->input('plugin'),
                    'user_agent' => substr((string) $request->userAgent(), 0, 255),
                    'enforced' => $enforced,
                ]
            );

            if ($resultado['esNuevo']) {
                app(BlockNotifier::class)->avisar($resultado['bloqueo']);
            }
        } catch (\Throwable $e) {
            Log::channel('api')->error('No se pudo marcar el bloqueo de la licencia', [
                'license_token_id' => $licenseToken->id,
                'error' => $e->getMessage(),
            ]);
        }
    }
}
