/**
 * @fileoverview Rule to enforce sorted `import` declarations within modules
 * @author Christian Schuller
 */

"use strict";

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

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

		defaultOptions: [
			{
				allowSeparatedGroups: false,
				ignoreCase: false,
				ignoreDeclarationSort: false,
				ignoreMemberSort: false,
				memberSyntaxSortOrder: ["none", "all", "multiple", "single"],
			},
		],

		docs: {
			description: "Enforce sorted `import` declarations within modules",
			recommended: false,
			frozen: true,
			url: "https://eslint.org/docs/latest/rules/sort-imports",
		},

		schema: [
			{
				type: "object",
				properties: {
					ignoreCase: {
						type: "boolean",
					},
					memberSyntaxSortOrder: {
						type: "array",
						items: {
							enum: ["none", "all", "multiple", "single"],
						},
						uniqueItems: true,
						minItems: 4,
						maxItems: 4,
					},
					ignoreDeclarationSort: {
						type: "boolean",
					},
					ignoreMemberSort: {
						type: "boolean",
					},
					allowSeparatedGroups: {
						type: "boolean",
					},
				},
				additionalProperties: false,
			},
		],

		fixable: "code",

		messages: {
			sortImportsAlphabetically:
				"Imports should be sorted alphabetically.",
			sortMembersAlphabetically:
				"Member '{{memberName}}' of the import declaration should be sorted alphabetically.",
			unexpectedSyntaxOrder:
				"Expected '{{syntaxA}}' syntax before '{{syntaxB}}' syntax.",
		},
	},

	create(context) {
		const [
			{
				ignoreCase,
				ignoreDeclarationSort,
				ignoreMemberSort,
				memberSyntaxSortOrder,
				allowSeparatedGroups,
			},
		] = context.options;
		const sourceCode = context.sourceCode;
		let previousDeclaration = null;

		/**
		 * Gets the used member syntax style.
		 *
		 * import "my-module.js" --> none
		 * import * as myModule from "my-module.js" --> all
		 * import {myMember} from "my-module.js" --> single
		 * import {foo, bar} from  "my-module.js" --> multiple
		 * @param {ASTNode} node the ImportDeclaration node.
		 * @returns {string} used member parameter style, ["all", "multiple", "single"]
		 */
		function usedMemberSyntax(node) {
			if (node.specifiers.length === 0) {
				return "none";
			}
			if (node.specifiers[0].type === "ImportNamespaceSpecifier") {
				return "all";
			}
			if (node.specifiers.length === 1) {
				return "single";
			}
			return "multiple";
		}

		/**
		 * Gets the group by member parameter index for given declaration.
		 * @param {ASTNode} node the ImportDeclaration node.
		 * @returns {number} the declaration group by member index.
		 */
		function getMemberParameterGroupIndex(node) {
			return memberSyntaxSortOrder.indexOf(usedMemberSyntax(node));
		}

		/**
		 * Gets the local name of the first imported module.
		 * @param {ASTNode} node the ImportDeclaration node.
		 * @returns {?string} the local name of the first imported module.
		 */
		function getFirstLocalMemberName(node) {
			if (node.specifiers[0]) {
				return node.specifiers[0].local.name;
			}
			return null;
		}

		/**
		 * Calculates number of lines between two nodes. It is assumed that the given `left` node appears before
		 * the given `right` node in the source code. Lines are counted from the end of the `left` node till the
		 * start of the `right` node. If the given nodes are on the same line, it returns `0`, same as if they were
		 * on two consecutive lines.
		 * @param {ASTNode} left node that appears before the given `right` node.
		 * @param {ASTNode} right node that appears after the given `left` node.
		 * @returns {number} number of lines between nodes.
		 */
		function getNumberOfLinesBetween(left, right) {
			return Math.max(right.loc.start.line - left.loc.end.line - 1, 0);
		}

		return {
			ImportDeclaration(node) {
				if (!ignoreDeclarationSort) {
					if (
						previousDeclaration &&
						allowSeparatedGroups &&
						getNumberOfLinesBetween(previousDeclaration, node) > 0
					) {
						// reset declaration sort
						previousDeclaration = null;
					}

					if (previousDeclaration) {
						const currentMemberSyntaxGroupIndex =
								getMemberParameterGroupIndex(node),
							previousMemberSyntaxGroupIndex =
								getMemberParameterGroupIndex(
									previousDeclaration,
								);
						let currentLocalMemberName =
								getFirstLocalMemberName(node),
							previousLocalMemberName =
								getFirstLocalMemberName(previousDeclaration);

						if (ignoreCase) {
							previousLocalMemberName =
								previousLocalMemberName &&
								previousLocalMemberName.toLowerCase();
							currentLocalMemberName =
								currentLocalMemberName &&
								currentLocalMemberName.toLowerCase();
						}

						/*
						 * When the current declaration uses a different member syntax,
						 * then check if the ordering is correct.
						 * Otherwise, make a default string compare (like rule sort-vars to be consistent) of the first used local member name.
						 */
						if (
							currentMemberSyntaxGroupIndex !==
							previousMemberSyntaxGroupIndex
						) {
							if (
								currentMemberSyntaxGroupIndex <
								previousMemberSyntaxGroupIndex
							) {
								context.report({
									node,
									messageId: "unexpectedSyntaxOrder",
									data: {
										syntaxA:
											memberSyntaxSortOrder[
												currentMemberSyntaxGroupIndex
											],
										syntaxB:
											memberSyntaxSortOrder[
												previousMemberSyntaxGroupIndex
											],
									},
								});
							}
						} else {
							if (
								previousLocalMemberName &&
								currentLocalMemberName &&
								currentLocalMemberName < previousLocalMemberName
							) {
								context.report({
									node,
									messageId: "sortImportsAlphabetically",
								});
							}
						}
					}

					previousDeclaration = node;
				}

				if (!ignoreMemberSort) {
					const importSpecifiers = node.specifiers.filter(
						specifier => specifier.type === "ImportSpecifier",
					);
					const getSortableName = ignoreCase
						? specifier => specifier.local.name.toLowerCase()
						: specifier => specifier.local.name;
					const firstUnsortedIndex = importSpecifiers
						.map(getSortableName)
						.findIndex(
							(name, index, array) => array[index - 1] > name,
						);

					if (firstUnsortedIndex !== -1) {
						context.report({
							node: importSpecifiers[firstUnsortedIndex],
							messageId: "sortMembersAlphabetically",
							data: {
								memberName:
									importSpecifiers[firstUnsortedIndex].local
										.name,
							},
							fix(fixer) {
								if (
									importSpecifiers.some(
										specifier =>
											sourceCode.getCommentsBefore(
												specifier,
											).length ||
											sourceCode.getCommentsAfter(
												specifier,
											).length,
									)
								) {
									// If there are comments in the ImportSpecifier list, don't rearrange the specifiers.
									return null;
								}

								return fixer.replaceTextRange(
									[
										importSpecifiers[0].range[0],
										importSpecifiers.at(-1).range[1],
									],
									importSpecifiers

										// Clone the importSpecifiers array to avoid mutating it
										.slice()

										// Sort the array into the desired order
										.sort((specifierA, specifierB) => {
											const aName =
												getSortableName(specifierA);
											const bName =
												getSortableName(specifierB);

											return aName > bName ? 1 : -1;
										})

										// Build a string out of the sorted list of import specifiers and the text between the originals
										.reduce(
											(sourceText, specifier, index) => {
												const textAfterSpecifier =
													index ===
													importSpecifiers.length - 1
														? ""
														: sourceCode
																.getText()
																.slice(
																	importSpecifiers[
																		index
																	].range[1],
																	importSpecifiers[
																		index +
																			1
																	].range[0],
																);

												return (
													sourceText +
													sourceCode.getText(
														specifier,
													) +
													textAfterSpecifier
												);
											},
											"",
										),
								);
							},
						});
					}
				}
			},
		};
	},
};
