/**
 * Edit check to detect generic text matches/replacements
 *
 * @class
 * @extends mw.editcheck.BaseEditCheck
 *
 * @constructor
 * @param {mw.editcheck.Controller} controller
 * @param {Object} [config]
 * @param {boolean} [includeSuggestions=false]
 */
mw.editcheck.TextMatchEditCheck = function MWTextMatchEditCheck() {
	// Parent constructor
	mw.editcheck.TextMatchEditCheck.super.apply( this, arguments );

	let lang = mw.config.get( 'wgContentLanguage' );
	if ( this.constructor.static.languageFallbacks[ lang ] ) {
		lang = this.constructor.static.languageFallbacks[ lang ];
	}
	try {
		lang = Intl.getCanonicalLocales( lang )[ 0 ];
	} catch ( e ) {
		mw.log.error( `TextMatchEditCheck falling back from invalid language '${ lang }'`, e );
		lang = navigator.language;
	}
	this.lang = lang;
	this.sensitivity = 'accent'; // TODO figure out how to determine this on an editcheck level
	this.collator = new Intl.Collator( this.lang, { sensitivity: this.sensitivity } );

	// Initialize lookup maps
	this.matchRules = [];

};

/* Inheritance */

OO.inheritClass( mw.editcheck.TextMatchEditCheck, mw.editcheck.BaseEditCheck );

/* Static properties */

mw.editcheck.TextMatchEditCheck.static.name = 'textMatch';

// Only show replacement preview if found text and replacement
// are below a certain length, to avoid UI issues with long text.
const replaceTextLengthLimit = 100;

/**
 * The configs of TextMatchEditCheck take priority over individual matchRule configs.
 * So we make TextMatch’s defaults nonrestrictive,
 * and let the finer limitations be handled by individual matchRules.
 */
mw.editcheck.TextMatchEditCheck.static.defaultConfig = ve.extendObject( {}, mw.editcheck.TextMatchEditCheck.super.static.defaultConfig, {
	showAsCheck: true,
	showAsSuggestion: true,
	maximumEditCount: false,
	minimumEditCount: false
} );

mw.editcheck.TextMatchEditCheck.static.choices = [
	{
		action: 'accept',
		label: OO.ui.deferMsg( 'editcheck-dialog-action-replace' ),
		modes: [ 'replace' ]
	},
	{
		action: 'delete',
		label: OO.ui.deferMsg( 'visualeditor-contextitemwidget-label-remove' ),
		modes: [ 'delete' ]
	},
	{
		action: 'dismiss',
		label: OO.ui.deferMsg( 'editcheck-action-dismiss' ),
		modes: [ '', 'info', 'replace', 'delete' ]
	}
];

/**
 * Map of known languages that might be in wgContentLanguage but aren't valid for Intl.Collator
 *
 * @type {Object}
 */
mw.editcheck.TextMatchEditCheck.static.languageFallbacks = {
	'map-bms': 'jv-x-bms'
};

/**
 * Object into which default matchRule configs can be placed
 *
 * This is largely a place for scripts to interact with the check
 *
 * @type {Object}
 */
mw.editcheck.TextMatchEditCheck.static.matchRules = {};

/**
 * Promise which holds the loading and processing of matchRules
 *
 * @type {Promise<Object>}
 */
mw.editcheck.TextMatchEditCheck.static.matchRulesPromise = null;

/**
 * Cache containing fully processed matchRules will all imports,
 * as well as any TextFinders created for them
 *
 * @type {Object}
 */
mw.editcheck.TextMatchEditCheck.static.matchCache = {
	rawMatchRules: null,
	memoizedFinders: {}
};

/**
 * Fetch corresponding MW file for any matchRules with the "import" property
 * and leave all other matchRules unchanged
 *
 * @param {Object|Array} rawMatchRules Map of matchRule IDs to raw config objects, or array (deprecated)
 * @return {Promise<Object>} Promise which resolves to map of processed matchRules
 */
mw.editcheck.TextMatchEditCheck.static.processMatchRules = function ( rawMatchRules ) {
	const processed = {};
	const pageMap = {};
	const filenames = [];

	Object.entries( rawMatchRules ).forEach( ( [ id, rule ] ) => {
		if ( rule.import ) {
			const importTitle = mw.Title.newFromText( rule.import );
			if ( !importTitle || importTitle.getNamespaceId() !== mw.config.get( 'wgNamespaceIds' ).mediawiki ) {
				mw.log.warn( `Skipped import for matchRule id:${ id } (${ rule.import } must be in mediawiki namespace.)` );
				return;
			}
			if ( !importTitle.getMainText().match( /^Editcheck-config-.+\.json$/ ) ) {
				mw.log.warn( `Skipped import for matchRule id:${ id } (${ rule.import } must be of the form Editcheck-config-<name>.json)` );
				return;
			}
			filenames.push( rule.import );
			pageMap[ id ] = rule.import;
		} else {
			processed[ id ] = rule;
		}
	} );
	if ( filenames.length === 0 ) {
		return Promise.resolve( processed );
	}
	return mw.editcheck.getMediaWikiJSON( filenames )
		.then( ( imported ) => {
			if ( imported ) {
				Object.entries( pageMap ).forEach( ( [ id, filename ] ) => {
					if ( imported.has( filename ) ) {
						processed[ id ] = imported.get( filename );
					}
				} );
			}
			return processed;
		} )
		.catch( ( err ) => {
			// If the api request fails entirely,
			// we'll log it but continue with the non-imported configs
			mw.log.error( ' Failed to import configs', err );
			return processed;
		} );
};

/**
 * Ensure matchRules and any imported configs are loaded exactly once per edit session
 *
 * @return {Promise<Object>} Promise which resolves to processed matchRules
 */
mw.editcheck.TextMatchEditCheck.static.ensureMatchRulesLoaded = function () {
	// If we've already started loading config, then every caller waits on same promise.
	if ( this.matchRulesPromise ) {
		return this.matchRulesPromise;
	}
	const rawMatchRules = Object.assign(
		{},
		mw.editcheck.TextMatchEditCheck.static.matchRules || {},
		// In T424678 we renamed matchItems to matchRules, but allow 'matchItems' for backwards compatibility temporarily
		ve.getProp( mw.editcheck.config, 'textMatch', 'matchRules' ) || ve.getProp( mw.editcheck.config, 'textMatch', 'matchItems' ) || {}
	);

	// Begin async processing and cache promise
	this.matchRulesPromise = this.processMatchRules( rawMatchRules )
		.then( ( processed ) => {
			const cache = {
				rawMatchRules: processed,
				memoizedFinders: {}
			};
			// Reset the cache when we get new matchRules
			this.matchCache = cache;
			return cache;
		} )
		.catch( ( err ) => {
			mw.log.error( 'Failed to process matchRules', err );
			this.matchRulesPromise = null;
		} );
	return this.matchRulesPromise;
};

/**
 * Create a prompt for a replacement dialog, if within length limits
 *
 * @param {string} foundText
 * @param {string} replacement
 * @return {string} prompt text
 */
mw.editcheck.TextMatchEditCheck.static.createReplacementPrompt = function ( foundText, replacement ) {
	if (
		replacement &&
		foundText.length <= replaceTextLengthLimit &&
		replacement.length <= replaceTextLengthLimit
	) {
		return ve.msg( 'editcheck-textmatch-replace', foundText, replacement );
	}
	return undefined;
};

/* Methods */

/**
 * Create a matchRule instance for each matchRule
 * NOTE: rawMatchRules should never be anything but this.constructor.static.matchCache.rawMatchRules
 *
 * @param {Object} rawMatchRules all matchRule objects from config
 */
mw.editcheck.TextMatchEditCheck.prototype.instantiateMatchRules = function ( rawMatchRules ) {
	// Create matchRule instances
	Object.entries( rawMatchRules ).forEach( ( [ id, rule ] ) => {
		try {
			const isValidMode = this.constructor.static.choices.some(
				( choice ) => choice.modes.includes( rule.mode )
			);
			rule.mode = isValidMode ? rule.mode : '';
			if ( !rule.expand ) {
				if ( ve.getProp( rule, 'config', 'minOccurrences' ) || rule.minOccurrences ) {
					mw.log.warn( 'MatchRule \'' + rule.title + '\' sets minOccurrences but is missing expand value.' );
				}
			}
			const textMatchRule = new mw.editcheck.TextMatchRule( rule, id, this.collator );
			this.matchRules.push( textMatchRule );
		} catch ( e ) {
			mw.log.error( `TextMatchEditCheck failed to instantiate rule '${ id }'`, e );
		}
	} );
};

/**
 * @param {ve.dm.SurfaceModel} surfaceModel
 * @param {string} listener
 * @return {Promise<mw.editcheck.TextMatchEditCheckAction[]>}
 */
mw.editcheck.TextMatchEditCheck.prototype.handleListener = function ( surfaceModel, listener ) {
	// wait here until matchRules are guaraunteed to exist!
	return this.constructor.static.ensureMatchRulesLoaded()
		.then( () => {
			if ( !this.matchRules.length ) {
				this.instantiateMatchRules( this.constructor.static.matchCache.rawMatchRules );
			}
			const finders = this.constructor.static.matchCache.memoizedFinders;
			const actions = [];
			const document = surfaceModel.getDocument();
			const modified = this.getModifiedContentRanges( document );

			for ( const matchRule of this.matchRules ) {
				try {
					const terms = Object.keys( matchRule.query );

					// Check if action can be created for this range
					const isUsableRange = ( range, tagName ) => {
						if ( !modified.some( ( modRange ) => range.touchesRange( modRange ) ) ) {
							return false;
						}
						if ( !this.isRangeValid( range, surfaceModel.documentModel ) ) {
							return false;
						}
						if ( this.isDismissedRange( range, tagName ) ) {
							return false;
						}
						if ( matchRule.listener && matchRule.listener !== listener ) {
							return false;
						}
						if ( matchRule.inNode && !matchRule.isRangeInNode( range, surfaceModel ) ) {
							return false;
						}
						// Above we checked for the overall textmatch config, but now
						// we need to know if this rule is more-specific:
						if ( !(
							this.constructor.static.doesConfigMatch( matchRule.config, surfaceModel.documentModel, this.includeSuggestions ) &&
							this.isRangeValid( range, surfaceModel.documentModel, matchRule.config )
						) ) {
							return false;
						}
						return true;
					};

					// Replacements with regular expressions work a bit differently,
					// since we need access to the original pattern to perform the replacement.
					// So we'll need to search by individual query, instead of grouping all the query terms into a set.
					if ( matchRule.isRegExp && matchRule.mode === 'replace' ) {
						Object.entries( matchRule.query ).forEach( ( [ pattern, replacer ] ) => {
							const regex = new RegExp( pattern, 'g' + ( matchRule.isCaseSensitive() ? '' : 'i' ) );
							const finder = new ve.dm.RegExpTextFinder( regex, { wholeWord: true } );
							const regexRanges = document.findText( finder );
							for ( const range of regexRanges ) {
								const tagName = this.constructor.static.name + matchRule.getSubTag( pattern );
								if ( !isUsableRange( range, tagName ) ) {
									continue;
								}
								const term = surfaceModel.getLinearFragment( range ).getText();
								// Regex replacements require special handling because the matched range may contain
								// inline objects that getText() skips but that occupy positions in the linear data
								// Note: the reason we call this here instead of act() is to display the replacement correctly in the action dialog
								const replacementConfig = this.getRegexReplacementData( range, regex, replacer, matchRule.preserveCase, surfaceModel );
								// if no valid replacement was generated, do nothing
								if ( replacementConfig.replacement === undefined ) {
									continue;
								}
								const fragment = matchRule.getExpandedFragment( surfaceModel.getLinearFragment( range ) );
								actions.push( this.buildAction( matchRule, fragment, term, tagName, replacementConfig ) );
							}
						} );
						continue;
					}

					// Create or retrieve the TextFinder for this match rule
					if ( !finders[ matchRule.id ] ) {

						let finder = null;
						if ( matchRule.isRegExp ) {
							const re = new RegExp( terms.join( '|' ), 'g' + ( matchRule.isCaseSensitive() ? '' : 'i' ) );
							finder = new ve.dm.RegExpTextFinder( re,
								{
									wholeWord: true
								}
							);
						} else {
							finder = new ve.dm.SetTextFinder( new Set( terms ),
								{
									caseSensitiveString: matchRule.isCaseSensitive(),
									wholeWord: true
								} );
						}
						finders[ matchRule.id ] = new ve.dm.MemoizedTextFinder( finder );
					}

					// Find all ranges that match this rule's search terms
					const ranges = document.findText( finders[ matchRule.id ] );
					const fragMap = new Map();

					for ( const range of ranges ) {
						const term = surfaceModel.getLinearFragment( range ).getText();
						const tagName = this.constructor.static.name + matchRule.getSubTag( term );
						if ( !isUsableRange( range, tagName ) ) {
							continue;
						}

						const fragment = matchRule.getExpandedFragment( surfaceModel.getLinearFragment( range ) );
						const min = matchRule.minOccurrences;
						// If this match rule requires a certain number of occurrences, start keeping track of those
						if ( min ) {
							const fragRange = fragment.getSelection().getRange();
							const key = `${ fragRange.start }-${ fragRange.end }`;
							const count = ( fragMap.get( key ) || 0 ) + 1;
							fragMap.set( key, count );
							// Use strict equality so that we can keep adding to the occurrences
							// in this fragment while only creating an action once
							if ( count !== min ) {
								continue;
							}
						}
						let replacement = matchRule.getReplacement( term );
						if ( matchRule.mode === 'replace' ) {
							if ( replacement !== null && matchRule.preserveCase ) {
								replacement = mw.editcheck.applyCase( replacement, term, this.lang );
							}
						}
						actions.push( this.buildAction( matchRule, fragment, term, tagName, { replacement } ) );
					}
				} catch ( e ) {
					// Probably a regexp creation issue
					mw.log.error( `TextMatchEditCheck failed to apply rule '${ matchRule.id }'`, e );
				}
			}
			return actions;
		} );
};

/**
 * Build a TextMatchEditCheckAction
 *
 * @param {mw.editcheck.TextMatchEditCheck} matchRule
 * @param {ve.dm.LinearFragment} fragment Fragment that the match covers, after optional expansion
 * @param {string} term Individual term that triggered the match, before optional expansion
 * @param {string} tagName Unique tag name for this matchRule+term pair
 * @param {Object} [replacementConfig] Configuration for the replacement
 * @param {string} [replacementConfig.replacement] Word or phrase to use as the replacement
 * @param {ve.dm.LinearFragment|undefined} [replacementConfig.replacementFragment] Optional fragment that the narrowed replacement covers
 * @param {function(): (string|undefined)|undefined} [replacementConfig.defaultPrompt] Optional special prompt to display
 *
 * @return {mw.editcheck.TextMatchEditCheckAction}
 */
mw.editcheck.TextMatchEditCheck.prototype.buildAction = function ( matchRule, fragment, term, tagName, replacementConfig ) {
	const { replacement, replacementFragment, defaultPrompt } = replacementConfig || {};
	let prompt = defaultPrompt;
	if ( !prompt ) {
		const foundText = fragment.getText();
		if ( matchRule.mode === 'replace' ) {
			prompt = this.constructor.static.createReplacementPrompt( foundText, replacement );
		}
	}
	return new mw.editcheck.TextMatchEditCheckAction( {
		fragments: [ fragment ],
		prompt,
		term,
		replacement,
		title: matchRule.title,
		message: matchRule.message,
		check: this,
		ruleConfig: matchRule.config,
		mode: matchRule.mode,
		matchRuleId: matchRule.id,
		tagName,
		replacementFragment
	} );
};

mw.editcheck.TextMatchEditCheck.prototype.onDocumentChange = function ( surfaceModel ) {
	return this.handleListener( surfaceModel, 'onDocumentChange' );
};

// For now it doesn't make sense to run a TextMatchEditCheck in review mode
// as there isn't a way to edit the text.
mw.editcheck.TextMatchEditCheck.prototype.onBeforeSave = null;

mw.editcheck.TextMatchEditCheck.prototype.act = function ( choice, action, surface ) {
	switch ( choice ) {
		case 'delete':
			action.fragments[ 0 ].removeContent();
			action.select( surface, true );
			return;
		case 'accept': {
			const replacementFragment = action.replacementFragment || action.fragments[ 0 ];
			replacementFragment.insertContent( action.replacement, true );
			action.select( surface, true );
			return;
		}
	}
	// Parent method
	return mw.editcheck.TextMatchEditCheck.super.prototype.act.apply( this, arguments );
};

/**
 * Compute the replacement string, target fragment, and prompt for a regex match that contains objects
 *
 * We need special handling when the matched range contains inline objects like templates that getText() omits
 * but that occupy positions in the linear data. To handle this, we narrow the replacement to only the portion
 * that actually changed by chopping the parts that the term and the replacement have in common.
 *
 * For ex, "foo bar baz" -> "foo qux baz" would become "bar" -> "qux" only.
 *
 * @param {ve.Range} range Range of the match
 * @param {RegExp} regex Compiled regex used to find the match
 * @param {string|Function} replacer Replacement string from the matchRule
 * @param {boolean} preserveCase If the replacement should match the case of the found term
 * @param {ve.dm.SurfaceModel} surfaceModel
 * @return {{ replacement: string, replacementFragment: ve.dm.SurfaceFragment, defaultPrompt: function(): (string|undefined) }|{}}
 * (the narrowed string to insert, the narrowed fragment to insert it into, and the string to display in the prompt)
 */
mw.editcheck.TextMatchEditCheck.prototype.getRegexReplacementData = function ( range, regex, replacer, preserveCase, surfaceModel ) {
	const fragment = surfaceModel.getLinearFragment( range );
	const termWithElements = fragment.getText( '\uFFFC' );
	// Now we can apply the substitution to the placeholder-substituted string
	const rawReplacement = termWithElements.replace( regex, replacer );
	const fullReplacement = preserveCase ?
		mw.editcheck.applyCase( rawReplacement, termWithElements, this.lang ) :
		rawReplacement;
	if ( fullReplacement === termWithElements ) {
		// No real change can be suggested
		return {};
	}
	// Find the range where the replacement differs from the term
	let prefix = 0, suffix = 0;
	while ( prefix < termWithElements.length && termWithElements[ prefix ] === fullReplacement[ prefix ] ) {
		prefix++;
	}
	while (
		suffix < termWithElements.length - prefix &&
		suffix < fullReplacement.length - prefix &&
		termWithElements[ termWithElements.length - suffix - 1 ] === fullReplacement[ fullReplacement.length - suffix - 1 ]
	) {
		suffix++;
	}

	const doc = surfaceModel.getDocument();
	// Get human-readable labels for the prompt, lazily; actions are built a lot and then
	// discarded, and the node-lookups here are the expensive part of this.
	// Any elements in the prompt will be represented as their template name or node type
	const defaultPrompt = () => {
		// The fragment translates with the document, so the offsets must be read now.
		// The content must not have changed, or the placeholder positions in
		// termWithElements will no longer line up with it.
		if ( fragment.getText( '\uFFFC' ) !== termWithElements ) {
			return undefined;
		}
		const currentRange = fragment.getSelection().getRange();
		const elementLabels = [];
		for ( let i = 0; i < termWithElements.length; i++ ) {
			if ( termWithElements[ i ] === '\uFFFC' ) {
				const offset = currentRange.start + i;
				let label = '';
				// Discard the closing tag, as we only need the open tag for grabbing template name(s)
				if ( doc.data.isOpenElementData( offset ) ) {
					try {
						const node = doc.getDocumentNode().getNodeFromOffset( offset + 1 );
						label = node instanceof ve.dm.MWTransclusionNode ? node.getWikitext( true ) : `<${ node.getType() }>`;
					} catch ( e ) {
						mw.log.warn( 'TextMatch edit check: Failed to get label for inline element', e );
					}
				}
				elementLabels.push( label );
			}
		}
		let j = 0;
		const termForPrompt = termWithElements.replace( /\uFFFC/g, () => elementLabels[ j++ ] || '' );
		j = 0;
		const replacementForPrompt = fullReplacement.replace( /\uFFFC/g, () => elementLabels[ j++ ] || '' );
		return this.constructor.static.createReplacementPrompt( termForPrompt, replacementForPrompt );
	};
	const newRange = new ve.Range( range.start + prefix, range.end - suffix );
	if (
		!doc.data.isContentOffset( newRange.start ) ||
		!doc.data.isContentOffset( newRange.end )
	) {
		// A boundary landed inside an inline node
		return {};
	}
	const replacement = fullReplacement.slice( prefix, fullReplacement.length - suffix );
	if ( replacement.includes( '\uFFFC' ) ) {
		// The replacement still references objects we can't reproduce as text
		return {};
	}
	return {
		replacement,
		replacementFragment: surfaceModel.getLinearFragment( newRange ),
		defaultPrompt
	};
};

/* Registration */

mw.editcheck.editCheckFactory.register( mw.editcheck.TextMatchEditCheck );

/**
 * TextMatchEditCheckAction
 *
 * Subclass of EditCheckAction to include information about the matchRule associated with this action
 *
 * @class
 * @extends mw.editcheck.EditCheckAction
 *
 * @constructor
 * @param {Object} config Configuration options
 * @param {string} config.matchRuleId ID of the matchRule that triggered the match
 * @param {string} config.term Term that prompted the action
 * @param {string} config.message Message for the action dialog
 * @param {string} config.replacement Word or phrase to use as the replacement, if action allows
 * @param {string} config.tagName Unique tag name for this matchRule+term pair
 * @param {ve.dm.SurfaceFragment} [config.replacementFragment] Fragment to insert content into
 */
mw.editcheck.TextMatchEditCheckAction = function MWTextMatchEditCheckAction( config ) {
	mw.editcheck.TextMatchEditCheckAction.super.call( this, config );
	this.matchRuleId = config.matchRuleId;
	this.term = config.term;
	const msgkey = `editcheck-textmatch-${ config.matchRuleId }-description`;
	ve.init.platform.addMessages( { [ msgkey ]: config.message } );
	this.message = ve.deferJQueryMsg( msgkey );
	this.replacement = config.replacement;
	this.tagName = config.tagName;
	this.replacementFragment = config.replacementFragment;
	this.ruleConfig = config.ruleConfig;
};

/* Inheritance */

OO.inheritClass( mw.editcheck.TextMatchEditCheckAction, mw.editcheck.EditCheckAction );

/* Events */

/**
 * Fired when the user selects an action (e.g., clicks a suggestion button).
 *
 * @event mw.editcheck.EditCheckAction#act
 * @param {jQuery.Promise} promise A promise that resolves when the action is complete
 */

/* Methods */

/**
 * Compare to another action
 *
 * @param {mw.editcheck.EditCheckAction} other Other action
 * @param {...any} args
 * @return {boolean}
 */
mw.editcheck.TextMatchEditCheckAction.prototype.equals = function ( other, ...args ) {
	if ( !this.constructor.super.prototype.equals.call( this, other, ...args ) ) {
		return false;
	}
	return this.matchRuleId === other.matchRuleId;
};

/**
 * Get unique tag name for this action
 *
 * @return {string} unique tag
 */
mw.editcheck.TextMatchEditCheckAction.prototype.getTagName = function () {
	return this.tagName ? this.tagName : this.check.getName();
};

/**
 * Get the name of the check type
 *
 * @return {string} Check type name
 */
mw.editcheck.TextMatchEditCheckAction.prototype.getName = function () {
	return this.check.getName() + '-' + this.matchRuleId;
};

/**
 * @inheritdoc
 */
mw.editcheck.TextMatchEditCheckAction.prototype.getRuleId = function () {
	return this.matchRuleId;
};

mw.editcheck.TextMatchEditCheckAction.prototype.isExperimental = function () {
	return this.constructor.super.prototype.isExperimental.apply( this, arguments ) ||
		( !this.suggestion && !this.ruleConfig.showAsCheck ) ||
		( this.suggestion && !this.ruleConfig.showAsSuggestion );
};

/**
 * TextMatchRule
 *
 * Class to represent a single matchRule for TextMatchEditCheck
 *
 * @class
 *
 * @constructor
 * @param {Object} rule Match rule
 * @param {string} rule.title Title of the match rule, used in the action prompt
 * @param {string} rule.message Message to show in the action description
 * @param {Object.<string,string>|string[]|string} rule.query Terms to match, string, array or object mapping terms to their replacements.
 * @param {string} [rule.mode] 'info', 'replace', or 'delete', to determine the type of action to show for this matchRule.
 * @param {Object} [rule.config] Configuration options.
 * @param {string} [rule.expand] Expansions mode 'sentence', 'paragraph', 'word', 'siblings', or 'parent'
 * @param {string} [rule.inNode] Node type that a match must be inside of
 * @param {string} [rule.listener] Listener that this matchRule applies to, if not all
 * @param {boolean} [rule.preserveCase] If the replacement should match the case of the found term
 * @param {boolean} [rule.isRegExp] If the query should be treated as a regular expression
 * @param {boolean} [rule.caseSensitive] If search should be case sensitive
 * @param {boolean} [rule.minOccurrences] Number of query instances that need to be found before it's declared a match
 * @param {string} id ID of matchRule in config
 * @param {Intl.Collator} collator Collator to use for comparisons
 */
mw.editcheck.TextMatchRule = function MWTextMatchRule( rule, id, collator ) {
	this.title = rule.title;
	this.mode = rule.mode || '';
	this.message = rule.message;
	this.config = ve.extendObject( {}, this.constructor.static.defaultConfig, rule.config );
	this.expand = rule.expand;
	this.inNode = rule.inNode || null;
	this.listener = rule.listener || null;
	// In T426004 we moved to defining caseSensitive and minOccurrences at the top-level of the matchItem config,
	// but we'll support the previous way for backwards compatibility with any existing on-wiki configs
	if ( rule.config ) {
		if ( rule.config.caseSensitive ) {
			rule.caseSensitive = rule.config.caseSensitive;
		}
		if ( rule.config.minOccurrences ) {
			rule.minOccurrences = rule.config.minOccurrences;
		}
	}
	this.caseSensitive = rule.caseSensitive;
	this.minOccurrences = rule.minOccurrences;
	// preserveCase defaults to true when caseSensitive is false or not set
	this.preserveCase = ( rule.preserveCase !== null && rule.preserveCase !== undefined ) ? rule.preserveCase : !this.caseSensitive;
	this.isRegExp = rule.isRegExp;

	// If the selection is meant to be expanded, then only one action should be created per expanded fragment range
	if ( this.expand && !this.minOccurrences ) {
		this.minOccurrences = 1;
	}

	this.id = id;
	this.collator = collator;

	// Normalize queries to allow support for both objects and arrays
	this.query = this.normalizeQuery( rule.query );
};

/* Inheritance */

OO.initClass( mw.editcheck.TextMatchRule );

/* Static properties */

mw.editcheck.TextMatchRule.static.defaultConfig = {
	showAsCheck: false,
	showAsSuggestion: true
};

/* Methods */

/**
 * Transform any query type into a dictionary of terms and their replacements,
 * with a null replacement if none exists
 *
 * @param {Object.<string,string>|string[]|string} query
 * @return {Object.<string,string>} Dictionary of each term and its replacement
 */
mw.editcheck.TextMatchRule.prototype.normalizeQuery = function ( query ) {
	if ( typeof query === 'string' ) {
		query = [ query ];
	}
	if ( Array.isArray( query ) ) {
		const normalized = Object.create( null );
		for ( const word of query ) {
			normalized[ word ] = null;
		}
		return normalized;
	}
	return query || Object.create( null );
};

/**
 * @return {boolean} if this matchRule is configured to be case sensitive
 */
mw.editcheck.TextMatchRule.prototype.isCaseSensitive = function () {
	return this.caseSensitive;
};

/**
 * Check if a range is inside the required inNode type.
 *
 * @param {ve.Range} range
 * @param {ve.dm.Surface} surfaceModel
 * @return {boolean}
 */
mw.editcheck.TextMatchRule.prototype.isRangeInNode = function ( range, surfaceModel ) {
	if ( !this.inNode ) {
		return true;
	}

	const fragment = surfaceModel.getLinearFragment( range );
	return fragment.hasMatchingAncestor( this.inNode );
};

/**
 * Return the corresponding replacement word,
 * as defined for the given word in this matchRule's query
 *
 * @param {string} term to get replacement for
 * @return {string} replacement term
 */
mw.editcheck.TextMatchRule.prototype.getReplacement = function ( term ) {
	if ( this.isCaseSensitive() ) {
		return this.query[ term ];
	}
	const key = Object.keys( this.query ).find(
		( k ) => this.collator.compare( k, term ) === 0
	);
	return key ? this.query[ key ] : null;
};

/**
 * Expand a fragment given the match rule's config
 *
 * @param {ve.dm.SurfaceFragment} fragment
 * @return {ve.dm.SurfaceFragment} Expanded fragment
 */
mw.editcheck.TextMatchRule.prototype.getExpandedFragment = function ( fragment ) {
	switch ( this.expand ) {
		case 'sentence':
			// TODO: implement once unicodejs support is added
			break;
		case 'paragraph':
			fragment = fragment.expandLinearSelection( 'closest', ve.dm.ContentBranchNode )
				// …but that covered the entire CBN, we only want the contents
				.adjustLinearSelection( 1, -1 );
			break;
		case 'word':
		case 'siblings':
		case 'parent':
			fragment = fragment.expandLinearSelection( this.expand );
			break;
	}
	return fragment;
};

/**
 * Get a unique subtag for this matchRule-term pair.
 * Builds the subtag from:
 * - the index of the matchRule when created
 * - and, optionally, the index of the term in the list of keys from the matchRule's query
 *
 * @param {string} term
 * @return {string} A subtag in the format '-{matchIndex}-{termIndex}'
 */
mw.editcheck.TextMatchRule.prototype.getSubTag = function ( term ) {
	const queries = Object.keys( this.query );
	let termIndex;
	if ( this.expand ) {
		// This operates under the assumption that, if the expand property is set,
		// there can only be one action from this matchRule for any given fragment.
		return `-${ this.id }`;
	}
	if ( this.isCaseSensitive() ) {
		termIndex = queries.indexOf( term );
	} else {
		termIndex = queries.findIndex( ( q ) => this.collator.compare( q, term ) === 0 );
	}
	if ( !this.id || termIndex === -1 ) {
		return '';
	}
	return `-${ this.id }-${ termIndex }`;
};