<?php
namespace Codeception\Lib;

use Codeception\Configuration;
use Codeception\Exception\ConfigurationException;
use Codeception\Exception\ModuleConflictException;
use Codeception\Exception\ModuleException;
use Codeception\Exception\ModuleRequireException;
use Codeception\Lib\Interfaces\ConflictsWithModule;
use Codeception\Lib\Interfaces\DependsOnModule;
use Codeception\Lib\Interfaces\PartedModule;
use Codeception\Util\Annotation;

/**
 * Class ModuleContainer
 * @package Codeception\Lib
 */
class ModuleContainer
{
    /**
     * @var string
     */
    const MODULE_NAMESPACE = '\\Codeception\\Module\\';

    /**
     * @var integer
     */
    const MAXIMUM_LEVENSHTEIN_DISTANCE = 5;

    public static $packages = [
        'AMQP' => 'codeception/module-amqp',
        'Apc' => 'codeception/module-apc',
        'Asserts' => 'codeception/module-asserts',
        'Cli' => 'codeception/module-cli',
        'DataFactory' => 'codeception/module-datafactory',
        'Db' => 'codeception/module-db',
        'Doctrine2' => "codeception/module-doctrine2",
        'Filesystem' => 'codeception/module-filesystem',
        'FTP' => 'codeception/module-ftp',
        'Laravel5' => 'codeception/module-laravel5',
        'Lumen' => 'codeception/module-lumen',
        'Memcache' => 'codeception/module-memcache',
        'MongoDb' => 'codeception/module-mongodb',
        'Phalcon' => 'codeception/module-phalcon',
        'PhpBrowser' => 'codeception/module-phpbrowser',
        'Queue' => 'codeception/module-queue',
        'Redis' => 'codeception/module-redis',
        'REST' => 'codeception/module-rest',
        'Sequence' => 'codeception/module-sequence',
        'SOAP' => 'codeception/module-soap',
        'Symfony' => 'codeception/module-symfony',
        'WebDriver' => "codeception/module-webdriver",
        'Yii2' => "codeception/module-yii2",
        'ZendExpressive' => 'codeception/module-zendexpressive',
        'ZF2' => 'codeception/module-zf2',
    ];

    /**
     * @var array
     */
    private $config;

    /**
     * @var Di
     */
    private $di;

    /**
     * @var array
     */
    private $modules = [];

    /**
     * @var array
     */
    private $active = [];

    /**
     * @var array
     */
    private $actions = [];

    /**
     * Constructor.
     *
     * @param Di $di
     * @param array $config
     */
    public function __construct(Di $di, $config)
    {
        $this->di = $di;
        $this->di->set($this);
        $this->config = $config;
    }

    /**
     * Create a module.
     *
     * @param string $moduleName
     * @param bool $active
     * @return \Codeception\Module
     * @throws \Codeception\Exception\ConfigurationException
     * @throws \Codeception\Exception\ModuleException
     * @throws \Codeception\Exception\ModuleRequireException
     * @throws \Codeception\Exception\InjectionException
     */
    public function create($moduleName, $active = true)
    {
        $this->active[$moduleName] = $active;

        $moduleClass = $this->getModuleClass($moduleName);
        if (!class_exists($moduleClass)) {
            if (isset(self::$packages[$moduleName])) {
                $package = self::$packages[$moduleName];
                throw new ConfigurationException("Module $moduleName is not installed.\nUse Composer to install corresponding package:\n\ncomposer require $package --dev");
            }
            throw new ConfigurationException("Module $moduleName could not be found and loaded");
        }

        $config = $this->getModuleConfig($moduleName);

        if (empty($config) && !$active) {
            // For modules that are a dependency of other modules we want to skip the validation of the config.
            // This config validation is performed in \Codeception\Module::__construct().
            // Explicitly setting $config to null skips this validation.
            $config = null;
        }

        $this->modules[$moduleName] = $module = $this->di->instantiate($moduleClass, [$this, $config], false);

        if ($this->moduleHasDependencies($module)) {
            $this->injectModuleDependencies($moduleName, $module);
        }

        // If module is not active its actions should not be included in the actor class
        $actions = $active ? $this->getActionsForModule($module, $config) : [];

        foreach ($actions as $action) {
            $this->actions[$action] = $moduleName;
        };

        return $module;
    }

    /**
     * Does a module have dependencies?
     *
     * @param \Codeception\Module $module
     * @return bool
     */
    private function moduleHasDependencies($module)
    {
        if (!$module instanceof DependsOnModule) {
            return false;
        }

        return (bool) $module->_depends();
    }

    /**
     * Get the actions of a module.
     *
     * @param \Codeception\Module $module
     * @param array $config
     * @return array
     */
    private function getActionsForModule($module, $config)
    {
        $reflectionClass = new \ReflectionClass($module);

        // Only public methods can be actions
        $methods = $reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC);

        // Should this module be loaded partially?
        $configuredParts = null;
        if ($module instanceof PartedModule && isset($config['part'])) {
            $configuredParts = is_array($config['part']) ? $config['part'] : [$config['part']];
        }

        $actions = [];
        foreach ($methods as $method) {
            if ($this->includeMethodAsAction($module, $method, $configuredParts)) {
                $actions[] = $method->name;
            }
        }

        return $actions;
    }

    /**
     * Should a method be included as an action?
     *
     * @param \Codeception\Module $module
     * @param \ReflectionMethod $method
     * @param array|null $configuredParts
     * @return bool
     */
    private function includeMethodAsAction($module, $method, $configuredParts = null)
    {
        // Filter out excluded actions
        if ($module::$excludeActions && in_array($method->name, $module::$excludeActions)) {
            return false;
        }

        // Keep only the $onlyActions if they are specified
        if ($module::$onlyActions && !in_array($method->name, $module::$onlyActions)) {
            return false;
        }

        // Do not include inherited actions if the static $includeInheritedActions property is set to false.
        // However, if an inherited action is also specified in the static $onlyActions property
        // it should be included as an action.
        if (!$module::$includeInheritedActions &&
            !in_array($method->name, $module::$onlyActions) &&
            $method->getDeclaringClass()->getName() != get_class($module)
        ) {
            return false;
        }

        // Do not include hidden methods, methods with a name starting with an underscore
        if (strpos($method->name, '_') === 0) {
            return false;
        };

        // If a part is configured for the module, only include actions from that part
        if ($configuredParts) {
            $moduleParts = Annotation::forMethod($module, $method->name)->fetchAll('part');
            if (!array_uintersect($moduleParts, $configuredParts, 'strcasecmp')) {
                return false;
            }
        }

        return true;
    }

    /**
     * Is the module a helper?
     *
     * @param string $moduleName
     * @return bool
     */
    private function isHelper($moduleName)
    {
        return strpos($moduleName, '\\') !== false;
    }

    /**
     * Get the fully qualified class name for a module.
     *
     * @param string $moduleName
     * @return string
     */
    private function getModuleClass($moduleName)
    {
        if ($this->isHelper($moduleName)) {
            return $moduleName;
        }

        return self::MODULE_NAMESPACE . $moduleName;
    }

    /**
     * Is a module instantiated in this ModuleContainer?
     *
     * @param string $moduleName
     * @return bool
     */
    public function hasModule($moduleName)
    {
        return isset($this->modules[$moduleName]);
    }

    /**
     * Get a module from this ModuleContainer.
     *
     * @param string $moduleName
     * @return \Codeception\Module
     * @throws \Codeception\Exception\ModuleException
     */
    public function getModule($moduleName)
    {
        if (!$this->hasModule($moduleName)) {
            $this->throwMissingModuleExceptionWithSuggestion(__CLASS__, $moduleName);
        }

        return $this->modules[$moduleName];
    }

    public function throwMissingModuleExceptionWithSuggestion($className, $moduleName)
    {
        $suggestedModuleNameInfo = $this->getModuleSuggestion($moduleName);
        throw new ModuleException($className, "Module $moduleName couldn't be connected" . $suggestedModuleNameInfo);
    }

    protected function getModuleSuggestion($missingModuleName)
    {
        $shortestLevenshteinDistance = null;
        $suggestedModuleName = null;
        foreach ($this->modules as $moduleName => $module) {
            $levenshteinDistance = levenshtein($missingModuleName, $moduleName);
            if ($shortestLevenshteinDistance === null || $levenshteinDistance <= $shortestLevenshteinDistance) {
                $shortestLevenshteinDistance = $levenshteinDistance;
                $suggestedModuleName = $moduleName;
            }
        }

        if ($suggestedModuleName !== null && $shortestLevenshteinDistance <= self::MAXIMUM_LEVENSHTEIN_DISTANCE) {
            return " (did you mean '$suggestedModuleName'?)";
        }

        return '';
    }

    /**
     * Get the module for an action.
     *
     * @param string $action
     * @return \Codeception\Module|null This method returns null if there is no module for $action
     */
    public function moduleForAction($action)
    {
        if (!isset($this->actions[$action])) {
            return null;
        }

        return $this->modules[$this->actions[$action]];
    }

    /**
     * Get all actions.
     *
     * @return array An array with actions as keys and module names as values.
     */
    public function getActions()
    {
        return $this->actions;
    }

    /**
     * Get all modules.
     *
     * @return array An array with module names as keys and modules as values.
     */
    public function all()
    {
        return $this->modules;
    }

    /**
     * Mock a module in this ModuleContainer.
     *
     * @param string $moduleName
     * @param object $mock
     */
    public function mock($moduleName, $mock)
    {
        $this->modules[$moduleName] = $mock;
    }

    /**
     * Inject the dependencies of a module.
     *
     * @param string $moduleName
     * @param \Codeception\Lib\Interfaces\DependsOnModule $module
     * @throws \Codeception\Exception\ModuleException
     * @throws \Codeception\Exception\ModuleRequireException
     */
    private function injectModuleDependencies($moduleName, DependsOnModule $module)
    {
        $this->checkForMissingDependencies($moduleName, $module);

        if (!method_exists($module, '_inject')) {
            throw new ModuleException($module, 'Module requires method _inject to be defined to accept dependencies');
        }

        $dependencies = array_map(function ($dependency) {
            return $this->create($dependency, false);
        }, $this->getConfiguredDependencies($moduleName));

        call_user_func_array([$module, '_inject'], $dependencies);
    }

    /**
     * Check for missing dependencies.
     *
     * @param string $moduleName
     * @param \Codeception\Lib\Interfaces\DependsOnModule $module
     * @throws \Codeception\Exception\ModuleException
     * @throws \Codeception\Exception\ModuleRequireException
     */
    private function checkForMissingDependencies($moduleName, DependsOnModule $module)
    {
        $dependencies = $this->getModuleDependencies($module);
        $configuredDependenciesCount = count($this->getConfiguredDependencies($moduleName));

        if ($configuredDependenciesCount < count($dependencies)) {
            $missingDependency = array_keys($dependencies)[$configuredDependenciesCount];

            $message = sprintf(
                "\nThis module depends on %s\n\n\n%s",
                $missingDependency,
                $this->getErrorMessageForDependency($module, $missingDependency)
            );

            throw new ModuleRequireException($moduleName, $message);
        }
    }

    /**
     * Get the dependencies of a module.
     *
     * @param \Codeception\Lib\Interfaces\DependsOnModule $module
     * @return array
     * @throws \Codeception\Exception\ModuleException
     */
    private function getModuleDependencies(DependsOnModule $module)
    {
        $depends = $module->_depends();

        if (!$depends) {
            return [];
        }

        if (!is_array($depends)) {
            $message = sprintf("Method _depends of module '%s' must return an array", get_class($module));
            throw new ModuleException($module, $message);
        }

        return $depends;
    }

    /**
     * Get the configured dependencies for a module.
     *
     * @param string $moduleName
     * @return array
     */
    private function getConfiguredDependencies($moduleName)
    {
        $config = $this->getModuleConfig($moduleName);

        if (!isset($config['depends'])) {
            return [];
        }

        return is_array($config['depends']) ? $config['depends'] : [$config['depends']];
    }

    /**
     * Get the error message for a module dependency that is missing.
     *
     * @param \Codeception\Module $module
     * @param string $missingDependency
     * @return string
     */
    private function getErrorMessageForDependency($module, $missingDependency)
    {
        $depends = $module->_depends();

        return $depends[$missingDependency];
    }

    /**
     * Get the configuration for a module.
     *
     * A module with name $moduleName can be configured at two paths in a configuration file:
     * - modules.config.$moduleName
     * - modules.enabled.$moduleName
     *
     * This method checks both locations for configuration. If there is configuration at both locations
     * this method merges them, where the configuration at modules.enabled.$moduleName takes precedence
     * over modules.config.$moduleName if the same parameters are configured at both locations.
     *
     * @param string $moduleName
     * @return array
     */
    private function getModuleConfig($moduleName)
    {
        $config = isset($this->config['modules']['config'][$moduleName])
            ? $this->config['modules']['config'][$moduleName]
            : [];

        if (!isset($this->config['modules']['enabled'])) {
            return $config;
        }

        if (!is_array($this->config['modules']['enabled'])) {
            return $config;
        }

        foreach ($this->config['modules']['enabled'] as $enabledModuleConfig) {
            if (!is_array($enabledModuleConfig)) {
                continue;
            }

            $enabledModuleName = key($enabledModuleConfig);
            if ($enabledModuleName === $moduleName) {
                return Configuration::mergeConfigs(reset($enabledModuleConfig), $config);
            }
        }

        return $config;
    }

    /**
     * Check if there are conflicting modules in this ModuleContainer.
     *
     * @throws \Codeception\Exception\ModuleConflictException
     */
    public function validateConflicts()
    {
        $canConflict = [];
        foreach ($this->modules as $moduleName => $module) {
            $parted = $module instanceof PartedModule && $module->_getConfig('part');

            if ($this->active[$moduleName] && !$parted) {
                $canConflict[] = $module;
            }
        }

        foreach ($canConflict as $module) {
            foreach ($canConflict as $otherModule) {
                $this->validateConflict($module, $otherModule);
            }
        }
    }

    /**
     * Check if the modules passed as arguments to this method conflict with each other.
     *
     * @param \Codeception\Module $module
     * @param \Codeception\Module $otherModule
     * @throws \Codeception\Exception\ModuleConflictException
     */
    private function validateConflict($module, $otherModule)
    {
        if ($module === $otherModule || !$module instanceof ConflictsWithModule) {
            return;
        }

        $conflicts = $this->normalizeConflictSpecification($module->_conflicts());
        if ($otherModule instanceof $conflicts) {
            throw new ModuleConflictException($module, $otherModule);
        }
    }

    /**
     * Normalize the return value of ConflictsWithModule::_conflicts() to a class name.
     * This is necessary because it can return a module name instead of the name of a class or interface.
     *
     * @param string $conflicts
     * @return string
     */
    private function normalizeConflictSpecification($conflicts)
    {
        if (interface_exists($conflicts) || class_exists($conflicts)) {
            return $conflicts;
        }

        if ($this->hasModule($conflicts)) {
            return $this->getModule($conflicts);
        }

        return $conflicts;
    }
}
