/**
 * @fileoverview Rule to disallow loops with a body that allows only one iteration
 * @author Milos Djermanovic
 */

"use strict";

const { isAnySegmentReachable } = require("./utils/code-path-utils");

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

const allLoopTypes = [
	"WhileStatement",
	"DoWhileStatement",
	"ForStatement",
	"ForInStatement",
	"ForOfStatement",
];

/**
 * Determines whether the given node is the first node in the code path to which a loop statement
 * 'loops' for the next iteration.
 * @param {ASTNode} node The node to check.
 * @returns {boolean} `true` if the node is a looping target.
 */
function isLoopingTarget(node) {
	const parent = node.parent;

	if (parent) {
		switch (parent.type) {
			case "WhileStatement":
				return node === parent.test;
			case "DoWhileStatement":
				return node === parent.body;
			case "ForStatement":
				return node === (parent.update || parent.test || parent.body);
			case "ForInStatement":
			case "ForOfStatement":
				return node === parent.left;

			// no default
		}
	}

	return false;
}

/**
 * Creates an array with elements from the first given array that are not included in the second given array.
 * @param {Array} arrA The array to compare from.
 * @param {Array} arrB The array to compare against.
 * @returns {Array} a new array that represents `arrA \ arrB`.
 */
function getDifference(arrA, arrB) {
	return arrA.filter(a => !arrB.includes(a));
}

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

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

		defaultOptions: [{ ignore: [] }],

		docs: {
			description:
				"Disallow loops with a body that allows only one iteration",
			recommended: false,
			url: "https://eslint.org/docs/latest/rules/no-unreachable-loop",
		},

		schema: [
			{
				type: "object",
				properties: {
					ignore: {
						type: "array",
						items: {
							enum: allLoopTypes,
						},
						uniqueItems: true,
					},
				},
				additionalProperties: false,
			},
		],

		messages: {
			invalid: "Invalid loop. Its body allows only one iteration.",
		},
	},

	create(context) {
		const [{ ignore: ignoredLoopTypes }] = context.options;
		const loopTypesToCheck = getDifference(allLoopTypes, ignoredLoopTypes);
		const loopSelector = loopTypesToCheck.join(",");

		if (!loopSelector) {
			return {};
		}

		const loopsByTargetSegments = new Map();
		const loopsToReport = new Set();
		const codePathSegments = [];
		let currentCodePathSegments = new Set();

		return {
			onCodePathStart() {
				codePathSegments.push(currentCodePathSegments);
				currentCodePathSegments = new Set();
			},

			onCodePathEnd() {
				currentCodePathSegments = codePathSegments.pop();
			},

			onUnreachableCodePathSegmentStart(segment) {
				currentCodePathSegments.add(segment);
			},

			onUnreachableCodePathSegmentEnd(segment) {
				currentCodePathSegments.delete(segment);
			},

			onCodePathSegmentEnd(segment) {
				currentCodePathSegments.delete(segment);
			},

			onCodePathSegmentStart(segment, node) {
				currentCodePathSegments.add(segment);

				if (isLoopingTarget(node)) {
					const loop = node.parent;

					loopsByTargetSegments.set(segment, loop);
				}
			},

			onCodePathSegmentLoop(_, toSegment, node) {
				const loop = loopsByTargetSegments.get(toSegment);

				/**
				 * The second iteration is reachable, meaning that the loop is valid by the logic of this rule,
				 * only if there is at least one loop event with the appropriate target (which has been already
				 * determined in the `loopsByTargetSegments` map), raised from either:
				 *
				 * - the end of the loop's body (in which case `node === loop`)
				 * - a `continue` statement
				 *
				 * This condition skips loop events raised from `ForInStatement > .right` and `ForOfStatement > .right` nodes.
				 */
				if (node === loop || node.type === "ContinueStatement") {
					// Removes loop if it exists in the set. Otherwise, `Set#delete` has no effect and doesn't throw.
					loopsToReport.delete(loop);
				}
			},

			[loopSelector](node) {
				/**
				 * Ignore unreachable loop statements to avoid unnecessary complexity in the implementation, or false positives otherwise.
				 * For unreachable segments, the code path analysis does not raise events required for this implementation.
				 */
				if (isAnySegmentReachable(currentCodePathSegments)) {
					loopsToReport.add(node);
				}
			},

			"Program:exit"() {
				loopsToReport.forEach(node =>
					context.report({ node, messageId: "invalid" }),
				);
			},
		};
	},
};
