<?php

namespace dokuwiki;

use dokuwiki\Extension\Event;
use dokuwiki\Action\AbstractAction;
use dokuwiki\Action\Exception\ActionDisabledException;
use dokuwiki\Action\Exception\ActionException;
use dokuwiki\Action\Exception\FatalException;
use dokuwiki\Action\Exception\NoActionException;
use dokuwiki\Action\Plugin;

/**
 * Class ActionRouter
 * @package dokuwiki
 */
class ActionRouter
{
    /** @var  AbstractAction */
    protected $action;

    /** @var  ActionRouter */
    protected static $instance;

    /** @var int transition counter */
    protected $transitions = 0;

    /** maximum loop */
    protected const MAX_TRANSITIONS = 5;

    /** @var string[] the actions disabled in the configuration */
    protected $disabled;

    /**
     * ActionRouter constructor. Singleton, thus protected!
     *
     * Sets up the correct action based on the $ACT global. Writes back
     * the selected action to $ACT
     */
    protected function __construct()
    {
        global $ACT;
        global $conf;

        $this->disabled = explode(',', $conf['disableactions']);
        $this->disabled = array_map(trim(...), $this->disabled);

        $ACT = act_clean($ACT);
        $this->setupAction($ACT);
        $ACT = $this->action->getActionName();
    }

    /**
     * Get the singleton instance
     *
     * @param bool $reinit
     * @return ActionRouter
     */
    public static function getInstance($reinit = false)
    {
        if ((!self::$instance instanceof \dokuwiki\ActionRouter) || $reinit) {
            self::$instance = new ActionRouter();
        }
        return self::$instance;
    }

    /**
     * Setup the given action
     *
     * Instantiates the right class, runs permission checks and pre-processing and
     * sets $action
     *
     * @param string $actionname this is passed as a reference to $ACT, for plugin backward compatibility
     * @triggers ACTION_ACT_PREPROCESS
     */
    protected function setupAction(&$actionname)
    {
        $presetup = $actionname;

        try {
            // give plugins an opportunity to process the actionname
            $evt = new Event('ACTION_ACT_PREPROCESS', $actionname);
            if ($evt->advise_before()) {
                $this->action = $this->loadAction($actionname);
                $this->checkAction($this->action);
                $this->action->preProcess();
            } else {
                // event said the action should be kept, assume action plugin will handle it later
                $this->action = new Plugin($actionname);
            }
            $evt->advise_after();
        } catch (ActionException $e) {
            // we should have gotten a new action
            $actionname = $e->getNewAction();

            // this one should trigger a user message
            if ($e instanceof ActionDisabledException) {
                msg('Action disabled: ' . hsc($presetup), -1);
            }

            // some actions may request the display of a message
            if ($e->displayToUser()) {
                msg(hsc($e->getMessage()), -1);
            }

            // do setup for new action
            $this->transitionAction($presetup, $actionname);
        } catch (NoActionException) {
            msg('Action unknown: ' . hsc($actionname), -1);
            $actionname = 'show';
            $this->transitionAction($presetup, $actionname);
        } catch (\Exception $e) {
            $this->handleFatalException($e);
        }
    }

    /**
     * Transitions from one action to another
     *
     * Basically just calls setupAction() again but does some checks before.
     *
     * @param string $from current action name
     * @param string $to new action name
     * @param null|ActionException $e any previous exception that caused the transition
     */
    protected function transitionAction($from, $to, $e = null)
    {
        $this->transitions++;

        // no infinite recursion
        if ($from == $to) {
            $this->handleFatalException(new FatalException('Infinite loop in actions', 500, $e));
        }

        // larger loops will be caught here
        if ($this->transitions >= self::MAX_TRANSITIONS) {
            $this->handleFatalException(new FatalException('Maximum action transitions reached', 500, $e));
        }

        // do the recursion
        $this->setupAction($to);
    }

    /**
     * Aborts all processing with a message
     *
     * When a FataException instanc is passed, the code is treated as Status code
     *
     * @param \Exception|FatalException $e
     * @throws FatalException during unit testing
     */
    protected function handleFatalException(\Throwable $e)
    {
        if ($e instanceof FatalException) {
            http_status($e->getCode());
        } else {
            http_status(500);
        }
        if (defined('DOKU_UNITTEST')) {
            throw $e;
        }
        ErrorHandler::logException($e);
        $msg = 'Something unforeseen has happened: ' . $e->getMessage();
        nice_die(hsc($msg));
    }

    /**
     * Load the given action
     *
     * This translates the given name to a class name by uppercasing the first letter.
     * Underscores translate to camelcase names. For actions with underscores, the different
     * parts are removed beginning from the end until a matching class is found. The instatiated
     * Action will always have the full original action set as Name
     *
     * Example: 'export_raw' -> ExportRaw then 'export' -> 'Export'
     *
     * @param $actionname
     * @return AbstractAction
     * @throws NoActionException
     */
    public function loadAction($actionname)
    {
        $actionname = strtolower($actionname); // FIXME is this needed here? should we run a cleanup somewhere else?
        $parts = explode('_', $actionname);
        while ($parts !== []) {
            $load = implode('_', $parts);
            $class = 'dokuwiki\\Action\\' . str_replace('_', '', ucwords($load, '_'));
            if (class_exists($class)) {
                return new $class($actionname);
            }
            array_pop($parts);
        }

        throw new NoActionException();
    }

    /**
     * Execute all the checks to see if this action can be executed
     *
     * @param AbstractAction $action
     * @throws ActionDisabledException
     * @throws ActionException
     */
    public function checkAction(AbstractAction $action)
    {
        global $INFO;
        global $ID;

        if (in_array($action->getActionName(), $this->disabled)) {
            throw new ActionDisabledException();
        }

        $action->checkPreconditions();

        if (isset($INFO)) {
            $perm = $INFO['perm'];
        } else {
            $perm = auth_quickaclcheck($ID);
        }

        if ($perm < $action->minimumPermission()) {
            throw new ActionException('denied');
        }
    }

    /**
     * Returns the action handling the current request
     *
     * @return AbstractAction
     */
    public function getAction()
    {
        return $this->action;
    }
}
