<?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 (is_numeric($exitCode))
			{
				$exitCode = (int) $exitCode;

				if ($exitCode === 0)
				{
					$exitCode = 1;
				}
			}
			else
			{
				$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
			{
				$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);
		}
	}
}
