<?php

namespace Tests\Feature\Security;

use App\Livewire\Products\CambioDeEstadoModal;
use App\Livewire\Setups\Index as SetupsIndex;
use App\Models\Auth\User;
use App\Models\Products\Product;
use App\Models\Setups\Setup;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use Spatie\Permission\Models\Permission;
use Tests\TestCase;

/**
 * Autorización de los métodos de Livewire (MGR-005 y MGR-006).
 *
 * **Por qué este fichero existe y por qué el primer test es un barrido de código.**
 *
 * El `permission:` de una ruta protege el GET de la página y nada más. Las llamadas a
 * métodos van a `/livewire/update`, donde Livewire solo reaplica el middleware de su
 * lista de persistentes, y el de Spatie no estaba en ella: el único filtro de las
 * mutaciones era estar autenticado.
 *
 * Se arregló por las dos vías —registrar el middleware como persistente, y un
 * `authorize()` explícito en cada método— pero el fallo es **invisible**: la pantalla
 * no se ve, el método sí se ejecuta, y nada avisa. Un componente nuevo escrito mañana
 * sin `authorize()` no rompe ningún test... salvo el primero de aquí.
 */
class LivewireAuthorizationTest extends TestCase
{
    use RefreshDatabase;

    /**
     * Escrituras en base de datos que delatan a un método mutador.
     */
    private const MUTACIONES = [
        '->save(', '->update(', '->delete(', '->forceDelete(', '->restore(',
        '::create(', '->create(', '::updateOrCreate(', '->updateOrCreate(',
        '::destroy(', '->sync(', '->attach(', '->detach(',
        'DB::table(', 'DB::statement(', '->increment(', '->decrement(',
        '->givePermissionTo(', '->syncPermissions(', '->assignRole(', '->removeRole(',
    ];

    /**
     * Métodos del ciclo de vida de Livewire: no los invoca el usuario directamente.
     */
    private const CICLO_DE_VIDA = [
        'mount', 'render', 'boot', 'booted', 'hydrate', 'dehydrate', 'updating', 'updated',
    ];

    public function test_ningun_metodo_mutador_de_livewire_se_queda_sin_authorize(): void
    {
        $sinProteger = [];

        foreach ($this->ficherosDeLivewire() as $fichero) {
            foreach ($this->metodosMutadoresSinAutorizar($fichero) as $metodo) {
                $sinProteger[] = str_replace('\\', '/', $fichero) . '::' . $metodo;
            }
        }

        $this->assertSame([], $sinProteger, implode("\n", array_merge(
            ['Hay métodos de Livewire que escriben en base de datos sin authorize().'],
            ['Cada uno es alcanzable por cualquier usuario autenticado (MGR-005):'],
            $sinProteger,
            ['', 'El permiso se saca de la ruta que sirve ese componente, no se inventa.'],
        )));
    }

    /* ==========================================================
       Y las comprobaciones funcionales de los casos más graves
       ========================================================== */

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

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

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

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

        return $user;
    }

    private function crearSetup(Product $producto, string $version = '2026010100'): Setup
    {
        return Setup::create([
            'product_id' => $producto->id,
            'version' => $version,
            'status' => 'active',
            'yaml' => "clave: valor\n",
            'created_by' => auth()->id(),
        ]);
    }

    public function test_sin_permiso_de_edicion_no_se_cambia_el_estado_de_un_setup(): void
    {
        // Con solo el permiso de la RUTA (`index`) no basta: cambiar el estado es
        // editar. Los roles reales tienen los dos, así que esto no cambia nada para
        // nadie; cierra el caso de un rol de solo lectura.
        $this->usuario(['admin.setups.index']);

        $producto = Product::create(['name' => 'Producto Authz', 'slug' => 'theme_authz', 'shortname' => 'authz']);
        $setup = $this->crearSetup($producto);

        Livewire::test(SetupsIndex::class, ['product' => $producto])
            ->call('pedirCambioDeEstado', $setup->id, 'deprecated')
            ->assertForbidden();

        $this->assertSame('active', $setup->fresh()->status, 'El estado no debía cambiar.');
    }

    public function test_la_modal_del_cambio_de_estado_tampoco_se_abre_sin_permiso(): void
    {
        // El listado solo abre la modal, así que **el permiso hace falta en los dos
        // sitios**: si solo estuviera en el listado, se llegaría a la modal por su nombre
        // de componente desde /livewire/update y el estado cambiaría igual (MGR-005).
        $this->usuario(['admin.setups.index']);

        $producto = Product::create(['name' => 'Producto modal', 'slug' => 'theme_modal', 'shortname' => 'modal']);
        $setup = $this->crearSetup($producto);

        // Livewire convierte la excepción del `authorize()` en un 403, así que se
        // comprueba como respuesta y no con un try/catch.
        Livewire::test(CambioDeEstadoModal::class, [
            'tipo' => 'setups',
            'productId' => $producto->id,
            'versionId' => $setup->id,
            'estado' => 'deprecated',
        ])->assertForbidden();

        $this->assertSame('active', $setup->fresh()->status, 'El estado no debía cambiar.');
    }

    public function test_la_modal_no_toca_una_version_de_otro_producto(): void
    {
        // MGR-006 en la modal: el id viaja por el navegador igual que en el listado.
        $this->usuario(['admin.setups.index', 'admin.setups.edit']);

        $mio = Product::create(['name' => 'Mi producto 3', 'slug' => 'theme_mio3', 'shortname' => 'mio3']);
        $ajeno = Product::create(['name' => 'Producto ajeno 3', 'slug' => 'theme_ajeno3', 'shortname' => 'ajeno3']);

        $setupAjeno = $this->crearSetup($ajeno);

        try {
            Livewire::test(CambioDeEstadoModal::class, [
                'tipo' => 'setups',
                'productId' => $mio->id,
                'versionId' => $setupAjeno->id,
                'estado' => 'deprecated',
            ]);

            $this->fail('Se esperaba que el setup de otro producto no se encontrara.');
        } catch (ModelNotFoundException) {
            // Es el resultado correcto.
        }

        $this->assertSame('active', $setupAjeno->fresh()->status, 'El setup ajeno no debía cambiar.');
    }

    public function test_no_se_puede_tocar_el_setup_de_otro_producto(): void
    {
        // MGR-006: el id llega del navegador y nadie comprobaba que el setup fuera del
        // producto de la URL.
        $this->usuario(['admin.setups.index', 'admin.setups.edit', 'admin.setups.create']);

        $mio = Product::create(['name' => 'Mi producto', 'slug' => 'theme_mio', 'shortname' => 'mio']);
        $ajeno = Product::create(['name' => 'Producto ajeno', 'slug' => 'theme_ajeno', 'shortname' => 'ajeno']);

        $setupAjeno = $this->crearSetup($ajeno);

        // `firstOrFail` lanza la excepción, que en una petición real es un 404. En un
        // test de Livewire sale como excepción, así que se captura para poder
        // comprobar además que no se ha tocado nada.
        try {
            Livewire::test(SetupsIndex::class, ['product' => $mio])
                ->call('pedirCambioDeEstado', $setupAjeno->id, 'deprecated');

            $this->fail('Se esperaba que el setup de otro producto no se encontrara.');
        } catch (ModelNotFoundException) {
            // Es el resultado correcto.
        }

        $this->assertSame('active', $setupAjeno->fresh()->status, 'El setup ajeno no debía cambiar.');
    }

    public function test_tampoco_se_puede_duplicar_el_setup_de_otro_producto(): void
    {
        // Era el caso peor de los dos: el duplicado se creaba DENTRO del producto ajeno.
        $this->usuario(['admin.setups.index', 'admin.setups.edit', 'admin.setups.create']);

        $mio = Product::create(['name' => 'Mi producto 2', 'slug' => 'theme_mio2', 'shortname' => 'mio2']);
        $ajeno = Product::create(['name' => 'Producto ajeno 2', 'slug' => 'theme_ajeno2', 'shortname' => 'ajeno2']);

        $setupAjeno = $this->crearSetup($ajeno);

        try {
            Livewire::test(SetupsIndex::class, ['product' => $mio])
                ->call('duplicate', $setupAjeno->id);

            $this->fail('Se esperaba que el setup de otro producto no se encontrara.');
        } catch (ModelNotFoundException) {
            // Es el resultado correcto.
        }

        $this->assertSame(1, Setup::where('product_id', $ajeno->id)->count());
        $this->assertSame(0, Setup::where('product_id', $mio->id)->count());
    }

    public function test_con_permiso_y_producto_correcto_si_funciona(): void
    {
        // La contrapartida: el arreglo no puede haber roto el camino normal. El estado se
        // cambia donde ahora se cambia —en la modal—, y el listado solo tiene que dejar
        // pasar la petición de abrirla.
        $this->usuario(['admin.setups.index', 'admin.setups.edit']);

        $producto = Product::create(['name' => 'Producto ok', 'slug' => 'theme_ok', 'shortname' => 'ok']);
        $setup = $this->crearSetup($producto);

        Livewire::test(SetupsIndex::class, ['product' => $producto])
            ->call('pedirCambioDeEstado', $setup->id, 'deprecated')
            ->assertOk();

        $this->assertSame('active', $setup->fresh()->status, 'Abrir la modal no cambia nada todavía.');

        Livewire::test(CambioDeEstadoModal::class, [
            'tipo' => 'setups',
            'productId' => $producto->id,
            'versionId' => $setup->id,
            'estado' => 'deprecated',
        ])
            ->call('cambiar')
            ->assertOk();

        $this->assertSame('deprecated', $setup->fresh()->status);
    }

    /* ===================== El barrido ===================== */

    /**
     * @return string[]
     */
    private function ficherosDeLivewire(): array
    {
        $ficheros = [];
        $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(app_path('Livewire')));

        foreach ($it as $f) {
            if ($f->isFile() && $f->getExtension() === 'php') {
                $ficheros[] = $f->getPathname();
            }
        }

        sort($ficheros);

        return $ficheros;
    }

    /**
     * Métodos públicos del fichero que escriben en base de datos y no llaman a
     * `authorize()` ni a `can()`.
     *
     * @return string[] nombre del método
     */
    private function metodosMutadoresSinAutorizar(string $fichero): array
    {
        $codigo = file_get_contents($fichero);
        $encontrados = [];

        if (!preg_match_all('/\n    public function (\w+)\s*\(/', $codigo, $m, PREG_OFFSET_CAPTURE)) {
            return [];
        }

        foreach ($m[1] as $i => [$nombre, $_]) {
            if (in_array($nombre, self::CICLO_DE_VIDA, true)
                || str_starts_with($nombre, 'updated')
                || str_starts_with($nombre, 'updating')
                || str_starts_with($nombre, 'get')) {
                continue;
            }

            $cuerpo = $this->cuerpoDelMetodo($codigo, $m[0][$i][1]);

            if ($cuerpo === null || !$this->escribeEnBaseDeDatos($cuerpo)) {
                continue;
            }

            if (str_contains($cuerpo, 'authorize(') || str_contains($cuerpo, '->can(')) {
                continue;
            }

            $encontrados[] = $nombre;
        }

        return $encontrados;
    }

    /**
     * Cuerpo del método a partir de su firma, contando llaves.
     */
    private function cuerpoDelMetodo(string $codigo, int $posicionFirma): ?string
    {
        $inicio = strpos($codigo, '{', $posicionFirma);

        if ($inicio === false) {
            return null;
        }

        $nivel = 0;

        for ($i = $inicio, $largo = strlen($codigo); $i < $largo; $i++) {
            if ($codigo[$i] === '{') {
                $nivel++;
            }

            if ($codigo[$i] === '}') {
                $nivel--;

                if ($nivel === 0) {
                    return substr($codigo, $inicio, $i - $inicio + 1);
                }
            }
        }

        return null;
    }

    private function escribeEnBaseDeDatos(string $cuerpo): bool
    {
        foreach (self::MUTACIONES as $aguja) {
            if (str_contains($cuerpo, $aguja)) {
                return true;
            }
        }

        return false;
    }
}
