/**
 * @fileoverview enforce the location of arrow function bodies
 * @author Sharmila Jesupaul
 */
"use strict";

const {
    isArrowToken,
    isParenthesised,
    isOpeningParenToken
} = require("../util/ast-utils");

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
module.exports = {
    meta: {
        type: "layout",

        docs: {
            description: "enforce the location of arrow function bodies",
            category: "Stylistic Issues",
            recommended: false,
            url: "https://eslint.org/docs/rules/implicit-arrow-linebreak"
        },

        fixable: "whitespace",

        schema: [
            {
                enum: ["beside", "below"]
            }
        ]
    },

    create(context) {
        const sourceCode = context.getSourceCode();

        //----------------------------------------------------------------------
        // Helpers
        //----------------------------------------------------------------------
        /**
         * Gets the applicable preference for a particular keyword
         * @returns {string} The applicable option for the keyword, e.g. 'beside'
         */
        function getOption() {
            return context.options[0] || "beside";
        }

        /**
         * Formats the comments depending on whether it's a line or block comment.
         * @param {Comment[]} comments The array of comments between the arrow and body
         * @param {Integer} column The column number of the first token
         * @returns {string} A string of comment text joined by line breaks
         */
        function formatComments(comments, column) {
            const whiteSpaces = " ".repeat(column);

            return `${comments.map(comment => {

                if (comment.type === "Line") {
                    return `//${comment.value}`;
                }

                return `/*${comment.value}*/`;
            }).join(`\n${whiteSpaces}`)}\n${whiteSpaces}`;
        }

        /**
         * Finds the first token to prepend comments to depending on the parent type
         * @param {Node} node The validated node
         * @returns {Token|Node} The node to prepend comments to
         */
        function findFirstToken(node) {
            switch (node.parent.type) {
                case "VariableDeclarator":

                    // If the parent is first or only declarator, return the declaration, else, declarator
                    return sourceCode.getFirstToken(
                        node.parent.parent.declarations.length === 1 ||
                        node.parent.parent.declarations[0].id.name === node.parent.id.name
                            ? node.parent.parent : node.parent
                    );
                case "CallExpression":
                case "Property":

                    // find the object key
                    return sourceCode.getFirstToken(node.parent);
                default:
                    return node;
            }
        }

        /**
         * Helper function for adding parentheses fixes for nodes containing nested arrow functions
         * @param {Fixer} fixer Fixer
         * @param {Token} arrow - The arrow token
         * @param {ASTNode} arrowBody - The arrow function body
         * @returns {Function[]} autofixer -- wraps function bodies with parentheses
         */
        function addParentheses(fixer, arrow, arrowBody) {
            const parenthesesFixes = [];
            let closingParentheses = "";

            let followingBody = arrowBody;
            let currentArrow = arrow;

            while (currentArrow) {
                if (!isParenthesised(sourceCode, followingBody)) {
                    parenthesesFixes.push(
                        fixer.insertTextAfter(currentArrow, " (")
                    );

                    const paramsToken = sourceCode.getTokenBefore(currentArrow, token =>
                        isOpeningParenToken(token) || token.type === "Identifier");

                    const whiteSpaces = " ".repeat(paramsToken.loc.start.column);

                    closingParentheses = `\n${whiteSpaces})${closingParentheses}`;
                }

                currentArrow = sourceCode.getTokenAfter(currentArrow, isArrowToken);

                if (currentArrow) {
                    followingBody = sourceCode.getTokenAfter(currentArrow, token => !isOpeningParenToken(token));
                }
            }

            return [...parenthesesFixes,
                fixer.insertTextAfter(arrowBody, closingParentheses)
            ];
        }

        /**
         * Autofixes the function body to collapse onto the same line as the arrow.
         * If comments exist, prepends the comments before the arrow function.
         * If the function body contains arrow functions, appends the function bodies with parentheses.
         * @param {Token} arrowToken The arrow token.
         * @param {ASTNode} arrowBody the function body
         * @param {ASTNode} node The evaluated node
         * @returns {Function} autofixer -- validates the node to adhere to besides
         */
        function autoFixBesides(arrowToken, arrowBody, node) {
            return fixer => {
                const placeBesides = fixer.replaceTextRange([arrowToken.range[1], arrowBody.range[0]], " ");

                const comments = sourceCode.getCommentsInside(node).filter(comment =>
                    comment.loc.start.line < arrowBody.loc.start.line);

                if (comments.length) {

                    // If the grandparent is not a variable declarator
                    if (
                        arrowBody.parent &&
                        arrowBody.parent.parent &&
                        arrowBody.parent.parent.type !== "VariableDeclarator"
                    ) {

                        // If any arrow functions follow, return the necessary parens fixes.
                        if (sourceCode.getTokenAfter(arrowToken, isArrowToken) && arrowBody.parent.parent.type !== "VariableDeclarator") {
                            return addParentheses(fixer, arrowToken, arrowBody);
                        }

                        // If any arrow functions precede, the necessary fixes have already been returned, so return null.
                        if (sourceCode.getTokenBefore(arrowToken, isArrowToken) && arrowBody.parent.parent.type !== "VariableDeclarator") {
                            return null;
                        }
                    }

                    const firstToken = findFirstToken(node);

                    const commentText = formatComments(comments, firstToken.loc.start.column);

                    const commentBeforeExpression = fixer.insertTextBeforeRange(
                        firstToken.range,
                        commentText
                    );

                    return [placeBesides, commentBeforeExpression];
                }

                return placeBesides;
            };
        }

        /**
         * Validates the location of an arrow function body
         * @param {ASTNode} node The arrow function body
         * @returns {void}
         */
        function validateExpression(node) {
            const option = getOption();

            let tokenBefore = sourceCode.getTokenBefore(node.body);
            const hasParens = tokenBefore.value === "(";

            if (node.type === "BlockStatement") {
                return;
            }

            let fixerTarget = node.body;

            if (hasParens) {

                // Gets the first token before the function body that is not an open paren
                tokenBefore = sourceCode.getTokenBefore(node.body, token => token.value !== "(");
                fixerTarget = sourceCode.getTokenAfter(tokenBefore);
            }

            if (tokenBefore.loc.end.line === fixerTarget.loc.start.line && option === "below") {
                context.report({
                    node: fixerTarget,
                    message: "Expected a linebreak before this expression.",
                    fix: fixer => fixer.insertTextBefore(fixerTarget, "\n")
                });
            } else if (tokenBefore.loc.end.line !== fixerTarget.loc.start.line && option === "beside") {
                context.report({
                    node: fixerTarget,
                    message: "Expected no linebreak before this expression.",
                    fix: autoFixBesides(tokenBefore, fixerTarget, node)
                });
            }
        }

        //----------------------------------------------------------------------
        // Public
        //----------------------------------------------------------------------
        return {
            ArrowFunctionExpression: node => validateExpression(node)
        };
    }
};
