/* 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 lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
  EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
  Log: "chrome://remote/content/shared/Log.sys.mjs",
  modal: "chrome://remote/content/shared/Prompt.sys.mjs",
  TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());

/**
 * The PromptListener listens to the DialogObserver events.
 *
 * Example:
 * ```
 * const listener = new PromptListener();
 * listener.on("opened", onPromptOpened);
 * listener.startListening();
 *
 * const onPromptOpened = (eventName, data = {}) => {
 *   const { contentBrowser, prompt } = data;
 *   ...
 * };
 * ```
 *
 * @fires message
 *    The PromptListener emits "opened" events,
 *    with the following object as payload:
 *      - {XULBrowser} contentBrowser
 *            The <xul:browser> which hold the <var>prompt</var>.
 *      - {modal.Dialog} prompt
 *            Returns instance of the Dialog class.
 *
 *    The PromptListener emits "closed" events,
 *    with the following object as payload:
 *      - {XULBrowser} contentBrowser
 *            The <xul:browser> which is the target of the event.
 *      - {object} detail
 *        {boolean=} detail.accepted
 *            Returns true if a user prompt was accepted
 *            and false if it was dismissed.
 *        {string=} detail.userText
 *            The user text specified in a prompt.
 */
export class PromptListener {
  #curBrowserFn;
  #listening;

  constructor(curBrowserFn) {
    lazy.EventEmitter.decorate(this);

    // curBrowserFn is used only for Marionette (WebDriver classic).
    this.#curBrowserFn = curBrowserFn;
    this.#listening = false;
  }

  destroy() {
    this.stopListening();
  }

  /**
   * Waits for the prompt to be closed.
   *
   * @returns {Promise}
   *    Promise that resolves when the prompt is closed.
   */
  async dialogClosed() {
    return new Promise(resolve => {
      const dialogClosed = () => {
        this.off("closed", dialogClosed);
        resolve();
      };

      this.on("closed", dialogClosed);
    });
  }

  /**
   * Handles `DOMModalDialogClosed` events.
   */
  handleEvent(event) {
    const chromeWin = (() => {
      if (ChromeUtils.getClassName(event.target) === "Window") {
        return event.target.opener || event.target;
      }
      return event.target.documentGlobal;
    })();
    const curBrowser = this.#curBrowserFn && this.#curBrowserFn();

    // For Marionette (WebDriver classic) we only care about events which come
    // the currently selected browser.
    if (curBrowser && chromeWin != curBrowser.window) {
      return;
    }

    let contentBrowser;
    if (lazy.AppInfo.isAndroid) {
      const tabBrowser = lazy.TabManager.getTabBrowser(event.target);
      // Since on Android we always have only one tab we can just check
      // the selected tab.
      const tab = tabBrowser.selectedTab;
      contentBrowser = lazy.TabManager.getBrowserForTab(tab);
    } else {
      contentBrowser = event.target;
    }

    const detail = {};

    // At the moment the event details are present for GeckoView and on desktop
    // only for Services.prompt.MODAL_TYPE_CONTENT prompts.
    if (event.detail) {
      const { areLeaving, owningBrowsingContext, promptType, value } =
        event.detail;
      // `areLeaving` returns undefined for alerts, for confirms and prompts
      // it returns true if a user prompt was accepted and false if it was dismissed.
      detail.accepted = areLeaving === undefined ? true : areLeaving;
      detail.promptType = promptType;
      if (value) {
        detail.userText = value;
      }
      detail.browsingContext = owningBrowsingContext;
    }

    this.emit("closed", {
      contentBrowser,
      detail,
    });
  }

  /**
   * Observes the following notifications:
   * `common-dialog-loaded` - when a modal dialog loaded on desktop,
   * `domwindowopened` - when a new chrome window opened,
   * `geckoview-prompt-show` - when a modal dialog opened on Android.
   */
  async observe(subject, topic) {
    let curBrowser = this.#curBrowserFn && this.#curBrowserFn();
    switch (topic) {
      case "common-dialog-loaded": {
        const browsingContext = subject.args.owningBrowsingContext;

        if (curBrowser) {
          if (
            !this.#hasCommonDialog(
              curBrowser.contentBrowser,
              curBrowser.window,
              subject
            )
          ) {
            return;
          }
        } else {
          curBrowser = { contentBrowser: browsingContext.embedderElement };
        }

        this.emit(
          "opened",
          await this.#getOpenedEventDetail(
            browsingContext,
            curBrowser.contentBrowser,
            subject
          )
        );

        break;
      }

      case "domwindowopened": {
        subject.addEventListener("DOMModalDialogClosed", this);
        break;
      }

      case "geckoview-prompt-show": {
        for (let win of Services.wm.getEnumerator(null)) {
          const subjectObject = subject.wrappedJSObject;
          const prompt = win
            .prompts()
            .find(item => item.getPromptId() == subjectObject.id);
          if (prompt) {
            const tabBrowser = lazy.TabManager.getTabBrowser(win);
            // Since on Android we always have only one tab we can just check
            // the selected tab.
            const tab = tabBrowser.selectedTab;
            const contentBrowser = lazy.TabManager.getBrowserForTab(tab);

            // Do not send the event if the curBrowser is specified,
            // and it's different from prompt browser.
            if (curBrowser && contentBrowser !== curBrowser.contentBrowser) {
              continue;
            }

            this.emit(
              "opened",
              await this.#getOpenedEventDetail(
                subjectObject.owningBrowsingContext,
                contentBrowser,
                prompt
              )
            );
            return;
          }
        }
        break;
      }
    }
  }

  startListening() {
    if (this.#listening) {
      return;
    }

    this.#register();
    this.#listening = true;
  }

  stopListening() {
    if (!this.#listening) {
      return;
    }

    this.#unregister();
    this.#listening = false;
  }

  async #getOpenedEventDetail(browsingContext, contentBrowser, dialog) {
    const prompt = new lazy.modal.Dialog(dialog);

    return {
      browsingContext,
      contentBrowser,
      prompt,
      // Resolve prompt details here to avoid sending an open event
      // with the data that is resolved after a prompt is handled.
      promptDetails: {
        defaultValue:
          prompt.promptType === "prompt" ? await prompt.getInputText() : null,
        message: await prompt.getText(),
      },
    };
  }

  #hasCommonDialog(contentBrowser, window, prompt) {
    const modalType = prompt.Dialog.args.modalType;
    if (
      modalType === Services.prompt.MODAL_TYPE_TAB ||
      modalType === Services.prompt.MODAL_TYPE_CONTENT
    ) {
      // Find the container of the dialog in the parent document, and ensure
      // it is a descendant of the same container as the content browser.
      const container = contentBrowser.closest(".browserSidebarContainer");

      return container.contains(prompt.docShell.chromeEventHandler);
    }

    return prompt == window || prompt.opener == window;
  }

  #register() {
    for (const observerName of [
      "common-dialog-loaded",
      "domwindowopened",
      "geckoview-prompt-show",
    ]) {
      Services.obs.addObserver(this, observerName);
    }

    // Register event listener and save already open prompts for all already open windows.
    for (const win of Services.wm.getEnumerator(null)) {
      win.addEventListener("DOMModalDialogClosed", this);
    }
  }

  #unregister() {
    [
      "common-dialog-loaded",
      "domwindowopened",
      "geckoview-prompt-show",
    ].forEach(observerName => {
      try {
        Services.obs.removeObserver(this, observerName);
      } catch (e) {
        lazy.logger.debug(
          `${this.constructor.name}: Failed to remove observer "${observerName}"`
        );
      }
    });

    // Unregister event listener for all open windows
    for (const win of Services.wm.getEnumerator(null)) {
      win.removeEventListener("DOMModalDialogClosed", this);
    }
  }
}
