<?php
/**
 * PHP Command Line Tools
 *
 * This source file is subject to the MIT license that is bundled
 * with this package in the file LICENSE.
 *
 * @author    James Logsdon <dwarf@girsbrain.org>
 * @copyright 2010 James Logsdom (http://girsbrain.org)
 * @license   http://www.opensource.org/licenses/mit-license.php The MIT License
 */

namespace cli;

use cli\arguments\Argument;
use cli\arguments\HelpScreen;
use cli\arguments\InvalidArguments;
use cli\arguments\Lexer;

/**
 * Parses command line arguments.
 */
class Arguments implements \ArrayAccess {
	protected $_flags = array();
	protected $_options = array();
	protected $_strict = false;
	protected $_input = array();
	protected $_invalid = array();
	protected $_parsed;
	protected $_lexer;

	/**
	 * Initializes the argument parser. If you wish to change the default behaviour
	 * you may pass an array of options as the first argument. Valid options are
	 * `'help'` and `'strict'`, each a boolean.
	 *
	 * `'help'` is `true` by default, `'strict'` is false by default.
	 *
	 * @param  array  $options  An array of options for this parser.
	 */
	public function __construct($options = array()) {
		$options += array(
			'strict' => false,
			'input'  => array_slice($_SERVER['argv'], 1)
		);

		$this->_input = $options['input'];
		$this->setStrict($options['strict']);

		if (isset($options['flags'])) {
			$this->addFlags($options['flags']);
		}
		if (isset($options['options'])) {
			$this->addOptions($options['options']);
		}
	}

	/**
	 * Get the list of arguments found by the defined definitions.
	 *
	 * @return array
	 */
	public function getArguments() {
		if (!isset($this->_parsed)) {
			$this->parse();
		}
		return $this->_parsed;
	}

	public function getHelpScreen() {
		return new HelpScreen($this);
	}

	/**
	 * Encodes the parsed arguments as JSON.
	 *
	 * @return string
	 */
	public function asJSON() {
		return json_encode($this->_parsed);
	}

	/**
	 * Returns true if a given argument was parsed.
	 *
	 * @param mixed  $offset  An Argument object or the name of the argument.
	 * @return bool
	 */
	#[\ReturnTypeWillChange]
	public function offsetExists($offset) {
		if ($offset instanceOf Argument) {
			$offset = $offset->key;
		}

		return array_key_exists($offset, $this->_parsed);
	}

	/**
	 * Get the parsed argument's value.
	 *
	 * @param mixed  $offset  An Argument object or the name of the argument.
	 * @return mixed
	 */
	#[\ReturnTypeWillChange]
	public function offsetGet($offset) {
		if ($offset instanceOf Argument) {
			$offset = $offset->key;
		}

		if (isset($this->_parsed[$offset])) {
			return $this->_parsed[$offset];
		}
	}

	/**
	 * Sets the value of a parsed argument.
	 *
	 * @param mixed  $offset  An Argument object or the name of the argument.
	 * @param mixed  $value   The value to set
	 */
	#[\ReturnTypeWillChange]
	public function offsetSet($offset, $value) {
		if ($offset instanceOf Argument) {
			$offset = $offset->key;
		}

		$this->_parsed[$offset] = $value;
	}

	/**
	 * Unset a parsed argument.
	 *
	 * @param mixed  $offset  An Argument object or the name of the argument.
	 */
	#[\ReturnTypeWillChange]
	public function offsetUnset($offset) {
		if ($offset instanceOf Argument) {
			$offset = $offset->key;
		}

		unset($this->_parsed[$offset]);
	}

	/**
	 * Adds a flag (boolean argument) to the argument list.
	 *
	 * @param mixed  $flag  A string representing the flag, or an array of strings.
	 * @param array  $settings  An array of settings for this flag.
	 * @setting string  description  A description to be shown in --help.
	 * @setting bool    default  The default value for this flag.
	 * @setting bool    stackable  Whether the flag is repeatable to increase the value.
	 * @setting array   aliases  Other ways to trigger this flag.
	 * @return $this
	 */
	public function addFlag($flag, $settings = array()) {
		if (is_string($settings)) {
			$settings = array('description' => $settings);
		}
		if (is_array($flag)) {
			$settings['aliases'] = $flag;
			$flag = array_shift($settings['aliases']);
		}
		if (isset($this->_flags[$flag])) {
			$this->_warn('flag already exists: ' . $flag);
			return $this;
		}

		$settings += array(
			'default'     => false,
			'stackable'   => false,
			'description' => null,
			'aliases'     => array()
		);

		$this->_flags[$flag] = $settings;
		return $this;
	}

	/**
	 * Add multiple flags at once. The input array should be keyed with the
	 * primary flag character, and the values should be the settings array
	 * used by {addFlag}.
	 *
	 * @param array  $flags  An array of flags to add
	 * @return $this
	 */
	public function addFlags($flags) {
		foreach ($flags as $flag => $settings) {
			if (is_numeric($flag)) {
				$this->_warn('No flag character given');
				continue;
			}

			$this->addFlag($flag, $settings);
		}

		return $this;
	}

	/**
	 * Adds an option (string argument) to the argument list.
	 *
	 * @param mixed  $option  A string representing the option, or an array of strings.
	 * @param array  $settings  An array of settings for this option.
	 * @setting string  description  A description to be shown in --help.
	 * @setting bool    default  The default value for this option.
	 * @setting array   aliases  Other ways to trigger this option.
	 * @return $this
	 */
	public function addOption($option, $settings = array()) {
		if (is_string($settings)) {
			$settings = array('description' => $settings);
		}
		if (is_array($option)) {
			$settings['aliases'] = $option;
			$option = array_shift($settings['aliases']);
		}
		if (isset($this->_options[$option])) {
			$this->_warn('option already exists: ' . $option);
			return $this;
		}

		$settings += array(
			'default'     => null,
			'description' => null,
			'aliases'     => array()
		);

		$this->_options[$option] = $settings;
		return $this;
	}

	/**
	 * Add multiple options at once. The input array should be keyed with the
	 * primary option string, and the values should be the settings array
	 * used by {addOption}.
	 *
	 * @param array  $options  An array of options to add
	 * @return $this
	 */
	public function addOptions($options) {
		foreach ($options as $option => $settings) {
			if (is_numeric($option)) {
				$this->_warn('No option string given');
				continue;
			}

			$this->addOption($option, $settings);
		}

		return $this;
	}

	/**
	 * Enable or disable strict mode. If strict mode is active any invalid
	 * arguments found by the parser will throw `cli\arguments\InvalidArguments`.
	 *
	 * Even if strict is disabled, invalid arguments are logged and can be
	 * retrieved with `cli\Arguments::getInvalidArguments()`.
	 *
	 * @param bool  $strict  True to enable, false to disable.
	 * @return $this
	 */
	public function setStrict($strict) {
		$this->_strict = (bool)$strict;
		return $this;
	}

	/**
	 * Get the list of invalid arguments the parser found.
	 *
	 * @return array
	 */
	public function getInvalidArguments() {
		return $this->_invalid;
	}

	/**
	 * Get a flag by primary matcher or any defined aliases.
	 *
	 * @param mixed  $flag  Either a string representing the flag or an
	 *                      cli\arguments\Argument object.
	 * @return array
	 */
	public function getFlag($flag) {
		if ($flag instanceOf Argument) {
			$obj  = $flag;
			$flag = $flag->value;
		}

		if (isset($this->_flags[$flag])) {
			return $this->_flags[$flag];
		}

		foreach ($this->_flags as $master => $settings) {
			if (in_array($flag, (array)$settings['aliases'])) {
				if (isset($obj)) {
					$obj->key = $master;
				}

				$cache[$flag] =& $settings;
				return $settings;
			}
		}
	}

	public function getFlags() {
		return $this->_flags;
	}

	public function hasFlags() {
		return !empty($this->_flags);
	}

	/**
	 * Returns true if the given argument is defined as a flag.
	 *
	 * @param mixed  $argument  Either a string representing the flag or an
	 *                          cli\arguments\Argument object.
	 * @return bool
	 */
	public function isFlag($argument) {
		return (null !== $this->getFlag($argument));
	}

	/**
	 * Returns true if the given flag is stackable.
	 *
	 * @param mixed  $flag  Either a string representing the flag or an
	 *                      cli\arguments\Argument object.
	 * @return bool
	 */
	public function isStackable($flag) {
		$settings = $this->getFlag($flag);

		return isset($settings) && (true === $settings['stackable']);
	}

	/**
	 * Get an option by primary matcher or any defined aliases.
	 *
	 * @param mixed  $option Either a string representing the option or an
	 *                       cli\arguments\Argument object.
	 * @return array
	 */
	public function getOption($option) {
		if ($option instanceOf Argument) {
			$obj = $option;
			$option = $option->value;
		}

		if (isset($this->_options[$option])) {
			return $this->_options[$option];
		}

		foreach ($this->_options as $master => $settings) {
			if (in_array($option, (array)$settings['aliases'])) {
				if (isset($obj)) {
					$obj->key = $master;
				}

				return $settings;
			}
		}
	}

	public function getOptions() {
		return $this->_options;
	}

	public function hasOptions() {
		return !empty($this->_options);
	}

	/**
	 * Returns true if the given argument is defined as an option.
	 *
	 * @param mixed  $argument  Either a string representing the option or an
	 *                          cli\arguments\Argument object.
	 * @return bool
	 */
	public function isOption($argument) {
		return (null != $this->getOption($argument));
	}

	/**
	 * Parses the argument list with the given options. The returned argument list
	 * will use either the first long name given or the first name in the list
	 * if a long name is not given.
	 *
	 * @return array
	 * @throws arguments\InvalidArguments
	 */
	public function parse() {
		$this->_invalid = array();
		$this->_parsed = array();
		$this->_lexer = new Lexer($this->_input);

		$this->_applyDefaults();

		foreach ($this->_lexer as $argument) {
			if ($this->_parseFlag($argument)) {
				continue;
			}
			if ($this->_parseOption($argument)) {
				continue;
			}

			array_push($this->_invalid, $argument->raw);
		}

		if ($this->_strict && !empty($this->_invalid)) {
			throw new InvalidArguments($this->_invalid);
		}
	}

	/**
	 * This applies the default values, if any, of all of the
	 * flags and options, so that if there is a default value
	 * it will be available.
	 */
	private function _applyDefaults() {
		foreach($this->_flags as $flag => $settings) {
			$this[$flag] = $settings['default'];
		}

		foreach($this->_options as $option => $settings) {
			// If the default is 0 we should still let it be set.
			if (!empty($settings['default']) || $settings['default'] === 0) {
				$this[$option] = $settings['default'];
			}
		}
	}

	private function _warn($message) {
		trigger_error('[' . __CLASS__ .'] ' . $message, E_USER_WARNING);
	}

	private function _parseFlag($argument) {
		if (!$this->isFlag($argument)) {
			return false;
		}

		if ($this->isStackable($argument)) {
			if (!isset($this[$argument])) {
				$this[$argument->key] = 0;
			}

			$this[$argument->key] += 1;
		} else {
			$this[$argument->key] = true;
		}

		return true;
	}

	private function _parseOption($option) {
		if (!$this->isOption($option)) {
			return false;
		}

		// Peak ahead to make sure we get a value.
		if ($this->_lexer->end() || !$this->_lexer->peek->isValue) {
			$optionSettings = $this->getOption($option->key);

			if (empty($optionSettings['default']) && $optionSettings !== 0) {
				// Oops! Got no value and no default , throw a warning and continue.
				$this->_warn('no value given for ' . $option->raw);
				$this[$option->key] = null;
			} else {
				// No value and we have a default, so we set to the default
				$this[$option->key] = $optionSettings['default'];
			}
			return true;
		}

		// Store as array and join to string after looping for values
		$values = array();

		// Loop until we find a flag in peak-ahead
		foreach ($this->_lexer as $value) {
			array_push($values, $value->raw);

			if (!$this->_lexer->end() && !$this->_lexer->peek->isValue) {
				break;
			}
		}

		$this[$option->key] = join(' ', $values);
		return true;
	}
}
