<?php

declare( strict_types = 1 );

namespace Wikimedia\Parsoid\Wt2Html\DOM\Processors;

use stdClass;
use Wikimedia\Assert\UnreachableException;
use Wikimedia\Parsoid\Config\Env;
use Wikimedia\Parsoid\Core\DomSourceRange;
use Wikimedia\Parsoid\DOM\Comment;
use Wikimedia\Parsoid\DOM\Element;
use Wikimedia\Parsoid\DOM\Node;
use Wikimedia\Parsoid\DOM\Text;
use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
use Wikimedia\Parsoid\NodeData\DataParsoid;
use Wikimedia\Parsoid\NodeData\TempData;
use Wikimedia\Parsoid\NodeData\TemplateInfo;
use Wikimedia\Parsoid\Utils\DiffDOMUtils;
use Wikimedia\Parsoid\Utils\DOMCompat;
use Wikimedia\Parsoid\Utils\DOMDataUtils;
use Wikimedia\Parsoid\Utils\DOMUtils;
use Wikimedia\Parsoid\Utils\PHPUtils;
use Wikimedia\Parsoid\Utils\Timing;
use Wikimedia\Parsoid\Utils\Utils;
use Wikimedia\Parsoid\Utils\WTUtils;
use Wikimedia\Parsoid\Wikitext\Consts;
use Wikimedia\Parsoid\Wt2Html\Wt2HtmlDOMProcessor;

/**
 * DOM pass that walks the DOM tree, detects specific wikitext patterns,
 * and emits them as linter events.
 */
class Linter implements Wt2HtmlDOMProcessor {
	private ?ParsoidExtensionAPI $extApi = null;
	private ?string $obsoleteTagsRE = null;
	private array $seenIds = [];

	/** @var array<string,bool>|null */
	private ?array $tagsWithChangedMisnestingBehavior = null;

	/**
	 * We are trying to find HTML5 tags that have different behavior compared to HTML4
	 * in some misnesting scenarios around wikitext paragraphs.
	 *
	 * Ex: Input: <p><small>a</p><p>b</small></p>
	 *     Tidy  output: <p><small>a</small></p><p><small>b</small></p>
	 *     HTML5 output: <p><small>a</small></p><p><small>b</small></p>
	 *
	 * So, all good here.
	 * But, see how output changes when we use <span> instead
	 *
	 * Ex: Input: <p><span>a</p><p>b</span></p>
	 *     Tidy  output: <p><span>a</span></p><p><span>b</span></p>
	 *     HTML5 output: <p><span>a</span></p><p>b</p>
	 *
	 * The source wikitext is "<span>a\n\nb</span>". The difference persists even
	 * when you have "<span>a\n\n<div>b</div>" or "<span>a\n\n{|\n|x\n|}\nbar".
	 *
	 * This is because Tidy seems to be doing the equivalent of HTM5-treebuilder's
	 * active formatting element reconstruction step on all *inline* elements.
	 * However, HTML5 parsers only do that on formatting elements. So, we need
	 * to compute which HTML5 tags are subject to this differential behavior.
	 *
	 * We compute that by excluding the following tags from the list of all HTML5 tags
	 * - If our sanitizer doesn't allow them, they will be escaped => ignore them
	 * - HTML4 block tags are excluded (obviously)
	 * - Void tags don't matter since they cannot wrap anything (obviously)
	 * - Active formatting elements have special handling in the HTML5 tree building
	 *   algorithm where they are reconstructed to wrap all originally intended content.
	 *   (ex: <small> above)
	 *
	 * Here is the list of 22 HTML5 tags that are affected:
	 *    ABBR, BDI, BDO, CITE, DATA, DEL, DFN, INS, KBD, MARK,
	 *    Q, RB, RP, RT, RTC, RUBY, SAMP, SPAN, SUB, SUP, TIME, VAR
	 *
	 * https://phabricator.wikimedia.org/T176363#3628173 verifies that this list of
	 * tags all demonstrate this behavior.
	 *
	 * @return array
	 * @phan-return array<string,bool>
	 */
	private function getTagsWithChangedMisnestingBehavior(): array {
		if ( $this->tagsWithChangedMisnestingBehavior === null ) {
			// This set is frozen in time.  It gets us down to the requisite
			// 22 HTML5 tags above, but shouldn't be used for anything other
			// than that.
			$HTML4TidyBlockTags = PHPUtils::makeSet( [
				'div', 'p',
				# tables
				'table', 'tbody', 'thead', 'tfoot', 'caption', 'th', 'tr', 'td',
				# lists
				'ul', 'ol', 'li', 'dl', 'dt', 'dd',
				# HTML5 heading content
				'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hgroup',
				# HTML5 sectioning content
				'article', 'aside', 'nav', 'section', 'footer', 'header',
				'figure', 'figcaption', 'fieldset', 'details', 'blockquote',
				# other
				'hr', 'button', 'canvas', 'center', 'col', 'colgroup', 'embed',
				'map', 'object', 'pre', 'progress', 'video',
			] );
			$this->tagsWithChangedMisnestingBehavior = [];
			foreach ( Consts::$HTML['HTML5Tags'] as $tag => $dummy ) {
				if ( isset( Consts::$Sanitizer['AllowedLiteralTags'][$tag] ) &&
					!isset( $HTML4TidyBlockTags[$tag] ) &&
					!isset( Consts::$HTML['FormattingTags'][$tag] ) &&
					!isset( Consts::$HTML['VoidTags'][$tag] )
				) {
					$this->tagsWithChangedMisnestingBehavior[$tag] = true;
				}
			}
		}

		return $this->tagsWithChangedMisnestingBehavior;
	}

	/**
	 * Finds a matching node at the "start" of this node.
	 */
	private function leftMostMisnestedDescendent( ?Node $node, Element $match ): ?Element {
		if ( !$node instanceof Element ) {
			return null;
		}

		if ( DOMUtils::isMarkerMeta( $node, 'mw:Placeholder/StrippedTag' ) ) {
			$name = DOMDataUtils::getDataParsoid( $node )->name ?? null;
			return $name === DOMCompat::nodeName( $match ) ? $node : null;
		}

		if ( DOMCompat::nodeName( $node ) === DOMCompat::nodeName( $match ) ) {
			$dp = DOMDataUtils::getDataParsoid( $node );
			if ( ( DOMDataUtils::getDataParsoid( $match )->stx ?? null ) === ( $dp->stx ?? null ) &&
				!empty( $dp->autoInsertedStart )
			) {
				if ( !empty( $dp->autoInsertedEnd ) ) {
					return $this->getMatchingMisnestedNode( $node, $match );
				} else {
					return $node;
				}
			}
		}

		return $this->leftMostMisnestedDescendent( $node->firstChild, $match );
	}

	/**
	 * $node has an 'autoInsertedEnd' flag set on it. We are looking for
	 * its matching node that has an 'autoInsertedStart' flag set on it.
	 * This happens when the tree-builder fixes up misnested tags.
	 * This "adjacency" is wrt the HTML string. In a DOM, this can either
	 * be the next sibling OR, it might be the left-most-descendent of
	 * of $node's parent's sibling (and so on up the ancestor chain).
	 */
	private function getMatchingMisnestedNode( Node $node, Element $match ): ?Element {
		if ( DOMUtils::atTheTop( $node ) ) {
			return null;
		}

		if ( DiffDOMUtils::nextNonSepSibling( $node ) ) {
			return $this->leftMostMisnestedDescendent( DiffDOMUtils::nextNonSepSibling( $node ), $match );
		}

		return $this->getMatchingMisnestedNode( $node->parentNode, $match );
	}

	/**
	 * Given a tplInfo object, determine whether we are:
	 * - Not processing template content (could be extension or top level page)
	 * - Processing encapsulated content that is produced by a single template.
	 *   If so, return the name of that template.
	 * - Processing encapsulated content that comes from multiple templates.
	 *   If so, return a flag indicating this.
	 *
	 * FIXME: We might potentially be computing this information redundantly
	 * for every lint we find within this template's content. It could probably
	 * be cached in tplInfo after it is computed once.
	 */
	public static function findEnclosingTemplateName( Env $env, ?stdClass $tplInfo ): ?array {
		if ( !$tplInfo ) {
			return null;
		}

		if ( !DOMUtils::hasTypeOf( $tplInfo->first, 'mw:Transclusion' ) ) {
			return null;
		}
		$dmw = DOMDataUtils::getDataMw( $tplInfo->first );
		// This count check is conservative in that link suffixes and prefixes
		// could artifically add an extra element to the parts array but we
		// don't have a good way of distinguishing that right now. It will require
		// a non-string representation for them and a change in spec along with
		// a version bump and all that song and dance. If linting accuracy in these
		// scenarios become a problem, we can revisit this.
		if (
			!empty( $dmw->parts ) &&
			count( $dmw->parts ) === 1
		) {
			$p0 = $dmw->parts[0];
			if ( !( $p0 instanceof TemplateInfo ) ) {
				throw new UnreachableException(
					"a single part will always be a TemplateInfo not a string"
				);
			}
			$name = null;
			if ( !empty( $p0->href ) ) { // Could be "function"
				// PORT-FIXME: Should that be SiteConfig::relativeLinkPrefix() rather than './'?
				$name = PHPUtils::stripPrefix( $p0->href, './' );
			} else {
				// type === 'templatearg' or 'template'
				$name = trim( $p0->targetWt );
			}
			return [ 'name' => $name ];
		} else {
			return [ 'multiPartTemplateBlock' => true ];
		}
	}

	/**
	 * Compute the DSR information for the lint object.
	 * - In the common case, this is simply the DSR value of the node
	 *   that generated the lint. But, occasionally, for some lints,
	 *   we might have to post-process the node's DSR.
	 * - If the lint is found in template content, then the DSR spans
	 *   the transclusion markup in the toplevel page source.
	 */
	public static function findLintDSR(
		?array $tplLintInfo, ?stdClass $tplInfo, ?DomSourceRange $nodeDSR,
		?callable $updateNodeDSR = null
	): ?DomSourceRange {
		if ( $tplLintInfo !== null || ( $tplInfo && !Utils::isValidDSR( $nodeDSR ) ) ) {
			return DOMDataUtils::getDataParsoid( $tplInfo->first )->dsr ?? null;
		} else {
			return $updateNodeDSR ? $updateNodeDSR( $nodeDSR ) : $nodeDSR;
		}
	}

	/**
	 * Determine if a node has an identical nested tag (?)
	 */
	private function hasIdenticalNestedTag( Element $node, string $name ): bool {
		$c = $node->firstChild;
		while ( $c ) {
			if ( $c instanceof Element ) {
				if (
					DOMCompat::nodeName( $c ) === $name &&
					empty( DOMDataUtils::getDataParsoid( $c )->autoInsertedEnd )
				) {
					return true;
				}

				return $this->hasIdenticalNestedTag( $c, $name );
			}

			$c = $c->nextSibling;
		}

		return false;
	}

	/**
	 * Determine if a node has misnestable content
	 */
	private function hasMisnestableContent( Node $node, string $name ): bool {
		// For A, TD, TH, H* tags, Tidy doesn't seem to propagate
		// the unclosed tag outside these tags.
		// No need to check for tr/table since content cannot show up there
		if ( DOMUtils::atTheTop( $node ) || preg_match( '/^(?:a|td|th|h\d)$/D', DOMCompat::nodeName( $node ) ) ) {
			return false;
		}

		$next = DiffDOMUtils::nextNonSepSibling( $node );
		if ( !$next ) {
			return $this->hasMisnestableContent( $node->parentNode, $name );
		}

		$contentNode = null;
		if ( DOMCompat::nodeName( $next ) === 'p' && !WTUtils::isLiteralHTMLNode( $next ) ) {
			$contentNode = DiffDOMUtils::firstNonSepChild( $next );
		} else {
			$contentNode = $next;
		}

		// If the first "content" node we find is a matching
		// stripped tag, we have nothing that can get misnested
		return $contentNode && !(
			$contentNode instanceof Element &&
			DOMUtils::isMarkerMeta( $contentNode, 'mw:Placeholder/StrippedTag' ) &&
			isset( DOMDataUtils::getDataParsoid( $contentNode )->name ) &&
			DOMDataUtils::getDataParsoid( $contentNode )->name === $name
		);
	}

	/**
	 * Indicate whether an end tag is optional for this node
	 *
	 * See https://www.w3.org/TR/html5/syntax.html#optional-tags
	 *
	 * End tags for tr/td/th/li are entirely optional since they
	 * require a parent container and can only be followed by like
	 * kind.
	 *
	 * Caveat: <li>foo</li><ol>..</ol> and <li>foo<ol>..</ol>
	 * generate different DOM trees, so explicit </li> tag
	 * is required to specify which of the two was intended.
	 *
	 * With that one caveat around nesting, the parse with/without
	 * the end tag is identical. For now, ignoring that caveat
	 * since they aren't like to show up in our corpus much.
	 *
	 * For the other tags in that w3c spec section, I haven't reasoned
	 * through when exactly they are optional. Not handling that complexity
	 * for now since those are likely uncommon use cases in our corpus.
	 */
	private function endTagOptional( Node $node ): bool {
		static $tagNames = [ 'tr', 'td', 'th', 'li' ];
		return in_array( DOMCompat::nodeName( $node ), $tagNames, true );
	}

	/**
	 * Find the nearest ancestor heading tag
	 */
	private function getHeadingAncestor( Node $node ): ?Node {
		while ( $node && !DOMUtils::isHeading( $node ) ) {
			$node = $node->parentNode;
		}
		return $node;
	}

	/**
	 * For formatting tags, Tidy seems to be doing this "smart" fixup of
	 * unclosed tags by looking for matching unclosed pairs of identical tags
	 * and if the content ends in non-whitespace text, it treats the second
	 * unclosed opening tag as a closing tag. But, a HTML5 parser won't do this.
	 * So, detect this pattern and flag for linter fixup.
	 */
	private function matchedOpenTagPairExists( Node $c, DataParsoid $dp ): bool {
		$lc = $c->lastChild;
		if ( !$lc instanceof Element || DOMCompat::nodeName( $lc ) !== DOMCompat::nodeName( $c ) ) {
			return false;
		}

		$lcDP = DOMDataUtils::getDataParsoid( $lc );
		if ( empty( $lcDP->autoInsertedEnd ) || ( $lcDP->stx ?? null ) !== ( $dp->stx ?? null ) ) {
			return false;
		}

		$prev = $lc->previousSibling;
		// PORT-FIXME: Do we care about non-ASCII whitespace here?
		if ( $prev instanceof Text && !preg_match( '/\s$/D', $prev->nodeValue ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Lint Treebuilder fixups marked by ProcessTreeBuilderFixups
	 *
	 * It handles the following scenarios:
	 *
	 * 1. Unclosed end tags (`missing-end-tag`, `missing-end-tag-in-heading`)
	 * 2. Invalid self-closed tags (`self-closed-tag`)
	 * 3. Stripped tags (`stripped-tag`)
	 *
	 * In addition, we have specialized categories for some patterns
	 * where we encounter unclosed end tags.
	 *
	 * 4. misnested-tag
	 * 5. html5-misnesting
	 * 6. multiple-unclosed-formatting-tags
	 * 7. unclosed-quotes-in-heading
	 */
	private function lintTreeBuilderFixup(
		Env $env, Element $c, DataParsoid $dp, ?stdClass $tplInfo
	): void {
		// This might have been processed as part of
		// misnested-tag category identification.
		if ( $dp->getTempFlag( TempData::LINTED ) ) {
			return;
		}

		$tplLintInfo = self::findEnclosingTemplateName( $env, $tplInfo );
		// During DSR computation, stripped meta tags
		// surrender their width to its previous sibling.
		// We record the original DSR in the tmp attribute
		// for that reason.
		$dsr = self::findLintDSR( $tplLintInfo, $tplInfo, $dp->tmp->origDSR ?? $dp->dsr ?? null );
		$lintObj = null;
		if ( DOMUtils::isMarkerMeta( $c, 'mw:Placeholder/StrippedTag' ) ) {
			$lintObj = [
				'dsr' => $dsr,
				'templateInfo' => $tplLintInfo,
				'params' => [ 'name' => $dp->name ?? null ],
			];
			$env->recordLint( 'stripped-tag', $lintObj );
		}

		// Dont bother linting for auto-inserted start/end or self-closing-tag if:
		// 1. c is a void element
		//    Void elements won't have auto-inserted start/end tags
		//    and self-closing versions are valid for them.
		//
		// 2. c is tbody (FIXME: don't remember why we have this exception)
		//
		// 3. c is not an HTML element (unless they are i/b quotes or tables)
		//
		// 4. c doesn't have DSR info and doesn't come from a template either
		$cNodeName = DOMCompat::nodeName( $c );
		$ancestor = null;
		$isHtmlElement = WTUtils::hasLiteralHTMLMarker( $dp );
		if ( !Utils::isVoidElement( $cNodeName ) &&
			$cNodeName !== 'tbody' &&
			( $isHtmlElement || DOMUtils::isQuoteElt( $c ) || $cNodeName === 'table' ) &&
			( $tplInfo !== null || $dsr !== null )
		) {
			if ( !empty( $dp->selfClose ) && $cNodeName !== 'meta' ) {
				$lintObj = [
					'dsr' => $dsr,
					'templateInfo' => $tplLintInfo,
					'params' => [ 'name' => $cNodeName ],
				];
				$env->recordLint( 'self-closed-tag', $lintObj );
				// The other checks won't pass - no need to test them.
				return;
			}

			if (
				( $dp->autoInsertedEnd ?? null ) === true &&
				( $tplInfo || ( $dsr->openWidth ?? 0 ) > 0 )
			) {
				$lintObj = [
					'dsr' => $dsr,
					'templateInfo' => $tplLintInfo,
					'params' => [ 'name' => $cNodeName ],
				];

				// FIXME: This literal html marker check is strictly not required
				// (a) we've already checked that above and know that isQuoteElt is
				//     not one of our tags.
				// (b) none of the tags in the list have native wikitext syntax =>
				//     they will show up as literal html tags.
				// But, in the interest of long-term maintenance in the face of
				// changes (to wikitext or html specs), let us make it explicit.
				if ( $isHtmlElement &&
					isset( $this->getTagsWithChangedMisnestingBehavior()[DOMCompat::nodeName( $c )] ) &&
					$this->hasMisnestableContent( $c, DOMCompat::nodeName( $c ) ) &&
					// Tidy WTF moment here!
					// I don't know why Tidy does something very different
					// when there is an identical nested tag here.
					//
					// <p><span id='1'>a<span>X</span></p><p>b</span></p>
					// vs.
					// <p><span id='1'>a</p><p>b</span></p>  OR
					// <p><span id='1'>a<del>X</del></p><p>b</span></p>
					//
					// For the first snippet, Tidy only wraps "a" with the id='1' span
					// For the second and third snippets, Tidy wraps "b" with the id='1' span as well.
					//
					// For the corresponding wikitext that generates the above token stream,
					// Parsoid (and Remex) won't wrap 'b' with the id=1' span at all.
					!$this->hasIdenticalNestedTag( $c, DOMCompat::nodeName( $c ) )
				) {
					$env->recordLint( 'html5-misnesting', $lintObj );
				} elseif (
					!$isHtmlElement && DOMUtils::isQuoteElt( $c ) &&
					// phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.Found
					( $ancestor = $this->getHeadingAncestor( $c->parentNode ) )
				) {
					$lintObj['params']['ancestorName'] = DOMCompat::nodeName( $ancestor );
					$env->recordLint( 'unclosed-quotes-in-heading', $lintObj );
				} else {
					$adjNode = $this->getMatchingMisnestedNode( $c, $c );
					if ( $adjNode ) {
						$adjDp = DOMDataUtils::getDataParsoid( $adjNode );
						$adjDp->setTempFlag( TempData::LINTED );
						$env->recordLint( 'misnested-tag', $lintObj );
					} elseif ( !$this->endTagOptional( $c ) && empty( $dp->autoInsertedStart ) ) {
						$lintObj['params']['inTable'] = DOMUtils::hasNameOrHasAncestorOfName( $c, 'table' );
						$category = $this->getHeadingAncestor( $c ) ?
							'missing-end-tag-in-heading' : 'missing-end-tag';
						$next = DiffDOMUtils::nextNonSepSibling( $c );
						if (
							// Skip if covered by deletable-table-tag
							!( $cNodeName === 'table' && $next &&
							( DOMCompat::nodeName( $c ) === 'table' ) )
						) {
							$env->recordLint( $category, $lintObj );
						}
						if ( isset( Consts::$HTML['FormattingTags'][DOMCompat::nodeName( $c )] ) &&
							$this->matchedOpenTagPairExists( $c, $dp )
						) {
							$env->recordLint( 'multiple-unclosed-formatting-tags', $lintObj );
						}
					}
				}
			}
		}
	}

	/**
	 * Lint fostered content marked by MarkFosteredContent.
	 *
	 * Lint category: `fostered`, `fostered-transparent`
	 *
	 * This will log cases like:
	 *
	 * {|
	 * foo
	 * |-
	 * | bar
	 * |}
	 *
	 * Here 'foo' gets fostered out.
	 */
	private function lintFostered(
		Env $env, Element $node, DataParsoid $dp, ?stdClass $tplInfo
	): void {
		if ( DOMCompat::nodeName( $node ) !== 'table' ) {
			return;
		}

		// The top-level nodes in the foster box are span/p wrapped
		// and so, if we have fostered content, previous siblings to
		// the table are expected to be elements.
		$maybeFostered = $node->previousSibling;

		// Emit "fostered" or "fostered-transparent" depending on if the fostered
		// content is entirely transparent or not.
		//
		// We're trying to find a balance between creating noise for wikignomes
		// and avoiding dirty-diffs from DiscussionTools.  DiscussionTools
		// expects to know when pages have fostered content otherwise it can
		// lead to corruption on edit.  However, rendering transparent nodes
		// often end up in fosterable positions, like category links from
		// templates or include directives on template pages.

		$fosteredRenderingTransparent = false;
		while (
			$maybeFostered instanceof Element &&
			!empty( DOMDataUtils::getDataParsoid( $maybeFostered )->fostered ) &&
			( WTUtils::isRenderingTransparentNode( $maybeFostered ) ||
				// TODO: Section tags are rendering transparent but not sol transparent,
				// and that method only considers WTUtils::isSolTransparentLink, though
				// there is a FIXME to consider all link nodes.
				( DOMCompat::nodeName( $maybeFostered ) === 'link' &&
					DOMUtils::hasTypeOf( $maybeFostered, 'mw:Extension/section' ) ) )
		) {
			// Skip rendering-transparent nodes if they come from a template,
			// since they'll roundtrip cleanly regardless
			$fosteredRenderingTransparent = $fosteredRenderingTransparent || !$tplInfo;

			$maybeFostered = $maybeFostered->previousSibling;
		}

		if (
			$maybeFostered instanceof Element &&
			!empty( DOMDataUtils::getDataParsoid( $maybeFostered )->fostered )
		) {
			$type = 'fostered';
		} elseif ( $fosteredRenderingTransparent ) {
			$type = 'fostered-transparent';
		} else {
			return;
		}

		$tplLintInfo = self::findEnclosingTemplateName( $env, $tplInfo );
		$lintObj = [
			'dsr' => self::findLintDSR(
				$tplLintInfo, $tplInfo, $dp->dsr ?? null
			),
			'templateInfo' => $tplLintInfo,
		];
		$env->recordLint( $type, $lintObj );
	}

	/**
	 * Lint obsolete HTML tags.
	 *
	 * Lint category: `obsolete-tag`, `tidy-font-bug`
	 */
	private function lintObsoleteTag(
		Env $env, Element $c, DataParsoid $dp, ?stdClass $tplInfo
	): void {
		if ( !$this->obsoleteTagsRE ) {
			$elts = [];
			foreach ( Consts::$HTML['OlderHTMLTags'] as $tag => $dummy ) {
				// Looks like all existing editors let editors add the <big> tag.
				// VE has a button to add <big>, it seems so does the WikiEditor
				// and JS wikitext editor. So, don't flag BIG as an obsolete tag.
				if ( $tag !== 'big' ) {
					$elts[] = preg_quote( $tag, '/' );
				}
			}
			$this->obsoleteTagsRE = '/^(?:' . implode( '|', $elts ) . ')$/D';
		}

		$tplLintInfo = null;
		if ( ( empty( $dp->autoInsertedStart ) || empty( $dp->autoInsertedEnd ) ) &&
			preg_match( $this->obsoleteTagsRE, DOMCompat::nodeName( $c ) )
		) {
			$tplLintInfo = self::findEnclosingTemplateName( $env, $tplInfo );
			$lintObj = [
				'dsr' => self::findLintDSR( $tplLintInfo, $tplInfo, $dp->dsr ?? null ),
				'templateInfo' => $tplLintInfo,
				'params' => [ 'name' => DOMCompat::nodeName( $c ) ],
			];
			$env->recordLint( 'obsolete-tag', $lintObj );
		}

		if ( DOMCompat::nodeName( $c ) === 'font' && $c->hasAttribute( 'color' ) ) {
			/* ----------------------------------------------------------
			 * Tidy migrates <font> into the link in these cases
			 *     <font>[[Foo]]</font>
			 *     <font>[[Foo]]l</font> (link-trail)
			 *     <font><!--boo-->[[Foo]]</font>
			 *     <font>__NOTOC__[[Foo]]</font>
			 *     <font>[[Category:Foo]][[Foo]]</font>
			 *     <font>{{1x|[[Foo]]}}</font>
			 *
			 * Tidy does not migrate <font> into the link in these cases
			 *     <font> [[Foo]]</font>
			 *     <font>[[Foo]] </font>
			 *     <font>[[Foo]]L</font> (not a link-trail)
			 *     <font>[[Foo]][[Bar]]</font>
			 *     <font>[[Foo]][[Bar]]</font>
			 *
			 * <font> is special.
			 * This behavior is not seen with other formatting tags.
			 *
			 * Remex/parsoid won't do any of this.
			 * This difference in behavior only matters when the font tag
			 * specifies a link colour because the link no longer renders
			 * as blue/red but in the font-specified colour.
			 * ---------------------------------------------------------- */
			$tidyFontBug = $c->firstChild !== null;
			$haveLink = false;
			for ( $n = $c->firstChild;  $n;  $n = $n->nextSibling ) {
				$nodeName = DOMCompat::nodeName( $n );
				if ( $nodeName !== 'a' &&
					!WTUtils::isRenderingTransparentNode( $n ) &&
					!WTUtils::isTplMarkerMeta( $n )
				) {
					$tidyFontBug = false;
					break;
				}

				if ( $nodeName === 'a' || $nodeName === 'figure' ) {
					if ( !$haveLink ) {
						$haveLink = true;
					} else {
						$tidyFontBug = false;
						break;
					}
				}
			}

			if ( $tidyFontBug ) {
				$tplLintInfo = self::findEnclosingTemplateName( $env, $tplInfo );
				$env->recordLint( 'tidy-font-bug', [
					'dsr' => self::findLintDSR( $tplLintInfo, $tplInfo, $dp->dsr ?? null ),
					'templateInfo' => $tplLintInfo,
					'params' => [ 'name' => 'font' ]
				] );
			}
		}
	}

	/**
	 * Log bogus (=unrecognized) media options.
	 *
	 * See - https://www.mediawiki.org/wiki/Help:Images#Syntax
	 *
	 * Lint category: `bogus-image-options`
	 */
	private function lintBogusImageOptions(
		Env $env, Node $c, DataParsoid $dp, ?stdClass $tplInfo
	): void {
		// Despite the lint category name, this checks all media, not just images
		if ( WTUtils::isGeneratedFigure( $c ) && !empty( $dp->optList ) ) {
			$items = [];
			$bogusPx = $dp->getTempFlag( TempData::BOGUS_PX );
			foreach ( $dp->optList as $item ) {
				if (
					$item['ck'] === 'bogus' ||
					( $bogusPx && $item['ck'] === 'width' )
				) {
					$items[] = $item['ak'];
				}
			}
			if ( $items ) {
				$tplLintInfo = self::findEnclosingTemplateName( $env, $tplInfo );
				$env->recordLint( 'bogus-image-options', [
					'dsr' => self::findLintDSR( $tplLintInfo, $tplInfo, $dp->dsr ?? null ),
					'templateInfo' => $tplLintInfo,
					'params' => [ 'items' => $items ]
				] );
			}
		}
	}

	/**
	 * Lint tables Tidy deletes.
	 *
	 * Lint category: `deletable-table-tag`
	 *
	 * In this example below, the second table is in a fosterable position
	 * (inside a <tr>). The tree builder closes the first table at that point
	 * and starts a new table there. We are detecting this pattern because
	 * Tidy does something very different here. It strips the inner table
	 * and retains the outer table. So, for preserving rendering of pages
	 * that are tailored for Tidy, editors have to fix up this wikitext
	 * to strip the inner table (to mimic what Tidy does).
	 *
	 *   {| style='border:1px solid red;'
	 *   |a
	 *   |-
	 *   {| style='border:1px solid blue;'
	 *   |b
	 *   |c
	 *   |}
	 *   |}
	 */
	private function lintDeletableTableTag(
		Env $env, Node $c, DataParsoid $dp, ?stdClass $tplInfo
	): void {
		if ( DOMCompat::nodeName( $c ) === 'table' ) {
			$prev = DiffDOMUtils::previousNonSepSibling( $c );
			if ( $prev instanceof Element && DOMCompat::nodeName( $prev ) === 'table' &&
				!empty( DOMDataUtils::getDataParsoid( $prev )->autoInsertedEnd )
			) {
				$tplLintInfo = self::findEnclosingTemplateName( $env, $tplInfo );
				$dsr = self::findLintDSR(
					$tplLintInfo,
					$tplInfo,
					$dp->dsr ?? null,
					static function ( ?DomSourceRange $nodeDSR ): ?DomSourceRange {
						// Identify the dsr-span of the opening tag
						// of the table that needs to be deleted
						$x = $nodeDSR === null ? null : ( clone $nodeDSR );
						if ( !empty( $x->openWidth ) ) {
							$x->end = $x->innerStart();
							$x->openWidth = 0;
							$x->closeWidth = 0;
						}
						return $x;
					}
				);
				$lintObj = [
					'dsr' => $dsr,
					'templateInfo' => $tplLintInfo,
					'params' => [ 'name' => 'table' ],
				];
				$env->recordLint( 'deletable-table-tag', $lintObj );
			}
		}
	}

	/**
	 * Find the first child passing the filter.
	 */
	private function findMatchingChild( Node $node, callable $filter ): ?Node {
		$c = $node->firstChild;
		while ( $c && !$filter( $c ) ) {
			$c = $c->nextSibling;
		}

		return $c;
	}

	/**
	 * Test if the node has a 'nowrap' CSS rule
	 *
	 * In the general case, this CSS can come from a class,
	 * or from a <style> tag or a stylesheet or even from JS code.
	 * But, for now, we are restricting this inspection to inline CSS
	 * since the intent is to aid editors in fixing patterns that
	 * can be automatically detected.
	 *
	 * Special case for enwiki that has Template:nowrap which
	 * assigns class='nowrap' with CSS white-space:nowrap in
	 * MediaWiki:Common.css
	 */
	private function hasNoWrapCSS( Node $node ): bool {
		return $node instanceof Element && (
			str_contains( DOMCompat::getAttribute( $node, 'style' ) ?? '', 'nowrap' ) ||
			DOMUtils::hasClass( $node, 'nowrap' )
		);
	}

	/**
	 * Lint bad P wrapping.
	 *
	 * Lint category: `pwrap-bug-workaround`
	 */
	private function lintPWrapBugWorkaround(
		Env $env, Element $node, DataParsoid $dp, ?stdClass $tplInfo
	): void {
		if (
			!DOMUtils::isWikitextBlockNode( $node ) &&
			DOMUtils::isWikitextBlockNode( $node->parentNode ) &&
			$this->hasNoWrapCSS( $node )
		) {
			$p = $this->findMatchingChild( $node, static function ( $e ) {
				return DOMCompat::nodeName( $e ) === 'p';
			} );
			if ( $p ) {
				$tplLintInfo = self::findEnclosingTemplateName( $env, $tplInfo );
				$lintObj = [
					'dsr' => self::findLintDSR( $tplLintInfo, $tplInfo, $dp->dsr ?? null ),
					'templateInfo' => $tplLintInfo,
					'params' => [
						'root' => DOMCompat::nodeName( $node->parentNode ),
						'child' => DOMCompat::nodeName( $node ),
					]
				];
				$env->recordLint( 'pwrap-bug-workaround', $lintObj );
			}
		}
	}

	/**
	 * Lint Tidy div span flip.
	 *
	 * Lint category: `misc-tidy-replacement-issues`
	 */
	private function lintMiscTidyReplacementIssues(
		Env $env, Element $node, DataParsoid $dp, ?stdClass $tplInfo
	): void {
		if ( DOMCompat::nodeName( $node ) !== 'span' ) {
			return;
		}

		$fc = DiffDOMUtils::firstNonSepChild( $node );
		if ( !$fc instanceof Element || DOMCompat::nodeName( $fc ) !== 'div' ) {
			return;
		}

		// No style/class attributes -- so, this won't affect rendering
		if ( !$node->hasAttribute( 'class' ) && !$node->hasAttribute( 'style' ) &&
			!$fc->hasAttribute( 'class' ) && !$fc->hasAttribute( 'style' )
		) {
			return;
		}

		$tplLintInfo = self::findEnclosingTemplateName( $env, $tplInfo );
		$lintObj = [
			'dsr' => self::findLintDSR( $tplLintInfo, $tplInfo, $dp->dsr ?? null ),
			'templateInfo' => $tplLintInfo,
			'params' => [ 'subtype' => 'div-span-flip' ]
		];
		$env->recordLint( 'misc-tidy-replacement-issues', $lintObj );
	}

	/**
	 * Lint tidy whitespace bug.
	 *
	 * Lint category: `tidy-whitespace-bug`
	 */
	private function lintTidyWhitespaceBug(
		Env $env, Node $node, DataParsoid $dp, ?stdClass $tplInfo
	): void {
		// We handle a run of nodes in one shot.
		// No need to reprocess repeatedly.
		if ( $dp->getTempFlag( TempData::PROCESSED_TIDY_WS_BUG ) ) {
			return;
		}

		// Find the longest run of nodes that are affected by white-space:nowrap CSS
		// in a way that leads to unsightly rendering in HTML5 compliant browsers.
		//
		// Check if Tidy does buggy whitespace hoisting there to provide the browser
		// opportunities to split the content in short segments.
		//
		// If so, editors would need to edit this run of nodes to introduce
		// whitespace breaks as necessary so that HTML5 browsers get that
		// same opportunity when Tidy is removed.
		$s = null;
		$nowrapNodes = [];
		'@phan-var array<array{node:Node,tidybug:bool,hasLeadingWS:bool}> $nowrapNodes';
		$startNode = $node;
		$haveTidyBug = false;
		$runLength = 0;

		// <br>, <wbr>, <hr> break a line
		while ( $node && !DOMUtils::isRemexBlockNode( $node ) &&
			!in_array( DOMCompat::nodeName( $node ), [ 'hr', 'br', 'wbr' ], true )
		) {
			if ( $node instanceof Text || !$this->hasNoWrapCSS( $node ) ) {
				// No CSS property that affects whitespace.
				$s = $node->textContent;
				if ( preg_match( '/^(\S*)\s/', $s, $m ) ) { // PORT-FIXME: non-ASCII whitespace?
					$runLength += strlen( $m[1] );
					$nowrapNodes[] = [
						'node' => $node,
						'tidybug' => false,
						'hasLeadingWS' => ( preg_match( '/^\s/', $s ) === 1 ), // PORT-FIXME: non-ASCII whitespace?
					];
					break;
				} else {
					$nowrapNodes[] = [ 'node' => $node, 'tidybug' => false ];
					$runLength += strlen( $s );
				}
			} else {
				// Find last non-comment child of node
				$last = $node->lastChild;
				while ( $last instanceof Comment ) {
					$last = $last->previousSibling;
				}

				$bug = false;
				if ( $last instanceof Text &&
					preg_match( '/\s$/D', $last->nodeValue ) // PORT-FIXME: non-ASCII whitespace?
				) {
					// In this scenario, when Tidy hoists the whitespace to
					// after the node, that whitespace is not subject to the
					// nowrap CSS => browsers can break content there.
					//
					// But, non-Tidy libraries won't hoist the whitespace.
					// So, browsers don't have a place to break content.
					$bug = true;
					$haveTidyBug = true;
				}

				$nowrapNodes[] = [ 'node' => $node, 'tidybug' => $bug ];
				$runLength += strlen( $node->textContent );
			}

			// Don't cross template boundaries at the top-level
			if ( $tplInfo && $tplInfo->last === $node ) {
				// Exiting a top-level template
				break;
			} elseif ( !$tplInfo && WTUtils::findFirstEncapsulationWrapperNode( $node ) ) {
				// Entering a top-level template
				break;
			}

			// Move to the next non-comment sibling
			$node = $node->nextSibling;
			while ( $node instanceof Comment ) {
				$node = $node->nextSibling;
			}
		}

		$markProcessedNodes = static function () use ( &$nowrapNodes ) { // Helper
			foreach ( $nowrapNodes as $o ) {
				// Phan fails at applying the instanceof type restriction to the array member when analyzing the
				// following call, but is fine when it's copied to a local variable.
				$node = $o['node'];
				if ( $node instanceof Element ) {
					DOMDataUtils::getDataParsoid( $node )->setTempFlag( TempData::PROCESSED_TIDY_WS_BUG );
				}
			}
		};

		if ( !$haveTidyBug ) {
			// Mark processed nodes and bail
			$markProcessedNodes();
			return;
		}

		// Find run before startNode that doesn't have a whitespace break
		$prev = $startNode->previousSibling;
		while ( $prev && !DOMUtils::isRemexBlockNode( $prev ) ) {
			if ( !( $prev instanceof Comment ) ) {
				$s = $prev->textContent;
				// Find the last \s in the string
				if ( preg_match( '/\s(\S*)$/D', $s, $m ) ) { // PORT-FIXME: non-ASCII whitespace here?
					$runLength += strlen( $m[1] );
					break;
				} else {
					$runLength += strlen( $s );
				}
			}
			$prev = $prev->previousSibling;
		}

		$lintConfig = $env->getLinterConfig();
		$tidyWhitespaceBugMaxLength = $lintConfig['tidyWhitespaceBugMaxLength'] ?? 100;

		if ( $runLength < $tidyWhitespaceBugMaxLength ) {
			// Mark processed nodes and bail
			$markProcessedNodes();
			return;
		}

		// For every node where Tidy hoists whitespace,
		// emit an event to flag a whitespace fixup opportunity.
		$tplLintInfo = self::findEnclosingTemplateName( $env, $tplInfo );
		$n = count( $nowrapNodes ) - 1;
		foreach ( $nowrapNodes as $i => $o ) {
			if ( $o['tidybug'] && $i < $n && empty( $nowrapNodes[$i + 1]['hasLeadingWS'] ) ) {
				$nowrapNode = $o['node']; // (see above)
				$lintObj = [
					'dsr' => self::findLintDSR(
						$tplLintInfo,
						$tplInfo,
						$nowrapNode instanceof Element
							? DOMDataUtils::getDataParsoid( $nowrapNode )->dsr ?? null
							: null
					),
					'templateInfo' => $tplLintInfo,
					'params' => [
						'node' => DOMCompat::nodeName( $o['node'] ),
						'sibling' => DOMCompat::nodeName( $o['node']->nextSibling )
					]
				];

				$env->recordLint( 'tidy-whitespace-bug', $lintObj );
			}
		}

		$markProcessedNodes();
	}

	/**
	 * Detect multiple-unclosed-formatting-tags errors.
	 *
	 * Since unclosed <small> and <big> tags accumulate their effects
	 * in HTML5 parsers (unlike in Tidy where it seems to suppress
	 * multiple unclosed elements of the same name), such pages will
	 * break pretty spectacularly with Remex.
	 *
	 * Ex: https://it.wikipedia.org/wiki/Hubert_H._Humphrey_Metrodome?oldid=93017491#Note
	 *
	 * Lint category: `multiple-unclosed-formatting-tags`
	 */
	private function lintMultipleUnclosedFormattingTags( array $lints, Env $env ): void {
		$firstUnclosedTag = [
			'small' => null,
			'big' => null
		];
		$multiUnclosedTagName = null;
		foreach ( $lints as $item ) {
			// Unclosed tags in tables don't leak out of the table
			if ( $item['type'] === 'missing-end-tag' && !$item['params']['inTable'] ) {
				if ( $item['params']['name'] === 'small' || $item['params']['name'] === 'big' ) {
					$tagName = $item['params']['name'];
					// @phan-suppress-next-line PhanPossiblyUndeclaredVariable
					if ( !$firstUnclosedTag[$tagName] ) {
						$firstUnclosedTag[$tagName] = $item;
					} else {
						$multiUnclosedTagName = $tagName;
						break;
					}
				}
			}
		}

		if ( $multiUnclosedTagName ) {
			$item = $firstUnclosedTag[$multiUnclosedTagName];
			if ( isset( $item['dsr'] ) ) {
				$item['dsr'] = DomSourceRange::newFromJsonArray( $item['dsr'] );
			}
			$env->recordLint( 'multiple-unclosed-formatting-tags', [
				'params' => $item['params'],
				'dsr' => $item['dsr'],
				'templateInfo' => $item['templateInfo'],
			] );
		}
	}

	/**
	 * Post-process an array of lints
	 */
	private function postProcessLints( array $lints, Env $env ): void {
		$this->lintMultipleUnclosedFormattingTags( $lints, $env );
	}

	/**
	 * Get wikitext list item ancestor
	 */
	private function getWikitextListItemAncestor( ?Node $node ): ?Node {
		while ( $node && !DOMUtils::isListItem( $node ) ) {
			$node = $node->parentNode;
		}

		if ( $node && !WTUtils::isLiteralHTMLNode( $node ) &&
			!WTUtils::fromExtensionContent( $node, 'references' )
		) {
			return $node;
		}

		return null;
	}

	/**
	 * Lint a PHP parser bug.
	 *
	 * When an HTML table is nested inside a list, if any part of the table
	 * is on a new line, the PHP parser misnests the list and the table.
	 * Tidy fixes the misnesting one way (puts table inside/outside the list)
	 * HTML5 parser fixes it another way (list expands to rest of the page!)
	 *
	 * Lint category: `multiline-html-table-in-list`
	 */
	private function lintMultilineHtmlTableInList(
		Env $env, Element $node, DataParsoid $dp, ?stdClass $tplInfo
	): void {
		$li = null;
		if ( !WTUtils::isLiteralHTMLNode( $node ) ||
			DOMCompat::nodeName( $node ) !== 'table' ||
			// phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.Found
			!( $li = $this->getWikitextListItemAncestor( $node ) ) ||
			!str_contains( DOMCompat::getOuterHTML( $node ), "\n" )
		) {
			return;
		}

		// We have an HTML table nested inside a list
		// that has a newline break in its outer HTML
		// => we are in trouble with the PHP Parser + Remex combo
		$tplLintInfo = self::findEnclosingTemplateName( $env, $tplInfo );
		$lintObj = [
			'dsr' => self::findLintDSR( $tplLintInfo, $tplInfo, $dp->dsr ?? null ),
			'templateInfo' => $tplLintInfo,
			'params' => [
				'name' => 'table',
				'ancestorName' => DOMCompat::nodeName( $li ),
			],
		];
		$env->recordLint( 'multiline-html-table-in-list', $lintObj );
	}

	/**
	 * Log wikilinks or media in external links.
	 *
	 * HTML tags can be nested but this is not the case for <a> tags
	 * which when nested outputs the <a> tags adjacent to each other
	 * In the example below, [[Google]] is a wikilink that is nested
	 * in the outer external link
	 * [http://google.com This is [[Google]]'s search page]
	 *
	 * Linter category: `wikilink-in-extlink`
	 */
	private function lintWikilinksInExtlink(
		Env $env, Element $node, DataParsoid $dp, ?stdClass $tplInfo
	): void {
		if (
			DOMCompat::nodeName( $node ) === 'a' &&
			DOMUtils::hasRel( $node, "mw:ExtLink" ) &&
			// Images in extlinks will end up with broken up extlinks inside the
			// <figure> DOM. Those have 'misnested' flag set on them. Ignore those.
			empty( $dp->misnested )
		) {
			$next = $node->nextSibling;
			$lintError = $next instanceof Element &&
				!empty( DOMDataUtils::getDataParsoid( $next )->misnested ) &&
				// This check may not be necessary but ensures that we are
				// really in a link-in-link misnested scenario.
				DOMUtils::treeHasElement( $next, 'a', true );

			// Media as opposed to most instances of img (barring the link= trick), don't result
			// in misnesting according the html5 spec since we're actively suppressing links in
			// their structure. However, since timed media is inherently clickable, being nested
			// in an extlink could surprise a user clicking on it by navigating away from the page.
			if ( !$lintError ) {
				DOMUtils::visitDOM( $node, static function ( $element ) use ( &$lintError ) {
					if ( $element instanceof Element &&
						( DOMCompat::nodeName( $element ) === 'audio' ||
							DOMCompat::nodeName( $element ) === 'video' )
					) {
						$lintError = true;
					}
				} );
			}
			if ( $lintError ) {
				$tplLintInfo = self::findEnclosingTemplateName( $env, $tplInfo );
				$lintObj = [
					'dsr' => self::findLintDSR( $tplLintInfo, $tplInfo, $dp->dsr ?? null ),
					'templateInfo' => $tplLintInfo,
				];
				$env->recordLint( 'wikilink-in-extlink', $lintObj );
			}
		}
	}

	private function recordLargeTablesLint(
		Env $env, ?stdClass $tplInfo, Element $node, int $numColumns, int $columnsMax
	): void {
		$tplLintInfo = self::findEnclosingTemplateName( $env, $tplInfo );
		$lintObj = [
			'dsr' => self::findLintDSR(
				$tplLintInfo, $tplInfo, DOMDataUtils::getDataParsoid( $node )->dsr ?? null
			),
			'templateInfo' => $tplLintInfo,
			'params' => [
				'name' => 'table',
				'columns' => $numColumns,
				'columnsMax' => $columnsMax,
			],
		];
		$env->recordLint( 'large-tables', $lintObj );
	}

	/**
	 * TODO: In the future, this may merit being moved to DOMUtils
	 * along with its "previous" variant.
	 */
	private function skipNonElementNodes( ?Node $n ): ?Element {
		while ( $n && !( $n instanceof Element ) ) {
			$n = $n->nextSibling;
		}
		return $n;
	}

	/**
	 * Lint large tables.
	 *
	 * Identify articles having overly-large tables
	 * to help editors optimize their articles.
	 *
	 * Linter category: `large-tables`
	 */
	private function lintLargeTables(
		Env $env, Element $node, DataParsoid $dp, ?stdClass $tplInfo
	): void {
		if ( DOMCompat::nodeName( $node ) !== 'table' ) {
			return;
		}

		// Skip tables that have nested tables in them as they are likely
		// to be used for layout and not for data representation.
		// We may check nested tables in the next iteration of this lint.
		$nestedTables = $node->getElementsByTagName( 'table' );
		if ( $nestedTables->length > 0 ) {
			return;
		}

		$lintConfig = $env->getLinterConfig();
		$maxColumns = $lintConfig['maxTableColumnHeuristic'] ?? 5;
		$maxRowsToCheck = $lintConfig['maxTableRowsToCheck'] ?? 10;

		$trCount = 0;
		$tbody = DOMCompat::querySelector( $node, 'tbody' );
		// empty table
		if ( !$tbody ) {
			return;
		}
		$tr = self::skipNonElementNodes( $tbody->firstChild );
		while ( $tr && $trCount < $maxRowsToCheck ) {
			$numTh = $tr->getElementsByTagName( 'th' )->length;
			if ( $numTh > $maxColumns ) {
				$this->recordLargeTablesLint( $env, $tplInfo, $node, $numTh, $maxColumns );
				return;
			}

			$numTd = $tr->getElementsByTagName( 'td' )->length;
			if ( $numTd > $maxColumns ) {
				$this->recordLargeTablesLint( $env, $tplInfo, $node, $numTd, $maxColumns );
				return;
			}

			$tr = self::skipNonElementNodes( $tr->nextSibling );
			$trCount++;
		}
	}

	/**
	 * Log inline background color style rules without a color style rule.
	 *
	 * This function identifies elements with inline style attributes
	 * that have background color set but don't have a color style rule.
	 * It records linter events for such elements to help editors make
	 * their articles comply with WCAG color contrast rules.
	 *
	 * Linter category: `night-mode-unaware-background-color`
	 */
	private function lintNightModeUnawareBackgroundColor(
		Env $env, Element $node, DataParsoid $dp, ?stdClass $tplInfo
	): void {
		// Get inline style attribute value
		$styleAttrValue = DOMCompat::getAttribute( $node, 'style' );

		// Check if background color is set but font color is not
		if (
			( $styleAttrValue !== null ) &&
			preg_match( '/(^|;)\s*background(-color)?\s*:/i', $styleAttrValue ) &&
			!preg_match( '/(^|;)\s*color\s*:/i', $styleAttrValue )
		) {
			$tplLintInfo = self::findEnclosingTemplateName( $env, $tplInfo );
			$lintObj = [
				'dsr' => self::findLintDSR( $tplLintInfo, $tplInfo, $dp->dsr ?? null ),
				'templateInfo' => $tplLintInfo,
			];
			$env->recordLint( 'night-mode-unaware-background-color', $lintObj );
		}
	}

	/**
	 * Lint for missing image alt text
	 *
	 * Linter category: `missing-image-alt-text`
	 */
	private function lintMissingAltText(
		Env $env, Element $c, DataParsoid $dp, ?stdClass $tplInfo
	): void {
		if ( !WTUtils::isGeneratedFigure( $c ) ) {
			return;
		}

		// Extract the media element in its standard place
		$media = $c->firstChild->firstChild ?? null;
		if ( !( $media instanceof Element ) || DOMCompat::nodeName( $media ) !== 'img' ) {
			// Videos and such are handled differently; check only
			// simple image output for alt text.
			return;
		}

		if ( $media->hasAttribute( 'alt' ) ) {
			// Present and accounted for, either via explicit markup
			// or filling in from an inline caption or other future
			// source.
			//
			// Note that an explicit empty alt text will be counted
			// as present, as this may be done deliberately for
			// spacer images or similar.
			return;
		}

		// Follow the parent tree looking for aria-hidden=true or equivalent roles
		for ( $node = $media; $node->parentNode; $node = $node->parentNode ) {
			$hidden = strtolower( DOMCompat::getAttribute( $node, 'aria-hidden' ) ?? '' );
			$role = strtolower( DOMCompat::getAttribute( $node, 'role' ) ?? '' );
			if ( $hidden === 'true'
				|| $role === 'presentation'
				|| $role === 'none' ) {
				// This entire subtree is excluded from the accessibility tree.
				return;
			}
		}

		$resource = DOMCompat::getAttribute( $media, 'resource' ) ?? '';
		$file = basename( urldecode( $resource ) );

		$tplLintInfo = self::findEnclosingTemplateName( $env, $tplInfo );
		$lintObj = [
			'dsr' => self::findLintDSR( $tplLintInfo, $tplInfo, $dp->dsr ?? null ),
			'templateInfo' => $tplLintInfo,
			'params' => [
				'file' => $file,
			]
		];
		$env->recordLint( 'missing-image-alt-text', $lintObj );
	}

	/**
	 * Lint duplicate ids in the page
	 *
	 * Linter category: `duplicate-ids`
	 */
	private function lintDuplicateIds(
		Env $env, Element $node, DataParsoid $dp, ?stdClass $tplInfo
	) {
		$id = DOMCompat::getAttribute( $node, 'id' );
		if ( $id === null ) {
			return;
		}
		if ( !isset( $this->seenIds[$id] ) ) {
			$this->seenIds[$id] = 1;
			return;
		}
		$tplLintInfo = self::findEnclosingTemplateName( $env, $tplInfo );
		$lintObj = [
			'dsr' => self::findLintDSR(
				$tplLintInfo, $tplInfo, $dp->dsr ?? null
			),
			'templateInfo' => $tplLintInfo,
			'params' => [ 'id' => $id ],
		];
		$env->recordLint( 'duplicate-ids', $lintObj );
	}

	/**
	 * Log wikitext fixups
	 */
	private function logWikitextFixups(
		Element $node, Env $env, ?stdClass $tplInfo
	): void {
		$dp = DOMDataUtils::getDataParsoid( $node );
		$this->lintTreeBuilderFixup( $env, $node, $dp, $tplInfo );
		$this->lintDeletableTableTag( $env, $node, $dp, $tplInfo ); // For T161341
		$this->lintPWrapBugWorkaround( $env, $node, $dp, $tplInfo ); // For T161306
		$this->lintObsoleteTag( $env, $node, $dp, $tplInfo );
		$this->lintBogusImageOptions( $env, $node, $dp, $tplInfo );
		$this->lintTidyWhitespaceBug( $env, $node, $dp, $tplInfo );
		$this->lintMiscTidyReplacementIssues( $env, $node, $dp, $tplInfo );
		$this->lintMultilineHtmlTableInList( $env, $node, $dp, $tplInfo );
		$this->lintWikilinksInExtlink( $env, $node, $dp, $tplInfo );
		$this->lintLargeTables( $env, $node, $dp, $tplInfo );
		$this->lintNightModeUnawareBackgroundColor( $env, $node, $dp, $tplInfo );
		$this->lintFostered( $env, $node, $dp, $tplInfo );
		$this->lintMissingAltText( $env, $node, $dp, $tplInfo );
		$this->lintDuplicateIds( $env, $node, $dp, $tplInfo );
	}

	/**
	 * Walk the DOM and compute lints for the entire tree.
	 * - When we enter encapsulated content (templates or extensions),
	 *   compute "tplInfo" (misnamed given that it can be an extension)
	 *   so that lints from the templates' content can be mapped back
	 *   to the transclusion that generated them.
	 * - When we process extensions, if we have a lint handler for the
	 *   extension, let the extension's lint handler compute lints.
	 */
	private function findLints(
		Node $root, Env $env, ?stdClass $tplInfo = null
	): void {
		$node = $root->firstChild;
		while ( $node !== null ) {
			if ( !$node instanceof Element ) {
				$node = $node->nextSibling;
				continue;
			}

			// !tplInfo check is to protect against templated content in
			// extensions which might in turn be nested in templated content.
			if ( !$tplInfo && WTUtils::isFirstEncapsulationWrapperNode( $node ) ) {
				$aboutSibs = WTUtils::getAboutSiblings( $node, DOMCompat::getAttribute( $node, 'about' ) );
				$tplInfo = (object)[
					'first' => $node,
					'last' => end( $aboutSibs ),
					'dsr' => DOMDataUtils::getDataParsoid( $node )->dsr ?? null,
					// FIXME: This is not being used. Instead the code is recomputing
					// this info in findEnclosingTemplateName.
					'isTemplated' => DOMUtils::hasTypeOf( $node, 'mw:Transclusion' ),
				];
			}

			$handled = false;

			// Let native extensions lint their content
			$nativeExt = WTUtils::getNativeExt( $env, $node );
			if ( $nativeExt ) {
				if ( !$this->extApi ) {
					$this->extApi = new ParsoidExtensionAPI( $env );
				}
				$handled = $nativeExt->lintHandler(
					$this->extApi,
					$node,
					function ( $extRootNode ) use ( $env, $tplInfo ) {
						$this->findLints(
							$extRootNode, $env,
							empty( $tplInfo->isTemplated ) ? null : $tplInfo
						);
					}
				);
				// NOTE: See the note in WrapSectionsState::shouldOmitFromTOC()
				// but we've assumed extension content is contained in a single
				// wrapper node and it's safe to move to $node->nextSibling.
			}

			// Default node handler
			if ( $handled === false ) {
				// Lint this node
				$this->logWikitextFixups( $node, $env, $tplInfo );

				// Lint subtree
				$this->findLints( $node, $env, $tplInfo );
			}

			if ( $tplInfo && $tplInfo->last === $node ) {
				$tplInfo = null;
			}

			$node = $node->nextSibling;
		}
	}

	/**
	 * This is only invoked on the top-level document
	 * @inheritDoc
	 */
	public function run(
		Env $env, Node $root, array $options = [], bool $atTopLevel = false
	): void {
		if ( !$env->linting() ) {
			return;
		}

		// Track time spent linting so we can evaluate benefits
		// of migrating this code off the critical path to its own
		// post processor.
		$metrics = $env->getSiteConfig()->metrics();
		$timer = null;
		if ( $metrics ) {
			$timer = Timing::start( $metrics );
		}

		$this->findLints( $root, $env );
		$this->postProcessLints( $env->getLints(), $env );

		if ( $metrics ) {
			$timer->end( "linting" );
		}
	}

}
