<?php

namespace Kirby\Api;

use Closure;
use Exception;
use Kirby\Cms\User;
use Kirby\Exception\Exception as ExceptionException;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\F;
use Kirby\Http\Response;
use Kirby\Http\Route;
use Kirby\Http\Router;
use Kirby\Toolkit\Collection as BaseCollection;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Pagination;
use Throwable;

/**
 * The API class is a generic container
 * for API routes, models and collections and is used
 * to run our REST API. You can find our API setup
 * in `kirby/config/api.php`.
 *
 * @package   Kirby Api
 * @author    Bastian Allgeier <bastian@getkirby.com>
 * @link      https://getkirby.com
 * @copyright Bastian Allgeier
 * @license   https://getkirby.com/license
 */
class Api
{
	/**
	 * Authentication callback
	 */
	protected Closure|null $authentication = null;

	/**
	 * Debugging flag
	 */
	protected bool $debug = false;

	/**
	 * Collection definition
	 */
	protected array $collections = [];

	/**
	 * Injected data/dependencies
	 */
	protected array $data = [];

	/**
	 * Model definitions
	 */
	protected array $models = [];

	/**
	 * The current route
	 */
	protected Route|null $route = null;

	/**
	 * The Router instance
	 */
	protected Router|null $router = null;

	/**
	 * Route definition
	 */
	protected array $routes = [];

	/**
	 * Request data
	 * [query, body, files]
	 */
	protected array $requestData = [];

	/**
	 * The applied request method
	 * (GET, POST, PATCH, etc.)
	 */
	protected string|null $requestMethod = null;

	/**
	 * Creates a new API instance
	 */
	public function __construct(array $props)
	{
		$this->authentication = $props['authentication'] ?? null;
		$this->data           = $props['data'] ?? [];
		$this->routes         = $props['routes'] ?? [];
		$this->debug  		  = $props['debug'] ?? false;

		if ($collections = $props['collections'] ?? null) {
			$this->collections = array_change_key_case($collections);
		}

		if ($models = $props['models'] ?? null) {
			$this->models = array_change_key_case($models);
		}

		$this->setRequestData($props['requestData'] ?? null);
		$this->setRequestMethod($props['requestMethod'] ?? null);
	}

	/**
	 * Magic accessor for any given data
	 *
	 * @throws \Kirby\Exception\NotFoundException
	 */
	public function __call(string $method, array $args = [])
	{
		return $this->data($method, ...$args);
	}

	/**
	 * Runs the authentication method
	 * if set
	 */
	public function authenticate()
	{
		return $this->authentication()?->call($this) ?? true;
	}

	/**
	 * Returns the authentication callback
	 */
	public function authentication(): Closure|null
	{
		return $this->authentication;
	}

	/**
	 * Execute an API call for the given path,
	 * request method and optional request data
	 *
	 * @throws \Kirby\Exception\NotFoundException
	 * @throws \Exception
	 */
	public function call(
		string|null $path = null,
		string $method = 'GET',
		array $requestData = []
	): mixed {
		$path = rtrim($path ?? '', '/');

		$this->setRequestMethod($method);
		$this->setRequestData($requestData);

		$this->router = new Router($this->routes());
		$this->route  = $this->router->find($path, $method);
		$auth = $this->route?->attributes()['auth'] ?? true;

		if ($auth !== false) {
			$user = $this->authenticate();

			// set PHP locales based on *user* language
			// so that e.g. strftime() gets formatted correctly
			if ($user instanceof User) {
				$language = $user->language();

				// get the locale from the translation
				$locale = $user->kirby()->translation($language)->locale();

				// provide some variants as fallbacks to be
				// compatible with as many systems as possible
				$locales = [
					$locale . '.UTF-8',
					$locale . '.UTF8',
					$locale . '.ISO8859-1',
					$locale,
					$language,
					setlocale(LC_ALL, 0) // fall back to the previously defined locale
				];

				// set the locales that are relevant for string formatting
				// *don't* set LC_CTYPE to avoid breaking other parts of the system
				setlocale(LC_MONETARY, $locales);
				setlocale(LC_NUMERIC, $locales);
				setlocale(LC_TIME, $locales);
			}
		}

		// don't throw pagination errors if pagination
		// page is out of bounds
		$validate = Pagination::$validate;
		Pagination::$validate = false;

		$output = $this->route?->action()->call(
			$this,
			...$this->route->arguments()
		);

		// restore old pagination validation mode
		Pagination::$validate = $validate;

		if (
			is_object($output) === true &&
			$output instanceof Response === false
		) {
			return $this->resolve($output)->toResponse();
		}

		return $output;
	}

	/**
	 * Creates a new instance while
	 * merging initial and new properties
	 */
	public function clone(array $props = []): static
	{
		return new static([
			'autentication' => $this->authentication,
			'data'			=> $this->data,
			'routes'		=> $this->routes,
			'debug'			=> $this->debug,
			'collections'   => $this->collections,
			'models'		=> $this->models,
			'requestData'   => $this->requestData,
			'requestMethod' => $this->requestMethod,
			...$props
		]);
	}

	/**
	 * Setter and getter for an API collection
	 *
	 * @throws \Kirby\Exception\NotFoundException If no collection for `$name` exists
	 * @throws \Exception
	 */
	public function collection(
		string $name,
		array|BaseCollection|null $collection = null
	): Collection {
		if (isset($this->collections[$name]) === false) {
			throw new NotFoundException(
				message: sprintf('The collection "%s" does not exist', $name)
			);
		}

		return new Collection($this, $collection, $this->collections[$name]);
	}

	/**
	 * Returns the collections definition
	 */
	public function collections(): array
	{
		return $this->collections;
	}

	/**
	 * Returns the injected data array
	 * or certain parts of it by key
	 *
	 * @throws \Kirby\Exception\NotFoundException If no data for `$key` exists
	 */
	public function data(string|null $key = null, ...$args): mixed
	{
		if ($key === null) {
			return $this->data;
		}

		if ($this->hasData($key) === false) {
			throw new NotFoundException(
				message: sprintf('Api data for "%s" does not exist', $key)
			);
		}

		// lazy-load data wrapped in Closures
		if ($this->data[$key] instanceof Closure) {
			return $this->data[$key]->call($this, ...$args);
		}

		return $this->data[$key];
	}

	/**
	 * Returns the debugging flag
	 */
	public function debug(): bool
	{
		return $this->debug;
	}

	/**
	 * Checks if injected data exists for the given key
	 */
	public function hasData(string $key): bool
	{
		return isset($this->data[$key]) === true;
	}

	/**
	 * Matches an object with an array item
	 * based on the `type` field
	 *
	 * @param array models or collections
	 * @return string|null key of match
	 */
	protected function match(
		array $array,
		$object = null
	): string|null {
		foreach ($array as $definition => $model) {
			if ($object instanceof $model['type']) {
				return $definition;
			}
		}

		return null;
	}

	/**
	 * Returns an API model instance by name
	 *
	 * @throws \Kirby\Exception\NotFoundException If no model for `$name` exists
	 */
	public function model(
		string|null $name = null,
		$object = null
	): Model {
		// Try to auto-match object with API models
		$name ??= $this->match($this->models, $object);

		if (isset($this->models[$name]) === false) {
			throw new NotFoundException(
				message: sprintf('The model "%s" does not exist', $name ?? 'NULL')
			);
		}

		return new Model($this, $object, $this->models[$name]);
	}

	/**
	 * Returns all model definitions
	 */
	public function models(): array
	{
		return $this->models;
	}

	/**
	 * Getter for request data
	 * Can either get all the data
	 * or certain parts of it.
	 */
	public function requestData(
		string|null $type = null,
		string|null $key = null,
		mixed $default = null
	): mixed {
		if ($type === null) {
			return $this->requestData;
		}

		if ($key === null) {
			return $this->requestData[$type] ?? [];
		}

		$data = array_change_key_case($this->requestData($type));
		$key  = strtolower($key);

		return $data[$key] ?? $default;
	}

	/**
	 * Returns the request body if available
	 */
	public function requestBody(
		string|null $key = null,
		mixed $default = null
	): mixed {
		return $this->requestData('body', $key, $default);
	}

	/**
	 * Returns the files from the request if available
	 */
	public function requestFiles(
		string|null $key = null,
		mixed $default = null
	): mixed {
		return $this->requestData('files', $key, $default);
	}

	/**
	 * Returns all headers from the request if available
	 */
	public function requestHeaders(
		string|null $key = null,
		mixed $default = null
	): mixed {
		return $this->requestData('headers', $key, $default);
	}

	/**
	 * Returns the request method
	 */
	public function requestMethod(): string|null
	{
		return $this->requestMethod;
	}

	/**
	 * Returns the request query if available
	 */
	public function requestQuery(
		string|null $key = null,
		mixed $default = null
	): mixed {
		return $this->requestData('query', $key, $default);
	}

	/**
	 * Turns a Kirby object into an
	 * API model or collection representation
	 *
	 * @throws \Kirby\Exception\NotFoundException If `$object` cannot be resolved
	 */
	public function resolve($object): Model|Collection
	{
		if (
			$object instanceof Model ||
			$object instanceof Collection
		) {
			return $object;
		}

		if ($model = $this->match($this->models, $object)) {
			return $this->model($model, $object);
		}

		if ($collection = $this->match($this->collections, $object)) {
			return $this->collection($collection, $object);
		}

		throw new NotFoundException(
			message: sprintf('The object "%s" cannot be resolved', $object::class)
		);
	}

	/**
	 * Returns all defined routes
	 */
	public function routes(): array
	{
		return $this->routes;
	}

	/**
	 * Renders the API call
	 */
	public function render(
		string $path,
		string $method = 'GET',
		array $requestData = []
	): mixed {
		try {
			$result = $this->call($path, $method, $requestData);
		} catch (Throwable $e) {
			$result = $this->responseForException($e);
		}

		$result = match ($result) {
			null    => $this->responseFor404(),
			false   => $this->responseFor400(),
			true    => $this->responseFor200(),
			default => $result
		};

		if (is_array($result) === false) {
			return $result;
		}

		// pretty print json data
		$pretty = (bool)($requestData['query']['pretty'] ?? false) === true;

		if (($result['status'] ?? 'ok') === 'error') {
			$code = $result['code'] ?? 400;

			// sanitize the error code
			if ($code < 400 || $code > 599) {
				$code = 500;
			}

			return Response::json($result, $code, $pretty);
		}

		return Response::json($result, 200, $pretty);
	}

	/**
	 * Returns a 200 - ok
	 * response array.
	 */
	public function responseFor200(): array
	{
		return [
			'status'  => 'ok',
			'message' => 'ok',
			'code'    => 200
		];
	}

	/**
	 * Returns a 400 - bad request
	 * response array.
	 */
	public function responseFor400(): array
	{
		return [
			'status'  => 'error',
			'message' => 'bad request',
			'code'    => 400,
		];
	}

	/**
	 * Returns a 404 - not found
	 * response array.
	 */
	public function responseFor404(): array
	{
		return [
			'status'  => 'error',
			'message' => 'not found',
			'code'    => 404,
		];
	}

	/**
	 * Creates the response array for
	 * an exception. Kirby exceptions will
	 * have more information
	 */
	public function responseForException(Throwable $e): array
	{
		if (isset($this->kirby) === true) {
			$docRoot = $this->kirby->environment()->get('DOCUMENT_ROOT');
		} else {
			$docRoot = $_SERVER['DOCUMENT_ROOT'] ?? null;
		}

		// determine which message to expose to avoid leaking
		// internal details (e.g. file paths) from PHP errors
		if ($e instanceof ExceptionException) {
			// Kirby exceptions carry safe, intentional messages
			// that can be returned as-is whether debugging or not
			$message = $e->getMessage();

		} elseif ($this->debug === true) {
			// in debug mode, expose the actual message
			// but disguise absolute file paths in it
			$message = $e->getMessage();

			if (isset($this->kirby) === true) {
				$message = $this->kirby->disguiseFilePath($message);
			}

		} else {
			// any other (PHP) error could leak internal details,
			// so only a generic message is returned
			$message = I18n::translate('error.unexpected');
		}

		// prepare the result array for all exception types
		$result = [
			'status'    => 'error',
			'message'   => $message,
			'code'      => empty($e->getCode()) === true ? 500 : $e->getCode(),
			'exception' => $e::class,
			'key'       => null,
			'file'      => F::relativepath($e->getFile(), $docRoot),
			'line'      => $e->getLine(),
			'details'   => [],
			'route'     => $this->route?->pattern()
		];

		// extend the information for Kirby Exceptions
		if ($e instanceof ExceptionException) {
			$result['key']     = $e->getKey();
			$result['details'] = $e->getDetails();
			$result['code']    = $e->getHttpCode();
		}

		// remove critical info from the result set if
		// debug mode is switched off
		if ($this->debug !== true) {
			unset(
				$result['file'],
				$result['exception'],
				$result['line'],
				$result['route']
			);
		}

		return $result;
	}

	/**
	 * Setter for the request data
	 * @return $this
	 */
	protected function setRequestData(
		array|null $requestData = []
	): static {
		$this->requestData = [
			'query' => [],
			'body'  => [],
			'files' => [],
			...$requestData ?? []
		];
		return $this;
	}

	/**
	 * Setter for the request method
	 * @return $this
	 */
	protected function setRequestMethod(
		string|null $requestMethod = null
	): static {
		$this->requestMethod = $requestMethod ?? 'GET';
		return $this;
	}

	/**
	 * Upload helper method
	 *
	 * move_uploaded_file() not working with unit test
	 * Added debug parameter for testing purposes as we did in the Email class
	 *
	 * @throws \Exception If request has no files or there was an error with the upload
	 */
	public function upload(
		Closure $callback,
		bool $single = false,
		bool $debug = false,
		string|null $template = null,
		Closure|null $preflight = null
	): array {
		$upload = new Upload(
			api: $this,
			single: $single,
			debug: $debug,
			template: $template,
			preflight: $preflight
		);

		return $upload->process($callback);
	}
}
