/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

const {interfaces: Ci, utils: Cu} = Components;

Cu.import("resource://gre/modules/Log.jsm");
Cu.import("resource://gre/modules/Preferences.jsm");
Cu.import("resource://gre/modules/Task.jsm");

Cu.import("chrome://marionette/content/assert.js");
Cu.import("chrome://marionette/content/driver.js");
Cu.import("chrome://marionette/content/error.js");
Cu.import("chrome://marionette/content/message.js");

this.EXPORTED_SYMBOLS = ["Dispatcher"];

const PROTOCOL_VERSION = 3;

const logger = Log.repository.getLogger("Marionette");

/**
 * Manages a Marionette connection, and dispatches packets received to
 * their correct destinations.
 *
 * @param {number} connId
 *     Unique identifier of the connection this dispatcher should handle.
 * @param {DebuggerTransport} transport
 *     Debugger transport connection to the client.
 * @param {function(): GeckoDriver} driverFactory
 *     A factory function that produces a GeckoDriver.
 */
this.Dispatcher = function (connId, transport, driverFactory) {
  this.connId = connId;
  this.conn = transport;

  // transport hooks are Dispatcher#onPacket
  // and Dispatcher#onClosed
  this.conn.hooks = this;

  // callback for when connection is closed
  this.onclose = null;

  // last received/sent message ID
  this.lastId = 0;

  this.driver = driverFactory();

  // lookup of commands sent by server to client by message ID
  this.commands_ = new Map();
};

/**
 * Debugger transport callback that cleans up
 * after a connection is closed.
 */
Dispatcher.prototype.onClosed = function (reason) {
  this.driver.deleteSession();
  if (this.onclose) {
    this.onclose(this);
  }
};

/**
 * Callback that receives data packets from the client.
 *
 * If the message is a Response, we look up the command previously issued
 * to the client and run its callback, if any.  In case of a Command,
 * the corresponding is executed.
 *
 * @param {Array.<number, number, ?, ?>} data
 *     A four element array where the elements, in sequence, signifies
 *     message type, message ID, method name or error, and parameters
 *     or result.
 */
Dispatcher.prototype.onPacket = function (data) {
  let msg = Message.fromMsg(data);
  msg.origin = MessageOrigin.Client;
  this.log_(msg);

  if (msg instanceof Response) {
    let cmd = this.commands_.get(msg.id);
    this.commands_.delete(msg.id);
    cmd.onresponse(msg);
  } else if (msg instanceof Command) {
    this.lastId = msg.id;
    this.execute(msg);
  }
};

/**
 * Executes a WebDriver command and sends back a response when it has
 * finished executing.
 *
 * Commands implemented in GeckoDriver and registered in its
 * {@code GeckoDriver.commands} attribute.  The return values from
 * commands are expected to be Promises.  If the resolved value of said
 * promise is not an object, the response body will be wrapped in an object
 * under a "value" field.
 *
 * If the command implementation sends the response itself by calling
 * {@code resp.send()}, the response is guaranteed to not be sent twice.
 *
 * Errors thrown in commands are marshaled and sent back, and if they
 * are not WebDriverError instances, they are additionally propagated and
 * reported to {@code Components.utils.reportError}.
 *
 * @param {Command} cmd
 *     The requested command to execute.
 */
Dispatcher.prototype.execute = function (cmd) {
  let resp = new Response(cmd.id, this.send.bind(this));
  let sendResponse = () => resp.sendConditionally(resp => !resp.sent);
  let sendError = resp.sendError.bind(resp);

  let req = Task.spawn(function*() {
    let fn = this.driver.commands[cmd.name];
    if (typeof fn == "undefined") {
      throw new UnknownCommandError(cmd.name);
    }

    if (cmd.name !== "newSession") {
      assert.session(this.driver);
    }

    let rv = yield fn.bind(this.driver)(cmd, resp);

    if (typeof rv != "undefined") {
      if (typeof rv != "object") {
        resp.body = {value: rv};
      } else {
        resp.body = rv;
      }
    }
  }.bind(this));

  req.then(sendResponse, sendError).catch(error.report);
};

Dispatcher.prototype.sendError = function (err, cmdId) {
  let resp = new Response(cmdId, this.send.bind(this));
  resp.sendError(err);
};

// Convenience methods:

/**
 * When a client connects we send across a JSON Object defining the
 * protocol level.
 *
 * This is the only message sent by Marionette that does not follow
 * the regular message format.
 */
Dispatcher.prototype.sayHello = function() {
  let whatHo = {
    applicationType: "gecko",
    marionetteProtocol: PROTOCOL_VERSION,
  };
  this.sendRaw(whatHo);
};


/**
 * Delegates message to client based on the provided  {@code cmdId}.
 * The message is sent over the debugger transport socket.
 *
 * The command ID is a unique identifier assigned to the client's request
 * that is used to distinguish the asynchronous responses.
 *
 * Whilst responses to commands are synchronous and must be sent in the
 * correct order.
 *
 * @param {Command,Response} msg
 *     The command or response to send.
 */
Dispatcher.prototype.send = function (msg) {
  msg.origin = MessageOrigin.Server;
  if (msg instanceof Command) {
    this.commands_.set(msg.id, msg);
    this.sendToEmulator(msg);
  } else if (msg instanceof Response) {
    this.sendToClient(msg);
  }
};

// Low-level methods:

/**
 * Send given response to the client over the debugger transport socket.
 *
 * @param {Response} resp
 *     The response to send back to the client.
 */
Dispatcher.prototype.sendToClient = function (resp) {
  this.driver.responseCompleted();
  this.sendMessage(resp);
};

/**
 * Marshal message to the Marionette message format and send it.
 *
 * @param {Command,Response} msg
 *     The message to send.
 */
Dispatcher.prototype.sendMessage = function (msg) {
  this.log_(msg);
  let payload = msg.toMsg();
  this.sendRaw(payload);
};

/**
 * Send the given payload over the debugger transport socket to the
 * connected client.
 *
 * @param {Object} payload
 *     The payload to ship.
 */
Dispatcher.prototype.sendRaw = function (payload) {
  this.conn.send(payload);
};

Dispatcher.prototype.log_ = function (msg) {
  let a = (msg.origin == MessageOrigin.Client ? " -> " : " <- ");
  let s = JSON.stringify(msg.toMsg());
  logger.trace(this.connId + a + s);
};