/**
 * @fileoverview Rule to preserve caught errors when re-throwing exceptions
 * @author Amnish Singh Arora
 */
"use strict";

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

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

//------------------------------------------------------------------------------
// Types
//------------------------------------------------------------------------------

/** @typedef {import("estree").Node} ASTNode */

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

/*
 * This is an indicator of an error cause node, that is too complicated to be detected and fixed.
 * Eg, when error options is an `Identifier` or a `SpreadElement`.
 */
const UNKNOWN_CAUSE = Symbol("unknown_cause");

const BUILT_IN_ERROR_TYPES = new Set([
	"Error",
	"EvalError",
	"RangeError",
	"ReferenceError",
	"SyntaxError",
	"TypeError",
	"URIError",
	"AggregateError",
]);

/**
 * Finds and returns information about the `cause` property of an error being thrown.
 * @param {ASTNode} throwStatement `ThrowStatement` to be checked.
 * @returns {{ value: ASTNode; multipleDefinitions: boolean; } | UNKNOWN_CAUSE | null}
 * Information about the `cause` of the error being thrown, such as the value node and
 * whether there are multiple definitions of `cause`. `null` if there is no `cause`.
 */
function getErrorCause(throwStatement) {
	const throwExpression = throwStatement.argument;
	/*
	 * Determine which argument index holds the options object
	 * `AggregateError` is a special case as it accepts the `options` object as third argument.
	 */
	const optionsIndex =
		throwExpression.callee.name === "AggregateError" ? 2 : 1;

	/*
	 * Make sure there is no `SpreadElement` at or before the `optionsIndex`
	 * as this messes up the effective order of arguments and makes it complicated
	 * to track where the actual error options need to be at
	 */
	const spreadExpressionIndex = throwExpression.arguments.findIndex(
		arg => arg.type === "SpreadElement",
	);
	if (spreadExpressionIndex >= 0 && spreadExpressionIndex <= optionsIndex) {
		return UNKNOWN_CAUSE;
	}

	const errorOptions = throwExpression.arguments[optionsIndex];

	if (errorOptions) {
		if (errorOptions.type === "ObjectExpression") {
			if (
				errorOptions.properties.some(
					prop => prop.type === "SpreadElement",
				)
			) {
				/*
				 * If there is a spread element as part of error options, it is too complicated
				 * to verify if the cause is used properly and auto-fix.
				 */
				return UNKNOWN_CAUSE;
			}

			const causeProperties = errorOptions.properties.filter(
				prop => astUtils.getStaticPropertyName(prop) === "cause",
			);

			const causeProperty = causeProperties.at(-1);
			return causeProperty
				? {
						value: causeProperty.value,
						multipleDefinitions: causeProperties.length > 1,
					}
				: null;
		}

		// Error options exist, but too complicated to be analyzed/fixed
		return UNKNOWN_CAUSE;
	}

	return null;
}

/**
 * Finds and returns the `CatchClause` node, that the `node` is part of.
 * @param {ASTNode} node The AST node to be evaluated.
 * @returns {ASTNode | null } The closest parent `CatchClause` node, `null` if the `node` is not in a catch block.
 */
function findParentCatch(node) {
	let currentNode = node;

	while (currentNode && currentNode.type !== "CatchClause") {
		if (
			[
				"FunctionDeclaration",
				"FunctionExpression",
				"ArrowFunctionExpression",
				"StaticBlock",
			].includes(currentNode.type)
		) {
			/*
			 * Make sure the ThrowStatement is not made inside a function definition or a static block inside a high level catch.
			 * In such cases, the caught error is not directly related to the Throw.
			 *
			 * For example,
			 * try {
			 * } catch (error) {
			 * 	foo = {
			 * 		bar() {
			 *	 	throw new Error();
			 * 	  }
			 * };
			 * }
			 */
			return null;
		}
		currentNode = currentNode.parent;
	}

	return currentNode;
}

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

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

		defaultOptions: [
			{
				requireCatchParameter: false,
			},
		],

		docs: {
			description:
				"Disallow losing originally caught error when re-throwing custom errors",
			recommended: true,
			url: "https://eslint.org/docs/latest/rules/preserve-caught-error", // URL to the documentation page for this rule
		},
		/*
		 * TODO: We should allow passing `customErrorTypes` option once something like `typescript-eslint`'s
		 * 		`TypeOrValueSpecifier` is implemented in core Eslint.
		 *      See:
		 * 		1. https://typescript-eslint.io/packages/type-utils/type-or-value-specifier/
		 *      2. https://github.com/eslint/eslint/pull/19913#discussion_r2192608593
		 *      3. https://github.com/eslint/eslint/discussions/16540
		 */
		schema: [
			{
				type: "object",
				properties: {
					requireCatchParameter: {
						type: "boolean",
						description:
							"Requires the catch blocks to always have the caught error parameter so it is not discarded.",
					},
				},
				additionalProperties: false,
			},
		],
		messages: {
			missingCause:
				"There is no `cause` attached to the symptom error being thrown.",
			incorrectCause:
				"The symptom error is being thrown with an incorrect `cause`.",
			includeCause:
				"Include the original caught error as the `cause` of the symptom error.",
			missingCatchErrorParam:
				"The caught error is not accessible because the catch clause lacks the error parameter. Start referencing the caught error using the catch parameter.",
			partiallyLostError:
				"Re-throws cannot preserve the caught error as a part of it is being lost due to destructuring.",
			caughtErrorShadowed:
				"The caught error is being attached as `cause`, but is shadowed by a closer scoped redeclaration.",
		},
		hasSuggestions: true,
	},

	create(context) {
		const sourceCode = context.sourceCode;
		const [{ requireCatchParameter }] = context.options;

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

		/**
		 * Checks if a `ThrowStatement` is constructing and throwing a new `Error` object.
		 *
		 * Covers all the error types on `globalThis` that support `cause` property:
		 * https://github.com/microsoft/TypeScript/blob/main/src/lib/es2022.error.d.ts
		 * @param {ASTNode} throwStatement The `ThrowStatement` that needs to be checked.
		 * @returns {boolean} `true` if a new "Error" is being thrown, else `false`.
		 */
		function isThrowingNewError(throwStatement) {
			return (
				(throwStatement.argument.type === "NewExpression" ||
					throwStatement.argument.type === "CallExpression") &&
				throwStatement.argument.callee.type === "Identifier" &&
				BUILT_IN_ERROR_TYPES.has(throwStatement.argument.callee.name) &&
				/*
				 * Make sure the thrown Error is instance is one of the built-in global error types.
				 * Custom imports could shadow this, which would lead to false positives.
				 * e.g. import { Error } from "./my-custom-error.js";
				 *      throw Error("Failed to perform error prone operations");
				 */
				sourceCode.isGlobalReference(throwStatement.argument.callee)
			);
		}

		/**
		 * Inserts `cause: <caughtErrorName>` into an inline options object expression.
		 * @param {RuleFixer} fixer The fixer object.
		 * @param {ASTNode} optionsNode The options object node.
		 * @param {string} caughtErrorName The name of the caught error (e.g., "err").
		 * @returns {Fix} The fix object.
		 */
		function insertCauseIntoOptions(fixer, optionsNode, caughtErrorName) {
			const properties = optionsNode.properties;

			if (properties.length === 0) {
				// Insert inside empty braces: `{}` → `{ cause: err }`
				return fixer.insertTextAfter(
					sourceCode.getFirstToken(optionsNode),
					`cause: ${caughtErrorName}`,
				);
			}

			const lastProp = properties.at(-1);
			return fixer.insertTextAfter(
				lastProp,
				`, cause: ${caughtErrorName}`,
			);
		}

		//----------------------------------------------------------------------
		// Public
		//----------------------------------------------------------------------
		return {
			ThrowStatement(node) {
				// Check if the throw is inside a catch block
				const parentCatch = findParentCatch(node);
				const throwStatement = node;

				// Check if a new error is being thrown in a catch block
				if (parentCatch && isThrowingNewError(throwStatement)) {
					if (
						parentCatch.param &&
						parentCatch.param.type !== "Identifier"
					) {
						/*
						 * When a part of the caught error is being lost at the parameter level, commonly due to destructuring.
						 * e.g. catch({ message, ...rest })
						 */
						context.report({
							messageId: "partiallyLostError",
							node: parentCatch,
						});
						return;
					}

					const caughtError =
						parentCatch.param?.type === "Identifier"
							? parentCatch.param
							: null;

					// Check if there are throw statements and caught error is being ignored
					if (!caughtError) {
						if (requireCatchParameter) {
							context.report({
								node: throwStatement,
								messageId: "missingCatchErrorParam",
							});
							return;
						}
						return;
					}

					// Check if there is a cause attached to the new error
					const errorCauseInfo = getErrorCause(throwStatement);

					if (errorCauseInfo === UNKNOWN_CAUSE) {
						// Error options exist, but too complicated to be analyzed/fixed
						return;
					}

					if (errorCauseInfo === null) {
						// If there is no `cause` attached to the error being thrown.
						context.report({
							messageId: "missingCause",
							node: throwStatement,
							suggest: [
								{
									messageId: "includeCause",
									fix(fixer) {
										const throwExpression =
											throwStatement.argument;
										const args = throwExpression.arguments;
										const errorType =
											throwExpression.callee.name;

										// AggregateError: errors, message, options
										if (errorType === "AggregateError") {
											const errorsArg = args[0];
											const messageArg = args[1];
											const optionsArg = args[2];

											if (!errorsArg) {
												// Case: `throw new AggregateError()` → insert all arguments
												const lastToken =
													sourceCode.getLastToken(
														throwExpression,
													);
												const lastCalleeToken =
													sourceCode.getLastToken(
														throwExpression.callee,
													);
												const parenToken =
													sourceCode.getFirstTokenBetween(
														lastCalleeToken,
														lastToken,
														astUtils.isOpeningParenToken,
													);

												if (parenToken) {
													return fixer.insertTextAfter(
														parenToken,
														`[], "", { cause: ${caughtError.name} }`,
													);
												}
												return fixer.insertTextAfter(
													throwExpression.callee,
													`([], "", { cause: ${caughtError.name} })`,
												);
											}

											if (!messageArg) {
												// Case: `throw new AggregateError([])` → insert message and options
												return fixer.insertTextAfter(
													errorsArg,
													`, "", { cause: ${caughtError.name} }`,
												);
											}

											if (!optionsArg) {
												// Case: `throw new AggregateError([], "")` → insert error options only
												return fixer.insertTextAfter(
													messageArg,
													`, { cause: ${caughtError.name} }`,
												);
											}

											if (
												optionsArg.type ===
												"ObjectExpression"
											) {
												return insertCauseIntoOptions(
													fixer,
													optionsArg,
													caughtError.name,
												);
											}

											// Complex dynamic options — skip
											return null;
										}

										// Normal Error types
										const messageArg = args[0];
										const optionsArg = args[1];

										if (!messageArg) {
											// Case: `throw new Error()` → insert both message and options
											const lastToken =
												sourceCode.getLastToken(
													throwExpression,
												);
											const lastCalleeToken =
												sourceCode.getLastToken(
													throwExpression.callee,
												);
											const parenToken =
												sourceCode.getFirstTokenBetween(
													lastCalleeToken,
													lastToken,
													astUtils.isOpeningParenToken,
												);

											if (parenToken) {
												return fixer.insertTextAfter(
													parenToken,
													`"", { cause: ${caughtError.name} }`,
												);
											}
											return fixer.insertTextAfter(
												throwExpression.callee,
												`("", { cause: ${caughtError.name} })`,
											);
										}
										if (!optionsArg) {
											// Case: `throw new Error("Some message")` → insert only options
											return fixer.insertTextAfter(
												messageArg,
												`, { cause: ${caughtError.name} }`,
											);
										}

										if (
											optionsArg.type ===
											"ObjectExpression"
										) {
											return insertCauseIntoOptions(
												fixer,
												optionsArg,
												caughtError.name,
											);
										}

										return null; // Identifier or spread — do not fix
									},
								},
							],
						});

						// We don't need to check further
						return;
					}

					const { value: thrownErrorCause } = errorCauseInfo;

					// If there is an attached cause, verify that it matches the caught error
					if (
						!(
							thrownErrorCause.type === "Identifier" &&
							thrownErrorCause.name === caughtError.name
						)
					) {
						const suggest = errorCauseInfo.multipleDefinitions
							? null // If there are multiple `cause` definitions, a suggestion could be confusing.
							: [
									{
										messageId: "includeCause",
										fix(fixer) {
											/*
											 * In case `cause` is attached using object property shorthand or as a method or accessor.
											 * e.g. throw Error("fail", { cause });
											 *      throw Error("fail", { cause() { doSomething(); } });
											 *      throw Error("fail", { get cause() { return error; } });
											 */
											if (
												thrownErrorCause.parent
													.method ||
												thrownErrorCause.parent
													.shorthand ||
												thrownErrorCause.parent.kind !==
													"init"
											) {
												return fixer.replaceText(
													thrownErrorCause.parent,
													`cause: ${caughtError.name}`,
												);
											}

											return fixer.replaceText(
												thrownErrorCause,
												caughtError.name,
											);
										},
									},
								];
						context.report({
							messageId: "incorrectCause",
							node: thrownErrorCause,
							suggest,
						});
						return;
					}

					/*
					 * If the attached cause matches the identifier name of the caught error,
					 * make sure it is not being shadowed by a closer scoped redeclaration.
					 *
					 * e.g. try {
					 *      doSomething();
					 * 	  } catch (error) {
					 * 	     if (whatever) {
					 * 	       const error = anotherError;
					 * 	       throw new Error("Something went wrong");
					 * 	     }
					 *   }
					 */
					let scope = sourceCode.getScope(throwStatement);
					do {
						const variable = scope.set.get(caughtError.name);
						if (variable) {
							break;
						}
						scope = scope.upper;
					} while (scope);

					if (scope?.block !== parentCatch) {
						// Caught error is being shadowed
						context.report({
							messageId: "caughtErrorShadowed",
							node: throwStatement,
						});
					}
				}
			},
		};
	},
};
