<?php
declare( strict_types = 1 );

namespace Wikimedia\Parsoid\Config;

use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\ErrorLogHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Psr\Log\NullLogger;
use Wikimedia\Assert\Assert;
use Wikimedia\Bcp47Code\Bcp47Code;
use Wikimedia\ObjectFactory\ObjectFactory;
use Wikimedia\Parsoid\Core\ContentMetadataCollector;
use Wikimedia\Parsoid\Core\ContentModelHandler;
use Wikimedia\Parsoid\Core\LinkTarget;
use Wikimedia\Parsoid\DOM\Document;
use Wikimedia\Parsoid\Ext\AnnotationStripper;
use Wikimedia\Parsoid\Ext\ExtensionModule;
use Wikimedia\Parsoid\Ext\ExtensionTagHandler;
use Wikimedia\Parsoid\Ext\Gallery\Gallery;
use Wikimedia\Parsoid\Ext\Indicator\Indicator;
use Wikimedia\Parsoid\Ext\JSON\JSON;
use Wikimedia\Parsoid\Ext\LST\LST;
use Wikimedia\Parsoid\Ext\Nowiki\Nowiki;
use Wikimedia\Parsoid\Ext\Pre\Pre;
use Wikimedia\Parsoid\Utils\DOMUtils;
use Wikimedia\Parsoid\Utils\PHPUtils;
use Wikimedia\Parsoid\Utils\Utils;
use Wikimedia\Parsoid\Wikitext\Consts;

/**
 * Site-level configuration interface for Parsoid
 *
 * This includes both global configuration and wiki-level configuration.
 */
abstract class SiteConfig {
	/**
	 * FIXME: not private so that ParserTests can reset these variables
	 * since they reuse site config and other objects between tests for
	 * efficiency reasons.
	 *
	 * @var array|null
	 */
	protected $mwAliases;

	/** @var array|null */
	private $behaviorSwitches;

	/** @var array|null */
	private $variables;

	/** @var array|null */
	private $mediaOptions;

	/** @var array|null */
	protected $functionSynonyms;

	/** @var string[] */
	private $protocolsRegexes = [];

	/**
	 * FIXME: not private so that ParserTests can reset these variables
	 * since they reuse site config and other objects between tests for
	 * efficiency reasons.
	 * @var array|null
	 */
	protected $interwikiMapNoNamespaces;

	/**
	 * FIXME: not private so that ParserTests can reset these variables
	 * since they reuse site config and other objects between tests for
	 * efficiency reasons.
	 * @var string|null|bool
	 */
	protected $linkTrailRegex = false;

	/**
	 * These extension modules provide "core" functionality
	 * and their implementations live in the Parsoid repo.
	 *
	 * @var class-string<ExtensionModule>[]
	 */
	private static $coreExtModules = [
		// content modules
		JSON::class,
		// extension tags
		Nowiki::class,
		Pre::class,
		Gallery::class,
		Indicator::class,
		// The following implementations will move to their own repositories
		// soon, but for now are implemented in the Parsoid repo.
		LST::class
	];

	/**
	 * Array specifying fully qualified class name for Parsoid-compatible extensions
	 * @var ?array<int,ExtensionModule>
	 */
	private $extModules = null;
	/**
	 * Private counter to assign IDs to $extModules
	 * @var int
	 */
	private $extModuleNextId = 0;

	// phpcs:disable Generic.Files.LineLength.TooLong

	/**
	 * Register a Parsoid extension module.
	 * @param string|array{name:string}|array{factory:callable}|array{class:class-string<ExtensionModule>} $configOrSpec
	 *  Either an object factory specification for an ExtensionModule object,
	 *  or else the configuration array which ExtensionModule::getConfig()
	 *  would return.  (The latter is preferred, but our internal extensions
	 *  use the former.)
	 * @return int An integer identifier that can be passed to
	 *  ::unregisterExtensionModule to remove this extension (
	 */
	final public function registerExtensionModule( $configOrSpec ): int {
		$this->getExtensionModules(); // ensure it's initialized w/ core modules
		if ( is_string( $configOrSpec ) || isset( $configOrSpec['class'] ) || isset( $configOrSpec['factory'] ) ) {
			// Treat this as an object factory spec for an ExtensionModule
			// ObjectFactory::createObject accepts an array, not just a callable (phan bug)
			// @phan-suppress-next-line PhanTypeInvalidCallableArraySize
			$module = $this->getObjectFactory()->createObject( $configOrSpec, [
				'allowClassName' => true,
				'assertClass' => ExtensionModule::class,
			] );
		} else {
			// Treat this as a configuration array, create a new anonymous
			// ExtensionModule object for it.
			$module = new class( $configOrSpec ) implements ExtensionModule {
				private $config;

				/** @param array $config */
				public function __construct( $config ) {
					$this->config = $config;
				}

				/** @inheritDoc */
				public function getConfig(): array {
					return $this->config;
				}
			};
		}
		$extId = $this->extModuleNextId++;
		$this->extModules[$extId] = $module;
		// remove cached extConfig to ensure this registration is picked up
		$this->extConfig = null;
		return $extId;
	}

	// phpcs:enable Generic.Files.LineLength.TooLong

	/**
	 * Unregister a Parsoid extension module.  This is typically used
	 * only for testing purposes in order to reset a shared SiteConfig
	 * to its original configuration.
	 * @param int $extId The value returned by the call to
	 *   ::registerExtensionModule()
	 */
	final public function unregisterExtensionModule( int $extId ): void {
		unset( $this->extModules[$extId] );
		$this->extConfig = null; // remove cached extConfig
	}

	/**
	 * Return the set of Parsoid extension modules associated with this
	 * SiteConfig.
	 *
	 * @return ExtensionModule[]
	 */
	final public function getExtensionModules() {
		if ( $this->extModules === null ) {
			$this->extModules = [];
			foreach ( self::$coreExtModules as $m ) {
				$this->extModules[$this->extModuleNextId++] = new $m();
			}
		}
		return array_values( $this->extModules );
	}

	/** @var LoggerInterface|null */
	protected $logger = null;

	/** @var int */
	protected $iwMatcherBatchSize = 4096;

	/** @var array|null */
	protected $iwMatcher = null;

	/** @var bool */
	protected $addHTMLTemplateParameters = false;

	/** @var bool */
	protected $scrubBidiChars = false;

	/** @var bool */
	protected $linterEnabled = false;

	/** @var ?array */
	protected $extConfig = null;

	/**
	 * Tag handlers for some extensions currently explicit call unstripNowiki
	 * first thing in their handlers. They do this to strip <nowiki>..</nowiki>
	 * wrappers around args when encountered in the {{#tag:...}} parser function.
	 * However, this strategy won't work for Parsoid which calls the preprocessor
	 * to get expanded wikitext. In this mode, <nowiki> wrappers won't be stripped
	 * and this leads to functional differences in parsing and output.
	 *
	 * See T203293 and T299103 for more details.
	 *
	 * To get around this, T299103 proposes that extensions that require this support
	 * set a config flag in their Parsoid extension config. On the Parsoid end, we
	 * then let the legacy parser know of these tags. When such extension tags are
	 * encountered in the {{#tag:...}} parser function handler (see tagObj function
	 * in CoreParserFunctions.php), that handler can than automatically strip these
	 * nowiki wrappers on behalf of the extension.
	 *
	 * This serves two purposes. For one, it lets Parsoid support these extensions
	 * in this nowiki use edge case. For another, extensions that register handlers
	 * with Parsoid can get rid of explicit calls to unstripNowiki() in the
	 * tag handlers for the legacy parser.
	 *
	 * This property maintains an array of tags that need this support.
	 *
	 * @var array an associative array of tag names
	 */
	private $t299103Tags = [];

	/**
	 * Base constructor.
	 *
	 * This constructor is public because it is used to create mock objects
	 * in our test suite.
	 */
	public function __construct() {
	}

	/************************************************************************//**
	 * @name   Global config
	 * @{
	 */

	/**
	 * General log channel
	 * @return LoggerInterface
	 */
	public function getLogger(): LoggerInterface {
		if ( $this->logger === null ) {
			$this->logger = new NullLogger;
		}
		return $this->logger;
	}

	/**
	 * Set the log channel, for debugging
	 * @param ?LoggerInterface $logger
	 */
	public function setLogger( ?LoggerInterface $logger ): void {
		$this->logger = $logger;
	}

	/**
	 * Default gallery options for this wiki.
	 * @return array<string,string|int|bool>
	 */
	public function galleryOptions(): array {
		return [
			'imagesPerRow' => 0,
			'imageWidth' => 120,
			'imageHeight' => 120,
			'captionLength' => true,
			'showBytes' => true,
			'showDimensions' => true,
			'mode' => 'traditional',
		];
	}

	/**
	 * When processing template parameters, parse them to HTML and add it to the
	 * template parameters data.
	 * @return bool
	 */
	public function addHTMLTemplateParameters(): bool {
		return $this->addHTMLTemplateParameters;
	}

	/**
	 * Statistics aggregator, for counting and timing.
	 *
	 * @return StatsdDataFactoryInterface|null
	 */
	public function metrics(): ?StatsdDataFactoryInterface {
		return null;
	}

	/**
	 * Increment a counter metric
	 * @param string $name
	 * @param array $labels
	 * @param float $amount
	 * @return void
	 */
	abstract public function incrementCounter( string $name, array $labels, float $amount = 1 );

	/**
	 * Record a timing metric.
	 *
	 * Note that the value should be provided in *milliseconds* even though
	 * the name of the metric may end (by convention) in `_seconds`.  The
	 * metrics infrastructure will make the appropriate conversion.
	 *
	 * @param string $name
	 * @param float $value A timing value *in milliseconds*
	 * @param array $labels
	 * @return void
	 */
	abstract public function observeTiming( string $name, float $value, array $labels );

	/**
	 * If enabled, bidi chars adjacent to category links will be stripped
	 * in the html -> wt serialization pass.
	 * @return bool
	 */
	public function scrubBidiChars(): bool {
		return $this->scrubBidiChars;
	}

	/** @} */

	/************************************************************************//**
	 * @name   Wiki config
	 * @{
	 */

	/**
	 * Allowed external image URL prefixes.
	 *
	 * @return string[] The empty array matches no URLs. The empty string matches
	 *  all URLs.
	 */
	abstract public function allowedExternalImagePrefixes(): array;

	/**
	 * Site base URI
	 *
	 * This would be the URI found in `<base href="..." />`.
	 *
	 * @return string
	 */
	abstract public function baseURI(): string;

	/**
	 * Prefix for relative links
	 *
	 * Prefix to prepend to a page title to link to that page.
	 * Intended to be relative to the URI returned by baseURI().
	 *
	 * If possible, keep the default "./" so clients need not know this value
	 * to extract titles from link hrefs.
	 *
	 * @return string
	 */
	public function relativeLinkPrefix(): string {
		return './';
	}

	/**
	 * Regex matching all double-underscore magic words
	 * @return string
	 */
	public function bswPagePropRegexp(): string {
		static $bswPagePropRegexp = null;
		if ( $bswPagePropRegexp === null ) {
			$bswRegexp = $this->bswRegexp();
			$bswPagePropRegexp =
				'@(?:^|\\s)mw:PageProp/(?:' .
				PHPUtils::reStrip( $bswRegexp, '@' ) .
				')(?=$|\\s)@uDS';
		}
		return $bswPagePropRegexp;
	}

	/**
	 * Map a canonical namespace name to its index
	 *
	 * @note This replaces canonicalNamespaces
	 * @param string $name all-lowercase and with underscores rather than spaces.
	 * @return int|null
	 */
	abstract public function canonicalNamespaceId( string $name ): ?int;

	/**
	 * Map a namespace name to its index
	 *
	 * @note This replaces canonicalNamespaces
	 * @param string $name all-lowercase and with underscores rather than spaces.
	 * @return int|null
	 */
	abstract public function namespaceId( string $name ): ?int;

	/**
	 * Map a namespace index to its preferred name
	 * (with spaces, not underscores).
	 *
	 * @note This replaces namespaceNames
	 * @param int $ns
	 * @return string|null
	 */
	abstract public function namespaceName( int $ns ): ?string;

	/**
	 * Test if a namespace has subpages
	 *
	 * @note This replaces namespacesWithSubpages
	 * @param int $ns
	 * @return bool
	 */
	abstract public function namespaceHasSubpages( int $ns ): bool;

	/**
	 * Return namespace case setting
	 * @param int $ns
	 * @return string 'first-letter' or 'case-sensitive'
	 */
	abstract public function namespaceCase( int $ns ): string;

	/**
	 * Test if a namespace is a talk namespace
	 *
	 * @note This replaces title.getNamespace().isATalkNamespace()
	 * @param int $ns
	 * @return bool
	 */
	public function namespaceIsTalk( int $ns ): bool {
		return $ns > 0 && $ns % 2;
	}

	/**
	 * Uppercasing method for titles
	 * @param string $str
	 * @return string
	 */
	public function ucfirst( string $str ): string {
		$o = ord( $str );
		if ( $o < 96 ) { // if already uppercase...
			return $str;
		} elseif ( $o < 128 ) {
			if ( $str[0] === 'i' &&
				 in_array( $this->langBcp47()->toBcp47Code(), [ 'az', 'tr', 'kaa', 'kk' ], true )
			) {
				return 'İ' . mb_substr( $str, 1 );
			}
			return ucfirst( $str ); // use PHP's ucfirst()
		} else {
			// fall back to more complex logic in case of multibyte strings
			$char = mb_substr( $str, 0, 1 );
			return mb_strtoupper( $char ) . mb_substr( $str, 1 );
		}
	}

	/**
	 * Get the default local name for a special page
	 * @param string $alias Special page alias
	 * @return string|null
	 */
	abstract public function specialPageLocalName( string $alias ): ?string;

	/**
	 * Treat language links as magic connectors, not inline links
	 * @return bool
	 */
	abstract public function interwikiMagic(): bool;

	/**
	 * Return true if the specified magic link syntax is enabled on this
	 * wiki.
	 * @param string $which One of "ISBN", "PMID", or "RFC"
	 * @return true if the specified magic link type is enabled on this wiki
	 */
	public function magicLinkEnabled( string $which ): bool {
		// This should be an abstract method, but in order to provide
		// graceful upgrades, start by defaulting to true for all link types
		return true;
	}

	/**
	 * Interwiki link data.
	 *
	 * Note that the order of the keys in this array is significant: if more
	 * than one prefix matches a given URL during html2wt conversion, the
	 * *first* match is used.  If you want `wikitech` to be used instead of
	 * `labsconsole`, for example, the `'wikitech'=>[....]` key needs to
	 * enumerate first.
	 *
	 * @return array<string,array> Keys are interwiki prefixes, values are arrays with the following keys:
	 *   - prefix: (string) The interwiki prefix, same as the key.
	 *   - url: (string) Target URL, containing a '$1' to be replaced by the interwiki target.
	 *   - protorel: (bool, optional) Whether the url may be accessed by both http:// and https://.
	 *   - local: (bool, optional) Whether the interwiki link is considered local (to the wikifarm).
	 *   - localinterwiki: (bool, optional) Whether the interwiki link points to the current wiki.
	 *   - language: (bool, optional) Whether the interwiki link is a language link.
	 *   - extralanglink: (bool, optional) Whether the interwiki link is an "extra language link".
	 *   - linktext: (string, optional) For "extra language links", the link text.
	 *  (booleans marked "optional" must be omitted if false)
	 */
	abstract public function interwikiMap(): array;

	/**
	 * Interwiki link data, after removing items that conflict with namespace names.
	 * (In case of such conflict, namespace wins, interwiki is ignored.)
	 * @return array<string,array> See interwikiMap()
	 */
	public function interwikiMapNoNamespaces(): array {
		if ( $this->interwikiMapNoNamespaces === null ) {
			$this->interwikiMapNoNamespaces = [];
			foreach ( $this->interwikiMap() as $key => $value ) {
				if ( $this->namespaceId( (string)$key ) === null ) {
					$this->interwikiMapNoNamespaces[$key] = $value;
				}
			}
		}
		return $this->interwikiMapNoNamespaces;
	}

	/**
	 * Match interwiki URLs
	 * @param string $href Link to match against
	 * @return string[]|null Two values [ string $key, string $target ] on success, null on no match.
	 */
	public function interwikiMatcher( string $href ): ?array {
		if ( $this->iwMatcher === null ) {
			$keys = [ [], [] ];
			$patterns = [ [], [] ];
			foreach ( $this->interwikiMapNoNamespaces() as $key => $iw ) {
				$key = (string)$key;
				$lang = (int)( !empty( $iw['language'] ) );

				$url = $iw['url'];
				$protocolRelative = substr( $url, 0, 2 ) === '//';
				if ( !empty( $iw['protorel'] ) ) {
					$url = preg_replace( '/^https?:/', '', $url );
					$protocolRelative = true;
				}

				// full-url match pattern
				$keys[$lang][] = $key;
				$patterns[$lang][] =
					// Support protocol-relative URLs
					( $protocolRelative ? '(?:https?:)?' : '' )
					// Convert placeholder to group match
					. strtr( preg_quote( $url, '/' ), [ '\\$1' => '(.*?)' ] );

				if ( !empty( $iw['local'] ) ) {
					// ./$interwikiPrefix:$title and
					// $interwikiPrefix%3A$title shortcuts
					// are recognized and the local wiki forwards
					// these shortcuts to the remote wiki

					$keys[$lang][] = $key;
					$patterns[$lang][] = '^\\.\\/' . $iw['prefix'] . ':(.*?)';

					$keys[$lang][] = $key;
					$patterns[$lang][] = '^' . $iw['prefix'] . '%3A(.*?)';
				}
			}

			// Prefer language matches over non-language matches
			$numLangs = count( $keys[1] );
			$keys = array_merge( $keys[1], $keys[0] );
			$patterns = array_merge( $patterns[1], $patterns[0] );

			// Chunk patterns into reasonably sized regexes
			$this->iwMatcher = [];
			$batchStart = 0;
			$batchLen = 0;
			foreach ( $patterns as $i => $pat ) {
				$len = strlen( $pat );
				if ( $i !== $batchStart && $batchLen + $len > $this->iwMatcherBatchSize ) {
					$this->iwMatcher[] = [
						array_slice( $keys, $batchStart, $i - $batchStart ),
						'/^(?:' . implode( '|', array_slice( $patterns, $batchStart, $i - $batchStart ) ) . ')$/Di',
						$numLangs - $batchStart,
					];
					$batchStart = $i;
					$batchLen = $len;
				} else {
					$batchLen += $len;
				}
			}
			$i = count( $patterns );
			if ( $i > $batchStart ) {
				$this->iwMatcher[] = [
					array_slice( $keys, $batchStart, $i - $batchStart ),
					'/^(?:' . implode( '|', array_slice( $patterns, $batchStart, $i - $batchStart ) ) . ')$/Di',
					$numLangs - $batchStart,
				];
			}
		}

		foreach ( $this->iwMatcher as [ $keys, $regex, $numLangs ] ) {
			if ( preg_match( $regex, $href, $m, PREG_UNMATCHED_AS_NULL ) ) {
				foreach ( $keys as $i => $key ) {
					if ( isset( $m[$i + 1] ) ) {
						if ( $i < $numLangs ) {
							// Escape language interwikis with a colon
							$key = ':' . $key;
						}
						return [ $key, $m[$i + 1] ];
					}
				}
			}
		}
		return null;
	}

	/**
	 * Wiki identifier, for cache keys.
	 * Should match a key in mwApiMap()?
	 * @return string
	 */
	abstract public function iwp(): string;

	/**
	 * Legal title characters
	 *
	 * Regex is intended to match bytes, not Unicode characters.
	 *
	 * @return string Regex character class (i.e. the bit that goes inside `[]`)
	 */
	abstract public function legalTitleChars(): string;

	/**
	 * Link prefix regular expression.
	 * @return string|null
	 */
	abstract public function linkPrefixRegex(): ?string;

	/**
	 * Return raw link trail regexp from config
	 * @return string
	 */
	abstract protected function linkTrail(): string;

	/**
	 * Link trail regular expression.
	 * @return string|null
	 */
	public function linkTrailRegex(): ?string {
		if ( $this->linkTrailRegex === false ) {
			$trail = $this->linkTrail();
			$trail = str_replace( '(.*)$', '', $trail );
			if ( strpos( $trail, '()' ) !== false ) {
				// Empty regex from zh-hans
				$this->linkTrailRegex = null;
			} else {
				$this->linkTrailRegex = $trail;
			}
		}
		return $this->linkTrailRegex;
	}

	/**
	 * Wiki language code.
	 * @return Bcp47Code BCP-47 language code
	 */
	abstract public function langBcp47(): Bcp47Code;

	/**
	 * Main page title, as LinkTarget
	 * @return LinkTarget
	 */
	abstract public function mainPageLinkTarget(): LinkTarget;

	/**
	 * Lookup config
	 * @param string $key
	 * @return mixed|null config value for $key, if present or null, if not.
	 * @deprecated This very broad interface is no longer needed.
	 */
	abstract public function getMWConfigValue( string $key );

	/**
	 * Whether the wiki language is right-to-left
	 * @return bool
	 */
	abstract public function rtl(): bool;

	/**
	 * Whether language converter is enabled for the specified language
	 * @param Bcp47Code $lang
	 * @return bool
	 */
	abstract public function langConverterEnabledBcp47( Bcp47Code $lang ): bool;

	/**
	 * The URL path to index.php.
	 * @return string
	 */
	abstract public function script(): string;

	/**
	 * FIXME: This is only used to compute the modules path below
	 * and maybe shouldn't be exposed.
	 *
	 * The base wiki path
	 * @return string
	 */
	abstract public function scriptpath(): string;

	/**
	 * The base URL of the server.
	 * @return string
	 */
	abstract public function server(): string;

	/**
	 * Export content metadata via meta tags (and via a stylesheet
	 * for now to aid some clients).
	 *
	 * @param Document $document
	 * @param ContentMetadataCollector $metadata
	 * @param string $defaultTitle The default title to display, as an
	 *   unescaped string
	 * @param Bcp47Code $lang a BCP-47 language code
	 */
	abstract public function exportMetadataToHeadBcp47(
		Document $document,
		ContentMetadataCollector $metadata,
		string $defaultTitle,
		Bcp47Code $lang
	): void;

	/**
	 * Helper function to create <head> elements from metadata.
	 * @param Document $document
	 * @param string $modulesLoadURI
	 * @param string[] $modules
	 * @param string[] $moduleStyles
	 * @param array<string,mixed> $jsConfigVars
	 * @param string $htmlTitle The display title, as escaped HTML
	 * @param Bcp47Code $lang a Bcp47Code object
	 */
	protected function exportMetadataHelper(
		Document $document,
		string $modulesLoadURI,
		array $modules,
		array $moduleStyles,
		array $jsConfigVars,
		string $htmlTitle,
		Bcp47Code $lang
	): void {
		// $htmlTitle contains the DISPLAYTITLE but it corresponds to the
		// value of the ParserOutput *not* the ultimate value which would
		// be used in the <h1> tag *nor* the plaintext value which would
		// be used for the page <title>.  OutputPage does additional
		// validation/stripping on the displaytitle value before using it.
		// As such we're going to just ignore $htmlTitle for now rather
		// than report an incorrect value in the <head> (T324431).

		// JsConfigVars
		$content = null;
		try {
			if ( $jsConfigVars ) {
				$content = PHPUtils::jsonEncode( $jsConfigVars );
			}
		} catch ( \Exception $e ) {
			// Similar to ResourceLoader::makeConfigSetScript.  See T289358
			$this->getLogger()->log(
				LogLevel::WARNING,
				'JSON serialization of config data failed. ' .
				'This usually means the config data is not valid UTF-8.'
			);
		}
		if ( $content ) {
			DOMUtils::appendToHead( $document, 'meta', [
				'property' => 'mw:jsConfigVars',
				'content' => $content,
			] );
		}
		// Styles from modules returned from preprocessor / parse requests
		if ( $modules ) {
			// mw:generalModules can be processed via JS (and async) and are usually (but
			// not always) JS scripts.
			DOMUtils::appendToHead( $document, 'meta', [
				'property' => 'mw:generalModules',
				'content' => implode( '|', array_unique( $modules ) )
			] );
		}
		// Styles from modules returned from preprocessor / parse requests
		if ( $moduleStyles ) {
			// mw:moduleStyles are CSS modules that are render-blocking.
			DOMUtils::appendToHead( $document, 'meta', [
				'property' => 'mw:moduleStyles',
				'content' => implode( '|', array_unique( $moduleStyles ) )
			] );
		}
		/*
		* While unnecessary for Wikimedia clients, a stylesheet url in
		* the <head> is useful for clients like Kiwix and others who
		* might not want to process the meta tags to construct the
		* resourceloader url.
		*
		* Given that these clients will be consuming Parsoid HTML outside
		* a MediaWiki skin, the clients are effectively responsible for
		* their own "skin". But, once again, as a courtesy, we are
		* hardcoding the vector skin modules for them. But, note that
		* this may cause page elements to render differently than how
		* they render on Wikimedia sites with the vector skin since this
		* is probably missing a number of other modules.
		*
		* All that said, note that JS-generated parts of the page will
		* still require them to have more intimate knowledge of how to
		* process the JS modules. Except for <graph>s, page content
		* doesn't require JS modules at this point. So, where these
		* clients want to invest in the necessary logic to construct a
		* better resourceloader url, they could simply delete / ignore
		* this stylesheet.
		*/
		$moreStyles = array_merge( $moduleStyles, [
			'mediawiki.skinning.content.parsoid',
			// Use the base styles that API output and fallback skin use.
			'mediawiki.skinning.interface',
			// Make sure to include contents of user generated styles
			// e.g. MediaWiki:Common.css / MediaWiki:Mobile.css
			'site.styles'
		] );
		# need to use MW-internal language code for constructing resource
		# loader path.
		$langMw = Utils::bcp47ToMwCode( $lang );
		$styleURI = $modulesLoadURI . '?lang=' . $langMw . '&modules=' .
			PHPUtils::encodeURIComponent( implode( '|', array_unique( $moreStyles ) ) ) .
			'&only=styles&skin=vector';
		DOMUtils::appendToHead( $document, 'link', [ 'rel' => 'stylesheet', 'href' => $styleURI ] );
	}

	/**
	 * A regexp matching the localized 'REDIRECT' marker for this wiki.
	 * The regexp should be delimited, but should not have boundary anchors
	 * or capture groups.
	 * @return string
	 */
	abstract public function redirectRegexp(): string;

	/**
	 * A regexp matching the localized 'Category' prefix for this wiki.
	 * The regexp should be delimited, but should not have boundary anchors
	 * or capture groups.
	 * @return string
	 */
	abstract public function categoryRegexp(): string;

	/**
	 * A regexp matching localized behavior switches for this wiki.
	 * The regexp should be delimited, but should not have boundary anchors
	 * or capture groups.
	 * @return string
	 */
	abstract public function bswRegexp(): string;

	/**
	 * A regex matching a line containing just whitespace, comments, and
	 * sol transparent links and behavior switches.
	 * @return string
	 */
	public function solTransparentWikitextRegexp(): string {
		// cscott sadly says: Note that this depends on the precise
		// localization of the magic words of this particular wiki.
		static $solTransparentWikitextRegexp = null;
		if ( $solTransparentWikitextRegexp === null ) {
			$redirect = PHPUtils::reStrip( $this->redirectRegexp(), '@' );
			$category = PHPUtils::reStrip( $this->categoryRegexp(), '@' );
			$bswRegexp = PHPUtils::reStrip( $this->bswRegexp(), '@' );
			$comment = PHPUtils::reStrip( Utils::COMMENT_REGEXP, '@' );
			$solTransparentWikitextRegexp = '@' .
				'^[ \t\n\r\0\x0b]*' .
				'(?:' .
				'(?:' . $redirect . ')' .
				'[ \t\n\r\x0c]*(?::[ \t\n\r\x0c]*)?\[\[[^\]]+\]\]' .
				')?' .
				'(?:' .
				'\[\[' . $category . '\:[^\]]*?\]\]|' .
				'__(?:' . $bswRegexp . ')__|' .
				$comment . '|' .
				'[ \t\n\r\0\x0b]' .
				')*$@';
		}
		return $solTransparentWikitextRegexp;
	}

	/**
	 * A regex matching a line containing just comments and
	 * sol transparent links and behavior switches.
	 *
	 * @param bool $addIncludes
	 * @return string
	 */
	public function solTransparentWikitextNoWsRegexp(
		bool $addIncludes = false
	): string {
		// cscott sadly says: Note that this depends on the precise
		// localization of the magic words of this particular wiki.
		static $solTransparentWikitextNoWsRegexp = null;
		if ( $solTransparentWikitextNoWsRegexp === null ) {
			$redirect = PHPUtils::reStrip( $this->redirectRegexp(), '@' );
			$category = PHPUtils::reStrip( $this->categoryRegexp(), '@' );
			$bswRegexp = PHPUtils::reStrip( $this->bswRegexp(), '@' );
			$comment = PHPUtils::reStrip( Utils::COMMENT_REGEXP, '@' );
			$solTransparentWikitextNoWsRegexp = '@' .
				'((?:' .
				  '(?:' . $redirect . ')' .
				  '[ \t\n\r\x0c]*(?::[ \t\n\r\x0c]*)?\[\[[^\]]+\]\]' .
				')?' .
				'(?:' .
				'\[\[' . $category . '\:[^\]]*?\]\]|' .
				'__(?:' . $bswRegexp . ')__|' .
				$comment .
				// FIXME(SSS): What about onlyinclude and noinclude?
				( $addIncludes ? '|<includeonly>[\S\s]*?</includeonly>' : '' ) .
				')*)@';
		}
		return $solTransparentWikitextNoWsRegexp;
	}

	/**
	 * The wiki's time zone offset
	 * @return int Minutes east of UTC
	 */
	abstract public function timezoneOffset(): int;

	/**
	 * Language variant information for the given language (or null if
	 * unknown).
	 * @param Bcp47Code $lang The language for which you want variant information
	 * @return ?array{base:Bcp47Code,fallbacks:Bcp47Code[]} an array with
	 * two fields:
	 *   - base: (Bcp47Code) Base BCP-47 language code (e.g. "zh")
	 *   - fallbacks: (Bcp47Code[]) Fallback variants, as BCP-47 codes
	 */
	abstract public function variantsFor( Bcp47Code $lang ): ?array;

	/**
	 * Default thumbnail width
	 */
	abstract public function widthOption(): int;

	abstract protected function getVariableIDs(): array;

	abstract protected function getMagicWords(): array;

	/**
	 * Does the SiteConfig provide precomputed function synonyms?
	 * If no, the SiteConfig is expected to provide an implementation
	 * for updateFunctionSynonym.
	 */
	protected function haveComputedFunctionSynonyms(): bool {
		return true;
	}

	/**
	 * Get a list of precomputed function synonyms
	 */
	protected function getFunctionSynonyms(): array {
		return [];
	}

	protected function updateFunctionSynonym( string $func, string $magicword, bool $caseSensitive ): void {
		throw new \RuntimeException( "Unexpected code path!" );
	}

	private function populateMagicWords() {
		if ( !empty( $this->mwAliases ) ) {
			return;
		}

		$this->mwAliases = $this->behaviorSwitches = $this->variables = $this->mediaOptions = [];
		$variablesMap = PHPUtils::makeSet( $this->getVariableIDs() );
		$this->functionSynonyms = $this->getFunctionSynonyms();
		$haveSynonyms = $this->haveComputedFunctionSynonyms();
		foreach ( $this->getMagicWords() as $magicword => $aliases ) {
			$caseSensitive = array_shift( $aliases );
			$isVariable = isset( $variablesMap[$magicword] );
			$isMediaOption = preg_match( '/^(img|timedmedia)_/', $magicword );
			foreach ( $aliases as $alias ) {
				$this->mwAliases[$magicword][] = $alias;
				if ( !$caseSensitive ) {
					$alias = mb_strtolower( $alias );
					$this->mwAliases[$magicword][] = $alias;
				}
				if ( substr( $alias, 0, 2 ) === '__' ) {
					$this->behaviorSwitches[$alias] = [ $caseSensitive, $magicword ];
				}
				if ( $isVariable ) {
					$this->variables[$alias] = $magicword;
				}
				if ( $isMediaOption ) {
					$this->mediaOptions[$alias] = [ $caseSensitive, $magicword ];
				}
				if ( !$haveSynonyms ) {
					$this->updateFunctionSynonym( $alias, $magicword, (bool)$caseSensitive );
				}
			}
		}
	}

	/**
	 * List all magic words by canonical name
	 * @return string[][] Keys are canonical names, values are arrays of aliases.
	 */
	public function mwAliases(): array {
		$this->populateMagicWords();
		return $this->mwAliases;
	}

	/**
	 * Return canonical magic word for a function hook
	 * @param string $str
	 * @return string|null
	 */
	public function getMagicWordForFunctionHook( string $str ): ?string {
		$this->populateMagicWords();
		return $this->functionSynonyms[1][$str] ??
			# Case insensitive functions
			$this->functionSynonyms[0][mb_strtolower( $str )] ?? null;
	}

	/**
	 * Return canonical magic word for a variable
	 * @param string $str
	 * @return string|null
	 */
	public function getMagicWordForVariable( string $str ): ?string {
		$this->populateMagicWords();
		return $this->variables[$str] ?? null;
	}

	private static function getMagicWordCanonicalName( array $mws, string $word ): ?string {
		if ( isset( $mws[$word] ) ) {
			return $mws[$word][1];
		}
		$mw = $mws[mb_strtolower( $word )] ?? null;
		return ( $mw && !$mw[0] ) ? $mw[1] : null;
	}

	/**
	 * Return canonical magic word for a media option
	 * @param string $word
	 * @return string|null
	 */
	public function getMagicWordForMediaOption( string $word ): ?string {
		$this->populateMagicWords();
		return self::getMagicWordCanonicalName( $this->mediaOptions, $word );
	}

	/**
	 * Return canonical magic word for a behavior switch
	 * @param string $word
	 * @return string|null
	 */
	public function getMagicWordForBehaviorSwitch( string $word ): ?string {
		$this->populateMagicWords();
		return self::getMagicWordCanonicalName( $this->behaviorSwitches, $word );
	}

	/**
	 * Check if a string is a recognized behavior switch.
	 *
	 * @param string $word
	 * @return bool
	 */
	public function isBehaviorSwitch( string $word ): bool {
		return $this->getMagicWordForBehaviorSwitch( $word ) !== null;
	}

	/**
	 * Convert the internal canonical magic word name to the wikitext alias.
	 * @param string $word Canonical magic word name
	 * @param string $suggest Suggested alias (used as fallback and preferred choice)
	 * @return string
	 */
	public function getMagicWordWT( string $word, string $suggest ): string {
		$aliases = $this->mwAliases()[$word] ?? null;
		if ( !$aliases ) {
			return $suggest;
		}
		$ind = 0;
		if ( $suggest ) {
			$ind = array_search( $suggest, $aliases, true );
		}
		return $aliases[$ind ?: 0];
	}

	/**
	 * Get a regexp matching a localized magic word, given its id.
	 *
	 * FIXME: misleading function name
	 *
	 * @param string $id
	 * @return string
	 */
	abstract public function getMagicWordMatcher( string $id ): string;

	/**
	 * Get a matcher function for fetching values out of interpolated magic words,
	 * ie those with `$1` in their aliases.
	 *
	 * The matcher takes a string and returns null if it doesn't match any of
	 * the words, or an associative array if it did match:
	 *  - k: The magic word that matched
	 *  - v: The value of $1 that was matched
	 * (the JS also returned 'a' with the specific alias that matched, but that
	 * seems to be unused and so is omitted here)
	 *
	 * @param string[] $words Magic words to match
	 * @return callable
	 */
	abstract protected function getParameterizedAliasMatcher( array $words ): callable;

	/**
	 * Get a matcher function for fetching values out of interpolated magic words
	 * which are media prefix options.
	 *
	 * The matcher takes a string and returns null if it doesn't match any of
	 * the words, or an associative array if it did match:
	 *  - k: The magic word that matched
	 *  - v: The value of $1 that was matched
	 * (the JS also returned 'a' with the specific alias that matched, but that
	 * seems to be unused and so is omitted here)
	 *
	 * @return callable
	 */
	final public function getMediaPrefixParameterizedAliasMatcher(): callable {
		// PORT-FIXME: this shouldn't be a constant, we should fetch these
		// from the SiteConfig.  Further, we probably need a hook here so
		// Parsoid can handle media options defined in extensions... in
		// particular timedmedia_* magic words from Extension:TimedMediaHandler
		$mws = array_keys( Consts::$Media['PrefixOptions'] );
		return $this->getParameterizedAliasMatcher( $mws );
	}

	/**
	 * Get the maximum template depth
	 *
	 * @return int
	 */
	abstract public function getMaxTemplateDepth(): int;

	/**
	 * Return name spaces aliases for the NS_SPECIAL namespace
	 * @return array
	 */
	abstract protected function getSpecialNSAliases(): array;

	/**
	 * Return Special Page aliases for a special page name
	 * @param string $specialPage
	 * @return array
	 */
	abstract protected function getSpecialPageAliases( string $specialPage ): array;

	/**
	 * Quote a title regex
	 *
	 * Assumes '/' as the delimiter, and replaces spaces or underscores with
	 * `[ _]` so either will be matched.
	 *
	 * @param string $s
	 * @param string $delimiter Defaults to '/'
	 * @return string
	 */
	protected static function quoteTitleRe( string $s, string $delimiter = '/' ): string {
		$s = preg_quote( $s, $delimiter );
		$s = strtr( $s, [
			' ' => '[ _]',
			'_' => '[ _]',
		] );
		return $s;
	}

	/**
	 * Matcher for ISBN/RFC/PMID URL patterns, returning the type and number.
	 *
	 * The match method takes a string and returns false on no match or a tuple
	 * like this on match: [ 'RFC', '12345' ]
	 *
	 * @return callable
	 */
	public function getExtResourceURLPatternMatcher(): callable {
		$nsAliases = implode( '|', array_unique( $this->getSpecialNSAliases() ) );
		$pageAliases = implode( '|', array_map( [ $this, 'quoteTitleRe' ],
			$this->getSpecialPageAliases( 'Booksources' )
		) );

		$pats = [
			'ISBN' => '(?:\.\.?/)*(?i:' . $nsAliases . ')(?:%3[Aa]|:)'
				. '(?i:' . $pageAliases . ')(?:%2[Ff]|/)(?P<ISBN>\d+[Xx]?)',
			'RFC' => '[^/]*//tools\.ietf\.org/html/rfc(?P<RFC>\w+)',
			'PMID' => '[^/]*//www\.ncbi\.nlm\.nih\.gov/pubmed/(?P<PMID>\w+)\?dopt=Abstract',
		];
		// T145590: remove patterns for disabled magic links
		foreach ( array_keys( $pats ) as $v ) {
			if ( !$this->magicLinkEnabled( $v ) ) {
				unset( $pats[$v] );
			}
		}
		$regex = '!^(?:' . implode( '|', $pats ) . ')$!';
		return static function ( $text ) use ( $pats, $regex ) {
			if ( preg_match( $regex, $text, $m ) ) {
				foreach ( $pats as $k => $re ) {
					if ( isset( $m[$k] ) && $m[$k] !== '' ) {
						return [ $k, $m[$k] ];
					}
				}
			}
			return false;
		};
	}

	/**
	 * @return bool
	 */
	public function linterEnabled(): bool {
		return $this->linterEnabled;
	}

	/**
	 * Return the desired linter configuration.  These are heuristic values
	 * which have hardcoded defaults but could be overridden on a per-wiki
	 * basis.
	 * @return array{enabled?:string[],disabled?:string[],maxTableColumnHeuristic?:int,maxTableRowsToCheck?:int}
	 */
	public function getLinterSiteConfig(): array {
		return [
			// Allow list for specific lint types.
			// Takes precedence over block list.
			'enabled' => null,
			// Block list for specific lint types.
			// Not used if an allow list is set.
			'disabled' => null,
			// The maximum columns in a table before the table is considered
			// large
			'maxTableColumnHeuristic' => 5,
			// The maximum rows (header or data) to be checked for the large
			// table lint
			// - If we consider the first N rows to be representative of the
			//   table, and the table is well-formed and uniform, it is
			//   sufficent to check the first N rows to check if the table is
			//   "large".
			// - This heuristic is used together with the
			//   'maxTableColumnHeuristic' to identify "large tables".
			'maxTableRowsToCheck' => 10,
			// Max length of content covered by 'white-space:nowrap' CSS
			// that we consider "safe" when Tidy is replaced.  Beyond that,
			// wikitext will have to be fixed up to manually insert whitespace
			// at the right places.  Length in bytes.
			'tidyWhitespaceBugMaxLength' => 100,
		];
	}

	/**
	 * Serialize ISBN/RFC/PMID URL patterns
	 *
	 * @param string[] $match As returned by the getExtResourceURLPatternMatcher() matcher
	 * @param string $href Fallback link target, if $match is invalid.
	 * @param string $content Link text
	 * @return string
	 */
	public function makeExtResourceURL( array $match, string $href, string $content ): string {
		$normalized = preg_replace(
			'/[ \x{00A0}\x{1680}\x{2000}-\x{200A}\x{202F}\x{205F}\x{3000}]+/u', ' ',
			Utils::decodeWtEntities( $content )
		);

		// TODO: T145590 ("Update Parsoid to be compatible with magic links being disabled")
		switch ( $match[0] ) {
			case 'ISBN':
				$normalized = strtoupper( preg_replace( '/[\- \t]/', '', $normalized ) );
				// validate ISBN length and format, so as not to produce magic links
				// which aren't actually magic
				$valid = preg_match( '/^ISBN(97[89])?\d{9}(\d|X)$/D', $normalized );
				if ( implode( '', $match ) === $normalized && $valid ) {
					return $content;
				}
				// strip "./" prefix. TODO: Use relativeLinkPrefix() instead?
				$href = PHPUtils::stripPrefix( $href, './' );
				return "[[$href|$content]]";

			case 'RFC':
			case 'PMID':
				$normalized = preg_replace( '/[ \t]/', '', $normalized );
				return implode( '', $match ) === $normalized ? $content : "[$href $content]";

			default:
				throw new \InvalidArgumentException( "Invalid match type '{$match[0]}'" );
		}
	}

	/**
	 * Get the list of valid protocols
	 * @return array
	 */
	abstract protected function getProtocols(): array;

	/**
	 * Get a regex fragment matching URL protocols, quoted for an exclamation
	 * mark delimiter. The case-insensitive option should be used.
	 *
	 * @param bool $excludeProtRel Whether to exclude protocol-relative URLs
	 * @return string
	 */
	public function getProtocolsRegex( bool $excludeProtRel = false ) {
		$excludeProtRel = (int)$excludeProtRel;
		if ( !isset( $this->protocolsRegexes[$excludeProtRel] ) ) {
			$parts = [];
			foreach ( $this->getProtocols() as $protocol ) {
				if ( !$excludeProtRel || $protocol !== '//' ) {
					$parts[] = preg_quote( $protocol, '!' );
				}
			}
			$this->protocolsRegexes[$excludeProtRel] = implode( '|', $parts );
		}
		return $this->protocolsRegexes[$excludeProtRel];
	}

	/**
	 * Matcher for valid protocols, must be anchored at start of string.
	 * @param string $potentialLink
	 * @return bool Whether $potentialLink begins with a valid protocol
	 */
	public function hasValidProtocol( string $potentialLink ): bool {
		$re = '!^(?:' . $this->getProtocolsRegex() . ')!i';
		return (bool)preg_match( $re, $potentialLink );
	}

	/**
	 * Matcher for valid protocols, may occur at any point within string.
	 * @param string $potentialLink
	 * @return bool Whether $potentialLink contains a valid protocol
	 */
	public function findValidProtocol( string $potentialLink ): bool {
		$re = '!(?:\W|^)(?:' . $this->getProtocolsRegex() . ')!i';
		return (bool)preg_match( $re, $potentialLink );
	}

	/** @} */

	/**
	 * Fake timestamp, for unit tests.
	 * @return int|null Unix timestamp, or null to not fake it
	 */
	public function fakeTimestamp(): ?int {
		return null;
	}

	/**
	 * Get an array of defined extension tags, with the lower case name in the
	 * key, the value arbitrary. This is the set of extension tags that are
	 * configured in M/W core. $coreExtModules may already be part of it,
	 * but eventually this distinction will disappear since all extension tags
	 * have to be defined against the Parsoid's extension API.
	 *
	 * @return array
	 */
	abstract protected function getNonNativeExtensionTags(): array;

	/**
	 * Return an object factory to use when instantiating extensions.
	 * (This is assumed to be plumbed up to an appropriate service container.)
	 * @return ObjectFactory The object factory to use for extensions
	 */
	public function getObjectFactory(): ObjectFactory {
		// Default implementation returns an object factory with an
		// empty service container.
		return new ObjectFactory( new class() implements ContainerInterface {

			/**
			 * @param string $id
			 * @return never
			 */
			public function get( $id ) {
				throw new class( "Empty service container" ) extends \Error
					implements NotFoundExceptionInterface {
				};
			}

			/**
			 * @param string $id
			 * @return false
			 */
			public function has( $id ): bool {
				return false;
			}
		} );
	}

	/**
	 * FIXME: might benefit from T250230 (caching) but see T270307 --
	 * currently SiteConfig::unregisterExtensionModule() is called
	 * during testing, which requires invalidating $this->extConfig.
	 * (See also SiteConfig::fakeTimestamp() etc.)  We'd probably need
	 * to more fully separate/mock the "testing SiteConfig" as well
	 * as provide a way for parser options to en/disable individual
	 * registered modules before this class can be considered immutable
	 * and cached.
	 */
	private function constructExtConfig() {
		$this->extConfig = [
			'allTags'        => [],
			'parsoidExtTags' => [],
			'annotationTags' => [],
			'domProcessors'  => [],
			'annotationStrippers' => [],
			'contentModels'  => [],
		];

		// There may be some tags defined by the parent wiki which have no
		// associated parsoid modules; for now we handle these by invoking
		// the legacy parser.
		$this->extConfig['allTags'] = $this->getNonNativeExtensionTags();

		foreach ( $this->getExtensionModules() as $module ) {
			$this->processExtensionModule( $module );
		}
	}

	/**
	 * @param string $lowerTagName
	 * @return bool
	 */
	public function tagNeedsNowikiStrippedInTagPF( string $lowerTagName ): bool {
		return isset( $this->t299103Tags[$lowerTagName] );
	}

	/**
	 * Register a Parsoid-compatible extension
	 * @param ExtensionModule $ext
	 */
	protected function processExtensionModule( ExtensionModule $ext ): void {
		Assert::invariant( $this->extConfig !== null, "not yet inited!" );
		$extConfig = $ext->getConfig();
		Assert::invariant(
			isset( $extConfig['name'] ),
			"Every extension module must have a name."
		);
		$name = $extConfig['name'];

		// These are extension tag handlers.  They have
		// wt2html (sourceToDom), html2wt (domToWikitext), and
		// linter functionality.
		foreach ( $extConfig['tags'] ?? [] as $tagConfig ) {
			$lowerTagName = mb_strtolower( $tagConfig['name'] );
			$this->extConfig['allTags'][$lowerTagName] = true;
			$this->extConfig['parsoidExtTags'][$lowerTagName] = $tagConfig;
			// Deal with b/c nowiki stripping support needed by some extensions.
			// This register this tag with the legacy parser for
			// implicit nowiki stripping in {{#tag:..}} args for this tag.
			if ( isset( $tagConfig['options']['stripNowiki'] ) ) {
				$this->t299103Tags[$lowerTagName] = true;
			}
		}

		if ( isset( $extConfig['annotations'] ) ) {
			$annotationConfig = $extConfig['annotations'];
			$annotationTags = $annotationConfig['tagNames'] ?? $annotationConfig;
			foreach ( $annotationTags ?? [] as $aTag ) {
				$lowerTagName = mb_strtolower( $aTag );
				$this->extConfig['allTags'][$lowerTagName] = true;
				$this->extConfig['annotationTags'][$lowerTagName] = true;
			}
			if ( isset( $annotationConfig['annotationStripper'] ) ) {
				$obj = $this->getObjectFactory()->createObject( $annotationConfig['annotationStripper'], [
					'allowClassName' => true,
					'assertClass' => AnnotationStripper::class,
				] );
				$this->extConfig['annotationStrippers'][$name] = $obj;
			}
		}

		// Extension modules may also register dom processors.
		// This is for wt2htmlPostProcessor and html2wtPreProcessor
		// functionality.
		if ( isset( $extConfig['domProcessors'] ) ) {
			$this->extConfig['domProcessors'][$name] = $extConfig['domProcessors'];
		}

		foreach ( $extConfig['contentModels'] ?? [] as $cm => $spec ) {
			// For compatibility with mediawiki core, the first
			// registered extension wins.
			if ( isset( $this->extConfig['contentModels'][$cm] ) ) {
				continue;
			}
			$handler = $this->getObjectFactory()->createObject( $spec, [
				'allowClassName' => true,
				'assertClass' => ContentModelHandler::class,
			] );
			$this->extConfig['contentModels'][$cm] = $handler;
		}
	}

	protected function getExtConfig(): array {
		if ( !$this->extConfig ) {
			$this->constructExtConfig();
		}
		return $this->extConfig;
	}

	/**
	 * Return a ContentModelHandler for the specified $contentmodel, if one is registered.
	 * If null is returned, will use the default wikitext content model handler.
	 *
	 * @param string $contentmodel
	 * @return ContentModelHandler|null
	 */
	public function getContentModelHandler( string $contentmodel ): ?ContentModelHandler {
		return ( $this->getExtConfig() )['contentModels'][$contentmodel] ?? null;
	}

	/**
	 * Returns all the annotationStrippers that are defined as annotation configuration
	 * @return array<AnnotationStripper>
	 */
	public function getAnnotationStrippers(): array {
		$res = $this->getExtConfig()['annotationStrippers'] ?? [];
		// ensures stability of the method list order
		ksort( $res );
		return array_values( $res );
	}

	/**
	 * Determine whether a given name, which must have already been converted
	 * to lower case, is a valid extension tag name.
	 *
	 * @param string $name
	 * @return bool
	 */
	public function isExtensionTag( string $name ): bool {
		return isset( $this->getExtensionTagNameMap()[$name] );
	}

	/**
	 * @param string $tagName is $tagName an annotation tag?
	 * @return bool
	 */
	public function isAnnotationTag( string $tagName ): bool {
		return $this->getExtConfig()['annotationTags'][mb_strtolower( $tagName )] ?? false;
	}

	/**
	 * Get an array of defined annotation tags in lower case
	 * @return array
	 */
	public function getAnnotationTags(): array {
		$extConfig = $this->getExtConfig();
		return array_keys( $extConfig['annotationTags'] );
	}

	/**
	 * Get an array of defined extension tags, with the lower case name
	 * in the key, and the value being arbitrary.
	 *
	 * @return array
	 */
	public function getExtensionTagNameMap(): array {
		$extConfig = $this->getExtConfig();
		return $extConfig['allTags'];
	}

	/**
	 * @param string $tagName Extension tag name
	 * @return array|null
	 */
	public function getExtTagConfig( string $tagName ): ?array {
		$extConfig = $this->getExtConfig();
		return $extConfig['parsoidExtTags'][mb_strtolower( $tagName )] ?? null;
	}

	private $tagHandlerCache = [];

	/**
	 * @param string $tagName Extension tag name
	 * @return ExtensionTagHandler|null
	 *   Returns the implementation of the named extension, if there is one.
	 */
	public function getExtTagImpl( string $tagName ): ?ExtensionTagHandler {
		if ( !array_key_exists( $tagName, $this->tagHandlerCache ) ) {
			$tagConfig = $this->getExtTagConfig( $tagName );
			$this->tagHandlerCache[$tagName] = isset( $tagConfig['handler'] ) ?
				$this->getObjectFactory()->createObject( $tagConfig['handler'], [
					'allowClassName' => true,
					'assertClass' => ExtensionTagHandler::class,
				] ) : null;
		}

		return $this->tagHandlerCache[$tagName];
	}

	/**
	 * Return an array mapping extension name to an array of object factory
	 * specs for Ext\DOMProcessor objects
	 * @return array
	 */
	public function getExtDOMProcessors(): array {
		$extConfig = $this->getExtConfig();
		return $extConfig['domProcessors'];
	}

	/** @var array<string,int> */
	protected $wt2htmlLimits = [
		// We won't handle pages beyond this size
		'wikitextSize' => 2048 * 1024, // ParserOptions::maxIncludeSize

		// Max list items per page
		'listItem' => 30000,

		// Max table cells per page
		'tableCell' => 30000,

		// Max transclusions per page
		'transclusion' => 10000,

		// DISABLED for now
		// Max images per page
		'image' => 1000,

		// Max top-level token size
		'token' => 1000000, // 1M
	];

	/**
	 * @return array<string,int>
	 */
	public function getWt2HtmlLimits(): array {
		return $this->wt2htmlLimits;
	}

	/** @var array<string,int> */
	protected $html2wtLimits = [
		// We refuse to serialize HTML strings bigger than this
		'htmlSize' => 10000000, // 10M
	];

	/**
	 * @return array<string,int>
	 */
	public function getHtml2WtLimits(): array {
		return $this->html2wtLimits;
	}

	/**
	 * @param ?string $filePath File to log to (if null, logs to console)
	 * @return Logger
	 */
	public static function createLogger( ?string $filePath = null ): Logger {
		// Use Monolog's PHP console handler
		$logger = new Logger( "Parsoid CLI" );
		$format = '%message%';
		if ( $filePath ) {
			$handler = new StreamHandler( $filePath );
			$format .= "\n";
		} else {
			$handler = new ErrorLogHandler();
		}
		// Don't suppress inline newlines
		$handler->setFormatter( new LineFormatter( $format, null, true ) );
		$logger->pushHandler( $handler );

		if ( $filePath ) {
			// Separator between logs since StreamHandler appends
			$logger->log( Logger::INFO, "-------------- starting fresh log --------------" );
		}

		return $logger;
	}

	abstract public function getNoFollowConfig(): array;

	/** @return string|false */
	abstract public function getExternalLinkTarget();
}
