/**
 * @fileoverview Rule to flag statements that use magic numbers (adapted from https://github.com/danielstjules/buddy.js)
 * @author Vincent Lemeunier
 */

"use strict";

const astUtils = require("./utils/ast-utils");

// Maximum array length by the ECMAScript Specification.
const MAX_ARRAY_LENGTH = 2 ** 32 - 1;

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

/**
 * Convert the value to bigint if it's a string. Otherwise return the value as-is.
 * @param {bigint|number|string} x The value to normalize.
 * @returns {bigint|number} The normalized value.
 */
function normalizeIgnoreValue(x) {
	if (typeof x === "string") {
		return BigInt(x.slice(0, -1));
	}
	return x;
}

/**
 * Checks if the node parent is a TypeScript enum member
 * @param {ASTNode} node The node to be validated
 * @returns {boolean} True if the node parent is a TypeScript enum member
 */
function isParentTSEnumDeclaration(node) {
	return node.parent.type === "TSEnumMember";
}

/**
 * Checks if the node is a valid TypeScript numeric literal type.
 * @param {ASTNode} node The node to be validated
 * @returns {boolean} True if the node is a TypeScript numeric literal type
 */
function isTSNumericLiteralType(node) {
	let ancestor = node.parent;

	// Go up while we're part of a type union
	while (ancestor.parent.type === "TSUnionType") {
		ancestor = ancestor.parent;
	}

	// Check if the final ancestor is in a type alias declaration
	return ancestor.parent.type === "TSTypeAliasDeclaration";
}

/**
 * Checks if the node parent is a readonly class property
 * @param {ASTNode} node The node to be validated
 * @returns {boolean} True if the node parent is a readonly class property
 */
function isParentTSReadonlyPropertyDefinition(node) {
	if (node.parent?.type === "PropertyDefinition" && node.parent.readonly) {
		return true;
	}

	return false;
}

/**
 * Checks if the node is part of a type indexed access (eg. Foo[4])
 * @param {ASTNode} node The node to be validated
 * @returns {boolean} True if the node is part of an indexed access
 */
function isAncestorTSIndexedAccessType(node) {
	let ancestor = node.parent;

	/*
	 * Go up another level while we're part of a type union (eg. 1 | 2) or
	 * intersection (eg. 1 & 2)
	 */
	while (
		ancestor.parent.type === "TSUnionType" ||
		ancestor.parent.type === "TSIntersectionType"
	) {
		ancestor = ancestor.parent;
	}

	return ancestor.parent.type === "TSIndexedAccessType";
}

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

		docs: {
			description: "Disallow magic numbers",
			dialects: ["JavaScript", "TypeScript"],
			recommended: false,
			frozen: true,
			url: "https://eslint.org/docs/latest/rules/no-magic-numbers",
		},

		schema: [
			{
				type: "object",
				properties: {
					detectObjects: {
						type: "boolean",
					},
					enforceConst: {
						type: "boolean",
					},
					ignore: {
						type: "array",
						items: {
							anyOf: [
								{ type: "number" },
								{
									type: "string",
									pattern: "^[+-]?(?:0|[1-9][0-9]*)n$",
								},
							],
						},
						uniqueItems: true,
					},
					ignoreArrayIndexes: {
						type: "boolean",
					},
					ignoreDefaultValues: {
						type: "boolean",
					},
					ignoreClassFieldInitialValues: {
						type: "boolean",
					},
					ignoreEnums: {
						type: "boolean",
					},
					ignoreNumericLiteralTypes: {
						type: "boolean",
					},
					ignoreReadonlyClassProperties: {
						type: "boolean",
					},
					ignoreTypeIndexes: {
						type: "boolean",
					},
				},
				additionalProperties: false,
			},
		],

		defaultOptions: [
			{
				detectObjects: false,
				enforceConst: false,
				ignore: [],
				ignoreArrayIndexes: false,
				ignoreDefaultValues: false,
				ignoreClassFieldInitialValues: false,
				ignoreEnums: false,
				ignoreNumericLiteralTypes: false,
				ignoreReadonlyClassProperties: false,
				ignoreTypeIndexes: false,
			},
		],

		messages: {
			useConst: "Number constants declarations must use 'const'.",
			noMagic: "No magic number: {{raw}}.",
		},
	},

	create(context) {
		const {
			detectObjects,
			enforceConst,
			ignore: rawIgnore,
			ignoreArrayIndexes,
			ignoreDefaultValues,
			ignoreClassFieldInitialValues,
			ignoreEnums,
			ignoreNumericLiteralTypes,
			ignoreReadonlyClassProperties,
			ignoreTypeIndexes,
		} = context.options[0];
		const ignore = new Set(rawIgnore.map(normalizeIgnoreValue));

		const okTypes = detectObjects
			? []
			: ["ObjectExpression", "Property", "AssignmentExpression"];

		/**
		 * Returns whether the rule is configured to ignore the given value
		 * @param {bigint|number} value The value to check
		 * @returns {boolean} true if the value is ignored
		 */
		function isIgnoredValue(value) {
			return ignore.has(value);
		}

		/**
		 * Returns whether the number is a default value assignment.
		 * @param {ASTNode} fullNumberNode `Literal` or `UnaryExpression` full number node
		 * @returns {boolean} true if the number is a default value
		 */
		function isDefaultValue(fullNumberNode) {
			const parent = fullNumberNode.parent;

			return (
				parent.type === "AssignmentPattern" &&
				parent.right === fullNumberNode
			);
		}

		/**
		 * Returns whether the number is the initial value of a class field.
		 * @param {ASTNode} fullNumberNode `Literal` or `UnaryExpression` full number node
		 * @returns {boolean} true if the number is the initial value of a class field.
		 */
		function isClassFieldInitialValue(fullNumberNode) {
			const parent = fullNumberNode.parent;

			return (
				parent.type === "PropertyDefinition" &&
				parent.value === fullNumberNode
			);
		}

		/**
		 * Returns whether the given node is used as a radix within parseInt() or Number.parseInt()
		 * @param {ASTNode} fullNumberNode `Literal` or `UnaryExpression` full number node
		 * @returns {boolean} true if the node is radix
		 */
		function isParseIntRadix(fullNumberNode) {
			const parent = fullNumberNode.parent;

			return (
				parent.type === "CallExpression" &&
				fullNumberNode === parent.arguments[1] &&
				(astUtils.isSpecificId(parent.callee, "parseInt") ||
					astUtils.isSpecificMemberAccess(
						parent.callee,
						"Number",
						"parseInt",
					))
			);
		}

		/**
		 * Returns whether the given node is a direct child of a JSX node.
		 * In particular, it aims to detect numbers used as prop values in JSX tags.
		 * Example: <input maxLength={10} />
		 * @param {ASTNode} fullNumberNode `Literal` or `UnaryExpression` full number node
		 * @returns {boolean} true if the node is a JSX number
		 */
		function isJSXNumber(fullNumberNode) {
			return fullNumberNode.parent.type.indexOf("JSX") === 0;
		}

		/**
		 * Returns whether the given node is used as an array index.
		 * Value must coerce to a valid array index name: "0", "1", "2" ... "4294967294".
		 *
		 * All other values, like "-1", "2.5", or "4294967295", are just "normal" object properties,
		 * which can be created and accessed on an array in addition to the array index properties,
		 * but they don't affect array's length and are not considered by methods such as .map(), .forEach() etc.
		 *
		 * The maximum array length by the specification is 2 ** 32 - 1 = 4294967295,
		 * thus the maximum valid index is 2 ** 32 - 2 = 4294967294.
		 *
		 * All notations are allowed, as long as the value coerces to one of "0", "1", "2" ... "4294967294".
		 *
		 * Valid examples:
		 * a[0], a[1], a[1.2e1], a[0xAB], a[0n], a[1n]
		 * a[-0] (same as a[0] because -0 coerces to "0")
		 * a[-0n] (-0n evaluates to 0n)
		 *
		 * Invalid examples:
		 * a[-1], a[-0xAB], a[-1n], a[2.5], a[1.23e1], a[12e-1]
		 * a[4294967295] (above the max index, it's an access to a regular property a["4294967295"])
		 * a[999999999999999999999] (even if it wasn't above the max index, it would be a["1e+21"])
		 * a[1e310] (same as a["Infinity"])
		 * @param {ASTNode} fullNumberNode `Literal` or `UnaryExpression` full number node
		 * @param {bigint|number} value Value expressed by the fullNumberNode
		 * @returns {boolean} true if the node is a valid array index
		 */
		function isArrayIndex(fullNumberNode, value) {
			const parent = fullNumberNode.parent;

			return (
				parent.type === "MemberExpression" &&
				parent.property === fullNumberNode &&
				(Number.isInteger(value) || typeof value === "bigint") &&
				value >= 0 &&
				value < MAX_ARRAY_LENGTH
			);
		}

		return {
			Literal(node) {
				if (!astUtils.isNumericLiteral(node)) {
					return;
				}

				let fullNumberNode;
				let value;
				let raw;

				// Treat unary minus/plus as a part of the number
				if (
					node.parent.type === "UnaryExpression" &&
					["-", "+"].includes(node.parent.operator)
				) {
					fullNumberNode = node.parent;
					value =
						node.parent.operator === "-" ? -node.value : node.value;
					raw = `${node.parent.operator}${node.raw}`;
				} else {
					fullNumberNode = node;
					value = node.value;
					raw = node.raw;
				}

				const parent = fullNumberNode.parent;

				// Always allow radix arguments and JSX props
				if (
					isIgnoredValue(value) ||
					(ignoreDefaultValues && isDefaultValue(fullNumberNode)) ||
					(ignoreClassFieldInitialValues &&
						isClassFieldInitialValue(fullNumberNode)) ||
					(ignoreEnums &&
						isParentTSEnumDeclaration(fullNumberNode)) ||
					(ignoreNumericLiteralTypes &&
						isTSNumericLiteralType(fullNumberNode)) ||
					(ignoreTypeIndexes &&
						isAncestorTSIndexedAccessType(fullNumberNode)) ||
					(ignoreReadonlyClassProperties &&
						isParentTSReadonlyPropertyDefinition(fullNumberNode)) ||
					isParseIntRadix(fullNumberNode) ||
					isJSXNumber(fullNumberNode) ||
					(ignoreArrayIndexes && isArrayIndex(fullNumberNode, value))
				) {
					return;
				}

				if (parent.type === "VariableDeclarator") {
					if (enforceConst && parent.parent.kind !== "const") {
						context.report({
							node: fullNumberNode,
							messageId: "useConst",
						});
					}
				} else if (
					!okTypes.includes(parent.type) ||
					(parent.type === "AssignmentExpression" &&
						parent.left.type === "Identifier")
				) {
					context.report({
						node: fullNumberNode,
						messageId: "noMagic",
						data: {
							raw,
						},
					});
				}
			},
		};
	},
};
