<?php
declare( strict_types = 1 );
// phpcs:disable MediaWiki.WhiteSpace.SpaceBeforeSingleLineComment.NewLineComment

namespace Cite\Parsoid;

use Cite\MarkSymbolRenderer;
use Cite\Validator;
use MediaWiki\Config\Config;
use MediaWiki\Html\HtmlHelper;
use MediaWiki\MediaWikiServices;
use Wikimedia\Parsoid\Core\DOMCompat;
use Wikimedia\Parsoid\Core\DomSourceRange;
use Wikimedia\Parsoid\Core\Sanitizer;
use Wikimedia\Parsoid\DOM\Document;
use Wikimedia\Parsoid\DOM\DocumentFragment;
use Wikimedia\Parsoid\DOM\Element;
use Wikimedia\Parsoid\DOM\Node;
use Wikimedia\Parsoid\Ext\DOMDataUtils;
use Wikimedia\Parsoid\Ext\DOMUtils;
use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
use Wikimedia\Parsoid\Ext\PHPUtils;
use Wikimedia\Parsoid\Ext\WTUtils;
use Wikimedia\Parsoid\NodeData\DataMw;
use Wikimedia\Parsoid\NodeData\DataMwBody;
use Wikimedia\Parsoid\NodeData\DataMwError;
use Wikimedia\Parsoid\NodeData\DataParsoid;
use Wikimedia\RemexHtml\HTMLData;
use Wikimedia\RemexHtml\Serializer\SerializerNode;

/**
 * @license GPL-2.0-or-later
 */
class References {

	private const CONFLICT_NONE = 0;
	/** A conflict in e.g. the data-mw metadata, but not in the visible content */
	private const CONFLICT_INVISIBLE = 1;
	/** A conflict in the content that is visible to the user */
	private const CONFLICT_VISIBLE = 2;

	private readonly MarkSymbolRenderer $markSymbolRenderer;
	private readonly ParsoidValidator $validator;
	/** @var array<string,array<string,string>>
	 * @internal Local copy of ref body HTML for conflict detection. Top level
	 * key is the ref group name, second level key is the ref name
	 */
	private array $conflictCache = [];

	public function __construct(
		private readonly Config $mainConfig,
	) {
		$this->markSymbolRenderer = MediaWikiServices::getInstance()->getService( 'Cite.MarkSymbolRenderer' );
		$this->validator = new ParsoidValidator();
	}

	private static function hasRef( ParsoidExtensionAPI $extApi, Node $node ): bool {
		$c = $node->firstChild;
		while ( $c ) {
			if ( $c instanceof Element ) {
				if ( WTUtils::isSealedFragmentOfType( $c, 'ref' ) ) {
					return true;
				}
				$hasEmbeddedRefs = false;
				$extApi->processAttributeEmbeddedDom( $c,
					function ( DocumentFragment $df ) use ( $extApi, &$hasEmbeddedRefs ) {
						$hasEmbeddedRefs = $hasEmbeddedRefs || self::hasRef( $extApi, $df );
						return true;
					}
				);
				if ( $hasEmbeddedRefs ) {
					return true;
				}
				if ( self::hasRef( $extApi, $c ) ) {
					return true;
				}
			}
			$c = $c->nextSibling;
		}
		return false;
	}

	public function createEmptyReferenceListFragment(
		ParsoidExtensionAPI $extApi, DocumentFragment $domFragment,
		array $refsOpts, ?callable $modifyDp, bool $autoGenerated = false
	): Element {
		$doc = $domFragment->ownerDocument;

		$ol = $doc->createElement( 'ol' );
		DOMCompat::getClassList( $ol )->add( 'mw-references', 'references' );

		DOMUtils::migrateChildren( $domFragment, $ol );

		if ( $refsOpts['responsive'] ?? $this->mainConfig->get( 'CiteResponsiveReferences' ) ) {
			$div = $doc->createElement( 'div' );
			DOMCompat::getClassList( $div )->add( 'mw-references-wrap' );
			$div->appendChild( $ol );
			$frag = $div;
		} else {
			$frag = $ol;
		}

		if ( $autoGenerated ) {
			// FIXME: This is very much trying to copy ExtensionHandler::onDocument
			DOMUtils::addAttributes( $frag, [
				'typeof' => 'mw:Extension/references',
				'about' => $extApi->newAboutId()
			] );
			$dataMw = new DataMw( [ 'name' => 'references', 'attrs' => (object)[] ] );
			// Dont emit empty keys
			if ( $refsOpts['group'] ) {
				$dataMw->setExtAttrib( 'group', $refsOpts['group'] );
			}
			DOMDataUtils::setDataMw( $frag, $dataMw );
		}

		$dp = DOMDataUtils::getDataParsoid( $frag );
		if ( $refsOpts['group'] ) {  // No group for the empty string either
			$dp->group = $refsOpts['group'];
			$ol->setAttribute( 'data-mw-group', $refsOpts['group'] );
		}
		if ( $modifyDp ) {
			$modifyDp( $dp );
		}

		// These module namess are copied from Cite extension.
		// They are hardcoded there as well.
		$metadata = $extApi->getMetadata();
		$metadata->addModules( [ 'ext.cite.ux-enhancements' ] );
		$metadata->addModuleStyles( [ 'ext.cite.parsoid.styles', 'ext.cite.styles' ] );

		return $frag;
	}

	private function processNestedRefInRef(
		ParsoidExtensionAPI $extApi,
		Element $refFragment,
		ReferencesData $referencesData,
		bool $hasDifferingHtml
	): ?string {
		$refFragmentDp = DOMDataUtils::getDataParsoid( $refFragment );
		if ( !empty( $refFragmentDp->selfClose ) || !self::hasRef( $extApi, $refFragment ) ) {
			return null;
		}

		if ( $hasDifferingHtml ) {
			// Critical call! This changes the behavior of ReferencesData::inEmbeddedContent in
			// rare edge-cases, see T393913.
			$referencesData->pushEmbeddedContentFlag();
		}

		// This prevents nested list-defined references from erroneously giving "group mismatch"
		// errors.
		$referencesData->incrementRefDepth();
		$this->processRefs( $extApi, $referencesData, $refFragment );
		$referencesData->decrementRefDepth();

		if ( $hasDifferingHtml ) {
			$referencesData->popEmbeddedContentFlag();
			// If we have refs and the content differs, we need to reserialize now that we processed
			// the refs.  Unfortunately, the cachedHtml we compared against already had its refs
			// processed so that would presumably never match and this will always be considered a
			// redefinition.  The implementation for the legacy parser also considers this a
			// redefinition so there is likely little content out there like this :)
			return $extApi->domToHtml( $refFragment, true, true );
		}
		return null;
	}

	private function extractRefFromNode(
		ParsoidExtensionAPI $extApi, Element $node, ReferencesData $referencesData
	): void {
		$doc = $node->ownerDocument;
		$errs = [];

		// This is data-parsoid from the dom fragment node that's gone through
		// DomSourceRange computation and template wrapping.
		$nodeDp = DOMDataUtils::getDataParsoid( $node );
		$contentId = $extApi->getContentId( $node );

		// Extract the ref fragment and ensure it's valid
		$refFragment = $extApi->getContentDOM( $contentId )->firstChild;
		DOMUtils::assertElt( $refFragment );
		$refFragmentDp = DOMDataUtils::getDataParsoid( $refFragment );

		// Get the data-mw from the $node, not $refFragment, so that
		// transclusion parts are included.  As of T214241, template wrapping
		// does not overwrite the extension data-mw that is copied over from
		// $refFragment to $node.
		$refDataMw = DOMDataUtils::getDataMw( $node );

		// Validate attribute keys
		$isSubreferenceSupported = $this->mainConfig->get( 'CiteSubReferencing' );
		$status = Validator::filterRefArguments( $refDataMw->getExtAttribs() ?? [], $isSubreferenceSupported );
		$arguments = $status->getValue();

		$isEmptyBody = trim( $refDataMw->body->extsrc ?? '' ) === '';

		$validator = new Validator( $referencesData->referenceListGroup() );
		$text = !empty( $refFragmentDp->selfClose ) ? null : ( $isEmptyBody ? '' : 'dummy' );
		$status->merge( $validator->validateRef( $text, $arguments ), true );
		$arguments = $status->getValue();
		$status->merge( $validator->validateListDefinedRefUsage(
			$arguments['name'],
			$referencesData->isKnown( $arguments['group'], $arguments['name'] )
		) );

		foreach ( $status->getMessages() as $msg ) {
			$errs[] = ErrorUtils::fromMessageSpecifier( $msg );
		}

		// Extract and validate attribute values
		$groupName = $arguments['group'];
		$refName = (string)$arguments['name'];
		$followName = (string)$arguments['follow'];
		$refDir = (string)$arguments['dir'];
		$details = $arguments['details'] ?? '';

		// Handle 'about' attribute with priority since it's
		// only added when the wrapper is a template sibling.
		$about = DOMCompat::getAttribute( $node, 'about' ) ??
			DOMCompat::getAttribute( $refFragment, 'about' );
		'@phan-var string $about'; // assert that $about is non-null

		$hasDetails = $details !== '' && $refName;

		$refGroup = $referencesData->getOrCreateRefGroup( $groupName );
		$ref = $refGroup->lookupRefByName( $refName );

		$refLock = false;
		$conflicts = self::CONFLICT_NONE;

		// No point in doing conflict detection on named refs without a body
		if ( $refName && !$isEmptyBody ) {
			$refLock = $referencesData->refLocks[$groupName][$refName] ?? false;
			$existingContentId = $ref->contentId ?? null;
			if ( $refLock ) {
				$conflicts = self::CONFLICT_VISIBLE;
			} elseif ( $existingContentId ) {
				$conflicts = $this->checkForConflictingContent(
					$extApi, $groupName, $refName, $existingContentId, $refFragment
				);
			}
		}

		$hasValidFollow = false;
		if ( $followName ) {
			$this->wrapFollower( $doc, $refFragment, $about );

			// Check that the followed ref exists
			$followThat = $refGroup->lookupRefByName( $followName );
			if ( $followThat ) {
				$hasValidFollow = true;
				$ref = $followThat;
			}
		}

		// Split subref and main ref; add main ref as a list-defined reference
		if ( $hasDetails ) {
			// Subref points to the main ref by name.
			$refDataMw->mainRef = $refName;

			// Create new, empty main ref
			$ref ??= $referencesData->addRef( $refGroup, $refName, $refDir );

			if ( !$isEmptyBody ) {
				if ( !$ref->contentId ) {
					// Create a main ref and transfer the tag body to it,
					$ref->isSyntheticMainRef = true;
					$ref->contentId = $contentId;
				}
				$refDataMw->mainBody = ParsoidAnchorFormatter::getNoteTextIdentifier( $ref );
			}

			// Switch $ref to a newly-created subref (or reuse a duplicate)
			$ref = $refGroup->lookupSubRefByDetails( $refName, $details ) ??
				$referencesData->addRef( $refGroup, $refName, $refDir, $details );
			// Move details attribute into subref content.
			$ref->externalFragment = $extApi->wikitextToDOM( $details, [
				'processInNewFrame' => true,
				'parseOpts' => [ 'context' => 'inline' ]
			], true );

			$refName = '';
			$contentId = null;
		}

		$oldLock = null;

		if ( $refName ) {
			$oldLock = $referencesData->refLocks[$groupName][$refName] ?? null;
			$referencesData->refLocks[$groupName][$refName] = true;
		}

		$refFragmentHtml = $this->processNestedRefInRef( $extApi, $refFragment, $referencesData,
			$conflicts !== self::CONFLICT_NONE ) ??
			// Fall back to the normal behavior without any nested <ref>
			$extApi->domToHtml( $refFragment, true );

		if ( $refName ) {
			// One might have just been created while processing nested refs
			$ref ??= $refGroup->lookupRefByName( $refName );

			if ( !$oldLock ) {
				unset( $referencesData->refLocks[$groupName][$refName] );
			}
		}

		// Add ref-index linkback
		$linkBackSup = $doc->createElement( 'sup' );

		$isVisibleNode = false;

		if ( $hasValidFollow ) {
			// Migrate content from the follow to the ref
			if ( $ref->contentId ) {
				$refContent = $extApi->getContentDOM( $ref->contentId )->firstChild;
				DOMUtils::migrateChildren( $refFragment, $refContent );
			} else {
				// Otherwise, we have a follow that comes after a named
				// ref without content so use the follow fragment as
				// the content
				// This will be set below with `$ref->contentId = $contentId;`
			}
		} else {
			$ref ??= $referencesData->addRef( $refGroup, $refName, $refDir );

			// Handle linkbacks
			if ( $referencesData->inEmbeddedContent() ) {
				$ref->embeddedNodes[] = $about;
				// While indicator content is embedded throughout the parsing
				// pipeline, it gets added back into the page in a post-processing
				// step, so consider it visible for the sake of linkbacks.
				if ( $referencesData->peekForIndicatorContext() ) {
					$ref->visibleNodes++;
					$isVisibleNode = true;
				}
			} else {
				$ref->nodes[] = $linkBackSup;
				$ref->visibleNodes++;
				$isVisibleNode = true;
			}
		}
		// Guaranteed from this point on
		'@phan-var RefGroupItem $ref';

		if ( isset( $arguments['dir'] ) ) {
			$dirError = $this->validator->validateDir( $refDir, $ref );
			if ( $dirError ) {
				$errs[] = $dirError;
			}
		}
		if ( $conflicts === self::CONFLICT_VISIBLE && $refFragmentHtml !== '' ) {
			$errs[] = new DataMwError( 'cite_error_references_duplicate_key', [ $arguments['name'] ] );
		}

		$hasMissingContent = $isEmptyBody && !$ref->externalFragment;
		if ( $hasMissingContent ) {
			if ( !empty( $refFragmentDp->selfClose ) ) {
				unset( $refDataMw->body );
			} else {
				// Empty the <sup> since we've serialized its children and
				// removing it below asserts everything has been migrated out
				DOMCompat::replaceChildren( $refFragment );
				$refDataMw->body = new DataMwBody();
				$refDataMw->body->setHtml( $extApi, $extApi->htmlToDom( $refFragmentHtml ) );
			}
		} else {
			if ( $ref->contentId && !$hasValidFollow ) {
				// Empty the <sup> since we've serialized its children and
				// removing it below asserts everything has been migrated out
				DOMCompat::replaceChildren( $refFragment );
			}
			// Sub-references cannot have conflicting content, the conflict is on the main ref
			$hasConflict = $conflicts !== self::CONFLICT_NONE && !$hasDetails;
			$refDataMw->body = new DataMwBody;
			if ( $hasConflict ) {
				// Conflicting bodies must be stored, otherwise the differences get lost
				$refDataMw->body->setHtml( $extApi, $extApi->htmlToDom( $refFragmentHtml ) );
			} else {
				// Prefer tracking the body via a short identifier instead of duplicating it
				$refDataMw->body->id = ParsoidAnchorFormatter::getNoteTextIdentifier( $ref );
			}
		}

		$this->addLinkBackAttributes(
			$linkBackSup,
			$isVisibleNode ? ParsoidAnchorFormatter::getBacklinkIdentifier( $ref ) : null,
			DOMCompat::getAttribute( $node, 'typeof' ),
			$about,
			$hasValidFollow
		);

		$this->addLinkBackData(
			$linkBackSup,
			$nodeDp,
			$refDataMw
		);

		// The current <ref> is inside <references>, and no previous <ref> had the same name. There
		// are no (visible) nodes that can be used to track errors.
		if ( $referencesData->inReferenceList() && !$ref->nodes ) {
			$refGroup->inReferencesListErrors[$about] = $errs;
		} else {
			// FIXME(T214241): Should the errors be added to data-mw if
			// $isTplWrapper?  Here and other calls to addErrorsToNode.
			ErrorUtils::addErrorsToNode( $linkBackSup, $errs );
		}

		// refLink is the link to the citation
		$refLink = $doc->createElement( 'a' );
		DOMUtils::addAttributes( $refLink, [
			'href' => $extApi->getPageUri() . '#' .
				Sanitizer::escapeIdForLink( ParsoidAnchorFormatter::getNoteIdentifier( $ref ) )
		] );
		if ( $ref->group ) {
			$refLink->setAttribute( 'data-mw-group', $ref->group );
		}

		// refLink-span which will contain a default rendering of the cite link
		// for browsers that don't support counters
		$refLinkSpan = $doc->createElement( 'span' );
		$refLinkSpan->setAttribute( 'class', 'mw-reflink-text' );
		$openBracket = $doc->createElement( 'span' );
		$openBracket->appendChild( $doc->createTextNode( '[' ) );
		$openBracket->setAttribute( 'class', 'cite-bracket' );
		$refLinkSpan->appendChild( $openBracket );
		$refLinkSpan->appendChild( $doc->createTextNode(
			$this->markSymbolRenderer->renderFootnoteMarkLabel( $ref->group, $ref->numberInGroup, $ref->subrefIndex )
		) );
		$closeBracket = $doc->createElement( 'span' );
		$closeBracket->appendChild( $doc->createTextNode( ']' ) );
		$closeBracket->setAttribute( 'class', 'cite-bracket' );
		$refLinkSpan->appendChild( $closeBracket );

		$refLink->appendChild( $refLinkSpan );
		$linkBackSup->appendChild( $refLink );

		// Checking if the <ref> is nested in a link
		$aParent = DOMUtils::findAncestorOfName( $node, 'a' );
		if ( $aParent ) {
			$this->insertChildOutsideOfLink( $linkBackSup, $aParent );
			// set misnested to true and DSR to zero-sized to avoid round-tripping issues
			$dsrOffset = DOMDataUtils::getDataParsoid( $aParent )->dsr->end ?? null;
			// we created that node hierarchy above, so we know that it only contains these nodes,
			// hence there's no need for a visitor
			self::setMisnested( $linkBackSup, $dsrOffset );
			self::setMisnested( $refLink, $dsrOffset );
			self::setMisnested( $refLinkSpan, $dsrOffset );
			$node->parentNode->removeChild( $node );
		} else {
			// if not, we insert it where we planned in the first place
			$node->parentNode->replaceChild( $linkBackSup, $node );
		}

		// Keep the first content to compare multiple <ref>s with the same name.
		if ( $ref->contentId === null && !$hasMissingContent && !$refLock ) {
			$ref->contentId = $contentId;
			// Use the dir parameter only from the full definition of a named ref tag
			$ref->dir = $refDir;
		} elseif ( $contentId ) {
			DOMCompat::remove( $refFragment );
			$extApi->clearContentId( $node );
		}
	}

	private function insertChildOutsideOfLink( Element $linkBackSup, Element $aParent ): void {
		// If we find a parent link, we hoist the reference up, just after the link
		// But if there's multiple references in a single link, we want to insert in order -
		// so we look for other misnested references before inserting
		$insertionPoint = $aParent->nextSibling;
		while ( $insertionPoint instanceof Element &&
			DOMUtils::nodeName( $insertionPoint ) === 'sup' &&
			!empty( DOMDataUtils::getDataParsoid( $insertionPoint )->misnested )
		) {
			$insertionPoint = $insertionPoint->nextSibling;
		}
		$aParent->parentNode->insertBefore( $linkBackSup, $insertionPoint );

		// Mark the two now un-nested elements as a DOM forest that belongs together
		$parentAbout = DOMCompat::getAttribute( $aParent, 'about' );
		if ( $parentAbout !== null ) {
			$linkBackSup->setAttribute( 'about', $parentAbout );
		}
	}

	/**
	 * wrap the content of the follow attribute
	 * so that there is no ambiguity
	 * where to find it when round tripping
	 */
	private function wrapFollower( Document $doc, Node $refFragment, string $about ): void {
		$span = $doc->createElement( 'span' );
		DOMUtils::addTypeOf( $span, 'mw:Cite/Follow' );
		$span->setAttribute( 'about', $about );
		$span->appendChild( $doc->createTextNode( ' ' ) );
		DOMUtils::migrateChildren( $refFragment, $span );
		$refFragment->appendChild( $span );
	}

	/**
	 * Sets a node as misnested and its DSR as zero-width.
	 */
	private static function setMisnested( Element $node, ?int $offset ) {
		$dataParsoid = DOMDataUtils::getDataParsoid( $node );
		$dataParsoid->misnested = true;
		$dataParsoid->dsr = new DomSourceRange( $offset, $offset, null, null );
	}

	public function insertReferencesIntoDOM(
		ParsoidExtensionAPI $extApi, Element $refsNode,
		ReferencesData $refsData, bool $autoGenerated = false
	): void {
		// FIXME: This is only looking for two very specific spots. Should probably loop instead.
		$parentElement = DOMCompat::getParentElement( $refsNode );
		$previousSibling = $parentElement ? DOMCompat::getPreviousElementSibling( $parentElement ) : null;
		$isTemplateWrapper = DOMUtils::hasTypeOf( $refsNode, 'mw:Transclusion' ) ||
			// recognize cases where the references tag is wrapped in the template
			( $parentElement && DOMUtils::hasTypeOf( $parentElement, 'mw:Transclusion' ) ) ||
			// In the TemplateStyles case, the ref list is the second element
			( $previousSibling && DOMUtils::hasTypeOf( $previousSibling, 'mw:Transclusion' ) );
		$nodeDp = DOMDataUtils::getDataParsoid( $refsNode );
		$groupName = $nodeDp->group ?? '';
		$refGroup = $refsData->lookupRefGroup( $groupName );

		// Iterate through the ref list to check for subrefs and
		// back-patch typeof and data-mw error information into ref
		// for errors only known at time of references insertion.
		// Refs in the top level dom will be processed immediately,
		// whereas embedded refs will be gathered for batch processing, since
		// we need to parse embedded content to find them.
		$hasSubref = false;

		if ( $refGroup ) {
			foreach ( $refGroup->toArray() as $ref ) {
				// Mark all refs that are named without content
				if ( $ref->name !== null && $ref->contentId === null ) {
					$err = new DataMwError( 'cite_error_references_no_text', [ $ref->name ] );
					foreach ( $ref->nodes as $node ) {
						ErrorUtils::addErrorsToNode( $node, [ $err ] );
					}
					foreach ( $ref->embeddedNodes as $about ) {
						$refsData->embeddedErrors[$about] = [ $err ];
					}
				}

				if ( $ref->subrefIndex !== null ) {
					$hasSubref = true;
				}
			}
		}

		// add tracking category for subrefs
		if ( $hasSubref ) {
			$extApi->addTrackingCategory( 'cite-tracking-category-ref-details' );
		}

		// Note that `$sup`s here are probably all we really need to check for
		// errors caught with `$refsData->inReferenceList()` but it's
		// probably easier to just know that state while they're being
		// constructed.
		$nestedRefsHTML = array_map(
			static fn ( Element $sup ) => $extApi->domToHtml( $sup, false, true ) . "\n",
			PHPUtils::iterable_to_array( DOMCompat::querySelectorAll(
				$refsNode, 'sup[typeof~=\'mw:Extension/ref\']'
			) )
		);

		// T401887: Any main+details refs should have the main part appear in the
		// reference list as a synthetic list-defined ref to help VE
		$syntheticRefsHtml = [];
		if ( $refGroup ) {
			$doc = $refsNode->ownerDocument;
			// TODO: use names rather than index numbers when handling RefGroupItems
			$mainRefIndexes = [];
			// Find all main refs with a subref.
			foreach ( $refGroup->toArray() as $ref ) {
				if ( $ref->subrefIndex !== null ) {
					$mainRefIndexes[ $ref->numberInGroup ] = 1;
				}
			}
			// Skip any main refs which already appear as list-defined refs.
			foreach ( $refGroup->toArray() as $ref ) {
				if ( array_key_exists( $ref->numberInGroup, $mainRefIndexes ) ) {
					if ( $ref->embeddedNodes ) {
						unset( $mainRefIndexes[ $ref->numberInGroup ] );
					}
				}
			}
			foreach ( $refGroup->toArray() as $ref ) {
				if ( ( $ref->isSyntheticMainRef ||
					( array_key_exists( $ref->numberInGroup, $mainRefIndexes ) && $ref->contentId !== null ) ) &&
					// T415526: gradual migration away from synthetic ref
					!$this->mainConfig->get( 'CiteRemoveSyntheticRefsUnsafe' )
				) {
					$sup = $doc->createElement( 'sup' );
					DOMUtils::addAttributes( $sup, [
						'typeof' => 'mw:Extension/ref',
					] );
					$dataMw = new DataMw( [
						'name' => 'ref',
						'attrs' => (object)[
							'name' => $ref->name,
							'group' => $ref->group,
						],
						'body' => DataMwBody::new( [
							'id' => ParsoidAnchorFormatter::getNoteTextIdentifier( $ref ),
						] ),
						'isSyntheticMainRef' => 1,
					] );
					DOMDataUtils::setDataMw( $sup, $dataMw );
					$refFragment = $extApi->getContentDOM( $ref->contentId )->firstChild->firstChild;
					if ( $refFragment ) {
						DOMCompat::setInnerHTML( $sup, $extApi->domToHtml( $refFragment, false, false ) );
						$syntheticRefsHtml[] = $extApi->domToHtml( $sup, false, false );
					}
				}
			}
		}

		$nestedRefsHTML = array_merge( $nestedRefsHTML, $syntheticRefsHtml );

		if ( !$isTemplateWrapper ) {
			$dataMw = DOMDataUtils::getDataMw( $refsNode );
			// Mark this auto-generated so that we can skip this during
			// html -> wt and so that clients can strip it if necessary.
			if ( $autoGenerated ) {
				$dataMw->autoGenerated = true;
			}
			if ( $nestedRefsHTML ) {
				$dataMw->body = new DataMwBody;
				$dataMw->body->setHtml( $extApi, $extApi->htmlToDom( "\n" . implode( $nestedRefsHTML ) ) );
			} elseif ( !$autoGenerated && empty( $nodeDp->selfClose ) ) {
				$dataMw->body = new DataMwBody;
				$dataMw->body->setHtml( $extApi, $extApi->htmlToDom( '' ) );
			} else {
				unset( $dataMw->body );
			}
			unset( $nodeDp->selfClose );
		} elseif ( $syntheticRefsHtml ) {
			$dataMw = DOMDataUtils::getDataMw( $refsNode );
			$dataMw->body = new DataMwBody;
			$dataMw->body->setHtml( $extApi, $extApi->htmlToDom(
				"\n" . implode( $syntheticRefsHtml )
			) );
		}

		$hasResponsiveWrapper = false;
		// Deal with responsive wrapper
		if ( DOMUtils::hasClass( $refsNode, 'mw-references-wrap' ) ) {
			// NOTE: The default Cite implementation hardcodes this threshold to 10.
			// We use a configurable parameter here primarily for test coverage purposes.
			// See citeParserTests.txt where we set a threshold of 1 or 2.
			$rrThreshold = $this->mainConfig->get( 'CiteResponsiveReferencesThreshold' ) ?? 10;
			if ( $refGroup && $refGroup->count() > $rrThreshold ) {
				DOMCompat::getClassList( $refsNode )->add( 'mw-references-columns' );
			}
			$refsNode = $refsNode->firstChild;
			$hasResponsiveWrapper = true;
		}
		DOMUtils::assertElt( $refsNode );

		// Remove all children from the references node
		//
		// Ex: When {{Reflist}} is reused from the cache, it comes with
		// a bunch of references as well. We have to remove all those cached
		// references before generating fresh references.
		DOMCompat::replaceChildren( $refsNode );

		// FIXME: There is another large `if ( $refGroup )` above, can these be merged?
		if ( $refGroup ) {
			$doc = $refsNode->ownerDocument;
			foreach ( $refGroup->toArray() as $ref ) {
				// Skip sub-references in the outer loop
				if ( $ref->subrefIndex === null ) {
					$refGroup->renderReferenceListElement( $extApi, $refsNode, $ref, $this->markSymbolRenderer );
					// Render and append related sub-refs to main ref node
					$subRefs = $this->renderSubReferencesList( $extApi, $doc, $refGroup, $ref );
					if ( $subRefs ) {
						$refsNode->lastChild->previousSibling->appendChild( $subRefs );
					}
				}
			}
			if ( $autoGenerated && $groupName !== '' ) {
				$error = new DataMwError( 'cite_error_group_refs_without_references',
					[ $groupName ]
				);
				$frag = ( new ErrorUtils( $extApi ) )->renderParsoidError( $error );
				$span = DOMCompat::getFirstElementChild( $frag );
				$refsNode->parentNode->insertBefore( $span, $refsNode->nextSibling );
				$refsNodeAbout = DOMCompat::getAttribute( $refsNode, 'about' );
				if ( $refsNodeAbout ) {
					$span->setAttribute( 'about', $refsNodeAbout );
				}

				$destNode = $hasResponsiveWrapper ? DOMCompat::getParentElement( $refsNode ) : $refsNode;
				ErrorUtils::addErrorsToNode( $destNode, [ $error ] );
			}

			// Handle errors that happened while being inside <references>
			$afterReferencesList = $refsNode->nextSibling;
			$i = 0;
			foreach ( $refGroup->inReferencesListErrors as $about => $errors ) {
				// TODO: Scan for the node via $about and attach the errors there, if possible
				foreach ( $errors as $error ) {
					if ( $hasResponsiveWrapper && $i++ ) {
						$refsNode->parentNode->insertBefore( $doc->createElement( 'br' ), $afterReferencesList );
						$refsNode->parentNode->insertBefore( $doc->createTextNode( "\n" ), $afterReferencesList );
					}
					$frag = ( new ErrorUtils( $extApi ) )->renderParsoidError( $error );
					$span = DOMCompat::getFirstElementChild( $frag );
					if ( !$hasResponsiveWrapper ) {
						$refsNodeAbout = DOMCompat::getAttribute( $refsNode, 'about' );
						if ( $refsNodeAbout ) {
							$span->setAttribute( 'about', $refsNodeAbout );
						}
					}
					// T384599: This workaround displays remaining errors under the reference list
					$refsNode->parentNode->insertBefore( $span, $afterReferencesList );
				}
			}
		}

		// T364830: Remove any templated refslist with zero refs
		if ( $refsNode->firstChild === null && WTUtils::fromTemplatedContent( $refsNode ) ) {
			$nodeToDelete = $hasResponsiveWrapper ? $refsNode->parentNode : $refsNode;
			$nodeToDelete->parentNode->removeChild( $nodeToDelete );
		}

		// Remove the group from refsData
		$refsData->removeRefGroup( $groupName );
	}

	public function processRefs(
		ParsoidExtensionAPI $extApi, ReferencesData $refsData, Node $node
	): void {
		$child = $node->firstChild;
		while ( $child !== null ) {
			$nextChild = $child->nextSibling;
			if ( $child instanceof Element ) {
				if ( WTUtils::isSealedFragmentOfType( $child, 'ref' ) ) {
					$this->extractRefFromNode( $extApi, $child, $refsData );
				} elseif ( DOMUtils::hasTypeOf( $child, 'mw:Extension/references' ) ) {
					$inReferenceList = $refsData->inReferenceList();
					if ( !$inReferenceList ) {
						$refsData->referencesGroup =
							DOMDataUtils::getDataParsoid( $child )->group ?? '';
					}
					$refsData->pushEmbeddedContentFlag();
					if ( $child->hasChildNodes() ) {
						$this->processRefs( $extApi, $refsData, $child );
					}
					$refsData->popEmbeddedContentFlag();
					if ( !$inReferenceList ) {
						$refsData->referencesGroup = null;
						$this->insertReferencesIntoDOM( $extApi, $child, $refsData, false );
					}
				} else {
					if ( DOMUtils::hasTypeOf( $child, 'mw:Extension/indicator' ) ) {
						$refsData->pushEmbeddedContentFlag( 'indicator' );
					} else {
						$refsData->pushEmbeddedContentFlag();
					}
					// Look for <ref>s embedded in data attributes
					$extApi->processAttributeEmbeddedDom( $child,
						function ( DocumentFragment $df ) use ( $extApi, $refsData ) {
							$this->processRefs( $extApi, $refsData, $df );
							return true;
						}
					);
					$refsData->popEmbeddedContentFlag();
					if ( $child->hasChildNodes() ) {
						$this->processRefs( $extApi, $refsData, $child );
					}
				}
			}
			$child = $nextChild;
		}
	}

	private function renderSubReferencesList(
		ParsoidExtensionAPI $extApi,
		Document $doc,
		RefGroup $refGroup,
		RefGroupItem $mainRef
	): ?Element {
		$ol = null;
		foreach ( $refGroup->toArray() as $ref ) {
			if ( $ref->numberInGroup === $mainRef->numberInGroup && $ref !== $mainRef ) {
				$ol ??= $doc->createElement( 'ol' );
				$refGroup->renderReferenceListElement( $extApi, $ol, $ref, $this->markSymbolRenderer );
			}
		}
		if ( $ol ) {
			DOMCompat::getClassList( $ol )->add( 'mw-subreference-list' );
		}
		return $ol;
	}

	private function addLinkBackData(
		Element $linkBackSup,
		DataParsoid $nodeDp,
		?DataMw $dataMw
	): void {
		$dataParsoid = new DataParsoid();
		if ( isset( $nodeDp->src ) ) {
			$dataParsoid->src = $nodeDp->src;
		}
		if ( isset( $nodeDp->dsr ) ) {
			$dataParsoid->dsr = $nodeDp->dsr;
		}
		if ( isset( $nodeDp->pi ) ) {
			$dataParsoid->pi = $nodeDp->pi;
		}
		DOMDataUtils::setDataParsoid( $linkBackSup, $dataParsoid );
		DOMDataUtils::setDataMw( $linkBackSup, $dataMw );
	}

	private function addLinkBackAttributes(
		Element $linkBackSup,
		?string $id,
		?string $typeof,
		?string $about,
		bool $hasValidFollow
	): void {
		$class = 'mw-ref reference';
		if ( $hasValidFollow ) {
			$class .= ' mw-ref-follow';
		}

		DOMUtils::addAttributes( $linkBackSup, [
			'about' => $about,
			'class' => $class,
			'id' => $hasValidFollow ? null : $id,
			'rel' => 'dc:references',
			'typeof' => $typeof,
		] );
		DOMUtils::removeTypeOf( $linkBackSup, 'mw:DOMFragment/sealed/ref' );
		DOMUtils::addTypeOf( $linkBackSup, 'mw:Extension/ref' );
	}

	/**
	 * Compare named <ref> content with previous contents.
	 *
	 * If there are multiple <ref>s with the same name, but different content, the content of the
	 * first <ref> shows up in the <references> section. In order to ensure lossless roundtripping
	 * for later <ref>s, we have to record HTML inline for all of them.
	 *
	 * @return int One of the self::CONFLICT_… constants specifying the type of conflict detected
	 */
	private function checkForConflictingContent(
		ParsoidExtensionAPI $extApi,
		string $refGroupName,
		string $refName,
		string $existingContentId,
		Element $newRefFragment
	): int {
		$this->conflictCache[$refGroupName][$refName] ??=
			self::normalizeRef( $extApi->domToHtml( $extApi->getContentDOM( $existingContentId )->firstChild, true ) );
		$existingHtml = $this->conflictCache[$refGroupName][$refName];

		$html = $extApi->domToHtml( $newRefFragment, true );

		// Optimized for performance: usually there is no conflict
		if ( $this->normalizeRef( $html ) === $existingHtml ) {
			return self::CONFLICT_NONE;
		} elseif ( $this->normalizeRef( $html, true ) ===
			$this->normalizeRef( $existingHtml, true )
		) {
			return self::CONFLICT_INVISIBLE;
		} else {
			return self::CONFLICT_VISIBLE;
		}
	}

	/**
	 * This method removes the data-parsoid and about attributes from the HTML string passed in parameters, so
	 * that it doesn't interfere for the comparison of identical references.
	 * Remex does not implement the removal of "foreign" attributes, which means that these attributes cannot be
	 * removed on math and svg elements (T380977), and that trying to do so crashes the rendering.
	 * To avoid this, we only apply the normalization to nodes in the HTML namespace. This is wider than the
	 * exact definition of foreign attributes in Remex, but the other integration points of non-foreign content
	 * would be embedded in foreign content anyway - whose data-parsoid/about attributes would not be stripped
	 * anyway, so there's no need to process them.
	 * This means that identical references containing math or svg tags will be detected as being different.
	 * This is probably a rare enough corner case. If it is not, implementing the handling of foreign attributes
	 * (as started in Idf30b3afa00743fd78b015ff080cac29e1673f09) is a path to re-consider.
	 * The extra parameter $checkContent allows to further suppress error messages for references that are not
	 * technically identical from a round-tripping perspective (hence the need to still consider them distinct) but
	 * "close enough" to not bother editors with a confusing error message. This is in particular the case for
	 * references that involve various levels of templating, which means involve different variation of
	 * data-mw presence/absence.
	 */
	private function normalizeRef( string $s, bool $removeMetadata = false ): string {
		return HtmlHelper::modifyElements( $s,
			static function ( SerializerNode $node ) use ( $removeMetadata ): bool {
				return $node->namespace == HTMLData::NS_HTML
					&& ( isset( $node->attrs['data-parsoid'] )
						|| isset( $node->attrs['about'] )
						|| ( $removeMetadata
							&& ( isset( $node->attrs['data-mw'] ) || isset( $node->attrs['typeof'] ) )
						)
					);
			},
			static function ( SerializerNode $node ) use ( $removeMetadata ): SerializerNode {
				unset( $node->attrs['data-parsoid'] );
				unset( $node->attrs['about'] );
				if ( $removeMetadata ) {
					unset( $node->attrs['data-mw'] );
					unset( $node->attrs['typeof'] );
				}
				return $node;
			}
		);
	}

}
