<?php

namespace Tests\Feature\System;

use App\Livewire\Configurations\Manager\Index as Configuraciones;
use App\Models\Auth\User;
use App\Models\Monitoring\ApiBlock;
use App\Services\System\Settings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use App\Livewire\Configurations\Manager\SettingModal;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Tests\TestCase;

/**
 * Umbrales de los límites de la API, configurables desde el panel.
 *
 * Estaban solo en el `.env`, y eso era una contradicción con el modo observación: se
 * observa **para afinar los topes**, y afinarlos pedía entrar al servidor, editar un
 * fichero y limpiar la caché de configuración. Lo previsible es que nadie los afine y se
 * acabe cortando con el número del primer día.
 *
 * Lo que este fichero fija, y que es fácil de romper sin darse cuenta:
 *
 * 1. **El 0 significa "usa el fichero"**, no "tope cero". Un tope de 0 cortaría toda la
 *    API, y el 0 tiene que seguir siendo la forma de volver atrás sin desplegar.
 * 2. **El panel manda sobre el fichero**, y solo cuando hay algo puesto.
 * 3. **El margen se guarda en porcentaje y se usa como fracción.** Es una conversión, y
 *    las conversiones se equivocan solas.
 */
class ApiThresholdsTest extends TestCase
{
    use RefreshDatabase;

    private function admin(): User
    {
        foreach (['admin.configs.index', 'admin.configs.edit'] as $nombre) {
            Permission::firstOrCreate(
                ['name' => $nombre, 'guard_name' => 'web'],
                ['model' => 'configs', 'description' => $nombre]
            );
        }

        $user = User::factory()->create();
        $user->givePermissionTo(['admin.configs.index', 'admin.configs.edit']);
        $this->actingAs($user);
        Cache::flush();

        return $user;
    }

    private function ajustes(): Settings
    {
        // El servicio cachea, y en un test cada lectura tiene que ver lo último escrito.
        app(Settings::class)->olvidar();

        return app(Settings::class);
    }

    /* ===================== Resolución de valores ===================== */

    public function test_sin_nada_configurado_manda_el_fichero(): void
    {
        config([
            'api.throttle.failures' => 20,
            'api.throttle.failures_decay' => 60,
            'api.throttle.requests' => 600,
            'api.throttle.requests_decay' => 60,
            'api.throttle.warning_ratio' => 0.5,
        ]);

        $umbrales = $this->ajustes()->umbralesDeLaApi();

        $this->assertSame(20, $umbrales['failures']);
        $this->assertSame(600, $umbrales['requests']);
        $this->assertSame(0.5, $umbrales['warning_ratio']);
    }

    public function test_lo_configurado_en_el_panel_manda_sobre_el_fichero(): void
    {
        config(['api.throttle.failures' => 20, 'api.throttle.requests' => 600]);

        $ajustes = $this->ajustes();
        $ajustes->set(Settings::LIMITE_FALLOS, '5');
        $ajustes->set(Settings::LIMITE_PETICIONES, '1200');

        $umbrales = $this->ajustes()->umbralesDeLaApi();

        $this->assertSame(5, $umbrales['failures']);
        $this->assertSame(1200, $umbrales['requests']);
    }

    public function test_un_cero_guardado_devuelve_al_valor_del_fichero(): void
    {
        // Es la marcha atrás: se puso un número, no gustó, se vuelve al del fichero sin
        // tocar el servidor. Si el 0 se tomara al pie de la letra, la API cortaría TODO.
        config(['api.throttle.failures' => 20]);

        $ajustes = $this->ajustes();
        $ajustes->set(Settings::LIMITE_FALLOS, '5');
        $this->assertSame(5, $this->ajustes()->umbralesDeLaApi()['failures']);

        $ajustes->set(Settings::LIMITE_FALLOS, '0');
        $this->assertSame(20, $this->ajustes()->umbralesDeLaApi()['failures']);
    }

    public function test_el_margen_se_guarda_en_porcentaje_y_se_usa_como_fraccion(): void
    {
        $this->ajustes()->set(Settings::LIMITE_MARGEN, '80');

        $this->assertSame(0.8, $this->ajustes()->umbralesDeLaApi()['warning_ratio']);
    }

    public function test_un_margen_imposible_cae_al_del_fichero(): void
    {
        // 100 % querría decir "avisa justo al cortar", que es no avisar.
        config(['api.throttle.warning_ratio' => 0.5]);

        $this->ajustes()->set(Settings::LIMITE_MARGEN, '100');

        $this->assertSame(0.5, $this->ajustes()->umbralesDeLaApi()['warning_ratio']);
    }

    /* ===================== La pantalla ===================== */

    public function test_el_panel_guarda_un_umbral_y_deja_rastro(): void
    {
        // Ahora se edita **de uno en uno**, en `SettingModal`: antes eran cinco campos y
        // un botón, y guardar el destinatario de un aviso se parecía a bajar un tope de la
        // API. Ver el docblock de `Manager\Index`.
        $this->admin();

        Livewire::test(SettingModal::class, ['clave' => Settings::LIMITE_FALLOS])
            ->set('valor', '8')
            ->call('guardar')
            ->assertHasNoErrors();

        Livewire::test(SettingModal::class, ['clave' => Settings::LIMITE_FALLOS_VENTANA])
            ->set('valor', '120')
            ->call('guardar')
            ->assertHasNoErrors();

        Livewire::test(SettingModal::class, ['clave' => Settings::LIMITE_MARGEN])
            ->set('valor', '70')
            ->call('guardar')
            ->assertHasNoErrors();

        $umbrales = $this->ajustes()->umbralesDeLaApi();

        $this->assertSame(8, $umbrales['failures']);
        $this->assertSame(120, $umbrales['failures_decay']);
        $this->assertSame(0.7, $umbrales['warning_ratio']);

        // Y en el log queda qué ajuste se cambió y a cuánto.
        $this->assertDatabaseHas('logs', ['code' => '16014', 'entity_id' => Settings::LIMITE_FALLOS]);
        $this->assertStringContainsString(
            'limits:failures',
            \App\Models\Monitoring\Log::where('code', '16014')->value('msg')
        );
    }

    public function test_guardar_un_umbral_no_toca_los_avisos(): void
    {
        // **Es la razón de editar de uno en uno.** Los umbrales son de Sistemas y los
        // destinatarios pueden ser de otra persona; un botón que guarda todo hacía que uno
        // pisara lo del otro sin enterarse.
        $this->admin();
        $this->ajustes()->set(Settings::AVISOS_EMAIL, 'sistemas@tresipunt.com');

        Livewire::test(SettingModal::class, ['clave' => Settings::LIMITE_FALLOS])
            ->set('valor', '15')
            ->call('guardar');

        $this->assertSame(
            ['sistemas@tresipunt.com'],
            $this->ajustes()->destinatariosDeAvisos()
        );
    }

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

        Livewire::test(SettingModal::class, ['clave' => Settings::LIMITE_FALLOS])
            ->set('valor', '-1')
            ->call('guardar')
            ->assertHasErrors(['valor']);

        $this->assertSame(0, (int) $this->ajustes()->get(Settings::LIMITE_FALLOS, 0));
    }

    public function test_una_ventana_absurda_no_se_acepta(): void
    {
        // Más de un día de ventana convierte un pico de cinco minutos en un bloqueo de
        // 24 horas para un cliente. El rango vive en `Settings::CATALOGO`, así que hay un
        // solo sitio donde está escrito.
        $this->admin();

        Livewire::test(SettingModal::class, ['clave' => Settings::LIMITE_FALLOS_VENTANA])
            ->set('valor', '999999')
            ->call('guardar')
            ->assertHasErrors(['valor']);
    }

    public function test_una_clave_que_no_esta_en_el_catalogo_no_se_puede_editar(): void
    {
        // `$clave` es una propiedad pública y llega del navegador (MGR-018): sin esta
        // comprobación, cualquiera podría escribir en `configs` una fila arbitraria del
        // ámbito `manager` —o pisar un ajuste que ninguna pantalla enseña—.
        $this->admin();

        Livewire::test(SettingModal::class, ['clave' => 'lo:que:me:invento'])
            ->assertNotFound();
    }

    public function test_sin_permiso_no_se_cambian_los_umbrales(): void
    {
        // MGR-005: el método viaja por /livewire/update, donde el permiso de la ruta no
        // se reaplica. Esconder el botón no protege nada.
        Permission::firstOrCreate(
            ['name' => 'admin.configs.index', 'guard_name' => 'web'],
            ['model' => 'configs', 'description' => 'ver']
        );
        $user = User::factory()->create();
        $user->givePermissionTo('admin.configs.index');
        $this->actingAs($user);
        Cache::flush();

        // Ni se puede abrir la modal desde la pantalla…
        Livewire::test(Configuraciones::class)
            ->call('editar', Settings::LIMITE_FALLOS)
            ->assertForbidden();

        // …ni montarla a mano saltándose la pantalla.
        Livewire::test(SettingModal::class, ['clave' => Settings::LIMITE_FALLOS])
            ->assertForbidden();

        $this->assertSame(0, (int) $this->ajustes()->get(Settings::LIMITE_FALLOS, 0));
    }

    /* ===================== Bloqueos manuales listados ===================== */

    private function bloqueoManual(): ApiBlock
    {
        return ApiBlock::create([
            'subject_type' => ApiBlock::SUBJECT_IP,
            'ip' => '203.0.113.44',
            'reason' => ApiBlock::REASON_MANUAL,
            'state' => ApiBlock::STATE_BLOCKED,
            'enforced' => true,
            'first_blocked_at' => now()->subDay(),
            'last_blocked_at' => now(),
            'hits' => 1,
        ]);
    }

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

        // Ya no hay pestaña que elegir: los bloqueos manuales se ven junto a los
        // límites, que es la decisión que explican.
        Livewire::test(Configuraciones::class)
            ->assertSee('203.0.113.44')
            ->assertSee('no recibe licencias ni contenido');
    }

    public function test_un_administrador_levanta_un_bloqueo_manual_desde_la_configuracion(): void
    {
        $usuario = $this->admin();
        $bloqueo = $this->bloqueoManual();

        // Cachea el estado antes, para comprobar que levantarlo invalida la caché: sin
        // eso el bloqueo sobrevive 30 s a su propio levantamiento.
        $this->assertTrue(ApiBlock::ipBloqueadaAMano('203.0.113.44'));

        Livewire::test(Configuraciones::class)
            ->call('quitarBloqueoManual', $bloqueo->id);

        $bloqueo->refresh();

        $this->assertSame(ApiBlock::STATE_CLEARED, $bloqueo->state);
        $this->assertSame($usuario->id, $bloqueo->cleared_by);
        $this->assertFalse(ApiBlock::ipBloqueadaAMano('203.0.113.44'));
    }

    public function test_quien_solo_mira_no_levanta_un_bloqueo_manual(): void
    {
        Permission::firstOrCreate(
            ['name' => 'admin.configs.index', 'guard_name' => 'web'],
            ['model' => 'configs', 'description' => 'ver']
        );
        $user = User::factory()->create();
        $user->givePermissionTo('admin.configs.index');
        $this->actingAs($user);
        Cache::flush();

        $bloqueo = $this->bloqueoManual();

        Livewire::test(Configuraciones::class)
            ->call('quitarBloqueoManual', $bloqueo->id)
            ->assertForbidden();

        $this->assertSame(ApiBlock::STATE_BLOCKED, $bloqueo->fresh()->state);
    }

    public function test_no_se_puede_levantar_un_bloqueo_que_no_es_manual(): void
    {
        // Los automáticos se revisan en Monitorización, con su contexto. Colar un id de
        // otro tipo por aquí se saltaría esa pantalla.
        $this->admin();

        $automatico = ApiBlock::create([
            'subject_type' => ApiBlock::SUBJECT_IP,
            'ip' => '203.0.113.45',
            'reason' => ApiBlock::REASON_FAILURES,
            'state' => ApiBlock::STATE_BLOCKED,
            'enforced' => true,
            'first_blocked_at' => now(),
            'last_blocked_at' => now(),
            'hits' => 30,
        ]);

        // `findOrFail` acotado a los manuales: no existe para esta pantalla, y eso es
        // justo lo que tiene que pasar.
        $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class);

        try {
            Livewire::test(Configuraciones::class)
                ->call('quitarBloqueoManual', $automatico->id);
        } finally {
            $this->assertSame(ApiBlock::STATE_BLOCKED, $automatico->fresh()->state);
        }
    }
    /* ===================== La pestaña sobrevive a un F5 ===================== */

    public function test_la_pantalla_ya_no_tiene_pestanas_dentro_de_pestanas(): void
    {
        // **Era el problema.** Cuatro pestañas propias dentro de una sección que ya tenía
        // pestañas, y el caso real: se ajustaba la retención, se recargaba para ver el
        // recuento y se volvía siempre a Bloqueos. El estado se llevó a la URL como
        // parche; ahora no hay pestañas que recordar, así que todo se ve de una vez.
        $this->admin();

        $this->get(route('configurations.manager.index'))
            ->assertOk()
            // Los tres bloques, en la misma página.
            ->assertSee('Límites de la API')
            ->assertSee('Avisos por correo')
            ->assertSee('Retención del log de la API')
            ->assertSee('Servidor de correo');
    }

    public function test_un_enlace_viejo_con_seccion_en_la_url_sigue_abriendo_la_pantalla(): void
    {
        // Los enlaces con `?seccion=logs` están guardados en favoritos y compartidos por
        // chat: tienen que seguir llevando a algún sitio, no a un error. Ahora el
        // parámetro sobra y se ignora.
        $this->admin();

        $this->get(route('configurations.manager.index', ['seccion' => 'logs']))
            ->assertOk()
            ->assertSee('Retención del log de la API');

        $this->get(route('configurations.manager.index', ['seccion' => 'loquesea']))
            ->assertOk()
            ->assertSee('Límites de la API');
    }

    public function test_el_tope_se_dice_entero_y_no_en_dos_filas_sueltas(): void
    {
        // «600 peticiones» y «60 segundos» no significan nada por separado: lo que corta
        // es la pareja. Antes había que juntarla leyendo dos filas, y la descripción de
        // una decía «en la ventana» sin decir cuál.
        $this->admin();

        $this->ajustes()->set(Settings::LIMITE_PETICIONES, '900');
        $this->ajustes()->set(Settings::LIMITE_PETICIONES_VENTANA, '120');
        Cache::flush();

        $this->get(route('configurations.manager.index'))
            ->assertOk()
            ->assertSee('900 peticiones cada 120 segundos')
            ->assertDontSee('en la ventana');
    }
}
