/**
 * @fileoverview Rule to flag missing semicolons.
 * @author Nicholas C. Zakas
 */
"use strict";

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

const FixTracker = require("../util/fix-tracker");
const astUtils = require("../util/ast-utils");

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

module.exports = {
    meta: {
        docs: {
            description: "require or disallow semicolons instead of ASI",
            category: "Stylistic Issues",
            recommended: false,
            url: "https://eslint.org/docs/rules/semi"
        },

        fixable: "code",

        schema: {
            anyOf: [
                {
                    type: "array",
                    items: [
                        {
                            enum: ["never"]
                        },
                        {
                            type: "object",
                            properties: {
                                beforeStatementContinuationChars: {
                                    enum: ["always", "any", "never"]
                                }
                            },
                            additionalProperties: false
                        }
                    ],
                    minItems: 0,
                    maxItems: 2
                },
                {
                    type: "array",
                    items: [
                        {
                            enum: ["always"]
                        },
                        {
                            type: "object",
                            properties: {
                                omitLastInOneLineBlock: { type: "boolean" }
                            },
                            additionalProperties: false
                        }
                    ],
                    minItems: 0,
                    maxItems: 2
                }
            ]
        }
    },

    create(context) {

        const OPT_OUT_PATTERN = /^[-[(/+`]/; // One of [(/+-`
        const options = context.options[1];
        const never = context.options[0] === "never";
        const exceptOneLine = Boolean(options && options.omitLastInOneLineBlock);
        const beforeStatementContinuationChars = (options && options.beforeStatementContinuationChars) || "any";
        const sourceCode = context.getSourceCode();

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

        /**
         * Reports a semicolon error with appropriate location and message.
         * @param {ASTNode} node The node with an extra or missing semicolon.
         * @param {boolean} missing True if the semicolon is missing.
         * @returns {void}
         */
        function report(node, missing) {
            const lastToken = sourceCode.getLastToken(node);
            let message,
                fix,
                loc = lastToken.loc;

            if (!missing) {
                message = "Missing semicolon.";
                loc = loc.end;
                fix = function(fixer) {
                    return fixer.insertTextAfter(lastToken, ";");
                };
            } else {
                message = "Extra semicolon.";
                loc = loc.start;
                fix = function(fixer) {

                    /*
                     * Expand the replacement range to include the surrounding
                     * tokens to avoid conflicting with no-extra-semi.
                     * https://github.com/eslint/eslint/issues/7928
                     */
                    return new FixTracker(fixer, sourceCode)
                        .retainSurroundingTokens(lastToken)
                        .remove(lastToken);
                };
            }

            context.report({
                node,
                loc,
                message,
                fix
            });

        }

        /**
         * Check whether a given semicolon token is redandant.
         * @param {Token} semiToken A semicolon token to check.
         * @returns {boolean} `true` if the next token is `;` or `}`.
         */
        function isRedundantSemi(semiToken) {
            const nextToken = sourceCode.getTokenAfter(semiToken);

            return (
                !nextToken ||
                astUtils.isClosingBraceToken(nextToken) ||
                astUtils.isSemicolonToken(nextToken)
            );
        }

        /**
         * Check whether a given token is the closing brace of an arrow function.
         * @param {Token} lastToken A token to check.
         * @returns {boolean} `true` if the token is the closing brace of an arrow function.
         */
        function isEndOfArrowBlock(lastToken) {
            if (!astUtils.isClosingBraceToken(lastToken)) {
                return false;
            }
            const node = sourceCode.getNodeByRangeIndex(lastToken.range[0]);

            return (
                node.type === "BlockStatement" &&
                node.parent.type === "ArrowFunctionExpression"
            );
        }

        /**
         * Check whether a given node is on the same line with the next token.
         * @param {Node} node A statement node to check.
         * @returns {boolean} `true` if the node is on the same line with the next token.
         */
        function isOnSameLineWithNextToken(node) {
            const prevToken = sourceCode.getLastToken(node, 1);
            const nextToken = sourceCode.getTokenAfter(node);

            return !!nextToken && astUtils.isTokenOnSameLine(prevToken, nextToken);
        }

        /**
         * Check whether a given node can connect the next line if the next line is unreliable.
         * @param {Node} node A statement node to check.
         * @returns {boolean} `true` if the node can connect the next line.
         */
        function maybeAsiHazardAfter(node) {
            const t = node.type;

            if (t === "DoWhileStatement" ||
                t === "BreakStatement" ||
                t === "ContinueStatement" ||
                t === "DebuggerStatement" ||
                t === "ImportDeclaration" ||
                t === "ExportAllDeclaration"
            ) {
                return false;
            }
            if (t === "ReturnStatement") {
                return Boolean(node.argument);
            }
            if (t === "ExportNamedDeclaration") {
                return Boolean(node.declaration);
            }
            if (isEndOfArrowBlock(sourceCode.getLastToken(node, 1))) {
                return false;
            }

            return true;
        }

        /**
         * Check whether a given token can connect the previous statement.
         * @param {Token} token A token to check.
         * @returns {boolean} `true` if the token is one of `[`, `(`, `/`, `+`, `-`, ```, `++`, and `--`.
         */
        function maybeAsiHazardBefore(token) {
            return (
                Boolean(token) &&
                OPT_OUT_PATTERN.test(token.value) &&
                token.value !== "++" &&
                token.value !== "--"
            );
        }

        /**
         * Check if the semicolon of a given node is unnecessary, only true if:
         *   - next token is a valid statement divider (`;` or `}`).
         *   - next token is on a new line and the node is not connectable to the new line.
         * @param {Node} node A statement node to check.
         * @returns {boolean} whether the semicolon is unnecessary.
         */
        function canRemoveSemicolon(node) {
            if (isRedundantSemi(sourceCode.getLastToken(node))) {
                return true; // `;;` or `;}`
            }
            if (isOnSameLineWithNextToken(node)) {
                return false; // One liner.
            }
            if (beforeStatementContinuationChars === "never" && !maybeAsiHazardAfter(node)) {
                return true; // ASI works. This statement doesn't connect to the next.
            }
            if (!maybeAsiHazardBefore(sourceCode.getTokenAfter(node))) {
                return true; // ASI works. The next token doesn't connect to this statement.
            }

            return false;
        }

        /**
         * Checks a node to see if it's in a one-liner block statement.
         * @param {ASTNode} node The node to check.
         * @returns {boolean} whether the node is in a one-liner block statement.
         */
        function isOneLinerBlock(node) {
            const parent = node.parent;
            const nextToken = sourceCode.getTokenAfter(node);

            if (!nextToken || nextToken.value !== "}") {
                return false;
            }
            return (
                !!parent &&
                parent.type === "BlockStatement" &&
                parent.loc.start.line === parent.loc.end.line
            );
        }

        /**
         * Checks a node to see if it's followed by a semicolon.
         * @param {ASTNode} node The node to check.
         * @returns {void}
         */
        function checkForSemicolon(node) {
            const isSemi = astUtils.isSemicolonToken(sourceCode.getLastToken(node));

            if (never) {
                if (isSemi && canRemoveSemicolon(node)) {
                    report(node, true);
                } else if (!isSemi && beforeStatementContinuationChars === "always" && maybeAsiHazardBefore(sourceCode.getTokenAfter(node))) {
                    report(node);
                }
            } else {
                const oneLinerBlock = (exceptOneLine && isOneLinerBlock(node));

                if (isSemi && oneLinerBlock) {
                    report(node, true);
                } else if (!isSemi && !oneLinerBlock) {
                    report(node);
                }
            }
        }

        /**
         * Checks to see if there's a semicolon after a variable declaration.
         * @param {ASTNode} node The node to check.
         * @returns {void}
         */
        function checkForSemicolonForVariableDeclaration(node) {
            const parent = node.parent;

            if ((parent.type !== "ForStatement" || parent.init !== node) &&
                (!/^For(?:In|Of)Statement/.test(parent.type) || parent.left !== node)
            ) {
                checkForSemicolon(node);
            }
        }

        //--------------------------------------------------------------------------
        // Public API
        //--------------------------------------------------------------------------

        return {
            VariableDeclaration: checkForSemicolonForVariableDeclaration,
            ExpressionStatement: checkForSemicolon,
            ReturnStatement: checkForSemicolon,
            ThrowStatement: checkForSemicolon,
            DoWhileStatement: checkForSemicolon,
            DebuggerStatement: checkForSemicolon,
            BreakStatement: checkForSemicolon,
            ContinueStatement: checkForSemicolon,
            ImportDeclaration: checkForSemicolon,
            ExportAllDeclaration: checkForSemicolon,
            ExportNamedDeclaration(node) {
                if (!node.declaration) {
                    checkForSemicolon(node);
                }
            },
            ExportDefaultDeclaration(node) {
                if (!/(?:Class|Function)Declaration/.test(node.declaration.type)) {
                    checkForSemicolon(node);
                }
            }
        };

    }
};
