<?php
namespace mikehaertl\shellcommand;

/**
 * Command
 *
 * This class represents a shell command.
 *
 * @author Michael Härtl <haertl.mike@gmail.com>
 * @license http://www.opensource.org/licenses/MIT
 */
class Command
{
    /**
     * @var bool whether to escape any argument passed through `addArg()`.
     * Default is `true`.
     */
    public $escapeArgs = true;

    /**
     * @var bool whether to escape the command passed to `setCommand()` or the
     * constructor.  This is only useful if `$escapeArgs` is `false`. Default
     * is `false`.
     */
    public $escapeCommand = false;

    /**
     * @var bool whether to use `exec()` instead of `proc_open()`. This can be
     * used on Windows system to workaround some quirks there. Note, that any
     * errors from your command will be output directly to the PHP output
     * stream. `getStdErr()` will also not work anymore and thus you also won't
     * get the error output from `getError()` in this case. You also can't pass
     * any environment variables to the command if this is enabled. Default is
     * `false`.
     */
    public $useExec = false;

    /**
     * @var bool whether to capture stderr (2>&1) when `useExec` is true. This
     * will try to redirect the stderr to stdout and provide the complete
     * output of both in `getStdErr()` and `getError()`.  Default is `true`.
     */
    public $captureStdErr = true;

    /**
     * @var string|null the initial working dir for `proc_open()`. Default is
     * `null` for current PHP working dir.
     */
    public $procCwd;

    /**
     * @var array|null an array with environment variables to pass to
     * `proc_open()`. Default is `null` for none.
     */
    public $procEnv;

    /**
     * @var array|null an array of other_options for `proc_open()`. Default is
     * `null` for none.
     */
    public $procOptions;

    /**
     * @var bool|null whether to set the stdin/stdout/stderr streams to
     * non-blocking mode when `proc_open()` is used. This allows to have huge
     * inputs/outputs without making the process hang. The default is `null`
     * which will enable the feature on Non-Windows systems. Set it to `true`
     * or `false` to manually enable/disable it. It does not work on Windows.
     */
    public $nonBlockingMode;

    /**
     * @var int the time in seconds after which a command should be terminated.
     * This only works in non-blocking mode. Default is `null` which means the
     * process is never terminated.
     */
    public $timeout;

    /**
     * @var null|string the locale to temporarily set before calling
     * `escapeshellargs()`. Default is `null` for none.
     */
    public $locale;

    /**
     * @var null|string|resource to pipe to standard input
     */
    protected $_stdIn;

    /**
     * @var string the command to execute
     */
    protected $_command;

    /**
     * @var array the list of command arguments
     */
    protected $_args = array();

    /**
     * @var string the full command string to execute
     */
    protected $_execCommand;

    /**
     * @var string the stdout output
     */
    protected $_stdOut = '';

    /**
     * @var string the stderr output
     */
    protected $_stdErr = '';

    /**
     * @var int the exit code
     */
    protected $_exitCode;

    /**
     * @var string the error message
     */
    protected $_error = '';

    /**
     * @var bool whether the command was successfully executed
     */
    protected $_executed = false;

    /**
     * @param string|array $options either a command string or an options array
     * @see setOptions
     */
    public function __construct($options = null)
    {
        if (is_array($options)) {
            $this->setOptions($options);
        } elseif (is_string($options)) {
            $this->setCommand($options);
        }
    }

    /**
     * @param array $options array of name => value options that should be
     * applied to the object You can also pass options that use a setter, e.g.
     * you can pass a `fileName` option which will be passed to
     * `setFileName()`.
     * @throws \Exception
     * @return static for method chaining
     */
    public function setOptions($options)
    {
        foreach ($options as $key => $value) {
            if (property_exists($this, $key)) {
                $this->$key = $value;
            } else {
                $method = 'set'.ucfirst($key);
                if (method_exists($this, $method)) {
                    call_user_func(array($this,$method), $value);
                } else {
                    throw new \Exception("Unknown configuration option '$key'");
                }
            }
        }
        return $this;
    }

    /**
     * @param string $command the command or full command string to execute,
     * like 'gzip' or 'gzip -d'.  You can still call addArg() to add more
     * arguments to the command. If $escapeCommand was set to true, the command
     * gets escaped with escapeshellcmd().
     * @return static for method chaining
     */
    public function setCommand($command)
    {
        if ($this->escapeCommand) {
            $command = escapeshellcmd($command);
        }
        if ($this->getIsWindows()) {
            // Make sure to switch to correct drive like "E:" first if we have
            // a full path in command
            if (isset($command[1]) && $command[1]===':') {
                $position = 1;
                // Could be a quoted absolute path because of spaces.
                // i.e. "C:\Program Files (x86)\file.exe"
            } elseif (isset($command[2]) && $command[2]===':') {
                $position = 2;
            } else {
                $position = false;
            }

            // Absolute path. If it's a relative path, let it slide.
            if ($position) {
                $command = sprintf(
                    $command[$position - 1] . ': && cd %s && %s',
                    escapeshellarg(dirname($command)),
                    escapeshellarg(basename($command))
                );
            }
        }
        $this->_command = $command;
        return $this;
    }

    /**
     * @param string|resource $stdIn If set, the string will be piped to the
     * command via standard input. This enables the same functionality as
     * piping on the command line. It can also be a resource like a file
     * handle or a stream in which case its content will be piped into the
     * command like an input redirection.
     * @return static for method chaining
     */
    public function setStdIn($stdIn) {
        $this->_stdIn = $stdIn;
        return $this;
    }

    /**
     * @return string|null the command that was set through setCommand() or
     * passed to the constructor. `null` if none.
     */
    public function getCommand()
    {
        return $this->_command;
    }

    /**
     * @return string|bool the full command string to execute. If no command
     * was set with setCommand() or passed to the constructor it will return
     * `false`.
     */
    public function getExecCommand()
    {
        if ($this->_execCommand===null) {
            $command = $this->getCommand();
            if (!$command) {
                $this->_error = 'Could not locate any executable command';
                return false;
            }
            $args = $this->getArgs();
            $this->_execCommand = $args ? $command.' '.$args : $command;
        }
        return $this->_execCommand;
    }

    /**
     * @param string $args the command arguments as string. Note that these
     * will not get escaped!
     * @return static for method chaining
     */
    public function setArgs($args)
    {
        $this->_args = array($args);
        return $this;
    }

    /**
     * @return string the command args that where set with setArgs() or added
     * with addArg() separated by spaces
     */
    public function getArgs()
    {
        return implode(' ', $this->_args);
    }

    /**
     * @param string $key the argument key to add e.g. `--feature` or
     * `--name=`. If the key does not end with and `=`, the $value will be
     * separated by a space, if any. Keys are not escaped unless $value is null
     * and $escape is `true`.
     * @param string|array|null $value the optional argument value which will
     * get escaped if $escapeArgs is true.  An array can be passed to add more
     * than one value for a key, e.g. `addArg('--exclude',
     * array('val1','val2'))` which will create the option `'--exclude' 'val1'
     * 'val2'`.
     * @param bool|null $escape if set, this overrides the $escapeArgs setting
     * and enforces escaping/no escaping
     * @return static for method chaining
     */
    public function addArg($key, $value = null, $escape = null)
    {
        $doEscape = $escape !== null ? $escape : $this->escapeArgs;
        $useLocale = $doEscape && $this->locale !== null;

        if ($useLocale) {
            $locale = setlocale(LC_CTYPE, 0);   // Returns current locale setting
            setlocale(LC_CTYPE, $this->locale);
        }
        if ($value === null) {
            $this->_args[] = $doEscape ? escapeshellarg($key) : $key;
        } else {
            if (substr($key, -1) === '=') {
                $separator = '=';
                $argKey = substr($key, 0, -1);
            } else {
                $separator = ' ';
                $argKey = $key;
            }
            $argKey = $doEscape ? escapeshellarg($argKey) : $argKey;

            if (is_array($value)) {
                $params = array();
                foreach ($value as $v) {
                    $params[] = $doEscape ? escapeshellarg($v) : $v;
                }
                $this->_args[] = $argKey . $separator . implode(' ', $params);
            } else {
                $this->_args[] = $argKey . $separator .
                    ($doEscape ? escapeshellarg($value) : $value);
            }
        }
        if ($useLocale) {
            setlocale(LC_CTYPE, $locale);
        }

        return $this;
    }

    /**
     * @param bool $trim whether to `trim()` the return value. The default is `true`.
     * @return string the command output (stdout). Empty if none.
     */
    public function getOutput($trim = true)
    {
        return $trim ? trim($this->_stdOut) : $this->_stdOut;
    }

    /**
     * @param bool $trim whether to `trim()` the return value. The default is `true`.
     * @return string the error message, either stderr or an internal message.
     * Empty string if none.
     */
    public function getError($trim = true)
    {
        return $trim ? trim($this->_error) : $this->_error;
    }

    /**
     * @param bool $trim whether to `trim()` the return value. The default is `true`.
     * @return string the stderr output. Empty if none.
     */
    public function getStdErr($trim = true)
    {
        return $trim ? trim($this->_stdErr) : $this->_stdErr;
    }

    /**
     * @return int|null the exit code or null if command was not executed yet
     */
    public function getExitCode()
    {
        return $this->_exitCode;
    }

    /**
     * @return string whether the command was successfully executed
     */
    public function getExecuted()
    {
        return $this->_executed;
    }

    /**
     * Execute the command
     *
     * @return bool whether execution was successful. If `false`, error details
     * can be obtained from getError(), getStdErr() and getExitCode().
     */
    public function execute()
    {
        $command = $this->getExecCommand();

        if (!$command) {
            return false;
        }

        if ($this->useExec) {
            $execCommand = $this->captureStdErr ? "$command 2>&1" : $command;
            exec($execCommand, $output, $this->_exitCode);
            $this->_stdOut = implode("\n", $output);
            if ($this->_exitCode !== 0) {
                $this->_stdErr = $this->_stdOut;
                $this->_error = empty($this->_stdErr) ? 'Command failed' : $this->_stdErr;
                return false;
            }
        } else {
            $isInputStream = $this->_stdIn !== null &&
                is_resource($this->_stdIn) &&
                in_array(get_resource_type($this->_stdIn), array('file', 'stream'));
            $isInputString = is_string($this->_stdIn);
            $hasInput = $isInputStream || $isInputString;
            $hasTimeout = $this->timeout !== null && $this->timeout > 0;

            $descriptors = array(
                1   => array('pipe','w'),
                2   => array('pipe', $this->getIsWindows() ? 'a' : 'w'),
            );
            if ($hasInput) {
                $descriptors[0] = array('pipe', 'r');
            }


            // Issue #20 Set non-blocking mode to fix hanging processes
            $nonBlocking = $this->nonBlockingMode === null ?
                !$this->getIsWindows() : $this->nonBlockingMode;

            $startTime = $hasTimeout ? time() : 0;
            $process = proc_open($command, $descriptors, $pipes, $this->procCwd, $this->procEnv, $this->procOptions);

            if (is_resource($process)) {

                if ($nonBlocking) {
                    stream_set_blocking($pipes[1], false);
                    stream_set_blocking($pipes[2], false);
                    if ($hasInput) {
                        $writtenBytes = 0;
                        $isInputOpen = true;
                        stream_set_blocking($pipes[0], false);
                        if ($isInputStream) {
                            stream_set_blocking($this->_stdIn, false);
                        }
                    }

                    // Due to the non-blocking streams we now have to check in
                    // a loop if the process is still running. We also need to
                    // ensure that all the pipes are written/read alternately
                    // until there's nothing left to write/read.
                    $isRunning = true;
                    while ($isRunning) {
                        $status = proc_get_status($process);
                        $isRunning = $status['running'];

                        // We first write to stdIn if we have an input. For big
                        // inputs it will only write until the input buffer of
                        // the command is full (the command may now wait that
                        // we read the output buffers - see below). So we may
                        // have to continue writing in another cycle.
                        //
                        // After everything is written it's safe to close the
                        // input pipe.
                        if ($isRunning && $hasInput && $isInputOpen) {
                            if ($isInputStream) {
                                $written = stream_copy_to_stream($this->_stdIn, $pipes[0], 16 * 1024, $writtenBytes);
                                if ($written === false || $written === 0) {
                                    $isInputOpen = false;
                                    fclose($pipes[0]);
                                } else {
                                    $writtenBytes += $written;
                                }
                            } else {
                                if ($writtenBytes < strlen($this->_stdIn)) {
                                    $writtenBytes += fwrite($pipes[0], substr($this->_stdIn, $writtenBytes));
                                } else {
                                    $isInputOpen = false;
                                    fclose($pipes[0]);
                                }
                            }
                        }

                        // Read out the output buffers because if they are full
                        // the command may block execution. We do this even if
                        // $isRunning is `false`, because there could be output
                        // left in the buffers.
                        //
                        // The latter is only an assumption and needs to be
                        // verified - but it does not hurt either and works as
                        // expected.
                        //
                        while (($out = fgets($pipes[1])) !== false) {
                            $this->_stdOut .= $out;
                        }
                        while (($err = fgets($pipes[2])) !== false) {
                            $this->_stdErr .= $err;
                        }

                        $runTime = $hasTimeout ? time() - $startTime : 0;
                        if ($isRunning && $hasTimeout && $runTime >= $this->timeout) {
                            // Only send a SIGTERM and handle status in the next cycle
                            proc_terminate($process);
                        }

                        if (!$isRunning) {
                            $this->_exitCode = $status['exitcode'];
                            if ($this->_exitCode !== 0 && empty($this->_stdErr)) {
                                if ($status['stopped']) {
                                    $signal = $status['stopsig'];
                                    $this->_stdErr = "Command stopped by signal $signal";
                                } elseif ($status['signaled']) {
                                    $signal = $status['termsig'];
                                    $this->_stdErr = "Command terminated by signal $signal";
                                } else {
                                    $this->_stdErr = 'Command unexpectedly terminated without error message';
                                }
                            }
                            fclose($pipes[1]);
                            fclose($pipes[2]);
                            proc_close($process);
                        } else {
                            // The command is still running. Let's wait some
                            // time before we start the next cycle.
                            usleep(10000);
                        }
                    }
                } else {
                    if ($hasInput) {
                        if ($isInputStream) {
                            stream_copy_to_stream($this->_stdIn, $pipes[0]);
                        } elseif ($isInputString) {
                            fwrite($pipes[0], $this->_stdIn);
                        }
                        fclose($pipes[0]);
                    }
                    $this->_stdOut = stream_get_contents($pipes[1]);
                    $this->_stdErr = stream_get_contents($pipes[2]);
                    fclose($pipes[1]);
                    fclose($pipes[2]);
                    $this->_exitCode = proc_close($process);
                }

                if ($this->_exitCode !== 0) {
                    $this->_error = $this->_stdErr ?
                        $this->_stdErr :
                        "Failed without error message: $command (Exit code: {$this->_exitCode})";
                    return false;
                }
            } else {
                $this->_error = "Could not run command $command";
                return false;
            }
        }

        $this->_executed = true;

        return true;
    }

    /**
     * @return bool whether we are on a Windows OS
     */
    public function getIsWindows()
    {
        return strncasecmp(PHP_OS, 'WIN', 3)===0;
    }

    /**
     * @return string the current command string to execute
     */
    public function __toString()
    {
        return (string) $this->getExecCommand();
    }
}
