<?php

/**
 * @see       https://github.com/laminas/laminas-console for the canonical source repository
 * @copyright https://github.com/laminas/laminas-console/blob/master/COPYRIGHT.md
 * @license   https://github.com/laminas/laminas-console/blob/master/LICENSE.md New BSD License
 */

namespace Laminas\Console\Adapter;

use Laminas\Console\Charset;
use Laminas\Console\Charset\CharsetInterface;
use Laminas\Console\Exception;

/**
 * MS Windows with ANSICON console adapter
 *
 * This adapter requires ANSICON extension to be installed. It can be obtained from:
 *      https://github.com/adoxa/ansicon
 *
 * ANSICON has to be loaded and enabled before using this adapter. It's best to install
 * it using following command:
 *      ansicon -I
 *
 * Console should not run in UTF8 code page (65001), because ANSICON does not behave well with it.
 * It's best to use non-unicode code page 437, 850, 851, 852 or similar. Run "help mode" for more
 * information on how to change Windows console code page.
 */
class WindowsAnsicon extends Posix
{
    /**
     * Whether or not mbstring is enabled
     *
     * @var null|bool
     */
    protected static $hasMBString;

    /**
     * Results of mode command
     *
     * @var mixed
     */
    protected $modeResult;

    /**
     * Determine and return current console width.
     *
     * @return int
     */
    public function getWidth()
    {
        static $width;
        if ($width > 0) {
            return $width;
        }

        // Try to read console size from ANSICON env var
        if (preg_match('/\((\d+)x/', getenv('ANSICON'), $matches)) {
            $width = $matches[1];
        } else {
            $width = AbstractAdapter::getWidth();
        }

        return $width;
    }

    /**
     * Determine and return current console height.
     *
     * @return false|int
     */
    public function getHeight()
    {
        static $height;
        if ($height > 0) {
            return $height;
        }

        // Try to read console size from ANSICON env var
        if (preg_match('/\(\d+x(\d+)/', getenv('ANSICON'), $matches)) {
            $height = $matches[1];
        } else {
            $height = AbstractAdapter::getHeight();
        }
        return $height;
    }

    /**
     * Run and cache results of mode command
     *
     * @return void
     */
    protected function runModeCommand()
    {
        exec('mode', $output, $return);
        if ($return || ! count($output)) {
            $this->modeResult = '';
        } else {
            $this->modeResult = trim(implode('', $output));
        }
    }

    /**
     * Check if console is UTF-8 compatible
     *
     * @return bool
     */
    public function isUtf8()
    {
        // Try to read code page info from "mode" command
        if ($this->modeResult === null) {
            $this->runModeCommand();
        }

        if (preg_match('/Code page\:\s+(\d+)/', $this->modeResult, $matches)) {
            return (int) $matches[1] == 65001;
        }

        return false;
    }

    /**
     * Return current console window title.
     *
     * @return string
     */
    public function getTitle()
    {
        // Try to use powershell to retrieve console window title
        exec('powershell -command "write $Host.UI.RawUI.WindowTitle"', $output, $result);
        if ($result || ! $output) {
            return '';
        }

        return trim($output, "\r\n");
    }

    /**
     * Clear console screen
     */
    public function clear()
    {
        echo chr(27) . '[1J' . chr(27) . '[u';
    }

    /**
     * Clear line at cursor position
     */
    public function clearLine()
    {
        echo chr(27) . '[1K';
    }

    /**
     * Set Console charset to use.
     *
     * @param CharsetInterface $charset
     */
    public function setCharset(CharsetInterface $charset)
    {
        $this->charset = $charset;
    }

    /**
     * Get charset currently in use by this adapter.
     *
     * @return CharsetInterface $charset
     */
    public function getCharset()
    {
        if ($this->charset === null) {
            $this->charset = $this->getDefaultCharset();
        }

        return $this->charset;
    }

    /**
     * @return Charset\AsciiExtended
     */
    public function getDefaultCharset()
    {
        return new Charset\AsciiExtended();
    }

    /**
     * Read a single character from the console input
     *
     * @param  string|null $mask A list of allowed chars
     * @return string
     * @throws Exception\RuntimeException
     */
    public function readChar($mask = null)
    {
        // Decide if we can use `choice` tool
        $useChoice = $mask !== null && preg_match('/^[a-zA-Z0-9]+$/D', $mask);

        if ($useChoice) {
            // Use Windows 98+ "choice" command, which allows for reading a
            // single character matching a mask, but is limited to lower ASCII
            // range.
            do {
                exec('choice /n /cs /c:' . $mask, $output, $return);
                if ($return == 255 || $return < 1 || $return > strlen($mask)) {
                    throw new Exception\RuntimeException(
                        '"choice" command failed to run. Are you using Windows XP or newer?'
                    );
                }

                // Fetch the char from mask
                $char = substr($mask, $return - 1, 1);
            } while ("" === $char || ($mask !== null && false === strstr($mask, $char)));

            return $char;
        }

        // Try to use PowerShell, giving it console access. Because PowersShell
        // interpreter can take a short while to load, we are emptying the
        // whole keyboard buffer and picking the last key that has been pressed
        // before or after PowerShell command has started. The ASCII code for
        // that key is then converted to a character.
        if ($mask === null) {
            exec(
                'powershell -NonInteractive -NoProfile -NoLogo -OutputFormat Text -Command "'
                . 'while ($Host.UI.RawUI.KeyAvailable) {$key = $Host.UI.RawUI.ReadKey(\'NoEcho,IncludeKeyDown\');}'
                . 'write $key.VirtualKeyCode;'
                . '"',
                $result,
                $return
            );

            // Retrieve char from the result.
            $char = ! empty($result) ? implode('', $result) : null;

            if (! empty($char) && ! $return) {
                // We have obtained an ASCII code, convert back to a char ...
                $char = chr($char);

                // ... and return it...
                return $char;
            }
        } else {
            // Windows and DOS will return carriage-return char (ASCII 13) when
            // the user presses [ENTER] key, but Console Adapter user might
            // have provided a \n Newline (ASCII 10) in the mask, to allow
            // [ENTER].  We are going to replace all CR with NL to conform.
            $mask = strtr($mask, "\n", "\r");

            // Prepare a list of ASCII codes from mask chars
            $asciiMask = array_map(function ($char) {
                return ord($char);
            }, str_split($mask));
            $asciiMask = array_unique($asciiMask);

            // Char mask filtering is now handled by the PowerShell itself,
            // because it's a much faster method than invoking PS interpreter
            // after each mismatch. The command should return ASCII code of a
            // matching key.
            $result = $return = null;
            exec(
                'powershell -NonInteractive -NoProfile -NoLogo -OutputFormat Text -Command "'
                . '[int[]] $mask = '.implode(',', $asciiMask).';'
                . 'do {'
                . '$key = $Host.UI.RawUI.ReadKey(\'NoEcho,IncludeKeyDown\').VirtualKeyCode;'
                . '} while ( !($mask -contains $key) );'
                . 'write $key;'
                . '"',
                $result,
                $return
            );

            $char = ! empty($result) ? trim(implode('', $result)) : null;

            if (! $return && $char && ($mask === null || in_array($char, $asciiMask))) {
                // We have obtained an ASCII code, check if it is a carriage
                // return and normalize it as needed
                if ($char == 13) {
                    $char = 10;
                }

                // Convert to a character
                $char = chr($char);

                // ... and return it...
                return $char;
            }
        }

        // Fall back to standard input, which on Windows does not allow reading
        // a single character. This is a limitation of Windows streams
        // implementation (not PHP) and this behavior cannot be changed with a
        // command like "stty", known to POSIX systems.
        $stream = fopen('php://stdin', 'rb');
        do {
            $char = fgetc($stream);
            $char = substr(trim($char), 0, 1);
        } while (! $char || ($mask !== null && ! stristr($mask, $char)));
        fclose($stream);

        return $char;
    }
}
