<?php
namespace Engine\Serializer;

/**
 * Custom serializer. Designed to work with custom class hierarchy.
 * Designed for importedOrders
 * @author Lucian Sirbu
 *
 * bugs:
 * 	- no circular references
 * 	- serialized classes have to extend \Engine\Serializer\MagicMethods\Custom
 * 	- serialized classes have to have simple constructors
 *
 *
 * Notation:
 * 	internal array key-map
 * 		_type => 		$%t*
 * 		_reference => 	$%r*
 * 		_class => 		$%c*
 * 		attribtues => 	$%a*
 * 		members => 		$%m*
 *
 * 	type-map
 * 		object => 		$%o
 * 		array => 		$%a
 */
class Custom extends \Engine\Serializer\SerializerAbstract
{
	protected $serializedObjects = [];

	/**
	 * Defaults to \Engine\Serializer\Json
	 * @var \Engine\Serializer\Json
	 */
	protected $serializer = null;

	/**
	 * By default, compress data
	 * @var bool
	 */
	protected $compress = true;

	/**
	 * The default compressor
	 * @var string
	 */
	protected $compressor = 	'gzdeflate';

	/**
	 * The default encoder
	 * @var string
	 */
	protected $encoder = 		'base64';	// base64 is needed because gzdeflate outputs a binary string ans json_encode doens't handle this correctly

	/**
	 * Set the serializer to be used internally for the generated array
	 * @param \Engine\Serializer\SerializerInterface $serializer
	 */
	public function setSerializer(\Engine\Serializer\SerializerInterface $serializer)
	{
		$this->serializer = $serializer;
		return $this;
	}

	/**
	 * Set the encoder used. Can be null or 'base64' for now
	 * @param string $encoder
	 */
	public function setEncoder($encoder)
	{
		$this->encoder = $encoder;
		return $this;
	}

	/**
	 * Set the compressor to be used internally. Can be gzcompress,gzdeflate
	 * @param string $compressor
	 */
	public function setCompressor($compressor)
	{
		$this->compressor = $compressor;
		return $this;
	}

	// SERIALIZE FUNCTIONS /////////////////////////////////////////////////////////////////////

	/**
	 * (non-PHPdoc)
	 * @see \Engine\Serializer\SerializerInterface::serialize()
	 */
	public function serialize($data)
	{
		$this->serializedObjects = [];	// reset list

		$rawSerializedData = $this->doSerialize($data, $this->serializedObjects);

		if ($this->serializer===null) {
			$this->serializer = new \Engine\Serializer\Json();
		}

		$serializedData = $this->serializer->serialize($rawSerializedData);

		return $this->doSerializeSerializedData($serializedData);
	}

	/**
	 * Serialize the internadiary serialized data
	 * @throws \Exception
	 * @return string
	 */
	public function doSerializeSerializedData($serializedData)
	{
		$compressor = null;
		if ($this->compress) {
			$compressor = $this->compressor;
			if ($compressor) {
				if ($compressor=='gzcompress') {
					$serializedData = gzcompress($serializedData, 9);
				}
				elseif ($compressor=='gzdeflate') {
					$serializedData = gzdeflate($serializedData, 9);
				}
				else {
					throw new \Exception(sprintf("Unknown compressor used: %s", $compressor));
				}
			}
		}

		$encoder = $this->encoder;
		if ($encoder) {
			if ($encoder=='base64') {
				// could also use base85/base91 encodings, but they are not supported directly by PHP
				$serializedData = base64_encode($serializedData);
			}
			elseif ($encoder=='none') {
				// $serializedData = $serializedData;	// this encoder is useful for debugging
			}
			else {
				throw new \Exception(sprintf("Unknown encoder used: %s", $encoder));
			}
		}

		$serializedRepresentation = [
			'serializer' => 	get_class($this),
			'dataSerializer' => get_class($this->serializer),
			'compressor' => 	$compressor,
			'encoder' => 		$encoder,
			'hash' => 			sprintf("%s,%d", substr(md5($serializedData), 0, 6), strlen($serializedData)),
			'data' => 			$serializedData,
		];

		$this->serializedObjects = [];	// clear memory
		$return = json_encode($serializedRepresentation);

		if ($return===false) {
			switch (json_last_error()) {
		        case JSON_ERROR_DEPTH:
		            throw new \RuntimeException('Maximum stack depth exceeded');
		        break;
		        case JSON_ERROR_STATE_MISMATCH:
		            throw new \RuntimeException('Underflow or the modes mismatch');
		        break;
		        case JSON_ERROR_CTRL_CHAR:
		            throw new \RuntimeException('Unexpected control character found');
		        break;
		        case JSON_ERROR_SYNTAX:
		            throw new \RuntimeException('Syntax error, malformed JSON');
		        break;
		        case JSON_ERROR_UTF8:
		            throw new \RuntimeException('Malformed UTF-8 characters, possibly incorrectly encoded');
		        break;
		        default:
		        	throw new \RuntimeException('Unknown JSON encode error.');
		    }
		}

		return $return;
	}

	/**
	 * Do the actual conversion from input data to the pseudo-array notation
	 * @param mixed $data
	 * @param array $serializedObjects
	 * @throws \Exception
	 * @return mixed
	 */
	public function doSerialize($data, &$serializedObjects)
	{
		$ret = null;
		if (is_object($data)) {

			if ($data instanceof \Engine\Serializer\MagicMethods\CustomAbstract) {
				$ret = $data->_getSerializedRepresentation($serializedObjects);
			}
			else {
				throw new \Exception(sprintf("We only know how to serialize objects extending Engine\Serializer\MagicMethods\CustomAbstract. Cannot handle class %s", get_class($data)));
			}
		}
		elseif (is_array($data)) {
			$ret = $this->doSerialize_array($data, $serializedObjects);
		}
		else {
			$ret = $this->doSerialize_scalar($data);
		}

		return $ret;
	}

	/**
	 * Convert an individual scalar to array pseudo-notation (not applicable for scalars though)
	 * @param mixed $scalar
	 * @return mixed
	 */
	protected function doSerialize_scalar($scalar)
	{
		return $scalar;
	}

	/**
	 * Convert an array to array pseudo-notation
	 * @param mixed $data
	 * @param array $serializedObjects
	 * @return mixed
	 */
	protected function doSerialize_array($data, &$serializedObjects)
	{
		$ret = [
			'$%t*' => '$%a',
			'$%m*' => [],
		];

		foreach ($data as $k=>$v) {
			$ret['$%m*'][$k] = $this->doSerialize($v, $serializedObjects);
		}

		return $ret;
	}


	// UNSERIALIZE /////////////////////////////////////////////////////////////////////////////
	/**
	 * (non-PHPdoc)
	 * @see \Engine\Serializer\SerializerInterface::unserialize()
	 */
	public function unserialize($data)
	{
		$serializedRepresentation = $this->doUnserializeRawSerializedData($data);

		$rawSerializedData = $this->serializer->unserialize($serializedRepresentation['data']);

		$data = $this->doUnserialize($rawSerializedData, $this->serializedObjects);

		$this->serializedObjects = [];	// clear memory
		return $data;
	}

	/**
	 * Unserialize the intermediary serialized data
	 * @throws \Exception
	 * @return mixed
	 */
	public function doUnserializeRawSerializedData($data)
	{
		$this->serializedObjects = [];	// reset list
		$serializedRepresentation = json_decode($data, true);

		if ($serializedRepresentation===null) {
			// json decode failed
			\Log::getInstance()->critical("Cannot unserialize data. Invalid data provided as input. json_decode returned null.", [
				'data' => 				new \Engine\Logger\Context\StringRaw($data),
				'json_last_errorStr' => json_last_errorStr(),
			]);
			throw new \Exception("Cannot unserialize data. Invalid data provided as input");
		}
		$serializerClass = "\\{$serializedRepresentation['dataSerializer']}";
		$this->serializer = new $serializerClass();

		list($md5, $length) = explode(",", $serializedRepresentation['hash']);

		if($length != strlen($serializedRepresentation['data'])) {
			throw new \Exception("Cannot unserialize data. Invalid data length");
		}
		if($md5 != substr(md5($serializedRepresentation['data']), 0, 6)) {
			throw new \Exception("Cannot unserialize data. Invalid hash");
		}
		if ($serializedRepresentation['encoder']) {
			if ($serializedRepresentation['encoder'] == 'base64') {
				$serializedRepresentation['data'] = base64_decode($serializedRepresentation['data']);
			}
			else {
				throw new \Exception("Unknown encoder used");
			}
		}

		if ($serializedRepresentation['compressor']) {
			if ($serializedRepresentation['compressor'] == 'gzcompress') {
				$serializedRepresentation['data'] = gzuncompress($serializedRepresentation['data']);
			}
			elseif ($serializedRepresentation['compressor'] == 'gzdeflate') {
				$serializedRepresentation['data'] = gzinflate($serializedRepresentation['data']);
			}
			else {
				throw new \Exception(sprintf("Unknown compressor used in serialized data: %s", $serializedRepresentation['compressor']));
			}
		}

		return $serializedRepresentation;
	}

	/**
	 * Do the actual conversion from array pseudo-notation to real-life PHP objects/arrays/scalars
	 * @param array $data
	 * @param array $serializedObjects
	 * @throws \Exception
	 * @return mixed
	 */
	public function doUnserialize($data, &$serializedObjects)
	{
		$ret = null;
		if (is_array($data) && array_key_exists('$%t*', $data) && $data['$%t*']=='$%o' && array_key_exists('$%c*', $data) && array_key_exists('$%a*', $data)) {	// is_object
			$ret = new $data['$%c*']();

			if ($ret instanceof \Engine\Serializer\MagicMethods\CustomAbstract) {
				$ret->_setSerializedRepresentation($data, $serializedObjects);
			}
			else {
				throw new \Exception("Class need to implement _setSerializedRepresentation()");
			}
		}
		elseif (is_array($data) && array_key_exists('$%t*', $data) && $data['$%t*']=='$%a' && array_key_exists('$%m*', $data)) {	// is_array
			$ret = [];
			foreach ($data['$%m*'] as $k=>$v) {
				$ret[$k] = $this->doUnserialize($v, $serializedObjects);
			}
		}
		elseif (is_array($data)) {
			throw new \Exception("this is an array, but we dont know how it got encoded like this");
		}
		else {
			$ret = $this->doUnserialize_scalar($data);
		}

		return $ret;
	}

	/**
	 * Convert from array pseudo-notation to real-life PHP scalars
	 * @param mixed $scalar
	 * @return mixed
	 */
	protected function doUnserialize_scalar($scalar)
	{
		return $scalar;
	}
}
