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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  PageEventManager: "resource://activity-stream/lib/PageEventManager.sys.mjs",
});

XPCOMUtils.defineLazyModuleGetters(lazy, {
  AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.jsm",
  ASRouter: "resource://activity-stream/lib/ASRouter.jsm",
});

const TRANSITION_MS = 500;
const CONTAINER_ID = "multi-stage-message-root";
const BUNDLE_SRC =
  "resource://activity-stream/aboutwelcome/aboutwelcome.bundle.js";

/**
 * Feature Callout fetches messages relevant to a given source and displays them
 * in the parent page pointing to the element they describe.
 */
export class FeatureCallout {
  /**
   * @typedef {Object} FeatureCalloutOptions
   * @property {Window} win window in which messages will be rendered
   * @property {String} prefName name of the pref used to track progress through
   *   a given feature tour, e.g. "browser.pdfjs.feature-tour"
   * @property {String} [page] string to pass as the page when requesting
   *   messages from ASRouter and sending telemetry. for browser chrome, the
   *   string "chrome" is used
   * @property {MozBrowser} [browser] <browser> element responsible for the
   *   feature callout. for content pages, this is the browser element that the
   *   callout is being shown in. for chrome, this is the active browser
   * @property {FeatureCalloutTheme} [theme] @see FeatureCallout.themePresets
   */

  /** @param {FeatureCalloutOptions} options */
  constructor({ win, prefName, page, browser, theme = {} } = {}) {
    this.win = win;
    this.doc = win.document;
    this.browser = browser || this.win.docShell.chromeEventHandler;
    this.config = null;
    this.loadingConfig = false;
    this.message = null;
    this.currentScreen = null;
    this.renderObserver = null;
    this.savedActiveElement = null;
    this.ready = false;
    this._positionListenersRegistered = false;
    this.AWSetup = false;
    this.page = page;
    this._initTheme(theme);

    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "featureTourProgress",
      prefName,
      '{"screen":"","complete":true}',
      this._handlePrefChange.bind(this),
      val => {
        try {
          return JSON.parse(val);
        } catch (error) {
          return null;
        }
      }
    );

    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "cfrFeaturesUserPref",
      "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
      true,
      function (pref, previous, latest) {
        if (latest) {
          this.showFeatureCallout();
        } else {
          this._handlePrefChange();
        }
      }.bind(this)
    );
    this.featureTourProgress; // Load initial value of progress pref

    // When the window is focused, ensure tour is synced with tours in any other
    // instances of the parent page. This does not apply when the Callout is
    // shown in the browser chrome.
    if (this.page !== "chrome") {
      this.win.addEventListener("visibilitychange", this);
    }
  }

  /**
   * Get the page event manager and instantiate it if necessary. Only used by
   * _attachPageEventListeners, since we don't want to do this unnecessary work
   * if a message with page event listeners hasn't loaded. Other consumers
   * should use `this._pageEventManager?.property` instead.
   */
  get _loadPageEventManager() {
    if (!this._pageEventManager) {
      this._pageEventManager = new lazy.PageEventManager(this.doc);
    }
    return this._pageEventManager;
  }

  _addPositionListeners() {
    if (!this._positionListenersRegistered) {
      this.win.addEventListener("resize", this);
      const parentEl = this.doc.querySelector(
        this.currentScreen?.parent_selector
      );
      parentEl?.addEventListener("toggle", this);
      this._positionListenersRegistered = true;
    }
  }

  _removePositionListeners() {
    if (this._positionListenersRegistered) {
      this.win.removeEventListener("resize", this);
      const parentEl = this.doc.querySelector(
        this.currentScreen?.parent_selector
      );
      parentEl?.removeEventListener("toggle", this);
      this._positionListenersRegistered = false;
    }
  }

  async _handlePrefChange() {
    if (this.doc.visibilityState === "hidden" || !this.featureTourProgress) {
      return;
    }

    // If we have more than one screen, it means that we're
    // displaying a feature tour, and transitions are handled
    // based on the value of a tour progress pref. Otherwise,
    // just show the feature callout.
    if (this.config?.screens.length === 1) {
      this.showFeatureCallout();
      return;
    }

    // If a pref change results from an event in a Spotlight message,
    // reload the page to clear the Spotlight and initialize the
    // feature callout with the next message in the tour.
    if (this.currentScreen == "spotlight") {
      this.win.location.reload();
      return;
    }

    let prefVal = this.featureTourProgress;
    // End the tour according to the tour progress pref or if the user disabled
    // contextual feature recommendations.
    if (prefVal.complete || !this.cfrFeaturesUserPref) {
      this.endTour();
      this.currentScreen = null;
    } else if (prefVal.screen !== this.currentScreen?.id) {
      this.ready = false;
      this._container?.classList.add("hidden");
      this._pageEventManager?.clear();
      // wait for fade out transition
      this.win.setTimeout(async () => {
        await this._loadConfig();
        this._container?.remove();
        this._removePositionListeners();
        this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
        await this._renderCallout();
      }, TRANSITION_MS);
    }
  }

  handleEvent(event) {
    switch (event.type) {
      case "focus": {
        if (!this._container) {
          return;
        }
        // If focus has fired on the feature callout window itself, or on something
        // contained in that window, ignore it, as we can't possibly place the focus
        // on it after the callout is closd.
        if (
          event.target === this._container ||
          (Node.isInstance(event.target) &&
            this._container.contains(event.target))
        ) {
          return;
        }
        // Save this so that if the next focus event is re-entering the popup,
        // then we'll put the focus back here where the user left it once we exit
        // the feature callout series.
        this.savedActiveElement = this.doc.activeElement;
        break;
      }

      case "keypress": {
        if (event.key !== "Escape") {
          return;
        }
        if (!this._container) {
          return;
        }
        let focusedElement =
          this.page === "chrome"
            ? Services.focus.focusedElement
            : this.doc.activeElement;
        // If the window has a focused element, let it handle the ESC key instead.
        if (
          !focusedElement ||
          focusedElement === this.doc.body ||
          focusedElement === this.browser ||
          this._container.contains(focusedElement)
        ) {
          this.win.AWSendEventTelemetry?.({
            event: "DISMISS",
            event_context: {
              source: `KEY_${event.key}`,
              page: this.page,
            },
            message_id: this.config?.id.toUpperCase(),
          });
          this._dismiss();
          event.preventDefault();
        }
        break;
      }

      case "visibilitychange":
        this._handlePrefChange();
        break;

      case "resize":
      case "toggle":
        this._positionCallout();
        break;

      default:
    }
  }

  _addCalloutLinkElements() {
    const addStylesheet = href => {
      if (this.doc.querySelector(`link[href="${href}"]`)) {
        return;
      }
      const link = this.doc.head.appendChild(this.doc.createElement("link"));
      link.rel = "stylesheet";
      link.href = href;
    };
    const addLocalization = hrefs => {
      hrefs.forEach(href => {
        // eslint-disable-next-line no-undef
        this.win.MozXULElement.insertFTLIfNeeded(href);
      });
    };

    // Update styling to be compatible with about:welcome bundle
    addStylesheet(
      "chrome://activity-stream/content/aboutwelcome/aboutwelcome.css"
    );

    addLocalization([
      "browser/newtab/onboarding.ftl",
      "browser/spotlight.ftl",
      "branding/brand.ftl",
      "toolkit/branding/brandings.ftl",
      "browser/newtab/asrouter.ftl",
      "browser/featureCallout.ftl",
    ]);
  }

  _createContainer() {
    let parent = this.doc.querySelector(this.currentScreen?.parent_selector);
    // Don't render the callout if the parent element is not present.
    // This means the message was misconfigured, mistargeted, or the
    // content of the parent page is not as expected.
    if (!parent && !this.currentScreen?.content?.callout_position_override) {
      if (this.message?.template === "feature_callout") {
        Services.telemetry.recordEvent(
          "messaging_experiments",
          "feature_callout",
          "create_failed",
          `${this.message.id || "no_message"}-${
            this.currentScreen?.parent_selector || "no_current_screen"
          }`
        );
      }

      return false;
    }

    if (!this._container?.parentElement) {
      this._container = this.doc.createElement("div");
      this._container.classList.add(
        "onboardingContainer",
        "featureCallout",
        "callout-arrow",
        "hidden"
      );
      this._container.classList.toggle(
        "hidden-arrow",
        !!this.currentScreen?.content?.hide_arrow
      );
      this._container.id = CONTAINER_ID;
      // This value is reported as the "page" in about:welcome telemetry
      this._container.dataset.page = this.page;
      this._container.setAttribute(
        "aria-describedby",
        `#${CONTAINER_ID} .welcome-text`
      );
      this._container.tabIndex = 0;
      this._applyTheme();
      this.doc.body.prepend(this._container);
    }
    return this._container;
  }

  /**
   * Set callout's position relative to parent element
   */
  _positionCallout() {
    const container = this._container;
    const parentEl = this.doc.querySelector(
      this.currentScreen?.parent_selector
    );
    const doc = this.doc;
    // All possible arrow positions
    // If the position contains a dash, the value before the dash
    // refers to which edge of the feature callout the arrow points
    // from. The value after the dash describes where along that edge
    // the arrow sits, with middle as the default.
    const arrowPositions = [
      "top",
      "bottom",
      "end",
      "start",
      "top-end",
      "top-start",
    ];
    const arrowPosition = this.currentScreen?.content?.arrow_position || "top";
    // Callout should overlap the parent element by 17px (so the box, not
    // including the arrow, will overlap by 5px)
    const arrowWidth = 12;
    let overlap = 17;
    // If we have no overlap, we send the callout the same number of pixels
    // in the opposite direction
    overlap = this.currentScreen?.content?.noCalloutOverlap
      ? overlap * -1
      : overlap;
    overlap -= arrowWidth;
    // Is the document layout right to left?
    const RTL = this.doc.dir === "rtl";
    const customPosition =
      this.currentScreen?.content.callout_position_override;

    // Early exit if the container doesn't exist,
    // or if we're missing a parent element and don't have a custom callout position
    if (!container || (!parentEl && !customPosition)) {
      return;
    }

    const getOffset = el => {
      const rect = el.getBoundingClientRect();
      return {
        left: rect.left + this.win.scrollX,
        right: rect.right + this.win.scrollX,
        top: rect.top + this.win.scrollY,
        bottom: rect.bottom + this.win.scrollY,
      };
    };

    const clearPosition = () => {
      Object.keys(positioners).forEach(position => {
        container.style[position] = "unset";
      });
      arrowPositions.forEach(position => {
        if (container.classList.contains(`arrow-${position}`)) {
          container.classList.remove(`arrow-${position}`);
        }
        if (container.classList.contains(`arrow-inline-${position}`)) {
          container.classList.remove(`arrow-inline-${position}`);
        }
      });
    };

    const addArrowPositionClassToContainer = finalArrowPosition => {
      let className;
      switch (finalArrowPosition) {
        case "bottom":
          className = "arrow-bottom";
          break;
        case "left":
          className = "arrow-inline-start";
          break;
        case "right":
          className = "arrow-inline-end";
          break;
        case "top-start":
          className = RTL ? "arrow-top-end" : "arrow-top-start";
          break;
        case "top-end":
          className = RTL ? "arrow-top-start" : "arrow-top-end";
          break;
        case "top":
        default:
          className = "arrow-top";
          break;
      }

      container.classList.add(className);
    };

    const addValueToPixelValue = (value, pixelValue) => {
      return `${Number(pixelValue.split("px")[0]) + value}px`;
    };

    const subtractPixelValueFromValue = (pixelValue, value) => {
      return `${value - Number(pixelValue.split("px")[0])}px`;
    };

    const overridePosition = () => {
      // We override _every_ positioner here, because we want to manually set all
      // container.style.positions in every positioner's "position" function
      // regardless of the actual arrow position
      // Note: We override the position functions with new functions here,
      // but they don't actually get executed until the respective position functions are called
      // and this function is not executed unless the message has a custom position property.

      // We're positioning relative to a parent element's bounds,
      // if that parent element exists.

      for (const position in positioners) {
        positioners[position].position = () => {
          if (customPosition.top) {
            container.style.top = addValueToPixelValue(
              parentEl.getBoundingClientRect().top,
              customPosition.top
            );
          }

          if (customPosition.left) {
            const leftPosition = addValueToPixelValue(
              parentEl.getBoundingClientRect().left,
              customPosition.left
            );

            RTL
              ? (container.style.right = leftPosition)
              : (container.style.left = leftPosition);
          }

          if (customPosition.right) {
            const rightPosition = subtractPixelValueFromValue(
              customPosition.right,
              parentEl.getBoundingClientRect().right - container.clientWidth
            );

            RTL
              ? (container.style.right = rightPosition)
              : (container.style.left = rightPosition);
          }

          if (customPosition.bottom) {
            container.style.top = subtractPixelValueFromValue(
              customPosition.bottom,
              parentEl.getBoundingClientRect().bottom - container.clientHeight
            );
          }
        };
      }
    };

    const positioners = {
      // availableSpace should be the space between the edge of the page in the assumed direction
      // and the edge of the parent (with the callout being intended to fit between those two edges)
      // while needed space should be the space necessary to fit the callout container
      top: {
        availableSpace() {
          return (
            doc.documentElement.clientHeight -
            getOffset(parentEl).top -
            parentEl.clientHeight
          );
        },
        neededSpace: container.clientHeight - overlap,
        position() {
          // Point to an element above the callout
          let containerTop =
            getOffset(parentEl).top + parentEl.clientHeight - overlap;
          container.style.top = `${Math.max(0, containerTop)}px`;
          alignHorizontally("center");
        },
      },
      bottom: {
        availableSpace() {
          return getOffset(parentEl).top;
        },
        neededSpace: container.clientHeight - overlap,
        position() {
          // Point to an element below the callout
          let containerTop =
            getOffset(parentEl).top - container.clientHeight + overlap;
          container.style.top = `${Math.max(0, containerTop)}px`;
          alignHorizontally("center");
        },
      },
      right: {
        availableSpace() {
          return getOffset(parentEl).left;
        },
        neededSpace: container.clientWidth - overlap,
        position() {
          // Point to an element to the right of the callout
          let containerLeft =
            getOffset(parentEl).left - container.clientWidth + overlap;
          container.style.left = `${Math.max(0, containerLeft)}px`;
          if (container.offsetHeight <= parentEl.offsetHeight) {
            container.style.top = `${getOffset(parentEl).top}px`;
          } else {
            centerVertically();
          }
        },
      },
      left: {
        availableSpace() {
          return doc.documentElement.clientWidth - getOffset(parentEl).right;
        },
        neededSpace: container.clientWidth - overlap,
        position() {
          // Point to an element to the left of the callout
          let containerLeft =
            getOffset(parentEl).left + parentEl.clientWidth - overlap;
          container.style.left = `${Math.max(0, containerLeft)}px`;
          if (container.offsetHeight <= parentEl.offsetHeight) {
            container.style.top = `${getOffset(parentEl).top}px`;
          } else {
            centerVertically();
          }
        },
      },
      "top-start": {
        availableSpace() {
          doc.documentElement.clientHeight -
            getOffset(parentEl).top -
            parentEl.clientHeight;
        },
        neededSpace: container.clientHeight - overlap,
        position() {
          // Point to an element above and at the start of the callout
          let containerTop =
            getOffset(parentEl).top + parentEl.clientHeight - overlap;
          container.style.top = `${Math.max(
            container.clientHeight - overlap,
            containerTop
          )}px`;
          alignHorizontally("start");
        },
      },
      "top-end": {
        availableSpace() {
          doc.documentElement.clientHeight -
            getOffset(parentEl).top -
            parentEl.clientHeight;
        },
        neededSpace: container.clientHeight - overlap,
        position() {
          // Point to an element above and at the end of the callout
          let containerTop =
            getOffset(parentEl).top + parentEl.clientHeight - overlap;
          container.style.top = `${Math.max(
            container.clientHeight - overlap,
            containerTop
          )}px`;
          alignHorizontally("end");
        },
      },
    };

    const calloutFits = position => {
      // Does callout element fit in this position relative
      // to the parent element without going off screen?

      // Only consider which edge of the callout the arrow points from,
      // not the alignment of the arrow along the edge of the callout
      let edgePosition = position.split("-")[0];
      return (
        positioners[edgePosition].availableSpace() >
        positioners[edgePosition].neededSpace
      );
    };

    const choosePosition = () => {
      let position = arrowPosition;
      if (!arrowPositions.includes(position)) {
        // Configured arrow position is not valid
        return false;
      }
      if (["start", "end"].includes(position)) {
        // position here is referencing the direction that the callout container
        // is pointing to, and therefore should be the _opposite_ side of the arrow
        // eg. if arrow is at the "end" in LTR layouts, the container is pointing
        // at an element to the right of itself, while in RTL layouts it is pointing to the left of itself
        position = RTL ^ (position === "start") ? "left" : "right";
      }
      // If we're overriding the position, we don't need to sort for available space
      if (customPosition || calloutFits(position)) {
        return position;
      }
      let sortedPositions = Object.keys(positioners)
        .filter(p => p !== position)
        .filter(calloutFits)
        .sort((a, b) => {
          return (
            positioners[b].availableSpace() - positioners[b].neededSpace >
            positioners[a].availableSpace() - positioners[a].neededSpace
          );
        });
      // If the callout doesn't fit in any position, use the configured one.
      // The callout will be adjusted to overlap the parent element so that
      // the former doesn't go off screen.
      return sortedPositions[0] || position;
    };

    const centerVertically = () => {
      let topOffset = (container.offsetHeight - parentEl.offsetHeight) / 2;
      container.style.top = `${getOffset(parentEl).top - topOffset}px`;
    };

    /**
     * Horizontally align a top/bottom-positioned callout according to the
     * passed position.
     * @param {String} [position = "start"] <"start"|"end"|"center">
     */
    const alignHorizontally = position => {
      switch (position) {
        case "center": {
          let sideOffset = (parentEl.clientWidth - container.clientWidth) / 2;
          let containerSide = RTL
            ? doc.documentElement.clientWidth -
              getOffset(parentEl).right +
              sideOffset
            : getOffset(parentEl).left + sideOffset;
          container.style[RTL ? "right" : "left"] = `${Math.max(
            containerSide,
            0
          )}px`;
          break;
        }
        default: {
          let containerSide =
            RTL ^ (position === "end")
              ? parentEl.getBoundingClientRect().left +
                parentEl.clientWidth -
                container.clientWidth
              : parentEl.getBoundingClientRect().left;
          container.style.left = `${Math.max(containerSide, 0)}px`;
          break;
        }
      }
    };

    clearPosition(container);

    if (customPosition) {
      overridePosition();
    }

    let finalPosition = choosePosition();
    if (finalPosition) {
      positioners[finalPosition].position();
      addArrowPositionClassToContainer(finalPosition);
    }

    container.classList.remove("hidden");
  }

  /** Expose top level functions expected by the aboutwelcome bundle. */
  _setupWindowFunctions() {
    if (this.AWSetup) {
      return;
    }
    const AWParent = new lazy.AboutWelcomeParent();
    this.win.addEventListener("unload", () => {
      AWParent.didDestroy();
    });
    const receive = name => data =>
      AWParent.onContentMessage(`AWPage:${name}`, data, this.doc);
    this.win.AWGetFeatureConfig = () => this.config;
    this.win.AWGetSelectedTheme = receive("GET_SELECTED_THEME");
    // Do not send telemetry if message config sets metrics as 'block'.
    if (this.config?.metrics !== "block") {
      this.win.AWSendEventTelemetry = receive("TELEMETRY_EVENT");
    }
    this.win.AWSendToDeviceEmailsSupported = receive(
      "SEND_TO_DEVICE_EMAILS_SUPPORTED"
    );
    this.win.AWSendToParent = (name, data) => receive(name)(data);
    this.win.AWFinish = () => {
      this.endTour();
    };
    this.win.AWEvaluateScreenTargeting = receive("EVALUATE_SCREEN_TARGETING");
    this.AWSetup = true;
  }

  /** Clean up the functions defined above. */
  _clearWindowFunctions() {
    const windowFuncs = [
      "AWGetFeatureConfig",
      "AWGetSelectedTheme",
      "AWSendEventTelemetry",
      "AWSendToDeviceEmailsSupported",
      "AWSendToParent",
      "AWFinish",
    ];
    windowFuncs.forEach(func => delete this.win[func]);
  }

  endTour(skipFadeOut = false) {
    // We don't want focus events that happen during teardown to affect
    // this.savedActiveElement
    this.win.removeEventListener("focus", this, {
      capture: true,
      passive: true,
    });
    this.win.removeEventListener("keypress", this, { capture: true });
    this._pageEventManager?.clear();

    // We're deleting featureTourProgress here to ensure that the
    // reference is freed for garbage collection. This prevents errors
    // caused by lingering instances when instantiating and removing
    // multiple feature tour instances in succession.
    delete this.featureTourProgress;
    this.ready = false;
    // wait for fade out transition
    this._container?.classList.add("hidden");
    this._clearWindowFunctions();
    this.win.setTimeout(
      () => {
        this._container?.remove();
        this.renderObserver?.disconnect();
        this._removePositionListeners();
        this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
        // Put the focus back to the last place the user focused outside of the
        // featureCallout windows.
        if (this.savedActiveElement) {
          this.savedActiveElement.focus({ focusVisible: true });
        }
      },
      skipFadeOut ? 0 : TRANSITION_MS
    );
  }

  _dismiss() {
    let action = this.currentScreen?.content.dismiss_button?.action;
    if (action?.type) {
      this.win.AWSendToParent("SPECIAL_ACTION", action);
    } else {
      this.endTour();
    }
  }

  async _addScriptsAndRender() {
    const reactSrc = "resource://activity-stream/vendor/react.js";
    const domSrc = "resource://activity-stream/vendor/react-dom.js";
    // Add React script
    const getReactReady = async () => {
      return new Promise(resolve => {
        let reactScript = this.doc.createElement("script");
        reactScript.src = reactSrc;
        this.doc.head.appendChild(reactScript);
        reactScript.addEventListener("load", resolve);
      });
    };
    // Add ReactDom script
    const getDomReady = async () => {
      return new Promise(resolve => {
        let domScript = this.doc.createElement("script");
        domScript.src = domSrc;
        this.doc.head.appendChild(domScript);
        domScript.addEventListener("load", resolve);
      });
    };
    // Load React, then React Dom
    if (!this.doc.querySelector(`[src="${reactSrc}"]`)) {
      await getReactReady();
    }
    if (!this.doc.querySelector(`[src="${domSrc}"]`)) {
      await getDomReady();
    }
    // Load the bundle to render the content as configured.
    this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
    let bundleScript = this.doc.createElement("script");
    bundleScript.src = BUNDLE_SRC;
    this.doc.head.appendChild(bundleScript);
  }

  _observeRender(container) {
    this.renderObserver?.observe(container, { childList: true });
  }

  /**
   * Request a message from ASRouter, targeting the `browser` and `page` values
   * passed to the constructor. The message content is stored in this.config,
   * which is returned by AWGetFeatureConfig. The aboutwelcome bundle will use
   * that function to get the content. It will only be called when the bundle
   * loads, so the bundle must be reloaded for a new message to be rendered.
   * @returns {Promise<boolean>} true if a message is loaded, false if not.
   */
  async _loadConfig() {
    if (this.loadingConfig) {
      return false;
    }
    this.loadingConfig = true;
    await lazy.ASRouter.waitForInitialized;
    let result = await lazy.ASRouter.sendTriggerMessage({
      browser: this.browser,
      // triggerId and triggerContext
      id: "featureCalloutCheck",
      context: { source: this.page },
    });
    this.message = result.message;
    this.loadingConfig = false;

    if (result.message.template !== "feature_callout") {
      // If another message type, like a Spotlight modal, is included
      // in the tour, save the template name as the current screen.
      this.currentScreen = result.message.template;
      return false;
    }

    this.config = result.message.content;

    let newScreen = this.config?.screens?.[this.config?.startScreen || 0];
    if (newScreen?.id === this.currentScreen?.id) {
      return false;
    }

    // Only add an impression if we actually have a message to impress
    if (Object.keys(result.message).length) {
      lazy.ASRouter.addImpression(result.message);
    }

    this.currentScreen = newScreen;
    return true;
  }

  async _renderCallout() {
    let container = this._createContainer();
    if (container) {
      // This results in rendering the Feature Callout
      await this._addScriptsAndRender();
      this._observeRender(container);
      this._addPositionListeners();
    }
  }

  /**
   * For each member of the screen's page_event_listeners array, add a listener.
   * @param {Array<PageEventListener>} listeners An array of listeners to set up
   *
   * @typedef {Object} PageEventListener
   * @property {PageEventListenerParams} params Event listener parameters
   * @property {PageEventListenerAction} action Sent when the event fires
   *
   * @typedef {Object} PageEventListenerParams See PageEventManager.sys.mjs
   * @property {String} type Event type string e.g. `click`
   * @property {String} selectors Target selector, e.g. `tag.class, #id[attr]`
   * @property {PageEventListenerOptions} [options] addEventListener options
   *
   * @typedef {Object} PageEventListenerOptions
   * @property {Boolean} [capture] Use event capturing phase?
   * @property {Boolean} [once] Remove listener after first event?
   * @property {Boolean} [preventDefault] Prevent default action?
   *
   * @typedef {Object} PageEventListenerAction Action sent to AboutWelcomeParent
   * @property {String} [type] Action type, e.g. `OPEN_URL`
   * @property {Object} [data] Extra data, properties depend on action type
   * @property {Boolean} [dismiss] Dismiss screen after performing action?
   */
  _attachPageEventListeners(listeners) {
    listeners?.forEach(({ params, action }) =>
      this._loadPageEventManager[params.options?.once ? "once" : "on"](
        params,
        event => {
          this._handlePageEventAction(action, event);
          if (params.options?.preventDefault) {
            event.preventDefault?.();
          }
        }
      )
    );
  }

  /**
   * Perform an action in response to a page event.
   * @param {PageEventListenerAction} action
   * @param {Event} event Triggering event
   */
  _handlePageEventAction(action, event) {
    const page = this.page;
    const message_id = this.config?.id.toUpperCase();
    const source = this._getUniqueElementIdentifier(event.target);
    this.win.AWSendEventTelemetry?.({
      event: "PAGE_EVENT",
      event_context: {
        action: action.type ?? (action.dismiss ? "DISMISS" : ""),
        reason: event.type?.toUpperCase(),
        source,
        page,
      },
      message_id,
    });
    if (action.type) {
      this.win.AWSendToParent("SPECIAL_ACTION", action);
    }
    if (action.dismiss) {
      this.win.AWSendEventTelemetry?.({
        event: "DISMISS",
        event_context: { source: `PAGE_EVENT:${source}`, page },
        message_id,
      });
      this._dismiss();
    }
  }

  /**
   * For a given element, calculate a unique string that identifies it.
   * @param {Element} target Element to calculate the selector for
   * @returns {String} Computed event target selector, e.g. `button#next`
   */
  _getUniqueElementIdentifier(target) {
    let source;
    if (Element.isInstance(target)) {
      source = target.localName;
      if (target.className) {
        source += `.${[...target.classList].join(".")}`;
      }
      if (target.id) {
        source += `#${target.id}`;
      }
      if (target.attributes.length) {
        source += `${[...target.attributes]
          .filter(attr => ["is", "role", "open"].includes(attr.name))
          .map(attr => `[${attr.name}="${attr.value}"]`)
          .join("")}`;
      }
      if (this.doc.querySelectorAll(source).length > 1) {
        let uniqueAncestor = target.closest(`[id]:not(:scope, :root, body)`);
        if (uniqueAncestor) {
          source = `${this._getUniqueElementIdentifier(
            uniqueAncestor
          )} > ${source}`;
        }
      }
    }
    return source;
  }

  async showFeatureCallout() {
    let updated = await this._loadConfig();

    if (!updated || !this.config?.screens?.length) {
      return;
    }

    this.renderObserver = new this.win.MutationObserver(() => {
      // Check if the Feature Callout screen has loaded for the first time
      if (!this.ready && this._container.querySelector(".screen")) {
        // Once the screen element is added to the DOM, wait for the
        // animation frame after next to ensure that _positionCallout
        // has access to the rendered screen with the correct height
        this.win.requestAnimationFrame(() => {
          this.win.requestAnimationFrame(() => {
            this.ready = true;
            this._attachPageEventListeners(
              this.currentScreen?.content?.page_event_listeners
            );
            this.win.addEventListener("keypress", this, { capture: true });
            this._positionCallout();
            let button = this._container.querySelector(".primary");
            button.focus();
            this.win.addEventListener("focus", this, {
              capture: true, // get the event before retargeting
              passive: true,
            });
          });
        });
      }
    });

    this._pageEventManager?.clear();
    this.ready = false;
    this._container?.remove();

    // If user has disabled CFR, don't show any callouts. But make sure we load
    // the necessary stylesheets first, since re-enabling CFR should allow
    // callouts to be shown without needing to reload. In the future this could
    // allow adding a CTA to disable recommendations with a label like "Don't show
    // these again" (or potentially a toggle to re-enable them).
    if (!this.cfrFeaturesUserPref) {
      this.currentScreen = null;
      return;
    }

    this._addCalloutLinkElements();
    this._setupWindowFunctions();
    await this._renderCallout();
  }

  /**
   * @typedef {Object} FeatureCalloutTheme An object with a set of custom color
   *   schemes and/or a preset key. If both are provided, the preset will be
   *   applied first, then the custom themes will override the preset values.
   * @property {String} [preset] Key of {@link FeatureCallout.themePresets}
   * @property {ColorScheme} [light] Custom light scheme
   * @property {ColorScheme} [dark] Custom dark scheme
   * @property {ColorScheme} [hcm] Custom high contrast scheme
   * @property {ColorScheme} [all] Custom scheme that will be applied in all
   *   cases, but overridden by the other schemes if they are present. This is
   *   useful if the values are already controlled by the browser theme.
   * @property {Boolean} [simulateContent] Set to true if the feature callout
   *   exists in the browser chrome but is meant to be displayed over the
   *   content area to appear as if it is part of the page. This will cause the
   *   styles to use a media query targeting the content instead of the chrome,
   *   so that if the browser theme doesn't match the content color scheme, the
   *   callout will correctly follow the content scheme. This is currently used
   *   for the feature callouts displayed over the PDF.js viewer.
   */

  /**
   * @typedef {Object} ColorScheme An object with key-value pairs, with keys
   *   from {@link FeatureCallout.themePropNames}, mapped to CSS color values
   */

  /**
   * Combine the preset and custom themes into a single object and store it.
   * @param {FeatureCalloutTheme} theme
   */
  _initTheme(theme) {
    /** @type {FeatureCalloutTheme} */
    this.theme = Object.assign(
      {},
      FeatureCallout.themePresets[theme.preset],
      theme
    );
  }

  /**
   * Apply all the theme colors to the feature callout's root element as CSS
   * custom properties in inline styles. These custom properties are consumed by
   * _feature-callout-theme.scss, which is bundled with the other styles that
   * are loaded by {@link FeatureCallout.prototype._addCalloutLinkElements}.
   */
  _applyTheme() {
    if (this._container) {
      // This tells the stylesheets to use -moz-content-prefers-color-scheme
      // instead of prefers-color-scheme, in order to follow the content color
      // scheme instead of the chrome color scheme, in case of a mismatch when
      // the feature callout exists in the chrome but is meant to look like it's
      // part of the content of a page in a browser tab (like PDF.js).
      this._container.classList.toggle(
        "simulateContent",
        !!(this.page === "chrome" && this.theme.simulateContent)
      );
      for (const type of ["light", "dark", "hcm"]) {
        const scheme = this.theme[type];
        for (const name of FeatureCallout.themePropNames) {
          this._setThemeVariable(
            `--fc-${name}-${type}`,
            scheme?.[name] || this.theme.all?.[name]
          );
        }
      }
    }
  }

  /**
   * Set or remove a CSS custom property on the feature callout container
   * @param {String} name Name of the CSS custom property
   * @param {String|void} [value] Value of the property, or omit to remove it
   */
  _setThemeVariable(name, value) {
    if (value) {
      this._container.style.setProperty(name, value);
    } else {
      this._container.style.removeProperty(name);
    }
  }

  /** A list of all the theme properties that can be set */
  static themePropNames = [
    "background",
    "color",
    "border",
    "accent-color",
    "button-background",
    "button-color",
    "button-border",
    "button-background-hover",
    "button-color-hover",
    "button-border-hover",
    "button-background-active",
    "button-color-active",
    "button-border-active",
  ];

  /** @type {Object<String, FeatureCalloutTheme>} */
  static themePresets = {
    // For themed system pages like New Tab and Firefox View. Themed content
    // colors inherit from the user's theme through contentTheme.js.
    "themed-content": {
      all: {
        background: "var(--newtab-background-color-secondary)",
        color: "var(--newtab-text-primary-color, var(--in-content-page-color))",
        border:
          "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #000)",
        "accent-color": "var(--in-content-primary-button-background)",
        "button-background": "color-mix(in srgb, transparent 93%, #000)",
        "button-color":
          "var(--newtab-text-primary-color, var(--in-content-page-color))",
        "button-border": "transparent",
        "button-background-hover": "color-mix(in srgb, transparent 88%, #000)",
        "button-color-hover":
          "var(--newtab-text-primary-color, var(--in-content-page-color))",
        "button-border-hover": "transparent",
        "button-background-active": "color-mix(in srgb, transparent 80%, #000)",
        "button-color-active":
          "var(--newtab-text-primary-color, var(--in-content-page-color))",
        "button-border-active": "transparent",
      },
      dark: {
        border:
          "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #FFF)",
        "button-background": "color-mix(in srgb, transparent 80%, #000)",
        "button-background-hover": "color-mix(in srgb, transparent 65%, #000)",
        "button-background-active": "color-mix(in srgb, transparent 55%, #000)",
      },
      hcm: {
        background: "-moz-dialog",
        color: "-moz-dialogtext",
        border: "-moz-dialogtext",
        "accent-color": "LinkText",
        "button-background": "ButtonFace",
        "button-color": "ButtonText",
        "button-border": "ButtonText",
        "button-background-hover": "ButtonText",
        "button-color-hover": "ButtonFace",
        "button-border-hover": "ButtonText",
        "button-background-active": "ButtonText",
        "button-color-active": "ButtonFace",
        "button-border-active": "ButtonText",
      },
    },
    // PDF.js colors are from toolkit/components/pdfjs/content/web/viewer.css
    pdfjs: {
      all: {
        background: "#FFF",
        color: "rgb(12, 12, 13)",
        border: "#CFCFD8",
        "accent-color": "#0A84FF",
        "button-background": "rgb(215, 215, 219)",
        "button-color": "rgb(12, 12, 13)",
        "button-border": "transparent",
        "button-background-hover": "rgb(221, 222, 223)",
        "button-color-hover": "rgb(12, 12, 13)",
        "button-border-hover": "transparent",
        "button-background-active": "rgb(221, 222, 223)",
        "button-color-active": "rgb(12, 12, 13)",
        "button-border-active": "transparent",
      },
      dark: {
        background: "#1C1B22",
        color: "#F9F9FA",
        border: "#3A3944",
        "button-background": "rgb(74, 74, 79)",
        "button-color": "#F9F9FA",
        "button-background-hover": "rgb(102, 102, 103)",
        "button-color-hover": "#F9F9FA",
        "button-background-active": "rgb(102, 102, 103)",
        "button-color-active": "#F9F9FA",
      },
      hcm: {
        background: "-moz-dialog",
        color: "-moz-dialogtext",
        border: "CanvasText",
        "accent-color": "Highlight",
        "button-background": "ButtonFace",
        "button-color": "ButtonText",
        "button-border": "ButtonText",
        "button-background-hover": "Highlight",
        "button-color-hover": "CanvasText",
        "button-border-hover": "Highlight",
        "button-background-active": "Highlight",
        "button-color-active": "CanvasText",
        "button-border-active": "Highlight",
      },
    },
    // These colors are intended to inherit the user's theme properties from the
    // main chrome window, for callouts to be anchored to chrome elements.
    // Specific schemes aren't necessary since the theme and frontend
    // stylesheets handle these variables' values.
    chrome: {
      all: {
        background: "var(--arrowpanel-background)",
        color: "var(--arrowpanel-color)",
        border: "var(--arrowpanel-border-color)",
        "accent-color": "var(--focus-outline-color)",
        "button-background": "var(--button-bgcolor)",
        "button-color": "var(--arrowpanel-color)",
        "button-border": "transparent",
        "button-background-hover": "var(--button-hover-bgcolor)",
        "button-color-hover": "var(--arrowpanel-color)",
        "button-border-hover": "transparent",
        "button-background-active": "var(--button-active-bgcolor)",
        "button-color-active": "var(--arrowpanel-color)",
        "button-border-active": "transparent",
      },
    },
  };
}
