/* 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 { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { XPCOMUtils } = ChromeUtils.import(
  "resource://gre/modules/XPCOMUtils.jsm"
);
const { error, WebDriverError } = ChromeUtils.import(
  "chrome://marionette/content/error.js"
);
const { evaluate } = ChromeUtils.import(
  "chrome://marionette/content/evaluate.js"
);
const { Log } = ChromeUtils.import("chrome://marionette/content/log.js");
const { modal } = ChromeUtils.import("chrome://marionette/content/modal.js");
const { MessageManagerDestroyedPromise } = ChromeUtils.import(
  "chrome://marionette/content/sync.js"
);
this.EXPORTED_SYMBOLS = ["proxy"];
XPCOMUtils.defineLazyGetter(this, "log", Log.get);
XPCOMUtils.defineLazyServiceGetter(
  this,
  "uuidgen",
  "@mozilla.org/uuid-generator;1",
  "nsIUUIDGenerator"
);
// Proxy handler that traps requests to get a property.  Will prioritise
// properties that exist on the object's own prototype.
const ownPriorityGetterTrap = {
  get: (obj, prop) => {
    if (obj.hasOwnProperty(prop)) {
      return obj[prop];
    }
    return (...args) => obj.send(prop, args);
  },
};
/** @namespace */
this.proxy = {};
/**
 * Creates a transparent interface between the chrome- and content
 * contexts.
 *
 * Calls to this object will be proxied via the message manager to a
 * content frame script, and responses are returend 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(string, Object, number)} sendAsyncFn
 *     Callback for sending async messages.
 * @param {function(): browser.Context} browserFn
 *     Closure that returns the current browsing context.
 */
proxy.toListener = function(sendAsyncFn, browserFn) {
  let sender = new proxy.AsyncMessageChannel(sendAsyncFn, browserFn);
  return new Proxy(sender, ownPriorityGetterTrap);
};
/**
 * Provides a transparent interface between chrome- and content space.
 *
 * The AsyncMessageChannel is an abstraction of the message manager
 * IPC architecture allowing calls to be made to any registered message
 * listener in Marionette.  The #send(...) method
 * returns a promise that gets resolved when the message handler calls
 * .reply(...).
 */
proxy.AsyncMessageChannel = class {
  constructor(sendAsyncFn, browserFn) {
    this.sendAsync = sendAsyncFn;
    this.browserFn_ = browserFn;
    // TODO(ato): Bug 1242595
    this.activeMessageId = null;
    this.listeners_ = new Map();
    this.dialogHandler = null;
    this.closeHandler = null;
  }
  get browser() {
    return this.browserFn_();
  }
  /**
   * Send a message across the channel.  The name of the function to
   * call must be registered as a message listener.
   *
   * Usage:
   *
   * 
   *     let channel = new AsyncMessageChannel(
   *         messageManager, sendAsyncMessage.bind(this));
   *     let rv = await channel.send("remoteFunction", ["argument"]);
   * 
   *
   * @param {string} name
   *     Function to call in the listener, e.g. for the message listener
   *     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.
   * @throws {TypeError}
   *     If an unsupported reply type is received.
   * @throws {WebDriverError}
   *     If an error is returned over the channel.
   */
  send(name, args = []) {
    let uuid = uuidgen.generateUUID().toString();
    // TODO(ato): Bug 1242595
    this.activeMessageId = uuid;
    return new Promise((resolve, reject) => {
      let path = proxy.AsyncMessageChannel.makePath(uuid);
      let cb = msg => {
        this.activeMessageId = null;
        let { data, type } = msg.json;
        switch (msg.json.type) {
          case proxy.AsyncMessageChannel.ReplyType.Ok:
          case proxy.AsyncMessageChannel.ReplyType.Value:
            let payload = evaluate.fromJSON(data);
            resolve(payload);
            break;
          case proxy.AsyncMessageChannel.ReplyType.Error:
            let err = WebDriverError.fromJSON(data);
            reject(err);
            break;
          default:
            throw new TypeError(`Unknown async response type: ${type}`);
        }
      };
      // The currently selected tab or window is closing. Make sure to wait
      // until it's fully gone.
      this.closeHandler = async ({ type, target }) => {
        log.trace(`Received DOM event ${type} for ${target}`);
        let messageManager;
        switch (type) {
          case "unload":
            messageManager = this.browser.window.messageManager;
            break;
          case "TabClose":
            messageManager = this.browser.messageManager;
            break;
        }
        await new MessageManagerDestroyedPromise(messageManager);
        this.removeHandlers();
        resolve();
      };
      // A modal or tab modal dialog has been opened. To be able to handle it,
      // the active command has to be aborted. Therefore remove all handlers,
      // and cancel any ongoing requests in the listener.
      this.dialogHandler = (action, dialogRef, win) => {
        // Only care about modals of the currently selected window.
        if (win !== this.browser.window) {
          return;
        }
        this.removeAllListeners_();
        // TODO(ato): It's not ideal to have listener specific behaviour here:
        this.sendAsync("cancelRequest");
        this.removeHandlers();
        resolve();
      };
      // start content message listener, and install handlers for
      // modal dialogues, and window/tab state changes.
      this.addListener_(path, cb);
      this.addHandlers();
      // sendAsync is GeckoDriver#sendAsync
      this.sendAsync(name, marshal(args), uuid);
    });
  }
  /**
   * Add all necessary handlers for events and observer notifications.
   */
  addHandlers() {
    this.browser.driver.dialogObserver.add(this.dialogHandler.bind(this));
    // Register event handlers in case the command closes the current
    // tab or window, and the promise has to be escaped.
    if (this.browser) {
      this.browser.window.addEventListener("unload", this.closeHandler);
      if (this.browser.tab) {
        let node = this.browser.tab.addEventListener
          ? this.browser.tab
          : this.browser.contentBrowser;
        node.addEventListener("TabClose", this.closeHandler);
      }
    }
  }
  /**
   * Remove all registered handlers for events and observer notifications.
   */
  removeHandlers() {
    this.browser.driver.dialogObserver.remove(this.dialogHandler.bind(this));
    if (this.browser) {
      this.browser.window.removeEventListener("unload", this.closeHandler);
      if (this.browser.tab) {
        let node = this.browser.tab.addEventListener
          ? this.browser.tab
          : this.browser.contentBrowser;
        if (node) {
          node.removeEventListener("TabClose", this.closeHandler);
        }
      }
    }
  }
  /**
   * Reply to an asynchronous request.
   *
   * Passing an {@link WebDriverError} prototype will cause the receiving
   * channel to throw this error.
   *
   * Usage:
   *
   * 
   *     let channel = proxy.AsyncMessageChannel(
   *         messageManager, sendAsyncMessage.bind(this));
   *
   *     // throws in requester:
   *     channel.reply(uuid, new WebDriverError());
   *
   *     // returns with value:
   *     channel.reply(uuid, "hello world!");
   *
   *     // returns with undefined:
   *     channel.reply(uuid);
   * 
   *
   * @param {UUID} uuid
   *     Unique identifier of the request.
   * @param {*} obj
   *     Message data to reply with.
   */
  reply(uuid, obj = undefined) {
    // TODO(ato): Eventually the uuid will be hidden in the dispatcher
    // in listener, and passing it explicitly to this function will be
    // unnecessary.
    if (typeof obj == "undefined") {
      this.sendReply_(uuid, proxy.AsyncMessageChannel.ReplyType.Ok);
    } else if (error.isError(obj)) {
      let err = error.wrap(obj);
      this.sendReply_(uuid, proxy.AsyncMessageChannel.ReplyType.Error, err);
    } else {
      this.sendReply_(uuid, proxy.AsyncMessageChannel.ReplyType.Value, obj);
    }
  }
  sendReply_(uuid, type, payload = undefined) {
    const path = proxy.AsyncMessageChannel.makePath(uuid);
    let data = evaluate.toJSON(payload);
    const msg = { type, data };
    // here sendAsync is actually the content frame's
    // sendAsyncMessage(path, message) global
    this.sendAsync(path, msg);
  }
  /**
   * Produces a path, or a name, for the message listener handler that
   * listens for a reply.
   *
   * @param {UUID} uuid
   *     Unique identifier of the channel request.
   *
   * @return {string}
   *     Path to be used for nsIMessageListener.addMessageListener.
   */
  static makePath(uuid) {
    return "Marionette:asyncReply:" + uuid;
  }
  addListener_(path, callback) {
    let autoRemover = msg => {
      this.removeListener_(path);
      this.removeHandlers();
      callback(msg);
    };
    Services.mm.addMessageListener(path, autoRemover);
    this.listeners_.set(path, autoRemover);
  }
  removeListener_(path) {
    if (!this.listeners_.has(path)) {
      return true;
    }
    let l = this.listeners_.get(path);
    Services.mm.removeMessageListener(path, l);
    return this.listeners_.delete(path);
  }
  removeAllListeners_() {
    let ok = true;
    for (let [p] of this.listeners_) {
      ok |= this.removeListener_(p);
    }
    return ok;
  }
};
proxy.AsyncMessageChannel.ReplyType = {
  Ok: 0,
  Value: 1,
  Error: 2,
};
function marshal(args) {
  if (args.length == 1 && typeof args[0] == "object") {
    return args[0];
  }
  return args;
}