<?php
/*
 *  $Id: Cli.php 2761 2007-10-07 23:42:29Z zYne $
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 * This software consists of voluntary contributions made by many individuals
 * and is licensed under the LGPL. For more information, see
 * <http://www.phpdoctrine.org>.
 */

/**
 * Command line interface class
 * 
 * Interface for easily executing Doctrine_Task classes from a command line interface
 *
 * @package     Doctrine
 * @subpackage  Cli
 * @license     http://www.opensource.org/licenses/lgpl-license.php LGPL
 * @link        www.phpdoctrine.org
 * @since       1.0
 * @version     $Revision: 2761 $
 * @author      Jonathan H. Wage <jwage@mac.com>
 */
class Doctrine_Cli
{
    /**
     * The name of the Doctrine Task base class
     * 
     * @var string
     */
    const TASK_BASE_CLASS = 'Doctrine_Task';

    /**
     * @var string
     */
    protected $_scriptName   = null;

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

    /**
     * @var object Doctrine_Cli_Formatter
     */
    private $_formatter;

    /**
     * An array, keyed on class name, containing task instances
     * 
     * @var array
     */
    private $_registeredTask = array();

    /**
     * @var object Doctrine_Task
     */
    private $_taskInstance;

    /**
     * __construct
     *
     * @param array [$config=array()]
     * @param object|null [$formatter=null] Doctrine_Cli_Formatter
     */
    public function __construct(array $config = array(), Doctrine_Cli_Formatter $formatter = null)
    {
        $this->setConfig($config);
        $this->setFormatter($formatter ? $formatter : new Doctrine_Cli_AnsiColorFormatter());
        $this->includeAndRegisterTaskClasses();
    }

    /**
     * @param array $config
     */
    public function setConfig(array $config)
    {
        $this->_config = $config;
    }

    /**
     * @return array
     */
    public function getConfig()
    {
        return $this->_config;
    }

    /**
     * @param object $formatter Doctrine_Cli_Formatter
     */
    public function setFormatter(Doctrine_Cli_Formatter $formatter)
    {
        $this->_formatter = $formatter;
    }

    /**
     * @return object Doctrine_Cli_Formatter
     */
    public function getFormatter()
    {
        return $this->_formatter;
    }

    /**
     * Returns the specified value from the config, or the default value, if specified
     * 
     * @param string $name
     * @return mixed
     * @throws OutOfBoundsException If the element does not exist in the config
     */
    public function getConfigValue($name/*, $defaultValue*/)
    {
        if (! isset($this->_config[$name])) {
            if (func_num_args() > 1) {
                return func_get_arg(1);
            }

            throw new OutOfBoundsException("The element \"{$name}\" does not exist in the config");
        }

        return $this->_config[$name];
    }

    /**
     * Returns TRUE if the element in the config has the specified value, or FALSE otherwise
     * 
     * If $value is not passed, this method will return TRUE if the specified element has _any_ value, or FALSE if the
     * element is not set
     * 
     * For strict checking, set $strict to TRUE - the default is FALSE
     * 
     * @param string $name
     * @param mixed [$value=null]
     * @param bool [$strict=false]
     * @return bool
     */
    public function hasConfigValue($name, $value = null, $strict = false)
    {
        if (isset($this->_config[$name])) {
            if (func_num_args() < 2) {
                return true;
            }

            if ($strict) {
                return $this->_config[$name] === $value;
            }

            return $this->_config[$name] == $value;
        }

        return false;
    }

    /**
     * Sets the array of registered tasks
     * 
     * @param array $registeredTask
     */
    public function setRegisteredTasks(array $registeredTask)
    {
        $this->_registeredTask = $registeredTask;
    }

    /**
     * Returns an array containing the registered tasks
     * 
     * @return array
     */
    public function getRegisteredTasks()
    {
        return $this->_registeredTask;
    }

    /**
     * Returns TRUE if the specified Task-class is registered, or FALSE otherwise
     * 
     * @param string $className
     * @return bool
     */
    public function taskClassIsRegistered($className)
    {
        return isset($this->_registeredTask[$className]);
    }

    /**
     * Returns TRUE if a task with the specified name is registered, or FALSE otherwise
     * 
     * If a matching task is found, $className is set with the name of the implementing class
     * 
     * @param string $taskName
     * @param string|null [&$className=null]
     * @return bool
     */
    public function taskNameIsRegistered($taskName, &$className = null)
    {
        foreach ($this->getRegisteredTasks() as $currClassName => $task) {
            if ($task->getTaskName() == $taskName) {
                $className = $currClassName;
                return true;
            }
        }

        return false;
    }

    /**
     * @param object $task Doctrine_Task
     */
    public function setTaskInstance(Doctrine_Task $task)
    {
        $this->_taskInstance = $task;
    }

    /**
     * @return object Doctrine_Task
     */
    public function getTaskInstance()
    {
        return $this->_taskInstance;
    }

    /**
     * Called by the constructor, this method includes and registers Doctrine core Tasks and then registers all other
     * loaded Task classes
     * 
     * The second round of registering will pick-up loaded custom Tasks.  Methods are provided that will allow users to
     * register Tasks loaded after creating an instance of Doctrine_Cli.
     */
    protected function includeAndRegisterTaskClasses()
    {
        $this->includeAndRegisterDoctrineTaskClasses();

        //Always autoregister custom tasks _unless_ we've been explicitly asked not to
        if ($this->getConfigValue('autoregister_custom_tasks', true)) {
            $this->registerIncludedTaskClasses();
        }
    }

    /**
     * Includes and registers Doctrine-style tasks from the specified directory / directories
     * 
     * If no directory is given it looks in the default Doctrine/Task folder for the core tasks
     * 
     * @param mixed [$directories=null] Can be a string path or array of paths
     */
    protected function includeAndRegisterDoctrineTaskClasses($directories = null)
    {
        if (is_null($directories)) {
            $directories = Doctrine_Core::getPath() . DIRECTORY_SEPARATOR . 'Doctrine' . DIRECTORY_SEPARATOR . 'Task';
        }

        foreach ((array) $directories as $directory) {
            foreach ($this->includeDoctrineTaskClasses($directory) as $className) {
                $this->registerTaskClass($className);
            }
        }
    }

    /**
     * Attempts to include Doctrine-style Task-classes from the specified directory - and nothing more besides
     * 
     * Returns an array containing the names of Task classes included
     * 
     * This method effectively makes two assumptions:
     * - The directory contains only _Task_ class-files
     * - The class files, and the class in each, follow the Doctrine naming conventions
     * 
     * This means that a file called "Foo.php", say, will be expected to contain a Task class called
     * "Doctrine_Task_Foo".  Hence the method's name, "include*Doctrine*TaskClasses".
     * 
     * @param string $directory
     * @return array $taskClassesIncluded
     * @throws InvalidArgumentException If the directory does not exist
     */
    protected function includeDoctrineTaskClasses($directory)
    {
        if (! is_dir($directory)) {
            throw new InvalidArgumentException("The directory \"{$directory}\" does not exist");
        }

        $taskClassesIncluded = array();

        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($directory),
            RecursiveIteratorIterator::LEAVES_ONLY
        );

        foreach ($iterator as $file) {
            $baseName = $file->getFileName();

            /*
             * Class-files must start with an uppercase letter.  This additional check will help prevent us
             * accidentally running 'executable' scripts that may be mixed-in with the class files.
             */
            $matched = (bool) preg_match('/^([A-Z].*?)\.php$/', $baseName, $matches);

            if ( ! ($matched && (strpos($baseName, '.inc') === false))) {
                continue;
            }

            $expectedClassName = self::TASK_BASE_CLASS . '_' . $matches[1];

            if ( ! class_exists($expectedClassName)) {
                require_once($file->getPathName());
            }

            //So was the expected class included, and is it a task?  If so, we'll let the calling function know.
            if (class_exists($expectedClassName, false) && $this->classIsTask($expectedClassName)) {
                $taskClassesIncluded[] = $expectedClassName;
            }
        }

        return $taskClassesIncluded;
    }

    /**
     * Registers the specified _included_ task-class
     * 
     * @param string $className
     * @throws InvalidArgumentException If the class does not exist or the task-name is blank
     * @throws DomainException If the class is not a Doctrine Task
     */
    public function registerTaskClass($className)
    {
        //Simply ignore registered classes
        if ($this->taskClassIsRegistered($className)) {
            return;
        }

        if ( ! class_exists($className/*, false*/)) {
            throw new InvalidArgumentException("The task class \"{$className}\" does not exist");
        }

        if ( ! $this->classIsTask($className)) {
            throw new DomainException("The class \"{$className}\" is not a Doctrine Task");
        }

        $this->_registeredTask[$className] = $this->createTaskInstance($className, $this);
    }

    /**
     * Returns TRUE if the specified class is a Task, or FALSE otherwise
     * 
     * @param string $className
     * @return bool
     */
    protected function classIsTask($className)
    {
        $reflectionClass = new ReflectionClass($className);
        return (bool) $reflectionClass->isSubClassOf(self::TASK_BASE_CLASS);
    }

    /**
     * Creates, and returns, a new instance of the specified Task class
     * 
     * Displays a message, and returns FALSE, if there were problems instantiating the class
     * 
     * @param string $className
     * @param object $cli Doctrine_Cli
     * @return object Doctrine_Task
     */
    protected function createTaskInstance($className, Doctrine_Cli $cli)
    {
        return new $className($cli);
    }

    /**
     * Registers all loaded classes - by default - or the specified loaded Task classes
     * 
     * This method will skip registered task classes, so it can be safely called many times over
     */
    public function registerIncludedTaskClasses()
    {
        foreach (get_declared_classes() as $className) {
            if ($this->classIsTask($className)) {
                $this->registerTaskClass($className);
            }
        }
    }

    /**
     * Notify the formatter of a message
     *
     * @param string $notification  The notification message
     * @param string $style         Style to format the notification with(INFO, ERROR)
     * @return void
     */
    public function notify($notification = null, $style = 'HEADER')
    {
        $formatter = $this->getFormatter();

        echo(
            $formatter->format($this->getTaskInstance()->getTaskName(), 'INFO') . ' - ' .
            $formatter->format($notification, $style) . "\n"
        );
    }

    /**
     * Formats, and then returns, the message in the specified exception
     *
     * @param  Exception $exception
     * @return string
     */
    protected function formatExceptionMessage(Exception $exception)
    {
        $message = $exception->getMessage();

        if (Doctrine_Core::debug()) {
            $message .= "\n" . $exception->getTraceAsString();
        }

        return $this->getFormatter()->format($message, 'ERROR') . "\n";
    }

    /**
     * Notify the formatter of an exception
     * 
     * N.B. This should really only be called by Doctrine_Cli::run().  Exceptions should be thrown when errors occur:
     * it's up to Doctrine_Cli::run() to determine how those exceptions are reported.
     *
     * @param  Exception $exception
     * @return void
     */
    protected function notifyException(Exception $exception)
    {
        echo $this->formatExceptionMessage($exception);
    }

    /**
     * Public function to run the loaded task with the passed arguments
     *
     * @param  array $args
     * @return void
     * @throws Doctrine_Cli_Exception
     * @todo Should know more about what we're attempting to run so feedback can be improved. Continue refactoring.
     */
    public function run(array $args)
    {
        try {
            $this->_run($args);
        } catch (Exception $exception) {
            //Do not rethrow exceptions by default
            if ($this->getConfigValue('rethrow_exceptions', false)) {
                throw new $exception($this->formatExceptionMessage($exception));
            }

            $this->notifyException($exception);

            //User error
            if ($exception instanceof Doctrine_Cli_Exception) {
                $this->printTasks();
            }
        }
    }

    /**
     * Run the actual task execution with the passed arguments
     *
     * @param  array $args Array of arguments for this task being executed
     * @return void
     * @throws Doctrine_Cli_Exception If the requested task has not been registered or if required arguments are missing
     * @todo Continue refactoring for testing
     */
    protected function _run(array $args)
    {        
        $this->_scriptName = $args[0];
        
        $requestedTaskName = isset($args[1]) ? $args[1] : null;
        
        if ( ! $requestedTaskName || $requestedTaskName == 'help') {
            $this->printTasks(null, $requestedTaskName == 'help' ? true : false);
            return;
        }
        
        if ($requestedTaskName && isset($args[2]) && $args[2] === 'help') {
            $this->printTasks($requestedTaskName, true);
            return;
        }

        if (! $this->taskNameIsRegistered($requestedTaskName, $taskClassName)) {
            throw new Doctrine_Cli_Exception("The task \"{$requestedTaskName}\" has not been registered");
        }

        $taskInstance = $this->createTaskInstance($taskClassName, $this);
        $this->setTaskInstance($taskInstance);
        $this->executeTask($taskInstance, $this->prepareArgs(array_slice($args, 2)));
    }

    /**
     * Executes the task with the specified _prepared_ arguments
     * 
     * @param object $task Doctrine_Task
     * @param array $preparedArguments
     * @throws Doctrine_Cli_Exception If required arguments are missing
     */
    protected function executeTask(Doctrine_Task $task, array $preparedArguments)
    {
        $task->setArguments($preparedArguments);

        if (! $task->validate()) {
            throw new Doctrine_Cli_Exception('Required arguments missing');
        }

        $task->execute();
    }

    /**
     * Prepare the raw arguments for execution. Combines with the required and optional argument
     * list in order to determine a complete array of arguments for the task
     *
     * @param  array $args      Array of raw arguments
     * @return array $prepared  Array of prepared arguments
     * @todo Continue refactoring for testing
     */
    protected function prepareArgs(array $args)
    {
        $taskInstance = $this->getTaskInstance();
        
        $args = array_values($args);
        
        // First lets load populate an array with all the possible arguments. required and optional
        $prepared = array();
        
        $requiredArguments = $taskInstance->getRequiredArguments();
        foreach ($requiredArguments as $key => $arg) {
            $prepared[$arg] = null;
        }
        
        $optionalArguments = $taskInstance->getOptionalArguments();
        foreach ($optionalArguments as $key => $arg) {
            $prepared[$arg] = null;
        }
        
        // If we have a config array then lets try and fill some of the arguments with the config values
        foreach ($this->getConfig() as $key => $value) {
            if (array_key_exists($key, $prepared)) {
                $prepared[$key] = $value;
            }
        }
        
        // Now lets fill in the entered arguments to the prepared array
        $copy = $args;
        foreach ($prepared as $key => $value) {
            if ( ! $value && !empty($copy)) {
                $prepared[$key] = $copy[0];
                unset($copy[0]);
                $copy = array_values($copy);
            }
        }
        
        return $prepared;
    }

    /**
     * Prints an index of all the available tasks in the CLI instance
     * 
     * @param string|null [$taskName=null]
     * @param bool [$full=false]
     * @todo Continue refactoring for testing
     */
    public function printTasks($taskName = null, $full = false)
    {
        $formatter = $this->getFormatter();
        $config = $this->getConfig();

        $taskIndex = $formatter->format('Doctrine Command Line Interface', 'HEADER') . "\n\n";

        foreach ($this->getRegisteredTasks() as $task) {
            if ($taskName && (strtolower($taskName) != strtolower($task->getTaskName()))) {
                continue;
            }

            $taskIndex .= $formatter->format($this->_scriptName . ' ' . $task->getTaskName(), 'INFO');

            if ($full) {
                $taskIndex .= ' - ' . $task->getDescription() . "\n";

                $args = '';
                $args .= $this->assembleArgumentList($task->getRequiredArgumentsDescriptions(), $config, $formatter);
                $args .= $this->assembleArgumentList($task->getOptionalArgumentsDescriptions(), $config, $formatter);

                if ($args) {
                    $taskIndex .= "\n" . $formatter->format('Arguments:', 'HEADER') . "\n" . $args;
                }
            }
            
            $taskIndex .= "\n";
        }

        echo $taskIndex;
    }

    /**
     * @param array $argumentsDescriptions
     * @param array $config
     * @param object $formatter Doctrine_Cli_Formatter
     * @return string
     */
    protected function assembleArgumentList(array $argumentsDescriptions, array $config, Doctrine_Cli_Formatter $formatter)
    {
        $argumentList = '';

        foreach ($argumentsDescriptions as $name => $description) {
            $argumentList .= $formatter->format($name, 'ERROR') . ' - ';
            
            if (isset($config[$name])) {
                $argumentList .= $formatter->format($config[$name], 'COMMENT');
            } else {
                $argumentList .= $description;
            }

            $argumentList .= "\n";
        }

        return $argumentList;
    }

    /**
     * Used by Doctrine_Cli::loadTasks() and Doctrine_Cli::getLoadedTasks() to re-create their pre-refactoring behaviour
     * 
     * @ignore
     * @param array $registeredTask
     * @return array
     */
    private function createOldStyleTaskList(array $registeredTask)
    {
        $taskNames = array();

        foreach ($registeredTask as $className => $task) {
            $taskName = $task->getTaskName();
            $taskNames[$taskName] = $taskName;
        }

        return $taskNames;
    }

    /**
     * Old method retained for backwards compatibility
     * 
     * @deprecated
     */
    public function loadTasks($directory = null)
    {
        $this->includeAndRegisterDoctrineTaskClasses($directory);
        return $this->createOldStyleTaskList($this->getRegisteredTasks());
    }

    /**
     * Old method retained for backwards compatibility
     * 
     * @deprecated
     */
    protected function _getTaskClassFromArgs(array $args)
    {
        return self::TASK_BASE_CLASS . '_' . Doctrine_Inflector::classify(str_replace('-', '_', $args[1]));
    }

    /**
     * Old method retained for backwards compatibility
     * 
     * @deprecated
     */
    public function getLoadedTasks()
    {
        return $this->createOldStyleTaskList($this->getRegisteredTasks());
    }
}