<?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 Kirby\Toolkit\Str;
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(array_merge([
			'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(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(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(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(sprintf('The object "%s" cannot be resolved', get_class($object)));
	}

	/**
	 * 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;
		}

		// prepare the result array for all exception types
		$result = [
			'status'    => 'error',
			'message'   => $e->getMessage(),
			'code'      => empty($e->getCode()) === true ? 500 : $e->getCode(),
			'exception' => get_class($e),
			'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 {
		$defaults = [
			'query' => [],
			'body'  => [],
			'files' => []
		];

		$this->requestData = array_merge($defaults, (array)$requestData);
		return $this;
	}

	/**
	 * Setter for the request method
	 * @return $this
	 */
	protected function setRequestMethod(
		string $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
	): array {
		$trials  = 0;
		$uploads = [];
		$errors  = [];
		$files   = $this->requestFiles();

		// get error messages from translation
		$errorMessages = [
			UPLOAD_ERR_INI_SIZE   => I18n::translate('upload.error.iniSize'),
			UPLOAD_ERR_FORM_SIZE  => I18n::translate('upload.error.formSize'),
			UPLOAD_ERR_PARTIAL    => I18n::translate('upload.error.partial'),
			UPLOAD_ERR_NO_FILE    => I18n::translate('upload.error.noFile'),
			UPLOAD_ERR_NO_TMP_DIR => I18n::translate('upload.error.tmpDir'),
			UPLOAD_ERR_CANT_WRITE => I18n::translate('upload.error.cantWrite'),
			UPLOAD_ERR_EXTENSION  => I18n::translate('upload.error.extension')
		];

		if (empty($files) === true) {
			$postMaxSize       = Str::toBytes(ini_get('post_max_size'));
			$uploadMaxFileSize = Str::toBytes(ini_get('upload_max_filesize'));

			if ($postMaxSize < $uploadMaxFileSize) {
				throw new Exception(
					I18n::translate(
						'upload.error.iniPostSize',
						'The uploaded file exceeds the post_max_size directive in php.ini'
					)
				);
			}

			throw new Exception(
				I18n::translate(
					'upload.error.noFiles',
					'No files were uploaded'
				)
			);
		}

		foreach ($files as $upload) {
			if (
				isset($upload['tmp_name']) === false &&
				is_array($upload) === true
			) {
				continue;
			}

			$trials++;

			try {
				if ($upload['error'] !== 0) {
					throw new Exception(
						$errorMessages[$upload['error']] ??
						I18n::translate('upload.error.default', 'The file could not be uploaded')
					);
				}

				// get the extension of the uploaded file
				$extension = F::extension($upload['name']);

				// try to detect the correct mime and add the extension
				// accordingly. This will avoid .tmp filenames
				if (
					empty($extension) === true ||
					in_array($extension, ['tmp', 'temp']) === true
				) {
					$mime      = F::mime($upload['tmp_name']);
					$extension = F::mimeToExtension($mime);
					$filename  = F::name($upload['name']) . '.' . $extension;
				} else {
					$filename = basename($upload['name']);
				}

				$source = dirname($upload['tmp_name']) . '/' . uniqid() . '.' . $filename;

				// move the file to a location including the extension,
				// for better mime detection
				if (
					$debug === false &&
					move_uploaded_file($upload['tmp_name'], $source) === false
				) {
					throw new Exception(
						I18n::translate('upload.error.cantMove')
					);
				}

				$data = $callback($source, $filename);

				if (is_object($data) === true) {
					$data = $this->resolve($data)->toArray();
				}

				$uploads[$upload['name']] = $data;
			} catch (Exception $e) {
				$errors[$upload['name']] = $e->getMessage();
			}

			if ($single === true) {
				break;
			}
		}

		// return a single upload response
		if ($trials === 1) {
			if (empty($errors) === false) {
				return [
					'status'  => 'error',
					'message' => current($errors)
				];
			}

			return [
				'status' => 'ok',
				'data'   => current($uploads)
			];
		}

		if (empty($errors) === false) {
			return [
				'status' => 'error',
				'errors' => $errors
			];
		}

		return [
			'status' => 'ok',
			'data'   => $uploads
		];
	}
}
