/**
 * @fileoverview The event generator for AST nodes.
 * @author Toru Nagashima
 */

"use strict";

//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

const esquery = require("esquery");

//------------------------------------------------------------------------------
// Typedefs
//------------------------------------------------------------------------------

/**
 * An object describing an AST selector
 * @typedef {Object} ASTSelector
 * @property {string} rawSelector The string that was parsed into this selector
 * @property {boolean} isExit `true` if this should be emitted when exiting the node rather than when entering
 * @property {Object} parsedSelector An object (from esquery) describing the matching behavior of the selector
 * @property {string[]|null} listenerTypes A list of node types that could possibly cause the selector to match,
 * or `null` if all node types could cause a match
 * @property {number} attributeCount The total number of classes, pseudo-classes, and attribute queries in this selector
 * @property {number} identifierCount The total number of identifier queries in this selector
 */

//------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------

/**
 * Computes the union of one or more arrays
 * @param {...any[]} arrays One or more arrays to union
 * @returns {any[]} The union of the input arrays
 */
function union(...arrays) {
	return [...new Set(arrays.flat())];
}

/**
 * Computes the intersection of one or more arrays
 * @param {...any[]} arrays One or more arrays to intersect
 * @returns {any[]} The intersection of the input arrays
 */
function intersection(...arrays) {
	if (arrays.length === 0) {
		return [];
	}

	let result = [...new Set(arrays[0])];

	for (const array of arrays.slice(1)) {
		result = result.filter(x => array.includes(x));
	}
	return result;
}

/**
 * Gets the possible types of a selector
 * @param {Object} parsedSelector An object (from esquery) describing the matching behavior of the selector
 * @returns {string[]|null} The node types that could possibly trigger this selector, or `null` if all node types could trigger it
 */
function getPossibleTypes(parsedSelector) {
	switch (parsedSelector.type) {
		case "identifier":
			return [parsedSelector.value];

		case "matches": {
			const typesForComponents =
				parsedSelector.selectors.map(getPossibleTypes);

			if (typesForComponents.every(Boolean)) {
				return union(...typesForComponents);
			}
			return null;
		}

		case "compound": {
			const typesForComponents = parsedSelector.selectors
				.map(getPossibleTypes)
				.filter(typesForComponent => typesForComponent);

			// If all of the components could match any type, then the compound could also match any type.
			if (!typesForComponents.length) {
				return null;
			}

			/*
			 * If at least one of the components could only match a particular type, the compound could only match
			 * the intersection of those types.
			 */
			return intersection(...typesForComponents);
		}

		case "child":
		case "descendant":
		case "sibling":
		case "adjacent":
			return getPossibleTypes(parsedSelector.right);

		case "class":
			if (parsedSelector.name === "function") {
				return [
					"FunctionDeclaration",
					"FunctionExpression",
					"ArrowFunctionExpression",
				];
			}

			return null;

		default:
			return null;
	}
}

/**
 * Counts the number of class, pseudo-class, and attribute queries in this selector
 * @param {Object} parsedSelector An object (from esquery) describing the selector's matching behavior
 * @returns {number} The number of class, pseudo-class, and attribute queries in this selector
 */
function countClassAttributes(parsedSelector) {
	switch (parsedSelector.type) {
		case "child":
		case "descendant":
		case "sibling":
		case "adjacent":
			return (
				countClassAttributes(parsedSelector.left) +
				countClassAttributes(parsedSelector.right)
			);

		case "compound":
		case "not":
		case "matches":
			return parsedSelector.selectors.reduce(
				(sum, childSelector) =>
					sum + countClassAttributes(childSelector),
				0,
			);

		case "attribute":
		case "field":
		case "nth-child":
		case "nth-last-child":
			return 1;

		default:
			return 0;
	}
}

/**
 * Counts the number of identifier queries in this selector
 * @param {Object} parsedSelector An object (from esquery) describing the selector's matching behavior
 * @returns {number} The number of identifier queries
 */
function countIdentifiers(parsedSelector) {
	switch (parsedSelector.type) {
		case "child":
		case "descendant":
		case "sibling":
		case "adjacent":
			return (
				countIdentifiers(parsedSelector.left) +
				countIdentifiers(parsedSelector.right)
			);

		case "compound":
		case "not":
		case "matches":
			return parsedSelector.selectors.reduce(
				(sum, childSelector) => sum + countIdentifiers(childSelector),
				0,
			);

		case "identifier":
			return 1;

		default:
			return 0;
	}
}

/**
 * Compares the specificity of two selector objects, with CSS-like rules.
 * @param {ASTSelector} selectorA An AST selector descriptor
 * @param {ASTSelector} selectorB Another AST selector descriptor
 * @returns {number}
 * a value less than 0 if selectorA is less specific than selectorB
 * a value greater than 0 if selectorA is more specific than selectorB
 * a value less than 0 if selectorA and selectorB have the same specificity, and selectorA <= selectorB alphabetically
 * a value greater than 0 if selectorA and selectorB have the same specificity, and selectorA > selectorB alphabetically
 */
function compareSpecificity(selectorA, selectorB) {
	return (
		selectorA.attributeCount - selectorB.attributeCount ||
		selectorA.identifierCount - selectorB.identifierCount ||
		(selectorA.rawSelector <= selectorB.rawSelector ? -1 : 1)
	);
}

/**
 * Parses a raw selector string, and throws a useful error if parsing fails.
 * @param {string} rawSelector A raw AST selector
 * @returns {Object} An object (from esquery) describing the matching behavior of this selector
 * @throws {Error} An error if the selector is invalid
 */
function tryParseSelector(rawSelector) {
	try {
		return esquery.parse(rawSelector.replace(/:exit$/u, ""));
	} catch (err) {
		if (
			err.location &&
			err.location.start &&
			typeof err.location.start.offset === "number"
		) {
			throw new SyntaxError(
				`Syntax error in selector "${rawSelector}" at position ${err.location.start.offset}: ${err.message}`,
			);
		}
		throw err;
	}
}

const selectorCache = new Map();

/**
 * Parses a raw selector string, and returns the parsed selector along with specificity and type information.
 * @param {string} rawSelector A raw AST selector
 * @returns {ASTSelector} A selector descriptor
 */
function parseSelector(rawSelector) {
	if (selectorCache.has(rawSelector)) {
		return selectorCache.get(rawSelector);
	}

	const parsedSelector = tryParseSelector(rawSelector);

	const result = {
		rawSelector,
		isExit: rawSelector.endsWith(":exit"),
		parsedSelector,
		listenerTypes: getPossibleTypes(parsedSelector),
		attributeCount: countClassAttributes(parsedSelector),
		identifierCount: countIdentifiers(parsedSelector),
	};

	selectorCache.set(rawSelector, result);
	return result;
}

//------------------------------------------------------------------------------
// Public Interface
//------------------------------------------------------------------------------

/**
 * The event generator for AST nodes.
 * This implements below interface.
 *
 * ```ts
 * interface EventGenerator {
 *     emitter: SafeEmitter;
 *     enterNode(node: ASTNode): void;
 *     leaveNode(node: ASTNode): void;
 * }
 * ```
 */
class NodeEventGenerator {
	/**
	 * @param {SafeEmitter} emitter
	 * An SafeEmitter which is the destination of events. This emitter must already
	 * have registered listeners for all of the events that it needs to listen for.
	 * (See lib/linter/safe-emitter.js for more details on `SafeEmitter`.)
	 * @param {ESQueryOptions} esqueryOptions `esquery` options for traversing custom nodes.
	 * @returns {NodeEventGenerator} new instance
	 */
	constructor(emitter, esqueryOptions) {
		this.emitter = emitter;
		this.esqueryOptions = esqueryOptions;
		this.currentAncestry = [];
		this.enterSelectorsByNodeType = new Map();
		this.exitSelectorsByNodeType = new Map();
		this.anyTypeEnterSelectors = [];
		this.anyTypeExitSelectors = [];

		emitter.eventNames().forEach(rawSelector => {
			const selector = parseSelector(rawSelector);

			if (selector.listenerTypes) {
				const typeMap = selector.isExit
					? this.exitSelectorsByNodeType
					: this.enterSelectorsByNodeType;

				selector.listenerTypes.forEach(nodeType => {
					if (!typeMap.has(nodeType)) {
						typeMap.set(nodeType, []);
					}
					typeMap.get(nodeType).push(selector);
				});
				return;
			}
			const selectors = selector.isExit
				? this.anyTypeExitSelectors
				: this.anyTypeEnterSelectors;

			selectors.push(selector);
		});

		this.anyTypeEnterSelectors.sort(compareSpecificity);
		this.anyTypeExitSelectors.sort(compareSpecificity);
		this.enterSelectorsByNodeType.forEach(selectorList =>
			selectorList.sort(compareSpecificity),
		);
		this.exitSelectorsByNodeType.forEach(selectorList =>
			selectorList.sort(compareSpecificity),
		);
	}

	/**
	 * Checks a selector against a node, and emits it if it matches
	 * @param {ASTNode} node The node to check
	 * @param {ASTSelector} selector An AST selector descriptor
	 * @returns {void}
	 */
	applySelector(node, selector) {
		if (
			esquery.matches(
				node,
				selector.parsedSelector,
				this.currentAncestry,
				this.esqueryOptions,
			)
		) {
			this.emitter.emit(selector.rawSelector, node);
		}
	}

	/**
	 * Applies all appropriate selectors to a node, in specificity order
	 * @param {ASTNode} node The node to check
	 * @param {boolean} isExit `false` if the node is currently being entered, `true` if it's currently being exited
	 * @returns {void}
	 */
	applySelectors(node, isExit) {
		const selectorsByNodeType =
			(isExit
				? this.exitSelectorsByNodeType
				: this.enterSelectorsByNodeType
			).get(node.type) || [];
		const anyTypeSelectors = isExit
			? this.anyTypeExitSelectors
			: this.anyTypeEnterSelectors;

		/*
		 * selectorsByNodeType and anyTypeSelectors were already sorted by specificity in the constructor.
		 * Iterate through each of them, applying selectors in the right order.
		 */
		let selectorsByTypeIndex = 0;
		let anyTypeSelectorsIndex = 0;

		while (
			selectorsByTypeIndex < selectorsByNodeType.length ||
			anyTypeSelectorsIndex < anyTypeSelectors.length
		) {
			if (
				selectorsByTypeIndex >= selectorsByNodeType.length ||
				(anyTypeSelectorsIndex < anyTypeSelectors.length &&
					compareSpecificity(
						anyTypeSelectors[anyTypeSelectorsIndex],
						selectorsByNodeType[selectorsByTypeIndex],
					) < 0)
			) {
				this.applySelector(
					node,
					anyTypeSelectors[anyTypeSelectorsIndex++],
				);
			} else {
				this.applySelector(
					node,
					selectorsByNodeType[selectorsByTypeIndex++],
				);
			}
		}
	}

	/**
	 * Emits an event of entering AST node.
	 * @param {ASTNode} node A node which was entered.
	 * @returns {void}
	 */
	enterNode(node) {
		this.applySelectors(node, false);
		this.currentAncestry.unshift(node);
	}

	/**
	 * Emits an event of leaving AST node.
	 * @param {ASTNode} node A node which was left.
	 * @returns {void}
	 */
	leaveNode(node) {
		this.currentAncestry.shift();
		this.applySelectors(node, true);
	}
}

module.exports = NodeEventGenerator;
