<?php

namespace AmpProject\Cli;

use AmpProject\Exception\AmpCliException;
use AmpProject\Exception\Cli\InvalidSapi;
use Exception;

/**
 * This file is adapted from the splitbrain\php-cli library, which is authored by Andreas Gohr <andi@splitbrain.org> and
 * licensed under the MIT license.
 *
 * Source: https://github.com/splitbrain/php-cli/blob/fb4f888866d090b10e3e68292d197ca274cea626/src/CLI.php
 */

/**
 * Your commandline script should inherit from this class and implement the abstract methods.
 *
 * @package ampproject/amp-toolbox
 */
abstract class Executable
{
    /**
     * Instance of the Colors helper object.
     *
     * @var Colors
     */
    public $colors;

    /**
     * The executable script itself.
     *
     * @var string
     */
    protected $bin;

    /**
     * Instance of the options parser to use.
     *
     * @var Options
     */
    protected $options;

    /**
     * PSR-3 compatible log levels and their prefix, color, output channel.
     *
     * @var array<array>
     */
    protected $loglevels = [
        LogLevel::DEBUG     => ['', Colors::C_RESET, STDOUT],
        LogLevel::INFO      => ['ℹ ', Colors::C_CYAN, STDOUT],
        LogLevel::NOTICE    => ['☛ ', Colors::C_CYAN, STDOUT],
        LogLevel::SUCCESS   => ['✓ ', Colors::C_GREEN, STDOUT],
        LogLevel::WARNING   => ['⚠ ', Colors::C_BROWN, STDERR],
        LogLevel::ERROR     => ['✗ ', Colors::C_RED, STDERR],
        LogLevel::CRITICAL  => ['☠ ', Colors::C_LIGHTRED, STDERR],
        LogLevel::ALERT     => ['✖ ', Colors::C_LIGHTRED, STDERR],
        LogLevel::EMERGENCY => ['✘ ', Colors::C_LIGHTRED, STDERR],
    ];

    /**
     * Default log level.
     *
     * @var string
     */
    protected $loglevel = 'info';

    /**
     * Constructor.
     *
     * Initialize the arguments, set up helper classes and set up the CLI environment.
     *
     * @param bool         $autocatch Optional. Whether exceptions should be caught and handled automatically. Defaults
     *                                to true.
     * @param Options|null $options   Optional. Instance of the Options object to use. Defaults to null to instantiate a
     *                                new one.
     * @param Colors|null  $colors    Optional. Instance of the Colors object to use. Defaults to null to instantiate a
     *                                new one.
     */
    public function __construct($autocatch = true, ?Options $options = null, ?Colors $colors = null)
    {
        if ($autocatch) {
            set_exception_handler([$this, 'fatal']);
        }

        $this->colors  = $colors instanceof Colors ? $colors : new Colors();
        $this->options = $options instanceof Options ? $options : new Options($this->colors);
    }

    /**
     * Execute the CLI program.
     *
     * Executes the setup() routine, adds default options, initiate the options parsing and argument checking
     * and finally executes main() - Each part is split into their own protected function below, so behaviour
     * can easily be overwritten.
     *
     * @param bool $exitOnCompletion Optional. Whether to exit on completion. Defaults to true.
     * @throws InvalidSapi If a SAPI other than 'cli' is detected.
     */
    public function run($exitOnCompletion = true)
    {
        $sapi = php_sapi_name();

        if ('cli' !== $sapi) {
            throw InvalidSapi::forSapi($sapi);
        }

        $this->setup($this->options);
        $this->registerDefaultOptions();
        $this->parseOptions();
        $this->handleDefaultOptions();
        $this->setupLogging();
        $this->checkArguments();
        $this->execute();

        if ($exitOnCompletion) {
            exit(0);
        }
    }

    /**
     * Exits the program on a fatal error.
     *
     * @param Exception|string $error   Either an exception or an error message.
     * @param array            $context Optional. Associative array of contextual information. Defaults to an empty
     *                                  array.
     */
    public function fatal($error, array $context = [])
    {
        $code = 0;

        if ($error instanceof Exception) {
            $this->debug(get_class($error) . ' caught in ' . $error->getFile() . ':' . $error->getLine());
            $this->debug($error->getTraceAsString());
            $code  = $error->getCode();
            $error = $error->getMessage();
        }

        if (! $code) {
            $code = AmpCliException::E_ANY;
        }

        $this->critical($error, $context);

        exit($code);
    }

    /**
     * System is unusable.
     *
     * @param string $message Log message.
     * @param array  $context Optional. Contextual information. Defaults to an empty array.
     * @return void
     */
    public function emergency($message, array $context = [])
    {
        $this->log(LogLevel::EMERGENCY, $message, $context);
    }

    /**
     * Action must be taken immediately.
     *
     * Example: Entire website down, database unavailable, etc. This should trigger the SMS alerts and wake you up.
     *
     * @param string $message Log message.
     * @param array  $context Optional. Contextual information. Defaults to an empty array.
     * @return void
     */
    public function alert($message, array $context = [])
    {
        $this->log(LogLevel::ALERT, $message, $context);
    }

    /**
     * Critical conditions.
     *
     * Example: Application component unavailable, unexpected exception.
     *
     * @param string $message Log message.
     * @param array  $context Optional. Contextual information. Defaults to an empty array.
     * @return void
     */
    public function critical($message, array $context = [])
    {
        $this->log(LogLevel::CRITICAL, $message, $context);
    }

    /**
     * Runtime errors that do not require immediate action but should typically be logged and monitored.
     *
     * @param string $message Log message.
     * @param array  $context Optional. Contextual information. Defaults to an empty array.
     * @return void
     */
    public function error($message, array $context = [])
    {
        $this->log(LogLevel::ERROR, $message, $context);
    }

    /**
     * Exceptional occurrences that are not errors.
     *
     * Example: Use of deprecated APIs, poor use of an API, undesirable things that are not necessarily wrong.
     *
     * @param string $message Log message.
     * @param array  $context Optional. Contextual information. Defaults to an empty array.
     * @return void
     */
    public function warning($message, array $context = [])
    {
        $this->log(LogLevel::WARNING, $message, $context);
    }

    /**
     * Normal, positive outcome.
     *
     * @param string $string  Log message.
     * @param array  $context Optional. Contextual information. Defaults to an empty array.
     * @return void
     */
    public function success($string, array $context = [])
    {
        $this->log(LogLevel::SUCCESS, $string, $context);
    }

    /**
     * Normal but significant events.
     *
     * @param string $message Log message.
     * @param array  $context Optional. Contextual information. Defaults to an empty array.
     * @return void
     */
    public function notice($message, array $context = [])
    {
        $this->log(LogLevel::NOTICE, $message, $context);
    }

    /**
     * Interesting events.
     *
     * Example: User logs in, SQL logs.
     *
     * @param string $message Log message.
     * @param array  $context Optional. Contextual information. Defaults to an empty array.
     * @return void
     */
    public function info($message, array $context = [])
    {
        $this->log(LogLevel::INFO, $message, $context);
    }

    /**
     * Detailed debug information.
     *
     * @param string $message Log message.
     * @param array  $context Optional. Contextual information. Defaults to an empty array.
     * @return void
     */
    public function debug($message, array $context = [])
    {
        $this->log(LogLevel::DEBUG, $message, $context);
    }

    /**
     * Log a message of a given log level to the logs.
     *
     * @param string $level   Log level to use.
     * @param string $message Log message.
     * @param array  $context Optional. Contextual information. Defaults to an empty array.
     * @return void
     */
    public function log($level, $message, array $context = [])
    {
        if (! LogLevel::matches($level, $this->options->getOption('loglevel', $this->loglevel))) {
            return;
        }

        list($prefix, $color, $channel) = $this->loglevels[$level];

        if (! $this->colors->isEnabled()) {
            $prefix = '';
        }

        $message = $this->interpolate($message, $context);

        $this->colors->line($prefix . $message, $color, $channel);
    }

    /**
     * Interpolates context values into the message placeholders.
     *
     * @param string $message Message to interpolate.
     * @param array  $context Optional. Contextual information. Defaults to an empty array.
     * @return string Interpolated string.
     */
    protected function interpolate($message, array $context = [])
    {
        // Build a replacement array with braces around the context keys.
        $replace = [];
        foreach ($context as $key => $val) {
            // Check that the value can be cast to string.
            if (
                ! is_array($val)
                &&
                (
                    ! is_object($val)
                    || method_exists($val, '__toString')
                )
            ) {
                $replace['{' . $key . '}'] = $val;
            }
        }

        // Interpolate replacement values into the message and return.
        return strtr($message, $replace);
    }

    /**
     * Add the default help, color and log options.
     */
    protected function registerDefaultOptions()
    {
        $this->options->registerOption(
            'help',
            'Display this help screen and exit immediately.',
            'h'
        );
        $this->options->registerOption(
            'no-colors',
            'Do not use any colors in output. Useful when piping output to other tools or files.'
        );
        $this->options->registerOption(
            'loglevel',
            "Minimum level of messages to display. Default is {$this->colors->wrap($this->loglevel, Colors::C_CYAN)}."
            . ' Valid levels are: debug, info, notice, success, warning, error, critical, alert, emergency.',
            null,
            'level'
        );
    }

    /**
     * Handle the default options.
     */
    protected function handleDefaultOptions()
    {
        if ($this->options->getOption('no-colors')) {
            $this->colors->disable();
        }
        if ($this->options->getOption('help')) {
            echo $this->options->help();
            exit(0);
        }
    }

    /**
     * Handle the logging options.
     */
    protected function setupLogging()
    {
        $this->loglevel = $this->options->getOption('loglevel', $this->loglevel);

        if (! in_array($this->loglevel, LogLevel::ORDER)) {
            $this->fatal('Unknown log level');
        }
    }

    /**
     * Wrapper around the option parsing.
     */
    protected function parseOptions()
    {
        $this->options->parseOptions();
    }

    /**
     * Wrapper around the argument checking.
     */
    protected function checkArguments()
    {
        $this->options->checkArguments();
    }

    /**
     * Wrapper around main.
     */
    protected function execute()
    {
        $this->main($this->options);
    }

    /**
     * Register options and arguments on the given $options object.
     *
     * @param Options $options Options instance to register the commands with.
     * @return void
     */
    abstract protected function setup(Options $options);

    /**
     * Main program routine.
     *
     * Arguments and options have been parsed when this is run.
     *
     * @param Options $options Options instance to register the commands with.
     * @return void
     */
    abstract protected function main(Options $options);
}
