<?php

namespace App\Services\Api\Actions;

use App\Models\Environments\Data;
use App\Models\Environments\Environment;
use App\Models\Environments\Plugin;
use App\Models\Environments\Update;
use App\Models\Environments\Type;
use App\Services\Api\Actions\Concerns\LogsPluginFailures;
use App\Services\Api\HostNormalizer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class SyncAction implements ActionInterface
{
    use LogsPluginFailures;

    /**
     * Filas por sentencia al guardar el inventario de plugins.
     *
     * 500 es el equilibrio habitual: baja las sentencias de 436 a una para un Moodle
     * normal, y deja la sentencia lo bastante corta para no acercarse al
     * `max_allowed_packet` de MariaDB ni al límite de marcadores de posición del
     * driver, que es donde duele pasarse.
     */
    private const PLUGINS_POR_LOTE = 500;

    public function execute(Request $request, array $payload): array
    {
        $environmentData = $payload['environment'] ?? $payload['site'] ?? [];
        $projectId = $payload['projectid'] ?? null;
        $pluginsList = $payload['data']['plugins'] ?? [];

        /** @var Environment $environment */
        $environment = $request->input('_environment');

        if (!$environment) {
            throw new \App\Services\Api\Exceptions\EnvironmentNotFoundException();
        }

        $processed = 0;
        $errors = [];

        DB::transaction(function () use ($request, $environment, $environmentData, $projectId, $pluginsList, &$processed, &$errors) {
            $environment = Environment::where('id', $environment->id)->lockForUpdate()->first();
            if (!$environment) {
                throw new \App\Services\Api\Exceptions\EnvironmentNotFoundException('Environment not found');
            }

            $normalizedHost = $request->input('_normalized_host');
            if ($normalizedHost === null) {
                $normalizedHost = HostNormalizer::normalize($request->input('host', ''));
            }

            $type = null;
            if (!empty($environmentData['type'])) {
                $type = Type::where('shortname', $environmentData['type'])->first();
            }

            // PRODSECU-152: el panel muestra `environments.version` y debe
            // contener el release humano formateado (p. ej. "5.1.1"), no el
            // versiondb numérico ni la cadena cruda de Moodle
            // ("5.1.1+ (Build: 20251219)"). Tomamos `release` del payload, lo
            // normalizamos y si llega ausente o vacío se conserva el valor
            // previo para no romper entornos creados a mano.
            $normalizedRelease = isset($environmentData['release'])
                ? $this->normalizeRelease((string) $environmentData['release'])
                : '';
            $release = $normalizedRelease !== '' ? $normalizedRelease : $environment->version;

            // `availableupdates` del core de Moodle viaja dentro del subárbol
            // del sitio. Aceptamos también el top-level del payload por
            // compatibilidad con clientes antiguos.
            $availableUpdates = $environmentData['availableupdates']
                ?? $payload['availableupdates']
                ?? null;

            $lastVersion = $this->getLastVersion($availableUpdates);
            $lastMinor = $this->getLastMinor($availableUpdates, $release);

            // `token` y `key` se tratan como ausentes si llegan vacíos (null,
            // "", 0 o false), para no sobrescribir valores válidos previos con
            // basura que a veces emite el cliente cuando no los tiene configurados.
            $tokenValue = $environmentData['token'] ?? null;
            $moodletoken = !empty($tokenValue) ? (string) $tokenValue : $environment->moodletoken;

            // `key` (clave Jira) viaja en `site.key` en el cliente actual.
            // Mantenemos `projectid` top-level como fallback por compatibilidad
            // con clientes antiguos.
            $keyValue = $environmentData['key'] ?? $projectId ?? null;
            $key = !empty($keyValue) ? (string) $keyValue : $environment->key;

            // **El versiondb del core, que hasta ahora se tiraba.** El contrato dice
            // que el sitio manda `site.version` —el número exacto del código, p. ej.
            // `2025100601.03`— junto a `site.release`, y aquí solo se leía la release.
            // Llegaba en cada sincronización y se descartaba, así que el panel no podía
            // enseñarlo. Si no viene, se conserva el que hubiera: un plugin antiguo que
            // no lo manda no debe borrar el último que sí llegó.
            $versiondbRecibido = $environmentData['version'] ?? null;
            $versiondb = $versiondbRecibido !== null && trim((string) $versiondbRecibido) !== ''
                ? trim((string) $versiondbRecibido)
                : $environment->versiondb;

            $environmentAttributes = [
                'version' => $release,
                'versiondb' => $versiondb,
                'env' => $environmentData['env'] ?? $environment->env,
                'lastversion' => $lastVersion ?? $environment->lastversion,
                'lastminor' => $lastMinor ?? $environment->lastminor,
                'moodletoken' => $moodletoken,
                'type_id' => $type?->id ?? $environment->type_id,
                'key' => $key,
                'refresh_at' => now(),
            ];

            if ($normalizedHost !== '') {
                $environmentAttributes['domain'] = $normalizedHost;
            }

            $environment->update($environmentAttributes);

            $dataAttributes = [];
            if (array_key_exists('release', $environmentData)) {
                $dataAttributes['moodlerelease'] = $environmentData['release'];
            }
            if (array_key_exists('availableupdatesfetch', $environmentData)) {
                $dataAttributes['availableupdatesfetch'] = (string) $environmentData['availableupdatesfetch'];
            }
            if ($dataAttributes !== []) {
                Data::updateOrCreate(
                    ['environment_id' => $environment->id],
                    $dataAttributes
                );
            }

            Plugin::where('environment_id', $environment->id)->forceDelete();

            $processed += $this->guardarInventario($environment, $pluginsList, $errors);

            // **Y las releases de core disponibles, que hasta ahora se tiraban.** Llegaban
            // en cada `sync` dentro de `availableupdates.core` y solo se leían para deducir
            // `lastversion` y `lastminor`: se perdía qué releases concretas hay, su madurez
            // y de dónde se descargan. Ver `guardarUpdates()`.
            $this->guardarUpdates($environment, $availableUpdates);
        });

        $environment->refresh();
        $environment->load('type');

        return [
            'id' => $environment->id,
            'name' => $environment->name,
            'domain' => $environment->domain,
            'version' => $environment->version,
            'env' => $environment->env,
            'type' => $environment->type?->shortname,
            'lastversion' => $environment->lastversion,
            'lastminor' => $environment->lastminor,
            'active' => $environment->active,
            'has_support' => $environment->has_support,
            'plugins_count' => $processed,
            'processed' => $processed,
            'total' => count($pluginsList),
            'errors' => $errors,
        ];
    }

    /**
     * Guarda las releases de core que ese Moodle dice tener disponibles.
     *
     * **Es el módulo `updates` del Manager anterior, retomado.** Allí era una fila por
     * release disponible con su madurez y su descarga; aquí la tabla existía con las mismas
     * columnas y **vacía**, porque se descartó escribirla mientras no hubiera pantalla
     * (PRODSECU-152). Ya la hay: la pestaña de versiones de Moodle del parque.
     *
     * **Se reemplaza en bloque, igual que el inventario de plugins**, y por el mismo motivo:
     * esto no es un histórico, es **la foto de lo que Moodle ofrece ahora**. Una release que
     * ya no aparece es una release que ya no está disponible —se publicó una nueva menor, o
     * se retiró—, y conservarla ofrecería una descarga que puede no existir.
     *
     * Y se borra con `forceDelete()`: la tabla tiene borrado lógico, pero un `delete()` aquí
     * apilaría una generación de filas muertas por sitio y por sincronización, que son
     * diarias. Nadie lee esas filas: el histórico de qué core estuvo disponible no lo
     * necesitamos, y si algún día se necesita se guarda a propósito y no como resto.
     *
     * @param  array<string, mixed>|null  $availableUpdates  El subárbol tal y como llega.
     */
    private function guardarUpdates(Environment $environment, ?array $availableUpdates): void
    {
        Update::where('environment_id', $environment->id)->forceDelete();

        $delCore = $availableUpdates['core'] ?? null;

        if (! is_array($delCore) || $delCore === []) {
            return;
        }

        $ahora = now();
        $filas = [];

        foreach ($delCore as $item) {
            if (! is_array($item) || ! isset($item['version'])) {
                // Sin `version` no hay fila que valga: es lo único que identifica una
                // release en la tabla, y el resto del subárbol es opcional en el payload.
                continue;
            }

            $filas[] = [
                'environment_id' => $environment->id,
                'version' => (string) $item['version'],
                // La release se normaliza igual que la del sitio —«5.1.1+ (Build: …)» pasa a
                // «5.1.1»—, para que las dos se puedan comparar entre sí.
                'release' => isset($item['release'])
                    ? $this->normalizeRelease((string) $item['release'])
                    : null,
                'maturity' => isset($item['maturity']) ? (int) $item['maturity'] : null,
                'url' => isset($item['url']) ? (string) $item['url'] : null,
                'download' => isset($item['download']) ? (string) $item['download'] : null,
                'downloadmd5' => isset($item['downloadmd5']) ? (string) $item['downloadmd5'] : null,
                'created_at' => $ahora,
                'updated_at' => $ahora,
            ];
        }

        if ($filas !== []) {
            // De una en una no: son pocas filas, pero esto corre dentro del `sync`, que
            // tiene cuatro segundos de tope en el cliente (ver el docblock de la clase).
            Update::insert($filas);
        }
    }

    /**
     * Guarda el inventario de plugins del entorno y devuelve cuántos se han guardado.
     *
     * **Por qué por lotes.** Antes esto era un `Plugin::create()` por plugin: con los
     * 436 plugins de un Moodle real son **436 sentencias INSERT**, medidas en 1,4-2,8
     * segundos en local. El cliente corta a los **4 segundos** (`tip::TIMEOUT` de
     * `local_tresipunt`), así que un sitio grande, con red por medio y una base de
     * datos con carga, se pasaba: el Manager terminaba el trabajo pero el Moodle daba
     * la petición por fallida y la repetía. Ver known-issues MGR-037.
     *
     * **Lo que se conserva del comportamiento anterior**, porque importa: un plugin
     * con datos malos no puede tumbar la sincronización entera. Antes eso salía gratis
     * porque cada fila iba en su propia sentencia. Con lotes, un solo valor inválido
     * haría fallar las 500 filas del lote. Se resuelve en dos pasos:
     *
     *   1. Las filas se validan **en PHP** antes de tocar la base, así que el caso
     *      normal —un plugin sin `name` ni `component`— se aparta sin coste.
     *   2. Si un lote falla en la base de todas formas, se reintenta **fila por fila**
     *      para aislar la culpable. La vía lenta solo se paga cuando ya ha pasado algo
     *      raro, no en cada sincronización.
     *
     * @param  array<int, array>  $pluginsList
     * @param  array<int, array>  $errors  se le añaden los plugins que no se han podido guardar
     */
    private function guardarInventario(Environment $environment, array $pluginsList, array &$errors): int
    {
        $filas = [];

        foreach ($pluginsList as $pluginData) {
            $fila = $this->filaDePlugin($environment, $pluginData);

            if ($fila === null) {
                // Sin `name` ni `component` no hay plugin que registrar. Se mantiene la
                // excepción para que el registro del fallo sea el mismo de siempre.
                $errors[] = $this->registrarPluginFallido(
                    $environment,
                    $pluginData,
                    new \InvalidArgumentException('El plugin debe tener al menos name o component')
                );

                continue;
            }

            $filas[] = $fila;
        }

        $guardados = 0;

        foreach (array_chunk($filas, self::PLUGINS_POR_LOTE) as $lote) {
            try {
                Plugin::insert($lote);
                $guardados += count($lote);
            } catch (\Throwable $e) {
                $guardados += $this->guardarUnaAUna($environment, $lote, $errors);
            }
        }

        return $guardados;
    }

    /**
     * Vía lenta: inserta las filas de un lote de una en una para que una fila mala no
     * se lleve por delante a las otras 499. Solo se llega aquí si el lote ha fallado.
     */
    private function guardarUnaAUna(Environment $environment, array $lote, array &$errors): int
    {
        $guardados = 0;

        foreach ($lote as $fila) {
            try {
                Plugin::insert([$fila]);
                $guardados++;
            } catch (\Throwable $e) {
                $errors[] = $this->registrarPluginFallido($environment, $fila, $e);
            }
        }

        return $guardados;
    }

    /**
     * Convierte un plugin del payload en una fila lista para insertar, o `null` si no
     * tiene ni `name` ni `component`.
     *
     * Se construye la fila a mano —y no con `Plugin::create()`— porque `insert()` no
     * pasa por el modelo: hay que codificar el JSON de `dependencies` y
     * `availableupdates` (que el modelo casteaba con `array`), poner los booleanos
     * como 0/1 y rellenar las marcas de tiempo, que Eloquent ya no añade.
     */
    private function filaDePlugin(Environment $environment, array $pluginData): ?array
    {
        $name = $pluginData['name'] ?? '';
        $component = $pluginData['component'] ?? '';

        if ($name === '' && $component === '') {
            return null;
        }

        $versionRequires = $pluginData['versionrequires'] ?? 0;
        $versiondisk = $pluginData['versiondisk'] ?? $pluginData['version'] ?? '0';
        $versiondb = $pluginData['versiondb'] ?? $pluginData['version'] ?? '0';

        $dependencies = $pluginData['dependencies'] ?? null;
        $availableupdates = $pluginData['availableupdates'] ?? null;

        $ahora = now();

        return [
            'environment_id' => $environment->id,
            'name' => $name !== '' ? $name : $component,
            'component' => $component !== '' ? $component : $name,
            'type' => $pluginData['type'] ?? '',
            'versiondisk' => (string) $versiondisk,
            'versiondb' => (string) $versiondb,
            'versionrequires' => (int) $versionRequires,
            'pluginsupported' => $pluginData['pluginsupported'] ?? null,
            'pluginincompatible' => $pluginData['pluginincompatible'] ?? null,
            'release' => $pluginData['release'] ?? null,
            'has_dependencies' => !empty($dependencies),
            'dependencies' => $dependencies !== null ? json_encode($dependencies) : null,
            'has_updates' => !empty($availableupdates),
            'availableupdates' => $availableupdates !== null ? json_encode($availableupdates) : null,
            'created_at' => $ahora,
            'updated_at' => $ahora,
        ];
    }

    /**
     * Normaliza una cadena de release de Moodle. Convierte por ejemplo
     * "5.1.1+ (Build: 20251219)" en "5.1.1": coge la primera parte antes
     * del espacio y elimina el `+` final que indica build de desarrollo.
     * Ver fixes/PRODSECU-152.
     */
    private function normalizeRelease(string $release): string
    {
        $parts = explode(' ', trim($release));

        return str_replace('+', '', $parts[0]);
    }

    /**
     * Mayor release disponible en `availableupdates.core` (sin filtro de major).
     * Replica la lógica del Manager anterior. Ver fixes/PRODSECU-152.
     */
    private function getLastVersion(?array $updates): ?string
    {
        if (empty($updates['core']) || !is_array($updates['core'])) {
            return null;
        }

        $maxValue = null;
        foreach ($updates['core'] as $item) {
            if (!isset($item['release'])) {
                continue;
            }
            $val = $this->normalizeRelease((string) $item['release']);
            if ($maxValue === null || version_compare($val, $maxValue, '>=')) {
                $maxValue = $val;
            }
        }

        return $maxValue;
    }

    /**
     * Mayor release del mismo major que la release actual del Moodle.
     * Considera "mismo major" cuando coinciden los dos primeros componentes
     * (p. ej. "5.1.1" y "5.1.4" son mismo major; "5.1.1" y "5.2" no).
     * Entre los del mismo major, devuelve el mayor por version_compare.
     * Ver fixes/PRODSECU-152.
     */
    private function getLastMinor(?array $updates, ?string $moodleRelease): ?string
    {
        if (empty($updates['core']) || !is_array($updates['core']) || $moodleRelease === null || $moodleRelease === '') {
            return null;
        }

        $currentMajor = $this->extractMajor($this->normalizeRelease($moodleRelease));

        $maxMinor = null;
        foreach ($updates['core'] as $item) {
            if (!isset($item['release'])) {
                continue;
            }
            $val = $this->normalizeRelease((string) $item['release']);

            if ($this->extractMajor($val) !== $currentMajor) {
                continue;
            }

            if ($maxMinor === null || version_compare($val, $maxMinor, '>=')) {
                $maxMinor = $val;
            }
        }

        return $maxMinor;
    }

    /**
     * Extrae el "major.minor" de una release ya normalizada. Para "5.1.1"
     * devuelve "5.1"; para "5.2" devuelve "5.2"; para "10.0.3" devuelve
     * "10.0". Robusto frente a versiones futuras con majors de dos dígitos.
     */
    private function extractMajor(string $release): string
    {
        $parts = explode('.', $release);

        return ($parts[0] ?? '0') . '.' . ($parts[1] ?? '0');
    }
}
