<?php

declare(strict_types=1);

namespace BotGuard\Core;

/**
 * Immutable-ish wrapper around the incoming HTTP request.
 *
 * Provides normalized access to query/body/JSON parameters, headers, cookies
 * and the client IP. A single instance is created by the Kernel and shared via
 * the container, so detection modules can read the exact same request data.
 */
final class Request
{
    /** @var array<string, mixed> */
    private array $query;

    /** @var array<string, mixed> */
    private array $post;

    /** @var array<string, string> */
    private array $headers;

    /** @var array<string, string> */
    private array $cookies;

    /** @var array<string, mixed>|null Decoded JSON body cache. */
    private ?array $json = null;

    private string $rawBody;

    /** @var array<string, mixed> Route parameters injected by the router. */
    private array $routeParams = [];

    /** @var array<string, mixed> Arbitrary attributes set by middleware (e.g. the API user). */
    private array $attributes = [];

    public function __construct(
        array $query,
        array $post,
        array $server,
        array $cookies,
        string $rawBody,
    ) {
        $this->query = $query;
        $this->post = $post;
        $this->cookies = $cookies;
        $this->rawBody = $rawBody;
        $this->headers = self::extractHeaders($server);
        $this->server = $server;
    }

    /** @var array<string, mixed> */
    private array $server;

    /**
     * Build a Request from PHP superglobals.
     */
    public static function capture(): self
    {
        return new self(
            $_GET,
            $_POST,
            $_SERVER,
            $_COOKIE,
            file_get_contents('php://input') ?: '',
        );
    }

    public function method(): string
    {
        $method = strtoupper($this->server['REQUEST_METHOD'] ?? 'GET');

        // Support method spoofing via _method for HTML forms.
        if ($method === 'POST' && isset($this->post['_method'])) {
            $spoofed = strtoupper((string) $this->post['_method']);
            if (in_array($spoofed, ['PUT', 'PATCH', 'DELETE'], true)) {
                return $spoofed;
            }
        }

        return $method;
    }

    public function isMethod(string $method): bool
    {
        return $this->method() === strtoupper($method);
    }

    /**
     * Request path without query string, always starting with "/".
     */
    public function path(): string
    {
        $uri = $this->server['REQUEST_URI'] ?? '/';
        $path = parse_url($uri, PHP_URL_PATH) ?: '/';

        return '/' . trim(rawurldecode($path), '/');
    }

    public function isAjax(): bool
    {
        return strtolower($this->header('X-Requested-With') ?? '') === 'xmlhttprequest';
    }

    public function wantsJson(): bool
    {
        $accept = $this->header('Accept') ?? '';

        return $this->isAjax() || str_contains($accept, 'application/json');
    }

    public function query(string $key, mixed $default = null): mixed
    {
        return $this->query[$key] ?? $default;
    }

    public function post(string $key, mixed $default = null): mixed
    {
        return $this->post[$key] ?? $default;
    }

    /**
     * Read an input value from JSON body, then POST, then query.
     */
    public function input(string $key, mixed $default = null): mixed
    {
        $json = $this->json();

        return $json[$key] ?? $this->post[$key] ?? $this->query[$key] ?? $default;
    }

    /**
     * @return array<string, mixed>
     */
    public function all(): array
    {
        return array_merge($this->query, $this->post, $this->json());
    }

    /**
     * Decoded JSON body (empty array when body is not valid JSON).
     *
     * @return array<string, mixed>
     */
    public function json(): array
    {
        if ($this->json !== null) {
            return $this->json;
        }

        if ($this->rawBody === '' || ! str_contains($this->header('Content-Type') ?? '', 'json')) {
            return $this->json = [];
        }

        $decoded = json_decode($this->rawBody, true);

        return $this->json = is_array($decoded) ? $decoded : [];
    }

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

    /**
     * @return array<string, string>
     */
    public function headers(): array
    {
        return $this->headers;
    }

    public function cookie(string $name, ?string $default = null): ?string
    {
        return $this->cookies[$name] ?? $default;
    }

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

    public function referer(): string
    {
        return $this->header('Referer') ?? '';
    }

    public function server(string $key, mixed $default = null): mixed
    {
        return $this->server[$key] ?? $default;
    }

    /**
     * Best-effort client IP resolution.
     *
     * Trusts proxy headers only when the direct peer is a configured trusted
     * proxy; otherwise falls back to REMOTE_ADDR to avoid spoofing.
     */
    public function ip(): string
    {
        $remote = (string) ($this->server['REMOTE_ADDR'] ?? '0.0.0.0');
        $trustedProxies = (array) config('app.trusted_proxies', []);

        if (in_array($remote, $trustedProxies, true)) {
            $forwarded = $this->header('X-Forwarded-For');
            if ($forwarded) {
                $first = trim(explode(',', $forwarded)[0]);
                if (filter_var($first, FILTER_VALIDATE_IP)) {
                    return $first;
                }
            }

            $cf = $this->header('CF-Connecting-IP');
            if ($cf && filter_var($cf, FILTER_VALIDATE_IP)) {
                return $cf;
            }
        }

        return filter_var($remote, FILTER_VALIDATE_IP) ? $remote : '0.0.0.0';
    }

    public function setRouteParams(array $params): void
    {
        $this->routeParams = $params;
    }

    public function routeParam(string $key, mixed $default = null): mixed
    {
        return $this->routeParams[$key] ?? $default;
    }

    /**
     * Attach an arbitrary attribute (used by middleware to pass data to controllers).
     */
    public function setAttribute(string $key, mixed $value): void
    {
        $this->attributes[$key] = $value;
    }

    public function attribute(string $key, mixed $default = null): mixed
    {
        return $this->attributes[$key] ?? $default;
    }

    /**
     * Normalize $_SERVER HTTP_* entries into a lowercase header map.
     *
     * @param array<string, mixed> $server
     * @return array<string, string>
     */
    private static function extractHeaders(array $server): array
    {
        $headers = [];

        foreach ($server as $key => $value) {
            if (str_starts_with($key, 'HTTP_')) {
                $name = strtolower(str_replace('_', '-', substr($key, 5)));
                $headers[$name] = (string) $value;
            }
        }

        // Content-Type / Content-Length are not prefixed with HTTP_.
        foreach (['CONTENT_TYPE' => 'content-type', 'CONTENT_LENGTH' => 'content-length'] as $srv => $hdr) {
            if (isset($server[$srv])) {
                $headers[$hdr] = (string) $server[$srv];
            }
        }

        return $headers;
    }
}
