<?php
namespace Consolidation\AnnotatedCommand\Hooks;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleCommandEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventDispatcher;

use Consolidation\AnnotatedCommand\ExitCodeInterface;
use Consolidation\AnnotatedCommand\OutputDataInterface;
use Consolidation\AnnotatedCommand\AnnotationData;
use Consolidation\AnnotatedCommand\CommandData;
use Consolidation\AnnotatedCommand\CommandError;
use Consolidation\AnnotatedCommand\Hooks\Dispatchers\CommandEventHookDispatcher;

/**
 * Manage named callback hooks
 */
class HookManager implements EventSubscriberInterface
{
    protected $hooks = [];
    /** var CommandInfo[] */
    protected $hookOptions = [];

    const REPLACE_COMMAND_HOOK = 'replace-command';
    const PRE_COMMAND_EVENT = 'pre-command-event';
    const COMMAND_EVENT = 'command-event';
    const POST_COMMAND_EVENT = 'post-command-event';
    const PRE_OPTION_HOOK = 'pre-option';
    const OPTION_HOOK = 'option';
    const POST_OPTION_HOOK = 'post-option';
    const PRE_INITIALIZE = 'pre-init';
    const INITIALIZE = 'init';
    const POST_INITIALIZE = 'post-init';
    const PRE_INTERACT = 'pre-interact';
    const INTERACT = 'interact';
    const POST_INTERACT = 'post-interact';
    const PRE_ARGUMENT_VALIDATOR = 'pre-validate';
    const ARGUMENT_VALIDATOR = 'validate';
    const POST_ARGUMENT_VALIDATOR = 'post-validate';
    const PRE_COMMAND_HOOK = 'pre-command';
    const COMMAND_HOOK = 'command';
    const POST_COMMAND_HOOK = 'post-command';
    const PRE_PROCESS_RESULT = 'pre-process';
    const PROCESS_RESULT = 'process';
    const POST_PROCESS_RESULT = 'post-process';
    const PRE_ALTER_RESULT = 'pre-alter';
    const ALTER_RESULT = 'alter';
    const POST_ALTER_RESULT = 'post-alter';
    const STATUS_DETERMINER = 'status';
    const EXTRACT_OUTPUT = 'extract';
    const ON_EVENT = 'on-event';

    public function __construct()
    {
    }

    public function getAllHooks()
    {
        return $this->hooks;
    }

    /**
     * Add a hook
     *
     * @param mixed $callback The callback function to call
     * @param string   $hook     The name of the hook to add
     * @param string   $name     The name of the command to hook
     *   ('*' for all)
     */
    public function add(callable $callback, $hook, $name = '*')
    {
        if (empty($name)) {
            $name = static::getClassNameFromCallback($callback);
        }
        $this->hooks[$name][$hook][] = $callback;
        return $this;
    }

    public function recordHookOptions($commandInfo, $name)
    {
        $this->hookOptions[$name][] = $commandInfo;
        return $this;
    }

    public static function getNames($command, $callback)
    {
        return array_filter(
            array_merge(
                static::getNamesUsingCommands($command),
                [static::getClassNameFromCallback($callback)]
            )
        );
    }

    protected static function getNamesUsingCommands($command)
    {
        return array_merge(
            [$command->getName()],
            $command->getAliases()
        );
    }

    /**
     * If a command hook does not specify any particular command
     * name that it should be attached to, then it will be applied
     * to every command that is defined in the same class as the hook.
     * This is controlled by using the namespace + class name of
     * the implementing class of the callback hook.
     */
    protected static function getClassNameFromCallback($callback)
    {
        if (!is_array($callback)) {
            return '';
        }
        $reflectionClass = new \ReflectionClass($callback[0]);
        return $reflectionClass->getName();
    }

    /**
     * Add a replace command hook
     *
     * @param type ReplaceCommandHookInterface $provider
     * @param type string $command_name The name of the command to replace
     */
    public function addReplaceCommandHook(ReplaceCommandHookInterface $replaceCommandHook, $name)
    {
        $this->hooks[$name][self::REPLACE_COMMAND_HOOK][] = $replaceCommandHook;
        return $this;
    }

    public function addPreCommandEventDispatcher(EventDispatcherInterface $eventDispatcher, $name = '*')
    {
        $this->hooks[$name][self::PRE_COMMAND_EVENT][] = $eventDispatcher;
        return $this;
    }

    public function addCommandEventDispatcher(EventDispatcherInterface $eventDispatcher, $name = '*')
    {
        $this->hooks[$name][self::COMMAND_EVENT][] = $eventDispatcher;
        return $this;
    }

    public function addPostCommandEventDispatcher(EventDispatcherInterface $eventDispatcher, $name = '*')
    {
        $this->hooks[$name][self::POST_COMMAND_EVENT][] = $eventDispatcher;
        return $this;
    }

    public function addCommandEvent(EventSubscriberInterface $eventSubscriber)
    {
        // Wrap the event subscriber in a dispatcher and add it
        $dispatcher = new EventDispatcher();
        $dispatcher->addSubscriber($eventSubscriber);
        return $this->addCommandEventDispatcher($dispatcher);
    }

    /**
     * Add an configuration provider hook
     *
     * @param type InitializeHookInterface $provider
     * @param type $name The name of the command to hook
     *   ('*' for all)
     */
    public function addInitializeHook(InitializeHookInterface $initializeHook, $name = '*')
    {
        $this->hooks[$name][self::INITIALIZE][] = $initializeHook;
        return $this;
    }

    /**
     * Add an option hook
     *
     * @param type ValidatorInterface $validator
     * @param type $name The name of the command to hook
     *   ('*' for all)
     */
    public function addOptionHook(OptionHookInterface $interactor, $name = '*')
    {
        $this->hooks[$name][self::INTERACT][] = $interactor;
        return $this;
    }

    /**
     * Add an interact hook
     *
     * @param type ValidatorInterface $validator
     * @param type $name The name of the command to hook
     *   ('*' for all)
     */
    public function addInteractor(InteractorInterface $interactor, $name = '*')
    {
        $this->hooks[$name][self::INTERACT][] = $interactor;
        return $this;
    }

    /**
     * Add a pre-validator hook
     *
     * @param type ValidatorInterface $validator
     * @param type $name The name of the command to hook
     *   ('*' for all)
     */
    public function addPreValidator(ValidatorInterface $validator, $name = '*')
    {
        $this->hooks[$name][self::PRE_ARGUMENT_VALIDATOR][] = $validator;
        return $this;
    }

    /**
     * Add a validator hook
     *
     * @param type ValidatorInterface $validator
     * @param type $name The name of the command to hook
     *   ('*' for all)
     */
    public function addValidator(ValidatorInterface $validator, $name = '*')
    {
        $this->hooks[$name][self::ARGUMENT_VALIDATOR][] = $validator;
        return $this;
    }

    /**
     * Add a pre-command hook.  This is the same as a validator hook, except
     * that it will run after all of the post-validator hooks.
     *
     * @param type ValidatorInterface $preCommand
     * @param type $name The name of the command to hook
     *   ('*' for all)
     */
    public function addPreCommandHook(ValidatorInterface $preCommand, $name = '*')
    {
        $this->hooks[$name][self::PRE_COMMAND_HOOK][] = $preCommand;
        return $this;
    }

    /**
     * Add a post-command hook.  This is the same as a pre-process hook,
     * except that it will run before the first pre-process hook.
     *
     * @param type ProcessResultInterface $postCommand
     * @param type $name The name of the command to hook
     *   ('*' for all)
     */
    public function addPostCommandHook(ProcessResultInterface $postCommand, $name = '*')
    {
        $this->hooks[$name][self::POST_COMMAND_HOOK][] = $postCommand;
        return $this;
    }

    /**
     * Add a result processor.
     *
     * @param type ProcessResultInterface $resultProcessor
     * @param type $name The name of the command to hook
     *   ('*' for all)
     */
    public function addResultProcessor(ProcessResultInterface $resultProcessor, $name = '*')
    {
        $this->hooks[$name][self::PROCESS_RESULT][] = $resultProcessor;
        return $this;
    }

    /**
     * Add a result alterer. After a result is processed
     * by a result processor, an alter hook may be used
     * to convert the result from one form to another.
     *
     * @param type AlterResultInterface $resultAlterer
     * @param type $name The name of the command to hook
     *   ('*' for all)
     */
    public function addAlterResult(AlterResultInterface $resultAlterer, $name = '*')
    {
        $this->hooks[$name][self::ALTER_RESULT][] = $resultAlterer;
        return $this;
    }

    /**
     * Add a status determiner. Usually, a command should return
     * an integer on error, or a result object on success (which
     * implies a status code of zero). If a result contains the
     * status code in some other field, then a status determiner
     * can be used to call the appropriate accessor method to
     * determine the status code.  This is usually not necessary,
     * though; a command that fails may return a CommandError
     * object, which contains a status code and a result message
     * to display.
     * @see CommandError::getExitCode()
     *
     * @param type StatusDeterminerInterface $statusDeterminer
     * @param type $name The name of the command to hook
     *   ('*' for all)
     */
    public function addStatusDeterminer(StatusDeterminerInterface $statusDeterminer, $name = '*')
    {
        $this->hooks[$name][self::STATUS_DETERMINER][] = $statusDeterminer;
        return $this;
    }

    /**
     * Add an output extractor. If a command returns an object
     * object, by default it is passed directly to the output
     * formatter (if in use) for rendering. If the result object
     * contains more information than just the data to render, though,
     * then an output extractor can be used to call the appopriate
     * accessor method of the result object to get the data to
     * rendered.  This is usually not necessary, though; it is preferable
     * to have complex result objects implement the OutputDataInterface.
     * @see OutputDataInterface::getOutputData()
     *
     * @param type ExtractOutputInterface $outputExtractor
     * @param type $name The name of the command to hook
     *   ('*' for all)
     */
    public function addOutputExtractor(ExtractOutputInterface $outputExtractor, $name = '*')
    {
        $this->hooks[$name][self::EXTRACT_OUTPUT][] = $outputExtractor;
        return $this;
    }

    public function getHookOptionsForCommand($command)
    {
        $names = $this->addWildcardHooksToNames($command->getNames(), $command->getAnnotationData());
        return $this->getHookOptions($names);
    }

    /**
     * @return CommandInfo[]
     */
    public function getHookOptions($names)
    {
        $result = [];
        foreach ($names as $name) {
            if (isset($this->hookOptions[$name])) {
                $result = array_merge($result, $this->hookOptions[$name]);
            }
        }
        return $result;
    }

    /**
     * Get a set of hooks with the provided name(s). Include the
     * pre- and post- hooks, and also include the global hooks ('*')
     * in addition to the named hooks provided.
     *
     * @param string|array $names The name of the function being hooked.
     * @param string[] $hooks A list of hooks (e.g. [HookManager::ALTER_RESULT])
     *
     * @return callable[]
     */
    public function getHooks($names, $hooks, $annotationData = null)
    {
        return $this->get($this->addWildcardHooksToNames($names, $annotationData), $hooks);
    }

    protected function addWildcardHooksToNames($names, $annotationData = null)
    {
        $names = array_merge(
            (array)$names,
            ($annotationData == null) ? [] : array_map(function ($item) {
                return "@$item";
            }, $annotationData->keys())
        );
        $names[] = '*';
        return array_unique($names);
    }

    /**
     * Get a set of hooks with the provided name(s).
     *
     * @param string|array $names The name of the function being hooked.
     * @param string[] $hooks The list of hook names (e.g. [HookManager::ALTER_RESULT])
     *
     * @return callable[]
     */
    public function get($names, $hooks)
    {
        $result = [];
        foreach ((array)$hooks as $hook) {
            foreach ((array)$names as $name) {
                $result = array_merge($result, $this->getHook($name, $hook));
            }
        }
        return $result;
    }

    /**
     * Get a single named hook.
     *
     * @param string $name The name of the hooked method
     * @param string $hook The specific hook name (e.g. alter)
     *
     * @return callable[]
     */
    public function getHook($name, $hook)
    {
        if (isset($this->hooks[$name][$hook])) {
            return $this->hooks[$name][$hook];
        }
        return [];
    }

    /**
     * Call the command event hooks.
     *
     * TODO: This should be moved to CommandEventHookDispatcher, which
     * should become the class that implements EventSubscriberInterface.
     * This change would break all clients, though, so postpone until next
     * major release.
     *
     * @param ConsoleCommandEvent $event
     */
    public function callCommandEventHooks(ConsoleCommandEvent $event)
    {
        /* @var Command $command */
        $command = $event->getCommand();
        $dispatcher = new CommandEventHookDispatcher($this, [$command->getName()]);
        $dispatcher->callCommandEventHooks($event);
    }

    /**
     * @{@inheritdoc}
     */
    public static function getSubscribedEvents(): array
    {
        return [ConsoleEvents::COMMAND => 'callCommandEventHooks'];
    }
}
