/*
 * Copyright (c) 2012 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.
 *
 */

/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50, node: true */

(function () {
    "use strict";

    var http     = require("http"),
        pathJoin = require("path").join,
        connect  = require("connect"),
        utils    = require("connect/lib/utils"),
        mime     = require("connect/node_modules/send/node_modules/mime"),
        parse    = utils.parseUrl;

    var _domainManager;

    var FILTER_REQUEST_TIMEOUT = 5000;

    /**
     * @private
     * @type {number}
     * Used to assign unique identifiers to each filter request
     */
    var _filterRequestCounter = 0;

    /**
     * @private
     * @type {number}
     * Duration to wait before passing a filtered request to the static file server.
     */
    var _filterRequestTimeout = FILTER_REQUEST_TIMEOUT;

    /**
     * When Chrome has a css stylesheet replaced over live development,
     * it re-checks any image urls in the new css stylesheet. If it has
     * to hit the server to check them, this is asynchronous, so it causes
     * two re-layouts of the webpage, which causes flickering. By setting
     * a max age of five seconds, Chrome won't bother to hit the server
     * on each keystroke. So, flickers will happen at most once every five
     * seconds.
     *
     * @const
     * @type {number}
     */
    var STATIC_CACHE_MAX_AGE = 5000; // 5 seconds

    /**
     * @private
     * @type {Object.<string, http.Server>}
     * A map from root paths to server instances.
     */
    var _servers = {};

    /**
     * @private
     * @type {Object.<string, {Object.<number, http.ServerResponse>}}
     * A map from a request identifier to its request/response mapping.
     */
    var _requests = {};

    /**
     * @private
     * @type {Object.<string, {Object.<string>}}
     * A map from root paths to relative paths to rewrite
     */
    var _rewritePaths = {};

    var PATH_KEY_PREFIX = "LiveDev_";

    /**
     * @private
     * Removes trailing forward slash for the project root absolute path
     * @param {string} path Absolute path for a server
     * @returns {string}
     */
    function normalizeRootPath(path) {
        return (path && path[path.length - 1] === "/") ? path.slice(0, -1) : path;
    }

    /**
     * @private
     * Generates a key based on a server's absolute path
     * @param {string} path Absolute path for a server
     * @returns {string}
     */
    function getPathKey(path) {
        return PATH_KEY_PREFIX + normalizeRootPath(path);
    }

    /**
     * @private
     * Helper function to create a new server.
     * @param {string} path The absolute path that should be the document root
     * @param {function(?string, ?httpServer)} cb Callback function that receives
     *    an error (or null if there was no error) and the server (or null if there
     *    was an error).
     */
    function _createServer(path, port, createCompleteCallback) {
        var server,
            app,
            address,
            pathKey = getPathKey(path);

        // create a new map for this server's requests
        _requests[pathKey] = {};

        function requestRoot(server, cb) {
            address = server.address();

            // Request the root file from the project in order to ensure that the
            // server is actually initialized. If we don't do this, it seems like
            // connect takes time to warm up the server.
            var req = http.get(
                {host: address.address, port: address.port},
                function (res) {
                    cb(null, res);
                }
            );
            req.on("error", function (err) {
                cb(err, null);
            });
        }

        function rewrite(req, res, next) {
            var location = {pathname: parse(req).pathname},
                hasListener = _rewritePaths[pathKey] && _rewritePaths[pathKey][location.pathname],
                requestId = _filterRequestCounter++,
                timeoutId;

            // ignore most HTTP methods and files that we're not watching
            if (("GET" !== req.method && "HEAD" !== req.method) || !hasListener) {
                next();
                return;
            }

            // pause the request and wait for listeners to possibly respond
            var pause = utils.pause(req);

            function resume(doNext) {
                // delete the callback after it's used or we hit the timeout.
                // if this path is requested again, a new callback is generated.
                delete _requests[pathKey][requestId];

                // pass request to next middleware
                if (doNext) {
                    next();
                }

                pause.resume();
            }

            // map request pathname to response callback
            _requests[pathKey][requestId] = function (resData) {
                // clear timeout immediately when this callback is called
                clearTimeout(timeoutId);

                // response data is optional
                if (resData.body) {
                    // HTTP headers
                    var type    = mime.lookup(location.pathname),
                        charset = mime.charsets.lookup(type);

                    res.setHeader("Content-Type", type + (charset ? "; charset=" + charset : ""));

                    // TODO (jasonsanjose): off-by-1 error here, why?
                    // Chrome seems to handle the request without issues when Content-Length is not specified
                    //res.setHeader("Content-Length", Buffer.byteLength(resData.body /* TODO encoding? */));

                    // response body
                    res.end(resData.body);
                }

                // resume the HTTP ServerResponse, pass to next middleware if
                // no response data was passed
                resume(!resData.body);
            };

            location.hostname = address.address;
            location.port = address.port;
            location.root = path;

            var request = {
                headers:    req.headers,
                location:   location,
                id:         requestId
            };

            // dispatch request event
            _domainManager.emitEvent("staticServer", "requestFilter", [request]);

            // set a timeout if custom responses are not returned
            timeoutId = setTimeout(function () { resume(true); }, _filterRequestTimeout);
        }

        app = connect();
        app.use(rewrite);
        // JSLint complains if we use `connect.static` because static is a
        // reserved word.
        app.use(connect["static"](path, { maxAge: STATIC_CACHE_MAX_AGE }));
        app.use(connect.directory(path));

        server = http.createServer(app);

        // Once the server is listening then verify we can handle requests
        // before calling the callback
        server.on("listening", function () {
            requestRoot(
                server,
                function (err, res) {
                    if (err) {
                        createCompleteCallback("Could not GET root after launching server", null);
                    } else {
                        createCompleteCallback(null, server);
                    }
                }
            );
        });

        // If the given port/address is in use then use a random port
        server.on("error", function (e) {
            if (e.code === "EADDRINUSE") {
                server.listen(0, "127.0.0.1");
            } else {
                throw e;
            }
        });

        server.listen(port, "127.0.0.1");
    }

    /**
     * @private
     * Handler function for the staticServer.getServer command. If a server
     * already exists for the given path, returns that, otherwise starts a new
     * one.
     * @param {string} path The absolute path that should be the document root
     * @param {function(?string, ?{address: string, family: string,
     *    port: number})} cb Callback that should receive the address information
     *    for the server. First argument is the error string (or null if no error),
     *    second argument is the address object (or null if there was an error).
     *    The "family" property of the address indicates whether the address is,
     *    for example, IPv4, IPv6, or a UNIX socket.
     */
    function _cmdGetServer(path, port, cb) {
        // Make sure the key doesn't conflict with some built-in property of Object.
        var pathKey = getPathKey(path);
        if (_servers[pathKey]) {
            cb(null, _servers[pathKey].address());
        } else {
            _createServer(path, port, function (err, server) {
                if (err) {
                    cb(err, null);
                } else {
                    _servers[pathKey] = server;
                    _rewritePaths[pathKey] = {};
                    cb(null, server.address());
                }
            });
        }
    }

    /**
     * @private
     * Handler function for the staticServer.closeServer command. If a server
     * exists for the given path, closes it, otherwise does nothing. Note that
     * this function doesn't wait for the actual socket to close, since the
     * server will actually wait for all client connections to close (which can
     * be awhile); but once it returns, you're guaranteed to get a different
     * server the next time you call getServer() on the same path.
     *
     * @param {string} path The absolute path whose server we should close.
     * @return {boolean} true if there was a server for that path, false otherwise
     */
    function _cmdCloseServer(path, cba) {
        var pathKey = getPathKey(path);
        if (_servers[pathKey]) {
            var serverToClose = _servers[pathKey];
            delete _servers[pathKey];
            serverToClose.close();
            return true;
        }
        return false;
    }

    /**
     * @private
     * Defines a set of paths from a server's root path to watch and fire "request" events for.
     *
     * @param {string} path The absolute path whose server we should watch
     * @param {Array.<string>} paths An array of root-relative paths to watch.
     *     Each path should begin with a forward slash "/".
     */
    function _cmdSetRequestFilterPaths(root, paths) {
        var pathKey = getPathKey(root),
            rewritePaths = {};

        // reset list of filtered paths for each call to setRequestFilterPaths
        _rewritePaths[pathKey] = rewritePaths;

        paths.forEach(function (path) {
            rewritePaths[path] = pathJoin(root, path);
        });
    }

    /**
     * @private
     * Overrides the server response from static middleware with the provided
     * response data. This should be called only in response to a filtered request.
     *
     * @param {string} path The absolute path of the server
     * @param {string} root The relative path of the file beginning with a forward slash "/"
     * @param {!Object} resData Response data to use
     */
    function _cmdWriteFilteredResponse(root, path, resData) {
        var pathKey  = getPathKey(root),
            callback = _requests[pathKey][resData.id];

        if (callback) {
            callback(resData);
        } else {
            console.warn("writeFilteredResponse: Missing callback for %s. This command must only be called after a requestFilter event has fired for a path.", pathJoin(root, path));
        }
    }

    /**
     * @private
     * Unit tests only. Set, or reset, timeout value for filtered requests.
     *
     * @param {number=} timeout Duration to wait before passing a filtered request to the static file server.
     *     If omitted, timeout is reset to FILTER_REQUEST_TIMEOUT (5s).
     */
    function _cmdSetRequestFilterTimeout(timeout) {
        timeout = (timeout === undefined) ? FILTER_REQUEST_TIMEOUT : timeout;
        _filterRequestTimeout = timeout;
    }

    /**
     * Initializes the StaticServer domain with its commands.
     * @param {DomainManager} domainManager The DomainManager for the server
     */
    function init(domainManager) {
        _domainManager = domainManager;

        if (!domainManager.hasDomain("staticServer")) {
            domainManager.registerDomain("staticServer", {major: 0, minor: 1});
        }
        _domainManager.registerCommand(
            "staticServer",
            "_setRequestFilterTimeout",
            _cmdSetRequestFilterTimeout,
            false,
            "Unit tests only. Set timeout value for filtered requests.",
            [{
                name: "timeout",
                type: "number",
                description: "Duration to wait before passing a filtered request to the static file server."
            }],
            []
        );
        _domainManager.registerCommand(
            "staticServer",
            "getServer",
            _cmdGetServer,
            true,
            "Starts or returns an existing server for the given path.",
            [
                {
                    name: "path",
                    type: "string",
                    description: "Absolute filesystem path for root of server."
                },
                {
                    name: "port",
                    type: "number",
                    description: "Port number to use for HTTP server.  Pass zero to assign a random port."
                }
            ],
            [{
                name: "address",
                type: "{address: string, family: string, port: number}",
                description: "hostname (stored in 'address' parameter), port, and socket type (stored in 'family' parameter) for the server. Currently, 'family' will always be 'IPv4'."
            }]
        );
        _domainManager.registerCommand(
            "staticServer",
            "closeServer",
            _cmdCloseServer,
            false,
            "Closes the server for the given path.",
            [{
                name: "path",
                type: "string",
                description: "absolute filesystem path for root of server"
            }],
            [{
                name: "result",
                type: "boolean",
                description: "indicates whether a server was found for the specific path then closed"
            }]
        );
        _domainManager.registerCommand(
            "staticServer",
            "setRequestFilterPaths",
            _cmdSetRequestFilterPaths,
            false,
            "Defines a set of paths from a server's root path to watch and fire 'requestFilter' events for.",
            [
                {
                    name: "root",
                    type: "string",
                    description: "absolute filesystem path for root of server"
                },
                {
                    name: "paths",
                    type: "Array",
                    description: "path to notify"
                }
            ],
            []
        );
        _domainManager.registerCommand(
            "staticServer",
            "writeFilteredResponse",
            _cmdWriteFilteredResponse,
            false,
            "Overrides the server response from static middleware with the provided response data. This should be called only in response to a filtered request.",
            [
                {
                    name: "root",
                    type: "string",
                    description: "absolute filesystem path for root of server"
                },
                {
                    name: "path",
                    type: "string",
                    description: "path to rewrite"
                },
                {
                    name: "resData",
                    type: "{body: string, headers: Array}",
                    description: "TODO"
                }
            ],
            []
        );
        _domainManager.registerEvent(
            "staticServer",
            "requestFilter",
            [{
                name: "location",
                type: "{hostname: string, pathname: string, port: number, root: string: id: number}",
                description: "request path"
            }]
        );
    }

    exports.init = init;

}());
