/* 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 chrome windows with the subscript loader. If you need to
// define globals, wrap in a block to prevent leaking onto `window`.
{
  MozElements.NotificationBox = class NotificationBox {
    /**
     * Creates a new class to handle a notification box, but does not add any
     * elements to the DOM until a notification has to be displayed.
     *
     * @param insertElementFn
     *        Called with the "notification-stack" element as an argument when the
     *        first notification has to be displayed.
     */
    constructor(insertElementFn) {
      this._insertElementFn = insertElementFn;
      this._animating = false;
      this.currentNotification = null;
    }

    get stack() {
      if (!this._stack) {
        let stack = document.createXULElement("vbox");
        stack._notificationBox = this;
        stack.className = "notificationbox-stack";
        stack.addEventListener("transitionend", event => {
          if (
            event.target.localName == "notification-message" &&
            event.propertyName == "margin-top"
          ) {
            this._finishAnimation();
          }
        });
        this._stack = stack;
        this._insertElementFn(stack);
      }
      return this._stack;
    }

    get _allowAnimation() {
      return window.matchMedia("(prefers-reduced-motion: no-preference)")
        .matches;
    }

    get allNotifications() {
      // Don't create any DOM if no new notification has been added yet.
      if (!this._stack) {
        return [];
      }

      var closedNotification = this._closedNotification;
      var notifications = [
        ...this.stack.getElementsByTagName("notification-message"),
      ];
      return notifications.filter(n => n != closedNotification);
    }

    getNotificationWithValue(aValue) {
      var notifications = this.allNotifications;
      for (var n = notifications.length - 1; n >= 0; n--) {
        if (aValue == notifications[n].getAttribute("value")) {
          return notifications[n];
        }
      }
      return null;
    }

    /**
     * Creates a <notification-message> element and shows it. The calling code can modify
     * the element asynchronously to add features to the notification.
     *
     * aType
     *        String identifier that can uniquely identify the type of the notification.
     * aNotification
     *        Object that contains any of the following properties, where only the
     *        priority must be specified:
     *    priority
     *        One of the PRIORITY_ constants. These determine the appearance of
     *        the notification based on severity (using the "type" attribute), and
     *        only the notification with the highest priority is displayed.
     *    label
     *        The main message text (as string), or object (with l10n-id, l10n-args),
     *        or a DocumentFragment containing elements to
     *        add as children of the notification's main <description> element.
     *    eventCallback
     *        This may be called with the "removed", "dismissed" or "disconnected"
     *        parameter:
     *          removed - notification has been removed
     *          dismissed - user dismissed notification
     *          disconnected - notification removed in any way
     * aButtons
     *        Array of objects defining action buttons:
     *        {
     *          label:
     *            Label of the <button> element.
     *          accessKey:
     *            Access key character for the <button> element.
     *          "l10n-id"
     *            Localization id for the <button>, to be used instead of
     *            specifying a separate label and access key.
     *          callback:
     *            When the button is used, this is called with the arguments:
     *             1. The <notification-message> element.
     *             2. This button object definition.
     *             3. The <button> element.
     *             4. The "command" event.
     *            If the callback returns false, the notification is closed.
     *          link:
     *             A url to open when the button is clicked. The button is
     *             rendered like a link. The callback is called as well.
     *          supportPage:
     *            Used for a support page link. If no other properties are specified,
     *            defaults to a link with a 'Learn more' label.
     *          popup:
     *            If specified, the button will open the popup element with this
     *            ID, anchored to the button. This is alternative to "callback".
     *          telemetry:
     *            Specifies the key to add for the telemetry to trigger when the
     *            button is pressed. If not specified, then 'action' is used for
     *            a press on any button. Specify this only if you want to distinguish
     *            which button has been pressed in telemetry data.
     *          is:
     *            Defines a Custom Element name to use as the "is" value on
     *            button creation.
     *        }
     *
     * @returns {Promise<Object>} The <notification-message> element that is shown.
     */
    async appendNotification(aType, aNotification, aButtons) {
      if (
        aNotification.priority < this.PRIORITY_SYSTEM ||
        aNotification.priority > this.PRIORITY_CRITICAL_HIGH
      ) {
        throw new Error(
          "Invalid notification priority " + aNotification.priority
        );
      }

      MozXULElement.insertFTLIfNeeded("toolkit/global/notification.ftl");

      // Create the Custom Element and connect it to the document immediately.
      let newitem;
      if (!customElements.get("notification-message")) {
        // There's some weird timing stuff when this element is created at
        // script load time, we don't need it until now anyway so be lazy.
        // Wrapped in a try/catch to handle rare cases where we start creating
        // a notification but then the window gets closed/goes away.
        try {
          await createNotificationMessageElement();
        } catch (err) {
          console.warn(err);
          throw err;
        }
      }
      newitem = document.createElement("notification-message");
      newitem.setAttribute("message-bar-type", "infobar");

      // Append or prepend notification, based on stack preference.
      if (this.stack.hasAttribute("prepend-notifications")) {
        this.stack.prepend(newitem);
      } else {
        this.stack.append(newitem);
      }

      if (aNotification.label) {
        newitem.label = aNotification.label;
      } else if (newitem.messageText) {
        // Can't use instanceof in case this was created from a different document:
        if (
          aNotification.label &&
          typeof aNotification.label == "object" &&
          aNotification.label.nodeType &&
          aNotification.label.nodeType ==
            aNotification.label.DOCUMENT_FRAGMENT_NODE
        ) {
          newitem.messageText.appendChild(aNotification.label);
        } else if (
          aNotification.label &&
          typeof aNotification.label == "object" &&
          "l10n-id" in aNotification.label
        ) {
          let message = document.createElement("span");
          document.l10n.setAttributes(
            message,
            aNotification.label["l10n-id"],
            aNotification.label["l10n-args"]
          );
          newitem.messageText.appendChild(message);
        } else {
          newitem.messageText.textContent = aNotification.label;
        }
      }
      newitem.setAttribute("value", aType);

      newitem.eventCallback = aNotification.eventCallback;

      if (aButtons) {
        newitem.setButtons(aButtons);
      }

      newitem.priority = aNotification.priority;
      if (aNotification.priority == this.PRIORITY_SYSTEM) {
        newitem.setAttribute("type", "system");
      } else if (aNotification.priority >= this.PRIORITY_CRITICAL_LOW) {
        newitem.setAttribute("type", "critical");
      } else if (aNotification.priority <= this.PRIORITY_INFO_HIGH) {
        newitem.setAttribute("type", "info");
      } else {
        newitem.setAttribute("type", "warning");
      }

      // Animate the notification.
      newitem.style.display = "block";
      newitem.style.position = "fixed";
      newitem.style.top = "100%";
      newitem.style.marginTop = "-15px";
      newitem.style.opacity = "0";

      // Ensure the DOM has been created for the Lit-based notification-message
      // element so that we add the .animated class + it animates as expected.
      await newitem.updateComplete;
      this._showNotification(newitem, true);

      // Fire event for accessibility APIs
      var event = document.createEvent("Events");
      event.initEvent("AlertActive", true, true);
      newitem.dispatchEvent(event);

      return newitem;
    }

    removeNotification(aItem, aSkipAnimation) {
      if (!aItem.parentNode) {
        return;
      }
      this.currentNotification = aItem;
      this.removeCurrentNotification(aSkipAnimation);
    }

    _removeNotificationElement(aChild) {
      let hadFocus = aChild.matches(":focus-within");

      if (aChild.eventCallback) {
        aChild.eventCallback("removed");
      }
      aChild.remove();

      // Make sure focus doesn't get lost (workaround for bug 570835).
      if (hadFocus) {
        Services.focus.moveFocus(
          window,
          this.stack,
          Services.focus.MOVEFOCUS_FORWARD,
          0
        );
      }
    }

    removeCurrentNotification(aSkipAnimation) {
      this._showNotification(this.currentNotification, false, aSkipAnimation);
    }

    removeAllNotifications(aImmediate) {
      var notifications = this.allNotifications;
      for (var n = notifications.length - 1; n >= 0; n--) {
        if (aImmediate) {
          this._removeNotificationElement(notifications[n]);
        } else {
          this.removeNotification(notifications[n]);
        }
      }
      this.currentNotification = null;

      // Clean up any currently-animating notification; this is necessary
      // if a notification was just opened and is still animating, but we
      // want to close it *without* animating.  This can even happen if
      // animations get disabled (via prefers-reduced-motion) and this method
      // is called immediately after an animated notification was displayed
      // (although this case isn't very likely).
      if (aImmediate || !this._allowAnimation) {
        this._finishAnimation();
      }
    }

    removeTransientNotifications() {
      var notifications = this.allNotifications;
      for (var n = notifications.length - 1; n >= 0; n--) {
        var notification = notifications[n];
        if (notification.persistence) {
          notification.persistence--;
        } else if (Date.now() > notification.timeout) {
          this.removeNotification(notification, true);
        }
      }
    }

    _showNotification(aNotification, aSlideIn, aSkipAnimation) {
      this._finishAnimation();

      let { marginTop, marginBottom } = getComputedStyle(aNotification);
      let baseHeight = aNotification.getBoundingClientRect().height;
      var height =
        baseHeight + parseInt(marginTop, 10) + parseInt(marginBottom, 10);
      var skipAnimation =
        aSkipAnimation || baseHeight == 0 || !this._allowAnimation;
      aNotification.classList.toggle("animated", !skipAnimation);

      if (aSlideIn) {
        this.currentNotification = aNotification;
        aNotification.style.removeProperty("display");
        aNotification.style.removeProperty("position");
        aNotification.style.removeProperty("top");
        aNotification.style.removeProperty("margin-top");
        aNotification.style.removeProperty("opacity");

        if (skipAnimation) {
          return;
        }
      } else {
        this._closedNotification = aNotification;
        var notifications = this.allNotifications;
        var idx = notifications.length - 1;
        this.currentNotification = idx >= 0 ? notifications[idx] : null;

        if (skipAnimation) {
          this._removeNotificationElement(this._closedNotification);
          delete this._closedNotification;
          return;
        }

        aNotification.style.marginTop = -height + "px";
        aNotification.style.opacity = 0;
      }

      this._animating = true;
    }

    _finishAnimation() {
      if (this._animating) {
        this._animating = false;
        if (this._closedNotification) {
          this._removeNotificationElement(this._closedNotification);
          delete this._closedNotification;
        }
      }
    }
  };

  // These are defined on the instance prototype for backwards compatibility.
  Object.assign(MozElements.NotificationBox.prototype, {
    PRIORITY_SYSTEM: 0,
    PRIORITY_INFO_LOW: 1,
    PRIORITY_INFO_MEDIUM: 2,
    PRIORITY_INFO_HIGH: 3,
    PRIORITY_WARNING_LOW: 4,
    PRIORITY_WARNING_MEDIUM: 5,
    PRIORITY_WARNING_HIGH: 6,
    PRIORITY_CRITICAL_LOW: 7,
    PRIORITY_CRITICAL_MEDIUM: 8,
    PRIORITY_CRITICAL_HIGH: 9,
  });

  async function createNotificationMessageElement() {
    document.createElement("moz-message-bar");
    let MozMessageBar = await customElements.whenDefined("moz-message-bar");
    class NotificationMessage extends MozMessageBar {
      static queries = {
        ...MozMessageBar.queries,
        messageText: ".message",
        messageImage: ".icon",
      };

      constructor() {
        super();
        this.persistence = 0;
        this.priority = 0;
        this.timeout = 0;
        this.dismissable = true;

        this.addEventListener("click", this);
        this.addEventListener("command", this);
      }

      connectedCallback() {
        super.connectedCallback();
        this.#setStyles();

        this.classList.add("infobar");
        this.setAlertRole();

        this.buttonContainer = document.createElement("span");
        this.buttonContainer.classList.add("notification-button-container");
        this.buttonContainer.setAttribute("slot", "actions");
        this.appendChild(this.buttonContainer);
      }

      disconnectedCallback() {
        super.disconnectedCallback();
        if (this.eventCallback) {
          this.eventCallback("disconnected");
        }
      }

      closeButtonTemplate() {
        return super.closeButtonTemplate({ size: "small" });
      }

      #setStyles() {
        let style = document.createElement("link");
        style.rel = "stylesheet";
        style.href = "chrome://global/content/elements/infobar.css";
        this.renderRoot.append(style);
      }

      get control() {
        return this.closest(".notificationbox-stack")._notificationBox;
      }

      close() {
        if (!this.parentNode) {
          return;
        }
        this.control.removeNotification(this);
      }

      setAlertRole() {
        // Wait a little for this to render before setting the role for more
        // consistent alerts to screen readers.
        this.removeAttribute("role");
        window.requestAnimationFrame(() => {
          window.requestAnimationFrame(() => {
            this.setAttribute("role", "alert");
          });
        });
      }

      handleEvent(e) {
        if (e.type == "click" && e.target.localName != "label") {
          return;
        }

        if ("buttonInfo" in e.target) {
          let { buttonInfo } = e.target;
          let { callback, popup } = buttonInfo;

          if (popup) {
            document
              .getElementById(popup)
              .openPopup(
                e.originalTarget,
                "after_start",
                0,
                0,
                false,
                false,
                e
              );
            e.stopPropagation();
          } else if (callback) {
            if (!callback(this, buttonInfo, e.target, e)) {
              this.close();
            }
            e.stopPropagation();
          }
        }
      }

      /**
       * Changes the text of an existing notification. If the notification was
       * created with a custom fragment, it will be overwritten with plain text
       * or a localized message.
       *
       * @param {string | { "l10n-id": string, "l10n-args"?: string }} value
       */
      set label(value) {
        if (value && typeof value == "object" && "l10n-id" in value) {
          this.messageL10nId = value["l10n-id"];
          this.messageL10nArgs = value["l10n-args"];
        } else {
          this.message = value;
        }
        this.setAlertRole();
      }

      setButtons(buttons) {
        this._buttons = buttons;
        for (let button of buttons) {
          let link = button.link || button.supportPage;
          let localeId = button["l10n-id"];

          let buttonElem;
          if (button.hasOwnProperty("supportPage")) {
            buttonElem = document.createElement("a", {
              is: "moz-support-link",
            });
            buttonElem.classList.add("notification-link");
            buttonElem.setAttribute("support-page", button.supportPage);
          } else if (link) {
            buttonElem = document.createXULElement("label", {
              is: "text-link",
            });
            buttonElem.setAttribute("href", link);
            buttonElem.classList.add("notification-link", "text-link");
          } else {
            buttonElem = document.createXULElement(
              "button",
              button.is ? { is: button.is } : {}
            );
            buttonElem.classList.add(
              "notification-button",
              "small-button",
              "footer-button"
            );

            if (button.primary) {
              buttonElem.classList.add("primary");
            }
          }

          if (localeId) {
            document.l10n.setAttributes(buttonElem, localeId);
          } else {
            buttonElem.setAttribute(link ? "value" : "label", button.label);
            if (typeof button.accessKey == "string") {
              buttonElem.setAttribute("accesskey", button.accessKey);
            }
          }

          if (link) {
            buttonElem.setAttribute("slot", "support-link");
            this.appendChild(buttonElem);
          } else {
            this.buttonContainer.appendChild(buttonElem);
          }
          buttonElem.buttonInfo = button;
        }
      }

      dismiss() {
        if (this.eventCallback) {
          this.eventCallback("dismissed");
        }
        super.dismiss();
      }
    }
    if (!customElements.get("notification-message")) {
      customElements.define("notification-message", NotificationMessage);
    }
  }
}
