<?php
namespace App\Utils\Views;

/**
 * This class can handle object, array or another type of data and transform into stdClass
 * @author oana
 */
class ObjectExplorer extends \App\Utils\Views\AbstractHtmlComponent
{
	/**
	 * Input data. This variable can be any type
	 */
	protected $data = NULL;

	protected $styleCallback = NULL;

	/**
	 * Internal object id, used in js
	 * @var string
	 */
	protected $ID = null;

	/**
	 * Main class contructor
	 */
	public function __construct($data)
	{
		$this->data = $data;

		$this->mergeOptions([
			'sort' => 				null,	// null, 'human:1', 'name'
			'collapsed' => 			false,
			'interactive' => 		true,

			'highlightArray'=> 		true,
			'highlightString'=>		null, // null, 'spaces', 'nonAscii', 'utf8AsHex'

			'showEmptyLeafs'=> 		true,
			'showPrivateMembers' =>	false,	// false, true
			'scrollContainer' => 	'window',	// 'window', 'self', or any other css selector

			'customConvertFunction' =>null,	// null, callback($data)

			'currentDay' => 		\Engine\DateTime::create('midnight'),
		]);
	}

	/**
	 * Add a callback to be called when generating element styles
	 * @param callback $styleCallback
	 */
	public function addGetStyleCallback($styleCallback)
	{
		/*
		 * This callback is expected to return null for elements that shouldn't appear in the navigator
		 *  ['style'] 		- a string that will be added to the elements classes
		 *  ['navLabel']	- the displayed label
		 *  ['navTarget']	- the tree target element
		 *  ['navStyle'] 	- a string that will be added to the elements classes
		 */
		$this->styleCallback = $styleCallback;
	}

	/**
	 * This method should convert the object to tree of stdclass objects.
	 * We cannot do serialize/unserialize on all objects.
	 * Alternatives:
	 * 		1. http://stackoverflow.com/a/4345609/3034235
	 * 			- we could convert each $data member to array and replace binary chars from keys
	 * 			- @see convertObjectToStdClass_v2
	 * 		2. Reflection (something along the lines of http://stackoverflow.com/a/16023589/11301)
	 * 		3. serialize/unserialize (current method)
	 * 			- @see http://www.phpinternalsbook.com/classes_objects/serialization.html
	 * 			- @see http://stackoverflow.com/questions/14297926/structure-of-a-serialized-php-string
	 * 			- @see convertObjectToStdClass_v1
	 */

	private function convertObjectToStdClass($data)
	{
		return $this->convertObjectToStdClass_v2($data);
	}

	/**
	 * Convert data to stdClass
	 * this method fails for items with custom serializers.
	 * @see http://www.phpinternalsbook.com/classes_objects/serialization.html
	 */
	private function convertObjectToStdClass_v1($data)
	{
		$serializeData = serialize($data);

		// replace class namespaces with stdClass
		$serializeData = preg_replace_callback('@O:(\d+):"([^"]+)"@', function($match)
	    	{
				$repl = "stdClass";
		    	return sprintf('O:%d:"%s"', strlen($repl), $repl);
		    }, $serializeData);

		// transform private members to public members
		$serializeData = preg_replace_callback('@s:(\d+):"(\x00[a-zA-Z0-9_\\\\]+\x00)([^"]+)"@', function($match)
	    	{
				return sprintf('s:%d:"%s"', (int)$match[1] - strlen($match[2]), $match[3]);
		    }, $serializeData);

		// transform protected members to public members
		$serializeData = preg_replace_callback('@s:(\d+):"(\x00\*\x00)([^"]+)"@', function($match)
	    	{
				return sprintf('s:%d:"%s"', (int)$match[1] - 3, $match[3]);
		    }, $serializeData);

		return unserialize($serializeData);
	}

	/**
	 * Convert data to stdClass
	 * this method fails for items with custom serializers.
	 * @see http://www.phpinternalsbook.com/classes_objects/serialization.html
	 */
	private function convertObjectToStdClass_v2($data)
	{
		if (is_object($data)) {
			$convertedData = new \stdClass();
			$dataArr = (array)$data;
			foreach ($dataArr as $k=>$v) {
				$k = preg_replace('/^\\x00\*\x00([a-zA-Z0-9_\\\\]+)$/', '$1', $k);	// protected members
				$k = preg_replace('/^\\x00([a-zA-Z0-9_\\\\]+)\\x00([a-zA-Z0-9_\\\\]+)$/', '$2', $k);	// private members
				if (is_object($v) || is_array($v)) {
					$convertedData->{$k} = $this->convertObjectToStdClass_v2($v);
				}
				else {
					$convertedData->{$k} = $v;
				}
			}
		}
		elseif (is_array($data)) {
			$convertedData = [];
			foreach ($data as $k=>$v) {
				if (is_object($v) || is_array($v)) {
					$convertedData[$k] = $this->convertObjectToStdClass_v2($v);
				}
				else {
					$convertedData[$k] = $v;
				}
			}
		}
		else {
			$convertedData = $data;
		}

		return $convertedData;
	}

	/**
	 * Get component id
	 * @return string
	 */
	private function getId()
	{
		if ($this->ID) {
			return $this->ID;
		}
		$this->ID = 'ObjectExplorer_'.md5(mt_rand());
		return $this->ID;
	}

	/**
	 * Generate object data table
	 */
	private function generateDataTable($data, Array $parents = [], $level=0, $options = [])
	{
		$html  = '';
		$containerClasses = [];
		if ($this->getOption('highlightArray')) {
			$containerClasses[]= 'highlight-array';
		}
		$html .= sprintf('<table class="content level-%s %s">', $level, implode(' ', $containerClasses));

		if ($this->getOption('sort')) {
			$data = $this->sortObject($data, $this->getOption('sort'));
		}

		if ($data===null) {
			$data = [];
		}

		foreach ($data as $key=>$entry) {
			if ($this->getOption('showEmptyLeafs')===false) {
				if (is_array($entry) && !count($entry)) {
					continue;
				}
				if (is_object($entry) && !count(get_object_vars($entry))) {
					continue;
				}
				if (!isset($entry)) {
					continue;
				}
			}

			$containerClasses = [];
			$disabled 	= '';

			if ($this->getOption('collapsed')) {
				$containerClasses[]= 'collapsed';
			}

			if ($this->getOption('interactive')) {
				if (is_array($entry) || is_object($entry)) {
					$containerClasses[]= 'interactive';
				}
			} else {
				$disabled  = 'disabled';
			}

			$valueBase = '<span class="placeholder">...</span><span class="body">%s</span>';

			$newParents = $parents;
			$newParents[] = $key;

			$newOptions = [];

			if ($this->styleCallback) {
				$fcnResults = call_user_func($this->styleCallback, $key, $parents, $entry, $this->data);
				$containerClasses[]= $fcnResults['style'];
			}

			$keyLabel = $key;
			if ($this->getOption('highlightArray') && is_array($entry)) {
				$newOptions['highlightArray'] = true;
			}
			if ($options['highlightArray']) {
				$keyLabel = sprintf("[%s]", $keyLabel);
			}

			if (is_array($entry)) {
				$html.= sprintf('<tr class="container container-%s %s">', $key, implode(' ', $containerClasses));
				$html.= 	sprintf('<td class="label %s"><span>%s</span></td>', $disabled, $keyLabel);
				$html.= 	sprintf('<td class="value array %s">'.$valueBase.'</td>', $disabled, $this->generateDataTable($entry, $newParents, $level+1, $newOptions));
				$html.= '</tr>';
			}
			elseif (is_object($entry)) {
				// try to determine if we can reconize some object types, by looking at their attributes
				// type: /Engine/DateTime, try to pretty-render it
				if ($this->objectHasOnlyTheseProperties($entry, ['valid', 'empty', 'date', 'timezone_type', 'timezone', ])) {
					$prettyEntry = sprintf("%s (tz:%s) %s%s%s", $entry->date, $entry->timezone, '/Engine/DateTime', ($entry->valid?'':'(not valid)'), ($entry->empty?'(empty)':''));
					$html.= sprintf('<tr class="scalar container-%s %s">', $key, implode(' ', $containerClasses));
					$html.= 	sprintf('<td class="label %s"><span>%s</span></td>', $disabled, $keyLabel);
					$html.= 	sprintf('<td class="value %s DateTime">'.$valueBase.'</td>', $disabled, $prettyEntry);
					$html.= '</tr>';
				}
				elseif ($entry instanceof \Engine\SimpleXmlElement) {
					$keySubLabel = "";
					if ($entry->attributes()->count()) {
						$keySubLabel .= sprintf('<table class="attributes %s"><tbody>', implode(' ', $containerClasses));
						foreach ($entry->attributes() as $attr=>$value) {
							$keySubLabel.= sprintf('<tr><td class="label">%s</td>%s</tr>', $attr, $this->generateScalarTd($value->__toString(), "%s", $disabled));
						}
						$keySubLabel .= sprintf('</tbody></table>');
					}

					if ($entry->count()==0) {
						$html.= sprintf('<tr class="scalar container-%s %s">', $key, implode(' ', $containerClasses));
						$html.= 	sprintf('<td class="label %s"><span>%s</span>%s</td>', $disabled, $keyLabel, $keySubLabel);

						$html.= 	$this->generateScalarTd($entry->__toString(), $valueBase, $disabled);

						$html.= '</tr>';
					}
					else {
						// default object renderer, goes recursivelly
						$html.= sprintf('<tr class="container container-%s %s">', $key, implode(' ', $containerClasses));
						$html.= 	sprintf('<td class="label %s"><span>%s</span>%s</td>', $disabled, $keyLabel, $keySubLabel);
						$html.= 	sprintf('<td class="value object %s">'.$valueBase.'</td>', $disabled, $this->generateDataTable($entry, $newParents, $level+1, $newOptions));
						$html.= '</tr>';
					}
				}
				else {	// default object renderer, goes recursivelly
					$html.= sprintf('<tr class="container container-%s %s">', $key, implode(' ', $containerClasses));
					$html.= 	sprintf('<td class="label %s"><span>%s</span></td>', $disabled, $keyLabel);
					$html.= 	sprintf('<td class="value object %s">'.$valueBase.'</td>', $disabled, $this->generateDataTable($entry, $newParents, $level+1, $newOptions));
					$html.= '</tr>';
				}
			}
			else {
				$html.= sprintf('<tr class="scalar container-%s %s">', $key, implode(' ', $containerClasses));
				$html.= 	sprintf('<td class="label %s"><span>%s</span></td>', $disabled, $keyLabel);

				$html.= $this->generateScalarTd($entry, $valueBase, $disabled);

				$html.= '</tr>';
			}
		}

		$html.= '</table>';

		return $html;
	}

	private function generateScalarTd($entry, $valueBase, $disabled)
	{
		$html = '';
		if ($entry===null) {
			$html.= sprintf('<td class="value %s null">'.$valueBase.'</td>', $disabled, 'null');
		}
		elseif (is_bool($entry)) {
			$html.= sprintf('<td class="value %s bool bool-'.($entry?'true':'false').'">'.$valueBase.'</td>', $disabled, ($entry?'true':'false'),  ($entry?'true':'false'));
		}
		elseif (is_float($entry)) {
			$html.= sprintf('<td class="value %s float">'.$valueBase.'</td>', $disabled, $entry);
		}
		elseif (is_int($entry)) {
			$html.= sprintf('<td class="value %s int">'.$valueBase.'</td>', $disabled, $entry);
		}
		elseif (is_string($entry)) {
			// TODO: detect url
			// TODO: detect email
			// TODO: time/datime

			// detect date
			if (preg_match("/^(19|20)[0-9]{2}\-(0|1)[0-9]\-(0|1|2|3)[0-9]$/", $entry)) {
				$dt = \Engine\DateTime::createFromIsoDate($entry);
				$diff = $this->getOption('currentDay')->diff($dt);
				$html.= sprintf('<td class="value %s string date" title="%s">'.$valueBase.'</td>', $disabled, \Engine\DateInterval::formatStdRelative($diff), $this->renderStr($entry));
			}
			else {
				$html.= sprintf('<td class="value %s string">'.$valueBase.'</td>', $disabled, $this->renderStr($entry));
			}
		}
		else {
			// this is not tested
			$html.= sprintf('<td class="value %s string">'.$valueBase.'</td>', $disabled, $this->renderStr($entry));
		}

		return $html;
	}

	private function generateTreeTd($entry, $valueBase, $disabled)
	{
	}

	/**
	 * Generate navigation menu
	 */
	private function generateNavigation($navSections)
	{

		$nav = '';
		if ($navSections) {
			$nav .= '<div class="nav">';
			$nav .= '<ul>';
			$nav .= '<li class="go-top">&uArr;</li>';

			foreach ($navSections as $value) {
				$nav .= sprintf("<li class='nav-%s %s' ", $value['navLabel'], $value['navStyle']);
				$nav .= sprintf("data-scrollTarget=\"%s\">%s</li>", implode("|", $value['navTarget']), $value['navLabel']);
			}

			$nav .= '<li class="go-bottom">&dArr;</li>';
			$nav .= '</ul>';
			$nav .= '</div>';
		}

		return $nav;
	}

	/**
	 * Generate navigation sections
	 */
	private function generateNavSections($data, Array $parents, Array &$navSections)
	{
		if ($this->getOption('interactive')===false) {
			return null;
		}

		if ($data===null) {
			$data = [];
		}

		foreach ($data as $key => $entry) {
			if ($this->getOption('showEmptyLeafs')===false) {
				if (!$entry) {
					continue;
				}
				if (is_object($entry) && !count(get_object_vars($entry))) {
					continue;
				}
			}

			$newParents = $parents;
			$newParents[] = $key;

			if ($this->styleCallback) {
				$fcnResults = call_user_func($this->styleCallback, $key, $parents, $entry, $this->data);
				if ($fcnResults['navLabel'] && $fcnResults['navTarget']) {
					$navSections[] = $fcnResults;
				}
			}

			if (is_array($entry)) {
				$this->generateNavSections($entry, $newParents, $navSections);
			}
			elseif (is_object($entry)) {
				$this->generateNavSections($entry, $newParents, $navSections);
			}
		}
	}

	/**
	 * render a string
	 */
	private function renderStr($str)
	{
		if ($this->getOption('highlightString')) {
			switch ($this->getOption('highlightString')) {
				case 'spaces':
					$str = preg_replace_callback("/[\x20\x09]/", function($m) {
						if ($m[0]==' ') {
							return sprintf("<span class='char space'>%s</span>", $m[0]);
						}
						if ($m[0]=="\t") {
							return sprintf("<span class='char tab'>%s</span>", $m[0]);
						}
					}, $str);
				break;

				case 'nonAscii':
					$str = preg_replace_callback("/[^\x20-\x7f]/", function($m) {
						if (in_array($m[0], ["\r", "\n", "\t"])) {
							return $m[0];
						}
						return sprintf("<span class='char invalid'>\\%02x</span>", ord($m[0]));
					}, $str);
				break;

				case 'utf8AsHex':
					$str = preg_replace_callback("/./u", function($m) {
						if (in_array($m[0], ["\r", "\n", "\t", " ",])) {
							return $m[0];
						}

						if (strlen($m[0])==1) {
							if (ord($m[0][0])>0x20 && ord($m[0][0])<0x7f) {
								return $m[0][0];
							}
						}


						$renderedChars = [];
						for ($i=0; $i<strlen($m[0]); $i++) {
							$renderedChars[]= sprintf("%02x", ord($m[0][$i]));
						}

						return sprintf("<span class='char utf8'><span class='real'>%s</span><span class='hex'>\\%s</span></span>", $m[0], implode("", $renderedChars));
					}, $str);
				break;
			}
		}

		return $str;
	}


	/**
	 * Sort the object
	 */
	private function sortObject($data, $sortCriteria)
	{
		// to be able to sort, we need to convert any object ao assoc array
		if (is_object($data)) {
			$newData = [];
			foreach ($data as $key=>$entry) {
				$newData[$key] = $entry;
			}
			$data = $newData;
		}


		$dataCopy = $data;

		uksort($data, function($k1, $k2) use($dataCopy, $sortCriteria) {
			$cmpInsensitive = null;
			$so1 = null; // sort order 1
			$so2 = null; // sort order 2

			if ($sortCriteria=='human:1') {
				$cmpInsensitive = true;

				if (is_object($dataCopy[$k1])) {
					$so1 = 10;
				}
				elseif (is_array($dataCopy[$k1])) {
					$so1 = 20;
				}
				else {
					$so1 = 50;
				}

				if (is_object($dataCopy[$k2])) {
					$so2 = 10;
				}
				elseif (is_array($dataCopy[$k2])) {
					$so2 = 20;
				}
				else {
					$so2 = 50;
				}
			}

			if ($sortCriteria=='name') {
				$cmpInsensitive = true;
				$so1 = 50;
				$so2 = 50;
			}

			$nk1 = sprintf("%02d:%s", $so1, $k1);	// new key 1
			$nk2 = sprintf("%02d:%s", $so2, $k2);	// new key 2

			if ($cmpInsensitive) {
				return strcasecmp($nk1, $nk2);
			}
			return strcmp($nk1, $nk2);
		});

		return $data;
	}

	private function objectHasOnlyTheseProperties($obj, $properties)
	{
		if (count(get_object_vars($obj))!=count($properties)) {
			return false;
		}

		foreach ($properties as $p) {
			if (!property_exists($obj, $p)) {
				return false;
			}
		}

		return true;

	}


	public function renderHtml()
	{
		$data = $this->data;

		if ($this->getOption('customConvertCallback')) {
			$data = call_user_func($this->getOption('customConvertCallback'), $data);
		}

		if ($this->getOption('showPrivateMembers')) {
			$data = $this->convertObjectToStdClass($data);
		}

		$html = $this->generateDataTable($data);

		$navSections = [];
		$this->generateNavSections($data, [], $navSections);
		$nav  = $this->generateNavigation($navSections);

		return "<div class='objectExplorer' id='{$this->getId()}'>{$nav}{$html}</div>";
	}

	public function renderJs(\JSLoader_complex $JSLoader)
	{
		return <<<JS
			{$this->getId()} = (new _objectExplorer({ container: $('{$this->getId()}'), scrollContainer:'{$this->getOption('scrollContainer')}' })).attach();
JS;
	}

	public function preloadCss(\Engine\Theme\CSSLoader\Complex $CSSLoader)
	{
		$CSSLoader->loadGroup("utils.views.objectexplorer");
		return '';
	}

	public function preloadJs(\JSLoader_complex $JSLoader)
	{
		$JSLoader->loadGroup("utils.views.objectexplorer");
		return '';
	}

}
