/**
 * @license Copyright (c) 2017-2025, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md.
 */

'use strict';

/**
 * A cursor class that enables creating a left recursion recursive descent parser.
 * It maintains position state and provides backtracking capabilities for parsing operations.
 */
class ParserCursor {
	#_stack = [];

	/**
	 * Creates a new cursor instance for parsing text.
	 *
	 * @param text - The text to parse.
	 * @param [index=0] - The initial position in the text.
	 */
	constructor( text, index = 0 ) {
		this.index = index;
		this.text = text;
	}

	/**
	 * Executes a callback with automatic cursor position management.
	 * If the callback returns undefined, the cursor position is restored to the state before the call.
	 * This enables backtracking in the parser.
	 *
	 * @param callback - The callback function to execute.
	 * @returns The result of the callback function.
	 */
	try( callback ) {
		this.#push();

		const result = callback( this );

		if ( result === undefined ) {
			this.index = this.#pop();
		} else {
			this.#pop( false );
		}

		return result;
	}

	/**
	 * Attempts to consume a single terminal character at the current position.
	 *
	 * @param ch - The character to match and consume.
	 * @returns True if the character was consumed, false otherwise.
	 */
	eatTerminal( ch ) {
		if ( this.text[ this.index ] !== ch ) {
			return false;
		}

		this.index++;
		return true;
	}

	/**
	 * Attempts to consume a sequence of terminal characters.
	 * All characters must match in order for the operation to succeed.
	 *
	 * @param chars - The sequence of characters to match and consume.
	 * @returns True if all characters were consumed, false otherwise.
	 */
	eatTerminals( chars ) {
		this.#push();

		for ( const ch of chars ) {
			if ( !this.eatTerminal( ch ) ) {
				this.#pop();
				return false;
			}
		}

		this.#pop( false );
		return true;
	}

	/**
	 * Consumes all terminal characters from the current position until a non-terminal character is encountered.
	 *
	 * @param chars - The characters to consume. If not provided, defaults to whitespace characters.
	 */
	eatAllNextTerminals( chars ) {
		while ( this.index < this.text.length && chars.includes( this.text[ this.index ] ) ) {
			this.index++;
		}
	}

	/**
	 * Consumes all whitespace characters (spaces and tabs) from the current position.
	 */
	eatIndent() {
		this.eatAllNextTerminals( ' \t' );
	}

	/**
	 * Consumes characters from the current position until a character matches the provided matcher function.
	 * It consumes terminal character but does not include it in the result.
	 *
	 * @param matcher - A function that takes a character and returns true if it matches the condition to stop consuming.
	 */
	eatAllNextUntil( matcher ) {
		let acc = '';

		while ( this.index < this.text.length ) {
			const ch = this.text[ this.index++ ];

			if ( matcher( ch ) ) {
				return acc;
			}

			acc += ch;
		}

		return acc;
	}

	/**
	 * Consumes characters from the current position until a specific character is encountered.
	 * It consumes terminal character but does not include it in the result.
	 *
	 * @param char - The character to stop consuming at.
	 * @returns The consumed characters up to the specified character.
	 */
	eatUntilTerminal( char ) {
		return this.eatAllNextUntil( c => c === char );
	}

	/**
	 * Consumes characters from the current position until a newline character is encountered.
	 * It consumes terminal character but does not include it in the result.
	 */
	eatUntilEOL() {
		return this.eatAllNextUntil( c => c === '\n' );
	}

	/**
	 * Attempts to consume a quoted string (single or double quotes).
	 * Returns the content between the quotes without the quote characters.
	 * Supports escaped quotes using backslash.
	 *
	 * @returns The quoted content or null if no valid quote was found.
	 */
	eatQuote() {
		const quoteChar = this.text[ this.index ];

		let started = false;
		let acc = '';
		let escaped = false;

		this.#push();

		for ( ; this.index < this.text.length; ++this.index ) {
			const ch = this.text[ this.index ];

			if ( escaped ) {
				// Handle escaped characters
				if ( ch === quoteChar || ch === '\\' ) {
					acc += ch;
				} else {
					acc += '\\' + ch;
				}

				escaped = false;
			} else if ( ch === '\\' ) {
				escaped = true;
			} else if ( ch !== quoteChar ) {
				acc += ch;
			} else if ( !started ) {
				started = true;
			} else {
				// eat the closing quote
				this.index++;
				break;
			}
		}

		if ( started ) {
			this.#pop( false );
			return acc;
		}

		this.#pop();
		return null;
	}

	/**
	 * Consumes a word (alphanumeric characters, underscore, and optionally other allowed characters).
	 *
	 * @param [whitelist=':-'] - Additional characters to allow in the word.
	 * @returns The consumed word, or empty string if no word was found.
	 */
	eatWord( whitelist = ':-' ) {
		let acc = '';

		this.#push();

		for ( ; this.index < this.text.length; ++this.index ) {
			const ch = this.text[ this.index ];

			if ( !isWordChar( ch ) && !whitelist.includes( ch ) ) {
				break;
			}

			acc += ch;
		}

		this.#pop( !acc );

		return acc;
	}

	/**
	 * Attempts to consume Nth productions until a production is not matched.
	 */
	accumulateEaters( production ) {
		const results = [];

		for ( let watchdog = 0; watchdog < 1000; watchdog++ ) {
			const result = this.try( production );

			if ( result !== undefined ) {
				results.push( result );
			} else {
				break;
			}
		}

		return results;
	}

	/**
	 * Saves the current cursor position to the internal stack.
	 * This enables backtracking by restoring to this position later.
	 */
	#push() {
		this.#_stack.push( this.index );
	}

	/**
	 * Removes and optionally restores the last saved cursor position from the stack.
	 *
	 * @param [assign=true] - If true, restores the cursor to the popped position.
	 * @returns The popped position value.
	 */
	#pop( assign = true ) {
		const value = this.#_stack.pop();

		if ( assign ) {
			this.index = value;
		}

		return value;
	}
};

/**
 * Checks if a character is a word character (alphanumeric or underscore).
 *
 * @param c - The character to check.
 * @returns True if the character is a word character.
 */
function isWordChar( c ) {
	const code = c.charCodeAt( 0 );

	return (
		( code >= 65 && code <= 90 ) || // A-Z
		( code >= 97 && code <= 122 ) || // a-z
		( code >= 48 && code <= 57 ) || // 0-9
		code === 95 // _
	);
}

module.exports = ParserCursor;
