/* 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 {classes: Cc, interfaces: Ci, utils: Cu} = Components;

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

Cu.import("chrome://marionette/content/command.js");
Cu.import("chrome://marionette/content/emulator.js");
Cu.import("chrome://marionette/content/error.js");
Cu.import("chrome://marionette/content/driver.js");

this.EXPORTED_SYMBOLS = ["Dispatcher"];

const logger = Log.repository.getLogger("Marionette");
const uuidGen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);

/**
 * 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(Emulator): GeckoDriver} driverFactory
 *     A factory function that takes an Emulator as argument and produces
 *     a GeckoDriver.
 * @param {function()} stopSignal
 *     Signal to stop the Marionette server.
 */
this.Dispatcher = function(connId, transport, driverFactory, stopSignal) {
  this.id = connId;
  this.conn = transport;

  // Marionette uses a protocol based on the debugger server, which
  // requires passing back actor ID's with responses.  Unlike the debugger
  // server, we don't actually have multiple actors, so just use a dummy
  // value of "0".
  this.actorId = "0";

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

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

  this.emulator = new Emulator(msg => this.sendResponse(msg, -1));
  this.driver = driverFactory(this.emulator);
  this.commandProcessor = new CommandProcessor(this.driver);

  this.stopSignal_ = stopSignal;
};

/**
 * Debugger transport callback that dispatches the request.
 * Request handlers defined in this.requests take presedence
 * over those defined in this.driver.commands.
 */
Dispatcher.prototype.onPacket = function(packet) {
  // Avoid using toSource and template strings (or touching the payload at all
  // if not necessary) for the sake of memory use.
  // See https://bugzilla.mozilla.org/show_bug.cgi?id=1150170
  if (logger.level <= Log.Level.Debug) {
    logger.debug(this.id + " -> (" + JSON.stringify(packet) + ")");
  }

  if (this.requests && this.requests[packet.name]) {
    this.requests[packet.name].bind(this)(packet);
  } else {
    let id = this.beginNewCommand();
    let ok = this.sendOk.bind(this);
    let send = this.send.bind(this);
    this.commandProcessor.execute(packet, ok, send, id);
  }
};

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

// Dispatcher specific command handlers:

Dispatcher.prototype.getMarionetteID = function() {
  let id = this.beginNewCommand();
  this.sendResponse({from: "root", id: this.actorId}, id);
};

Dispatcher.prototype.emulatorCmdResult = function(msg) {
  switch (this.driver.context) {
    case Context.CONTENT:
      this.driver.sendAsync("emulatorCmdResult", msg);
      break;
    case Context.CHROME:
      let cb = this.emulator.popCallback(msg.id);
      if (!cb) {
        return;
      }
      cb.result(msg);
      break;
  }
};

/**
 * Quits Firefox with the provided flags and tears down the current
 * session.
 */
Dispatcher.prototype.quitApplication = function(msg) {
  let id = this.beginNewCommand();

  if (this.driver.appName != "Firefox") {
    this.sendError({
      "message": "In app initiated quit only supported on Firefox",
      "status": 500
    }, id);
    return;
  }

  let flags = Ci.nsIAppStartup.eAttemptQuit;
  for (let k of msg.parameters.flags) {
    flags |= Ci.nsIAppStartup[k];
  }

  this.stopSignal_();
  this.sendOk(id);

  this.driver.sessionTearDown();
  Services.startup.quit(flags);
};

// Convenience methods:

Dispatcher.prototype.sayHello = function() {
  let id = this.beginNewCommand();
  let yo = {from: "root", applicationType: "gecko", traits: []};
  this.sendResponse(yo, id);
};

Dispatcher.prototype.sendOk = function(cmdId) {
  this.sendResponse({from: this.actorId, ok: true}, cmdId);
};

Dispatcher.prototype.sendError = function(err, cmdId) {
  let packet = {
    from: this.actorId,
    status: err.status,
    sessionId: this.driver.sessionId,
    error: err
  };
  this.sendResponse(packet, cmdId);
};

/**
 * Marshals and sends message to either client or emulator based on the
 * provided {@code cmdId}.
 *
 * This routine produces a Marionette protocol packet, which is different
 * to a WebDriver protocol response in that it contains an extra key
 * {@code from} for the debugger transport actor ID.  It also replaces the
 * key {@code value} with {@code error} when {@code msg.status} isn't
 * {@code 0}.
 *
 * @param {Object} msg
 *     Object with the properties {@code value}, {@code status}, and
 *     {@code sessionId}.
 * @param {UUID} cmdId
 *     The unique identifier for the command the message is a response to.
 */
Dispatcher.prototype.send = function(msg, cmdId) {
  let packet = {
    from: this.actorId,
    value: msg.value,
    status: msg.status,
    sessionId: msg.sessionId,
  };

  if (typeof packet.value == "undefined") {
    packet.value = null;
  }

  // the Marionette protocol sends errors using the "error"
  // key instead of, as Selenium, "value"
  if (!error.isSuccess(msg.status)) {
    packet.error = packet.value;
    delete packet.value;
  }

  this.sendResponse(packet, cmdId);
};

// Low-level methods:

/**
 * Delegates message to client or emulator 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, emulator callbacks are more transparent and can be sent
 * at any time.  These callbacks won't change the current command state.
 *
 * @param {Object} payload
 *     The payload to send.
 * @param {UUID} cmdId
 *     The unique identifier for this payload.  {@code -1} signifies
 *     that it's an emulator callback.
 */
Dispatcher.prototype.sendResponse = function(payload, cmdId) {
  if (emulator.isCallback(cmdId)) {
    this.sendToEmulator(payload);
  } else {
    this.sendToClient(payload, cmdId);
    this.commandId = null;
  }
};

/**
 * Send message to emulator over the debugger transport socket.
 * Notably this skips out-of-sync command checks.
 */
Dispatcher.prototype.sendToEmulator = function(payload) {
  this.sendRaw("emulator", payload);
};

/**
 * Send given payload as-is to the connected client over the debugger
 * transport socket.
 *
 * If {@code cmdId} evaluates to false, the current command state isn't
 * set, or the response is out-of-sync, a warning is logged and this
 * routine will return (no-op).
 */
Dispatcher.prototype.sendToClient = function(payload, cmdId) {
  if (!cmdId) {
    logger.warn("Got response with no command ID");
    return;
  } else if (this.commandId === null) {
    logger.warn(`No current command, ignoring response: ${payload.toSource}`);
    return;
  } else if (this.isOutOfSync(cmdId)) {
    logger.warn(`Ignoring out-of-sync response with command ID: ${cmdId}`);
    return;
  }
  this.driver.responseCompleted();
  this.sendRaw("client", payload);
};

/**
 * Sends payload as-is over debugger transport socket to client,
 * and logs it.
 */
Dispatcher.prototype.sendRaw = function(dest, payload) {
  // Avoid using toSource and template strings (or touching the payload at all
  // if not necessary) for the sake of memory use.
  // See https://bugzilla.mozilla.org/show_bug.cgi?id=1150170
  if (logger.level <= Log.Level.Debug) {
    logger.debug(this.id + " " + dest + " <- (" + JSON.stringify(payload) + ")");
  }
  this.conn.send(payload);
};

/**
 * Begins a new command by generating a unique identifier and assigning
 * it to the current command state {@code Dispatcher.prototype.commandId}.
 *
 * @return {UUID}
 *     The generated unique identifier for the current command.
 */
Dispatcher.prototype.beginNewCommand = function() {
  let uuid = uuidGen.generateUUID().toString();
  this.commandId = uuid;
  return uuid;
};

Dispatcher.prototype.isOutOfSync = function(cmdId) {
  return this.commandId !== cmdId;
};

Dispatcher.prototype.requests = {
  getMarionetteID: Dispatcher.prototype.getMarionetteID,
  emulatorCmdResult: Dispatcher.prototype.emulatorCmdResult,
  quitApplication: Dispatcher.prototype.quitApplication
};
