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

export class ContentEventListenerChild extends JSWindowActorChild {
  actorCreated() {
    this._contentEvents = new Map();
    this._shutdown = false;
    this._chromeEventHandler = null;
    Services.cpmm.sharedData.addEventListener("change", this);
  }

  didDestroy() {
    this._shutdown = true;
    Services.cpmm.sharedData.removeEventListener("change", this);
    this._updateContentEventListeners(/* clearListeners = */ true);
    if (this._contentEvents.size != 0) {
      throw new Error(`Didn't expect content events after willDestroy`);
    }
  }

  handleEvent(event) {
    switch (event.type) {
      case "DOMWindowCreated": {
        this._updateContentEventListeners();
        break;
      }

      case "change": {
        if (
          !event.changedKeys.includes("BrowserTestUtils:ContentEventListener")
        ) {
          return;
        }
        this._updateContentEventListeners();
        break;
      }
    }
  }

  /**
   * This method first determines the desired set of content event listeners
   * for the window. This is either the empty set, if clearListeners is true,
   * or is retrieved from the message manager's shared data. It then compares
   * this event listener data to the existing set of listeners that we have
   * registered, as recorded in this._contentEvents. Each content event listener
   * has been assigned a unique id by the parent process. If a listener was
   * added, but is not in the new event data, it is removed. If a listener was
   * not present, but is in the new event data, it is added. If it is in both,
   * then a basic check is done to see if they are the same.
   *
   * @param {bool} clearListeners [optional]
   *        If this is true, then instead of checking shared data to decide
   *        what the desired set of listeners is, just use the empty set. This
   *        will result in any existing listeners being cleared, and is used
   *        when the window is going away.
   */
  _updateContentEventListeners(clearListeners = false) {
    // If we've already begun the destruction process, any new event
    // listeners for our bc id can't possibly really be for us, so ignore them.
    if (this._shutdown && !clearListeners) {
      throw new Error(
        "Tried to update after we shut down content event listening"
      );
    }

    let newEventData;
    if (!clearListeners) {
      newEventData = Services.cpmm.sharedData.get(
        "BrowserTestUtils:ContentEventListener"
      );
    }
    if (!newEventData) {
      newEventData = new Map();
    }

    // Check that entries that continue to exist are the same and remove entries
    // that no longer exist.
    for (let [
      listenerId,
      { eventName, listener, listenerOptions },
    ] of this._contentEvents.entries()) {
      let newData = newEventData.get(listenerId);
      if (newData) {
        if (newData.eventName !== eventName) {
          // Could potentially check if listenerOptions are the same, but
          // checkFnSource can't be checked unless we store it, and this is
          // just a smoke test anyways, so don't bother.
          throw new Error(
            "Got new content event listener that disagreed with existing data"
          );
        }
        continue;
      }
      if (!this._chromeEventHandler) {
        throw new Error(
          "Trying to remove an event listener for waitForContentEvent without a cached event handler"
        );
      }
      this._chromeEventHandler.removeEventListener(
        eventName,
        listener,
        listenerOptions
      );
      this._contentEvents.delete(listenerId);
    }

    let actorChild = this;

    // Add in new entries.
    for (let [
      listenerId,
      { eventName, listenerOptions, checkFnSource },
    ] of newEventData.entries()) {
      let oldData = this._contentEvents.get(listenerId);
      if (oldData) {
        // We checked that the data is the same in the previous loop.
        continue;
      }

      let checkFn;
      if (checkFnSource) {
        const systemPrincipal =
          Services.scriptSecurityManager.getSystemPrincipal();
        const sb = Cu.Sandbox(systemPrincipal, {
          sandboxPrototype: globalThis,
        });
        checkFn = Cu.evalInSandbox(
          `(() => (${unescape(checkFnSource)}))()`,
          sb
        );
      }

      function listener(event) {
        if (checkFn && !checkFn(event)) {
          return;
        }
        actorChild.sendAsyncMessage("ContentEventListener:Run", {
          listenerId,
        });
      }

      // Cache the chrome event handler because this.docShell won't be
      // available during shut down.
      if (!this._chromeEventHandler) {
        try {
          this._chromeEventHandler = this.docShell.chromeEventHandler;
        } catch (error) {
          if (error.name === "InvalidStateError") {
            // We'll arrive here if we no longer have our manager, so we can
            // just swallow this error.
            continue;
          }
          throw error;
        }
      }

      // Some windows, like top-level browser windows, maybe not have a chrome
      // event handler set up as this point, but we don't actually care about
      // events on those windows, so ignore them.
      if (!this._chromeEventHandler) {
        continue;
      }

      this._chromeEventHandler.addEventListener(
        eventName,
        listener,
        listenerOptions
      );
      this._contentEvents.set(listenerId, {
        eventName,
        listener,
        listenerOptions,
      });
    }

    // If there are no active content events, clear our reference to the chrome
    // event handler to prevent leaks.
    if (this._contentEvents.size == 0) {
      this._chromeEventHandler = null;
    }
  }
}
