/**
 * @fileoverview A rule to disallow unnecessary assignments`.
 * @author Yosuke Ota
 */

"use strict";

const { findVariable } = require("@eslint-community/eslint-utils");

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

/** @typedef {import("estree").Node} ASTNode */
/** @typedef {import("estree").Pattern} Pattern */
/** @typedef {import("estree").Identifier} Identifier */
/** @typedef {import("estree").VariableDeclarator} VariableDeclarator */
/** @typedef {import("estree").AssignmentExpression} AssignmentExpression */
/** @typedef {import("estree").UpdateExpression} UpdateExpression */
/** @typedef {import("estree").Expression} Expression */
/** @typedef {import("eslint-scope").Scope} Scope */
/** @typedef {import("eslint-scope").Variable} Variable */
/** @typedef {import("../linter/code-path-analysis/code-path")} CodePath */
/** @typedef {import("../linter/code-path-analysis/code-path-segment")} CodePathSegment */

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

/**
 * Extract identifier from the given pattern node used on the left-hand side of the assignment.
 * @param {Pattern} pattern The pattern node to extract identifier
 * @returns {Iterable<Identifier>} The extracted identifier
 */
function* extractIdentifiersFromPattern(pattern) {
	switch (pattern.type) {
		case "Identifier":
			yield pattern;
			return;
		case "ObjectPattern":
			for (const property of pattern.properties) {
				yield* extractIdentifiersFromPattern(
					property.type === "Property" ? property.value : property,
				);
			}
			return;
		case "ArrayPattern":
			for (const element of pattern.elements) {
				if (!element) {
					continue;
				}
				yield* extractIdentifiersFromPattern(element);
			}
			return;
		case "RestElement":
			yield* extractIdentifiersFromPattern(pattern.argument);
			return;
		case "AssignmentPattern":
			yield* extractIdentifiersFromPattern(pattern.left);

		// no default
	}
}

/**
 * Checks whether the given identifier node is evaluated after the assignment identifier.
 * @param {AssignmentInfo} assignment The assignment info.
 * @param {Identifier} identifier The identifier to check.
 * @returns {boolean} `true` if the given identifier node is evaluated after the assignment identifier.
 */
function isIdentifierEvaluatedAfterAssignment(assignment, identifier) {
	if (identifier.range[0] < assignment.identifier.range[1]) {
		return false;
	}
	if (
		assignment.expression &&
		assignment.expression.range[0] <= identifier.range[0] &&
		identifier.range[1] <= assignment.expression.range[1]
	) {
		/*
		 * The identifier node is in an expression that is evaluated before the assignment.
		 * e.g. x = id;
		 *          ^^ identifier to check
		 *      ^      assignment identifier
		 */
		return false;
	}

	/*
	 * e.g.
	 *      x = 42; id;
	 *              ^^ identifier to check
	 *      ^          assignment identifier
	 *      let { x, y = id } = obj;
	 *                   ^^  identifier to check
	 *            ^          assignment identifier
	 */
	return true;
}

/**
 * Checks whether the given identifier node is used between the assigned identifier and the equal sign.
 *
 * e.g. let { x, y = x } = obj;
 *                   ^   identifier to check
 *            ^          assigned identifier
 * @param {AssignmentInfo} assignment The assignment info.
 * @param {Identifier} identifier The identifier to check.
 * @returns {boolean} `true` if the given identifier node is used between the assigned identifier and the equal sign.
 */
function isIdentifierUsedBetweenAssignedAndEqualSign(assignment, identifier) {
	if (!assignment.expression) {
		return false;
	}
	return (
		assignment.identifier.range[1] <= identifier.range[0] &&
		identifier.range[1] <= assignment.expression.range[0]
	);
}

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

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

		docs: {
			description:
				"Disallow variable assignments when the value is not used",
			recommended: true,
			url: "https://eslint.org/docs/latest/rules/no-useless-assignment",
		},

		schema: [],

		messages: {
			unnecessaryAssignment:
				"The value assigned to '{{name}}' is not used in subsequent statements.",
		},
	},

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

		/**
		 * @typedef {Object} ScopeStack
		 * @property {CodePath} codePath The code path of this scope stack.
		 * @property {Scope} scope The scope of this scope stack.
		 * @property {ScopeStack} upper The upper scope stack.
		 * @property {Record<string, ScopeStackSegmentInfo>} segments The map of ScopeStackSegmentInfo.
		 * @property {Set<CodePathSegment>} currentSegments The current CodePathSegments.
		 * @property {Map<Variable, AssignmentInfo[]>} assignments The map of list of AssignmentInfo for each variable.
		 * @property {Array} tryStatementBlocks The array of TryStatement block nodes in this scope stack.
		 */
		/**
		 * @typedef {Object} ScopeStackSegmentInfo
		 * @property {CodePathSegment} segment The code path segment.
		 * @property {Identifier|null} first The first identifier that appears within the segment.
		 * @property {Identifier|null} last The last identifier that appears within the segment.
		 * `first` and `last` are used to determine whether an identifier exists within the segment position range.
		 * Since it is used as a range of segments, we should originally hold all nodes, not just identifiers,
		 * but since the only nodes to be judged are identifiers, it is sufficient to have a range of identifiers.
		 */
		/**
		 * @typedef {Object} AssignmentInfo
		 * @property {Variable} variable The variable that is assigned.
		 * @property {Identifier} identifier The identifier that is assigned.
		 * @property {VariableDeclarator|AssignmentExpression|UpdateExpression} node The node where the variable was updated.
		 * @property {Expression|null} expression The expression that is evaluated before the assignment.
		 * @property {CodePathSegment[]} segments The code path segments where the assignment was made.
		 */

		/** @type {ScopeStack} */
		let scopeStack = null;

		/** @type {Set<Scope>} */
		const codePathStartScopes = new Set();

		/**
		 * Gets the scope of code path start from given scope
		 * @param {Scope} scope The initial scope
		 * @returns {Scope} The scope of code path start
		 * @throws {Error} Unexpected error
		 */
		function getCodePathStartScope(scope) {
			let target = scope;

			while (target) {
				if (codePathStartScopes.has(target)) {
					return target;
				}
				target = target.upper;
			}

			// Should be unreachable
			return null;
		}

		/**
		 * Verify the given scope stack.
		 * @param {ScopeStack} target The scope stack to verify.
		 * @returns {void}
		 */
		function verify(target) {
			/**
			 * Checks whether the given identifier is used in the segment.
			 * @param {CodePathSegment} segment The code path segment.
			 * @param {Identifier} identifier The identifier to check.
			 * @returns {boolean} `true` if the identifier is used in the segment.
			 */
			function isIdentifierUsedInSegment(segment, identifier) {
				const segmentInfo = target.segments[segment.id];

				return (
					segmentInfo.first &&
					segmentInfo.last &&
					segmentInfo.first.range[0] <= identifier.range[0] &&
					identifier.range[1] <= segmentInfo.last.range[1]
				);
			}

			/**
			 * Verifies whether the given assignment info is an used assignment.
			 * Report if it is an unused assignment.
			 * @param {AssignmentInfo} targetAssignment The assignment info to verify.
			 * @param {AssignmentInfo[]} allAssignments The list of all assignment info for variables.
			 * @returns {void}
			 */
			function verifyAssignmentIsUsed(targetAssignment, allAssignments) {
				// Skip assignment if it is in a try block.
				const isAssignmentInTryBlock = target.tryStatementBlocks.some(
					tryBlock =>
						tryBlock.range[0] <=
							targetAssignment.identifier.range[0] &&
						targetAssignment.identifier.range[1] <=
							tryBlock.range[1],
				);

				if (isAssignmentInTryBlock) {
					return;
				}

				/**
				 * @typedef {Object} SubsequentSegmentData
				 * @property {CodePathSegment} segment The code path segment
				 * @property {AssignmentInfo} [assignment] The first occurrence of the assignment within the segment.
				 * There is no need to check if the variable is used after this assignment,
				 * as the value it was assigned will be used.
				 */

				/**
				 * Information used in `getSubsequentSegments()`.
				 * To avoid unnecessary iterations, cache information that has already been iterated over,
				 * and if additional iterations are needed, start iterating from the retained position.
				 */
				const subsequentSegmentData = {
					/**
					 * Cache of subsequent segment information list that have already been iterated.
					 * @type {SubsequentSegmentData[]}
					 */
					results: [],

					/**
					 * Subsequent segments that have already been iterated on. Used to avoid infinite loops.
					 * @type {Set<CodePathSegment>}
					 */
					subsequentSegments: new Set(),

					/**
					 * Unexplored code path segment.
					 * If additional iterations are needed, consume this information and iterate.
					 * @type {CodePathSegment[]}
					 */
					queueSegments: targetAssignment.segments.flatMap(
						segment => segment.nextSegments,
					),
				};

				/**
				 * Gets the subsequent segments from the segment of
				 * the assignment currently being validated (targetAssignment).
				 * @returns {Iterable<SubsequentSegmentData>} the subsequent segments
				 */
				function* getSubsequentSegments() {
					yield* subsequentSegmentData.results;

					while (subsequentSegmentData.queueSegments.length > 0) {
						const nextSegment =
							subsequentSegmentData.queueSegments.shift();

						if (
							subsequentSegmentData.subsequentSegments.has(
								nextSegment,
							)
						) {
							continue;
						}
						subsequentSegmentData.subsequentSegments.add(
							nextSegment,
						);

						const assignmentInSegment = allAssignments.find(
							otherAssignment =>
								otherAssignment.segments.includes(
									nextSegment,
								) &&
								!isIdentifierUsedBetweenAssignedAndEqualSign(
									otherAssignment,
									targetAssignment.identifier,
								),
						);

						if (!assignmentInSegment) {
							/*
							 * Stores the next segment to explore.
							 * If `assignmentInSegment` exists,
							 * we are guarding it because we don't need to explore the next segment.
							 */
							subsequentSegmentData.queueSegments.push(
								...nextSegment.nextSegments,
							);
						}

						/** @type {SubsequentSegmentData} */
						const result = {
							segment: nextSegment,
							assignment: assignmentInSegment,
						};

						subsequentSegmentData.results.push(result);
						yield result;
					}
				}

				if (
					targetAssignment.variable.references.some(ref => {
						const type = ref.identifier.type;
						return (
							type !== "Identifier" && type !== "JSXIdentifier"
						);
					})
				) {
					/**
					 * Skip checking for a variable that has at least one non-identifier reference.
					 * It's generated by plugins and cannot be handled reliably in the core rule.
					 */
					return;
				}

				const readReferences =
					targetAssignment.variable.references.filter(reference =>
						reference.isRead(),
					);

				if (!readReferences.length) {
					/*
					 * It is not just an unnecessary assignment, but an unnecessary (unused) variable
					 * and thus should not be reported by this rule because it is reported by `no-unused-vars`.
					 */
					return;
				}

				/**
				 * Other assignment on the current segment and after current assignment.
				 */
				const otherAssignmentAfterTargetAssignment =
					allAssignments.find(assignment => {
						if (
							assignment === targetAssignment ||
							(assignment.segments.length &&
								assignment.segments.every(
									segment =>
										!targetAssignment.segments.includes(
											segment,
										),
								))
						) {
							return false;
						}
						if (
							isIdentifierEvaluatedAfterAssignment(
								targetAssignment,
								assignment.identifier,
							)
						) {
							return true;
						}
						if (
							assignment.expression &&
							assignment.expression.range[0] <=
								targetAssignment.identifier.range[0] &&
							targetAssignment.identifier.range[1] <=
								assignment.expression.range[1]
						) {
							/*
							 * The target assignment is in an expression that is evaluated before the assignment.
							 * e.g. x=(x=1);
							 *         ^^^ targetAssignment
							 *      ^^^^^^^ assignment
							 */
							return true;
						}

						return false;
					});

				for (const reference of readReferences) {
					/*
					 * If the scope of the reference is outside the current code path scope,
					 * we cannot track whether this assignment is not used.
					 * For example, it can also be called asynchronously.
					 */
					if (
						target.scope !== getCodePathStartScope(reference.from)
					) {
						return;
					}

					// Checks if it is used in the same segment as the target assignment.
					if (
						isIdentifierEvaluatedAfterAssignment(
							targetAssignment,
							reference.identifier,
						) &&
						(isIdentifierUsedBetweenAssignedAndEqualSign(
							targetAssignment,
							reference.identifier,
						) ||
							targetAssignment.segments.some(segment =>
								isIdentifierUsedInSegment(
									segment,
									reference.identifier,
								),
							))
					) {
						if (
							otherAssignmentAfterTargetAssignment &&
							isIdentifierEvaluatedAfterAssignment(
								otherAssignmentAfterTargetAssignment,
								reference.identifier,
							)
						) {
							// There was another assignment before the reference. Therefore, it has not been used yet.
							continue;
						}

						// Uses in statements after the written identifier.
						return;
					}

					if (otherAssignmentAfterTargetAssignment) {
						/*
						 * The assignment was followed by another assignment in the same segment.
						 * Therefore, there is no need to check the next segment.
						 */
						continue;
					}

					// Check subsequent segments.
					for (const subsequentSegment of getSubsequentSegments()) {
						if (
							isIdentifierUsedInSegment(
								subsequentSegment.segment,
								reference.identifier,
							)
						) {
							if (
								subsequentSegment.assignment &&
								isIdentifierEvaluatedAfterAssignment(
									subsequentSegment.assignment,
									reference.identifier,
								)
							) {
								// There was another assignment before the reference. Therefore, it has not been used yet.
								continue;
							}

							// It is used
							return;
						}
					}
				}
				context.report({
					node: targetAssignment.identifier,
					messageId: "unnecessaryAssignment",
					data: { name: targetAssignment.identifier.name },
				});
			}

			// Verify that each assignment in the code path is used.
			for (const assignments of target.assignments.values()) {
				assignments.sort(
					(a, b) => a.identifier.range[0] - b.identifier.range[0],
				);
				for (const assignment of assignments) {
					verifyAssignmentIsUsed(assignment, assignments);
				}
			}
		}

		return {
			onCodePathStart(codePath, node) {
				const scope = sourceCode.getScope(node);

				scopeStack = {
					upper: scopeStack,
					codePath,
					scope,
					segments: Object.create(null),
					currentSegments: new Set(),
					assignments: new Map(),
					tryStatementBlocks: [],
				};
				codePathStartScopes.add(scopeStack.scope);
			},
			onCodePathEnd() {
				verify(scopeStack);

				scopeStack = scopeStack.upper;
			},
			onCodePathSegmentStart(segment) {
				const segmentInfo = { segment, first: null, last: null };

				scopeStack.segments[segment.id] = segmentInfo;
				scopeStack.currentSegments.add(segment);
			},
			onCodePathSegmentEnd(segment) {
				scopeStack.currentSegments.delete(segment);
			},
			TryStatement(node) {
				scopeStack.tryStatementBlocks.push(node.block);
			},
			"Identifier, JSXIdentifier"(node) {
				for (const segment of scopeStack.currentSegments) {
					const segmentInfo = scopeStack.segments[segment.id];

					if (!segmentInfo.first) {
						segmentInfo.first = node;
					}
					segmentInfo.last = node;
				}
			},
			"VariableDeclarator[init!=null], AssignmentExpression, UpdateExpression:exit"(
				node,
			) {
				if (scopeStack.currentSegments.size === 0) {
					// Ignore unreachable segments
					return;
				}

				const assignments = scopeStack.assignments;

				let pattern;
				let expression = null;

				if (node.type === "VariableDeclarator") {
					pattern = node.id;
					expression = node.init;
				} else if (node.type === "AssignmentExpression") {
					pattern = node.left;
					expression = node.right;
				} else {
					// UpdateExpression
					pattern = node.argument;
				}

				for (const identifier of extractIdentifiersFromPattern(
					pattern,
				)) {
					const scope = sourceCode.getScope(identifier);

					/** @type {Variable} */
					const variable = findVariable(scope, identifier);

					if (!variable) {
						continue;
					}

					// We don't know where global variables are used.
					if (
						variable.scope.type === "global" &&
						variable.defs.length === 0
					) {
						continue;
					}

					/*
					 * If the scope of the variable is outside the current code path scope,
					 * we cannot track whether this assignment is not used.
					 */
					if (
						scopeStack.scope !==
						getCodePathStartScope(variable.scope)
					) {
						continue;
					}

					// Variables marked by `markVariableAsUsed()` or
					// exported by "exported" block comment.
					if (variable.eslintUsed) {
						continue;
					}

					// Variables exported by ESM export syntax
					if (variable.scope.type === "module") {
						if (
							variable.defs.some(
								def =>
									(def.type === "Variable" &&
										def.parent.parent.type ===
											"ExportNamedDeclaration") ||
									(def.type === "FunctionName" &&
										(def.node.parent.type ===
											"ExportNamedDeclaration" ||
											def.node.parent.type ===
												"ExportDefaultDeclaration")) ||
									(def.type === "ClassName" &&
										(def.node.parent.type ===
											"ExportNamedDeclaration" ||
											def.node.parent.type ===
												"ExportDefaultDeclaration")),
							)
						) {
							continue;
						}
						if (
							variable.references.some(
								reference =>
									reference.identifier.parent.type ===
									"ExportSpecifier",
							)
						) {
							// It have `export { ... }` reference.
							continue;
						}
					}

					let list = assignments.get(variable);

					if (!list) {
						list = [];
						assignments.set(variable, list);
					}
					list.push({
						variable,
						identifier,
						node,
						expression,
						segments: [...scopeStack.currentSegments],
					});
				}
			},
		};
	},
};
