<?php

declare(strict_types=1);

namespace Grav\Plugin\Api\Tests\Unit\Controllers;

use Grav\Common\Config\Config;
use Grav\Plugin\Api\Controllers\MediaController;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Services\UploadFieldSettings;
use Grav\Plugin\Api\Tests\Unit\TestHelper;
use PHPUnit\Framework\Attributes\CoversTrait;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
use ReflectionMethod;

/**
 * Unit coverage for the shared upload pipeline (HandlesMediaUploads). The trait
 * is the security-critical, storage-agnostic core reused by both page media and
 * flex-object media, so it carries the validation guarantees that matter:
 * dangerous extensions are blocked, traversal filenames are rejected, the size
 * cap is enforced, and a clean file lands in the target folder.
 *
 * Exercised through MediaController since it uses the trait; the methods under
 * test are storage-agnostic, so the same behavior applies to FlexApiController.
 */
#[CoversTrait(\Grav\Plugin\Api\Controllers\HandlesMediaUploads::class)]
class HandlesMediaUploadsTest extends TestCase
{
    private string $tempDir;
    private MediaController $controller;

    protected function setUp(): void
    {
        $this->tempDir = sys_get_temp_dir() . '/grav_api_media_trait_' . uniqid();
        mkdir($this->tempDir, 0775, true);

        $config = new Config([
            'security' => [
                'uploads_dangerous_extensions' => ['php', 'phtml', 'phar', 'js', 'html'],
                // Core's Security::sanitizeSVG() is gated on this flag.
                'sanitize_svg' => true,
            ],
        ]);

        // createMockGrav installs the Grav singleton the base controller reads.
        $grav = TestHelper::createMockGrav(['config' => $config]);
        $this->controller = new MediaController($grav, $config);
    }

    protected function tearDown(): void
    {
        $this->rmrf($this->tempDir);
    }

    #[Test]
    public function a_clean_file_lands_in_the_target_folder(): void
    {
        $file = new TraitTestUploadedFile('avatar.png', 'binary-png-data');

        $name = $this->invoke('processUploadedFile', $file, $this->tempDir);

        self::assertSame('avatar.png', $name);
        self::assertFileExists($this->tempDir . '/avatar.png');
        self::assertSame('binary-png-data', file_get_contents($this->tempDir . '/avatar.png'));
    }

    #[Test]
    public function dangerous_extension_is_rejected_and_no_file_is_written(): void
    {
        $file = new TraitTestUploadedFile('shell.php', '<?php evil();');

        try {
            $this->invoke('processUploadedFile', $file, $this->tempDir);
            self::fail('Expected ValidationException for a .php upload.');
        } catch (ValidationException) {
            self::assertFileDoesNotExist($this->tempDir . '/shell.php');
        }
    }

    #[Test]
    public function double_extension_disguised_as_image_is_rejected(): void
    {
        // "shell.php.jpg" passes a naive last-extension check (".jpg") but the
        // ".php" must still be caught — the double-extension RCE bypass
        // (GHSA-66v2-vxxf-xc3v).
        $file = new TraitTestUploadedFile('shell.php.jpg', '<?php evil();');

        try {
            $this->invoke('processUploadedFile', $file, $this->tempDir);
            self::fail('Expected ValidationException for a .php.jpg upload.');
        } catch (ValidationException) {
            self::assertFileDoesNotExist($this->tempDir . '/shell.php.jpg');
        }
    }

    #[Test]
    public function dangerous_extension_in_any_position_is_rejected(): void
    {
        // The dangerous component need not be adjacent to the final extension.
        $file = new TraitTestUploadedFile('archive.phtml.tar.gz', 'payload');

        $this->expectException(ValidationException::class);
        $this->invoke('processUploadedFile', $file, $this->tempDir);
    }

    /**
     * @return array<string, array{string}>
     */
    public static function unsafeFilenames(): array
    {
        return [
            'encoded null byte' => ['shell.php%00.png'],
            'encoded trailing space' => ['shell.php%20.png'],
            'trailing space' => ['photo.png '],
            'trailing non-breaking space' => ["photo.png\u{00A0}"],
            'trailing dot' => ['photo.png.'],
            'zero-width space' => ["photo\u{200B}.png"],
            'right-to-left override' => ["photo\u{202E}gnp.png"],
            'control character' => ["photo\x01.png"],
        ];
    }

    #[Test]
    #[DataProvider('unsafeFilenames')]
    public function unsafe_filename_is_rejected_and_no_file_is_written(string $filename): void
    {
        $file = new TraitTestUploadedFile($filename, 'png');

        try {
            $this->invoke('processUploadedFile', $file, $this->tempDir);
            self::fail('Expected the filename to be refused.');
        } catch (ValidationException $e) {
            self::assertSame(422, $e->getStatusCode());
        }

        self::assertSame([], array_values(array_diff(scandir($this->tempDir) ?: [], ['.', '..'])));
    }

    #[Test]
    public function percent_sign_in_a_filename_is_accepted(): void
    {
        $file = new TraitTestUploadedFile('100%.pdf', 'pdf');

        self::assertSame('100%.pdf', $this->invoke('processUploadedFile', $file, $this->tempDir));
        self::assertFileExists($this->tempDir . '/100%.pdf');
    }

    #[Test]
    public function multi_dot_safe_filename_is_accepted(): void
    {
        // Dots are legitimate in names; only dangerous extension components are
        // blocked, not every dot-separated segment.
        $file = new TraitTestUploadedFile('my.vacation.photo.png', 'png-bytes');

        $name = $this->invoke('processUploadedFile', $file, $this->tempDir);

        self::assertSame('my.vacation.photo.png', $name);
        self::assertFileExists($this->tempDir . '/my.vacation.photo.png');
    }

    #[Test]
    public function svg_upload_is_sanitized_of_embedded_script(): void
    {
        // A stored SVG must not keep an executable <script> payload that would
        // run when served inline as image/svg+xml (GHSA-7vhm-8x52-2r5p).
        $svg = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(document.cookie)</script>'
            . '<rect width="10" height="10"/></svg>';
        $file = new TraitTestUploadedFile('logo.svg', $svg);

        $name = $this->invoke('processUploadedFile', $file, $this->tempDir);

        self::assertSame('logo.svg', $name);
        $written = file_get_contents($this->tempDir . '/logo.svg');
        self::assertStringNotContainsStringIgnoringCase('<script', $written);
        self::assertStringNotContainsStringIgnoringCase('alert(', $written);
    }

    #[Test]
    public function svg_detection_reads_the_file_not_the_extension(): void
    {
        // The sanitizer gate used to be the literal extension "svg", so the
        // same markup under any other name a browser still renders inline —
        // `.svgz` above all — went to disk untouched (GHSA-66xf-ggf4-6hmc).
        $svg = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>';

        $cases = [
            'logo.svg' => [$svg, true],
            'logo.svgz' => [(string)gzencode($svg), true],
            // Markup parked under a name that looks like a raster image.
            'logo.webp' => [$svg, true],
            // ...and the same trick with the bytes compressed.
            'logo.png' => [(string)gzencode($svg), true],
            'photo.png' => ['\x89PNG not-really-but-not-svg-either', false],
            // A real PNG whose metadata happens to contain "<svg" (#70).
            'chunk.png' => [$this->pngWithTextChunk("Comment\0<svg>"), false],
        ];

        foreach ($cases as $name => [$bytes, $expected]) {
            $path = $this->tempDir . '/' . $name;
            file_put_contents($path, $bytes);

            self::assertSame(
                $expected,
                $this->invoke('isSvgUpload', $path, $name),
                "isSvgUpload() misjudged {$name}"
            );
        }
    }

    #[Test]
    public function a_png_that_mentions_svg_in_its_metadata_is_stored_intact(): void
    {
        // A text chunk right after IHDR puts "<svg" in the first 1024 bytes of
        // an otherwise ordinary PNG. Sniffing the binary as text took it for SVG
        // and the sanitizer emptied it to 0 bytes (getgrav/grav-plugin-api#70).
        $png = $this->pngWithTextChunk("Comment\0exported from <svg> editor");
        $file = new TraitTestUploadedFile('photo.png', $png);

        $name = $this->invoke('processUploadedFile', $file, $this->tempDir);

        self::assertSame('photo.png', $name);
        self::assertSame($png, file_get_contents($this->tempDir . '/photo.png'));
    }

    #[Test]
    public function an_svg_the_sanitizer_empties_is_rejected_not_stored_as_zero_bytes(): void
    {
        $file = new TraitTestUploadedFile('broken.svg', "\x00\x01 not markup at all");

        try {
            $this->invoke('processUploadedFile', $file, $this->tempDir);
            self::fail('Expected ValidationException for an SVG that sanitizes to nothing.');
        } catch (ValidationException) {
            self::assertFileDoesNotExist($this->tempDir . '/broken.svg');
        }
    }

    #[Test]
    public function a_namespaced_svg_root_is_detected_under_another_extension(): void
    {
        $path = $this->tempDir . '/drawing.xml';
        file_put_contents($path, '<s:svg xmlns:s="http://www.w3.org/2000/svg"><s:rect width="10" height="10"/></s:svg>');
        self::assertTrue($this->invoke('isSvgUpload', $path, 'drawing.xml'));
    }

    #[Test]
    public function raw_upload_bytes_are_never_moved_to_the_public_folder(): void
    {
        $file = new class ($this->tempDir) implements UploadedFileInterface {
            public function __construct(private string $publicDir) {}
            public function getError(): int { return UPLOAD_ERR_OK; }
            public function getSize(): ?int { return 64; }
            public function getClientFilename(): ?string { return 'logo.svg'; }
            public function getClientMediaType(): ?string { return 'image/svg+xml'; }
            public function getStream(): StreamInterface { throw new \LogicException('Unused.'); }
            public function moveTo(string $targetPath): void
            {
                TestCase::assertNotSame($this->publicDir, dirname($targetPath));
                file_put_contents($targetPath, '<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10"/></svg>');
            }
        };
        self::assertSame('logo.svg', $this->invoke('processUploadedFile', $file, $this->tempDir));
    }

    /** A real 1x1 PNG with a tEXt chunk inserted straight after IHDR. */
    private function pngWithTextChunk(string $text): string
    {
        $png = (string)base64_decode(
            'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
        );
        $chunk = pack('N', strlen($text)) . 'tEXt' . $text . pack('N', crc32('tEXt' . $text));

        return substr($png, 0, 33) . $chunk . substr($png, 33);
    }

    #[Test]
    public function extensionless_file_is_rejected(): void
    {
        $file = new TraitTestUploadedFile('README', 'no extension');

        $this->expectException(ValidationException::class);
        $this->invoke('processUploadedFile', $file, $this->tempDir);
    }

    #[Test]
    public function traversal_filename_is_rejected(): void
    {
        $file = new TraitTestUploadedFile('../../evil.png', 'png');

        // basename() strips the path, but the resulting name still must not be
        // a dotfile or contain traversal markers — assert nothing escapes.
        try {
            $this->invoke('processUploadedFile', $file, $this->tempDir);
        } catch (ValidationException) {
            // acceptable — rejected outright
        }

        self::assertFileDoesNotExist(dirname($this->tempDir) . '/evil.png');
    }

    #[Test]
    public function dotfile_is_rejected(): void
    {
        $file = new TraitTestUploadedFile('.htaccess', 'deny');

        $this->expectException(ValidationException::class);
        $this->invoke('processUploadedFile', $file, $this->tempDir);
    }

    #[Test]
    public function oversize_file_is_rejected(): void
    {
        // Reports a size beyond the 64 MB cap without writing 64 MB to disk.
        $file = new TraitTestUploadedFile('big.png', 'x', 67_108_864 + 1);

        $this->expectException(ValidationException::class);
        $this->invoke('processUploadedFile', $file, $this->tempDir);
    }

    #[Test]
    public function random_name_replaces_the_filename_but_keeps_the_extension(): void
    {
        $file = new TraitTestUploadedFile('My Photo.PNG', 'png-bytes');
        $settings = UploadFieldSettings::fromParams(['random_name' => '1']);

        $name = $this->invoke('processUploadedFile', $file, $this->tempDir, $settings);

        self::assertNotSame('My Photo.PNG', $name);
        self::assertMatchesRegularExpression('/^[a-z0-9]{15}\.png$/', $name);
        self::assertFileExists($this->tempDir . '/' . $name);
        self::assertFileDoesNotExist($this->tempDir . '/My Photo.PNG');
    }

    #[Test]
    public function avoid_overwriting_prefixes_a_conflicting_filename(): void
    {
        // Pre-seed a colliding file so the conflict branch fires.
        file_put_contents($this->tempDir . '/logo.png', 'existing');

        $file = new TraitTestUploadedFile('logo.png', 'new-bytes');
        $settings = UploadFieldSettings::fromParams(['avoid_overwriting' => true]);

        $name = $this->invoke('processUploadedFile', $file, $this->tempDir, $settings);

        self::assertMatchesRegularExpression('/^\d{14}-logo\.png$/', $name);
        self::assertSame('existing', file_get_contents($this->tempDir . '/logo.png'));
        self::assertSame('new-bytes', file_get_contents($this->tempDir . '/' . $name));
    }

    #[Test]
    public function accept_allowlist_rejects_a_non_matching_file(): void
    {
        $file = new TraitTestUploadedFile('notes.txt', 'text');
        $settings = UploadFieldSettings::fromParams(['accept' => 'image/*']);

        $this->expectException(ValidationException::class);
        $this->invoke('processUploadedFile', $file, $this->tempDir, $settings);
    }

    #[Test]
    public function accept_allowlist_admits_a_matching_extension(): void
    {
        $file = new TraitTestUploadedFile('photo.png', 'png');
        $settings = UploadFieldSettings::fromParams(['accept' => '.png,.jpg']);

        $name = $this->invoke('processUploadedFile', $file, $this->tempDir, $settings);

        self::assertSame('photo.png', $name);
        self::assertFileExists($this->tempDir . '/photo.png');
    }

    #[Test]
    public function per_field_filesize_limit_is_enforced_under_the_hard_cap(): void
    {
        // 2 MB file against a 1 MB per-field limit — well under the 64 MB cap.
        $file = new TraitTestUploadedFile('big.png', 'x', 2 * 1_048_576);
        $settings = UploadFieldSettings::fromParams(['filesize' => 1]);

        $this->expectException(ValidationException::class);
        $this->invoke('processUploadedFile', $file, $this->tempDir, $settings);
    }

    #[Test]
    public function random_name_cannot_smuggle_a_dangerous_extension(): void
    {
        // The extension floor runs before random_name, which preserves the
        // extension — so a .php upload is still rejected even with randomizing.
        $file = new TraitTestUploadedFile('shell.php', '<?php evil();');
        $settings = UploadFieldSettings::fromParams(['random_name' => '1']);

        $this->expectException(ValidationException::class);
        $this->invoke('processUploadedFile', $file, $this->tempDir, $settings);
    }

    #[Test]
    public function nested_uploaded_files_are_flattened(): void
    {
        $a = new TraitTestUploadedFile('a.png', 'a');
        $b = new TraitTestUploadedFile('b.png', 'b');
        $c = new TraitTestUploadedFile('c.png', 'c');

        // Mirrors PSR-7 nesting: files[gallery][] alongside files[avatar].
        $flat = $this->invoke('flattenUploadedFiles', ['avatar' => $a, 'gallery' => [$b, $c]]);

        self::assertCount(3, $flat);
        self::assertContainsOnlyInstancesOf(UploadedFileInterface::class, $flat);
    }

    private function invoke(string $method, mixed ...$args): mixed
    {
        $ref = new ReflectionMethod($this->controller, $method);
        return $ref->invoke($this->controller, ...$args);
    }

    private function rmrf(string $path): void
    {
        if (is_file($path) || is_link($path)) {
            unlink($path);
            return;
        }
        if (!is_dir($path)) {
            return;
        }
        foreach (scandir($path) ?: [] as $item) {
            if ($item === '.' || $item === '..') {
                continue;
            }
            $this->rmrf($path . '/' . $item);
        }
        rmdir($path);
    }
}

final class TraitTestUploadedFile implements UploadedFileInterface
{
    private readonly string $source;
    private bool $moved = false;

    public function __construct(
        private readonly string $filename,
        string $contents,
        private readonly ?int $reportedSize = null,
    ) {
        $this->source = tempnam(sys_get_temp_dir(), 'grav_api_trait_upload_') ?: '';
        file_put_contents($this->source, $contents);
    }

    public function __destruct()
    {
        if (!$this->moved && is_file($this->source)) {
            unlink($this->source);
        }
    }

    public function getStream(): StreamInterface
    {
        throw new \RuntimeException('Not needed in tests.');
    }

    public function moveTo(string $targetPath): void
    {
        if ($this->moved) {
            throw new \RuntimeException('File already moved.');
        }
        $dir = dirname($targetPath);
        if (!is_dir($dir)) {
            mkdir($dir, 0775, true);
        }
        rename($this->source, $targetPath);
        $this->moved = true;
    }

    public function getSize(): ?int
    {
        return $this->reportedSize ?? (file_exists($this->source) ? filesize($this->source) : null);
    }

    public function getError(): int { return UPLOAD_ERR_OK; }
    public function getClientFilename(): ?string { return $this->filename; }
    public function getClientMediaType(): ?string { return 'application/octet-stream'; }
}
