/**
 * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
 */

'use strict';

( function() {

	CKEDITOR.plugins.add( 'textmatch', {} );

	/**
	 * A global namespace for methods exposed by the [Text Match](https://ckeditor.com/cke4/addon/textmatch) plugin.
	 *
	 * The most important function is {@link #match} which performs a text
	 * search in the DOM.
	 *
	 * @singleton
	 * @class
	 * @since 4.10.0
	 */
	CKEDITOR.plugins.textMatch = {};

	/**
	 * Allows to search in the DOM for matching text using a callback which operates on strings instead of text nodes.
	 * Returns {@link CKEDITOR.dom.range} and the matching text.
	 *
	 * ```javascript
	 *	var range = editor.getSelection().getRanges()[ 0 ];
	 *
	 *	CKEDITOR.plugins.textMatch.match( range, function( text, offset ) {
	 *		// Let's assume that text is 'Special thanks to #jo.' and offset is 21.
	 *		// The offset "21" means that the caret is between '#jo' and '.'.
	 *
	 *		// Get the text before the caret.
	 *		var left = text.slice( 0, offset ),
	 *			// Will look for a literal '#' character and at least two word characters.
	 *			match = left.match( /#\w{2,}$/ );
	 *
	 *		if ( !match ) {
	 *			return null;
	 *		}
	 *
	 *		// The matching fragment is the '#jo', which can
	 *		// be identified by the following offsets: { start: 18, end: 21 }.
	 *		return { start: match.index, end: offset };
	 *	} );
	 * ```
	 *
	 * @member CKEDITOR.plugins.textMatch
	 * @param {CKEDITOR.dom.range} range A collapsed range &mdash; the position from which the scanning starts.
	 * Usually the caret position.
	 * @param {Function} testCallback A callback executed to check if the text matches.
	 * @param {String} testCallback.text The full text to check.
	 * @param {Number} testCallback.rangeOffset An offset of the `range` in the `text` to be checked.
	 * @param {Object} [testCallback.return] The position of the matching fragment (`null` if nothing matches).
	 * @param {Number} testCallback.return.start The offset of the start of the matching fragment.
	 * @param {Number} testCallback.return.end The offset of the end of the matching fragment.
	 *
	 * @returns {Object/null} An object with information about the matching text or `null`.
	 * @returns {String} return.text The matching text.
	 * The text does not reflect the range offsets. The range could contain additional,
	 * browser-related characters like {@link CKEDITOR.dom.selection#FILLING_CHAR_SEQUENCE}.
	 * @returns {CKEDITOR.dom.range} return.range A range in the DOM for the text that matches.
	 */
	CKEDITOR.plugins.textMatch.match = function( range, callback ) {
		var textAndOffset = CKEDITOR.plugins.textMatch.getTextAndOffset( range ),
			fillingCharSequence = CKEDITOR.dom.selection.FILLING_CHAR_SEQUENCE,
			fillingSequenceOffset = 0;

		if ( !textAndOffset ) {
			return;
		}

		// Remove filling char sequence for clean query (#2038).
		if ( textAndOffset.text.indexOf( fillingCharSequence ) == 0 ) {
			fillingSequenceOffset = fillingCharSequence.length;

			textAndOffset.text = textAndOffset.text.replace( fillingCharSequence, '' );
			textAndOffset.offset -= fillingSequenceOffset;
		}

		var result = callback( textAndOffset.text, textAndOffset.offset );

		if ( !result ) {
			return null;
		}

		return {
			range: CKEDITOR.plugins.textMatch.getRangeInText( range, result.start, result.end + fillingSequenceOffset ),
			text: textAndOffset.text.slice( result.start, result.end )
		};
	};

	/**
	 * Returns a text (as a string) in which the DOM range is located (the function scans for adjacent text nodes)
	 * and the offset of the caret in that text.
	 *
	 * ## Examples
	 *
	 * * `{}` is the range position in the text node (it means that the text node is **not** split at that position).
	 * * `[]` is the range position in the element (it means that the text node is split at that position).
	 * * `.` is a separator for text nodes (it means that the text node is split at that position).
	 *
	 * Examples:
	 *
	 * ```
	 *	Input: <p>he[]llo</p>
	 *	Result: { text: 'hello', offset: 2 }
	 *
	 *	Input: <p>he.llo{}</p>
	 *	Result: { text: 'hello', offset: 5 }
	 *
	 *	Input: <p>{}he.ll<i>o</i></p>
	 *	Result: { text: 'hell', offset: 0 }
	 *
	 *	Input: <p>he{}<i>ll</i>o</p>
	 *	Result: { text: 'he', offset: 2 }
	 *
	 *	Input: <p>he<i>ll</i>o.m{}y.friend</p>
	 *	Result: { text: 'omyfriend', offset: 2 }
	 * ```
	 *
	 * @member CKEDITOR.plugins.textMatch
	 * @param {CKEDITOR.dom.range} range
	 * @returns {Object/null}
	 * @returns {String} return.text The text in which the DOM range is located.
	 * @returns {Number} return.offset An offset of the caret.
	 */
	CKEDITOR.plugins.textMatch.getTextAndOffset = function( range ) {
		if ( !range.collapsed ) {
			return null;
		}

		var text = '', offset = 0,
			textNodes = CKEDITOR.plugins.textMatch.getAdjacentTextNodes( range ),
			nodeReached = false,
			elementIndex,
			startContainerIsText = ( range.startContainer.type != CKEDITOR.NODE_ELEMENT );

		if ( startContainerIsText ) {
			// Determining element index in textNodes array.
			elementIndex = indexOf( textNodes, function( current ) {
				return range.startContainer.equals( current );
			} );
		} else {
			// Based on range startOffset decreased by first text node index.
			elementIndex = range.startOffset - ( textNodes[ 0 ] ? textNodes[ 0 ].getIndex() : 0 );
		}

		var max = textNodes.length;
		for ( var i = 0; i < max; i += 1 ) {
			var currentNode = textNodes[ i ];
			text += currentNode.getText();

			// We want to increase text offset only when startContainer is not reached.
			if ( !nodeReached ) {
				if ( startContainerIsText ) {
					if ( i == elementIndex ) {
						nodeReached = true;
						offset += range.startOffset;
					} else {
						offset += currentNode.getText().length;
					}
				} else {
					if ( i == elementIndex ) {
						nodeReached = true;
					}

					// In below example there are three text nodes in p element and four possible offsets ( 0, 1, 2, 3 )
					// We are going to increase offset while iteration:
					// index 0 ==> 0
					// index 1 ==> 3
					// index 2 ==> 3 + 3
					// index 3 ==> 3 + 3 + 2

					// <p> foo bar ba </p>
					//    0^^^1^^^2^^3
					if ( i > 0 ) {
						offset += textNodes[ i - 1 ].getText().length;
					}

					// If element index at last element we also want to increase offset.
					if ( max == elementIndex && i + 1 == max ) {
						offset += currentNode.getText().length;
					}
				}
			}
		}

		return {
			text: text,
			offset: offset
		};
	};

	/**
	 * Transforms the `start` and `end` offsets in the text generated by the {@link #getTextAndOffset}
	 * method into a DOM range.
	 *
	 * ## Examples
	 *
	 * * `{}` is the range position in the text node (it means that the text node is **not** split at that position).
	 * * `.` is a separator for text nodes (it means that the text node is split at that position).
	 *
	 * Examples:
	 *
	 * ```
	 *	Input: <p>f{}oo.bar</p>, 0, 3
	 *	Result: <p>{foo}.bar</p>
	 *
	 *	Input: <p>f{}oo.bar</p>, 1, 5
	 *	Result: <p>f{oo.ba}r</p>
	 * ```
	 *
	 * @member CKEDITOR.plugins.textMatch
	 * @param {CKEDITOR.dom.range} range
	 * @param {Number} start A start offset.
	 * @param {Number} end An end offset.
	 * @returns {CKEDITOR.dom.range} Transformed range.
	 */
	CKEDITOR.plugins.textMatch.getRangeInText = function( range, start, end ) {
		var resultRange = new CKEDITOR.dom.range( range.root ),
			elements = CKEDITOR.plugins.textMatch.getAdjacentTextNodes( range ),
			startData = findElementAtOffset( elements, start ),
			endData = findElementAtOffset( elements, end );

		resultRange.setStart( startData.element, startData.offset );
		resultRange.setEnd( endData.element, endData.offset );

		return resultRange;
	};

	/**
	 * Creates a collection of adjacent text nodes which are between DOM elements, starting from the given range.
	 * This function works only for collapsed ranges.
	 *
	 * ## Examples
	 *
	 * * `{}` is the range position in the text node (it means that the text node is **not** split at that position).
	 * * `.` is a separator for text nodes (it means that the text node is split at that position).
	 *
	 * Examples:
	 *
	 * ```
	 *	Input: <p>he.llo{}</p>
	 *	Result: [ 'he', 'llo' ]
	 *
	 *	Input: <p>{}he.ll<i>o</i></p>
	 *	Result:  [ 'he', 'll' ]
	 *
	 *	Input: <p>he{}<i>ll</i>o.</p>
	 *	Result:  [ 'he' ]
	 *
	 *	Input: <p>he<i>ll</i>{}o.my.friend</p>
	 *	Result: [ 'o', 'my', 'friend' ]
	 * ```
	 *
	 * @member CKEDITOR.plugins.textMatch
	 * @param {CKEDITOR.dom.range} range
	 * @return {CKEDITOR.dom.text[]} An array of text nodes.
	 */
	CKEDITOR.plugins.textMatch.getAdjacentTextNodes = function( range ) {
		if ( !range.collapsed ) {
			throw new Error( 'Range must be collapsed.' ); // %REMOVE_LINE%
			// Reachable in prod mode.
			return []; // jshint ignore:line
		}

		var collection = [],
			siblings,
			elementIndex,
			node, i;

		if ( range.startContainer.type != CKEDITOR.NODE_ELEMENT ) {
			siblings = range.startContainer.getParent().getChildren();
			elementIndex = range.startContainer.getIndex();
		} else {
			siblings = range.startContainer.getChildren();
			elementIndex = range.startOffset;
		}

		i = elementIndex;
		while ( node = siblings.getItem( --i ) ) {
			if ( node.type == CKEDITOR.NODE_TEXT ) {
				collection.unshift( node );
			} else {
				break;
			}
		}

		i = elementIndex;
		while ( node = siblings.getItem( i++ ) ) {
			if ( node.type == CKEDITOR.NODE_TEXT ) {
				collection.push( node );
			} else {
				break;
			}
		}

		return collection;
	};

	function findElementAtOffset( elements, offset ) {
		var max = elements.length,
			currentOffset = 0;
		for ( var i = 0; i < max; i += 1 ) {
			var current = elements[ i ];
			if ( offset >= currentOffset && currentOffset + current.getText().length >= offset ) {
				return {
					element: current,
					offset: offset - currentOffset
				};
			}

			currentOffset += current.getText().length;
		}

		return null;
	}

	function indexOf( arr, checker ) {
		for ( var i = 0; i < arr.length; i++ ) {
			if ( checker( arr[ i ] ) ) {
				return i;
			}
		}

		return -1;
	}
} )();
