<?php
declare( strict_types = 1 );

namespace Wikimedia\Parsoid\Wt2Html\PP\Processors;

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\Utils\DOMCompat;
use Wikimedia\Parsoid\Utils\DOMDataUtils;
use Wikimedia\Parsoid\Utils\DOMUtils;
use Wikimedia\Parsoid\Utils\PHPUtils;
use Wikimedia\Parsoid\Wikitext\Consts;
use Wikimedia\Parsoid\Wt2Html\Wt2HtmlDOMProcessor;

class PWrap implements Wt2HtmlDOMProcessor {
	private const RANGE_TYPE_RE = '!^mw:(Transclusion(/|$)|Param(/|$)|Annotation/)!';

	/**
	 * 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 );
	}

	/**
	 * 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.
	 *
	 * @param Node $n
	 * @return bool
	 */
	private function pWrapOptional( Node $n ): bool {
		return ( $n instanceof Text && preg_match( '/^\s*$/D', $n->nodeValue ) ) ||
			$n instanceof Comment ||
			isset( Consts::$HTML['MetaDataTags'][DOMCompat::nodeName( $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 = $n->cloneNode();
				'@phan-var Element $cnode'; // @var Element $cnode
				if ( $n->hasAttribute( DOMDataUtils::DATA_OBJECT_ATTR_NAME ) ) {
					DOMDataUtils::setNodeData( $cnode, DOMDataUtils::getNodeData( $n )->clone() );
				}
				$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 ) );
		}
	}

	/**
	 * Unwrap a run of trailing nodes that don't need p-wrapping.
	 * This only matters for meta tags representing transclusions
	 * and annotations. Unwrapping can prevent unnecessary expansion
	 * of template/annotation ranges.
	 *
	 * @param ?Element $p
	 * @param array $seenTypes
	 *   Maps typeof+about attribute composite string to a node position.
	 *   This map/cache eliminates the need to look up matching types repeatedly.
	 */
	private function unwrapTrailingPWrapOptionalNodes( ?Element $p, array $seenTypes ) {
		if ( $p && count( $seenTypes ) > 0 ) {
			// We have potential hoistable meta tags
			$lastChild = $p->lastChild;
			while ( $this->pWrapOptional( $lastChild ) ) {
				$t = DOMUtils::matchNameAndTypeOf( $lastChild, 'meta', self::RANGE_TYPE_RE );
				if ( $t && str_ends_with( $t, '/End' ) ) {
					'@phan-var Element $lastChild';  // @var Element $lastChild
					// Check if one of its prior siblings has a matching opening tag.
					// If so, we are done with unwrapping here since we don't want to
					// hoist this closing tag by itself.
					$aboutId = $lastChild->getAttribute( 'about' );
					$endPosn = $seenTypes[$t . $aboutId]; // guaranteed to exist
					$openPosn = $seenTypes[substr( $t, 0, -4 ) . $aboutId] ?? null;
					if ( $openPosn !== null && $openPosn < $endPosn ) {
						return;
					}
				}
				$p->parentNode->insertBefore( $lastChild, $p->nextSibling );
				$lastChild = $p->lastChild;
			}
		}
	}

	/**
	 * 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 ) {
		$posn = 0;
		$p = null;
		$seenTypes = [];
		$c = $root->firstChild;
		while ( $c ) {
			$next = $c->nextSibling;
			if ( DOMUtils::isRemexBlockNode( $c ) ) {
				$this->unwrapTrailingPWrapOptionalNodes( $p, $seenTypes );
				$p = null;
				$seenTypes = [];
			} else {
				$vs = $this->split( $c );
				foreach ( $vs as $v ) {
					$n = $v['node'];
					if ( $v['pwrap'] === false ) {
						$this->unwrapTrailingPWrapOptionalNodes( $p, $seenTypes );
						$p = null;
						$seenTypes = [];
						$root->insertBefore( $n, $next );
					} elseif ( $v['pwrap'] === null ) {
						if ( $p ) {
							$p->appendChild( $n );
							$t = DOMUtils::matchNameAndTypeOf( $n, 'meta', self::RANGE_TYPE_RE );
							if ( $t ) {
								$seenTypes[$t . $n->getAttribute( 'about' )] = $posn++;
							}
						} else {
							$root->insertBefore( $n, $next );
						}
					} elseif ( $v['pwrap'] === true ) {
						if ( !$p ) {
							$p = $root->ownerDocument->createElement( 'p' );
							$root->insertBefore( $p, $next );
						}
						$p->appendChild( $n );
					} else {
						PHPUtils::unreachable( 'Unexpected value for pwrap.' );
					}
				}
			}
			$c = $next;
		}
		$this->unwrapTrailingPWrapOptionalNodes( $p, $seenTypes );
	}

	/**
	 * 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 {
		'@phan-var Element|DocumentFragment $root';  // @var Element|DocumentFragment $root
		$this->pWrapDOM( $root );
		$this->pWrapInsideTag( $root, 'blockquote' );
	}
}
