<?php

namespace App\Livewire\LicenseTokens;

use App\Livewire\Traits\AssignsTokenEnvironments;
use App\Models\Products\LicenseToken;
use Carbon\Carbon;
use App\Models\Products\Product;
use App\Models\Environments\Environment;
use App\Models\Environments\Type;
use App\Rules\UniqueEnvironmentDomain;
use Illuminate\Support\Facades\Auth;
use App\Models\Monitoring\ApiRateEvent;
use Illuminate\Support\Facades\RateLimiter;
use App\Livewire\Components\ConfirmModal;
use App\Models\Monitoring\Log as MonitoringLog;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\On;
use Livewire\Component;

class Show extends Component
{
    use AssignsTokenEnvironments;

    public LicenseToken $licenseToken;
    public array $selectedProducts = [];
    public array $selectedEnvironments = [];

    // Propiedades para el formulario de nuevo entorno
    public string $newEnvironmentName = '';
    public string $newEnvironmentDomain = '';
    public ?string $newEnvironmentDescription = null;
    public ?int $newEnvironmentTypeId = null;
    public string $newEnvironmentVersion = '';
    public string $newEnvironmentEnv = 'local';
    public ?int $newEnvironmentHosting = null; // Por ahora nullable hasta confirmar qué es

    public function mount(LicenseToken $license_token)
    {
        $this->licenseToken = $license_token->load(['client', 'products', 'environments.type']);
        // Inicializar los arrays con los valores actuales
        $this->selectedProducts = $this->licenseToken->products->pluck('id')->toArray();
        $this->selectedEnvironments = $this->licenseToken->environments->pluck('id')->toArray();
    }

    public function toggleProduct($productId)
    {
        // Permiso de escritura, aunque la ruta exija solo `license-tokens.show`:
        // vincular o desvincular un producto cambia lo que ese cliente recibe por la
        // API. Ver known-issues MGR-005.
        $this->authorize('admin.license-tokens.edit');

        $isLinked = $this->licenseToken->products->contains('id', $productId);

        if ($isLinked) {
            // Quitar ya NO se hace de un clic: abre la pantalla que dice a cuántos
            // entornos afecta y ofrece hacerlo al final del periodo. Un interruptor que
            // corta servicio en todos los sitios de un cliente no puede ser un
            // interruptor.
            $this->dispatch('openModal',
                component: \App\Livewire\LicenseTokens\ProductRemovalModal::class,
                arguments: [
                    'licenseTokenId' => $this->licenseToken->id,
                    'productId' => $productId,
                ]
            );

            return;
        }

        $this->vincularProducto($productId);

        // Refrescar la relación
        $this->licenseToken->refresh();
        $this->licenseToken->load('products');
    }

    /**
     * Vincula un producto, **recuperando su fila si estaba quitada**.
     *
     * El índice único `(license_token_id, product_id)` **no incluye `deleted_at`**, así
     * que no puede haber dos filas para el mismo par: un `syncWithoutDetaching` sobre
     * un producto quitado antes reventaría contra ese índice. Hay que restaurar.
     *
     * Y al restaurar se limpia una `end_at` pasada: un producto que vuelve no puede
     * volver caducado —quedaría «activo y caducado», que es el estado que evitamos en
     * las renovaciones—.
     */
    private function vincularProducto(int $productId): void
    {
        $fila = \App\Models\Products\LicenseTokenProduct::withTrashed()
            ->where('license_token_id', $this->licenseToken->id)
            ->where('product_id', $productId)
            ->first();

        if ($fila === null) {
            $this->licenseToken->products()->attach($productId, [
                'status' => 'active',
                'assigned_by' => Auth::id(),
            ]);

            return;
        }

        $fila->restore();
        $fila->status = 'active';

        if ($fila->end_at && $fila->end_at->isPast()) {
            $fila->end_at = null;
        }

        $fila->save();
    }

    #[On('licenceProductsChanged')]
    public function refrescarProductos(): void
    {
        $this->licenseToken->refresh();
        $this->licenseToken->load('products');
        $this->selectedProducts = $this->licenseToken->products->pluck('id')->toArray();
    }

    public function isProductLinked($productId): bool
    {
        return $this->licenseToken->products->contains('id', $productId);
    }

    public function saveProducts(array $productIds)
    {
        // Ver known-issues MGR-005.
        $this->authorize('admin.license-tokens.edit');

        // Obtener los IDs de productos actualmente vinculados
        $currentProductIds = $this->licenseToken->products->pluck('id')->toArray();

        // Productos a agregar (están en $productIds pero no en $currentProductIds)
        $productsToAdd = array_diff($productIds, $currentProductIds);

        // Productos a eliminar (están en $currentProductIds pero no en $productIds)
        $productsToRemove = array_diff($currentProductIds, $productIds);

        // Cada alta pasa por el mismo camino que el interruptor: restaura la fila si
        // estaba quitada, porque el índice único del pivote no admite dos filas para el
        // mismo par y un `syncWithoutDetaching` reventaría contra él.
        foreach ($productsToAdd as $productId) {
            $this->vincularProducto((int) $productId);
        }

        // Y las bajas, en blando: `detach()` borraba la fila de verdad —con sus fechas
        // y su `assigned_by`— pese a que la tabla tiene `deleted_at`. Aquí no hay modal
        // porque este método es el guardado en bloque del modo edición, pero el rastro
        // sí se deja: era la otra vía por la que se cortaba servicio sin constancia.
        foreach ($productsToRemove as $productId) {
            $fila = \App\Models\Products\LicenseTokenProduct::where('license_token_id', $this->licenseToken->id)
                ->where('product_id', $productId)
                ->first();

            if ($fila === null) {
                continue;
            }

            $fila->delete();

            \App\Models\Monitoring\Log::db(
                'warning',
                '16021',
                'Producto ' . (\App\Models\Products\Product::find($productId)?->name ?? $productId)
                . ' quitado de la licencia ' . ($this->licenseToken->name ?? 'sin nombre')
                . ' (' . ($this->licenseToken->client?->name ?? 'sin cliente') . '), quitado ahora'
                . '. Afecta a ' . $this->licenseToken->environments->count() . ' entorno(s)',
                'LicenseToken',
                (string) $this->licenseToken->id
            );
        }

        // Refrescar la relación
        $this->licenseToken->refresh();
        $this->licenseToken->load('products');

        // Actualizar el array de productos seleccionados
        $this->selectedProducts = $this->licenseToken->products->pluck('id')->toArray();
    }


    public function saveEnvironments(array $environmentIds)
    {
        // El permiso de la ruta solo protege el GET de la pantalla: las llamadas a
        // métodos van a /livewire/update, donde el middleware `permission:` no se
        // reaplica. Ver known-issues MGR-005.
        $this->authorize('admin.license-tokens.manage-environments');

        // La lista de candidatos que pinta render() ya está filtrada por cliente,
        // pero los IDs llegan del navegador y hay que revalidarlos aquí: vincular
        // un entorno de otro cliente se lo QUITA a su token y lo deja sin licencia.
        // Ver known-issues MGR-004.
        $validIds = $this->validateAssignableEnvironments($this->licenseToken, $environmentIds);

        if ($validIds === null) {
            session()->flash('error', 'La operación se ha cancelado: alguno de los entornos seleccionados no pertenece al cliente de este token.');

            return;
        }

        $result = $this->syncTokenEnvironments($this->licenseToken, $validIds);

        // Desvincular deja ese sitio SIN licencia: su plugin empezará a recibir
        // "Environment not found". Antes esto pasaba en silencio.
        if (!empty($result['unlinked'])) {
            session()->flash('warning', count($result['unlinked']) . ' entorno(s) se han quedado sin token y dejarán de recibir licencia ('
                . implode(', ', $result['unlinked']) . ').');
        }

        // Refrescar la relación y el modelo completo
        $this->licenseToken->refresh();
        $this->licenseToken->load(['environments.type', 'client', 'products']);

        // Actualizar el array de entornos seleccionados
        $this->selectedEnvironments = $this->licenseToken->environments->pluck('id')->toArray();
    }

    public function createNewEnvironment()
    {
        // Se exige el permiso de escritura de ESTA pantalla, no `admin.environments.create`.
        //
        // Y queda una incoherencia anotada, que es una decisión de producto y no se
        // resuelve en silencio: este método **crea un entorno**, y hoy
        // `admin.environments.create` es solo de Admin mientras
        // `admin.license-tokens.edit` lo tienen Admin y Manager. O sea que un Manager
        // no puede crear un entorno desde la pantalla de Entornos pero sí desde aquí.
        // Las dos salidas son razonables —darle `environments.create` al Manager, o
        // quitar este modal— y hay que elegir, no adivinar. Ver known-issues MGR-005.
        $this->authorize('admin.license-tokens.edit');

        $this->validate([
            'newEnvironmentName' => 'required|string|max:255',
            'newEnvironmentDomain' => ['required', 'string', 'max:255', new UniqueEnvironmentDomain($this->newEnvironmentEnv)],
            'newEnvironmentDescription' => 'nullable|string',
            'newEnvironmentTypeId' => 'nullable|exists:types,id',
            'newEnvironmentVersion' => 'required|string|max:50',
            'newEnvironmentEnv' => 'required|string|in:local,develop,pre,pro',
        ]);

        $environment = Environment::create([
            'name' => $this->newEnvironmentName,
            'domain' => $this->newEnvironmentDomain,
            'description' => $this->newEnvironmentDescription,
            'type_id' => $this->newEnvironmentTypeId,
            'version' => $this->newEnvironmentVersion,
            'env' => $this->newEnvironmentEnv,
            'client_id' => $this->licenseToken->client_id,
            'license_token_id' => $this->licenseToken->id,
            'active' => true,
        ]);

        // Limpiar el formulario
        $this->resetNewEnvironmentForm();

        // Refrescar la relación
        $this->licenseToken->refresh();
        $this->licenseToken->load('environments.type');

        // Actualizar selectedEnvironments con los valores actuales
        $this->selectedEnvironments = $this->licenseToken->environments->pluck('id')->toArray();

        // Emitir evento para cambiar a la vista de entornos existentes
        $this->dispatch('environment-created');
    }

    public function resetNewEnvironmentForm()
    {
        $this->newEnvironmentName = '';
        $this->newEnvironmentDomain = '';
        $this->newEnvironmentDescription = null;
        $this->newEnvironmentTypeId = null;
        $this->newEnvironmentVersion = '';
        $this->newEnvironmentEnv = 'local';
        $this->newEnvironmentHosting = null;
    }

    /* ==========================================================
       Renovación
       ========================================================== */

    /** Periodos que se ofrecen, en meses. */
    public const PERIODOS = [12 => '1 año', 6 => '6 meses'];

    /**
     * Desde qué fecha se cuenta la renovación.
     *
     * **Si la licencia está viva, desde su fecha de fin**: renovar un mes antes de
     * caducar no debe regalar ni quitar tiempo, y contar desde hoy le robaría ese mes al
     * cliente.
     *
     * **Si ya caducó, desde hoy**: contar desde su fecha de fin le daría un periodo que
     * empieza en el pasado —renovar por un año una licencia vencida hace tres meses
     * dejaría nueve—, y eso no es lo que nadie quiere decir al pulsar «renovar 1 año».
     */
    private function baseDeLaRenovacion(): Carbon
    {
        $fin = $this->licenseToken->end_at;

        if ($fin === null || $fin->lt(now())) {
            return now();
        }

        return $fin->copy();
    }

    private function nuevoFin(int $meses): Carbon
    {
        return $this->baseDeLaRenovacion()->addMonths($meses);
    }

    /**
     * Productos a los que la renovación va a mover la fecha.
     *
     * Solo los que **caducan antes** que la fecha nueva y **tienen fecha propia**:
     *
     * - Un producto sin fecha propia vale mientras valga la licencia; ponerle una sería
     *   restringirlo, no renovarlo.
     * - Un producto con fecha POSTERIOR a la nueva no corta nada, así que no se toca:
     *   alinearlo sería acortarlo, y una renovación no puede quitar servicio.
     *
     * @return array<int, int> ids de producto
     */
    private function productosAAlinear(Carbon $nuevoFin): array
    {
        return $this->licenseToken->products
            ->filter(function ($producto) use ($nuevoFin) {
                $hasta = $producto->pivot->end_at;

                return $hasta !== null && Carbon::parse($hasta)->lt($nuevoFin);
            })
            ->pluck('id')
            ->all();
    }

    /**
     * Pide confirmación de la renovación, diciendo exactamente qué va a cambiar.
     *
     * Renovar toca **tres cosas** y las tres son necesarias: la fecha, el interruptor
     * `active` —que `licences:check-expiry` apaga al caducar, y el middleware mira antes
     * que las fechas— y las fechas de los productos. Hacer solo una de las tres deja al
     * cliente cortado con un mensaje distinto cada vez, que es exactamente lo que pasaba
     * renovando a mano.
     */
    public function pedirRenovacion(int $meses): void
    {
        $this->authorize('admin.license-tokens.edit');

        if (!array_key_exists($meses, self::PERIODOS)) {
            return;
        }

        $nuevoFin = $this->nuevoFin($meses);
        $productos = $this->productosAAlinear($nuevoFin);
        $caducada = $this->licenseToken->end_at && $this->licenseToken->end_at->lt(now());

        $mensaje = 'La licencia pasará a caducar el ' . $nuevoFin->format('d/m/Y') . '.';

        if ($caducada) {
            $mensaje .= ' Estaba caducada, así que el periodo se cuenta desde hoy.';
        }

        if (!$this->licenseToken->active) {
            $mensaje .= ' Se reactivará: ahora está inactiva y por eso los Moodles reciben un 403.';
        }

        $mensaje .= $productos === []
            ? ' Ningún producto necesita cambio de fecha.'
            : ' Se alinearán ' . count($productos) . ' producto(s) que caducaban antes.';

        $this->dispatch('openModal',
            component: ConfirmModal::class,
            arguments: [
                'title' => 'Renovar ' . self::PERIODOS[$meses],
                'message' => $mensaje,
                'confirmText' => 'Sí, renovar',
                'cancelText' => 'Cancelar',
                'eventName' => 'licenceRenewConfirmed',
                'confirmButtonColor' => 'blue',
                // El id transporta los meses.
                'itemId' => $meses,
                'itemName' => $this->licenseToken->name ?? 'la licencia',
            ]
        );
    }

    /**
     * OJO con la firma: `ConfirmModal` dispara el evento con un **array**, así que aquí
     * no puede haber parámetros tipados ni inyección del contenedor. Ver MGR-042.
     */
    #[On('licenceRenewConfirmed')]
    public function renovar($datos = null): void
    {
        $this->authorize('admin.license-tokens.edit');

        $meses = (int) (is_array($datos) ? ($datos['itemId'] ?? 0) : $datos);

        if (!array_key_exists($meses, self::PERIODOS)) {
            return;
        }

        $anterior = $this->licenseToken->end_at?->format('d/m/Y') ?? 'sin fecha';
        $nuevoFin = $this->nuevoFin($meses);
        $productos = $this->productosAAlinear($nuevoFin);

        // Las tres cosas juntas y en una transacción: una renovación a medias —fecha
        // nueva y productos viejos, o fecha nueva sin reactivar— deja al cliente cortado
        // pareciendo que está todo bien.
        DB::transaction(function () use ($nuevoFin, $productos) {
            $this->licenseToken->update([
                'end_at' => $nuevoFin,
                'active' => true,
            ]);

            foreach ($productos as $productoId) {
                $this->licenseToken->products()->updateExistingPivot($productoId, [
                    'end_at' => $nuevoFin,
                ]);
            }
        });

        $this->licenseToken->refresh();
        $this->licenseToken->load('products');

        MonitoringLog::db(
            'info',
            '16018',
            'Licencia renovada ' . self::PERIODOS[$meses] . ': ' . ($this->licenseToken->name ?? 'sin nombre')
            . ' (' . ($this->licenseToken->client?->name ?? 'sin cliente') . '), de ' . $anterior
            . ' a ' . $nuevoFin->format('d/m/Y')
            . ($productos === [] ? '' : ', ' . count($productos) . ' producto(s) alineados')
            . ' (por ' . (Auth::user()?->name ?? 'desconocido') . ')',
            'LicenseToken',
            (string) $this->licenseToken->id
        );

        // Los avisos de caducidad se rearman solos: `notices.reference` es la fecha de
        // fin, así que al cambiarla ningún aviso viejo casa y el ciclo empieza de nuevo.
        session()->flash('success', 'Licencia renovada hasta el ' . $nuevoFin->format('d/m/Y') . '.');
    }

    /**
     * Vigencia real de cada producto de la licencia.
     *
     * **Por qué hace falta.** Hay DOS fechas, no una: la de la licencia
     * (`license_tokens.end_at`) y la de cada producto dentro de ella
     * (`license_token_product.end_at`). El middleware corta por las dos, con mensajes
     * distintos —«Token expirado» por la primera y «Licence does not include this
     * plugin» (2001) por la segunda—, y **las del producto no se veían en ninguna
     * pantalla**: `grep license_token_product resources/views` no devolvía nada.
     *
     * Eso hacía que un producto caducado dentro de una licencia vigente cortara servicio
     * con un mensaje que apunta a otro sitio, y que renovar la licencia cambiando su
     * `end_at` **no arreglara nada** en las licencias que vienen de la importación del
     * Manager antiguo (11 de las 13 filas de la base traen fecha propia; las que se crean
     * desde el panel nacen sin ella).
     *
     * Tampoco avisa nadie: `licences:check-expiry` mira solo `license_tokens.end_at`.
     *
     * @return array<int, array{producto: \App\Models\Products\Product, estado: string, texto: string, desde: ?string, hasta: ?string, dias: ?int}>
     */
    private function vigenciasDeLosProductos(): array
    {
        $finDeLaLicencia = $this->licenseToken->end_at;
        $vigencias = [];

        foreach ($this->licenseToken->products as $producto) {
            $pivot = $producto->pivot;
            $hasta = $pivot->end_at ? Carbon::parse($pivot->end_at) : null;
            $desde = $pivot->start_at ? Carbon::parse($pivot->start_at) : null;

            // Días de calendario, y con la fecha pasada como sujeto: en Carbon 3
            // `diffInDays` va con signo. Ver MGR-030.
            $dias = $hasta
                ? (int) now()->startOfDay()->diffInDays($hasta->copy()->startOfDay(), false)
                : null;

            $estado = match (true) {
                // Un `status` distinto de `active` en el pivote también corta, y es otra
                // cosa que no se veía.
                ($pivot->status ?? 'active') !== 'active' => 'inactivo',
                $hasta === null => 'sin_fecha',
                $dias < 1 => 'caducado',
                // Desalineado: el producto caduca ANTES que la licencia. Es el caso que
                // rompe una renovación hecha solo sobre la licencia.
                $finDeLaLicencia && $hasta->lt($finDeLaLicencia->copy()->startOfDay()) => 'antes',
                default => 'vigente',
            };

            $vigencias[] = [
                'producto' => $producto,
                'estado' => $estado,
                'texto' => match ($estado) {
                    'inactivo' => 'Desactivado en esta licencia',
                    'sin_fecha' => 'Sin fecha propia: vale mientras valga la licencia',
                    'caducado' => 'Caducado hace ' . abs($dias) . ' día(s)',
                    'antes' => 'Caduca ' . abs((int) $hasta->startOfDay()->diffInDays($finDeLaLicencia->copy()->startOfDay(), false)) . ' día(s) antes que la licencia',
                    default => 'Vigente hasta ' . $hasta->format('d/m/Y'),
                },
                'desde' => $desde?->format('d/m/Y'),
                'hasta' => $hasta?->format('d/m/Y'),
                'dias' => $dias,
            ];
        }

        return $vigencias;
    }

    /* ==========================================================
       Acciones de una sola fila

       El diseño (`Licencias.dc.html`) pide que la ficha se lea de un tirón y que
       añadir o quitar sea un clic en la fila, no entrar en un «modo edición» que
       cambia media pantalla. Estos métodos **delegan en los de siempre** —con sus
       comprobaciones de pertenencia (MGR-004) y sus avisos— en lugar de repetir la
       lógica.
       ========================================================== */

    /**
     * Añade un producto a la licencia.
     *
     * Pasa por `vincularProducto()`, que **restaura** el pivote borrado en blando si
     * ese producto ya estuvo: el índice único ignora `deleted_at`, así que un `attach`
     * a secas fallaría al re-añadir algo que se quitó.
     */
    public function anadirProducto(int $productId): void
    {
        $this->authorize('admin.license-tokens.edit');

        if ($this->licenseToken->products->contains($productId)) {
            return;
        }

        $this->vincularProducto($productId);

        $this->licenseToken->refresh();
        $this->licenseToken->load('products');
        $this->selectedProducts = $this->licenseToken->products->pluck('id')->toArray();

        session()->flash('success', 'Producto añadido a la licencia.');
    }

    /**
     * Asocia un entorno a esta licencia.
     *
     * Delega en `saveEnvironments()` con la lista completa porque ahí está la
     * comprobación de que el entorno es **del mismo cliente**: asociar el de otro se lo
     * quita a su token y lo deja sin licencia (MGR-004).
     */
    public function asociarEntorno(int $environmentId): void
    {
        $actuales = $this->licenseToken->environments->pluck('id')->all();

        if (in_array($environmentId, $actuales, true)) {
            return;
        }

        $this->saveEnvironments([...$actuales, $environmentId]);
    }

    /**
     * Pide confirmación antes de quitar un entorno de la licencia.
     *
     * **Era un `wire:confirm`**, es decir, la alerta del navegador: un cuadro gris del
     * sistema, sin formato, que no puede decir bien lo que está en juego —que ese sitio
     * se queda **sin licencia** y su plugin empezará a recibir «Environment not
     * found»—. Con la modal del panel cabe el dominio, la consecuencia y el aviso.
     */
    public function pedirDesasociar(int $environmentId): void
    {
        $this->authorize('admin.license-tokens.manage-environments');

        $entorno = $this->licenseToken->environments->firstWhere('id', $environmentId);

        if ($entorno === null) {
            return;
        }

        $this->dispatch('openModal',
            component: \App\Livewire\Components\ConfirmModal::class,
            arguments: [
                'itemId' => $environmentId,
                'itemName' => $entorno->domain ?: $entorno->name,
                'title' => 'Quitar el entorno de esta licencia',
                'message' => '«' . ($entorno->domain ?: $entorno->name) . '» se quedará '
                    . '**sin licencia asignada**: su plugin dejará de recibir contenido y '
                    . 'empezará a responder «Environment not found for this host» en la '
                    . 'siguiente sincronización.' . "

"
                    . 'No se borra nada: el entorno sigue existiendo y se le puede asignar '
                    . 'otra licencia cuando toque.',
                'confirmText' => 'Quitar de la licencia',
                'confirmButtonColor' => 'red',
                'eventName' => 'environmentUnlinkConfirmed',
            ]
        );
    }

    /**
     * El receptor de la confirmación.
     *
     * **Sin parámetros tipados**: `ConfirmModal` despacha un único argumento array y
     * Livewire intentaría inyectar por tipo, con un TypeError. Ver MGR-042.
     */
    #[On('environmentUnlinkConfirmed')]
    public function desasociarConfirmado($datos = null): void
    {
        $id = is_array($datos) ? ($datos['itemId'] ?? null) : null;

        if ($id === null) {
            return;
        }

        $this->desasociarEntorno((int) $id);
    }

    /**
     * Quita un entorno de esta licencia.
     *
     * **Deja ese sitio sin licencia**: su plugin empezará a recibir «Environment not
     * found». El aviso lo da `saveEnvironments()`, que es quien sabe cuáles se han
     * quedado colgados.
     */
    public function desasociarEntorno(int $environmentId): void
    {
        $this->saveEnvironments(
            array_values(array_filter(
                $this->licenseToken->environments->pluck('id')->all(),
                fn ($id) => $id !== $environmentId
            ))
        );
    }

    /**
     * Abre la modal de asociar un entorno.
     *
     * Sustituye al «modo edición» con casillas sobre la lista completa: ahí quitar un
     * entorno y añadir otro eran el mismo gesto y no se veía qué iba a pasar.
     */
    public function abrirAsociarEntorno(): void
    {
        $this->authorize('admin.license-tokens.manage-environments');

        $this->dispatch('openModal',
            component: \App\Livewire\LicenseTokens\EnvironmentAssociationModal::class,
            arguments: ['licenseTokenId' => $this->licenseToken->id]
        );
    }

    #[On('licenseEnvironmentsChanged')]
    public function refrescarEntornos(): void
    {
        $this->licenseToken->refresh();
        $this->licenseToken->load(['environments.type', 'client']);
        $this->selectedEnvironments = $this->licenseToken->environments->pluck('id')->toArray();
    }

    /**
     * Abre la modal de apagar o encender la licencia.
     *
     * **Sustituye a `toggleActive()`**, que invertía `active` y guardaba: un clic y
     * todos los entornos de la licencia dejaban de recibir contenido, sin decir a
     * cuántos afectaba y sin dejar rastro de quién ni por qué. Ver MGR-056.
     */
    public function abrirCambioDeEstado(): void
    {
        $this->authorize('admin.license-tokens.edit');

        $this->dispatch('openModal',
            component: \App\Livewire\LicenseTokens\LicenseStatusModal::class,
            arguments: ['licenseTokenId' => $this->licenseToken->id]
        );
    }

    #[On('licenseStatusChanged')]
    public function refrescarEstado(): void
    {
        $this->licenseToken->refresh();
        $this->licenseToken->load(['products', 'environments.type', 'client', 'deactivatedBy']);
    }

    /**
     * Abre la modal para cambiar el tope de peticiones de esta licencia.
     *
     * **Se puede cambiar aquí y en «Editar», y no es una duplicidad.** En el formulario es
     * un campo entre cliente, fechas, modo y metadatos: se toca sin ver nada de lo que
     * hace falta para decidir el número. Y eso está justo aquí al lado —el consumo del
     * minuto en curso y los cortes de la semana—, que sale del mismo contador que usa el
     * middleware.
     */
    public function abrirLimite(): void
    {
        $this->authorize('admin.license-tokens.edit');

        $this->dispatch('openModal',
            component: RateLimitModal::class,
            arguments: ['licenseTokenId' => $this->licenseToken->id]
        );
    }

    /** Tras cambiar el tope, la ficha vuelve a leerlo: si no, sigue enseñando el viejo. */
    #[On('rateLimitChanged')]
    public function refrescarLimite(): void
    {
        $this->licenseToken->refresh();
    }

    /**
     * El estado de la licencia, en las tres palabras que usa el diseño.
     *
     * El modelo devuelve cuatro valores porque «vence pronto» le sirve al listado de
     * clientes; aquí «pronto» es una licencia **activa** con un aviso, no un estado
     * aparte. Se traduce en un solo sitio.
     *
     * @return array{estado:string, apagada:bool, caducada:bool, activa:bool, pronto:bool, dias:?int}
     */
    private function estadoDeLaLicencia(): array
    {
        $estado = $this->licenseToken->estado();

        $dias = $this->licenseToken->end_at
            ? (int) now()->startOfDay()->diffInDays($this->licenseToken->end_at->copy()->startOfDay(), false)
            : null;

        return [
            'estado' => $estado,
            'apagada' => $estado === 'desactivada',
            'caducada' => $estado === 'vencida',
            'activa' => in_array($estado, ['activa', 'pronto'], true),
            'pronto' => $estado === 'pronto',
            'dias' => $dias,
        ];
    }

    /**
     * Los entornos de la licencia con su estado de sincronización.
     *
     * **Tres estados, no dos**, igual que en el listado de Entornos y en la ficha del
     * cliente: nunca sincronizado es un alta sin terminar y sin señal es una avería.
     *
     * @return array<int, array<string, mixed>>
     */
    private function entornosDeLaLicencia(): array
    {
        return $this->licenseToken->environments
            ->sortBy('domain')
            ->map(function ($entorno) {
                $sincronizado = $entorno->refresh_at !== null;

                return [
                    'id' => $entorno->id,
                    'nombre' => $entorno->name,
                    'dominio' => $entorno->domain,
                    'env' => $entorno->env,
                    'version' => $entorno->version,
                    'tipo' => $entorno->type?->name,
                    'nunca' => ! $sincronizado,
                    // Mismo umbral que el Dashboard, Entornos y la ficha del cliente.
                    'sinSenal' => $sincronizado
                        && $entorno->refresh_at->copy()->startOfDay()->diffInDays(now()->startOfDay()) >= 3,
                    'dias' => $sincronizado
                        ? (int) $entorno->refresh_at->copy()->startOfDay()->diffInDays(now()->startOfDay())
                        : null,
                    'fecha' => $entorno->refresh_at?->diffForHumans(),
                ];
            })
            ->all();
    }

    public function render()
    {
        $allProducts = Product::all();
        // Obtener todos los entornos del cliente asociado al token
        $allEnvironments = Environment::with('type')
            ->where('client_id', $this->licenseToken->client_id)
            ->orderBy('name')
            ->get()
            ->map(function ($environment) {
                // Agregar información sobre si está asociado a otro token
                $environment->is_associated_to_other = $environment->license_token_id &&
                                                $environment->license_token_id !== $this->licenseToken->id;
                return $environment;
            });

        return view('livewire.license-tokens.show', [
            'allProducts' => $allProducts,
            'allEnvironments' => $allEnvironments,
            'environmentTypes' => Type::orderBy('name')->get(),
            'environmentEnvironments' => Environment::envs(),
            'limite' => $this->estadoDelLimite(),
            'vigencias' => $this->vigenciasDeLosProductos(),
            'situacion' => $this->estadoDeLaLicencia(),
            'entornos' => $this->entornosDeLaLicencia(),
        ])->layout('layouts.app');
    }

    /**
     * Estado del límite de peticiones por minuto de esta licencia.
     *
     * Es lo que faltaba para que el límite fuera algo más que un número guardado: aquí
     * se ve **cuánto se está usando ahora mismo** y **si ha cortado alguna vez**.
     * Antes no había forma de saberlo: el corte devolvía un 429 y no dejaba rastro.
     * Ver known-issues MGR-013.
     *
     * El consumo sale del mismo contador que usa el middleware, así que es el número
     * real, no una estimación. Es del minuto EN CURSO: al cambiar de minuto vuelve a
     * cero, y eso hay que decirlo en la pantalla o el número desconcierta.
     */
    private function estadoDelLimite(): array
    {
        $tope = $this->licenseToken->rate_limit_per_minute;

        return [
            'tope' => $tope,
            // Sin tope configurado se aplica el techo por defecto del middleware.
            'porDefecto' => $tope === null,
            'techoPorDefecto' => 10000,
            'consumidas' => RateLimiter::attempts('rate_limit:token:' . $this->licenseToken->token),
            // Cortes registrados en la última semana, para saber si el tope aprieta.
            'cortes' => ApiRateEvent::where('license_token_id', $this->licenseToken->id)
                ->where('limit_kind', ApiRateEvent::LIMIT_TOKEN)
                ->where('window_started_at', '>=', now()->subWeek())
                ->sum('rejected'),
            'ultimoCorte' => ApiRateEvent::where('license_token_id', $this->licenseToken->id)
                ->where('limit_kind', ApiRateEvent::LIMIT_TOKEN)
                ->orderByDesc('window_started_at')
                ->value('window_started_at'),
        ];
    }
}

