<?php

declare(strict_types=1);

namespace App\Tests\Unit\Enrichment;

use App\Infrastructure\Enrichment\Downloaders\PrivateHostGuardMiddleware;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\Psr7\Request;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\RequestInterface;

/**
 * SEC_REVIEW F50 — defence-in-depth host guard around the GeoIP
 * downloader's Guzzle client. Refuses to dial loopback, link-local,
 * RFC1918, CGNAT, multicast, instance-metadata hostnames, and
 * IPv6 link-local / unique-local / loopback / multicast / unspec
 * addresses. Public production hosts (`download.db-ip.com`,
 * `download.maxmind.com`, `ipinfo.io`) pass through.
 */
final class PrivateHostGuardMiddlewareTest extends TestCase
{
    /**
     * @return iterable<string, array{string}>
     */
    public static function blockedHosts(): iterable
    {
        // Literal blocked names.
        yield 'localhost' => ['https://localhost/foo'];
        yield 'localhost:8080' => ['https://localhost:8080/foo'];
        yield 'metadata.google.internal' => ['https://metadata.google.internal/computeMetadata/v1/'];
        yield 'cloud metadata IP' => ['https://169.254.169.254/latest/meta-data/'];

        // IPv4 ranges.
        yield 'loopback v4' => ['https://127.0.0.1/'];
        yield 'rfc1918 10/8' => ['https://10.0.0.1/'];
        yield 'rfc1918 172.16/12 inside' => ['https://172.16.0.1/'];
        yield 'rfc1918 172.31/12 (boundary)' => ['https://172.31.255.255/'];
        yield 'rfc1918 192.168/16' => ['https://192.168.1.1/'];
        yield 'cgnat 100.64/10' => ['https://100.64.0.1/'];
        yield 'multicast' => ['https://224.0.0.1/'];
        yield 'all-zero unspec v4' => ['https://0.0.0.0/'];

        // IPv6 ranges. Square brackets in URL hosts are stripped
        // before lookup so the guard sees the literal address.
        yield 'loopback v6' => ['https://[::1]/'];
        yield 'unspec v6' => ['https://[::]/'];
        yield 'link-local v6' => ['https://[fe80::1]/'];
        yield 'unique-local v6' => ['https://[fc00::1]/'];
        yield 'multicast v6' => ['https://[ff02::1]/'];
    }

    #[DataProvider('blockedHosts')]
    public function testBlockedHostThrows(string $url): void
    {
        $request = new Request('GET', $url);
        $this->expectException(TransferException::class);
        PrivateHostGuardMiddleware::assertHostAllowed($request);
    }

    /**
     * @return iterable<string, array{string}>
     */
    public static function allowedHosts(): iterable
    {
        yield 'db-ip download host' => ['https://download.db-ip.com/free/dbip-country-lite-2026-04.mmdb.gz'];
        yield 'maxmind download host' => ['https://download.maxmind.com/app/geoip_download'];
        yield 'ipinfo host' => ['https://ipinfo.io/data/free/country.mmdb'];
        // 172.32.x.x is just outside RFC1918's 172.16/12 range — must pass.
        yield 'just outside 172.16/12' => ['https://172.32.0.1/'];
        // Public DNS resolver IP (should pass — it's a public address).
        yield 'public 1.1.1.1' => ['https://1.1.1.1/'];
        // Public IPv6 (RFC 3849 docs prefix; not in any blocked range).
        yield 'public v6 2001:db8' => ['https://[2001:db8::1]/'];
    }

    #[DataProvider('allowedHosts')]
    public function testAllowedHostPasses(string $url): void
    {
        $request = new Request('GET', $url);
        PrivateHostGuardMiddleware::assertHostAllowed($request);
        // No assertion needed — absence of an exception is success.
        $this->expectNotToPerformAssertions();
    }

    public function testFactoryProducesMiddlewareThatGuardsBeforeHandler(): void
    {
        // The factory closure should pass through allowed hosts and
        // block disallowed hosts — without ever invoking the handler
        // for the blocked branch.
        $handlerCalled = false;
        $handler = static function (RequestInterface $request, array $options) use (&$handlerCalled): mixed {
            $handlerCalled = true;

            return new \GuzzleHttp\Psr7\Response(200);
        };
        $middleware = PrivateHostGuardMiddleware::factory()($handler);

        // Allowed: handler runs.
        $middleware(new Request('GET', 'https://download.db-ip.com/'), []);
        self::assertTrue($handlerCalled);

        // Blocked: throw before reaching the handler.
        $handlerCalled = false;
        $threw = false;
        try {
            $middleware(new Request('GET', 'https://169.254.169.254/'), []);
        } catch (TransferException) {
            $threw = true;
        }
        self::assertTrue($threw, 'expected guard to throw for metadata host');
        self::assertFalse($handlerCalled, 'handler must not be invoked for blocked host');
    }

    public function testEmptyHostIsRejected(): void
    {
        // Construct a Request with no Host part by going through Uri.
        $uri = (new \GuzzleHttp\Psr7\Uri('https://download.db-ip.com/'))->withHost('');
        $request = (new Request('GET', 'https://download.db-ip.com/'))->withUri($uri);

        $this->expectException(TransferException::class);
        PrivateHostGuardMiddleware::assertHostAllowed($request);
    }
}
