<?php

namespace Tests\Feature\Monitoring;

use App\Livewire\Monitoring\IpBlockModal;
use App\Livewire\Monitoring\Traffic;
use App\Models\ApiRequestLog;
use App\Models\Auth\User;
use App\Models\Monitoring\ApiBlock;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Tests\TestCase;

/**
 * La sección de Monitorización: lo que se revisa, separado de lo que se configura.
 *
 * **Dos niveles de permiso a propósito.** Ver la sección exige `admin.api-logs.index`
 * —que tienen Admin, Support y Manager, porque revisar es el trabajo de Soporte—, y
 * actuar exige `admin.configs.edit`, que es solo de Admin. Liberar un bloqueo rearma un
 * aviso, y bloquear una IP **corta servicio**: no es lo mismo mirar que tocar. Este
 * fichero fija esa separación, porque es la que se pierde en cuanto alguien añade un
 * botón sin pensarlo.
 */
class MonitoringPagesTest extends TestCase
{
    use RefreshDatabase;

    private const VER = 'admin.api-logs.index';
    private const TOCAR = 'admin.configs.edit';

    private function usuario(array $permisos): User
    {
        foreach ($permisos as $nombre) {
            Permission::firstOrCreate(
                ['name' => $nombre, 'guard_name' => 'web'],
                ['model' => 'monitoring', 'description' => $nombre]
            );
        }

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

        if ($permisos !== []) {
            $user->givePermissionTo($permisos);
        }

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

        return $user;
    }

    private function bloqueo(array $atributos = []): ApiBlock
    {
        return ApiBlock::create(array_merge([
            'subject_type' => ApiBlock::SUBJECT_IP,
            'ip' => '203.0.113.9',
            'reason' => ApiBlock::REASON_FAILURES,
            'state' => ApiBlock::STATE_BLOCKED,
            'enforced' => true,
            'first_blocked_at' => now()->subHour(),
            'last_blocked_at' => now(),
            'hits' => 12,
            'limit_value' => 20,
        ], $atributos));
    }

    /* ===================== Acceso ===================== */

    public function test_sin_permiso_de_lectura_no_se_entra(): void
    {
        $this->usuario(['admin.dashboard.index']);

        $this->get(route('monitoring.index'))->assertForbidden();
        $this->get(route('monitoring.notices'))->assertForbidden();
        $this->get(route('monitoring.expirations'))->assertForbidden();
    }

    public function test_con_permiso_de_lectura_se_ven_las_pestanas(): void
    {
        $this->usuario([self::VER]);

        foreach (['monitoring.index', 'monitoring.notices', 'monitoring.expirations'] as $ruta) {
            $this->get(route($ruta))
                ->assertOk()
                ->assertSee('Monitorización')
                ->assertSee('Tráfico y cortes')
                ->assertSee('Avisos');
        }
    }

    /* ===================== Bloqueos ===================== */

    public function test_el_listado_muestra_el_bloqueo_con_su_motivo(): void
    {
        $this->usuario([self::VER]);
        $this->bloqueo();

        // **El sujeto cortado sale arriba, en la tabla que abre la pantalla.** Con los
        // límites cortando, la pregunta es «¿quién está cortado?» y la respuesta es lo
        // primero que se lee: hay un cliente sin servicio.
        app(\App\Services\System\Settings::class)
            ->set(\App\Services\System\Settings::LIMITES_CORTAN, '1');

        Livewire::test(Traffic::class)
            ->assertSee('¿Quién está cortado?')
            ->assertSee('203.0.113.9')
            ->assertSee('demasiados intentos rechazados');
    }

    public function test_distingue_lo_cortado_de_lo_solo_observado(): void
    {
        // Es la distinción que evita que alguien salga corriendo a arreglar algo que
        // está funcionando. Ver `ObservationModeTest`.
        $this->usuario([self::VER]);
        $this->bloqueo(['enforced' => false, 'ip' => '203.0.113.10']);

        // **El modo va arriba del todo**, porque cambia el significado de todo lo de abajo.
        // Y la pregunta de la tabla se pone en condicional: nadie está sin servicio.
        Livewire::test(Traffic::class)
            ->assertSee('Observación')
            ->assertSee('no se rechaza ninguna petición')
            ->assertSee('¿Quién estaría cortado?')
            ->assertDontSee('¿Quién está cortado?');
    }

    public function test_quien_solo_puede_mirar_no_ve_el_boton_de_liberar(): void
    {
        $this->usuario([self::VER]);
        $this->bloqueo();

        Livewire::test(Traffic::class)
            ->assertDontSee('Liberar…')
            ->assertDontSee('Bloquear…')
            ->assertDontSee('Limpiar el registro…');
    }

    public function test_quien_solo_puede_mirar_tampoco_puede_liberar_llamando_al_metodo(): void
    {
        // MGR-005: esconder el botón no es protegerlo. El método va a
        // /livewire/update, donde el gate de la ruta no se reaplica.
        $this->usuario([self::VER]);
        $bloqueo = $this->bloqueo();

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

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

    public function test_un_administrador_libera_y_queda_quien_fue(): void
    {
        $usuario = $this->usuario([self::VER, self::TOCAR]);
        $bloqueo = $this->bloqueo();

        // Pasa por la modal: el receptor recibe un array (MGR-042).
        Livewire::test(Traffic::class)->dispatch('apiBlockClearConfirmed', ['itemId' => $bloqueo->id]);

        $bloqueo->refresh();

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

    /* ============ Revisar y borrar un grupo entero ============
     *
     * **Ya no existen.** La pantalla agrupada que los ofrecía la sustituyó la tabla plana
     * de «¿Quién estaría cortado?», y desde que borrar dejó de exigir que algo estuviera
     * revisado —se protege lo que corta, no lo que nadie ha mirado— la revisión en lote no
     * es el paso previo de nada. Lo que se borra se prueba en `CortesAgrupadosTest`.
     * ============================================================ */

    private function peticiones(string $ip, int $cuantas, int $status = 200): void
    {
        for ($i = 0; $i < $cuantas; $i++) {
            ApiRequestLog::create([
                'request_uuid' => (string) \Illuminate\Support\Str::uuid(),
                'method' => 'POST',
                'path' => 'api/v1',
                'ip' => $ip,
                'auth_type' => 'bearer',
                'http_status' => $status,
                'action' => 'products',
                'host' => 'https://sitio-' . $ip . '.test',
                'started_at' => now()->subHours(2),
                'ended_at' => now()->subHours(2),
                'duration_ms' => 30,
            ]);
        }
    }

    /** Una petición con lo que haga falta cambiado. */
    private function peticionCon(array $atributos): ApiRequestLog
    {
        return ApiRequestLog::create(array_merge([
            'request_uuid' => (string) \Illuminate\Support\Str::uuid(),
            'method' => 'POST',
            'path' => 'api/v1',
            'ip' => '203.0.113.9',
            'auth_type' => 'bearer',
            'http_status' => 200,
            'action' => 'products',
            'host' => 'https://sitio.test',
            'started_at' => now()->subHour(),
            'ended_at' => now()->subHour(),
            'duration_ms' => 30,
        ], $atributos));
    }

    public function test_el_ranking_y_los_picos_contestan_preguntas_distintas(): void
    {
        // **El rediseño había quitado el ranking** y puesto los picos en su lugar, pero no
        // contestan lo mismo: los picos dicen *quién se dispara en un tramo* y el ranking
        // *quién está llamando*, cruce o no cruce límites. Sin el ranking no quedaba ninguna
        // pantalla que respondiera «¿quién nos está dando el tráfico?».
        $this->usuario([self::VER]);

        $this->peticiones('203.0.113.1', 3);
        $this->peticiones('203.0.113.2', 9);

        $componente = Livewire::test(Traffic::class);

        $this->assertNotNull($componente->viewData('sePasan'), 'Los picos por tramo siguen estando.');

        $ranking = $componente->viewData('ranking');

        $this->assertCount(2, $ranking);
        // Por volumen y con su posición: es lo que lo hace un ranking y no una lista.
        $this->assertSame('203.0.113.2', $ranking[0]['ip']);
        $this->assertSame(1, $ranking[0]['pos']);
        $this->assertSame(9, $ranking[0]['peticiones']);
        $this->assertSame(2, $ranking[1]['pos']);
    }

    public function test_el_ranking_dice_el_ultimo_user_agent_y_los_dominios_de_cada_ip(): void
    {
        // **Es lo que le da identidad a una IP.** A secas no dice nada; con su user-agent se
        // ve si es un Moodle, un bot o un monitor, y con sus dominios si es un sitio o un
        // hosting compartido — donde cortar la IP corta a todos.
        $this->usuario([self::VER]);

        $this->peticionCon([
            'host' => 'https://uno.example.test',
            'user_agent' => 'Moodle/5.1 viejo',
            'started_at' => now()->subHours(3),
        ]);
        $this->peticionCon([
            'host' => 'https://dos.example.test',
            'user_agent' => 'Moodle/5.1 nuevo',
            'started_at' => now()->subMinutes(5),
        ]);

        $fila = Livewire::test(Traffic::class)->viewData('ranking')[0];

        // El **último**, no uno cualquiera de los suyos.
        $this->assertSame('Moodle/5.1 nuevo', $fila['userAgent']);
        $this->assertSame(2, $fila['dominios']);
        $this->assertStringContainsString('uno.example.test', $fila['dominiosTexto']);
        $this->assertStringContainsString('dos.example.test', $fila['dominiosTexto']);
    }

    public function test_una_ip_con_episodios_apuntados_se_marca_en_el_ranking(): void
    {
        // «Apuntada» **no es «con corte»**: casi todos los episodios son de observación y no
        // cortan nada. Pero que una IP del ranking ya esté en el registro de arriba es lo
        // primero que hay que saber antes de decidir si su volumen es normal.
        $this->usuario([self::VER]);

        $this->peticiones('203.0.113.4', 2);
        $this->peticiones('203.0.113.5', 2);

        // Un episodio ya cerrado y que no cortaba: aun así queda apuntado.
        $this->bloqueo([
            'ip' => '203.0.113.4',
            'state' => ApiBlock::STATE_CLEARED,
            'enforced' => false,
        ]);

        $ranking = collect(Livewire::test(Traffic::class)->viewData('ranking'))->keyBy('ip');

        $this->assertTrue($ranking['203.0.113.4']['apuntada']);
        $this->assertFalse($ranking['203.0.113.5']['apuntada']);
    }

    public function test_los_picos_salen_del_registro_de_peticiones_y_no_de_las_ventanas(): void
    {
        // **De dónde sale el número, que es la diferencia con la pantalla anterior.** Las
        // ventanas del limitador solo existen para quien rozó el margen: de una IP que nunca
        // lo cruzó no hay ni una, así que no se podía saber si iba holgada o al borde. Con
        // el registro de peticiones sale cualquiera, y por eso hay tramos de hora y de día.
        $this->usuario([self::VER]);

        $this->peticiones('203.0.113.77', 6);

        Livewire::test(Traffic::class)
            ->assertSee('¿Está pasando algo raro?')
            ->assertSee('203.0.113.77');
    }

    public function test_sin_tope_del_tramo_se_dice_en_vez_de_inventarse_uno(): void
    {
        // Solo hay `limits:requests`, que es por minuto. Un «por hora = por minuto × 60»
        // fabricado aquí se leería como un umbral que alguien acordó, y nadie lo ha hecho.
        $this->usuario([self::VER]);

        $this->peticiones('203.0.113.78', 4);

        Livewire::test(Traffic::class)
            ->call('verTramo', \App\Services\System\QuienSePasa::HORA)
            // Sin el nombre del tramo dentro, la frase valdría para cualquiera de los tres.
            ->assertSee('El tope por hora')
            // Y aun sin tope la IP sale: lo que se enseña es el pico observado.
            ->assertSee('203.0.113.78');
    }

    public function test_quien_solo_puede_mirar_no_puede_bloquear_una_ip(): void
    {
        $this->usuario([self::VER]);

        // Ni abriendo la modal ni llamando al método de dentro: MGR-005, esconder el
        // botón no es protegerlo.
        Livewire::test(Traffic::class)
            ->call('abrirBloqueo', '203.0.113.4')
            ->assertForbidden();

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

    public function test_un_administrador_bloquea_una_ip_a_mano(): void
    {
        $this->usuario([self::VER, self::TOCAR]);

        Livewire::test(IpBlockModal::class, ['ip' => '203.0.113.5'])
            ->set('motivo', 'sitio de desarrollo en bucle')
            ->call('bloquear')
            ->assertHasNoErrors();

        $bloqueo = ApiBlock::blocked()->firstOrFail();

        $this->assertSame('203.0.113.5', $bloqueo->ip);
        $this->assertSame(ApiBlock::REASON_MANUAL, $bloqueo->reason);
        // Por defecto corta: el modo se elige, y «cortar» es lo normal.
        $this->assertTrue($bloqueo->enforced);
        $this->assertSame('sitio de desarrollo en bucle', $bloqueo->cleared_note);

        // Y queda en el log del panel, con quién lo hizo.
        $this->assertDatabaseHas('logs', ['code' => '16011']);
    }

    public function test_no_se_bloquea_dos_veces_la_misma_ip(): void
    {
        $this->usuario([self::VER, self::TOCAR]);

        foreach ([1, 2] as $intento) {
            Livewire::test(IpBlockModal::class, ['ip' => '203.0.113.6'])
                ->set('motivo', 'sitio en bucle sin token')
                ->call('bloquear');
        }

        $this->assertSame(1, ApiBlock::where('ip', '203.0.113.6')->count());
    }

    public function test_una_ip_que_no_es_una_ip_no_se_bloquea(): void
    {
        $this->usuario([self::VER, self::TOCAR]);

        Livewire::test(IpBlockModal::class, ['ip' => 'no-soy-una-ip'])
            ->set('motivo', 'prueba de validación')
            ->call('bloquear')
            ->assertHasErrors(['ip' => 'ip']);

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

    /* ===================== Bloquear una IP que no está en la lista ===================== */

    public function test_se_puede_bloquear_una_ip_escribiendola_a_mano(): void
    {
        // **Antes no se podía.** El botón «Bloquear…» solo existía en las filas del
        // ranking de tráfico, o sea que solo se podía bloquear a quien ya hubiera llamado.
        // Una IP que aún no aparece —un escaneo que empieza, o la que te pasa Sistemas— no
        // había forma de meterla, así que «bloquear una IP a mano» en realidad no existía.
        $this->usuario([self::VER, self::TOCAR, 'admin.api-logs.destroy']);

        $modal = Livewire::test(IpBlockModal::class);

        // Se abre sin IP y el campo es editable.
        $this->assertSame('', $modal->get('ip'));
        $this->assertTrue($modal->instance()->seEscribeLaIp());
        $modal->assertSee('Bloquear una IP a mano');

        $modal->set('ip', '198.51.100.7')
            ->set('motivo', 'Escaneo continuo contra el endpoint de licencias')
            ->set('modo', 'cortar')
            ->call('bloquear')
            ->assertHasNoErrors();

        $this->assertDatabaseHas('api_blocks', [
            'ip' => '198.51.100.7',
            'reason' => ApiBlock::REASON_MANUAL,
            'state' => ApiBlock::STATE_BLOCKED,
            'enforced' => true,
        ]);
    }

    public function test_una_ip_mal_escrita_no_se_bloquea(): void
    {
        // Es el riesgo de dejar escribirla: un dedazo bloquearía una IP que no es. La
        // validación ya estaba en `bloquear()`; lo que faltaba era el campo.
        $this->usuario([self::VER, self::TOCAR, 'admin.api-logs.destroy']);

        Livewire::test(IpBlockModal::class)
            ->set('ip', '198.51.100')
            ->set('motivo', 'Motivo suficientemente largo')
            ->call('bloquear')
            ->assertHasErrors(['ip']);

        $this->assertDatabaseCount('api_blocks', 0);
    }

    public function test_al_escribir_una_ip_sin_trafico_se_avisa_de_que_puede_ser_un_dedazo(): void
    {
        // Cero peticiones es lo normal si se bloquea por adelantado, **y también lo que se
        // ve con la IP mal escrita**. Confundir las dos cosas es fácil, así que la modal
        // dice las dos.
        $this->usuario([self::VER, self::TOCAR, 'admin.api-logs.destroy']);

        Livewire::test(IpBlockModal::class)
            ->set('ip', '198.51.100.7')
            ->assertSee('no ha hecho ninguna petición')
            ->assertSee('comprueba que está bien escrita');
    }

    public function test_desde_la_pantalla_de_ajustes_tambien_se_puede_bloquear_una_ip(): void
    {
        // Es donde se ven los bloqueos manuales, y a esa pantalla se llega sabiendo qué IP
        // hay que cortar. El ranking de tráfico solo ofrece las que ya han llamado.
        $this->usuario([self::VER, self::TOCAR, 'admin.api-logs.destroy']);

        Livewire::test(\App\Livewire\Configurations\Manager\Index::class)
            ->call('abrirBloqueoDeIp')
            ->assertDispatched('openModal');
    }
}
