var hasOwn = Object.prototype.hasOwnProperty;

module.exports = function (fork) {
    var types = fork.use(require("./types"));
    var Type = types.Type;
    var namedTypes = types.namedTypes;
    var Node = namedTypes.Node;
    var Expression = namedTypes.Expression;
    var isArray = types.builtInTypes.array;
    var b = types.builders;

    function Scope(path, parentScope) {
        if (!(this instanceof Scope)) {
            throw new Error("Scope constructor cannot be invoked without 'new'");
        }
        if (!(path instanceof fork.use(require("./node-path")))) {
            throw new Error("");
        }
        ScopeType.assert(path.value);

        var depth;

        if (parentScope) {
            if (!(parentScope instanceof Scope)) {
                throw new Error("");
            }
            depth = parentScope.depth + 1;
        } else {
            parentScope = null;
            depth = 0;
        }

        Object.defineProperties(this, {
            path: { value: path },
            node: { value: path.value },
            isGlobal: { value: !parentScope, enumerable: true },
            depth: { value: depth },
            parent: { value: parentScope },
            bindings: { value: {} },
            types: { value: {} },
        });
    }

    var scopeTypes = [
        // Program nodes introduce global scopes.
        namedTypes.Program,

        // Function is the supertype of FunctionExpression,
        // FunctionDeclaration, ArrowExpression, etc.
        namedTypes.Function,

        // In case you didn't know, the caught parameter shadows any variable
        // of the same name in an outer scope.
        namedTypes.CatchClause
    ];

    var ScopeType = Type.or.apply(Type, scopeTypes);

    Scope.isEstablishedBy = function(node) {
        return ScopeType.check(node);
    };

    var Sp = Scope.prototype;

// Will be overridden after an instance lazily calls scanScope.
    Sp.didScan = false;

    Sp.declares = function(name) {
        this.scan();
        return hasOwn.call(this.bindings, name);
    };

    Sp.declaresType = function(name) {
        this.scan();
        return hasOwn.call(this.types, name);
    };

    Sp.declareTemporary = function(prefix) {
        if (prefix) {
            if (!/^[a-z$_]/i.test(prefix)) {
                throw new Error("");
            }
        } else {
            prefix = "t$";
        }

        // Include this.depth in the name to make sure the name does not
        // collide with any variables in nested/enclosing scopes.
        prefix += this.depth.toString(36) + "$";

        this.scan();

        var index = 0;
        while (this.declares(prefix + index)) {
            ++index;
        }

        var name = prefix + index;
        return this.bindings[name] = types.builders.identifier(name);
    };

    Sp.injectTemporary = function(identifier, init) {
        identifier || (identifier = this.declareTemporary());

        var bodyPath = this.path.get("body");
        if (namedTypes.BlockStatement.check(bodyPath.value)) {
            bodyPath = bodyPath.get("body");
        }

        bodyPath.unshift(
          b.variableDeclaration(
            "var",
            [b.variableDeclarator(identifier, init || null)]
          )
        );

        return identifier;
    };

    Sp.scan = function(force) {
        if (force || !this.didScan) {
            for (var name in this.bindings) {
                // Empty out this.bindings, just in cases.
                delete this.bindings[name];
            }
            scanScope(this.path, this.bindings, this.types);
            this.didScan = true;
        }
    };

    Sp.getBindings = function () {
        this.scan();
        return this.bindings;
    };

    Sp.getTypes = function () {
        this.scan();
        return this.types;
    };

    function scanScope(path, bindings, scopeTypes) {
        var node = path.value;
        ScopeType.assert(node);

        if (namedTypes.CatchClause.check(node)) {
            // A catch clause establishes a new scope but the only variable
            // bound in that scope is the catch parameter. Any other
            // declarations create bindings in the outer scope.
            addPattern(path.get("param"), bindings);

        } else {
            recursiveScanScope(path, bindings, scopeTypes);
        }
    }

    function recursiveScanScope(path, bindings, scopeTypes) {
        var node = path.value;

        if (path.parent &&
          namedTypes.FunctionExpression.check(path.parent.node) &&
          path.parent.node.id) {
            addPattern(path.parent.get("id"), bindings);
        }

        if (!node) {
            // None of the remaining cases matter if node is falsy.

        } else if (isArray.check(node)) {
            path.each(function(childPath) {
                recursiveScanChild(childPath, bindings, scopeTypes);
            });

        } else if (namedTypes.Function.check(node)) {
            path.get("params").each(function(paramPath) {
                addPattern(paramPath, bindings);
            });

            recursiveScanChild(path.get("body"), bindings, scopeTypes);

        } else if (namedTypes.TypeAlias && namedTypes.TypeAlias.check(node)) {
            addTypePattern(path.get("id"), scopeTypes);

        } else if (namedTypes.VariableDeclarator.check(node)) {
            addPattern(path.get("id"), bindings);
            recursiveScanChild(path.get("init"), bindings, scopeTypes);

        } else if (node.type === "ImportSpecifier" ||
          node.type === "ImportNamespaceSpecifier" ||
          node.type === "ImportDefaultSpecifier") {
            addPattern(
              // Esprima used to use the .name field to refer to the local
              // binding identifier for ImportSpecifier nodes, but .id for
              // ImportNamespaceSpecifier and ImportDefaultSpecifier nodes.
              // ESTree/Acorn/ESpree use .local for all three node types.
              path.get(node.local ? "local" :
                node.name ? "name" : "id"),
              bindings
            );

        } else if (Node.check(node) && !Expression.check(node)) {
            types.eachField(node, function(name, child) {
                var childPath = path.get(name);
                if (!pathHasValue(childPath, child)) {
                    throw new Error("");
                }
                recursiveScanChild(childPath, bindings, scopeTypes);
            });
        }
    }

    function pathHasValue(path, value) {
        if (path.value === value) {
            return true;
        }

        // Empty arrays are probably produced by defaults.emptyArray, in which
        // case is makes sense to regard them as equivalent, if not ===.
        if (Array.isArray(path.value) &&
          path.value.length === 0 &&
          Array.isArray(value) &&
          value.length === 0) {
            return true;
        }

        return false;
    }

    function recursiveScanChild(path, bindings, scopeTypes) {
        var node = path.value;

        if (!node || Expression.check(node)) {
            // Ignore falsy values and Expressions.

        } else if (namedTypes.FunctionDeclaration.check(node) &&
                   node.id !== null) {
            addPattern(path.get("id"), bindings);

        } else if (namedTypes.ClassDeclaration &&
          namedTypes.ClassDeclaration.check(node)) {
            addPattern(path.get("id"), bindings);

        } else if (ScopeType.check(node)) {
            if (namedTypes.CatchClause.check(node)) {
                var catchParamName = node.param.name;
                var hadBinding = hasOwn.call(bindings, catchParamName);

                // Any declarations that occur inside the catch body that do
                // not have the same name as the catch parameter should count
                // as bindings in the outer scope.
                recursiveScanScope(path.get("body"), bindings, scopeTypes);

                // If a new binding matching the catch parameter name was
                // created while scanning the catch body, ignore it because it
                // actually refers to the catch parameter and not the outer
                // scope that we're currently scanning.
                if (!hadBinding) {
                    delete bindings[catchParamName];
                }
            }

        } else {
            recursiveScanScope(path, bindings, scopeTypes);
        }
    }

    function addPattern(patternPath, bindings) {
        var pattern = patternPath.value;
        namedTypes.Pattern.assert(pattern);

        if (namedTypes.Identifier.check(pattern)) {
            if (hasOwn.call(bindings, pattern.name)) {
                bindings[pattern.name].push(patternPath);
            } else {
                bindings[pattern.name] = [patternPath];
            }

        } else if (namedTypes.ObjectPattern &&
          namedTypes.ObjectPattern.check(pattern)) {
            patternPath.get('properties').each(function(propertyPath) {
                var property = propertyPath.value;
                if (namedTypes.Pattern.check(property)) {
                    addPattern(propertyPath, bindings);
                } else  if (namedTypes.Property.check(property)) {
                    addPattern(propertyPath.get('value'), bindings);
                } else if (namedTypes.SpreadProperty &&
                  namedTypes.SpreadProperty.check(property)) {
                    addPattern(propertyPath.get('argument'), bindings);
                }
            });

        } else if (namedTypes.ArrayPattern &&
          namedTypes.ArrayPattern.check(pattern)) {
            patternPath.get('elements').each(function(elementPath) {
                var element = elementPath.value;
                if (namedTypes.Pattern.check(element)) {
                    addPattern(elementPath, bindings);
                } else if (namedTypes.SpreadElement &&
                  namedTypes.SpreadElement.check(element)) {
                    addPattern(elementPath.get("argument"), bindings);
                }
            });

        } else if (namedTypes.PropertyPattern &&
          namedTypes.PropertyPattern.check(pattern)) {
            addPattern(patternPath.get('pattern'), bindings);

        } else if ((namedTypes.SpreadElementPattern &&
          namedTypes.SpreadElementPattern.check(pattern)) ||
          (namedTypes.SpreadPropertyPattern &&
          namedTypes.SpreadPropertyPattern.check(pattern))) {
            addPattern(patternPath.get('argument'), bindings);
        }
    }

    function addTypePattern(patternPath, types) {
        var pattern = patternPath.value;
        namedTypes.Pattern.assert(pattern);

        if (namedTypes.Identifier.check(pattern)) {
            if (hasOwn.call(types, pattern.name)) {
                types[pattern.name].push(patternPath);
            } else {
                types[pattern.name] = [patternPath];
            }

        }
    }

    Sp.lookup = function(name) {
        for (var scope = this; scope; scope = scope.parent)
            if (scope.declares(name))
                break;
        return scope;
    };

    Sp.lookupType = function(name) {
        for (var scope = this; scope; scope = scope.parent)
            if (scope.declaresType(name))
                break;
        return scope;
    };

    Sp.getGlobalScope = function() {
        var scope = this;
        while (!scope.isGlobal)
            scope = scope.parent;
        return scope;
    };

    return Scope;
};
