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

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

let lazy = {};

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "dragService",
  "@mozilla.org/widget/dragservice;1",
  "nsIDragService"
);

const HTML_NS = "http://www.w3.org/1999/xhtml";

/**
 * The SubDialog resize callback.
 * @callback SubDialog~resizeCallback
 * @param {DOMNode} title - The title element of the dialog.
 * @param {xul:browser} frame - The browser frame of the dialog.
 */

/**
 * SubDialog constructor creates a new subdialog from a template and appends
 * it to the parentElement.
 * @param {DOMNode} template - The template is copied to create a new dialog.
 * @param {DOMNode} parentElement - New dialog is appended onto parentElement.
 * @param {String}  id - A unique identifier for the dialog.
 * @param {Object}  dialogOptions - Dialog options object.
 * @param {String[]} [dialogOptions.styleSheets] - An array of URLs to additional
 * stylesheets to inject into the frame.
 * @param {Boolean} [consumeOutsideClicks] - Whether to close the dialog when
 * its background overlay is clicked.
 * @param {SubDialog~resizeCallback} [resizeCallback] - Function to be called on
 * dialog resize.
 */
export function SubDialog({
  template,
  parentElement,
  id,
  dialogOptions: {
    styleSheets = [],
    consumeOutsideClicks = true,
    resizeCallback,
  } = {},
}) {
  this._id = id;

  this._injectedStyleSheets = this._injectedStyleSheets.concat(styleSheets);
  this._consumeOutsideClicks = consumeOutsideClicks;
  this._resizeCallback = resizeCallback;
  this._overlay = template.cloneNode(true);
  this._box = this._overlay.querySelector(".dialogBox");
  this._titleBar = this._overlay.querySelector(".dialogTitleBar");
  this._titleElement = this._overlay.querySelector(".dialogTitle");
  this._closeButton = this._overlay.querySelector(".dialogClose");
  this._frame = this._overlay.querySelector(".dialogFrame");

  this._overlay.classList.add(`dialogOverlay-${id}`);
  this._frame.setAttribute("name", `dialogFrame-${id}`);
  this._frameCreated = new Promise(resolve => {
    this._frame.addEventListener(
      "load",
      () => {
        // We intentionally avoid handling or passing the event to the
        // resolve method to avoid shutdown window leaks. See bug 1686743.
        resolve();
      },
      {
        once: true,
        capture: true,
      }
    );
  });

  parentElement.appendChild(this._overlay);
  this._overlay.hidden = false;
}

SubDialog.prototype = {
  _closingCallback: null,
  _closingEvent: null,
  _isClosing: false,
  _frame: null,
  _frameCreated: null,
  _overlay: null,
  _box: null,
  _openedURL: null,
  _injectedStyleSheets: ["chrome://global/skin/in-content/common.css"],
  _resizeObserver: null,
  _template: null,
  _id: null,
  _titleElement: null,
  _closeButton: null,

  get frameContentWindow() {
    return this._frame?.contentWindow;
  },

  get _window() {
    return this._overlay?.ownerGlobal;
  },

  updateTitle(aEvent) {
    if (aEvent.target != this._frame.contentDocument) {
      return;
    }
    this._titleElement.textContent = this._frame.contentDocument.title;
  },

  injectStylesheet(aStylesheetURL) {
    const doc = this._frame.contentDocument;
    if ([...doc.styleSheets].find(s => s.href === aStylesheetURL)) {
      return;
    }

    // Attempt to insert the stylesheet as a link element into the same place in
    // the document as other link elements. It is almost certain that any
    // document will already have a localization or other stylesheet link
    // present.
    let links = doc.getElementsByTagNameNS(HTML_NS, "link");
    if (links.length) {
      let stylesheetLink = doc.createElementNS(HTML_NS, "link");
      stylesheetLink.setAttribute("rel", "stylesheet");
      stylesheetLink.setAttribute("href", aStylesheetURL);

      // Insert after the last found link element.
      links[links.length - 1].after(stylesheetLink);

      return;
    }

    // In the odd case just insert at the top as a processing instruction.
    let contentStylesheet = doc.createProcessingInstruction(
      "xml-stylesheet",
      'href="' + aStylesheetURL + '" type="text/css"'
    );
    doc.insertBefore(contentStylesheet, doc.documentElement);
  },

  async open(
    aURL,
    { features, closingCallback, closedCallback, sizeTo } = {},
    ...aParams
  ) {
    if (["available", "limitheight"].includes(sizeTo)) {
      this._box.setAttribute("sizeto", sizeTo);
    }

    // Create a promise so consumers can tell when we're done setting up.
    this._dialogReady = new Promise(resolve => {
      this._resolveDialogReady = resolve;
    });
    this._frame._dialogReady = this._dialogReady;

    // Assign close callbacks sync to ensure we can always callback even if the
    // SubDialog is closed directly after opening.
    let dialog = null;

    if (closingCallback) {
      this._closingCallback = (...args) => {
        closingCallback.apply(dialog, args);
      };
    }
    if (closedCallback) {
      this._closedCallback = (...args) => {
        closedCallback.apply(dialog, args);
      };
    }

    // Wait until frame is ready to prevent browser crash in tests
    await this._frameCreated;

    // If we're closing now that we've waited for the dialog to load, abort.
    if (this._isClosing) {
      return;
    }
    this._addDialogEventListeners();

    // Ensure we end any pending drag sessions:
    try {
      // The drag service getService call fails in puppeteer tests on Linux,
      // so this is in a try...catch as it shouldn't stop us from opening the
      // dialog. Bug 1806870 tracks fixing this.
      let session = lazy.dragService.getCurrentSession(this._window);
      if (session) {
        session.endDragSession(true);
      }
    } catch (ex) {
      console.error(ex);
    }

    // If the parent is chrome we also need open the dialog as chrome, otherwise
    // the openDialog call will fail.
    let dialogFeatures = `resizable,dialog=no,centerscreen,chrome=${
      this._window?.isChromeWindow ? "yes" : "no"
    }`;
    if (features) {
      dialogFeatures = `${features},${dialogFeatures}`;
    }

    dialog = this._window.openDialog(
      aURL,
      `dialogFrame-${this._id}`,
      dialogFeatures,
      ...aParams
    );

    this._closingEvent = null;
    this._isClosing = false;
    this._openedURL = aURL;

    dialogFeatures = dialogFeatures.replace(/,/g, "&");
    let featureParams = new URLSearchParams(dialogFeatures.toLowerCase());
    this._box.setAttribute(
      "resizable",
      featureParams.has("resizable") &&
        featureParams.get("resizable") != "no" &&
        featureParams.get("resizable") != "0"
    );
  },

  /**
   * Close the dialog and mark it as aborted.
   */
  abort() {
    this._closingEvent = new CustomEvent("dialogclosing", {
      bubbles: true,
      detail: { dialog: this, abort: true },
    });
    this._frame.contentWindow?.close();
    // It's possible that we're aborting this dialog before we've had a
    // chance to set up the contentWindow.close function override in
    // _onContentLoaded. If so, call this.close() directly to clean things
    // up. That'll be a no-op if the contentWindow.close override had been
    // set up, since this.close is idempotent.
    this.close(this._closingEvent);
  },

  close(aEvent = null) {
    if (this._isClosing) {
      return;
    }
    this._isClosing = true;
    this._closingPromise = new Promise(resolve => {
      this._resolveClosePromise = resolve;
    });

    if (this._closingCallback) {
      try {
        this._closingCallback.call(null, aEvent);
      } catch (ex) {
        console.error(ex);
      }
      this._closingCallback = null;
    }

    this._removeDialogEventListeners();

    this._overlay.style.visibility = "";
    // Clear the sizing inline styles.
    this._frame.removeAttribute("style");
    // Clear the sizing attributes
    this._box.removeAttribute("width");
    this._box.removeAttribute("height");
    this._box.style.removeProperty("--box-max-height-requested");
    this._box.style.removeProperty("--box-max-width-requested");
    this._box.style.removeProperty("min-height");
    this._box.style.removeProperty("min-width");
    this._overlay.style.removeProperty("--subdialog-inner-height");

    let onClosed = () => {
      this._openedURL = null;

      this._resolveClosePromise();

      if (this._closedCallback) {
        try {
          this._closedCallback.call(null, aEvent);
        } catch (ex) {
          console.error(ex);
        }
        this._closedCallback = null;
      }
    };

    // Wait for the frame to unload before running the closed callback.
    if (this._frame.contentWindow) {
      this._frame.contentWindow.addEventListener("unload", onClosed, {
        once: true,
      });
    } else {
      onClosed();
    }

    this._overlay.dispatchEvent(
      new CustomEvent("dialogclose", {
        bubbles: true,
        detail: { dialog: this },
      })
    );

    // Defer removing the overlay so the frame content window can unload.
    Services.tm.dispatchToMainThread(() => {
      this._overlay.remove();
    });
  },

  handleEvent(aEvent) {
    switch (aEvent.type) {
      case "click":
        // Close the dialog if the user clicked the overlay background, just
        // like when the user presses the ESC key (case "command" below).
        if (aEvent.target !== this._overlay) {
          break;
        }
        if (this._consumeOutsideClicks) {
          this._frame.contentWindow.close();
          break;
        }
        this._frame.focus();
        break;
      case "command":
        this._frame.contentWindow.close();
        break;
      case "dialogclosing":
        this._onDialogClosing(aEvent);
        break;
      case "DOMTitleChanged":
        this.updateTitle(aEvent);
        break;
      case "DOMFrameContentLoaded":
        this._onContentLoaded(aEvent);
        break;
      case "load":
        this._onLoad(aEvent);
        break;
      case "unload":
        this._onUnload(aEvent);
        break;
      case "keydown":
        this._onKeyDown(aEvent);
        break;
      case "focus":
        this._onParentWinFocus(aEvent);
        break;
    }
  },

  /* Private methods */

  _onUnload(aEvent) {
    if (
      aEvent.target !== this._frame?.contentDocument ||
      aEvent.target.location.href !== this._openedURL
    ) {
      return;
    }
    this.abort();
  },

  _onContentLoaded(aEvent) {
    if (
      aEvent.target != this._frame ||
      aEvent.target.contentWindow.location == "about:blank"
    ) {
      return;
    }

    for (let styleSheetURL of this._injectedStyleSheets) {
      this.injectStylesheet(styleSheetURL);
    }

    let { contentDocument } = this._frame;
    // Provide the ability for the dialog to know that it is loaded in a frame
    // rather than as a top-level window.
    contentDocument.documentElement.toggleAttribute("subdialog", true);

    // Sub-dialogs loaded in a chrome window should use the system font size so
    // that the user has a way to increase or decrease it via system settings.
    // Sub-dialogs loaded in the content area, on the other hand, can be zoomed
    // like web content.
    if (this._window.isChromeWindow) {
      contentDocument.documentElement.classList.add("system-font-size");
    }
    // Used by CSS to give the appropriate background colour in dark mode.
    contentDocument.documentElement.setAttribute("dialogroot", "true");

    this._frame.contentWindow.addEventListener("dialogclosing", this);

    let oldResizeBy = this._frame.contentWindow.resizeBy;
    this._frame.contentWindow.resizeBy = (resizeByWidth, resizeByHeight) => {
      // Only handle resizeByHeight currently.
      let frameHeight = this._overlay.style.getPropertyValue(
        "--subdialog-inner-height"
      );
      if (frameHeight) {
        frameHeight = parseFloat(frameHeight);
      } else {
        frameHeight = this._frame.clientHeight;
      }
      let boxMinHeight = parseFloat(
        this._window.getComputedStyle(this._box).minHeight
      );

      this._box.style.minHeight = boxMinHeight + resizeByHeight + "px";

      this._overlay.style.setProperty(
        "--subdialog-inner-height",
        frameHeight + resizeByHeight + "px"
      );

      oldResizeBy.call(
        this._frame.contentWindow,
        resizeByWidth,
        resizeByHeight
      );
    };

    // Defining resizeDialog on the contentWindow object to resize dialogs when prompted
    this._frame.contentWindow.resizeDialog = () => {
      return this.resizeDialog();
    };

    // Make window.close calls work like dialog closing.
    let oldClose = this._frame.contentWindow.close;
    this._frame.contentWindow.close = () => {
      var closingEvent = this._closingEvent;
      // If this._closingEvent is set, the dialog is closed externally
      // (dialog.js) and "dialogclosing" has already been dispatched.
      if (!closingEvent) {
        // If called without closing event, we need to create and dispatch it.
        // This is the case for any external close calls not going through
        // dialog.js.
        closingEvent = new CustomEvent("dialogclosing", {
          bubbles: true,
          detail: { button: null },
        });

        this._frame.contentWindow.dispatchEvent(closingEvent);
      } else if (this._closingEvent.detail?.abort) {
        // If the dialog is aborted (SubDialog#abort) we need to dispatch the
        // "dialogclosing" event ourselves.
        this._frame.contentWindow.dispatchEvent(closingEvent);
      }

      this.close(closingEvent);
      oldClose.call(this._frame.contentWindow);
    };

    // XXX: Hack to make focus during the dialog's load functions work. Make the element visible
    // sooner in DOMContentLoaded but mostly invisible instead of changing visibility just before
    // the dialog's load event.
    // Note that this needs to inherit so that hideDialog() works as expected.
    this._overlay.style.visibility = "inherit";
    this._overlay.style.opacity = "0.01";

    // Ensure the document gets an a11y role of dialog.
    const a11yDoc = contentDocument.body || contentDocument.documentElement;
    a11yDoc.setAttribute("role", "dialog");

    Services.obs.notifyObservers(this._frame.contentWindow, "subdialog-loaded");
  },

  async _onLoad(aEvent) {
    let target = aEvent.currentTarget;
    if (target.contentWindow.location == "about:blank") {
      return;
    }

    // In order to properly calculate the sizing of the subdialog, we need to
    // ensure that all of the l10n is done.
    if (target.contentDocument.l10n) {
      await target.contentDocument.l10n.ready;
    }

    // Some subdialogs may want to perform additional, asynchronous steps during initializations.
    //
    // In that case, we expect them to define a Promise which will delay measuring
    // until the promise is fulfilled.
    if (target.contentDocument.mozSubdialogReady) {
      await target.contentDocument.mozSubdialogReady;
    }

    await this.resizeDialog();
    this._resolveDialogReady();
  },

  async resizeDialog() {
    this.resizeHorizontally();
    this.resizeVertically();

    this._overlay.dispatchEvent(
      new CustomEvent("dialogopen", {
        bubbles: true,
        detail: { dialog: this },
      })
    );
    this._overlay.style.visibility = "inherit";
    this._overlay.style.opacity = ""; // XXX: focus hack continued from _onContentLoaded

    if (this._box.getAttribute("resizable") == "true") {
      this._onResize = this._onResize.bind(this);
      this._resizeObserver = new this._window.MutationObserver(this._onResize);
      this._resizeObserver.observe(this._box, { attributes: true });
    }

    this._trapFocus();

    this._resizeCallback?.({
      title: this._titleElement,
      frame: this._frame,
    });
  },

  resizeHorizontally() {
    // Do this on load to wait for the CSS to load and apply before calculating the size.
    let docEl = this._frame.contentDocument.documentElement;

    // These are deduced from styles which we don't change, so it's safe to get them now:
    let boxHorizontalBorder =
      2 * parseFloat(this._window.getComputedStyle(this._box).borderLeftWidth);
    let frameHorizontalMargin =
      2 * parseFloat(this._window.getComputedStyle(this._frame).marginLeft);

    // Then determine and set a bunch of width stuff:
    let { scrollWidth } = docEl.ownerDocument.body || docEl;
    // We need to convert em to px because an em value from the dialog window could
    // translate to something else in the host window, as font sizes may vary.
    let frameMinWidth =
      this._emToPx(docEl.style.minWidth) ||
      this._emToPx(docEl.style.width) ||
      scrollWidth + "px";
    let frameWidth = docEl.getAttribute("width")
      ? docEl.getAttribute("width") + "px"
      : scrollWidth + "px";
    if (
      this._box.getAttribute("sizeto") == "available" &&
      docEl.style.maxWidth
    ) {
      this._box.style.setProperty(
        "--box-max-width-requested",
        this._emToPx(docEl.style.maxWidth)
      );
    }

    if (this._box.getAttribute("sizeto") != "available") {
      this._frame.style.width = frameWidth;
      this._frame.style.minWidth = frameMinWidth;
    }

    let boxMinWidth = `calc(${
      boxHorizontalBorder + frameHorizontalMargin
    }px + ${frameMinWidth})`;

    // Temporary fix to allow parent chrome to collapse properly to min width.
    // See Bug 1658722.
    if (this._window.isChromeWindow) {
      boxMinWidth = `min(80vw, ${boxMinWidth})`;
    }
    this._box.style.minWidth = boxMinWidth;
  },

  resizeVertically() {
    let docEl = this._frame.contentDocument.documentElement;
    let getDocHeight = () => {
      let { scrollHeight } = docEl.ownerDocument.body || docEl;
      // We need to convert em to px because an em value from the dialog window could
      // translate to something else in the host window, as font sizes may vary.
      return this._emToPx(docEl.style.height) || scrollHeight + "px";
    };

    // If the title bar is disabled (not in the template),
    // set its height to 0 for the calculation.
    let titleBarHeight = 0;
    if (this._titleBar) {
      titleBarHeight =
        this._titleBar.clientHeight +
        parseFloat(
          this._window.getComputedStyle(this._titleBar).borderBottomWidth
        );
    }

    let boxVerticalBorder =
      2 * parseFloat(this._window.getComputedStyle(this._box).borderTopWidth);
    let frameVerticalMargin =
      2 * parseFloat(this._window.getComputedStyle(this._frame).marginTop);

    // The difference between the frame and box shouldn't change, either:
    let boxRect = this._box.getBoundingClientRect();
    let frameRect = this._frame.getBoundingClientRect();
    let frameSizeDifference =
      frameRect.top - boxRect.top + (boxRect.bottom - frameRect.bottom);

    let contentPane =
      this._frame.contentDocument.querySelector(".contentPane") ||
      this._frame.contentDocument.querySelector("dialog");

    let sizeTo = this._box.getAttribute("sizeto");
    if (["available", "limitheight"].includes(sizeTo)) {
      if (sizeTo == "limitheight") {
        this._overlay.style.setProperty("--doc-height-px", getDocHeight());
        contentPane?.classList.add("sizeDetermined");
      } else {
        if (docEl.style.maxHeight) {
          this._box.style.setProperty(
            "--box-max-height-requested",
            this._emToPx(docEl.style.maxHeight)
          );
        }
        // Inform the CSS of the toolbar height so the bottom padding can be
        // correctly calculated.
        this._box.style.setProperty("--box-top-px", `${boxRect.top}px`);
      }
      return;
    }

    // Now do the same but for the height. We need to do this afterwards because otherwise
    // XUL assumes we'll optimize for height and gives us "wrong" values which then are no
    // longer correct after we set the width:
    let frameMinHeight = getDocHeight();
    let frameHeight = docEl.getAttribute("height")
      ? docEl.getAttribute("height") + "px"
      : frameMinHeight;

    // Now check if the frame height we calculated is possible at this window size,
    // accounting for titlebar, padding/border and some spacing.
    let frameOverhead = frameSizeDifference + titleBarHeight;
    let maxHeight = this._window.innerHeight - frameOverhead;
    // Do this with a frame height in pixels...
    if (!frameHeight.endsWith("px")) {
      console.error(
        "This dialog (",
        this._frame.contentWindow.location.href,
        ") set a height in non-px-non-em units ('",
        frameHeight,
        "'), " +
          "which is likely to lead to bad sizing in in-content preferences. " +
          "Please consider changing this."
      );
    }

    if (
      parseFloat(frameMinHeight) > maxHeight ||
      parseFloat(frameHeight) > maxHeight
    ) {
      // If the height is bigger than that of the window, we should let the
      // contents scroll. The class is set on the "dialog" element, unless a
      // content pane exists, which is usually the case when the "window"
      // element is used to implement the subdialog instead.
      frameMinHeight = maxHeight + "px";
      // There also instances where the subdialog is neither implemented using
      // a content pane, nor a <dialog> (such as manageAddresses.xhtml)
      // so make sure to check that we actually got a contentPane before we
      // use it.
      contentPane?.classList.add("doScroll");
    }

    this._overlay.style.setProperty("--subdialog-inner-height", frameHeight);
    this._frame.style.height = `min(
      calc(100vh - ${frameOverhead}px),
      var(--subdialog-inner-height, ${frameHeight})
    )`;
    this._box.style.minHeight = `calc(
      ${boxVerticalBorder + titleBarHeight + frameVerticalMargin}px +
      ${frameMinHeight}
    )`;
  },

  /**
   * Helper for converting em to px because an em value from the dialog window could
   * translate to something else in the host window, as font sizes may vary.
   *
   * @param {String} val
   *                 A CSS length value.
   * @return {String} The converted CSS length value, or the original value if
   *                  no conversion took place.
   */
  _emToPx(val) {
    if (val && val.endsWith("em")) {
      let { fontSize } = this.frameContentWindow.getComputedStyle(
        this._frame.contentDocument.documentElement
      );
      return parseFloat(val) * parseFloat(fontSize) + "px";
    }
    return val;
  },

  _onResize(mutations) {
    let frame = this._frame;
    // The width and height styles are needed for the initial
    // layout of the frame, but afterward they need to be removed
    // or their presence will restrict the contents of the <browser>
    // from resizing to a smaller size.
    frame.style.removeProperty("width");
    frame.style.removeProperty("height");

    let docEl = frame.contentDocument.documentElement;
    let persistedAttributes = docEl.getAttribute("persist");
    if (
      !persistedAttributes ||
      (!persistedAttributes.includes("width") &&
        !persistedAttributes.includes("height"))
    ) {
      return;
    }

    for (let mutation of mutations) {
      if (mutation.attributeName == "width") {
        docEl.setAttribute("width", docEl.scrollWidth);
      } else if (mutation.attributeName == "height") {
        docEl.setAttribute("height", docEl.scrollHeight);
      }
    }
  },

  _onDialogClosing(aEvent) {
    this._frame.contentWindow.removeEventListener("dialogclosing", this);
    this._closingEvent = aEvent;
  },

  _onKeyDown(aEvent) {
    // Close on ESC key if target is SubDialog
    // If we're in the parent window, we need to check if the SubDialogs
    // frame is targeted, so we don't close the wrong dialog.
    if (aEvent.keyCode == aEvent.DOM_VK_ESCAPE && !aEvent.defaultPrevented) {
      if (
        (this._window.isChromeWindow && aEvent.currentTarget == this._box) ||
        (!this._window.isChromeWindow && aEvent.currentTarget == this._window)
      ) {
        // Prevent ESC on SubDialog from cancelling page load (Bug 1665339).
        aEvent.preventDefault();
        this._frame.contentWindow.close();
        return;
      }
    }

    if (
      this._window.isChromeWindow ||
      aEvent.keyCode != aEvent.DOM_VK_TAB ||
      aEvent.ctrlKey ||
      aEvent.altKey ||
      aEvent.metaKey
    ) {
      return;
    }

    let fm = Services.focus;

    let isLastFocusableElement = el => {
      // XXXgijs unfortunately there is no way to get the last focusable element without asking
      // the focus manager to move focus to it.
      let rv =
        el ==
        fm.moveFocus(this._frame.contentWindow, null, fm.MOVEFOCUS_LAST, 0);
      fm.setFocus(el, 0);
      return rv;
    };

    let forward = !aEvent.shiftKey;
    // check if focus is leaving the frame (incl. the close button):
    if (
      (aEvent.target == this._closeButton && !forward) ||
      (isLastFocusableElement(aEvent.originalTarget) && forward)
    ) {
      aEvent.preventDefault();
      aEvent.stopImmediatePropagation();

      let parentWin = this._window.docShell.chromeEventHandler.ownerGlobal;
      if (forward) {
        fm.moveFocus(parentWin, null, fm.MOVEFOCUS_FIRST, fm.FLAG_BYKEY);
      } else {
        // Somehow, moving back 'past' the opening doc is not trivial. Cheat by doing it in 2 steps:
        fm.moveFocus(this._window, null, fm.MOVEFOCUS_ROOT, fm.FLAG_BYKEY);
        fm.moveFocus(parentWin, null, fm.MOVEFOCUS_BACKWARD, fm.FLAG_BYKEY);
      }
    }
  },

  _onParentWinFocus(aEvent) {
    // Explicitly check for the focus target of |window| to avoid triggering this when the window
    // is refocused
    if (
      this._closeButton &&
      aEvent.target != this._closeButton &&
      aEvent.target != this._window
    ) {
      this._closeButton.focus();
    }
  },

  /**
   * Setup dialog event listeners.
   * @param {Boolean} [includeLoad] - Whether to register load/unload listeners.
   */
  _addDialogEventListeners(includeLoad = true) {
    if (this._window.isChromeWindow) {
      // Only register an event listener if we have a title to show.
      if (this._titleBar) {
        this._frame.addEventListener("DOMTitleChanged", this, true);
      }

      if (includeLoad) {
        this._window.addEventListener("unload", this, true);
      }
    } else {
      let chromeBrowser = this._window.docShell.chromeEventHandler;

      if (includeLoad) {
        // For content windows we listen for unload of the browser
        chromeBrowser.addEventListener("unload", this, true);
      }

      if (this._titleBar) {
        chromeBrowser.addEventListener("DOMTitleChanged", this, true);
      }
    }

    // Make the close button work.
    this._closeButton?.addEventListener("command", this);

    if (includeLoad) {
      // DOMFrameContentLoaded only fires on the top window
      this._window.addEventListener("DOMFrameContentLoaded", this, true);

      // Wait for the stylesheets injected during DOMContentLoaded to load before showing the dialog
      // otherwise there is a flicker of the stylesheet applying.
      this._frame.addEventListener("load", this, true);
    }

    // Ensure we get <esc> keypresses even if nothing in the subdialog is focusable
    // (happens on OS X when only text inputs and lists are focusable, and
    //  the subdialog only has checkboxes/radiobuttons/buttons)
    if (!this._window.isChromeWindow) {
      this._window.addEventListener("keydown", this, true);
    }

    this._overlay.addEventListener("click", this, true);
  },

  /**
   * Remove dialog event listeners.
   * @param {Boolean} [includeLoad] - Whether to remove load/unload listeners.
   */
  _removeDialogEventListeners(includeLoad = true) {
    if (this._window.isChromeWindow) {
      this._frame.removeEventListener("DOMTitleChanged", this, true);

      if (includeLoad) {
        this._window.removeEventListener("unload", this, true);
      }
    } else {
      let chromeBrowser = this._window.docShell.chromeEventHandler;
      if (includeLoad) {
        chromeBrowser.removeEventListener("unload", this, true);
      }

      chromeBrowser.removeEventListener("DOMTitleChanged", this, true);
    }

    this._closeButton?.removeEventListener("command", this);

    if (includeLoad) {
      this._window.removeEventListener("DOMFrameContentLoaded", this, true);
      this._frame.removeEventListener("load", this, true);
      this._frame.contentWindow.removeEventListener("dialogclosing", this);
    }

    this._window.removeEventListener("keydown", this, true);

    this._overlay.removeEventListener("click", this, true);

    if (this._resizeObserver) {
      this._resizeObserver.disconnect();
      this._resizeObserver = null;
    }

    this._untrapFocus();
  },

  /**
   * Focus the dialog content.
   * If the embedded document defines a custom focus handler it will be called.
   * Otherwise we will focus the first focusable element in the content window.
   * @param {boolean} [isInitialFocus] - Whether the dialog is focused for the
   * first time after opening.
   */
  focus(isInitialFocus = false) {
    // If the content window has its own focus logic, hand off the focus call.
    let focusHandler = this._frame?.contentDocument?.subDialogSetDefaultFocus;
    if (focusHandler) {
      focusHandler(isInitialFocus);
      return;
    }
    // Handle focus ourselves. Try to move the focus to the first element in
    // the content window.
    let fm = Services.focus;

    // We're intentionally hiding the focus ring here for now per bug 1704882,
    // but we aim to have a better fix that retains the focus ring for users
    // that had brought up the dialog by keyboard in bug 1708261.
    let focusedElement = fm.moveFocus(
      this._frame.contentWindow,
      null,
      fm.MOVEFOCUS_FIRST,
      fm.FLAG_NOSHOWRING
    );
    if (!focusedElement) {
      // Ensure the focus is pulled out of the content document even if there's
      // nothing focusable in the dialog.
      this._frame.focus();
    }
  },

  _trapFocus() {
    // Attach a system event listener so the dialog can cancel keydown events.
    // See Bug 1669990.
    this._box.addEventListener("keydown", this, { mozSystemGroup: true });
    this._closeButton?.addEventListener("keydown", this);

    if (!this._window.isChromeWindow) {
      this._window.addEventListener("focus", this, true);
    }
  },

  _untrapFocus() {
    this._box.removeEventListener("keydown", this, { mozSystemGroup: true });
    this._closeButton?.removeEventListener("keydown", this);
    this._window.removeEventListener("focus", this, true);
  },
};

/**
 * Manages multiple SubDialogs in a dialog stack element.
 */
export class SubDialogManager {
  /**
   * @param {Object} options - Dialog manager options.
   * @param {DOMNode} options.dialogStack - Container element for all dialogs
   * this instance manages.
   * @param {DOMNode} options.dialogTemplate - Element to use as template for
   * constructing new dialogs.
   * @param {Number} [options.orderType] - Whether dialogs should be ordered as
   * a stack or a queue.
   * @param {Boolean} [options.allowDuplicateDialogs] - Whether to allow opening
   * duplicate dialogs (same URI) at the same time. If disabled, opening a
   * dialog with the same URI as an existing dialog will be a no-op.
   * @param {Object} options.dialogOptions - Options passed to every
   * SubDialog instance.
   * @see {@link SubDialog} for a list of dialog options.
   */
  constructor({
    dialogStack,
    dialogTemplate,
    orderType = SubDialogManager.ORDER_STACK,
    allowDuplicateDialogs = false,
    dialogOptions,
  }) {
    /**
     * New dialogs are pushed to the end of the _dialogs array.
     * Depending on the orderType either the last element (stack) or the first
     * element (queue) in the array will be the top and visible.
     * @type {SubDialog[]}
     */
    this._dialogs = [];
    this._dialogStack = dialogStack;
    this._dialogTemplate = dialogTemplate;
    this._topLevelPrevActiveElement = null;
    this._orderType = orderType;
    this._allowDuplicateDialogs = allowDuplicateDialogs;
    this._dialogOptions = dialogOptions;

    this._preloadDialog = new SubDialog({
      template: this._dialogTemplate,
      parentElement: this._dialogStack,
      id: SubDialogManager._nextDialogID++,
      dialogOptions: this._dialogOptions,
    });
  }

  /**
   * Get the dialog which is currently on top. This depends on whether the
   * dialogs are in a stack or a queue.
   */
  get _topDialog() {
    if (!this._dialogs.length) {
      return undefined;
    }
    if (this._orderType === SubDialogManager.ORDER_STACK) {
      return this._dialogs[this._dialogs.length - 1];
    }
    return this._dialogs[0];
  }

  open(
    aURL,
    {
      features,
      closingCallback,
      closedCallback,
      allowDuplicateDialogs,
      sizeTo,
      hideContent,
    } = {},
    ...aParams
  ) {
    let allowDuplicates =
      allowDuplicateDialogs != null
        ? allowDuplicateDialogs
        : this._allowDuplicateDialogs;
    // If we're already open/opening on this URL, do nothing.
    if (
      !allowDuplicates &&
      this._dialogs.some(dialog => dialog._openedURL == aURL)
    ) {
      return undefined;
    }

    let doc = this._dialogStack.ownerDocument;

    // For dialog stacks, remember the last active element before opening the
    // next dialog. This allows us to restore focus on dialog close.
    if (
      this._orderType === SubDialogManager.ORDER_STACK &&
      this._dialogs.length
    ) {
      this._topDialog._prevActiveElement = doc.activeElement;
    }

    if (!this._dialogs.length) {
      // When opening the first dialog, show the dialog stack.
      this._dialogStack.hidden = false;
      this._dialogStack.classList.remove("temporarilyHidden");
      this._topLevelPrevActiveElement = doc.activeElement;
    }

    // Consumers may pass this flag to make the dialog overlay background opaque,
    // effectively hiding the content behind it. For example,
    // this is used by the prompt code to prevent certain http authentication spoofing scenarios.
    if (hideContent) {
      this._preloadDialog._overlay.setAttribute("hideContent", true);
    }
    this._dialogs.push(this._preloadDialog);
    this._preloadDialog.open(
      aURL,
      {
        features,
        closingCallback,
        closedCallback,
        sizeTo,
      },
      ...aParams
    );

    let openedDialog = this._preloadDialog;

    this._preloadDialog = new SubDialog({
      template: this._dialogTemplate,
      parentElement: this._dialogStack,
      id: SubDialogManager._nextDialogID++,
      dialogOptions: this._dialogOptions,
    });

    if (this._dialogs.length == 1) {
      this._ensureStackEventListeners();
    }

    return openedDialog;
  }

  close() {
    this._topDialog.close();
  }

  /**
   * Hides the dialog stack for a specific browser, without actually destroying
   * frames for stuff within it.
   *
   * @param aBrowser - The browser associated with the tab dialog.
   */
  hideDialog(aBrowser) {
    aBrowser.removeAttribute("tabDialogShowing");
    this._dialogStack.classList.add("temporarilyHidden");
  }

  /**
   * Abort open dialogs.
   * @param {function} [filterFn] - Function which should return true for
   * dialogs that should be aborted and false for dialogs that should remain
   * open. Defaults to aborting all dialogs.
   */
  abortDialogs(filterFn = () => true) {
    this._dialogs.filter(filterFn).forEach(dialog => dialog.abort());
  }

  get hasDialogs() {
    if (!this._dialogs.length) {
      return false;
    }
    return this._dialogs.some(dialog => !dialog._isClosing);
  }

  get dialogs() {
    return [...this._dialogs];
  }

  focusTopDialog() {
    this._topDialog?.focus();
  }

  handleEvent(aEvent) {
    switch (aEvent.type) {
      case "dialogopen": {
        this._onDialogOpen(aEvent.detail.dialog);
        break;
      }
      case "dialogclose": {
        this._onDialogClose(aEvent.detail.dialog);
        break;
      }
    }
  }

  _onDialogOpen(dialog) {
    let lowerDialogs = [];
    if (dialog == this._topDialog) {
      dialog.focus(true);
    } else {
      // Opening dialog is not on top, hide it
      lowerDialogs.push(dialog);
    }

    // For stack order, hide the previous top
    if (
      this._dialogs.length &&
      this._orderType === SubDialogManager.ORDER_STACK
    ) {
      let index = this._dialogs.indexOf(dialog);
      if (index > 0) {
        lowerDialogs.push(this._dialogs[index - 1]);
      }
    }

    lowerDialogs.forEach(d => {
      if (d._overlay.hasAttribute("topmost")) {
        d._overlay.removeAttribute("topmost");
        d._removeDialogEventListeners(false);
      }
    });
  }

  _onDialogClose(dialog) {
    this._dialogs.splice(this._dialogs.indexOf(dialog), 1);

    if (this._topDialog) {
      // The prevActiveElement is only set for stacked dialogs
      if (this._topDialog._prevActiveElement) {
        this._topDialog._prevActiveElement.focus();
      } else {
        this._topDialog.focus(true);
      }
      this._topDialog._overlay.setAttribute("topmost", true);
      this._topDialog._addDialogEventListeners(false);
      this._dialogStack.hidden = false;
      this._dialogStack.classList.remove("temporarilyHidden");
    } else {
      // We have closed the last dialog, do cleanup.
      this._topLevelPrevActiveElement.focus();
      this._dialogStack.hidden = true;
      this._removeStackEventListeners();
    }
  }

  _ensureStackEventListeners() {
    this._dialogStack.addEventListener("dialogopen", this);
    this._dialogStack.addEventListener("dialogclose", this);
  }

  _removeStackEventListeners() {
    this._dialogStack.removeEventListener("dialogopen", this);
    this._dialogStack.removeEventListener("dialogclose", this);
  }
}

// Used for the SubDialogManager orderType option.
SubDialogManager.ORDER_STACK = 0;
SubDialogManager.ORDER_QUEUE = 1;

SubDialogManager._nextDialogID = 0;
