<?php

namespace Tests\Unit\Uploads;

use App\Uploads\Services\ArchiveInspectorService;
use App\Uploads\Services\InspectionResult;
use Tests\TestCase;
use ZipArchive;

class ArchiveInspectorServiceTest extends TestCase
{
    /** @var array<int, string> */
    private array $tempFiles = [];

    protected function tearDown(): void
    {
        foreach ($this->tempFiles as $file) {
            if (is_file($file)) {
                @unlink($file);
            }
        }

        parent::tearDown();
    }

    public function test_rejects_zip_slip_path(): void
    {
        $archive = $this->makeZip([
            '../evil.txt' => 'x',
        ]);

        $result = app(ArchiveInspectorService::class)->inspect($archive);

        $this->assertFalse($result->valid);
        $this->assertStringContainsString('path traversal', (string) $result->reason);
    }

    public function test_rejects_symlink_entries(): void
    {
        $archive = $this->makeZipWithCallback([
            'safe/file.txt' => 'ok',
            'safe/link' => 'target',
        ], function (ZipArchive $zip, string $entryName): void {
            if ($entryName === 'safe/link') {
                $zip->setExternalAttributesName($entryName, ZipArchive::OPSYS_UNIX, 0120777 << 16);
            }
        });

        $result = app(ArchiveInspectorService::class)->inspect($archive);

        $this->assertFalse($result->valid);
        $this->assertStringContainsString('symlink', strtolower((string) $result->reason));
    }

    public function test_rejects_deep_nesting(): void
    {
        $archive = $this->makeZip([
            'a/b/c/d/e/f/file.txt' => 'too deep',
        ]);

        $result = app(ArchiveInspectorService::class)->inspect($archive);

        $this->assertFalse($result->valid);
        $this->assertStringContainsString('depth', strtolower((string) $result->reason));
    }

    public function test_rejects_too_many_files(): void
    {
        $entries = [];
        for ($index = 0; $index < 5001; $index++) {
            $entries['f' . $index . '.txt'] = 'x';
        }

        $archive = $this->makeZip($entries);

        $result = app(ArchiveInspectorService::class)->inspect($archive);

        $this->assertFalse($result->valid);
        $this->assertStringContainsString('5000', (string) $result->reason);
    }

    public function test_rejects_executable_extensions(): void
    {
        $archive = $this->makeZip([
            'skins/readme.txt' => 'ok',
            'skins/run.exe' => 'MZ',
        ]);

        $result = app(ArchiveInspectorService::class)->inspect($archive);

        $this->assertFalse($result->valid);
        $this->assertStringContainsString('blocked', strtolower((string) $result->reason));
    }

    public function test_rejects_zip_bomb_ratio(): void
    {
        $archive = $this->makeZip([
            'payload.txt' => str_repeat('A', 6 * 1024 * 1024),
        ]);

        $result = app(ArchiveInspectorService::class)->inspect($archive);

        $this->assertFalse($result->valid);
        $this->assertStringContainsString('ratio', strtolower((string) $result->reason));
    }

    public function test_valid_archive_passes(): void
    {
        $archive = $this->makeZip([
            'skins/theme/readme.txt' => 'safe',
            'skins/theme/colors.ini' => 'accent=blue',
        ]);

        $result = app(ArchiveInspectorService::class)->inspect($archive);

        $this->assertInstanceOf(InspectionResult::class, $result);
        $this->assertTrue($result->valid);
        $this->assertNull($result->reason);
        $this->assertIsArray($result->stats);
        $this->assertArrayHasKey('files', $result->stats);
        $this->assertArrayHasKey('depth', $result->stats);
        $this->assertArrayHasKey('size', $result->stats);
        $this->assertArrayHasKey('ratio', $result->stats);
    }

    /**
     * @param array<string, string> $entries
     */
    private function makeZip(array $entries): string
    {
        return $this->makeZipWithCallback($entries, null);
    }

    /**
     * @param array<string, string> $entries
     * @param (callable(ZipArchive,string):void)|null $entryCallback
     */
    private function makeZipWithCallback(array $entries, ?callable $entryCallback): string
    {
        if (! class_exists(ZipArchive::class)) {
            $this->markTestSkipped('ZipArchive extension is required.');
        }

        $path = tempnam(sys_get_temp_dir(), 'sb_zip_');
        if ($path === false) {
            throw new \RuntimeException('Unable to create temporary zip path.');
        }

        $this->tempFiles[] = $path;

        $zip = new ZipArchive();
        if ($zip->open($path, ZipArchive::OVERWRITE | ZipArchive::CREATE) !== true) {
            throw new \RuntimeException('Unable to open temporary zip for writing.');
        }

        foreach ($entries as $name => $content) {
            $zip->addFromString($name, $content);

            if ($entryCallback !== null) {
                $entryCallback($zip, $name);
            }
        }

        $zip->close();

        return $path;
    }
}
