<?php

namespace App\Livewire\Plugins;

use App\Models\Environments\Environment;
use App\Models\Environments\Plugin;
use App\Models\Monitoring\Log as MonitoringLog;
use App\Models\Products\OwnPlugin;
use App\Models\Products\PluginFicha;
use App\Models\Products\Product;
use App\Support\FamiliaDePlugin;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Collection;
use Livewire\Attributes\Url;

/**
 * La ficha de un plugin: qué es, dónde está y en qué versión, y quién va atrasado.
 *
 * Implementada desde `Plugin vista.dc.html` (Claude Design), sobre el brief
 * `briefs/ficha-de-plugin.md` (PRODSECU-183).
 *
 * **Antes solo había un desplegable** bajo la fila, en dos de las cinco pantallas de la
 * sección: no tenía dirección propia, no decía qué es el plugin y con cuarenta sitios eran
 * cuarenta filas sueltas. Esta ficha tiene URL —`/plugins/{component}`—, resume y agrupa.
 *
 * Hereda de `PantallaDelParque` para que las reglas sean **las mismas que en el resto de la
 * sección**: los dos desfases (MGR-066), la versión candidata, la incompatibilidad y el
 * recorte de los entornos apagados. Lo único propio son las dependencias, que aquí se traen
 * de una vez para todos los entornos y no con una consulta por fila.
 *
 * **Qué es** lo decide `FamiliaDePlugin`, la misma regla que el filtro del inventario. Y lo que
 * el inventario no sabe —que es de terceros, o su página del Marketplace— se fija a mano aquí
 * (`PluginFicha`), igual que marcarlo como nuestro (`OwnPlugin`). **El uso no**: el plugin lo
 * manda vacío, y se decidió no pedirlo por ahora.
 */
class Ficha extends PantallaDelParque
{
    public string $component;

    /** todos | pro | nueva | desfase | vers: lo que filtra cada cifra del resumen. */
    #[Url(as: 'ver', except: 'todos')]
    public string $filtro = 'todos';

    #[Url(as: 'uso', except: '')]
    public string $uso = '';

    #[Url(as: 'version', except: '')]
    public string $version = '';

    #[Url(as: 'agrupar', except: false)]
    public bool $agrupar = false;

    /** Los colores de las versiones, de la más nueva a la más vieja. */
    private const COLORES = ['var(--teal-700)', 'var(--teal-600)', 'var(--warning-500)', 'var(--danger-500)'];

    /** @var Collection<int, Plugin>|null */
    private ?Collection $cacheFilas = null;

    /** Lo fijado a mano: '' (lo que diga Moodle), `terceros` o `estandar`. */
    public string $clasificacionManual = '';

    /** La página buena del Marketplace, fijada a mano. */
    public string $marketplaceUrl = '';

    public function mount(string $component): void
    {
        $this->component = $component;

        $ficha = PluginFicha::where('component', $component)->first();
        $this->clasificacionManual = (string) ($ficha?->clasificacion ?? '');
        $this->marketplaceUrl = (string) ($ficha?->marketplace_url ?? '');
    }

    /* ======================================================================
       Qué es
       ====================================================================== */

    protected function producto(): ?Product
    {
        return once(fn () => Product::where('slug', $this->component)->first());
    }

    protected function propio(): ?OwnPlugin
    {
        return once(fn () => OwnPlugin::where('component', $this->component)->first());
    }

    /**
     * producto | nuestro | estandar | terceros | sinclas.
     *
     * Producto y nuestro salen del catálogo del Manager y mandan sobre todo lo demás. Estándar
     * o terceros **lo dice cada sitio** (`isstandard`, desde el 2026-10-01), porque solo Moodle
     * lo sabe y depende de su versión. **Si ningún sitio lo ha dicho**, «sin clasificar»:
     * llamar «de terceros» a `mod_assign` sería falso.
     *
     * Si unos sitios dicen que sí y otros que no —un plugin que salió del paquete en una
     * versión de Moodle—, cuenta como estándar: lo es en alguna versión que hay en el parque.
     * `estandarEnCuantos()` da el detalle para el `title`.
     */
    protected function familia(): string
    {
        // La misma regla que el filtro del inventario: ver `FamiliaDePlugin`.
        return FamiliaDePlugin::para($this->component);
    }

    /* ======================================================================
       Lo que se fija a mano
       ====================================================================== */

    /**
     * De dónde es, fijado a mano: `auto` (lo que diga el Moodle de cada sitio), `nuestro`,
     * `terceros` o `estandar`. **Una sola pregunta y un solo control**: antes eran una casilla
     * «es nuestro» y un desplegable de origen, que se podían contradecir.
     *
     * `nuestro` es el catálogo de plugins propios (`OwnPlugin`), el mismo de Configuración ›
     * Catálogos. El resto va en `PluginFicha`. Elegir uno deshace el otro. Un producto ya es
     * nuestro por definición y no se toca desde aquí.
     *
     * Se aplica al pulsar, sin botón de guardar, y avisa de qué ha cambiado.
     */
    public function fijarOrigen(string $origen): void
    {
        $this->authorize('admin.configs.edit');

        // Llega del navegador: solo los cuatro que existen.
        if (! in_array($origen, ['auto', 'nuestro', ...PluginFicha::CLASIFICACIONES], true) || $this->producto() !== null) {
            return;
        }

        $this->marcarNuestro($origen === 'nuestro');

        $this->clasificacionManual = in_array($origen, PluginFicha::CLASIFICACIONES, true) ? $origen : '';
        $this->guardarPluginFicha(['clasificacion' => $this->clasificacionManual ?: null]);

        $this->dispatch('ficha-guardada', mensaje: 'Origen: ' . [
            'auto' => 'lo que diga el Moodle de cada sitio',
            'nuestro' => 'nuestro',
            'terceros' => 'de terceros',
            'estandar' => 'estándar de Moodle',
        ][$origen]);
    }

    /**
     * Guarda la página buena del Marketplace.
     *
     * **Solo si es del Marketplace** (`https://` de `moodle.org` o `marketplace.moodle.com`):
     * se pinta como enlace, y es la misma regla que se aplica a la que manda cada sitio.
     */
    public function guardarUrl(): void
    {
        $this->authorize('admin.configs.edit');

        $this->marketplaceUrl = trim($this->marketplaceUrl);

        $this->validate([
            'marketplaceUrl' => ['required', 'max:500', function (string $atributo, mixed $valor, \Closure $falla) {
                if (! PluginFicha::esDelMarketplace((string) $valor)) {
                    $falla('Tiene que ser la página del plugin en el Marketplace: https://marketplace.moodle.com/… o https://moodle.org/plugins/…');
                }
            }],
        ], ['marketplaceUrl.required' => 'Pega la dirección de la página del plugin.']);

        $this->guardarPluginFicha(['marketplace_url' => $this->marketplaceUrl]);

        $this->dispatch('ficha-guardada', mensaje: 'Página del Marketplace guardada');
    }

    public function quitarUrl(): void
    {
        $this->authorize('admin.configs.edit');

        $this->marketplaceUrl = '';
        $this->resetValidation('marketplaceUrl');
        $this->guardarPluginFicha(['marketplace_url' => null]);

        $this->dispatch('ficha-guardada', mensaje: 'Página del Marketplace quitada: se enlaza la búsqueda');
    }

    /**
     * Cambia **solo lo que se le pasa** de `PluginFicha` y conserva el resto tal como está en
     * la base, y deja rastro si algo cambió.
     *
     * Solo lo pasado, y no lo que haya en las propiedades: si no, una URL a medio escribir en
     * el campo —sin guardar— borraría la buena al pulsar un origen.
     *
     * @param  array{clasificacion?: ?string, marketplace_url?: ?string}  $cambios
     */
    protected function guardarPluginFicha(array $cambios): void
    {
        $ficha = PluginFicha::firstOrNew(['component' => $this->component]);
        $antes = [$ficha->clasificacion, $ficha->marketplace_url];

        $clasificacion = array_key_exists('clasificacion', $cambios) ? $cambios['clasificacion'] : $ficha->clasificacion;
        $url = array_key_exists('marketplace_url', $cambios) ? $cambios['marketplace_url'] : $ficha->marketplace_url;

        $clasificacion = in_array($clasificacion, PluginFicha::CLASIFICACIONES, true) ? $clasificacion : null;
        $url = PluginFicha::esDelMarketplace($url) ? trim((string) $url) : null;

        if ($clasificacion === null && $url === null) {
            // Nada fijado: la fila sobra, y sin ella vuelve a mandar el inventario.
            $ficha->exists && $ficha->delete();
        } else {
            $ficha->fill(['clasificacion' => $clasificacion, 'marketplace_url' => $url, 'updated_by' => Auth::id()])->save();
        }

        if ($antes !== [$clasificacion, $url]) {
            MonitoringLog::db('info', '16047',
                'Ficha de plugin modificada: ' . $this->component
                . ' (origen: ' . ($clasificacion ?? 'el que diga Moodle')
                . ', Marketplace: ' . ($url ?? 'sin fijar') . ')'
                . ' (por ' . (Auth::user()?->name ?? 'desconocido') . ')',
                'PluginFicha', (string) ($ficha->id ?? ''));
        }
    }

    /**
     * Pone o quita el plugin del catálogo de propios.
     *
     * **Con los borrados**: el catálogo borra en lógico y el índice único de `component` los
     * cuenta, así que volver a marcar uno quitado es restaurarlo, no crear otro.
     */
    protected function marcarNuestro(bool $nuestro): void
    {
        $propio = OwnPlugin::withTrashed()->where('component', $this->component)->first();

        if ($nuestro) {
            if ($propio === null) {
                $propio = OwnPlugin::create([
                    'component' => $this->component,
                    'name' => (string) Plugin::where('component', $this->component)->value('name'),
                ]);
            } elseif ($propio->trashed()) {
                $propio->restore();
            } else {
                return;
            }

            MonitoringLog::db('info', '16028',
                'Plugin declarado como propio: ' . $this->component . ' (desde su ficha, por ' . (Auth::user()?->name ?? 'desconocido') . ')',
                'OwnPlugin', (string) $propio->id);
        } elseif ($propio !== null && ! $propio->trashed()) {
            $propio->delete();

            MonitoringLog::db('info', '16029',
                'Plugin retirado del catálogo de propios: ' . $this->component . ' (desde su ficha, por ' . (Auth::user()?->name ?? 'desconocido') . ')',
                'OwnPlugin', (string) $propio->id);
        } else {
            return;
        }

        OwnPlugin::olvidar();
    }

    /**
     * En cuántos sitios dice Moodle que es estándar y en cuántos que no. Los que no lo
     * mandan no cuentan en ninguno de los dos.
     *
     * @return array{0:int, 1:int}
     */
    protected function estandarEnCuantos(): array
    {
        $conDato = $this->todas()->filter(fn (Plugin $p) => $p->isstandard !== null);

        return [
            $conDato->filter(fn (Plugin $p) => $p->isstandard === true)->count(),
            $conDato->filter(fn (Plugin $p) => $p->isstandard === false)->count(),
        ];
    }

    /** El nombre bueno: el del catálogo si es nuestro, y si no el más repetido en los sitios. */
    protected function nombre(): string
    {
        $catalogo = $this->producto()?->name ?? $this->propio()?->name;

        if ($catalogo) {
            return $catalogo;
        }

        return $this->todas()->pluck('name')->filter()->countBy()->sortDesc()->keys()->first()
            ?? $this->component;
    }

    /* ======================================================================
       Dónde está
       ====================================================================== */

    /**
     * Todas las instalaciones, apagados incluidos: el resumen los recorta según el botón.
     *
     * Sin lista de columnas: `release` es palabra reservada en MySQL (ver `versionesDe()`).
     *
     * @return Collection<int, Plugin>
     */
    protected function todas(): Collection
    {
        return $this->cacheFilas ??= Plugin::query()
            ->where('component', $this->component)
            ->with('environment.client:id,name')
            ->get()
            // Un entorno borrado deja el plugin sin entorno: no hay nada que enseñar de él.
            ->filter(fn (Plugin $p) => $p->environment !== null)
            ->values();
    }

    /** @return Collection<int, Plugin> */
    protected function visibles(): Collection
    {
        return $this->recortaApagados()
            ? $this->todas()->filter(fn (Plugin $p) => (bool) $p->environment->active)->values()
            : $this->todas();
    }

    public function apagadosOcultos(): int
    {
        return $this->recortaApagados()
            ? $this->todas()->filter(fn (Plugin $p) => ! $p->environment->active)->count()
            : 0;
    }

    /**
     * Cada instalación, ya con su estado.
     *
     * @return Collection<int, array<string, mixed>>
     */
    protected function instalaciones(): Collection
    {
        $plugins = $this->visibles();
        $faltan = $this->dependenciasQueFaltan($plugins);

        return $plugins->map(function (Plugin $p) use ($faltan) {
            $desfase = $this->desfaseDe($p);

            $estado = match (true) {
                $desfase === 'retrocedido' => 'retro',
                $desfase === 'pendiente' => 'sinaplicar',
                (bool) $p->has_updates => 'nueva',
                default => 'aldia',
            };

            return [
                'entorno' => $p->environment,
                'release' => $p->release ?: ((int) $p->versiondisk !== 0 ? (string) $p->versiondisk : '—'),
                'versiondisk' => (string) $p->versiondisk,
                'versiondb' => (string) $p->versiondb,
                'moodle' => $p->environment->version,
                'estado' => $estado,
                'candidata' => $estado === 'nueva' ? $this->candidataDe($p) : null,
                'incompatible' => $this->incompatible($p),
                'faltan' => $faltan[$p->id] ?? [],
            ];
        })->sortByDesc('versiondisk')->values();
    }

    /**
     * Las dependencias que no están instaladas, por instalación. **Una consulta para todas**:
     * `dependenciasDe()` de la clase base hace una por fila, y aquí pueden ser cuarenta.
     *
     * @param  Collection<int, Plugin>  $plugins
     * @return array<int, list<string>> id del plugin => componentes que le faltan
     */
    protected function dependenciasQueFaltan(Collection $plugins): array
    {
        $pide = $plugins->mapWithKeys(function (Plugin $p) {
            $crudo = is_string($p->dependencies) ? json_decode($p->dependencies, true) : $p->dependencies;

            if (! is_array($crudo) || $crudo === []) {
                return [$p->id => []];
            }

            $componentes = array_is_list($crudo) ? $crudo : array_keys($crudo);

            return [$p->id => array_values(array_filter($componentes, fn ($c) => is_string($c) && $c !== ''))];
        });

        $todas = $pide->flatten()->unique()->values();

        if ($todas->isEmpty()) {
            return [];
        }

        $instaladas = Plugin::query()
            ->whereIn('environment_id', $plugins->pluck('environment_id'))
            ->whereIn('component', $todas)
            ->get(['environment_id', 'component'])
            ->groupBy('environment_id')
            ->map(fn (Collection $filas) => $filas->pluck('component')->all());

        return $plugins->mapWithKeys(fn (Plugin $p) => [
            $p->id => array_values(array_diff($pide[$p->id], $instaladas[$p->environment_id] ?? [])),
        ])->all();
    }

    /**
     * Las versiones que conviven, de la más nueva a la más vieja, con cuántos sitios.
     *
     * @return list<array{release:string, versiondisk:string, n:int, color:string}>
     */
    protected function versiones(Collection $instalaciones): array
    {
        return $instalaciones
            ->groupBy('release')
            ->map(fn (Collection $g, string $release) => [
                'release' => $release,
                'versiondisk' => (string) $g->max('versiondisk'),
                'n' => $g->count(),
            ])
            ->sortByDesc('versiondisk')
            ->values()
            ->map(fn (array $v, int $i) => $v + ['color' => self::COLORES[min($i, count(self::COLORES) - 1)]])
            ->all();
    }

    /**
     * La página del plugin en el directorio de Moodle, si algún sitio la ha mandado.
     *
     * Viaja en `availableupdates[].url`: `local_tresipunt` la reenvía desde la información
     * de actualizaciones de Moodle (`normalize_available_updates()`). Solo llega si el sitio
     * tiene una actualización pendiente de ese plugin, y solo vale si es del directorio: la
     * de un plugin nuestro apunta a nuestro servidor de descargas, que no es una página.
     *
     * Sin ella, la ficha enlaza a la búsqueda por `component`, que vale para cualquiera.
     */
    protected function paginaEnElDirectorio(): ?string
    {
        // **La fijada a mano manda**: la ha puesto una persona que ha comprobado que es esa.
        $fijada = PluginFicha::where('component', $this->component)->value('marketplace_url');

        if (PluginFicha::esDelMarketplace($fijada)) {
            return $fijada;
        }

        foreach ($this->todas() as $plugin) {
            $actualizaciones = is_string($plugin->availableupdates)
                ? json_decode($plugin->availableupdates, true)
                : $plugin->availableupdates;

            foreach ((array) $actualizaciones as $actualizacion) {
                $url = is_array($actualizacion) ? trim((string) ($actualizacion['url'] ?? '')) : '';

                // Solo https y solo el directorio: esto se pinta como enlace, y la URL la
                // manda el Moodle del cliente. La misma regla que la fijada a mano.
                if (PluginFicha::esDelMarketplace($url)) {
                    return $url;
                }
            }
        }

        return null;
    }

    /* ======================================================================
       Contratado y sin instalar
       ====================================================================== */

    /**
     * Si es un producto que no está en ningún inventario: en qué licencias activas está.
     *
     * @return Collection<int, \App\Models\Products\LicenseToken>
     */
    protected function licenciasSinInstalar(): Collection
    {
        $producto = $this->producto();

        if ($producto === null || ! $producto->esPluginDeMoodle()) {
            return collect();
        }

        return $producto->tokens()
            ->where('active', true)
            ->with(['client:id,name', 'environments:id,license_token_id,name,domain'])
            ->get();
    }

    /** Los entornos que envían inventario: para decir «sin inventario» de los que no. */
    protected function entornosConInventario(): array
    {
        return Plugin::query()->distinct()->pluck('environment_id')->map(fn ($id) => (int) $id)->all();
    }

    /* ======================================================================
       El 404
       ====================================================================== */

    /** El component más parecido de los que existen, para el «¿querías decir…?». */
    protected function parecido(): ?string
    {
        $candidatos = Plugin::query()->distinct()->pluck('component')
            ->merge(Product::pluck('slug'))
            ->filter()
            ->unique();

        $mejor = $candidatos
            ->map(fn (string $c) => ['c' => $c, 'd' => levenshtein($this->component, $c)])
            ->sortBy('d')
            ->first();

        // Más de tres letras de diferencia ya no es una errata: es otro plugin.
        return $mejor !== null && $mejor['d'] <= 3 ? $mejor['c'] : null;
    }

    /* ======================================================================
       Filtros
       ====================================================================== */

    public function alternarFiltro(string $filtro): void
    {
        // Llega del navegador: solo los cuatro que existen.
        if (! in_array($filtro, ['pro', 'nueva', 'desfase', 'vers'], true)) {
            return;
        }

        $this->filtro = $this->filtro === $filtro ? 'todos' : $filtro;
    }

    public function limpiarFiltros(): void
    {
        parent::limpiarFiltros();
        $this->filtro = 'todos';
        $this->uso = '';
        $this->version = '';
    }

    /**
     * @param  Collection<int, array<string, mixed>>  $instalaciones
     * @return Collection<int, array<string, mixed>>
     */
    protected function filtradas(Collection $instalaciones): Collection
    {
        return $instalaciones
            ->when($this->filtro === 'pro', fn ($c) => $c->filter(fn ($f) => $f['entorno']->env === 'pro'))
            ->when($this->filtro === 'nueva', fn ($c) => $c->where('estado', 'nueva'))
            ->when($this->filtro === 'desfase', fn ($c) => $c->whereIn('estado', ['sinaplicar', 'retro']))
            ->when($this->uso !== '', fn ($c) => $c->filter(fn ($f) => $f['entorno']->env === $this->uso))
            ->when($this->version !== '', fn ($c) => $c->where('release', $this->version))
            ->values();
    }

    /**
     * Dónde está instalado, en CSV, con los filtros que haya puestos (PRODSECU-153).
     *
     * Solo datos del inventario y del entorno: ni tokens ni credenciales, como en el resto de
     * descargas del panel (`ExportacionCsv`).
     */
    public function descargar(): \Symfony\Component\HttpFoundation\StreamedResponse
    {
        $this->authorize('admin.plugins.environment');

        $estados = [
            'aldia' => 'Al día',
            'nueva' => 'Versión nueva',
            'sinaplicar' => 'Actualización sin aplicar',
            'retro' => 'Ficheros retrocedidos',
        ];

        $cabeceras = [
            'Entorno', 'Cliente', 'Dominio', 'Uso', 'Activo', 'Versión', 'versiondisk', 'versiondb',
            'Moodle', 'Estado', 'Versión candidata', 'Exige un Moodle más nuevo', 'Dependencias que faltan',
        ];

        $filas = $this->filtradas($this->instalaciones())->map(fn (array $f) => [
            $f['entorno']->name,
            $f['entorno']->client?->name,
            $f['entorno']->domain,
            $f['entorno']->env,
            (bool) $f['entorno']->active,
            $f['release'],
            $f['versiondisk'],
            $f['versiondb'],
            $f['moodle'],
            $estados[$f['estado']],
            $f['candidata'],
            (bool) $f['incompatible'],
            implode(', ', $f['faltan']),
        ]);

        return \App\Support\ExportacionCsv::descargar('plugin-' . $this->component, $cabeceras, $filas, [
            'filtro' => $this->filtro === 'todos' ? '' : $this->filtro,
            'uso' => $this->uso,
            'version' => $this->version,
            'apagados' => $this->incluirApagados,
        ]);
    }

    public function render()
    {
        $existe = $this->todas()->isNotEmpty() || $this->producto() !== null || $this->propio() !== null;

        $instalaciones = $existe ? $this->instalaciones() : collect();
        $versiones = $this->versiones($instalaciones);
        $filas = $this->filtradas($instalaciones);
        $agrupar = $this->agrupar || $this->filtro === 'vers';

        // Agrupada, una cabecera por versión con sus filas debajo; sin agrupar, un solo grupo.
        $grupos = $agrupar
            ? collect($versiones)
                ->map(fn (array $v) => $v + ['filas' => $filas->where('release', $v['release'])->values()])
                ->filter(fn (array $g) => $g['filas']->isNotEmpty())
                ->values()
            : collect([['release' => null, 'filas' => $filas]]);

        $licencias = $existe && $this->todas()->isEmpty() ? $this->licenciasSinInstalar() : collect();

        return view('livewire.plugins.ficha', [
            'existe' => $existe,
            'familia' => $existe ? $this->familia() : null,
            'estandarEnCuantos' => $this->estandarEnCuantos(),
            // Lo fijado a mano, **leído de la base** y no de las propiedades: es lo que el
            // control tiene que marcar como elegido.
            'origenFijado' => match (true) {
                $this->producto() !== null => 'producto',
                OwnPlugin::where('component', $this->component)->exists() => 'nuestro',
                default => PluginFicha::where('component', $this->component)->value('clasificacion') ?? 'auto',
            },
            'urlGuardada' => PluginFicha::where('component', $this->component)->value('marketplace_url'),
            'nombre' => $existe ? $this->nombre() : null,
            'producto' => $this->producto(),
            'propio' => $this->propio(),
            'tipo' => $this->todas()->first()?->type ?? strtok($this->component, '_'),
            'paginaEnElDirectorio' => $this->paginaEnElDirectorio(),
            'instalaciones' => $instalaciones,
            'versiones' => $versiones,
            'grupos' => $grupos,
            'agrupado' => $agrupar,
            'cuantasFilas' => $filas->count(),
            'nPro' => $instalaciones->filter(fn ($f) => $f['entorno']->env === 'pro')->count(),
            'nNueva' => $instalaciones->where('estado', 'nueva')->count(),
            'nDesfase' => $instalaciones->whereIn('estado', ['sinaplicar', 'retro'])->count(),
            'licencias' => $licencias,
            'conInventario' => $licencias->isNotEmpty() ? $this->entornosConInventario() : [],
            'apagadosOcultos' => $this->apagadosOcultos(),
            'parecido' => $existe ? null : $this->parecido(),
        ])->layout('layouts.app');
    }
}
