/**
 * @fileoverview Rule to disallow Math.pow in favor of the ** operator
 * @author Milos Djermanovic
 */

"use strict";

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

const astUtils = require("./utils/ast-utils");
const { CALL, ReferenceTracker } = require("@eslint-community/eslint-utils");

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

const PRECEDENCE_OF_EXPONENTIATION_EXPR = astUtils.getPrecedence({
	type: "BinaryExpression",
	operator: "**",
});

/*
 * Characters that can cause continuation without preceding semi
 */
const continuationChars = new Set(["(", "[", "/", "`"]);

/**
 * Determines whether the given node needs parens if used as the base in an exponentiation binary expression.
 * @param {ASTNode} base The node to check.
 * @returns {boolean} `true` if the node needs to be parenthesised.
 */
function doesBaseNeedParens(base) {
	return (
		// '**' is right-associative, parens are needed when Math.pow(a ** b, c) is converted to (a ** b) ** c
		astUtils.getPrecedence(base) <= PRECEDENCE_OF_EXPONENTIATION_EXPR ||
		// An unary operator cannot be used immediately before an exponentiation expression
		base.type === "AwaitExpression" ||
		base.type === "UnaryExpression"
	);
}

/**
 * Determines whether the given node needs parens if used as the exponent in an exponentiation binary expression.
 * @param {ASTNode} exponent The node to check.
 * @returns {boolean} `true` if the node needs to be parenthesised.
 */
function doesExponentNeedParens(exponent) {
	// '**' is right-associative, there is no need for parens when Math.pow(a, b ** c) is converted to a ** b ** c
	return astUtils.getPrecedence(exponent) < PRECEDENCE_OF_EXPONENTIATION_EXPR;
}

/**
 * Determines whether an exponentiation binary expression at the place of the given node would need parens.
 * @param {ASTNode} node A node that would be replaced by an exponentiation binary expression.
 * @param {SourceCode} sourceCode A SourceCode object.
 * @returns {boolean} `true` if the expression needs to be parenthesised.
 */
function doesExponentiationExpressionNeedParens(node, sourceCode) {
	const parent =
		node.parent.type === "ChainExpression"
			? node.parent.parent
			: node.parent;

	const parentPrecedence = astUtils.getPrecedence(parent);
	const needsParens =
		parent.type === "ClassDeclaration" ||
		(parent.type.endsWith("Expression") &&
			(parentPrecedence === -1 ||
				parentPrecedence >= PRECEDENCE_OF_EXPONENTIATION_EXPR) &&
			!(
				parent.type === "BinaryExpression" &&
				parent.operator === "**" &&
				parent.right === node
			) &&
			!(
				(parent.type === "CallExpression" ||
					parent.type === "NewExpression") &&
				parent.arguments.includes(node)
			) &&
			!(
				parent.type === "MemberExpression" &&
				parent.computed &&
				parent.property === node
			) &&
			!(parent.type === "ArrayExpression"));

	return needsParens && !astUtils.isParenthesised(sourceCode, node);
}

/**
 * Optionally parenthesizes given text.
 * @param {string} text The text to parenthesize.
 * @param {boolean} shouldParenthesize If `true`, the text will be parenthesised.
 * @returns {string} parenthesised or unchanged text.
 */
function parenthesizeIfShould(text, shouldParenthesize) {
	return shouldParenthesize ? `(${text})` : text;
}

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

/** @type {import('../types').Rule.RuleModule} */
module.exports = {
	meta: {
		type: "suggestion",

		docs: {
			description:
				"Disallow the use of `Math.pow` in favor of the `**` operator",
			recommended: false,
			frozen: true,
			url: "https://eslint.org/docs/latest/rules/prefer-exponentiation-operator",
		},

		schema: [],
		fixable: "code",

		messages: {
			useExponentiation: "Use the '**' operator instead of 'Math.pow'.",
		},
	},

	create(context) {
		const sourceCode = context.sourceCode;

		/**
		 * Reports the given node.
		 * @param {ASTNode} node 'Math.pow()' node to report.
		 * @returns {void}
		 */
		function report(node) {
			context.report({
				node,
				messageId: "useExponentiation",
				fix(fixer) {
					if (
						node.arguments.length !== 2 ||
						node.arguments.some(
							arg => arg.type === "SpreadElement",
						) ||
						sourceCode.getCommentsInside(node).length > 0
					) {
						return null;
					}

					const base = node.arguments[0],
						exponent = node.arguments[1],
						baseText = sourceCode.getText(base),
						exponentText = sourceCode.getText(exponent),
						shouldParenthesizeBase = doesBaseNeedParens(base),
						shouldParenthesizeExponent =
							doesExponentNeedParens(exponent),
						isStartOfExpressionStatement =
							astUtils.isStartOfExpressionStatement(node);

					let shouldParenthesizeAll =
						doesExponentiationExpressionNeedParens(
							node,
							sourceCode,
						);

					/*
					 * `function`, `class`, or `{` token at the start of an expression statement
					 * would cause incorrect parsing.
					 * https://github.com/eslint/eslint/issues/20987
					 */
					if (
						!shouldParenthesizeAll &&
						!shouldParenthesizeBase &&
						isStartOfExpressionStatement
					) {
						const firstTokenOfBase = sourceCode.getFirstToken(base);

						if (
							astUtils.isOpeningBraceToken(firstTokenOfBase) ||
							(firstTokenOfBase.type === "Keyword" &&
								(firstTokenOfBase.value === "function" ||
									firstTokenOfBase.value === "class"))
						) {
							shouldParenthesizeAll = true;
						}
					}

					let prefix = "",
						suffix = "";

					if (!shouldParenthesizeAll) {
						if (!shouldParenthesizeBase) {
							const firstReplacementToken =
									sourceCode.getFirstToken(base),
								tokenBefore = sourceCode.getTokenBefore(node);

							if (
								tokenBefore &&
								tokenBefore.range[1] === node.range[0] &&
								!astUtils.canTokensBeAdjacent(
									tokenBefore,
									firstReplacementToken,
								)
							) {
								prefix = " "; // a+Math.pow(++b, c) -> a+ ++b**c
							}
						}
						if (!shouldParenthesizeExponent) {
							const lastReplacementToken =
									sourceCode.getLastToken(exponent),
								tokenAfter = sourceCode.getTokenAfter(node);

							if (
								tokenAfter &&
								node.range[1] === tokenAfter.range[0] &&
								!astUtils.canTokensBeAdjacent(
									lastReplacementToken,
									tokenAfter,
								)
							) {
								suffix = " "; // Math.pow(a, b)in c -> a**b in c
							}
						}
					}

					const baseReplacement = parenthesizeIfShould(
							baseText,
							shouldParenthesizeBase,
						),
						exponentReplacement = parenthesizeIfShould(
							exponentText,
							shouldParenthesizeExponent,
						),
						replacement = parenthesizeIfShould(
							`${baseReplacement}**${exponentReplacement}`,
							shouldParenthesizeAll,
						);

					if (
						!prefix &&
						isStartOfExpressionStatement &&
						continuationChars.has(replacement[0]) &&
						astUtils.needsPrecedingSemicolon(sourceCode, node)
					) {
						prefix = ";";
					}

					return fixer.replaceText(
						node,
						`${prefix}${replacement}${suffix}`,
					);
				},
			});
		}

		return {
			Program(node) {
				const scope = sourceCode.getScope(node);
				const tracker = new ReferenceTracker(scope);
				const trackMap = {
					Math: {
						pow: { [CALL]: true },
					},
				};

				for (const { node: refNode } of tracker.iterateGlobalReferences(
					trackMap,
				)) {
					report(refNode);
				}
			},
		};
	},
};
