/* 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";

// This is loaded into all XUL windows. Wrap in a block to prevent
// leaking to window scope.
{
  const { AppConstants } = ChromeUtils.importESModule(
    "resource://gre/modules/AppConstants.sys.mjs"
  );

  class MozDialog extends MozXULElement {
    #subDialogObserver = null;

    constructor() {
      super();
    }

    static get observedAttributes() {
      return super.observedAttributes.concat(
        "subdialog",
        "extra1-is-secondary",
        "default-is-destructive"
      );
    }

    #onSubDialogEnabled() {
      console.assert(
        document.documentElement.hasAttribute("subdialog"),
        `Turning off subdialog style is not supported`
      );
      this.shadowRoot.appendChild(
        MozXULElement.parseXULToFragment(this.inContentStyle)
      );
    }

    attributeChangedCallback(name, oldValue, newValue) {
      // Only move the button on UNIX, since it's already in the correct spot on Windows
      if (name === "extra1-is-secondary" && AppConstants.XP_UNIX) {
        this.getButton("cancel").after(this.getButton("extra1"));
      }
      if (name === "default-is-destructive") {
        this.#setButtonIsDestructive(
          this.getButton(this.defaultButton),
          this.hasAttribute("default-is-destructive")
        );
      }
      super.attributeChangedCallback(name, oldValue, newValue);
    }

    static get inheritedAttributes() {
      return {
        ".dialog-button-box":
          "pack=buttonpack,align=buttonalign,dir=buttondir,orient=buttonorient",
        "[dlgtype='accept']": "disabled=buttondisabledaccept",
      };
    }

    get inContentStyle() {
      return `
      <html:link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
    `;
    }

    get _markup() {
      let buttons = AppConstants.XP_UNIX
        ? `
      <hbox class="dialog-button-box">
        <button part="dialog-button" dlgtype="disclosure" hidden="true"/>
        <button part="dialog-button" dlgtype="extra2" hidden="true"/>
        <button part="dialog-button" dlgtype="extra1" hidden="true"/>
        <spacer part="button-spacer" class="button-spacer" flex="1"/>
        <button part="dialog-button" dlgtype="cancel"/>
        <button part="dialog-button" dlgtype="accept"/>
      </hbox>`
        : `
      <hbox class="dialog-button-box" pack="end">
        <button part="dialog-button" dlgtype="extra2" hidden="true"/>
        <spacer part="button-spacer" class="button-spacer" flex="1" hidden="true"/>
        <button part="dialog-button" dlgtype="accept"/>
        <button part="dialog-button" dlgtype="extra1" hidden="true"/>
        <button part="dialog-button" dlgtype="cancel"/>
        <button part="dialog-button" dlgtype="disclosure" hidden="true"/>
      </hbox>`;

      return `
      <html:link rel="stylesheet" href="chrome://global/skin/button.css"/>
      <html:link rel="stylesheet" href="chrome://global/skin/dialog.css"/>
      ${
        document.documentElement.hasAttribute("subdialog")
          ? this.inContentStyle
          : ""
      }
      <vbox class="box-inherit" part="content-box">
        <html:slot></html:slot>
      </vbox>
      ${buttons}`;
    }

    connectedCallback() {
      if (this.delayConnectedCallback()) {
        return;
      }
      if (this.hasConnected) {
        return;
      }
      this.hasConnected = true;
      this.attachShadow({ mode: "open" });

      let docRoot = document.documentElement;
      docRoot.setAttribute("role", "dialog");
      if (!docRoot.hasAttribute("subdialog")) {
        this.#subDialogObserver = new MutationObserver(() => {
          this.#onSubDialogEnabled();
          this.#subDialogObserver.disconnect();
          this.#subDialogObserver = null;
        });
        this.#subDialogObserver.observe(docRoot, {
          attributes: true,
          attributeFilter: ["subdialog"],
        });
      }
      document.l10n?.connectRoot(this.shadowRoot);

      this.shadowRoot.textContent = "";
      this.shadowRoot.appendChild(
        MozXULElement.parseXULToFragment(this._markup)
      );
      this.initializeAttributeInheritance();

      this._configureButtons(this.buttons);

      window.moveToAlertPosition = this.moveToAlertPosition;
      window.centerWindowOnScreen = this.centerWindowOnScreen;

      document.addEventListener(
        "keypress",
        event => {
          if (event.keyCode == KeyEvent.DOM_VK_RETURN) {
            this._hitEnter(event);
          } else if (
            event.keyCode == KeyEvent.DOM_VK_ESCAPE &&
            !event.defaultPrevented
          ) {
            this.cancelDialog();
          }
        },
        { mozSystemGroup: true }
      );

      if (AppConstants.platform == "macosx") {
        document.addEventListener(
          "keypress",
          event => {
            if (event.key == "." && event.metaKey) {
              this.cancelDialog();
            }
          },
          true
        );
      } else {
        this.addEventListener("focus", this, true);
        this.shadowRoot.addEventListener("focus", this, true);
      }

      // listen for when window is closed via native close buttons
      window.addEventListener("close", event => {
        if (!this.cancelDialog()) {
          event.preventDefault();
        }
      });

      // Call postLoadInit for things that we need to initialize after onload.
      if (document.readyState == "complete") {
        this._postLoadInit();
      } else {
        window.addEventListener("load", () => this._postLoadInit());
      }
    }

    set buttons(val) {
      this._configureButtons(val);
    }

    get buttons() {
      return this.getAttribute("buttons");
    }

    set defaultButton(val) {
      this._setDefaultButton(val);
    }

    get defaultButton() {
      if (this.hasAttribute("defaultButton")) {
        return this.getAttribute("defaultButton");
      }
      return "accept"; // default to the accept button
    }

    get _strBundle() {
      if (!this.__stringBundle) {
        this.__stringBundle = Services.strings.createBundle(
          "chrome://global/locale/dialog.properties"
        );
      }
      return this.__stringBundle;
    }

    acceptDialog() {
      return this._doButtonCommand("accept");
    }

    cancelDialog() {
      return this._doButtonCommand("cancel");
    }

    getButton(aDlgType) {
      return this._buttons[aDlgType];
    }

    get buttonBox() {
      return this.shadowRoot.querySelector(".dialog-button-box");
    }

    // NOTE(emilio): This has to match AppWindow::IntrinsicallySizeShell, to
    // prevent flickering, see bug 1799394.
    _sizeToPreferredSize() {
      const docEl = document.documentElement;
      const prefWidth = (() => {
        if (docEl.hasAttribute("width")) {
          return parseInt(docEl.getAttribute("width"));
        }
        let prefWidthProp = docEl.getAttribute("prefwidth");
        if (prefWidthProp) {
          let minWidth = parseFloat(
            getComputedStyle(docEl).getPropertyValue(prefWidthProp)
          );
          if (isFinite(minWidth)) {
            return minWidth;
          }
        }
        return 0;
      })();
      window.sizeToContent({ prefWidth });
    }

    moveToAlertPosition() {
      // hack. we need this so the window has something like its final size
      if (window.outerWidth == 1) {
        dump(
          "Trying to position a sizeless window; caller should have called sizeToContent() or sizeTo(). See bug 75649.\n"
        );
        this._sizeToPreferredSize();
      }

      if (opener) {
        var xOffset = (opener.outerWidth - window.outerWidth) / 2;
        var yOffset = opener.outerHeight / 5;

        var newX = opener.screenX + xOffset;
        var newY = opener.screenY + yOffset;
      } else {
        newX = (screen.availWidth - window.outerWidth) / 2;
        newY = (screen.availHeight - window.outerHeight) / 2;
      }

      // ensure the window is fully onscreen (if smaller than the screen)
      if (newX < screen.availLeft) {
        newX = screen.availLeft + 20;
      }
      if (newX + window.outerWidth > screen.availLeft + screen.availWidth) {
        newX = screen.availLeft + screen.availWidth - window.outerWidth - 20;
      }

      if (newY < screen.availTop) {
        newY = screen.availTop + 20;
      }
      if (newY + window.outerHeight > screen.availTop + screen.availHeight) {
        newY = screen.availTop + screen.availHeight - window.outerHeight - 60;
      }

      window.moveTo(newX, newY);
    }

    centerWindowOnScreen() {
      var xOffset = screen.availWidth / 2 - window.outerWidth / 2;
      var yOffset = screen.availHeight / 2 - window.outerHeight / 2;

      xOffset = xOffset > 0 ? xOffset : 0;
      yOffset = yOffset > 0 ? yOffset : 0;
      window.moveTo(xOffset, yOffset);
    }

    // Give focus to the first focusable element in the dialog
    _setInitialFocusIfNeeded() {
      let focusedElt = document.commandDispatcher.focusedElement;
      if (focusedElt) {
        return;
      }

      const defaultButton = this.getButton(this.defaultButton);
      Services.focus.moveFocus(
        window,
        null,
        Services.focus.MOVEFOCUS_FORWARD,
        Services.focus.FLAG_NOPARENTFRAME
      );

      focusedElt = document.commandDispatcher.focusedElement;
      if (!focusedElt) {
        return; // No focusable element?
      }

      let firstFocusedElt = focusedElt;
      while (
        focusedElt.localName == "tab" ||
        focusedElt.getAttribute("noinitialfocus") == "true"
      ) {
        Services.focus.moveFocus(
          window,
          focusedElt,
          Services.focus.MOVEFOCUS_FORWARD,
          Services.focus.FLAG_NOPARENTFRAME
        );
        focusedElt = document.commandDispatcher.focusedElement;
        if (focusedElt == firstFocusedElt) {
          if (focusedElt.getAttribute("noinitialfocus") == "true") {
            focusedElt.blur();
          }
          // Didn't find anything else to focus, we're done.
          return;
        }
      }

      if (firstFocusedElt.localName == "tab") {
        if (focusedElt.hasAttribute("dlgtype")) {
          // We don't want to focus on anonymous OK, Cancel, etc. buttons,
          // so return focus to the tab itself
          firstFocusedElt.focus();
        }
      } else if (
        AppConstants.platform != "macosx" &&
        focusedElt.hasAttribute("dlgtype") &&
        focusedElt != defaultButton
      ) {
        defaultButton.focus();
        if (document.commandDispatcher.focusedElement != defaultButton) {
          // If the default button is not focusable, then return focus to the
          // initial element if possible, or blur otherwise.
          if (firstFocusedElt.getAttribute("noinitialfocus") == "true") {
            focusedElt.blur();
          } else {
            firstFocusedElt.focus();
          }
        }
      }
    }

    async _postLoadInit() {
      this._setInitialFocusIfNeeded();
      let finalStep = () => {
        this._sizeToPreferredSize();
        this._snapCursorToDefaultButtonIfNeeded();
      };
      // As a hack to ensure Windows sizes the window correctly,
      // _sizeToPreferredSize() needs to happen after
      // AppWindow::OnChromeLoaded. That one is called right after the load
      // event dispatch but within the same task. Using direct dispatch let's
      // all this code run before the next task (which might be a task to
      // paint the window).
      // But, MacOS doesn't like resizing after window/dialog becoming visible.
      // Linux seems to be able to handle both cases.
      if (Services.appinfo.OS == "Darwin") {
        finalStep();
      } else {
        Services.tm.dispatchDirectTaskToCurrentThread(finalStep);
      }
    }

    // This snaps the cursor to the default button rect on windows, when
    // SPI_GETSNAPTODEFBUTTON is set.
    async _snapCursorToDefaultButtonIfNeeded() {
      const defaultButton = this.getButton(this.defaultButton);
      if (!defaultButton) {
        return;
      }
      try {
        // FIXME(emilio, bug 1797624): This setTimeout() ensures enough time
        // has passed so that the dialog vertical margin has been set by the
        // front-end. For subdialogs, cursor positioning should probably be
        // done by the opener instead, once the dialog is positioned.
        await new Promise(r => setTimeout(r, 0));
        await window.promiseDocumentFlushed(() => {});
        window.notifyDefaultButtonLoaded(defaultButton);
      } catch (e) {}
    }

    _configureButtons(aButtons) {
      // by default, get all the anonymous button elements
      var buttons = {};
      this._buttons = buttons;

      for (let type of ["accept", "cancel", "extra1", "extra2", "disclosure"]) {
        buttons[type] = this.shadowRoot.querySelector(`[dlgtype="${type}"]`);
      }

      // look for any overriding explicit button elements
      var exBtns = this.getElementsByAttribute("dlgtype", "*");
      var dlgtype;
      for (let i = 0; i < exBtns.length; ++i) {
        dlgtype = exBtns[i].getAttribute("dlgtype");
        buttons[dlgtype].hidden = true; // hide the anonymous button
        buttons[dlgtype] = exBtns[i];
      }

      // add the label and oncommand handler to each button
      for (dlgtype in buttons) {
        var button = buttons[dlgtype];
        button.addEventListener(
          "command",
          this._handleButtonCommand.bind(this),
          true
        );

        // don't override custom labels with pre-defined labels on explicit buttons
        if (!button.hasAttribute("label")) {
          // dialog attributes override the default labels in dialog.properties
          if (this.hasAttribute("buttonlabel" + dlgtype)) {
            button.setAttribute(
              "label",
              this.getAttribute("buttonlabel" + dlgtype)
            );
            if (this.hasAttribute("buttonaccesskey" + dlgtype)) {
              button.setAttribute(
                "accesskey",
                this.getAttribute("buttonaccesskey" + dlgtype)
              );
            }
          } else if (this.hasAttribute("buttonid" + dlgtype)) {
            document.l10n.setAttributes(
              button,
              this.getAttribute("buttonid" + dlgtype)
            );
          } else if (dlgtype != "extra1" && dlgtype != "extra2") {
            button.setAttribute(
              "label",
              this._strBundle.GetStringFromName("button-" + dlgtype)
            );
            var accessKey = this._strBundle.GetStringFromName(
              "accesskey-" + dlgtype
            );
            if (accessKey) {
              button.setAttribute("accesskey", accessKey);
            }
          }
        }
      }

      // ensure that hitting enter triggers the default button command
      // eslint-disable-next-line no-self-assign
      this.defaultButton = this.defaultButton;

      // if there is a special button configuration, use it
      if (aButtons) {
        // expect a comma delimited list of dlgtype values
        var list = aButtons.split(",");

        // mark shown dlgtypes as true
        var shown = {
          accept: false,
          cancel: false,
          disclosure: false,
          extra1: false,
          extra2: false,
        };
        for (let i = 0; i < list.length; ++i) {
          shown[list[i].replace(/ /g, "")] = true;
        }

        // hide/show the buttons we want
        for (dlgtype in buttons) {
          buttons[dlgtype].hidden = !shown[dlgtype];
        }

        // show the spacer on Windows only when the extra2 button is present
        if (AppConstants.platform == "win") {
          let spacer = this.shadowRoot.querySelector(".button-spacer");
          spacer.removeAttribute("hidden");
          spacer.setAttribute("flex", shown.extra2 ? "1" : "0");
        }
      }
    }

    _setDefaultButton(aNewDefault) {
      // remove the default attribute from the previous default button, if any
      var oldDefaultButton = this.getButton(this.defaultButton);
      if (oldDefaultButton) {
        oldDefaultButton.removeAttribute("default");
        this.#setButtonIsDestructive(oldDefaultButton, false);
      }

      var newDefaultButton = this.getButton(aNewDefault);
      if (newDefaultButton) {
        this.setAttribute("defaultButton", aNewDefault);
        newDefaultButton.setAttribute("default", "true");
        this.#setButtonIsDestructive(
          newDefaultButton,
          this.hasAttribute("default-is-destructive")
        );
      } else {
        this.setAttribute("defaultButton", "none");
        if (aNewDefault != "none") {
          dump(
            "invalid new default button: " + aNewDefault + ", assuming: none\n"
          );
        }
      }
    }

    /**
     * Mark or un-mark a button as a destructive action.
     *
     * @param {?Element} button - The button to mark.
     * @param {boolean} isDestructive - Whether the button is destructive.
     */
    #setButtonIsDestructive(button, isDestructive) {
      button?.classList.toggle("danger-button", isDestructive);
    }

    _handleButtonCommand(aEvent) {
      return this._doButtonCommand(aEvent.target.getAttribute("dlgtype"));
    }

    _doButtonCommand(aDlgType) {
      var button = this.getButton(aDlgType);
      if (!button.disabled) {
        var noCancel = this._fireButtonEvent(aDlgType);
        if (noCancel) {
          if (aDlgType == "accept" || aDlgType == "cancel") {
            var closingEvent = new CustomEvent("dialogclosing", {
              bubbles: true,
              detail: { button: aDlgType },
            });
            this.dispatchEvent(closingEvent);
            window.close();
          }
        }
        return noCancel;
      }
      return true;
    }

    _fireButtonEvent(aDlgType) {
      var event = document.createEvent("Events");
      event.initEvent("dialog" + aDlgType, true, true);

      // handle dom event handlers
      return this.dispatchEvent(event);
    }

    _hitEnter(evt) {
      if (evt.defaultPrevented) {
        return;
      }

      var btn = this.getButton(this.defaultButton);
      if (btn && !btn.hidden) {
        this._doButtonCommand(this.defaultButton);
      }
    }

    on_focus(event) {
      let btn = this.getButton(this.defaultButton);
      if (btn) {
        btn.setAttribute(
          "default",
          event.originalTarget == btn ||
            !(
              event.originalTarget.localName == "button" ||
              event.originalTarget.localName == "toolbarbutton"
            )
        );
      }
    }
  }

  customElements.define("dialog", MozDialog);
}
