/**
 * @fileoverview Rule to flag fall-through cases in switch statements.
 * @author Matt DuVall <http://mattduvall.com/>
 */
"use strict";

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

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

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

const DEFAULT_FALLTHROUGH_COMMENT = /falls?\s?through/iu;

/**
 * Checks whether or not a given comment string is really a fallthrough comment and not an ESLint directive.
 * @param {string} comment The comment string to check.
 * @param {RegExp} fallthroughCommentPattern The regular expression used for checking for fallthrough comments.
 * @returns {boolean} `true` if the comment string is truly a fallthrough comment.
 */
function isFallThroughComment(comment, fallthroughCommentPattern) {
	return (
		fallthroughCommentPattern.test(comment) &&
		!directivesPattern.test(comment.trim())
	);
}

/**
 * Checks whether or not a given case has a fallthrough comment.
 * @param {ASTNode} caseWhichFallsThrough SwitchCase node which falls through.
 * @param {ASTNode} subsequentCase The case after caseWhichFallsThrough.
 * @param {RuleContext} context A rule context which stores comments.
 * @param {RegExp} fallthroughCommentPattern A pattern to match comment to.
 * @returns {null | object} the comment if the case has a valid fallthrough comment, otherwise null
 */
function getFallthroughComment(
	caseWhichFallsThrough,
	subsequentCase,
	context,
	fallthroughCommentPattern,
) {
	const sourceCode = context.sourceCode;

	if (
		caseWhichFallsThrough.consequent.length === 1 &&
		caseWhichFallsThrough.consequent[0].type === "BlockStatement"
	) {
		const trailingCloseBrace = sourceCode.getLastToken(
			caseWhichFallsThrough.consequent[0],
		);
		const commentInBlock = sourceCode
			.getCommentsBefore(trailingCloseBrace)
			.pop();

		if (
			commentInBlock &&
			isFallThroughComment(
				commentInBlock.value,
				fallthroughCommentPattern,
			)
		) {
			return commentInBlock;
		}
	}

	const comment = sourceCode.getCommentsBefore(subsequentCase).pop();

	if (
		comment &&
		isFallThroughComment(comment.value, fallthroughCommentPattern)
	) {
		return comment;
	}

	return null;
}

/**
 * Checks whether a node and a token are separated by blank lines
 * @param {ASTNode} node The node to check
 * @param {Token} token The token to compare against
 * @returns {boolean} `true` if there are blank lines between node and token
 */
function hasBlankLinesBetween(node, token) {
	return token.loc.start.line > node.loc.end.line + 1;
}

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

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

		defaultOptions: [
			{
				allowEmptyCase: false,
				reportUnusedFallthroughComment: false,
			},
		],

		docs: {
			description: "Disallow fallthrough of `case` statements",
			recommended: true,
			url: "https://eslint.org/docs/latest/rules/no-fallthrough",
		},

		schema: [
			{
				type: "object",
				properties: {
					commentPattern: {
						type: "string",
					},
					allowEmptyCase: {
						type: "boolean",
					},
					reportUnusedFallthroughComment: {
						type: "boolean",
					},
				},
				additionalProperties: false,
			},
		],
		messages: {
			unusedFallthroughComment:
				"Found a comment that would permit fallthrough, but case cannot fall through.",
			case: "Expected a 'break' statement before 'case'.",
			default: "Expected a 'break' statement before 'default'.",
		},
	},

	create(context) {
		const codePathSegments = [];
		let currentCodePathSegments = new Set();
		const sourceCode = context.sourceCode;
		const [
			{ allowEmptyCase, commentPattern, reportUnusedFallthroughComment },
		] = context.options;
		const fallthroughCommentPattern = commentPattern
			? new RegExp(commentPattern, "u")
			: DEFAULT_FALLTHROUGH_COMMENT;

		/*
		 * We need to use leading comments of the next SwitchCase node because
		 * trailing comments is wrong if semicolons are omitted.
		 */
		let previousCase = null;

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

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

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

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

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

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

			SwitchCase(node) {
				/*
				 * Checks whether or not there is a fallthrough comment.
				 * And reports the previous fallthrough node if that does not exist.
				 */

				if (previousCase && previousCase.node.parent === node.parent) {
					const previousCaseFallthroughComment =
						getFallthroughComment(
							previousCase.node,
							node,
							context,
							fallthroughCommentPattern,
						);

					if (
						previousCase.isFallthrough &&
						!previousCaseFallthroughComment
					) {
						context.report({
							messageId: node.test ? "case" : "default",
							node,
						});
					} else if (
						reportUnusedFallthroughComment &&
						!previousCase.isSwitchExitReachable &&
						previousCaseFallthroughComment
					) {
						context.report({
							messageId: "unusedFallthroughComment",
							node: previousCaseFallthroughComment,
						});
					}
				}
				previousCase = null;
			},

			"SwitchCase:exit"(node) {
				const nextToken = sourceCode.getTokenAfter(node);

				/*
				 * `reachable` meant fall through because statements preceded by
				 * `break`, `return`, or `throw` are unreachable.
				 * And allows empty cases and the last case.
				 */
				const isSwitchExitReachable = isAnySegmentReachable(
					currentCodePathSegments,
				);
				const isFallthrough =
					isSwitchExitReachable &&
					(node.consequent.length > 0 ||
						(!allowEmptyCase &&
							hasBlankLinesBetween(node, nextToken))) &&
					node.parent.cases.at(-1) !== node;

				previousCase = {
					node,
					isSwitchExitReachable,
					isFallthrough,
				};
			},
		};
	},
};
