const {
	EditorView,
	HighlightStyle,
	LanguageSupport,
	StreamLanguage,
	StreamParser,
	StringStream,
	Tag,
	codeFolding,
	foldedRanges,
	syntaxHighlighting,
	unfoldEffect
} = require( 'ext.CodeMirror.lib' );
const CodeMirrorMode = require( '../codemirror.mode.js' );
const mwModeConfig = require( './codemirror.mediawiki.config.js' );
const bidiIsolationExtension = require( './codemirror.mediawiki.bidiIsolation.js' );
const { codeFoldingExtension, foldAllRefs } = require( './codemirror.mediawiki.codeFolding.js' );
const { autocompleteExtension, completionSource } = require( './codemirror.mediawiki.autocomplete.js' );
const openLinksExtension = require( './codemirror.mediawiki.openLinks.js' );
const closeTagsExtension = require( './codemirror.mediawiki.closeTags.js' );
const mwKeymap = require( './codemirror.mediawiki.keymap.js' );
const { lintSource, lintApi } = require( './codemirror.mediawiki.lint.js' );

const specialParserFunctions = {
		ifexist: 0,
		lst: 0,
		lstx: 0,
		lsth: 0,
		filepath: 6,
		int: 8,
		invoke: 828
	},
	nsIds = mw.config.get( 'wgNamespaceIds' ),
	fileNsRegex = new RegExp( `^(?:${
		Object.entries( nsIds ).filter( ( [ , id ] ) => id === 6 ).map( ( [ ns ] ) => ns ).join( '|' )
	})\\s*:`, 'i' );

const startState = ( tokenize ) => ( {
	tokenize,
	stack: [],
	inHtmlTag: [],
	extName: false,
	extMode: false,
	extState: false,
	nTemplate: 0,
	nLink: 0,
	nExtLink: 0,
	nExt: 0,
	nDt: 0,
	bold: false,
	italic: false,
	sof: true,
	data: {
		firstSingleLetterWord: null,
		firstMultiLetterWord: null,
		firstSpace: null,
		readyTokens: [],
		oldToken: null,
		mark: null
	}
} );

const copyState = ( state ) => {
	const newState = {};
	for ( const key in state ) {
		const val = state[ key ];
		if ( Array.isArray( val ) ) {
			newState[ key ] = [ ...val ];
		} else if ( key === 'extState' ) {
			newState.extState =
				( state.extName && state.extMode && state.extMode.copyState || copyState )( val );
		} else {
			newState[ key ] = key !== 'data' && val && typeof val === 'object' ? Object.assign( {}, val ) : val;
		}
	}
	return newState;
};

/* eslint-disable max-len */
/**
 * MediaWiki language support for CodeMirror 6.
 * Adapted from the original CodeMirror 5 stream parser by Pavel Astakhov.
 * Exposed through the {@link module:ext.CodeMirror.mode.mediawiki ext.CodeMirror.mode.mediawiki} module.
 *
 * @class
 * @extends CodeMirrorMode
 */
class CodeMirrorMediaWiki extends CodeMirrorMode {
	/* eslint-enable max-len */
	/**
	 * @param {Object} config MediaWiki configuration as generated by DataScript.php,
	 *   along with additional properties documented below.
	 * @param {string[]} config.languageVariants List of language variants to support.
	 * @internal
	 * @hideconstructor
	 */
	constructor( config ) {
		super( 'mediawiki' );
		this.config = config;
		this.urlProtocols = new RegExp( `^(?:${ config.urlProtocols })(?=[^\\s{[\\]<>~).,'])`, 'i' );
		this.languageVariants = config.languageVariants || mw.config.get( 'cmLanguageVariants', [] );
		this.tokenTable = mwModeConfig.tokenTable;
		this.registerGroundTokens();

		for ( const k in specialParserFunctions ) {
			mwModeConfig.addFunction( specialParserFunctions[ k ] );
		}
		// Dynamically register any tags that aren't already in CodeMirrorMediaWikiConfig
		Object.keys( config.tags ).forEach( ( tag ) => mwModeConfig.addTag( tag ) );

		this.functionSynonyms = [
			...Object.keys( config.functionSynonyms[ 0 ] )
				.map( ( label ) => ( { type: 'function', label } ) ),
			...Object.keys( config.functionSynonyms[ 1 ] )
				.map( ( label ) => ( { type: 'constant', label } ) )
		];
		this.doubleUnderscore = [
			...Object.keys( config.doubleUnderscore[ 0 ] ),
			...Object.keys( config.doubleUnderscore[ 1 ] )
		].map( ( label ) => ( { type: 'constant', label } ) );
		const extTags = Object.keys( config.tags );
		this.extTags = extTags.map( ( label ) => ( { type: 'type', label } ) );
		this.htmlTags = Object.keys( mwModeConfig.permittedHtmlTags )
			.filter( ( tag ) => !extTags.includes( tag ) )
			.map( ( label ) => ( { type: 'type', label } ) );
		this.protocols = config.urlProtocols.split( '|' )
			.map( ( label ) => ( { type: 'namespace', label: label.replace( /\\([:/])/g, '$1' ) } ) );
		this.redirectRegex = new RegExp( `^\\s*(?:${
			config.redirection.join( '|' )
		})(\\s*:)?\\s*(?=\\[\\[)`, 'i' );
		this.nsRegex = new RegExp( `^(${
			Object.keys( nsIds ).filter( Boolean ).join( '|' ).replace( /_/g, ' ' )
		})\\s*:\\s*`, 'i' );
		const img = Object.keys( config.imageKeywords ).filter( ( word ) => !/\$1./.test( word ) );
		this.imgRegex = new RegExp( `^(?:${
			img.filter( ( word ) => word.endsWith( '$1' ) ).map( ( word ) => word.slice( 0, -2 ) ).join( '|' )
		}|(?:${
			img.filter( ( word ) => !word.endsWith( '$1' ) ).join( '|' )
		}|(?:\\d+x?|\\d*x\\d+)\\s*(?:px)?px)\\s*(?=\\||\\]\\]|$))` );
		this.substRegex = new RegExp( `^(?:(${
			Object.keys( config.subst ).join( '|' )
		})\\s*)?`, 'i' );
	}

	/** @inheritDoc */
	get language() {
		return StreamLanguage.define( this.parser );
	}

	/** @inheritDoc */
	get lintSource() {
		return lintSource;
	}

	/** @inheritDoc */
	get lintApi() {
		return lintApi;
	}

	/** @inheritDoc */
	get bracketMatchingConfig() {
		return {
			// Also match CJK full-width brackets (T362992)
			// This is only for wikitext as it can be confusing in programming languages.
			brackets: '()[]{}（）【】［］｛｝'
		};
	}

	/** @inheritDoc */
	get support() {
		return [
			syntaxHighlighting(
				HighlightStyle.define(
					mwModeConfig.getTagStyles( this.parser )
				)
			),
			// This only makes Wikitext "foldable".
			// Given no fold service registered, this will actually takes no effect.
			codeFolding( {
				placeholderDOM( view ) {
					const element = document.createElement( 'span' );
					element.textContent = '…';
					element.setAttribute( 'aria-label', mw.msg( 'codemirror-folded-code' ) );
					element.title = mw.msg( 'codemirror-unfold' );
					element.className = 'cm-foldPlaceholder';
					element.onclick = ( { target } ) => {
						const pos = view.posAtDOM( target ),
							{ state } = view,
							{ selection } = state;
						foldedRanges( state ).between( pos, pos, ( from, to ) => {
							if ( from === pos ) {
								// Unfold the code and redraw the selections
								view.dispatch( {
									effects: unfoldEffect.of( { from, to } ),
									selection
								} );
							}
						} );
					};
					return element;
				}
			} )
		];
	}

	/**
	 * Register the ground tokens. These aren't referenced directly in the StreamParser, nor do
	 * they have a parent Tag, so we don't need them as constants like we do for other tokens.
	 * See this.makeLocalStyle() for how these tokens are used.
	 *
	 * @private
	 */
	registerGroundTokens() {
		[
			'mw-ext-ground',
			'mw-ext-link-ground',
			'mw-ext2-ground',
			'mw-ext2-link-ground',
			'mw-ext3-ground',
			'mw-ext3-link-ground',
			'mw-link-ground',
			'mw-template-ext-ground',
			'mw-template-ext-link-ground',
			'mw-template-ext2-ground',
			'mw-template-ext2-link-ground',
			'mw-template-ext3-ground',
			'mw-template-ext3-link-ground',
			'mw-template-ground',
			'mw-template-link-ground',
			'mw-template2-ext-ground',
			'mw-template2-ext-link-ground',
			'mw-template2-ext2-ground',
			'mw-template2-ext2-link-ground',
			'mw-template2-ext3-ground',
			'mw-template2-ext3-link-ground',
			'mw-template2-ground',
			'mw-template2-link-ground',
			'mw-template3-ext-ground',
			'mw-template3-ext-link-ground',
			'mw-template3-ext2-ground',
			'mw-template3-ext2-link-ground',
			'mw-template3-ext3-ground',
			'mw-template3-ext3-link-ground',
			'mw-template3-ground',
			'mw-template3-link-ground'
		].forEach( ( ground ) => mwModeConfig.addToken( ground ) );
	}

	eatHtmlEntity( stream, style ) {
		let ok;
		if ( stream.eat( '#' ) ) {
			if ( stream.eat( 'x' ) ) {
				ok = stream.eatWhile( /[a-fA-F\d]/ ) && stream.eat( ';' );
			} else {
				ok = stream.eatWhile( /[\d]/ ) && stream.eat( ';' );
			}
		} else {
			ok = stream.eatWhile( /[\w.\-:]/ ) && stream.eat( ';' );
		}
		if ( ok ) {
			return mwModeConfig.tags.htmlEntity;
		}
		return style;
	}

	isNested( state ) {
		return state.nExt > 0 || state.nTemplate > 0 || state.nLink > 0 || state.nExtLink > 0;
	}

	makeFullStyle( style, state ) {
		return ( typeof style === 'string' ?
			style :
			`${ style[ 0 ] } ${ state.bold || state.nDt > 0 ? mwModeConfig.tags.strong : '' } ${ state.italic ? mwModeConfig.tags.em : '' }`
		).replace( /\s{2,}/g, ' ' ).trim() || ' ';
	}

	makeStyle( style, state, endGround ) {
		return [ this.makeLocalStyle( style, state, endGround ) ];
	}

	makeLocalStyle( style, state, endGround ) {
		let ground = '';
		switch ( state.nTemplate ) {
			case 0:
				break;
			case 1:
				ground += '-template';
				break;
			case 2:
				ground += '-template2';
				break;
			default:
				ground += '-template3';
				break;
		}
		switch ( state.nExt ) {
			case 0:
				break;
			case 1:
				ground += '-ext';
				break;
			case 2:
				ground += '-ext2';
				break;
			default:
				ground += '-ext3';
				break;
		}
		if ( state.nLink > 0 || state.nExtLink > 0 ) {
			ground += '-link';
		}
		if ( ground !== '' ) {
			style = `mw${ ground }-ground ${ style }`;
		}
		if ( endGround ) {
			state[ endGround ]--;
		}
		return style.trim();
	}

	eatBlock( style, terminator, consumeLast ) {
		return ( stream, state ) => {
			if ( stream.skipTo( terminator ) ) {
				if ( consumeLast !== false ) {
					stream.match( terminator );
				}
				state.tokenize = state.stack.pop();
			} else {
				stream.skipToEnd();
			}
			return this.makeLocalStyle( style, state );
		};
	}

	eatEnd( style ) {
		return ( stream, state ) => {
			stream.skipToEnd();
			state.tokenize = state.stack.pop();
			return this.makeLocalStyle( style, state );
		};
	}

	eatChar( char, style ) {
		return ( stream, state ) => {
			state.tokenize = state.stack.pop();
			if ( stream.eat( char ) ) {
				return this.makeLocalStyle( style, state );
			}
			return this.makeLocalStyle( mwModeConfig.tags.error, state );
		};
	}

	eatSectionHeader( count ) {
		return ( stream, state ) => {
			if ( stream.match( /^[^&<[{~-]+/ ) ) {
				if ( stream.eol() ) {
					stream.backUp( count );
					state.tokenize = this.eatEnd( mwModeConfig.tags.sectionHeader );
				} else if ( stream.match( /^<!--(?!.*?-->.*?=)/, false ) ) {
					// T171074: handle trailing comments
					stream.backUp( count );
					state.tokenize = this.eatBlock( mwModeConfig.tags.sectionHeader, '<!--', false );
				}
				return mwModeConfig.tags.section; // style is null
			}
			return this.eatWikiText( mwModeConfig.tags.section )( stream, state );
		};
	}

	inVariable( stream, state ) {
		if ( stream.match( /^[^{}|]+/ ) ) {
			return this.makeLocalStyle( mwModeConfig.tags.templateVariableName, state );
		}
		if ( stream.eat( '|' ) ) {
			state.tokenize = this.inVariableDefault.bind( this );
			return this.makeLocalStyle( mwModeConfig.tags.templateVariableDelimiter, state );
		}
		if ( stream.match( '}}}' ) ) {
			state.tokenize = state.stack.pop();
			return this.makeLocalStyle( mwModeConfig.tags.templateVariableBracket, state );
		}
		if ( stream.match( '{{{' ) ) {
			state.stack.push( state.tokenize );
			return this.makeLocalStyle( mwModeConfig.tags.templateVariableBracket, state );
		}
		stream.next();
		return this.makeLocalStyle( mwModeConfig.tags.templateVariableName, state );
	}

	inVariableDefault( stream, state ) {
		if ( stream.match( /^[^{}[<&~-]+/ ) ) {
			return this.makeLocalStyle( mwModeConfig.tags.templateVariable, state );
		}
		if ( stream.match( '}}}' ) ) {
			state.tokenize = state.stack.pop();
			return this.makeLocalStyle( mwModeConfig.tags.templateVariableBracket, state );
		}
		return this.eatWikiText( mwModeConfig.tags.templateVariable )( stream, state );
	}

	inSubstitution( subst ) {
		return ( stream, state ) => {
			if ( subst ) {
				stream.match( subst );
				stream.backUp( 1 );
				state.tokenize = this.inSubstitution();
				return this.makeLocalStyle( mwModeConfig.tags.parserFunctionName, state );
			}
			stream.next();
			stream.eatSpace();
			state.tokenize = state.stack.pop();
			return this.makeLocalStyle( mwModeConfig.tags.parserFunctionDelimiter, state );
		};
	}

	inParserFunctionName( ns ) {
		return ( stream, state ) => {
			// FIXME: {{#name}} and {{uc}} are wrong, must have ':'
			const mt = stream.match( /^#?[^:}{~]+/ );
			if ( mt ) {
				const name = this.config.functionSynonyms[ 0 ][ mt[ 0 ].trim().toLowerCase() ];
				if ( name in specialParserFunctions ) {
					state.tokenize = this.inParserFunctionName( specialParserFunctions[ name ] );
				}
				return this.makeLocalStyle( mwModeConfig.tags.parserFunctionName, state );
			}
			if ( stream.eat( ':' ) ) {
				state.tokenize = this.inParserFunctionArguments( ns );
				return this.makeLocalStyle( mwModeConfig.tags.parserFunctionDelimiter, state );
			}
			if ( stream.match( '}}' ) ) {
				state.tokenize = state.stack.pop();
				return this.makeLocalStyle( mwModeConfig.tags.parserFunctionBracket, state, 'nExt' );
			}
			return this.eatWikiText( mwModeConfig.tags.parserFunction )( stream, state );
		};
	}

	inParserFunctionArguments( ns ) {
		const style = ns === undefined ?
			mwModeConfig.tags.parserFunction :
			`${ mwModeConfig.tags.parserFunction } ${ mwModeConfig.tags.pageName } mw-function-${ ns }`;
		return ( stream, state ) => {
			if ( stream.match( /^[^|}{[<&~-]+/ ) ) {
				return this.makeLocalStyle( style, state );
			} else if ( stream.eat( '|' ) ) {
				if ( ns !== undefined ) {
					state.tokenize = this.inParserFunctionArguments();
				}
				return this.makeLocalStyle( mwModeConfig.tags.parserFunctionDelimiter, state );
			} else if ( stream.match( '}}' ) ) {
				state.tokenize = state.stack.pop();
				return this.makeLocalStyle( mwModeConfig.tags.parserFunctionBracket, state, 'nExt' );
			}
			return this.eatWikiText( style )( stream, state );
		};
	}

	eatTemplatePageName( haveAte ) {
		return ( stream, state ) => {
			if ( stream.match( /^\s*\|\s*/ ) ) {
				state.tokenize = this.eatTemplateArgument( true );
				return this.makeLocalStyle( mwModeConfig.tags.templateDelimiter, state );
			}
			if ( stream.match( /^\s*\}\}/ ) ) {
				state.tokenize = state.stack.pop();
				return this.makeLocalStyle( mwModeConfig.tags.templateBracket, state, 'nTemplate' );
			}
			if ( stream.match( /^\s*<!--.*?-->/ ) ) {
				return this.makeLocalStyle( mwModeConfig.tags.comment, state );
			}
			if ( haveAte && stream.sol() ) {
				// @todo error message
				state.nTemplate--;
				state.tokenize = state.stack.pop();
				return '';
			}
			if ( stream.match( /^\s*[^\s|}<{&~]+/ ) ) {
				state.tokenize = this.eatTemplatePageName( true );
				return this.makeLocalStyle( mwModeConfig.tags.templateName, state );
			} else if ( stream.eatSpace() ) {
				if ( stream.eol() === true ) {
					return this.makeLocalStyle( mwModeConfig.tags.templateName, state );
				}
				return this.makeLocalStyle( mwModeConfig.tags.templateName, state );
			}
			return this.eatWikiText( mwModeConfig.tags.templateName )( stream, state );
		};
	}

	eatTemplateArgument( expectArgName ) {
		return ( stream, state ) => {
			if ( expectArgName && stream.match( /^(?:[^=|}{[<&~_-]|-(?!\{)|_(?!_))+/ ) ) {
				if ( stream.eat( '=' ) ) {
					state.tokenize = this.eatTemplateArgument( false );
					return this.makeLocalStyle( mwModeConfig.tags.templateArgumentName, state );
				}
				return this.makeLocalStyle( mwModeConfig.tags.template, state );
			} else if ( stream.eatWhile( /[^|}{[<&~_-]/ ) ) {
				return this.makeLocalStyle( mwModeConfig.tags.template, state );
			} else if ( stream.eat( '|' ) ) {
				state.tokenize = this.eatTemplateArgument( true );
				return this.makeLocalStyle( mwModeConfig.tags.templateDelimiter, state );
			} else if ( stream.match( '}}' ) ) {
				state.tokenize = state.stack.pop();
				return this.makeLocalStyle( mwModeConfig.tags.templateBracket, state, 'nTemplate' );
			}
			return this.eatWikiText( mwModeConfig.tags.template )( stream, state );
		};
	}

	eatExternalLinkProtocol( chars ) {
		return ( stream, state ) => {
			while ( chars > 0 ) {
				chars--;
				stream.next();
			}
			if ( stream.eol() ) {
				state.nExtLink--;
				// @todo error message
				state.tokenize = state.stack.pop();
			} else {
				state.tokenize = this.inExternalLink.bind( this );
			}
			return this.makeLocalStyle( mwModeConfig.tags.extLinkProtocol, state );
		};
	}

	inExternalLink( stream, state ) {
		if ( stream.sol() ) {
			state.nExtLink--;
			// @todo error message
			state.tokenize = state.stack.pop();
			return '';
		}
		if ( stream.match( /^\s*\]/ ) ) {
			state.tokenize = state.stack.pop();
			return this.makeLocalStyle( mwModeConfig.tags.extLinkBracket, state, 'nExtLink' );
		}
		if ( stream.eatSpace() || stream.peek() === '<' ) {
			state.tokenize = this.inExternalLinkText.bind( this );
			return this.makeStyle( '', state );
		}
		if ( stream.match( /^[^\s\]{&~'<]+/ ) || stream.eatSpace() ) {
			if ( stream.peek() === '\'' ) {
				if ( stream.match( '\'\'', false ) ) {
					state.tokenize = this.inExternalLinkText.bind( this );
				} else {
					stream.next();
				}
			}
			return this.makeStyle( mwModeConfig.tags.extLink, state );
		}
		return this.eatWikiText( mwModeConfig.tags.extLink )( stream, state );
	}

	inExternalLinkText( stream, state ) {
		if ( stream.sol() ) {
			state.nExtLink--;
			// @todo error message
			state.tokenize = state.stack.pop();
			return '';
		}
		if ( stream.eat( ']' ) ) {
			state.tokenize = state.stack.pop();
			return this.makeLocalStyle( mwModeConfig.tags.extLinkBracket, state, 'nExtLink' );
		}
		if ( stream.match( /^[^'\]{&~<-]+/ ) ) {
			return this.makeStyle( mwModeConfig.tags.extLinkText, state );
		}
		return this.eatWikiText( mwModeConfig.tags.extLinkText )( stream, state );
	}

	inGallery( stream, state ) {
		if ( stream.match( /^\s*\|\s*/ ) ) {
			state.tokenize = this.eatLinkText( true, true );
			this.toEatImageParameters( stream, state );
			return this.makeLocalStyle( mwModeConfig.tags.linkDelimiter, state );
		}
		if ( stream.match( /^\s*[^\s|&{]+/ ) || stream.eatSpace() ) {
			return this.makeStyle(
				`${ mwModeConfig.tags.linkPageName } ${ mwModeConfig.tags.pageName }`,
				state
			);
		}
		return this.eatWikiText(
			`${ mwModeConfig.tags.linkPageName } ${ mwModeConfig.tags.pageName }`
		)( stream, state );
	}

	inLink( file ) {
		return ( stream, state ) => {
			if ( stream.sol() ) {
				state.nLink--;
				// @todo error message
				state.tokenize = state.stack.pop();
				return '';
			}
			if ( stream.match( /^\s*#\s*/ ) ) {
				state.tokenize = this.inLinkToSection.bind( this );
				return this.makeLocalStyle( mwModeConfig.tags.link, state );
			}
			if ( stream.match( /^\s*\|\s*/ ) ) {
				state.tokenize = this.eatLinkText( file );
				if ( file ) {
					this.toEatImageParameters( stream, state );
				}
				return this.makeLocalStyle( mwModeConfig.tags.linkDelimiter, state );
			}
			if ( stream.match( /^\s*\]\]/ ) ) {
				state.tokenize = state.stack.pop();
				return this.makeLocalStyle( mwModeConfig.tags.linkBracket, state, 'nLink' );
			}
			if ( stream.match( /^(?:[^#|\]&~{<]|<(?!!--))+/ ) ) {
				return this.makeStyle(
					`${ mwModeConfig.tags.linkPageName } ${ mwModeConfig.tags.pageName }`,
					state
				);
			}
			return this.eatWikiText(
				`${ mwModeConfig.tags.linkPageName } ${ mwModeConfig.tags.pageName }`
			)( stream, state );
		};
	}

	inLinkToSection( stream, state ) {
		if ( stream.sol() ) {
			// @todo error message
			state.nLink--;
			state.tokenize = state.stack.pop();
			return '';
		}
		// FIXME '{{' breaks links, example: [[z{{page]]
		if ( stream.match( /^[^|\]&~{}]+/ ) ) {
			return this.makeLocalStyle( mwModeConfig.tags.linkToSection, state );
		}
		if ( stream.eat( '|' ) ) {
			state.tokenize = this.eatLinkText();
			return this.makeLocalStyle( mwModeConfig.tags.linkDelimiter, state );
		}
		if ( stream.match( ']]' ) ) {
			state.tokenize = state.stack.pop();
			return this.makeLocalStyle( mwModeConfig.tags.linkBracket, state, 'nLink' );
		}
		return this.eatWikiText( mwModeConfig.tags.linkToSection )( stream, state );
	}

	eatLinkText( file, gallery ) {
		let linkIsBold, linkIsItalic;
		return ( stream, state ) => {
			let tmpstyle;
			if ( !gallery && stream.match( ']]' ) || !file && stream.match( '[[', false ) ) {
				state.tokenize = state.stack.pop();
				return this.makeLocalStyle( mwModeConfig.tags.linkBracket, state, 'nLink' );
			}
			if ( file && stream.match( /^\|\s*/ ) ) {
				this.toEatImageParameters( stream, state );
				return this.makeLocalStyle( mwModeConfig.tags.linkDelimiter, state );
			}
			if ( stream.match( '\'\'\'' ) ) {
				linkIsBold = !linkIsBold;
				return this.makeLocalStyle(
					`${ mwModeConfig.tags.linkText } ${ mwModeConfig.tags.apostrophes }`,
					state
				);
			}
			if ( stream.match( '\'\'' ) ) {
				linkIsItalic = !linkIsItalic;
				return this.makeLocalStyle(
					`${ mwModeConfig.tags.linkText } ${ mwModeConfig.tags.apostrophes }`,
					state
				);
			}
			tmpstyle = mwModeConfig.tags.linkText;
			if ( linkIsBold ) {
				tmpstyle += ' ' + mwModeConfig.tags.strong;
			}
			if ( linkIsItalic ) {
				tmpstyle += ' ' + mwModeConfig.tags.em;
			}
			if ( stream.match( file ? /^[^'\]{&~<[|-]+/ : /^(?:[^'[\]{&~<-]|\[(?!\[))+/ ) ) {
				return this.makeStyle( tmpstyle, state );
			}
			return this.eatWikiText( tmpstyle )( stream, state );
		};
	}

	toEatImageParameters( stream, state ) {
		const mt = stream.match( this.imgRegex, false );
		if ( mt ) {
			state.stack.push( state.tokenize );
			state.tokenize = ( stream2, state2 ) => {
				stream2.pos += mt[ 0 ].length;
				state2.tokenize = state2.stack.pop();
				return this.makeLocalStyle( mwModeConfig.tags.imageParameter, state2 );
			};
		}
	}

	eatTagName( chars, isCloseTag, isHtmlTag ) {
		return ( stream, state ) => {
			let name = '';
			while ( chars > 0 ) {
				chars--;
				name = name + stream.next();
			}
			stream.eatSpace();
			name = name.toLowerCase();

			if ( isHtmlTag ) {
				if ( isCloseTag && !mwModeConfig.implicitlyClosedHtmlTags[ name ] ) {
					state.tokenize = this.eatChar( '>', mwModeConfig.tags.htmlTagBracket );
				} else {
					state.tokenize = this.eatHtmlTagAttribute( name );
				}
				return this.makeLocalStyle( mwModeConfig.tags.htmlTagName, state );
			}
			// it is the extension tag
			if ( isCloseTag ) {
				state.tokenize = this.eatChar(
					'>',
					`${ mwModeConfig.tags.extTagBracket } mw-ext-${ name }`
				);
			} else {
				state.tokenize = this.eatExtTagAttribute( name );
			}
			return this.makeLocalStyle( `${ mwModeConfig.tags.extTagName } mw-ext-${ name }`, state );
		};
	}

	eatHtmlTagAttribute( name, quote ) {
		const style = mwModeConfig.tags[ quote === undefined ? 'htmlTagAttribute' : 'htmlTagAttributeValue' ];
		return ( stream, state ) => {

			if ( stream.eat( '>' ) ) {
				if ( !( name in mwModeConfig.implicitlyClosedHtmlTags ) ) {
					state.inHtmlTag.push( name );
				}
				state.tokenize = state.stack.pop();
				return this.makeLocalStyle( mwModeConfig.tags.htmlTagBracket, state );
			}
			if ( stream.match( '/>' ) ) {
				state.tokenize = state.stack.pop();
				return this.makeLocalStyle( mwModeConfig.tags.htmlTagBracket, state );
			}
			const peek = stream.peek();
			if ( peek === '<' ) {
				const { pos } = stream,
					{ length } = state.stack,
					// eat comment or extension tag
					result = this.eatWikiText( '' )( stream, state );
				if (
					typeof result !== 'string' ||
					!result.includes( mwModeConfig.tags.comment ) &&
					!result.includes( mwModeConfig.tags.extTagBracket )
				) {
					state.stack.length = length;
					state.tokenize = state.stack.pop();
					stream.pos = pos;
					return '';
				}
				return result;
			}
			if ( peek === '&' || peek === '{' ) {
				return this.eatWikiText( style )( stream, state );
			}
			if ( quote ) {
				if ( stream.eat( quote[ 0 ] ) ) {
					state.tokenize = this.eatHtmlTagAttribute( name, quote[ 1 ] );
				} else {
					stream.match( new RegExp( `^(?:[^<>&{/${ quote[ 0 ] }]|/(?!>))+` ) );
				}
				return this.makeLocalStyle( style, state );
			}
			if ( quote === '' ) {
				if ( stream.sol() || /\s/.test( peek ) ) {
					state.tokenize = this.eatHtmlTagAttribute( name );
					return '';
				}
				stream.match( /^(?:[^\s<>&{/]|\/(?!>))+/ );
				return this.makeLocalStyle( style, state );
			}
			if ( stream.match( /^=\s*/ ) ) {
				const next = stream.peek();
				state.tokenize = this.eatHtmlTagAttribute( name, next === '"' || next === "'" ? next.repeat( 2 ) : '' );
				return this.makeLocalStyle( style, state );
			}
			stream.match( /^(?:[^<>&={/]|\/(?!>))+/ );
			return this.makeLocalStyle( style, state );
		};
	}

	eatNowiki() {
		return ( stream ) => {
			if ( stream.match( /^[^&]+/ ) ) {
				return '';
			}
			// eat &
			stream.next();
			return this.eatHtmlEntity( stream, '' );
		};
	}

	eatExtTagAttribute( name, quote, isPage ) {
		const style = `${ mwModeConfig.tags.extTagAttribute } mw-ext-${ name }`,
			valueStyle = `${ mwModeConfig.tags.extTagAttributeValue } ${ isPage ?
				`${ mwModeConfig.tags.pageName } mw-ext-${ name }` :
				'' }`;
		return ( stream, state ) => {

			if ( stream.eat( '>' ) ) {
				state.extName = name;

				// FIXME: remove nowiki and pre from TagModes in extension.json after CM6 upgrade
				// leverage the tagModes system for <nowiki> and <pre>
				if ( name === 'nowiki' || name === 'pre' ) {
					// There's no actual processing within these tags (apart from HTML entities),
					// so startState and copyState can be no-ops.
					state.extMode = {
						startState: () => {},
						copyState: () => {},
						token: this.eatNowiki()
					};
				} else if ( name === 'gallery' ) {
					state.extMode = {
						startState: () => startState( this.inGallery.bind( this ) ),
						copyState,
						token: ( stream2, state2 ) => {
							if ( stream2.sol() ) {
								Object.assign( state2, startState( this.inGallery.bind( this ) ) );
							}
							return state2.tokenize( stream2, state2 );
						}
					};
					state.extState = state.extMode.startState();
				} else if ( name in this.config.tagModes ) {
					const mode = this.config.tagModes[ name ];
					if ( mode === 'mediawiki' || mode === 'text/mediawiki' ) {
						state.extMode = this.parser;
						state.extState = state.extMode.startState();
					}
				}

				state.tokenize = this.eatExtTagArea( name );
				return this.makeLocalStyle( `${ mwModeConfig.tags.extTagBracket } mw-ext-${ name }`, state );
			}
			if ( stream.match( '/>' ) ) {
				state.tokenize = state.stack.pop();
				return this.makeLocalStyle( `${ mwModeConfig.tags.extTagBracket } mw-ext-${ name }`, state );
			}
			if ( quote ) {
				if ( stream.eat( quote[ 0 ] ) ) {
					state.tokenize = this.eatExtTagAttribute(
						name,
						quote[ 1 ],
						quote[ 1 ] && isPage
					);
					return this.makeLocalStyle( mwModeConfig.tags.extTagAttributeValue, state );
				}
				stream.match( new RegExp( `^(?:[^>/${ quote[ 0 ] }]|/(?!>))+` ) );
				return this.makeLocalStyle( valueStyle, state );
			}
			if ( quote === '' ) {
				if ( stream.sol() || /\s/.test( stream.peek() ) ) {
					state.tokenize = this.eatExtTagAttribute( name );
					return '';
				}
				stream.match( /^(?:[^>/\s]|\/(?!>))+/ );
				return this.makeLocalStyle( valueStyle, state );
			}
			if ( stream.match( /^=\s*/ ) ) {
				const next = stream.peek();
				state.tokenize = this.eatExtTagAttribute( name, next === '"' || next === "'" ? next.repeat( 2 ) : '', isPage );
				return this.makeLocalStyle( style, state );
			}
			const mt = stream.match( /^(?:[^>/=]|\/(?!>))+/ );
			if ( stream.peek() === '=' && name === 'templatestyles' && /(?:^|\s)src\s*$/i.test( mt[ 0 ] ) ) {
				state.tokenize = this.eatExtTagAttribute( name, undefined, true );
			}
			return this.makeLocalStyle( style, state );
		};
	}

	eatExtTagArea( name ) {
		return ( stream, state ) => {
			const from = stream.pos,

				pattern = new RegExp( `</${ name }\\s*>`, 'i' ),
				m = pattern.exec( from ? stream.string.slice( from ) : stream.string );
			let origString = false,
				to;

			if ( m ) {
				if ( m.index === 0 ) {
					state.tokenize = this.eatExtCloseTag( name );
					state.extName = false;
					if ( state.extMode !== false ) {
						state.extMode = false;
						state.extState = false;
					}
					return state.tokenize( stream, state );
				}
				to = m.index + from;
				origString = stream.string;
				stream.string = origString.slice( 0, to );
			}

			state.stack.push( state.tokenize );
			state.tokenize = this.eatExtTokens( origString );
			return state.tokenize( stream, state );
		};
	}

	eatExtCloseTag( name ) {
		return ( stream, state ) => {
			stream.next(); // eat <
			stream.next(); // eat /
			state.tokenize = this.eatTagName( name.length, true, false );
			return this.makeLocalStyle( `${ mwModeConfig.tags.extTagBracket } mw-ext-${ name }`, state );
		};
	}

	eatExtTokens( origString ) {
		const tokenize = ( stream, state ) => {
			let ret;
			if ( state.extMode === false ) {
				ret = mwModeConfig.tags.extTag;
				stream.skipToEnd();
			} else {
				ret = `mw-tag-${ state.extName } ` +
					state.extMode.token( stream, state.extState, origString === false );
			}
			if ( stream.eol() ) {
				if ( origString !== false ) {
					stream.string = origString;
				}
				state.tokenize = state.stack.pop();
			}
			return this.makeLocalStyle( ret, state );
		};
		Object.defineProperty( tokenize, 'name', { value: 'eatExtTokens' } );
		return tokenize;
	}

	eatStartTable( stream, state ) {
		stream.match( '{|' );
		stream.eatSpace();
		state.tokenize = this.inTableDefinition();
		return mwModeConfig.tags.tableBracket;
	}

	inTableDefinition( quote ) {
		const style = mwModeConfig.tags[ quote === undefined ? 'tableDefinition' : 'tableDefinitionValue' ];
		return ( stream, state ) => {
			if ( stream.sol() ) {
				state.tokenize = this.inTable.bind( this );
				return this.inTable( stream, state );
			} else if ( stream.match( /^[&{<]/, false ) ) {
				return this.eatWikiText( style )( stream, state );
			} else if ( quote ) {
				if ( stream.eat( quote[ 0 ] ) ) {
					state.tokenize = this.inTableDefinition( quote[ 1 ] );
				} else {
					stream.match( new RegExp( `^[^&{<${ quote[ 0 ] }]+` ) );
				}
				return this.makeLocalStyle( style, state );
			} else if ( quote === '' ) {
				if ( /\s/.test( stream.peek() ) ) {
					state.tokenize = this.inTableDefinition();
					return '';
				}
				stream.match( /^[^\s&{<]+/ );
				return this.makeLocalStyle( style, state );
			} else if ( stream.match( /^=\s*/ ) ) {
				const next = stream.peek();
				state.tokenize = this.inTableDefinition( next === '"' || next === "'" ? next.repeat( 2 ) : '' );
				return this.makeLocalStyle( style, state );
			}
			stream.match( /^[^&{<=]+/ );
			return this.makeLocalStyle( style, state );
		};
	}

	inTable( stream, state ) {
		if ( stream.sol() ) {
			stream.eatSpace();
			if ( stream.eat( '|' ) ) {
				if ( stream.eat( '-' ) ) {
					stream.eatSpace();
					state.tokenize = this.inTableDefinition();
					return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
				}
				if ( stream.eat( '+' ) ) {
					stream.eatSpace();
					state.tokenize = this.eatTableRow( true, false, true );
					return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
				}
				if ( stream.eat( '}' ) ) {
					state.tokenize = state.stack.pop();
					return this.makeLocalStyle( mwModeConfig.tags.tableBracket, state );
				}
				stream.eatSpace();
				state.tokenize = this.eatTableRow( true, false );
				return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
			}
			if ( stream.eat( '!' ) ) {
				stream.eatSpace();
				state.tokenize = this.eatTableRow( true, true );
				return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
			}
		}
		return this.eatWikiText( '' )( stream, state );
	}

	// isStart actually means whether there may be attributes */
	eatTableRow( isStart, isHead, isCaption ) {
		let tag = '';
		if ( isCaption ) {
			tag = mwModeConfig.tags.tableCaption;
		} else if ( isHead ) {
			tag = mwModeConfig.tags.strong;
		}
		return ( stream, state ) => {
			if ( stream.sol() ) {
				if ( stream.match( /^\s*[|!]/, false ) ) {
					state.tokenize = this.inTable.bind( this );
					return this.inTable( stream, state );
				}
			} else {
				if ( stream.match( /^[^'|{[<&~!-]+/ ) ) {
					return this.makeStyle( tag, state );
				}
				if ( stream.match( '||' ) || ( isHead && stream.match( '!!' ) ) ) {
					state.bold = false;
					state.italic = false;
					state.tokenize = this.eatTableRow( true, isHead, isCaption );
					return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
				}
				if ( isStart && stream.eat( '|' ) ) {
					state.bold = false;
					state.italic = false;
					state.tokenize = this.eatTableRow( false, isHead, isCaption );
					return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
				}
			}
			return this.eatWikiText( tag )( stream, state );
		};
	}

	eatFreeExternalLinkProtocol( stream, state ) {
		stream.match( this.urlProtocols );
		state.tokenize = this.eatFreeExternalLink.bind( this );
		return this.makeLocalStyle( mwModeConfig.tags.freeExtLinkProtocol, state );
	}

	eatFreeExternalLink( stream, state ) {
		if ( stream.sol() ) {
			// @todo error message
		} else if ( stream.match( /^[^\s{[\]<>~).,']*/ ) ) {
			if ( stream.peek() === '~' ) {
				if ( !stream.match( /^~~~+/, false ) ) {
					stream.match( /^~*/ );
					return this.makeLocalStyle( mwModeConfig.tags.freeExtLink, state );
				}
			} else if ( stream.peek() === '{' ) {
				if ( !stream.match( '{{', false ) ) {
					stream.next();
					return this.makeLocalStyle( mwModeConfig.tags.freeExtLink, state );
				}
			} else if ( stream.peek() === '\'' ) {
				if ( !stream.match( '\'\'', false ) ) {
					stream.next();
					return this.makeLocalStyle( mwModeConfig.tags.freeExtLink, state );
				}
			} else if ( stream.match( /^[).,]+(?=[^\s{[\]<>~).,])/ ) ) {
				return this.makeLocalStyle( mwModeConfig.tags.freeExtLink, state );
			}
		}
		state.tokenize = state.stack.pop();
		return this.makeLocalStyle( mwModeConfig.tags.freeExtLink, state );
	}

	eatList( stream, state ) {
		// Just consume all nested list and indention syntax when there is more
		const mt = stream.match( /^[*#;:]*/ );
		if ( mt && !this.isNested( state ) && mt[ 0 ].includes( ';' ) ) {
			state.nDt += mt[ 0 ].split( ';' ).length - 1;
		}
		return this.makeLocalStyle( mwModeConfig.tags.list, state );
	}

	inConversion( style, needFlag, needLang ) {
		const variants = this.languageVariants.join( '|' ),
			semicolon = new RegExp( `^;\\s*(?=(?:[^;]*?=>\\s*)?(?:${ variants })\\s*:|(?:$|\\}-))`, 'i' ),
			lang = new RegExp( `^(?:=>\\s*)?(?:${ variants })\\s*:`, 'i' );
		return ( stream, state ) => {
			const space = stream.eatSpace();
			if ( stream.match( '}-' ) ) {
				state.tokenize = state.stack.pop();
				return this.makeLocalStyle( mwModeConfig.tags.conversionBracket, state );
			} else if ( needFlag && stream.match( /^(?:[^}|={[<_])*(?=\|)/ ) ) {
				state.stack.push( this.inConversion( style, false, true ) );
				state.tokenize = this.eatChar( '|', mwModeConfig.tags.conversionDelimiter );
				return this.makeLocalStyle( mwModeConfig.tags.conversionFlag, state );
			} else if ( stream.match( semicolon ) ) {
				if ( needLang || !needFlag ) {
					state.tokenize = this.inConversion( style, false, true );
				}
				return this.makeLocalStyle( mwModeConfig.tags.conversionDelimiter, state );
			} else if ( needLang && stream.match( lang ) ) {
				state.tokenize = this.inConversion( style );
				return this.makeLocalStyle( mwModeConfig.tags.conversionLang, state );
			} else if ( stream.match( /^(?:[^}|;&='{[<~_-])+/ ) || space ) {
				return this.makeStyle( style, state );
			}
			return this.eatWikiText( style )( stream, state );
		};
	}

	/**
	 * @param {string} style
	 * @return {string|Function}
	 * @private
	 */
	eatWikiText( style ) {
		return ( stream, state ) => {
			if ( stream.eol() ) {
				return '';
			}
			let ch, tmp, mt, name, isCloseTag, tagname;
			const sol = stream.sol();

			function chain( parser ) {
				state.stack.push( state.tokenize );
				state.tokenize = parser;
				return parser( stream, state );
			}

			if ( sol ) {
				if ( state.sof ) {
					if ( stream.match( /^\s+$/ ) ) {
						return '';
					}
					state.sof = false;
					const mtRedirect = stream.match( this.redirectRegex );
					if ( mtRedirect ) {
						return mwModeConfig.tags.redirect;
					}
				}
				// highlight free external links, see T108448
				if ( !stream.match( '//', false ) && stream.match( this.urlProtocols ) ) {
					state.stack.push( state.tokenize );
					state.tokenize = this.eatFreeExternalLink.bind( this );
					return this.makeLocalStyle( mwModeConfig.tags.freeExtLinkProtocol, state );
				}
				ch = stream.next();
				switch ( ch ) {
					case '-':
						if ( stream.match( /^---+/ ) ) {
							return mwModeConfig.tags.hr;
						}
						break;
					case '=':

						tmp = stream.match( /^(={0,5})(.+?(=\1\s*)(<!--(?!.*-->.*\S).*?)?)$/ );
						// Title
						if ( tmp ) {
							stream.backUp( tmp[ 2 ].length );
							state.stack.push( state.tokenize );
							state.tokenize = this.eatSectionHeader( tmp[ 3 ].length );
							return mwModeConfig.tags.sectionHeader + ' ' +
								/**
								 * Tokens used here include:
								 * - cm-mw-section-1
								 * - cm-mw-section-2
								 * - cm-mw-section-3
								 * - cm-mw-section-4
								 * - cm-mw-section-5
								 * - cm-mw-section-6
								 */
								mwModeConfig.tags[ `sectionHeader${ tmp[ 1 ].length + 1 }` ];
						}
						break;
					case ';':
						stream.backUp( 1 );
					// fall through
					case '*':
					case '#':
						return this.eatList( stream, state );
					case ':':
						// Highlight indented tables :{|, bug T108454
						if ( stream.match( /^:*\s*(?={\|)/ ) ) {
							state.stack.push( state.tokenize );
							state.tokenize = this.eatStartTable.bind( this );
							return mwModeConfig.tags.indenting;
						}
						return this.eatList( stream, state );
					case ' ':
						// Leading spaces is valid syntax for tables, bug T108454
						if ( stream.match( /^\s*(?::+\s*)?{\|/, false ) ) {
							stream.eatSpace();
							if ( stream.match( /^:+/ ) ) { // ::{|
								stream.eatSpace();
								state.stack.push( state.tokenize );
								state.tokenize = this.eatStartTable.bind( this );
								return mwModeConfig.tags.indenting;
							}
							stream.eat( '{' );
						} else {
							return mwModeConfig.tags.skipFormatting;
						}
					// break is not necessary here
					// falls through
					case '{':
						if ( stream.eat( '|' ) ) {
							stream.eatSpace();
							state.stack.push( state.tokenize );
							state.tokenize = this.inTableDefinition();
							return mwModeConfig.tags.tableBracket;
						}
				}
			} else {
				ch = stream.next();
			}

			switch ( ch ) {
				case '&':
					return this.makeStyle(
						this.eatHtmlEntity( stream, style ),
						state
					);
				case '\'':
					// skip the irrelevant apostrophes ( >5 or =4 )
					if ( stream.match( /^'*(?=''''')/ ) || stream.match( /^'''(?!')/, false ) ) {
						break;
					}
					if ( stream.match( '\'\'' ) ) { // bold
						if ( !( state.data.firstSingleLetterWord || stream.match( '\'\'', false ) ) ) {
							this.prepareItalicForCorrection( stream, state );
						}
						state.bold = !state.bold;
						return this.makeLocalStyle( mwModeConfig.tags.apostrophes, state );
					} else if ( stream.eat( '\'' ) ) { // italic
						state.italic = !state.italic;
						return this.makeLocalStyle( mwModeConfig.tags.apostrophes, state );
					}
					break;
				case '[':
					if ( stream.eat( '[' ) ) { // Link Example: [[ Foo | Bar ]]
						stream.eatSpace();
						if ( /[^\]|[]/.test( stream.peek() ) ) {
							state.nLink++;
							state.stack.push( state.tokenize );
							state.tokenize = this.inLink( stream.match( fileNsRegex, false ) );
							return this.makeLocalStyle( mwModeConfig.tags.linkBracket, state );
						}
					} else {
						mt = stream.match( this.urlProtocols );
						if ( mt ) {
							state.nExtLink++;
							stream.backUp( mt[ 0 ].length );
							state.stack.push( state.tokenize );
							state.tokenize = this.eatExternalLinkProtocol( mt[ 0 ].length );
							return this.makeLocalStyle( mwModeConfig.tags.extLinkBracket, state );
						}
					}
					break;
				case '{':
					// Can't be a variable when it starts with more than 3 brackets (T108450) or
					// a single { followed by a template. E.g. {{{!}} starts a table (T292967).
					if ( stream.match( /^{{(?!{|[^{}]*}}(?!}))/ ) ) {
						stream.eatSpace();
						state.stack.push( state.tokenize );
						state.tokenize = this.inVariable.bind( this );
						return this.makeLocalStyle(
							mwModeConfig.tags.templateVariableBracket,
							state
						);
					} else if ( stream.match( /^{(?!{(?!{))\s*/ ) ) {
						const [ { length }, subst ] = stream.match( this.substRegex ),
							tokenizer = subst && this.inSubstitution( subst );
						// Parser function
						if ( stream.peek() === '#' ) {
							stream.backUp( length );
							state.nExt++;
							state.stack.push( state.tokenize );
							if ( tokenizer ) {
								state.stack.push( this.inParserFunctionName() );
								state.tokenize = tokenizer;
							} else {
								state.tokenize = this.inParserFunctionName();
							}
							return this.makeLocalStyle(
								mwModeConfig.tags.parserFunctionBracket,
								state
							);
						}
						// Check for parser function without '#'
						name = stream.match( /^([^}[\]<{|:]+)(.)?/, false );
						if ( name ) {
							const [ , f, delimiter ] = name,
								ff = delimiter === ':' ? f : f.trim(),
								ffLower = ff.toLowerCase(),
								{ config: { functionSynonyms, variableIDs } } = this,
								insensitiveName = Object.prototype.hasOwnProperty.call(
									functionSynonyms[ 0 ], ffLower
								) && functionSynonyms[ 0 ][ ffLower ],
								sensitiveName = Object.prototype.hasOwnProperty.call(
									functionSynonyms[ 1 ], ff
								) && functionSynonyms[ 1 ][ ff ],
								canonicalName = insensitiveName || sensitiveName;
							if (
								( !delimiter || delimiter === ':' || delimiter === '}' ) &&
								canonicalName &&
								( delimiter === ':' || variableIDs.includes( canonicalName ) )
							) {
								stream.backUp( length );
								state.nExt++;
								state.stack.push( state.tokenize );
								if ( tokenizer ) {
									state.stack.push( this.inParserFunctionName() );
									state.tokenize = tokenizer;
								} else {
									state.tokenize = this.inParserFunctionName();
								}
								return this.makeLocalStyle(
									mwModeConfig.tags.parserFunctionBracket,
									state
								);
							}
						}
						// Template
						stream.backUp( length );
						state.nTemplate++;
						state.stack.push( state.tokenize );
						if ( tokenizer ) {
							state.stack.push( this.eatTemplatePageName( false ) );
							state.tokenize = tokenizer;
						} else {
							state.tokenize = this.eatTemplatePageName( false );
						}
						return this.makeLocalStyle( mwModeConfig.tags.templateBracket, state );
					}
					break;
				case '<':
					if ( stream.match( '!--' ) ) { // comment
						return chain( this.eatBlock( mwModeConfig.tags.comment, '-->' ) );
					}
					isCloseTag = !!stream.eat( '/' );
					tagname = stream.match( /^[a-z][^>/\s]*/i );
					if ( tagname ) {
						tagname = tagname[ 0 ].toLowerCase();
						if ( tagname in this.config.tags ) {
							// Parser function
							if ( isCloseTag === true ) {
								return mwModeConfig.tags.error;
							}
							stream.backUp( tagname.length );
							state.stack.push( state.tokenize );
							state.tokenize = this.eatTagName( tagname.length, isCloseTag, false );
							return this.makeLocalStyle( `${ mwModeConfig.tags.extTagBracket } mw-ext-${ tagname }`, state );
						}
						if ( tagname in mwModeConfig.permittedHtmlTags ) {
							// Html tag
							if ( isCloseTag === true && tagname !== state.inHtmlTag.pop() ) {
								// Increment position so that the closing '>' gets highlighted red.
								stream.pos++;
								return mwModeConfig.tags.error;
							}
							if (
								isCloseTag === true &&
								tagname in mwModeConfig.implicitlyClosedHtmlTags
							) {
								return mwModeConfig.tags.error;
							}
							stream.backUp( tagname.length );
							state.stack.push( state.tokenize );
							state.tokenize = this.eatTagName(
								tagname.length,
								// Opening void tags should also be treated as the closing tag.
								isCloseTag ||
								( tagname in mwModeConfig.implicitlyClosedHtmlTags ),
								true
							);
							return this.makeLocalStyle( mwModeConfig.tags.htmlTagBracket, state );
						}
						stream.backUp( tagname.length );
					}
					break;
				case '~':
					if ( stream.match( /^~{2,4}/ ) ) {
						return mwModeConfig.tags.signature;
					}
					break;
				// Maybe double underscored Magic Word such as __TOC__
				case '_':
					tmp = 1;
					// Optimize processing of many underscore symbols
					while ( stream.eat( '_' ) ) {
						tmp++;
					}
					// Many underscore symbols
					if ( tmp > 2 ) {
						if ( !stream.eol() ) {
							// Leave last two underscore symbols for processing in next iteration
							stream.backUp( 2 );
						}
						// Optimization: skip regex function for EOL and backup-ed symbols
						return this.makeStyle( style, state );
						// Check on double underscore Magic Word
					} else if ( tmp === 2 ) {
						// The same as the end of function except '_' inside and '__' at the end.
						name = stream.match( /^([^\s>}[\]<{'|&:~]+?)__/ );
						if ( name && name[ 0 ] ) {
							if (
								'__' + name[ 0 ].toLowerCase() in this.config.doubleUnderscore[ 0 ] ||
								'__' + name[ 0 ] in this.config.doubleUnderscore[ 1 ]
							) {
								return mwModeConfig.tags.doubleUnderscore;
							}
							if ( !stream.eol() ) {
								// Two underscore symbols at the end can be the
								// beginning of another double underscored Magic Word
								stream.backUp( 2 );
							}
							// Optimization: skip regex for EOL and backup-ed symbols
							return this.makeStyle( style, state );
						}
					}
					break;
				case ':':
					if ( state.nDt > 0 && !this.isNested( state ) ) {
						state.nDt--;
						return mwModeConfig.tags.indenting;
					}
					break;
				case '-':
					if ( this.languageVariants.length && stream.match( /^\{(?!\{)\s*/ ) ) {
						state.stack.push( state.tokenize );
						state.tokenize = this.inConversion( style, true, true );
						return this.makeLocalStyle( mwModeConfig.tags.conversionBracket, state );
					}
					break;
				default:
					if ( /\s/.test( ch ) ) {
						stream.eatSpace();
						// highlight free external links, bug T108448
						if ( stream.match( this.urlProtocols, false ) && !stream.match( '//' ) ) {
							state.stack.push( state.tokenize );
							state.tokenize = this.eatFreeExternalLinkProtocol.bind( this );
							return this.makeStyle( style, state );
						}
					}
					break;
			}
			stream.match( /^[^\s\u00a0_>}[\]<{'|&:~=-]+/ );
			return this.makeStyle( style, state );
		};
	}

	/**
	 * Remembers position and status for rollbacking.
	 * It is needed for changing from bold to italic with apostrophes before it, if required.
	 *
	 * @see https://phabricator.wikimedia.org/T108455
	 *
	 * @param {StringStream} stream
	 * @param {Object} state
	 * @private
	 */
	prepareItalicForCorrection( stream, state ) {
		// See Parser::doQuotes() in MediaWiki Core, it works similarly.
		// firstSingleLetterWord has maximum priority
		// firstMultiLetterWord has medium priority
		// firstSpace has low priority
		const end = stream.pos,
			str = stream.string.slice( 0, end - 3 ),
			x1 = str.slice( -1 ),
			x2 = str.slice( -2, -1 );

		// firstSingleLetterWord always is undefined here
		if ( x1 === ' ' ) {
			if ( state.data.firstMultiLetterWord || state.data.firstSpace ) {
				return;
			}
			state.data.firstSpace = end;
		} else if ( x2 === ' ' ) {
			state.data.firstSingleLetterWord = end;
		} else if ( state.data.firstMultiLetterWord ) {
			return;
		} else {
			state.data.firstMultiLetterWord = end;
		}
		state.data.mark = end;
	}

	/**
	 * @see https://codemirror.net/docs/ref/#language.StreamParser
	 * @type {StreamParser}
	 * @private
	 */
	get parser() {
		return {
			name: 'mediawiki',

			/**
			 * Initial State for the parser.
			 *
			 * @return {Object}
			 * @private
			 */
			startState: () => startState( this.eatWikiText( '' ) ),

			/**
			 * Copies the given state.
			 *
			 * @param {Object} state
			 * @return {Object}
			 * @private
			 */
			copyState,

			/**
			 * Reads one token, advancing the stream past it,
			 * and returning a string indicating the token's style tag.
			 *
			 * @param {StringStream} stream
			 * @param {Object} state
			 * @return {string|null}
			 * @private
			 */
			token: ( stream, state ) => {
				const { data } = state,
					{ readyTokens } = data;
				let { oldToken } = data;
				while ( oldToken && (
					// If the start of PartialParse is after the current position
					stream.pos > oldToken.pos ||
					stream.pos === oldToken.pos && state.tokenize !== oldToken.state.tokenize
				) ) {
					oldToken = readyTokens.shift();
				}
				if ( // check the start
					oldToken &&
					stream.pos === oldToken.pos &&
					stream.string === oldToken.string
				) {
					const { pos, string, state: other, style } = readyTokens[ 0 ];
					for ( const key in other ) {
						if ( key !== 'bold' && key !== 'italic' ) {
							state[ key ] = other[ key ];
						}
					}
					if (
						!( state.extName && state.extMode ) &&
						state.nLink === 0 &&
						typeof style === 'string' &&
						style.includes( mwModeConfig.tags.apostrophes )
					) {
						if ( data.mark === pos ) {
							// rollback
							data.mark = null;
							// add one apostrophe, next token will be italic (two apostrophes)
							stream.string = string.slice( 0, pos - 2 );
							const s = state.tokenize( stream, state );
							stream.string = string;
							oldToken.pos++;
							data.oldToken = oldToken;
							return this.makeFullStyle( s, state );
						}
						const length = pos - stream.pos;
						if ( length !== 3 ) {
							state.italic = !state.italic;
						}
						if ( length !== 2 ) {
							state.bold = !state.bold;
						}
					} else if ( typeof style === 'string' && style.includes( mwModeConfig.tags.tableDelimiter ) ) {
						state.bold = false;
						state.italic = false;
					}
					// return first saved token
					data.oldToken = readyTokens.shift();
					stream.pos = pos;
					stream.string = string;
					return this.makeFullStyle( style, state );
				} else if ( stream.sol() ) {
					// reset bold and italic status in every new line
					state.bold = false;
					state.italic = false;
					state.nDt = 0;
					data.firstSingleLetterWord = null;
					data.firstMultiLetterWord = null;
					data.firstSpace = null;
					if ( state.tokenize.name === 'eatExtTokens' ) {
						state.stack.pop(); // dispose eatExtTokens
						state.tokenize = state.stack.pop(); // dispose eatExtTagArea
						state.extName = false;
						state.extMode = false;
						state.extState = false;
					}
				}
				readyTokens.length = 0;
				data.mark = null;
				data.oldToken = { pos: stream.pos, string: stream.string, state: copyState( state ), style: '' };
				const { start } = stream;
				do {
					// get token style
					stream.start = stream.pos;
					const st = state.tokenize( stream, state );
					// save token
					readyTokens.push( {
						pos: stream.pos,
						string: stream.string,
						state: copyState( state ),
						style: st
					} );
				} while ( !stream.eol() );
				if ( !state.bold || !state.italic ) {
					// no need to rollback
					data.mark = null;
				}
				stream.start = start;
				stream.pos = data.oldToken.pos;
				stream.string = data.oldToken.string;
				Object.assign( state, data.oldToken.state );
				return '';
			},

			/**
			 * @param {Object} state
			 * @private
			 */
			blankLine: ( state ) => {
				if ( state.extMode && state.extMode.blankLine ) {
					state.extMode.blankLine( state.extState );
				}
			},

			/**
			 * Extra tokens to use in this parser.
			 *
			 * @see CodeMirrorMediaWikiConfig.defaultTokenTable
			 * @return {Object<Tag>}
			 * @private
			 */
			tokenTable: this.tokenTable,

			/**
			 * @see https://codemirror.net/docs/ref/#language.StreamParser.languageData
			 * @return {Object}
			 * @private
			 */
			languageData: {
				autocomplete: completionSource( this ),
				closeBrackets: {
					brackets: [ '(', '[', '{', '"' ],
					before: ')]}>'
				}
			}
		};
	}
}

/**
 * @module ext.CodeMirror.mode.mediawiki
 * @description
 * This module provides syntax highlighting for the MediaWiki mode in CodeMirror.
 * @example
 * const require = await mw.loader.using( [ 'ext.CodeMirror', 'ext.CodeMirror.mode.mediawiki' ] );
 * const CodeMirror = require( 'ext.CodeMirror' );
 * const { mediawiki } = require( 'ext.CodeMirror.mode.mediawiki' );
 * const cm = new CodeMirror( myTextarea, mediawiki() );
 * cm.initialize();
 */

let handler;

/**
 * Gets a LanguageSupport-like instance for the MediaWiki mode.
 *
 * @param {Object} [config] Configuration options for the MediaWiki mode.
 * @param {boolean} [config.bidiIsolation=false] Enable bidi isolation around HTML tags.
 *   This should generally always be enabled on RTL pages, but it comes with a performance cost.
 * @param {boolean} [config.codeFolding=true] Enable code folding.
 * @param {boolean} [config.foldAllRefs=false] Fold all references on initial load.
 * @param {boolean} [config.autocomplete=true] Enable autocompletion.
 * @param {boolean} [config.openLinks=true] Enable opening of links.
 * @param {boolean} [config.highlightRefs=true] Highlight references.
 * @param {boolean} [config.closeTags=true] Enable auto-closing of HTML tags.
 * @param {string[]} [config.languageVariants] Language variants that should be supported.
 * @return {CodeMirrorMediaWiki|LanguageSupport}
 * @stable to call
 */
const mediawiki = ( config = { bidiIsolation: false } ) => {
	const mwConfig = mw.config.get( 'extCodeMirrorConfig' );
	// Register MW-specific Extensions into CodeMirror preferences. Whether they are enabled
	// or not is determined by the user's preferences and wiki configuration.
	if ( handler ) {
		mw.hook( 'ext.CodeMirror.ready' ).remove( handler );
	}

	handler = ( cm ) => {
		// T380840
		if ( !cm.view ) {
			return;
		}

		// Register MW-specific keymaps.
		mwKeymap( cm );

		// Add reference-related preferences to a dedicated section.
		if ( mwConfig.tags.ref ) {
			cm.preferences.dialogConfig.references = [ 'highlightRefs', 'foldAllRefs' ];

			if ( config.highlightRefs !== false ) {
				const highlightRefsExtension = EditorView.theme( {
					'.cm-mw-tag-ref': {
						backgroundColor: 'var( --background-color-refs )'
					}
				} );
				cm.preferences.registerExtension(
					'highlightRefs',
					highlightRefsExtension,
					cm.view
				);
			}

			if ( config.foldAllRefs !== false ) {
				cm.preferences.registerCallback( 'foldAllRefs', ( enabled ) => {
					if ( enabled ) {
						foldAllRefs( cm.view );
					}
				}, cm.view, true );
			}
		}

		if ( config.codeFolding !== false ) {
			cm.preferences.registerExtension( 'codeFolding', codeFoldingExtension, cm.view );
			if ( mwConfig.tags.ref ) {
				cm.keymap.registerKeyBindingHelp(
					'codeFolding',
					'foldRef',
					{
						key: 'Mod-Alt-,',
						run: () => foldAllRefs( cm.view ),
						slow: true
					},
					cm.view
				);
			}
		}
		if ( config.autocomplete !== false ) {
			cm.preferences.registerExtension( 'autocomplete', autocompleteExtension, cm.view );
		}
		if ( config.openLinks !== false ) {
			cm.preferences.registerExtension( 'openLinks', openLinksExtension, cm.view );
		}
		if ( config.closeTags !== false ) {
			cm.preferences.registerExtension( 'closeTags', closeTagsExtension, cm.view );
		}
		if ( config.bidiIsolation ) {
			cm.preferences.registerExtension( 'bidiIsolation', bidiIsolationExtension, cm.view );
		}
	};
	mw.hook( 'ext.CodeMirror.ready' ).add( handler );

	return new CodeMirrorMediaWiki( mwConfig );
};

module.exports = mediawiki;
module.exports.mediawiki = mediawiki;
module.exports.CodeMirrorMediaWiki = CodeMirrorMediaWiki;
