<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Http;

use CurlHandle;
use InvalidArgumentException;
use Plugin\tnt_mcp\src\Facade\HttpResult;
use RuntimeException;

/**
 * The real outbound HTTP client (design §4.7).
 *
 * cURL rather than `Request::http_get_contents()` — which the old `CallURL` used — because a
 * webhook needs a method, headers and a body, and none of those can be expressed through it.
 *
 * The parts that are about safety rather than about HTTP:
 * - **Redirects are followed BY HAND** (`CURLOPT_FOLLOWLOCATION` stays off). Letting cURL follow
 *   them would mean the {@see UrlGuard} checked the first URL and nothing else — a public URL that
 *   302s to `http://127.0.0.1:6379/` is the standard way around a naive SSRF check.
 * - **The connection is pinned** to the address the guard approved (`CURLOPT_RESOLVE`), so a name
 *   that answers publicly for the check and privately for the connection buys nothing.
 * - **The body is capped while it arrives**, not after: a 4 GB response must not be a way to run the
 *   shop out of memory. Past the cap the transfer is aborted and what was read is returned with
 *   `truncated = true` — a cut answer is still an answer.
 * - **`Location:` is re-checked on every hop**, by the same guard, with the same rules.
 */
final class CurlHttpClient implements HttpClient
{
    /** Response headers we will hold. A header block larger than this is not a real answer. */
    private const MAX_HEADER_BYTES = 65536;

    /** Longest a snippet may make the shop wait. The wall-clock budget is not the only clock. */
    private const MAX_TIMEOUT = 30;

    /** Sent so the receiving end can tell shop traffic from a browser — and block it if it wants. */
    private const USER_AGENT = 'JTL-Shop tnt_mcp';

    public function __construct(private readonly UrlGuard $guard = new UrlGuard())
    {
    }

    public function request(
        string $method,
        string $url,
        array $headers = [],
        ?string $body = null,
        int $timeout = self::DEFAULT_TIMEOUT,
    ): HttpResult {
        if (!\function_exists('curl_init')) {
            throw new RuntimeException('the PHP cURL extension is not installed');
        }

        $method = \strtoupper(\trim($method));
        if (!\in_array($method, self::METHODS, true)) {
            throw new InvalidArgumentException(
                \sprintf('"%s" is not a method we send — one of %s', $method, \implode(', ', self::METHODS))
            );
        }
        $lines   = self::headerLines($headers);
        $timeout = \max(1, \min(self::MAX_TIMEOUT, $timeout));

        // A body on GET/HEAD is what a caller means when it has mixed up two examples; sending it
        // would be legal HTTP and would confuse most receivers. Say so instead.
        if ($body !== null && $body !== '' && ($method === 'GET' || $method === 'HEAD')) {
            throw new InvalidArgumentException(\sprintf('a %s request carries no body', $method));
        }

        $current = $url;
        for ($hop = 0; $hop <= self::MAX_REDIRECTS; ++$hop) {
            $target   = $this->guard->inspect($current);
            $response = $this->send($target, $method, $lines, $body, $timeout);

            $location = $response->header('location');
            if ($location === null || !self::isRedirect($response->status) || $hop === self::MAX_REDIRECTS) {
                return $response;
            }

            $current = self::resolveLocation($target, $location);
            // 303 always, and 301/302 in practice: every browser turns those into a GET, and a
            // receiver that answers a POST with one is asking for the result to be fetched.
            if ($method !== 'HEAD' && \in_array($response->status, [301, 302, 303], true)) {
                $method = 'GET';
                $body   = null;
            }
        }

        throw new RuntimeException(\sprintf('more than %d redirects', self::MAX_REDIRECTS));
    }

    /**
     * @param list<string> $headerLines
     */
    private function send(
        GuardedTarget $target,
        string $method,
        array $headerLines,
        ?string $body,
        int $timeout,
    ): HttpResult {
        $received  = '';
        $rawHeader = '';
        $truncated = false;

        $curl = \curl_init();
        if (!$curl instanceof CurlHandle) {
            throw new RuntimeException('cURL could not be initialised');
        }

        \curl_setopt_array($curl, [
            \CURLOPT_URL            => $target->url,
            \CURLOPT_CUSTOMREQUEST  => $method,
            \CURLOPT_NOBODY         => $method === 'HEAD',
            \CURLOPT_FOLLOWLOCATION => false,
            \CURLOPT_RETURNTRANSFER => false,
            \CURLOPT_TIMEOUT        => $timeout,
            \CURLOPT_CONNECTTIMEOUT => \min(5, $timeout),
            \CURLOPT_SSL_VERIFYPEER => true,
            \CURLOPT_SSL_VERIFYHOST => 2,
            \CURLOPT_USERAGENT      => self::USER_AGENT,
            \CURLOPT_HTTPHEADER     => $headerLines,
            // Defence in depth behind the guard's own scheme check: even a redirect we mishandled
            // cannot become a `file://` or `gopher://` fetch.
            \CURLOPT_PROTOCOLS      => \CURLPROTO_HTTP | \CURLPROTO_HTTPS,
            \CURLOPT_REDIR_PROTOCOLS => \CURLPROTO_HTTP | \CURLPROTO_HTTPS,
            // The whole point of the guard: connect to what was approved, not to what DNS says now.
            \CURLOPT_RESOLVE        => [
                \sprintf('%s:%d:%s', $target->host, $target->port, \implode(',', $target->ips)),
            ],
            \CURLOPT_HEADERFUNCTION => static function ($handle, string $line) use (&$rawHeader): int {
                if (\strlen($rawHeader) < self::MAX_HEADER_BYTES) {
                    $rawHeader .= $line;
                }

                return \strlen($line);
            },
            \CURLOPT_WRITEFUNCTION  => static function (
                $handle,
                string $chunk
            ) use (
                &$received,
                &$truncated
            ): int {
                $room = self::MAX_BODY_BYTES - \strlen($received);
                if ($room <= 0 || \strlen($chunk) > $room) {
                    $received  .= \substr($chunk, 0, \max(0, $room));
                    $truncated = true;

                    // Aborts the transfer — cURL reports CURLE_WRITE_ERROR, which is exactly what
                    // we asked for and is handled as a truncated success below.
                    return 0;
                }
                $received .= $chunk;

                return \strlen($chunk);
            },
        ]);

        if ($body !== null && $method !== 'GET' && $method !== 'HEAD') {
            \curl_setopt($curl, \CURLOPT_POSTFIELDS, $body);
        }

        $ok     = \curl_exec($curl) !== false;
        $errno  = \curl_errno($curl);
        $status = (int)\curl_getinfo($curl, \CURLINFO_RESPONSE_CODE);
        $final  = (string)\curl_getinfo($curl, \CURLINFO_EFFECTIVE_URL);
        $error  = \curl_error($curl);
        \curl_close($curl);

        // CURLE_WRITE_ERROR with a status means we stopped reading on purpose.
        if (!$ok && !($errno === \CURLE_WRITE_ERROR && $truncated && $status > 0)) {
            throw new RuntimeException(\sprintf('%s %s failed: %s', $method, $target->url, $error));
        }

        return new HttpResult(
            status: $status,
            headers: self::parseHeaders($rawHeader),
            body: $received,
            truncated: $truncated,
            url: $final === '' ? $target->url : $final,
        );
    }

    /**
     * @param array<string,string> $headers
     *
     * @return list<string>
     */
    private static function headerLines(array $headers): array
    {
        // The client owns the connection, so it owns these: a caller-set Host defeats the pinning,
        // and a caller-set length or encoding contradicts the body cURL actually sends.
        $reserved = ['host', 'content-length', 'transfer-encoding', 'connection'];
        $lines    = [];

        foreach ($headers as $name => $value) {
            $name  = \trim((string)$name);
            $value = (string)$value;
            if (\preg_match('/^[A-Za-z0-9!#$%&\'*+\-.^_`|~]+$/', $name) !== 1) {
                throw new InvalidArgumentException(\sprintf('"%s" is not a header name', $name));
            }
            // Header splitting: a newline in a value appends headers the caller never asked for —
            // and, through a proxy, a second request.
            if (\preg_match('/[\r\n\0]/', $value) === 1) {
                throw new InvalidArgumentException(\sprintf('header "%s" contains a line break', $name));
            }
            if (\in_array(\strtolower($name), $reserved, true)) {
                continue;
            }
            $lines[] = $name . ': ' . \trim($value);
        }

        return $lines;
    }

    /**
     * @return array<string,string>
     */
    private static function parseHeaders(string $raw): array
    {
        $headers = [];
        foreach (\preg_split('/\r?\n/', $raw) ?: [] as $line) {
            $parts = \explode(':', $line, 2);
            if (\count($parts) !== 2) {
                continue;
            }
            $name  = \strtolower(\trim($parts[0]));
            $value = \trim($parts[1]);
            if ($name === '') {
                continue;
            }
            // Repeated names are joined rather than overwritten — dropping one would silently lose
            // a `Set-Cookie` or a `Link`.
            $headers[$name] = isset($headers[$name]) ? $headers[$name] . ', ' . $value : $value;
        }

        return $headers;
    }

    private static function isRedirect(int $status): bool
    {
        return \in_array($status, [301, 302, 303, 307, 308], true);
    }

    /**
     * `Location` is allowed to be relative (RFC 9110 §10.2.2), and a receiver that sends `/next`
     * must not become a request to a URL we never checked.
     */
    private static function resolveLocation(GuardedTarget $target, string $location): string
    {
        $location = \trim($location);
        if (\preg_match('#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', $location) === 1) {
            return $location;
        }

        $authority = $target->host;
        if (\str_contains($authority, ':')) {
            $authority = '[' . $authority . ']';
        }
        $isDefaultPort = ($target->scheme === 'https' && $target->port === 443)
            || ($target->scheme === 'http' && $target->port === 80);
        if (!$isDefaultPort) {
            $authority .= ':' . $target->port;
        }

        if (\str_starts_with($location, '//')) {
            return $target->scheme . ':' . $location;
        }
        if (\str_starts_with($location, '/')) {
            return $target->scheme . '://' . $authority . $location;
        }

        $path = (string)(\parse_url($target->url, \PHP_URL_PATH) ?: '/');
        $base = \substr($path, 0, (int)\strrpos($path, '/') + 1);

        return $target->scheme . '://' . $authority . ($base === '' ? '/' : $base) . $location;
    }
}
