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

/* eslint-env mozilla/browser-window */

ChromeUtils.defineESModuleGetters(this, {
  ContentBlockingAllowList:
    "resource://gre/modules/ContentBlockingAllowList.sys.mjs",
  ReportBrokenSite: "resource:///modules/ReportBrokenSite.sys.mjs",
  SpecialMessageActions:
    "resource://messaging-system/lib/SpecialMessageActions.sys.mjs",
});

XPCOMUtils.defineLazyServiceGetter(
  this,
  "TrackingDBService",
  "@mozilla.org/tracking-db-service;1",
  "nsITrackingDBService"
);

/**
 * Represents a protection category shown in the protections UI. For the most
 * common categories we can directly instantiate this category. Some protections
 * categories inherit from this class and overwrite some of its members.
 */
class ProtectionCategory {
  /**
   * Creates a protection category.
   * @param {string} id - Identifier of the category. Used to query the category
   * UI elements in the DOM.
   * @param {Object} options - Category options.
   * @param {string} options.prefEnabled - ID of pref which controls the
   * category enabled state.
   * @param {Object} flags - Flags for this category to look for in the content
   * blocking event and content blocking log.
   * @param {Number} [flags.load] - Load flag for this protection category. If
   * omitted, we will never match a isAllowing check for this category.
   * @param {Number} [flags.block] - Block flag for this protection category. If
   * omitted, we will never match a isBlocking check for this category.
   * @param {Number} [flags.shim] - Shim flag for this protection category. This
   * flag is set if we replaced tracking content with a non-tracking shim
   * script.
   * @param {Number} [flags.allow] - Allow flag for this protection category.
   * This flag is set if we explicitly allow normally blocked tracking content.
   * The webcompat extension can do this if it needs to unblock content on user
   * opt-in.
   */
  constructor(
    id,
    { prefEnabled },
    {
      load,
      block,
      shim = Ci.nsIWebProgressListener.STATE_REPLACED_TRACKING_CONTENT,
      allow = Ci.nsIWebProgressListener.STATE_ALLOWED_TRACKING_CONTENT,
    }
  ) {
    this._id = id;
    this.prefEnabled = prefEnabled;

    this._flags = { load, block, shim, allow };

    if (
      Services.prefs.getPrefType(this.prefEnabled) == Services.prefs.PREF_BOOL
    ) {
      XPCOMUtils.defineLazyPreferenceGetter(
        this,
        "_enabled",
        this.prefEnabled,
        false,
        this.updateCategoryItem.bind(this)
      );
    }

    MozXULElement.insertFTLIfNeeded("browser/siteProtections.ftl");

    ChromeUtils.defineLazyGetter(this, "subView", () =>
      document.getElementById(`protections-popup-${this._id}View`)
    );

    ChromeUtils.defineLazyGetter(this, "subViewHeading", () =>
      document.getElementById(`protections-popup-${this._id}View-heading`)
    );

    ChromeUtils.defineLazyGetter(this, "subViewList", () =>
      document.getElementById(`protections-popup-${this._id}View-list`)
    );

    ChromeUtils.defineLazyGetter(this, "subViewShimAllowHint", () =>
      document.getElementById(
        `protections-popup-${this._id}View-shim-allow-hint`
      )
    );

    ChromeUtils.defineLazyGetter(this, "isWindowPrivate", () =>
      PrivateBrowsingUtils.isWindowPrivate(window)
    );
  }

  // Child classes may override these to do init / teardown. We expect them to
  // be called when the protections panel is initialized or destroyed.
  init() {}
  uninit() {}

  // Some child classes may overide this getter.
  get enabled() {
    return this._enabled;
  }

  /**
   * Get the category item associated with this protection from the main
   * protections panel.
   * @returns {xul:toolbarbutton|undefined} - Item or undefined if the panel is
   * not yet initialized.
   */
  get categoryItem() {
    // We don't use defineLazyGetter for the category item, since it may be null
    // on first access.
    return (
      this._categoryItem ||
      (this._categoryItem = document.getElementById(
        `protections-popup-category-${this._id}`
      ))
    );
  }

  /**
   * Defaults to enabled state. May be overridden by child classes.
   * @returns {boolean} - Whether the protection is set to block trackers.
   */
  get blockingEnabled() {
    return this.enabled;
  }

  /**
   * Update the category item state in the main view of the protections panel.
   * Determines whether the category is set to block trackers.
   * @returns {boolean} - true if the state has been updated, false if the
   * protections popup has not been initialized yet.
   */
  updateCategoryItem() {
    // Can't get `this.categoryItem` without the popup. Using the popup instead
    // of `this.categoryItem` to guard access, because the category item getter
    // can trigger bug 1543537. If there's no popup, we'll be called again the
    // first time the popup shows.
    if (!gProtectionsHandler._protectionsPopup) {
      return false;
    }
    this.categoryItem.classList.toggle("blocked", this.enabled);
    this.categoryItem.classList.toggle("subviewbutton-nav", this.enabled);
    return true;
  }

  /**
   * Update the category sub view that is shown when users click on the category
   * button.
   */
  async updateSubView() {
    let { items, anyShimAllowed } = await this._generateSubViewListItems();
    this.subViewShimAllowHint.hidden = !anyShimAllowed;

    this.subViewList.textContent = "";
    this.subViewList.append(items);
    const isBlocking =
      this.blockingEnabled && !gProtectionsHandler.hasException;
    let l10nId;
    switch (this._id) {
      case "cryptominers":
        l10nId = isBlocking
          ? "protections-blocking-cryptominers"
          : "protections-not-blocking-cryptominers";
        break;
      case "fingerprinters":
        l10nId = isBlocking
          ? "protections-blocking-fingerprinters"
          : "protections-not-blocking-fingerprinters";
        break;
      case "socialblock":
        l10nId = isBlocking
          ? "protections-blocking-social-media-trackers"
          : "protections-not-blocking-social-media-trackers";
        break;
    }
    if (l10nId) {
      document.l10n.setAttributes(this.subView, l10nId);
    }
  }

  /**
   * Create a list of items, each representing a tracker.
   * @returns {Object} result - An object containing the results.
   * @returns {HTMLDivElement[]} result.items - Generated tracker items. May be
   * empty.
   * @returns {boolean} result.anyShimAllowed - Flag indicating if any of the
   * items have been unblocked by a shim script.
   */
  async _generateSubViewListItems() {
    let contentBlockingLog = gBrowser.selectedBrowser.getContentBlockingLog();
    contentBlockingLog = JSON.parse(contentBlockingLog);
    let anyShimAllowed = false;

    let fragment = document.createDocumentFragment();
    for (let [origin, actions] of Object.entries(contentBlockingLog)) {
      let { item, shimAllowed } = await this._createListItem(origin, actions);
      if (!item) {
        continue;
      }
      anyShimAllowed = anyShimAllowed || shimAllowed;
      fragment.appendChild(item);
    }

    return {
      items: fragment,
      anyShimAllowed,
    };
  }

  /**
   * Create a DOM item representing a tracker.
   * @param {string} origin - Origin of the tracker.
   * @param {Array} actions - Array of actions from the content blocking log
   * associated with the tracking origin.
   * @returns {Object} result - An object containing the results.
   * @returns {HTMLDListElement} [options.item] - Generated item or null if we
   * don't have an item for this origin based on the actions log.
   * @returns {boolean} options.shimAllowed - Flag indicating whether the
   * tracking origin was allowed by a shim script.
   */
  _createListItem(origin, actions) {
    let isAllowed = actions.some(
      ([state]) => this.isAllowing(state) && !this.isShimming(state)
    );
    let isDetected =
      isAllowed || actions.some(([state]) => this.isBlocking(state));

    if (!isDetected) {
      return {};
    }

    // Create an item to hold the origin label and shim allow indicator. Using
    // an html element here, so we can use CSS flex, which handles the label
    // overflow in combination with the icon correctly.
    let listItem = document.createElementNS(
      "http://www.w3.org/1999/xhtml",
      "div"
    );
    listItem.className = "protections-popup-list-item";
    listItem.classList.toggle("allowed", isAllowed);

    let label = document.createXULElement("label");
    // Repeat the host in the tooltip in case it's too long
    // and overflows in our panel.
    label.tooltipText = origin;
    label.value = origin;
    label.className = "protections-popup-list-host-label";
    label.setAttribute("crop", "end");
    listItem.append(label);

    // Determine whether we should show a shim-allow indicator for this item.
    let shimAllowed = actions.some(([flag]) => flag == this._flags.allow);
    if (shimAllowed) {
      listItem.append(this._getShimAllowIndicator());
    }

    return { item: listItem, shimAllowed };
  }

  /**
   * Create an indicator icon for marking origins that have been allowed by a
   * shim script.
   * @returns {HTMLImageElement} - Created element.
   */
  _getShimAllowIndicator() {
    let allowIndicator = document.createXULElement("image");
    document.l10n.setAttributes(
      allowIndicator,
      "protections-panel-shim-allowed-indicator"
    );
    allowIndicator.classList.add(
      "protections-popup-list-host-shim-allow-indicator"
    );
    return allowIndicator;
  }

  /**
   * @param {Number} state - Content blocking event flags.
   * @returns {boolean} - Whether the protection has blocked a tracker.
   */
  isBlocking(state) {
    return (state & this._flags.block) != 0;
  }

  /**
   * @param {Number} state - Content blocking event flags.
   * @returns {boolean} - Whether the protection has allowed a tracker.
   */
  isAllowing(state) {
    return (state & this._flags.load) != 0;
  }

  /**
   * @param {Number} state - Content blocking event flags.
   * @returns {boolean} - Whether the protection has detected (blocked or
   * allowed) a tracker.
   */
  isDetected(state) {
    return this.isBlocking(state) || this.isAllowing(state);
  }

  /**
   * @param {Number} state - Content blocking event flags.
   * @returns {boolean} - Whether the protections has allowed a tracker that
   * would have normally been blocked.
   */
  isShimming(state) {
    return (state & this._flags.shim) != 0 && this.isAllowing(state);
  }
}

let Fingerprinting =
  new (class FingerprintingProtection extends ProtectionCategory {
    constructor() {
      super(
        "fingerprinters",
        {
          prefEnabled: "privacy.trackingprotection.fingerprinting.enabled",
        },
        {
          load: Ci.nsIWebProgressListener.STATE_LOADED_FINGERPRINTING_CONTENT,
          block: Ci.nsIWebProgressListener.STATE_BLOCKED_FINGERPRINTING_CONTENT,
          shim: Ci.nsIWebProgressListener.STATE_REPLACED_FINGERPRINTING_CONTENT,
          allow: Ci.nsIWebProgressListener.STATE_ALLOWED_FINGERPRINTING_CONTENT,
        }
      );

      this.prefFPPEnabled = "privacy.fingerprintingProtection";
      this.prefFPPEnabledInPrivateWindows =
        "privacy.fingerprintingProtection.pbmode";

      this.enabledFPB = false;
      this.enabledFPPGlobally = false;
      this.enabledFPPInPrivateWindows = false;
    }

    init() {
      this.updateEnabled();

      Services.prefs.addObserver(this.prefEnabled, this);
      Services.prefs.addObserver(this.prefFPPEnabled, this);
      Services.prefs.addObserver(this.prefFPPEnabledInPrivateWindows, this);
    }

    uninit() {
      Services.prefs.removeObserver(this.prefEnabled, this);
      Services.prefs.removeObserver(this.prefFPPEnabled, this);
      Services.prefs.removeObserver(this.prefFPPEnabledInPrivateWindows, this);
    }

    updateEnabled() {
      this.enabledFPB = Services.prefs.getBoolPref(this.prefEnabled);
      this.enabledFPPGlobally = Services.prefs.getBoolPref(this.prefFPPEnabled);
      this.enabledFPPInPrivateWindows = Services.prefs.getBoolPref(
        this.prefFPPEnabledInPrivateWindows
      );
    }

    observe() {
      this.updateEnabled();
      this.updateCategoryItem();
    }

    get enabled() {
      return (
        this.enabledFPB ||
        this.enabledFPPGlobally ||
        (this.isWindowPrivate && this.enabledFPPInPrivateWindows)
      );
    }

    isBlocking(state) {
      let blockFlag = this._flags.block;

      // We only consider the suspicious fingerprinting flag if the
      // fingerprinting protection is enabled in the context.
      if (
        this.enabledFPPGlobally ||
        (this.isWindowPrivate && this.enabledFPPInPrivateWindows)
      ) {
        blockFlag |=
          Ci.nsIWebProgressListener.STATE_BLOCKED_SUSPICIOUS_FINGERPRINTING;
      }

      return (state & blockFlag) != 0;
    }

    // TODO (Bug 1864914): Consider showing suspicious fingerprinting as allowed
    // when the fingerprinting protection is disabled.
  })();

let Cryptomining = new ProtectionCategory(
  "cryptominers",
  {
    prefEnabled: "privacy.trackingprotection.cryptomining.enabled",
  },
  {
    load: Ci.nsIWebProgressListener.STATE_LOADED_CRYPTOMINING_CONTENT,
    block: Ci.nsIWebProgressListener.STATE_BLOCKED_CRYPTOMINING_CONTENT,
  }
);

let TrackingProtection =
  new (class TrackingProtection extends ProtectionCategory {
    constructor() {
      super(
        "trackers",
        {
          prefEnabled: "privacy.trackingprotection.enabled",
        },
        {
          load: null,
          block:
            Ci.nsIWebProgressListener.STATE_BLOCKED_TRACKING_CONTENT |
            Ci.nsIWebProgressListener.STATE_BLOCKED_EMAILTRACKING_CONTENT,
        }
      );

      this.prefEnabledInPrivateWindows =
        "privacy.trackingprotection.pbmode.enabled";
      this.prefTrackingTable = "urlclassifier.trackingTable";
      this.prefTrackingAnnotationTable =
        "urlclassifier.trackingAnnotationTable";
      this.prefAnnotationsLevel2Enabled =
        "privacy.annotate_channels.strict_list.enabled";
      this.prefEmailTrackingProtectionEnabled =
        "privacy.trackingprotection.emailtracking.enabled";
      this.prefEmailTrackingProtectionEnabledInPrivateWindows =
        "privacy.trackingprotection.emailtracking.pbmode.enabled";

      this.enabledGlobally = false;
      this.emailTrackingProtectionEnabledGlobally = false;

      this.enabledInPrivateWindows = false;
      this.emailTrackingProtectionEnabledInPrivateWindows = false;

      XPCOMUtils.defineLazyPreferenceGetter(
        this,
        "trackingTable",
        this.prefTrackingTable,
        ""
      );
      XPCOMUtils.defineLazyPreferenceGetter(
        this,
        "trackingAnnotationTable",
        this.prefTrackingAnnotationTable,
        ""
      );
      XPCOMUtils.defineLazyPreferenceGetter(
        this,
        "annotationsLevel2Enabled",
        this.prefAnnotationsLevel2Enabled,
        false
      );
    }

    init() {
      this.updateEnabled();

      Services.prefs.addObserver(this.prefEnabled, this);
      Services.prefs.addObserver(this.prefEnabledInPrivateWindows, this);
      Services.prefs.addObserver(this.prefEmailTrackingProtectionEnabled, this);
      Services.prefs.addObserver(
        this.prefEmailTrackingProtectionEnabledInPrivateWindows,
        this
      );
    }

    uninit() {
      Services.prefs.removeObserver(this.prefEnabled, this);
      Services.prefs.removeObserver(this.prefEnabledInPrivateWindows, this);
      Services.prefs.removeObserver(
        this.prefEmailTrackingProtectionEnabled,
        this
      );
      Services.prefs.removeObserver(
        this.prefEmailTrackingProtectionEnabledInPrivateWindows,
        this
      );
    }

    observe() {
      this.updateEnabled();
      this.updateCategoryItem();
    }

    get trackingProtectionLevel2Enabled() {
      const CONTENT_TABLE = "content-track-digest256";
      return this.trackingTable.includes(CONTENT_TABLE);
    }

    get enabled() {
      return (
        this.enabledGlobally ||
        this.emailTrackingProtectionEnabledGlobally ||
        (this.isWindowPrivate &&
          (this.enabledInPrivateWindows ||
            this.emailTrackingProtectionEnabledInPrivateWindows))
      );
    }

    updateEnabled() {
      this.enabledGlobally = Services.prefs.getBoolPref(this.prefEnabled);
      this.enabledInPrivateWindows = Services.prefs.getBoolPref(
        this.prefEnabledInPrivateWindows
      );
      this.emailTrackingProtectionEnabledGlobally = Services.prefs.getBoolPref(
        this.prefEmailTrackingProtectionEnabled
      );
      this.emailTrackingProtectionEnabledInPrivateWindows =
        Services.prefs.getBoolPref(
          this.prefEmailTrackingProtectionEnabledInPrivateWindows
        );
    }

    isAllowingLevel1(state) {
      return (
        (state &
          Ci.nsIWebProgressListener.STATE_LOADED_LEVEL_1_TRACKING_CONTENT) !=
        0
      );
    }

    isAllowingLevel2(state) {
      return (
        (state &
          Ci.nsIWebProgressListener.STATE_LOADED_LEVEL_2_TRACKING_CONTENT) !=
        0
      );
    }

    isAllowing(state) {
      return this.isAllowingLevel1(state) || this.isAllowingLevel2(state);
    }

    async updateSubView() {
      let previousURI = gBrowser.currentURI.spec;
      let previousWindow = gBrowser.selectedBrowser.innerWindowID;

      let { items, anyShimAllowed } = await this._generateSubViewListItems();

      // If we don't have trackers we would usually not show the menu item
      // allowing the user to show the sub-panel. However, in the edge case
      // that we annotated trackers on the page using the strict list but did
      // not detect trackers on the page using the basic list, we currently
      // still show the panel. To reduce the confusion, tell the user that we have
      // not detected any tracker.
      if (!items.childNodes.length) {
        let emptyImage = document.createXULElement("image");
        emptyImage.classList.add("protections-popup-trackersView-empty-image");
        emptyImage.classList.add("trackers-icon");

        let emptyLabel = document.createXULElement("label");
        emptyLabel.classList.add("protections-popup-empty-label");
        document.l10n.setAttributes(
          emptyLabel,
          "content-blocking-trackers-view-empty"
        );

        items.appendChild(emptyImage);
        items.appendChild(emptyLabel);

        this.subViewList.classList.add("empty");
      } else {
        this.subViewList.classList.remove("empty");
      }

      // This might have taken a while. Only update the list if we're still on the same page.
      if (
        previousURI == gBrowser.currentURI.spec &&
        previousWindow == gBrowser.selectedBrowser.innerWindowID
      ) {
        this.subViewShimAllowHint.hidden = !anyShimAllowed;

        this.subViewList.textContent = "";
        this.subViewList.append(items);
        const l10nId =
          this.enabled && !gProtectionsHandler.hasException
            ? "protections-blocking-tracking-content"
            : "protections-not-blocking-tracking-content";
        document.l10n.setAttributes(this.subView, l10nId);
      }
    }

    async _createListItem(origin, actions) {
      // Figure out if this list entry was actually detected by TP or something else.
      let isAllowed = actions.some(
        ([state]) => this.isAllowing(state) && !this.isShimming(state)
      );
      let isDetected =
        isAllowed || actions.some(([state]) => this.isBlocking(state));

      if (!isDetected) {
        return {};
      }

      // Because we might use different lists for annotation vs. blocking, we
      // need to make sure that this is a tracker that we would actually have blocked
      // before showing it to the user.
      if (
        this.annotationsLevel2Enabled &&
        !this.trackingProtectionLevel2Enabled &&
        actions.some(
          ([state]) =>
            (state &
              Ci.nsIWebProgressListener
                .STATE_LOADED_LEVEL_2_TRACKING_CONTENT) !=
            0
        )
      ) {
        return {};
      }

      let listItem = document.createElementNS(
        "http://www.w3.org/1999/xhtml",
        "div"
      );
      listItem.className = "protections-popup-list-item";
      listItem.classList.toggle("allowed", isAllowed);

      let label = document.createXULElement("label");
      // Repeat the host in the tooltip in case it's too long
      // and overflows in our panel.
      label.tooltipText = origin;
      label.value = origin;
      label.className = "protections-popup-list-host-label";
      label.setAttribute("crop", "end");
      listItem.append(label);

      let shimAllowed = actions.some(([flag]) => flag == this._flags.allow);
      if (shimAllowed) {
        listItem.append(this._getShimAllowIndicator());
      }

      return { item: listItem, shimAllowed };
    }
  })();

let ThirdPartyCookies =
  new (class ThirdPartyCookies extends ProtectionCategory {
    constructor() {
      super(
        "cookies",
        {
          // This would normally expect a boolean pref. However, this category
          // overwrites the enabled getter for custom handling of cookie behavior
          // states.
          prefEnabled: "network.cookie.cookieBehavior",
        },
        {
          // ThirdPartyCookies implements custom flag processing.
          allow: null,
          shim: null,
          load: null,
          block: null,
        }
      );

      ChromeUtils.defineLazyGetter(this, "categoryLabel", () =>
        document.getElementById("protections-popup-cookies-category-label")
      );

      this.prefEnabledValues = [
        // These values match the ones exposed under the Content Blocking section
        // of the Preferences UI.
        Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN, // Block all third-party cookies
        Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER, // Block third-party cookies from trackers
        Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, // Block trackers and patition third-party trackers
        Ci.nsICookieService.BEHAVIOR_REJECT, // Block all cookies
      ];

      XPCOMUtils.defineLazyPreferenceGetter(
        this,
        "behaviorPref",
        this.prefEnabled,
        Ci.nsICookieService.BEHAVIOR_ACCEPT,
        this.updateCategoryItem.bind(this)
      );
    }

    isBlocking(state) {
      return (
        (state & Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_TRACKER) !=
          0 ||
        (state &
          Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_SOCIALTRACKER) !=
          0 ||
        (state & Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_ALL) != 0 ||
        (state &
          Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_BY_PERMISSION) !=
          0 ||
        (state & Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_FOREIGN) !=
          0 ||
        (state & Ci.nsIWebProgressListener.STATE_COOKIES_PARTITIONED_TRACKER) !=
          0
      );
    }

    isDetected(state) {
      if (this.isBlocking(state)) {
        return true;
      }

      if (
        [
          Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN,
          Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER,
          Ci.nsICookieService.BEHAVIOR_ACCEPT,
        ].includes(this.behaviorPref)
      ) {
        return (
          (state & Ci.nsIWebProgressListener.STATE_COOKIES_LOADED_TRACKER) !=
            0 ||
          (SocialTracking.enabled &&
            (state &
              Ci.nsIWebProgressListener.STATE_COOKIES_LOADED_SOCIALTRACKER) !=
              0)
        );
      }

      // We don't have specific flags for the other cookie behaviors so just
      // fall back to STATE_COOKIES_LOADED.
      return (state & Ci.nsIWebProgressListener.STATE_COOKIES_LOADED) != 0;
    }

    updateCategoryItem() {
      if (!super.updateCategoryItem()) {
        return;
      }

      let l10nId;
      if (!this.enabled) {
        l10nId = "content-blocking-cookies-blocking-trackers-label";
      } else {
        switch (this.behaviorPref) {
          case Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN:
            l10nId = "content-blocking-cookies-blocking-third-party-label";
            break;
          case Ci.nsICookieService.BEHAVIOR_REJECT:
            l10nId = "content-blocking-cookies-blocking-all-label";
            break;
          case Ci.nsICookieService.BEHAVIOR_LIMIT_FOREIGN:
            l10nId = "content-blocking-cookies-blocking-unvisited-label";
            break;
          case Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER:
          case Ci.nsICookieService
            .BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN:
            l10nId = "content-blocking-cookies-blocking-trackers-label";
            break;
          default:
            console.error(
              `Error: Unknown cookieBehavior pref observed: ${this.behaviorPref}`
            );
            this.categoryLabel.removeAttribute("data-l10n-id");
            this.categoryLabel.textContent = "";
            return;
        }
      }
      document.l10n.setAttributes(this.categoryLabel, l10nId);
    }

    get enabled() {
      return this.prefEnabledValues.includes(this.behaviorPref);
    }

    updateSubView() {
      let contentBlockingLog = gBrowser.selectedBrowser.getContentBlockingLog();
      contentBlockingLog = JSON.parse(contentBlockingLog);

      let categories = this._processContentBlockingLog(contentBlockingLog);

      this.subViewList.textContent = "";

      let categoryNames = ["trackers"];
      switch (this.behaviorPref) {
        case Ci.nsICookieService.BEHAVIOR_REJECT:
          categoryNames.push("firstParty");
        // eslint-disable-next-line no-fallthrough
        case Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN:
          categoryNames.push("thirdParty");
      }

      for (let category of categoryNames) {
        let itemsToShow = categories[category];

        if (!itemsToShow.length) {
          continue;
        }

        let box = document.createXULElement("vbox");
        box.className = "protections-popup-cookiesView-list-section";
        let label = document.createXULElement("label");
        label.className = "protections-popup-cookiesView-list-header";
        let l10nId;
        switch (category) {
          case "trackers":
            l10nId = "content-blocking-cookies-view-trackers-label";
            break;
          case "firstParty":
            l10nId = "content-blocking-cookies-view-first-party-label";
            break;
          case "thirdParty":
            l10nId = "content-blocking-cookies-view-third-party-label";
            break;
        }
        if (l10nId) {
          document.l10n.setAttributes(label, l10nId);
        }
        box.appendChild(label);

        for (let info of itemsToShow) {
          box.appendChild(this._createListItem(info));
        }

        this.subViewList.appendChild(box);
      }

      this.subViewHeading.hidden = false;
      if (!this.enabled) {
        document.l10n.setAttributes(
          this.subView,
          "protections-not-blocking-cross-site-tracking-cookies"
        );
        return;
      }

      let l10nId;
      let siteException = gProtectionsHandler.hasException;
      switch (this.behaviorPref) {
        case Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN:
          l10nId = siteException
            ? "protections-not-blocking-cookies-third-party"
            : "protections-blocking-cookies-third-party";
          this.subViewHeading.hidden = true;
          if (this.subViewHeading.nextSibling.nodeName == "toolbarseparator") {
            this.subViewHeading.nextSibling.hidden = true;
          }
          break;
        case Ci.nsICookieService.BEHAVIOR_REJECT:
          l10nId = siteException
            ? "protections-not-blocking-cookies-all"
            : "protections-blocking-cookies-all";
          this.subViewHeading.hidden = true;
          if (this.subViewHeading.nextSibling.nodeName == "toolbarseparator") {
            this.subViewHeading.nextSibling.hidden = true;
          }
          break;
        case Ci.nsICookieService.BEHAVIOR_LIMIT_FOREIGN:
          l10nId = "protections-blocking-cookies-unvisited";
          this.subViewHeading.hidden = true;
          if (this.subViewHeading.nextSibling.nodeName == "toolbarseparator") {
            this.subViewHeading.nextSibling.hidden = true;
          }
          break;
        case Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER:
        case Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN:
          l10nId = siteException
            ? "protections-not-blocking-cross-site-tracking-cookies"
            : "protections-blocking-cookies-trackers";
          break;
        default:
          console.error(
            `Error: Unknown cookieBehavior pref when updating subview: ${this.behaviorPref}`
          );
          return;
      }

      document.l10n.setAttributes(this.subView, l10nId);
    }

    _getExceptionState(origin) {
      let thirdPartyStorage = Services.perms.testPermissionFromPrincipal(
        gBrowser.contentPrincipal,
        "3rdPartyStorage^" + origin
      );

      if (thirdPartyStorage != Services.perms.UNKNOWN_ACTION) {
        return thirdPartyStorage;
      }

      let principal =
        Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin);
      // Cookie exceptions get "inherited" from parent- to sub-domain, so we need to
      // make sure to include parent domains in the permission check for "cookie".
      return Services.perms.testPermissionFromPrincipal(principal, "cookie");
    }

    _clearException(origin) {
      for (let perm of Services.perms.getAllForPrincipal(
        gBrowser.contentPrincipal
      )) {
        if (perm.type == "3rdPartyStorage^" + origin) {
          Services.perms.removePermission(perm);
        }
      }

      // OAs don't matter here, so we can just use the hostname.
      let host = Services.io.newURI(origin).host;

      // Cookie exceptions get "inherited" from parent- to sub-domain, so we need to
      // clear any cookie permissions from parent domains as well.
      for (let perm of Services.perms.all) {
        if (
          perm.type == "cookie" &&
          Services.eTLD.hasRootDomain(host, perm.principal.host)
        ) {
          Services.perms.removePermission(perm);
        }
      }
    }

    // Transforms and filters cookie entries in the content blocking log
    // so that we can categorize and display them in the UI.
    _processContentBlockingLog(log) {
      let newLog = {
        firstParty: [],
        trackers: [],
        thirdParty: [],
      };

      let firstPartyDomain = null;
      try {
        firstPartyDomain = Services.eTLD.getBaseDomain(gBrowser.currentURI);
      } catch (e) {
        // There are nasty edge cases here where someone is trying to set a cookie
        // on a public suffix or an IP address. Just categorize those as third party...
        if (
          e.result != Cr.NS_ERROR_HOST_IS_IP_ADDRESS &&
          e.result != Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS
        ) {
          throw e;
        }
      }

      for (let [origin, actions] of Object.entries(log)) {
        if (!origin.startsWith("http")) {
          continue;
        }

        let info = {
          origin,
          isAllowed: true,
          exceptionState: this._getExceptionState(origin),
        };
        let hasCookie = false;
        let isTracker = false;

        // Extract information from the states entries in the content blocking log.
        // Each state will contain a single state flag from nsIWebProgressListener.
        // Note that we are using the same helper functions that are applied to the
        // bit map passed to onSecurityChange (which contains multiple states), thus
        // not checking exact equality, just presence of bits.
        for (let [state, blocked] of actions) {
          if (this.isDetected(state)) {
            hasCookie = true;
          }
          if (TrackingProtection.isAllowing(state)) {
            isTracker = true;
          }
          // blocked tells us whether the resource was actually blocked
          // (which it may not be in case of an exception).
          if (this.isBlocking(state)) {
            info.isAllowed = !blocked;
          }
        }

        if (!hasCookie) {
          continue;
        }

        let isFirstParty = false;
        try {
          let uri = Services.io.newURI(origin);
          isFirstParty = Services.eTLD.getBaseDomain(uri) == firstPartyDomain;
        } catch (e) {
          if (
            e.result != Cr.NS_ERROR_HOST_IS_IP_ADDRESS &&
            e.result != Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS
          ) {
            throw e;
          }
        }

        if (isFirstParty) {
          newLog.firstParty.push(info);
        } else if (isTracker) {
          newLog.trackers.push(info);
        } else {
          newLog.thirdParty.push(info);
        }
      }

      return newLog;
    }

    _createListItem({ origin, isAllowed, exceptionState }) {
      let listItem = document.createElementNS(
        "http://www.w3.org/1999/xhtml",
        "div"
      );
      listItem.className = "protections-popup-list-item";
      // Repeat the origin in the tooltip in case it's too long
      // and overflows in our panel.
      listItem.tooltipText = origin;

      let label = document.createXULElement("label");
      label.value = origin;
      label.className = "protections-popup-list-host-label";
      label.setAttribute("crop", "end");
      listItem.append(label);

      if (
        (isAllowed && exceptionState == Services.perms.ALLOW_ACTION) ||
        (!isAllowed && exceptionState == Services.perms.DENY_ACTION)
      ) {
        listItem.classList.add("protections-popup-list-item-with-state");

        let stateLabel = document.createXULElement("label");
        stateLabel.className = "protections-popup-list-state-label";
        let l10nId;
        if (isAllowed) {
          l10nId = "content-blocking-cookies-view-allowed-label";
          listItem.classList.toggle("allowed", true);
        } else {
          l10nId = "content-blocking-cookies-view-blocked-label";
        }
        document.l10n.setAttributes(stateLabel, l10nId);

        let removeException = document.createXULElement("button");
        removeException.className = "permission-popup-permission-remove-button";
        document.l10n.setAttributes(
          removeException,
          "content-blocking-cookies-view-remove-button",
          { domain: origin }
        );
        removeException.appendChild(stateLabel);

        removeException.addEventListener(
          "click",
          () => {
            this._clearException(origin);
            removeException.remove();
            listItem.classList.toggle("allowed", !isAllowed);
          },
          { once: true }
        );
        listItem.append(removeException);
      }

      return listItem;
    }
  })();

let SocialTracking =
  new (class SocialTrackingProtection extends ProtectionCategory {
    constructor() {
      super(
        "socialblock",
        {
          prefEnabled: "privacy.socialtracking.block_cookies.enabled",
        },
        {
          load: Ci.nsIWebProgressListener.STATE_LOADED_SOCIALTRACKING_CONTENT,
          block: Ci.nsIWebProgressListener.STATE_BLOCKED_SOCIALTRACKING_CONTENT,
        }
      );

      this.prefStpTpEnabled =
        "privacy.trackingprotection.socialtracking.enabled";
      this.prefSTPCookieEnabled = this.prefEnabled;
      this.prefCookieBehavior = "network.cookie.cookieBehavior";

      XPCOMUtils.defineLazyPreferenceGetter(
        this,
        "socialTrackingProtectionEnabled",
        this.prefStpTpEnabled,
        false,
        this.updateCategoryItem.bind(this)
      );
      XPCOMUtils.defineLazyPreferenceGetter(
        this,
        "rejectTrackingCookies",
        this.prefCookieBehavior,
        null,
        this.updateCategoryItem.bind(this),
        val =>
          [
            Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER,
            Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN,
          ].includes(val)
      );
    }

    get blockingEnabled() {
      return (
        (this.socialTrackingProtectionEnabled || this.rejectTrackingCookies) &&
        this.enabled
      );
    }

    isBlockingCookies(state) {
      return (
        (state &
          Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_SOCIALTRACKER) !=
        0
      );
    }

    isBlocking(state) {
      return super.isBlocking(state) || this.isBlockingCookies(state);
    }

    isAllowing(state) {
      if (this.socialTrackingProtectionEnabled) {
        return super.isAllowing(state);
      }

      return (
        (state &
          Ci.nsIWebProgressListener.STATE_COOKIES_LOADED_SOCIALTRACKER) !=
        0
      );
    }

    updateCategoryItem() {
      // Can't get `this.categoryItem` without the popup. Using the popup instead
      // of `this.categoryItem` to guard access, because the category item getter
      // can trigger bug 1543537. If there's no popup, we'll be called again the
      // first time the popup shows.
      if (!gProtectionsHandler._protectionsPopup) {
        return;
      }
      if (this.enabled) {
        this.categoryItem.removeAttribute("uidisabled");
      } else {
        this.categoryItem.setAttribute("uidisabled", true);
      }
      this.categoryItem.classList.toggle("blocked", this.blockingEnabled);
    }
  })();

/**
 * Singleton to manage the cookie banner feature section in the protections
 * panel and the cookie banner handling subview.
 */
let cookieBannerHandling = new (class {
  // Check if this is a private window. We don't expect PBM state to change
  // during the lifetime of this window.
  #isPrivateBrowsing = PrivateBrowsingUtils.isWindowPrivate(window);

  constructor() {
    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "_serviceModePref",
      "cookiebanners.service.mode",
      Ci.nsICookieBannerService.MODE_DISABLED
    );
    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "_serviceModePrefPrivateBrowsing",
      "cookiebanners.service.mode.privateBrowsing",
      Ci.nsICookieBannerService.MODE_DISABLED
    );
    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "_serviceDetectOnly",
      "cookiebanners.service.detectOnly",
      false
    );
    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "_uiEnabled",
      "cookiebanners.ui.desktop.enabled",
      false
    );
    ChromeUtils.defineLazyGetter(this, "_cookieBannerSection", () =>
      document.getElementById("protections-popup-cookie-banner-section")
    );
    ChromeUtils.defineLazyGetter(this, "_cookieBannerSectionSeparator", () =>
      document.getElementById(
        "protections-popup-cookie-banner-section-separator"
      )
    );
    ChromeUtils.defineLazyGetter(this, "_cookieBannerSwitch", () =>
      document.getElementById("protections-popup-cookie-banner-switch")
    );
    ChromeUtils.defineLazyGetter(this, "_cookieBannerSubview", () =>
      document.getElementById("protections-popup-cookieBannerView")
    );
    ChromeUtils.defineLazyGetter(this, "_cookieBannerEnableSite", () =>
      document.getElementById("cookieBannerView-enable-site")
    );
    ChromeUtils.defineLazyGetter(this, "_cookieBannerDisableSite", () =>
      document.getElementById("cookieBannerView-disable-site")
    );
  }

  /**
   * Tests if the current site has a user-created exception from the default
   * cookie banner handling mode. Currently that means the feature is disabled
   * for the current site.
   *
   * Note: bug 1790688 will move this mode handling logic into the
   * nsCookieBannerService.
   *
   * @returns {boolean} - true if the user has manually created an exception.
   */
  get #hasException() {
    // If the CBH feature is preffed off, we can't have an exception.
    if (!Services.cookieBanners.isEnabled) {
      return false;
    }

    // URLs containing IP addresses are not supported by the CBH service, and
    // will throw. In this case, users can't create an exception, so initialize
    // `pref` to the default value returned by `getDomainPref`.
    let pref = Ci.nsICookieBannerService.MODE_UNSET;
    try {
      pref = Services.cookieBanners.getDomainPref(
        gBrowser.currentURI,
        this.#isPrivateBrowsing
      );
    } catch (ex) {
      console.error(
        "Cookie Banner Handling error checking for per-site exceptions: ",
        ex
      );
    }
    return pref == Ci.nsICookieBannerService.MODE_DISABLED;
  }

  /**
   * Tests if the cookie banner handling code supports the current site.
   *
   * See nsICookieBannerService.hasRuleForBrowsingContextTree for details.
   *
   * @returns {boolean} - true if the base domain is in the list of rules.
   */
  get isSiteSupported() {
    return (
      Services.cookieBanners.isEnabled &&
      Services.cookieBanners.hasRuleForBrowsingContextTree(
        gBrowser.selectedBrowser.browsingContext
      )
    );
  }

  /*
   * @returns {string} - Base domain (eTLD + 1) used for clearing site data.
   */
  get #currentBaseDomain() {
    return gBrowser.contentPrincipal.baseDomain;
  }

  /**
   * Helper method used by both updateSection and updateSubView to map internal
   * state to UI attribute state. We have to separately set the subview's state
   * because the subview is not a descendant of the menu item in the DOM, and
   * we rely on CSS to toggle UI visibility based on attribute state.
   *
   * @returns A string value to be set as a UI attribute value.
   */
  get #uiState() {
    if (this.#hasException) {
      return "site-disabled";
    } else if (this.isSiteSupported) {
      return "detected";
    }
    return "undetected";
  }

  updateSection() {
    let showSection = this.#shouldShowSection();
    let state = this.#uiState;

    for (let el of [
      this._cookieBannerSection,
      this._cookieBannerSectionSeparator,
    ]) {
      el.hidden = !showSection;
    }

    this._cookieBannerSection.dataset.state = state;

    // On unsupported sites, disable button styling and click behavior.
    // Note: to be replaced with a "please support site" subview in bug 1801971.
    if (state == "undetected") {
      this._cookieBannerSection.setAttribute("disabled", true);
      this._cookieBannerSwitch.classList.remove("subviewbutton-nav");
      this._cookieBannerSwitch.setAttribute("disabled", true);
    } else {
      this._cookieBannerSection.removeAttribute("disabled");
      this._cookieBannerSwitch.classList.add("subviewbutton-nav");
      this._cookieBannerSwitch.removeAttribute("disabled");
    }
  }

  #shouldShowSection() {
    // Don't show UI if globally disabled by pref, or if the cookie service
    // is in detect-only mode.
    if (!this._uiEnabled || this._serviceDetectOnly) {
      return false;
    }

    // Show the section if the feature is not in disabled mode, being sure to
    // check the different prefs for regular and private windows.
    if (this.#isPrivateBrowsing) {
      return (
        this._serviceModePrefPrivateBrowsing !=
        Ci.nsICookieBannerService.MODE_DISABLED
      );
    }
    return this._serviceModePref != Ci.nsICookieBannerService.MODE_DISABLED;
  }

  /*
   * Updates the cookie banner handling subview just before it's shown.
   */
  updateSubView() {
    this._cookieBannerSubview.dataset.state = this.#uiState;

    let baseDomain = JSON.stringify({ host: this.#currentBaseDomain });
    this._cookieBannerEnableSite.setAttribute("data-l10n-args", baseDomain);
    this._cookieBannerDisableSite.setAttribute("data-l10n-args", baseDomain);
  }

  async #disableCookieBannerHandling() {
    // We can't clear data during a private browsing session until bug 1818783
    // is fixed. In the meantime, don't allow the cookie banner controls in a
    // private window to clear data for regular browsing mode.
    if (!this.#isPrivateBrowsing) {
      await SiteDataManager.remove(this.#currentBaseDomain);
    }
    Services.cookieBanners.setDomainPref(
      gBrowser.currentURI,
      Ci.nsICookieBannerService.MODE_DISABLED,
      this.#isPrivateBrowsing
    );
  }

  #enableCookieBannerHandling() {
    Services.cookieBanners.removeDomainPref(
      gBrowser.currentURI,
      this.#isPrivateBrowsing
    );
  }

  async onCookieBannerToggleCommand() {
    let hasException =
      this._cookieBannerSection.toggleAttribute("hasException");
    if (hasException) {
      await this.#disableCookieBannerHandling();
      Glean.securityUiProtectionspopup.clickCookiebToggleOff.record();
    } else {
      this.#enableCookieBannerHandling();
      Glean.securityUiProtectionspopup.clickCookiebToggleOn.record();
    }
    gProtectionsHandler._hidePopup();
    gBrowser.reloadTab(gBrowser.selectedTab);
  }
})();

/**
 * Utility object to handle manipulations of the protections indicators in the UI
 */
var gProtectionsHandler = {
  PREF_CB_CATEGORY: "browser.contentblocking.category",

  /**
   * Contains an array of smartblock compatible sites and information on the corresponding shim
   * site is the compatible site
   * shimId is the id of the shim blocking content from the origin
   * toggleDisplayName is the name shown for the toggle used for blocking/unblocking the origin
   */
  smartblockEmbedInfo: [
    {
      sites: ["https://www.instagram.com", "https://platform.instagram.com"],
      shimId: "InstagramEmbed",
      displayName: "Instagram",
    },
    {
      sites: ["https://www.tiktok.com"],
      shimId: "TiktokEmbed",
      displayName: "Tiktok",
    },
  ],

  _protectionsPopup: null,
  _initializePopup() {
    if (!this._protectionsPopup) {
      let wrapper = document.getElementById("template-protections-popup");
      this._protectionsPopup = wrapper.content.firstElementChild;
      this._protectionsPopup.addEventListener("popupshown", this);
      this._protectionsPopup.addEventListener("popuphidden", this);
      wrapper.replaceWith(wrapper.content);

      this.maybeSetMilestoneCounterText();

      for (let blocker of Object.values(this.blockers)) {
        blocker.updateCategoryItem();
      }

      this._protectionsPopup.addEventListener("command", this);
      this._protectionsPopup.addEventListener("popupshown", this);
      this._protectionsPopup.addEventListener("popuphidden", this);

      function openTooltip(event) {
        document.getElementById(event.target.tooltip).openPopup(event.target);
      }
      function closeTooltip(event) {
        document.getElementById(event.target.tooltip).hidePopup();
      }
      let notBlockingWhy = document.getElementById(
        "protections-popup-not-blocking-section-why"
      );
      notBlockingWhy.addEventListener("mouseover", openTooltip);
      notBlockingWhy.addEventListener("focus", openTooltip);
      notBlockingWhy.addEventListener("mouseout", closeTooltip);
      notBlockingWhy.addEventListener("blur", closeTooltip);

      document
        .getElementById(
          "protections-popup-trackers-blocked-counter-description"
        )
        .addEventListener("click", () =>
          gProtectionsHandler.openProtections(true)
        );
      document
        .getElementById("protections-popup-cookie-banner-switch")
        .addEventListener("click", () =>
          gProtectionsHandler.onCookieBannerClick()
        );
    }
  },

  _hidePopup() {
    if (this._protectionsPopup) {
      PanelMultiView.hidePopup(this._protectionsPopup);
    }
  },

  // smart getters
  get iconBox() {
    delete this.iconBox;
    return (this.iconBox = document.getElementById(
      "tracking-protection-icon-box"
    ));
  },
  get _protectionsPopupMultiView() {
    delete this._protectionsPopupMultiView;
    return (this._protectionsPopupMultiView = document.getElementById(
      "protections-popup-multiView"
    ));
  },
  get _protectionsPopupMainView() {
    delete this._protectionsPopupMainView;
    return (this._protectionsPopupMainView = document.getElementById(
      "protections-popup-mainView"
    ));
  },
  get _protectionsPopupMainViewHeaderLabel() {
    delete this._protectionsPopupMainViewHeaderLabel;
    return (this._protectionsPopupMainViewHeaderLabel = document.getElementById(
      "protections-popup-mainView-panel-header-span"
    ));
  },
  get _protectionsPopupTPSwitch() {
    delete this._protectionsPopupTPSwitch;
    return (this._protectionsPopupTPSwitch = document.getElementById(
      "protections-popup-tp-switch"
    ));
  },
  get _protectionsPopupCategoryList() {
    delete this._protectionsPopupCategoryList;
    return (this._protectionsPopupCategoryList = document.getElementById(
      "protections-popup-category-list"
    ));
  },
  get _protectionsPopupBlockingHeader() {
    delete this._protectionsPopupBlockingHeader;
    return (this._protectionsPopupBlockingHeader = document.getElementById(
      "protections-popup-blocking-section-header"
    ));
  },
  get _protectionsPopupNotBlockingHeader() {
    delete this._protectionsPopupNotBlockingHeader;
    return (this._protectionsPopupNotBlockingHeader = document.getElementById(
      "protections-popup-not-blocking-section-header"
    ));
  },
  get _protectionsPopupNotFoundHeader() {
    delete this._protectionsPopupNotFoundHeader;
    return (this._protectionsPopupNotFoundHeader = document.getElementById(
      "protections-popup-not-found-section-header"
    ));
  },
  get _protectionsPopupSmartblockContainer() {
    delete this._protectionsPopupSmartblockContainer;
    return (this._protectionsPopupSmartblockContainer = document.getElementById(
      "protections-popup-smartblock-highlight-container"
    ));
  },
  get _protectionsPopupSmartblockDescription() {
    delete this._protectionsPopupSmartblockDescription;
    return (this._protectionsPopupSmartblockDescription =
      document.getElementById("protections-popup-smartblock-description"));
  },
  get _protectionsPopupSmartblockToggleContainer() {
    delete this._protectionsPopupSmartblockToggleContainer;
    return (this._protectionsPopupSmartblockToggleContainer =
      document.getElementById("protections-popup-smartblock-toggle-container"));
  },
  get _protectionsPopupSettingsButton() {
    delete this._protectionsPopupSettingsButton;
    return (this._protectionsPopupSettingsButton = document.getElementById(
      "protections-popup-settings-button"
    ));
  },
  get _protectionsPopupFooter() {
    delete this._protectionsPopupFooter;
    return (this._protectionsPopupFooter = document.getElementById(
      "protections-popup-footer"
    ));
  },
  get _protectionsPopupTrackersCounterBox() {
    delete this._protectionsPopupTrackersCounterBox;
    return (this._protectionsPopupTrackersCounterBox = document.getElementById(
      "protections-popup-trackers-blocked-counter-box"
    ));
  },
  get _protectionsPopupTrackersCounterDescription() {
    delete this._protectionsPopupTrackersCounterDescription;
    return (this._protectionsPopupTrackersCounterDescription =
      document.getElementById(
        "protections-popup-trackers-blocked-counter-description"
      ));
  },
  get _protectionsPopupFooterProtectionTypeLabel() {
    delete this._protectionsPopupFooterProtectionTypeLabel;
    return (this._protectionsPopupFooterProtectionTypeLabel =
      document.getElementById(
        "protections-popup-footer-protection-type-label"
      ));
  },
  get _trackingProtectionIconTooltipLabel() {
    delete this._trackingProtectionIconTooltipLabel;
    return (this._trackingProtectionIconTooltipLabel = document.getElementById(
      "tracking-protection-icon-tooltip-label"
    ));
  },
  get _trackingProtectionIconContainer() {
    delete this._trackingProtectionIconContainer;
    return (this._trackingProtectionIconContainer = document.getElementById(
      "tracking-protection-icon-container"
    ));
  },

  get noTrackersDetectedDescription() {
    delete this.noTrackersDetectedDescription;
    return (this.noTrackersDetectedDescription = document.getElementById(
      "protections-popup-no-trackers-found-description"
    ));
  },

  get _protectionsPopupMilestonesText() {
    delete this._protectionsPopupMilestonesText;
    return (this._protectionsPopupMilestonesText = document.getElementById(
      "protections-popup-milestones-text"
    ));
  },

  get _notBlockingWhyLink() {
    delete this._notBlockingWhyLink;
    return (this._notBlockingWhyLink = document.getElementById(
      "protections-popup-not-blocking-section-why"
    ));
  },

  // A list of blockers that will be displayed in the categories list
  // when blockable content is detected. A blocker must be an object
  // with at least the following two properties:
  //  - enabled: Whether the blocker is currently turned on.
  //  - isDetected(state): Given a content blocking state, whether the blocker has
  //                       either allowed or blocked elements.
  //  - categoryItem: The DOM item that represents the entry in the category list.
  //
  // It may also contain an init() and uninit() function, which will be called
  // on gProtectionsHandler.init() and gProtectionsHandler.uninit().
  // The buttons in the protections panel will appear in the same order as this array.
  blockers: {
    SocialTracking,
    ThirdPartyCookies,
    TrackingProtection,
    Fingerprinting,
    Cryptomining,
  },

  init() {
    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "_fontVisibilityTrackingProtection",
      "layout.css.font-visibility.trackingprotection",
      3000
    );

    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "_protectionsPopupToastTimeout",
      "browser.protections_panel.toast.timeout",
      3000
    );

    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "milestoneListPref",
      "browser.contentblocking.cfr-milestone.milestones",
      "[]",
      () => this.maybeSetMilestoneCounterText(),
      val => JSON.parse(val)
    );

    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "milestonePref",
      "browser.contentblocking.cfr-milestone.milestone-achieved",
      0,
      () => this.maybeSetMilestoneCounterText()
    );

    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "milestoneTimestampPref",
      "browser.contentblocking.cfr-milestone.milestone-shown-time",
      "0",
      null,
      val => parseInt(val)
    );

    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "milestonesEnabledPref",
      "browser.contentblocking.cfr-milestone.enabled",
      false,
      () => this.maybeSetMilestoneCounterText()
    );

    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "protectionsPanelMessageSeen",
      "browser.protections_panel.infoMessage.seen",
      false
    );

    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "smartblockEmbedsEnabledPref",
      "extensions.webcompat.smartblockEmbeds.enabled",
      false
    );

    for (let blocker of Object.values(this.blockers)) {
      if (blocker.init) {
        blocker.init();
      }
    }

    // Add an observer to observe that the history has been cleared.
    Services.obs.addObserver(this, "browser:purge-session-history");
    // Add an observer to listen to requests to open the protections panel
    Services.obs.addObserver(this, "smartblock:open-protections-panel");
  },

  uninit() {
    for (let blocker of Object.values(this.blockers)) {
      if (blocker.uninit) {
        blocker.uninit();
      }
    }

    Services.obs.removeObserver(this, "browser:purge-session-history");
    Services.obs.removeObserver(this, "smartblock:open-protections-panel");
  },

  getTrackingProtectionLabel() {
    const value = Services.prefs.getStringPref(this.PREF_CB_CATEGORY);

    switch (value) {
      case "strict":
        return "protections-popup-footer-protection-label-strict";
      case "custom":
        return "protections-popup-footer-protection-label-custom";
      case "standard":
      /* fall through */
      default:
        return "protections-popup-footer-protection-label-standard";
    }
  },

  openPreferences(origin) {
    openPreferences("privacy-trackingprotection", { origin });
  },

  openProtections(relatedToCurrent = false) {
    switchToTabHavingURI("about:protections", true, {
      replaceQueryString: true,
      relatedToCurrent,
      triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
    });

    // Don't show the milestones section anymore.
    Services.prefs.clearUserPref(
      "browser.contentblocking.cfr-milestone.milestone-shown-time"
    );
  },

  async showTrackersSubview() {
    await TrackingProtection.updateSubView();
    this._protectionsPopupMultiView.showSubView(
      "protections-popup-trackersView"
    );
  },

  async showSocialblockerSubview() {
    await SocialTracking.updateSubView();
    this._protectionsPopupMultiView.showSubView(
      "protections-popup-socialblockView"
    );
  },

  async showCookiesSubview() {
    await ThirdPartyCookies.updateSubView();
    this._protectionsPopupMultiView.showSubView(
      "protections-popup-cookiesView"
    );
  },

  async showFingerprintersSubview() {
    await Fingerprinting.updateSubView();
    this._protectionsPopupMultiView.showSubView(
      "protections-popup-fingerprintersView"
    );
  },

  async showCryptominersSubview() {
    await Cryptomining.updateSubView();
    this._protectionsPopupMultiView.showSubView(
      "protections-popup-cryptominersView"
    );
  },

  async onCookieBannerClick() {
    if (!cookieBannerHandling.isSiteSupported) {
      return;
    }
    await cookieBannerHandling.updateSubView();
    this._protectionsPopupMultiView.showSubView(
      "protections-popup-cookieBannerView"
    );
  },

  shieldHistogramAdd(value) {
    if (PrivateBrowsingUtils.isWindowPrivate(window)) {
      return;
    }
    Services.telemetry
      .getHistogramById("TRACKING_PROTECTION_SHIELD")
      .add(value);
  },

  cryptominersHistogramAdd(value) {
    Services.telemetry
      .getHistogramById("CRYPTOMINERS_BLOCKED_COUNT")
      .add(value);
  },

  fingerprintersHistogramAdd(value) {
    Services.telemetry
      .getHistogramById("FINGERPRINTERS_BLOCKED_COUNT")
      .add(value);
  },

  handleProtectionsButtonEvent(event) {
    event.stopPropagation();
    if (
      (event.type == "click" && event.button != 0) ||
      (event.type == "keypress" &&
        event.charCode != KeyEvent.DOM_VK_SPACE &&
        event.keyCode != KeyEvent.DOM_VK_RETURN)
    ) {
      return; // Left click, space or enter only
    }

    this.showProtectionsPopup({ event });
  },

  onPopupShown(event) {
    if (event.target == this._protectionsPopup) {
      PopupNotifications.suppressWhileOpen(this._protectionsPopup);

      window.addEventListener("focus", this, true);
      this._protectionsPopupTPSwitch.addEventListener("toggle", this);

      // Insert the info message if needed. This will be shown once and then
      // remain collapsed.
      this._insertProtectionsPanelInfoMessage(event);

      if (!event.target.hasAttribute("toast")) {
        Glean.securityUiProtectionspopup.openProtectionsPopup.record();
      }

      ReportBrokenSite.updateParentMenu(event);
    }
  },

  onPopupHidden(event) {
    if (event.target == this._protectionsPopup) {
      window.removeEventListener("focus", this, true);
      this._protectionsPopupTPSwitch.removeEventListener("toggle", this);
    }
  },

  async onTrackingProtectionIconHoveredOrFocused() {
    // We would try to pre-fetch the data whenever the shield icon is hovered or
    // focused. We check focus event here due to the keyboard navigation.
    if (this._updatingFooter) {
      return;
    }
    this._updatingFooter = true;

    // Take the popup out of its template.
    this._initializePopup();

    // Get the tracker count and set it to the counter in the footer.
    const trackerCount = await TrackingDBService.sumAllEvents();
    this.setTrackersBlockedCounter(trackerCount);

    // Set tracking protection label
    const l10nId = this.getTrackingProtectionLabel();
    const elem = this._protectionsPopupFooterProtectionTypeLabel;
    document.l10n.setAttributes(elem, l10nId);

    // Try to get the earliest recorded date in case that there was no record
    // during the initiation but new records come after that.
    await this.maybeUpdateEarliestRecordedDateTooltip(trackerCount);

    this._updatingFooter = false;
  },

  // This triggers from top level location changes.
  onLocationChange() {
    if (this._showToastAfterRefresh) {
      this._showToastAfterRefresh = false;

      // We only display the toast if we're still on the same page.
      if (
        this._previousURI == gBrowser.currentURI.spec &&
        this._previousOuterWindowID == gBrowser.selectedBrowser.outerWindowID
      ) {
        this.showProtectionsPopup({
          toast: true,
        });
      }
    }

    // Reset blocking and exception status so that we can send telemetry
    this.hadShieldState = false;

    // Don't deal with about:, file: etc.
    if (!ContentBlockingAllowList.canHandle(gBrowser.selectedBrowser)) {
      // We hide the icon and thus avoid showing the doorhanger, since
      // the information contained there would mostly be broken and/or
      // irrelevant anyway.
      this._trackingProtectionIconContainer.hidden = true;
      return;
    }
    this._trackingProtectionIconContainer.hidden = false;

    // Check whether the user has added an exception for this site.
    this.hasException = ContentBlockingAllowList.includes(
      gBrowser.selectedBrowser
    );

    if (this._protectionsPopup) {
      this._protectionsPopup.toggleAttribute("hasException", this.hasException);
    }
    this.iconBox.toggleAttribute("hasException", this.hasException);

    // Add to telemetry per page load as a baseline measurement.
    this.fingerprintersHistogramAdd("pageLoad");
    this.cryptominersHistogramAdd("pageLoad");
    this.shieldHistogramAdd(0);
  },

  notifyContentBlockingEvent(event) {
    // We don't notify observers until the document stops loading, therefore
    // a merged event can be sent, which gives an opportunity to decide the
    // priority by the handler.
    // Content blocking events coming after stopping will not be merged, and are
    // sent directly.
    if (!this._isStoppedState || !this.anyDetected) {
      return;
    }

    let uri = gBrowser.currentURI;
    let uriHost = uri.asciiHost ? uri.host : uri.spec;
    Services.obs.notifyObservers(
      {
        wrappedJSObject: {
          browser: gBrowser.selectedBrowser,
          host: uriHost,
          event,
        },
      },
      "SiteProtection:ContentBlockingEvent"
    );
  },

  onStateChange(aWebProgress, stateFlags) {
    if (!aWebProgress.isTopLevel) {
      return;
    }

    this._isStoppedState = !!(
      stateFlags & Ci.nsIWebProgressListener.STATE_STOP
    );
    this.notifyContentBlockingEvent(
      gBrowser.selectedBrowser.getContentBlockingEvents()
    );
  },

  /**
   * Update the in-panel UI given a blocking event. Called when the popup
   * is being shown, or when the popup is open while a new event comes in.
   */
  updatePanelForBlockingEvent(event) {
    // Update the categories:
    for (let blocker of Object.values(this.blockers)) {
      if (blocker.categoryItem.hasAttribute("uidisabled")) {
        continue;
      }
      blocker.categoryItem.classList.toggle(
        "notFound",
        !blocker.isDetected(event)
      );
      blocker.categoryItem.classList.toggle(
        "subviewbutton-nav",
        blocker.isDetected(event)
      );
    }

    // And the popup attributes:
    this._protectionsPopup.toggleAttribute("detected", this.anyDetected);
    this._protectionsPopup.toggleAttribute("blocking", this.anyBlocking);
    this._protectionsPopup.toggleAttribute("hasException", this.hasException);

    this.noTrackersDetectedDescription.hidden = this.anyDetected;

    if (this.anyDetected) {
      // Reorder categories if any are in use.
      this.reorderCategoryItems();
    }
  },

  reportBlockingEventTelemetry(event, isSimulated, previousState) {
    if (!isSimulated) {
      if (this.hasException && !this.hadShieldState) {
        this.hadShieldState = true;
        this.shieldHistogramAdd(1);
      } else if (
        !this.hasException &&
        this.anyBlocking &&
        !this.hadShieldState
      ) {
        this.hadShieldState = true;
        this.shieldHistogramAdd(2);
      }
    }

    // We report up to one instance of fingerprinting and cryptomining
    // blocking and/or allowing per page load.
    let fingerprintingBlocking =
      Fingerprinting.isBlocking(event) &&
      !Fingerprinting.isBlocking(previousState);
    let fingerprintingAllowing =
      Fingerprinting.isAllowing(event) &&
      !Fingerprinting.isAllowing(previousState);
    let cryptominingBlocking =
      Cryptomining.isBlocking(event) && !Cryptomining.isBlocking(previousState);
    let cryptominingAllowing =
      Cryptomining.isAllowing(event) && !Cryptomining.isAllowing(previousState);

    if (fingerprintingBlocking) {
      this.fingerprintersHistogramAdd("blocked");
    } else if (fingerprintingAllowing) {
      this.fingerprintersHistogramAdd("allowed");
    }

    if (cryptominingBlocking) {
      this.cryptominersHistogramAdd("blocked");
    } else if (cryptominingAllowing) {
      this.cryptominersHistogramAdd("allowed");
    }
  },

  onContentBlockingEvent(event, webProgress, isSimulated, previousState) {
    // Don't deal with about:, file: etc.
    if (!ContentBlockingAllowList.canHandle(gBrowser.selectedBrowser)) {
      this.iconBox.removeAttribute("active");
      this.iconBox.removeAttribute("hasException");
      return;
    }

    // First update all our internal state based on the allowlist and the
    // different blockers:
    this.anyDetected = false;
    this.anyBlocking = false;
    this._lastEvent = event;

    // Check whether the user has added an exception for this site.
    this.hasException = ContentBlockingAllowList.includes(
      gBrowser.selectedBrowser
    );

    // Update blocker state and find if they detected or blocked anything.
    for (let blocker of Object.values(this.blockers)) {
      if (blocker.categoryItem?.hasAttribute("uidisabled")) {
        continue;
      }
      // Store data on whether the blocker is activated for reporting it
      // using the "report breakage" dialog. Under normal circumstances this
      // dialog should only be able to open in the currently selected tab
      // and onSecurityChange runs on tab switch, so we can avoid associating
      // the data with the document directly.
      blocker.activated = blocker.isBlocking(event);
      this.anyDetected = this.anyDetected || blocker.isDetected(event);
      this.anyBlocking = this.anyBlocking || blocker.activated;
    }

    this._categoryItemOrderInvalidated = true;

    // Now, update the icon UI:

    // We consider the shield state "active" when some kind of blocking activity
    // occurs on the page.  Note that merely allowing the loading of content that
    // we could have blocked does not trigger the appearance of the shield.
    // This state will be overriden later if there's an exception set for this site.
    this.iconBox.toggleAttribute("active", this.anyBlocking);
    this.iconBox.toggleAttribute("hasException", this.hasException);

    // Update the icon's tooltip:
    if (this.hasException) {
      this.showDisabledTooltipForTPIcon();
    } else if (this.anyBlocking) {
      this.showActiveTooltipForTPIcon();
    } else {
      this.showNoTrackerTooltipForTPIcon();
    }

    // Update the panel if it's open.
    let isPanelOpen = ["showing", "open"].includes(
      this._protectionsPopup?.state
    );
    if (isPanelOpen) {
      this.updatePanelForBlockingEvent(event);
    }

    // Notify other consumers, like CFR.
    // Don't send a content blocking event to CFR for
    // tab switches since this will already be done via
    // onStateChange.
    if (!isSimulated) {
      this.notifyContentBlockingEvent(event);
    }

    // Finally, report telemetry.
    this.reportBlockingEventTelemetry(event, isSimulated, previousState);
  },

  onCommand(event) {
    switch (event.target.id) {
      case "protections-popup-category-trackers":
        gProtectionsHandler.showTrackersSubview(event);
        Glean.securityUiProtectionspopup.clickTrackers.record();
        break;
      case "protections-popup-category-socialblock":
        gProtectionsHandler.showSocialblockerSubview(event);
        Glean.securityUiProtectionspopup.clickSocial.record();
        break;
      case "protections-popup-category-cookies":
        gProtectionsHandler.showCookiesSubview(event);
        Glean.securityUiProtectionspopup.clickCookies.record();
        break;
      case "protections-popup-category-cryptominers":
        gProtectionsHandler.showCryptominersSubview(event);
        Glean.securityUiProtectionspopup.clickCryptominers.record();
        return;
      case "protections-popup-category-fingerprinters":
        gProtectionsHandler.showFingerprintersSubview(event);
        Glean.securityUiProtectionspopup.clickFingerprinters.record();
        break;
      case "protections-popup-settings-button":
        gProtectionsHandler.openPreferences();
        Glean.securityUiProtectionspopup.clickSettings.record();
        break;
      case "protections-popup-show-report-button":
        gProtectionsHandler.openProtections(true);
        Glean.securityUiProtectionspopup.clickFullReport.record();
        break;
      case "protections-popup-milestones-content":
        gProtectionsHandler.openProtections(true);
        Glean.securityUiProtectionspopup.clickMilestoneMessage.record();
        break;
      case "protections-popup-trackersView-settings-button":
        gProtectionsHandler.openPreferences();
        Glean.securityUiProtectionspopup.clickSubviewSettings.record({
          value: "trackers",
        });
        break;
      case "protections-popup-socialblockView-settings-button":
        gProtectionsHandler.openPreferences();
        Glean.securityUiProtectionspopup.clickSubviewSettings.record({
          value: "social",
        });
        break;
      case "protections-popup-cookiesView-settings-button":
        gProtectionsHandler.openPreferences();
        Glean.securityUiProtectionspopup.clickSubviewSettings.record({
          value: "cookies",
        });
        break;
      case "protections-popup-fingerprintersView-settings-button":
        gProtectionsHandler.openPreferences();
        Glean.securityUiProtectionspopup.clickSubviewSettings.record({
          value: "fingerprinters",
        });
        break;
      case "protections-popup-cryptominersView-settings-button":
        gProtectionsHandler.openPreferences();
        Glean.securityUiProtectionspopup.clickSubviewSettings.record({
          value: "cryptominers",
        });
        break;
      case "protections-popup-cookieBannerView-cancel":
        gProtectionsHandler._protectionsPopupMultiView.goBack();
        break;
      case "protections-popup-cookieBannerView-enable-button":
      case "protections-popup-cookieBannerView-disable-button":
        gProtectionsHandler.onCookieBannerToggleCommand();
        break;
      case "protections-popup-toast-panel-tp-on-desc":
      case "protections-popup-toast-panel-tp-off-desc":
        // Hide the toast first.
        PanelMultiView.hidePopup(this._protectionsPopup);

        // Open the full protections panel.
        this.showProtectionsPopup({ event });
        break;
    }
  },

  // We handle focus here when the panel is shown.
  handleEvent(event) {
    switch (event.type) {
      case "command":
        this.onCommand(event);
        break;
      case "focus": {
        let elem = document.activeElement;
        let position = elem.compareDocumentPosition(this._protectionsPopup);

        if (
          !(
            position &
            (Node.DOCUMENT_POSITION_CONTAINS |
              Node.DOCUMENT_POSITION_CONTAINED_BY)
          ) &&
          !this._protectionsPopup.hasAttribute("noautohide")
        ) {
          // Hide the panel when focusing an element that is
          // neither an ancestor nor descendant unless the panel has
          // @noautohide (e.g. for a tour).
          PanelMultiView.hidePopup(this._protectionsPopup);
        }
        break;
      }
      case "popupshown":
        this.onPopupShown(event);
        break;
      case "popuphidden":
        this.onPopupHidden(event);
        break;
      case "toggle": {
        this.onTPSwitchCommand(event);
        break;
      }
    }
  },

  observe(subject, topic) {
    switch (topic) {
      case "browser:purge-session-history":
        // We need to update the earliest recorded date if history has been
        // cleared.
        this._earliestRecordedDate = 0;
        this.maybeUpdateEarliestRecordedDateTooltip();
        break;
      case "smartblock:open-protections-panel":
        if (!this.smartblockEmbedsEnabledPref) {
          // don't react if smartblock disabled by pref
          break;
        }

        if (this._protectionsPopup?.state == "open") {
          // don't open the panel if it is already open
          // This is not sufficent since the panel is technically "closed" by the button click
          // outside the panel, see Bug 1926460
          break;
        }

        if (gBrowser.selectedBrowser.browserId !== subject.browserId) {
          break;
        }
        this.showProtectionsPopup();
        break;
    }
  },

  /**
   * Update the popup contents. Only called when the popup has been taken
   * out of the template and is shown or about to be shown.
   */
  refreshProtectionsPopup() {
    let host = gIdentityHandler.getHostForDisplay();
    document.l10n.setAttributes(
      this._protectionsPopupMainViewHeaderLabel,
      "protections-header",
      { host }
    );

    let currentlyEnabled = !this.hasException;

    this.updateProtectionsToggle(currentlyEnabled);

    this._notBlockingWhyLink.setAttribute(
      "tooltip",
      currentlyEnabled
        ? "protections-popup-not-blocking-why-etp-on-tooltip"
        : "protections-popup-not-blocking-why-etp-off-tooltip"
    );

    // Update the tooltip of the blocked tracker counter.
    this.maybeUpdateEarliestRecordedDateTooltip();

    let today = Date.now();
    let threeDaysMillis = 72 * 60 * 60 * 1000;
    let expired = today - this.milestoneTimestampPref > threeDaysMillis;

    if (this._milestoneTextSet && !expired) {
      this._protectionsPopup.setAttribute("milestone", this.milestonePref);
    } else {
      this._protectionsPopup.removeAttribute("milestone");
    }

    cookieBannerHandling.updateSection();

    this._protectionsPopup.toggleAttribute("detected", this.anyDetected);
    this._protectionsPopup.toggleAttribute("blocking", this.anyBlocking);
    this._protectionsPopup.toggleAttribute("hasException", this.hasException);
  },

  /**
   * Updates the "pressed" state and labels for the toggle
   *
   * @param {boolean} isPressed - Whether or not the toggle should be pressed.
   *  True if ETP is enabled for a given site.
   */
  updateProtectionsToggle(isPressed) {
    let host = gIdentityHandler.getHostForDisplay();
    let toggle = this._protectionsPopupTPSwitch;
    toggle.toggleAttribute("pressed", isPressed);
    toggle.toggleAttribute("disabled", !!this._TPSwitchCommanding);
    document.l10n.setAttributes(
      toggle,
      isPressed
        ? "protections-panel-etp-toggle-on"
        : "protections-panel-etp-toggle-off",
      { host }
    );
  },

  /*
   * This function sorts the category items into the Blocked/Allowed/None Detected
   * sections. It's called immediately in onContentBlockingEvent if the popup
   * is presently open. Otherwise, the next time the popup is shown.
   */
  reorderCategoryItems() {
    if (!this._categoryItemOrderInvalidated) {
      return;
    }

    delete this._categoryItemOrderInvalidated;

    // Hide all the headers to start with.
    this._protectionsPopupBlockingHeader.hidden = true;
    this._protectionsPopupNotBlockingHeader.hidden = true;
    this._protectionsPopupNotFoundHeader.hidden = true;
    this._protectionsPopupSmartblockContainer.hidden = true;

    for (let { categoryItem } of Object.values(this.blockers)) {
      if (
        categoryItem.classList.contains("notFound") ||
        categoryItem.hasAttribute("uidisabled")
      ) {
        // Add the item to the bottom of the list. This will be under
        // the "None Detected" section.
        this._protectionsPopupCategoryList.insertAdjacentElement(
          "beforeend",
          categoryItem
        );
        categoryItem.setAttribute("disabled", true);
        // We have an undetected category, show the header.
        this._protectionsPopupNotFoundHeader.hidden = false;
        continue;
      }

      // Clear the disabled attribute in case we are moving the item out of
      // "None Detected"
      categoryItem.removeAttribute("disabled");

      if (categoryItem.classList.contains("blocked") && !this.hasException) {
        // Add the item just above the Smartblock embeds section - this will be the
        // bottom of the "Blocked" section.
        categoryItem.parentNode.insertBefore(
          categoryItem,
          this._protectionsPopupSmartblockContainer
        );
        // We have a blocking category, show the header.
        this._protectionsPopupBlockingHeader.hidden = false;
        continue;
      }

      // Add the item just above the "None Detected" section - this will be the
      // bottom of the "Allowed" section.
      categoryItem.parentNode.insertBefore(
        categoryItem,
        this._protectionsPopupNotFoundHeader
      );
      // We have an allowing category, show the header.
      this._protectionsPopupNotBlockingHeader.hidden = false;
    }

    // add toggles if required to the Smartblock embed section
    let smartblockEmbedDetected = this._addSmartblockEmbedToggles();

    if (smartblockEmbedDetected) {
      // We have a compatible smartblock toggle, show the smartblock
      // embed section
      this._protectionsPopupSmartblockContainer.hidden = false;
    }
  },

  /**
   * Adds the toggles into the smartblock toggle container. Clears existing toggles first, then
   * searches through the contentBlockingLog for smartblock-compatible content.
   *
   * @returns {boolean} true if a smartblock compatible resource is blocked or shimmed, false otherwise
   */
  _addSmartblockEmbedToggles() {
    if (!this.smartblockEmbedsEnabledPref) {
      // Do not insert toggles if feature is disabled.
      return false;
    }

    let contentBlockingLog = gBrowser.selectedBrowser.getContentBlockingLog();
    contentBlockingLog = JSON.parse(contentBlockingLog);
    let smartBlockEmbedToggleAdded = false;

    // remove all old toggles
    while (this._protectionsPopupSmartblockToggleContainer.lastChild) {
      this._protectionsPopupSmartblockToggleContainer.lastChild.remove();
    }

    // check that there is an allowed or replaced flag present
    let contentBlockingEvents =
      gBrowser.selectedBrowser.getContentBlockingEvents();

    // In the future, we should add a flag specifically for smartblock embeds so that
    // these checks do not trigger when a non-embed-related shim is shimming
    // a smartblock compatible site, see Bug 1926461
    let somethingAllowedOrReplaced =
      contentBlockingEvents &
        Ci.nsIWebProgressListener.STATE_ALLOWED_TRACKING_CONTENT ||
      contentBlockingEvents &
        Ci.nsIWebProgressListener.STATE_REPLACED_TRACKING_CONTENT;

    if (!somethingAllowedOrReplaced) {
      // return early if there is no content that is allowed or replaced
      return smartBlockEmbedToggleAdded;
    }

    // search through content log for compatible blocked origins
    for (let [origin, actions] of Object.entries(contentBlockingLog)) {
      let shimAllowed = actions.some(
        ([flag]) =>
          (flag & Ci.nsIWebProgressListener.STATE_ALLOWED_TRACKING_CONTENT) != 0
      );

      let shimDetected = actions.some(
        ([flag]) =>
          (flag & Ci.nsIWebProgressListener.STATE_REPLACED_TRACKING_CONTENT) !=
          0
      );

      if (!shimAllowed && !shimDetected) {
        // origin is not being shimmed or allowed
        continue;
      }

      let shimInfo = this.smartblockEmbedInfo.find(element =>
        element.sites.includes(origin)
      );
      if (!shimInfo) {
        // origin not relevant to smartblock
        continue;
      }

      const { shimId, displayName } = shimInfo;
      smartBlockEmbedToggleAdded = true;

      // check that a toggle doesn't already exist
      let existingToggle = document.getElementById(
        `smartblock-${shimId.toLowerCase()}-toggle`
      );
      if (existingToggle) {
        // make sure toggle state is allowed if ANY of the sites are allowed
        if (shimAllowed) {
          existingToggle.setAttribute("pressed", true);
        }
        // skip adding a new toggle
        continue;
      }

      // create the toggle element
      let toggle = document.createElement("moz-toggle");
      toggle.setAttribute("id", `smartblock-${shimId.toLowerCase()}-toggle`);
      toggle.setAttribute("data-l10n-attrs", "label");
      document.l10n.setAttributes(
        toggle,
        "protections-panel-smartblock-blocking-toggle",
        {
          trackername: displayName,
        }
      );

      // set toggle to correct position
      toggle.toggleAttribute("pressed", !!shimAllowed);

      // add functionality to toggle
      toggle.addEventListener("toggle", event => {
        let newToggleState = event.target.pressed;
        if (newToggleState) {
          this._sendUnblockMessageToSmartblock(shimId);
        } else {
          this._sendReblockMessageToSmartblock(shimId);
        }
      });

      this._protectionsPopupSmartblockToggleContainer.insertAdjacentElement(
        "beforeend",
        toggle
      );
    }

    return smartBlockEmbedToggleAdded;
  },

  disableForCurrentPage(shouldReload = true) {
    ContentBlockingAllowList.add(gBrowser.selectedBrowser);
    if (shouldReload) {
      this._hidePopup();
      BrowserCommands.reload();
    }
  },

  enableForCurrentPage(shouldReload = true) {
    ContentBlockingAllowList.remove(gBrowser.selectedBrowser);
    if (shouldReload) {
      this._hidePopup();
      BrowserCommands.reload();
    }
  },

  async onTPSwitchCommand() {
    // When the switch is clicked, we wait 500ms and then disable/enable
    // protections, causing the page to refresh, and close the popup.
    // We need to ensure we don't handle more clicks during the 500ms delay,
    // so we keep track of state and return early if needed.
    if (this._TPSwitchCommanding) {
      return;
    }

    this._TPSwitchCommanding = true;

    // Toggling the 'hasException' on the protections panel in order to do some
    // styling after toggling the TP switch.
    let newExceptionState =
      this._protectionsPopup.toggleAttribute("hasException");

    this.updateProtectionsToggle(!newExceptionState);

    // Change the tooltip of the tracking protection icon.
    if (newExceptionState) {
      this.showDisabledTooltipForTPIcon();
    } else {
      this.showNoTrackerTooltipForTPIcon();
    }

    // Change the state of the tracking protection icon.
    this.iconBox.toggleAttribute("hasException", newExceptionState);

    // Indicating that we need to show a toast after refreshing the page.
    // And caching the current URI and window ID in order to only show the mini
    // panel if it's still on the same page.
    this._showToastAfterRefresh = true;
    this._previousURI = gBrowser.currentURI.spec;
    this._previousOuterWindowID = gBrowser.selectedBrowser.outerWindowID;

    if (newExceptionState) {
      this.disableForCurrentPage(false);
      Glean.securityUiProtectionspopup.clickEtpToggleOff.record();
    } else {
      this.enableForCurrentPage(false);
      Glean.securityUiProtectionspopup.clickEtpToggleOn.record();
    }

    // We need to flush the TP state change immediately without waiting the
    // 500ms delay if the Tab get switched out.
    let targetTab = gBrowser.selectedTab;
    let onTabSelectHandler;
    let tabSelectPromise = new Promise(resolve => {
      onTabSelectHandler = () => resolve();
      gBrowser.tabContainer.addEventListener("TabSelect", onTabSelectHandler);
    });
    let timeoutPromise = new Promise(resolve => setTimeout(resolve, 500));

    await Promise.race([tabSelectPromise, timeoutPromise]);
    gBrowser.tabContainer.removeEventListener("TabSelect", onTabSelectHandler);
    PanelMultiView.hidePopup(this._protectionsPopup);
    gBrowser.reloadTab(targetTab);

    delete this._TPSwitchCommanding;
  },

  onCookieBannerToggleCommand() {
    cookieBannerHandling.onCookieBannerToggleCommand();
  },

  setTrackersBlockedCounter(trackerCount) {
    if (this._earliestRecordedDate) {
      document.l10n.setAttributes(
        this._protectionsPopupTrackersCounterDescription,
        "protections-footer-blocked-tracker-counter",
        { trackerCount, date: this._earliestRecordedDate }
      );
    } else {
      document.l10n.setAttributes(
        this._protectionsPopupTrackersCounterDescription,
        "protections-footer-blocked-tracker-counter-no-tooltip",
        { trackerCount }
      );
      this._protectionsPopupTrackersCounterDescription.removeAttribute(
        "tooltiptext"
      );
    }

    // Show the counter if the number of tracker is not zero.
    this._protectionsPopupTrackersCounterBox.toggleAttribute(
      "showing",
      trackerCount != 0
    );
  },

  // Whenever one of the milestone prefs are changed, we attempt to update
  // the milestone section string. This requires us to fetch the earliest
  // recorded date from the Tracking DB, hence this process is async.
  // When completed, we set _milestoneSetText to signal that the section
  // is populated and ready to be shown - which happens next time we call
  // refreshProtectionsPopup.
  _milestoneTextSet: false,
  async maybeSetMilestoneCounterText() {
    if (!this._protectionsPopup) {
      return;
    }
    let trackerCount = this.milestonePref;
    if (
      !this.milestonesEnabledPref ||
      !trackerCount ||
      !this.milestoneListPref.includes(trackerCount)
    ) {
      this._milestoneTextSet = false;
      return;
    }

    let date = await TrackingDBService.getEarliestRecordedDate();
    document.l10n.setAttributes(
      this._protectionsPopupMilestonesText,
      "protections-milestone",
      { date: date ?? 0, trackerCount }
    );
    this._milestoneTextSet = true;
  },

  showDisabledTooltipForTPIcon() {
    document.l10n.setAttributes(
      this._trackingProtectionIconTooltipLabel,
      "tracking-protection-icon-disabled"
    );
    document.l10n.setAttributes(
      this._trackingProtectionIconContainer,
      "tracking-protection-icon-disabled-container"
    );
  },

  showActiveTooltipForTPIcon() {
    document.l10n.setAttributes(
      this._trackingProtectionIconTooltipLabel,
      "tracking-protection-icon-active"
    );
    document.l10n.setAttributes(
      this._trackingProtectionIconContainer,
      "tracking-protection-icon-active-container"
    );
  },

  showNoTrackerTooltipForTPIcon() {
    document.l10n.setAttributes(
      this._trackingProtectionIconTooltipLabel,
      "tracking-protection-icon-no-trackers-detected"
    );
    document.l10n.setAttributes(
      this._trackingProtectionIconContainer,
      "tracking-protection-icon-no-trackers-detected-container"
    );
  },

  /**
   * Showing the protections popup.
   *
   * @param {Object} options
   *                 The object could have two properties.
   *                 event:
   *                   The event triggers the protections popup to be opened.
   *                 toast:
   *                   A boolean to indicate if we need to open the protections
   *                   popup as a toast. A toast only has a header section and
   *                   will be hidden after a certain amount of time.
   */
  showProtectionsPopup(options = {}) {
    const { event, toast } = options;

    this._initializePopup();

    // Ensure we've updated category state based on the last blocking event:
    if (this.hasOwnProperty("_lastEvent")) {
      this.updatePanelForBlockingEvent(this._lastEvent);
      delete this._lastEvent;
    }

    // We need to clear the toast timer if it exists before showing the
    // protections popup.
    if (this._toastPanelTimer) {
      clearTimeout(this._toastPanelTimer);
      delete this._toastPanelTimer;
    }

    this._protectionsPopup.toggleAttribute("toast", !!toast);
    if (!toast) {
      // Refresh strings if we want to open it as a standard protections popup.
      this.refreshProtectionsPopup();
    }

    if (toast) {
      this._protectionsPopup.addEventListener(
        "popupshown",
        () => {
          this._toastPanelTimer = setTimeout(() => {
            PanelMultiView.hidePopup(this._protectionsPopup, true);
            delete this._toastPanelTimer;
          }, this._protectionsPopupToastTimeout);
        },
        { once: true }
      );
    }

    // Add the "open" attribute to the tracking protection icon container
    // for styling.
    this._trackingProtectionIconContainer.setAttribute("open", "true");

    // Check the panel state of other panels. Hide them if needed.
    let openPanels = Array.from(document.querySelectorAll("panel[openpanel]"));
    for (let panel of openPanels) {
      PanelMultiView.hidePopup(panel);
    }

    // Now open the popup, anchored off the primary chrome element
    PanelMultiView.openPopup(
      this._protectionsPopup,
      this._trackingProtectionIconContainer,
      {
        position: "bottomleft topleft",
        triggerEvent: event,
      }
    ).catch(console.error);
  },

  async maybeUpdateEarliestRecordedDateTooltip(trackerCount) {
    // If we've already updated or the popup isn't in the DOM yet, don't bother
    // doing this:
    if (this._earliestRecordedDate || !this._protectionsPopup) {
      return;
    }

    let date = await TrackingDBService.getEarliestRecordedDate();

    // If there is no record for any blocked tracker, we don't have to do anything
    // since the tracker counter won't be shown.
    if (date) {
      if (typeof trackerCount !== "number") {
        trackerCount = await TrackingDBService.sumAllEvents();
      }
      document.l10n.setAttributes(
        this._protectionsPopupTrackersCounterDescription,
        "protections-footer-blocked-tracker-counter",
        { trackerCount, date }
      );
      this._earliestRecordedDate = date;
    }
  },

  /**
   * Sends a message to webcompat extension to unblock content and remove placeholders
   *
   * @param {String} shimId - the id of the shim blocking the content
   */
  _sendUnblockMessageToSmartblock(shimId) {
    Services.obs.notifyObservers(
      gBrowser.selectedTab,
      "smartblock:unblock-embed",
      shimId
    );
  },

  /**
   * Sends a message to webcompat extension to reblock content
   *
   * @param {String} shimId - the id of the shim blocking the content
   */
  _sendReblockMessageToSmartblock(shimId) {
    Services.obs.notifyObservers(
      gBrowser.selectedTab,
      "smartblock:reblock-embed",
      shimId
    );
  },

  /**
   * Dispatch the action defined in the message and user telemetry event.
   */
  _dispatchUserAction(message) {
    let url;
    try {
      // Set platform specific path variables for SUMO articles
      url = Services.urlFormatter.formatURL(message.content.cta_url);
    } catch (e) {
      console.error(e);
      url = message.content.cta_url;
    }
    SpecialMessageActions.handleAction(
      {
        type: message.content.cta_type,
        data: {
          args: url,
          where: message.content.cta_where || "tabshifted",
        },
      },
      window.browser
    );

    // Only send telemetry for non private browsing windows
    if (!PrivateBrowsingUtils.isWindowPrivate(window)) {
      Glean.securityUiProtectionspopup.clickProtectionspopupCfr.record({
        value: "learn_more_link",
        message: message.id,
      });
    }
  },

  /**
   * Attach event listener to dispatch message defined action.
   */
  _attachCommandListener(element, message) {
    // Add event listener for `mouseup` not to overlap with the
    // `mousedown` & `click` events dispatched from PanelMultiView.sys.mjs
    // https://searchfox.org/mozilla-central/rev/7531325c8660cfa61bf71725f83501028178cbb9/browser/components/customizableui/PanelMultiView.jsm#1830-1837
    element.addEventListener("mouseup", () => {
      this._dispatchUserAction(message);
    });
    element.addEventListener("keyup", e => {
      if (e.key === "Enter" || e.key === " ") {
        this._dispatchUserAction(message);
      }
    });
  },

  /**
   * Inserts a message into the Protections Panel. The message is visible once
   * and afterwards set in a collapsed state. It can be shown again using the
   * info button in the panel header.
   */
  _insertProtectionsPanelInfoMessage(event) {
    // const PROTECTIONS_PANEL_INFOMSG_PREF =
    //   "browser.protections_panel.infoMessage.seen";
    const message = {
      id: "PROTECTIONS_PANEL_1",
      content: {
        title: { string_id: "cfr-protections-panel-header" },
        body: { string_id: "cfr-protections-panel-body" },
        link_text: { string_id: "cfr-protections-panel-link-text" },
        cta_url: `${Services.urlFormatter.formatURLPref(
          "app.support.baseURL"
        )}etp-promotions?as=u&utm_source=inproduct`,
        cta_type: "OPEN_URL",
      },
    };

    const doc = event.target.ownerDocument;
    const container = doc.getElementById("info-message-container");
    const infoButton = doc.getElementById("protections-popup-info-button");
    const panelContainer = doc.getElementById("protections-popup");
    const toggleMessage = () => {
      const learnMoreLink = doc.querySelector(
        "#info-message-container .text-link"
      );
      if (learnMoreLink) {
        container.toggleAttribute("disabled");
        infoButton.toggleAttribute("checked");
        panelContainer.toggleAttribute("infoMessageShowing");
        learnMoreLink.disabled = !learnMoreLink.disabled;
      }
      // If the message panel is opened, send impression telemetry
      // if we are in a non private browsing window.
      if (
        panelContainer.hasAttribute("infoMessageShowing") &&
        !PrivateBrowsingUtils.isWindowPrivate(window)
      ) {
        Glean.securityUiProtectionspopup.openProtectionspopupCfr.record({
          value: "impression",
          message: message.id,
        });
      }
    };
    if (!container.childElementCount) {
      const messageEl = this._createHeroElement(doc, message);
      container.appendChild(messageEl);
      infoButton.addEventListener("click", toggleMessage);
    }
    // Message is collapsed by default. If it was never shown before we want
    // to expand it
    if (
      !this.protectionsPanelMessageSeen &&
      container.hasAttribute("disabled")
    ) {
      toggleMessage(message);
    }
    // Save state that we displayed the message
    if (!this.protectionsPanelMessageSeen) {
      Services.prefs.setBoolPref(
        "browser.protections_panel.infoMessage.seen",
        true
      );
    }
    // Collapse the message after the panel is hidden so we don't get the
    // animation when opening the panel
    panelContainer.addEventListener(
      "popuphidden",
      () => {
        if (
          this.protectionsPanelMessageSeen &&
          !container.hasAttribute("disabled")
        ) {
          toggleMessage(message);
        }
      },
      {
        once: true,
      }
    );
  },

  _createElement(doc, elem, options = {}) {
    const node = doc.createElementNS("http://www.w3.org/1999/xhtml", elem);
    if (options.classList) {
      node.classList.add(options.classList);
    }
    if (options.content) {
      doc.l10n.setAttributes(node, options.content.string_id);
    }
    return node;
  },

  _createHeroElement(doc, message) {
    const messageEl = this._createElement(doc, "div");
    messageEl.setAttribute("id", "protections-popup-message");
    messageEl.classList.add("protections-hero-message");
    const wrapperEl = this._createElement(doc, "div");
    wrapperEl.classList.add("protections-popup-message-body");
    messageEl.appendChild(wrapperEl);

    wrapperEl.appendChild(
      this._createElement(doc, "h2", {
        classList: "protections-popup-message-title",
        content: message.content.title,
      })
    );

    wrapperEl.appendChild(
      this._createElement(doc, "p", { content: message.content.body })
    );

    if (message.content.link_text) {
      let linkEl = this._createElement(doc, "a", {
        classList: "text-link",
        content: message.content.link_text,
      });

      linkEl.disabled = true;
      wrapperEl.appendChild(linkEl);
      this._attachCommandListener(linkEl, message);
    } else {
      this._attachCommandListener(wrapperEl, message);
    }

    return messageEl;
  },
};
