<?php

/**
 * Part of the Joomla Framework Console Package
 *
 * @copyright  Copyright (C) 2005 - 2021 Open Source Matters, Inc. All rights reserved.
 * @license    GNU General Public License version 2 or later; see LICENSE
 */

namespace Joomla\Console;

use Joomla\Application\AbstractApplication;
use Joomla\Application\ApplicationEvents;
use Joomla\Console\Command\AbstractCommand;
use Joomla\Console\Command\HelpCommand;
use Joomla\Console\Event\ApplicationErrorEvent;
use Joomla\Console\Event\BeforeCommandExecuteEvent;
use Joomla\Console\Event\CommandErrorEvent;
use Joomla\Console\Event\TerminateEvent;
use Joomla\Console\Exception\NamespaceNotFoundException;
use Joomla\Registry\Registry;
use Joomla\String\StringHelper;
use Symfony\Component\Console\Exception\CommandNotFoundException;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Helper\DebugFormatterHelper;
use Symfony\Component\Console\Helper\FormatterHelper;
use Symfony\Component\Console\Helper\HelperSet;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputAwareInterface;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Terminal;
use Symfony\Component\ErrorHandler\ErrorHandler;

/**
 * Base application class for a Joomla! command line application.
 *
 * @since  2.0.0
 */
class Application extends AbstractApplication
{
    /**
     * Flag indicating the application should automatically exit after the command is run.
     *
     * @var    boolean
     * @since  2.0.0
     */
    private $autoExit = true;

    /**
     * Flag indicating the application should catch and handle Throwables.
     *
     * @var    boolean
     * @since  2.0.0
     */
    private $catchThrowables = true;

    /**
     * The available commands.
     *
     * @var    AbstractCommand[]
     * @since  2.0.0
     */
    private $commands = [];

    /**
     * The command loader.
     *
     * @var    Loader\LoaderInterface|null
     * @since  2.0.0
     */
    private $commandLoader;

    /**
     * Console input handler.
     *
     * @var    InputInterface
     * @since  2.0.0
     */
    private $consoleInput;

    /**
     * Console output handler.
     *
     * @var    OutputInterface
     * @since  2.0.0
     */
    private $consoleOutput;

    /**
     * The default command for the application.
     *
     * @var    string
     * @since  2.0.0
     */
    private $defaultCommand = 'list';

    /**
     * The application input definition.
     *
     * @var    InputDefinition|null
     * @since  2.0.0
     */
    private $definition;

    /**
     * The application helper set.
     *
     * @var    HelperSet|null
     * @since  2.0.0
     */
    private $helperSet;

    /**
     * Internal flag tracking if the command store has been initialised.
     *
     * @var    boolean
     * @since  2.0.0
     */
    private $initialised = false;

    /**
     * The name of the application.
     *
     * @var    string
     * @since  2.0.0
     */
    private $name = '';

    /**
     * Reference to the currently running command.
     *
     * @var    AbstractCommand|null
     * @since  2.0.0
     */
    private $runningCommand;

    /**
     * The console terminal helper.
     *
     * @var    Terminal
     * @since  2.0.0
     */
    private $terminal;

    /**
     * The version of the application.
     *
     * @var    string
     * @since  2.0.0
     */
    private $version = '';

    /**
     * Internal flag tracking if the user is seeking help for the given command.
     *
     * @var    boolean
     * @since  2.0.0
     */
    private $wantsHelp = false;

    /**
     * Class constructor.
     *
     * @param   ?InputInterface   $input   An optional argument to provide dependency injection for the application's input object.  If the argument is
     *                                     an InputInterface object that object will become the application's input object, otherwise a default input
     *                                     object is created.
     * @param   ?OutputInterface  $output  An optional argument to provide dependency injection for the application's output object.  If the argument
     *                                     is an OutputInterface object that object will become the application's output object, otherwise a default
     *                                     output object is created.
     * @param   ?Registry         $config  An optional argument to provide dependency injection for the application's config object.  If the argument
     *                                     is a Registry object that object will become the application's config object, otherwise a default config
     *                                     object is created.
     *
     * @since   2.0.0
     */
    public function __construct(?InputInterface $input = null, ?OutputInterface $output = null, ?Registry $config = null)
    {
        // Close the application if we are not executed from the command line.
        if (!\defined('STDOUT') || !\defined('STDIN') || !isset($_SERVER['argv'])) {
            $this->close();
        }

        $this->consoleInput  = $input ?: new ArgvInput();
        $this->consoleOutput = $output ?: new ConsoleOutput();
        $this->terminal      = new Terminal();

        // Call the constructor as late as possible (it runs `initialise`).
        parent::__construct($config);
    }

    /**
     * Adds a command object.
     *
     * If a command with the same name already exists, it will be overridden. If the command is not enabled it will not be added.
     *
     * @param   AbstractCommand  $command  The command to add to the application.
     *
     * @return  AbstractCommand
     *
     * @since   2.0.0
     * @throws  LogicException
     */
    public function addCommand(AbstractCommand $command): AbstractCommand
    {
        $this->initCommands();

        if (!$command->isEnabled()) {
            return $command;
        }

        $command->setApplication($this);

        try {
            $command->getDefinition();
        } catch (\TypeError $exception) {
            throw new LogicException(sprintf('Command class "%s" is not correctly initialised.', \get_class($command)), 0, $exception);
        }

        if (!$command->getName()) {
            throw new LogicException(sprintf('The command class "%s" does not have a name.', \get_class($command)));
        }

        $this->commands[$command->getName()] = $command;

        foreach ($command->getAliases() as $alias) {
            $this->commands[$alias] = $command;
        }

        return $command;
    }

    /**
     * Configures the console input and output instances for the process.
     *
     * @return  void
     *
     * @since   2.0.0
     */
    protected function configureIO(): void
    {
        if ($this->consoleInput->hasParameterOption(['--ansi'], true)) {
            $this->consoleOutput->setDecorated(true);
        } elseif ($this->consoleInput->hasParameterOption(['--no-ansi'], true)) {
            $this->consoleOutput->setDecorated(false);
        }

        if ($this->consoleInput->hasParameterOption(['--no-interaction', '-n'], true)) {
            $this->consoleInput->setInteractive(false);
        }

        if ($this->consoleInput->hasParameterOption(['--quiet', '-q'], true)) {
            $this->consoleOutput->setVerbosity(OutputInterface::VERBOSITY_QUIET);
            $this->consoleInput->setInteractive(false);
        } else {
            if (
                $this->consoleInput->hasParameterOption('-vvv', true)
                || $this->consoleInput->hasParameterOption('--verbose=3', true)
                || $this->consoleInput->getParameterOption('--verbose', false, true) === 3
            ) {
                $this->consoleOutput->setVerbosity(OutputInterface::VERBOSITY_DEBUG);
            } elseif (
                $this->consoleInput->hasParameterOption('-vv', true)
                || $this->consoleInput->hasParameterOption('--verbose=2', true)
                || $this->consoleInput->getParameterOption('--verbose', false, true) === 2
            ) {
                $this->consoleOutput->setVerbosity(OutputInterface::VERBOSITY_VERY_VERBOSE);
            } elseif (
                $this->consoleInput->hasParameterOption('-v', true)
                || $this->consoleInput->hasParameterOption('--verbose=1', true)
                || $this->consoleInput->hasParameterOption('--verbose', true)
                || $this->consoleInput->getParameterOption('--verbose', false, true)
            ) {
                $this->consoleOutput->setVerbosity(OutputInterface::VERBOSITY_VERBOSE);
            }
        }
    }

    /**
     * Method to run the application routines.
     *
     * @return  integer  The exit code for the application
     *
     * @since   2.0.0
     * @throws  \Throwable
     */
    protected function doExecute(): int
    {
        $input  = $this->consoleInput;
        $output = $this->consoleOutput;

        // If requesting the version, short circuit the application and send the version data
        if ($input->hasParameterOption(['--version', '-V'], true)) {
            $output->writeln($this->getLongVersion());

            return 0;
        }

        try {
            // Makes ArgvInput::getFirstArgument() able to distinguish an option from an argument.
            $input->bind($this->getDefinition());
        } catch (ExceptionInterface $e) {
            // Errors must be ignored, full binding/validation happens later when the command is known.
        }

        $name = $this->getCommandName($input);

        // Redirect to the help command if requested
        if ($input->hasParameterOption(['--help', '-h'], true)) {
            // If no command name was given, use the help command with a minimal input; otherwise flag the request for processing later
            if (!$name) {
                $name  = 'help';
                $input = new ArrayInput(['command_name' => $this->defaultCommand]);
            } else {
                $this->wantsHelp = true;
            }
        }

        // If we still do not have a command name, then the user has requested the application's default command
        if (!$name) {
            $name       = $this->defaultCommand;
            $definition = $this->getDefinition();

            // Overwrite the default value of the command argument with the default command name
            $definition->setArguments(
                array_merge(
                    $definition->getArguments(),
                    [
                        'command' => new InputArgument(
                            'command',
                            InputArgument::OPTIONAL,
                            $definition->getArgument('command')->getDescription(),
                            $name
                        ),
                    ]
                )
            );
        }

        try {
            $this->runningCommand = null;

            $command = $this->getCommand($name);
        } catch (\Throwable $e) {
            if ($e instanceof CommandNotFoundException && !($e instanceof NamespaceNotFoundException)) {
                (new SymfonyStyle($input, $output))->block(sprintf("\nCommand \"%s\" is not defined.\n", $name), null, 'error');
            }

            $event = new CommandErrorEvent($e, $this);

            $this->dispatchEvent(ConsoleEvents::COMMAND_ERROR, $event);

            if ($event->getExitCode() === 0) {
                return 0;
            }

            $e = $event->getError();

            throw $e;
        }

        $this->runningCommand = $command;
        $exitCode             = $this->runCommand($command, $input, $output);
        $this->runningCommand = null;

        return $exitCode;
    }

    /**
     * Execute the application.
     *
     * @return  void
     *
     * @since   2.0.0
     * @throws  \Throwable
     */
    public function execute()
    {
        putenv('LINES=' . $this->terminal->getHeight());
        putenv('COLUMNS=' . $this->terminal->getWidth());

        $this->configureIO();

        $renderThrowable = function (\Throwable $e) {
            $this->renderThrowable($e);
        };

        if ($phpHandler = set_exception_handler($renderThrowable)) {
            restore_exception_handler();

            if (!\is_array($phpHandler) || !$phpHandler[0] instanceof ErrorHandler) {
                $errorHandler = true;
            } elseif ($errorHandler = $phpHandler[0]->setExceptionHandler($renderThrowable)) {
                $phpHandler[0]->setExceptionHandler($errorHandler);
            }
        }

        try {
            $this->dispatchEvent(ApplicationEvents::BEFORE_EXECUTE);

            // Perform application routines.
            $exitCode = $this->doExecute();

            $this->dispatchEvent(ApplicationEvents::AFTER_EXECUTE);
        } catch (\Throwable $throwable) {
            if (!$this->shouldCatchThrowables()) {
                throw $throwable;
            }

            $renderThrowable($throwable);

            $event = new ApplicationErrorEvent($throwable, $this, $this->runningCommand);

            $this->dispatchEvent(ConsoleEvents::APPLICATION_ERROR, $event);

            $exitCode = $event->getExitCode();

            if ($exitCode === 0) {
                $exitCode = 1;
            }
        } finally {
            // If the exception handler changed, keep it; otherwise, unregister $renderThrowable
            if (!$phpHandler) {
                if (set_exception_handler($renderThrowable) === $renderThrowable) {
                    restore_exception_handler();
                }

                restore_exception_handler();
            } elseif (!$errorHandler) {
                $finalHandler = $phpHandler[0]->setExceptionHandler(null);

                if ($finalHandler !== $renderThrowable) {
                    $phpHandler[0]->setExceptionHandler($finalHandler);
                }
            }

            if ($this->shouldAutoExit() && isset($exitCode)) {
                $exitCode = $exitCode > 255 ? 255 : $exitCode;
                $this->close($exitCode);
            }
        }
    }

    /**
     * Finds a registered namespace by a name.
     *
     * @param   string  $namespace  A namespace to search for
     *
     * @return  string
     *
     * @since   2.0.0
     * @throws  NamespaceNotFoundException When namespace is incorrect or ambiguous
     */
    public function findNamespace(string $namespace): string
    {
        $allNamespaces = $this->getNamespaces();

        $expr = preg_replace_callback(
            '{([^:]+|)}',
            function ($matches) {
                return preg_quote($matches[1]) . '[^:]*';
            },
            $namespace
        );

        $namespaces = preg_grep('{^' . $expr . '}', $allNamespaces);

        if (empty($namespaces)) {
            throw new NamespaceNotFoundException(sprintf('There are no commands defined in the "%s" namespace.', $namespace));
        }

        $exact = \in_array($namespace, $namespaces, true);

        if (\count($namespaces) > 1 && !$exact) {
            throw new NamespaceNotFoundException(sprintf('The namespace "%s" is ambiguous.', $namespace));
        }

        return $exact ? $namespace : reset($namespaces);
    }

    /**
     * Gets all commands, including those available through a command loader, optionally filtered on a command namespace.
     *
     * @param   string  $namespace  An optional command namespace to filter by.
     *
     * @return  AbstractCommand[]
     *
     * @since   2.0.0
     */
    public function getAllCommands(string $namespace = ''): array
    {
        $this->initCommands();

        if ($namespace === '') {
            $commands = $this->commands;

            if (!$this->commandLoader) {
                return $commands;
            }

            foreach ($this->commandLoader->getNames() as $name) {
                if (!isset($commands[$name])) {
                    $commands[$name] = $this->getCommand($name);
                }
            }

            return $commands;
        }

        $commands = [];

        foreach ($this->commands as $name => $command) {
            if ($namespace === $this->extractNamespace($name, substr_count($namespace, ':') + 1)) {
                $commands[$name] = $command;
            }
        }

        if ($this->commandLoader) {
            foreach ($this->commandLoader->getNames() as $name) {
                if (!isset($commands[$name]) && $namespace === $this->extractNamespace($name, substr_count($namespace, ':') + 1)) {
                    $commands[$name] = $this->getCommand($name);
                }
            }
        }

        return $commands;
    }

    /**
     * Returns a registered command by name or alias.
     *
     * @param   string  $name  The command name or alias
     *
     * @return  AbstractCommand
     *
     * @since   2.0.0
     * @throws  CommandNotFoundException
     */
    public function getCommand(string $name): AbstractCommand
    {
        $this->initCommands();

        if (!$this->hasCommand($name)) {
            throw new CommandNotFoundException(sprintf('The command "%s" does not exist.', $name));
        }

        // If the command isn't registered, pull it from the loader if registered
        if (!isset($this->commands[$name]) && $this->commandLoader) {
            $this->addCommand($this->commandLoader->get($name));
        }

        $command = $this->commands[$name];

        // If the user requested help, we'll fetch the help command now and inject the user's command into it
        if ($this->wantsHelp) {
            $this->wantsHelp = false;

            /** @var HelpCommand $helpCommand */
            $helpCommand = $this->getCommand('help');
            $helpCommand->setCommand($command);

            return $helpCommand;
        }

        return $command;
    }

    /**
     * Get the name of the command to run.
     *
     * @param   InputInterface  $input  The input to read the argument from
     *
     * @return  string|null
     *
     * @since   2.0.0
     */
    protected function getCommandName(InputInterface $input): ?string
    {
        return $input->getFirstArgument();
    }

    /**
     * Get the registered commands.
     *
     * This method only retrieves commands which have been explicitly registered.  To get all commands including those from a
     * command loader, use the `getAllCommands()` method.
     *
     * @return  AbstractCommand[]
     *
     * @since   2.0.0
     */
    public function getCommands(): array
    {
        return $this->commands;
    }

    /**
     * Get the console input handler.
     *
     * @return  InputInterface
     *
     * @since   2.0.0
     */
    public function getConsoleInput(): InputInterface
    {
        return $this->consoleInput;
    }

    /**
     * Get the console output handler.
     *
     * @return  OutputInterface
     *
     * @since   2.0.0
     */
    public function getConsoleOutput(): OutputInterface
    {
        return $this->consoleOutput;
    }

    /**
     * Get the commands which should be registered by default to the application.
     *
     * @return  AbstractCommand[]
     *
     * @since   2.0.0
     */
    protected function getDefaultCommands(): array
    {
        return [
            new Command\ListCommand(),
            new Command\HelpCommand(),
        ];
    }

    /**
     * Builds the default input definition.
     *
     * @return  InputDefinition
     *
     * @since   2.0.0
     */
    protected function getDefaultInputDefinition(): InputDefinition
    {
        return new InputDefinition(
            [
                new InputArgument('command', InputArgument::REQUIRED, 'The command to execute'),
                new InputOption('--help', '-h', InputOption::VALUE_NONE, 'Display the help information'),
                new InputOption('--quiet', '-q', InputOption::VALUE_NONE, 'Flag indicating that all output should be silenced'),
                new InputOption(
                    '--verbose',
                    '-v|vv|vvv',
                    InputOption::VALUE_NONE,
                    'Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug'
                ),
                new InputOption('--version', '-V', InputOption::VALUE_NONE, 'Displays the application version'),
                new InputOption('--ansi', '', InputOption::VALUE_NONE, 'Force ANSI output'),
                new InputOption('--no-ansi', '', InputOption::VALUE_NONE, 'Disable ANSI output'),
                new InputOption('--no-interaction', '-n', InputOption::VALUE_NONE, 'Flag to disable interacting with the user'),
            ]
        );
    }

    /**
     * Builds the default helper set.
     *
     * @return  HelperSet
     *
     * @since   2.0.0
     */
    protected function getDefaultHelperSet(): HelperSet
    {
        return new HelperSet(
            [
                new FormatterHelper(),
                new DebugFormatterHelper(),
                new ProcessHelper(),
                new QuestionHelper(),
            ]
        );
    }

    /**
     * Gets the InputDefinition related to this Application.
     *
     * @return  InputDefinition
     *
     * @since   2.0.0
     */
    public function getDefinition(): InputDefinition
    {
        if (!$this->definition) {
            $this->definition = $this->getDefaultInputDefinition();
        }

        return $this->definition;
    }

    /**
     * Get the helper set associated with the application.
     *
     * @return  HelperSet
     */
    public function getHelperSet(): HelperSet
    {
        if (!$this->helperSet) {
            $this->helperSet = $this->getDefaultHelperSet();
        }

        return $this->helperSet;
    }

    /**
     * Get the long version string for the application.
     *
     * Typically, this is the application name and version and is used in the application help output.
     *
     * @return  string
     *
     * @since   2.0.0
     */
    public function getLongVersion(): string
    {
        $name = $this->getName();

        if ($name === '') {
            $name = 'Joomla Console Application';
        }

        if ($this->getVersion() !== '') {
            return sprintf('%s <info>%s</info>', $name, $this->getVersion());
        }

        return $name;
    }

    /**
     * Get the name of the application.
     *
     * @return  string
     *
     * @since   2.0.0
     */
    public function getName(): string
    {
        return $this->name;
    }

    /**
     * Returns an array of all unique namespaces used by currently registered commands.
     *
     * Note that this does not include the global namespace which always exists.
     *
     * @return  string[]
     *
     * @since   2.0.0
     */
    public function getNamespaces(): array
    {
        $namespaces = [];

        foreach ($this->getAllCommands() as $command) {
            $namespaces = array_merge($namespaces, $this->extractAllNamespaces($command->getName()));

            foreach ($command->getAliases() as $alias) {
                $namespaces = array_merge($namespaces, $this->extractAllNamespaces($alias));
            }
        }

        return array_values(array_unique(array_filter($namespaces)));
    }

    /**
     * Get the version of the application.
     *
     * @return  string
     *
     * @since   2.0.0
     */
    public function getVersion(): string
    {
        return $this->version;
    }

    /**
     * Check if the application has a command with the given name.
     *
     * @param   string  $name  The name of the command to check for existence.
     *
     * @return  boolean
     *
     * @since   2.0.0
     */
    public function hasCommand(string $name): bool
    {
        $this->initCommands();

        // If command is already registered, we're good
        if (isset($this->commands[$name])) {
            return true;
        }

        // If there is no loader, we can't look for a command there
        if (!$this->commandLoader) {
            return false;
        }

        return $this->commandLoader->has($name);
    }

    /**
     * Custom initialisation method.
     *
     * @return  void
     *
     * @since   2.0.0
     */
    protected function initialise(): void
    {
        // Set the current directory.
        $this->set('cwd', getcwd());
    }

    /**
     * Renders an error message for a Throwable object
     *
     * @param   \Throwable  $throwable  The Throwable object to render the message for.
     *
     * @return  void
     *
     * @since   2.0.0
     */
    public function renderThrowable(\Throwable $throwable): void
    {
        $output = $this->consoleOutput instanceof ConsoleOutputInterface ? $this->consoleOutput->getErrorOutput() : $this->consoleOutput;

        $output->writeln('', OutputInterface::VERBOSITY_QUIET);

        $this->doRenderThrowable($throwable, $output);

        if (null !== $this->runningCommand) {
            $output->writeln(
                sprintf(
                    '<info>%s</info>',
                    sprintf($this->runningCommand->getSynopsis(), $this->getName())
                ),
                OutputInterface::VERBOSITY_QUIET
            );

            $output->writeln('', OutputInterface::VERBOSITY_QUIET);
        }
    }

    /**
     * Handles recursively rendering error messages for a Throwable and all previous Throwables contained within.
     *
     * @param   \Throwable       $throwable  The Throwable object to render the message for.
     * @param   OutputInterface  $output     The output object to send the message to.
     *
     * @return  void
     *
     * @since   2.0.0
     */
    protected function doRenderThrowable(\Throwable $throwable, OutputInterface $output): void
    {
        do {
            $message = trim($throwable->getMessage());

            if ($message === '' || OutputInterface::VERBOSITY_VERBOSE <= $output->getVerbosity()) {
                $class = \get_class($throwable);

                if ($class[0] === 'c' && strpos($class, "class@anonymous\0") === 0) {
                    $class = get_parent_class($class) ?: key(class_implements($class));
                }

                $title = sprintf('  [%s%s]  ', $class, ($code = $throwable->getCode()) !== 0 ? ' (' . $code . ')' : '');
                $len   = StringHelper::strlen($title);
            } else {
                $title = '';
                $len = 0;
            }

            if (strpos($message, "class@anonymous\0") !== false) {
                $message = preg_replace_callback(
                    '/class@anonymous\x00.*?\.php(?:0x?|:[0-9]++\$)[0-9a-fA-F]++/',
                    function ($m) {
                        return class_exists($m[0], false) ? (get_parent_class($m[0]) ?: key(class_implements($m[0]))) . '@anonymous' : $m[0];
                    },
                    $message
                );
            }

            $width = $this->terminal->getWidth() ? $this->terminal->getWidth() - 1 : PHP_INT_MAX;
            $lines = [];

            foreach ($message !== '' ? preg_split('/\r?\n/', $message) : [] as $line) {
                foreach ($this->splitStringByWidth($line, $width - 4) as $line) {
                    // Pre-format lines to get the right string length
                    $lineLength = StringHelper::strlen($line) + 4;
                    $lines[]    = [$line, $lineLength];
                    $len        = max($lineLength, $len);
                }
            }

            $messages = [];

            if (!$throwable instanceof ExceptionInterface || OutputInterface::VERBOSITY_VERBOSE <= $output->getVerbosity()) {
                $messages[] = sprintf(
                    '<comment>%s</comment>',
                    OutputFormatter::escape(
                        sprintf(
                            'In %s line %s:',
                            basename($throwable->getFile()) ?: 'n/a',
                            $throwable->getLine() ?: 'n/a'
                        )
                    )
                );
            }

            $messages[] = $emptyLine = sprintf('<error>%s</error>', str_repeat(' ', $len));

            if ($message === '' || OutputInterface::VERBOSITY_VERBOSE <= $output->getVerbosity()) {
                $messages[] = sprintf('<error>%s%s</error>', $title, str_repeat(' ', max(0, $len - StringHelper::strlen($title))));
            }

            foreach ($lines as $line) {
                $messages[] = sprintf('<error>  %s  %s</error>', OutputFormatter::escape($line[0]), str_repeat(' ', $len - $line[1]));
            }

            $messages[] = $emptyLine;
            $messages[] = '';

            $output->writeln($messages, OutputInterface::VERBOSITY_QUIET);

            if (OutputInterface::VERBOSITY_VERBOSE <= $output->getVerbosity()) {
                $output->writeln('<comment>Exception trace:</comment>', OutputInterface::VERBOSITY_QUIET);

                // Exception related properties
                $trace = $throwable->getTrace();
                array_unshift(
                    $trace,
                    [
                        'function' => '',
                        'file'     => $throwable->getFile() ?: 'n/a',
                        'line'     => $throwable->getLine() ?: 'n/a',
                        'args'     => [],
                    ]
                );

                for ($i = 0, $count = \count($trace); $i < $count; ++$i) {
                    $class    = $trace[$i]['class'] ?? '';
                    $type     = $trace[$i]['type'] ?? '';
                    $function = $trace[$i]['function'] ?? '';
                    $file     = $trace[$i]['file'] ?? 'n/a';
                    $line     = $trace[$i]['line'] ?? 'n/a';

                    $output->writeln(
                        sprintf(
                            ' %s%s at <info>%s:%s</info>',
                            $class,
                            $function ? $type . $function . '()' : '',
                            $file,
                            $line
                        ),
                        OutputInterface::VERBOSITY_QUIET
                    );
                }

                $output->writeln('', OutputInterface::VERBOSITY_QUIET);
            }
        } while ($throwable = $throwable->getPrevious());
    }

    /**
     * Splits a string for a specified width for use in an output.
     *
     * @param   string   $string  The string to split.
     * @param   integer  $width   The maximum width of the output.
     *
     * @return  string[]
     *
     * @since   2.0.0
     */
    private function splitStringByWidth(string $string, int $width): array
    {
        /*
         * The str_split function is not suitable for multi-byte characters, we should use preg_split to get char array properly.
         * Additionally, array_slice() is not enough as some character has doubled width.
         * We need a function to split string not by character count but by string width
         */
        if (false === $encoding = mb_detect_encoding($string, null, true)) {
            return str_split($string, $width);
        }

        $utf8String = mb_convert_encoding($string, 'utf8', $encoding);
        $lines      = [];
        $line       = '';
        $offset     = 0;

        while (preg_match('/.{1,10000}/u', $utf8String, $m, 0, $offset)) {
            $offset += \strlen($m[0]);

            foreach (preg_split('//u', $m[0]) as $char) {
                // Test if $char could be appended to current line
                if (mb_strwidth($line . $char, 'utf8') <= $width) {
                    $line .= $char;

                    continue;
                }

                // If not, push current line to array and make a new line
                $lines[] = str_pad($line, $width);
                $line    = $char;
            }
        }

        $lines[] = \count($lines) ? str_pad($line, $width) : $line;
        mb_convert_variables($encoding, 'utf8', $lines);

        return $lines;
    }

    /**
     * Run the given command.
     *
     * @param   AbstractCommand  $command  The command to run.
     * @param   InputInterface   $input    The input to inject into the command.
     * @param   OutputInterface  $output   The output to inject into the command.
     *
     * @return  integer
     *
     * @since   2.0.0
     * @throws  \Throwable
     */
    protected function runCommand(AbstractCommand $command, InputInterface $input, OutputInterface $output): int
    {
        if ($command->getHelperSet() !== null) {
            foreach ($command->getHelperSet() as $helper) {
                if ($helper instanceof InputAwareInterface) {
                    $helper->setInput($input);
                }
            }
        }

        // If the application doesn't have an event dispatcher, we can short circuit and just execute the command
        try {
            $this->getDispatcher();
        } catch (\UnexpectedValueException $exception) {
            return $command->execute($input, $output);
        }

        // Bind before dispatching the event so the listeners have access to input options/arguments
        try {
            $command->mergeApplicationDefinition();
            $input->bind($command->getDefinition());
        } catch (ExceptionInterface $e) {
            // Ignore invalid options/arguments for now
        }

        $event     = new BeforeCommandExecuteEvent($this, $command);
        $exception = null;

        try {
            $this->dispatchEvent(ConsoleEvents::BEFORE_COMMAND_EXECUTE, $event);

            if ($event->isCommandEnabled()) {
                $exitCode = $command->execute($input, $output);
            } else {
                $exitCode = BeforeCommandExecuteEvent::RETURN_CODE_DISABLED;
            }
        } catch (\Throwable $exception) {
            $event = new CommandErrorEvent($exception, $this, $command);

            $this->dispatchEvent(ConsoleEvents::COMMAND_ERROR, $event);

            $exception = $event->getError();
            $exitCode  = $event->getExitCode();

            if ($exitCode === 0) {
                $exception = null;
            }
        }

        $event = new TerminateEvent($exitCode, $this, $command);

        $this->dispatchEvent(ConsoleEvents::TERMINATE, $event);

        if ($exception !== null) {
            throw $exception;
        }

        return $event->getExitCode();
    }

    /**
     * Set whether the application should auto exit.
     *
     * @param   boolean  $autoExit  The auto exit state.
     *
     * @return  void
     *
     * @since   2.0.0
     */
    public function setAutoExit(bool $autoExit): void
    {
        $this->autoExit = $autoExit;
    }

    /**
     * Set whether the application should catch Throwables.
     *
     * @param   boolean  $catchThrowables  The catch Throwables state.
     *
     * @return  void
     *
     * @since   2.0.0
     */
    public function setCatchThrowables(bool $catchThrowables): void
    {
        $this->catchThrowables = $catchThrowables;
    }

    /**
     * Set the command loader.
     *
     * @param   Loader\LoaderInterface  $loader  The new command loader.
     *
     * @return  void
     *
     * @since   2.0.0
     */
    public function setCommandLoader(Loader\LoaderInterface $loader): void
    {
        $this->commandLoader = $loader;
    }

    /**
     * Set the application's helper set.
     *
     * @param   HelperSet  $helperSet  The new HelperSet.
     *
     * @return  void
     *
     * @since   2.0.0
     */
    public function setHelperSet(HelperSet $helperSet): void
    {
        $this->helperSet = $helperSet;
    }

    /**
     * Set the name of the application.
     *
     * @param   string  $name  The new application name.
     *
     * @return  void
     *
     * @since   2.0.0
     */
    public function setName(string $name): void
    {
        $this->name = $name;
    }

    /**
     * Set the version of the application.
     *
     * @param   string  $version  The new application version.
     *
     * @return  void
     *
     * @since   2.0.0
     */
    public function setVersion(string $version): void
    {
        $this->version = $version;
    }

    /**
     * Get the application's auto exit state.
     *
     * @return  boolean
     *
     * @since   2.0.0
     */
    public function shouldAutoExit(): bool
    {
        return $this->autoExit;
    }

    /**
     * Get the application's catch Throwables state.
     *
     * @return  boolean
     *
     * @since   2.0.0
     */
    public function shouldCatchThrowables(): bool
    {
        return $this->catchThrowables;
    }

    /**
     * Returns all namespaces of the command name.
     *
     * @param   string  $name  The full name of the command
     *
     * @return  string[]
     *
     * @since   2.0.0
     */
    private function extractAllNamespaces(string $name): array
    {
        // -1 as third argument is needed to skip the command short name when exploding
        $parts      = explode(':', $name, -1);
        $namespaces = [];

        foreach ($parts as $part) {
            if (\count($namespaces)) {
                $namespaces[] = end($namespaces) . ':' . $part;
            } else {
                $namespaces[] = $part;
            }
        }

        return $namespaces;
    }

    /**
     * Returns the namespace part of the command name.
     *
     * @param   string    $name   The command name to process
     * @param   ?integer  $limit  The maximum number of parts of the namespace
     *
     * @return  string
     *
     * @since   2.0.0
     */
    private function extractNamespace(string $name, ?int $limit = null): string
    {
        $parts = explode(':', $name);
        array_pop($parts);

        return implode(':', $limit === null ? $parts : \array_slice($parts, 0, $limit));
    }

    /**
     * Internal function to initialise the command store, this allows the store to be lazy loaded only when needed.
     *
     * @return  void
     *
     * @since   2.0.0
     */
    private function initCommands(): void
    {
        if ($this->initialised) {
            return;
        }

        $this->initialised = true;

        foreach ($this->getDefaultCommands() as $command) {
            $this->addCommand($command);
        }
    }
}
