<?php

declare(strict_types=1);

namespace App\Infrastructure\Enrichment\Downloaders;

use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use PharData;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use Throwable;

/**
 * Opt-in — requires MAXMIND_LICENSE_KEY. Downloads the permalink
 * tarball and verifies SHA-256 against the published companion file.
 *
 * The MaxMind tarball nests one directory like
 * `GeoLite2-Country_20260427/GeoLite2-Country.mmdb`. We extract and
 * walk to find the .mmdb leaf.
 *
 * The license key is never logged: error messages substitute a
 * fixed-shape URL token instead of the real query string.
 */
final class MaxMindDownloader implements GeoIpDownloader
{
    private const ENDPOINT = 'https://download.maxmind.com/app/geoip_download';

    /**
     * SEC_REVIEW F48: per-entry uncompressed-size cap. Real GeoLite2
     * country/ASN MMDBs are ~6–7 MiB; 200 MiB is generous headroom
     * against future growth while bounding the worst case at "no
     * single entry can fill a small disk by itself".
     */
    private const MAX_ENTRY_BYTES = 200 * 1024 * 1024;

    /**
     * Per-archive cap on the sum of every entry's uncompressed size.
     * The legitimate tarball is two MMDBs plus a couple of small text
     * files (COPYRIGHT, LICENSE); 400 MiB sits well above that and
     * well below "this could exhaust /data".
     */
    private const MAX_TOTAL_BYTES = 400 * 1024 * 1024;

    public function __construct(
        private readonly ClientInterface $http,
        private readonly string $licenseKey,
    ) {
    }

    public function name(): string
    {
        return 'maxmind';
    }

    public function requiresCredential(): bool
    {
        return true;
    }

    public function hasCredential(): bool
    {
        return $this->licenseKey !== '';
    }

    public function download(string $tempDir): array
    {
        $countryPath = $this->fetchEdition($tempDir, 'GeoLite2-Country', 'country');
        MmdbVerifier::assertCountry($countryPath);

        $asnPath = $this->fetchEdition($tempDir, 'GeoLite2-ASN', 'asn');
        MmdbVerifier::assertAsn($asnPath);

        return ['country' => $countryPath, 'asn' => $asnPath];
    }

    private function fetchEdition(string $tempDir, string $edition, string $kind): string
    {
        $tarPath = $tempDir . '/' . $kind . '.tar.gz';
        $sha256Path = $tempDir . '/' . $kind . '.tar.gz.sha256';

        $this->fetchTo($edition, 'tar.gz', $tarPath);
        $this->fetchTo($edition, 'tar.gz.sha256', $sha256Path);

        $expected = trim((string) file_get_contents($sha256Path));
        // The .sha256 file is "<hash>  <filename>"; take the leading hash.
        if (preg_match('/^([0-9a-f]{64})/', $expected, $m) !== 1) {
            throw new DownloaderException(sprintf('maxmind %s sha256 file unparseable', $kind));
        }
        $expectedHash = $m[1];
        $actualHash = hash_file('sha256', $tarPath);
        if ($actualHash !== $expectedHash) {
            throw new DownloaderException(sprintf(
                'maxmind %s sha256 mismatch (expected %s, got %s)',
                $kind,
                $expectedHash,
                (string) $actualHash,
            ));
        }

        $extractDir = $tempDir . '/' . $kind . '-extract';
        if (!mkdir($extractDir) && !is_dir($extractDir)) {
            throw new DownloaderException(sprintf('cannot mkdir %s', $extractDir));
        }
        try {
            $phar = new PharData($tarPath);
            // SEC_REVIEW F48: walk the archive before extracting and
            // enforce a per-entry + total uncompressed-size cap. A
            // small .tar.gz that decompresses into multi-GB would
            // otherwise exhaust disk before MmdbVerifier could see
            // the file. PharData entries' `getSize()` reports the
            // uncompressed size, so we can do the check without
            // touching the filesystem.
            self::assertSizeBudget($phar, $kind);
            $phar->extractTo($extractDir, null, true);
        } catch (DownloaderException $e) {
            throw $e;
        } catch (Throwable $e) {
            throw new DownloaderException(
                sprintf('maxmind %s extract failed: %s', $kind, $e->getMessage()),
                0,
                $e,
            );
        }

        $mmdbPath = $this->findMmdb($extractDir);
        if ($mmdbPath === null) {
            throw new DownloaderException(sprintf('maxmind %s tarball had no .mmdb', $kind));
        }
        $finalPath = $tempDir . '/' . $kind . '.mmdb';
        if (!@rename($mmdbPath, $finalPath)) {
            throw new DownloaderException(sprintf('rename %s -> %s failed', $mmdbPath, $finalPath));
        }

        return $finalPath;
    }

    private function fetchTo(string $edition, string $suffix, string $sink): void
    {
        $url = sprintf('%s?edition_id=%s&license_key=%s&suffix=%s', self::ENDPOINT, $edition, $this->licenseKey, $suffix);
        try {
            $this->http->request('GET', $url, ['sink' => $sink]);
        } catch (GuzzleException $e) {
            // Sanitise the URL in the surfaced message.
            $sanitised = sprintf('%s?edition_id=%s&license_key=***&suffix=%s', self::ENDPOINT, $edition, $suffix);

            throw new DownloaderException(sprintf('GET %s failed', $sanitised), 0, $e);
        }
    }

    private function findMmdb(string $dir): ?string
    {
        $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS));
        foreach ($it as $file) {
            if ($file->isFile() && str_ends_with($file->getFilename(), '.mmdb')) {
                return $file->getPathname();
            }
        }

        return null;
    }

    /**
     * SEC_REVIEW F48: zip-bomb guard for the MaxMind tarball. Walks
     * every entry recursively, summing uncompressed sizes, and
     * throws if any single entry exceeds `$maxEntryBytes` or the
     * total exceeds `$maxTotalBytes`. The check runs *before*
     * `extractTo`, so an attacker who serves a bomb tarball never
     * gets a single decompressed byte on disk.
     *
     * Public-but-`@internal` so tests can drive it with small caps
     * without having to build a real >200 MiB tarball.
     *
     * @internal
     */
    public static function assertSizeBudget(
        PharData $phar,
        string $kind,
        int $maxEntryBytes = self::MAX_ENTRY_BYTES,
        int $maxTotalBytes = self::MAX_TOTAL_BYTES,
    ): void {
        $total = 0;
        $it = new RecursiveIteratorIterator($phar);
        foreach ($it as $entry) {
            if (!$entry->isFile()) {
                continue;
            }
            $size = $entry->getSize();
            if ($size > $maxEntryBytes) {
                throw new DownloaderException(sprintf(
                    'maxmind %s entry %s uncompressed size %d > cap %d',
                    $kind,
                    $entry->getFilename(),
                    $size,
                    $maxEntryBytes,
                ));
            }
            $total += $size;
            if ($total > $maxTotalBytes) {
                throw new DownloaderException(sprintf(
                    'maxmind %s archive uncompressed size > cap %d',
                    $kind,
                    $maxTotalBytes,
                ));
            }
        }
    }
}
