<?php

namespace MobileFrontend\Transforms;

use DomException;
use LogicException;
use MediaWiki\Html\Html;
use MediaWiki\ResourceLoader\ResourceLoader;
use Wikimedia\Parsoid\Core\DOMCompat;
use Wikimedia\Parsoid\DOM\Document;
use Wikimedia\Parsoid\DOM\Element;
use Wikimedia\Parsoid\DOM\Node;

/**
 * Implements IMobileTransform, that splits the body of the document into
 * sections demarcated by the $headings elements. Also moves the first paragraph
 * in the lead section above the infobox.
 *
 * All member elements of the sections are added to a <code><div></code> so
 * that the section bodies are clearly defined (to be "expandable" for
 * example).
 *
 * @see IMobileTransform
 */
class MakeSectionsTransform implements IMobileTransform {

	/**
	 * Class name for collapsible section wrappers
	 */
	public const STYLE_COLLAPSIBLE_SECTION_CLASS = 'collapsible-block';

	/**
	 * @param string[] $topHeadingTags List of tags that could be considered as section headers.
	 * @param bool $scriptsEnabled Whether scripts can be added in the output.
	 */
	public function __construct(
		private readonly array $topHeadingTags,
		private readonly bool $scriptsEnabled,
	) {
	}

	/**
	 * @param Node|null $node
	 * @return string|false Heading tag name if the node is a heading
	 */
	private function getHeadingName( $node ): bool|string {
		if ( !( $node instanceof Element ) ) {
			return false;
		}
		// We accept both kinds of nodes that can be returned by getTopHeadings():
		// a `<h1>` to `<h6>` node, or a `<div class="mw-heading">` node wrapping it.
		// In the future `<div class="mw-heading">` will be required (T13555).
		if ( DOMCompat::getClassList( $node )->contains( 'mw-heading' ) ) {
			$node = DOMCompat::querySelector( $node, implode( ',', $this->topHeadingTags ) );
			if ( !( $node instanceof Element ) ) {
				return false;
			}
		}
		return $node->tagName;
	}

	/**
	 * Actually splits the body of the document into sections
	 *
	 * @param Element $body representing the HTML of the current article. In the HTML the sections
	 *  should not be wrapped.
	 * @param Element[] $headingWrappers The headings (or wrappers) returned by getTopHeadings():
	 *  `<h1>` to `<h6>` nodes, or `<div class="mw-heading">` nodes wrapping them.
	 *  In the future `<div class="mw-heading">` will be required (T13555).
	 * @throws DomException
	 */
	private function makeSections( Element $body, array $headingWrappers ): void {
		$ownerDocument = $body->ownerDocument;
		if ( $ownerDocument === null ) {
			return;
		}
		// Find the parser output wrapper div
		$container = DOMCompat::querySelector(
			$ownerDocument, 'body > div.mw-parser-output'
		);
		if ( !$container ) {
			// No wrapper? This could be an old parser cache entry, or perhaps the
			// OutputPage contained something that was not generated by the parser.
			// Try using the <body> as the container.
			$container = DOMCompat::getBody( $ownerDocument );
			if ( !$container ) {
				throw new LogicException( "HTML lacked body element even though we put it there ourselves" );
			}
		}

		$containerChild = $container->firstChild;
		$firstHeading = reset( $headingWrappers );
		$firstHeadingName = $this->getHeadingName( $firstHeading );
		$sectionNumber = 0;
		$sectionBody = $this->createSectionBodyElement( $ownerDocument, $sectionNumber, false );

		while ( $containerChild ) {
			$node = $containerChild;
			$containerChild = $containerChild->nextSibling;
			// If we've found a top level heading, insert the previous section if
			// necessary and clear the container div.
			if ( $firstHeadingName && $this->getHeadingName( $node ) === $firstHeadingName ) {
				// The heading we are transforming is always 1 section ahead of the
				// section we are currently processing
				/** @phan-suppress-next-line PhanTypeMismatchArgumentSuperType Node vs. Element */
				$this->prepareHeading( $ownerDocument, $node, $sectionNumber + 1, $this->scriptsEnabled );
				// Insert the previous section body and reset it for the new section
				$container->insertBefore( $sectionBody, $node );

				$sectionNumber += 1;
				$sectionBody = $this->createSectionBodyElement(
					$ownerDocument,
					$sectionNumber,
					$this->scriptsEnabled
				);
				continue;
			}

			// If it is not a top level heading, keep appending the nodes to the
			// section body container.
			$sectionBody->appendChild( $node );
		}

		// Append the last section body.
		$container->appendChild( $sectionBody );
	}

	/**
	 * Prepare section headings, add required classes and onclick actions
	 *
	 * @param Document $doc
	 * @param Element $heading
	 * @param int $sectionNumber
	 * @param bool $isCollapsible
	 * @throws DOMException
	 */
	private function prepareHeading(
		Document $doc, Element $heading, $sectionNumber, $isCollapsible
	): void {
		$className = $heading->hasAttribute( 'class' ) ? $heading->getAttribute( 'class' ) . ' ' : '';
		$heading->setAttribute( 'class', $className . 'section-heading' );
		if ( $isCollapsible ) {
			$heading->setAttribute( 'onclick', 'mfTempOpenSection(' . $sectionNumber . ')' );
		}

		// prepend indicator - this avoids a reflow by creating a placeholder for a toggling indicator
		$indicator = $doc->createElement( 'span' );
		$indicator->setAttribute( 'class', 'indicator mf-icon mf-icon-expand mf-icon--small' );
		$heading->insertBefore( $indicator, $heading->firstChild );
	}

	/**
	 * Creates a Section body element
	 *
	 * @param Document $doc
	 * @param int $sectionNumber
	 * @param bool $isCollapsible
	 *
	 * @return Element
	 * @throws DOMException
	 */
	private function createSectionBodyElement( Document $doc, $sectionNumber, $isCollapsible ): Element {
		$sectionClass = 'mf-section-' . $sectionNumber;
		if ( $isCollapsible ) {
			// TODO: Probably good to rename this to the more generic 'section'.
			// We have no idea how the skin will use this.
			$sectionClass .= ' ' . self::STYLE_COLLAPSIBLE_SECTION_CLASS;
		}

		// FIXME: The class `/mf\-section\-[0-9]+/` is kept for caching reasons
		// but given class is unique usage is discouraged. [T126825]
		$sectionBody = $doc->createElement( 'section' );
		$sectionBody->setAttribute( 'class', $sectionClass );
		$sectionBody->setAttribute( 'id', 'mf-section-' . $sectionNumber );
		return $sectionBody;
	}

	/**
	 * Gets top headings in the document.
	 *
	 * Note well that the rank order is defined by the
	 * <code>MobileFormatter#topHeadingTags</code> property.
	 *
	 * @param Element $doc
	 * @return array An array first is the highest rank headings
	 */
	private function getTopHeadings( Element $doc ): array {
		$headings = [];

		foreach ( $this->topHeadingTags as $tagName ) {
			$allTags = DOMCompat::querySelectorAll( $doc, $tagName );

			foreach ( $allTags as $el ) {
				$parent = $el->parentNode;
				if ( !( $parent instanceof Element ) ) {
					continue;
				}
				// Use the `<div class="mw-heading">` wrapper if it is present. When they are required
				// (T13555), the querySelectorAll() above can use the class and this can be removed.
				if ( DOMCompat::getClassList( $parent )->contains( 'mw-heading' ) ) {
					$el = $parent;
				}
				// This check can be removed too when we require the wrappers.
				if ( $parent->getAttribute( 'class' ) !== 'toctitle' ) {
					$headings[] = $el;
				}
			}
			if ( $headings ) {
				return $headings;
			}

		}

		return $headings;
	}

	/**
	 * Make it possible to open sections while JavaScript is still loading.
	 *
	 * @return string The JavaScript code to add event handlers to the skin
	 */
	public static function interimTogglingSupport(): string {
		$js = file_get_contents( __DIR__ . '/../../resources/inline/interim-toggling-legacy.js' );
		return Html::inlineScript(
			ResourceLoader::filter( 'minify-js', $js )
		);
	}

	/**
	 * Performs html transformation that splits the body of the document into
	 * sections demarcated by the $headings elements. Also moves the first paragraph
	 * in the lead section above the infobox.
	 * @param Element $node to be transformed
	 * @throws DomException
	 */
	public function apply( Element $node ): void {
		$this->makeSections( $node, $this->getTopHeadings( $node ) );
	}
}
