/**
 * @fileoverview Enforces empty lines around comments.
 * @author Jamund Ferguson
 * @deprecated in ESLint v8.53.0
 */
"use strict";

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

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

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

/**
 * Return an array with any line numbers that are empty.
 * @param {Array} lines An array of each line of the file.
 * @returns {Array} An array of line numbers.
 */
function getEmptyLineNums(lines) {
	const emptyLines = lines
		.map((line, i) => ({
			code: line.trim(),
			num: i + 1,
		}))
		.filter(line => !line.code)
		.map(line => line.num);

	return emptyLines;
}

/**
 * Return an array with any line numbers that contain comments.
 * @param {Array} comments An array of comment tokens.
 * @returns {Array} An array of line numbers.
 */
function getCommentLineNums(comments) {
	const lines = [];

	comments.forEach(token => {
		const start = token.loc.start.line;
		const end = token.loc.end.line;

		lines.push(start, end);
	});
	return lines;
}

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

/** @type {import('../types').Rule.RuleModule} */
module.exports = {
	meta: {
		deprecated: {
			message: "Formatting rules are being moved out of ESLint core.",
			url: "https://eslint.org/blog/2023/10/deprecating-formatting-rules/",
			deprecatedSince: "8.53.0",
			availableUntil: "10.0.0",
			replacedBy: [
				{
					message:
						"ESLint Stylistic now maintains deprecated stylistic core rules.",
					url: "https://eslint.style/guide/migration",
					plugin: {
						name: "@stylistic/eslint-plugin",
						url: "https://eslint.style",
					},
					rule: {
						name: "lines-around-comment",
						url: "https://eslint.style/rules/lines-around-comment",
					},
				},
			],
		},
		type: "layout",

		docs: {
			description: "Require empty lines around comments",
			recommended: false,
			url: "https://eslint.org/docs/latest/rules/lines-around-comment",
		},

		fixable: "whitespace",

		schema: [
			{
				type: "object",
				properties: {
					beforeBlockComment: {
						type: "boolean",
						default: true,
					},
					afterBlockComment: {
						type: "boolean",
						default: false,
					},
					beforeLineComment: {
						type: "boolean",
						default: false,
					},
					afterLineComment: {
						type: "boolean",
						default: false,
					},
					allowBlockStart: {
						type: "boolean",
						default: false,
					},
					allowBlockEnd: {
						type: "boolean",
						default: false,
					},
					allowClassStart: {
						type: "boolean",
					},
					allowClassEnd: {
						type: "boolean",
					},
					allowObjectStart: {
						type: "boolean",
					},
					allowObjectEnd: {
						type: "boolean",
					},
					allowArrayStart: {
						type: "boolean",
					},
					allowArrayEnd: {
						type: "boolean",
					},
					ignorePattern: {
						type: "string",
					},
					applyDefaultIgnorePatterns: {
						type: "boolean",
					},
					afterHashbangComment: {
						type: "boolean",
						default: false,
					},
				},
				additionalProperties: false,
			},
		],
		messages: {
			after: "Expected line after comment.",
			before: "Expected line before comment.",
		},
	},

	create(context) {
		const options = Object.assign({}, context.options[0]);
		const ignorePattern = options.ignorePattern;
		const defaultIgnoreRegExp = astUtils.COMMENTS_IGNORE_PATTERN;
		const customIgnoreRegExp = new RegExp(ignorePattern, "u");
		const applyDefaultIgnorePatterns =
			options.applyDefaultIgnorePatterns !== false;

		options.beforeBlockComment =
			typeof options.beforeBlockComment !== "undefined"
				? options.beforeBlockComment
				: true;

		const sourceCode = context.sourceCode;

		const lines = sourceCode.lines,
			numLines = lines.length + 1,
			comments = sourceCode.getAllComments(),
			commentLines = getCommentLineNums(comments),
			emptyLines = getEmptyLineNums(lines),
			commentAndEmptyLines = new Set(commentLines.concat(emptyLines));

		/**
		 * Returns whether or not comments are on lines starting with or ending with code
		 * @param {token} token The comment token to check.
		 * @returns {boolean} True if the comment is not alone.
		 */
		function codeAroundComment(token) {
			let currentToken = token;

			do {
				currentToken = sourceCode.getTokenBefore(currentToken, {
					includeComments: true,
				});
			} while (currentToken && astUtils.isCommentToken(currentToken));

			if (
				currentToken &&
				astUtils.isTokenOnSameLine(currentToken, token)
			) {
				return true;
			}

			currentToken = token;
			do {
				currentToken = sourceCode.getTokenAfter(currentToken, {
					includeComments: true,
				});
			} while (currentToken && astUtils.isCommentToken(currentToken));

			if (
				currentToken &&
				astUtils.isTokenOnSameLine(token, currentToken)
			) {
				return true;
			}

			return false;
		}

		/**
		 * Returns whether or not comments are inside a node type or not.
		 * @param {ASTNode} parent The Comment parent node.
		 * @param {string} nodeType The parent type to check against.
		 * @returns {boolean} True if the comment is inside nodeType.
		 */
		function isParentNodeType(parent, nodeType) {
			return (
				parent.type === nodeType ||
				(parent.body && parent.body.type === nodeType) ||
				(parent.consequent && parent.consequent.type === nodeType)
			);
		}

		/**
		 * Returns the parent node that contains the given token.
		 * @param {token} token The token to check.
		 * @returns {ASTNode|null} The parent node that contains the given token.
		 */
		function getParentNodeOfToken(token) {
			const node = sourceCode.getNodeByRangeIndex(token.range[0]);

			/*
			 * For the purpose of this rule, the comment token is in a `StaticBlock` node only
			 * if it's inside the braces of that `StaticBlock` node.
			 *
			 * Example where this function returns `null`:
			 *
			 *   static
			 *   // comment
			 *   {
			 *   }
			 *
			 * Example where this function returns `StaticBlock` node:
			 *
			 *   static
			 *   {
			 *   // comment
			 *   }
			 *
			 */
			if (node && node.type === "StaticBlock") {
				const openingBrace = sourceCode.getFirstToken(node, {
					skip: 1,
				}); // skip the `static` token

				return token.range[0] >= openingBrace.range[0] ? node : null;
			}

			return node;
		}

		/**
		 * Returns whether or not comments are at the parent start or not.
		 * @param {token} token The Comment token.
		 * @param {string} nodeType The parent type to check against.
		 * @returns {boolean} True if the comment is at parent start.
		 */
		function isCommentAtParentStart(token, nodeType) {
			const parent = getParentNodeOfToken(token);

			if (parent && isParentNodeType(parent, nodeType)) {
				let parentStartNodeOrToken = parent;

				if (parent.type === "StaticBlock") {
					parentStartNodeOrToken = sourceCode.getFirstToken(parent, {
						skip: 1,
					}); // opening brace of the static block
				} else if (parent.type === "SwitchStatement") {
					parentStartNodeOrToken = sourceCode.getTokenAfter(
						parent.discriminant,
						{
							filter: astUtils.isOpeningBraceToken,
						},
					); // opening brace of the switch statement
				}

				return (
					token.loc.start.line -
						parentStartNodeOrToken.loc.start.line ===
					1
				);
			}

			return false;
		}

		/**
		 * Returns whether or not comments are at the parent end or not.
		 * @param {token} token The Comment token.
		 * @param {string} nodeType The parent type to check against.
		 * @returns {boolean} True if the comment is at parent end.
		 */
		function isCommentAtParentEnd(token, nodeType) {
			const parent = getParentNodeOfToken(token);

			return (
				!!parent &&
				isParentNodeType(parent, nodeType) &&
				parent.loc.end.line - token.loc.end.line === 1
			);
		}

		/**
		 * Returns whether or not comments are at the block start or not.
		 * @param {token} token The Comment token.
		 * @returns {boolean} True if the comment is at block start.
		 */
		function isCommentAtBlockStart(token) {
			return (
				isCommentAtParentStart(token, "ClassBody") ||
				isCommentAtParentStart(token, "BlockStatement") ||
				isCommentAtParentStart(token, "StaticBlock") ||
				isCommentAtParentStart(token, "SwitchCase") ||
				isCommentAtParentStart(token, "SwitchStatement")
			);
		}

		/**
		 * Returns whether or not comments are at the block end or not.
		 * @param {token} token The Comment token.
		 * @returns {boolean} True if the comment is at block end.
		 */
		function isCommentAtBlockEnd(token) {
			return (
				isCommentAtParentEnd(token, "ClassBody") ||
				isCommentAtParentEnd(token, "BlockStatement") ||
				isCommentAtParentEnd(token, "StaticBlock") ||
				isCommentAtParentEnd(token, "SwitchCase") ||
				isCommentAtParentEnd(token, "SwitchStatement")
			);
		}

		/**
		 * Returns whether or not comments are at the class start or not.
		 * @param {token} token The Comment token.
		 * @returns {boolean} True if the comment is at class start.
		 */
		function isCommentAtClassStart(token) {
			return isCommentAtParentStart(token, "ClassBody");
		}

		/**
		 * Returns whether or not comments are at the class end or not.
		 * @param {token} token The Comment token.
		 * @returns {boolean} True if the comment is at class end.
		 */
		function isCommentAtClassEnd(token) {
			return isCommentAtParentEnd(token, "ClassBody");
		}

		/**
		 * Returns whether or not comments are at the object start or not.
		 * @param {token} token The Comment token.
		 * @returns {boolean} True if the comment is at object start.
		 */
		function isCommentAtObjectStart(token) {
			return (
				isCommentAtParentStart(token, "ObjectExpression") ||
				isCommentAtParentStart(token, "ObjectPattern")
			);
		}

		/**
		 * Returns whether or not comments are at the object end or not.
		 * @param {token} token The Comment token.
		 * @returns {boolean} True if the comment is at object end.
		 */
		function isCommentAtObjectEnd(token) {
			return (
				isCommentAtParentEnd(token, "ObjectExpression") ||
				isCommentAtParentEnd(token, "ObjectPattern")
			);
		}

		/**
		 * Returns whether or not comments are at the array start or not.
		 * @param {token} token The Comment token.
		 * @returns {boolean} True if the comment is at array start.
		 */
		function isCommentAtArrayStart(token) {
			return (
				isCommentAtParentStart(token, "ArrayExpression") ||
				isCommentAtParentStart(token, "ArrayPattern")
			);
		}

		/**
		 * Returns whether or not comments are at the array end or not.
		 * @param {token} token The Comment token.
		 * @returns {boolean} True if the comment is at array end.
		 */
		function isCommentAtArrayEnd(token) {
			return (
				isCommentAtParentEnd(token, "ArrayExpression") ||
				isCommentAtParentEnd(token, "ArrayPattern")
			);
		}

		/**
		 * Checks if a comment token has lines around it (ignores inline comments)
		 * @param {token} token The Comment token.
		 * @param {Object} opts Options to determine the newline.
		 * @param {boolean} opts.after Should have a newline after this line.
		 * @param {boolean} opts.before Should have a newline before this line.
		 * @returns {void}
		 */
		function checkForEmptyLine(token, opts) {
			if (
				applyDefaultIgnorePatterns &&
				defaultIgnoreRegExp.test(token.value)
			) {
				return;
			}

			if (ignorePattern && customIgnoreRegExp.test(token.value)) {
				return;
			}

			let after = opts.after,
				before = opts.before;

			const prevLineNum = token.loc.start.line - 1,
				nextLineNum = token.loc.end.line + 1,
				commentIsNotAlone = codeAroundComment(token);

			const blockStartAllowed =
					options.allowBlockStart &&
					isCommentAtBlockStart(token) &&
					!(
						options.allowClassStart === false &&
						isCommentAtClassStart(token)
					),
				blockEndAllowed =
					options.allowBlockEnd &&
					isCommentAtBlockEnd(token) &&
					!(
						options.allowClassEnd === false &&
						isCommentAtClassEnd(token)
					),
				classStartAllowed =
					options.allowClassStart && isCommentAtClassStart(token),
				classEndAllowed =
					options.allowClassEnd && isCommentAtClassEnd(token),
				objectStartAllowed =
					options.allowObjectStart && isCommentAtObjectStart(token),
				objectEndAllowed =
					options.allowObjectEnd && isCommentAtObjectEnd(token),
				arrayStartAllowed =
					options.allowArrayStart && isCommentAtArrayStart(token),
				arrayEndAllowed =
					options.allowArrayEnd && isCommentAtArrayEnd(token);

			const exceptionStartAllowed =
				blockStartAllowed ||
				classStartAllowed ||
				objectStartAllowed ||
				arrayStartAllowed;
			const exceptionEndAllowed =
				blockEndAllowed ||
				classEndAllowed ||
				objectEndAllowed ||
				arrayEndAllowed;

			// ignore top of the file and bottom of the file
			if (prevLineNum < 1) {
				before = false;
			}
			if (nextLineNum >= numLines) {
				after = false;
			}

			// we ignore all inline comments
			if (commentIsNotAlone) {
				return;
			}

			const previousTokenOrComment = sourceCode.getTokenBefore(token, {
				includeComments: true,
			});
			const nextTokenOrComment = sourceCode.getTokenAfter(token, {
				includeComments: true,
			});

			// check for newline before
			if (
				!exceptionStartAllowed &&
				before &&
				!commentAndEmptyLines.has(prevLineNum) &&
				!(
					astUtils.isCommentToken(previousTokenOrComment) &&
					astUtils.isTokenOnSameLine(previousTokenOrComment, token)
				)
			) {
				const lineStart = token.range[0] - token.loc.start.column;
				const range = [lineStart, lineStart];

				context.report({
					node: token,
					messageId: "before",
					fix(fixer) {
						return fixer.insertTextBeforeRange(range, "\n");
					},
				});
			}

			// check for newline after
			if (
				!exceptionEndAllowed &&
				after &&
				!commentAndEmptyLines.has(nextLineNum) &&
				!(
					astUtils.isCommentToken(nextTokenOrComment) &&
					astUtils.isTokenOnSameLine(token, nextTokenOrComment)
				)
			) {
				context.report({
					node: token,
					messageId: "after",
					fix(fixer) {
						return fixer.insertTextAfter(token, "\n");
					},
				});
			}
		}

		//--------------------------------------------------------------------------
		// Public
		//--------------------------------------------------------------------------

		return {
			Program() {
				comments.forEach(token => {
					if (token.type === "Line") {
						if (
							options.beforeLineComment ||
							options.afterLineComment
						) {
							checkForEmptyLine(token, {
								after: options.afterLineComment,
								before: options.beforeLineComment,
							});
						}
					} else if (token.type === "Block") {
						if (
							options.beforeBlockComment ||
							options.afterBlockComment
						) {
							checkForEmptyLine(token, {
								after: options.afterBlockComment,
								before: options.beforeBlockComment,
							});
						}
					} else if (token.type === "Shebang") {
						if (options.afterHashbangComment) {
							checkForEmptyLine(token, {
								after: options.afterHashbangComment,
								before: false,
							});
						}
					}
				});
			},
		};
	},
};
