<?php
/**
 * Copyright 2003-2017 Horde LLC (http://www.horde.org/)
 *
 * See the enclosed file COPYING for license information (LGPL). If you
 * did not receive this file, see http://www.horde.org/licenses/lgpl21.
 *
 * @author   Chuck Hagenbuch <chuck@horde.org>
 * @author   Jan Schneider <jan@horde.org>
 * @category Horde
 * @license  http://www.horde.org/licenses/lgpl21 LGPL
 * @package  Cli
 */

/**
 * Horde_Cli API for basic command-line functionality/checks.
 *
 * @author    Chuck Hagenbuch <chuck@horde.org>
 * @author    Jan Schneider <jan@horde.org>
 * @category  Horde
 * @copyright 2003-2017 Horde LLC
 * @license   http://www.horde.org/licenses/lgpl21 LGPL
 * @package   Cli
 */
class Horde_Cli
{
    /**
     * The output pipe.
     *
     * @var resource
     */
    protected $_output;

    /**
     * The newline string to use.
     *
     * @var string
     */
    protected $_newline;

    /**
     * The formatted space string to use.
     *
     * @var string
     */
    protected $_space;

    /**
     * The string to use for clearing the screen.
     *
     * @var string
     */
    protected $_clearscreen = '';

    /**
     * The indent string to use.
     *
     * @var string
     */
    protected $_indent;

    /**
     * The color formatter.
     *
     * @var Horde_Cli_Color
     */
    protected $_color;

    /**
     * The terminal width.
     *
     * @var integer
     */
    protected $_width;

    /**
     * Detect the current environment (web server or console) and sets
     * internal values accordingly.
     *
     * Use init() if you also want to set environment variables that may be
     * missing in a CLI environment.
     *
     * @param array $opts  Configuration options:
     *                     - 'output': (resource, optional) Open pipe to write
     *                                 the output to.
     *                     - 'pager': (boolean) Pipe output through a pager?
     *                                @since 2.3.0
     */
    public function __construct(array $opts = array())
    {
        $this->_color = new Horde_Cli_Color();
        $console = $this->runningFromCLI();

        if ($console) {
            $this->_newline = PHP_EOL;
            $this->_space = ' ';
            if (getenv('TERM')) {
                $this->_clearscreen  = "\x1b[2J\x1b[H";
            }
            $this->_setWidth();
        } else {
            $this->_newline = '<br />';
            $this->_space = '&nbsp;';
        }
        $this->_indent = str_repeat($this->_space, 4);

        if (isset($opts['output'])) {
            $this->_output = $opts['output'];
        } elseif (defined('STDOUT')) {
            $this->_output = STDOUT;
            if (!empty($opts['pager'])) {
                $this->_detectPager();
            }
        } else {
            $this->_output = fopen('php://output', 'w');
        }

        // We really want to call this at the end of the script, not in the
        // destructor.
        if ($console) {
            register_shutdown_function(array($this, 'shutdown'));
        }
    }

    /**
     * Retuns the detected terminal screen width.
     *
     * Defaults to 80 if the width cannot be detected automatically.
     *
     * @since Horde_Cli 2.2.0
     *
     * @return integer  The terminal screen width or null if not a terminal.
     */
    public function getWidth()
    {
        return $this->_width;
    }

    /**
     * Prints $text on a single line.
     *
     * @param string $text  The text to print.
     * @param boolean $pre  If true the linebreak is printed before
     *                      the text instead of after it.
     */
    public function writeln($text = '', $pre = false)
    {
        if ($pre) {
            fwrite($this->_output, $this->_newline . $text);
        } else {
            fwrite($this->_output, $text . $this->_newline);
        }
    }

    /**
     * Clears the entire screen, if possible.
     */
    public function clearScreen()
    {
        fwrite($this->_output, $this->_clearscreen);
    }

    /**
     * Returns the indented string.
     *
     * @param string $text  The text to indent.
     *
     * @return string  The indented text.
     */
    public function indent($text)
    {
        return $this->_indent . $text;
    }

    /**
     * Returns a bold version of $text.
     *
     * @deprecated Use Horde_Cli_Color instead.
     *
     * @param string $text  The text to bold.
     *
     * @return string  The bolded text.
     */
    public function bold($text)
    {
        return $this->_color->bold($text);
    }

    /**
     * Returns a colored version of $text.
     *
     * @since Horde_Cli 2.1.0
     *
     * @param string $color  The color to use. @see $_foregroundColors
     * @param string $text   The text to print in this color.
     *
     * @return string  The colored text.
     */
    public function color($color, $text)
    {
        return $this->_color->color($color, $text);
    }

    /**
     * Returns a red version of $text.
     *
     * @deprecated Use Horde_Cli_Color or color() instead.
     *
     * @param string $text  The text to print in red.
     *
     * @return string  The red text.
     */
    public function red($text)
    {
        return $this->_color->red($text);
    }

    /**
     * Returns a green version of $text.
     *
     * @deprecated Use Horde_Cli_Color or color() instead.
     *
     * @param string $text  The text to print in green.
     *
     * @return string  The green text.
     */
    public function green($text)
    {
        return $this->_color->green($text);
    }

    /**
     * Returns a blue version of $text.
     *
     * @deprecated Use Horde_Cli_Color or color() instead.
     *
     * @param string $text  The text to print in blue.
     *
     * @return string  The blue text.
     */
    public function blue($text)
    {
        return $this->_color->blue($text);
    }

    /**
     * Returns a yellow version of $text.
     *
     * @deprecated Use Horde_Cli_Color or color() instead.
     *
     * @param string $text  The text to print in yellow.
     *
     * @return string  The yellow text.
     */
    public function yellow($text)
    {
        return $this->_color->yellow($text);
    }

    /**
     * Creates a header from a string by drawing character lines above or below
     * the header content.
     *
     * @since Horde_Cli 2.1.0
     *
     * @param string $message  A message to turn into a header.
     * @param string $below    Character to use for drawing the line below the
     *                         message.
     * @param string $above    Character to use for drawing the line above the
     *                         message.
     */
    public function header($message, $below = '-', $above = null)
    {
        $length = 0;
        foreach (explode($this->_newline, $this->_color->remove($message)) as $line) {
            $length = max($length, Horde_String::length($line));
        }
        if ($width = $this->getWidth()) {
            $length = min($length, $width);
        }
        if (strlen($above)) {
            $this->writeln(str_repeat($above, $length));
        }
        $this->writeln(wordwrap($message, $length, $this->_newline));
        if (strlen($below)) {
            $this->writeln(str_repeat($below, $length));
        }
    }

    /**
     * Displays a message.
     *
     * @param string $event  The message string.
     * @param string $type   The type of message: 'cli.error', 'cli.warning',
     *                       'cli.success', or 'cli.message'.
     */
    public function message($message, $type = 'cli.message')
    {
        if ($width = $this->getWidth()) {
            $indent = $this->_newline . str_repeat($this->_space, 11);
            $message = wordwrap(
                str_replace($this->_newline, $indent, $message),
                $width - 12,
                $indent,
                true
            );
        }

        switch ($type) {
        case 'cli.error':
            $this->writeln(
                $this->_color->lightgray(
                    $this->block(
                        '[' . $this->_space . 'ERROR!' . $this->_space . '] '
                            . $message,
                        'red'
                    )
                )
            );
            break;

        case 'cli.warning':
            $this->writeln(
                $this->_color->black(
                    $this->block(
                        '[' . $this->_space . $this->_space . 'WARN'
                            . $this->_space . $this->_space . '] '
                            . $message,
                        'brown'
                    )
                )
            );
            break;

        case 'cli.success':
            $this->writeln(
                $this->_color->black(
                    $this->block(
                        '[' . $this->_space . $this->_space . $this->_space
                            . 'OK' . $this->_space . $this->_space
                            . $this->_space . '] '
                            . $message,
                        'green'
                    )
                )
            );
            break;

        case 'cli.message':
            $this->writeln(
                $this->_color->lightgray(
                    $this->block(
                        '[' . $this->_space . $this->_space . 'INFO'
                            . $this->_space . $this->_space . '] '
                            . $message,
                        'blue'
                    )
                )
            );
            break;

        default:
            $this->writeln($message);
            break;
        }
    }

    /**
     * Displays a fatal error message.
     *
     * @param mixed $error  The error text to display, an exception or an
     *                      object with a getMessage() method.
     */
    public function fatal($error)
    {
        if ($error instanceof Throwable ||
            $error instanceof Exception) {
            $trace = $error;
        } else {
            $trace = debug_backtrace();
        }
        $backtrace = new Horde_Support_Backtrace($trace);
        $details = null;
        if (is_object($error)) {
            $tmp = $error;
            while (!isset($tmp->details) && isset($tmp->previous)) {
                $tmp = $tmp->previous;
            }
            if (isset($tmp->details)) {
                $details = $tmp->details;
            }
        }
        $location = '';
        if (is_object($error) && method_exists($error, 'getMessage')) {
            $first = $error;
            while (method_exists($first, 'getPrevious') &&
                   $previous = $first->getPrevious()) {
                $first = $previous;
            }
            $file = method_exists($first, 'getFile') ? $first->getFile() : null;
            $line = method_exists($first, 'getLine') ? $first->getLine() : null;
            if ($file) {
                $location .= sprintf(Horde_Cli_Translation::t("In %s"), $file);
            }
            if ($line) {
                $location .= sprintf(Horde_Cli_Translation::t(" on line %d"), $line);
            }
            $error = $error->getMessage();
        }

        $lines = array('');
        $lines = $this->_addLines(
            $lines, Horde_Cli_Translation::t("Fatal Error:")
        );
        $lines = $this->_addLines($lines, $error);
        if ($details) {
            $this->_addLines($lines, print_r($details, true));
        }
        if ($location) {
            $lines = $this->_addLines($lines, $location);
        }
        $lines = $this->_addLines($lines, '');
        $lines = $this->_addLines($lines, (string)$backtrace);
        $lines = $this->_addLines($lines, '');
        $this->writeln(
            $this->_color->lightgray(
                $this->block(implode($this->_newline, $lines), 'red', '  ')
            )
        );
        exit(1);
    }

    /**
     * Adds content to a line buffer, wrapping long lines if necessary.
     *
     * @param array $buffer    The line buffer.
     * @param string $content  The lines to add.
     *
     * @return string  The updated line buffer.
     */
    protected function _addLines($buffer, $content)
    {
        $width = max(0, $this->getWidth() - 7);
        if ($width) {
            foreach (explode($this->_newline, $content) as $line) {
                $buffer[] = wordwrap($line, $width, "\n   ", true);
            }
        } else {
            $buffer[] = $content;
        }
        return $buffer;
    }

    /**
     * Formats text in a visual block with optional margin.
     *
     * @since Horde_Cli 2.2.0
     *
     * @param string $text    The block text.
     * @param string $color   The background color.
     * @param string $margin  The block margin string.
     * @param integer $width  The block width.
     *
     * @return string  The formatted block.
     */
    public function block($text, $color, $margin = '', $width = null)
    {
        $text = explode($this->_newline, $text);
        if (!$width) {
            $width = 0;
            foreach ($text as $line) {
                $width = max($width, strlen($line));
            }
            if ($maxWidth = $this->getWidth()) {
                $width = min($width, $maxWidth - 2 * strlen($margin));
            }
        }
        foreach ($text as &$line) {
            $line = $this->_color->background(
                $color,
                $margin . sprintf('%-' . $width . 's', $line) . $margin
            );
        }
        return implode($this->_newline, $text);
    }

    /**
     * Prompts for a user response.
     *
     * @todo Horde 5: switch $choices and $default
     *
     * @param string $prompt   The message to display when prompting the user.
     * @param array $choices   The choices available to the user or null for a
     *                         text input.
     * @param string $default  The default value if no value specified.
     *
     * @return mixed  The user's response to the prompt.
     */
    public function prompt($prompt, $choices = null, $default = null)
    {
        $width = $this->getWidth();

        if (!is_array($choices) || empty($choices)) {
            if ($default !== null) {
                $prompt .= ' [' . $default . ']';
            }
            if ($width) {
                $prompt = wordwrap($prompt, $width);
            }
            $this->writeln($prompt . ' ', true);
            @ob_flush();
            $response = trim(fgets(STDIN));
            if ($response === '' && $default !== null) {
                $response = $default;
            }
            return $response;
        }

        if ($width) {
            $prompt = wordwrap($prompt, $width);
        }

        // Main event loop to capture top level command.
        while (true) {
            // Print out the prompt message.
            $this->writeln($prompt . ' ', !is_array($choices));
            foreach ($choices as $key => $choice) {
                $this->writeln(
                    $this->indent('(' . $this->_color->bold($key) . ') ' . $choice)
                );
            }
            $question = Horde_Cli_Translation::t("Type your choice");
            if ($default !== null) {
                $question .= ' [' . $default . ']';
            }
            $this->writeln($question . ': ', true);
            @ob_flush();

            // Get the user choice.
            $response = trim(fgets(STDIN));
            if ($response === '' && $default !== null) {
                $response = $default;
            }
            if (isset($choices[$response])) {
                return $response;
            } else {
                $this->writeln($this->_color->red(sprintf(
                    Horde_Cli_Translation::t(
                        "\"%s\" is not a valid choice."
                    ),
                    $response
                )));
            }
        }
    }

    /**
     * Interactively prompts for input without echoing to the terminal.
     * Requires a bash shell or Windows and won't work with safe_mode settings
     * (uses shell_exec).
     *
     * From: http://www.sitepoint.com/blogs/2009/05/01/interactive-cli-password-prompt-in-php/
     *
     * @param string $prompt  The message to display when prompting the user.
     *
     * @return string  The user's response to the prompt.
     */
    public function passwordPrompt($prompt)
    {
        $prompt .= ' ';

        if (preg_match('/^win/i', PHP_OS)) {
            $vbscript = sys_get_temp_dir() . 'prompt_password.vbs';
            file_put_contents(
                $vbscript,
                'wscript.echo(InputBox("' . addslashes($prompt)
                . '", "", "password here"))'
            );
            $command = "cscript //nologo " . escapeshellarg($vbscript);
            $password = rtrim(shell_exec($command));
            unlink($vbscript);
        } else {
            $command = '/usr/bin/env bash -c "echo OK"';
            if (rtrim(shell_exec($command)) !== 'OK') {
                /* Cannot spawn shell, fall back to standard prompt. */
                return $this->prompt($prompt);
            }
            $command = '/usr/bin/env bash -c "read -s -p '
                . escapeshellarg($prompt) . ' mypassword && echo \$mypassword"';
            $password = rtrim(shell_exec($command));
            fwrite($this->_output, $this->_newline);
        }

        return $password;
    }

    /**
     * Reads everything that is sent through standard input and returns it as a
     * single string.
     *
     * @return string  The contents of the standard input.
     */
    public function readStdin()
    {
        $in = '';
        while (!feof(STDIN)) {
            $in .= fgets(STDIN, 1024);
        }
        return $in;
    }

    /**
     * CLI scripts shouldn't timeout, so try to set the time limit to
     * none. Also initialize a few variables in $_SERVER that aren't present
     * from the CLI.
     *
     * @param array $opts  Configuration options:
     *                     - 'pager': (boolean) Pipe output through a pager?
     *                                @since 2.3.0
     *
     * @return Horde_Cli  A Horde_Cli instance.
     */
    public static function init(array $opts = array())
    {
        /* Run constructor now because it requires $_SERVER['SERVER_NAME'] to
         * be empty if called with a CGI SAPI. */
        $cli = new static($opts);

        @set_time_limit(0);
        ob_implicit_flush(true);
        ini_set('html_errors', false);
        set_exception_handler(array($cli, 'fatal'));
        if (!isset($_SERVER['HTTP_HOST'])) {
            $_SERVER['HTTP_HOST'] = '127.0.0.1';
        }
        if (!isset($_SERVER['SERVER_NAME'])) {
            $_SERVER['SERVER_NAME'] = '127.0.0.1';
        }
        if (!isset($_SERVER['SERVER_PORT'])) {
            $_SERVER['SERVER_PORT'] = '';
        }
        if (!isset($_SERVER['REMOTE_ADDR'])) {
            $_SERVER['REMOTE_ADDR'] = '';
        }
        $_SERVER['PHP_SELF'] = isset($argv) ? $argv[0] : '';
        if (!defined('STDIN')) {
            define('STDIN', fopen('php://stdin', 'r'));
        }
        if (!defined('STDOUT')) {
            define('STDOUT', fopen('php://stdout', 'r'));
        }
        if (!defined('STDERR')) {
            define('STDERR', fopen('php://stderr', 'r'));
        }

        return $cli;
    }

    /**
     * Make sure we're being called from the command line, and not via
     * the web.
     *
     * @return boolean  True if we are, false otherwise.
     */
    public static function runningFromCLI()
    {
        return (PHP_SAPI == 'cli') ||
               (((PHP_SAPI == 'cgi') || (PHP_SAPI == 'cgi-fcgi')) &&
                empty($_SERVER['SERVER_NAME']));
    }

    /**
     * Destroys any session on script end.
     *
     * @todo Rely on session_status() in H6.
     */
    public function shutdown()
    {
        if ((function_exists('session_status') &&
             session_status() == PHP_SESSION_ACTIVE) ||
            (!function_exists('session_status') &&
             session_id())) {
            session_destroy();
        }
    }

    /**
     * Detects the terminal screen width.
     */
    protected function _setWidth()
    {
        $this->_width = getenv('COLUMNS');
        if (!$this->_width) {
            $this->_width = @exec('tput cols 2> /dev/null');
        }
        if (!$this->_width) {
            $size = explode(' ', @exec('stty size 2> /dev/null'));
            if (count($size) == 2) {
                $this->_width = $size[1];
            }
        }
        if (!$this->_width) {
            $this->_width = 80;
        }
    }

    /**
     * Detects available pagers (less, more) and creates pipes to output
     * through them.
     */
    protected function _detectPager()
    {
        $paths = array_unique(array_merge(
            explode(':', getenv('PATH')),
            array(
                '/usr/local/sbin',
                '/usr/local/bin',
                '/usr/sbin',
                '/usr/bin',
                '/sbin',
                '/bin'
            )
        ));
        $pager = null;
        foreach (array('less', 'more') as $cmd) {
            foreach ($paths as $path) {
                if (is_executable($path . '/' . $cmd)) {
                    $pager = $path . '/' . $cmd;
                    break 2;
                }
            }
        }
        if (!$pager) {
            return;
        }
        $help = shell_exec($pager . ' --help');
        if (!$help) {
            return;
        }
        foreach (array('--RAW-CONTROL-CHARS', '--raw-control-chars') as $opt) {
            if (strpos($help, $opt)) {
                $pager .= ' ' . $opt;
                break;
            }
        }
        if (strpos($help, '--no-init')) {
            $pager .= ' --no-init';
        }
        if (strpos($help, '--quit-if-one-screen')) {
            $pager .= ' --quit-if-one-screen';
        }
        $this->_output = popen($pager, 'w');
    }
}
