<?php
/**
 * @package   FOF
 * @copyright Copyright (c)2010-2022 Nicholas K. Dionysopoulos / Akeeba Ltd
 * @license   GNU General Public License version 3, or later
 */

namespace FOF40\Dispatcher;

defined('_JEXEC') || die;

use Exception;
use FOF40\Container\Container;
use FOF40\Controller\Controller;
use FOF40\Dispatcher\Exception\AccessForbidden;
use FOF40\TransparentAuthentication\TransparentAuthentication;

/**
 * A generic MVC dispatcher
 *
 * @property-read  \FOF40\Input\Input $input  The input object (magic __get returns the Input from the Container)
 */
class Dispatcher
{
	/** @var   string  The name of the default view, in case none is specified */
	public $defaultView;

	/** @var  array  Local cache of the dispatcher configuration */
	protected $config = [];

	/** @var  Container  The container we belong to */
	protected $container;

	/** @var  string  The view which will be rendered by the dispatcher */
	protected $view;

	/** @var  string  The layout for rendering the view */
	protected $layout;

	/** @var  Controller  The controller which will be used */
	protected $controller;

	/** @var  bool  Is this user transparently logged in? */
	protected $isTransparentlyLoggedIn = false;

	/**
	 * Public constructor
	 *
	 * The $config array can contain the following optional values:
	 * defaultView  string  The view to render if none is specified in $input
	 *
	 * Do note that $config is passed to the Controller and through it to the Model and View. Please see these classes
	 * for more information on the configuration variables they accept.
	 *
	 * @param   Container  $container
	 * @param   array      $config
	 */
	public function __construct(Container $container, array $config = [])
	{
		$this->container = $container;

		$this->config = $config;

		$this->defaultView = $container->appConfig->get('dispatcher.defaultView', $this->defaultView);

		if (isset($config['defaultView']))
		{
			$this->defaultView = $config['defaultView'];
		}

		$this->supportCustomViewAndTaskParameters();

		// Get the default values for the view and layout names
		$this->view   = $this->input->getCmd('view', null);
		$this->layout = $this->input->getCmd('layout', null);

		// Not redundant; you may pass an empty but non-null view which is invalid, so we need the fallback
		if (empty($this->view))
		{
			$this->view = $this->defaultView;
			$this->container->input->set('view', $this->view);
		}
	}

	/**
	 * Magic get method. Handles magic properties:
	 * $this->input  mapped to $this->container->input
	 *
	 * @param   string  $name  The property to fetch
	 *
	 * @return  mixed|null
	 */
	public function __get(string $name)
	{
		// Handle $this->input
		if ($name == 'input')
		{
			return $this->container->input;
		}

		// Property not found; raise error
		$trace = debug_backtrace();
		trigger_error(
			'Undefined property via __get(): ' . $name .
			' in ' . $trace[0]['file'] .
			' on line ' . $trace[0]['line'],
			E_USER_NOTICE);

		return null;
	}

	/**
	 * The main code of the Dispatcher. It spawns the necessary controller and
	 * runs it.
	 *
	 * @return  void
	 *
	 * @throws AccessForbidden  When the access is forbidden
	 * @throws Exception For displaying an error page
	 */
	public function dispatch(): void
	{
		// Load the translations for this component;
		$this->container->platform->loadTranslations($this->container->componentName);

		// Perform transparent authentication
		if ($this->container->platform->getUser()->guest)
		{
			$this->transparentAuthenticationLogin();
		}

		// Get the event names (different for CLI)
		$onBeforeEventName = 'onBeforeDispatch';
		$onAfterEventName  = 'onAfterDispatch';

		if ($this->container->platform->isCli())
		{
			$onBeforeEventName = 'onBeforeDispatchCLI';
			$onAfterEventName  = 'onAfterDispatchCLI';
		}

		try
		{
			$result = $this->triggerEvent($onBeforeEventName);
			$error  = '';
		}
		catch (\Exception $e)
		{
			$result = false;
			$error  = $e->getMessage();
		}

		if ($result === false)
		{
			if ($this->container->platform->isCli())
			{
				$this->container->platform->setHeader('Status', '403 Forbidden', true);
			}

			$this->transparentAuthenticationLogout();

			$this->container->platform->showErrorPage(new AccessForbidden);
		}

		// Get and execute the controller
		$view = $this->input->getCmd('view', $this->defaultView);
		$task = $this->input->getCmd('task', 'default');

		if (empty($task))
		{
			$task = 'default';
			$this->input->set('task', $task);
		}

		try
		{
			$this->controller = $this->container->factory->controller($view, $this->config);
			$status           = $this->controller->execute($task);
		}
		catch (Exception $e)
		{
			$this->container->platform->showErrorPage($e);

			// Redundant; just to make code sniffers happy
			return;
		}

		if ($status !== false)
		{
			try
			{
				$this->triggerEvent($onAfterEventName);
			}
			catch (\Exception $e)
			{
				$status = false;
			}
		}

		if ($status === false)
		{
			if ($this->container->platform->isCli())
			{
				$this->container->platform->setHeader('Status', '403 Forbidden', true);
			}

			$this->transparentAuthenticationLogout();

			$this->container->platform->showErrorPage(new AccessForbidden);
		}

		$this->transparentAuthenticationLogout();

		$this->controller->redirect();
	}

	/**
	 * Returns a reference to the Controller object currently in use by the dispatcher
	 *
	 * @return Controller|null
	 */
	public function &getController(): ?Controller
	{
		return $this->controller;
	}

	/**
	 * Triggers an object-specific event. The event runs both locally –if a suitable method exists– and through the
	 * Joomla! plugin system. A true/false return value is expected. The first false return cancels the event.
	 *
	 * EXAMPLE
	 * Component: com_foobar, Object name: item, Event: onBeforeDispatch, Arguments: array(123, 456)
	 * The event calls:
	 * 1. $this->onBeforeDispatch(123, 456)
	 * 2. Joomla! plugin event onComFoobarDispatcherBeforeDispatch($this, 123, 456)
	 *
	 * @param   string  $event      The name of the event, typically named onPredicateVerb e.g. onBeforeKick
	 * @param   array   $arguments  The arguments to pass to the event handlers
	 *
	 * @return  bool
	 */
	protected function triggerEvent(string $event, array $arguments = []): bool
	{
		$result = true;

		// If there is an object method for this event, call it
		if (method_exists($this, $event))
		{
			$result = $this->{$event}(...$arguments);
		}

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

		// All other event handlers live outside this object, therefore they need to be passed a reference to this
		// objects as the first argument.
		array_unshift($arguments, $this);

		// If we have an "on" prefix for the event (e.g. onFooBar) remove it and stash it for later.
		$prefix = '';

		if (substr($event, 0, 2) == 'on')
		{
			$prefix = 'on';
			$event  = substr($event, 2);
		}

		// Get the component/model prefix for the event
		$prefix .= 'Com' . ucfirst($this->container->bareComponentName) . 'Dispatcher';

		// The event name will be something like onComFoobarItemsBeforeSomething
		$event = $prefix . $event;

		// Call the Joomla! plugins
		$results = $this->container->platform->runPlugins($event, $arguments);

		return !in_array(false, $results, true);
	}

	/**
	 * Handles the transparent authentication log in
	 */
	protected function transparentAuthenticationLogin(): void
	{
		/** @var TransparentAuthentication $transparentAuth */
		$transparentAuth = $this->container->transparentAuth;
		$authInfo        = $transparentAuth->getTransparentAuthenticationCredentials();

		if (empty($authInfo))
		{
			return;
		}

		$this->isTransparentlyLoggedIn = $this->container->platform->loginUser($authInfo);
	}

	/**
	 * Handles the transparent authentication log out
	 */
	protected function transparentAuthenticationLogout(): void
	{
		if (!$this->isTransparentlyLoggedIn)
		{
			return;
		}

		/** @var TransparentAuthentication $transparentAuth */
		$transparentAuth = $this->container->transparentAuth;

		if (!$transparentAuth->getLogoutOnExit())
		{
			return;
		}

		$this->container->platform->logoutUser();
	}

	/**
	 * Adds support for akview/aktask in lieu of view and task.
	 *
	 * This is for future-proofing FOF in case Joomla assigns special meaning to view and task, e.g. by trying to find a
	 * specific controller / task class instead of letting the component's front-end router handle it. If that happens
	 * FOF components can have a single Joomla-compatible view/task which launches the Dispatcher and perform internal
	 * routing using akview/aktask.
	 *
	 * @return  void
	 * @since   3.6.3
	 */
	private function supportCustomViewAndTaskParameters()
	{
		$view = $this->input->getCmd('akview', null);
		$task = $this->input->getCmd('aktask', null);

		if (!is_null($view))
		{
			$this->input->remove('akview');
			$this->input->set('view', $view);
		}

		if (!is_null($task))
		{
			$this->input->remove('aktask');
			$this->input->set('task', $task);
		}
	}
}
