/*
 * Copyright (c) 2013 - present Adobe Systems Incorporated. All rights reserved.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 * DEALINGS IN THE SOFTWARE.
 *
 */

/*
 * Throughout this file, the term "outer scope" is used to refer to the outer-
 * most/global/root Scope objects for particular file. The term "inner scope"
 * is used to refer to a Scope object that is reachable via the child relation
 * from an outer scope.
 */

define(function (require, exports, module) {
    "use strict";

    var _ = require("thirdparty/lodash");

    var CodeMirror          = require("thirdparty/CodeMirror/lib/codemirror"),
        DefaultDialogs      = require("widgets/DefaultDialogs"),
        Dialogs             = require("widgets/Dialogs"),
        DocumentManager     = require("document/DocumentManager"),
        EditorManager       = require("editor/EditorManager"),
        ExtensionUtils      = require("utils/ExtensionUtils"),
        FileSystem          = require("filesystem/FileSystem"),
        FileUtils           = require("file/FileUtils"),
        LanguageManager     = require("language/LanguageManager"),
        PreferencesManager  = require("preferences/PreferencesManager"),
        ProjectManager      = require("project/ProjectManager"),
        Strings             = require("strings"),
        StringUtils         = require("utils/StringUtils"),
        NodeDomain          = require("utils/NodeDomain"),
        InMemoryFile        = require("document/InMemoryFile");

    var HintUtils           = require("./HintUtils"),
        MessageIds          = require("./MessageIds"),
        Preferences         = require("./Preferences");

    var ternEnvironment     = [],
        pendingTernRequests = {},
        builtinFiles        = ["ecmascript.json", "browser.json", "jquery.json"],
        builtinLibraryNames = [],
        isDocumentDirty     = false,
        _hintCount          = 0,
        currentModule       = null,
        documentChanges     = null,     // bounds of document changes
        preferences         = null,
        deferredPreferences = null;
    
    var _bracketsPath       = FileUtils.getNativeBracketsDirectoryPath(),
        _modulePath         = FileUtils.getNativeModuleDirectoryPath(module),
        _nodePath           = "node/TernNodeDomain",
        _absoluteModulePath = [_bracketsPath, _modulePath].join("/"),
        _domainPath         = [_bracketsPath, _modulePath, _nodePath].join("/");
    
    
    var MAX_HINTS           = 30,  // how often to reset the tern server
        LARGE_LINE_CHANGE   = 100,
        LARGE_LINE_COUNT    = 10000,
        OFFSET_ZERO         = {line: 0, ch: 0};

    var config = {};
    
    /**
     *  An array of library names that contain JavaScript builtins definitions.
     *
     * @return {Array.<string>} - array of library  names.
     */
    function getBuiltins() {
        return builtinLibraryNames;
    }

    /**
     * Read in the json files that have type information for the builtins, dom,etc
     */
    function initTernEnv() {
        var path = [_absoluteModulePath, "node/node_modules/tern/defs/"].join("/"),
            files = builtinFiles,
            library;

        files.forEach(function (i) {
            FileSystem.resolve(path + i, function (err, file) {
                if (!err) {
                    FileUtils.readAsText(file).done(function (text) {
                        library = JSON.parse(text);
                        builtinLibraryNames.push(library["!name"]);
                        ternEnvironment.push(library);
                    }).fail(function (error) {
                        console.log("failed to read tern config file " + i);
                    });
                } else {
                    console.log("failed to read tern config file " + i);
                }
            });
        });
    }

    initTernEnv();

    /**
     *  Init preferences from a file in the project root or builtin
     *  defaults if no file is found;
     *
     *  @param {string=} projectRootPath - new project root path. Only needed
     *  for unit tests.
     */
    function initPreferences(projectRootPath) {

        // Reject the old preferences if they have not completed.
        if (deferredPreferences && deferredPreferences.state() === "pending") {
            deferredPreferences.reject();
        }

        deferredPreferences = $.Deferred();
        var pr = ProjectManager.getProjectRoot();

        // Open preferences relative to the project root
        // Normally there is a project root, but for unit tests we need to
        // pass in a project root.
        if (pr) {
            projectRootPath = pr.fullPath;
        } else if (!projectRootPath) {
            console.log("initPreferences: projectRootPath has no value");
        }

        var path = projectRootPath + Preferences.FILE_NAME;

        FileSystem.resolve(path, function (err, file) {
            if (!err) {
                FileUtils.readAsText(file).done(function (text) {
                    var configObj = null;
                    try {
                        configObj = JSON.parse(text);
                    } catch (e) {
                        // continue with null configObj which will result in
                        // default settings.
                        console.log("Error parsing preference file: " + path);
                        if (e instanceof SyntaxError) {
                            console.log(e.message);
                        }
                    }
                    preferences = new Preferences(configObj);
                    deferredPreferences.resolve();
                }).fail(function (error) {
                    preferences = new Preferences();
                    deferredPreferences.resolve();
                });
            } else {
                preferences = new Preferences();
                deferredPreferences.resolve();
            }
        });
    }

    /**
     * Will initialize preferences only if they do not exist.
     *
     */
    function ensurePreferences() {
        if (!deferredPreferences) {
            initPreferences();
        }
    }

    /**
     * Send a message to the tern module - if the module is being initialized,
     * the message will not be posted until initialization is complete
     */
    function postMessage(msg) {
        if (currentModule) {
            currentModule.postMessage(msg);
        }
    }

    /**
     * Test if the directory should be excluded from analysis.
     *
     * @param {!string} path - full directory path.
     * @return {boolean} true if excluded, false otherwise.
     */
    function isDirectoryExcluded(path) {
        var excludes = preferences.getExcludedDirectories();

        if (!excludes) {
            return false;
        }

        var testPath = ProjectManager.makeProjectRelativeIfPossible(path);
        testPath = FileUtils.stripTrailingSlash(testPath);

        return excludes.test(testPath);
    }

    /**
     * Test if the file path is in current editor
     *
     * @param {string} filePath file path to test for exclusion.
     * @return {boolean} true if in editor, false otherwise.
     */
    function isFileBeingEdited(filePath) {
        var currentEditor   = EditorManager.getActiveEditor(),
            currentDoc      = currentEditor && currentEditor.document;

        return (currentDoc && currentDoc.file.fullPath === filePath);
    }

    /**
     * Test if the file path is an internal exclusion.
     *
     * @param {string} path file path to test for exclusion.
     * @return {boolean} true if excluded, false otherwise.
     */
    function isFileExcludedInternal(path) {
        // The detectedExclusions are files detected to be troublesome with current versions of Tern.
        // detectedExclusions is an array of full paths.
        var detectedExclusions = PreferencesManager.get("jscodehints.detectedExclusions") || [];
        if (detectedExclusions && detectedExclusions.indexOf(path) !== -1) {
            return true;
        }

        return false;
    }

    /**
     * Test if the file should be excluded from analysis.
     *
     * @param {!File} file - file to test for exclusion.
     * @return {boolean} true if excluded, false otherwise.
     */
    function isFileExcluded(file) {
        if (file.name[0] === ".") {
            return true;
        }

        var languageID = LanguageManager.getLanguageForPath(file.fullPath).getId();
        if (languageID !== HintUtils.LANGUAGE_ID) {
            return true;
        }

        var excludes = preferences.getExcludedFiles();
        if (excludes && excludes.test(file.name)) {
            return true;
        }

        if (isFileExcludedInternal(file.fullPath)) {
            return true;
        }

        return false;
    }

    /**
     * Add a pending request waiting for the tern-module to complete.
     * If file is a detected exclusion, then reject request.
     *
     * @param {string} file - the name of the file
     * @param {{line: number, ch: number}} offset - the offset into the file the request is for
     * @param {string} type - the type of request
     * @return {jQuery.Promise} - the promise for the request
     */
    function addPendingRequest(file, offset, type) {
        var requests,
            key = file + "@" + offset.line + "@" + offset.ch,
            $deferredRequest;

        // Reject detected exclusions
        if (isFileExcludedInternal(file)) {
            return (new $.Deferred()).reject().promise();
        }

        if (_.has(pendingTernRequests, key)) {
            requests = pendingTernRequests[key];
        } else {
            requests = {};
            pendingTernRequests[key] = requests;
        }

        if (_.has(requests, type)) {
            $deferredRequest = requests[type];
        } else {
            requests[type] = $deferredRequest = new $.Deferred();
        }
        return $deferredRequest.promise();
    }

    /**
     * Get any pending $.Deferred object waiting on the specified file and request type
     * @param {string} file - the file
     * @param {{line: number, ch: number}} offset - the offset into the file the request is for
     * @param {string} type - the type of request
     * @return {jQuery.Deferred} - the $.Deferred for the request
     */
    function getPendingRequest(file, offset, type) {
        var key = file + "@" + offset.line + "@" + offset.ch;
        if (_.has(pendingTernRequests, key)) {
            var requests = pendingTernRequests[key],
                requestType = requests[type];

            delete pendingTernRequests[key][type];

            if (!Object.keys(requests).length) {
                delete pendingTernRequests[key];
            }

            return requestType;
        }
    }

    /**
     * @param {string} file a relative path
     * @return {string} returns the path we resolved when we tried to parse the file, or undefined
     */
    function getResolvedPath(file) {
        return currentModule.getResolvedPath(file);
    }

    /**
     * Get a Promise for the definition from TernJS, for the file & offset passed in.
     * @param {{type: string, name: string, offsetLines: number, text: string}} fileInfo
     * - type of update, name of file, and the text of the update.
     * For "full" updates, the whole text of the file is present. For "part" updates,
     * the changed portion of the text. For "empty" updates, the file has not been modified
     * and the text is empty.
     * @param {{line: number, ch: number}} offset - the offset in the file the hints should be calculate at
     * @return {jQuery.Promise} - a promise that will resolve to definition when
     *      it is done
     */
    function getJumptoDef(fileInfo, offset) {
        postMessage({
            type: MessageIds.TERN_JUMPTODEF_MSG,
            fileInfo: fileInfo,
            offset: offset
        });

        return addPendingRequest(fileInfo.name, offset, MessageIds.TERN_JUMPTODEF_MSG);
    }

    /**
     * check to see if the text we are sending to Tern is too long.
     * @param {string} the text to check
     * @return {string} the text, or the empty text if the original was too long
     */
    function filterText(text) {
        var newText = text;
        if (text.length > preferences.getMaxFileSize()) {
            newText = "";
        }
        return newText;
    }

    /**
     * Get the text of a document, applying any size restrictions
     * if necessary
     * @param {Document} document - the document to get the text from
     * @return {string} the text, or the empty text if the original was too long
     */
    function getTextFromDocument(document) {
        var text = document.getText();
        text = filterText(text);
        return text;
    }

    /**
     * Handle the response from the tern node domain when
     * it responds with the references
     *
     * @param response - the response from the node domain
     */
    function handleRename(response) {

        if (response.error) {
            EditorManager.getActiveEditor().displayErrorMessageAtCursor(response.error);
            return;
        }

        var file = response.file,
            offset = response.offset;

        var $deferredFindRefs = getPendingRequest(file, offset, MessageIds.TERN_REFS);

        if ($deferredFindRefs) {
            $deferredFindRefs.resolveWith(null, [response]);
        }
    }

    /**
     * Request Jump-To-Definition from Tern.
     *
     * @param {session} session - the session
     * @param {Document} document - the document
     * @param {{line: number, ch: number}} offset - the offset into the document
     * @return {jQuery.Promise} - The promise will not complete until tern
     *      has completed.
     */
    function requestJumptoDef(session, document, offset) {
        var path    = document.file.fullPath,
            fileInfo = {
                type: MessageIds.TERN_FILE_INFO_TYPE_FULL,
                name: path,
                offsetLines: 0,
                text: filterText(session.getJavascriptText())
            };

        var ternPromise = getJumptoDef(fileInfo, offset);

        return {promise: ternPromise};
    }

    /**
     * Handle the response from the tern node domain when
     * it responds with the definition
     *
     * @param response - the response from the node domain
     */
    function handleJumptoDef(response) {

        var file = response.file,
            offset = response.offset;

        var $deferredJump = getPendingRequest(file, offset, MessageIds.TERN_JUMPTODEF_MSG);

        if ($deferredJump) {
            response.fullPath = getResolvedPath(response.resultFile);
            $deferredJump.resolveWith(null, [response]);
        }
    }

    /**
     * Handle the response from the tern node domain when
     * it responds with the scope data
     *
     * @param response - the response from the node domain
     */
    function handleScopeData(response) {
        var file = response.file,
            offset = response.offset;

        var $deferredJump = getPendingRequest(file, offset, MessageIds.TERN_SCOPEDATA_MSG);

        if ($deferredJump) {
            $deferredJump.resolveWith(null, [response]);
        }
    }

    /**
     * Get a Promise for the completions from TernJS, for the file & offset passed in.
     *
     * @param {{type: string, name: string, offsetLines: number, text: string}} fileInfo
     * - type of update, name of file, and the text of the update.
     * For "full" updates, the whole text of the file is present. For "part" updates,
     * the changed portion of the text. For "empty" updates, the file has not been modified
     * and the text is empty.
     * @param {{line: number, ch: number}} offset - the offset in the file the hints should be calculate at
     * @param {boolean} isProperty - true if getting a property hint,
     * otherwise getting an identifier hint.
     * @return {jQuery.Promise} - a promise that will resolve to an array of completions when
     *      it is done
     */
    function getTernHints(fileInfo, offset, isProperty) {

        /**
         *  If the document is large and we have modified a small portions of it that
         *  we are asking hints for, then send a partial document.
         */
        postMessage({
            type: MessageIds.TERN_COMPLETIONS_MSG,
            fileInfo: fileInfo,
            offset: offset,
            isProperty: isProperty
        });

        return addPendingRequest(fileInfo.name, offset, MessageIds.TERN_COMPLETIONS_MSG);
    }

    /**
     * Get a Promise for the function type from TernJS.
     * @param {{type: string, name: string, offsetLines: number, text: string}} fileInfo
     * - type of update, name of file, and the text of the update.
     * For "full" updates, the whole text of the file is present. For "part" updates,
     * the changed portion of the text. For "empty" updates, the file has not been modified
     * and the text is empty.
     * @param {{line:number, ch:number}} offset - the line, column info for what we want the function type of.
     * @return {jQuery.Promise} - a promise that will resolve to the function type of the function being called.
     */
    function getTernFunctionType(fileInfo, offset) {
        postMessage({
            type: MessageIds.TERN_CALLED_FUNC_TYPE_MSG,
            fileInfo: fileInfo,
            offset: offset
        });

        return addPendingRequest(fileInfo.name, offset, MessageIds.TERN_CALLED_FUNC_TYPE_MSG);
    }


    /**
     *  Given a starting and ending position, get a code fragment that is self contained
     *  enough to be compiled.
     *
     * @param {!Session} session - the current session
     * @param {{line: number, ch: number}} start - the starting position of the changes
     * @return {{type: string, name: string, offsetLines: number, text: string}}
     */
    function getFragmentAround(session, start) {
        var minIndent = null,
            minLine   = null,
            endLine,
            cm        = session.editor._codeMirror,
            tabSize   = cm.getOption("tabSize"),
            document  = session.editor.document,
            p,
            min,
            indent,
            line;

        // expand range backwards
        for (p = start.line - 1, min = Math.max(0, p - 100); p >= min; --p) {
            line = session.getLine(p);
            var fn = line.search(/\bfunction\b/);

            if (fn >= 0) {
                indent = CodeMirror.countColumn(line, null, tabSize);
                if (minIndent === null || minIndent > indent) {
                    if (session.getToken({line: p, ch: fn + 1}).type === "keyword") {
                        minIndent = indent;
                        minLine = p;
                    }
                }
            }
        }

        if (minIndent === null) {
            minIndent = 0;
        }

        if (minLine === null) {
            minLine = min;
        }

        var max = Math.min(cm.lastLine(), start.line + 100),
            endCh = 0;

        for (endLine = start.line + 1; endLine < max; ++endLine) {
            line = cm.getLine(endLine);

            if (line.length > 0) {
                indent = CodeMirror.countColumn(line, null, tabSize);
                if (indent <= minIndent) {
                    endCh = line.length;
                    break;
                }
            }
        }

        var from = {line: minLine, ch: 0},
            to   = {line: endLine, ch: endCh};

        return {type: MessageIds.TERN_FILE_INFO_TYPE_PART,
            name: document.file.fullPath,
            offsetLines: from.line,
            text: document.getRange(from, to)};
    }


    /**
     * Get an object that describes what tern needs to know about the updated
     * file to produce a hint. As a side-effect of this calls the document
     * changes are reset.
     *
     * @param {!Session} session - the current session
     * @param {boolean=} preventPartialUpdates - if true, disallow partial updates.
     * Optional, defaults to false.
     * @return {{type: string, name: string, offsetLines: number, text: string}}
     */
    function getFileInfo(session, preventPartialUpdates) {
        var start = session.getCursor(),
            end = start,
            document = session.editor.document,
            path = document.file.fullPath,
            isHtmlFile = LanguageManager.getLanguageForPath(path).getId() === "html",
            result;

        if (isHtmlFile) {
            result = {type: MessageIds.TERN_FILE_INFO_TYPE_FULL,
                name: path,
                text: session.getJavascriptText()};
        } else if (!documentChanges) {
            result = {type: MessageIds.TERN_FILE_INFO_TYPE_EMPTY,
                name: path,
                text: ""};
        } else if (!preventPartialUpdates && session.editor.lineCount() > LARGE_LINE_COUNT &&
                (documentChanges.to - documentChanges.from < LARGE_LINE_CHANGE) &&
                documentChanges.from <= start.line &&
                documentChanges.to > end.line) {
            result = getFragmentAround(session, start);
        } else {
            result = {type: MessageIds.TERN_FILE_INFO_TYPE_FULL,
                name: path,
                text: getTextFromDocument(document)};
        }

        documentChanges = null;
        return result;
    }

    /**
     *  Get the current offset. The offset is adjusted for "part" updates.
     *
     * @param {!Session} session - the current session
     * @param {{type: string, name: string, offsetLines: number, text: string}} fileInfo
     * - type of update, name of file, and the text of the update.
     * For "full" updates, the whole text of the file is present. For "part" updates,
     * the changed portion of the text. For "empty" updates, the file has not been modified
     * and the text is empty.
     * @param {{line: number, ch: number}=} offset - the default offset (optional). Will
     * use the cursor if not provided.
     * @return {{line: number, ch: number}}
     */
    function getOffset(session, fileInfo, offset) {
        var newOffset;

        if (offset) {
            newOffset = {line: offset.line, ch: offset.ch};
        } else {
            newOffset = session.getCursor();
        }

        if (fileInfo.type === MessageIds.TERN_FILE_INFO_TYPE_PART) {
            newOffset.line = Math.max(0, newOffset.line - fileInfo.offsetLines);
        }

        return newOffset;
    }

    /**
     * Get a Promise for all of the known properties from TernJS, for the directory and file.
     * The properties will be used as guesses in tern.
     * @param {Session} session - the active hinting session
     * @param {Document} document - the document for which scope info is
     *      desired
     * @return {jQuery.Promise} - The promise will not complete until the tern
     *      request has completed.
     */
    function requestGuesses(session, document) {
        var $deferred = $.Deferred(),
            fileInfo = getFileInfo(session),
            offset = getOffset(session, fileInfo);

        postMessage({
            type: MessageIds.TERN_GET_GUESSES_MSG,
            fileInfo: fileInfo,
            offset: offset
        });

        var promise = addPendingRequest(fileInfo.name, offset, MessageIds.TERN_GET_GUESSES_MSG);
        promise.done(function (guesses) {
            session.setGuesses(guesses);
            $deferred.resolve();
        }).fail(function () {
            $deferred.reject();
        });

        return $deferred.promise();
    }

    /**
     * Handle the response from the tern node domain when
     * it responds with the list of completions
     *
     * @param {{file: string, offset: {line: number, ch: number}, completions:Array.<string>,
     *          properties:Array.<string>}} response - the response from node domain
     */
    function handleTernCompletions(response) {

        var file = response.file,
            offset = response.offset,
            completions = response.completions,
            properties = response.properties,
            fnType  = response.fnType,
            type = response.type,
            error = response.error,
            $deferredHints = getPendingRequest(file, offset, type);

        if ($deferredHints) {
            if (error) {
                $deferredHints.reject();
            } else if (completions) {
                $deferredHints.resolveWith(null, [{completions: completions}]);
            } else if (properties) {
                $deferredHints.resolveWith(null, [{properties: properties}]);
            } else if (fnType) {
                $deferredHints.resolveWith(null, [fnType]);
            }
        }
    }

    /**
     * Handle the response from the tern node domain when
     * it responds to the get guesses message.
     *
     * @param {{file: string, type: string, offset: {line: number, ch: number},
     *      properties: Array.<string>}} response -
     *      the response from node domain contains the guesses for a
     *      property lookup.
     */
    function handleGetGuesses(response) {
        var path = response.file,
            type = response.type,
            offset = response.offset,
            $deferredHints = getPendingRequest(path, offset, type);

        if ($deferredHints) {
            $deferredHints.resolveWith(null, [response.properties]);
        }
    }

    /**
     * Handle the response from the tern node domain when
     * it responds to the update file message.
     *
     * @param {{path: string, type: string}} response - the response from node domain
     */
    function handleUpdateFile(response) {

        var path = response.path,
            type = response.type,
            $deferredHints = getPendingRequest(path, OFFSET_ZERO, type);

        if ($deferredHints) {
            $deferredHints.resolve();
        }
    }

    /**
     * Handle timed out inference
     *
     * @param {{path: string, type: string}} response - the response from node domain
     */
    function handleTimedOut(response) {

        var detectedExclusions  = PreferencesManager.get("jscodehints.detectedExclusions") || [],
            filePath            = response.file;

        // Don't exclude the file currently being edited
        if (isFileBeingEdited(filePath)) {
            return;
        }

        // Handle file that is already excluded
        if (detectedExclusions.indexOf(filePath) !== -1) {
            console.log("JavaScriptCodeHints.handleTimedOut: file already in detectedExclusions array timed out: " + filePath);
            return;
        }

        // Save detected exclusion in project prefs so no further time is wasted on it
        detectedExclusions.push(filePath);
        PreferencesManager.set("jscodehints.detectedExclusions", detectedExclusions, { location: { scope: "project" } });

        // Show informational dialog
        Dialogs.showModalDialog(
            DefaultDialogs.DIALOG_ID_INFO,
            Strings.DETECTED_EXCLUSION_TITLE,
            StringUtils.format(
                Strings.DETECTED_EXCLUSION_INFO,
                StringUtils.breakableUrl(filePath)
            ),
            [
                {
                    className : Dialogs.DIALOG_BTN_CLASS_PRIMARY,
                    id        : Dialogs.DIALOG_BTN_OK,
                    text      : Strings.OK
                }
            ]
        );
    }
    
    DocumentManager.on("dirtyFlagChange", function (event, changedDoc) {
        if (changedDoc.file.fullPath) {
            postMessage({
                type: MessageIds.TERN_UPDATE_DIRTY_FILE,
                name: changedDoc.file.fullPath,
                action: changedDoc.isDirty
            });
        }
    });

    // Clear dirty document list in tern node domain
    ProjectManager.on("beforeProjectClose", function () {
        postMessage({
            type: MessageIds.TERN_CLEAR_DIRTY_FILES_LIST
        });
    });

    /**
     * Encapsulate all the logic to talk to the tern module.  This will create
     * a new instance of a TernModule, which the rest of the hinting code can use to talk
     * to the tern node domain, without worrying about initialization, priming the pump, etc.
     *
     */
    function TernModule() {
        var ternPromise         = null,
            addFilesPromise     = null,
            rootTernDir         = null,
            projectRoot         = null,
            stopAddingFiles     = false,
            resolvedFiles       = {},       // file -> resolved file
            numInitialFiles     = 0,
            numResolvedFiles    = 0,
            numAddedFiles       = 0,
            _ternNodeDomain     = null;

        /**
         * @param {string} file a relative path
         * @return {string} returns the path we resolved when we tried to parse the file, or undefined
         */
        function getResolvedPath(file) {
            return resolvedFiles[file];
        }

        /**
         *  Determine whether the current set of files are using modules to pull in
         *  additional files.
         *
         * @return {boolean} - true if more files than the current directory have
         * been read in.
         */
        function usingModules() {
            return numInitialFiles !== numResolvedFiles;
        }

        /**
         * Send a message to the tern node domain - if the module is being initialized,
         * the message will not be posted until initialization is complete
         */
        function postMessage(msg) {
            addFilesPromise.done(function (ternModule) {
                // If an error came up during file handling, bail out now
                if (!_ternNodeDomain) {
                    return;
                }

                if (config.debug) {
                    console.debug("Sending message", msg);
                }
                _ternNodeDomain.exec("invokeTernCommand", msg);
            });
        }

        /**
         * Send a message to the tern node domain - this is only for messages that
         * need to be sent before and while the addFilesPromise is being resolved.
         */
        function _postMessageByPass(msg) {
            ternPromise.done(function (ternModule) {
                if (config.debug) {
                    console.debug("Sending message", msg);
                }
                _ternNodeDomain.exec("invokeTernCommand", msg);
            });
        }

        /**
         *  Update tern with the new contents of a given file.
         *
         * @param {Document} document - the document to update
         * @return {jQuery.Promise} - the promise for the request
         */
        function updateTernFile(document) {
            var path  = document.file.fullPath;

            _postMessageByPass({
                type       : MessageIds.TERN_UPDATE_FILE_MSG,
                path       : path,
                text       : getTextFromDocument(document)
            });

            return addPendingRequest(path, OFFSET_ZERO, MessageIds.TERN_UPDATE_FILE_MSG);
        }

        /**
         * Handle a request from the tern node domain for text of a file
         *
         * @param {{file:string}} request - the request from the tern node domain.  Should be an Object containing the name
         *      of the file tern wants the contents of 
         */
        function handleTernGetFile(request) {

            function replyWith(name, txt) {
                _postMessageByPass({
                    type: MessageIds.TERN_GET_FILE_MSG,
                    file: name,
                    text: txt
                });
            }

            var name = request.file;

            /**
             * Helper function to get the text of a given document and send it to tern.
             * If DocumentManager successfully gets the file's text then we'll send it to the tern node domain.
             * The Promise for getDocumentText() is returned so that custom fail functions can be used.
             *
             * @param {string} filePath - the path of the file to get the text of
             * @return {jQuery.Promise} - the Promise returned from DocumentMangaer.getDocumentText()
             */
            function getDocText(filePath) {
                if (!FileSystem.isAbsolutePath(filePath) || // don't handle URLs
                        filePath.slice(0, 2) === "//") { // don't handle protocol-relative URLs like //example.com/main.js (see #10566)
                    return (new $.Deferred()).reject().promise();
                }

                var file = FileSystem.getFileForPath(filePath),
                    promise = DocumentManager.getDocumentText(file);

                promise.done(function (docText) {
                    resolvedFiles[name] = filePath;
                    numResolvedFiles++;
                    replyWith(name, filterText(docText));
                });
                return promise;
            }

            /**
             * Helper function to find any files in the project that end with the
             * name we are looking for.  This is so we can find requirejs modules
             * when the baseUrl is unknown, or when the project root is not the same
             * as the script root (e.g. if you open the 'brackets' dir instead of 'brackets/src' dir).
             */
            function findNameInProject() {
                // check for any files in project that end with the right path.
                var fileName = name.substring(name.lastIndexOf("/") + 1);

                function _fileFilter(entry) {
                    return entry.name === fileName;
                }

                ProjectManager.getAllFiles(_fileFilter).done(function (files) {
                    var file;
                    files = files.filter(function (file) {
                        var pos = file.fullPath.length - name.length;
                        return pos === file.fullPath.lastIndexOf(name);
                    });

                    if (files.length === 1) {
                        file = files[0];
                    }
                    if (file) {
                        getDocText(file.fullPath).fail(function () {
                            replyWith(name, "");
                        });
                    } else {
                        replyWith(name, "");
                    }
                });
            }

            if (!isFileExcludedInternal(name)) {
                getDocText(name).fail(function () {
                    getDocText(rootTernDir + name).fail(function () {
                        // check relative to project root
                        getDocText(projectRoot + name)
                            // last look for any files that end with the right path
                            // in the project
                            .fail(findNameInProject);
                    });
                });
            }
        }

        /**
         *  Prime the pump for a fast first lookup.
         *
         * @param {string} path - full path of file
         * @return {jQuery.Promise} - the promise for the request
         */
        function primePump(path, isUntitledDoc) {
            _postMessageByPass({
                type            : MessageIds.TERN_PRIME_PUMP_MSG,
                path            : path,
                isUntitledDoc   : isUntitledDoc
            });

            return addPendingRequest(path, OFFSET_ZERO, MessageIds.TERN_PRIME_PUMP_MSG);
        }

        /**
         * Handle the response from the tern node domain when
         * it responds to the prime pump message.
         *
         * @param {{path: string, type: string}} response - the response from node domain
         */
        function handlePrimePumpCompletion(response) {

            var path = response.path,
                type = response.type,
                $deferredHints = getPendingRequest(path, OFFSET_ZERO, type);

            if ($deferredHints) {
                $deferredHints.resolve();
            }
        }

        /**
         *  Add new files to tern, keeping any previous files.
         *  The tern server must be initialized before making
         *  this call.
         *
         * @param {Array.<string>} files - array of file to add to tern.
         * @return {boolean} - true if more files may be added, false if maximum has been reached.
         */
        function addFilesToTern(files) {
            // limit the number of files added to tern.
            var maxFileCount = preferences.getMaxFileCount();
            if (numResolvedFiles + numAddedFiles < maxFileCount) {
                var available = maxFileCount - numResolvedFiles - numAddedFiles;

                if (available < files.length) {
                    files = files.slice(0, available);
                }

                numAddedFiles += files.length;
                ternPromise.done(function (ternModule) {
                    var msg = {
                        type        : MessageIds.TERN_ADD_FILES_MSG,
                        files       : files
                    };

                    if (config.debug) {
                        console.debug("Sending message", msg);
                    }
                    _ternNodeDomain.exec("invokeTernCommand", msg);
                });

            } else {
                stopAddingFiles = true;
            }

            return stopAddingFiles;
        }

        /**
         *  Add the files in the directory and subdirectories of a given directory
         *  to tern.
         *
         * @param {string} dir - the root directory to add.
         * @param {function ()} doneCallback - called when all files have been
         * added to tern.
         */
        function addAllFilesAndSubdirectories(dir, doneCallback) {
            FileSystem.resolve(dir, function (err, directory) {
                function visitor(entry) {
                    if (entry.isFile) {
                        if (!isFileExcluded(entry)) { // ignore .dotfiles and non-.js files
                            addFilesToTern([entry.fullPath]);
                        }
                    } else {
                        return !isDirectoryExcluded(entry.fullPath) &&
                            entry.name.indexOf(".") !== 0 &&
                            !stopAddingFiles;
                    }
                }

                if (err) {
                    return;
                }

                if (dir === FileSystem.getDirectoryForPath(rootTernDir)) {
                    doneCallback();
                    return;
                }

                directory.visit(visitor, doneCallback);
            });
        }

        /**
         * Init the Tern module that does all the code hinting work.
         */
        function initTernModule() {
            var moduleDeferred = $.Deferred();
            ternPromise = moduleDeferred.promise();
            
            function prepareTern() {  
                _ternNodeDomain.exec("setInterface", {
                    messageIds : MessageIds
                });

                _ternNodeDomain.exec("invokeTernCommand", {
                    type: MessageIds.SET_CONFIG,
                    config: config
                });
                moduleDeferred.resolveWith(null, [_ternNodeDomain]);
            }
            
            if (_ternNodeDomain) {
                _ternNodeDomain.exec("resetTernServer");
                moduleDeferred.resolveWith(null, [_ternNodeDomain]);
            } else {
                _ternNodeDomain     = new NodeDomain("TernNodeDomain", _domainPath);
                _ternNodeDomain.on("data", function (evt, data) {
                    if (config.debug) {
                        console.log("Message received", data.type);
                    }

                    var response = data,
                        type = response.type;

                    if (type === MessageIds.TERN_COMPLETIONS_MSG ||
                            type === MessageIds.TERN_CALLED_FUNC_TYPE_MSG) {
                        // handle any completions the tern server calculated
                        handleTernCompletions(response);
                    } else if (type === MessageIds.TERN_GET_FILE_MSG) {
                        // handle a request for the contents of a file
                        handleTernGetFile(response);
                    } else if (type === MessageIds.TERN_JUMPTODEF_MSG) {
                        handleJumptoDef(response);
                    } else if (type === MessageIds.TERN_SCOPEDATA_MSG) {
                        handleScopeData(response);
                    } else if (type === MessageIds.TERN_REFS) {
                        handleRename(response);
                    } else if (type === MessageIds.TERN_PRIME_PUMP_MSG) {
                        handlePrimePumpCompletion(response);
                    } else if (type === MessageIds.TERN_GET_GUESSES_MSG) {
                        handleGetGuesses(response);
                    } else if (type === MessageIds.TERN_UPDATE_FILE_MSG) {
                        handleUpdateFile(response);
                    } else if (type === MessageIds.TERN_INFERENCE_TIMEDOUT) {
                        handleTimedOut(response);
                    } else if (type === MessageIds.TERN_WORKER_READY) {
                        moduleDeferred.resolveWith(null, [_ternNodeDomain]);
                    } else if (type === "RE_INIT_TERN") {
                        // Ensure the request is because of a node restart
                        if (currentModule) {
                            prepareTern();
                            // Mark the module with resetForced, then creation of TernModule will 
                            // happen again as part of '_maybeReset' call
                            currentModule.resetForced = true;
                        }
                    } else {
                        console.log("Tern Module: " + (response.log || response));
                    }
                });
            
                _ternNodeDomain.promise().done(prepareTern);
            }
        }

        /**
         * Create a new tern server.
         */
        function initTernServer(dir, files) {
            initTernModule();
            numResolvedFiles = 0;
            numAddedFiles = 0;
            stopAddingFiles = false;
            numInitialFiles = files.length;

            ternPromise.done(function (ternModule) {
                var msg = {
                    type        : MessageIds.TERN_INIT_MSG,
                    dir         : dir,
                    files       : files,
                    env         : ternEnvironment,
                    timeout     : PreferencesManager.get("jscodehints.inferenceTimeout")
                };
                _ternNodeDomain.exec("invokeTernCommand", msg);
            });
            rootTernDir = dir + "/";
        }

        /**
         *  We can skip tern initialization if we are opening a file that has
         *  already been added to tern.
         *
         * @param {string} newFile - full path of new file being opened in the editor.
         * @return {boolean} - true if tern initialization should be skipped,
         * false otherwise.
         */
        function canSkipTernInitialization(newFile) {
            return resolvedFiles[newFile] !== undefined;
        }


        /**
         *  Do the work to initialize a code hinting session.
         *
         * @param {Session} session - the active hinting session (TODO: currently unused)
         * @param {!Document} document - the document the editor has changed to
         * @param {?Document} previousDocument - the document the editor has changed from
         */
        function doEditorChange(session, document, previousDocument) {
            var file        = document.file,
                path        = file.fullPath,
                dir         = file.parentPath,
                pr;

            var addFilesDeferred = $.Deferred();

            documentChanges = null;
            addFilesPromise = addFilesDeferred.promise();
            pr = ProjectManager.getProjectRoot() ? ProjectManager.getProjectRoot().fullPath : null;

            // avoid re-initializing tern if possible.
            if (canSkipTernInitialization(path)) {

                // update the previous document in tern to prevent stale files.
                if (isDocumentDirty && previousDocument) {
                    var updateFilePromise = updateTernFile(previousDocument);
                    updateFilePromise.done(function () {
                        primePump(path, document.isUntitled());
                        addFilesDeferred.resolveWith(null, [_ternNodeDomain]);
                    });
                } else {
                    addFilesDeferred.resolveWith(null, [_ternNodeDomain]);
                }

                isDocumentDirty = false;
                return;
            }
            
            if (previousDocument && previousDocument.isDirty) {
                updateTernFile(previousDocument);
            }
    
            isDocumentDirty = false;
            resolvedFiles = {};
            projectRoot = pr;

            ensurePreferences();
            deferredPreferences.done(function () {
                if (file instanceof InMemoryFile) {
                    initTernServer(pr, []);
                    var hintsPromise = primePump(path, true);
                    hintsPromise.done(function () {
                        addFilesDeferred.resolveWith(null, [_ternNodeDomain]);
                    });
                    return;
                }

                FileSystem.resolve(dir, function (err, directory) {
                    if (err) {
                        console.error("Error resolving", dir);
                        addFilesDeferred.resolveWith(null);
                        return;
                    }

                    directory.getContents(function (err, contents) {
                        if (err) {
                            console.error("Error getting contents for", directory);
                            addFilesDeferred.resolveWith(null);
                            return;
                        }

                        var files = contents
                            .filter(function (entry) {
                                return entry.isFile && !isFileExcluded(entry);
                            })
                            .map(function (entry) {
                                return entry.fullPath;
                            });

                        initTernServer(dir, files);

                        var hintsPromise = primePump(path, false);
                        hintsPromise.done(function () {
                            if (!usingModules()) {
                                // Read the subdirectories of the new file's directory.
                                // Read them first in case there are too many files to
                                // read in the project.
                                addAllFilesAndSubdirectories(dir, function () {
                                    // If the file is in the project root, then read
                                    // all the files under the project root.
                                    var currentDir = (dir + "/");
                                    if (projectRoot && currentDir !== projectRoot &&
                                            currentDir.indexOf(projectRoot) === 0) {
                                        addAllFilesAndSubdirectories(projectRoot, function () {
                                            // prime the pump again but this time don't wait
                                            // for completion.
                                            primePump(path, false);
                                            addFilesDeferred.resolveWith(null, [_ternNodeDomain]);
                                        });
                                    } else {
                                        addFilesDeferred.resolveWith(null, [_ternNodeDomain]);
                                    }
                                });
                            } else {
                                addFilesDeferred.resolveWith(null, [_ternNodeDomain]);
                            }
                        });
                    });
                });
            });
        }

        /**
         * Called each time a new editor becomes active.
         *
         * @param {Session} session - the active hinting session (TODO: currently unused by doEditorChange())
         * @param {!Document} document - the document of the editor that has changed
         * @param {?Document} previousDocument - the document of the editor is changing from
         */
        function handleEditorChange(session, document, previousDocument) {
            if (addFilesPromise === null) {
                doEditorChange(session, document, previousDocument);
            } else {
                addFilesPromise.done(function () {
                    doEditorChange(session, document, previousDocument);
                });
            }
        }

        /**
         * Do some cleanup when a project is closed.
         *
         * We can clean up the node tern server we use to calculate hints now, since
         * we know we will need to re-init it in any new project that is opened.  
         */
        function resetModule() {
            function resetTernServer() {
                if (_ternNodeDomain.ready()) {
                    _ternNodeDomain.exec('resetTernServer');
                }
            }
            
            if (_ternNodeDomain) {
                if (addFilesPromise) {
                    // If we're in the middle of added files, don't reset 
                    // until we're done
                    addFilesPromise.done(resetTernServer).fail(resetTernServer);
                } else {
                    resetTernServer();
                }
            }
        }

        function whenReady(func) {
            addFilesPromise.done(func);
        }
        
        this.resetModule = resetModule;
        this.handleEditorChange = handleEditorChange;
        this.postMessage = postMessage;
        this.getResolvedPath = getResolvedPath;
        this.whenReady = whenReady;

        return this;
    }

    var resettingDeferred = null;

    /**
     * reset the tern module, if necessary.  
     *
     * During debugging, you can turn this automatic resetting behavior off
     * by running this in the console:
     * brackets._configureJSCodeHints({ noReset: true })
     *
     * This function is also used in unit testing with the "force" flag to
     * reset the module for each test to start with a clean environment.
     *
     * @param {Session} session
     * @param {Document} document
     * @param {boolean} force true to force a reset regardless of how long since the last one
     * @return {Promise} Promise resolved when the module is ready. 
     *                   The new (or current, if there was no reset) module is passed to the callback.
     */
    function _maybeReset(session, document, force) {
        var newTernModule;
        // if we're in the middle of a reset, don't have to check
        // the new module will be online soon
        if (!resettingDeferred) {

            // We don't reset if the debugging flag is set
            // because it's easier to debug if the module isn't
            // getting reset all the time.
            if (currentModule.resetForced || force || (!config.noReset && ++_hintCount > MAX_HINTS)) {
                if (config.debug) {
                    console.debug("Resetting tern module");
                }

                resettingDeferred = new $.Deferred();
                newTernModule = new TernModule();
                newTernModule.handleEditorChange(session, document, null);
                newTernModule.whenReady(function () {
                    // reset the old module
                    currentModule.resetModule();
                    currentModule = newTernModule;
                    resettingDeferred.resolve(currentModule);
                    // all done reseting
                    resettingDeferred = null;
                });
                _hintCount = 0;
            } else {
                var d = new $.Deferred();
                d.resolve(currentModule);
                return d.promise();
            }
        }

        return resettingDeferred.promise();
    }

    /**
     * Request a parameter hint from Tern.
     *
     * @param {Session} session - the active hinting session
     * @param {{line: number, ch: number}} functionOffset - the offset of the function call.
     * @return {jQuery.Promise} - The promise will not complete until the
     *      hint has completed.
     */
    function requestParameterHint(session, functionOffset) {
        var $deferredHints = $.Deferred(),
            fileInfo = getFileInfo(session, true),
            offset = getOffset(session, fileInfo, functionOffset),
            fnTypePromise = getTernFunctionType(fileInfo, offset);

        $.when(fnTypePromise).done(
            function (fnType) {
                session.setFnType(fnType);
                session.setFunctionCallPos(functionOffset);
                $deferredHints.resolveWith(null, [fnType]);
            }
        ).fail(function () {
            $deferredHints.reject();
        });

        return $deferredHints.promise();
    }

    /**
     * Request hints from Tern.
     *
     * Note that successive calls to getScope may return the same objects, so
     * clients that wish to modify those objects (e.g., by annotating them based
     * on some temporary context) should copy them first. See, e.g.,
     * Session.getHints().
     *
     * @param {Session} session - the active hinting session
     * @param {Document} document - the document for which scope info is
     *      desired
     * @return {jQuery.Promise} - The promise will not complete until the tern
     *      hints have completed.
     */
    function requestHints(session, document) {
        var $deferredHints = $.Deferred(),
            hintPromise,
            sessionType = session.getType(),
            fileInfo = getFileInfo(session),
            offset = getOffset(session, fileInfo, null);

        _maybeReset(session, document);

        hintPromise = getTernHints(fileInfo, offset, sessionType.property);

        $.when(hintPromise).done(
            function (completions, fnType) {
                if (completions.completions) {
                    session.setTernHints(completions.completions);
                    session.setGuesses(null);
                } else {
                    session.setTernHints([]);
                    session.setGuesses(completions.properties);
                }

                $deferredHints.resolveWith(null);
            }
        ).fail(function () {
            $deferredHints.reject();
        });

        return $deferredHints.promise();
    }

    /**
     *  Track the update area of the current document so we can tell if we can send
     *  partial updates to tern or not.
     *
     * @param {Array.<{from: {line:number, ch: number}, to: {line:number, ch: number},
     *     text: Array<string>}>} changeList - the document changes from the current change event
     */
    function trackChange(changeList) {
        var changed = documentChanges, i;
        if (changed === null) {
            documentChanges = changed = {from: changeList[0].from.line, to: changeList[0].from.line};
            if (config.debug) {
                console.debug("ScopeManager: document has changed");
            }
        }

        for (i = 0; i < changeList.length; i++) {
            var thisChange = changeList[i],
                end = thisChange.from.line + (thisChange.text.length - 1);
            if (thisChange.from.line < changed.to) {
                changed.to = changed.to - (thisChange.to.line - end);
            }

            if (end >= changed.to) {
                changed.to = end + 1;
            }

            if (changed.from > thisChange.from.line) {
                changed.from = thisChange.from.line;
            }
        }
    }

    /*
     * Called each time the file associated with the active editor changes.
     * Marks the file as being dirty.
     *
     * @param {from: {line:number, ch: number}, to: {line:number, ch: number}}
     */
    function handleFileChange(changeList) {
        isDocumentDirty = true;
        trackChange(changeList);
    }

    /**
     * Called each time a new editor becomes active.
     *
     * @param {Session} session - the active hinting session
     * @param {Document} document - the document of the editor that has changed
     * @param {?Document} previousDocument - the document of the editor is changing from
     */
    function handleEditorChange(session, document, previousDocument) {

        if (!currentModule) {
            currentModule = new TernModule();
        }
        
        return currentModule.handleEditorChange(session, document, previousDocument);
    }

    /**
     * Do some cleanup when a project is closed.
     * Clean up previous analysis data from the module
     */
    function handleProjectClose() {
        if (currentModule) {
            currentModule.resetModule();
        }
    }

    /**
     *  Read in project preferences when a new project is opened.
     *  Look in the project root directory for a preference file.
     *
     *  @param {string=} projectRootPath - new project root path(optional).
     *  Only needed for unit tests.
     */
    function handleProjectOpen(projectRootPath) {
        initPreferences(projectRootPath);
    }

    /** Used to avoid timing bugs in unit tests */
    function _readyPromise() {
        return deferredPreferences;
    }

    /**
     * @private
     * 
     * Update the configuration in the tern node domain.
     */
    function _setConfig(configUpdate) {
        config = brackets._configureJSCodeHints.config;
        postMessage({
            type: MessageIds.SET_CONFIG,
            config: configUpdate
        });
    }

    exports._setConfig = _setConfig;
    exports._maybeReset = _maybeReset;
    exports.getBuiltins = getBuiltins;
    exports.getResolvedPath = getResolvedPath;
    exports.getTernHints = getTernHints;
    exports.handleEditorChange = handleEditorChange;
    exports.requestGuesses = requestGuesses;
    exports.handleFileChange = handleFileChange;
    exports.requestHints = requestHints;
    exports.requestJumptoDef = requestJumptoDef;
    exports.requestParameterHint = requestParameterHint;
    exports.handleProjectClose = handleProjectClose;
    exports.handleProjectOpen = handleProjectOpen;
    exports._readyPromise = _readyPromise;
    exports.filterText = filterText;
    exports.postMessage = postMessage;
    exports.addPendingRequest = addPendingRequest;
});
