<?php
/*
 * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
 * SPDX-License-Identifier: Apache-2.0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * See the LICENSE file in the project root for the full license text.
 */

declare(strict_types=1);

namespace App\Http;

final class Request
{
    /**
     * R01-N24: hard cap on request body size. PHP's default
     * `post_max_size` is 8 MiB; this stricter app-level cap kicks in
     * before any controller logic so a runaway client cannot pump the
     * server with multi-megabyte JSON. Any non-safe request whose
     * body (or declared `Content-Length`) exceeds this cap is rejected
     * with 413 by the front controller before dispatch.
     */
    public const MAX_BODY_BYTES = 1024 * 1024; // 1 MiB

    /**
     * @param array<string,mixed>  $query   raw $_GET (may contain arrays for ?a[]=…)
     * @param array<string,mixed>  $post    raw $_POST
     * @param array<string,string> $headers header names already lower-cased
     * @param array<string,mixed>  $server  raw $_SERVER (may contain arrays)
     */
    public function __construct(
        public readonly string $method,
        public readonly string $path,
        public readonly array  $query,
        public readonly array  $post,
        public readonly string $rawBody,
        public readonly array  $headers,
        public readonly array  $server,
        public readonly bool   $bodyTooLarge = false,
    ) {
    }

    public static function fromGlobals(): self
    {
        $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
        $uri    = (string) ($_SERVER['REQUEST_URI'] ?? '/');
        $path   = parse_url($uri, PHP_URL_PATH) ?: '/';
        if ($path !== '/' && str_ends_with($path, '/')) {
            $path = rtrim($path, '/');
        }

        $headers = [];
        foreach ($_SERVER as $k => $v) {
            if (!is_scalar($v)) {
                continue; // mod_php can occasionally put non-scalars here
            }
            if (str_starts_with((string) $k, 'HTTP_')) {
                $name = strtolower(str_replace('_', '-', substr((string) $k, 5)));
                $headers[$name] = (string) $v;
            }
        }
        if (isset($_SERVER['CONTENT_TYPE']) && is_scalar($_SERVER['CONTENT_TYPE'])) {
            $headers['content-type'] = (string) $_SERVER['CONTENT_TYPE'];
        }
        if (isset($_SERVER['CONTENT_LENGTH']) && is_scalar($_SERVER['CONTENT_LENGTH'])) {
            $headers['content-length'] = (string) $_SERVER['CONTENT_LENGTH'];
        }

        // R01-N24: short-circuit oversized requests BEFORE reading the body
        // into memory. The Content-Length check is the primary gate; the
        // post-read length check is a defence-in-depth fallback for clients
        // that under-report (or omit) the header.
        $bodyTooLarge = false;
        $declaredLen  = isset($_SERVER['CONTENT_LENGTH']) && is_scalar($_SERVER['CONTENT_LENGTH'])
            ? (int) $_SERVER['CONTENT_LENGTH']
            : 0;

        if ($declaredLen > self::MAX_BODY_BYTES) {
            $bodyTooLarge = true;
            $raw          = '';
        } else {
            $raw = (string) (file_get_contents('php://input') ?: '');
            if (strlen($raw) > self::MAX_BODY_BYTES) {
                $bodyTooLarge = true;
                $raw          = '';
            }
        }

        return new self(
            method:       $method,
            path:         $path,
            query:        $_GET,
            post:         $_POST,
            rawBody:      $raw,
            headers:      $headers,
            server:       $_SERVER,
            bodyTooLarge: $bodyTooLarge,
        );
    }

    /** Read a single-value query param as a trimmed string. Returns '' if missing or non-scalar. */
    public function queryString(string $name): string
    {
        $v = $this->query[$name] ?? null;
        return is_scalar($v) ? trim((string) $v) : '';
    }

    /** Read a single-value post param as a trimmed string. Returns '' if missing or non-scalar. */
    public function postString(string $name): string
    {
        $v = $this->post[$name] ?? null;
        return is_scalar($v) ? trim((string) $v) : '';
    }

    public function header(string $name): ?string
    {
        return $this->headers[strtolower($name)] ?? null;
    }

    /** Parsed JSON body; null if body is empty or not JSON. */
    public function json(): ?array
    {
        if ($this->rawBody === '') {
            return null;
        }
        $ct = $this->header('content-type') ?? '';
        if (!str_contains($ct, 'json')) {
            return null;
        }
        try {
            /** @var mixed $data */
            $data = json_decode($this->rawBody, true, 64, JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            return null;
        }
        return is_array($data) ? $data : null;
    }

    /**
     * The originating client IP, honouring `X-Forwarded-For` only when the
     * direct peer is in a configured `TRUSTED_PROXIES` CIDR (R01-N07).
     */
    public function ip(): string
    {
        return TrustedProxies::fromEnv()->clientIp($this->server);
    }

    /**
     * True iff the request is effectively over HTTPS, after consulting the
     * trusted-proxy `X-Forwarded-Proto` header (R01-N05).
     */
    public function isHttps(): bool
    {
        return TrustedProxies::fromEnv()->isHttps($this->server);
    }

    public function userAgent(): string
    {
        return $this->header('user-agent') ?? '';
    }
}
