<?php
declare( strict_types = 1 );

namespace Wikimedia\Parsoid\Wt2Html\DOM\Processors;

use Wikimedia\Assert\UnreachableException;
use Wikimedia\Parsoid\Config\Env;
use Wikimedia\Parsoid\DOM\Comment;
use Wikimedia\Parsoid\DOM\DocumentFragment;
use Wikimedia\Parsoid\DOM\Element;
use Wikimedia\Parsoid\DOM\Node;
use Wikimedia\Parsoid\DOM\Text;
use Wikimedia\Parsoid\NodeData\TempData;
use Wikimedia\Parsoid\Utils\DOMCompat;
use Wikimedia\Parsoid\Utils\DOMDataUtils;
use Wikimedia\Parsoid\Utils\DOMUtils;
use Wikimedia\Parsoid\Wt2Html\Wt2HtmlDOMProcessor;

class PWrap implements Wt2HtmlDOMProcessor {

	/**
	 * Flattens an array with other arrays for elements into
	 * an array without nested arrays.
	 *
	 * @param array[] $a
	 * @return array
	 */
	private function flatten( array $a ): array {
		return $a === [] ? [] : array_merge( ...$a );
	}

	private static function pWrapOptionalChildren( Element $elt ): bool {
		foreach ( $elt->childNodes as $c ) {
			if ( !self::pWrapOptional( $c ) ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Is a P-wrapper optional for this node?
	 *
	 * The following nodes do not need p wrappers of their own:
	 * - whitespace nodes
	 * - comment nodes
	 * - HTML metadata tags generated by wikitext (not always rendering-transparent)
	 *   and these metatags don't need p-wrappers of their own. Both Remex and Parsoid
	 *   have identical p-wrapping behavior on these tags. This is a superset of
	 *   \\MediaWiki\Tidy\RemexCompatMunger::$metadataElements.
	 * - parsoid-added span wrappers around pwrap-optional nodes
	 *
	 * @param Node $n
	 * @return bool
	 */
	public static function pWrapOptional( Node $n ): bool {
		return $n instanceof Comment ||
			( $n instanceof Text && preg_match( '/^\s*$/D', $n->nodeValue ) ) ||
			(
				$n instanceof Element &&
				( DOMUtils::isMetaDataTag( $n ) || (
					DOMDataUtils::getDataParsoid( $n )->getTempFlag( TempData::WRAPPER ) &&
					self::pWrapOptionalChildren( $n )
				) )
			);
	}

	/**
	 * Can we split the subtree rooted at $n into multiple adjacent
	 * subtrees rooted in a clone of $n where each of those subtrees
	 * get a contiguous subset of $n's children?
	 *
	 * This is probably equivalent to asking if this node supports the
	 * adoption agency algorithm in the HTML5 spec.
	 *
	 * @param Node $n
	 * @return bool
	 */
	private function isSplittableTag( Node $n ): bool {
		// Seems safe to split span, sub, sup, cite tags
		//
		// However, if we want to mimic Parsoid and HTML5 spec
		// precisely, we should only use isFormattingElt(n)
		return DOMUtils::isFormattingElt( $n );
	}

	/**
	 * Merge a contiguous run of split subtrees that have identical pwrap properties
	 *
	 * @param Element $n
	 * @param array $a
	 * @return array
	 */
	private function mergeRuns( Element $n, array $a ): array {
		$ret = [];
		// This flag should be transferred to the rightmost
		// clone of this node in the loop below.
		$ndp = DOMDataUtils::getDataParsoid( $n );
		$origAIEnd = $ndp->autoInsertedEnd ?? null;
		$origEndTSR = $ndp->tmp->endTSR ?? null;
		$i = -1;
		foreach ( $a as $v ) {
			if ( $i < 0 ) {
				$ret[] = [ 'pwrap' => $v['pwrap'], 'node' => $n ];
				$i++;
			} elseif ( $ret[$i]['pwrap'] === null ) {
				// @phan-suppress-previous-line PhanTypeInvalidDimOffset
				$ret[$i]['pwrap'] = $v['pwrap'];
			} elseif ( $ret[$i]['pwrap'] !== $v['pwrap'] && $v['pwrap'] !== null ) {
				// @phan-suppress-previous-line PhanTypeInvalidDimOffset
				// @phan-suppress-next-line PhanTypeInvalidDimOffset
				$dp = DOMDataUtils::getDataParsoid( $ret[$i]['node'] );
				$dp->autoInsertedEnd = true;
				unset( $dp->tmp->endTSR );
				$cnode = DOMDataUtils::cloneNode( $n, false );
				$ret[] = [ 'pwrap' => $v['pwrap'], 'node' => $cnode ];
				$i++;
				DOMDataUtils::getDataParsoid( $ret[$i]['node'] )->autoInsertedStart = true;
			}
			$ret[$i]['node']->appendChild( $v['node'] );
		}
		if ( $i >= 0 ) {
			$dp = DOMDataUtils::getDataParsoid( $ret[$i]['node'] );
			if ( $origAIEnd ) {
				$dp->autoInsertedEnd = true;
				unset( $dp->tmp->endTSR );
			} else {
				unset( $dp->autoInsertedEnd );
				if ( $origEndTSR ) {
					$dp->getTemp()->endTSR = $origEndTSR;
				}
			}
		}

		return $ret;
	}

	/**
	 * Implements the split operation described in the algorithm below.
	 *
	 * The values of 'pwrap' here bear out in pWrapDOM below.
	 *
	 *  true: opens a paragaph or continues adding to a paragraph
	 *  false: closes a paragraph
	 *  null: agnostic, doesn't open or close a paragraph
	 *
	 * @param Node $n
	 * @return array
	 */
	private function split( Node $n ): array {
		if ( $this->pWrapOptional( $n ) ) {
			// Set 'pwrap' to null so p-wrapping doesn't break
			// a run of wrappable nodes because of these.
			return [ [ 'pwrap' => null, 'node' => $n ] ];
		} elseif ( $n instanceof Text ) {
			return [ [ 'pwrap' => true, 'node' => $n ] ];
		} elseif ( !$this->isSplittableTag( $n ) || count( $n->childNodes ) === 0 ) {
			// block tag OR non-splittable inline tag
			return [
				[ 'pwrap' => !DOMUtils::hasBlockTag( $n ), 'node' => $n ]
			];
		} else {
			DOMUtils::assertElt( $n );
			// splittable inline tag
			// split for each child and merge runs
			$children = $n->childNodes;
			$splits = [];
			foreach ( $children as $child ) {
				$splits[] = $this->split( $child );
			}
			return $this->mergeRuns( $n, $this->flatten( $splits ) );
		}
	}

	/**
	 * Wrap children of '$root' with paragraph tags
	 * so that the final output has the following properties:
	 *
	 * 1. A paragraph will have at least one non-whitespace text
	 *    node or an non-block element node in its subtree.
	 *
	 * 2. Two paragraph nodes aren't siblings of each other.
	 *
	 * 3. If a child of $root is not a paragraph node, it is one of:
	 * - a white-space only text node
	 * - a comment node
	 * - a block element
	 * - a splittable inline element which has some block node
	 *   on *all* paths from it to all leaves in its subtree.
	 * - a non-splittable inline element which has some block node
	 *   on *some* path from it to a leaf in its subtree.
	 *
	 * This output is generated with the following algorithm
	 *
	 * 1. Block nodes are skipped over
	 * 2. Non-splittable inline nodes that have a block tag
	 *    in its subtree are skipped over.
	 * 3. A splittable inline node, I, that has at least one block tag
	 *    in its subtree is split into multiple tree such that
	 *    - each new tree is $rooted in I
	 *    - the trees alternate between two kinds
	 *    (a) it has no block node inside
	 *        => pwrap is true
	 *    (b) all paths from I to its leaves have some block node inside
	 *        => pwrap is false
	 * 4. A paragraph tag is wrapped around adjacent runs of comment nodes,
	 *    text nodes, and an inline node that has no block node embedded inside.
	 *    This paragraph tag does not start with nodes for which p-wrapping is
	 *    optional (as determined by the pWrapOptional helper). The current
	 *    algorithm also ensures that it doesn't end with one of those either
	 *    (if it impacts template / param / annotation range building).
	 *
	 * @param Element|DocumentFragment $root
	 */
	private function pWrapDOM( Node $root ) {
		$state = new PWrapState();
		$c = $root->firstChild;
		while ( $c ) {
			$next = $c->nextSibling;
			if ( DOMUtils::isRemexBlockNode( $c ) ) {
				$state->reset();
			} else {
				$vs = $this->split( $c );
				foreach ( $vs as $v ) {
					$n = $v['node'];
					if ( $v['pwrap'] === false ) {
						$state->reset();
						$root->insertBefore( $n, $next );
					} elseif ( $v['pwrap'] === null ) {
						if ( $state->p ) {
							$state->p->appendChild( $n );
							$state->processOptionalNode( $n );
						} else {
							$root->insertBefore( $n, $next );
						}
					} elseif ( $v['pwrap'] === true ) {
						if ( !$state->p ) {
							$state->p = $root->ownerDocument->createElement( 'p' );
							$root->insertBefore( $state->p, $next );
						}
						$state->p->appendChild( $n );
					} else {
						throw new UnreachableException( 'Unexpected value for pwrap.' );
					}
				}
			}
			$c = $next;
		}
		$state->reset();
	}

	/**
	 * This function walks the DOM tree $rooted at '$root'
	 * and uses pWrapDOM to add appropriate paragraph wrapper
	 * tags around children of nodes with tag name '$tagName'.
	 *
	 * @param Element|DocumentFragment $root
	 * @param string $tagName
	 */
	private function pWrapInsideTag( Node $root, string $tagName ) {
		$c = $root->firstChild;
		while ( $c ) {
			$next = $c->nextSibling;
			if ( $c instanceof Element ) {
				if ( DOMCompat::nodeName( $c ) === $tagName ) {
					$this->pWrapDOM( $c );
				} else {
					$this->pWrapInsideTag( $c, $tagName );
				}
			}
			$c = $next;
		}
	}

	/**
	 * Wrap children of <body> as well as children of
	 * <blockquote> found anywhere in the DOM tree.
	 *
	 * @inheritDoc
	 */
	public function run(
		Env $env, Node $root, array $options = [], bool $atTopLevel = false
	): void {
		// No p-wrapping in an inline context!
		if ( !empty( $options['inlineContext'] ) ) {
			return;
		}

		'@phan-var Element|DocumentFragment $root';  // @var Element|DocumentFragment $root
		$this->pWrapDOM( $root );
		$this->pWrapInsideTag( $root, 'blockquote' );
	}
}
