<?php

namespace Tests\Feature\ScssCdn;

use App\Models\Products\Product;
use App\Models\ScssCdn\ScssCdnBundle;
use App\Services\ScssCdn\ScssCdnStorageService;
use App\Services\ScssCdn\ScssCdnZipService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

/**
 * Un ZIP no puede escribir fuera de su bundle.
 *
 * **Esto se pudo explotar, y está reproducido.** El nombre de cada entrada del ZIP se
 * normalizaba de separadores y de la barra inicial, pero **nunca de los `..`**. Así que una
 * entrada llamada `a/../../../otro-producto/2026010100/main.scss` se guardaba tal cual, y
 * Flysystem la resolvía a un sitio **dentro del disco pero fuera del bundle**: subiendo un
 * ZIP al producto X se **sobrescribía el SCSS del bundle de otro producto**, y a partir de
 * ahí ese CSS manipulado se le sirve al Moodle de otro cliente.
 *
 * **Y por qué era fácil creer que Flysystem lo tapaba**: su `PathTraversalDetected` salta
 * cuando la ruta escapa de la **raíz del disco**, no de la carpeta que nosotros
 * consideramos el límite. Con cuatro `..` desde `SCSS_CDN/x/2026010100/a` se llega a la raíz
 * sin salirse de ella, así que para Flysystem es una ruta válida. Con un `..` de más
 * devolvía `false`; **con el número justo, escribía**. Eso es lo que hacía que pareciera
 * protegido: la primera prueba que uno hace es la que falla sola.
 *
 * El límite lo tiene que poner quien lo define, y eso somos nosotros.
 */
class RutasDelZipTest extends TestCase
{
    use RefreshDatabase;

    private function producto(string $slug): Product
    {
        return Product::create(['name' => 'Producto ' . $slug, 'slug' => $slug, 'actived' => true]);
    }

    private function bundle(Product $producto, string $version = '2026010100'): ScssCdnBundle
    {
        return ScssCdnBundle::create([
            'product_id' => $producto->id,
            'version' => $version,
            'description' => 'Bundle de prueba',
        ]);
    }

    /** Un ZIP de verdad, con los nombres de entrada que se le pidan. */
    private function zip(array $entradas): string
    {
        $ruta = tempnam(sys_get_temp_dir(), 'scss') . '.zip';

        $zip = new \ZipArchive;
        $zip->open($ruta, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);

        foreach ($entradas as $nombre => $contenido) {
            $zip->addFromString($nombre, $contenido);
        }

        $zip->close();

        return $ruta;
    }

    public function test_un_zip_no_puede_sobrescribir_el_bundle_de_otro_producto(): void
    {
        // **El ataque, tal como se reprodujo.**
        Storage::fake(config('filesystems.default'));

        $victima = $this->producto('otro-cliente');
        $suBundle = $this->bundle($victima);
        ScssCdnStorageService::saveFile($victima, $suBundle, 'main.scss', '/* el bueno */');

        $atacante = $this->producto('x');
        $bundleDelAtacante = $this->bundle($atacante);

        try {
            ScssCdnStorageService::saveFile(
                $atacante,
                $bundleDelAtacante,
                'a/../../../otro-cliente/2026010100/main.scss',
                '/* INYECTADO */'
            );
        } catch (\InvalidArgumentException $e) {
            // Es la respuesta correcta: se rechaza la ruta.
        }

        $this->assertSame(
            '/* el bueno */',
            ScssCdnStorageService::getFile($victima, $suBundle, 'main.scss'),
            'Un ZIP ha sobrescrito el SCSS del bundle de otro producto.'
        );
    }

    public function test_la_ruta_con_dos_puntos_se_rechaza_en_el_almacenamiento(): void
    {
        // La comprobación va en la construcción de la ruta, así que protege **todas** las
        // puertas —la subida, el editor de un fichero, lo que se añada— y no solo la que se
        // conocía el día del arreglo.
        Storage::fake(config('filesystems.default'));

        $producto = $this->producto('x');
        $bundle = $this->bundle($producto);

        foreach ([
            '../fuera.scss',
            'a/../../fuera.scss',
            'a/b/../../../../fuera.scss',
        ] as $ruta) {
            $lanzo = false;

            try {
                ScssCdnStorageService::saveFile($producto, $bundle, $ruta, 'body{}');
            } catch (\InvalidArgumentException $e) {
                $lanzo = true;
            }

            $this->assertTrue($lanzo, "La ruta «{$ruta}» debería rechazarse.");
        }
    }

    public function test_las_rutas_normales_siguen_funcionando(): void
    {
        // El arreglo no puede llevarse por delante los árboles de siempre: subcarpetas,
        // parciales con guion bajo.
        Storage::fake(config('filesystems.default'));

        $producto = $this->producto('x');
        $bundle = $this->bundle($producto);

        foreach ([
            'main.scss',
            '_variables.scss',
            'componentes/_botones.scss',
            'utils/mixins/_media.scss',
        ] as $ruta) {
            $this->assertTrue(
                ScssCdnStorageService::saveFile($producto, $bundle, $ruta, 'body{}'),
                "La ruta «{$ruta}» tendría que guardarse."
            );

            $this->assertSame('body{}', ScssCdnStorageService::getFile($producto, $bundle, $ruta));
        }
    }

    public function test_la_validacion_del_zip_avisa_antes_de_subir_nada(): void
    {
        // **En la validación previa y no solo al extraer**: es la que lee la pantalla, y el
        // error tiene que salir antes de haber escrito un solo fichero.
        $zip = $this->zip([
            'main.scss' => "@import 'variables';",
            'a/../../../fuera.scss' => 'body{}',
        ]);

        $resultado = ScssCdnZipService::validateZipStructure($zip);

        $this->assertFalse($resultado['valid']);
        $this->assertStringContainsString(
            'sale de su carpeta',
            implode(' | ', $resultado['errors'])
        );

        @unlink($zip);
    }

    public function test_extraer_un_zip_asi_lanza_y_no_devuelve_nada(): void
    {
        // No se ignora la entrada y se sigue con las demás: un ZIP con una ruta así no es un
        // descuido de quien lo comprimió, así que no se acepta nada de él.
        $zip = $this->zip([
            'main.scss' => "@import 'variables';",
            '_variables.scss' => '$rojo: red;',
            'a/../../../fuera.scss' => 'body{}',
        ]);

        $this->expectException(\Exception::class);
        $this->expectExceptionMessageMatches('/sale de su carpeta/');

        try {
            ScssCdnZipService::extractZip($zip);
        } finally {
            @unlink($zip);
        }
    }

    public function test_una_ruta_absoluta_tambien_se_rechaza(): void
    {
        $zip = $this->zip([
            '/etc/algo.scss' => 'body{}',
        ]);

        $resultado = ScssCdnZipService::validateZipStructure($zip);

        $this->assertFalse($resultado['valid']);

        @unlink($zip);
    }

    public function test_un_zip_normal_sigue_pasando_la_validacion(): void
    {
        $zip = $this->zip([
            'main.scss' => "@import 'variables';\n@import 'componentes/botones';",
            '_variables.scss' => '$rojo: red;',
            'componentes/_botones.scss' => '.btn {}',
        ]);

        $resultado = ScssCdnZipService::validateZipStructure($zip);

        $this->assertTrue($resultado['valid'], implode(' | ', $resultado['errors']));
        $this->assertSame(3, $resultado['scss_count']);

        @unlink($zip);
    }
}
