/**
 * @fileoverview Rule to flag use of implied eval via setTimeout and setInterval
 * @author James Allardice
 */

"use strict";

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

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

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

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

		docs: {
			description: "Disallow the use of `eval()`-like methods",
			recommended: false,
			url: "https://eslint.org/docs/latest/rules/no-implied-eval",
		},

		schema: [],

		messages: {
			impliedEval:
				"Implied eval. Consider passing a function instead of a string.",
		},
	},

	create(context) {
		const GLOBAL_CANDIDATES = Object.freeze([
			"global",
			"window",
			"globalThis",
		]);
		const EVAL_LIKE_FUNC_PATTERN =
			/^(?:set(?:Interval|Timeout)|execScript)$/u;
		const sourceCode = context.sourceCode;

		/**
		 * Checks whether a node is evaluated as a string or not.
		 * @param {ASTNode} node A node to check.
		 * @returns {boolean} True if the node is evaluated as a string.
		 */
		function isEvaluatedString(node) {
			if (
				(node.type === "Literal" && typeof node.value === "string") ||
				node.type === "TemplateLiteral"
			) {
				return true;
			}
			if (node.type === "BinaryExpression" && node.operator === "+") {
				return (
					isEvaluatedString(node.left) ||
					isEvaluatedString(node.right)
				);
			}
			return false;
		}

		/**
		 * Reports if the `CallExpression` node has evaluated argument.
		 * @param {ASTNode} node A CallExpression to check.
		 * @returns {void}
		 */
		function reportImpliedEvalCallExpression(node) {
			const [firstArgument] = node.arguments;

			if (firstArgument) {
				const staticValue = getStaticValue(
					firstArgument,
					sourceCode.getScope(node),
				);
				const isStaticString =
					staticValue && typeof staticValue.value === "string";
				const isString =
					isStaticString || isEvaluatedString(firstArgument);

				if (isString) {
					context.report({
						node,
						messageId: "impliedEval",
					});
				}
			}
		}

		/**
		 * Reports calls of `implied eval` via the global references.
		 * @param {Variable} globalVar A global variable to check.
		 * @returns {void}
		 */
		function reportImpliedEvalViaGlobal(globalVar) {
			const { references, name } = globalVar;

			references.forEach(ref => {
				const identifier = ref.identifier;
				let node = identifier.parent;

				while (astUtils.isSpecificMemberAccess(node, null, name)) {
					node = node.parent;
				}

				if (
					astUtils.isSpecificMemberAccess(
						node,
						null,
						EVAL_LIKE_FUNC_PATTERN,
					)
				) {
					const calleeNode =
						node.parent.type === "ChainExpression"
							? node.parent
							: node;
					const parent = calleeNode.parent;

					if (
						parent.type === "CallExpression" &&
						parent.callee === calleeNode
					) {
						reportImpliedEvalCallExpression(parent);
					}
				}
			});
		}

		//--------------------------------------------------------------------------
		// Public
		//--------------------------------------------------------------------------

		return {
			CallExpression(node) {
				if (
					astUtils.isSpecificId(node.callee, EVAL_LIKE_FUNC_PATTERN)
				) {
					reportImpliedEvalCallExpression(node);
				}
			},
			"Program:exit"(node) {
				const globalScope = sourceCode.getScope(node);

				GLOBAL_CANDIDATES.map(candidate =>
					astUtils.getVariableByName(globalScope, candidate),
				)
					.filter(
						globalVar => !!globalVar && globalVar.defs.length === 0,
					)
					.forEach(reportImpliedEvalViaGlobal);
			},
		};
	},
};
