/**
 * @fileoverview Rule to flag use of variables before they are defined
 * @author Ilya Volodin
 */

"use strict";

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

const SENTINEL_TYPE = /^(?:(?:Function|Class)(?:Declaration|Expression)|ArrowFunctionExpression|CatchClause|ImportDeclaration|ExportNamedDeclaration)$/u;
const FOR_IN_OF_TYPE = /^For(?:In|Of)Statement$/u;

/**
 * Parses a given value as options.
 * @param {any} options A value to parse.
 * @returns {Object} The parsed options.
 */
function parseOptions(options) {
    let functions = true;
    let classes = true;
    let variables = true;

    if (typeof options === "string") {
        functions = (options !== "nofunc");
    } else if (typeof options === "object" && options !== null) {
        functions = options.functions !== false;
        classes = options.classes !== false;
        variables = options.variables !== false;
    }

    return { functions, classes, variables };
}

/**
 * Checks whether or not a given variable is a function declaration.
 * @param {eslint-scope.Variable} variable A variable to check.
 * @returns {boolean} `true` if the variable is a function declaration.
 */
function isFunction(variable) {
    return variable.defs[0].type === "FunctionName";
}

/**
 * Checks whether or not a given variable is a class declaration in an upper function scope.
 * @param {eslint-scope.Variable} variable A variable to check.
 * @param {eslint-scope.Reference} reference A reference to check.
 * @returns {boolean} `true` if the variable is a class declaration.
 */
function isOuterClass(variable, reference) {
    return (
        variable.defs[0].type === "ClassName" &&
        variable.scope.variableScope !== reference.from.variableScope
    );
}

/**
 * Checks whether or not a given variable is a variable declaration in an upper function scope.
 * @param {eslint-scope.Variable} variable A variable to check.
 * @param {eslint-scope.Reference} reference A reference to check.
 * @returns {boolean} `true` if the variable is a variable declaration.
 */
function isOuterVariable(variable, reference) {
    return (
        variable.defs[0].type === "Variable" &&
        variable.scope.variableScope !== reference.from.variableScope
    );
}

/**
 * Checks whether or not a given location is inside of the range of a given node.
 * @param {ASTNode} node An node to check.
 * @param {number} location A location to check.
 * @returns {boolean} `true` if the location is inside of the range of the node.
 */
function isInRange(node, location) {
    return node && node.range[0] <= location && location <= node.range[1];
}

/**
 * Checks whether or not a given reference is inside of the initializers of a given variable.
 *
 * This returns `true` in the following cases:
 *
 *     var a = a
 *     var [a = a] = list
 *     var {a = a} = obj
 *     for (var a in a) {}
 *     for (var a of a) {}
 * @param {Variable} variable A variable to check.
 * @param {Reference} reference A reference to check.
 * @returns {boolean} `true` if the reference is inside of the initializers.
 */
function isInInitializer(variable, reference) {
    if (variable.scope !== reference.from) {
        return false;
    }

    let node = variable.identifiers[0].parent;
    const location = reference.identifier.range[1];

    while (node) {
        if (node.type === "VariableDeclarator") {
            if (isInRange(node.init, location)) {
                return true;
            }
            if (FOR_IN_OF_TYPE.test(node.parent.parent.type) &&
                isInRange(node.parent.parent.right, location)
            ) {
                return true;
            }
            break;
        } else if (node.type === "AssignmentPattern") {
            if (isInRange(node.right, location)) {
                return true;
            }
        } else if (SENTINEL_TYPE.test(node.type)) {
            break;
        }

        node = node.parent;
    }

    return false;
}

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

module.exports = {
    meta: {
        type: "problem",

        docs: {
            description: "disallow the use of variables before they are defined",
            category: "Variables",
            recommended: false,
            url: "https://eslint.org/docs/rules/no-use-before-define"
        },

        schema: [
            {
                oneOf: [
                    {
                        enum: ["nofunc"]
                    },
                    {
                        type: "object",
                        properties: {
                            functions: { type: "boolean" },
                            classes: { type: "boolean" },
                            variables: { type: "boolean" }
                        },
                        additionalProperties: false
                    }
                ]
            }
        ]
    },

    create(context) {
        const options = parseOptions(context.options[0]);

        /**
         * Determines whether a given use-before-define case should be reported according to the options.
         * @param {eslint-scope.Variable} variable The variable that gets used before being defined
         * @param {eslint-scope.Reference} reference The reference to the variable
         * @returns {boolean} `true` if the usage should be reported
         */
        function isForbidden(variable, reference) {
            if (isFunction(variable)) {
                return options.functions;
            }
            if (isOuterClass(variable, reference)) {
                return options.classes;
            }
            if (isOuterVariable(variable, reference)) {
                return options.variables;
            }
            return true;
        }

        /**
         * Finds and validates all variables in a given scope.
         * @param {Scope} scope The scope object.
         * @returns {void}
         * @private
         */
        function findVariablesInScope(scope) {
            scope.references.forEach(reference => {
                const variable = reference.resolved;

                /*
                 * Skips when the reference is:
                 * - initialization's.
                 * - referring to an undefined variable.
                 * - referring to a global environment variable (there're no identifiers).
                 * - located preceded by the variable (except in initializers).
                 * - allowed by options.
                 */
                if (reference.init ||
                    !variable ||
                    variable.identifiers.length === 0 ||
                    (variable.identifiers[0].range[1] < reference.identifier.range[1] && !isInInitializer(variable, reference)) ||
                    !isForbidden(variable, reference)
                ) {
                    return;
                }

                // Reports.
                context.report({
                    node: reference.identifier,
                    message: "'{{name}}' was used before it was defined.",
                    data: reference.identifier
                });
            });

            scope.childScopes.forEach(findVariablesInScope);
        }

        return {
            Program() {
                findVariablesInScope(context.getScope());
            }
        };
    }
};
