<?php

declare(strict_types=1);

namespace App\Infrastructure\Enrichment\Downloaders;

use GuzzleHttp\Exception\TransferException;
use Psr\Http\Message\RequestInterface;

/**
 * SEC_REVIEW F50: defence-in-depth guard around the GeoIP downloader's
 * HTTP client. Rejects requests whose URL points at a literal
 * loopback / link-local / multicast / private host.
 *
 * The downloaders' base URLs are constants in the default deploy
 * (`https://download.maxmind.com`, `https://download.db-ip.com`,
 * `https://ipinfo.io`), so the only way a request to a private
 * destination shows up here is via a 30x redirect or a DNS-poisoning
 * MitM. Combined with `allow_redirects.protocols=['https']` and a
 * tight redirect cap, this middleware blocks the post-redirect
 * `http://169.254.169.254/...` / `https://localhost/...` /
 * `https://10.0.0.1/...` patterns at the request layer before Guzzle
 * opens a socket to the dangerous host.
 *
 * The guard inspects the URL's literal host string only — it does NOT
 * resolve DNS to catch a public hostname that points at a private IP.
 * Pinning DNS is out of scope for "defence in depth"; the primary
 * controls are the constant base URL and the
 * `allow_redirects.protocols=['https']` config.
 *
 * Plug into a Guzzle handler stack with
 * `$stack->push(PrivateHostGuardMiddleware::factory())`.
 */
final class PrivateHostGuardMiddleware
{
    /**
     * Hostnames + IP CIDRs we refuse to dial. Covers the AWS / GCP /
     * Azure instance-metadata addresses, IPv6 link-local, and the
     * RFC1918 ranges typical operators don't intend to expose.
     *
     * @var list<string>
     */
    private const BLOCKED_HOSTS = [
        'localhost',
        '0.0.0.0',
        '169.254.169.254', // AWS / GCP / Azure metadata
        'metadata.google.internal',
    ];

    /**
     * Returns the middleware closure Guzzle expects. Signature is
     * `function (callable $handler): callable`.
     */
    public static function factory(): callable
    {
        return static function (callable $handler): callable {
            return static function (RequestInterface $request, array $options) use ($handler) {
                self::assertHostAllowed($request);

                return $handler($request, $options);
            };
        };
    }

    /**
     * Public so tests and the IPinfo downloader can call it directly
     * (e.g. for redirect targets resolved out-of-band).
     *
     * @internal
     */
    public static function assertHostAllowed(RequestInterface $request): void
    {
        $host = strtolower($request->getUri()->getHost());
        if ($host === '') {
            throw new TransferException('refusing to dial empty host');
        }

        // Strip an IPv6 literal's brackets — `[::1]` → `::1`.
        if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
            $host = substr($host, 1, -1);
        }

        if (in_array($host, self::BLOCKED_HOSTS, true)) {
            throw new TransferException(sprintf('refusing to dial blocked host: %s', $host));
        }

        // IPv4: literal dotted quad
        if (preg_match('/^\d{1,3}(?:\.\d{1,3}){3}$/', $host) === 1) {
            $ip = ip2long($host);
            if ($ip === false) {
                throw new TransferException(sprintf('refusing to dial malformed ipv4 host: %s', $host));
            }
            if (self::ipv4InBlockedRanges($ip)) {
                throw new TransferException(sprintf('refusing to dial private/loopback ipv4 host: %s', $host));
            }
        }

        // IPv6: anything containing colons. Treat unique-local
        // (fc00::/7), link-local (fe80::/10), and loopback (::1) as
        // blocked; multicast (ff00::/8) too for good measure.
        if (str_contains($host, ':')) {
            $packed = @inet_pton($host);
            if ($packed === false) {
                throw new TransferException(sprintf('refusing to dial malformed ipv6 host: %s', $host));
            }
            if (strlen($packed) === 16 && self::ipv6InBlockedRanges($packed)) {
                throw new TransferException(sprintf('refusing to dial private/loopback ipv6 host: %s', $host));
            }
        }
    }

    private static function ipv4InBlockedRanges(int $ip): bool
    {
        // ip2long can return a negative int on 32-bit PHP for >2^31 hosts;
        // operate on it as unsigned via bitwise AND with the netmask.
        $ranges = [
            ['10.0.0.0', 8],
            ['127.0.0.0', 8],
            ['169.254.0.0', 16], // link-local incl. 169.254.169.254 metadata
            ['172.16.0.0', 12],
            ['192.168.0.0', 16],
            ['100.64.0.0', 10], // CGNAT
            ['224.0.0.0', 4], // multicast
            ['0.0.0.0', 8],
        ];
        foreach ($ranges as [$net, $prefix]) {
            $netLong = ip2long((string) $net);
            if ($netLong === false) {
                continue;
            }
            // Each prefix in $ranges is in [4, 32]; no /0 case.
            $mask = -1 << (32 - $prefix);
            if (($ip & $mask) === ($netLong & $mask)) {
                return true;
            }
        }

        return false;
    }

    private static function ipv6InBlockedRanges(string $packed16): bool
    {
        $first = ord($packed16[0]);
        // ::1 (loopback)
        if ($packed16 === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01") {
            return true;
        }
        // ::0 (unspec)
        if ($packed16 === str_repeat("\x00", 16)) {
            return true;
        }
        // fe80::/10 link-local
        if ($first === 0xfe && (ord($packed16[1]) & 0xc0) === 0x80) {
            return true;
        }
        // fc00::/7 unique-local
        if (($first & 0xfe) === 0xfc) {
            return true;
        }
        // ff00::/8 multicast
        if ($first === 0xff) {
            return true;
        }

        return false;
    }
}
