const {
	HighlightStyle,
	LanguageSupport,
	StreamLanguage,
	StreamParser,
	StringStream,
	Tag,
	syntaxHighlighting
} = require( 'ext.CodeMirror.v6.lib' );
const mwModeConfig = require( './codemirror.mediawiki.config.js' );
const bidiIsolationExtension = require( './codemirror.mediawiki.bidiIsolation.js' );
const templateFoldingExtension = require( './codemirror.mediawiki.templateFolding.js' );

/**
 * MediaWiki language support for CodeMirror 6.
 * Adapted from the original CodeMirror 5 stream parser by Pavel Astakhov.
 *
 * @module CodeMirrorModeMediaWiki
 *
 * @example
 * mw.loader.using( [
 *   'ext.CodeMirror.v6',
 *   'ext.CodeMirror.v6.mode.mediawiki'
 * ] ).then( ( require ) => {
 *   const CodeMirror = require( 'ext.CodeMirror.v6' );
 *   const mediawikiLang = require( 'ext.CodeMirror.v6.mode.mediawiki' );
 *   const cm = new CodeMirror( myTextarea );
 *   cm.initialize( [ cm.defaultExtensions, mediawikiLang() ] );
 * } );
 */
class CodeMirrorModeMediaWiki {
	/**
	 * @param {Object} config MediaWiki configuration as generated by DataScript.php
	 * @internal
	 */
	constructor( config ) {
		this.config = config;

		this.urlProtocols = new RegExp( `^(?:${ this.config.urlProtocols })(?=[^\\s\u00a0{[\\]<>~).,'])`, 'i' );
		this.isBold = false;
		this.wasBold = false;
		this.isItalic = false;
		this.wasItalic = false;
		this.firstSingleLetterWord = null;
		this.firstMultiLetterWord = null;
		this.firstSpace = null;
		this.oldStyle = null;
		this.tokens = [];
		this.oldTokens = [];
		this.tokenTable = mwModeConfig.tokenTable;
		this.registerGroundTokens();

		// Dynamically register any tags that aren't already in CodeMirrorModeMediaWikiConfig
		Object.keys( this.config.tags ).forEach( ( tag ) => mwModeConfig.addTag( tag ) );
	}

	/**
	 * 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;
	}

	makeStyle( style, state, endGround ) {
		if ( this.isBold || state.nDt > 0 ) {
			style += ' ' + mwModeConfig.tags.strong;
		}
		if ( this.isItalic ) {
			style += ' ' + mwModeConfig.tags.em;
		}
		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 ) {
			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 );
	}

	inParserFunctionName( stream, state ) {
		// FIXME: {{#name}} and {{uc}} are wrong, must have ':'
		if ( stream.match( /^#?[^:}{~]+/ ) ) {
			return this.makeLocalStyle( mwModeConfig.tags.parserFunctionName, state );
		}
		if ( stream.eat( ':' ) ) {
			state.tokenize = this.inParserFunctionArguments.bind( this );
			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( stream, state ) {
		if ( stream.match( /^[^|}{[<&~]+/ ) ) {
			return this.makeLocalStyle( mwModeConfig.tags.parserFunction, state );
		} else if ( stream.eat( '|' ) ) {
			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( mwModeConfig.tags.parserFunction )( stream, state );
	}

	eatTemplatePageName( haveAte ) {
		return ( stream, state ) => {
			if ( stream.match( /^[\s\u00a0]*\|[\s\u00a0]*/ ) ) {
				state.tokenize = this.eatTemplateArgument( true );
				return this.makeLocalStyle( mwModeConfig.tags.templateDelimiter, state );
			}
			if ( stream.match( /^[\s\u00a0]*\}\}/ ) ) {
				state.tokenize = state.stack.pop();
				return this.makeLocalStyle( mwModeConfig.tags.templateBracket, state, 'nTemplate' );
			}
			if ( stream.match( /^[\s\u00a0]*<!--.*?-->/ ) ) {
				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\u00a0]*[^\s\u00a0|}<{&~]+/ ) ) {
				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.eatWhile( /[^=|}{[<&~]/ ) ) {
				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.nLink--;
				// @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.nLink--;
			// @todo error message
			state.tokenize = state.stack.pop();
			return;
		}
		if ( stream.match( /^[\s\u00a0]*\]/ ) ) {
			state.tokenize = state.stack.pop();
			return this.makeLocalStyle( mwModeConfig.tags.extLinkBracket, state, 'nLink' );
		}
		if ( stream.eatSpace() ) {
			state.tokenize = this.inExternalLinkText.bind( this );
			return this.makeStyle( '', state );
		}
		if ( stream.match( /^[^\s\u00a0\]{&~']+/ ) || 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.nLink--;
			// @todo error message
			state.tokenize = state.stack.pop();
			return;
		}
		if ( stream.eat( ']' ) ) {
			state.tokenize = state.stack.pop();
			return this.makeLocalStyle( mwModeConfig.tags.extLinkBracket, state, 'nLink' );
		}
		if ( stream.match( /^[^'\]{&~<]+/ ) ) {
			return this.makeStyle( mwModeConfig.tags.extLinkText, state );
		}
		return this.eatWikiText( mwModeConfig.tags.extLinkText )( stream, state );
	}

	inLink( stream, state ) {
		if ( stream.sol() ) {
			state.nLink--;
			// @todo error message
			state.tokenize = state.stack.pop();
			return;
		}
		if ( stream.match( /^[\s\u00a0]*#[\s\u00a0]*/ ) ) {
			state.tokenize = this.inLinkToSection.bind( this );
			return this.makeLocalStyle( mwModeConfig.tags.link, state );
		}
		if ( stream.match( /^[\s\u00a0]*\|[\s\u00a0]*/ ) ) {
			state.tokenize = this.eatLinkText();
			return this.makeLocalStyle( mwModeConfig.tags.linkDelimiter, state );
		}
		if ( stream.match( /^[\s\u00a0]*\]\]/ ) ) {
			state.tokenize = state.stack.pop();
			return this.makeLocalStyle( mwModeConfig.tags.linkBracket, state, 'nLink' );
		}
		if ( stream.match( /^[\s\u00a0]*[^\s\u00a0#|\]&~{]+/ ) || stream.eatSpace() ) {
			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() {
		let linkIsBold, linkIsItalic;
		return ( stream, state ) => {
			let tmpstyle;
			if ( stream.match( ']]' ) ) {
				state.tokenize = state.stack.pop();
				return this.makeLocalStyle( mwModeConfig.tags.linkBracket, state, 'nLink' );
			}
			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( /^[^'\]{&~<]+/ ) ) {
				return this.makeStyle( tmpstyle, state );
			}
			return this.eatWikiText( tmpstyle )( stream, state );
		};
	}

	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 ) {
		return ( stream, state ) => {

			if ( stream.match( /^(?:"[^<">]*"|'[^<'>]*'|[^>/<{&~])+/ ) ) {
				return this.makeLocalStyle( mwModeConfig.tags.htmlTagAttribute, 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 );
			}
			return this.eatWikiText( mwModeConfig.tags.htmlTagAttribute )( stream, state );
		};
	}

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

	eatExtTagAttribute( name ) {
		return ( stream, state ) => {

			if ( stream.match( /^(?:"[^">]*"|'[^'>]*'|[^>/<{&~])+/ ) ) {
				return this.makeLocalStyle( `${ mwModeConfig.tags.extTagAttribute } mw-ext-${ name }`, 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 in this.config.tagModes ) {
					const mode = this.config.tagModes[ name ];
					if ( mode === 'mediawiki' || mode === 'text/mediawiki' ) {
						state.extMode = this.mediawiki;
						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 );
			}
			return this.eatWikiText( `${ mwModeConfig.tags.extTagAttribute } mw-ext-${ name }` )( stream, 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 ) {
		return ( 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 );
		};
	}

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

	inTableDefinition( stream, state ) {
		if ( stream.sol() ) {
			state.tokenize = this.inTable.bind( this );
			return this.inTable( stream, state );
		}
		return this.eatWikiText( mwModeConfig.tags.tableDefinition )( stream, state );
	}

	inTable( stream, state ) {
		if ( stream.sol() ) {
			stream.eatSpace();
			if ( stream.eat( '|' ) ) {
				if ( stream.eat( '-' ) ) {
					stream.eatSpace();
					state.tokenize = this.inTableDefinition.bind( this );
					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\u00a0]*[|!]/, 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( '!!' ) ) ) {
					this.isBold = false;
					this.isItalic = false;
					state.tokenize = this.eatTableRow( true, isHead, isCaption );
					return this.makeLocalStyle( mwModeConfig.tags.tableDelimiter, state );
				}
				if ( isStart && stream.eat( '|' ) ) {
					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\u00a0{[\]<>~).,']*/ ) ) {
			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\u00a0{[\]<>~).,])/ ) ) {
				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( /^[*#;:]*/u );
		if ( mt && !this.isNested( state ) && mt[ 0 ].includes( ';' ) ) {
			state.nDt += mt[ 0 ].split( ';' ).length - 1;
		}
		return this.makeLocalStyle( mwModeConfig.tags.list, state );
	}

	/**
	 * @param {string} style
	 * @return {string|Function}
	 * @private
	 */
	eatWikiText( style ) {
		return ( stream, state ) => {
			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 ) {
				// 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\u00a0]*(?={\|)/ ) ) {
							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\u00a0]*(?::+[\s\u00a0]*)?{\|/, 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.bind( this );
							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 ( !( this.firstSingleLetterWord || stream.match( '\'\'', false ) ) ) {
							this.prepareItalicForCorrection( stream );
						}
						this.isBold = !this.isBold;
						return this.makeLocalStyle( mwModeConfig.tags.apostrophesBold, state );
					} else if ( stream.eat( '\'' ) ) { // italic
						this.isItalic = !this.isItalic;
						return this.makeLocalStyle( mwModeConfig.tags.apostrophesItalic, 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.bind( this );
							return this.makeLocalStyle( mwModeConfig.tags.linkBracket, state );
						}
					} else {
						mt = stream.match( this.urlProtocols );
						if ( mt ) {
							state.nLink++;
							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\u00a0]*/ ) ) {
						// Parser function
						if ( stream.peek() === '#' ) {
							state.nExt++;
							state.stack.push( state.tokenize );
							state.tokenize = this.inParserFunctionName.bind( this );
							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 } } = this;
							if (
								( !delimiter || delimiter === ':' || delimiter === '}' ) &&
								(
									Object.prototype.hasOwnProperty.call(
										functionSynonyms[ 0 ], ffLower
									) ||
									Object.prototype.hasOwnProperty.call(
										functionSynonyms[ 1 ], ff
									)
								)
							) {
								state.nExt++;
								state.stack.push( state.tokenize );
								state.tokenize = this.inParserFunctionName.bind( this );
								return this.makeLocalStyle(
									mwModeConfig.tags.parserFunctionBracket,
									state
								);
							}
						}
						// Template
						state.nTemplate++;
						state.stack.push( state.tokenize );
						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\u00a0]*/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\u00a0>}[\]<{'|&:~]+?)__/ );
						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;
				default:
					if ( /[\s\u00a0]/.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
	 * @private
	 */
	prepareItalicForCorrection( stream ) {
		// See Parser::doQuotes() in MediaWiki Core, it works similarly.
		// this.firstSingleLetterWord has maximum priority
		// this.firstMultiLetterWord has medium priority
		// this.firstSpace has low priority
		const end = stream.pos,
			str = stream.string.slice( 0, end - 3 ),
			x1 = str.slice( -1 ),
			x2 = str.slice( -2, -1 );

		// this.firstSingleLetterWord always is undefined here
		if ( x1 === ' ' ) {
			if ( this.firstMultiLetterWord || this.firstSpace ) {
				return;
			}
			this.firstSpace = end;
		} else if ( x2 === ' ' ) {
			this.firstSingleLetterWord = end;
		} else if ( this.firstMultiLetterWord ) {
			return;
		} else {
			this.firstMultiLetterWord = end;
		}
		// remember bold and italic state for later restoration
		this.wasBold = this.isBold;
		this.wasItalic = this.isItalic;
	}

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

			/**
			 * Initial State for the parser.
			 *
			 * @return {Object}
			 * @private
			 */
			startState: () => ( {
				tokenize: this.eatWikiText( '' ),
				stack: [],
				inHtmlTag: [],
				extName: false,
				extMode: false,
				extState: false,
				nTemplate: 0,
				nLink: 0,
				nExt: 0,
				nDt: 0
			} ),

			/**
			 * Copies the given state.
			 *
			 * @param {Object} state
			 * @return {Object}
			 * @private
			 */
			copyState: ( state ) => ( {
				tokenize: state.tokenize,
				stack: state.stack.concat( [] ),
				inHtmlTag: state.inHtmlTag.concat( [] ),
				extName: state.extName,
				extMode: state.extMode,
				extState: state.extMode !== false && state.extMode.copyState( state.extState ),
				nTemplate: state.nTemplate,
				nLink: state.nLink,
				nExt: state.nExt,
				nDt: state.nDt
			} ),

			/**
			 * 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 ) => {
				let style, p, t, f,
					readyTokens = [],
					tmpTokens = [];

				if ( this.oldTokens.length > 0 ) {
					// just send saved tokens till they exists
					t = this.oldTokens.shift();
					stream.pos = t.pos;
					state = t.state;
					return t.style;
				}

				if ( stream.sol() ) {
					// reset bold and italic status in every new line
					state.nDt = 0;
					this.isBold = false;
					this.isItalic = false;
					this.firstSingleLetterWord = null;
					this.firstMultiLetterWord = null;
					this.firstSpace = null;
				}

				do {
					// get token style
					style = state.tokenize( stream, state );
					f = this.firstSingleLetterWord || this.firstMultiLetterWord || this.firstSpace;
					if ( f ) {
						// rollback point exists
						if ( f !== p ) {
							// new rollback point
							p = f;
							// it's not first rollback point
							if ( tmpTokens.length > 0 ) {
								// save tokens
								readyTokens = readyTokens.concat( tmpTokens );
								tmpTokens = [];
							}
						}
						// save token
						tmpTokens.push( {
							pos: stream.pos,
							style,
							state: ( state.extMode || this.mediawiki ).copyState( state )
						} );
					} else {
						// rollback point does not exist
						// remember style before possible rollback point
						this.oldStyle = style;
						// just return token style
						return style;
					}
				} while ( !stream.eol() );

				if ( this.isBold && this.isItalic ) {
					// needs to rollback
					// restore status
					this.isItalic = this.wasItalic;
					this.isBold = this.wasBold;
					this.firstSingleLetterWord = null;
					this.firstMultiLetterWord = null;
					this.firstSpace = null;
					if ( readyTokens.length > 0 ) {
						// it contains tickets before the point of rollback
						// add one apostrophe, next token will be italic (two apostrophes)
						readyTokens[ readyTokens.length - 1 ].pos++;
						// for sending tokens till the point of rollback
						this.oldTokens = readyTokens;
					} else {
						// there are no tickets before the point of rollback
						stream.pos = tmpTokens[ 0 ].pos - 2; // eat( '\'')
						// send saved Style
						return this.oldStyle;
					}
				} else {
					// do not need to rollback
					// send all saved tokens
					this.oldTokens = readyTokens.concat( tmpTokens );
				}
				// return first saved token
				t = this.oldTokens.shift();
				stream.pos = t.pos;
				state = t.state;
				return t.style;
			},

			/**
			 * @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 CodeMirrorModeMediaWikiConfig.defaultTokenTable
			 * @return {Object<Tag>}
			 * @private
			 */
			tokenTable: this.tokenTable
		};
	}
}

/**
 * Gets a LanguageSupport instance for the MediaWiki mode.
 *
 * @member CodeMirrorModeMediaWiki
 * @method
 * @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.templateFolding=true] Enable template folding.
 * @param {Object|null} [mwConfig] Ignore; used only by unit tests.
 * @return {LanguageSupport}
 * @stable to call
 */
const mediaWikiLang = ( config = { bidiIsolation: false }, mwConfig = null ) => {
	mwConfig = mwConfig || mw.config.get( 'extCodeMirrorConfig' );
	const mode = new CodeMirrorModeMediaWiki( mwConfig );
	const parser = mode.mediawiki;
	const lang = StreamLanguage.define( parser );
	const langExtension = [ syntaxHighlighting(
		HighlightStyle.define(
			mwModeConfig.getTagStyles( parser )
		)
	) ];

	// Set to [] to disable everywhere, or null to enable everywhere.
	const templateFoldingNs = mwConfig.templateFoldingNamespaces;
	const shouldUseFolding = !templateFoldingNs || templateFoldingNs.includes( mw.config.get( 'wgNamespaceNumber' ) );
	// Add template folding if in supported namespace.
	if ( shouldUseFolding && ( config.templateFolding || config.templateFolding === undefined ) ) {
		langExtension.push( templateFoldingExtension );
	}

	// Bundle the bidi isolation extension, as it's coded specifically for MediaWiki.
	// This is behind a config option for performance reasons (we only use it on RTL pages).
	if ( config.bidiIsolation ) {
		langExtension.push( bidiIsolationExtension );
	}

	return new LanguageSupport( lang, langExtension );
};

module.exports = mediaWikiLang;
