/* 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/. */

const IS_PARENT_PROCESS =
  Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT;

class ChildActorDispatcher {
  constructor(actor) {
    this._actor = actor;
  }

  // TODO: Bug 1658980
  registerListener(aListener, aEvents) {
    throw new Error("Cannot registerListener in child actor");
  }
  unregisterListener(aListener, aEvents) {
    throw new Error("Cannot registerListener in child actor");
  }

  /**
   * Sends a request to Java.
   *
   * @param aMsg      Message to send; must be an object with a "type" property
   */
  sendRequest(aMsg) {
    this._actor.sendAsyncMessage("DispatcherMessage", aMsg);
  }

  /**
   * Sends a request to Java, returning a Promise that resolves to the response.
   *
   * @param aMsg Message to send; must be an object with a "type" property
   * @return A Promise resolving to the response
   */
  sendRequestForResult(aMsg) {
    return this._actor.sendQuery("DispatcherQuery", aMsg);
  }
}

function DispatcherDelegate(aDispatcher, aMessageManager) {
  this._dispatcher = aDispatcher;
  this._messageManager = aMessageManager;

  if (!aDispatcher) {
    // Child process.
    // TODO: this doesn't work with Fission, remove this code path once every
    // consumer has been migrated. Bug 1569360.
    this._replies = new Map();
    (aMessageManager || Services.cpmm).addMessageListener(
      "GeckoView:MessagingReply",
      this
    );
  }
}

DispatcherDelegate.prototype = {
  /**
   * Register a listener to be notified of event(s).
   *
   * @param aListener Target listener implementing nsIAndroidEventListener.
   * @param aEvents   String or array of strings of events to listen to.
   */
  registerListener(aListener, aEvents) {
    if (!this._dispatcher) {
      throw new Error("Can only listen in parent process");
    }
    this._dispatcher.registerListener(aListener, aEvents);
  },

  /**
   * Unregister a previously-registered listener.
   *
   * @param aListener Registered listener implementing nsIAndroidEventListener.
   * @param aEvents   String or array of strings of events to stop listening to.
   */
  unregisterListener(aListener, aEvents) {
    if (!this._dispatcher) {
      throw new Error("Can only listen in parent process");
    }
    this._dispatcher.unregisterListener(aListener, aEvents);
  },

  /**
   * Dispatch an event to registered listeners for that event, and pass an
   * optional data object and/or a optional callback interface to the
   * listeners.
   *
   * @param aEvent     Name of event to dispatch.
   * @param aData      Optional object containing data for the event.
   * @param aCallback  Optional callback implementing nsIAndroidEventCallback.
   * @param aFinalizer Optional finalizer implementing nsIAndroidEventFinalizer.
   */
  dispatch(aEvent, aData, aCallback, aFinalizer) {
    if (this._dispatcher) {
      this._dispatcher.dispatch(aEvent, aData, aCallback, aFinalizer);
      return;
    }

    const mm = this._messageManager || Services.cpmm;
    const forwardData = {
      global: !this._messageManager,
      event: aEvent,
      data: aData,
    };

    if (aCallback) {
      const uuid = Services.uuid.generateUUID().toString();
      this._replies.set(uuid, {
        callback: aCallback,
        finalizer: aFinalizer,
      });
      forwardData.uuid = uuid;
    }

    mm.sendAsyncMessage("GeckoView:Messaging", forwardData);
  },

  /**
   * Sends a request to Java.
   *
   * @param aMsg      Message to send; must be an object with a "type" property
   * @param aCallback Optional callback implementing nsIAndroidEventCallback.
   */
  sendRequest(aMsg, aCallback) {
    const type = aMsg.type;
    aMsg.type = undefined;
    this.dispatch(type, aMsg, aCallback);
  },

  /**
   * Sends a request to Java, returning a Promise that resolves to the response.
   *
   * @param aMsg Message to send; must be an object with a "type" property
   * @return A Promise resolving to the response
   */
  sendRequestForResult(aMsg) {
    return new Promise((resolve, reject) => {
      const type = aMsg.type;
      aMsg.type = undefined;

      // Manually release the resolve/reject functions after one callback is
      // received, so the JS GC is not tied up with the Java GC.
      const onCallback = (callback, ...args) => {
        if (callback) {
          callback(...args);
        }
        resolve = undefined;
        reject = undefined;
      };
      const callback = {
        onSuccess: result => onCallback(resolve, result),
        onError: error => onCallback(reject, error),
        onFinalize: _ => onCallback(reject),
      };
      this.dispatch(type, aMsg, callback, callback);
    });
  },

  finalize() {
    if (!this._replies) {
      return;
    }
    this._replies.forEach(reply => {
      if (typeof reply.finalizer === "function") {
        reply.finalizer();
      } else if (reply.finalizer) {
        reply.finalizer.onFinalize();
      }
    });
    this._replies.clear();
  },

  receiveMessage(aMsg) {
    const { uuid, type } = aMsg.data;
    const reply = this._replies.get(uuid);
    if (!reply) {
      return;
    }

    if (type === "success") {
      reply.callback.onSuccess(aMsg.data.response);
    } else if (type === "error") {
      reply.callback.onError(aMsg.data.response);
    } else if (type === "finalize") {
      if (typeof reply.finalizer === "function") {
        reply.finalizer();
      } else if (reply.finalizer) {
        reply.finalizer.onFinalize();
      }
      this._replies.delete(uuid);
    } else {
      throw new Error("invalid reply type");
    }
  },
};

export var EventDispatcher = {
  instance: new DispatcherDelegate(
    IS_PARENT_PROCESS ? Services.androidBridge : undefined
  ),

  /**
   * Return an EventDispatcher instance for a chrome DOM window. In a content
   * process, return a proxy through the message manager that automatically
   * forwards events to the main process.
   *
   * To force using a message manager proxy (for example in a frame script
   * environment), call forMessageManager.
   *
   * @param aWindow a chrome DOM window.
   */
  for(aWindow) {
    const view =
      aWindow &&
      aWindow.arguments &&
      aWindow.arguments[0] &&
      aWindow.arguments[0].QueryInterface(Ci.nsIAndroidView);

    if (!view) {
      const mm = !IS_PARENT_PROCESS && aWindow && aWindow.messageManager;
      if (!mm) {
        throw new Error(
          "window is not a GeckoView-connected window and does" +
            " not have a message manager"
        );
      }
      return this.forMessageManager(mm);
    }

    return new DispatcherDelegate(view);
  },

  /**
   * Returns a named EventDispatcher, which can communicate with the
   * corresponding EventDispatcher on the java side.
   */
  byName(aName) {
    if (!IS_PARENT_PROCESS) {
      return undefined;
    }
    const dispatcher = Services.androidBridge.getDispatcherByName(aName);
    return new DispatcherDelegate(dispatcher);
  },

  /**
   * Return an EventDispatcher instance for a message manager associated with a
   * window.
   *
   * @param aWindow a message manager.
   */
  forMessageManager(aMessageManager) {
    return new DispatcherDelegate(null, aMessageManager);
  },

  /**
   * Return the EventDispatcher instance associated with an actor.
   *
   * @param aActor an actor
   */
  forActor(aActor) {
    return new ChildActorDispatcher(aActor);
  },

  receiveMessage(aMsg) {
    // aMsg.data includes keys: global, event, data, uuid
    let callback;
    if (aMsg.data.uuid) {
      const reply = (type, response) => {
        const mm = aMsg.data.global ? aMsg.target : aMsg.target.messageManager;
        if (!mm) {
          if (type === "finalize") {
            // It's normal for the finalize call to come after the browser has
            // been destroyed. We can gracefully handle that case despite
            // having no message manager.
            return;
          }
          throw Error(
            `No message manager for ${aMsg.data.event}:${type} reply`
          );
        }
        mm.sendAsyncMessage("GeckoView:MessagingReply", {
          type,
          response,
          uuid: aMsg.data.uuid,
        });
      };
      callback = {
        onSuccess: response => reply("success", response),
        onError: error => reply("error", error),
        onFinalize: () => reply("finalize"),
      };
    }

    try {
      if (aMsg.data.global) {
        this.instance.dispatch(
          aMsg.data.event,
          aMsg.data.data,
          callback,
          callback
        );
        return;
      }

      const win = aMsg.target.ownerGlobal;
      const dispatcher = win.WindowEventDispatcher || this.for(win);
      dispatcher.dispatch(aMsg.data.event, aMsg.data.data, callback, callback);
    } catch (e) {
      callback?.onError(`Error getting dispatcher: ${e}`);
      throw e;
    }
  },
};

if (IS_PARENT_PROCESS) {
  Services.mm.addMessageListener("GeckoView:Messaging", EventDispatcher);
  Services.ppmm.addMessageListener("GeckoView:Messaging", EventDispatcher);
}