<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Http;

use Closure;
use InvalidArgumentException;

/**
 * The SSRF guard (design §4.7, "mandatory") — the reason an LLM-authored snippet may be handed an
 * outbound HTTP call at all.
 *
 * The threat is not a snippet fetching a web page. It is a snippet fetching
 * `http://169.254.169.254/…` (cloud metadata, i.e. the shop's own credentials),
 * `http://127.0.0.1:6379/` (the cache, which speaks a protocol tolerant enough to be driven by a
 * crafted HTTP body) or `http://192.168.x.y/` (whatever else lives on the customer's LAN). None of
 * those is reachable from outside the shop, which is exactly why they are worth attacking through
 * something inside it.
 *
 * Four decisions, all deliberate:
 * - **Scheme allowlist.** `http`/`https` only — no `file:`, `gopher:`, `dict:`, `ftp:`.
 * - **No credentials in the URL.** cURL would send them; a snippet that wants auth sends a header.
 * - **Resolve first, then judge.** A name is not a target; `localtest.me` resolves to 127.0.0.1 and
 *   looks perfectly public until it does. If ANY address of the host is private, the whole host is
 *   refused — a host that answers with one public and one private address is not a host we want.
 * - **Hand the addresses back.** The caller connects to the address that was approved
 *   ({@see GuardedTarget}), so a name that resolves differently a moment later changes nothing.
 *
 * Every refusal throws: a snippet must hear "that target is not allowed", not get an empty result
 * it might read as "the endpoint had nothing to say".
 */
final class UrlGuard
{
    /**
     * IPv4 ranges PHP's own `FILTER_FLAG_NO_PRIV_RANGE`/`NO_RES_RANGE` do not cover, and which are
     * every bit as much "not the public internet".
     *
     * @var list<array{0:string,1:int}>
     */
    private const EXTRA_V4_BLOCKS = [
        ['100.64.0.0', 10],   // CGNAT — the ISP's side of a carrier NAT
        ['192.0.0.0', 24],    // IETF protocol assignments
        ['192.0.2.0', 24],    // TEST-NET-1
        ['192.88.99.0', 24],  // 6to4 relay anycast
        ['198.18.0.0', 15],   // benchmarking
        ['198.51.100.0', 24], // TEST-NET-2
        ['203.0.113.0', 24],  // TEST-NET-3
    ];

    /** @var Closure(string):list<string> */
    private readonly Closure $resolver;

    /**
     * @param (callable(string):list<string>)|null $resolver host => addresses; injected so the rules
     *                                                       can be tested without a DNS server
     */
    public function __construct(?callable $resolver = null)
    {
        $this->resolver = $resolver !== null
            ? Closure::fromCallable($resolver)
            : self::systemResolver(...);
    }

    /**
     * @throws InvalidArgumentException when the URL is malformed, not http(s), carries credentials,
     *                                  does not resolve, or resolves anywhere that is not public
     */
    public function inspect(string $url): GuardedTarget
    {
        $url   = \trim($url);
        $parts = \parse_url($url);
        if ($parts === false || !isset($parts['host']) || $parts['host'] === '') {
            throw new InvalidArgumentException(\sprintf('"%s" is not a URL', $url));
        }

        $scheme = \strtolower($parts['scheme'] ?? '');
        if ($scheme !== 'http' && $scheme !== 'https') {
            throw new InvalidArgumentException(
                \sprintf('only http and https can be called, not "%s"', $scheme === '' ? $url : $scheme)
            );
        }
        if (isset($parts['user']) || isset($parts['pass'])) {
            throw new InvalidArgumentException('credentials in the URL are not sent — use a header');
        }

        $host = \trim($parts['host'], '[]');
        $port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80);

        $ips = $this->addressesOf($host);
        foreach ($ips as $ip) {
            if (!self::isPublic($ip)) {
                throw new InvalidArgumentException(\sprintf(
                    '"%s" resolves to %s, which is not on the public internet',
                    $host,
                    $ip
                ));
            }
        }

        return new GuardedTarget($url, $scheme, $host, $port, $ips);
    }

    /**
     * @return list<string>
     *
     * @throws InvalidArgumentException
     */
    private function addressesOf(string $host): array
    {
        if (\filter_var($host, \FILTER_VALIDATE_IP) !== false) {
            // An address literal is its own resolution — and still has to pass the same test.
            return [$host];
        }

        $ips = \array_values(\array_unique(\array_filter(
            ($this->resolver)($host),
            static fn (string $ip): bool => \filter_var($ip, \FILTER_VALIDATE_IP) !== false
        )));
        if ($ips === []) {
            throw new InvalidArgumentException(\sprintf('"%s" does not resolve', $host));
        }

        return $ips;
    }

    /**
     * Reachable from the internet, i.e. not loopback, link-local, RFC1918, unique-local, reserved or
     * one of the ranges in {@see self::EXTRA_V4_BLOCKS}.
     *
     * IPv4-mapped IPv6 (`::ffff:127.0.0.1`) needs no special case: PHP's reserved-range filter
     * rejects the whole `::ffff:0:0/96` block, so the disguise is refused before the address inside
     * it matters.
     */
    private static function isPublic(string $ip): bool
    {
        $public = \filter_var(
            $ip,
            \FILTER_VALIDATE_IP,
            \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE
        );
        if ($public === false) {
            return false;
        }
        if (\filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4) === false) {
            return true;
        }

        foreach (self::EXTRA_V4_BLOCKS as [$network, $bits]) {
            if (self::inV4Block($ip, $network, $bits)) {
                return false;
            }
        }

        return true;
    }

    private static function inV4Block(string $ip, string $network, int $bits): bool
    {
        $address = \ip2long($ip);
        $base    = \ip2long($network);
        if ($address === false || $base === false) {
            return false;
        }
        $mask = -1 << (32 - $bits);

        return ($address & $mask) === ($base & $mask);
    }

    /**
     * A + AAAA. `gethostbynamel()` alone would miss a host that is IPv6-only — and, worse, would let
     * an AAAA record pointing at `::1` through unchecked on a dual-stack machine.
     *
     * @return list<string>
     */
    private static function systemResolver(string $host): array
    {
        $ips = \gethostbynamel($host);
        $ips = $ips === false ? [] : $ips;

        $records = @\dns_get_record($host, \DNS_AAAA);
        foreach ($records === false ? [] : $records as $record) {
            if (isset($record['ipv6']) && \is_string($record['ipv6'])) {
                $ips[] = $record['ipv6'];
            }
        }

        return $ips;
    }
}
