<?php

namespace App\Services\Moodle;

use App\Models\Environments\Environment;
use App\Models\Monitoring\Log as MonitoringLog;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

/**
 * Lanza la sincronización de un entorno DESDE el Manager.
 *
 * Hasta ahora el tráfico era de una sola dirección: el Moodle empuja
 * (`action = sync`) cuando su tarea programada le toca, y el Manager espera. Si un
 * dato estaba desfasado había que esperar a la siguiente pasada o pedirle al cliente
 * que entrara en su Moodle.
 *
 * El camino de vuelta ya estaba construido en el plugin y sin usar:
 *
 *   1. `local_tresipunt` **expone** la función de web service
 *      `local_tresipunt_auth_sync` (`db/services.php` → `auth_external::sync()` →
 *      `product_service::sync()`), que es exactamente la misma sincronización que
 *      corre sola.
 *   2. El Manager **ya guarda** el token de web service de cada Moodle en
 *      `environments.moodletoken`: llega dentro del propio `sync`, en `site.token`.
 *
 * O sea que el recorrido completo es Manager → Moodle → Manager: pedimos al Moodle
 * que se sincronice, el Moodle recoge sus datos y llama a nuestra API, y la fila del
 * entorno queda actualizada. Nosotros no escribimos nada aquí: quien escribe sigue
 * siendo `SyncAction`, con las mismas validaciones y el mismo registro.
 *
 * Lo que este servicio NO hace, a propósito:
 *
 * - **No inventa un camino alternativo de escritura.** Si la sincronización del
 *   Moodle falla, falla igual que cuando la lanza su propia tarea.
 * - **No funciona sin `moodletoken`.** Un entorno que nunca ha sincronizado con una
 *   versión del plugin que envíe el token no se puede empujar, y hay que decirlo en
 *   la interfaz en vez de fallar.
 */
class MoodleSyncService
{
    /**
     * Pide al Moodle que sincronice su inventario de plugins.
     *
     * @return array{ok: bool, mensaje: string, detalle: ?string}
     */
    public function lanzar(Environment $environment): array
    {
        return $this->pedir($environment, QueSeLePide::sincronizacion());
    }

    /**
     * Pide al Moodle que envíe su foto de uso —usuarios, cursos, matrículas—.
     *
     * **Lo que devuelve no son los datos.** El Moodle contesta que acepta el encargo y
     * acto seguido nos los manda **en una llamada aparte** a nuestra propia API, igual que
     * hace su tarea nocturna. Así que «ha respondido correctamente» quiere decir «los va a
     * mandar», y quien llame a esto tiene que recargar para verlos.
     *
     * @return array{ok: bool, mensaje: string, detalle: ?string}
     */
    public function pedirDatosDeUso(Environment $environment): array
    {
        return $this->pedir($environment, QueSeLePide::datosDeUso());
    }

    /**
     * El cuerpo común: comprobar que se puede llamar, y llamar una sola vez.
     *
     * @return array{ok: bool, mensaje: string, detalle: ?string}
     */
    private function pedir(Environment $environment, QueSeLePide $que): array
    {
        if (!config('moodle.sync.enabled')) {
            // **Un solo interruptor para todo lo que sale del Manager**, y no uno por
            // función: lo que se apaga con esto es la salida a internet, y el día que haya
            // que cortarla no se puede depender de acordarse de apagar tres cosas.
            return $this->fallo('Las llamadas del Manager al Moodle están desactivadas por configuración.');
        }

        $token = trim((string) $environment->moodletoken);

        if ($token === '') {
            // No es un error de red ni una avería: es que ese sitio nunca nos ha dado
            // su token. Se explica, porque la acción a tomar es distinta.
            return $this->fallo($que->sinToken);
        }

        $dominio = rtrim((string) $environment->domain, '/');

        if ($dominio === '') {
            return $this->fallo('El entorno no tiene dominio.');
        }

        // Cerrojo por entorno **y por lo que se pide**: evita que dos clics seguidos
        // —o dos personas a la vez— manden al mismo Moodle a hacer el mismo trabajo dos
        // veces. No es un límite de abuso, es cortesía con el sitio del cliente.
        //
        // Y separado por acción a propósito: pedir los datos de uso no puede quedar
        // bloqueado porque alguien acabe de lanzar una sincronización, que es otro
        // trabajo distinto en el Moodle.
        $cerrojo = Cache::lock(
            'moodle:' . $que->clave . ':' . $environment->id,
            config('moodle.sync.cooldown_seconds')
        );

        if (!$cerrojo->get()) {
            return $this->fallo(
                'Ya se ha pedido ' . $que->nombre . ' para este entorno hace unos segundos. Espera un momento.'
            );
        }

        try {
            return $this->llamar($environment, $dominio, $token, $que);
        } finally {
            // El cerrojo NO se libera: su tiempo de vida es el enfriamiento. Si se
            // liberase al terminar, un doble clic rápido pasaría los dos.
        }
    }

    private function llamar(Environment $environment, string $dominio, string $token, QueSeLePide $que): array
    {
        $url = $dominio . '/webservice/rest/server.php';

        try {
            $respuesta = Http::asForm()
                ->timeout(config('moodle.sync.timeout'))
                ->connectTimeout(config('moodle.sync.connect_timeout'))
                // Verificar el certificado es el valor por defecto y el de producción;
                // se puede apagar por `.env` para los certificados autofirmados de un
                // entorno local. Ver `config/moodle.php`.
                ->withOptions(['verify' => (bool) config('moodle.sync.verify_ssl', true)])
                ->post($url, [
                    'wstoken' => $token,
                    'wsfunction' => $que->funcion,
                    'moodlewsrestformat' => 'json',
                ]);
        } catch (\Throwable $e) {
            // Sitio caído, DNS que no resuelve, certificado, cortafuegos… El Manager
            // llama hacia fuera y eso puede fallar de muchas maneras que no son
            // culpa nuestra ni del plugin.
            $detalle = $e::class . ': ' . $e->getMessage();

            return $this->registrarFallo(
                $environment,
                $que,
                $this->explicarFalloDeConexion($detalle),
                $detalle
            );
        }

        if ($respuesta->failed()) {
            return $this->registrarFallo(
                $environment,
                $que,
                'El Moodle ha respondido con un error HTTP ' . $respuesta->status() . '.',
                mb_substr($respuesta->body(), 0, 500)
            );
        }

        $cuerpo = $respuesta->json();

        if (!is_array($cuerpo)) {
            return $this->registrarFallo(
                $environment,
                $que,
                'El Moodle ha respondido algo que no es JSON. Comprueba que los servicios web están activados.',
                mb_substr($respuesta->body(), 0, 500)
            );
        }

        // Moodle contesta 200 con este sobre cuando el token no vale, el servicio está
        // desactivado o el usuario no tiene la capacidad. Mirar solo el código HTTP
        // daría todo esto por bueno.
        if (isset($cuerpo['exception']) || isset($cuerpo['errorcode'])) {
            return $this->registrarFallo(
                $environment,
                $que,
                $this->explicarErrorDeMoodle((string) ($cuerpo['errorcode'] ?? '')),
                json_encode($cuerpo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
            );
        }

        // El plugin devuelve su propio sobre: `success`, `error`, `code`, `data`.
        if (empty($cuerpo['success'])) {
            return $this->registrarFallo(
                $environment,
                $que,
                'El Moodle ha rechazado ' . $que->nombre . ': ' . ($cuerpo['error'] ?: 'sin detalle'),
                json_encode($cuerpo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
            );
        }

        MonitoringLog::db(
            'info',
            $que->codigoOk,
            'Pedido al Moodle de ' . $environment->domain . ': ' . $que->nombre
            . ' (desde el Manager)',
            'Environment',
            (string) $environment->id
        );

        return [
            'ok' => true,
            'mensaje' => $que->exito,
            'detalle' => null,
        ];
    }

    /**
     * Traduce un fallo de conexión a algo con lo que se pueda actuar.
     *
     * «No se ha podido contactar con el Moodle» es verdad y no sirve de nada: la causa
     * está en el mensaje de cURL, que nadie va a leer en `storage/logs`. Y las cuatro
     * causas reales piden cosas distintas —una es del cliente, otra del DNS, otra del
     * certificado y otra de nuestro propio servidor—.
     *
     * El caso del certificado apareció el primer día en local: Laragon sirve los `.dev`
     * con un certificado autofirmado y el error decía solo «no se ha podido contactar».
     */
    private function explicarFalloDeConexion(string $detalle): string
    {
        return match (true) {
            // 60 y 51: la cadena de certificados no valida. Es lo que pasa con un
            // certificado autofirmado o con una CA interna que este servidor no conoce.
            str_contains($detalle, 'cURL error 60'), str_contains($detalle, 'cURL error 51') =>
                'El certificado HTTPS de ese Moodle no es de confianza para este servidor '
                . '(autofirmado o de una CA interna). No es un problema del plugin ni del '
                . 'token.',

            // 6: el nombre no resuelve. Dominio mal escrito, o un dominio interno.
            str_contains($detalle, 'cURL error 6 ') , str_contains($detalle, 'Could not resolve host') =>
                'El dominio de este entorno no resuelve desde el Manager. Comprueba que está '
                . 'bien escrito y que es accesible desde aquí.',

            // 7: hay DNS pero nadie escucha. Sitio apagado o puerto cerrado.
            str_contains($detalle, 'cURL error 7 '), str_contains($detalle, 'Connection refused') =>
                'El servidor de ese Moodle rechaza la conexión: puede estar apagado o con el '
                . 'puerto cerrado al Manager.',

            // 28: ha aceptado la conexión y no ha contestado a tiempo.
            str_contains($detalle, 'cURL error 28'), str_contains($detalle, 'Operation timed out') =>
                'El Moodle ha tardado más de lo permitido en responder. Si el sitio es muy '
                . 'grande, la sincronización puede necesitar más tiempo del configurado.',

            default => 'No se ha podido contactar con el Moodle.',
        };
    }

    /**
     * Traduce los `errorcode` de Moodle que se van a ver de verdad.
     *
     * Se traducen porque el mensaje crudo de Moodle no dice qué hacer, y quien mira
     * esta pantalla es soporte, no un desarrollador de Moodle.
     */
    private function explicarErrorDeMoodle(string $errorcode): string
    {
        return match ($errorcode) {
            'invalidtoken', 'accessexception' =>
                'El token de servicios web ya no es válido en ese Moodle. Se renovará solo en la próxima '
                . 'sincronización del sitio, o hay que revisar el plugin allí.',
            'enablewsdescription', 'servicenotavailable' =>
                'Los servicios web están desactivados en ese Moodle.',
            'accessdenied', 'nopermissions', 'requiredcapability' =>
                'El usuario de servicios web del Moodle no tiene permiso para sincronizar. '
                . 'Revisar el plugin en el sitio.',
            // **El token es válido; la cuenta no puede actuar.** Moodle comprueba las
            // políticas del sitio antes de ejecutar nada, así que el usuario de servicios
            // web queda bloqueado igual que cualquier otro. Y no llega de uno en uno: el
            // día que un cliente activa las políticas —o publica una versión nueva— todos
            // sus usuarios quedan pendientes otra vez, el de servicios web incluido.
            'sitepolicynotagreed' =>
                'El usuario de servicios web de ese Moodle no ha aceptado las políticas del '
                . 'sitio. El token es correcto: lo que falta es esa aceptación. Un administrador '
                . 'del sitio puede consentir en su nombre desde Usuarios → Privacidad y políticas '
                . '→ Acuerdos de usuario. Volverá a pasar cada vez que publiquen una política nueva.',
            // Misma familia: credencial buena, cuenta incompleta.
            'usernotfullysetup' =>
                'Al usuario de servicios web de ese Moodle le falta algún dato obligatorio del '
                . 'perfil (nombre o correo). Hay que completarlo en el sitio.',
            'invalidrecord', 'dmlreadexception' =>
                'El Moodle ha dado un error interno al sincronizar.',
            default => 'El Moodle ha rechazado la llamada'
                . ($errorcode !== '' ? ' (' . $errorcode . ')' : '') . '.',
        };
    }

    /**
     * El detalle técnico va al log; al usuario, la frase que le dice qué hacer.
     */
    private function registrarFallo(
        Environment $environment,
        QueSeLePide $que,
        string $mensaje,
        string $detalle
    ): array {
        Log::channel('api')->warning('Llamada del Manager al Moodle fallida', [
            'environment_id' => $environment->id,
            'domain' => $environment->domain,
            'ws' => $que->funcion,
            'mensaje' => $mensaje,
            'detalle' => $detalle,
        ]);

        MonitoringLog::db(
            'warning',
            $que->codigoFallo,
            'No se ha podido pedir ' . $que->nombre . ' a ' . $environment->domain . ': ' . $mensaje,
            'Environment',
            (string) $environment->id
        );

        return ['ok' => false, 'mensaje' => $mensaje, 'detalle' => $detalle];
    }

    private function fallo(string $mensaje): array
    {
        return ['ok' => false, 'mensaje' => $mensaje, 'detalle' => null];
    }
}
