<?php

/**
 * @see       https://github.com/laminas/laminas-mvc for the canonical source repository
 * @copyright https://github.com/laminas/laminas-mvc/blob/master/COPYRIGHT.md
 * @license   https://github.com/laminas/laminas-mvc/blob/master/LICENSE.md New BSD License
 */

namespace Laminas\Mvc\Controller\Plugin;

use Laminas\EventManager\SharedEventManagerInterface as SharedEvents;
use Laminas\Mvc\Controller\ControllerManager;
use Laminas\Mvc\Exception;
use Laminas\Mvc\InjectApplicationEventInterface;
use Laminas\Mvc\MvcEvent;
use Laminas\Router\RouteMatch;
use Laminas\Stdlib\CallbackHandler;

class Forward extends AbstractPlugin
{
    /**
     * @var ControllerManager
     */
    protected $controllers;

    /**
     * @var MvcEvent
     */
    protected $event;

    /**
     * @var int
     */
    protected $maxNestedForwards = 10;

    /**
     * @var int
     */
    protected $numNestedForwards = 0;

    /**
     * @var array[]|null
     */
    protected $listenersToDetach = null;

    /**
     * @param ControllerManager $controllers
     */
    public function __construct(ControllerManager $controllers)
    {
        $this->controllers = $controllers;
    }

    /**
     * Set maximum number of nested forwards allowed
     *
     * @param  int $maxNestedForwards
     * @return self
     */
    public function setMaxNestedForwards($maxNestedForwards)
    {
        $this->maxNestedForwards = (int) $maxNestedForwards;

        return $this;
    }

    /**
     * Get information on listeners that need to be detached before dispatching.
     *
     * Each entry in the array contains three keys:
     *
     * id (identifier for event-emitting component),
     * event (the hooked event)
     * and class (the class of listener that should be detached).
     *
     * @return array
     */
    public function getListenersToDetach()
    {
        // If a blacklist has not been explicitly set, return the default:
        if (null === $this->listenersToDetach) {
            // We need to detach the InjectViewModelListener to prevent templates
            // from getting attached to the ViewModel twice when a calling action
            // returns the output generated by a forwarded action.
            $this->listenersToDetach = [[
                'id'    => 'Laminas\Stdlib\DispatchableInterface',
                'event' => MvcEvent::EVENT_DISPATCH,
                'class' => 'Laminas\Mvc\View\Http\InjectViewModelListener',
            ]];
        }
        return $this->listenersToDetach;
    }

    /**
     * Set information on listeners that need to be detached before dispatching.
     *
     * @param  array $listeners Listener information; see getListenersToDetach() for details on format.
     *
     * @return self
     */
    public function setListenersToDetach($listeners)
    {
        $this->listenersToDetach = $listeners;

        return $this;
    }

    /**
     * Dispatch another controller
     *
     * @param  string $name Controller name; either a class name or an alias used in the controller manager
     * @param  null|array $params Parameters with which to seed a custom RouteMatch object for the new controller
     * @return mixed
     * @throws Exception\DomainException if composed controller does not define InjectApplicationEventInterface
     *         or Locator aware; or if the discovered controller is not dispatchable
     */
    public function dispatch($name, array $params = null)
    {
        $event   = clone($this->getEvent());

        $controller = $this->controllers->get($name);
        if ($controller instanceof InjectApplicationEventInterface) {
            $controller->setEvent($event);
        }

        // Allow passing parameters to seed the RouteMatch with & copy matched route name
        if ($params !== null) {
            $routeMatch = new RouteMatch($params);
            $routeMatch->setMatchedRouteName($event->getRouteMatch()->getMatchedRouteName());
            $event->setRouteMatch($routeMatch);
        }

        if ($this->numNestedForwards > $this->maxNestedForwards) {
            throw new Exception\DomainException(
                "Circular forwarding detected: greater than $this->maxNestedForwards nested forwards"
            );
        }
        $this->numNestedForwards++;

        // Detach listeners that may cause problems during dispatch:
        $sharedEvents = $event->getApplication()->getEventManager()->getSharedManager();
        $listeners = $this->detachProblemListeners($sharedEvents);

        $return = $controller->dispatch($event->getRequest(), $event->getResponse());

        // If we detached any listeners, reattach them now:
        $this->reattachProblemListeners($sharedEvents, $listeners);

        $this->numNestedForwards--;

        return $return;
    }

    /**
     * Detach problem listeners specified by getListenersToDetach() and return an array of information that will
     * allow them to be reattached.
     *
     * @param  SharedEvents $sharedEvents Shared event manager
     * @return array
     */
    protected function detachProblemListeners(SharedEvents $sharedEvents)
    {
        // Convert the problem list from two-dimensional array to more convenient id => event => class format:
        $formattedProblems = [];
        foreach ($this->getListenersToDetach() as $current) {
            if (! isset($formattedProblems[$current['id']])) {
                $formattedProblems[$current['id']] = [];
            }
            if (! isset($formattedProblems[$current['id']][$current['event']])) {
                $formattedProblems[$current['id']][$current['event']] = [];
            }
            $formattedProblems[$current['id']][$current['event']][] = $current['class'];
        }

        // Loop through the class blacklist, detaching problem events and remembering their CallbackHandlers
        // for future reference:
        $results = [];
        foreach ($formattedProblems as $id => $eventArray) {
            $results[$id] = [];
            foreach ($eventArray as $eventName => $classArray) {
                $results[$id][$eventName] = [];
                $events = $this->getSharedListenersById($id, $eventName, $sharedEvents);
                foreach ($events as $priority => $currentPriorityEvents) {
                    foreach ($currentPriorityEvents as $currentEvent) {
                        $currentCallback = $currentEvent;

                        // If we have an array, grab the object
                        if (is_array($currentCallback)) {
                            $currentCallback = array_shift($currentCallback);
                        }

                        // This routine is only valid for object callbacks
                        if (! is_object($currentCallback)) {
                            continue;
                        }

                        foreach ($classArray as $class) {
                            if ($currentCallback instanceof $class) {
                                $this->detachSharedListener($id, $currentEvent, $sharedEvents);
                                $results[$id][$eventName][$priority] = $currentEvent;
                            }
                        }
                    }
                }
            }
        }

        return $results;
    }

    /**
     * Reattach all problem listeners detached by detachProblemListeners(), if any.
     *
     * @param  SharedEvents $sharedEvents Shared event manager
     * @param  array        $listeners    Output of detachProblemListeners()
     * @return void
     */
    protected function reattachProblemListeners(SharedEvents $sharedEvents, array $listeners)
    {
        foreach ($listeners as $id => $eventArray) {
            foreach ($eventArray as $eventName => $callbacks) {
                foreach ($callbacks as $priority => $current) {
                    $callback = $current;

                    $sharedEvents->attach($id, $eventName, $callback, $priority);
                }
            }
        }
    }

    /**
     * Get the event
     *
     * @return MvcEvent
     * @throws Exception\DomainException if unable to find event
     */
    protected function getEvent()
    {
        if ($this->event) {
            return $this->event;
        }

        $controller = $this->getController();
        if (! $controller instanceof InjectApplicationEventInterface) {
            throw new Exception\DomainException(sprintf(
                'Forward plugin requires a controller that implements InjectApplicationEventInterface; received %s',
                (is_object($controller) ? get_class($controller) : var_export($controller, 1))
            ));
        }

        $event = $controller->getEvent();
        if (! $event instanceof MvcEvent) {
            $params = [];
            if ($event) {
                $params = $event->getParams();
            }
            $event  = new MvcEvent();
            $event->setParams($params);
        }
        $this->event = $event;

        return $this->event;
    }

    /**
     * Retrieve shared listeners for an event by identifier.
     *
     * Varies retrieval based on laminas-eventmanager version.
     *
     * @param string|int $id
     * @param string $event
     * @param SharedEvents $sharedEvents
     * @return array|\Traversable
     */
    private function getSharedListenersById($id, $event, SharedEvents $sharedEvents)
    {
        return $sharedEvents->getListeners([$id], $event);
    }

    /**
     * Detach a shared listener by identifier.
     *
     * Varies detachment based on laminas-eventmanager version.
     *
     * @param string|int $id
     * @param callable|CallbackHandler $listener
     * @param SharedEvents $sharedEvents
     * @return void
     */
    private function detachSharedListener($id, $listener, SharedEvents $sharedEvents)
    {
        $sharedEvents->detach($listener, $id);
    }
}
