<?php

declare(strict_types=1);

namespace App\Infrastructure\Enrichment\Downloaders;

use App\Domain\Time\Clock;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use Throwable;

/**
 * Default provider — no auth, CC BY 4.0. Files publish on/around the
 * 1st of every month at:
 *   https://download.db-ip.com/free/dbip-{country,asn}-lite-YYYY-MM.mmdb.gz
 *
 * On 404 (rollover edge: the new month exists but the file isn't up
 * yet) we fall back exactly one month and try again. No SHA-256 is
 * published; integrity is gzip-decode + MMDB-metadata + node-count.
 */
final class DbipDownloader implements GeoIpDownloader
{
    private const BASE_URL = 'https://download.db-ip.com/free';

    /**
     * SEC_REVIEW F49: cap on the total bytes we'll decompress out of a
     * DB-IP gzip. Real `dbip-country-lite-*.mmdb` is ~10 MiB and
     * `dbip-asn-lite-*.mmdb` is ~5 MiB; 400 MiB is generous against
     * future growth while bounding the worst case at "no single
     * gunzip can fill /data". Mirrors the MaxMind tarball total cap
     * so both downloaders agree on what "too big" looks like.
     */
    private const MAX_DECOMPRESSED_BYTES = 400 * 1024 * 1024;

    /**
     * Stream chunk size for `gzread`. Decoupled from the cap so the
     * peak memory footprint of a download is the chunk, not the
     * decompressed file.
     */
    private const GUNZIP_CHUNK_BYTES = 64 * 1024;

    public function __construct(
        private readonly ClientInterface $http,
        private readonly Clock $clock,
    ) {
    }

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

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

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

    public function download(string $tempDir): array
    {
        $now = $this->clock->now();
        $stamps = [
            $now->format('Y-m'),
            $now->modify('first day of last month')->format('Y-m'),
        ];

        $countryPath = $this->downloadOne(
            $tempDir,
            'country',
            'dbip-country-lite',
            $stamps,
        );
        MmdbVerifier::assertCountry($countryPath);

        $asnPath = $this->downloadOne(
            $tempDir,
            'asn',
            'dbip-asn-lite',
            $stamps,
        );
        MmdbVerifier::assertAsn($asnPath);

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

    /**
     * @param list<string> $stamps Try month stamps in order; cap at 2 (current + 1 fallback).
     */
    private function downloadOne(string $tempDir, string $kind, string $slug, array $stamps): string
    {
        $lastError = null;
        foreach ($stamps as $stamp) {
            $url = sprintf('%s/%s-%s.mmdb.gz', self::BASE_URL, $slug, $stamp);
            $gzPath = $tempDir . '/' . $kind . '.mmdb.gz';
            try {
                $this->http->request('GET', $url, ['sink' => $gzPath]);
            } catch (RequestException $e) {
                $status = $e->getResponse()?->getStatusCode();
                $lastError = sprintf('GET %s: HTTP %d', $url, $status ?? 0);
                if ($status === 404) {
                    continue; // try fallback month
                }

                throw new DownloaderException($lastError, 0, $e);
            } catch (GuzzleException $e) {
                throw new DownloaderException(sprintf('GET %s: %s', $url, $e->getMessage()), 0, $e);
            }

            $mmdbPath = $tempDir . '/' . $kind . '.mmdb';
            $this->gunzip($gzPath, $mmdbPath);

            return $mmdbPath;
        }

        throw new DownloaderException(
            sprintf('dbip %s download exhausted fallback (last: %s)', $kind, $lastError ?? 'unknown'),
        );
    }

    /**
     * Streaming gunzip with a cap on the decompressed output
     * (SEC_REVIEW F49). Reads {@see GUNZIP_CHUNK_BYTES} at a time so
     * the peak memory footprint is the chunk, not the file; aborts
     * before exceeding {@see MAX_DECOMPRESSED_BYTES}. A malicious or
     * compromised DB-IP endpoint serving a gzip whose decompressed
     * form is multi-GB no longer OOMs the api or fills the disk.
     *
     * Public-but-`@internal` so the unit test can drive it directly
     * with a small fixture; production call site uses the default
     * cap. Wraps the production `gunzip()` method so the real call
     * site stays a single-arg path.
     *
     * @internal
     */
    public static function gunzipWithCap(string $gzPath, string $outPath, int $maxBytes): void
    {
        self::gunzipImpl($gzPath, $outPath, $maxBytes);
    }

    private function gunzip(string $gzPath, string $outPath): void
    {
        self::gunzipImpl($gzPath, $outPath, self::MAX_DECOMPRESSED_BYTES);
    }

    private static function gunzipImpl(string $gzPath, string $outPath, int $maxBytes): void
    {
        $in = @gzopen($gzPath, 'rb');
        if ($in === false) {
            throw new DownloaderException(sprintf('cannot gzopen %s', $gzPath));
        }
        $out = @fopen($outPath, 'wb');
        if ($out === false) {
            gzclose($in);

            throw new DownloaderException(sprintf('cannot fopen %s for write', $outPath));
        }

        $written = 0;
        $cleanup = static function (mixed $in, mixed $out, string $outPath): void {
            if ($in !== false) {
                @gzclose($in);
            }
            if ($out !== false) {
                @fclose($out);
            }
            // Don't leave a half-decoded file on disk: caller treats
            // existence of `$outPath` as "verified ready to swap".
            @unlink($outPath);
        };

        try {
            while (!gzeof($in)) {
                $chunk = @gzread($in, self::GUNZIP_CHUNK_BYTES);
                if ($chunk === false) {
                    $cleanup($in, $out, $outPath);

                    throw new DownloaderException(sprintf('gzread failed on %s', $gzPath));
                }
                if ($chunk === '') {
                    continue;
                }
                $written += strlen($chunk);
                if ($written > $maxBytes) {
                    $cleanup($in, $out, $outPath);

                    throw new DownloaderException(sprintf(
                        'dbip gunzip output exceeds cap %d bytes',
                        $maxBytes,
                    ));
                }
                if (@fwrite($out, $chunk) === false) {
                    $cleanup($in, $out, $outPath);

                    throw new DownloaderException(sprintf('write to %s failed', $outPath));
                }
            }
        } catch (Throwable $e) {
            if (!$e instanceof DownloaderException) {
                $cleanup($in, $out, $outPath);

                throw new DownloaderException(sprintf('gunzip of %s failed', $gzPath), 0, $e);
            }

            throw $e;
        }

        @gzclose($in);
        @fclose($out);

        if ($written === 0) {
            @unlink($outPath);

            throw new DownloaderException(sprintf('gunzip produced empty output for %s', $gzPath));
        }

        @unlink($gzPath);
    }
}
