<?php

declare(strict_types=1);

namespace App\Tests\Unit\Enrichment;

use App\Infrastructure\Enrichment\Downloaders\DbipDownloader;
use App\Infrastructure\Enrichment\Downloaders\DownloaderException;
use PHPUnit\Framework\TestCase;

/**
 * SEC_REVIEW F49 — `DbipDownloader::gunzipWithCap` streams via
 * `gzopen`/`gzread` and aborts past a configurable cap so a
 * compromised DB-IP endpoint serving a tiny gzip whose decompressed
 * form is multi-GB cannot OOM the api or fill the disk.
 */
final class DbipDownloaderTest extends TestCase
{
    private string $tmpDir = '';

    protected function setUp(): void
    {
        $this->tmpDir = sys_get_temp_dir() . '/irdb-dbip-' . bin2hex(random_bytes(6));
        mkdir($this->tmpDir);
    }

    protected function tearDown(): void
    {
        if ($this->tmpDir !== '' && is_dir($this->tmpDir)) {
            foreach (glob($this->tmpDir . '/*') ?: [] as $f) {
                @unlink($f);
            }
            @rmdir($this->tmpDir);
        }
    }

    public function testNormalGunzipPasses(): void
    {
        $gzPath = $this->writeGzip('hello world');
        $outPath = $this->tmpDir . '/out.bin';

        DbipDownloader::gunzipWithCap($gzPath, $outPath, maxBytes: 1024);

        self::assertFileExists($outPath);
        self::assertSame('hello world', file_get_contents($outPath));
        // gz file is removed on success.
        self::assertFileDoesNotExist($gzPath);
    }

    public function testOutputOverCapIsRejectedAndCleanedUp(): void
    {
        // 4 KiB of plaintext, cap at 1 KiB.
        $gzPath = $this->writeGzip(str_repeat('A', 4096));
        $outPath = $this->tmpDir . '/out.bin';

        $threw = false;
        try {
            DbipDownloader::gunzipWithCap($gzPath, $outPath, maxBytes: 1024);
        } catch (DownloaderException $e) {
            $threw = true;
            self::assertStringContainsString('exceeds cap 1024 bytes', $e->getMessage());
        }
        self::assertTrue($threw, 'expected DownloaderException for over-cap output');
        // Partial output must NOT be left on disk — caller treats
        // existence of `$outPath` as "verified ready to swap".
        self::assertFileDoesNotExist($outPath);
        // gz input was NOT cleaned up (caller decides whether to retry).
        self::assertFileExists($gzPath);
    }

    public function testEmptyGzipIsRejected(): void
    {
        // gzip of an empty string produces a valid gzip with 0 bytes
        // payload — the verifier downstream needs SOMETHING; an empty
        // file is suspicious enough to bail on.
        $gzPath = $this->writeGzip('');
        $outPath = $this->tmpDir . '/out.bin';

        $threw = false;
        try {
            DbipDownloader::gunzipWithCap($gzPath, $outPath, maxBytes: 1024);
        } catch (DownloaderException $e) {
            $threw = true;
            self::assertStringContainsString('empty output', $e->getMessage());
        }
        self::assertTrue($threw);
        self::assertFileDoesNotExist($outPath);
    }

    public function testMissingInputIsRejected(): void
    {
        $this->expectException(DownloaderException::class);
        DbipDownloader::gunzipWithCap(
            $this->tmpDir . '/does-not-exist.gz',
            $this->tmpDir . '/out.bin',
            maxBytes: 1024,
        );
    }

    /**
     * Streams >chunk-size of plaintext through the gunzip helper to
     * prove the chunked read loop accumulates correctly.
     */
    public function testLargeInputStreamsCorrectly(): void
    {
        // 256 KiB of distinguishable bytes (4 × 64 KiB chunks).
        $payload = str_repeat('abcd', 256 * 1024 / 4);
        self::assertSame(256 * 1024, strlen($payload));
        $gzPath = $this->writeGzip($payload);
        $outPath = $this->tmpDir . '/big.bin';

        DbipDownloader::gunzipWithCap($gzPath, $outPath, maxBytes: 512 * 1024);

        self::assertFileExists($outPath);
        self::assertSame($payload, file_get_contents($outPath));
    }

    private function writeGzip(string $plain): string
    {
        $path = $this->tmpDir . '/' . bin2hex(random_bytes(4)) . '.gz';
        $compressed = gzencode($plain, 6);
        self::assertNotFalse($compressed);
        file_put_contents($path, $compressed);

        return $path;
    }
}
