/**
 * @fileoverview Rule to flag comparisons to the value NaN
 * @author James Allardice
 */

"use strict";

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

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

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

/**
 * Determines if the given node is a NaN `Identifier` node.
 * @param {ASTNode|null} node The node to check.
 * @param {SourceCode} sourceCode The source code object.
 * @returns {boolean} `true` if the node is a global 'NaN' identifier.
 */
function isNaNIdentifier(node, sourceCode) {
	if (!node) {
		return false;
	}

	const nodeToCheck =
		node.type === "SequenceExpression" ? node.expressions.at(-1) : node;

	if (astUtils.isSpecificId(nodeToCheck, "NaN")) {
		return sourceCode.isGlobalReference(nodeToCheck);
	}

	if (astUtils.isSpecificMemberAccess(nodeToCheck, "Number", "NaN")) {
		const objectNode = astUtils.skipChainExpression(nodeToCheck).object;
		return sourceCode.isGlobalReference(objectNode);
	}

	return false;
}

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

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

		docs: {
			description: "Require calls to `isNaN()` when checking for `NaN`",
			recommended: true,
			url: "https://eslint.org/docs/latest/rules/use-isnan",
		},

		schema: [
			{
				type: "object",
				properties: {
					enforceForSwitchCase: {
						type: "boolean",
					},
					enforceForIndexOf: {
						type: "boolean",
					},
				},
				additionalProperties: false,
			},
		],

		defaultOptions: [
			{
				enforceForIndexOf: false,
				enforceForSwitchCase: true,
			},
		],

		messages: {
			comparisonWithNaN: "Use the isNaN function to compare with NaN.",
			switchNaN:
				"'switch(NaN)' can never match a case clause. Use Number.isNaN instead of the switch.",
			caseNaN:
				"'case NaN' can never match. Use Number.isNaN before the switch.",
			indexOfNaN:
				"Array prototype method '{{ methodName }}' cannot find NaN.",
			replaceWithIsNaN: "Replace with Number.isNaN.",
			replaceWithCastingAndIsNaN:
				"Replace with Number.isNaN and cast to a Number.",
			replaceWithFindIndex:
				"Replace with Array.prototype.{{ methodName }}.",
		},
	},

	create(context) {
		const [{ enforceForIndexOf, enforceForSwitchCase }] = context.options;
		const sourceCode = context.sourceCode;

		const fixableOperators = new Set(["==", "===", "!=", "!=="]);
		const castableOperators = new Set(["==", "!="]);

		/**
		 * Get a fixer for a binary expression that compares to NaN.
		 * @param  {ASTNode} node The node to fix.
		 * @param {function(string): string} wrapValue A function that wraps the compared value with a fix.
		 * @returns {function(Fixer): Fix} The fixer function.
		 */
		function getBinaryExpressionFixer(node, wrapValue) {
			return fixer => {
				const comparedValue = isNaNIdentifier(node.left, sourceCode)
					? node.right
					: node.left;
				const shouldWrap = comparedValue.type === "SequenceExpression";
				const shouldNegate = node.operator[0] === "!";

				const negation = shouldNegate ? "!" : "";
				let comparedValueText = sourceCode.getText(comparedValue);

				if (shouldWrap) {
					comparedValueText = `(${comparedValueText})`;
				}

				const fixedValue = wrapValue(comparedValueText);

				return fixer.replaceText(node, `${negation}${fixedValue}`);
			};
		}

		/**
		 * Checks the given `BinaryExpression` node for `foo === NaN` and other comparisons.
		 * @param {ASTNode} node The node to check.
		 * @returns {void}
		 */
		function checkBinaryExpression(node) {
			if (
				/^(?:[<>]|[!=]=)=?$/u.test(node.operator) &&
				(isNaNIdentifier(node.left, sourceCode) ||
					isNaNIdentifier(node.right, sourceCode))
			) {
				const suggestedFixes = [];
				const NaNNode = isNaNIdentifier(node.left, sourceCode)
					? node.left
					: node.right;

				const isSequenceExpression =
					NaNNode.type === "SequenceExpression";
				const isSuggestable =
					fixableOperators.has(node.operator) &&
					!isSequenceExpression;
				const isCastable = castableOperators.has(node.operator);

				if (isSuggestable) {
					suggestedFixes.push({
						messageId: "replaceWithIsNaN",
						fix: getBinaryExpressionFixer(
							node,
							value => `Number.isNaN(${value})`,
						),
					});

					if (isCastable) {
						suggestedFixes.push({
							messageId: "replaceWithCastingAndIsNaN",
							fix: getBinaryExpressionFixer(
								node,
								value => `Number.isNaN(Number(${value}))`,
							),
						});
					}
				}

				context.report({
					node,
					messageId: "comparisonWithNaN",
					suggest: suggestedFixes,
				});
			}
		}

		/**
		 * Checks the discriminant and all case clauses of the given `SwitchStatement` node for `switch(NaN)` and `case NaN:`
		 * @param {ASTNode} node The node to check.
		 * @returns {void}
		 */
		function checkSwitchStatement(node) {
			if (isNaNIdentifier(node.discriminant, sourceCode)) {
				context.report({ node, messageId: "switchNaN" });
			}

			for (const switchCase of node.cases) {
				if (isNaNIdentifier(switchCase.test, sourceCode)) {
					context.report({ node: switchCase, messageId: "caseNaN" });
				}
			}
		}

		/**
		 * Checks the given `CallExpression` node for `.indexOf(NaN)` and `.lastIndexOf(NaN)`.
		 * @param {ASTNode} node The node to check.
		 * @returns {void}
		 */
		function checkCallExpression(node) {
			const callee = astUtils.skipChainExpression(node.callee);

			if (callee.type === "MemberExpression") {
				const methodName = astUtils.getStaticPropertyName(callee);

				if (
					(methodName === "indexOf" ||
						methodName === "lastIndexOf") &&
					node.arguments.length <= 2 &&
					isNaNIdentifier(node.arguments[0], sourceCode)
				) {
					/*
					 * To retain side effects, it's essential to address `NaN` beforehand, which
					 * is not possible with fixes like `arr.findIndex(Number.isNaN)`.
					 */
					const isSuggestable =
						node.arguments[0].type !== "SequenceExpression" &&
						!node.arguments[1];
					const suggestedFixes = [];

					if (isSuggestable) {
						const shouldWrap = callee.computed;
						const findIndexMethod =
							methodName === "indexOf"
								? "findIndex"
								: "findLastIndex";
						const propertyName = shouldWrap
							? `"${findIndexMethod}"`
							: findIndexMethod;

						suggestedFixes.push({
							messageId: "replaceWithFindIndex",
							data: { methodName: findIndexMethod },
							fix: fixer => [
								fixer.replaceText(
									callee.property,
									propertyName,
								),
								fixer.replaceText(
									node.arguments[0],
									"Number.isNaN",
								),
							],
						});
					}

					context.report({
						node,
						messageId: "indexOfNaN",
						data: { methodName },
						suggest: suggestedFixes,
					});
				}
			}
		}

		const listeners = {
			BinaryExpression: checkBinaryExpression,
		};

		if (enforceForSwitchCase) {
			listeners.SwitchStatement = checkSwitchStatement;
		}

		if (enforceForIndexOf) {
			listeners.CallExpression = checkCallExpression;
		}

		return listeners;
	},
};
