<?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 Response
{
    /** @param array<string,string> $headers */
    public function __construct(
        public int    $status = 200,
        public string $body = '',
        public array  $headers = [],
    ) {
    }

    public static function html(string $html, int $status = 200): self
    {
        return new self($status, $html, ['Content-Type' => 'text/html; charset=utf-8']);
    }

    public static function json(mixed $data, int $status = 200): self
    {
        $body = json_encode(
            $data,
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
        );
        return new self($status, $body, ['Content-Type' => 'application/json; charset=utf-8']);
    }

    /** JSON success envelope per spec §7: { ok: true, data: … } */
    public static function ok(mixed $data, int $status = 200): self
    {
        return self::json(['ok' => true, 'data' => $data], $status);
    }

    /**
     * JSON error envelope per spec §7: { ok: false, error: { code, message, details? } }.
     * Validation errors should pass $status = 422.
     */
    public static function err(
        string $code,
        string $message,
        int $status = 400,
        ?array $details = null,
    ): self {
        $error = ['code' => $code, 'message' => $message];
        if ($details !== null) {
            $error['details'] = $details;
        }
        return self::json(['ok' => false, 'error' => $error], $status);
    }

    /**
     * Same-origin redirect. The contract is path-only — the location MUST
     * begin with a single `/` and continue with something other than `/`.
     * That rules out:
     *
     *   - relative paths (`foo`, `?bar=1`) — ambiguous resolution rules,
     *   - protocol-relative URLs (`//evil.example.com/x`) — silently
     *     leaves the origin while *looking* like a path,
     *   - absolute URLs (`https://evil/`) — ditto, more obvious.
     *
     * Callers that genuinely need to leave the origin (e.g. the canonical
     * HTTPS redirect from `public/index.php`) must use `external()`
     * instead. Refusing the wrong shape at construction time means a
     * future caller passing user-supplied input by accident gets a
     * loud `InvalidArgumentException` in dev/test instead of an
     * undetected open redirect in production (R01-N20).
     */
    public static function redirect(string $location, int $status = 302): self
    {
        if (!self::isPathOnly($location)) {
            throw new \InvalidArgumentException(
                "Response::redirect requires a path-only location starting with '/' "
                . "and not '//'. Got: " . self::previewForError($location) . ". "
                . "Use Response::external() for cross-origin redirects."
            );
        }
        return new self($status, '', ['Location' => $location]);
    }

    /**
     * Cross-origin redirect. Use only when leaving the origin is the
     * point of the redirect — currently the HTTP→HTTPS canonical hop in
     * `public/index.php`. Schemes are restricted to `http` / `https` so
     * a stray `javascript:` / `data:` payload cannot wind up in the
     * `Location:` header.
     */
    public static function external(string $url, int $status = 302): self
    {
        if (!self::isExternalUrl($url)) {
            throw new \InvalidArgumentException(
                "Response::external requires an http(s):// URL. "
                . "Got: " . self::previewForError($url)
            );
        }
        return new self($status, '', ['Location' => $url]);
    }

    public static function isPathOnly(string $location): bool
    {
        if ($location === '') {
            return false;
        }
        if ($location[0] !== '/') {
            return false;
        }
        // Reject protocol-relative URLs (`//host/path`).
        if (isset($location[1]) && $location[1] === '/') {
            return false;
        }
        // Reject control characters / CR-LF (would corrupt the
        // `Location:` header).
        return preg_match('/[\r\n\0]/', $location) === 0;
    }

    public static function isExternalUrl(string $url): bool
    {
        if ($url === '' || preg_match('/[\r\n\0]/', $url) !== 0) {
            return false;
        }
        $parts = parse_url($url);
        if ($parts === false || !isset($parts['scheme'], $parts['host'])) {
            return false;
        }
        $scheme = strtolower($parts['scheme']);
        return ($scheme === 'http' || $scheme === 'https') && $parts['host'] !== '';
    }

    private static function previewForError(string $value): string
    {
        $clipped = substr($value, 0, 80);
        return $clipped === $value ? "'{$clipped}'" : "'{$clipped}…'";
    }

    public static function text(string $text, int $status = 200): self
    {
        return new self($status, $text, ['Content-Type' => 'text/plain; charset=utf-8']);
    }

    public function withHeader(string $name, string $value): self
    {
        $this->headers[$name] = $value;
        return $this;
    }

    public function send(): void
    {
        if (!headers_sent()) {
            http_response_code($this->status);
            foreach ($this->headers as $name => $value) {
                header($name . ': ' . $value, true);
            }
        }
        echo $this->body;
    }
}
