/* 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("chrome://marionette/content/modal.js");

this.EXPORTED_SYMBOLS = ["proxy"];

const MARIONETTE_OK = "Marionette:ok";
const MARIONETTE_DONE = "Marionette:done";
const MARIONETTE_ERROR = "Marionette:error";

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

// Proxy handler that traps requests to get a property.  Will prioritise
// properties that exist on the object's own prototype.
var ownPriorityGetterTrap = {
  get: (obj, prop) => {
    if (obj.hasOwnProperty(prop)) {
      return obj[prop];
    }
    return (...args) => obj.send(prop, args);
  }
};

this.proxy = {};

/**
 * Creates a transparent interface between the chrome- and content
 * contexts.
 *
 * Calls to this object will be proxied via the message manager to the active
 * browsing context (content) and responses will be provided back as
 * promises.
 *
 * The argument sequence is serialised and passed as an array, unless it
 * consists of a single object type that isn't null, in which case it's
 * passed literally.  The latter specialisation is temporary to achieve
 * backwards compatibility with listener.js.
 *
 * @param {function(): (nsIMessageSender|nsIMessageBroadcaster)} mmFn
 *     Function returning the current message manager.
 * @param {function(string, Object, number)} sendAsyncFn
 *     Callback for sending async messages to the current listener.
 */
proxy.toListener = function(mmFn, sendAsyncFn) {
  let sender = new AsyncContentSender(mmFn, sendAsyncFn);
  return new Proxy(sender, ownPriorityGetterTrap);
};

/**
 * The AsyncContentSender allows one to make synchronous calls to the
 * message listener of the content frame of the current browsing context.
 *
 * Presumptions about the responses from content space are made so we
 * can provide a nicer API on top of the message listener primitives that
 * make calls from chrome- to content space seem synchronous by leveraging
 * promises.
 *
 * The promise is guaranteed not to resolve until the execution of the
 * command in content space is complete.
 */
this.AsyncContentSender = class {
  constructor(mmFn, sendAsyncFn) {
    this.curId = null;
    this.sendAsync = sendAsyncFn;
    this.mmFn_ = mmFn;
    this._listeners = [];
  }

  get mm() {
    return this.mmFn_();
  }

  removeListeners() {
    this._listeners.map(l => this.mm.removeMessageListener(l[0], l[1]));
    this._listeners = [];
  }

  /**
   * Call registered function in the frame script environment of the
   * current browsing context's content frame.
   *
   * @param {string} name
   *     Function to call in the listener, e.g. for "Marionette:foo8",
   *     use "foo".
   * @param {Array} args
   *     Argument list to pass the function.  If args has a single entry
   *     that is an object, we assume it's an old style dispatch, and
   *     the object will passed literally.
   *
   * @return {Promise}
   *     A promise that resolves to the result of the command.
   */
  send(name, args) {
    if (this._listeners[0]) {
      // A prior (probably timed-out) request has left listeners behind.
      // Remove them before proceeding.
      logger.warn("A previous failed command left content listeners behind!");
      this.removeListeners();
    }

    this.curId = uuidgen.generateUUID().toString();

    let proxy = new Promise((resolve, reject) => {
      let removeListeners = (n, fn) => {
        let rmFn = msg => {
          if (this.curId !== msg.json.command_id) {
            logger.warn("Skipping out-of-sync response from listener: " +
                `Expected response to ${name} with ID ${this.curId}, ` +
                "but got: " + msg.name + msg.json.toSource());
            return;
          }

          this.removeListeners();
          modal.removeHandler(handleDialog);

          fn(msg);
          this.curId = null;
        };

        this._listeners.push([n, rmFn]);
        return rmFn;
      };

      let okListener = () => resolve();
      let valListener = msg => resolve(msg.json.value);
      let errListener = msg => reject(msg.objects.error);

      let handleDialog = (subject, topic) => {
        this.removeListeners()
        modal.removeHandler(handleDialog);
        this.sendAsync("cancelRequest");
        resolve();
      };

      // start content process listeners, and install observers for global-
      // and tab modal dialogues
      this.mm.addMessageListener(MARIONETTE_OK, removeListeners(MARIONETTE_OK, okListener));
      this.mm.addMessageListener(MARIONETTE_DONE, removeListeners(MARIONETTE_DONE, valListener));
      this.mm.addMessageListener(MARIONETTE_ERROR, removeListeners(MARIONETTE_ERROR, errListener));
      modal.addHandler(handleDialog);

      this.sendAsync(name, marshal(args), this.curId);
    });

    return proxy;
  }
};

/**
 * Creates a transparent interface from the content- to the chrome context.
 *
 * Calls to this object will be proxied via the frame's sendSyncMessage
 * (nsISyncMessageSender) function.  Since the message is synchronous,
 * the return value is presented as a return value.
 *
 * Example on how to use from a frame content script:
 *
 *     let chrome = proxy.toChrome(sendSyncMessage.bind(this));
 *     let cookie = chrome.getCookie("foo");
 *
 * @param {nsISyncMessageSender} sendSyncMessageFn
 *     The frame message manager's sendSyncMessage function.
 */
proxy.toChrome = function(sendSyncMessageFn) {
  let sender = new SyncChromeSender(sendSyncMessageFn);
  return new Proxy(sender, ownPriorityGetterTrap);
};

/**
 * The SyncChromeSender sends synchronous RPC messages to the chrome
 * context, using a frame's sendSyncMessage (nsISyncMessageSender) function.
 *
 * Example on how to use from a frame content script:
 *
 *     let sender = new SyncChromeSender(sendSyncMessage.bind(this));
 *     let res = sender.send("addCookie", cookie);
 */
this.SyncChromeSender = class {
  constructor(sendSyncMessage) {
    this.sendSyncMessage_ = sendSyncMessage;
  }

  send(func, args) {
    let name = "Marionette:" + func;
    return this.sendSyncMessage_(name, marshal(args));
  }
};

var marshal = function(args) {
  if (args.length == 1 && typeof args[0] == "object") {
    return args[0];
  }
  return args;
};
