<?php
declare(strict_types=1);

namespace SafeTun\Backend\Http;

use SafeTun\Backend\Exception\PublicApiException;
use SafeTun\Backend\Net\CidrMatcher;

final class Request
{
    /** @param array<string,string> $headers @param array<string,string> $server */
    public function __construct(
        public readonly string $method,
        public readonly string $path,
        public readonly string $query,
        public readonly array $headers,
        public readonly array $server,
        public readonly string $body,
    ) {
    }

    public static function fromGlobals(): self
    {
        $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
        $uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
        $path = parse_url($uri, PHP_URL_PATH);
        $query = parse_url($uri, PHP_URL_QUERY);
        if (!is_string($path) || $path === '' || str_contains($path, "\0")) {
            throw new PublicApiException('INVALID_REQUEST', 400, 'Malformed request path.');
        }
        $path = self::stripFrontControllerDirectory($path, $_SERVER['SCRIPT_NAME'] ?? null);

        $headers = [];
        foreach ($_SERVER as $key => $value) {
            if (!is_string($value)) {
                continue;
            }
            if (str_starts_with($key, 'HTTP_')) {
                $name = strtolower(str_replace('_', '-', substr($key, 5)));
                $headers[$name] = $value;
            } elseif (in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH'], true)) {
                $headers[strtolower(str_replace('_', '-', $key))] = $value;
            }
        }

        $contentLength = isset($headers['content-length']) ? filter_var($headers['content-length'], FILTER_VALIDATE_INT) : null;
        if ($contentLength === false || (is_int($contentLength) && ($contentLength < 0 || $contentLength > 1024 * 1024))) {
            throw new PublicApiException('INVALID_REQUEST', 413, 'Request exceeds the hard size limit.');
        }
        $body = file_get_contents('php://input', false, null, 0, 1024 * 1024 + 1);
        if (!is_string($body) || strlen($body) > 1024 * 1024) {
            throw new PublicApiException('INVALID_REQUEST', 413, 'Request exceeds the hard size limit.');
        }

        $server = [];
        foreach (['REMOTE_ADDR', 'HTTPS', 'SERVER_PORT', 'DOCUMENT_ROOT'] as $key) {
            if (isset($_SERVER[$key]) && is_scalar($_SERVER[$key])) {
                $server[strtolower($key)] = (string) $_SERVER[$key];
            }
        }
        return new self($method, $path, is_string($query) ? $query : '', $headers, $server, $body);
    }

    private static function stripFrontControllerDirectory(string $path, mixed $scriptName): string
    {
        if (!is_string($scriptName) || $scriptName === '' || strlen($scriptName) > 4096
            || str_contains($scriptName, "\0")) {
            return $path;
        }
        $scriptPath = parse_url($scriptName, PHP_URL_PATH);
        if (!is_string($scriptPath)) {
            return $path;
        }
        $scriptPath = str_replace('\\', '/', $scriptPath);
        if (!preg_match('#\\A(?:/[A-Za-z0-9._~-]+)*/index\\.php\\z#D', $scriptPath)) {
            return $path;
        }
        $base = substr($scriptPath, 0, -strlen('/index.php'));
        if ($base === '') {
            return $path;
        }
        $segments = explode('/', ltrim($base, '/'));
        if (in_array('.', $segments, true) || in_array('..', $segments, true)) {
            return $path;
        }
        if ($path === $base) {
            return '/';
        }
        if (str_starts_with($path, $base . '/')) {
            return substr($path, strlen($base));
        }
        return $path;
    }

    /** @return array<string,mixed> */
    public function jsonObject(int $maximumBytes): array
    {
        if (strlen($this->body) > $maximumBytes) {
            throw new PublicApiException('INVALID_REQUEST', 413, 'Request body is too large.');
        }
        if (($this->headers['content-type'] ?? '') !== 'application/json') {
            $type = strtolower(trim(explode(';', $this->headers['content-type'] ?? '')[0]));
            if ($type !== 'application/json') {
                throw new PublicApiException('INVALID_REQUEST', 415, 'Content-Type must be application/json.');
            }
        }
        try {
            $decoded = json_decode($this->body === '' ? '{}' : $this->body, true, 32, JSON_THROW_ON_ERROR);
        } catch (\JsonException $exception) {
            throw new PublicApiException('INVALID_REQUEST', 400, 'Malformed JSON body.');
        }
        if (!is_array($decoded) || ($decoded !== [] && array_is_list($decoded))) {
            throw new PublicApiException('INVALID_REQUEST', 400, 'JSON body must be an object.');
        }
        /** @var array<string,mixed> $decoded */
        return $decoded;
    }

    /** @param array<string,mixed> $authorization */
    public function isHttps(array $authorization): bool
    {
        $https = strtolower($this->server['https'] ?? '');
        if (in_array($https, ['on', '1', 'true'], true) || ($this->server['server_port'] ?? '') === '443') {
            return true;
        }
        if ($authorization['client_ip_mode'] !== 'trusted_proxy') {
            return false;
        }
        $remote = $this->server['remote_addr'] ?? '';
        if (!CidrMatcher::containsAny($authorization['trusted_proxy_cidrs'], $remote)) {
            return false;
        }
        $forwarded = strtolower(trim($this->headers['x-forwarded-proto'] ?? ''));
        return $forwarded === 'https';
    }
}
