/**
 * @fileoverview Rule to enforce getter and setter pairs in objects and classes.
 * @author Gyandeep Singh
 */

"use strict";

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

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

//------------------------------------------------------------------------------
// Typedefs
//------------------------------------------------------------------------------

/**
 * Property name if it can be computed statically, otherwise the list of the tokens of the key node.
 * @typedef {string|Token[]} Key
 */

/**
 * Accessor nodes with the same key.
 * @typedef {Object} AccessorData
 * @property {Key} key Accessor's key
 * @property {ASTNode[]} getters List of getter nodes.
 * @property {ASTNode[]} setters List of setter nodes.
 */

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

/**
 * Checks whether or not the given lists represent the equal tokens in the same order.
 * Tokens are compared by their properties, not by instance.
 * @param {Token[]} left First list of tokens.
 * @param {Token[]} right Second list of tokens.
 * @returns {boolean} `true` if the lists have same tokens.
 */
function areEqualTokenLists(left, right) {
	if (left.length !== right.length) {
		return false;
	}

	for (let i = 0; i < left.length; i++) {
		const leftToken = left[i],
			rightToken = right[i];

		if (
			leftToken.type !== rightToken.type ||
			leftToken.value !== rightToken.value
		) {
			return false;
		}
	}

	return true;
}

/**
 * Checks whether or not the given keys are equal.
 * @param {Key} left First key.
 * @param {Key} right Second key.
 * @returns {boolean} `true` if the keys are equal.
 */
function areEqualKeys(left, right) {
	if (typeof left === "string" && typeof right === "string") {
		// Statically computed names.
		return left === right;
	}
	if (Array.isArray(left) && Array.isArray(right)) {
		// Token lists.
		return areEqualTokenLists(left, right);
	}

	return false;
}

/**
 * Checks whether or not a given node is of an accessor kind ('get' or 'set').
 * @param {ASTNode} node A node to check.
 * @returns {boolean} `true` if the node is of an accessor kind.
 */
function isAccessorKind(node) {
	return node.kind === "get" || node.kind === "set";
}

/**
 * Checks whether or not a given node is an argument of a specified method call.
 * @param {ASTNode} node A node to check.
 * @param {number} index An expected index of the node in arguments.
 * @param {string} object An expected name of the object of the method.
 * @param {string} property An expected name of the method.
 * @returns {boolean} `true` if the node is an argument of the specified method call.
 */
function isArgumentOfMethodCall(node, index, object, property) {
	const parent = node.parent;

	return (
		parent.type === "CallExpression" &&
		astUtils.isSpecificMemberAccess(parent.callee, object, property) &&
		parent.arguments[index] === node
	);
}

/**
 * Checks whether or not a given node is a property descriptor.
 * @param {ASTNode} node A node to check.
 * @returns {boolean} `true` if the node is a property descriptor.
 */
function isPropertyDescriptor(node) {
	// Object.defineProperty(obj, "foo", {set: ...})
	if (
		isArgumentOfMethodCall(node, 2, "Object", "defineProperty") ||
		isArgumentOfMethodCall(node, 2, "Reflect", "defineProperty")
	) {
		return true;
	}

	/*
	 * Object.defineProperties(obj, {foo: {set: ...}})
	 * Object.create(proto, {foo: {set: ...}})
	 */
	const grandparent = node.parent.parent;

	return (
		grandparent.type === "ObjectExpression" &&
		(isArgumentOfMethodCall(grandparent, 1, "Object", "create") ||
			isArgumentOfMethodCall(
				grandparent,
				1,
				"Object",
				"defineProperties",
			))
	);
}

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

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

		defaultOptions: [
			{
				enforceForClassMembers: true,
				getWithoutSet: false,
				setWithoutGet: true,
			},
		],

		docs: {
			description:
				"Enforce getter and setter pairs in objects and classes",
			recommended: false,
			url: "https://eslint.org/docs/latest/rules/accessor-pairs",
		},

		schema: [
			{
				type: "object",
				properties: {
					getWithoutSet: {
						type: "boolean",
					},
					setWithoutGet: {
						type: "boolean",
					},
					enforceForClassMembers: {
						type: "boolean",
					},
				},
				additionalProperties: false,
			},
		],

		messages: {
			missingGetterInPropertyDescriptor:
				"Getter is not present in property descriptor.",
			missingSetterInPropertyDescriptor:
				"Setter is not present in property descriptor.",
			missingGetterInObjectLiteral:
				"Getter is not present for {{ name }}.",
			missingSetterInObjectLiteral:
				"Setter is not present for {{ name }}.",
			missingGetterInClass: "Getter is not present for class {{ name }}.",
			missingSetterInClass: "Setter is not present for class {{ name }}.",
		},
	},
	create(context) {
		const [
			{
				getWithoutSet: checkGetWithoutSet,
				setWithoutGet: checkSetWithoutGet,
				enforceForClassMembers,
			},
		] = context.options;
		const sourceCode = context.sourceCode;

		/**
		 * Reports the given node.
		 * @param {ASTNode} node The node to report.
		 * @param {string} messageKind "missingGetter" or "missingSetter".
		 * @returns {void}
		 * @private
		 */
		function report(node, messageKind) {
			if (node.type === "Property") {
				context.report({
					node,
					messageId: `${messageKind}InObjectLiteral`,
					loc: astUtils.getFunctionHeadLoc(node.value, sourceCode),
					data: {
						name: astUtils.getFunctionNameWithKind(node.value),
					},
				});
			} else if (node.type === "MethodDefinition") {
				context.report({
					node,
					messageId: `${messageKind}InClass`,
					loc: astUtils.getFunctionHeadLoc(node.value, sourceCode),
					data: {
						name: astUtils.getFunctionNameWithKind(node.value),
					},
				});
			} else {
				context.report({
					node,
					messageId: `${messageKind}InPropertyDescriptor`,
				});
			}
		}

		/**
		 * Reports each of the nodes in the given list using the same messageId.
		 * @param {ASTNode[]} nodes Nodes to report.
		 * @param {string} messageKind "missingGetter" or "missingSetter".
		 * @returns {void}
		 * @private
		 */
		function reportList(nodes, messageKind) {
			for (const node of nodes) {
				report(node, messageKind);
			}
		}

		/**
		 * Checks accessor pairs in the given list of nodes.
		 * @param {ASTNode[]} nodes The list to check.
		 * @returns {void}
		 * @private
		 */
		function checkList(nodes) {
			const accessors = [];
			let found = false;

			for (let i = 0; i < nodes.length; i++) {
				const node = nodes[i];

				if (isAccessorKind(node)) {
					// Creates a new `AccessorData` object for the given getter or setter node.
					const name = astUtils.getStaticPropertyName(node);
					const key =
						name !== null ? name : sourceCode.getTokens(node.key);

					// Merges the given `AccessorData` object into the given accessors list.
					for (let j = 0; j < accessors.length; j++) {
						const accessor = accessors[j];

						if (areEqualKeys(accessor.key, key)) {
							accessor.getters.push(
								...(node.kind === "get" ? [node] : []),
							);
							accessor.setters.push(
								...(node.kind === "set" ? [node] : []),
							);
							found = true;
							break;
						}
					}
					if (!found) {
						accessors.push({
							key,
							getters: node.kind === "get" ? [node] : [],
							setters: node.kind === "set" ? [node] : [],
						});
					}
					found = false;
				}
			}

			for (const { getters, setters } of accessors) {
				if (checkSetWithoutGet && setters.length && !getters.length) {
					reportList(setters, "missingGetter");
				}
				if (checkGetWithoutSet && getters.length && !setters.length) {
					reportList(getters, "missingSetter");
				}
			}
		}

		/**
		 * Checks accessor pairs in an object literal.
		 * @param {ASTNode} node `ObjectExpression` node to check.
		 * @returns {void}
		 * @private
		 */
		function checkObjectLiteral(node) {
			checkList(node.properties.filter(p => p.type === "Property"));
		}

		/**
		 * Checks accessor pairs in a property descriptor.
		 * @param {ASTNode} node Property descriptor `ObjectExpression` node to check.
		 * @returns {void}
		 * @private
		 */
		function checkPropertyDescriptor(node) {
			const namesToCheck = new Set(
				node.properties
					.filter(
						p =>
							p.type === "Property" &&
							p.kind === "init" &&
							!p.computed,
					)
					.map(({ key }) => key.name),
			);

			const hasGetter = namesToCheck.has("get");
			const hasSetter = namesToCheck.has("set");

			if (checkSetWithoutGet && hasSetter && !hasGetter) {
				report(node, "missingGetter");
			}
			if (checkGetWithoutSet && hasGetter && !hasSetter) {
				report(node, "missingSetter");
			}
		}

		/**
		 * Checks the given object expression as an object literal and as a possible property descriptor.
		 * @param {ASTNode} node `ObjectExpression` node to check.
		 * @returns {void}
		 * @private
		 */
		function checkObjectExpression(node) {
			checkObjectLiteral(node);
			if (isPropertyDescriptor(node)) {
				checkPropertyDescriptor(node);
			}
		}

		/**
		 * Checks the given class body.
		 * @param {ASTNode} node `ClassBody` node to check.
		 * @returns {void}
		 * @private
		 */
		function checkClassBody(node) {
			const methodDefinitions = node.body.filter(
				m => m.type === "MethodDefinition",
			);

			checkList(methodDefinitions.filter(m => m.static));
			checkList(methodDefinitions.filter(m => !m.static));
		}

		const listeners = {};

		if (checkSetWithoutGet || checkGetWithoutSet) {
			listeners.ObjectExpression = checkObjectExpression;
			if (enforceForClassMembers) {
				listeners.ClassBody = checkClassBody;
			}
		}

		return listeners;
	},
};
