<?php

namespace RemexHtml\TreeBuilder;

use RemexHtml\HTMLData;
use RemexHtml\PropGuard;
use RemexHtml\Tokenizer\Attributes;
use RemexHtml\Tokenizer\PlainAttributes;
use RemexHtml\Tokenizer\Tokenizer;

/**
 * TreeBuilder is the receiver of events from the InsertionMode subclasses,
 * and is responsible for forwarding events on to the TreeHandler, which is
 * responsible for constructing a DOM.
 *
 * TreeBuilder contains most of the state referred to by the "tree construction"
 * part of the HTML spec which is not handled elsewhere, such as the stack of
 * open elements and the list of active formatting elements.
 *
 * Miscellaneous helpers for InsertionMode subclasses are also in this class.
 *
 * https://www.w3.org/TR/2016/REC-html51-20161101/syntax.html
 */
class TreeBuilder {
	use PropGuard;

	// Quirks
	const NO_QUIRKS = 0;
	const LIMITED_QUIRKS = 1;
	const QUIRKS = 2;

	// Insertion placement
	const BEFORE = 0;
	const UNDER = 1;
	const ROOT = 2;

	// Configuration
	public $isIframeSrcdoc;
	public $scriptingFlag;
	public $ignoreErrors;
	public $ignoreNulls;

	// Objects
	public $handler;
	public $stack;
	public $afe;
	public $tokenizer;

	// State
	public $isFragment = false;
	public $fragmentContext;
	public $headElement;
	public $formElement;
	public $framesetOK = true;
	public $quirks = self::NO_QUIRKS;
	public $fosterParenting = false;
	public $pendingTableCharacters = [];

	private static $fosterTriggers = [
		'table' => true,
		'tbody' => true,
		'tfoot' => true,
		'thead' => true,
		'tr' => true
	];

	private static $impliedEndTags = [
		'dd' => true,
		'dt' => true,
		'li' => true,
		'option' => true,
		'optgroup' => true,
		'p' => true,
		'rb' => true,
		'rp' => true,
		'rt' => true,
		'rtc' => true,
	];

	private static $thoroughlyImpliedEndTags = [
		'caption' => true,
		'colgroup' => true,
		'dd' => true,
		'dt' => true,
		'li' => true,
		'optgroup' => true,
		'option' => true,
		'p' => true,
		'rb' => true,
		'rp' => true,
		'rt' => true,
		'rtc' => true,
		'tbody' => true,
		'td' => true,
		'tfoot' => true,
		'th' => true,
		'thead' => true,
		'tr' => true
	];

	public function __construct( TreeHandler $handler, $options = [] ) {
		$this->handler = $handler;
		$this->afe = new ActiveFormattingElements;
		$options = $options + [
			'isIframeSrcdoc' => false,
			'scriptingFlag' => true,
			'ignoreErrors' => false,
			'ignoreNulls' => false,
			'scopeCache' => true,
		];

		$this->isIframeSrcdoc = $options['isIframeSrcdoc'];
		$this->scriptingFlag = $options['scriptingFlag'];
		$this->ignoreErrors = $options['ignoreErrors'];
		$this->ignoreNulls = $options['ignoreNulls'];

		if ( $options['scopeCache'] ) {
			$this->stack = new CachingStack;
		} else {
			$this->stack = new SimpleStack;
		}
	}

	public function startDocument( Tokenizer $tokenizer, $namespace, $name ) {
		$tokenizer->setEnableCdataCallback(
			function () {
				$acn = $this->adjustedCurrentNode();
				return $acn && $acn->namespace !== HTMLData::NS_HTML;
			}
		);
		$this->tokenizer = $tokenizer;

		$this->handler->startDocument( $namespace, $name );
		if ( $namespace !== null ) {
			$this->isFragment = true;
			$this->fragmentContext = new Element( $namespace, $name, new PlainAttributes );
			$this->fragmentContext->isVirtual = true;
			$html = new Element( HTMLData::NS_HTML, 'html', new PlainAttributes );
			$html->isVirtual = true;
			$this->stack->push( $html );
			$this->handler->insertElement( self::ROOT, null, $html, false, 0, 0 );
		}
	}

	/**
	 * Get the adjusted current node
	 * @return Element|null
	 */
	public function adjustedCurrentNode() {
		$current = $this->stack->current;
		if ( $this->isFragment && ( !$current || $current->stackIndex === 0 ) ) {
			return $this->fragmentContext;
		} else {
			return $current;
		}
	}

	private function appropriatePlace( $target = null ) {
		$stack = $this->stack;
		if ( $target === null ) {
			$target = $stack->current;
		}
		if ( $target === null ) {
			return [ self::ROOT, null ];
		}
		if ( !$this->fosterParenting ) {
			return [ self::UNDER, $target ];
		}
		if ( !isset( self::$fosterTriggers[$target->htmlName] ) ) {
			return [ self::UNDER, $target ];
		}
		$node = null;
		for ( $idx = $this->stack->length() - 1; $idx >= 0; $idx-- ) {
			$node = $this->stack->item( $idx );
			if ( $node->htmlName === 'table' && $idx >= 1 ) {
				return [ self::BEFORE, $node ];
			}
			if ( $node->htmlName === 'template' ) {
				return [ self::UNDER, $node ];
			}
		}
		return [ self::UNDER, $node ];
	}

	public function insertCharacters( $text, $start, $length, $sourceStart, $sourceLength ) {
		list( $prep, $ref ) = $this->appropriatePlace();
		$this->handler->characters( $prep, $ref, $text, $start, $length,
			$sourceStart, $sourceLength );
	}

	public function insertElement( $name, Attributes $attrs, $void, $sourceStart, $sourceLength ) {
		return $this->insertForeign( HTMLData::NS_HTML, $name, $attrs, $void,
			$sourceStart, $sourceLength );
	}

	public function insertForeign( $ns, $name, Attributes $attrs, $void,
		$sourceStart, $sourceLength
	) {
		list( $prep, $ref ) = $this->appropriatePlace();
		$element = new Element( $ns, $name, $attrs );
		$this->handler->insertElement( $prep, $ref, $element, $void,
			$sourceStart, $sourceLength );
		if ( !$void ) {
			$this->stack->push( $element );
		}
		return $element;
	}

	/**
	 * Pop the current node from the stack of open elements, and notify the
	 * handler that we are done with that node.
	 * @param int $sourceStart
	 * @param int $sourceLength
	 * @return Element
	 */
	public function pop( $sourceStart, $sourceLength ) {
		$element = $this->stack->pop();
		$this->handler->endTag( $element, $sourceStart, $sourceLength );
		return $element;
	}

	public function doctype( $name, $public, $system, $quirks, $sourceStart, $sourceLength ) {
		$this->handler->doctype( $name, $public, $system, $quirks, $sourceStart, $sourceLength );
		$this->quirks = $quirks;
	}

	public function comment( $place, $text, $sourceStart, $sourceLength ) {
		list( $prep, $ref ) = $place !== null ? $place : $this->appropriatePlace();
		$this->handler->comment( $prep, $ref, $text, $sourceStart, $sourceLength );
	}

	public function error( $text, $pos ) {
		if ( !$this->ignoreErrors ) {
			$this->handler->error( $text, $pos );
		}
	}

	public function mergeAttributes( Element $elt, Attributes $attrs, $sourceStart, $sourceLength ) {
		if ( $attrs->count() && !$elt->isVirtual ) {
			$this->handler->mergeAttributes( $elt, $attrs, $sourceStart, $sourceLength );
		}
	}

	public function closePInButtonScope( $pos ) {
		if ( $this->stack->isInButtonScope( 'p' ) ) {
			$this->generateImpliedEndTagsAndPop( 'p', $pos, 0 );
		}
	}

	/**
	 * Check the stack to see if there is any element which is not on the list
	 * of allowed elements. Raise an error if any are found.
	 *
	 * @param array $allowed An array with the HTML element names in the key
	 * @param int $pos
	 */
	public function checkUnclosed( $allowed, $pos ) {
		if ( $this->ignoreErrors ) {
			return;
		}

		$stack = $this->stack;
		$unclosedErrors = [];
		for ( $i = $stack->length() - 1; $i >= 0; $i-- ) {
			$unclosedName = $stack->item( $i )->htmlName;
			if ( !isset( $allowed[$unclosedName] ) ) {
				$unclosedErrors[$unclosedName] = true;
			}
		}
		if ( $unclosedErrors ) {
			$names = implode( ', ', array_keys( $unclosedErrors ) );
			$this->error( "closing unclosed $names", $pos );
		}
	}

	/**
	 * Reconstruct the active formatting elements.
	 * @author C. Scott Ananian, Tim Starling
	 * @param int $sourceStart
	 */
	public function reconstructAFE( $sourceStart ) {
		$entry = $this->afe->getTail();
		// If there are no entries in the list of active formatting elements,
		// then there is nothing to reconstruct
		if ( !$entry ) {
			return;
		}
		// If the last is a marker, do nothing.
		if ( $entry instanceof Marker ) {
			return;
		}
		// Or if it is an open element, do nothing.
		if ( $entry->stackIndex !== null ) {
			return;
		}

		// Loop backward through the list until we find a marker or an
		// open element
		$foundIt = false;
		while ( $entry->prevAFE ) {
			$entry = $entry->prevAFE;
			if ( $entry instanceof Marker || $entry->stackIndex !== null ) {
				$foundIt = true;
				break;
			}
		}

		// Now loop forward, starting from the element after the current one (or
		// the first element if we didn't find a marker or open element),
		// recreating formatting elements and pushing them back onto the list
		// of open elements.
		if ( $foundIt ) {
			$entry = $entry->nextAFE;
		}
		do {
			$newElement = $this->insertForeign( HTMLData::NS_HTML, $entry->name,
				$entry->attrs, false, $sourceStart, 0 );
			$this->afe->replace( $entry, $newElement );
			$entry = $newElement->nextAFE;
		} while ( $entry );
	}

	private function trace( $msg ) {
		// print "[AAA] $msg\n";
	}

	/**
	 * Run the "adoption agency algorithm" (AAA) for the given subject
	 * tag name.
	 * @author C. Scott Ananian, Tim Starling
	 *
	 * @param string $subject The subject tag name.
	 * @param int $sourceStart
	 * @param int $sourceLength
	 */
	public function adoptionAgency( $subject, $sourceStart, $sourceLength ) {
		$afe = $this->afe;
		$stack = $this->stack;
		$handler = $this->handler;

		// If the current node is an HTML element whose tag name is subject,
		// and the current node is not in the list of active formatting
		// elements, then pop the current node off the stack of open
		// elements and abort these steps. [1]
		if (
			$stack->current->htmlName === $subject &&
			!$afe->isInList( $stack->current )
		) {
			$this->pop( $sourceStart, $sourceLength );
			return;
		}
		$this->trace( "AAA invoked on $subject" );

		// Outer loop: If outer loop counter is greater than or
		// equal to eight, then abort these steps. [2-4]
		for ( $outer = 0; $outer < 8; $outer++ ) {
			$this->trace( "Outer $outer" );
			$this->trace( "AFE\n" . $afe->dump() . "STACK\n" . $stack->dump() );

			// Let the formatting element be the last element in the list
			// of active formatting elements that: is between the end of
			// the list and the last scope marker in the list, if any, or
			// the start of the list otherwise, and has the same tag name
			// as the token. [5]
			$fmtElt = $afe->findElementByName( $subject );

			// If there is no such node, then abort these steps and instead
			// act as described in the "any other end tag" entry above.
			if ( !$fmtElt ) {
				$this->anyOtherEndTag( $subject, $sourceStart, $sourceLength );
				return;
			}

			// Otherwise, if there is such a node, but that node is not in
			// the stack of open elements, then this is a parse error;
			// remove the element from the list, and abort these steps. [6]
			$fmtEltIndex = $fmtElt->stackIndex;
			if ( $fmtEltIndex === null ) {
				$this->error( 'closing tag matched an active formatting element ' .
					'which is not in the stack', $sourceStart );
				$afe->remove( $fmtElt );
				return;
			}

			// Otherwise, if there is such a node, and that node is also in
			// the stack of open elements, but the element is not in scope,
			// then this is a parse error; ignore the token, and abort
			// these steps. [7]
			if ( !$stack->isElementInScope( $fmtElt ) ) {
				$this->error( 'end tag matched a start tag which is not in scope',
					$sourceStart );
				return;
			}

			// If formatting element is not the current node, this is a parse
			// error. (But do not abort these steps.) [8]
			if ( $fmtElt !== $stack->current ) {
				$this->error( 'end tag matched a formatting element which was ' .
					'not the current node', $sourceStart );
			}

			// Let the furthest block be the topmost node in the stack of
			// open elements that is lower in the stack than the formatting
			// element, and is an element in the special category. There
			// might not be one. [9]
			$furthestBlock = null;
			$furthestBlockIndex = -1;
			$stackLength = $stack->length();

			for ( $i = $fmtEltIndex + 1; $i < $stackLength; $i++ ) {
				$item = $stack->item( $i );
				if ( isset( HTMLData::$special[$item->namespace][$item->name] ) ) {
					$furthestBlock = $item;
					$furthestBlockIndex = $i;
					break;
				}
			}

			// If there is no furthest block, then the UA must skip the
			// subsequent steps and instead just pop all the nodes from the
			// bottom of the stack of open elements, from the current node up
			// to and including the formatting element, and remove the
			// formatting element from the list of active formatting
			// elements. [10]
			if ( !$furthestBlock ) {
				$this->trace( "no furthest block" );
				$this->popAllUpToElement( $fmtElt, $sourceStart, $sourceLength );
				$afe->remove( $fmtElt );
				return;
			}

			$this->trace( "furthestBlock = " . $furthestBlock->getDebugTag() );

			// Let the common ancestor be the element immediately above the
			// formatting element in the stack of open elements. [11]
			$ancestor = $stack->item( $fmtEltIndex - 1 );

			// Let a bookmark note the position of the formatting element in
			// the list of active formatting elements relative to the elements
			// on either side of it in the list. [12]
			$bookmark = new Marker( 'bookmark' );
			$afe->insertAfter( $fmtElt, $bookmark );

			// Let node and last node be the furthest block. [13]
			$lastNode = $furthestBlock;
			$nodeIndex = $furthestBlockIndex;
			$isAFE = false;
			$stackRemovals = [];
			$insertions = [];

			// Inner loop
			for ( $inner = 1; true; $inner++ ) {
				// Let node be the element immediately above node in the stack
				// of open elements, or if node is no longer in the stack of
				// open elements (e.g. because it got removed by this
				// algorithm), the element that was immediately above node in
				// the stack of open elements before node was removed. [13.3]
				$node = $stack->item( --$nodeIndex );

				// If node is the formatting element, then go to the next step
				// in the overall algorithm. [13.4]
				if ( $node === $fmtElt ) {
					break;
				}
				$this->trace( "inner $inner, {$node->getDebugTag()} is not fmtElt" );

				// If the inner loop counter is greater than three and node
				// is in the list of active formatting elements, then remove
				// node from the list of active formatting elements. [13.5]
				$isAFE = $afe->isInList( $node );
				if ( $inner > 3 && $isAFE ) {
					$afe->remove( $node );
					$isAFE = false;
				}

				// If node is not in the list of active formatting elements,
				// then remove node from the stack of open elements and then
				// go back to the step labeled inner loop. [13.6]
				if ( !$isAFE ) {
					$stackRemovals[$nodeIndex] = true;
					continue;
				}

				// Create an element for the token for which the element node
				// was created with common ancestor as the intended parent,
				// replace the entry for node in the list of active formatting
				// elements with an entry for the new element, replace the
				// entry for node in the stack of open elements with an entry
				// for the new element, and let node be the new element. [13.7]
				$newElt = new Element(
					$node->namespace, $node->name, $node->attrs );
				$afe->replace( $node, $newElt );
				$stack->replace( $node, $newElt );
				$node = $newElt;

				// If last node is the furthest block, then move the
				// aforementioned bookmark to be immediately after the new node
				// in the list of active formatting elements. [13.8]
				if ( $lastNode === $furthestBlock ) {
					$afe->remove( $bookmark );
					$afe->insertAfter( $newElt, $bookmark );
				}

				// Insert last node into node, first removing it from its
				// previous parent node if any. [13.9]
				$insertions[] = [ self::UNDER, $node, $lastNode ];

				// Let last node be node. [13.10]
				$lastNode = $node;
			}

			// Insert whatever last node ended up being in the previous step at
			// the appropriate place for inserting a node, but using common
			// ancestor as the override target. [14]
			list( $prep, $ref ) = $this->appropriatePlace( $ancestor );
			$insertions[] = [ $prep, $ref, $lastNode ];

			// Execute queued insertions in reverse order.
			// This has the same effect but allows the handler to assume that
			// elements are always in the tree.
			for ( $i = count( $insertions ) - 1; $i >= 0; $i-- ) {
				$ins = $insertions[$i];
				$handler->insertElement( $ins[0], $ins[1], $ins[2], false, $sourceStart, 0 );
			}

			// Create an element for the token for which the formatting element
			// was created, with furthest block as the intended parent. [15]
			$newElt2 = new Element(
				$fmtElt->namespace, $fmtElt->name, $fmtElt->attrs );

			// Take all of the child nodes of the furthest block and append
			// them to the element created in the last step. [16]
			// Append the new element to the furthest block. [17]
			$handler->reparentChildren( $furthestBlock, $newElt2, $sourceStart );

			// Remove the formatting element from the list of active formatting
			// elements, and insert the new element into the list of active
			// formatting elements at the position of the aforementioned
			// bookmark. [18]
			$afe->remove( $fmtElt );
			$afe->replace( $bookmark, $newElt2 );

			// Remove the formatting element from the stack of open elements,
			// and insert the new element into the stack of open elements
			// immediately below the position of the furthest block in that
			// stack. [19]
			$this->trace( "Removing " . $stack->length() . "-" . ( $furthestBlockIndex + 1 ) );
			$this->trace( "Inserting the new element below $furthestBlockIndex" );
			$this->trace( "Removing stack elements " .
				implode( ', ', array_keys( $stackRemovals ) ) );

			// Make a temporary stack with the elements we are going to push back in
			$tempStack = [];

			// Stash the elements up to the furthest block
			for ( $index = $stack->length() - 1; $index > $furthestBlockIndex; $index-- ) {
				$tempStack[] = $stack->pop();
			}
			// Add the new element
			$tempStack[] = $newElt2;
			// Stash the elements up to the formatting element
			for ( 0; $index > $fmtEltIndex; $index-- ) {
				$elt = $stack->pop();
				// Drop elements previously marked for removal
				if ( isset( $stackRemovals[$index] ) ) {
					$this->trace( "ending marked node {$elt->getDebugTag()}" );
					$handler->endTag( $elt, $sourceStart, 0 );
				} else {
					$tempStack[] = $elt;
				}
			}
			// Remove the formatting element
			$elt = $stack->pop();
			$this->trace( "ending formatting element {$elt->getDebugTag()}" );
			$handler->endTag( $elt, $sourceStart, 0 );
			// Reinsert
			foreach ( array_reverse( $tempStack ) as $elt ) {
				$stack->push( $elt );
			}
		}
	}

	public function anyOtherEndTag( $name, $sourceStart, $sourceLength ) {
		$stack = $this->stack;
		for ( $index = $stack->length() - 1; $index >= 0; $index-- ) {
			$node = $stack->item( $index );
			if ( $node->htmlName === $name ) {
				$this->generateImpliedEndTags( $name, $sourceStart );
				// If node is not the current node, then this is a parse error
				if ( $node !== $stack->current ) {
					$this->error( 'end tag matched an element which was not the current node',
						$sourceStart );
				}
				// Pop all the nodes from the current node up to node, including
				// node, then stop these steps.
				for ( $j = $stack->length() - 1; $j > $index; $j-- ) {
					$elt = $stack->pop();
					$this->handler->endTag( $elt, $sourceStart, 0 );
				}
				$elt = $stack->pop();
				$this->handler->endTag( $elt, $sourceStart, $sourceLength );
				return;
			}

			// If node is in the special category, then this is a parse error;
			// ignore the token, and abort these steps
			if ( isset( HTMLData::$special[$node->namespace][$node->name] ) ) {
				$this->error( "cannot implicitly close a special element <{$node->htmlName}>",
					$sourceStart );
				return;
			}
		}
	}

	/**
	 * Generate implied end tags, optionally with an element to exclude.
	 *
	 * @param string|null $name The name to exclude
	 * @param int $pos The source position
	 */
	public function generateImpliedEndTags( $name, $pos ) {
		$stack = $this->stack;
		$current = $stack->current;
		while ( $current && $current->htmlName !== $name &&
		  isset( self::$impliedEndTags[$current->htmlName] )
		) {
			$popped = $stack->pop();
			$this->handler->endTag( $popped, $pos, 0 );
			$current = $stack->current;
		}
	}

	/**
	 * Generate all implied end tags thoroughly. This was introduced in
	 * HTML 5.1 in order to expand the set of elements which can be implicitly
	 * closed by a </template>.
	 * @param int $pos
	 */
	public function generateImpliedEndTagsThoroughly( $pos ) {
		$stack = $this->stack;
		$current = $stack->current;
		while ( $current && isset( self::$thoroughlyImpliedEndTags[$current->htmlName] ) ) {
			$popped = $stack->pop();
			$this->handler->endTag( $popped, $pos, 0 );
			$current = $stack->current;
		}
	}

	/**
	 * Generate implied end tags, with an element to exclude, and if the
	 * current element is not now the named excluded element, raise an error.
	 * Then, pop all elements until an element with the name is popped from
	 * the list.
	 *
	 * @param string $name The name to exclude
	 * @param int $sourceStart
	 * @param int $sourceLength
	 */
	public function generateImpliedEndTagsAndPop( $name, $sourceStart, $sourceLength ) {
		$this->generateImpliedEndTags( $name, $sourceStart );
		if ( $this->stack->current->htmlName !== $name ) {
			$this->error( "found </$name> but elements are open that cannot " .
				"have implied end tags, closing them", $sourceStart );
		}
		$this->popAllUpToName( $name, $sourceStart, $sourceLength );
	}

	public function popAllUpToElement( Element $elt, $sourceStart, $sourceLength ) {
		while ( true ) {
			$popped = $this->stack->pop();
			if ( !$popped ) {
				break;
			} elseif ( $popped === $elt ) {
				$this->handler->endTag( $popped, $sourceStart, $sourceLength );
				break;
			} else {
				$this->handler->endTag( $popped, $sourceStart, 0 );
			}
		}
	}

	public function popAllUpToName( $name, $sourceStart, $sourceLength ) {
		while ( true ) {
			$popped = $this->stack->pop();
			if ( !$popped ) {
				break;
			} elseif ( $popped->htmlName === $name ) {
				$this->handler->endTag( $popped, $sourceStart, $sourceLength );
				break;
			} else {
				$this->handler->endTag( $popped, $sourceStart, 0 );
			}
		}
	}

	public function popAllUpToNames( $names, $sourceStart, $sourceLength ) {
		while ( true ) {
			$popped = $this->stack->pop();
			if ( !$popped ) {
				break;
			} elseif ( isset( $names[$popped->htmlName] ) ) {
				$this->handler->endTag( $popped, $sourceStart, $sourceLength );
				break;
			} else {
				$this->handler->endTag( $popped, $sourceStart, 0 );
			}
		}
	}

	/**
	 * The "clear stack back to" algorithm used by several template insertion
	 * modes. Similar to popAllUpToName(), except that the named element is
	 * not popped, and a set of names is used instead of a single name.
	 *
	 * @param array $names
	 * @param int $pos
	 */
	public function clearStackBack( $names, $pos ) {
		$stack = $this->stack;
		while ( $stack->current && !isset( $names[$stack->current->htmlName] ) ) {
			$this->pop( $pos, 0 );
		}
		if ( !$stack->current ) {
			throw new TreeBuilderError( 'clearStackBack: stack is unexpectedly empty' );
		}
	}

	public function stopParsing( $pos ) {
		$stack = $this->stack;
		while ( $stack->current ) {
			$popped = $stack->pop();
			if ( !$this->isFragment || $popped->htmlName !== 'html' ) {
				$this->handler->endTag( $popped, $pos, 0 );
			}
		}
		$this->handler->endDocument( $pos );

		$this->afe = new ActiveFormattingElements;
		$this->headElement = null;
		$this->formElement = null;
		$this->tokenizer->setEnableCdataCallback( null );
		$this->tokenizer = null;
	}
}
