/**
 * @fileoverview Enforces empty lines around comments.
 * @author Jamund Ferguson
 */
"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('../shared/types').Rule} */
module.exports = {
    meta: {
        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
                            });
                        }
                    }
                });
            }
        };
    }
};
