<?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\Command;

use Joomla\Console\Application;
use Symfony\Component\Console\Helper\HelperSet;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

/**
 * Base command class for a Joomla! command line application.
 *
 * @since  2.0.0
 */
abstract class AbstractCommand
{
    /**
     * The default command name
     *
     * @var    string|null
     * @since  2.0.0
     */
    protected static $defaultName;

    /**
     * The command's aliases.
     *
     * @var    string[]
     * @since  2.0.0
     */
    private $aliases = [];

    /**
     * The application running this command.
     *
     * @var    Application|null
     * @since  2.0.0
     */
    private $application;

    /**
     * Flag tracking whether the application definition has been merged to this command.
     *
     * @var    boolean
     * @since  2.0.0
     */
    private $applicationDefinitionMerged = false;

    /**
     * Flag tracking whether the application definition with arguments has been merged to this command.
     *
     * @var    boolean
     * @since  2.0.0
     */
    private $applicationDefinitionMergedWithArgs = false;

    /**
     * The command's input definition.
     *
     * @var    InputDefinition
     * @since  2.0.0
     */
    private $definition;

    /**
     * The command's description.
     *
     * @var    string
     * @since  2.0.0
     */
    private $description = '';

    /**
     * The command's help.
     *
     * @var    string
     * @since  2.0.0
     */
    private $help = '';

    /**
     * The command's input helper set.
     *
     * @var    HelperSet|null
     * @since  2.0.0
     */
    private $helperSet;

    /**
     * Flag tracking whether the command is hidden from the command listing.
     *
     * @var    boolean
     * @since  2.0.0
     */
    private $hidden = false;

    /**
     * The command's name.
     *
     * @var    string
     * @since  2.0.0
     */
    private $name;

    /**
     * The command's synopses.
     *
     * @var    string[]
     * @since  2.0.0
     */
    private $synopsis = [];

    /**
     * Command constructor.
     *
     * @param   string|null  $name  The name of the command; if the name is empty and no default is set, a name must be set in the configure() method
     *
     * @since   2.0.0
     */
    public function __construct(?string $name = null)
    {
        $this->definition = new InputDefinition();

        if ($name !== null || null !== $name = static::getDefaultName()) {
            $this->setName($name);
        }

        $this->configure();
    }

    /**
     * Adds an argument to the input definition.
     *
     * @param   string    $name         The argument name
     * @param   ?integer  $mode         The argument mode: InputArgument::REQUIRED or InputArgument::OPTIONAL
     * @param   string    $description  A description text
     * @param   mixed     $default      The default value (for InputArgument::OPTIONAL mode only)
     *
     * @return  $this
     *
     * @since   2.0.0
     */
    public function addArgument(string $name, ?int $mode = null, string $description = '', $default = null): self
    {
        $this->definition->addArgument(new InputArgument($name, $mode, $description, $default));

        return $this;
    }

    /**
     * Adds an option to the input definition.
     *
     * @param   string         $name         The option name
     * @param   string|array   $shortcut     The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts
     * @param   ?integer       $mode         The option mode: One of the VALUE_* constants
     * @param   string         $description  A description text
     * @param   ?mixed         $default      The default value (must be null for InputOption::VALUE_NONE)
     *
     * @return  $this
     *
     * @since   2.0.0
     */
    public function addOption(string $name, $shortcut = null, ?int $mode = null, $description = '', $default = null): self
    {
        $this->definition->addOption(new InputOption($name, $shortcut, $mode, $description, $default));

        return $this;
    }

    /**
     * Configure the command.
     *
     * @return  void
     *
     * @since   2.0.0
     */
    protected function configure(): void
    {
    }

    /**
     * Internal function to execute the command.
     *
     * @param   InputInterface   $input   The input to inject into the command.
     * @param   OutputInterface  $output  The output to inject into the command.
     *
     * @return  integer  The command exit code
     *
     * @since   2.0.0
     */
    abstract protected function doExecute(InputInterface $input, OutputInterface $output): int;

    /**
     * Executes the command.
     *
     * @param   InputInterface   $input   The input to inject into the command.
     * @param   OutputInterface  $output  The output to inject into the command.
     *
     * @return  integer  The command exit code
     *
     * @since   2.0.0
     */
    public function execute(InputInterface $input, OutputInterface $output): int
    {
        // Force the creation of the synopsis before the merge with the app definition
        $this->getSynopsis(true);
        $this->getSynopsis(false);

        // Add the application arguments and options
        $this->mergeApplicationDefinition();

        // Bind the input against the command specific arguments/options
        $input->bind($this->getDefinition());

        $this->initialise($input, $output);

        // Ensure that the command has a `command` argument so it does not fail validation
        if ($input->hasArgument('command') && $input->getArgument('command') === null) {
            $input->setArgument('command', $this->getName());
        }

        $input->validate();

        return $this->doExecute($input, $output);
    }

    /**
     * Get the command's aliases.
     *
     * @return  string[]
     *
     * @since   2.0.0
     */
    public function getAliases(): array
    {
        return $this->aliases;
    }

    /**
     * Get the application object.
     *
     * @return  Application  The application object.
     *
     * @since   2.0.0
     * @throws  \UnexpectedValueException if the application has not been set.
     */
    public function getApplication(): Application
    {
        if ($this->application) {
            return $this->application;
        }

        throw new \UnexpectedValueException('Application not set in ' . \get_class($this));
    }

    /**
     * Get the default command name for this class.
     *
     * This allows a command name to defined and referenced without instantiating the full command class.
     *
     * @return  string|null
     *
     * @since  2.0.0
     */
    public static function getDefaultName(): ?string
    {
        $class = \get_called_class();
        $r     = new \ReflectionProperty($class, 'defaultName');

        return $class === $r->class ? static::$defaultName : null;
    }

    /**
     * Gets the InputDefinition attached to this command.
     *
     * @return  InputDefinition
     *
     * @since   2.0.0
     */
    public function getDefinition(): InputDefinition
    {
        return $this->definition;
    }

    /**
     * Get the command's description.
     *
     * @return  string
     *
     * @since   2.0.0
     */
    public function getDescription(): string
    {
        return $this->description;
    }

    /**
     * Get the command's help.
     *
     * @return  string
     *
     * @since   2.0.0
     */
    public function getHelp(): string
    {
        return $this->help;
    }

    /**
     * Get the command's input helper set.
     *
     * @return  HelperSet|null
     *
     * @since   2.0.0
     */
    public function getHelperSet(): ?HelperSet
    {
        return $this->helperSet;
    }

    /**
     * Get the command's name.
     *
     * @return  string|null
     *
     * @since   2.0.0
     */
    public function getName(): ?string
    {
        return $this->name;
    }

    /**
     * Returns the processed help for the command.
     *
     * This method is used to replace placeholders in commands with the real values.
     * By default, this supports `%command.name%` and `%command.full_name`.
     *
     * @return  string
     *
     * @since   2.0.0
     */
    public function getProcessedHelp(): string
    {
        $name = $this->getName();

        $placeholders = [
            '%command.name%',
            '%command.full_name%',
        ];

        $replacements = [
            $name,
            $_SERVER['PHP_SELF'] . ' ' . $name,
        ];

        return str_replace($placeholders, $replacements, $this->getHelp() ?: $this->getDescription());
    }

    /**
     * Get the command's synopsis.
     *
     * @param   boolean  $short  Flag indicating whether the short or long version of the synopsis should be returned
     *
     * @return  string
     *
     * @since   2.0.0
     */
    public function getSynopsis(bool $short = false): string
    {
        $key = $short ? 'short' : 'long';

        if (!isset($this->synopsis[$key])) {
            $this->synopsis[$key] = trim(sprintf('%s %s', $this->getName(), $this->getDefinition()->getSynopsis($short)));
        }

        return $this->synopsis[$key];
    }

    /**
     * Internal hook to initialise the command after the input has been bound and before the input is validated.
     *
     * @param   InputInterface   $input   The input to inject into the command.
     * @param   OutputInterface  $output  The output to inject into the command.
     *
     * @return  void
     *
     * @since   2.0.0
     */
    protected function initialise(InputInterface $input, OutputInterface $output): void
    {
    }

    /**
     * Check if the command is enabled in this environment.
     *
     * @return  boolean
     *
     * @since   2.0.0
     */
    public function isEnabled(): bool
    {
        return true;
    }

    /**
     * Check if the command is hidden from the command listing.
     *
     * @return  boolean
     *
     * @since   2.0.0
     */
    public function isHidden(): bool
    {
        return $this->hidden;
    }

    /**
     * Merges the application definition with the command definition.
     *
     * @param   boolean  $mergeArgs  Flag indicating whether the application's definition arguments should be merged
     *
     * @return  void
     *
     * @since   2.0.0
     * @internal  This method should not be relied on as part of the public API
     */
    final public function mergeApplicationDefinition(bool $mergeArgs = true): void
    {
        if (!$this->application || ($this->applicationDefinitionMerged && ($this->applicationDefinitionMergedWithArgs || !$mergeArgs))) {
            return;
        }

        $this->getDefinition()->addOptions($this->getApplication()->getDefinition()->getOptions());

        $this->applicationDefinitionMerged = true;

        if ($mergeArgs) {
            $currentArguments = $this->getDefinition()->getArguments();
            $this->getDefinition()->setArguments($this->getApplication()->getDefinition()->getArguments());
            $this->getDefinition()->addArguments($currentArguments);

            $this->applicationDefinitionMergedWithArgs = true;
        }
    }

    /**
     * Set the command's aliases.
     *
     * @param   string[]  $aliases  The command aliases
     *
     * @return  void
     *
     * @since   2.0.0
     */
    public function setAliases(array $aliases): void
    {
        $this->aliases = $aliases;
    }

    /**
     * Set the command's application.
     *
     * @param   ?Application  $application  The command's application
     *
     * @return  void
     *
     * @since   2.0.0
     */
    public function setApplication(?Application $application = null): void
    {
        $this->application = $application;

        if ($application) {
            $this->setHelperSet($application->getHelperSet());
        } else {
            $this->helperSet = null;
        }
    }

    /**
     * Sets the input definition for the command.
     *
     * @param   array|InputDefinition  $definition  Either an InputDefinition object or an array of objects to write to the definition.
     *
     * @return  void
     *
     * @since   2.0.0
     */
    public function setDefinition($definition): void
    {
        if ($definition instanceof InputDefinition) {
            $this->definition = $definition;
        } else {
            $this->definition->setDefinition($definition);
        }

        $this->applicationDefinitionMerged = false;
    }

    /**
     * Sets the description for the command.
     *
     * @param   string  $description  The description for the command
     *
     * @return  void
     *
     * @since   2.0.0
     */
    public function setDescription(string $description): void
    {
        $this->description = $description;
    }

    /**
     * Sets the help for the command.
     *
     * @param   string  $help  The help for the command
     *
     * @return  void
     *
     * @since   2.0.0
     */
    public function setHelp(string $help): void
    {
        $this->help = $help;
    }

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

    /**
     * Set whether this command is hidden from the command listing.
     *
     * @param   boolean  $hidden  Flag if this command is hidden.
     *
     * @return  void
     *
     * @since   2.0.0
     */
    public function setHidden(bool $hidden): void
    {
        $this->hidden = $hidden;
    }

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