<?php

namespace Tests\Feature\Environments;

use App\Livewire\Environments\Components\EnvironmentCard;
use App\Models\Auth\User;
use App\Models\Clients\Client;
use App\Models\Environments\Environment;
use App\Models\Environments\Type;
use App\Services\Moodle\MoodleSyncService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Tests\TestCase;

/**
 * Botón "Sincronizar ahora": el Manager pide al Moodle que se sincronice.
 *
 * Es la primera vez que el Manager llama HACIA FUERA, así que lo que se fija aquí no
 * es solo el camino feliz: es que un sitio caído, un token caducado o un servicio web
 * desactivado se cuenten de forma que soporte sepa qué hacer, y que nadie sin permiso
 * pueda disparar llamadas a los Moodles de los clientes.
 *
 * Los mensajes se comprueban contra el SERVICIO y no contra el componente: son suyos,
 * y las sesiones de los tests de Livewire no dejan leer el flash.
 */
class ManualSyncTest extends TestCase
{
    use RefreshDatabase;

    private const DOMAIN = 'https://sincroniza.test';

    protected function setUp(): void
    {
        parent::setUp();

        // Sin esto, un fallo del test saldría a internet de verdad.
        Http::preventStrayRequests();
    }

    private function usuario(bool $conPermiso = true, bool $conPermisoDeEdicion = false): User
    {
        foreach (['admin.environments.refresh', 'admin.environments.edit'] as $permiso) {
            Permission::firstOrCreate(
                ['name' => $permiso, 'guard_name' => 'web'],
                ['model' => 'environments', 'description' => $permiso]
            );
        }

        $user = User::factory()->create();

        if ($conPermiso) {
            $user->givePermissionTo('admin.environments.refresh');
        }

        if ($conPermisoDeEdicion) {
            $user->givePermissionTo('admin.environments.edit');
        }

        $this->actingAs($user);
        Cache::flush();

        return $user;
    }

    private function entorno(?string $moodletoken = 'ws-token-del-moodle'): array
    {
        $type = Type::create(['name' => 'Moodle', 'shortname' => 'moodle']);
        $client = Client::create(['name' => 'Cliente Sync', 'shortname' => 'sync', 'actived' => true]);

        $environment = Environment::create([
            'name' => 'Entorno Sync',
            'domain' => self::DOMAIN,
            'version' => '5.1.1',
            'env' => 'pro',
            'client_id' => $client->id,
            'type_id' => $type->id,
            'active' => true,
            'moodletoken' => $moodletoken,
        ]);

        return [$client, $environment];
    }

    private function lanzar(Environment $environment): array
    {
        return app(MoodleSyncService::class)->lanzar($environment);
    }

    /** Respuesta del web service de Moodle. */
    private function moodleResponde(array $cuerpo, int $status = 200): void
    {
        Http::fake([
            '*/webservice/rest/server.php' => Http::response($cuerpo, $status),
        ]);
    }

    /* ===================== El camino y sus errores ===================== */

    public function test_llama_al_web_service_del_plugin_con_su_token(): void
    {
        [, $environment] = $this->entorno();
        $this->moodleResponde(['success' => true, 'error' => '', 'data' => ['id' => 1]]);

        $resultado = $this->lanzar($environment);

        $this->assertTrue($resultado['ok'], $resultado['mensaje']);

        Http::assertSent(function ($request) {
            // La URL, la función y el token: los tres exactos o el Moodle no hace nada.
            return $request->url() === self::DOMAIN . '/webservice/rest/server.php'
                && $request['wsfunction'] === 'local_tresipunt_auth_sync'
                && $request['wstoken'] === 'ws-token-del-moodle'
                && $request['moodlewsrestformat'] === 'json';
        });

        // Queda registrado que se ha lanzado a mano: si un dato cambia justo después,
        // hay que poder saber quién lo pidió.
        $this->assertDatabaseHas('logs', ['code' => '16005']);
    }

    public function test_sin_token_de_servicios_web_no_llama_y_lo_explica(): void
    {
        [, $environment] = $this->entorno(moodletoken: null);

        $resultado = $this->lanzar($environment);

        Http::assertNothingSent();
        $this->assertFalse($resultado['ok']);
        $this->assertStringContainsString('no ha enviado su token de servicios web', $resultado['mensaje']);
    }

    public function test_un_moodle_caido_se_cuenta_como_tal(): void
    {
        [, $environment] = $this->entorno();

        Http::fake(function () {
            throw new \Illuminate\Http\Client\ConnectionException('cURL error 6: Could not resolve host');
        });

        $resultado = $this->lanzar($environment);

        $this->assertFalse($resultado['ok']);
        // El mensaje dice la causa concreta, no "no se ha podido contactar": ver
        // `explicarFalloDeConexion()`.
        $this->assertStringContainsString('no resuelve', $resultado['mensaje']);

        // Y queda registrado, que es lo que permite ver un patrón de sitios caídos.
        $this->assertDatabaseHas('logs', ['code' => '16006']);
    }

    public function test_un_token_caducado_se_explica_en_lugar_de_soltar_el_error_de_moodle(): void
    {
        // Moodle contesta 200 con este sobre: mirar solo el código HTTP daría el
        // fallo por bueno.
        [, $environment] = $this->entorno();

        $this->moodleResponde([
            'exception' => 'moodle_exception',
            'errorcode' => 'invalidtoken',
            'message' => 'Token de acceso no válido',
        ]);

        $resultado = $this->lanzar($environment);

        $this->assertFalse($resultado['ok']);
        $this->assertStringContainsString('token de servicios web ya no es válido', $resultado['mensaje']);
    }

    public function test_los_servicios_web_desactivados_se_explican(): void
    {
        [, $environment] = $this->entorno();

        $this->moodleResponde([
            'exception' => 'moodle_exception',
            'errorcode' => 'enablewsdescription',
            'message' => 'Web services are not enabled',
        ]);

        $this->assertSame(
            'Los servicios web están desactivados en ese Moodle.',
            $this->lanzar($environment)['mensaje']
        );
    }

    public function test_las_politicas_sin_aceptar_se_explican_y_no_se_confunden_con_el_token(): void
    {
        // **El caso que más se va a ver, y el que peor se leía.** Caía en el `default` y
        // salía «El Moodle ha rechazado la llamada (sitepolicynotagreed)», que manda a
        // soporte a revisar el token — cuando el token está perfectamente bien. Lo que
        // falla es el usuario: Moodle comprueba las políticas antes de ejecutar nada.
        //
        // Y no llega de uno en uno: el día que un cliente publica una política nueva,
        // todos sus usuarios quedan pendientes otra vez, el de servicios web incluido.
        [, $environment] = $this->entorno();

        $this->moodleResponde([
            'exception' => 'moodle_exception',
            'errorcode' => 'sitepolicynotagreed',
            'message' => 'You must agree to the site policy before continuing',
        ]);

        $resultado = $this->lanzar($environment);

        $this->assertFalse($resultado['ok']);
        $this->assertStringContainsString('no ha aceptado las políticas', $resultado['mensaje']);
        // Y se dice dónde se arregla, que está en el Moodle del cliente y no aquí.
        $this->assertStringContainsString('Acuerdos de usuario', $resultado['mensaje']);
        $this->assertStringNotContainsString('sitepolicynotagreed', $resultado['mensaje']);
    }

    public function test_un_usuario_de_servicios_web_incompleto_se_explica(): void
    {
        [, $environment] = $this->entorno();

        $this->moodleResponde([
            'exception' => 'moodle_exception',
            'errorcode' => 'usernotfullysetup',
            'message' => 'User not fully set-up',
        ]);

        $this->assertStringContainsString(
            'falta algún dato obligatorio del perfil',
            $this->lanzar($environment)['mensaje']
        );
    }

    public function test_si_el_plugin_rechaza_la_sincronizacion_se_dice_su_motivo(): void
    {
        [, $environment] = $this->entorno();

        $this->moodleResponde(['success' => false, 'error' => 'Invalid token for theme_fresk']);

        $this->assertStringContainsString(
            'Invalid token for theme_fresk',
            $this->lanzar($environment)['mensaje']
        );
    }

    public function test_una_respuesta_que_no_es_json_se_cuenta_sin_volcarla_en_pantalla(): void
    {
        [, $environment] = $this->entorno();

        // Un Moodle en mantenimiento devuelve HTML con estado 200.
        Http::fake([
            '*/webservice/rest/server.php' => Http::response('<html>En mantenimiento</html>', 200),
        ]);

        $resultado = $this->lanzar($environment);

        $this->assertStringContainsString('no es JSON', $resultado['mensaje']);
        // El HTML del sitio no se pinta en la interfaz; va al detalle, que es para el log.
        $this->assertStringNotContainsString('<html>', $resultado['mensaje']);
        $this->assertStringContainsString('<html>', (string) $resultado['detalle']);
    }

    public function test_un_error_http_del_moodle_se_cuenta_con_su_codigo(): void
    {
        [, $environment] = $this->entorno();

        $this->moodleResponde(['nada' => true], 503);

        $this->assertStringContainsString(
            'error HTTP 503',
            $this->lanzar($environment)['mensaje']
        );
    }

    public function test_dos_lanzamientos_seguidos_no_mandan_dos_veces_al_mismo_moodle(): void
    {
        [, $environment] = $this->entorno();

        $this->moodleResponde(['success' => true, 'error' => '']);

        $primero = $this->lanzar($environment);
        $segundo = $this->lanzar($environment);

        // El cerrojo dura el enfriamiento: la segunda no sale.
        $this->assertTrue($primero['ok']);
        $this->assertFalse($segundo['ok']);
        Http::assertSentCount(1);
        // El mensaje nombra **qué** se pidió: desde que el Manager también puede pedir los
        // datos de uso, «ya hay una en curso» no diría cuál de las dos.
        $this->assertStringContainsString('Ya se ha pedido la sincronización', $segundo['mensaje']);
    }

    public function test_se_puede_desactivar_por_configuracion(): void
    {
        // Si un día hay que cortar la salida a internet del Manager, se apaga sin
        // desplegar código.
        config(['moodle.sync.enabled' => false]);

        [, $environment] = $this->entorno();

        $resultado = $this->lanzar($environment);

        Http::assertNothingSent();
        // El interruptor es **uno para todo lo que sale del Manager**, no uno por función:
        // lo que se apaga es la salida a internet.
        $this->assertStringContainsString(
            'Las llamadas del Manager al Moodle están desactivadas',
            $resultado['mensaje']
        );
    }

    /* ===================== El botón ===================== */

    public function test_sin_permiso_no_se_puede_lanzar(): void
    {
        // MGR-005: el permiso de la ruta no se reaplica en /livewire/update, así que
        // sin el `authorize()` del método cualquier usuario autenticado podría hacer
        // que el Manager llamara a los Moodles de los clientes.
        $this->usuario(conPermiso: false);
        [$client, $environment] = $this->entorno();

        Livewire::test(EnvironmentCard::class, ['environment' => $environment, 'client' => $client])
            ->call('sincronizar')
            ->assertForbidden();

        Http::assertNothingSent();
    }

    public function test_con_permiso_y_token_el_boton_esta_disponible(): void
    {
        $this->usuario();
        [$client, $environment] = $this->entorno();

        $componente = Livewire::test(EnvironmentCard::class, [
            'environment' => $environment,
            'client' => $client,
        ]);

        $this->assertTrue($componente->instance()->puedeSincronizar);
        // El botón está FUERA del menú de la rueda: es la acción más usada de esta
        // pantalla y antes había que abrir un desplegable por tarjeta.
        $componente->assertSee('Sincronizar');
    }

    public function test_sin_token_el_boton_lleva_a_conectar(): void
    {
        $this->usuario(conPermisoDeEdicion: true);
        [$client, $environment] = $this->entorno(moodletoken: null);

        $componente = Livewire::test(EnvironmentCard::class, [
            'environment' => $environment,
            'client' => $client,
        ]);

        $this->assertFalse($componente->instance()->puedeSincronizar);

        // Antes esto era una opción en gris que explicaba por qué no se podía. Ahora
        // lleva a lo que hace falta antes: pegar el token del Moodle.
        //
        // El botón es solo icono —el diseño lo quiere así en una tabla densa—, así que
        // lo que se comprueba es el texto del tooltip y la acción, no una etiqueta.
        $componente->assertSee('Conectar: guardar el token');
        $componente->assertSeeHtml('wire:click="abrirConexionMoodle"');
    }

    public function test_sin_permiso_el_boton_no_se_pinta(): void
    {
        $this->usuario(conPermiso: false);
        [$client, $environment] = $this->entorno();

        Livewire::test(EnvironmentCard::class, ['environment' => $environment, 'client' => $client])
            ->assertDontSee('Sincronizar');
    }

    /* ===================== Los tres estados de sincronización ===================== */

    public function test_un_entorno_que_nunca_ha_sincronizado_lo_dice(): void
    {
        // «Nunca ha sincronizado» y «ha dejado de sincronizar» son cosas distintas: la
        // primera es un alta a medio terminar y la segunda una avería. Antes las dos
        // se veían igual —«Sin sincronizar»—, y la acción a tomar no se parece.
        $this->usuario();
        [$client, $environment] = $this->entorno();
        $environment->update(['refresh_at' => null]);

        // El literal se acortó a «No sincronizado» —la columna es estrecha y son 18 de
        // 20 filas—, pero lo que distingue los dos casos sigue dicho: la frase entera
        // está en el `title`, que es donde caben.
        Livewire::test(EnvironmentCard::class, ['environment' => $environment, 'client' => $client])
            ->assertSee('No sincronizado')
            ->assertSee('es un alta sin terminar');
    }

    public function test_un_entorno_al_dia_muestra_cuando_fue(): void
    {
        $this->usuario();
        [$client, $environment] = $this->entorno();
        $environment->update(['refresh_at' => now()->subHours(2)]);

        Livewire::test(EnvironmentCard::class, ['environment' => $environment, 'client' => $client])
            ->assertDontSee('Nunca sincronizado')
            ->assertDontSee('Sin señal desde hace');
    }

    public function test_un_entorno_que_ha_dejado_de_dar_senal_lo_dice_con_los_dias(): void
    {
        $this->usuario();
        [$client, $environment] = $this->entorno();
        $environment->update(['refresh_at' => now()->subDays(9)]);

        Livewire::test(EnvironmentCard::class, ['environment' => $environment, 'client' => $client])
            // Y el número sale en positivo: `diffInDays` va con signo en Carbon 3 y el
            // Dashboard ya dijo una vez "hace -20 días". Ver MGR-030.
            //
            // El texto lo fija el diseño de Claude Design (`Entornos.dc.html`): «Sin
            // señal · N día(s)», con el símbolo de aviso delante.
            ->assertSee('Sin señal · 9 día(s)');
    }
    /* ===================== Fallos de conexión, explicados ===================== */

    public function test_un_certificado_autofirmado_se_explica_como_tal(): void
    {
        // Caso real (2026-09-02): al pulsar «Sincronizar» en un Moodle local de Laragon
        // el mensaje era «No se ha podido contactar con el Moodle» —verdad e inútil—, y
        // el motivo real (cURL 60, certificado autofirmado) se quedaba en el log.
        $this->usuario();
        [, $environment] = $this->entorno();

        Http::fake(function () {
            throw new ConnectionException(
                'cURL error 60: SSL certificate problem: self-signed certificate '
                . '(see https://curl.haxx.se/libcurl/c/libcurl-errors.html) '
                . 'for https://sincroniza.test/webservice/rest/server.php'
            );
        });

        $resultado = $this->lanzar($environment);

        $this->assertFalse($resultado['ok']);
        $this->assertStringContainsString('certificado HTTPS', $resultado['mensaje']);
        // Y dice que NO es culpa del token, que es la conclusión equivocada más fácil.
        $this->assertStringContainsString('no es un problema del plugin ni del token', mb_strtolower($resultado['mensaje']));
    }

    public function test_un_dominio_que_no_resuelve_se_explica(): void
    {
        $this->usuario();
        [, $environment] = $this->entorno();

        Http::fake(function () {
            throw new ConnectionException('cURL error 6 : Could not resolve host: no-existe.test');
        });

        $resultado = $this->lanzar($environment);

        $this->assertStringContainsString('no resuelve', $resultado['mensaje']);
    }

    public function test_una_conexion_rechazada_se_explica(): void
    {
        $this->usuario();
        [, $environment] = $this->entorno();

        Http::fake(function () {
            throw new ConnectionException('cURL error 7 : Failed to connect: Connection refused');
        });

        $resultado = $this->lanzar($environment);

        $this->assertStringContainsString('rechaza la conexión', $resultado['mensaje']);
    }

    public function test_el_detalle_tecnico_viaja_con_el_resultado(): void
    {
        // El mensaje corto dice qué pasa y el detalle dice dónde mirar. Sin el detalle,
        // el motivo se queda en `storage/logs/api-*.log`, que nadie abre.
        $this->usuario();
        [, $environment] = $this->entorno();

        Http::fake(function () {
            throw new ConnectionException('cURL error 60: SSL certificate problem');
        });

        $resultado = $this->lanzar($environment);

        $this->assertStringContainsString('cURL error 60', (string) $resultado['detalle']);
    }

    public function test_por_defecto_se_verifica_el_certificado(): void
    {
        // El valor seguro es el de fábrica: lo que se manda en esa llamada es el token
        // de administración del Moodle de un cliente.
        $this->assertTrue((bool) config('moodle.sync.verify_ssl'));
    }
    /* ===================== El fallo se ve ===================== */

    public function test_un_fallo_abre_una_modal_con_el_motivo(): void
    {
        // Lo que pasó de verdad: el fallo se contaba con un flash de error y la pantalla
        // de entornos solo pintaba el de éxito, así que pulsar «Sincronizar» y que
        // fallara se veía IGUAL que si el botón no hiciera nada. Un banner tampoco vale:
        // arriba de una lista larga de tarjetas se pierde.
        $this->usuario();
        [$client, $environment] = $this->entorno();

        Http::fake(function () {
            throw new ConnectionException('cURL error 60: SSL certificate problem: self-signed certificate');
        });

        Livewire::test(EnvironmentCard::class, ['environment' => $environment, 'client' => $client])
            ->call('sincronizar')
            ->assertDispatched('openModal', function (string $evento, array $datos) {
                $argumentos = $datos['arguments'] ?? [];

                return ($datos['component'] ?? null) === \App\Livewire\Environments\SyncResultModal::class
                    // El mensaje traducido y el detalle técnico, los dos: uno dice qué
                    // pasa y el otro dónde mirar.
                    && str_contains($argumentos['mensaje'] ?? '', 'certificado HTTPS')
                    && str_contains($argumentos['detalle'] ?? '', 'cURL error 60');
            });
    }

    public function test_una_sincronizacion_correcta_no_abre_ninguna_modal(): void
    {
        $this->usuario();
        [$client, $environment] = $this->entorno();

        Http::fake(['*' => Http::response(['success' => true, 'data' => []], 200)]);

        Livewire::test(EnvironmentCard::class, ['environment' => $environment, 'client' => $client])
            ->call('sincronizar')
            ->assertNotDispatched('openModal');
    }
}
