/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  ClientID: "resource://gre/modules/ClientID.sys.mjs",
  CustomizableUI:
    "moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs",
  DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
  NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
  PageActions: "resource:///modules/PageActions.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
  SearchSERPTelemetry:
    "moz-src:///browser/components/search/SearchSERPTelemetry.sys.mjs",
  SearchSERPTelemetryUtils:
    "moz-src:///browser/components/search/SearchSERPTelemetry.sys.mjs",
  SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
  TabMetrics: "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs",
  WindowsInstallsInfo:
    "resource://gre/modules/components-utils/WindowsInstallsInfo.sys.mjs",

  clearTimeout: "resource://gre/modules/Timer.sys.mjs",
  setTimeout: "resource://gre/modules/Timer.sys.mjs",
});

// This pref is in seconds!
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "gRecentVisitedOriginsExpiry",
  "browser.engagement.recent_visited_origins.expiry"
);
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "sidebarVerticalTabs",
  "sidebar.verticalTabs",
  false,
  (_aPreference, _previousValue, isVertical) => {
    let tabCount = getOpenTabsAndWinsCounts().tabCount;
    BrowserUsageTelemetry.maxTabCount = tabCount;
    let pinnedTabCount = getPinnedTabsCount();
    BrowserUsageTelemetry.maxTabPinnedCount = pinnedTabCount;
    if (isVertical) {
      Glean.browserEngagement.maxConcurrentVerticalTabCount.set(tabCount);
      Glean.browserEngagement.maxConcurrentVerticalTabPinnedCount.set(
        pinnedTabCount
      );
    } else {
      Glean.browserEngagement.maxConcurrentTabCount.set(tabCount);
      Glean.browserEngagement.maxConcurrentTabPinnedCount.set(pinnedTabCount);
    }
    BrowserUsageTelemetry.recordPinnedTabsCount(pinnedTabCount);
  }
);

// The upper bound for the count of the visited unique domain names.
const MAX_UNIQUE_VISITED_DOMAINS = 100;

// Observed topic names.
const TAB_RESTORING_TOPIC = "SSTabRestoring";
const TELEMETRY_SUBSESSIONSPLIT_TOPIC =
  "internal-telemetry-after-subsession-split";
const DOMWINDOW_OPENED_TOPIC = "domwindowopened";
const SESSION_STORE_SAVED_TAB_GROUPS_TOPIC =
  "sessionstore-saved-tab-groups-changed";

export const MINIMUM_TAB_COUNT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes, in ms

// The elements we consider to be interactive.
const UI_TARGET_CHANGE_ELEMENTS = new Set([
  "moz-checkbox",
  "moz-select",
  "moz-radio",
  "moz-toggle",
  "moz-input-folder",
  "moz-input-password",
  "moz-input-search",
  "moz-input-text",
  "moz-visual-picker-item",
]);
const UI_TARGET_COMMAND_ELEMENTS = new Set([
  "menuitem",
  "toolbarbutton",
  "key",
  "command",
  "checkbox",
  "input",
  "button",
  "image",
  "radio",
  "richlistitem",
  "moz-button",
  "moz-box-button",
  "moz-box-link",
  "dialog-button",
]);
const UI_TARGET_ELEMENTS = new Map([
  ["change", UI_TARGET_CHANGE_ELEMENTS],
  ["click", UI_TARGET_COMMAND_ELEMENTS],
  ["command", UI_TARGET_COMMAND_ELEMENTS],
]);

// The containers of interactive elements that we care about and their pretty
// names. These should be listed in order of most-specific to least-specific,
// when iterating JavaScript will guarantee that ordering and so we will find
// the most specific area first.
const BROWSER_UI_CONTAINER_IDS = {
  "toolbar-menubar": "menu-bar",
  TabsToolbar: "tabs-bar",
  "vertical-tabs": "vertical-tabs-container",
  PersonalToolbar: "bookmarks-bar",
  "appMenu-popup": "app-menu",
  tabContextMenu: "tabs-context",
  contentAreaContextMenu: "content-context",
  "widget-overflow-list": "overflow-menu",
  "widget-overflow-fixed-list": "pinned-overflow-menu",
  "page-action-buttons": "pageaction-urlbar",
  pageActionPanel: "pageaction-panel",
  "unified-extensions-area": "unified-extensions-area",
  "allTabsMenu-allTabsView": "alltabs-menu",
  // Historically, panels opened from a button on any toolbar have been
  // considered part of the nav-bar. Due to a technical change these panels
  // are no longer descendants of the nav-bar; this entry just preserves
  // continuity for telemetry.
  "customizationui-widget-panel": "nav-bar",

  // This should appear last as some of the above are inside the nav bar.
  "nav-bar": "nav-bar",
};

const ENTRYPOINT_TRACKED_CONTEXT_MENU_IDS = {
  [BROWSER_UI_CONTAINER_IDS.tabContextMenu]: "tabs-context-entrypoint",
};

// A list of the expected panes in about:preferences
const PREFERENCES_PANES = [
  "paneHome",
  "paneGeneral",
  "panePrivacy",
  "paneSearch",
  "paneSearchResults",
  "paneSync",
  "paneContainers",
  "paneExperimental",
  "paneMoreFromMozilla",
];

const IGNORABLE_EVENTS = new WeakMap();

const KNOWN_ADDONS = [];

// Buttons that, when clicked, set a preference to true. The convention
// is that the preference is named:
//
// browser.engagement.<button id>.has-used
//
// and is defaulted to false.
const SET_USAGE_PREF_BUTTONS = [
  "downloads-button",
  "fxa-toolbar-menu-button",
  "home-button",
  "sidebar-button",
  "library-button",
];

// Buttons that, when clicked, increase a counter. The convention
// is that the preference is named:
//
// browser.engagement.<button id>.used-count
//
// and doesn't have a default value.
const SET_USAGECOUNT_PREF_BUTTONS = [
  "pageAction-panel-copyURL",
  "pageAction-panel-emailLink",
  "pageAction-panel-pinTab",
  "pageAction-panel-screenshots_mozilla_org",
  "pageAction-panel-shareURL",
];

// Places context menu IDs.
const PLACES_CONTEXT_MENU_ID = "placesContext";
const PLACES_OPEN_IN_CONTAINER_TAB_MENU_ID =
  "placesContext_open:newcontainertab";

// Commands used to open history or bookmark links from places context menu.
const PLACES_OPEN_COMMANDS = [
  "placesCmd_open",
  "placesCmd_open:window",
  "placesCmd_open:privatewindow",
  "placesCmd_open:tab",
];

// How long of a delay between events means the start of a new flow?
// Used by Browser UI Interaction event instrumentation.
// Default: 5min.
const FLOW_IDLE_TIME = 5 * 60 * 1000;

const externalTabMovementRegistry = {
  internallyOpenedTabs: new WeakSet(),
  externallyOpenedTabsNextToActiveTab: new WeakSet(),
  externallyOpenedTabsAtEndOfTabStrip: new WeakSet(),
};

function telemetryId(widgetId, obscureAddons = true) {
  // Add-on IDs need to be obscured.
  function addonId(id) {
    if (!obscureAddons) {
      return id;
    }

    let pos = KNOWN_ADDONS.indexOf(id);
    if (pos < 0) {
      pos = KNOWN_ADDONS.length;
      KNOWN_ADDONS.push(id);
    }
    return `addon${pos}`;
  }

  if (widgetId.endsWith("-browser-action")) {
    widgetId = addonId(
      widgetId.substring(0, widgetId.length - "-browser-action".length)
    );
  } else if (widgetId.startsWith("pageAction-")) {
    let actionId;
    if (widgetId.startsWith("pageAction-urlbar-")) {
      actionId = widgetId.substring("pageAction-urlbar-".length);
    } else if (widgetId.startsWith("pageAction-panel-")) {
      actionId = widgetId.substring("pageAction-panel-".length);
    }

    if (actionId) {
      let action = lazy.PageActions.actionForID(actionId);
      widgetId = action?._isMozillaAction ? actionId : addonId(actionId);
    }
  } else if (widgetId.startsWith("ext-keyset-id-")) {
    // Webextension command shortcuts don't have an id on their key element so
    // we see the id from the keyset that contains them.
    widgetId = addonId(widgetId.substring("ext-keyset-id-".length));
  } else if (widgetId.startsWith("ext-key-id-")) {
    // The command for a webextension sidebar action is an exception to the above rule.
    widgetId = widgetId.substring("ext-key-id-".length);
    if (widgetId.endsWith("-sidebar-action")) {
      widgetId = addonId(
        widgetId.substring(0, widgetId.length - "-sidebar-action".length)
      );
    }
  }

  return widgetId.replace(/_/g, "-");
}

function getOpenTabsAndWinsCounts() {
  let loadedTabCount = 0;
  let tabCount = 0;
  let tabsInGroupsCount = 0;
  let winCount = 0;

  for (let win of Services.wm.getEnumerator("navigator:browser")) {
    winCount++;
    tabCount += win.gBrowser.tabs.length;
    for (const tab of win.gBrowser.tabs) {
      if (tab.getAttribute("pending") !== "true") {
        loadedTabCount += 1;
      }

      if (tab.getAttribute("group")) {
        tabsInGroupsCount += 1;
      }
    }
  }

  let tabsNotInGroupsCount = tabCount - tabsInGroupsCount;

  return {
    loadedTabCount,
    tabCount,
    winCount,
    tabsInGroupsCount,
    tabsNotInGroupsCount,
  };
}

function getPinnedTabsCount() {
  let pinnedTabs = 0;

  for (let win of Services.wm.getEnumerator("navigator:browser")) {
    pinnedTabs += [...win.ownerGlobal.gBrowser.tabs].filter(
      t => t.pinned
    ).length;
  }

  return pinnedTabs;
}

export let URICountListener = {
  // A set containing the visited domains, see bug 1271310.
  _domainSet: new Set(),
  // A map containing the visited origins during the last 24 hours (similar
  // to domains, but not quite the same), mapping to a timeoutId or 0.
  _domain24hrSet: new Map(),
  // A map to keep track of the URIs loaded from the restored tabs.
  _restoredURIsMap: new WeakMap(),

  isHttpURI(uri) {
    // Only consider http(s) schemas.
    return uri.schemeIs("http") || uri.schemeIs("https");
  },

  addRestoredURI(browser, uri) {
    if (!this.isHttpURI(uri)) {
      return;
    }

    this._restoredURIsMap.set(browser, uri.spec);
  },

  onLocationChange(browser, webProgress, request, uri, flags) {
    if (
      !(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) &&
      webProgress.isTopLevel
    ) {
      // By default, assume we no longer need to track this tab.
      lazy.SearchSERPTelemetry.stopTrackingBrowser(
        browser,
        lazy.SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION
      );
    }

    // Don't count this URI if it's an error page.
    if (flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) {
      return;
    }

    // We only care about top level loads.
    if (!webProgress.isTopLevel) {
      return;
    }

    // The SessionStore sets the URI of a tab first, firing onLocationChange the
    // first time, then manages content loading using its scheduler. Once content
    // loads, we will hit onLocationChange again.
    // We can catch the first case by checking for null requests: be advised that
    // this can also happen when navigating page fragments, so account for it.
    if (
      !request &&
      !(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)
    ) {
      return;
    }

    // Don't include URI and domain counts when in private mode.
    let shouldCountURI =
      !lazy.PrivateBrowsingUtils.isWindowPrivate(browser.ownerGlobal) ||
      Services.prefs.getBoolPref(
        "browser.engagement.total_uri_count.pbm",
        false
      );

    // Track URI loads, even if they're not http(s).
    let uriSpec = null;
    try {
      uriSpec = uri.spec;
    } catch (e) {
      // If we have troubles parsing the spec, still count this as
      // an unfiltered URI.
      if (shouldCountURI) {
        Glean.browserEngagement.unfilteredUriCount.add(1);
      }
      return;
    }

    // Don't count about:blank and similar pages, as they would artificially
    // inflate the counts.
    if (browser.ownerGlobal.gInitialPages.includes(uriSpec)) {
      return;
    }

    // If the URI we're loading is in the _restoredURIsMap, then it comes from a
    // restored tab. If so, let's skip it and remove it from the map as we want to
    // count page refreshes.
    if (this._restoredURIsMap.get(browser) === uriSpec) {
      this._restoredURIsMap.delete(browser);
      return;
    }

    // The URI wasn't from a restored tab. Count it among the unfiltered URIs.
    // If this is an http(s) URI, this also gets counted by the "total_uri_count"
    // probe.
    if (shouldCountURI) {
      Glean.browserEngagement.unfilteredUriCount.add(1);
    }

    if (!this.isHttpURI(uri)) {
      return;
    }

    if (!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)) {
      lazy.SearchSERPTelemetry.updateTrackingStatus(
        browser,
        uriSpec,
        webProgress.loadType
      );
    } else {
      lazy.SearchSERPTelemetry.updateTrackingSinglePageApp(
        browser,
        uriSpec,
        webProgress.loadType,
        flags
      );
    }

    // Update total URI count, including when in private mode.
    Glean.browserEngagement.uriCount.add(1);

    if (!shouldCountURI) {
      return;
    }

    // Update the URI counts.
    Glean.browserEngagement.uriCountNormalMode.add(1);

    // Update tab count
    BrowserUsageTelemetry._recordTabCounts(getOpenTabsAndWinsCounts());

    // Unique domains should be aggregated by (eTLD + 1): x.test.com and y.test.com
    // are counted once as test.com.
    let baseDomain;
    try {
      // Even if only considering http(s) URIs, |getBaseDomain| could still throw
      // due to the URI containing invalid characters or the domain actually being
      // an ipv4 or ipv6 address.
      baseDomain = Services.eTLD.getBaseDomain(uri);
    } catch (e) {
      return;
    }

    // We only want to count the unique domains up to MAX_UNIQUE_VISITED_DOMAINS.
    if (this._domainSet.size < MAX_UNIQUE_VISITED_DOMAINS) {
      this._domainSet.add(baseDomain);
      Glean.browserEngagement.uniqueDomainsCount.set(this._domainSet.size);
    }

    // Clear and re-add the expiration timeout for this base domain, if any.
    let timeoutId = this._domain24hrSet.get(baseDomain);
    if (timeoutId) {
      lazy.clearTimeout(timeoutId);
    }
    if (lazy.gRecentVisitedOriginsExpiry) {
      timeoutId = lazy.setTimeout(() => {
        this._domain24hrSet.delete(baseDomain);
      }, lazy.gRecentVisitedOriginsExpiry * 1000);
    } else {
      timeoutId = 0;
    }
    this._domain24hrSet.set(baseDomain, timeoutId);
  },

  /**
   * Reset the counts. This should be called when breaking a session in Telemetry.
   */
  reset() {
    this._domainSet.clear();
  },

  /**
   * Returns the number of unique domains visited in this session during the
   * last 24 hours.
   */
  get uniqueDomainsVisitedInPast24Hours() {
    return this._domain24hrSet.size;
  },

  /**
   * Resets the number of unique domains visited in this session.
   */
  resetUniqueDomainsVisitedInPast24Hours() {
    this._domain24hrSet.forEach(value => lazy.clearTimeout(value));
    this._domain24hrSet.clear();
  },

  QueryInterface: ChromeUtils.generateQI([
    "nsIWebProgressListener",
    "nsISupportsWeakReference",
  ]),
};

let gInstallationTelemetryPromise = null;

export let BrowserUsageTelemetry = {
  /**
   * This is a policy object used to override behavior for testing.
   */
  Policy: {
    getTelemetryClientId: async () => lazy.ClientID.getClientID(),
    getUpdateDirectory: () => Services.dirsvc.get("UpdRootD", Ci.nsIFile),
    readProfileCountFile: async path => IOUtils.readUTF8(path),
    writeProfileCountFile: async (path, data) => IOUtils.writeUTF8(path, data),
  },

  _inited: false,

  /**
   * @typedef {object} TabMovementsRecord
   * @property {DeferredTask} deferredTask
   *   The `DeferredTask` that will report this record's metrics once all
   *   tab movement events with the same `telemetrySource` have been received
   *   in the current event loop.
   * @property {number} numberAddedToTabGroup
   *   The number of tabs from `tabs` which started out as ungrouped tabs but
   *   moved into a tab group during the tab movement operation.
   */

  /** @type {Map<string, TabMovementsRecord>} */
  _tabMovementsBySegment: new Map(),

  init() {
    this._lastRecordTabCount = 0;
    this._lastRecordLoadedTabCount = 0;
    this._setupAfterRestore();
    this._inited = true;

    Services.prefs.addObserver("browser.tabs.inTitlebar", this);
    Services.prefs.addObserver(
      "media.videocontrols.picture-in-picture.enable-when-switching-tabs.enabled",
      this
    );
    Services.prefs.addObserver("idle-daily", this);

    this._recordUITelemetry();
    this._recordInitialPrefValues();
    this.recordPinnedTabsCount();

    this._onTabsOpenedTask = new lazy.DeferredTask(
      () => this._onTabsOpened(),
      0
    );

    this._onTabGroupChangeTask = new lazy.DeferredTask(
      () => this._doOnTabGroupChange(),
      0
    );

    this._onTabGroupExpandOrCollapseTask = new lazy.DeferredTask(
      () => this._doOnTabGroupExpandOrCollapse(),
      0
    );

    this._onSavedTabGroupsChangedTask = new lazy.DeferredTask(
      () => this._doOnSavedTabGroupsChange(),
      0
    );
    this._onSavedTabGroupsChangedTask.arm();
  },

  maxWindowCount: 0,
  maxTabCount: 0,
  get maxTabCountGleanQuantity() {
    return lazy.sidebarVerticalTabs
      ? Glean.browserEngagement.maxConcurrentVerticalTabCount
      : Glean.browserEngagement.maxConcurrentTabCount;
  },

  maxTabPinnedCount: 0,
  updateMaxTabPinnedCount(pinnedTabs) {
    if (pinnedTabs > this.maxTabPinnedCount) {
      this.maxTabPinnedCount = pinnedTabs;
      if (lazy.sidebarVerticalTabs) {
        Glean.browserEngagement.maxConcurrentVerticalTabPinnedCount.set(
          pinnedTabs
        );
      } else {
        Glean.browserEngagement.maxConcurrentTabPinnedCount.set(pinnedTabs);
      }
    }
  },

  recordPinnedTabsCount(count = getPinnedTabsCount()) {
    if (lazy.sidebarVerticalTabs) {
      Glean.pinnedTabs.count.sidebar.set(count);
    } else {
      Glean.pinnedTabs.count.horizontalBar.set(count);
    }
  },

  /**
   * Resets the masked add-on identifiers. Only for use in tests.
   */
  _resetAddonIds() {
    KNOWN_ADDONS.length = 0;
  },

  /**
   * Handle subsession splits in the parent process.
   */
  afterSubsessionSplit() {
    // Scalars just got cleared due to a subsession split. We need to set the maximum
    // concurrent tab and window counts so that they reflect the correct value for the
    // new subsession.
    this._initMaxTabAndWindowCounts();

    // Reset the URI counter.
    URICountListener.reset();
  },

  QueryInterface: ChromeUtils.generateQI([
    "nsIObserver",
    "nsISupportsWeakReference",
  ]),

  uninit() {
    if (!this._inited) {
      return;
    }
    Services.obs.removeObserver(this, DOMWINDOW_OPENED_TOPIC);
    Services.obs.removeObserver(this, TELEMETRY_SUBSESSIONSPLIT_TOPIC);
    Services.obs.removeObserver(this, SESSION_STORE_SAVED_TAB_GROUPS_TOPIC);
  },

  observe(subject, topic, data) {
    switch (topic) {
      case DOMWINDOW_OPENED_TOPIC:
        this._onWindowOpen(subject);
        break;
      case TELEMETRY_SUBSESSIONSPLIT_TOPIC:
        this.afterSubsessionSplit();
        break;
      case SESSION_STORE_SAVED_TAB_GROUPS_TOPIC:
        this._onSavedTabGroupsChange();
        break;
      case "nsPref:changed":
        switch (data) {
          case "browser.tabs.inTitlebar":
            this._recordWidgetChange(
              "titlebar",
              Services.appinfo.drawInTitlebar ? "off" : "on",
              "pref"
            );
            break;
          case "media.videocontrols.picture-in-picture.enable-when-switching-tabs.enabled":
            if (Services.prefs.getBoolPref(data)) {
              Glean.pictureinpictureSettings.enableAutotriggerSettings.record();
            }
            break;

          case "idle-daily":
            this._recordInitialPrefValues();
            break;
        }
        break;
    }
  },

  handleEvent(event) {
    switch (event.type) {
      case "TabOpen":
        this._onTabOpen(event);
        break;
      case "TabClose":
        this._onTabClosed(event);
        break;
      case "TabPinned":
        this._onTabPinned(event);
        break;
      case "TabUnpinned":
        this._onTabUnpinned();
        break;
      case "TabGroupCreateByUser":
        this._onTabGroupCreateByUser(event);
        break;
      case "TabGrouped":
      case "TabUngrouped":
        this._onTabGroupChange();
        break;
      case "TabGroupCollapse":
      case "TabGroupExpand":
        this._onTabGroupExpandOrCollapse();
        break;
      case "TabMove":
        this._onTabMove(event);
        break;
      case "TabSelect":
        this._onTabSelect(event);
        break;
      case "TabGroupRemoveRequested":
        this._onTabGroupRemoveRequested(event);
        break;
      case "TabGroupSaved":
        this._onTabGroupSave(event);
        break;
      case "TabGroupUngroup":
        this._onTabGroupUngroup(event);
        break;

      case "unload":
        this._unregisterWindow(event.target);
        break;
      case TAB_RESTORING_TOPIC: {
        // We're restoring a new tab from a previous or crashed session.
        // We don't want to track the URIs from these tabs, so let
        // |URICountListener| know about them.
        let browser = event.target.linkedBrowser;
        URICountListener.addRestoredURI(browser, browser.currentURI);

        const { loadedTabCount } = getOpenTabsAndWinsCounts();
        this._recordTabCounts({ loadedTabCount });
        break;
      }
    }
  },

  _initMaxTabAndWindowCounts() {
    const counts = getOpenTabsAndWinsCounts();
    this.maxTabCount = counts.tabCount;
    this.maxTabCountGleanQuantity.set(counts.tabCount);
    this.maxWindowCount = counts.winCount;
    Glean.browserEngagement.maxConcurrentWindowCount.set(counts.winCount);
  },

  /**
   * This gets called shortly after the SessionStore has finished restoring
   * windows and tabs. It counts the open tabs and adds listeners to all the
   * windows.
   */
  _setupAfterRestore() {
    // Make sure to catch new chrome windows and subsession splits.
    Services.obs.addObserver(this, DOMWINDOW_OPENED_TOPIC, true);
    Services.obs.addObserver(this, TELEMETRY_SUBSESSIONSPLIT_TOPIC, true);
    Services.obs.addObserver(this, SESSION_STORE_SAVED_TAB_GROUPS_TOPIC, true);

    // Attach the tabopen handlers to the existing Windows.
    for (let win of Services.wm.getEnumerator("navigator:browser")) {
      this._registerWindow(win);
    }

    // Get the initial tab and windows max counts.
    this._initMaxTabAndWindowCounts();
  },

  _buildWidgetPositions() {
    let widgetMap = new Map();

    const toolbarState = nodeId => {
      let value;
      if (nodeId == "PersonalToolbar") {
        value = Services.prefs.getCharPref(
          "browser.toolbars.bookmarks.visibility",
          "newtab"
        );
        if (value != "newtab") {
          return value == "never" ? "off" : "on";
        }
        return value;
      }
      value = Services.xulStore.getValue(
        AppConstants.BROWSER_CHROME_URL,
        nodeId,
        "collapsed"
      );

      if (value) {
        return value == "true" ? "off" : "on";
      }
      return "off";
    };

    widgetMap.set(
      BROWSER_UI_CONTAINER_IDS.PersonalToolbar,
      toolbarState("PersonalToolbar")
    );

    let menuBarHidden =
      Services.xulStore.getValue(
        AppConstants.BROWSER_CHROME_URL,
        "toolbar-menubar",
        "autohide"
      ) != "false";

    widgetMap.set("menu-toolbar", menuBarHidden ? "off" : "on");

    // Drawing in the titlebar means not showing the titlebar, hence the negation.
    widgetMap.set("titlebar", Services.appinfo.drawInTitlebar ? "off" : "on");

    for (let area of lazy.CustomizableUI.areas) {
      if (!(area in BROWSER_UI_CONTAINER_IDS)) {
        continue;
      }

      let position = BROWSER_UI_CONTAINER_IDS[area];
      if (area == "nav-bar") {
        position = `${BROWSER_UI_CONTAINER_IDS[area]}-start`;
      }

      let widgets = lazy.CustomizableUI.getWidgetsInArea(area);

      for (let widget of widgets) {
        if (!widget) {
          continue;
        }

        if (widget.id.startsWith("customizableui-special-")) {
          continue;
        }

        if (area == "nav-bar" && widget.id == "urlbar-container") {
          position = `${BROWSER_UI_CONTAINER_IDS[area]}-end`;
          continue;
        }

        widgetMap.set(widget.id, position);
      }
    }

    let actions = lazy.PageActions.actions;
    for (let action of actions) {
      if (action.pinnedToUrlbar) {
        widgetMap.set(action.id, "pageaction-urlbar");
      }
    }

    return widgetMap;
  },

  _getWidgetID(node) {
    // We want to find a sensible ID for this element.
    if (!node) {
      return null;
    }

    // See if this is a customizable widget.
    if (node.ownerDocument.URL == AppConstants.BROWSER_CHROME_URL) {
      // First find if it is inside one of the customizable areas.
      for (let area of lazy.CustomizableUI.areas) {
        if (node.closest(`#${CSS.escape(area)}`)) {
          for (let widget of lazy.CustomizableUI.getWidgetIdsInArea(area)) {
            if (
              // We care about the buttons on the tabs themselves.
              widget == "tabbrowser-tabs" ||
              // We care about the page action and other buttons in here.
              widget == "urlbar-container" ||
              // We care about the actual menu items.
              widget == "menubar-items" ||
              // We care about individual bookmarks here.
              widget == "personal-bookmarks"
            ) {
              continue;
            }

            if (node.closest(`#${CSS.escape(widget)}`)) {
              return widget;
            }
          }
          break;
        }
      }
    }

    if (node.id) {
      return node.id;
    }

    // A couple of special cases in the tabs.
    for (let cls of ["bookmark-item", "tab-icon-sound", "tab-close-button"]) {
      if (!node.classList.contains(cls)) {
        continue;
      }
      if (cls == "bookmark-item" && node.parentElement.id.includes("history")) {
        return "history-item";
      }
      return cls;
    }

    // One of these will at least let us know what the widget is for.
    let possibleAttributes = [
      "preference",
      "command",
      "observes",
      "data-l10n-id",
    ];

    // The key attribute on key elements is the actual key to listen for.
    if (node.localName != "key") {
      possibleAttributes.unshift("key");
    }

    for (let idAttribute of possibleAttributes) {
      if (node.hasAttribute(idAttribute)) {
        return node.getAttribute(idAttribute);
      }
    }

    return this._getWidgetID(node.parentElement);
  },

  _getBrowserWidgetContainer(node) {
    // Find the container holding this element.
    for (let containerId of Object.keys(BROWSER_UI_CONTAINER_IDS)) {
      let container = node.ownerDocument.getElementById(containerId);
      if (container && container.contains(node)) {
        return BROWSER_UI_CONTAINER_IDS[containerId];
      }
    }
    // Treat toolbar context menu items that relate to tabs as the tab menu:
    if (
      node.closest("#toolbar-context-menu") &&
      node.getAttribute("contexttype") == "tabbar"
    ) {
      return BROWSER_UI_CONTAINER_IDS.tabContextMenu;
    }
    return null;
  },

  _getWidgetContainer(node) {
    if (node.localName == "key") {
      return "keyboard";
    }

    const { URL: url } = node.ownerDocument;
    if (url == AppConstants.BROWSER_CHROME_URL) {
      return this._getBrowserWidgetContainer(node);
    }
    if (
      url.startsWith("about:preferences") ||
      url.startsWith("about:settings")
    ) {
      // Find the element's category.
      let container = node.closest("[data-category]");
      if (!container) {
        return null;
      }

      let pane = container.getAttribute("data-category");

      if (!PREFERENCES_PANES.includes(pane)) {
        pane = "paneUnknown";
      }

      return `preferences_${pane}`;
    }

    return null;
  },

  lastClickTarget: null,

  ignoreEvent(event) {
    IGNORABLE_EVENTS.set(event, true);
  },

  _recordCommand(event) {
    if (IGNORABLE_EVENTS.get(event)) {
      return;
    }

    let sourceEvent = event;
    while (sourceEvent.sourceEvent) {
      sourceEvent = sourceEvent.sourceEvent;
    }

    let lastTarget = this.lastClickTarget?.get();
    if (
      lastTarget &&
      sourceEvent.type == "command" &&
      sourceEvent.target.contains(lastTarget)
    ) {
      // Ignore a command event triggered by a click.
      this.lastClickTarget = null;
      return;
    }

    this.lastClickTarget = null;

    if (sourceEvent.type == "click") {
      // Only care about main button clicks.
      if (sourceEvent.button != 0) {
        return;
      }

      // This click may trigger a command event so retain the target to be able
      // to dedupe that event.
      this.lastClickTarget = Cu.getWeakReference(sourceEvent.target);
    }

    // We should never see events from web content as they are fired in a
    // content process, but let's be safe.
    let url = sourceEvent.target.ownerDocument.documentURIObject;
    if (!url.schemeIs("chrome") && !url.schemeIs("about")) {
      return;
    }

    // This is what events targetted  at content will actually look like.
    if (sourceEvent.target.localName == "browser") {
      return;
    }

    // Find the actual element we're interested in.
    let node = sourceEvent.target;
    const isAboutPreferences =
      node.ownerDocument.URL.startsWith("about:preferences") ||
      node.ownerDocument.URL.startsWith("about:settings");
    let targetElements = UI_TARGET_ELEMENTS.get(event.type);

    while (
      !targetElements.has(node.localName) &&
      !node.classList?.contains("wants-telemetry") &&
      // We are interested in links on about:preferences as well.
      !(
        isAboutPreferences &&
        (node.getAttribute("is") === "text-link" || node.localName === "a")
      )
    ) {
      node = node.parentNode;
      if (!node?.parentNode) {
        // A click on a space or label or top-level document or something we're
        // not interested in.
        return;
      }
    }

    if (sourceEvent.type === "command") {
      const { command, ownerDocument, parentNode } = node;
      // Check if this command is for a history or bookmark link being opened
      // from the context menu. In this case, we are interested in the DOM node
      // for the link, not the menu item itself.
      if (
        PLACES_OPEN_COMMANDS.includes(command) ||
        parentNode?.parentNode?.id === PLACES_OPEN_IN_CONTAINER_TAB_MENU_ID
      ) {
        node = ownerDocument.getElementById(PLACES_CONTEXT_MENU_ID).triggerNode;
      }
    }

    let item = this._getWidgetID(node);
    let source = this._getWidgetContainer(node);

    if (item && source) {
      this.recordInteractionEvent(item, source);
      let name = source
        .replace(/-/g, "_")
        .replace(/_([a-z])/g, (m, p) => p.toUpperCase());
      Glean.browserUiInteraction[name]?.[telemetryId(item)].add(1);
      if (SET_USAGECOUNT_PREF_BUTTONS.includes(item)) {
        let pref = `browser.engagement.${item}.used-count`;
        Services.prefs.setIntPref(pref, Services.prefs.getIntPref(pref, 0) + 1);
      }
      if (SET_USAGE_PREF_BUTTONS.includes(item)) {
        Services.prefs.setBoolPref(`browser.engagement.${item}.has-used`, true);
      }
    }

    if (ENTRYPOINT_TRACKED_CONTEXT_MENU_IDS[source]) {
      let contextMenu = ENTRYPOINT_TRACKED_CONTEXT_MENU_IDS[source];
      let triggerContainer = this._getWidgetContainer(
        node.closest("menupopup")?.triggerNode
      );
      if (triggerContainer) {
        this.recordInteractionEvent(item, contextMenu);
        let name = contextMenu
          .replace(/-/g, "_")
          .replace(/_([a-z])/g, (m, p) => p.toUpperCase());
        Glean.browserUiInteraction[name]?.[telemetryId(triggerContainer)].add(
          1
        );
      }
    }
  },

  _flowId: null,
  _flowIdTS: 0,

  recordInteractionEvent(widgetId, source) {
    // A note on clocks. ChromeUtils.now() is monotonic, but its behaviour across
    // computer sleeps is different per platform.
    // We're okay with this for flows because we're looking at idle times
    // on the order of minutes and within the same machine, so the weirdest
    // thing we may expect is a flow that accidentally continues across a
    // sleep. Until we have evidence that this is common, we're in the clear.
    if (!this._flowId || this._flowIdTS + FLOW_IDLE_TIME < ChromeUtils.now()) {
      // We submit the ping full o' events on every new flow,
      // including at startup.
      GleanPings.prototypeNoCodeEvents.submit();
      // We use a GUID here because we need to identify events in a flow
      // out of all events from all flows across all clients.
      this._flowId = Services.uuid.generateUUID();
    }
    this._flowIdTS = ChromeUtils.now();

    const extra = {
      source,
      widget_id: telemetryId(widgetId),
      flow_id: this._flowId,
    };
    Glean.browserUsage.interaction.record(extra);
  },

  /**
   * Listens for UI interactions in the window.
   */
  _addUsageListeners(win) {
    // Listen for events that UI_TARGET_ELEMENTS expect from the UI.
    UI_TARGET_ELEMENTS.keys().forEach(type =>
      win.addEventListener(type, event => this._recordCommand(event), true)
    );
  },

  /**
   * A public version of the private method to take care of the `nav-bar-start`,
   * `nav-bar-end` thing that callers shouldn't have to care about. It also
   * accepts the DOM ids for the areas rather than the cleaner ones we report
   * to telemetry.
   */
  recordWidgetChange(widgetId, newPos, reason) {
    try {
      if (newPos) {
        newPos = BROWSER_UI_CONTAINER_IDS[newPos];
      }

      if (newPos == "nav-bar") {
        let { position } = lazy.CustomizableUI.getPlacementOfWidget(widgetId);
        let { position: urlPosition } =
          lazy.CustomizableUI.getPlacementOfWidget("urlbar-container");
        newPos = newPos + (urlPosition > position ? "-start" : "-end");
      }

      this._recordWidgetChange(widgetId, newPos, reason);
    } catch (e) {
      console.error(e);
    }
  },

  recordToolbarVisibility(toolbarId, newState, reason) {
    if (typeof newState != "string") {
      newState = newState ? "on" : "off";
    }
    this._recordWidgetChange(
      BROWSER_UI_CONTAINER_IDS[toolbarId],
      newState,
      reason
    );
  },

  _recordWidgetChange(widgetId, newPos, reason) {
    // In some cases (like when add-ons are detected during startup) this gets
    // called before we've reported the initial positions. Ignore such cases.
    if (!this.widgetMap) {
      return;
    }

    if (widgetId == "urlbar-container") {
      // We don't report the position of the url bar, it is after nav-bar-start
      // and before nav-bar-end. But moving it means the widgets around it have
      // effectively moved so update those.
      let position = "nav-bar-start";
      let widgets = lazy.CustomizableUI.getWidgetsInArea("nav-bar");

      for (let widget of widgets) {
        if (!widget) {
          continue;
        }

        if (widget.id.startsWith("customizableui-special-")) {
          continue;
        }

        if (widget.id == "urlbar-container") {
          position = "nav-bar-end";
          continue;
        }

        // This will do nothing if the position hasn't changed.
        this._recordWidgetChange(widget.id, position, reason);
      }

      return;
    }

    let oldPos = this.widgetMap.get(widgetId);
    if (oldPos == newPos) {
      return;
    }

    let action = "move";

    if (!oldPos) {
      action = "add";
    } else if (!newPos) {
      action = "remove";
    }

    let key = `${telemetryId(widgetId, false)}_${action}_${oldPos ?? "na"}_${
      newPos ?? "na"
    }_${reason}`;
    Glean.browserUi.customizedWidgets[key].add(1);

    if (newPos) {
      this.widgetMap.set(widgetId, newPos);
    } else {
      this.widgetMap.delete(widgetId);
    }
  },

  _recordUITelemetry() {
    this.widgetMap = this._buildWidgetPositions();

    // FIXME(bug 1883857): object metric type not available in artefact builds.
    if ("toolbarWidgets" in Glean.browserUi) {
      Glean.browserUi.toolbarWidgets.set(
        this.widgetMap
          .entries()
          .map(([widgetId, position]) => {
            return { widgetId: telemetryId(widgetId, false), position };
          })
          .toArray()
      );
    }

    for (let [widgetId, position] of this.widgetMap.entries()) {
      let key = `${telemetryId(widgetId, false)}_pinned_${position}`;
      Glean.browserUi.mirrorForToolbarWidgets[key].set(true);
    }
  },

  /**
   * Records the startup values of prefs that govern important browser behavior
   * options.
   */
  _recordInitialPrefValues() {
    this._recordOpenNextToActiveTabSettingValue();
  },

  /**
   * @returns {boolean}
   */
  _isOpenNextToActiveTabSettingEnabled() {
    /** @type {number} proxy for `browser.link.open_newwindow.override.external` */
    const externalLinkOpeningBehavior =
      lazy.NimbusFeatures.externalLinkHandling.getVariable("openBehavior");
    return (
      externalLinkOpeningBehavior ==
      Ci.nsIBrowserDOMWindow.OPEN_NEWTAB_AFTER_CURRENT
    );
  },

  _recordOpenNextToActiveTabSettingValue() {
    Glean.linkHandling.openNextToActiveTabSettingsEnabled.set(
      this._isOpenNextToActiveTabSettingEnabled()
    );
  },

  /**
   * Adds listeners to a single chrome window.
   * @param {Window} win
   */
  _registerWindow(win) {
    this._addUsageListeners(win);

    win.addEventListener("unload", this);
    win.addEventListener("TabMove", this);
    win.addEventListener("TabOpen", this, true);
    win.addEventListener("TabClose", this, true);
    win.addEventListener("TabPinned", this, true);
    win.addEventListener("TabUnpinned", this, true);
    win.addEventListener("TabSelect", this);
    win.addEventListener("TabGroupCreateByUser", this);
    win.addEventListener("TabGroupRemoveRequested", this);
    win.addEventListener("TabGrouped", this);
    win.addEventListener("TabUngrouped", this);
    win.addEventListener("TabGroupCollapse", this);
    win.addEventListener("TabGroupExpand", this);
    win.addEventListener("TabGroupSaved", this);
    win.addEventListener("TabGroupUngroup", this);

    win.gBrowser.tabContainer.addEventListener(TAB_RESTORING_TOPIC, this);
    win.gBrowser.addTabsProgressListener(URICountListener);
  },

  /**
   * Removes listeners from a single chrome window.
   */
  _unregisterWindow(win) {
    win.removeEventListener("unload", this);
    win.removeEventListener("TabMove", this);
    win.removeEventListener("TabOpen", this, true);
    win.removeEventListener("TabClose", this, true);
    win.removeEventListener("TabPinned", this, true);
    win.removeEventListener("TabUnpinned", this, true);
    win.removeEventListener("TabSelect", this);
    win.removeEventListener("TabGroupCreateByUser", this);
    win.removeEventListener("TabGroupRemoveRequested", this);
    win.removeEventListener("TabGrouped", this);
    win.removeEventListener("TabUngrouped", this);
    win.removeEventListener("TabGroupCollapse", this);
    win.removeEventListener("TabGroupExpand", this);
    win.removeEventListener("TabGroupSaved", this);
    win.removeEventListener("TabGroupUngroup", this);

    win.defaultView.gBrowser.tabContainer.removeEventListener(
      TAB_RESTORING_TOPIC,
      this
    );
    win.defaultView.gBrowser.removeTabsProgressListener(URICountListener);
  },

  /**
   * Updates the tab counts.
   * @param {CustomEvent} [event]
   *   `TabOpen` event
   */
  _onTabOpen(event) {
    // Update the "tab opened" count and its maximum.
    if (lazy.sidebarVerticalTabs) {
      Glean.browserEngagement.verticalTabOpenEventCount.add(1);
    } else {
      Glean.browserEngagement.tabOpenEventCount.add(1);
    }

    if (event?.target?.group) {
      Glean.tabgroup.tabInteractions.new.add();
    }

    if (event) {
      if (event.detail?.fromExternal) {
        const wasOpenedNextToActiveTab =
          this._isOpenNextToActiveTabSettingEnabled();

        Glean.linkHandling.openFromExternalApp.record({
          next_to_active_tab: wasOpenedNextToActiveTab,
        });

        if (wasOpenedNextToActiveTab) {
          externalTabMovementRegistry.externallyOpenedTabsNextToActiveTab.add(
            event.target
          );
        } else {
          externalTabMovementRegistry.externallyOpenedTabsAtEndOfTabStrip.add(
            event.target
          );
        }
      } else {
        externalTabMovementRegistry.internallyOpenedTabs.add(event.target);
      }
    }

    const userContextId = event?.target?.getAttribute("usercontextid");
    if (userContextId) {
      Glean.containers.containerTabOpened.record({
        container_id: String(userContextId),
      });
    }

    // In the case of opening multiple tabs at once, avoid enumerating all open
    // tabs and windows each time a tab opens.
    this._onTabsOpenedTask.disarm();
    this._onTabsOpenedTask.arm();
  },

  /**
   * Update tab counts after opening multiple tabs.
   */
  _onTabsOpened() {
    const { tabCount, loadedTabCount } = getOpenTabsAndWinsCounts();
    if (tabCount > this.maxTabCount) {
      this.maxTabCount = tabCount;
      this.maxTabCountGleanQuantity.set(tabCount);
    }

    this._recordTabCounts({ tabCount, loadedTabCount });
  },

  /**
   *
   * @param {CustomEvent} event
   *   TabClose event.
   */
  _onTabClosed(event) {
    const group = event.target?.group;
    const isUserTriggered = event.detail?.isUserTriggered;
    const source = event.detail?.telemetrySource;

    if (group && isUserTriggered) {
      if (source == lazy.TabMetrics.METRIC_SOURCE.TAB_STRIP) {
        Glean.tabgroup.tabInteractions.close_tabstrip.add();
      } else if (source == lazy.TabMetrics.METRIC_SOURCE.TAB_OVERFLOW_MENU) {
        Glean.tabgroup.tabInteractions.close_tabmenu.add();
      } else {
        Glean.tabgroup.tabInteractions.close_tab_other.add();
      }
    }

    const userContextId = event?.target?.getAttribute("usercontextid");
    if (userContextId) {
      Glean.containers.containerTabClosed.record({
        container_id: String(userContextId),
      });
    }

    if (event.target?.pinned) {
      const pinnedTabs = getPinnedTabsCount();
      this.recordPinnedTabsCount(pinnedTabs - 1);
      Glean.pinnedTabs.close.record({
        layout: lazy.sidebarVerticalTabs ? "vertical" : "horizontal",
      });
    }

    if (event.target) {
      // Stop tracking any tabs that have been tracked since their `TabOpen` events.
      Object.values(externalTabMovementRegistry).forEach(set => {
        set.delete(event.target);
      });
    }
  },

  _onTabPinned(event) {
    const pinnedTabs = getPinnedTabsCount();

    // Update the "tab pinned" count and its maximum.
    if (lazy.sidebarVerticalTabs) {
      Glean.browserEngagement.verticalTabPinnedEventCount.add(1);
    } else {
      Glean.browserEngagement.tabPinnedEventCount.add(1);
    }
    this.updateMaxTabPinnedCount(pinnedTabs);
    this.recordPinnedTabsCount(pinnedTabs);
    Glean.pinnedTabs.pin.record({
      layout: lazy.sidebarVerticalTabs ? "vertical" : "horizontal",
      source: event.detail?.telemetrySource,
    });
  },

  _onTabUnpinned() {
    this.recordPinnedTabsCount();
  },

  _onTabGroupCreateByUser(event) {
    Glean.tabgroup.createGroup.record({
      id: event.target.id,
      layout: lazy.sidebarVerticalTabs
        ? lazy.TabMetrics.METRIC_TABS_LAYOUT.VERTICAL
        : lazy.TabMetrics.METRIC_TABS_LAYOUT.HORIZONTAL,
      source: event.detail.telemetryUserCreateSource,
      tabs: event.target.tabs.length,
    });

    this._onTabGroupChange();
  },

  _onTabGroupSave(event) {
    const { isUserTriggered } = event.detail;

    Glean.tabgroup.save.record({
      user_triggered: isUserTriggered,
      id: event.target.id,
    });

    if (isUserTriggered) {
      Glean.tabgroup.groupInteractions.save.add(1);
    }

    this._onTabGroupChange();
  },

  _onTabGroupChange() {
    this._onTabGroupChangeTask.disarm();
    this._onTabGroupChangeTask.arm();
  },

  /**
   * @param {CustomEvent} event `TabGroupUngroup` event
   */
  _onTabGroupUngroup(event) {
    const { isUserTriggered, telemetrySource } = event.detail;
    if (isUserTriggered) {
      Glean.tabgroup.ungroup.record({ source: telemetrySource });
      // Only count explicit user actions (i.e. "Ungroup tabs" in the tab group
      // context menu) toward the total number of tab group ungroup interations.
      // This excludes implicit user actions, e.g. canceling tab group creation.
      if (telemetrySource == lazy.TabMetrics.METRIC_SOURCE.TAB_GROUP_MENU) {
        Glean.tabgroup.groupInteractions.ungroup.add(1);
      }
    }
  },

  /**
   * Returns summary statistics of a set of numbers.
   *
   * @param {number[]} data
   * @returns {{max: number, min: number, median: number, average: number}}
   */
  _getSummaryStats(data) {
    let count = data.length;
    data.sort((a, b) => a - b);
    let middleIndex = Math.floor(count / 2);

    return {
      max: data.at(-1),
      min: data.at(0),
      median:
        count % 2 == 0
          ? (data[middleIndex - 1] + data[middleIndex]) / 2
          : data[middleIndex],
      average: data.reduce((a, b) => a + b, 0) / count,
    };
  },

  _doOnTabGroupChange() {
    let totalTabs = 0;
    let totalTabsInGroups = 0;

    // Used for calculation of average and median
    let tabGroupLengths = [];

    for (let win of Services.wm.getEnumerator("navigator:browser")) {
      totalTabs += win.gBrowser.tabs.length;
      for (let group of win.gBrowser.tabGroups) {
        totalTabsInGroups += group.tabs.length;
        tabGroupLengths.push(group.tabs.length);
      }
    }

    let { max, min, median, average } = this._getSummaryStats(tabGroupLengths);

    Glean.tabgroup.tabCountInGroups.inside.set(totalTabsInGroups);
    Glean.tabgroup.tabCountInGroups.outside.set(totalTabs - totalTabsInGroups);

    Glean.tabgroup.tabsPerActiveGroup.median.set(median);
    Glean.tabgroup.tabsPerActiveGroup.average.set(average);
    Glean.tabgroup.tabsPerActiveGroup.max.set(max);
    Glean.tabgroup.tabsPerActiveGroup.min.set(min);
  },

  _onSavedTabGroupsChange() {
    this._onSavedTabGroupsChangedTask.disarm();
    this._onSavedTabGroupsChangedTask.arm();
  },

  _doOnSavedTabGroupsChange() {
    let savedGroups = lazy.SessionStore.getSavedTabGroups();
    let tabCounts = savedGroups.map(group => group.tabs.length);
    let { max, min, median, average } = this._getSummaryStats(tabCounts);

    Glean.tabgroup.savedGroups.set(savedGroups.length);

    Glean.tabgroup.tabsPerSavedGroup.median.set(median);
    Glean.tabgroup.tabsPerSavedGroup.average.set(average);
    Glean.tabgroup.tabsPerSavedGroup.max.set(max);
    Glean.tabgroup.tabsPerSavedGroup.min.set(min);
  },

  _onTabGroupExpandOrCollapse() {
    this._onTabGroupExpandOrCollapseTask.disarm();
    this._onTabGroupExpandOrCollapseTask.arm();
  },

  _doOnTabGroupExpandOrCollapse() {
    let expanded = 0,
      collapsed = 0;

    for (let win of Services.wm.getEnumerator("navigator:browser")) {
      for (let group of win.gBrowser.tabGroups) {
        if (group.collapsed) {
          collapsed += 1;
        } else {
          expanded += 1;
        }
      }
    }

    Glean.tabgroup.activeGroups.collapsed.set(collapsed);
    Glean.tabgroup.activeGroups.expanded.set(expanded);
  },

  /**
   * @param {CustomEvent} event
   */
  _onTabGroupRemoveRequested(event) {
    let {
      isUserTriggered = false,
      telemetrySource = lazy.TabMetrics.METRIC_SOURCE.UNKNOWN,
    } = event.detail;

    if (isUserTriggered) {
      Glean.tabgroup.delete.record({
        id: event.target.id,
        source: telemetrySource,
      });
      Glean.tabgroup.groupInteractions.delete.add(1);
    }
  },

  /**
   * Accumulates `TabMove` events in order to record 1 metrics event per frame
   * per telemetry source.
   *
   * For example, dragging and dropping 4 tabs should listen for 4 `TabMove`
   * events but result in 1 metrics event being recorded with a source of
   * `drag` and a tab count of 4.
   *
   * @param {CustomEvent} event
   */
  _onTabMove(event) {
    let { isUserTriggered, telemetrySource } = event.detail;

    if (!isUserTriggered) {
      return;
    }

    let groupType = "";
    if (event.target.group) {
      groupType = event.target.group.collapsed
        ? lazy.TabMetrics.METRIC_GROUP_TYPE.COLLAPSED
        : lazy.TabMetrics.METRIC_GROUP_TYPE.EXPANDED;
    }

    let segmentKey = [telemetrySource, groupType].join(",");

    let tabMovementsRecord = this._tabMovementsBySegment.get(segmentKey);
    if (!tabMovementsRecord) {
      let deferredTask = new lazy.DeferredTask(() => {
        if (tabMovementsRecord.numberAddedToTabGroup) {
          Glean.tabgroup.addTab.record({
            source: telemetrySource,
            tabs: tabMovementsRecord.numberAddedToTabGroup,
            layout: lazy.sidebarVerticalTabs
              ? lazy.TabMetrics.METRIC_TABS_LAYOUT.VERTICAL
              : lazy.TabMetrics.METRIC_TABS_LAYOUT.HORIZONTAL,
            group_type: groupType,
          });
        }
        this._tabMovementsBySegment.delete(segmentKey);
      }, 0);
      tabMovementsRecord = {
        deferredTask,
        numberAddedToTabGroup: 0,
      };
      this._tabMovementsBySegment.set(segmentKey, tabMovementsRecord);
      this._updateTabMovementsRecord(tabMovementsRecord, event);
      deferredTask.arm();
    } else {
      tabMovementsRecord.deferredTask.disarm();
      this._updateTabMovementsRecord(tabMovementsRecord, event);
      tabMovementsRecord.deferredTask.arm();
    }

    this._recordExternalTabMovement(event);
  },

  /**
   * @param {TabMovementsRecord} record
   * @param {CustomEvent} event
   */
  _updateTabMovementsRecord(record, event) {
    let { previousTabState, currentTabState } = event.detail;

    if (!previousTabState.tabGroupId && currentTabState.tabGroupId) {
      Glean.tabgroup.tabInteractions.add.add();
      record.numberAddedToTabGroup += 1;
    }

    if (
      previousTabState.tabGroupId &&
      previousTabState.tabGroupId == currentTabState.tabGroupId &&
      previousTabState.tabIndex != currentTabState.tabIndex
    ) {
      Glean.tabgroup.tabInteractions.reorder.add();
    }

    if (previousTabState.tabGroupId && !currentTabState.tabGroupId) {
      Glean.tabgroup.tabInteractions.remove_same_window.add();
    }
  },

  /**
   * @param {CustomEvent} event
   *   TabMove event
   */
  _recordExternalTabMovement(event) {
    if (externalTabMovementRegistry.internallyOpenedTabs.has(event.target)) {
      Glean.browserUiInteraction.tabMovement.not_from_external_app.add();
    } else if (
      externalTabMovementRegistry.externallyOpenedTabsNextToActiveTab.has(
        event.target
      )
    ) {
      Glean.browserUiInteraction.tabMovement.from_external_app_next_to_active_tab.add();
    } else if (
      externalTabMovementRegistry.externallyOpenedTabsAtEndOfTabStrip.has(
        event.target
      )
    ) {
      Glean.browserUiInteraction.tabMovement.from_external_app_tab_strip_end.add();
    }
  },

  _onTabSelect(event) {
    if (event.target.group) {
      let interaction = event.target.group.collapsed
        ? Glean.tabgroup.tabInteractions.activate_collapsed
        : Glean.tabgroup.tabInteractions.activate_expanded;
      interaction.add();
    }
    if (event.target.pinned) {
      const counter = lazy.sidebarVerticalTabs
        ? Glean.pinnedTabs.activations.sidebar
        : Glean.pinnedTabs.activations.horizontalBar;
      counter.add();
    }
  },

  /**
   * Tracks the window count and registers the listeners for the tab count.
   * @param{Object} win The window object.
   */
  _onWindowOpen(win) {
    // Make sure to have a |nsIDOMWindow|.
    if (!(win instanceof Ci.nsIDOMWindow)) {
      return;
    }

    let onLoad = () => {
      win.removeEventListener("load", onLoad);

      // Ignore non browser windows.
      if (
        win.document.documentElement.getAttribute("windowtype") !=
        "navigator:browser"
      ) {
        return;
      }

      this._registerWindow(win);
      // Track the window open event and check the maximum.
      const counts = getOpenTabsAndWinsCounts();
      Glean.browserEngagement.windowOpenEventCount.add(1);

      if (counts.winCount > this.maxWindowCount) {
        this.maxWindowCount = counts.winCount;
        Glean.browserEngagement.maxConcurrentWindowCount.set(counts.winCount);
      }

      // We won't receive the "TabOpen" event for the first tab within a new window.
      // Account for that.
      this._onTabOpen();
    };
    win.addEventListener("load", onLoad);
  },

  /**
   * Record telemetry about the given tab counts.
   *
   * Telemetry for each count will only be recorded if the value isn't
   * `undefined`.
   *
   * @param {object} [counts] The tab counts to register with telemetry.
   * @param {number} [counts.tabCount] The number of tabs in all browsers.
   * @param {number} [counts.loadedTabCount] The number of loaded (i.e., not
   *                                         pending) tabs in all browsers.
   */
  _recordTabCounts({ tabCount, loadedTabCount }) {
    let currentTime = Date.now();
    if (
      tabCount !== undefined &&
      currentTime > this._lastRecordTabCount + MINIMUM_TAB_COUNT_INTERVAL_MS
    ) {
      Glean.browserEngagement.tabCount.accumulateSingleSample(tabCount);
      this._lastRecordTabCount = currentTime;
    }

    if (
      loadedTabCount !== undefined &&
      currentTime >
        this._lastRecordLoadedTabCount + MINIMUM_TAB_COUNT_INTERVAL_MS
    ) {
      Glean.browserEngagement.loadedTabCount.accumulateSingleSample(
        loadedTabCount
      );
      this._lastRecordLoadedTabCount = currentTime;
    }
  },

  _checkProfileCountFileSchema(fileData) {
    // Verifies that the schema of the file is the expected schema
    if (typeof fileData.version != "string") {
      throw new Error("Schema Mismatch Error: Bad type for 'version' field");
    }
    if (!Array.isArray(fileData.profileTelemetryIds)) {
      throw new Error(
        "Schema Mismatch Error: Bad type for 'profileTelemetryIds' field"
      );
    }
    for (let profileTelemetryId of fileData.profileTelemetryIds) {
      if (typeof profileTelemetryId != "string") {
        throw new Error(
          "Schema Mismatch Error: Bad type for an element of 'profileTelemetryIds'"
        );
      }
    }
  },

  // Reports the number of Firefox profiles on this machine to telemetry.
  async reportProfileCount() {
    // Note: this is currently a windows-only feature.

    // To report only as much data as we need, we will bucket our values.
    // Rather than the raw value, we will report the greatest value in the list
    // below that is no larger than the raw value.
    const buckets = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 100, 1000, 10000];

    // We need both the C:\ProgramData\Mozilla directory and the install
    // directory hash to create the profile count file path. We can easily
    // reassemble this from the update directory, which looks like:
    // C:\ProgramData\Mozilla\updates\hash
    // Retrieving the directory this way also ensures that the "Mozilla"
    // directory is created with the correct permissions.
    // The ProgramData directory, by default, grants write permissions only to
    // file creators. The directory service calls GetCommonUpdateDirectory,
    // which makes sure the the directory is created with user-writable
    // permissions.
    const updateDirectory = BrowserUsageTelemetry.Policy.getUpdateDirectory();
    const hash = updateDirectory.leafName;
    const profileCountFilename = "profile_count_" + hash + ".json";
    let profileCountFile = updateDirectory.parent.parent;
    profileCountFile.append(profileCountFilename);

    let readError = false;
    let fileData;
    try {
      let json = await BrowserUsageTelemetry.Policy.readProfileCountFile(
        profileCountFile.path
      );
      fileData = JSON.parse(json);
      BrowserUsageTelemetry._checkProfileCountFileSchema(fileData);
    } catch (ex) {
      // Note that since this also catches the "no such file" error, this is
      // always the template that we use when writing to the file for the first
      // time.
      fileData = { version: "1", profileTelemetryIds: [] };
      if (!(ex.name == "NotFoundError")) {
        console.error(ex);
        // Don't just return here on a read error. We need to send the error
        // value to telemetry and we want to attempt to fix the file.
        // However, we will still report an error for this ping, even if we
        // fix the file. This is to prevent always sending a profile count of 1
        // if, for some reason, we always get a read error but never a write
        // error.
        readError = true;
      }
    }

    let writeError = false;
    let currentTelemetryId =
      await BrowserUsageTelemetry.Policy.getTelemetryClientId();
    // Don't add our telemetry ID to the file if we've already reached the
    // largest bucket. This prevents the file size from growing forever.
    if (
      !fileData.profileTelemetryIds.includes(currentTelemetryId) &&
      fileData.profileTelemetryIds.length < Math.max(...buckets)
    ) {
      fileData.profileTelemetryIds.push(currentTelemetryId);
      try {
        await BrowserUsageTelemetry.Policy.writeProfileCountFile(
          profileCountFile.path,
          JSON.stringify(fileData)
        );
      } catch (ex) {
        console.error(ex);
        writeError = true;
      }
    }

    // Determine the bucketed value to report
    let rawProfileCount = fileData.profileTelemetryIds.length;
    let valueToReport = 0;
    for (let bucket of buckets) {
      if (bucket <= rawProfileCount && bucket > valueToReport) {
        valueToReport = bucket;
      }
    }

    if (readError || writeError) {
      // We convey errors via a profile count of 0.
      valueToReport = 0;
    }

    Glean.browserEngagement.profileCount.set(valueToReport);
  },

  /**
   * Check if this is the first run of this profile since installation,
   * if so then collect installation telemetry.
   *
   * @param {nsIFile} [dataPathOverride] Optional, full data file path, for tests.
   * @param {Array<string>} [msixPackagePrefixes] Optional, list of prefixes to
            consider "existing" installs when looking at installed MSIX packages.
            Defaults to prefixes for builds produced in Firefox automation.
   * @return {Promise<Object>} A JSON object containing install telemetry.
   * @resolves When the event has been recorded, or if the data file was not found.
   * @rejects JavaScript exception on any failure.
   */
  async collectInstallationTelemetry(
    dataPathOverride,
    msixPackagePrefixes = ["Mozilla.Firefox", "Mozilla.MozillaFirefox"]
  ) {
    if (AppConstants.platform != "win") {
      // This is a windows-only feature.
      return {};
    }

    const TIMESTAMP_PREF = "app.installation.timestamp";
    const lastInstallTime = Services.prefs.getStringPref(TIMESTAMP_PREF, null);
    const wpm = Cc["@mozilla.org/windows-package-manager;1"].createInstance(
      Ci.nsIWindowsPackageManager
    );
    let installer_type = "";
    let pfn;
    try {
      pfn = Services.sysinfo.getProperty("winPackageFamilyName");
    } catch (e) {}

    function getInstallData() {
      // We only care about where _any_ other install existed - no
      // need to count more than 1.
      const installPaths = lazy.WindowsInstallsInfo.getInstallPaths(
        1,
        new Set([Services.dirsvc.get("GreBinD", Ci.nsIFile).path])
      );
      const msixInstalls = new Set();
      // We're just going to eat all errors here -- we don't want the event
      // to go unsent if we were unable to look for MSIX installs.
      try {
        wpm
          .findUserInstalledPackages(msixPackagePrefixes)
          .forEach(i => msixInstalls.add(i));
        if (pfn) {
          msixInstalls.delete(pfn);
        }
      } catch (ex) {}
      return {
        installPaths,
        msixInstalls,
      };
    }

    let extra = {};

    if (pfn) {
      if (lastInstallTime != null) {
        // We've already seen this install
        return {};
      }

      // First time seeing this install, record the timestamp.
      Services.prefs.setStringPref(TIMESTAMP_PREF, wpm.getInstalledDate());
      let install_data = getInstallData();

      installer_type = "msix";

      // Build the extra event data
      extra.version = AppConstants.MOZ_APP_VERSION;
      extra.build_id = AppConstants.MOZ_BUILDID;
      // The next few keys are static for the reasons described
      // No way to detect whether or not we were installed by an admin
      extra.admin_user = "false";
      // Always false at the moment, because we create a new profile
      // on first launch
      extra.profdir_existed = "false";
      // Obviously false for MSIX installs
      extra.from_msi = "false";
      // We have no way of knowing whether we were installed via the GUI,
      // through the command line, or some Enterprise management tool.
      extra.silent = "false";
      // There's no way to change the install path for an MSIX package
      extra.default_path = "true";
      extra.install_existed = install_data.msixInstalls.has(pfn).toString();
      install_data.msixInstalls.delete(pfn);
      extra.other_inst = (!!install_data.installPaths.size).toString();
      extra.other_msix_inst = (!!install_data.msixInstalls.size).toString();
    } else {
      let dataPath = dataPathOverride;
      if (!dataPath) {
        dataPath = Services.dirsvc.get("GreD", Ci.nsIFile);
        dataPath.append("installation_telemetry.json");
      }

      let dataBytes;
      try {
        dataBytes = await IOUtils.read(dataPath.path);
      } catch (ex) {
        if (ex.name == "NotFoundError") {
          // Many systems will not have the data file, return silently if not found as
          // there is nothing to record.
          return {};
        }
        throw ex;
      }
      const dataString = new TextDecoder("utf-16").decode(dataBytes);
      const data = JSON.parse(dataString);

      if (lastInstallTime && data.install_timestamp == lastInstallTime) {
        // We've already seen this install
        return {};
      }

      // First time seeing this install, record the timestamp.
      Services.prefs.setStringPref(TIMESTAMP_PREF, data.install_timestamp);
      let install_data = getInstallData();

      installer_type = data.installer_type;

      // Installation timestamp is not intended to be sent with telemetry,
      // remove it to emphasize this point.
      delete data.install_timestamp;

      // Build the extra event data
      extra.version = data.version;
      extra.build_id = data.build_id;
      extra.admin_user = data.admin_user.toString();
      extra.install_existed = data.install_existed.toString();
      extra.profdir_existed = data.profdir_existed.toString();
      extra.other_inst = (!!install_data.installPaths.size).toString();
      extra.other_msix_inst = (!!install_data.msixInstalls.size).toString();

      if (data.installer_type == "full") {
        extra.silent = data.silent.toString();
        extra.from_msi = data.from_msi.toString();
        extra.default_path = data.default_path.toString();
      }
    }
    return { installer_type, extra };
  },

  async reportInstallationTelemetry(
    dataPathOverride,
    msixPackagePrefixes = ["Mozilla.Firefox", "Mozilla.MozillaFirefox"]
  ) {
    // The optional dataPathOverride is only used for testing purposes.
    // Use this as a proxy for whether we're in a testing environment.
    // If we're in a testing environment we don't want to return the
    // same data even if we call this function multiple times in the
    // same instance.
    if (gInstallationTelemetryPromise && !dataPathOverride) {
      return gInstallationTelemetryPromise;
    }

    gInstallationTelemetryPromise = (async () => {
      let data = await BrowserUsageTelemetry.collectInstallationTelemetry(
        dataPathOverride,
        msixPackagePrefixes
      );

      if (data?.installer_type) {
        let { installer_type, extra } = data;

        // Record the event (mirrored to legacy telemetry using GIFFT)
        if (installer_type == "full") {
          Glean.installation.firstSeenFull.record(extra);
        } else if (installer_type == "stub") {
          Glean.installation.firstSeenStub.record(extra);
        } else if (installer_type == "msix") {
          Glean.installation.firstSeenMsix.record(extra);
        }

        // Scalars for the new-profile ping. We don't need to collect the build version
        // These are mirrored to legacy telemetry using GIFFT
        Glean.installationFirstSeen.installerType.set(installer_type);
        Glean.installationFirstSeen.version.set(extra.version);
        // Convert "true" or "false" strings back into booleans
        Glean.installationFirstSeen.adminUser.set(extra.admin_user === "true");
        Glean.installationFirstSeen.installExisted.set(
          extra.install_existed === "true"
        );
        Glean.installationFirstSeen.profdirExisted.set(
          extra.profdir_existed === "true"
        );
        Glean.installationFirstSeen.otherInst.set(extra.other_inst === "true");
        Glean.installationFirstSeen.otherMsixInst.set(
          extra.other_msix_inst === "true"
        );
        if (installer_type == "full") {
          Glean.installationFirstSeen.silent.set(extra.silent === "true");
          Glean.installationFirstSeen.fromMsi.set(extra.from_msi === "true");
          Glean.installationFirstSeen.defaultPath.set(
            extra.default_path === "true"
          );
        }
      }
      return data;
    })();

    return gInstallationTelemetryPromise;
  },
};

// Used by nsIBrowserUsage
export function getUniqueDomainsVisitedInPast24Hours() {
  return URICountListener.uniqueDomainsVisitedInPast24Hours;
}
