<?php

namespace Kirby\Http;

use Closure;
use Exception;
use Kirby\Cms\App;
use Kirby\Exception\LogicException;
use Kirby\Filesystem\F;
use Stringable;

/**
 * Representation of an Http response,
 * to simplify sending correct headers
 * and Http status codes.
 *
 * @package   Kirby Http
 * @author    Bastian Allgeier <bastian@getkirby.com>
 * @link      https://getkirby.com
 * @copyright Bastian Allgeier
 * @license   https://opensource.org/licenses/MIT
 */
class Response implements Stringable
{
	/**
	 * Store for all registered headers,
	 * which will be sent with the response
	 */
	protected array $headers = [];

	/**
	 * The response body
	 */
	protected string $body;

	/**
	 * The HTTP response code
	 */
	protected int $code;

	/**
	 * The content type for the response
	 */
	protected string $type;

	/**
	 * The content type charset
	 */
	protected string $charset = 'UTF-8';

	/**
	 * Creates a new response object
	 */
	public function __construct(
		string|array $body = '',
		string|null $type = null,
		int|null $code = null,
		array|null $headers = null,
		string|null $charset = null
	) {
		// array construction
		if (is_array($body) === true) {
			$params  = $body;
			$body    = $params['body'] ?? '';
			$type    = $params['type'] ?? $type;
			$code    = $params['code'] ?? $code;
			$headers = $params['headers'] ?? $headers;
			$charset = $params['charset'] ?? $charset;
		}

		// regular construction
		$this->body    = $body;
		$this->type    = $type ?? 'text/html';
		$this->code    = $code ?? 200;
		$this->headers = $headers ?? [];
		$this->charset = $charset ?? 'UTF-8';

		// automatic mime type detection
		if (str_contains($this->type, '/') === false) {
			$this->type = F::extensionToMime($this->type) ?? 'text/html';
		}
	}

	/**
	 * Improved `var_dump` output
	 * @codeCoverageIgnore
	 */
	public function __debugInfo(): array
	{
		return $this->toArray();
	}

	/**
	 * Makes it possible to convert the
	 * entire response object to a string
	 * to send the headers and print the body
	 */
	public function __toString(): string
	{
		return $this->send();
	}

	/**
	 * Getter for the body
	 */
	public function body(): string
	{
		return $this->body;
	}

	/**
	 * Getter for the content type charset
	 */
	public function charset(): string
	{
		return $this->charset;
	}

	/**
	 * Getter for the HTTP status code
	 */
	public function code(): int
	{
		return $this->code;
	}

	/**
	 * Creates a response that triggers
	 * a file download for the given file
	 *
	 * @param array $props Custom overrides for response props (e.g. headers)
	 */
	public static function download(
		string $file,
		string|null $filename = null,
		array $props = []
	): static {
		if (file_exists($file) === false) {
			throw new Exception(message: 'The file could not be found');
		}

		$filename ??= basename($file);
		$modified   = filemtime($file);
		$body       = file_get_contents($file);
		$size       = strlen($body);

		$props = array_replace_recursive([
			'body'    => $body,
			'type'    => F::mime($file),
			'headers' => [
				'Pragma'                    => 'public',
				'Cache-Control'             => 'no-cache, no-store, must-revalidate',
				'Last-Modified'             => gmdate('D, d M Y H:i:s', $modified) . ' GMT',
				'Content-Disposition'       => 'attachment; filename="' . $filename . '"',
				'Content-Transfer-Encoding' => 'binary',
				'Content-Length'            => $size,
				'Connection'                => 'close'
			]
		], $props);

		return new static($props);
	}

	/**
	 * Ensures safe MIME type handling by forcing plain text
	 * for files without recognizable MIME types to harden
	 * against attacks from malicious file uploads
	 * @since 5.3.0
	 * @internal
	 */
	public static function ensureSafeMimeType(array $props): array
	{
		if ($props['type'] === null) {
			$props['type'] = 'text/plain';
			$props['headers']['X-Content-Type-Options'] = 'nosniff';
		}

		return $props;
	}

	/**
	 * Creates a response for a file and
	 * sends the file content to the browser
	 *
	 * @param array $props Custom overrides for response props (e.g. headers)
	 */
	public static function file(string $file, array $props = []): static
	{
		$request = App::instance(lazy: true)?->request();

		// handle byte-range requests (e.g., for video streaming in Safari)
		if ($range = $request?->header('Range')) {
			return Range::response($file, $range, $props);
		}

		// always indicate that byte-range requests are supported
		$props['headers'] = [
			'Accept-Ranges' => 'bytes',
			...$props['headers'] ?? []
		];

		$props = static::ensureSafeMimeType([
			'body' => F::read($file),
			'type' => F::extensionToMime(F::extension($file)),
			...$props
		]);

		return new static($props);
	}


	/**
	 * Redirects to the given Urls
	 * Urls can be relative or absolute.
	 * @since 3.7.0
	 *
	 * @codeCoverageIgnore
	 */
	public static function go(string $url = '/', int $code = 302): never
	{
		die(static::redirect($url, $code));
	}

	/**
	 * Ensures that the callback does not produce the first body output
	 * (used to show when loading a file creates side effects)
	 */
	public static function guardAgainstOutput(Closure $callback, ...$args): mixed
	{
		$before = headers_sent();
		$result = $callback(...$args);
		$after  = headers_sent($file, $line);

		if ($before === false && $after === true) {
			throw new LogicException("Disallowed output from file $file:$line, possible accidental whitespace?");
		}

		return $result;
	}

	/**
	 * Getter for single headers
	 *
	 * @param string $key Name of the header
	 */
	public function header(string $key): string|null
	{
		return $this->headers[$key] ?? null;
	}

	/**
	 * Getter for all headers
	 */
	public function headers(): array
	{
		return $this->headers;
	}

	/**
	 * Creates a json response with appropriate
	 * header and automatic conversion of arrays.
	 */
	public static function json(
		string|array $body = '',
		int|null $code = null,
		bool|null $pretty = null,
		array $headers = []
	): static {
		if (is_array($body) === true) {
			$body = json_encode($body, $pretty === true ? JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES : 0);
		}

		return new static([
			'body'    => $body,
			'code'    => $code,
			'type'    => 'application/json',
			'headers' => $headers
		]);
	}

	/**
	 * Creates a redirect response,
	 * which will send the visitor to the
	 * given location.
	 */
	public static function redirect(string $location = '/', int $code = 302): static
	{
		return new static([
			'code' => $code,
			'headers' => [
				'Location' => Url::unIdn($location)
			]
		]);
	}

	/**
	 * Creates a refresh response, which will
	 * send the visitor to the given location
	 * after the specified number of seconds.
	 *
	 * @since 5.0.3
	 */
	public static function refresh(
		string $location = '/',
		int $code = 302,
		int $refresh = 0
	): static {
		return new static([
			'code'    => $code,
			'headers' => [
				'Refresh' => $refresh . '; url=' . Url::unIdn($location)
			]
		]);
	}

	/**
	 * Sends all registered headers and
	 * returns the response body
	 */
	public function send(): string
	{
		// send the status response code
		http_response_code($this->code());

		// send all custom headers
		foreach ($this->headers() as $key => $value) {
			header($key . ': ' . $value);
		}

		// send the content type header
		header('Content-Type: ' . $this->type() . '; charset=' . $this->charset());

		// print the response body
		return $this->body();
	}

	/**
	 * Sets the provided headers in case they are not already set
	 * @internal
	 * @return $this
	 */
	public function setHeaderFallbacks(array $headers): static
	{
		// the case-insensitive nature of headers will be
		// handled by PHP's `header()` functions
		$this->headers = [...$headers, ...$this->headers];
		return $this;
	}

	/**
	 * Converts all relevant response attributes
	 * to an associative array for debugging,
	 * testing or whatever.
	 */
	public function toArray(): array
	{
		return [
			'type'    => $this->type(),
			'charset' => $this->charset(),
			'code'    => $this->code(),
			'headers' => $this->headers(),
			'body'    => $this->body()
		];
	}

	/**
	 * Getter for the content type
	 */
	public function type(): string
	{
		return $this->type;
	}
}
