<?php



namespace WPGMZA;



require_once(plugin_dir_path(__FILE__) . 'class.selector-to-xpath.php');



class DOMElement extends \DOMElement

{

	protected static $xpathConverter;

	

	public function __construct()

	{

		\DOMElement::__construct();

	}

	

	/**

	 * Runs a CSS selector on the element. This is equivilant to Javascripts querySelector

	 * @param string $query a CSS selector

	 * @return mixed The first descendant element that matches the selector, or NULL if there is no match

	 */

	public function querySelector($query)

	{

		$results = $this->querySelectorAll($query);		

		if(empty($results))

			return null;

		return $results[0];

	}

	

	/**

	 * Runs a CSS selector on the element. This is equivilant to Javascripts querySelectorAll

	 * @return mixed Any descendant element's that match the selector, or NULL if there is no match

	 */

	public function querySelectorAll($query, $sort=true)

	{

		$xpath 		= new \DOMXPath($this->ownerDocument);

		

		try{

			$expr 		= DOMElement::selectorToXPath($query);

		}catch(Exception $e) {

			echo "<p class='notice notice-warning'>Failed to convert CSS selector to XPath (" . $e->getMessage() . ")</p>";

		}

		

		$list	 	= $xpath->query($expr, $this);

		$results	= array();

		

		for($i = 0; $i < $list->length; $i++)

			array_push($results, $list->item($i));

		

		if($sort)

			usort($results, array('WPGMZA\DOMElement', 'sortByDOMPosition'));

		

		return $results;

	}

	

	/** 

	 * Prepends the subject to this element.

	 * @param $subject element or array of elements

	 * @return $this element

	 */

	public function prepend($subject)

	{

		if(is_array($subject))

		{

			$originalFirst = $this->firstChild;

			

			foreach($subject as $el)

				$this->insertBefore($el, $originalFirst);

		}

		else

			$this->insertBefore($subject, $this->firstChild);

		

		return $this;

	}

	

	/**

	 * Appends the subject to this element.

	 */

	public function append($subject)

	{

		if(is_array($subject))

		{

			foreach($subject as $el)

				$this->appendChild($subject);

		}

		else

			$this->appendChild($subject);

		

		return $this;

	}

	

	/**

	 * Traverses from this node up it's ancestors and returns the first node that matches the given selector

	 * @param mixed $selector Either this node the first ancestor that matches this selector, or NULL if no match is found

	 */

	public function closest($selector)

	{

		if($this === $this->ownerDocument->documentElement)

			throw new \Exception('Method not valid on document element');

		

		for($el = $this; $el->parentNode != null; $el = $el->parentNode)

		{

			$m = $el->parentNode->querySelectorAll($selector);

			if(array_search($el, $m, true) !== false)

				return $el;

		}

		

		return null;

	}

	

	/**

	 * Test if this element comes before the other element in the DOM tree

	 * @return boolean TRUE if this element comes before the other, FALSE if not

	 */

	public function isBefore($other)

	{

		if($this->parentNode === $other->parentNode)

			return ($this->getBreadth() < $other->getBreadth());

		

		$this_depth = $this->getDepth();

		$other_depth = $other->getDepth();

		

		if($this_depth == $other_depth)

			return $this->parentNode->isBefore($other->parentNode);

		

		if($this_depth > $other_depth)

		{

			$ancestor = $this;

			$ancestor_depth = $this_depth;

			

			while($ancestor_depth > $other_depth)

			{

				$ancestor = $ancestor->parentNode;

				$ancestor_depth--;

			}

			

			return $ancestor->isBefore($other);

		}

		

		if($this_depth < $other_depth)

		{

			$ancestor = $other;

			$ancestor_depth = $other_depth;

			

			while($ancestor_depth > $this_depth)

			{

				$ancestor = $ancestor->parentNode;

				$ancestor_depth--;

			}

			

			return $this->isBefore($ancestor);

		}

	}

	

	/**

	 * Returns the breadth (sometimes called child index) of this node in regards to it's siblings

	 * @return int The index of this node

	 */

	public function getBreadth()

	{

		$breadth = 0;

		for($node = $this->previousSibling; $node != null; $node = $node->previousSibling)

			$breadth++;

		return $breadth;

	}

	

	/**

	 * Returns the depth of this node in regards to it's ancestors

	 * @return int The depth of this node

	 */

	public function getDepth()

	{

		$depth = 0;

		for($node = $this->parentNode; $node != null; $node = $node->parentNode)

			$depth++;

		return $depth;

	}

	

	/**

	 * @internal sort function for DOM position sort

	 */

	private static function sortByDOMPosition($a, $b)

	{

		return ($a->isBefore($b) ? -1 : 1);

	}

	

	/**

	 * @internal Calls the CSS to XPath converter on the specified selector

	 * @param string $selector The CSS selector

	 * @return string The resulting XPath expression

	 */

	public static function selectorToXPath($selector)

	{

		if(!DOMElement::$xpathConverter)

			DOMElement::$xpathConverter = new Selector\XPathConverter();

		

		$xpath = DOMElement::$xpathConverter->convert($selector);

		

		return $xpath;

	}



	/**

	 * Imports the supplied subject into this node.

	 * @param mixed $subject. Either a \DOMDocument, \DOMNode, .html filename, or string containing HTML/text. The function will attempt to detect which. If you import HTML that contains a <body> element, it will only import the inner body

	 * @throws \Exception the subject was not recognised as any of the types above

	 * @return $this element

	 */

	public function import($subject, $forcePHP=false)

	{

		global $wpgmza;

		

		$node = null;

		

		if($subject instanceof \DOMDocument)

		{

			if(!$subject->documentElement)

				throw new \Exception('Document is empty');

			

			$node = $this->ownerDocument->importNode($subject->documentElement, true);



		}

		else if($subject instanceof \DOMNode)

		{

			$node = $this->ownerDocument->importNode($subject, true);

		}

		else if(preg_match('/(\.html|\.php)$/i', $subject, $m))

		{

			// Subject is a filename

			if(!file_exists($subject))

				throw new \Exception('HTML file not found');

			

			$temp = new DOMDocument('1.0', 'UTF-8');

			if($forcePHP || preg_match('/\.php$/i', $m[1]))

				$temp->loadPHPFile($subject);

			else

				$temp->load($subject);

			

			$node = $this->ownerDocument->importNode($temp->documentElement, true);

		}

		else if(is_string($subject))

		{

			if(empty($subject))

				return;

			

			if($subject != strip_tags($subject))

			{

				// Subject is a HTML string

				$html = DOMDocument::convertUTF8ToHTMLEntities($subject);

				

				$temp = new DOMDocument('1.0', 'UTF-8');

				$str = "<div id='domdocument-import-payload___'>" . $subject . "</div>";

				

				if(!empty($wpgmza->settings->developer_mode))

					$temp->loadHTML($str);

				else

					@$temp->loadHTML($str);

				

				$body = $temp->querySelector('#domdocument-import-payload___');

				for($child = $body->firstChild; $child != null; $child = $child->nextSibling)

				{

					$node = $this->ownerDocument->importNode($child, true);

					$this->appendChild($node);

				}

			}

			else

				// Subject is a plain string

				$this->appendText($subject);

			

			return;

		}

		else if(empty($subject))

		{

			return;

		}

		else

			throw new \Exception('Don\'t know how to import "' . print_r($subject, true) . '" in ' . $this->ownerDocument->documentURI . ' on line ' . $this->getLineNo());

		

		if($body = $node->querySelector("body"))

		{

			// TODO: I don't think a query selector is necessary here. Iterating over the bodies children should be more optimal.

			foreach($node->querySelectorAll("body>*") as $child)

				$this->appendChild($child);

		}

		else

			$this->appendChild($node);

		

		return $this;

	}

	

	/**

	 * Sets an inline CSS style on this element. If it's already set, the old value will be removed first

	 * @param string $name the CSS property name eg 'background-color'

	 * @param string $value the value of the property eg '#ff4400'

	 * @return $this

	 */

	public function setInlineStyle($name, $value)

	{

		$this->removeInlineStyle($name);

		$style = $this->getAttribute('style');

		$this->setAttribute('style', $style . $name . ':' . $value . ';');

		return $this;

	}

	

	/**

	 * Removes the inline CSS style specified by name

	 * @param string $name the name of the CSS property eg 'background-color'

	 * @return $this

	 */

	public function removeInlineStyle($name)

	{

		if(!$this->hasAttribute('style'))

			return;

		$style = $this->getAttribute('style');

		

		$rules = preg_split('/\s*;\s*/', $style);

		

		for($i = count($rules) - 1; $i >= 0; $i--)

		{

			$param = preg_quote($name);

			

			if(preg_match("/^$param\s*:/", trim($rules[$i])))

				unset($rules[$i]);

		}

		

		$this->setAttribute('style', implode(';', $rules));

		return $this;

	}

	

	/**

	 * Check if this element has an inline style by name

	 * @param string $name the name of the CSS property to test for

	 */

	public function hasInlineStyle($name)

	{

		if(!$this->hasAttribute('style'))

			return false;

		return preg_match("/\s*$name:.*?((;\s*)|$)/", $this->getAttribute('style'));

	}

	

	/**

	 * Gets the value of the inline style by name

	 * @param string $name the name of the CSS property you want the value for

	 * @return mixed FALSE if there is no style property or no style with that name exists, or a string containing the property value if it does

	 */

	public function getInlineStyle($name)

	{

		if(!$this->hasAttribute('style'))

			return false;

			

		$m = null;

		if(!preg_match("/\s*$name:(.*?)((;\s*)|$)/", $this->getAttribute('style')))

			return false;

			

		return $m[1];

	}

	

	/**

	 * Adds a class to this elements class attribute. It will be ignored if the class already exists

	 * @param string $name The classname

	 * @return $this

	 */

	public function addClass($name)

	{

		if($this->hasClass($name))

			return;

			

		$class = ($this->hasAttribute('class') ? $this->getAttribute('class') : '');

		$this->setAttribute('class', $class . (strlen($class) > 0 ? ' ' : '') . $name);

		

		return $this;

	}

	

	/**

	 * Removes the specified class from this nodes class attribute

	 * @param string $name The classname

	 * @return $this

	 */

	public function removeClass($name)

	{

		if(!$this->hasAttribute('class'))

			return;

			

		$class = trim(

				preg_replace('/\s{2,}/', ' ',

					preg_replace('/\\b' . $name . '\\b/', ' ', $this->getAttribute('class'))

				)

			);

			

		$this->setAttribute('class', $class);

		

		return $this;

	}

	

	/**

	 * Tests if the specified class exists on this elements class attribute

	 * @param string $name The classname

	 * @return boolean FALSE if no such class existst, TRUE if it does

	 */

	public function hasClass($name)

	{

		if(!$this->hasAttribute('class'))

			return false;

			

		return preg_match('/\\b' . $name . '\\b/', $this->getAttribute('class'));

	}

	

	/**

	 * Populates the target element. If it is a form element, the value will be set according to the elements type/nodeName. If not, the value will be imported instead.

	 * @param The target element. Usually a descendant of this element

	 * @param string $key the key of the value we're populating with, used for formatting

	 * @param string $value the value to populate with

	 * @param array $formatters an array associative of functions to format certain values with, functions should be specified by key

	 * @return void

	 */

	protected function populateElement($target, $key, $value, $formatters)

	{

		if(!($target instanceof \DOMElement))

			throw new \Exception('Argument must be a DOMElement');

		

		switch(strtolower($target->nodeName))

		{

			case 'textarea':

			case 'select':

			case 'input':

				$target->setValue($value);

				break;

				

			case 'img':

				$target->setAttribute('src', $value);

				break;

				

			default:

				if(!is_null($formatters) && isset($formatters[$key]))

					$value = $formatters[$key]($value);

					

				if($value instanceof \DateTime)

					$value = $value->format('D jS M \'y g:ia');

					

				if($key == 'price')

					$value = number_format($value, 2, '.', '');

					

				if(is_object($value))

					throw new \Exception('Expected simple type in "'.$key.'" => "'.print_r($value,true).'"');

				

				$target->import( $value );

				

				break;

		}

	}

	

	/**

	 * Takes a source object or associative array and optional array of formatting functions, and populates descendant named elements with the values from that source.

	 * @param mixed $src Associative array or object with the keys and values

	 * @param array $formatters Optional associative array of functions to format values with. The keys on this array correspond with the keys on $src

	 * @return DOMElement This element

	 */

	public function populate($src=null, $formatters=null)

	{

		$x = new \DOMXPath($this->ownerDocument);

		

		if(!$src)

			return $this;

		

		if(is_scalar($src))

		{

			$this->appendText($src);

			return $this;

		}

		

		foreach($src as $key => $value)

		{

			if(is_array($value))

			{

				$m = $x->query('descendant-or-self::*[@name="' . $key . '[]"]', $this);

				

				if($m->length > 0 && count($value) != $m->length)

				{

					if($src = $m->item(0)->closest('li,tr'))

					{

						for($i = $m->length; $i < count($value); $i++)

						{

							$item = $src->cloneNode(true);

							$src->parentNode->appendChild($item);

						}

						$m = $x->query('descendant-or-self::*[@name="' . $key . '[]"]', $this);

					}

					else

						throw new \Exception('Number of elements must match (' . count($value) . ' != ' . $m->length . ')');

				}

				

				for($i = 0; $i < $m->length; $i++)

					$this->populateElement($m->item($i), $key, $value[$i], $formatters);

			}

			else

			{

				$m = $x->query('descendant-or-self::*[@name="' . $key . '" or @data-name="' . $key . '"]', $this);

				

				for($i = 0; $i < $m->length; $i++)

					$this->populateElement($m->item($i), $key, $value, $formatters);

			}

		}

		

		return $this;

	}

	

	/**

	 * Gets the value of this element

	 * @return mixed A string if the element a text input, textarea or plain node, a boolean if the element is a checkbox or radio, or the value of the selected option if this element is a select

	 */

	public function getValue()

	{

		switch(strtolower($this->nodeName))

		{

			case 'input':

				$type = ($this->hasAttribute('type') ? $this->getAttribute('type') : 'text');

				switch($type)

				{

					case 'radio':

					case 'checkbox':

						return $this->hasAttribute('checked');

						break;

					

					default:

						return $this->getAttribute('value');

						break;

				}

				break;

				

			case 'select':

				$option = $this->querySelector('option[selected]');

				if(!$option)

					return null;

				

				if($option->hasAttribute('value'))

					return $option->getAttribute('value');

				

			default:

				return $this->nodeValue;

				break;

		}

	}

	 

	/**

	 * Sets the value of this element. Intended for form elements only. If this element is a textarea, it will be appended as plain text. If this element is a select, it will attempt to select the option with the specified value. If the input is a radio or checkbox, it will set it accordingly. Otherwise, the value will be put into the value attribute

	 * @throws \Exception If this element is a select, SMART_STRICT_MODE is declared and no option with that value exists

	 * @throws \Exception If you call this method on a non-form element

	 * @return This element

	 */

	public function setValue($value)

	{

		switch(strtolower($this->nodeName))

		{

			case 'textarea':

				$this->clear();

				$this->appendText( $value );

				break;

			

			case 'select':

				$deselect = $this->querySelectorAll('option[selected]');

				foreach($deselect as $d)

					$d->removeAttribute('selected');

				

				if($value === null)

					return $this;

				

				$option = $this->querySelector('option[value="' . $value . '"]');

				

				if(!$option)

					trigger_error('Option with value "' . $value . '" not found in "' . ($this->getAttribute('name')) . '"', E_USER_WARNING);

				else

					$option->setAttribute('selected', 'selected');

				

				break;

				

			case 'input':

				if(!$this->hasAttribute('type') || $this->getAttribute('type') == 'text')

				{

					if(is_string($value))

						$this->setAttribute('value', $value);

				}

				else switch(strtolower($this->getAttribute('type')))

				{

					case 'radio':

						if($this->hasAttribute('value') && $this->getAttribute('value') == $value)

							$this->setAttribute('checked', 'checked');

						else

							$this->removeAttribute('checked');

						break;

						

					case 'checkbox':

						if(!empty($value) && $value != false)

							$this->setAttribute('checked', 'checked');

						else

							$this->removeAttribute('checked');

						break;

						

					default:

						$this->setAttribute('value', $value);

						break;

				}

				break;

				

			default:

				throw new \Exception('Not yet implemented');

				

				$this->nodeValue = $value;

				break;

		}

		

		return $this;

	}

	

	/**

	 * Appends the specified text to this element, shorthand utility function

	 * @return \Smart\Element This element 

	 */

	public function appendText($text)

	{

		$this->appendChild( $this->ownerDocument->createTextNode( $text ) );

		return $this;

	}



	/**

	 * Utility function to append the specified element after one of this elements children. Will append at the end if after is null

	 * @param \Smart\Element the element to insert

	 * @param \Smart\Element one of this elements children, or null

	 * *@return \Smart\Element this element

	 */

	public function insertAfter($elem, $after=null)

	{

		if($after->parentNode && $after->parentNode !== $this)

			throw new \Exception('Hierarchy error');

		

		if($after->nextSibling)

			$this->insertBefore($elem, $after->nextSibling);

		else

			$this->appendChild($elem);

		

		return $this;

	}

	

	/**

	 * Clears this element, completely removing all it's contents

	 * @return \Smart\Element This element

	 */

	public function clear()

	{

		while($this->childNodes->length)

			$this->removeChild($this->firstChild);

		return $this;

	}

	 

	/**

	 * Removes this element from it's parent

	 * @return \Smart\Element This element

	 */

	public function remove()

	{

		if($this->parentNode)

			$this->parentNode->removeChild($this);

		return $this;

	}

}



?>