/**
 * @fileoverview A rule to control the use of single variable declarations.
 * @author Ian Christian Myers
 */

"use strict";

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

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

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

/**
 * Determines whether the given node is in a statement list.
 * @param {ASTNode} node node to check
 * @returns {boolean} `true` if the given node is in a statement list
 */
function isInStatementList(node) {
	return astUtils.STATEMENT_LIST_PARENTS.has(node.parent.type);
}

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

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

		docs: {
			description:
				"Enforce variables to be declared either together or separately in functions",
			recommended: false,
			frozen: true,
			url: "https://eslint.org/docs/latest/rules/one-var",
		},

		fixable: "code",

		schema: [
			{
				oneOf: [
					{
						enum: ["always", "never", "consecutive"],
					},
					{
						type: "object",
						properties: {
							separateRequires: {
								type: "boolean",
							},
							var: {
								enum: ["always", "never", "consecutive"],
							},
							let: {
								enum: ["always", "never", "consecutive"],
							},
							const: {
								enum: ["always", "never", "consecutive"],
							},
							using: {
								enum: ["always", "never", "consecutive"],
							},
							awaitUsing: {
								enum: ["always", "never", "consecutive"],
							},
						},
						additionalProperties: false,
					},
					{
						type: "object",
						properties: {
							initialized: {
								enum: ["always", "never", "consecutive"],
							},
							uninitialized: {
								enum: ["always", "never", "consecutive"],
							},
						},
						additionalProperties: false,
					},
				],
			},
		],

		defaultOptions: ["always"],

		messages: {
			combineUninitialized:
				"Combine this with the previous '{{type}}' statement with uninitialized variables.",
			combineInitialized:
				"Combine this with the previous '{{type}}' statement with initialized variables.",
			splitUninitialized:
				"Split uninitialized '{{type}}' declarations into multiple statements.",
			splitInitialized:
				"Split initialized '{{type}}' declarations into multiple statements.",
			splitRequires:
				"Split requires to be separated into a single block.",
			combine: "Combine this with the previous '{{type}}' statement.",
			split: "Split '{{type}}' declarations into multiple statements.",
		},
	},

	create(context) {
		const MODE_ALWAYS = "always";
		const MODE_NEVER = "never";
		const MODE_CONSECUTIVE = "consecutive";
		const mode = context.options[0];

		const options = {};

		if (typeof mode === "string") {
			// simple options configuration with just a string
			options.var = { uninitialized: mode, initialized: mode };
			options.let = { uninitialized: mode, initialized: mode };
			options.const = { uninitialized: mode, initialized: mode };
			options.using = { uninitialized: mode, initialized: mode };
			options.awaitUsing = { uninitialized: mode, initialized: mode };
		} else if (typeof mode === "object") {
			// options configuration is an object
			options.separateRequires = !!mode.separateRequires;
			options.var = { uninitialized: mode.var, initialized: mode.var };
			options.let = { uninitialized: mode.let, initialized: mode.let };
			options.const = {
				uninitialized: mode.const,
				initialized: mode.const,
			};
			options.using = {
				uninitialized: mode.using,
				initialized: mode.using,
			};
			options.awaitUsing = {
				uninitialized: mode.awaitUsing,
				initialized: mode.awaitUsing,
			};
			if (Object.hasOwn(mode, "uninitialized")) {
				options.var.uninitialized = mode.uninitialized;
				options.let.uninitialized = mode.uninitialized;
				options.const.uninitialized = mode.uninitialized;
				options.using.uninitialized = mode.uninitialized;
				options.awaitUsing.uninitialized = mode.uninitialized;
			}
			if (Object.hasOwn(mode, "initialized")) {
				options.var.initialized = mode.initialized;
				options.let.initialized = mode.initialized;
				options.const.initialized = mode.initialized;
				options.using.initialized = mode.initialized;
				options.awaitUsing.initialized = mode.initialized;
			}
		}

		const sourceCode = context.sourceCode;

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

		const functionStack = [];
		const blockStack = [];

		/**
		 * Increments the blockStack counter.
		 * @returns {void}
		 * @private
		 */
		function startBlock() {
			blockStack.push({
				let: { initialized: false, uninitialized: false },
				const: { initialized: false, uninitialized: false },
				using: { initialized: false, uninitialized: false },
				awaitUsing: { initialized: false, uninitialized: false },
			});
		}

		/**
		 * Increments the functionStack counter.
		 * @returns {void}
		 * @private
		 */
		function startFunction() {
			functionStack.push({ initialized: false, uninitialized: false });
			startBlock();
		}

		/**
		 * Decrements the blockStack counter.
		 * @returns {void}
		 * @private
		 */
		function endBlock() {
			blockStack.pop();
		}

		/**
		 * Decrements the functionStack counter.
		 * @returns {void}
		 * @private
		 */
		function endFunction() {
			functionStack.pop();
			endBlock();
		}

		/**
		 * Check if a variable declaration is a require.
		 * @param {ASTNode} decl variable declaration Node
		 * @returns {bool} if decl is a require, return true; else return false.
		 * @private
		 */
		function isRequire(decl) {
			return (
				decl.init &&
				decl.init.type === "CallExpression" &&
				decl.init.callee.name === "require"
			);
		}

		/**
		 * Records whether initialized/uninitialized/required variables are defined in current scope.
		 * @param {string} statementType one of: "var", "let", "const", "using", or "awaitUsing"
		 * @param {ASTNode[]} declarations List of declarations
		 * @param {Object} currentScope The scope being investigated
		 * @returns {void}
		 * @private
		 */
		function recordTypes(statementType, declarations, currentScope) {
			for (let i = 0; i < declarations.length; i++) {
				if (declarations[i].init === null) {
					if (
						options[statementType] &&
						options[statementType].uninitialized === MODE_ALWAYS
					) {
						currentScope.uninitialized = true;
					}
				} else {
					if (
						options[statementType] &&
						options[statementType].initialized === MODE_ALWAYS
					) {
						if (
							options.separateRequires &&
							isRequire(declarations[i])
						) {
							currentScope.required = true;
						} else {
							currentScope.initialized = true;
						}
					}
				}
			}
		}

		/**
		 * Determines the current scope (function or block)
		 * @param {string} statementType one of: "var", "let", "const", "using", or "awaitUsing"
		 * @returns {Object} The scope associated with statementType
		 */
		function getCurrentScope(statementType) {
			let currentScope;

			if (statementType === "var") {
				currentScope = functionStack.at(-1);
			} else if (statementType === "let") {
				currentScope = blockStack.at(-1).let;
			} else if (statementType === "const") {
				currentScope = blockStack.at(-1).const;
			} else if (statementType === "using") {
				currentScope = blockStack.at(-1).using;
			} else if (statementType === "awaitUsing") {
				currentScope = blockStack.at(-1).awaitUsing;
			}
			return currentScope;
		}

		/**
		 * Counts the number of initialized and uninitialized declarations in a list of declarations
		 * @param {ASTNode[]} declarations List of declarations
		 * @returns {Object} Counts of 'uninitialized' and 'initialized' declarations
		 * @private
		 */
		function countDeclarations(declarations) {
			const counts = { uninitialized: 0, initialized: 0 };

			for (let i = 0; i < declarations.length; i++) {
				if (declarations[i].init === null) {
					counts.uninitialized++;
				} else {
					counts.initialized++;
				}
			}
			return counts;
		}

		/**
		 * Determines if there is more than one var statement in the current scope.
		 * @param {string} statementType one of: "var", "let", "const", "using", or "awaitUsing"
		 * @param {ASTNode[]} declarations List of declarations
		 * @returns {boolean} Returns true if it is the first var declaration, false if not.
		 * @private
		 */
		function hasOnlyOneStatement(statementType, declarations) {
			const declarationCounts = countDeclarations(declarations);
			const currentOptions = options[statementType] || {};
			const currentScope = getCurrentScope(statementType);
			const hasRequires = declarations.some(isRequire);

			if (
				currentOptions.uninitialized === MODE_ALWAYS &&
				currentOptions.initialized === MODE_ALWAYS
			) {
				if (currentScope.uninitialized || currentScope.initialized) {
					if (!hasRequires) {
						return false;
					}
				}
			}

			if (declarationCounts.uninitialized > 0) {
				if (
					currentOptions.uninitialized === MODE_ALWAYS &&
					currentScope.uninitialized
				) {
					return false;
				}
			}
			if (declarationCounts.initialized > 0) {
				if (
					currentOptions.initialized === MODE_ALWAYS &&
					currentScope.initialized
				) {
					if (!hasRequires) {
						return false;
					}
				}
			}
			if (currentScope.required && hasRequires) {
				return false;
			}
			recordTypes(statementType, declarations, currentScope);
			return true;
		}

		/**
		 * Fixer to join VariableDeclaration's into a single declaration
		 * @param {VariableDeclarator[]} declarations The `VariableDeclaration` to join
		 * @returns {Function} The fixer function
		 */
		function joinDeclarations(declarations) {
			const declaration = declarations[0];
			const body = Array.isArray(declaration.parent.parent.body)
				? declaration.parent.parent.body
				: [];
			const currentIndex = body.findIndex(
				node => node.range[0] === declaration.parent.range[0],
			);
			const previousNode = body[currentIndex - 1];

			return function* joinDeclarationsFixer(fixer) {
				const type = sourceCode.getFirstToken(declaration.parent);
				const beforeType = sourceCode.getTokenBefore(type);

				if (
					previousNode &&
					previousNode.kind === declaration.parent.kind
				) {
					if (beforeType.value === ";") {
						yield fixer.replaceText(beforeType, ",");
					} else {
						yield fixer.insertTextAfter(beforeType, ",");
					}

					if (declaration.parent.kind === "await using") {
						const usingToken = sourceCode.getTokenAfter(type);
						yield fixer.remove(usingToken);
					}

					yield fixer.replaceText(type, "");
				}
			};
		}

		/**
		 * Fixer to split a VariableDeclaration into individual declarations
		 * @param {VariableDeclaration} declaration The `VariableDeclaration` to split
		 * @returns {Function|null} The fixer function
		 */
		function splitDeclarations(declaration) {
			const { parent } = declaration;

			// don't autofix code such as: if (foo) var x, y;
			if (
				!isInStatementList(
					parent.type === "ExportNamedDeclaration"
						? parent
						: declaration,
				)
			) {
				return null;
			}

			return fixer =>
				declaration.declarations
					.map(declarator => {
						const tokenAfterDeclarator =
							sourceCode.getTokenAfter(declarator);

						if (tokenAfterDeclarator === null) {
							return null;
						}

						const afterComma = sourceCode.getTokenAfter(
							tokenAfterDeclarator,
							{ includeComments: true },
						);

						if (tokenAfterDeclarator.value !== ",") {
							return null;
						}

						const exportPlacement =
							declaration.parent.type === "ExportNamedDeclaration"
								? "export "
								: "";

						/*
						 * `var x,y`
						 * tokenAfterDeclarator ^^ afterComma
						 */
						if (
							afterComma.range[0] ===
							tokenAfterDeclarator.range[1]
						) {
							return fixer.replaceText(
								tokenAfterDeclarator,
								`; ${exportPlacement}${declaration.kind} `,
							);
						}

						/*
						 * `var x,
						 * tokenAfterDeclarator ^
						 *      y`
						 *      ^ afterComma
						 */
						if (
							afterComma.loc.start.line >
								tokenAfterDeclarator.loc.end.line ||
							afterComma.type === "Line" ||
							afterComma.type === "Block"
						) {
							let lastComment = afterComma;

							while (
								lastComment.type === "Line" ||
								lastComment.type === "Block"
							) {
								lastComment = sourceCode.getTokenAfter(
									lastComment,
									{ includeComments: true },
								);
							}

							return fixer.replaceTextRange(
								[
									tokenAfterDeclarator.range[0],
									lastComment.range[0],
								],
								`;${sourceCode.text.slice(
									tokenAfterDeclarator.range[1],
									lastComment.range[0],
								)}${exportPlacement}${declaration.kind} `,
							);
						}

						return fixer.replaceText(
							tokenAfterDeclarator,
							`; ${exportPlacement}${declaration.kind}`,
						);
					})
					.filter(x => x);
		}

		/**
		 * Checks a given VariableDeclaration node for errors.
		 * @param {ASTNode} node The VariableDeclaration node to check
		 * @returns {void}
		 * @private
		 */
		function checkVariableDeclaration(node) {
			const parent = node.parent;
			const type = node.kind;
			const key = type === "await using" ? "awaitUsing" : type;

			if (!options[key]) {
				return;
			}

			const declarations = node.declarations;
			const declarationCounts = countDeclarations(declarations);
			const mixedRequires =
				declarations.some(isRequire) && !declarations.every(isRequire);

			if (options[key].initialized === MODE_ALWAYS) {
				if (options.separateRequires && mixedRequires) {
					context.report({
						node,
						messageId: "splitRequires",
					});
				}
			}

			// consecutive
			const nodeIndex =
				(parent.body &&
					parent.body.length > 0 &&
					parent.body.indexOf(node)) ||
				0;

			if (nodeIndex > 0) {
				const previousNode = parent.body[nodeIndex - 1];
				const isPreviousNodeDeclaration =
					previousNode.type === "VariableDeclaration";
				const declarationsWithPrevious = declarations.concat(
					previousNode.declarations || [],
				);

				if (
					isPreviousNodeDeclaration &&
					previousNode.kind === type &&
					!(
						declarationsWithPrevious.some(isRequire) &&
						!declarationsWithPrevious.every(isRequire)
					)
				) {
					const previousDeclCounts = countDeclarations(
						previousNode.declarations,
					);

					if (
						options[key].initialized === MODE_CONSECUTIVE &&
						options[key].uninitialized === MODE_CONSECUTIVE
					) {
						context.report({
							node,
							messageId: "combine",
							data: {
								type,
							},
							fix: joinDeclarations(declarations),
						});
					} else if (
						options[key].initialized === MODE_CONSECUTIVE &&
						declarationCounts.initialized > 0 &&
						previousDeclCounts.initialized > 0
					) {
						context.report({
							node,
							messageId: "combineInitialized",
							data: {
								type,
							},
							fix: joinDeclarations(declarations),
						});
					} else if (
						options[key].uninitialized === MODE_CONSECUTIVE &&
						declarationCounts.uninitialized > 0 &&
						previousDeclCounts.uninitialized > 0
					) {
						context.report({
							node,
							messageId: "combineUninitialized",
							data: {
								type,
							},
							fix: joinDeclarations(declarations),
						});
					}
				}
			}

			// always
			if (!hasOnlyOneStatement(key, declarations)) {
				if (
					options[key].initialized === MODE_ALWAYS &&
					options[key].uninitialized === MODE_ALWAYS
				) {
					context.report({
						node,
						messageId: "combine",
						data: {
							type,
						},
						fix: joinDeclarations(declarations),
					});
				} else {
					if (
						options[key].initialized === MODE_ALWAYS &&
						declarationCounts.initialized > 0
					) {
						context.report({
							node,
							messageId: "combineInitialized",
							data: {
								type,
							},
							fix: joinDeclarations(declarations),
						});
					}
					if (
						options[key].uninitialized === MODE_ALWAYS &&
						declarationCounts.uninitialized > 0
					) {
						if (
							node.parent.left === node &&
							(node.parent.type === "ForInStatement" ||
								node.parent.type === "ForOfStatement")
						) {
							return;
						}
						context.report({
							node,
							messageId: "combineUninitialized",
							data: {
								type,
							},
							fix: joinDeclarations(declarations),
						});
					}
				}
			}

			// never
			if (parent.type !== "ForStatement" || parent.init !== node) {
				const totalDeclarations =
					declarationCounts.uninitialized +
					declarationCounts.initialized;

				if (totalDeclarations > 1) {
					if (
						options[key].initialized === MODE_NEVER &&
						options[key].uninitialized === MODE_NEVER
					) {
						// both initialized and uninitialized
						context.report({
							node,
							messageId: "split",
							data: {
								type,
							},
							fix: splitDeclarations(node),
						});
					} else if (
						options[key].initialized === MODE_NEVER &&
						declarationCounts.initialized > 0
					) {
						// initialized
						context.report({
							node,
							messageId: "splitInitialized",
							data: {
								type,
							},
							fix: splitDeclarations(node),
						});
					} else if (
						options[key].uninitialized === MODE_NEVER &&
						declarationCounts.uninitialized > 0
					) {
						// uninitialized
						context.report({
							node,
							messageId: "splitUninitialized",
							data: {
								type,
							},
							fix: splitDeclarations(node),
						});
					}
				}
			}
		}

		//--------------------------------------------------------------------------
		// Public API
		//--------------------------------------------------------------------------

		return {
			Program: startFunction,
			FunctionDeclaration: startFunction,
			FunctionExpression: startFunction,
			ArrowFunctionExpression: startFunction,
			StaticBlock: startFunction, // StaticBlock creates a new scope for `var` variables

			BlockStatement: startBlock,
			ForStatement: startBlock,
			ForInStatement: startBlock,
			ForOfStatement: startBlock,
			SwitchStatement: startBlock,
			VariableDeclaration: checkVariableDeclaration,
			"ForStatement:exit": endBlock,
			"ForOfStatement:exit": endBlock,
			"ForInStatement:exit": endBlock,
			"SwitchStatement:exit": endBlock,
			"BlockStatement:exit": endBlock,

			"Program:exit": endFunction,
			"FunctionDeclaration:exit": endFunction,
			"FunctionExpression:exit": endFunction,
			"ArrowFunctionExpression:exit": endFunction,
			"StaticBlock:exit": endFunction,
		};
	},
};
