/**
 * @fileoverview This option sets a specific tab width for your code
 *
 * This rule has been ported and modified from nodeca.
 * @author Vitaly Puzrin
 * @author Gyandeep Singh
 */

"use strict";

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
var util = require("util");
var lodash = require("lodash");

module.exports = {
    meta: {
        docs: {
            description: "enforce consistent indentation",
            category: "Stylistic Issues",
            recommended: false
        },

        fixable: "whitespace",

        schema: [
            {
                oneOf: [
                    {
                        enum: ["tab"]
                    },
                    {
                        type: "integer",
                        minimum: 0
                    }
                ]
            },
            {
                type: "object",
                properties: {
                    SwitchCase: {
                        type: "integer",
                        minimum: 0
                    },
                    VariableDeclarator: {
                        oneOf: [
                            {
                                type: "integer",
                                minimum: 0
                            },
                            {
                                type: "object",
                                properties: {
                                    var: {
                                        type: "integer",
                                        minimum: 0
                                    },
                                    let: {
                                        type: "integer",
                                        minimum: 0
                                    },
                                    const: {
                                        type: "integer",
                                        minimum: 0
                                    }
                                }
                            }
                        ]
                    },
                    outerIIFEBody: {
                        type: "integer",
                        minimum: 0
                    }
                },
                additionalProperties: false
            }
        ]
    },

    create: function(context) {

        var MESSAGE = "Expected indentation of {{needed}} {{type}} {{characters}} but found {{gotten}}.";
        var DEFAULT_VARIABLE_INDENT = 1;

        var indentType = "space";
        var indentSize = 4;
        var options = {
            SwitchCase: 0,
            VariableDeclarator: {
                var: DEFAULT_VARIABLE_INDENT,
                let: DEFAULT_VARIABLE_INDENT,
                const: DEFAULT_VARIABLE_INDENT
            },
            outerIIFEBody: null
        };

        var sourceCode = context.getSourceCode();

        if (context.options.length) {
            if (context.options[0] === "tab") {
                indentSize = 1;
                indentType = "tab";
            } else /* istanbul ignore else : this will be caught by options validation */ if (typeof context.options[0] === "number") {
                indentSize = context.options[0];
                indentType = "space";
            }

            if (context.options[1]) {
                var opts = context.options[1];

                options.SwitchCase = opts.SwitchCase || 0;
                var variableDeclaratorRules = opts.VariableDeclarator;

                if (typeof variableDeclaratorRules === "number") {
                    options.VariableDeclarator = {
                        var: variableDeclaratorRules,
                        let: variableDeclaratorRules,
                        const: variableDeclaratorRules
                    };
                } else if (typeof variableDeclaratorRules === "object") {
                    lodash.assign(options.VariableDeclarator, variableDeclaratorRules);
                }

                if (typeof opts.outerIIFEBody === "number") {
                    options.outerIIFEBody = opts.outerIIFEBody;
                }
            }
        }

        var indentPattern = {
            normal: indentType === "space" ? /^ +/ : /^\t+/,
            excludeCommas: indentType === "space" ? /^[ ,]+/ : /^[\t,]+/
        };

        var caseIndentStore = {};

        /**
         * Reports a given indent violation and properly pluralizes the message
         * @param {ASTNode} node Node violating the indent rule
         * @param {int} needed Expected indentation character count
         * @param {int} gotten Indentation character count in the actual node/code
         * @param {Object=} loc Error line and column location
         * @param {boolean} isLastNodeCheck Is the error for last node check
         * @returns {void}
         */
        function report(node, needed, gotten, loc, isLastNodeCheck) {
            var msgContext = {
                needed: needed,
                type: indentType,
                characters: needed === 1 ? "character" : "characters",
                gotten: gotten
            };
            var indentChar = indentType === "space" ? " " : "\t";

            /**
             * Responsible for fixing the indentation issue fix
             * @returns {Function} function to be executed by the fixer
             * @private
             */
            function getFixerFunction() {
                var rangeToFix = [];

                if (needed > gotten) {
                    var spaces = "" + new Array(needed - gotten + 1).join(indentChar);  // replace with repeat in future

                    if (isLastNodeCheck === true) {
                        rangeToFix = [
                            node.range[1] - 1,
                            node.range[1] - 1
                        ];
                    } else {
                        rangeToFix = [
                            node.range[0],
                            node.range[0]
                        ];
                    }

                    return function(fixer) {
                        return fixer.insertTextBeforeRange(rangeToFix, spaces);
                    };
                } else {
                    if (isLastNodeCheck === true) {
                        rangeToFix = [
                            node.range[1] - (gotten - needed) - 1,
                            node.range[1] - 1
                        ];
                    } else {
                        rangeToFix = [
                            node.range[0] - (gotten - needed),
                            node.range[0]
                        ];
                    }

                    return function(fixer) {
                        return fixer.removeRange(rangeToFix);
                    };
                }
            }

            if (loc) {
                context.report({
                    node: node,
                    loc: loc,
                    message: MESSAGE,
                    data: msgContext,
                    fix: getFixerFunction()
                });
            } else {
                context.report({
                    node: node,
                    message: MESSAGE,
                    data: msgContext,
                    fix: getFixerFunction()
                });
            }
        }

        /**
         * Get the actual indent of node
         * @param {ASTNode|Token} node Node to examine
         * @param {boolean} [byLastLine=false] get indent of node's last line
         * @param {boolean} [excludeCommas=false] skip comma on start of line
         * @returns {int} Indent
         */
        function getNodeIndent(node, byLastLine, excludeCommas) {
            var token = byLastLine ? sourceCode.getLastToken(node) : sourceCode.getFirstToken(node);
            var src = sourceCode.getText(token, token.loc.start.column);
            var regExp = excludeCommas ? indentPattern.excludeCommas : indentPattern.normal;
            var indent = regExp.exec(src);

            return indent ? indent[0].length : 0;
        }

        /**
         * Checks node is the first in its own start line. By default it looks by start line.
         * @param {ASTNode} node The node to check
         * @param {boolean} [byEndLocation=false] Lookup based on start position or end
         * @returns {boolean} true if its the first in the its start line
         */
        function isNodeFirstInLine(node, byEndLocation) {
            var firstToken = byEndLocation === true ? sourceCode.getLastToken(node, 1) : sourceCode.getTokenBefore(node),
                startLine = byEndLocation === true ? node.loc.end.line : node.loc.start.line,
                endLine = firstToken ? firstToken.loc.end.line : -1;

            return startLine !== endLine;
        }

        /**
         * Check indent for node
         * @param {ASTNode} node Node to check
         * @param {int} indent needed indent
         * @param {boolean} [excludeCommas=false] skip comma on start of line
         * @returns {void}
         */
        function checkNodeIndent(node, indent, excludeCommas) {
            var nodeIndent = getNodeIndent(node, false, excludeCommas);

            if (
                node.type !== "ArrayExpression" && node.type !== "ObjectExpression" &&
                nodeIndent !== indent && isNodeFirstInLine(node)
            ) {
                report(node, indent, nodeIndent);
            }
        }

        /**
         * Check indent for nodes list
         * @param {ASTNode[]} nodes list of node objects
         * @param {int} indent needed indent
         * @param {boolean} [excludeCommas=false] skip comma on start of line
         * @returns {void}
         */
        function checkNodesIndent(nodes, indent, excludeCommas) {
            nodes.forEach(function(node) {
                if (node.type === "IfStatement" && node.alternate) {
                    var elseToken = sourceCode.getTokenBefore(node.alternate);

                    checkNodeIndent(elseToken, indent, excludeCommas);
                }
                checkNodeIndent(node, indent, excludeCommas);
            });
        }

        /**
         * Check last node line indent this detects, that block closed correctly
         * @param {ASTNode} node Node to examine
         * @param {int} lastLineIndent needed indent
         * @returns {void}
         */
        function checkLastNodeLineIndent(node, lastLineIndent) {
            var lastToken = sourceCode.getLastToken(node);
            var endIndent = getNodeIndent(lastToken, true);

            if (endIndent !== lastLineIndent && isNodeFirstInLine(node, true)) {
                report(
                    node,
                    lastLineIndent,
                    endIndent,
                    { line: lastToken.loc.start.line, column: lastToken.loc.start.column },
                    true
                );
            }
        }

        /**
         * Check first node line indent is correct
         * @param {ASTNode} node Node to examine
         * @param {int} firstLineIndent needed indent
         * @returns {void}
         */
        function checkFirstNodeLineIndent(node, firstLineIndent) {
            var startIndent = getNodeIndent(node, false);

            if (startIndent !== firstLineIndent && isNodeFirstInLine(node)) {
                report(
                    node,
                    firstLineIndent,
                    startIndent,
                    { line: node.loc.start.line, column: node.loc.start.column }
                );
            }
        }

        /**
         * Returns the VariableDeclarator based on the current node
         * if not present then return null
         * @param {ASTNode} node node to examine
         * @returns {ASTNode|void} if found then node otherwise null
         */
        function getVariableDeclaratorNode(node) {
            var parent = node.parent;

            while (parent.type !== "VariableDeclarator" && parent.type !== "Program") {
                parent = parent.parent;
            }

            return parent.type === "VariableDeclarator" ? parent : null;
        }

        /**
         * Check to see if the node is part of the multi-line variable declaration.
         * Also if its on the same line as the varNode
         * @param {ASTNode} node node to check
         * @param {ASTNode} varNode variable declaration node to check against
         * @returns {boolean} True if all the above condition satisfy
         */
        function isNodeInVarOnTop(node, varNode) {
            return varNode &&
                varNode.parent.loc.start.line === node.loc.start.line &&
                varNode.parent.declarations.length > 1;
        }

        /**
         * Check to see if the argument before the callee node is multi-line and
         * there should only be 1 argument before the callee node
         * @param {ASTNode} node node to check
         * @returns {boolean} True if arguments are multi-line
         */
        function isArgBeforeCalleeNodeMultiline(node) {
            var parent = node.parent;

            if (parent.arguments.length >= 2 && parent.arguments[1] === node) {
                return parent.arguments[0].loc.end.line > parent.arguments[0].loc.start.line;
            }

            return false;
        }

		/**
         * Check to see if the node is a file level IIFE
         * @param {ASTNode} node The function node to check.
         * @returns {boolean} True if the node is the outer IIFE
         */
        function isOuterIIFE(node) {
            var parent = node.parent;

            return (
                parent.type === "CallExpression" &&
                parent.callee === node &&
                parent.parent.type === "ExpressionStatement" &&
                parent.parent.parent && parent.parent.parent.type === "Program"
            );
        }

        /**
         * Check indent for function block content
         * @param {ASTNode} node A BlockStatement node that is inside of a function.
         * @returns {void}
         */
        function checkIndentInFunctionBlock(node) {

            /*
             * Search first caller in chain.
             * Ex.:
             *
             * Models <- Identifier
             *   .User
             *   .find()
             *   .exec(function() {
             *   // function body
             * });
             *
             * Looks for 'Models'
             */
            var calleeNode = node.parent; // FunctionExpression
            var indent;

            if (calleeNode.parent &&
                (calleeNode.parent.type === "Property" ||
                calleeNode.parent.type === "ArrayExpression")) {

                // If function is part of array or object, comma can be put at left
                indent = getNodeIndent(calleeNode, false, false);
            } else {

                // If function is standalone, simple calculate indent
                indent = getNodeIndent(calleeNode);
            }

            if (calleeNode.parent.type === "CallExpression") {
                var calleeParent = calleeNode.parent;

                if (calleeNode.type !== "FunctionExpression" && calleeNode.type !== "ArrowFunctionExpression") {
                    if (calleeParent && calleeParent.loc.start.line < node.loc.start.line) {
                        indent = getNodeIndent(calleeParent);
                    }
                } else {
                    if (isArgBeforeCalleeNodeMultiline(calleeNode) &&
                        calleeParent.callee.loc.start.line === calleeParent.callee.loc.end.line &&
                        !isNodeFirstInLine(calleeNode)) {
                        indent = getNodeIndent(calleeParent);
                    }
                }
            }

            // function body indent should be indent + indent size, unless this
            // is the outer IIFE and that option is enabled.
            var functionOffset = indentSize;

            if (options.outerIIFEBody !== null && isOuterIIFE(calleeNode)) {
                functionOffset = options.outerIIFEBody * indentSize;
            }
            indent += functionOffset;

            // check if the node is inside a variable
            var parentVarNode = getVariableDeclaratorNode(node);

            if (parentVarNode && isNodeInVarOnTop(node, parentVarNode)) {
                indent += indentSize * options.VariableDeclarator[parentVarNode.parent.kind];
            }

            if (node.body.length > 0) {
                checkNodesIndent(node.body, indent);
            }

            checkLastNodeLineIndent(node, indent - functionOffset);
        }


        /**
         * Checks if the given node starts and ends on the same line
         * @param {ASTNode} node The node to check
         * @returns {boolean} Whether or not the block starts and ends on the same line.
         */
        function isSingleLineNode(node) {
            var lastToken = sourceCode.getLastToken(node),
                startLine = node.loc.start.line,
                endLine = lastToken.loc.end.line;

            return startLine === endLine;
        }

        /**
         * Check to see if the first element inside an array is an object and on the same line as the node
         * If the node is not an array then it will return false.
         * @param {ASTNode} node node to check
         * @returns {boolean} success/failure
         */
        function isFirstArrayElementOnSameLine(node) {
            if (node.type === "ArrayExpression" && node.elements[0]) {
                return node.elements[0].loc.start.line === node.loc.start.line && node.elements[0].type === "ObjectExpression";
            } else {
                return false;
            }
        }

        /**
         * Check indent for array block content or object block content
         * @param {ASTNode} node node to examine
         * @returns {void}
         */
        function checkIndentInArrayOrObjectBlock(node) {

            // Skip inline
            if (isSingleLineNode(node)) {
                return;
            }

            var elements = (node.type === "ArrayExpression") ? node.elements : node.properties;

            // filter out empty elements example would be [ , 2] so remove first element as espree considers it as null
            elements = elements.filter(function(elem) {
                return elem !== null;
            });

            // Skip if first element is in same line with this node
            if (elements.length > 0 && elements[0].loc.start.line === node.loc.start.line) {
                return;
            }

            var nodeIndent;
            var elementsIndent;
            var parentVarNode = getVariableDeclaratorNode(node);

            // TODO - come up with a better strategy in future
            if (isNodeFirstInLine(node)) {
                var parent = node.parent;
                var effectiveParent = parent;

                if (parent.type === "MemberExpression") {
                    if (isNodeFirstInLine(parent)) {
                        effectiveParent = parent.parent.parent;
                    } else {
                        effectiveParent = parent.parent;
                    }
                }
                nodeIndent = getNodeIndent(effectiveParent);
                if (parentVarNode && parentVarNode.loc.start.line !== node.loc.start.line) {
                    if (parent.type !== "VariableDeclarator" || parentVarNode === parentVarNode.parent.declarations[0]) {
                        if (parent.type === "VariableDeclarator" && parentVarNode.loc.start.line === effectiveParent.loc.start.line) {
                            nodeIndent = nodeIndent + (indentSize * options.VariableDeclarator[parentVarNode.parent.kind]);
                        } else if (
                            parent.type === "ObjectExpression" ||
                            parent.type === "ArrayExpression" ||
                            parent.type === "CallExpression" ||
                            parent.type === "ArrowFunctionExpression" ||
                            parent.type === "NewExpression"
                        ) {
                            nodeIndent = nodeIndent + indentSize;
                        }
                    }
                } else if (!parentVarNode && !isFirstArrayElementOnSameLine(parent) && effectiveParent.type !== "MemberExpression" && effectiveParent.type !== "ExpressionStatement" && effectiveParent.type !== "AssignmentExpression" && effectiveParent.type !== "Property") {
                    nodeIndent = nodeIndent + indentSize;
                }

                elementsIndent = nodeIndent + indentSize;

                checkFirstNodeLineIndent(node, nodeIndent);
            } else {
                nodeIndent = getNodeIndent(node);
                elementsIndent = nodeIndent + indentSize;
            }

            /*
             * Check if the node is a multiple variable declaration; if so, then
             * make sure indentation takes that into account.
             */
            if (isNodeInVarOnTop(node, parentVarNode)) {
                elementsIndent += indentSize * options.VariableDeclarator[parentVarNode.parent.kind];
            }

            // Comma can be placed before property name
            checkNodesIndent(elements, elementsIndent, true);

            if (elements.length > 0) {

                // Skip last block line check if last item in same line
                if (elements[elements.length - 1].loc.end.line === node.loc.end.line) {
                    return;
                }
            }

            checkLastNodeLineIndent(node, elementsIndent - indentSize);
        }

        /**
         * Check if the node or node body is a BlockStatement or not
         * @param {ASTNode} node node to test
         * @returns {boolean} True if it or its body is a block statement
         */
        function isNodeBodyBlock(node) {
            return node.type === "BlockStatement" || node.type === "ClassBody" || (node.body && node.body.type === "BlockStatement") ||
                (node.consequent && node.consequent.type === "BlockStatement");
        }

        /**
         * Check indentation for blocks
         * @param {ASTNode} node node to check
         * @returns {void}
         */
        function blockIndentationCheck(node) {

            // Skip inline blocks
            if (isSingleLineNode(node)) {
                return;
            }

            if (node.parent && (
                    node.parent.type === "FunctionExpression" ||
                    node.parent.type === "FunctionDeclaration" ||
                    node.parent.type === "ArrowFunctionExpression"
            )) {
                checkIndentInFunctionBlock(node);
                return;
            }

            var indent;
            var nodesToCheck = [];

            /*
             * For this statements we should check indent from statement beginning,
             * not from the beginning of the block.
             */
            var statementsWithProperties = [
                "IfStatement", "WhileStatement", "ForStatement", "ForInStatement", "ForOfStatement", "DoWhileStatement", "ClassDeclaration"
            ];

            if (node.parent && statementsWithProperties.indexOf(node.parent.type) !== -1 && isNodeBodyBlock(node)) {
                indent = getNodeIndent(node.parent);
            } else {
                indent = getNodeIndent(node);
            }

            if (node.type === "IfStatement" && node.consequent.type !== "BlockStatement") {
                nodesToCheck = [node.consequent];
            } else if (util.isArray(node.body)) {
                nodesToCheck = node.body;
            } else {
                nodesToCheck = [node.body];
            }

            if (nodesToCheck.length > 0) {
                checkNodesIndent(nodesToCheck, indent + indentSize);
            }

            if (node.type === "BlockStatement") {
                checkLastNodeLineIndent(node, indent);
            }
        }

        /**
         * Filter out the elements which are on the same line of each other or the node.
         * basically have only 1 elements from each line except the variable declaration line.
         * @param {ASTNode} node Variable declaration node
         * @returns {ASTNode[]} Filtered elements
         */
        function filterOutSameLineVars(node) {
            return node.declarations.reduce(function(finalCollection, elem) {
                var lastElem = finalCollection[finalCollection.length - 1];

                if ((elem.loc.start.line !== node.loc.start.line && !lastElem) ||
                    (lastElem && lastElem.loc.start.line !== elem.loc.start.line)) {
                    finalCollection.push(elem);
                }

                return finalCollection;
            }, []);
        }

        /**
         * Check indentation for variable declarations
         * @param {ASTNode} node node to examine
         * @returns {void}
         */
        function checkIndentInVariableDeclarations(node) {
            var elements = filterOutSameLineVars(node);
            var nodeIndent = getNodeIndent(node);
            var lastElement = elements[elements.length - 1];

            var elementsIndent = nodeIndent + indentSize * options.VariableDeclarator[node.kind];

            // Comma can be placed before declaration
            checkNodesIndent(elements, elementsIndent, true);

            // Only check the last line if there is any token after the last item
            if (sourceCode.getLastToken(node).loc.end.line <= lastElement.loc.end.line) {
                return;
            }

            var tokenBeforeLastElement = sourceCode.getTokenBefore(lastElement);

            if (tokenBeforeLastElement.value === ",") {

                // Special case for comma-first syntax where the semicolon is indented
                checkLastNodeLineIndent(node, getNodeIndent(tokenBeforeLastElement));
            } else {
                checkLastNodeLineIndent(node, elementsIndent - indentSize);
            }
        }

        /**
         * Check and decide whether to check for indentation for blockless nodes
         * Scenarios are for or while statements without braces around them
         * @param {ASTNode} node node to examine
         * @returns {void}
         */
        function blockLessNodes(node) {
            if (node.body.type !== "BlockStatement") {
                blockIndentationCheck(node);
            }
        }

        /**
         * Returns the expected indentation for the case statement
         * @param {ASTNode} node node to examine
         * @param {int} [switchIndent] indent for switch statement
         * @returns {int} indent size
         */
        function expectedCaseIndent(node, switchIndent) {
            var switchNode = (node.type === "SwitchStatement") ? node : node.parent;
            var caseIndent;

            if (caseIndentStore[switchNode.loc.start.line]) {
                return caseIndentStore[switchNode.loc.start.line];
            } else {
                if (typeof switchIndent === "undefined") {
                    switchIndent = getNodeIndent(switchNode);
                }

                if (switchNode.cases.length > 0 && options.SwitchCase === 0) {
                    caseIndent = switchIndent;
                } else {
                    caseIndent = switchIndent + (indentSize * options.SwitchCase);
                }

                caseIndentStore[switchNode.loc.start.line] = caseIndent;
                return caseIndent;
            }
        }

        return {
            Program: function(node) {
                if (node.body.length > 0) {

                    // Root nodes should have no indent
                    checkNodesIndent(node.body, getNodeIndent(node));
                }
            },

            ClassBody: blockIndentationCheck,

            BlockStatement: blockIndentationCheck,

            WhileStatement: blockLessNodes,

            ForStatement: blockLessNodes,

            ForInStatement: blockLessNodes,

            ForOfStatement: blockLessNodes,

            DoWhileStatement: blockLessNodes,

            IfStatement: function(node) {
                if (node.consequent.type !== "BlockStatement" && node.consequent.loc.start.line > node.loc.start.line) {
                    blockIndentationCheck(node);
                }
            },

            VariableDeclaration: function(node) {
                if (node.declarations[node.declarations.length - 1].loc.start.line > node.declarations[0].loc.start.line) {
                    checkIndentInVariableDeclarations(node);
                }
            },

            ObjectExpression: function(node) {
                checkIndentInArrayOrObjectBlock(node);
            },

            ArrayExpression: function(node) {
                checkIndentInArrayOrObjectBlock(node);
            },

            SwitchStatement: function(node) {

                // Switch is not a 'BlockStatement'
                var switchIndent = getNodeIndent(node);
                var caseIndent = expectedCaseIndent(node, switchIndent);

                checkNodesIndent(node.cases, caseIndent);


                checkLastNodeLineIndent(node, switchIndent);
            },

            SwitchCase: function(node) {

                // Skip inline cases
                if (isSingleLineNode(node)) {
                    return;
                }
                var caseIndent = expectedCaseIndent(node);

                checkNodesIndent(node.consequent, caseIndent + indentSize);
            }
        };

    }
};
