<?php


namespace Zend\Json;

use Iterator;
use IteratorAggregate;
use ReflectionClass;
use Zend\Json\Exception\InvalidArgumentException;
use Zend\Json\Exception\RecursionException;

/**
 * Encode PHP constructs to JSON
 */
class Encoder {
	/**
	 * Whether or not to check for possible cycling
	 *
	 * @var bool
	 */
	protected $cycleCheck;
	
	/**
	 * Additional options used during encoding
	 *
	 * @var array
	 */
	protected $options = array ();
	
	/**
	 * Array of visited objects; used to prevent cycling.
	 *
	 * @var array
	 */
	protected $visited = array ();
	
	/**
	 * Constructor
	 *
	 * @param bool $cycleCheck
	 *        	Whether or not to check for recursion when encoding
	 * @param array $options
	 *        	Additional options used during encoding
	 * @return Encoder
	 */
	protected function __construct($cycleCheck = false, $options = array()) {
		$this->cycleCheck = $cycleCheck;
		$this->options = $options;
	}
	
	/**
	 * Use the JSON encoding scheme for the value specified
	 *
	 * @param mixed $value
	 *        	The value to be encoded
	 * @param bool $cycleCheck
	 *        	Whether or not to check for possible object recursion when encoding
	 * @param array $options
	 *        	Additional options used during encoding
	 * @return string The encoded value
	 */
	public static function encode($value, $cycleCheck = false, $options = array()) {
		$encoder = new static ( ($cycleCheck) ? true : false, $options );
		
		return $encoder->_encodeValue ( $value );
	}
	
	/**
	 * Recursive driver which determines the type of value to be encoded
	 * and then dispatches to the appropriate method.
	 * $values are either
	 * - objects (returns from {@link _encodeObject()})
	 * - arrays (returns from {@link _encodeArray()})
	 * - basic datums (e.g. numbers or strings) (returns from {@link _encodeDatum()})
	 *
	 * @param $value mixed
	 *        	The value to be encoded
	 * @return string Encoded value
	 */
	protected function _encodeValue(&$value) {
		if (is_object ( $value )) {
			return $this->_encodeObject ( $value );
		} elseif (is_array ( $value )) {
			return $this->_encodeArray ( $value );
		}
		
		return $this->_encodeDatum ( $value );
	}
	
	/**
	 * Encode an object to JSON by encoding each of the public properties
	 *
	 * A special property is added to the JSON object called '__className'
	 * that contains the name of the class of $value. This is used to decode
	 * the object on the client into a specific class.
	 *
	 * @param $value object        	
	 * @return string
	 * @throws RecursionException If recursive checks are enabled and the
	 *         object has been serialized previously
	 */
	protected function _encodeObject(&$value) {
		if ($this->cycleCheck) {
			if ($this->_wasVisited ( $value )) {
				
				if (isset ( $this->options ['silenceCyclicalExceptions'] ) && $this->options ['silenceCyclicalExceptions'] === true) {
					
					return '"* RECURSION (' . str_replace ( '\\', '\\\\', get_class ( $value ) ) . ') *"';
				} else {
					throw new RecursionException ( 'Cycles not supported in JSON encoding, cycle introduced by ' . 'class "' . get_class ( $value ) . '"' );
				}
			}
			
			$this->visited [] = $value;
		}
		
		$props = '';
		
		if (method_exists ( $value, 'toJson' )) {
			$props = ',' . preg_replace ( "/^\{(.*)\}$/", "\\1", $value->toJson () );
		} else {
			if ($value instanceof IteratorAggregate) {
				$propCollection = $value->getIterator ();
			} elseif ($value instanceof Iterator) {
				$propCollection = $value;
			} else {
				$propCollection = get_object_vars ( $value );
			}
			
			foreach ( $propCollection as $name => $propValue ) {
				if (isset ( $propValue )) {
					$props .= ',' . $this->_encodeValue ( $name ) . ':' . $this->_encodeValue ( $propValue );
				}
			}
		}
		
		$className = get_class ( $value );
		return '{"__className":' . $this->_encodeString ( $className ) . $props . '}';
	}
	
	/**
	 * Determine if an object has been serialized already
	 *
	 * @param mixed $value        	
	 * @return bool
	 */
	protected function _wasVisited(&$value) {
		if (in_array ( $value, $this->visited, true )) {
			return true;
		}
		
		return false;
	}
	
	/**
	 * JSON encode an array value
	 *
	 * Recursively encodes each value of an array and returns a JSON encoded
	 * array string.
	 *
	 * Arrays are defined as integer-indexed arrays starting at index 0, where
	 * the last index is (count($array) -1); any deviation from that is
	 * considered an associative array, and will be encoded as such.
	 *
	 * @param $array array        	
	 * @return string
	 */
	protected function _encodeArray(&$array) {
		$tmpArray = array ();
		
		// Check for associative array
		if (! empty ( $array ) && (array_keys ( $array ) !== range ( 0, count ( $array ) - 1 ))) {
			// Associative array
			$result = '{';
			foreach ( $array as $key => $value ) {
				$key = ( string ) $key;
				$tmpArray [] = $this->_encodeString ( $key ) . ':' . $this->_encodeValue ( $value );
			}
			$result .= implode ( ',', $tmpArray );
			$result .= '}';
		} else {
			// Indexed array
			$result = '[';
			$length = count ( $array );
			for($i = 0; $i < $length; $i ++) {
				$tmpArray [] = $this->_encodeValue ( $array [$i] );
			}
			$result .= implode ( ',', $tmpArray );
			$result .= ']';
		}
		
		return $result;
	}
	
	/**
	 * JSON encode a basic data type (string, number, boolean, null)
	 *
	 * If value type is not a string, number, boolean, or null, the string
	 * 'null' is returned.
	 *
	 * @param mixed $value        	
	 * @return string
	 */
	protected function _encodeDatum(&$value) {
		$result = 'null';
		
		if (is_int ( $value ) || is_float ( $value )) {
			$result = ( string ) $value;
			$result = str_replace ( ',', '.', $result );
		} elseif (is_string ( $value )) {
			$result = $this->_encodeString ( $value );
		} elseif (is_bool ( $value )) {
			$result = $value ? 'true' : 'false';
		}
		
		return $result;
	}
	
	/**
	 * JSON encode a string value by escaping characters as necessary
	 *
	 * @param string $string        	
	 * @return string
	 */
	protected function _encodeString(&$string) {
		// Escape these characters with a backslash or unicode escape:
		// " \ / \n \r \t \b \f
		$search = array (
				'\\',
				"\n",
				"\t",
				"\r",
				"\b",
				"\f",
				'"',
				'\'',
				'&',
				'<',
				'>',
				'/' 
		);
		$replace = array (
				'\\\\',
				'\\n',
				'\\t',
				'\\r',
				'\\b',
				'\\f',
				'\\u0022',
				'\\u0027',
				'\\u0026',
				'\\u003C',
				'\\u003E',
				'\\/' 
		);
		$string = str_replace ( $search, $replace, $string );
		
		// Escape certain ASCII characters:
		// 0x08 => \b
		// 0x0c => \f
		$string = str_replace ( array (
				chr ( 0x08 ),
				chr ( 0x0C ) 
		), array (
				'\b',
				'\f' 
		), $string );
		$string = self::encodeUnicodeString ( $string );
		
		return '"' . $string . '"';
	}
	
	/**
	 * Encode the constants associated with the ReflectionClass
	 * parameter.
	 * The encoding format is based on the class2 format
	 *
	 * @param ReflectionClass $cls        	
	 * @return string Encoded constant block in class2 format
	 */
	private static function _encodeConstants(ReflectionClass $cls) {
		$result = "constants : {";
		$constants = $cls->getConstants ();
		
		$tmpArray = array ();
		if (! empty ( $constants )) {
			foreach ( $constants as $key => $value ) {
				$tmpArray [] = "$key: " . self::encode ( $value );
			}
			
			$result .= implode ( ', ', $tmpArray );
		}
		
		return $result . "}";
	}
	
	/**
	 * Encode the public methods of the ReflectionClass in the
	 * class2 format
	 *
	 * @param ReflectionClass $cls        	
	 * @return string Encoded method fragment
	 *        
	 */
	private static function _encodeMethods(ReflectionClass $cls) {
		$methods = $cls->getMethods ();
		$result = 'methods:{';
		
		$started = false;
		foreach ( $methods as $method ) {
			if (! $method->isPublic () || ! $method->isUserDefined ()) {
				continue;
			}
			
			if ($started) {
				$result .= ',';
			}
			$started = true;
			
			$result .= '' . $method->getName () . ':function(';
			
			if ('__construct' != $method->getName ()) {
				$parameters = $method->getParameters ();
				$paramCount = count ( $parameters );
				$argsStarted = false;
				
				$argNames = "var argNames=[";
				foreach ( $parameters as $param ) {
					if ($argsStarted) {
						$result .= ',';
					}
					
					$result .= $param->getName ();
					
					if ($argsStarted) {
						$argNames .= ',';
					}
					
					$argNames .= '"' . $param->getName () . '"';
					
					$argsStarted = true;
				}
				$argNames .= "];";
				
				$result .= "){" . $argNames . 'var result = ZAjaxEngine.invokeRemoteMethod(' . "this, '" . $method->getName () . "',argNames,arguments);" . 'return(result);}';
			} else {
				$result .= "){}";
			}
		}
		
		return $result . "}";
	}
	
	/**
	 * Encode the public properties of the ReflectionClass in the class2
	 * format.
	 *
	 * @param ReflectionClass $cls        	
	 * @return string Encode properties list
	 *        
	 */
	private static function _encodeVariables(ReflectionClass $cls) {
		$properties = $cls->getProperties ();
		$propValues = get_class_vars ( $cls->getName () );
		$result = "variables:{";
		$cnt = 0;
		
		$tmpArray = array ();
		foreach ( $properties as $prop ) {
			if (! $prop->isPublic ()) {
				continue;
			}
			
			$tmpArray [] = $prop->getName () . ':' . self::encode ( $propValues [$prop->getName ()] );
		}
		$result .= implode ( ',', $tmpArray );
		
		return $result . "}";
	}
	
	/**
	 * Encodes the given $className into the class2 model of encoding PHP
	 * classes into JavaScript class2 classes.
	 * NOTE: Currently only public methods and variables are proxied onto
	 * the client machine
	 *
	 * @param $className string
	 *        	The name of the class, the class must be
	 *        	instantiable using a null constructor
	 * @param $package string
	 *        	Optional package name appended to JavaScript
	 *        	proxy class name
	 * @return string The class2 (JavaScript) encoding of the class
	 * @throws InvalidArgumentException
	 */
	public static function encodeClass($className, $package = '') {
		$cls = new \ReflectionClass ( $className );
		if (! $cls->isInstantiable ()) {
			throw new InvalidArgumentException ( "'{$className}' must be instantiable" );
		}
		
		return "Class.create('$package$className',{" . self::_encodeConstants ( $cls ) . "," . self::_encodeMethods ( $cls ) . "," . self::_encodeVariables ( $cls ) . '});';
	}
	
	/**
	 * Encode several classes at once
	 *
	 * Returns JSON encoded classes, using {@link encodeClass()}.
	 *
	 * @param array $classNames        	
	 * @param string $package        	
	 * @return string
	 */
	public static function encodeClasses(array $classNames, $package = '') {
		$result = '';
		foreach ( $classNames as $className ) {
			$result .= static::encodeClass ( $className, $package );
		}
		
		return $result;
	}
	
	/**
	 * Encode Unicode Characters to \u0000 ASCII syntax.
	 *
	 * This algorithm was originally developed for the
	 * Solar Framework by Paul M. Jones
	 *
	 * @link http://solarphp.com/
	 * @link http://svn.solarphp.com/core/trunk/Solar/JSON.php
	 * @param string $value        	
	 * @return string
	 */
	public static function encodeUnicodeString($value) {
		$strlenVar = strlen ( $value );
		$ascii = "";
		
		/**
		 * Iterate over every character in the string,
		 * escaping with a slash or encoding to UTF-8 where necessary
		 */
		for($i = 0; $i < $strlenVar; $i ++) {
			$ordVarC = ord ( $value [$i] );
			
			switch (true) {
				case (($ordVarC >= 0x20) && ($ordVarC <= 0x7F)) :
					// characters U-00000000 - U-0000007F (same as ASCII)
					$ascii .= $value [$i];
					break;
				
				case (($ordVarC & 0xE0) == 0xC0) :
					// characters U-00000080 - U-000007FF, mask 110XXXXX
					// see http://www.cl.cam.ac.uk/~mgk25/unicode.html#utf-8
					$char = pack ( 'C*', $ordVarC, ord ( $value [$i + 1] ) );
					$i += 1;
					$utf16 = self::_utf82utf16 ( $char );
					$ascii .= sprintf ( '\u%04s', bin2hex ( $utf16 ) );
					break;
				
				case (($ordVarC & 0xF0) == 0xE0) :
					// characters U-00000800 - U-0000FFFF, mask 1110XXXX
					// see http://www.cl.cam.ac.uk/~mgk25/unicode.html#utf-8
					$char = pack ( 'C*', $ordVarC, ord ( $value [$i + 1] ), ord ( $value [$i + 2] ) );
					$i += 2;
					$utf16 = self::_utf82utf16 ( $char );
					$ascii .= sprintf ( '\u%04s', bin2hex ( $utf16 ) );
					break;
				
				case (($ordVarC & 0xF8) == 0xF0) :
					// characters U-00010000 - U-001FFFFF, mask 11110XXX
					// see http://www.cl.cam.ac.uk/~mgk25/unicode.html#utf-8
					$char = pack ( 'C*', $ordVarC, ord ( $value [$i + 1] ), ord ( $value [$i + 2] ), ord ( $value [$i + 3] ) );
					$i += 3;
					$utf16 = self::_utf82utf16 ( $char );
					$ascii .= sprintf ( '\u%04s', bin2hex ( $utf16 ) );
					break;
				
				case (($ordVarC & 0xFC) == 0xF8) :
					// characters U-00200000 - U-03FFFFFF, mask 111110XX
					// see http://www.cl.cam.ac.uk/~mgk25/unicode.html#utf-8
					$char = pack ( 'C*', $ordVarC, ord ( $value [$i + 1] ), ord ( $value [$i + 2] ), ord ( $value [$i + 3] ), ord ( $value [$i + 4] ) );
					$i += 4;
					$utf16 = self::_utf82utf16 ( $char );
					$ascii .= sprintf ( '\u%04s', bin2hex ( $utf16 ) );
					break;
				
				case (($ordVarC & 0xFE) == 0xFC) :
					// characters U-04000000 - U-7FFFFFFF, mask 1111110X
					// see http://www.cl.cam.ac.uk/~mgk25/unicode.html#utf-8
					$char = pack ( 'C*', $ordVarC, ord ( $value [$i + 1] ), ord ( $value [$i + 2] ), ord ( $value [$i + 3] ), ord ( $value [$i + 4] ), ord ( $value [$i + 5] ) );
					$i += 5;
					$utf16 = self::_utf82utf16 ( $char );
					$ascii .= sprintf ( '\u%04s', bin2hex ( $utf16 ) );
					break;
			}
		}
		
		return $ascii;
	}
	
	/**
	 * Convert a string from one UTF-8 char to one UTF-16 char.
	 *
	 * Normally should be handled by mb_convert_encoding, but
	 * provides a slower PHP-only method for installations
	 * that lack the multibyte string extension.
	 *
	 * This method is from the Solar Framework by Paul M. Jones
	 *
	 * @link http://solarphp.com
	 * @param string $utf8
	 *        	UTF-8 character
	 * @return string UTF-16 character
	 */
	protected static function _utf82utf16($utf8) {
		// Check for mb extension otherwise do by hand.
		if (function_exists ( 'mb_convert_encoding' )) {
			return mb_convert_encoding ( $utf8, 'UTF-16', 'UTF-8' );
		}
		
		switch (strlen ( $utf8 )) {
			case 1 :
				// this case should never be reached, because we are in ASCII range
				// see: http://www.cl.cam.ac.uk/~mgk25/unicode.html#utf-8
				return $utf8;
			
			case 2 :
				// return a UTF-16 character from a 2-byte UTF-8 char
				// see: http://www.cl.cam.ac.uk/~mgk25/unicode.html#utf-8
				return chr ( 0x07 & (ord ( $utf8 {0} ) >> 2) ) . chr ( (0xC0 & (ord ( $utf8 {0} ) << 6)) | (0x3F & ord ( $utf8 {1} )) );
			
			case 3 :
				// return a UTF-16 character from a 3-byte UTF-8 char
				// see: http://www.cl.cam.ac.uk/~mgk25/unicode.html#utf-8
				return chr ( (0xF0 & (ord ( $utf8 {0} ) << 4)) | (0x0F & (ord ( $utf8 {1} ) >> 2)) ) . chr ( (0xC0 & (ord ( $utf8 {1} ) << 6)) | (0x7F & ord ( $utf8 {2} )) );
		}
		
		// ignoring UTF-32 for now, sorry
		return '';
	}
}
