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

	this.lang = mw.config.get( 'wgContentLanguage' );
	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 = 25;

/**
 * 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: false,
	maximumEditcount: null,
	minimumEditcount: null
} );

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' ]
	}
];

/**
 * 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} rawMatchRules map of matchRule IDs to raw config objects
 * @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 filename = rule.import;
			if ( !filename.startsWith( 'MediaWiki:' ) ) {
				mw.log.warn( `Skipped import for matchRule id:${ id } (${ filename } must be in mediawiki namespace.)` );
				return;
			}
			if ( !filename.endsWith( '.json' ) ) {
				mw.log.warn( `Skipped import for matchRule id:${ id } (${ filename } must be a json file.)` );
				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;
};

/* 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 ] ) => {
		const isValidMode = this.constructor.static.choices.some(
			( choice ) => choice.modes.includes( rule.mode )
		);
		rule.mode = isValidMode ? rule.mode : '';
		if ( !rule.expand && ve.getProp( rule, 'config', '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 );
	} );
};

/**
 * @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 ) {

				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();
							const fragment = matchRule.getExpandedFragment( surfaceModel.getLinearFragment( range ) );
							// To get the replacement using regex, we need to pass in the original pattern
							let replacement = term.replace( regex, replacer );
							if ( matchRule.preserveCase ) {
								replacement = mw.editcheck.applyCase( replacement, term, this.lang );
							}
							actions.push( this.buildAction( matchRule, fragment, term, replacement, tagName ) );
						}
					} );
					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.config.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.preserveCase ) {
						replacement = mw.editcheck.applyCase( replacement, term, this.lang );
					}
					actions.push( this.buildAction( matchRule, fragment, term, replacement, tagName ) );
				}
			}
			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} replacement word or phrase to use as the replacement, if action allows
 * @param {string} tagName unique tag name for this matchRule+term pair
 * @return {mw.editcheck.TextMatchEditCheckAction}
 */
mw.editcheck.TextMatchEditCheck.prototype.buildAction = function ( matchRule, fragment, term, replacement, tagName ) {
	let prompt;
	const foundText = fragment.getText();
	if ( matchRule.mode === 'replace' ) {
		if (
			replacement &&
			foundText.length <= replaceTextLengthLimit &&
			replacement.length <= replaceTextLengthLimit
		) {
			prompt = ve.msg( 'editcheck-textmatch-replace', foundText, replacement );
		}
	}
	return new mw.editcheck.TextMatchEditCheckAction( {
		fragments: [ fragment ],
		prompt,
		term,
		replacement,
		title: matchRule.title,
		message: matchRule.message,
		check: this,
		mode: matchRule.mode,
		matchRuleId: matchRule.id,
		tagName
	} );
};

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 fragment = action.fragments[ 0 ];
			fragment.insertContent( action.replacement, true );
			action.select( surface, true );
			return;
		}
	}
	// Parent method
	return mw.editcheck.TextMatchEditCheck.super.prototype.act.apply( this, arguments );
};

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

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

/**
 * 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 {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;
	this.preserveCase = rule.preserveCase;
	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.config.minOccurrences ) {
		this.config.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: true,
	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.config && this.config.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.config.caseSensitive ) {
		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 }`;
};