<?php

namespace Tests\Feature\Configurations;

use App\Livewire\Configurations\ApiTokens;
use App\Livewire\Users\Index as UsersIndex;
use App\Models\Auth\User;
use App\Models\Monitoring\Log as MonitoringLog;
use App\Support\ApiDeAdministracion;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Laravel\Sanctum\PersonalAccessToken;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/**
 * Tokens de la API de administración — Configuración (PRODSECU-215).
 *
 * Lo que se fija: que **solo quien tiene el permiso** emite y revoca tokens —también
 * llamando al método de Livewire a pelo, que no pasa por el gate de la ruta (MGR-005)—,
 * que cada token lleva **exactamente** los permisos marcados, que cuelgan del usuario de
 * servicio y no de quien los crea, y que **el token en claro no acaba en el log**.
 */
class ApiTokensTest extends TestCase
{
    use RefreshDatabase;

    private const PERMISOS_DE_LA_PANTALLA = [
        'admin.api-tokens.index',
        'admin.api-tokens.create',
        'admin.api-tokens.destroy',
    ];

    /**
     * Un usuario con los permisos indicados de la pantalla, ya autenticado.
     *
     * @param  array<int, string>  $permisos
     */
    private function usuarioCon(array $permisos): User
    {
        $rol = Role::create(['name' => 'Rol-' . uniqid(), 'guard_name' => 'web']);

        foreach ($permisos as $nombre) {
            $rol->givePermissionTo(Permission::firstOrCreate(
                ['name' => $nombre, 'guard_name' => 'web'],
                ['model' => 'api_tokens', 'description' => $nombre]
            ));
        }

        // Que existan todos aunque el rol no los tenga: `@can` con un permiso que no
        // existe lanza en vez de decir que no.
        foreach (self::PERMISOS_DE_LA_PANTALLA as $nombre) {
            Permission::firstOrCreate(
                ['name' => $nombre, 'guard_name' => 'web'],
                ['model' => 'api_tokens', 'description' => $nombre]
            );
        }

        $user = User::factory()->create(['name' => 'Ana Admin']);
        $user->assignRole($rol);
        $this->actingAs($user);

        Cache::flush();

        return $user;
    }

    private function admin(): User
    {
        return $this->usuarioCon(self::PERMISOS_DE_LA_PANTALLA);
    }

    private function tokenDeServicio(string $nombre = 'Script de pruebas'): PersonalAccessToken
    {
        return ApiDeAdministracion::usuarioDeServicio()
            ->createToken($nombre, ['admin.clients.read'])
            ->accessToken;
    }

    // ============ Acceso ============

    public function test_el_usuario_de_servicio_no_se_edita_ni_se_borra_ni_siendo_admin(): void
    {
        $this->admin();
        $servicio = \App\Support\ApiDeAdministracion::usuarioDeServicio();

        $this->assertFalse($servicio->is_editable());
        $this->assertFalse($servicio->is_deletable());

        Livewire::test(\App\Livewire\Users\Edit::class, ['user' => $servicio])
            ->assertRedirect(route('users.index'));
    }

    public function test_sin_sesion_ni_la_documentacion_ni_el_yaml_se_ven(): void
    {
        foreach (['configurations.api-tokens', 'configurations.api-tokens.docs', 'configurations.api-tokens.openapi'] as $ruta) {
            $respuesta = $this->get(route($ruta));

            $respuesta->assertRedirect(route('login'));
            $this->assertStringNotContainsString('openapi:', (string) $respuesta->getContent());
        }

        // Ni como fichero estático: la especificación no está en `public/`.
        $this->assertFileDoesNotExist(public_path('openapi/admin-v1.yaml'));
        $this->assertFileDoesNotExist(public_path('admin-v1.yaml'));
    }

    public function test_sin_permiso_las_tres_rutas_dan_403(): void
    {
        $this->usuarioCon([]);

        $this->get(route('configurations.api-tokens'))->assertForbidden();
        $this->get(route('configurations.api-tokens.docs'))->assertForbidden();
        $this->get(route('configurations.api-tokens.openapi'))->assertForbidden();
    }

    public function test_con_permiso_se_ve_la_pantalla_y_el_enlace_a_la_documentacion(): void
    {
        $this->admin();

        $this->get(route('configurations.api-tokens'))
            ->assertOk()
            ->assertSee('Documentación de la API (Swagger)')
            ->assertSee(route('configurations.api-tokens.docs'));
    }

    public function test_la_pagina_de_swagger_apunta_a_la_especificacion(): void
    {
        $this->withoutVite();
        $this->admin();

        $this->get(route('configurations.api-tokens.docs'))
            ->assertOk()
            ->assertSee('data-swagger-url="' . route('configurations.api-tokens.openapi') . '"', false);
    }

    public function test_la_especificacion_se_sirve_como_yaml(): void
    {
        $this->admin();

        $respuesta = $this->get(route('configurations.api-tokens.openapi'))->assertOk();

        $this->assertStringStartsWith('application/yaml', $respuesta->headers->get('Content-Type'));
        $this->assertStringContainsString('openapi: 3.0.3', $respuesta->getContent());
        $this->assertStringContainsString('/clients/{id}', $respuesta->getContent());
    }

    public function test_los_servidores_son_este_manager_pre_y_pro_y_ninguno_mas(): void
    {
        config(['app.url' => 'http://manager.test']);
        \Illuminate\Support\Facades\URL::forceRootUrl('http://manager.test');
        $this->admin();

        $yaml = $this->get(route('configurations.api-tokens.openapi'))->assertOk()->getContent();

        // Este Manager va primero: es el que Swagger elige de entrada.
        $this->assertMatchesRegularExpression(
            '/^servers:\n  - url: http:\/\/manager\.test\/api\/admin\/v1\n/m',
            $yaml
        );
        $this->assertStringContainsString('https://pre-licenses.host3ip.eu/api/admin/v1', $yaml);
        $this->assertStringContainsString('https://managerv2.3ip.eu/api/admin/v1', $yaml);
        $this->assertSame(1, substr_count($yaml, "\nservers:"));

        // Ningún host que se pueda escribir: el token no puede salir hacia fuera.
        $this->assertStringNotContainsString('{base}', $yaml);
        $this->assertStringNotContainsString('variables:', $yaml);
    }

    public function test_si_este_manager_es_produccion_no_sale_dos_veces(): void
    {
        \Illuminate\Support\Facades\URL::forceRootUrl('https://managerv2.3ip.eu');
        $this->admin();

        $yaml = $this->get(route('configurations.api-tokens.openapi'))->assertOk()->getContent();

        $this->assertSame(1, substr_count($yaml, '- url: https://managerv2.3ip.eu/api/admin/v1'));
    }

    // ============ Crear ============

    public function test_crea_un_token_del_usuario_de_servicio_con_sus_permisos_y_caducidad(): void
    {
        $admin = $this->admin();
        $fecha = now()->addMonths(6)->format('Y-m-d');

        $componente = Livewire::test(ApiTokens::class)
            ->set('nombre', 'Script de altas de Sistemas')
            ->set('permisos', ['admin.clients.write', 'admin.catalogs.read'])
            ->set('caducidad', $fecha)
            ->call('crear')
            ->assertHasNoErrors()
            ->assertSee('No se volverá a ver');

        $servicio = ApiDeAdministracion::usuarioDeServicio();
        $token = PersonalAccessToken::sole();

        $this->assertSame($servicio->getMorphClass(), $token->tokenable_type);
        $this->assertSame($servicio->id, (int) $token->tokenable_id);
        $this->assertNotSame($admin->id, (int) $token->tokenable_id);
        $this->assertSame('Script de altas de Sistemas', $token->name);
        // En el orden del catálogo, no en el de los clics, y escribir arrastra leer: la API
        // exige los dos en las rutas de escritura.
        $this->assertSame(['admin.catalogs.read', 'admin.clients.read', 'admin.clients.write'], $token->abilities);
        $this->assertSame($fecha . ' 23:59:59', $token->expires_at->format('Y-m-d H:i:s'));

        // El token en claro sale en la página una vez, pero no se queda en el estado
        // público del componente, que viaja al navegador en cada petición.
        $this->assertStringNotContainsString('|', json_encode($componente->instance()->all()));

        // Y el formulario queda limpio para el siguiente.
        $componente->assertSet('nombre', '')->assertSet('permisos', [])->assertSet('caducidad', '');
    }

    public function test_el_token_en_claro_se_ve_una_vez_y_desaparece_al_cerrar_el_aviso(): void
    {
        $this->admin();

        $componente = Livewire::test(ApiTokens::class)
            ->set('nombre', 'Una vez')
            ->set('permisos', ['admin.clients.read'])
            ->call('crear');

        $html = $componente->html();
        preg_match('/\d+\|[A-Za-z0-9]{40,}/', $html, $coincidencia);
        $this->assertNotEmpty($coincidencia, 'El token en claro debería aparecer tras crearlo.');

        $componente->call('cerrarAviso')
            ->assertDontSee($coincidencia[0])
            ->assertDontSee('No se volverá a ver');
    }

    public function test_sin_caducidad_el_token_no_caduca(): void
    {
        $this->admin();

        Livewire::test(ApiTokens::class)
            ->set('nombre', 'Sin fecha')
            ->set('permisos', ['admin.clients.read'])
            ->call('crear')
            ->assertHasNoErrors();

        $this->assertNull(PersonalAccessToken::sole()->expires_at);
    }

    public function test_rechaza_permisos_que_no_existen_o_ninguno_y_fechas_pasadas(): void
    {
        $this->admin();

        Livewire::test(ApiTokens::class)
            ->set('nombre', 'Inventado')
            ->set('permisos', ['admin.clients.read', '*'])
            ->call('crear')
            ->assertHasErrors(['permisos.1' => 'in']);

        Livewire::test(ApiTokens::class)
            ->set('nombre', 'Vacío')
            ->set('permisos', [])
            ->call('crear')
            ->assertHasErrors(['permisos' => 'required']);

        Livewire::test(ApiTokens::class)
            ->set('nombre', 'Caducado de nacimiento')
            ->set('permisos', ['admin.clients.read'])
            ->set('caducidad', now()->subDay()->format('Y-m-d'))
            ->call('crear')
            ->assertHasErrors(['caducidad' => 'after']);

        Livewire::test(ApiTokens::class)
            ->set('nombre', '')
            ->set('permisos', ['admin.clients.read'])
            ->call('crear')
            ->assertHasErrors(['nombre' => 'required']);

        $this->assertSame(0, PersonalAccessToken::count());
    }

    public function test_sin_permiso_de_crear_no_se_puede_crear_aunque_se_llame_al_metodo(): void
    {
        $this->usuarioCon(['admin.api-tokens.index']);

        Livewire::test(ApiTokens::class)
            ->assertDontSee('Crear un token')
            ->set('nombre', 'Colado')
            ->set('permisos', ['admin.clients.write'])
            ->call('crear')
            ->assertForbidden();

        $this->assertSame(0, PersonalAccessToken::count());
        $this->assertDatabaseMissing('logs', ['code' => '16048']);
    }

    public function test_el_log_de_creacion_dice_quien_y_que_pero_no_lleva_el_token(): void
    {
        $this->admin();

        $componente = Livewire::test(ApiTokens::class)
            ->set('nombre', 'Script de altas de Sistemas')
            ->set('permisos', ['admin.clients.write'])
            ->call('crear');

        preg_match('/\d+\|([A-Za-z0-9]{40,})/', $componente->html(), $coincidencia);
        $this->assertNotEmpty($coincidencia);

        $log = MonitoringLog::where('code', '16048')->sole();

        $this->assertStringContainsString('Script de altas de Sistemas', $log->msg);
        $this->assertStringContainsString('admin.clients.write', $log->msg);
        $this->assertStringContainsString('sin caducidad', $log->msg);
        $this->assertStringContainsString('Ana Admin', $log->msg);
        $this->assertStringNotContainsString($coincidencia[1], $log->msg);
        $this->assertStringNotContainsString($coincidencia[1], (string) $log->trace);
    }

    // ============ Listado ============

    public function test_lista_los_tokens_con_sus_permisos_uso_y_estado(): void
    {
        $this->admin();

        $usado = $this->tokenDeServicio('Script que se usa');
        $usado->forceFill(['last_used_at' => now()->subHour()])->save();

        $caducado = $this->tokenDeServicio('Script caducado');
        $caducado->forceFill(['expires_at' => now()->subDay()])->save();

        Livewire::test(ApiTokens::class)
            ->assertSee('Script que se usa')
            ->assertSee('Script caducado')
            ->assertSee(ApiDeAdministracion::PERMISOS['admin.clients.read'])
            ->assertSee('nunca usado')
            ->assertSee('caducado')
            ->assertSee('activo');
    }

    // ============ Revocar ============

    public function test_revocar_borra_el_token_y_lo_registra(): void
    {
        $this->admin();
        $token = $this->tokenDeServicio('Para revocar');

        Livewire::test(ApiTokens::class)
            ->call('revocar', $token->id)
            ->assertHasNoErrors();

        $this->assertDatabaseMissing('personal_access_tokens', ['id' => $token->id]);

        $log = MonitoringLog::where('code', '16049')->sole();
        $this->assertStringContainsString('Para revocar', $log->msg);
        $this->assertStringContainsString('Ana Admin', $log->msg);
    }

    public function test_sin_permiso_de_revocar_no_se_borra_nada(): void
    {
        $this->usuarioCon(['admin.api-tokens.index']);
        $token = $this->tokenDeServicio();

        Livewire::test(ApiTokens::class)
            ->assertDontSee('Sí, revocar')
            ->call('revocar', $token->id)
            ->assertForbidden();

        $this->assertDatabaseHas('personal_access_tokens', ['id' => $token->id]);
        $this->assertDatabaseMissing('logs', ['code' => '16049']);
    }

    public function test_no_revoca_tokens_de_otro_dueno(): void
    {
        $admin = $this->admin();
        $ajeno = $admin->createToken('De una persona')->accessToken;

        // Para esta pantalla, un token que no es del usuario de servicio no existe: 404.
        try {
            Livewire::test(ApiTokens::class)->call('revocar', $ajeno->id);
            $this->fail('Debería no encontrar el token de otro dueño.');
        } catch (\Illuminate\Database\Eloquent\ModelNotFoundException) {
            // Lo esperado.
        }

        $this->assertDatabaseHas('personal_access_tokens', ['id' => $ajeno->id]);
    }

    // ============ Portada de Configuración ============

    public function test_la_tarjeta_de_la_portada_sale_con_permiso_y_cuenta_los_activos(): void
    {
        $this->admin();

        $this->tokenDeServicio('Activo 1');
        $this->tokenDeServicio('Activo 2');
        $this->tokenDeServicio('Caducado')->forceFill(['expires_at' => now()->subDay()])->save();

        $this->get(route('configurations.index'))
            ->assertOk()
            ->assertSee('API de administración')
            ->assertSee('2 tokens activos');
    }

    public function test_la_tarjeta_de_la_portada_no_sale_sin_permiso(): void
    {
        $this->usuarioCon([]);

        $this->get(route('configurations.index'))
            ->assertOk()
            ->assertDontSee('API de administración')
            ->assertDontSee(route('configurations.api-tokens'));
    }

    // ============ Listado de usuarios ============

    public function test_el_usuario_de_servicio_no_sale_en_el_listado_de_usuarios(): void
    {
        $this->admin();
        ApiDeAdministracion::usuarioDeServicio();

        Livewire::test(UsersIndex::class)
            ->assertDontSee(ApiDeAdministracion::EMAIL_DEL_USUARIO)
            ->assertDontSee(ApiDeAdministracion::NOMBRE_DEL_USUARIO);
    }
}
