/**
 * NOTE: THIS FILE IS A COPY OF @wordpress/element/src/create-interpolate-element.js.
 *
 * Once our minimum supported WordPress version is 5.4, we can remove this file and import from @wordpress/element instead.
 *
 * However, since that version of @wordpress/element ships an experimental API, it might be best until they include a stable one.
 *
 * I suspect that this happens in Wordpress 5.5, so it would be best to wait until our minimum version is 5.5 before shifting over.
 */
/**
 * External dependencies
 */
import { createElement, cloneElement, Fragment, isValidElement } from "@wordpress/element";

/** @typedef {import('./react').WPElement} WPElement */

let indoc, offset, output, stack;

/**
 * Matches tags in the localized string
 *
 * This is used for extracting the tag pattern groups for parsing the localized
 * string and along with the map converting it to a react element.
 *
 * There are four references extracted using this tokenizer:
 *
 * match: Full match of the tag (i.e. <strong>, </strong>, <br/>)
 * isClosing: The closing slash, it it exists.
 * name: The name portion of the tag (strong, br) (if )
 * isSelfClosed: The slash on a self closing tag, if it exists.
 *
 * @type {RegExp}
 */
const tokenizer = /<(\/)?(\w+)\s*(\/)?>/g;

/**
 * Tracks recursive-descent parse state.
 *
 * This is a Stack frame holding parent elements until all children have been
 * parsed.
 *
 * @private
 * @param {WPElement} element            A parent element which may still have
 *                                       nested children not yet parsed.
 * @param {number}    tokenStart         Offset at which parent element first
 *                                       appears.
 * @param {number}    tokenLength        Length of string marking start of parent
 *                                       element.
 * @param {number}    [prevOffset]       Running offset at which parsing should
 *                                       continue.
 * @param {number}    [leadingTextStart] Offset at which last closing element
 *                                       finished, used for finding text between
 *                                       elements.
 *
 * @returns {Frame} The stack frame tracking parse progress.
 */
function createFrame(
	element,
	tokenStart,
	tokenLength,
	prevOffset,
	leadingTextStart
) {
	return {
		element,
		tokenStart,
		tokenLength,
		prevOffset,
		leadingTextStart,
		children: [],
	};
}

/**
 * Grabs the next token match in the string and returns it's details.
 *
 * @private
 *
 * @returns {Array}  An array of details for the token matched.
 */
function nextToken() {
	const matches = tokenizer.exec( indoc );
	// We have no more tokens
	if ( null === matches ) {
		return [ "no-more-tokens" ];
	}
	const startedAt = matches.index;
	const [ match, isClosing, name, isSelfClosed ] = matches;
	const length = match.length;
	if ( isSelfClosed ) {
		return [ "self-closed", name, startedAt, length ];
	}
	if ( isClosing ) {
		return [ "closer", name, startedAt, length ];
	}
	return [ "opener", name, startedAt, length ];
}

/**
 * Pushes text extracted from the indoc string to the output stack given the
 * current rawLength value and offset (if rawLength is provided ) or the
 * indoc.length and offset.
 *
 * @private
 *
 * @returns {void}
 */
function addText() {
	const length = indoc.length - offset;
	if ( 0 === length ) {
		return;
	}
	output.push( indoc.substring( offset, offset + length ) );
}

/**
 * Pushes a child element to the associated parent element's children for the
 * parent currently active in the stack.
 *
 * @private
 *
 * @param {Frame}    frame       The Frame containing the child element and it's
 *                               token information.
 *
 * @returns {void}
 */
function addChild( frame ) {
	const { element, tokenStart, tokenLength, prevOffset, children } = frame;
	const parent = stack[ stack.length - 1 ];
	const text = indoc.substring( parent.prevOffset, tokenStart );

	if ( text ) {
		parent.children.push( text );
	}

	parent.children.push( cloneElement( element, null, ...children ) );
	parent.prevOffset = prevOffset ? prevOffset : tokenStart + tokenLength;
}

/**
 * This is called for closing tags. It creates the element currently active in
 * the stack.
 *
 * @private
 *
 * @param {number} endOffset Offset at which the closing tag for the element
 *                           begins in the string. If this is greater than the
 *                           prevOffset attached to the element, then this
 *                           helps capture any remaining nested text nodes in
 *                           the element.
 *
 * @returns {void}
 */
function closeOuterElement( endOffset ) {
	const {
		element,
		leadingTextStart,
		prevOffset,
		tokenStart,
		children,
	} = stack.pop();

	const text = endOffset
		? indoc.substring( prevOffset, endOffset )
		: indoc.substring( prevOffset );

	if ( text ) {
		children.push( text );
	}

	if ( null !== leadingTextStart ) {
		output.push(
			indoc.substring( leadingTextStart, tokenStart )
		);
	}

	output.push( cloneElement( element, null, ...children ) );
}

/**
 * Validate conversion map.
 *
 * A map is considered valid if it's an object and every value in the object
 * is a WPElement
 *
 * @private
 *
 * @param {Object} conversionMap  The map being validated.
 *
 * @returns {boolean}  True means the map is valid.
 */
const isValidConversionMap = ( conversionMap ) => {
	const isObject = typeof conversionMap === "object";
	const values = isObject && Object.values( conversionMap );
	return (
		isObject &&
		values.length &&
		values.every( ( element ) => isValidElement( element ) )
	);
};

/* eslint-disable complexity */
/**
 * This is the iterator over the matches in the string.
 *
 * @private
 *
 * @param {Object} conversionMap The conversion map for the string.
 *
 * @returns {boolean} true for continuing to iterate, false for finished.
 */
function proceed( conversionMap ) {
	const next = nextToken();
	const [ tokenType, name, startOffset, tokenLength ] = next;
	const stackDepth = stack.length;
	const leadingTextStart = startOffset > offset ? offset : null;
	if ( ! conversionMap[ name ] ) {
		addText();
		return false;
	}
	switch ( tokenType ) {
		case "no-more-tokens":
			if ( stackDepth !== 0 ) {
				const {
					leadingTextStart: stackLeadingText,
					tokenStart,
				} = stack.pop();
				output.push( indoc.substring( stackLeadingText, stackLeadingText + tokenStart ) );
			}
			addText();
			return false;

		case "self-closed":
			if ( 0 === stackDepth ) {
				if ( null !== leadingTextStart ) {
					output.push(
						indoc.substring(
							leadingTextStart,
							startOffset
						)
					);
				}
				output.push( conversionMap[ name ] );
				offset = startOffset + tokenLength;
				return true;
			}

			// Otherwise we found an inner element
			addChild(
				createFrame( conversionMap[ name ], startOffset, tokenLength )
			);
			offset = startOffset + tokenLength;
			return true;

		case "opener":
			stack.push(
				createFrame(
					conversionMap[ name ],
					startOffset,
					tokenLength,
					startOffset + tokenLength,
					leadingTextStart
				)
			);
			offset = startOffset + tokenLength;
			return true;

		case "closer":
			/* eslint-disable no-case-declarations */
			// If we're not nesting then this is easy - close the block
			if ( 1 === stackDepth ) {
				closeOuterElement( startOffset );
				offset = startOffset + tokenLength;
				return true;
			}

			// Otherwise we're nested and we have to close out the current
			// Block and add it as a innerBlock to the parent
			const stackTop = stack.pop();
			const text = indoc.substring( stackTop.prevOffset, startOffset );
			stackTop.children.push( text );
			stackTop.prevOffset = startOffset + tokenLength;
			const frame = createFrame(
				stackTop.element,
				stackTop.tokenStart,
				stackTop.tokenLength,
				startOffset + tokenLength
			);
			frame.children = stackTop.children;
			addChild( frame );
			offset = startOffset + tokenLength;
			return true;
			/* eslint-enable no-case-declarations */

		default:
			addText();
			return false;
	}
}
/* eslint-enable complexity */

/**
 * This function creates an interpolated element from a passed in string with
 * specific tags matching how the string should be converted to an element via
 * the conversion map value.
 *
 * @example
 * For example, for the given string:
 *
 * "This is a <span>string</span> with <a>a link</a> and a self-closing
 * <CustomComponentB/> tag"
 *
 * You would have something like this as the conversionMap value:
 *
 * ```js
 * {
 *     span: <span />,
 *     a: <a href={ 'https://github.com' } />,
 *     CustomComponentB: <CustomComponent />,
 * }
 * ```
 *
 * @param {string}  interpolatedString  The interpolation string to be parsed.
 * @param {Object}  conversionMap       The map used to convert the string to
 *                                      a react element.
 * @throws {TypeError}
 * @returns {WPElement}  A wp element.
 */
const createInterpolateElement = ( interpolatedString, conversionMap ) => {
	indoc = interpolatedString;
	offset = 0;
	output = [];
	stack = [];
	tokenizer.lastIndex = 0;

	if ( ! isValidConversionMap( conversionMap ) ) {
		throw new TypeError(
			"The conversionMap provided is not valid. It must be an object with values that are WPElements"
		);
	}

	do {
		// Twiddle our thumbs
	} while ( proceed( conversionMap ) );
	return createElement( Fragment, null, ...output );
};

export default createInterpolateElement;
