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

/*jslint forin: true */
/*global FileError */

 /**
 * Inspector manages the connection to Chrome/Chromium's remote debugger.
 * See inspector.html for the documentation of the remote debugger.
 *
 * __SETUP__
 *
 * To enable remote debugging in Chrome or Chromium open either application
 * with the following parameters:
 *
 *     --enable-remote-debugger --remote-debugging-port=9222
 *
 * This will open an HTTP server on the specified port, which can be used to
 * browse the available remote debugger sessions. In general, every open
 * browser tab can host an individual remote debugger session. The
 * available interfaces can be exported by requesting:
 *
 *     http://127.0.0.1:9222/json
 *
 * The response is a JSON-formatted array that specifies all available remote
 * debugger sessions including the remote debugging web sockets.
 *
 * Inspector can connect directly to a web socket via `connect(socketURL)`, or
 * it can find the web socket that corresponds to the tab at the given URL and
 * connect to it via `connectToURL(url)`. The later returns a promise. To
 * disconnect use `disconnect()`.
 *
 * __EVENTS__
 *
 * Inspector dispatches several connectivity-related events + all remote debugger
 * events (see below). Event handlers are attached via `on(event, function)` and
 * detached via `off(event, function)`.
 *
 *   `connect`    Inspector did successfully connect to the remote debugger
 *   `disconnect` Inspector did disconnect from the remote debugger
 *   `error`      Inspector encountered an error
 *   `message`    Inspector received a message from the remote debugger - this
 *                provides a low-level entry point to remote debugger events
 *
 * __REMOTE DEBUGGER COMMANDS__
 *
 * Commands are executed by calling `{Domain}.{Command}()` with the parameters
 * specified in the order of the remote debugger documentation. These command
 * functions are generated automatically at runtime from Inspector.json. The
 * actual implementation of these functions is found in
 * `_send(method, signature, varargs)`, which verifies, serializes, and
 * transmits the command to the remote debugger. If the last parameter of any
 * command function call is a function, it will be used as the callback.
 *
 * __REMOTE DEBUGGER EVENTS__
 *
 * Debugger events are dispatched as regular events using {Domain}.{Event} as
 * the event name. The handler function will be called with a single parameter
 * that stores all returned values as an object.
 */
define(function Inspector(require, exports, module) {
    "use strict";

    var Async           = require("utils/Async"),
        EventDispatcher = require("utils/EventDispatcher");

    /**
     * Map message IDs to the callback function and original JSON message
     * @type {Object.<number, {callback: function, message: Object}}
     */
    var _messageCallbacks = {};

    var _messageId = 1,     // id used for remote method calls, auto-incrementing
        _socket,            // remote debugger WebSocket
        _connectDeferred,   // The deferred connect
        _userAgent = "";    // user agent string

    /** Check a parameter value against the given signature
     * This only checks for optional parameters, not types
     * Type checking is complex because of $ref and done on the remote end anyways
     * @param {signature}
     * @param {value}
     */
    function _verifySignature(signature, value) {
        if (value === undefined) {
            console.assert(signature.optional === true, "Missing argument: " + signature.name);
        }
        return true;
    }

    /** Send a message to the remote debugger
     * All passed arguments after the signature are passed on as parameters.
     * If the last argument is a function, it is used as the callback function.
     * @param {string} remote method
     * @param {object} the method signature
     */
    function _send(method, signature, varargs) {
        if (!_socket) {
            console.log("You must connect to the WebSocket before sending messages.");

            // FUTURE: Our current implementation closes and re-opens an inspector connection whenever
            // a new HTML file is selected. If done quickly enough, pending requests from the previous
            // connection could come in before the new socket connection is established. For now we
            // simply ignore this condition.
            // This race condition will go away once we support multiple inspector connections and turn
            // off auto re-opening when a new HTML file is selected.
            return (new $.Deferred()).reject().promise();
        }

        var id, callback, args, i, params = {}, promise, msg;

        // extract the parameters, the callback function, and the message id
        args = Array.prototype.slice.call(arguments, 2);
        if (typeof args[args.length - 1] === "function") {
            callback = args.pop();
        } else {
            var deferred = new $.Deferred();
            promise = deferred.promise();
            callback = function (result, error) {
                if (error) {
                    deferred.reject(error);
                } else {
                    deferred.resolve(result);
                }
            };
        }

        id = _messageId++;

        // verify the parameters against the method signature
        // this also constructs the params object of type {name -> value}
        if (signature) {
            for (i in signature) {
                if (_verifySignature(args[i], signature[i])) {
                    params[signature[i].name] = args[i];
                }
            }
        }

        // Store message callback and send message
        msg = { method: method, id: id, params: params };
        _messageCallbacks[id] = { callback: callback, message: msg };
        _socket.send(JSON.stringify(msg));

        return promise;
    }

    /**
     * Manually send a message to the remote debugger
     * All passed arguments after the command are passed on as parameters.
     * If the last argument is a function, it is used as the callback function.
     * @param {string} domain
     * @param {string} command
     */
    function send(domain, command, varargs) {
        return _send(domain + "." + command, null, varargs);
    }

    /** WebSocket did close */
    function _onDisconnect() {
        _socket = undefined;
        exports.trigger("disconnect");
    }

    /** WebSocket reported an error */
    function _onError(error) {
        if (_connectDeferred) {
            _connectDeferred.reject();
            _connectDeferred = null;
        }
        exports.trigger("error", error);
    }

    /** WebSocket did open */
    function _onConnect() {
        if (_connectDeferred) {
            _connectDeferred.resolve();
            _connectDeferred = null;
        }
        exports.trigger("connect");
    }

    /** Received message from the WebSocket
     * A message can be one of three things:
     *   1. an error -> report it
     *   2. the response to a previous command -> run the stored callback
     *   3. an event -> trigger an event handler method
     * @param {object} message
     */
    function _onMessage(message) {
        var response    = JSON.parse(message.data),
            msgRecord   = _messageCallbacks[response.id],
            callback    = msgRecord && msgRecord.callback,
            msgText     = (msgRecord && msgRecord.message) || "No message";

        if (msgRecord) {
            // Messages with an ID are a response to a command, fire callback
            callback(response.result, response.error);
            delete _messageCallbacks[response.id];
        } else if (response.method) {
            // Messages with a method are an event, trigger event handlers
            var domainAndMethod = response.method.split("."),
                domain = domainAndMethod[0],
                method = domainAndMethod[1];

            EventDispatcher.triggerWithArray(exports[domain], method, response.params);
        }

        // Always fire event handlers for all messages/errors
        exports.trigger("message", response);

        if (response.error) {
            exports.trigger("error", response.error, msgText);
        }
    }


    /** Public Functions *****************************************************/

    /** Get a list of the available windows/tabs/extensions that are remote-debuggable
     * @param {string} host IP or name
     * @param {integer} debugger port
     */
    function getDebuggableWindows(host, port) {
        if (!host) {
            host = "127.0.0.1";
        }
        if (!port) {
            port = 9222;
        }
        var def = new $.Deferred();
        var request = new XMLHttpRequest();
        request.open("GET", "http://" + host + ":" + port + "/json");
        request.onload = function onLoad() {
            var sockets = JSON.parse(request.response);
            def.resolve(sockets);
        };
        request.onerror = function onError() {
            def.reject(request.response);
        };

        request.send(null);

        return def.promise();
    }

    /**
     * Disconnect from the remote debugger WebSocket
     * @return {jQuery.Promise} Promise that is resolved immediately if not
     *     currently connected or asynchronously when the socket is closed.
     */
    function disconnect() {
        var deferred = new $.Deferred(),
            promise = deferred.promise();

        if (_socket && (_socket.readyState === WebSocket.OPEN)) {
            _socket.onclose = function () {
                // trigger disconnect event
                _onDisconnect();

                deferred.resolve();
            };

            promise = Async.withTimeout(promise, 5000);

            _socket.close();
        } else {
            if (_socket) {
                delete _socket.onmessage;
                delete _socket.onopen;
                delete _socket.onclose;
                delete _socket.onerror;

                _socket = undefined;
            }

            deferred.resolve();
        }

        return promise;
    }

    /**
     * Connect to the remote debugger WebSocket at the given URL.
     * Clients must listen for the `connect` event.
     * @param {string} WebSocket URL
     */
    function connect(socketURL) {
        disconnect().done(function () {
            _socket = new WebSocket(socketURL);
            _socket.onmessage = _onMessage;
            _socket.onopen = _onConnect;
            _socket.onclose = _onDisconnect;
            _socket.onerror = _onError;
        });
    }

    /** Connect to the remote debugger of the page that is at the given URL
     * @param {string} url
     */
    function connectToURL(url) {
        if (_connectDeferred) {
            // reject an existing connection attempt
            _connectDeferred.reject("CANCEL");
        }
        var deferred = new $.Deferred();
        _connectDeferred = deferred;
        var promise = getDebuggableWindows();
        promise.done(function onGetAvailableSockets(response) {
            var i, page;
            for (i in response) {
                page = response[i];
                if (page.webSocketDebuggerUrl && page.url.indexOf(url) === 0) {
                    connect(page.webSocketDebuggerUrl);
                    // _connectDeferred may be resolved by onConnect or rejected by onError
                    return;
                }
            }
            deferred.reject(FileError.ERR_NOT_FOUND); // Reject with a "not found" error
        });
        promise.fail(function onFail(err) {
            deferred.reject(err);
        });
        return deferred.promise();
    }

    /** Check if the inspector is connected */
    function connected() {
        return _socket !== undefined && _socket.readyState === WebSocket.OPEN;
    }

    /**
     * Get user agent string
     * @return {string}
     */
    function getUserAgent() {
        return _userAgent;
    }

    /**
     * Set user agent string
     * @param {string} userAgent User agent string returned from Chrome
     */
    function setUserAgent(userAgent) {
        _userAgent = userAgent;
    }

    /** Initialize the Inspector
     * Read the Inspector.json configuration and define the command objects
     * -> Inspector.domain.command()
     */
    function init(theConfig) {
        exports.config = theConfig;

        var InspectorText = require("text!LiveDevelopment/Inspector/Inspector.json"),
            InspectorJSON = JSON.parse(InspectorText);

        var i, j, domain, command;
        for (i in InspectorJSON.domains) {
            domain = InspectorJSON.domains[i];
            var exportedDomain = {};
            exports[domain.domain] = exportedDomain;
            EventDispatcher.makeEventDispatcher(exportedDomain);
            for (j in domain.commands) {
                command = domain.commands[j];
                exportedDomain[command.name] = _send.bind(undefined, domain.domain + "." + command.name, command.parameters);
            }
        }
    }


    EventDispatcher.makeEventDispatcher(exports);

    // Export public functions
    exports.connect              = connect;
    exports.connected            = connected;
    exports.connectToURL         = connectToURL;
    exports.disconnect           = disconnect;
    exports.getDebuggableWindows = getDebuggableWindows;
    exports.getUserAgent         = getUserAgent;
    exports.init                 = init;
    exports.send                 = send;
    exports.setUserAgent         = setUserAgent;
});
