<?php

namespace Kirby\Panel;

use Closure;
use Kirby\Api\Upload;
use Kirby\Cms\App;
use Kirby\Cms\Url as CmsUrl;
use Kirby\Cms\User;
use Kirby\Exception\Exception;
use Kirby\Exception\NotFoundException;
use Kirby\Exception\PermissionException;
use Kirby\Http\Response;
use Kirby\Http\Router;
use Kirby\Http\Uri;
use Kirby\Http\Url;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\Tpl;
use Throwable;

/**
 * The Panel class is only responsible to create
 * a working panel view with all the right URLs
 * and other panel options. The view template is
 * located in `kirby/views/panel.php`
 * @since 3.6.0
 *
 * @package   Kirby Panel
 * @author    Bastian Allgeier <bastian@getkirby.com>
 * @link      https://getkirby.com
 * @copyright Bastian Allgeier
 * @license   https://getkirby.com/license
 */
class Panel
{
	/**
	 * Normalize a panel area
	 */
	public static function area(string $id, array $area): array
	{
		$area['id']                = $id;
		$area['label']           ??= $id;
		$area['breadcrumb']      ??= [];
		$area['breadcrumbLabel'] ??= $area['label'];
		$area['title']             = $area['label'];
		$area['menu']            ??= false;
		$area['link']            ??= $id;
		$area['search']          ??= null;

		return $area;
	}

	/**
	 * Collect all registered areas
	 */
	public static function areas(): array
	{
		$kirby  = App::instance();
		$system = $kirby->system();
		$user   = $kirby->user();
		$areas  = $kirby->load()->areas();

		// the system is not ready
		if (
			$system->isOk() === false ||
			$system->isInstalled() === false
		) {
			return [
				'installation' => static::area(
					'installation',
					$areas['installation']
				),
			];
		}

		// not yet authenticated
		if (!$user) {
			return [
				'logout' => static::area('logout', $areas['logout']),
				// login area last because it defines a fallback route
				'login'  => static::area('login', $areas['login']),
			];
		}

		unset($areas['installation'], $areas['login']);

		// Disable the language area for single-language installations
		// This does not check for installed languages. Otherwise you'd
		// not be able to add the first language through the view
		if (!$kirby->option('languages')) {
			unset($areas['languages']);
		}

		$result = [];

		foreach ($areas as $id => $area) {
			$result[$id] = static::area($id, $area);
		}

		return $result;
	}

	/**
	 * Collect all registered buttons from areas
	 * @since 5.0.0
	 */
	public static function buttons(): array
	{
		return array_merge(...array_values(
			A::map(
				Panel::areas(),
				fn ($area) => $area['buttons'] ?? []
			)
		));
	}

	/**
	 * Check for access permissions
	 */
	public static function firewall(
		User|null $user = null,
		string|null $areaId = null
	): bool {
		// a user has to be logged in
		if ($user === null) {
			throw new PermissionException(
				key: 'access.panel'
			);
		}

		// get all access permissions for the user role
		$permissions = $user->role()->permissions()->toArray()['access'];

		// check for general panel access
		if (($permissions['panel'] ?? true) !== true) {
			throw new PermissionException(
				key: 'access.panel'
			);
		}

		// don't check if the area is not defined
		if (empty($areaId) === true) {
			return true;
		}

		// undefined area permissions means access
		if (isset($permissions[$areaId]) === false) {
			return true;
		}

		// no access
		if ($permissions[$areaId] !== true) {
			throw new PermissionException(
				key: 'access.view'
			);
		}

		return true;
	}

	/**
	 * Garbage collection which runs with a probability
	 * of 10% on each Panel request
	 *
	 * @since 5.0.0
	 * @codeCoverageIgnore
	 */
	protected static function garbage(): void
	{
		// run garbage collection with a chance of 10%;
		if (mt_rand(1, 10000) <= 0.1 * 10000) {
			// clean up leftover upload chunks
			Upload::cleanTmpDir();
		}
	}

	/**
	 * Redirect to a Panel url
	 *
	 * @throws \Kirby\Panel\Redirect
	 * @codeCoverageIgnore
	 */
	public static function go(string|null $url = null, int $code = 302, int|false $refresh = false): void
	{
		throw new Redirect(static::url($url), $code, $refresh);
	}

	/**
	 * Check if the given user has access to the panel
	 * or to a given area
	 */
	public static function hasAccess(
		User|null $user = null,
		string|null $area = null
	): bool {
		try {
			static::firewall($user, $area);
			return true;
		} catch (Throwable) {
			return false;
		}
	}

	/**
	 * Checks for a Fiber request
	 * via get parameters or headers
	 */
	public static function isFiberRequest(): bool
	{
		$request = App::instance()->request();

		if ($request->method() === 'GET') {
			return
				(bool)($request->get('_json') ??
				$request->header('X-Fiber'));
		}

		return false;
	}

	/**
	 * Returns a JSON response
	 * for Fiber calls
	 */
	public static function json(array $data, int $code = 200): Response
	{
		$request = App::instance()->request();

		return Response::json($data, $code, $request->get('_pretty'), [
			'X-Fiber'       => 'true',
			'Cache-Control' => 'no-store, private'
		]);
	}

	/**
	 * Checks for a multilanguage installation
	 */
	public static function multilang(): bool
	{
		// multilang setup check
		$kirby = App::instance();
		return $kirby->option('languages') || $kirby->multilang();
	}

	/**
	 * Returns the referrer path if present
	 */
	public static function referrer(): string
	{
		$request = App::instance()->request();

		$referrer = $request->header('X-Fiber-Referrer')
				 ?? $request->get('_referrer')
				 ?? '';

		return '/' . trim($referrer, '/');
	}

	/**
	 * Creates a Response object from the result of
	 * a Panel route call
	 */
	public static function response($result, array $options = []): Response
	{
		// pass responses directly down to the Kirby router
		if ($result instanceof Response) {
			return $result;
		}

		// interpret missing/empty results as not found
		if ($result === null || $result === false) {
			$result = new NotFoundException(
				message: 'The data could not be found'
			);

		// interpret strings as errors
		} elseif (is_string($result) === true) {
			$result = new Exception($result);
		}

		// handle different response types (view, dialog, ...)
		return match ($options['type'] ?? null) {
			'dialog'   => Dialog::response($result, $options),
			'drawer'   => Drawer::response($result, $options),
			'dropdown' => Dropdown::response($result, $options),
			'request'  => Request::response($result, $options),
			'search'   => Search::response($result, $options),
			default    => View::response($result, $options)
		};
	}

	/**
	 * Router for the Panel views
	 */
	public static function router(string|null $path = null): Response|null
	{
		$kirby = App::instance();

		if ($kirby->option('panel') === false) {
			return null;
		}

		// run garbage collection
		static::garbage();

		// set the translation for Panel UI before
		// gathering areas and routes, so that the
		// `t()` helper can already be used
		static::setTranslation();

		// set the language in multi-lang installations
		static::setLanguage();

		$areas  = static::areas();
		$routes = static::routes($areas);

		// create a micro-router for the Panel
		return Router::execute($path, $method = $kirby->request()->method(), $routes, function ($route) use ($areas, $kirby, $method, $path) {
			// route needs authentication?
			$auth   = $route->attributes()['auth'] ?? true;
			$areaId = $route->attributes()['area'] ?? null;
			$type   = $route->attributes()['type'] ?? 'view';
			$area   = $areas[$areaId ?? ''] ?? null;

			// call the route action to check the result
			try {
				// trigger hook
				$route = $kirby->apply(
					'panel.route:before',
					compact('route', 'path', 'method')
				);

				// check for access before executing area routes
				if ($auth !== false) {
					static::firewall($kirby->user(), $areaId);
				}

				$result = $route->action()->call($route, ...$route->arguments());
			} catch (Throwable $e) {
				$result = $e;
			}

			$response = static::response($result, [
				'area'  => $area,
				'areas' => $areas,
				'path'  => $path,
				'type'  => $type
			]);

			return $kirby->apply(
				'panel.route:after',
				compact('route', 'path', 'method', 'response'),
				'response'
			);
		});
	}

	/**
	 * Extract the routes from the given array
	 * of active areas.
	 */
	public static function routes(array $areas): array
	{
		$kirby = App::instance();

		// the browser incompatibility
		// warning is always needed
		$routes = [
			[
				'pattern' => 'browser',
				'auth'    => false,
				'action'  => fn () => new Response(
					Tpl::load($kirby->root('kirby') . '/views/browser.php')
				),
			]
		];

		// register all routes from areas
		foreach ($areas as $areaId => $area) {
			$routes = [
				...$routes,
				...static::routesForViews($areaId, $area),
				...static::routesForSearches($areaId, $area),
				...static::routesForDialogs($areaId, $area),
				...static::routesForDrawers($areaId, $area),
				...static::routesForDropdowns($areaId, $area),
				...static::routesForRequests($areaId, $area),
			];
		}

		// if the Panel is already installed and/or the
		// user is authenticated, those areas won't be
		// included, which is why we add redirect routes
		// to main Panel view as fallbacks
		$routes[] = [
			'pattern' => [
				'/',
				'installation',
				'login',
			],
			'action' => fn () => Panel::go(Home::url()),
			'auth' => false
		];

		// catch all route
		$routes[] = [
			'pattern' => '(:all)',
			'action'  => fn (string $pattern) => 'Could not find Panel view for route: ' . $pattern
		];

		return $routes;
	}

	/**
	 * Extract all routes from an area
	 */
	public static function routesForDialogs(string $areaId, array $area): array
	{
		$dialogs = $area['dialogs'] ?? [];
		$routes  = [];

		foreach ($dialogs as $dialogId => $dialog) {
			$routes = [
				...$routes,
				...Dialog::routes(
					id: $dialogId,
					areaId: $areaId,
					prefix: 'dialogs',
					options: $dialog
				)
			];
		}

		return $routes;
	}

	/**
	 * Extract all routes from an area
	 */
	public static function routesForDrawers(string $areaId, array $area): array
	{
		$drawers = $area['drawers'] ?? [];
		$routes  = [];

		foreach ($drawers as $drawerId => $drawer) {
			$routes = [
				...$routes,
				...Drawer::routes(
					id: $drawerId,
					areaId: $areaId,
					prefix: 'drawers',
					options: $drawer
				)
			];
		}

		return $routes;
	}

	/**
	 * Extract all routes for dropdowns
	 */
	public static function routesForDropdowns(string $areaId, array $area): array
	{
		$dropdowns = $area['dropdowns'] ?? [];
		$routes    = [];

		foreach ($dropdowns as $dropdownId => $dropdown) {
			$routes = [
				...$routes,
				...Dropdown::routes(
					id: $dropdownId,
					areaId: $areaId,
					prefix: 'dropdowns',
					options: $dropdown
				)
			];
		}

		return $routes;
	}

	/**
	 * Extract all routes from an area
	 */
	public static function routesForRequests(string $areaId, array $area): array
	{
		$routes = $area['requests'] ?? [];

		foreach ($routes as $key => $route) {
			$routes[$key]['area'] = $areaId;
			$routes[$key]['type'] = 'request';
		}

		return $routes;
	}

	/**
	 * Extract all routes for searches
	 */
	public static function routesForSearches(string $areaId, array $area): array
	{
		$searches = $area['searches'] ?? [];
		$routes   = [];

		foreach ($searches as $name => $params) {
			// create the full routing pattern
			$pattern = 'search/' . $name;

			// load event
			$routes[] = [
				'pattern' => $pattern,
				'type'    => 'search',
				'area'    => $areaId,
				'action'  => function () use ($params) {
					$kirby   = App::instance();
					$request = $kirby->request();
					$query   = $request->get('query');
					$limit   = (int)$request->get('limit', $kirby->option('panel.search.limit', 10));
					$page    = (int)$request->get('page', 1);

					return $params['query']($query, $limit, $page);
				}
			];
		}

		return $routes;
	}

	/**
	 * Extract all views from an area
	 */
	public static function routesForViews(string $areaId, array $area): array
	{
		$views  = $area['views'] ?? [];
		$routes = [];

		foreach ($views as $view) {
			$view['area'] = $areaId;
			$view['type'] = 'view';

			$when = $view['when'] ?? null;
			unset($view['when']);

			// enable the route by default, but if there is a
			// when condition closure, it must return `true`
			if (
				$when instanceof Closure === false ||
				$when($view, $area) === true
			) {
				$routes[] = $view;
			}
		}

		return $routes;
	}

	/**
	 * Set the current language in multi-lang
	 * installations based on the session or the
	 * query language query parameter
	 */
	public static function setLanguage(): string|null
	{
		$kirby = App::instance();

		// language switcher
		if (static::multilang()) {
			$fallback = 'en';

			if ($defaultLanguage = $kirby->defaultLanguage()) {
				$fallback = $defaultLanguage->code();
			}

			$session         = $kirby->session();
			$sessionLanguage = $session->get('panel.language', $fallback);
			$language        = $kirby->request()->get('language') ?? $sessionLanguage;

			// keep the language for the next visit
			if ($language !== $sessionLanguage) {
				$session->set('panel.language', $language);
			}

			// activate the current language in Kirby
			$kirby->setCurrentLanguage($language);

			return $language;
		}

		return null;
	}

	/**
	 * Set the currently active Panel translation
	 * based on the current user or config
	 */
	public static function setTranslation(): string
	{
		$kirby = App::instance();

		// use the user language for the default translation or
		// fall back to the language from the config
		$translation = $kirby->user()?->language() ??
						$kirby->panelLanguage();

		$kirby->setCurrentTranslation($translation);

		return $translation;
	}

	/**
	 * Creates an absolute Panel URL
	 * independent of the Panel slug config
	 */
	public static function url(string|null $url = null, array $options = []): string
	{
		// only touch relative paths
		if (Url::isAbsolute($url) === false) {
			$kirby = App::instance();
			$slug  = $kirby->option('panel.slug', 'panel');
			$path  = trim($url ?? '', '/');

			$baseUri  = new Uri($kirby->url());
			$basePath = trim($baseUri->path()->toString(), '/');

			// removes base path if relative path contains it
			if (empty($basePath) === false && Str::startsWith($path, $basePath) === true) {
				$path = Str::after($path, $basePath);
			}
			// add the panel slug prefix if it it's not
			// included in the path yet
			elseif (Str::startsWith($path, $slug . '/') === false) {
				$path = $slug . '/' . $path;
			}

			// create an absolute URL
			$url = CmsUrl::to($path, $options);
		}

		return $url;
	}
}
