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

/**
 * This module exports the UrlbarUtils singleton, which contains constants and
 * helper functions that are useful to all components of the urlbar.
 */

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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  ContextualIdentityService:
    "resource://gre/modules/ContextualIdentityService.sys.mjs",
  FormHistory: "resource://gre/modules/FormHistory.sys.mjs",
  KeywordUtils: "resource://gre/modules/KeywordUtils.sys.mjs",
  PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs",
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
  SearchSuggestionController:
    "resource://gre/modules/SearchSuggestionController.sys.mjs",
  UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
  UrlbarProviderInterventions:
    "resource:///modules/UrlbarProviderInterventions.sys.mjs",
  UrlbarProviderOpenTabs: "resource:///modules/UrlbarProviderOpenTabs.sys.mjs",
  UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs",
  UrlbarProviderSearchTips:
    "resource:///modules/UrlbarProviderSearchTips.sys.mjs",
  UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs",
  UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs",
  BrowserUIUtils: "resource:///modules/BrowserUIUtils.sys.mjs",
});

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "parserUtils",
  "@mozilla.org/parserutils;1",
  "nsIParserUtils"
);

export var UrlbarUtils = {
  // Results are categorized into groups to help the muxer compose them.  See
  // UrlbarUtils.getResultGroup.  Since result groups are stored in result
  // groups and result groups are stored in prefs, additions and changes to
  // result groups may require adding UI migrations to BrowserGlue.  Be careful
  // about making trivial changes to existing groups, like renaming them,
  // because we don't want to make downgrades unnecessarily hard.
  RESULT_GROUP: {
    ABOUT_PAGES: "aboutPages",
    GENERAL: "general",
    GENERAL_PARENT: "generalParent",
    FORM_HISTORY: "formHistory",
    HEURISTIC_AUTOFILL: "heuristicAutofill",
    HEURISTIC_ENGINE_ALIAS: "heuristicEngineAlias",
    HEURISTIC_EXTENSION: "heuristicExtension",
    HEURISTIC_FALLBACK: "heuristicFallback",
    HEURISTIC_BOOKMARK_KEYWORD: "heuristicBookmarkKeyword",
    HEURISTIC_HISTORY_URL: "heuristicHistoryUrl",
    HEURISTIC_OMNIBOX: "heuristicOmnibox",
    HEURISTIC_RESTRICT_KEYWORD_AUTOFILL: "heuristicRestrictKeywordAutofill",
    HEURISTIC_SEARCH_TIP: "heuristicSearchTip",
    HEURISTIC_TEST: "heuristicTest",
    HEURISTIC_TOKEN_ALIAS_ENGINE: "heuristicTokenAliasEngine",
    INPUT_HISTORY: "inputHistory",
    OMNIBOX: "extension",
    RECENT_SEARCH: "recentSearch",
    REMOTE_SUGGESTION: "remoteSuggestion",
    REMOTE_TAB: "remoteTab",
    RESTRICT_SEARCH_KEYWORD: "restrictSearchKeyword",
    SUGGESTED_INDEX: "suggestedIndex",
    TAIL_SUGGESTION: "tailSuggestion",
  },

  // Defines provider types.
  PROVIDER_TYPE: {
    // Should be executed immediately, because it returns heuristic results
    // that must be handed to the user asap.
    // WARNING: these providers must be extremely fast, because the urlbar will
    // await for them before returning results to the user. In particular it is
    // critical to reply quickly to isActive and startQuery.
    HEURISTIC: 1,
    // Can be delayed, contains results coming from the session or the profile.
    PROFILE: 2,
    // Can be delayed, contains results coming from the network.
    NETWORK: 3,
    // Can be delayed, contains results coming from unknown sources.
    EXTENSION: 4,
  },

  // Defines UrlbarResult types.
  RESULT_TYPE: {
    // An open tab.
    TAB_SWITCH: 1,
    // A search suggestion or engine.
    SEARCH: 2,
    // A common url/title tuple, may be a bookmark with tags.
    URL: 3,
    // A bookmark keyword.
    KEYWORD: 4,
    // A WebExtension Omnibox result.
    OMNIBOX: 5,
    // A tab from another synced device.
    REMOTE_TAB: 6,
    // An actionable message to help the user with their query.
    TIP: 7,
    // A type of result which layout is defined at runtime.
    DYNAMIC: 8,
    // A restrict keyword result, could be @bookmarks, @history, or @tabs.
    RESTRICT: 9,

    // When you add a new type, also add its schema to
    // UrlbarUtils.RESULT_PAYLOAD_SCHEMA below.  Also consider checking if
    // consumers of "urlbar-user-start-navigation" need updating.
  },

  // This defines the source of results returned by a provider. Each provider
  // can return results from more than one source. This is used by the
  // ProvidersManager to decide which providers must be queried and which
  // results can be returned.
  // If you add new source types, consider checking if consumers of
  // "urlbar-user-start-navigation" need update as well.
  RESULT_SOURCE: {
    BOOKMARKS: 1,
    HISTORY: 2,
    SEARCH: 3,
    TABS: 4,
    OTHER_LOCAL: 5,
    OTHER_NETWORK: 6,
    ADDON: 7,
    ACTIONS: 8,
  },

  // Per-result exposure telemetry.
  EXPOSURE_TELEMETRY: {
    // Exposure telemetry will not be recorded for the result.
    NONE: 0,
    // Exposure telemetry will be recorded for the result and the result will be
    // visible in the view as usual.
    SHOWN: 1,
    // Exposure telemetry will be recorded for the result but the result will
    // not be present in the view.
    HIDDEN: 2,
  },

  // This defines icon locations that are commonly used in the UI.
  ICON: {
    // DEFAULT is defined lazily so it doesn't eagerly initialize PlacesUtils.
    EXTENSION: "chrome://mozapps/skin/extensions/extension.svg",
    HISTORY: "chrome://browser/skin/history.svg",
    SEARCH_GLASS: "chrome://global/skin/icons/search-glass.svg",
    TRENDING: "chrome://global/skin/icons/trending.svg",
    TIP: "chrome://global/skin/icons/lightbulb.svg",
  },

  // The number of results by which Page Up/Down move the selection.
  PAGE_UP_DOWN_DELTA: 5,

  // IME composition states.
  COMPOSITION: {
    NONE: 1,
    COMPOSING: 2,
    COMMIT: 3,
    CANCELED: 4,
  },

  // Limit the length of titles and URLs we display so layout doesn't spend too
  // much time building text runs.
  MAX_TEXT_LENGTH: 255,

  // Whether a result should be highlighted up to the point the user has typed
  // or after that point.
  HIGHLIGHT: {
    NONE: 0,
    TYPED: 1,
    SUGGESTED: 2,
  },

  // UrlbarProviderPlaces's autocomplete results store their titles and tags
  // together in their comments.  This separator is used to separate them.
  // After bug 1717511, we should stop using this old hack and store titles and
  // tags separately.  It's important that this be a character that no title
  // would ever have.  We use \x1F, the non-printable unit separator.
  TITLE_TAGS_SEPARATOR: "\x1F",

  // Regex matching single word hosts with an optional port; no spaces, auth or
  // path-like chars are admitted.
  REGEXP_SINGLE_WORD: /^[^\s@:/?#]+(:\d+)?$/,

  // Valid entry points for search mode. If adding a value here, please update
  // telemetry documentation and Scalars.yaml.
  SEARCH_MODE_ENTRY: new Set([
    "bookmarkmenu",
    "handoff",
    "keywordoffer",
    "oneoff",
    "historymenu",
    "other",
    "searchbutton",
    "shortcut",
    "tabmenu",
    "tabtosearch",
    "tabtosearch_onboard",
    "topsites_newtab",
    "topsites_urlbar",
    "touchbar",
    "typed",
  ]),

  // The favicon service stores icons for URLs with the following protocols.
  PROTOCOLS_WITH_ICONS: [
    "chrome:",
    "moz-extension:",
    "about:",
    "http:",
    "https:",
    "ftp:",
  ],

  // Valid URI schemes that are considered safe but don't contain
  // an authority component (e.g host:port). There are many URI schemes
  // that do not contain an authority, but these in particular have
  // some likelihood of being entered or bookmarked by a user.
  // `file:` is an exceptional case because an authority is optional
  PROTOCOLS_WITHOUT_AUTHORITY: [
    "about:",
    "data:",
    "file:",
    "javascript:",
    "view-source:",
  ],

  // Search mode objects corresponding to the local shortcuts in the view, in
  // order they appear.  Pref names are relative to the `browser.urlbar` branch.
  get LOCAL_SEARCH_MODES() {
    return [
      {
        source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS,
        restrict: lazy.UrlbarTokenizer.RESTRICT.BOOKMARK,
        icon: "chrome://browser/skin/bookmark.svg",
        pref: "shortcuts.bookmarks",
        telemetryLabel: "bookmarks",
        uiLabel: "urlbar-searchmode-bookmarks",
      },
      {
        source: UrlbarUtils.RESULT_SOURCE.TABS,
        restrict: lazy.UrlbarTokenizer.RESTRICT.OPENPAGE,
        icon: "chrome://browser/skin/tabs.svg",
        pref: "shortcuts.tabs",
        telemetryLabel: "tabs",
        uiLabel: "urlbar-searchmode-tabs",
      },
      {
        source: UrlbarUtils.RESULT_SOURCE.HISTORY,
        restrict: lazy.UrlbarTokenizer.RESTRICT.HISTORY,
        icon: "chrome://browser/skin/history.svg",
        pref: "shortcuts.history",
        telemetryLabel: "history",
        uiLabel: "urlbar-searchmode-history",
      },
      {
        source: UrlbarUtils.RESULT_SOURCE.ACTIONS,
        restrict: lazy.UrlbarTokenizer.RESTRICT.ACTION,
        icon: "chrome://browser/skin/quickactions.svg",
        pref: "shortcuts.actions",
        telemetryLabel: "actions",
        uiLabel: "urlbar-searchmode-actions",
      },
    ];
  },

  /**
   * Returns the payload schema for the given type of result.
   *
   * @param {number} type One of the UrlbarUtils.RESULT_TYPE values.
   * @returns {object} The schema for the given type.
   */
  getPayloadSchema(type) {
    return UrlbarUtils.RESULT_PAYLOAD_SCHEMA[type];
  },

  /**
   * Adds a url to history as long as it isn't in a private browsing window,
   * and it is valid.
   *
   * @param {string} url The url to add to history.
   * @param {nsIDomWindow} window The window from where the url is being added.
   */
  addToUrlbarHistory(url, window) {
    if (
      !lazy.PrivateBrowsingUtils.isWindowPrivate(window) &&
      url &&
      !url.includes(" ") &&
      // eslint-disable-next-line no-control-regex
      !/[\x00-\x1F]/.test(url)
    ) {
      lazy.PlacesUIUtils.markPageAsTyped(url);
    }
  },

  /**
   * Given a string, will generate a more appropriate urlbar value if a Places
   * keyword or a search alias is found at the beginning of it.
   *
   * @param {string} url
   *        A string that may begin with a keyword or an alias.
   *
   * @returns {Promise<{ url, postData, mayInheritPrincipal }>}
   *        If it's not possible to discern a keyword or an alias, url will be
   *        the input string.
   */
  async getShortcutOrURIAndPostData(url) {
    let mayInheritPrincipal = false;
    let postData = null;
    // Split on the first whitespace.
    let [keyword, param = ""] = url.trim().split(/\s(.+)/, 2);

    if (!keyword) {
      return { url, postData, mayInheritPrincipal };
    }

    let engine = await Services.search.getEngineByAlias(keyword);
    if (engine) {
      let submission = engine.getSubmission(param, null, "keyword");
      return {
        url: submission.uri.spec,
        postData: submission.postData,
        mayInheritPrincipal,
      };
    }

    // A corrupt Places database could make this throw, breaking navigation
    // from the location bar.
    let entry = null;
    try {
      entry = await lazy.PlacesUtils.keywords.fetch(keyword);
    } catch (ex) {
      console.error(`Unable to fetch Places keyword "${keyword}":`, ex);
    }
    if (!entry || !entry.url) {
      // This is not a Places keyword.
      return { url, postData, mayInheritPrincipal };
    }

    try {
      [url, postData] = await lazy.KeywordUtils.parseUrlAndPostData(
        entry.url.href,
        entry.postData,
        param
      );
      if (postData) {
        postData = this.getPostDataStream(postData);
      }

      // Since this URL came from a bookmark, it's safe to let it inherit the
      // current document's principal.
      mayInheritPrincipal = true;
    } catch (ex) {
      // It was not possible to bind the param, just use the original url value.
    }

    return { url, postData, mayInheritPrincipal };
  },

  /**
   * Returns an input stream wrapper for the given post data.
   *
   * @param {string} postDataString The string to wrap.
   * @param {string} [type] The encoding type.
   * @returns {nsIInputStream} An input stream of the wrapped post data.
   */
  getPostDataStream(
    postDataString,
    type = "application/x-www-form-urlencoded"
  ) {
    let dataStream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
      Ci.nsIStringInputStream
    );
    dataStream.setByteStringData(postDataString);

    let mimeStream = Cc[
      "@mozilla.org/network/mime-input-stream;1"
    ].createInstance(Ci.nsIMIMEInputStream);
    mimeStream.addHeader("Content-Type", type);
    mimeStream.setData(dataStream);
    return mimeStream.QueryInterface(Ci.nsIInputStream);
  },

  _compareIgnoringDiacritics: null,

  /**
   * Returns a list of all the token substring matches in a string.  Matching is
   * case insensitive.  Each match in the returned list is a tuple: [matchIndex,
   * matchLength].  matchIndex is the index in the string of the match, and
   * matchLength is the length of the match.
   *
   * @param {Array} tokens The tokens to search for.
   * @param {string} str The string to match against.
   * @param {boolean} highlightType
   *   One of the HIGHLIGHT values:
   *     TYPED: match ranges matching the tokens; or
   *     SUGGESTED: match ranges for words not matching the tokens and the
   *                endings of words that start with a token.
   * @returns {Array} An array: [
   *            [matchIndex_0, matchLength_0],
   *            [matchIndex_1, matchLength_1],
   *            ...
   *            [matchIndex_n, matchLength_n]
   *          ].
   *          The array is sorted by match indexes ascending.
   */
  getTokenMatches(tokens, str, highlightType) {
    // Only search a portion of the string, because not more than a certain
    // amount of characters are visible in the UI, matching over what is visible
    // would be expensive and pointless.
    str = str.substring(0, UrlbarUtils.MAX_TEXT_LENGTH).toLocaleLowerCase();
    // To generate non-overlapping ranges, we start from a 0-filled array with
    // the same length of the string, and use it as a collision marker, setting
    // 1 where the text should be highlighted.
    let hits = new Array(str.length).fill(
      highlightType == this.HIGHLIGHT.SUGGESTED ? 1 : 0
    );
    let compareIgnoringDiacritics;
    for (let i = 0, totalTokensLength = 0; i < tokens.length; i++) {
      const { lowerCaseValue: needle } = tokens[i];

      // Ideally we should never hit the empty token case, but just in case
      // the `needle` check protects us from an infinite loop.
      if (!needle) {
        continue;
      }
      let index = 0;
      let found = false;
      // First try a diacritic-sensitive search.
      for (;;) {
        index = str.indexOf(needle, index);
        if (index < 0) {
          break;
        }

        if (highlightType == UrlbarUtils.HIGHLIGHT.SUGGESTED) {
          // We de-emphasize the match only if it's preceded by a space, thus
          // it's a perfect match or the beginning of a longer word.
          let previousSpaceIndex = str.lastIndexOf(" ", index) + 1;
          if (index != previousSpaceIndex) {
            index += needle.length;
            // We found the token but we won't de-emphasize it, because it's not
            // after a word boundary.
            found = true;
            continue;
          }
        }

        hits.fill(
          highlightType == this.HIGHLIGHT.SUGGESTED ? 0 : 1,
          index,
          index + needle.length
        );
        index += needle.length;
        found = true;
      }
      // If that fails to match anything, try a (computationally intensive)
      // diacritic-insensitive search.
      if (!found) {
        if (!compareIgnoringDiacritics) {
          if (!this._compareIgnoringDiacritics) {
            // Diacritic insensitivity in the search engine follows a set of
            // general rules that are not locale-dependent, so use a generic
            // English collator for highlighting matching words instead of a
            // collator for the user's particular locale.
            this._compareIgnoringDiacritics = new Intl.Collator("en", {
              sensitivity: "base",
            }).compare;
          }
          compareIgnoringDiacritics = this._compareIgnoringDiacritics;
        }
        index = 0;
        while (index < str.length) {
          let hay = str.substr(index, needle.length);
          if (compareIgnoringDiacritics(needle, hay) === 0) {
            if (highlightType == UrlbarUtils.HIGHLIGHT.SUGGESTED) {
              let previousSpaceIndex = str.lastIndexOf(" ", index) + 1;
              if (index != previousSpaceIndex) {
                index += needle.length;
                continue;
              }
            }
            hits.fill(
              highlightType == this.HIGHLIGHT.SUGGESTED ? 0 : 1,
              index,
              index + needle.length
            );
            index += needle.length;
          } else {
            index++;
          }
        }
      }

      totalTokensLength += needle.length;
      if (totalTokensLength > UrlbarUtils.MAX_TEXT_LENGTH) {
        // Limit the number of tokens to reduce calculate time.
        break;
      }
    }
    // Starting from the collision array, generate [start, len] tuples
    // representing the ranges to be highlighted.
    let ranges = [];
    for (let index = hits.indexOf(1); index >= 0 && index < hits.length; ) {
      let len = 0;
      // eslint-disable-next-line no-empty
      for (let j = index; j < hits.length && hits[j]; ++j, ++len) {}
      ranges.push([index, len]);
      // Move to the next 1.
      index = hits.indexOf(1, index + len);
    }
    return ranges;
  },

  /**
   * Returns the group for a result.
   *
   * @param {UrlbarResult} result
   *   The result.
   * @returns {UrlbarUtils.RESULT_GROUP}
   *   The reuslt's group.
   */
  getResultGroup(result) {
    if (result.group) {
      return result.group;
    }

    if (result.hasSuggestedIndex && !result.isSuggestedIndexRelativeToGroup) {
      return UrlbarUtils.RESULT_GROUP.SUGGESTED_INDEX;
    }
    if (result.heuristic) {
      switch (result.providerName) {
        case "AliasEngines":
          return UrlbarUtils.RESULT_GROUP.HEURISTIC_ENGINE_ALIAS;
        case "Autofill":
          return UrlbarUtils.RESULT_GROUP.HEURISTIC_AUTOFILL;
        case "BookmarkKeywords":
          return UrlbarUtils.RESULT_GROUP.HEURISTIC_BOOKMARK_KEYWORD;
        case "HeuristicFallback":
          return UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK;
        case "Omnibox":
          return UrlbarUtils.RESULT_GROUP.HEURISTIC_OMNIBOX;
        case "RestrictKeywordsAutofill":
          return UrlbarUtils.RESULT_GROUP.HEURISTIC_RESTRICT_KEYWORD_AUTOFILL;
        case "TokenAliasEngines":
          return UrlbarUtils.RESULT_GROUP.HEURISTIC_TOKEN_ALIAS_ENGINE;
        case "UrlbarProviderSearchTips":
          return UrlbarUtils.RESULT_GROUP.HEURISTIC_SEARCH_TIP;
        case "HistoryUrlHeuristic":
          return UrlbarUtils.RESULT_GROUP.HEURISTIC_HISTORY_URL;
        default:
          if (result.providerName.startsWith("TestProvider")) {
            return UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST;
          }
          break;
      }
      if (result.providerType == UrlbarUtils.PROVIDER_TYPE.EXTENSION) {
        return UrlbarUtils.RESULT_GROUP.HEURISTIC_EXTENSION;
      }
      console.error(
        "Returning HEURISTIC_FALLBACK for unrecognized heuristic result: ",
        result
      );
      return UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK;
    }

    switch (result.providerName) {
      case "AboutPages":
        return UrlbarUtils.RESULT_GROUP.ABOUT_PAGES;
      case "InputHistory":
        return UrlbarUtils.RESULT_GROUP.INPUT_HISTORY;
      case "UrlbarProviderQuickSuggest":
        return UrlbarUtils.RESULT_GROUP.GENERAL_PARENT;
      default:
        break;
    }

    switch (result.type) {
      case UrlbarUtils.RESULT_TYPE.SEARCH:
        if (result.source == UrlbarUtils.RESULT_SOURCE.HISTORY) {
          return result.providerName == "RecentSearches"
            ? UrlbarUtils.RESULT_GROUP.RECENT_SEARCH
            : UrlbarUtils.RESULT_GROUP.FORM_HISTORY;
        }
        if (result.payload.tail && !result.isRichSuggestion) {
          return UrlbarUtils.RESULT_GROUP.TAIL_SUGGESTION;
        }
        if (result.payload.suggestion) {
          return UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION;
        }
        break;
      case UrlbarUtils.RESULT_TYPE.OMNIBOX:
        return UrlbarUtils.RESULT_GROUP.OMNIBOX;
      case UrlbarUtils.RESULT_TYPE.REMOTE_TAB:
        return UrlbarUtils.RESULT_GROUP.REMOTE_TAB;
      case UrlbarUtils.RESULT_TYPE.RESTRICT:
        return UrlbarUtils.RESULT_GROUP.RESTRICT_SEARCH_KEYWORD;
    }
    return UrlbarUtils.RESULT_GROUP.GENERAL;
  },

  /**
   * Extracts the URL from a result.
   *
   * @param {UrlbarResult} result
   *   The result to extract from.
   * @returns {object}
   *   An object: `{ url, postData }`
   *   `url` will be null if the result doesn't have a URL. `postData` will be
   *   null if the result doesn't have post data.
   */
  getUrlFromResult(result) {
    if (
      result.type == UrlbarUtils.RESULT_TYPE.SEARCH &&
      result.payload.engine
    ) {
      const engine = Services.search.getEngineByName(result.payload.engine);
      let [url, postData] = this.getSearchQueryUrl(
        engine,
        result.payload.suggestion || result.payload.query
      );
      return { url, postData };
    }

    return {
      url: result.payload.url ?? null,
      postData: result.payload.postData
        ? this.getPostDataStream(result.payload.postData)
        : null,
    };
  },

  /**
   * Get the url to load for the search query.
   *
   * @param {nsISearchEngine} engine
   *   The engine to generate the query for.
   * @param {string} query
   *   The query string to search for.
   * @returns {Array}
   *   Returns an array containing the query url (string) and the
   *    post data (object).
   */
  getSearchQueryUrl(engine, query) {
    let submission = engine.getSubmission(query, null, "keyword");
    return [submission.uri.spec, submission.postData];
  },

  // Ranks a URL prefix from 3 - 0 with the following preferences:
  // https:// > https://www. > http:// > http://www.
  // Higher is better for the purposes of deduping URLs.
  // Returns -1 if the prefix does not match any of the above.
  getPrefixRank(prefix) {
    return ["http://www.", "http://", "https://www.", "https://"].indexOf(
      prefix
    );
  },

  /**
   * Gets the number of rows a result should span in the view.
   *
   * @param {UrlbarResult} result
   *   The result.
   * @param {bool} includeHiddenExposures
   *   Whether a span should be returned if the result is a hidden exposure. If
   *   false and `result.isHiddenExposure` is true, zero will be returned since
   *   the result should be hidden and not take up any rows at all. Otherwise
   *   the result's true span is returned.
   * @returns {number}
   *   The number of rows the result should span in the view.
   */
  getSpanForResult(result, { includeHiddenExposures = false } = {}) {
    if (!includeHiddenExposures && result.isHiddenExposure) {
      return 0;
    }

    if (result.resultSpan) {
      return result.resultSpan;
    }

    switch (result.type) {
      case UrlbarUtils.RESULT_TYPE.URL:
      case UrlbarUtils.RESULT_TYPE.BOOKMARKS:
      case UrlbarUtils.RESULT_TYPE.REMOTE_TAB:
      case UrlbarUtils.RESULT_TYPE.TAB_SWITCH:
      case UrlbarUtils.RESULT_TYPE.KEYWORD:
      case UrlbarUtils.RESULT_TYPE.SEARCH:
      case UrlbarUtils.RESULT_TYPE.OMNIBOX:
        return 1;
      case UrlbarUtils.RESULT_TYPE.TIP:
        return 3;
    }
    return 1;
  },

  /**
   * Gets a default icon for a URL.
   *
   * @param {string} url
   *   The URL to get the icon for.
   * @returns {string} A URI pointing to an icon for `url`.
   */
  getIconForUrl(url) {
    if (typeof url == "string") {
      return UrlbarUtils.PROTOCOLS_WITH_ICONS.some(p => url.startsWith(p))
        ? "page-icon:" + url
        : UrlbarUtils.ICON.DEFAULT;
    }
    if (
      URL.isInstance(url) &&
      UrlbarUtils.PROTOCOLS_WITH_ICONS.includes(url.protocol)
    ) {
      return "page-icon:" + url.href;
    }
    return UrlbarUtils.ICON.DEFAULT;
  },

  /**
   * Returns a search mode object if a token should enter search mode when
   * typed. This does not handle engine aliases.
   *
   * @param {UrlbarUtils.RESTRICT} token
   *   A restriction token to convert to search mode.
   * @returns {object}
   *   A search mode object. Null if search mode should not be entered. See
   *   setSearchMode documentation for details.
   */
  searchModeForToken(token) {
    if (token == lazy.UrlbarTokenizer.RESTRICT.SEARCH) {
      return {
        engineName: lazy.UrlbarSearchUtils.getDefaultEngine(this.isPrivate)
          ?.name,
      };
    }

    let mode = UrlbarUtils.LOCAL_SEARCH_MODES.find(m => m.restrict == token);
    if (!mode) {
      return null;
    }

    // Return a copy so callers don't modify the object in LOCAL_SEARCH_MODES.
    return { ...mode };
  },

  /**
   * Tries to initiate a speculative connection to a given url.
   *
   * Note: This is not infallible, if a speculative connection cannot be
   *       initialized, it will be a no-op.
   *
   * @param {nsISearchEngine|nsIURI|URL|string} urlOrEngine entity to initiate
   *        a speculative connection for.
   * @param {window} window the window from where the connection is initialized.
   */
  setupSpeculativeConnection(urlOrEngine, window) {
    if (!lazy.UrlbarPrefs.get("speculativeConnect.enabled")) {
      return;
    }
    if (urlOrEngine instanceof Ci.nsISearchEngine) {
      try {
        urlOrEngine.speculativeConnect({
          window,
          originAttributes: window.gBrowser.contentPrincipal.originAttributes,
        });
      } catch (ex) {
        // Can't setup speculative connection for this url, just ignore it.
      }
      return;
    }

    if (URL.isInstance(urlOrEngine)) {
      urlOrEngine = urlOrEngine.href;
    }

    try {
      let uri =
        urlOrEngine instanceof Ci.nsIURI
          ? urlOrEngine
          : Services.io.newURI(urlOrEngine);
      Services.io.speculativeConnect(
        uri,
        window.gBrowser.contentPrincipal,
        null,
        false
      );
    } catch (ex) {
      // Can't setup speculative connection for this url, just ignore it.
    }
  },

  /**
   * Splits a url into base and ref strings, according to nsIURI.idl.
   * Base refers to the part of the url before the ref, excluding the #.
   *
   * @param {string} url
   *   The url to split.
   * @returns {object} { base, ref }
   *   Base and ref parts of the given url. Ref is an empty string
   *   if there is no ref and undefined if url is not well-formed.
   */
  extractRefFromUrl(url) {
    try {
      let nsUri = Services.io.newURI(url);
      return { base: nsUri.specIgnoringRef, ref: nsUri.ref };
    } catch {
      return { base: url };
    }
  },

  /**
   * Strips parts of a URL defined in `options`.
   *
   * @param {string} spec
   *        The text to modify.
   * @param {object} [options]
   *        The options object.
   * @param {boolean} options.stripHttp
   *        Whether to strip http.
   * @param {boolean} options.stripHttps
   *        Whether to strip https.
   * @param {boolean} options.stripWww
   *        Whether to strip `www.`.
   * @param {boolean} options.trimSlash
   *        Whether to trim the trailing slash.
   * @param {boolean} options.trimEmptyQuery
   *        Whether to trim a trailing `?`.
   * @param {boolean} options.trimEmptyHash
   *        Whether to trim a trailing `#`.
   * @param {boolean} options.trimTrailingDot
   *        Whether to trim a trailing '.'.
   * @returns {string[]} [modified, prefix, suffix]
   *          modified: {string} The modified spec.
   *          prefix: {string} The parts stripped from the prefix, if any.
   *          suffix: {string} The parts trimmed from the suffix, if any.
   */
  stripPrefixAndTrim(spec, options = {}) {
    let prefix = "";
    let suffix = "";
    if (options.stripHttp && spec.startsWith("http://")) {
      spec = spec.slice(7);
      prefix = "http://";
    } else if (options.stripHttps && spec.startsWith("https://")) {
      spec = spec.slice(8);
      prefix = "https://";
    }
    if (options.stripWww && spec.startsWith("www.")) {
      spec = spec.slice(4);
      prefix += "www.";
    }
    if (options.trimEmptyHash && spec.endsWith("#")) {
      spec = spec.slice(0, -1);
      suffix = "#" + suffix;
    }
    if (options.trimEmptyQuery && spec.endsWith("?")) {
      spec = spec.slice(0, -1);
      suffix = "?" + suffix;
    }
    if (options.trimSlash && spec.endsWith("/")) {
      spec = spec.slice(0, -1);
      suffix = "/" + suffix;
    }
    if (options.trimTrailingDot && spec.endsWith(".")) {
      spec = spec.slice(0, -1);
      suffix = "." + suffix;
    }
    return [spec, prefix, suffix];
  },

  /**
   * Strips a PSL verified public suffix from an hostname.
   *
   * Note: Because stripping the full suffix requires to verify it against the
   *   Public Suffix List, this call is not the cheapest, and thus it should
   *   not be used in hot paths.
   *
   * @param {string} host A host name.
   * @returns {string} Host name without the public suffix.
   */
  stripPublicSuffixFromHost(host) {
    try {
      return host.substring(
        0,
        host.length - Services.eTLD.getKnownPublicSuffixFromHost(host).length
      );
    } catch (ex) {
      if (ex.result != Cr.NS_ERROR_HOST_IS_IP_ADDRESS) {
        throw ex;
      }
    }
    return host;
  },

  /**
   * Used to filter out the javascript protocol from URIs, since we don't
   * support LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL for those.
   *
   * @param {string} pasteData The data to check for javacript protocol.
   * @returns {string} The modified paste data.
   */
  stripUnsafeProtocolOnPaste(pasteData) {
    for (;;) {
      let scheme = "";
      try {
        scheme = Services.io.extractScheme(pasteData);
      } catch (ex) {
        // If it throws, this is not a javascript scheme.
      }
      if (scheme != "javascript") {
        break;
      }

      pasteData = pasteData.substring(pasteData.indexOf(":") + 1);
    }
    return pasteData;
  },

  /**
   * Add a (url, input) tuple to the input history table that drives adaptive
   * results.
   *
   * @param {string} url The url to add input history for
   * @param {string} input The associated search term
   */
  async addToInputHistory(url, input) {
    await lazy.PlacesUtils.withConnectionWrapper("addToInputHistory", db => {
      // use_count will asymptotically approach the max of 10.
      return db.executeCached(
        `
        INSERT OR REPLACE INTO moz_inputhistory
        SELECT h.id, IFNULL(i.input, :input), IFNULL(i.use_count, 0) * .9 + 1
        FROM moz_places h
        LEFT JOIN moz_inputhistory i ON i.place_id = h.id AND i.input = :input
        WHERE url_hash = hash(:url) AND url = :url
      `,
        { url, input: input.toLowerCase() }
      );
    });
  },

  /**
   * Remove a (url, input*) tuple from the input history table that drives
   * adaptive results.
   * Note the input argument is used as a wildcard so any match starting with
   * it will also be removed.
   *
   * @param {string} url The url to add input history for
   * @param {string} input The associated search term
   */
  async removeInputHistory(url, input) {
    await lazy.PlacesUtils.withConnectionWrapper("removeInputHistory", db => {
      return db.executeCached(
        `
        DELETE FROM moz_inputhistory
        WHERE input BETWEEN :input AND :input || X'FFFF'
          AND place_id =
            (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url)
        `,
        { url, input: input.toLowerCase() }
      );
    });
  },

  /**
   * Whether the passed-in input event is paste event.
   *
   * @param {DOMEvent} event an input DOM event.
   * @returns {boolean} Whether the event is a paste event.
   */
  isPasteEvent(event) {
    return (
      event.inputType &&
      (event.inputType.startsWith("insertFromPaste") ||
        event.inputType == "insertFromYank")
    );
  },

  /**
   * Given a string, checks if it looks like a single word host, not containing
   * spaces nor dots (apart from a possible trailing one).
   *
   * Note: This matching should stay in sync with the related code in
   * URIFixup::KeywordURIFixup
   *
   * @param {string} value
   *   The string to check.
   * @returns {boolean}
   *   Whether the value looks like a single word host.
   */
  looksLikeSingleWordHost(value) {
    let str = value.trim();
    return this.REGEXP_SINGLE_WORD.test(str);
  },

  /**
   * Returns the portion of a string starting at the index where another string
   * begins.
   *
   * @param   {string} sourceStr
   *          The string to search within.
   * @param   {string} targetStr
   *          The string to search for.
   * @returns {string} The substring within sourceStr starting at targetStr, or
   *          the empty string if targetStr does not occur in sourceStr.
   */
  substringAt(sourceStr, targetStr) {
    let index = sourceStr.indexOf(targetStr);
    return index < 0 ? "" : sourceStr.substr(index);
  },

  /**
   * Returns the portion of a string starting at the index where another string
   * ends.
   *
   * @param   {string} sourceStr
   *          The string to search within.
   * @param   {string} targetStr
   *          The string to search for.
   * @returns {string} The substring within sourceStr where targetStr ends, or
   *          the empty string if targetStr does not occur in sourceStr.
   */
  substringAfter(sourceStr, targetStr) {
    let index = sourceStr.indexOf(targetStr);
    return index < 0 ? "" : sourceStr.substr(index + targetStr.length);
  },

  /**
   * Strips the prefix from a URL and returns the prefix and the remainder of
   * the URL. "Prefix" is defined to be the scheme and colon plus zero to two
   * slashes (see `UrlbarTokenizer.REGEXP_PREFIX`). If the given string is not
   * actually a URL or it has a prefix we don't recognize, then an empty prefix
   * and the string itself is returned.
   *
   * @param   {string} str The possible URL to strip.
   * @returns {Array} If `str` is a URL with a prefix we recognize,
   *          then [prefix, remainder].  Otherwise, ["", str].
   */
  stripURLPrefix(str) {
    let match = lazy.UrlbarTokenizer.REGEXP_PREFIX.exec(str);
    if (!match) {
      return ["", str];
    }
    let prefix = match[0];
    if (prefix.length < str.length && str[prefix.length] == " ") {
      // A space following a prefix:
      // e.g. "http:// some search string", "about: some search string"
      return ["", str];
    }
    if (
      prefix.endsWith(":") &&
      !UrlbarUtils.PROTOCOLS_WITHOUT_AUTHORITY.includes(prefix.toLowerCase())
    ) {
      // Something that looks like a URI scheme but we won't treat as one:
      // e.g. "localhost:8888"
      return ["", str];
    }
    return [prefix, str.substring(prefix.length)];
  },

  /**
   * Runs a search for the given string, and returns the heuristic result.
   *
   * @param {string} searchString The string to search for.
   * @param {nsIDOMWindow} window The window requesting it.
   * @returns {UrlbarResult} an heuristic result.
   */
  async getHeuristicResultFor(searchString, window) {
    if (!searchString) {
      throw new Error("Must pass a non-null search string");
    }

    let options = {
      allowAutofill: false,
      isPrivate: lazy.PrivateBrowsingUtils.isWindowPrivate(window),
      maxResults: 1,
      searchString,
      userContextId: parseInt(
        window.gBrowser.selectedBrowser.getAttribute("usercontextid") || 0
      ),
      prohibitRemoteResults: true,
      providers: ["AliasEngines", "BookmarkKeywords", "HeuristicFallback"],
    };
    if (window.gURLBar.searchMode) {
      let searchMode = window.gURLBar.searchMode;
      options.searchMode = searchMode;
      if (searchMode.source) {
        options.sources = [searchMode.source];
      }
    }
    let context = new UrlbarQueryContext(options);
    await lazy.UrlbarProvidersManager.startQuery(context);
    if (!context.heuristicResult) {
      throw new Error("There should always be an heuristic result");
    }
    return context.heuristicResult;
  },

  /**
   * Creates a console logger.
   * Logging level can be controlled through the `browser.urlbar.loglevel`
   * preference.
   *
   * @param {object} [options] Options for the logger.
   * @param {string} [options.prefix] Prefix to use for the logged messages.
   * @returns {ConsoleInstance} The console logger.
   */
  getLogger({ prefix = "" } = {}) {
    if (!this._loggers) {
      this._loggers = new Map();
    }
    let logger = this._loggers.get(prefix);
    if (!logger) {
      logger = console.createInstance({
        prefix: `URLBar${prefix ? " - " + prefix : ""}`,
        maxLogLevelPref: "browser.urlbar.loglevel",
      });
      this._loggers.set(prefix, logger);
    }
    return logger;
  },

  /**
   * Returns the name of a result source.  The name is the lowercase name of the
   * corresponding property in the RESULT_SOURCE object.
   *
   * @param {string} source A UrlbarUtils.RESULT_SOURCE value.
   * @returns {string} The token's name, a lowercased name in the RESULT_SOURCE
   *   object.
   */
  getResultSourceName(source) {
    if (!this._resultSourceNamesBySource) {
      this._resultSourceNamesBySource = new Map();
      for (let [name, src] of Object.entries(this.RESULT_SOURCE)) {
        this._resultSourceNamesBySource.set(src, name.toLowerCase());
      }
    }
    return this._resultSourceNamesBySource.get(source);
  },

  /**
   * Add the search to form history.  This also updates any existing form
   * history for the search.
   *
   * @param {UrlbarInput} input The UrlbarInput object requesting the addition.
   * @param {string} value The value to add.
   * @param {string} [source] The source of the addition, usually
   *        the name of the engine the search was made with.
   * @returns {Promise} resolved once the operation is complete
   */
  addToFormHistory(input, value, source) {
    // If the user types a search engine alias without a search string,
    // we have an empty search string and we can't bump it.
    // We also don't want to add history in private browsing mode.
    // Finally we don't want to store extremely long strings that would not be
    // particularly useful to the user.
    if (
      !value ||
      input.isPrivate ||
      value.length >
        lazy.SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH
    ) {
      return Promise.resolve();
    }
    return lazy.FormHistory.update({
      op: "bump",
      fieldname: input.formHistoryName,
      value,
      source,
    });
  },

  /**
   * Returns whether a URL can be autofilled from a candidate string. This
   * function is specifically designed for origin and up-to-the-next-slash URL
   * autofill. It should not be used for other types of autofill.
   *
   * @param {string} url
   *                 The URL to test
   * @param {string} candidate
   *                 The candidate string to test against
   * @param {string} checkFragmentOnly
   *                 If want to check the fragment only, pass true.
   *                 Otherwise, check whole url.
   * @returns {boolean} true: can autofill
   */
  canAutofillURL(url, candidate, checkFragmentOnly = false) {
    // If the URL does not start with the candidate, it can't be autofilled.
    // The length check is an optimization to short-circuit the `startsWith()`.
    if (
      !checkFragmentOnly &&
      (url.length <= candidate.length ||
        !url.toLocaleLowerCase().startsWith(candidate.toLocaleLowerCase()))
    ) {
      return false;
    }

    // Create `URL` objects to make the logic below easier. The strings must
    // include schemes for this to work.
    if (!lazy.UrlbarTokenizer.REGEXP_PREFIX.test(url)) {
      url = "http://" + url;
    }
    if (!lazy.UrlbarTokenizer.REGEXP_PREFIX.test(candidate)) {
      candidate = "http://" + candidate;
    }
    try {
      url = new URL(url);
      candidate = new URL(candidate);
    } catch (e) {
      return false;
    }

    if (checkFragmentOnly) {
      return url.hash.startsWith(candidate.hash);
    }

    // For both origin and URL autofill, autofill should stop when the user
    // types a trailing slash. This is a fundamental part of autofill's
    // up-to-the-next-slash behavior. We handle that here in the else-if branch.
    // The length and hash checks in the else-if condition aren't strictly
    // necessary -- the else-if branch could simply be an else-branch that
    // returns false -- but they mean this function will return true when the
    // URL and candidate have the same case-insenstive path and no hash. In
    // other words, we allow a URL to autofill itself.
    if (!candidate.href.endsWith("/")) {
      // The candidate doesn't end in a slash. The URL can't be autofilled if
      // its next slash is not at the end.
      let nextSlashIndex = url.pathname.indexOf("/", candidate.pathname.length);
      if (nextSlashIndex >= 0 && nextSlashIndex != url.pathname.length - 1) {
        return false;
      }
    } else if (url.pathname.length > candidate.pathname.length || url.hash) {
      return false;
    }

    return url.hash.startsWith(candidate.hash);
  },

  /**
   * Extracts a telemetry type from a result, used by scalars and event
   * telemetry.
   *
   * Note: New types should be added to Scalars.yaml under the urlbar.picked
   *       category and documented in the in-tree documentation. A data-review
   *       is always necessary.
   *
   * @param {UrlbarResult} result The result to analyze.
   * @param {boolean} camelCase Whether the returned telemetry type should be the
                                camelCase version.
                                Eventually this should be the default (bug 1928946).
   * @returns {string} A string type for telemetry.
   */
  telemetryTypeFromResult(result, camelCase = false) {
    if (!result) {
      return "unknown";
    }
    switch (result.type) {
      case UrlbarUtils.RESULT_TYPE.TAB_SWITCH:
        return "switchtab";
      case UrlbarUtils.RESULT_TYPE.SEARCH:
        if (result.providerName == "RecentSearches") {
          return camelCase ? "recentSearch" : "recent_search";
        }
        if (result.source == UrlbarUtils.RESULT_SOURCE.HISTORY) {
          return "formhistory";
        }
        if (result.providerName == "TabToSearch") {
          return "tabtosearch";
        }
        if (result.payload.suggestion) {
          let type = result.payload.trending ? "trending" : "searchsuggestion";
          if (result.isRichSuggestion) {
            type += camelCase ? "Rich" : "_rich";
          }
          return type;
        }
        return "searchengine";
      case UrlbarUtils.RESULT_TYPE.URL:
        if (result.autofill) {
          let { type } = result.autofill;
          if (!type) {
            type = "other";
            console.error(
              new Error(
                "`result.autofill.type` not set, falling back to 'other'"
              )
            );
          }
          if (camelCase) {
            return `autofill${type[0].toUpperCase()}${type.slice(1)}`;
          }
          return `autofill_${type}`;
        }
        if (
          result.source == UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL &&
          result.heuristic
        ) {
          return "visiturl";
        }
        if (result.providerName == "UrlbarProviderQuickSuggest") {
          // Don't add any more `urlbar.picked` legacy telemetry if possible!
          // Return "quicksuggest" here and rely on Glean instead.
          switch (result.payload.telemetryType) {
            case "top_picks":
              return "navigational";
            case "wikipedia":
              return camelCase ? "dynamicWikipedia" : "dynamic_wikipedia";
          }
          return "quicksuggest";
        }
        if (result.providerName == "UrlbarProviderClipboard") {
          return "clipboard";
        }
        {
          let type =
            result.source == UrlbarUtils.RESULT_SOURCE.BOOKMARKS
              ? "bookmark"
              : "history";
          if (result.providerName == "InputHistory") {
            return type + (camelCase ? "Adaptive" : "adaptive");
          }
          return type;
        }
      case UrlbarUtils.RESULT_TYPE.KEYWORD:
        return "keyword";
      case UrlbarUtils.RESULT_TYPE.OMNIBOX:
        return "extension";
      case UrlbarUtils.RESULT_TYPE.REMOTE_TAB:
        return "remotetab";
      case UrlbarUtils.RESULT_TYPE.TIP:
        return "tip";
      case UrlbarUtils.RESULT_TYPE.DYNAMIC:
        if (result.providerName == "TabToSearch") {
          // This is the onboarding result.
          return "tabtosearch";
        }
        return "dynamic";
      case UrlbarUtils.RESULT_TYPE.RESTRICT:
        if (result.payload.keyword === lazy.UrlbarTokenizer.RESTRICT.BOOKMARK) {
          return camelCase
            ? "restrictKeywordBookmarks"
            : "restrict_keyword_bookmarks";
        }
        if (result.payload.keyword === lazy.UrlbarTokenizer.RESTRICT.OPENPAGE) {
          return camelCase ? "restrictKeywordTabs" : "restrict_keyword_tabs";
        }
        if (result.payload.keyword === lazy.UrlbarTokenizer.RESTRICT.HISTORY) {
          return camelCase
            ? "restrictKeywordHistory"
            : "restrict_keyword_history";
        }
        if (result.payload.keyword === lazy.UrlbarTokenizer.RESTRICT.ACTION) {
          return camelCase
            ? "restrictKeywordActions"
            : "restrict_keyword_actions";
        }
    }
    return "unknown";
  },

  /**
   * Unescape the given uri to use as UI.
   * NOTE: If the length of uri is over MAX_TEXT_LENGTH,
   *       return the given uri as it is.
   *
   * @param {string} uri will be unescaped.
   * @returns {string} Unescaped uri.
   */
  unEscapeURIForUI(uri) {
    return uri.length > UrlbarUtils.MAX_TEXT_LENGTH
      ? uri
      : Services.textToSubURI.unEscapeURIForUI(uri);
  },

  /**
   * Checks whether a given text has right-to-left direction or not.
   *
   * @param {string} value The text which should be check for RTL direction.
   * @param {Window} window The window where 'value' is going to be displayed.
   * @returns {boolean} Returns true if text has right-to-left direction and
   *                    false otherwise.
   */
  isTextDirectionRTL(value, window) {
    let directionality = window.windowUtils.getDirectionFromText(value);
    return directionality == window.windowUtils.DIRECTION_RTL;
  },

  /**
   * Unescape, decode punycode, and trim (both protocol and trailing slash)
   * the URL. Use for displaying purposes only!
   *
   * @param {string} url The url that should be prepared for display.
   * @param {object} [options] Preparation options.
   * @param {boolean} [options.trimURL] Whether the displayed URL should be
   *                  trimmed or not.
   * @param {boolean} [options.schemeless] Trim `http(s)://`.
   * @returns {string} Prepared url.
   */
  prepareUrlForDisplay(url, { trimURL = true, schemeless = false } = {}) {
    // Some domains are encoded in punycode. The following ensures we display
    // the url in utf-8.
    try {
      url = new URL(url).URI.displaySpec;
    } catch {} // In some cases url is not a valid url.

    if (url) {
      if (schemeless) {
        url = UrlbarUtils.stripPrefixAndTrim(url, {
          stripHttp: true,
          stripHttps: true,
        })[0];
      } else if (trimURL && lazy.UrlbarPrefs.get("trimURLs")) {
        url = lazy.BrowserUIUtils.removeSingleTrailingSlashFromURL(url);
        if (url.startsWith("https://")) {
          url = url.substring(8);
          if (url.startsWith("www.")) {
            url = url.substring(4);
          }
        }
      }
    }

    return this.unEscapeURIForUI(url);
  },

  /**
   * Extracts a group for search engagement telemetry from a result.
   *
   * @param {UrlbarResult} result The result to analyze.
   * @returns {string} Group name as string.
   */
  searchEngagementTelemetryGroup(result) {
    if (!result) {
      return "unknown";
    }
    if (result.isBestMatch) {
      return "top_pick";
    }
    if (result.providerName === "UrlbarProviderTopSites") {
      return "top_site";
    }

    switch (this.getResultGroup(result)) {
      case UrlbarUtils.RESULT_GROUP.INPUT_HISTORY: {
        return "adaptive_history";
      }
      case UrlbarUtils.RESULT_GROUP.RECENT_SEARCH: {
        return "recent_search";
      }
      case UrlbarUtils.RESULT_GROUP.FORM_HISTORY: {
        return "search_history";
      }
      case UrlbarUtils.RESULT_GROUP.TAIL_SUGGESTION:
      case UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION: {
        let group = result.payload.trending
          ? "trending_search"
          : "search_suggest";
        if (result.isRichSuggestion) {
          group += "_rich";
        }
        return group;
      }
      case UrlbarUtils.RESULT_GROUP.REMOTE_TAB: {
        return "remote_tab";
      }
      case UrlbarUtils.RESULT_GROUP.HEURISTIC_EXTENSION:
      case UrlbarUtils.RESULT_GROUP.HEURISTIC_OMNIBOX:
      case UrlbarUtils.RESULT_GROUP.OMNIBOX: {
        return "addon";
      }
      case UrlbarUtils.RESULT_GROUP.GENERAL: {
        return "general";
      }
      // Group of UrlbarProviderQuickSuggest is GENERAL_PARENT.
      case UrlbarUtils.RESULT_GROUP.GENERAL_PARENT: {
        return "suggest";
      }
      case UrlbarUtils.RESULT_GROUP.ABOUT_PAGES: {
        return "about_page";
      }
      case UrlbarUtils.RESULT_GROUP.SUGGESTED_INDEX: {
        return "suggested_index";
      }
      case UrlbarUtils.RESULT_GROUP.RESTRICT_SEARCH_KEYWORD: {
        return "restrict_keyword";
      }
    }

    return result.heuristic ? "heuristic" : "unknown";
  },

  /**
   * Extracts a type for search engagement telemetry from a result.
   *
   * @param {UrlbarResult} result The result to analyze.
   * @param {string} selType An optional parameter for the selected type.
   * @returns {string} Type as string.
   */
  searchEngagementTelemetryType(result, selType = null) {
    if (!result) {
      return selType === "oneoff" ? "search_shortcut_button" : "input_field";
    }

    // While product doesn't use experimental addons anymore, tests may still do
    // for testing purposes.
    if (
      result.providerType === UrlbarUtils.PROVIDER_TYPE.EXTENSION &&
      result.providerName != "Omnibox"
    ) {
      return "experimental_addon";
    }

    switch (result.type) {
      case UrlbarUtils.RESULT_TYPE.DYNAMIC:
        switch (result.providerName) {
          case "calculator":
            return "calc";
          case "TabToSearch":
            return "tab_to_search";
          case "UnitConversion":
            return "unit";
          case "UrlbarProviderQuickSuggest":
            return this._getQuickSuggestTelemetryType(result);
          case "UrlbarProviderQuickSuggestContextualOptIn":
            return "fxsuggest_data_sharing_opt_in";
          case "UrlbarProviderGlobalActions":
          case "UrlbarProviderActionsSearchMode":
            return "action";
        }
        break;
      case UrlbarUtils.RESULT_TYPE.KEYWORD:
        return "keyword";
      case UrlbarUtils.RESULT_TYPE.OMNIBOX:
        return "addon";
      case UrlbarUtils.RESULT_TYPE.REMOTE_TAB:
        return "remote_tab";
      case UrlbarUtils.RESULT_TYPE.SEARCH:
        if (result.providerName === "TabToSearch") {
          return "tab_to_search";
        }
        if (result.source == UrlbarUtils.RESULT_SOURCE.HISTORY) {
          return result.providerName == "RecentSearches"
            ? "recent_search"
            : "search_history";
        }
        if (result.payload.suggestion) {
          let type = result.payload.trending
            ? "trending_search"
            : "search_suggest";
          if (result.isRichSuggestion) {
            type += "_rich";
          }
          return type;
        }
        return "search_engine";
      case UrlbarUtils.RESULT_TYPE.TAB_SWITCH:
        return "tab";
      case UrlbarUtils.RESULT_TYPE.TIP:
        if (result.providerName === "UrlbarProviderInterventions") {
          switch (result.payload.type) {
            case lazy.UrlbarProviderInterventions.TIP_TYPE.CLEAR:
              return "intervention_clear";
            case lazy.UrlbarProviderInterventions.TIP_TYPE.REFRESH:
              return "intervention_refresh";
            case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_ASK:
            case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_CHECKING:
            case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_REFRESH:
            case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_RESTART:
            case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_WEB:
              return "intervention_update";
            default:
              return "intervention_unknown";
          }
        }

        switch (result.payload.type) {
          case lazy.UrlbarProviderSearchTips.TIP_TYPE.ONBOARD:
            return "tip_onboard";
          case lazy.UrlbarProviderSearchTips.TIP_TYPE.REDIRECT:
            return "tip_redirect";
          case "dismissalAcknowledgment":
            return "tip_dismissal_acknowledgment";
          default:
            return "tip_unknown";
        }
      case UrlbarUtils.RESULT_TYPE.URL:
        if (
          result.source === UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL &&
          result.heuristic
        ) {
          return "url";
        }
        if (result.autofill) {
          return `autofill_${result.autofill.type ?? "unknown"}`;
        }
        if (result.providerName === "UrlbarProviderQuickSuggest") {
          return this._getQuickSuggestTelemetryType(result);
        }
        if (result.providerName === "UrlbarProviderTopSites") {
          return "top_site";
        }
        if (result.providerName === "UrlbarProviderClipboard") {
          return "clipboard";
        }
        return result.source === UrlbarUtils.RESULT_SOURCE.BOOKMARKS
          ? "bookmark"
          : "history";
      case UrlbarUtils.RESULT_TYPE.RESTRICT:
        if (result.payload.keyword === lazy.UrlbarTokenizer.RESTRICT.BOOKMARK) {
          return "restrict_keyword_bookmarks";
        }
        if (result.payload.keyword === lazy.UrlbarTokenizer.RESTRICT.OPENPAGE) {
          return "restrict_keyword_tabs";
        }
        if (result.payload.keyword === lazy.UrlbarTokenizer.RESTRICT.HISTORY) {
          return "restrict_keyword_history";
        }
        if (result.payload.keyword === lazy.UrlbarTokenizer.RESTRICT.ACTION) {
          return "restrict_keyword_actions";
        }
    }

    return "unknown";
  },

  searchEngagementTelemetryAction(result) {
    if (result.providerName != "UrlbarProviderGlobalActions") {
      return result.payload.action?.key ?? "none";
    }
    return result.payload.results.map(({ key }) => key).join(",");
  },

  _getQuickSuggestTelemetryType(result) {
    if (result.payload.telemetryType == "weather") {
      // Return "weather" without the usual source prefix for consistency with
      // past reporting of weather suggestions.
      return "weather";
    }
    return result.payload.source + "_" + result.payload.telemetryType;
  },

  /**
   * For use when we want to hash a pair of items in a dictionary
   *
   * @param {string[]} tokens
   *   list of tokens to join into a string eg "a" "b" "c"
   * @returns {string}
   *   the tokens joined in a string "a|b|c"
   */
  tupleString(...tokens) {
    return tokens.filter(t => t).join("|");
  },

  /**
   * Creates camelCase versions of snake_case keys in the given object and
   * recursively all nested objects. All objects are modified in place and the
   * original snake_case keys are preserved.
   *
   * @param {object} obj
   *   The object to modify.
   * @param {boolean} overwrite
   *   Controls what happens when a camelCase key is already defined for a
   *   snake_case key (excluding keys that don't have underscores). If true the
   *   existing key will be overwritten. If false an error will be thrown.
   * @returns {object} The passed-in modified-in-place object.
   */
  copySnakeKeysToCamel(obj, overwrite = true) {
    for (let [key, value] of Object.entries(obj)) {
      // Trim off leading underscores since they'll interfere with the replace.
      // We'll tack them back on after.
      let match = key.match(/^_+/);
      if (match) {
        key = key.substring(match[0].length);
      }
      let camelKey = key.replace(/_([^_])/g, (m, p1) => p1.toUpperCase());
      if (match) {
        camelKey = match[0] + camelKey;
      }
      if (!overwrite && camelKey != key && obj.hasOwnProperty(camelKey)) {
        throw new Error(
          `Can't copy snake_case key '${key}' to camelCase key ` +
            `'${camelKey}' because '${camelKey}' is already defined`
        );
      }
      obj[camelKey] = value;
      if (value && typeof value == "object") {
        this.copySnakeKeysToCamel(value);
      }
    }
    return obj;
  },

  /**
   * Create secondary action button data for tab switch.
   *
   * @param {number} userContextId
   *   The container id for the tab.
   * @returns {object} data to create secondary action button.
   */
  createTabSwitchSecondaryAction(userContextId) {
    let action = { key: "tabswitch" };
    let identity =
      lazy.ContextualIdentityService.getPublicIdentityFromId(userContextId);

    if (identity) {
      let label =
        lazy.ContextualIdentityService.getUserContextLabel(
          userContextId
        ).toLowerCase();
      action.l10nId = "urlbar-result-action-switch-tab-with-container";
      action.l10nArgs = {
        container: label,
      };
      action.classList = [
        "urlbarView-userContext",
        `identity-color-${identity.color}`,
      ];
    } else {
      action.l10nId = "urlbar-result-action-switch-tab";
    }

    return action;
  },
};

ChromeUtils.defineLazyGetter(UrlbarUtils.ICON, "DEFAULT", () => {
  return lazy.PlacesUtils.favicons.defaultFavicon.spec;
});

ChromeUtils.defineLazyGetter(UrlbarUtils, "strings", () => {
  return Services.strings.createBundle(
    "chrome://global/locale/autocomplete.properties"
  );
});

const L10N_SCHEMA = {
  type: "object",
  required: ["id"],
  properties: {
    id: {
      type: "string",
    },
    args: {
      type: "object",
      additionalProperties: true,
    },
    // The remaining properties are related to l10n string caching. See
    // `L10nCache`. All are optional and are false by default.
    parseMarkup: {
      type: "boolean",
    },
    cacheable: {
      type: "boolean",
    },
    excludeArgsFromCacheKey: {
      type: "boolean",
    },
  },
};

/**
 * Payload JSON schemas for each result type.  Payloads are validated against
 * these schemas using JsonSchemaValidator.sys.mjs.
 */
UrlbarUtils.RESULT_PAYLOAD_SCHEMA = {
  [UrlbarUtils.RESULT_TYPE.TAB_SWITCH]: {
    type: "object",
    required: ["url"],
    properties: {
      action: {
        type: "object",
        properties: {
          classList: {
            type: "array",
            items: {
              type: "string",
            },
          },
          l10nArgs: {
            type: "object",
            additionalProperties: true,
          },
          l10nId: {
            type: "string",
          },
          key: {
            type: "string",
          },
        },
      },
      displayUrl: {
        type: "string",
      },
      icon: {
        type: "string",
      },
      isPinned: {
        type: "boolean",
      },
      isSponsored: {
        type: "boolean",
      },
      lastVisit: {
        type: "number",
      },
      title: {
        type: "string",
      },
      url: {
        type: "string",
      },
      userContextId: {
        type: "number",
      },
    },
  },
  [UrlbarUtils.RESULT_TYPE.SEARCH]: {
    type: "object",
    properties: {
      blockL10n: L10N_SCHEMA,
      description: {
        type: "string",
      },
      displayUrl: {
        type: "string",
      },
      engine: {
        type: "string",
      },
      helpUrl: {
        type: "string",
      },
      icon: {
        type: "string",
      },
      inPrivateWindow: {
        type: "boolean",
      },
      isBlockable: {
        type: "boolean",
      },
      isPinned: {
        type: "boolean",
      },
      isPrivateEngine: {
        type: "boolean",
      },
      isGeneralPurposeEngine: {
        type: "boolean",
      },
      keyword: {
        type: "string",
      },
      keywords: {
        type: "string",
      },
      lowerCaseSuggestion: {
        type: "string",
      },
      providesSearchMode: {
        type: "boolean",
      },
      query: {
        type: "string",
      },
      satisfiesAutofillThreshold: {
        type: "boolean",
      },
      searchUrlDomainWithoutSuffix: {
        type: "string",
      },
      suggestion: {
        type: "string",
      },
      tail: {
        type: "string",
      },
      tailPrefix: {
        type: "string",
      },
      tailOffsetIndex: {
        type: "number",
      },
      title: {
        type: "string",
      },
      trending: {
        type: "boolean",
      },
      url: {
        type: "string",
      },
    },
  },
  [UrlbarUtils.RESULT_TYPE.URL]: {
    type: "object",
    required: ["url"],
    properties: {
      blockL10n: L10N_SCHEMA,
      bottomTextL10n: L10N_SCHEMA,
      description: {
        type: "string",
      },
      descriptionL10n: L10N_SCHEMA,
      displayUrl: {
        type: "string",
      },
      dupedHeuristic: {
        type: "boolean",
      },
      fallbackTitle: {
        type: "string",
      },
      helpL10n: L10N_SCHEMA,
      helpUrl: {
        type: "string",
      },
      icon: {
        type: "string",
      },
      iconBlob: {
        type: "object",
      },
      isBlockable: {
        type: "boolean",
      },
      isManageable: {
        type: "boolean",
      },
      isPinned: {
        type: "boolean",
      },
      isSponsored: {
        type: "boolean",
      },
      lastVisit: {
        type: "number",
      },
      originalUrl: {
        type: "string",
      },
      provider: {
        type: "string",
      },
      qsSuggestion: {
        type: "string",
      },
      requestId: {
        type: "string",
      },
      sendAttributionRequest: {
        type: "boolean",
      },
      shouldShowUrl: {
        type: "boolean",
      },
      source: {
        type: "string",
      },
      sponsoredAdvertiser: {
        type: "string",
      },
      sponsoredBlockId: {
        type: "number",
      },
      sponsoredClickUrl: {
        type: "string",
      },
      sponsoredIabCategory: {
        type: "string",
      },
      sponsoredImpressionUrl: {
        type: "string",
      },
      sponsoredTileId: {
        type: "number",
      },
      subtype: {
        type: "string",
      },
      tags: {
        type: "array",
        items: {
          type: "string",
        },
      },
      telemetryType: {
        type: "string",
      },
      title: {
        type: "string",
      },
      titleL10n: L10N_SCHEMA,
      url: {
        type: "string",
      },
      urlTimestampIndex: {
        type: "number",
      },
    },
  },
  [UrlbarUtils.RESULT_TYPE.KEYWORD]: {
    type: "object",
    required: ["keyword", "url"],
    properties: {
      displayUrl: {
        type: "string",
      },
      icon: {
        type: "string",
      },
      input: {
        type: "string",
      },
      keyword: {
        type: "string",
      },
      postData: {
        type: "string",
      },
      title: {
        type: "string",
      },
      url: {
        type: "string",
      },
    },
  },
  [UrlbarUtils.RESULT_TYPE.OMNIBOX]: {
    type: "object",
    required: ["keyword"],
    properties: {
      blockL10n: L10N_SCHEMA,
      content: {
        type: "string",
      },
      icon: {
        type: "string",
      },
      isBlockable: {
        type: "boolean",
      },
      keyword: {
        type: "string",
      },
      title: {
        type: "string",
      },
    },
  },
  [UrlbarUtils.RESULT_TYPE.REMOTE_TAB]: {
    type: "object",
    required: ["device", "url", "lastUsed"],
    properties: {
      device: {
        type: "string",
      },
      displayUrl: {
        type: "string",
      },
      icon: {
        type: "string",
      },
      lastUsed: {
        type: "number",
      },
      title: {
        type: "string",
      },
      url: {
        type: "string",
      },
    },
  },
  [UrlbarUtils.RESULT_TYPE.TIP]: {
    type: "object",
    required: ["type"],
    properties: {
      buttons: {
        type: "array",
        items: {
          type: "object",
          required: ["l10n"],
          properties: {
            l10n: L10N_SCHEMA,
            url: {
              type: "string",
            },
          },
        },
      },
      // TODO: This is intended only for WebExtensions. We should remove it and
      // the WebExtensions urlbar API since we're no longer using it.
      buttonText: {
        type: "string",
      },
      // TODO: This is intended only for WebExtensions. We should remove it and
      // the WebExtensions urlbar API since we're no longer using it.
      buttonUrl: {
        type: "string",
      },
      helpL10n: L10N_SCHEMA,
      helpUrl: {
        type: "string",
      },
      icon: {
        type: "string",
      },
      // TODO: This is intended only for WebExtensions. We should remove it and
      // the WebExtensions urlbar API since we're no longer using it.
      text: {
        type: "string",
      },
      titleL10n: L10N_SCHEMA,
      // `type` is used in the names of keys in the `urlbar.tips` keyed scalar
      // telemetry (see telemetry.rst).  If you add a new type, then you are
      // also adding new `urlbar.tips` keys and therefore need an expanded data
      // collection review.
      type: {
        type: "string",
        enum: [
          "dismissalAcknowledgment",
          "extension",
          "intervention_clear",
          "intervention_refresh",
          "intervention_update_ask",
          "intervention_update_refresh",
          "intervention_update_restart",
          "intervention_update_web",
          "searchTip_onboard",
          "searchTip_redirect",
          "test", // for tests only
        ],
      },
    },
  },
  [UrlbarUtils.RESULT_TYPE.DYNAMIC]: {
    type: "object",
    required: ["dynamicType"],
    properties: {
      dynamicType: {
        type: "string",
      },
    },
  },
  [UrlbarUtils.RESULT_TYPE.RESTRICT]: {
    type: "object",
    properties: {
      icon: {
        type: "string",
      },
      keyword: {
        type: "string",
      },
      l10nRestrictKeywords: {
        type: "array",
        items: {
          type: "string",
        },
      },
      autofillKeyword: {
        type: "string",
      },
      providesSearchMode: {
        type: "boolean",
      },
    },
  },
};

/**
 * UrlbarQueryContext defines a user's autocomplete input from within the urlbar.
 * It supplements it with details of how the search results should be obtained
 * and what they consist of.
 */
export class UrlbarQueryContext {
  /**
   * Constructs the UrlbarQueryContext instance.
   *
   * @param {object} options
   *   The initial options for UrlbarQueryContext.
   * @param {string} options.searchString
   *   The string the user entered in autocomplete. Could be the empty string
   *   in the case of the user opening the popup via the mouse.
   * @param {boolean} options.isPrivate
   *   Set to true if this query was started from a private browsing window.
   * @param {number} options.maxResults
   *   The maximum number of results that will be displayed for this query.
   * @param {boolean} options.allowAutofill
   *   Whether or not to allow providers to include autofill results.
   * @param {number} options.userContextId
   *   The container id where this context was generated, if any.
   * @param {Array} [options.sources]
   *   A list of acceptable UrlbarUtils.RESULT_SOURCE for the context.
   * @param {object} [options.searchMode]
   *   The input's current search mode.  See UrlbarInput.setSearchMode for a
   *   description.
   * @param {boolean} [options.prohibitRemoteResults]
   *   This provides a short-circuit override for `context.allowRemoteResults`.
   *   If it's false, then `allowRemoteResults` will do its usual checks to
   *   determine whether remote results are allowed. If it's true, then
   *   `allowRemoteResults` will immediately return false. Defaults to false.
   * @param {string} [options.formHistoryName]
   *   The name under which the local form history is registered.
   */
  constructor(options = {}) {
    this._checkRequiredOptions(options, [
      "allowAutofill",
      "isPrivate",
      "maxResults",
      "searchString",
    ]);

    if (isNaN(parseInt(options.maxResults))) {
      throw new Error(
        `Invalid maxResults property provided to UrlbarQueryContext`
      );
    }

    // Manage optional properties of options.
    for (let [prop, checkFn, defaultValue] of [
      ["currentPage", v => typeof v == "string" && !!v.length],
      ["formHistoryName", v => typeof v == "string" && !!v.length],
      ["prohibitRemoteResults", () => true, false],
      ["providers", v => Array.isArray(v) && v.length],
      ["searchMode", v => v && typeof v == "object"],
      ["sources", v => Array.isArray(v) && v.length],
      ["view", () => true],
    ]) {
      if (prop in options) {
        if (!checkFn(options[prop])) {
          throw new Error(`Invalid value for option "${prop}"`);
        }
        this[prop] = options[prop];
      } else if (defaultValue !== undefined) {
        this[prop] = defaultValue;
      }
    }

    this.lastResultCount = 0;
    // Note that Set is not serializable through JSON, so these may not be
    // easily shared with add-ons.
    this.pendingHeuristicProviders = new Set();
    this.deferUserSelectionProviders = new Set();
    this.trimmedSearchString = this.searchString.trim();
    this.lowerCaseSearchString = this.searchString.toLowerCase();
    this.trimmedLowerCaseSearchString = this.trimmedSearchString.toLowerCase();
    this.userContextId =
      lazy.UrlbarProviderOpenTabs.getUserContextIdForOpenPagesTable(
        options.userContextId,
        this.isPrivate
      ) || Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID;
  }

  /**
   * Checks the required options, saving them as it goes.
   *
   * @param {object} options The options object to check.
   * @param {Array} optionNames The names of the options to check for.
   * @throws {Error} Throws if there is a missing option.
   */
  _checkRequiredOptions(options, optionNames) {
    for (let optionName of optionNames) {
      if (!(optionName in options)) {
        throw new Error(
          `Missing or empty ${optionName} provided to UrlbarQueryContext`
        );
      }
      this[optionName] = options[optionName];
    }
  }

  /**
   * Caches and returns fixup info from URIFixup for the current search string.
   * Only returns a subset of the properties from URIFixup. This is both to
   * reduce the memory footprint of UrlbarQueryContexts and to keep them
   * serializable so they can be sent to extensions.
   *
   * @returns {{ href: string; isSearch: boolean; }?}
   */
  get fixupInfo() {
    if (!this._fixupError && !this._fixupInfo && this.trimmedSearchString) {
      let flags =
        Ci.nsIURIFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS |
        Ci.nsIURIFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP;
      if (this.isPrivate) {
        flags |= Ci.nsIURIFixup.FIXUP_FLAG_PRIVATE_CONTEXT;
      }

      try {
        let info = Services.uriFixup.getFixupURIInfo(this.searchString, flags);

        this._fixupInfo = {
          href: info.fixedURI.spec,
          isSearch: !!info.keywordAsSent,
          scheme: info.fixedURI.scheme,
        };
      } catch (ex) {
        this._fixupError = ex.result;
      }
    }

    return this._fixupInfo || null;
  }

  /**
   * Returns the error that was thrown when fixupInfo was fetched, if any. If
   * fixupInfo has not yet been fetched for this queryContext, it is fetched
   * here.
   *
   * @returns {any?}
   */
  get fixupError() {
    if (!this.fixupInfo) {
      return this._fixupError;
    }

    return null;
  }

  /**
   * Returns whether results from remote services are generally allowed for the
   * context. Callers can impose further restrictions as appropriate, but
   * typically they should not fetch remote results if this returns false.
   *
   * @param {string} [searchString]
   *   Usually this is just the context's search string, but if you need to
   *   fetch remote results based on a modified version, you can pass it here.
   * @param {boolean} [allowEmptySearchString]
   *   Whether to check for the minimum length of the search string.
   * @returns {boolean}
   *   Whether remote results are allowed.
   */
  allowRemoteResults(
    searchString = this.searchString,
    allowEmptySearchString = false
  ) {
    if (this.prohibitRemoteResults) {
      return false;
    }

    // We're unlikely to get useful remote results for a single character.
    if (
      searchString.length < 2 &&
      !(!searchString.length && allowEmptySearchString)
    ) {
      return false;
    }

    // Disallow remote results if only an origin is typed to avoid disclosing
    // sites the user visits. This also catches partially typed origins, like
    // mozilla.o, because the fixup check below can't validate them.
    if (
      this.tokens.length == 1 &&
      this.tokens[0].type == lazy.UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN
    ) {
      return false;
    }

    // Disallow remote results for strings containing tokens that look like URIs
    // to avoid disclosing information about networks and passwords.
    if (this.fixupInfo?.href && !this.fixupInfo?.isSearch) {
      return false;
    }

    // Allow remote results.
    return true;
  }
}

/**
 * Base class for a muxer.
 * The muxer scope is to sort a given list of results.
 */
export class UrlbarMuxer {
  /**
   * Unique name for the muxer, used by the context to sort results.
   * Not using a unique name will cause the newest registration to win.
   *
   * @abstract
   */
  get name() {
    return "UrlbarMuxerBase";
  }

  /**
   * Sorts queryContext results in-place.
   *
   * @param {UrlbarQueryContext} _queryContext the context to sort results for.
   * @abstract
   */
  sort(_queryContext) {
    throw new Error("Trying to access the base class, must be overridden");
  }
}

/**
 * Base class for a provider.
 * The provider scope is to query a datasource and return results from it.
 */
export class UrlbarProvider {
  constructor() {
    ChromeUtils.defineLazyGetter(this, "logger", () =>
      UrlbarUtils.getLogger({ prefix: `Provider.${this.name}` })
    );
  }

  /**
   * Unique name for the provider, used by the context to filter on providers.
   * Not using a unique name will cause the newest registration to win.
   *
   * @abstract
   */
  get name() {
    return "UrlbarProviderBase";
  }

  /**
   * The type of the provider, must be one of UrlbarUtils.PROVIDER_TYPE.
   *
   * @abstract
   */
  get type() {
    throw new Error("Trying to access the base class, must be overridden");
  }

  /**
   * Calls a method on the provider in a try-catch block and reports any error.
   * Unlike most other provider methods, `tryMethod` is not intended to be
   * overridden.
   *
   * @param {string} methodName The name of the method to call.
   * @param {*} args The method arguments.
   * @returns {*} The return value of the method, or undefined if the method
   *          throws an error.
   * @abstract
   */
  tryMethod(methodName, ...args) {
    try {
      return this[methodName](...args);
    } catch (ex) {
      console.error(ex);
    }
    return undefined;
  }

  /**
   * Whether this provider should be invoked for the given context.
   * If this method returns false, the providers manager won't start a query
   * with this provider, to save on resources.
   *
   * @param {UrlbarQueryContext} _queryContext The query context object
   * @returns {boolean} Whether this provider should be invoked for the search.
   * @abstract
   */
  isActive(_queryContext) {
    throw new Error("Trying to access the base class, must be overridden");
  }

  /**
   * Gets the provider's priority.  Priorities are numeric values starting at
   * zero and increasing in value.  Smaller values are lower priorities, and
   * larger values are higher priorities.  For a given query, `startQuery` is
   * called on only the active and highest-priority providers.
   *
   * @param {UrlbarQueryContext} _queryContext The query context object
   * @returns {number} The provider's priority for the given query.
   * @abstract
   */
  getPriority(_queryContext) {
    // By default, all providers share the lowest priority.
    return 0;
  }

  /**
   * Starts querying.
   *
   * Note: Extended classes should return a Promise resolved when the provider
   *       is done searching AND returning results.
   *
   * @param {UrlbarQueryContext} _queryContext The query context object
   * @param {Function} _addCallback Callback invoked by the provider to add a new
   *        result. A UrlbarResult should be passed to it.
   * @abstract
   */
  startQuery(_queryContext, _addCallback) {
    throw new Error("Trying to access the base class, must be overridden");
  }

  /**
   * Cancels a running query,
   *
   * @param {UrlbarQueryContext} _queryContext the query context object to cancel
   *        query for.
   * @abstract
   */
  cancelQuery(_queryContext) {
    // Override this with your clean-up on cancel code.
  }

  // The following `on{Event}` notification methods are invoked only when
  // defined, thus there is no base class implementation for them
  /**
   * Called when a user engages with a result in the urlbar. This is called for
   * all providers who have implemented this method.
   *
   * @param {UrlbarQueryContext} _queryContext
   *   The engagement's query context. It will always be defined for
   *   "engagement" and "abandonment".
   * @param {UrlbarController} _controller
   *  The associated controller.
   * @param {object} _details
   *   This object is non-empty only when `state` is "engagement" or
   *   "abandonment", and it describes the search string and engaged result.
   *
   *   For "engagement", it has the following properties:
   *
   *   {UrlbarResult} result
   *       The engaged result. If a result itself was picked, this will be it.
   *       If an element related to a result was picked (like a button or menu
   *       command), this will be that result. This property will be present if
   *       and only if `state` == "engagement", so it can be used to quickly
   *       tell when the user engaged with a result.
   *   {Element} element
   *       The picked DOM element.
   *   {boolean} isSessionOngoing
   *       True if the search session remains ongoing or false if the engagement
   *       ended it. Typically picking a result ends the session but not always.
   *       Picking a button or menu command may not end the session; dismissals
   *       do not, for example.
   *   {string} searchString
   *       The search string for the engagement's query.
   *   {number} selIndex
   *       The index of the picked result.
   *   {string} selType
   *       The type of the selected result.  See TelemetryEvent.record() in
   *       UrlbarController.sys.mjs.
   *   {string} provider
   *       The name of the provider that produced the picked result.
   *
   *   For "abandonment", only `searchString` is defined.
   *
   * onEngagement(_queryContext, _controller, _details) {}
   */

  /**
   * Called when the user abandons a search session without selecting a result.
   * This could be due to losing focus on the urlbar, switching tabs, or other
   * actions that imply the user is no longer actively engaging with the search
   * suggestions. The method is called for all providers who have implemented
   * this method and whose results were visible at the time of the abandonment.
   *
   * @param {UrlbarQueryContext} _queryContext
   *    The query context at the time of abandonment.
   * @param {UrlbarController} _controller
   * The associated controller.
   *
   * onAbandonment(_queryContext, _controller) {}
   */

  /**
   * Called for providers whose results are visible at the time of either
   * engagement or abandonment. The method is called when a user actively
   * interacts with a search result. This interaction could be clicking on a
   * suggestion, using a keyboard to select a suggestion, or any other form of
   * direct engagement with the results displayed. It is also called
   * when a user decides to abandon the search session without engaging with any
   * of the presented results. This is called for all providers who have
   * implemented this method.
   *
   * @param {string} _state
   *    The state of the user interaction, either "engagement" or "abandonment".
   * @param {UrlbarQueryContext} _queryContext
   *    The current query context.
   * @param {UrlbarController} _controller
   *    The associated controller.
   * @param {Array} _providerVisibleResults
   *    Array of visible results at the time of either an engagement or
   *    abandonment event relevant to the provider. Each object in the array
   *    contains:
   *    - `index`: The position of the visible result within the original list
   *               visible results.
   *    - `result`: The visible result itself
   *
   * onImpression(_state, _queryContext, _controller, _providerVisibleResults)
   * {}
   */

  /**
   * Called when a search session concludes regardless of how it ends -
   * whether through engagement or abandonment or otherwise. This is
   * called for all providers who have implemented this method.
   *
   * @param {UrlbarQueryContext} _queryContext
   *    The current query context.
   * @param {UrlbarController} _controller
   *    The associated controller.
   *
   * onSearchSessionEnd(_queryContext, _controller) {}
   */

  /**
   * Called before a result from the provider is selected. See `onSelection`
   * for details on what that means.
   *
   * @param {UrlbarResult} _result
   *   The result that was selected.
   * @param {Element} _element
   *   The element in the result's view that was selected.
   * @abstract
   */
  onBeforeSelection(_result, _element) {}

  /**
   * Called when a result from the provider is selected. "Selected" refers to
   * the user highlighing the result with the arrow keys/Tab, before it is
   * picked. onSelection is also called when a user clicks a result. In the
   * event of a click, onSelection is called just before onEngagement. Note that
   * this is called when heuristic results are pre-selected.
   *
   * @param {UrlbarResult} _result
   *   The result that was selected.
   * @param {Element} _element
   *   The element in the result's view that was selected.
   * @abstract
   */
  onSelection(_result, _element) {}

  /**
   * This is called only for dynamic result types, when the urlbar view updates
   * the view of one of the results of the provider.  It should return an object
   * describing the view update that looks like this:
   *
   *   {
   *     nodeNameFoo: {
   *       attributes: {
   *         someAttribute: someValue,
   *       },
   *       style: {
   *         someStyleProperty: someValue,
   *       },
   *       l10n: {
   *         id: someL10nId,
   *         args: someL10nArgs,
   *       },
   *       textContent: "some text content",
   *     },
   *     nodeNameBar: {
   *       ...
   *     },
   *     nodeNameBaz: {
   *       ...
   *     },
   *   }
   *
   * The object should contain a property for each element to update in the
   * dynamic result type view.  The names of these properties are the names
   * declared in the view template of the dynamic result type; see
   * UrlbarView.addDynamicViewTemplate().  The values are similar to the nested
   * objects specified in the view template but not quite the same; see below.
   * For each property, the element in the view subtree with the specified name
   * is updated according to the object in the property's value.  If an
   * element's name is not specified, then it will not be updated and will
   * retain its current state.
   *
   * @param {UrlbarResult} _result
   *   The result whose view will be updated.
   * @param {Map} _idsByName
   *   A Map from an element's name, as defined by the provider; to its ID in
   *   the DOM, as defined by the browser. The browser manages element IDs for
   *   dynamic results to prevent collisions. However, a provider may need to
   *   access the IDs of the elements created for its results. For example, to
   *   set various `aria` attributes.
   * @returns {object}
   *   A view update object as described above.  The names of properties are the
   *   the names of elements declared in the view template.  The values of
   *   properties are objects that describe how to update each element, and
   *   these objects may include the following properties, all of which are
   *   optional:
   *
   *   {object} [attributes]
   *     A mapping from attribute names to values.  Each name-value pair results
   *     in an attribute being added to the element.  The `id` attribute is
   *     reserved and cannot be set by the provider.
   *   {object} [style]
   *     A plain object that can be used to add inline styles to the element,
   *     like `display: none`.   `element.style` is updated for each name-value
   *     pair in this object.
   *   {object} [l10n]
   *     An { id, args } object that will be passed to
   *     document.l10n.setAttributes().
   *   {string} [textContent]
   *     A string that will be set as `element.textContent`.
   */
  getViewUpdate(_result, _idsByName) {
    return null;
  }

  /**
   * Gets the list of commands that should be shown in the result menu for a
   * given result from the provider. All commands returned by this method should
   * be handled by implementing `onEngagement()` with the possible exception of
   * commands automatically handled by the urlbar, like "help".
   *
   * @param {UrlbarResult} _result
   *   The menu will be shown for this result.
   * @returns {Array}
   *   If the result doesn't have any commands, this should return null.
   *   Otherwise it should return an array of command objects that look like:
   *   `{ name, l10n, children}`
   *
   *   {string} name
   *     The name of the command. Must be specified unless `children` is
   *     present. When a command is picked, its name will be passed as
   *     `details.selType` to `onEngagement()`. The special name "separator"
   *     will create a menu separator.
   *   {object} l10n
   *     An l10n object for the command's label: `{ id, args }`
   *     Must be specified unless `name` is "separator".
   *   {array} children
   *     If specified, a submenu will be created with the given child commands.
   *     Each object in the array must be a command object.
   */
  getResultCommands(_result) {
    return null;
  }

  /**
   * Defines whether the view should defer user selection events while waiting
   * for the first result from this provider.
   *
   * Note: UrlbarEventBufferer has a timeout after which user events will be
   *       processed regardless.
   *
   * @returns {boolean} Whether the provider wants to defer user selection
   *          events.
   * @see {@link UrlbarEventBufferer}
   */
  get deferUserSelection() {
    return false;
  }
}

/**
 * Class used to create a timer that can be manually fired, to immediately
 * invoke the callback, or canceled, as necessary.
 * Examples:
 *   let timer = new SkippableTimer();
 *   // Invokes the callback immediately without waiting for the delay.
 *   await timer.fire();
 *   // Cancel the timer, the callback won't be invoked.
 *   await timer.cancel();
 *   // Wait for the timer to have elapsed.
 *   await timer.promise;
 */
export class SkippableTimer {
  /**
   * This can be used to track whether the timer completed.
   */
  done = false;

  /**
   * Creates a skippable timer for the given callback and time.
   *
   * @param {object} options An object that configures the timer
   * @param {string} options.name The name of the timer, logged when necessary
   * @param {Function} options.callback To be invoked when requested
   * @param {number} options.time A delay in milliseconds to wait for
   * @param {boolean} options.reportErrorOnTimeout If true and the timer times
   *                  out, an error will be logged with Cu.reportError
   * @param {logger} options.logger An optional logger
   */
  constructor({
    name = "<anonymous timer>",
    callback = null,
    time = 0,
    reportErrorOnTimeout = false,
    logger = null,
  } = {}) {
    this.name = name;
    this.logger = logger;

    let timerPromise = new Promise(resolve => {
      this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
      this._timer.initWithCallback(
        () => {
          this._log(`Timed out!`, reportErrorOnTimeout);
          this.done = true;
          this._timer = null;
          resolve();
        },
        time,
        Ci.nsITimer.TYPE_ONE_SHOT
      );
      this._log(`Started`);
    });

    let firePromise = new Promise(resolve => {
      this.fire = async () => {
        this.done = true;
        if (this._timer) {
          if (!this._canceled) {
            this._log(`Skipped`);
          }
          this._timer.cancel();
          this._timer = null;
          resolve();
        }
        await this.promise;
      };
    });

    this.promise = Promise.race([timerPromise, firePromise]).then(() => {
      // If we've been canceled, don't call back.
      if (callback && !this._canceled) {
        callback();
      }
    });
  }

  /**
   * Allows to cancel the timer and the callback won't be invoked.
   * It is not strictly necessary to await for this, the promise can just be
   * used to ensure all the internal work is complete.
   */
  async cancel() {
    if (this._timer) {
      this._log(`Canceling`);
      this._canceled = true;
    }
    await this.fire();
  }

  _log(msg, isError = false) {
    let line = `SkippableTimer :: ${this.name} :: ${msg}`;
    if (this.logger) {
      this.logger.debug(line);
    }
    if (isError) {
      console.error(line);
    }
  }
}

/**
 * This class implements a cache for l10n strings. Cached strings can be
 * accessed synchronously, avoiding the asynchronicity of `data-l10n-id` and
 * `document.l10n.setAttributes`, which can lead to text pop-in and flickering
 * as strings are fetched from Fluent. (`document.l10n.formatValueSync` is also
 * sync but should not be used since it may perform sync I/O.)
 *
 * Values stored and returned by the cache are JS objects similar to
 * `L10nMessage` objects, not bare strings. This allows the cache to store not
 * only l10n strings with bare values but also strings that define attributes
 * (e.g., ".label = My label value"). See `get` for details.
 */
export class L10nCache {
  /**
   * @param {Localization} l10n
   *   A `Localization` object like `document.l10n`. This class keeps a weak
   *   reference to this object, so the caller or something else must hold onto
   *   it.
   */
  constructor(l10n) {
    this.l10n = Cu.getWeakReference(l10n);
    this.QueryInterface = ChromeUtils.generateQI([
      "nsIObserver",
      "nsISupportsWeakReference",
    ]);
    Services.obs.addObserver(this, "intl:app-locales-changed", true);
  }

  /**
   * Gets a cached l10n message.
   *
   * @param {object} options
   *   Options
   * @param {string} options.id
   *   The string's Fluent ID.
   * @param {object} options.args
   *   The Fluent arguments as passed to `l10n.setAttributes`.
   * @param {boolean} options.excludeArgsFromCacheKey
   *   Pass true if the string was cached using a key that excluded arguments.
   * @returns {object}
   *   The message object or undefined if it's not cached. The message object is
   *   similar to `L10nMessage` (defined in Localization.webidl) but its
   *   attributes are stored differently for convenience. It looks like this:
   *
   *     { value, attributes }
   *
   *   The properties are:
   *
   *     {string} value
   *       The bare value of the string. If the string does not have a bare
   *       value (i.e., it has only attributes), this will be null.
   *     {object} attributes
   *       A mapping from attribute names to their values. If the string doesn't
   *       have any attributes, this will be null.
   *
   *   For example, if we cache these strings from an ftl file:
   *
   *     foo = Foo's value
   *     bar =
   *       .label = Bar's label value
   *
   *   Then:
   *
   *     cache.get("foo")
   *     // => { value: "Foo's value", attributes: null }
   *     cache.get("bar")
   *     // => { value: null, attributes: { label: "Bar's label value" }}
   */
  get({ id, args = undefined, excludeArgsFromCacheKey = false }) {
    return this._messagesByKey.get(
      this._key({ id, args, excludeArgsFromCacheKey })
    );
  }

  /**
   * Fetches a string from Fluent and caches it.
   *
   * @param {object} options
   *   Options
   * @param {string} options.id
   *   The string's Fluent ID.
   * @param {object} options.args
   *   The Fluent arguments as passed to `l10n.setAttributes`.
   * @param {boolean} options.excludeArgsFromCacheKey
   *   Pass true to cache the string using a key that excludes the arguments.
   *   The string will be cached only by its ID. This is useful if the string is
   *   used only once in the UI, its arguments vary, and it's acceptable to
   *   fetch and show a cached value with old arguments until the string is
   *   relocalized with new arguments.
   */
  async add({ id, args = undefined, excludeArgsFromCacheKey = false }) {
    let l10n = this.l10n.get();
    if (!l10n) {
      return;
    }
    let messages = await l10n.formatMessages([{ id, args }]);
    if (!messages?.length) {
      console.error(
        "l10n.formatMessages returned an unexpected value for ID: ",
        id
      );
      return;
    }
    let message = messages[0];
    if (message.attributes) {
      // Convert `attributes` from an array of `{ name, value }` objects to one
      // object mapping names to values.
      message.attributes = message.attributes.reduce(
        (valuesByName, { name, value }) => {
          valuesByName[name] = value;
          return valuesByName;
        },
        {}
      );
    }
    this._messagesByKey.set(
      this._key({ id, args, excludeArgsFromCacheKey }),
      message
    );
  }

  /**
   * Fetches and caches a string if it's not already cached. This is just a
   * slight optimization over `add` that avoids calling into Fluent
   * unnecessarily.
   *
   * @param {object} options
   *   Options
   * @param {string} options.id
   *   The string's Fluent ID.
   * @param {object} options.args
   *   The Fluent arguments as passed to `l10n.setAttributes`.
   * @param {boolean} options.excludeArgsFromCacheKey
   *   Pass true to cache the string using a key that excludes the arguments.
   *   The string will be cached only by its ID. See `add()` for more.
   */
  async ensure({ id, args = undefined, excludeArgsFromCacheKey = false }) {
    // Always re-cache if `excludeArgsFromCacheKey` is true. The values in
    // `args` may be different from the values in the cached string.
    if (excludeArgsFromCacheKey || !this.get({ id, args })) {
      await this.add({ id, args, excludeArgsFromCacheKey });
    }
  }

  /**
   * Fetches and caches strings that aren't already cached.
   *
   * @param {Array} objects
   *   An array of objects as passed to `ensure()`.
   */
  async ensureAll(objects) {
    let promises = [];
    for (let obj of objects) {
      promises.push(this.ensure(obj));
    }
    await Promise.all(promises);
  }

  /**
   * Removes a cached string.
   *
   * @param {object} options
   *   Options
   * @param {string} options.id
   *   The string's Fluent ID.
   * @param {object} options.args
   *   The Fluent arguments as passed to `l10n.setAttributes`.
   * @param {boolean} options.excludeArgsFromCacheKey
   *   Pass true if the string was cached using a key that excludes the
   *   arguments. If true, `args` is ignored.
   */
  delete({ id, args = undefined, excludeArgsFromCacheKey = false }) {
    this._messagesByKey.delete(
      this._key({ id, args, excludeArgsFromCacheKey })
    );
  }

  /**
   * Removes all cached strings.
   */
  clear() {
    this._messagesByKey.clear();
  }

  /**
   * Returns the number of cached messages.
   *
   * @returns {number}
   */
  size() {
    return this._messagesByKey.size;
  }

  /**
   * Sets an element's content or attribute to a cached l10n string. If the
   * string isn't cached, then this falls back to the usual
   * `document.l10n.setAttributes()` using the given l10n ID and args, which
   * means the string will pop in on a later animation frame.
   *
   * This also optionally caches the string so that it will be ready the next
   * time this is called for it. The function returns a promise that will be
   * resolved when the string has been cached. Typically there's no need to
   * await it unless you want to be sure the string is cached before continuing.
   *
   * @param {Element} element
   *   The l10n string will be applied to this element.
   * @param {object} options
   *   Options object.
   * @param {string} options.id
   *   The l10n string ID.
   * @param {object} options.args
   *   The l10n string arguments.
   * @param {string} options.attribute
   *   If the string applies to an attribute on the element, pass the name of
   *   the attribute. The string in the Fluent file should define a value for
   *   the attribute, like ".foo = My value". If the string applies to the
   *   element's content, leave this undefined.
   * @param {boolean} options.parseMarkup
   *   This controls whether the cached string is applied to the element's
   *   `textContent` or its `innerHTML`. It's not relevant if the string is
   *   applied to an attribute. Typically it should be set to true when the
   *   string is expected to contain markup. When true, the cached string is
   *   essentially assigned to the element's `innerHTML`. When false, it's
   *   assigned to the element's `textContent`.
   * @param {boolean} options.cacheable
   *   Whether the string should be cached in addition to applying it to the
   *   given element.
   * @param {boolean} options.excludeArgsFromCacheKey
   *   This affects how the string is stored in and fetched from the cache and
   *   is only relevant if the string has arguments. When true, all formatted
   *   values of the string share the same cache entry regardless of the
   *   arguments they were formatted with. In other words, only the ID matters.
   *   When false, formatted values with different arguments have separate cache
   *   entries. Typically it should be true when the number of possible argument
   *   values is unbounded and false otherwise. For example, it should be true
   *   if the argument is a user search string since that could be anything. It
   *   should be false if the argument is the name of an installed search engine
   *   since there's a relatively small number of those.
   *
   *   If `cacheable` is false but you previously cached the string using
   *   another function, you should pass the same value you passed for
   *   `excludeArgsFromCacheKey` when you cached it.
   * @returns {Promise|null}
   *   If `cacheable` is true, this returns a promise that's resolved when the
   *   string has been cached. Otherwise it returns null.
   */
  setElementL10n(
    element,
    {
      id,
      args = undefined,
      attribute = undefined,
      parseMarkup = false,
      cacheable = false,
      excludeArgsFromCacheKey = false,
    }
  ) {
    let message = this.get({ id, args, excludeArgsFromCacheKey });
    if (message) {
      element.removeAttribute("data-l10n-id");
      element.removeAttribute("data-l10n-attrs");
      element.removeAttribute("data-l10n-args");
      if (attribute) {
        element.setAttribute(attribute, message.attributes[attribute]);
      } else if (!parseMarkup) {
        element.textContent = message.value;
      } else {
        element.innerHTML = "";
        element.append(
          lazy.parserUtils.parseFragment(
            message.value,
            Ci.nsIParserUtils.SanitizerDropNonCSSPresentation |
              Ci.nsIParserUtils.SanitizerDropForms |
              Ci.nsIParserUtils.SanitizerDropMedia,
            false,
            Services.io.newURI(element.ownerDocument.documentURI),
            element
          )
        );
      }
    }

    // If the message wasn't cached, set the element's l10n attributes and let
    // `DOMLocalization` do its asynchronous translation. The element's content
    // will pop in when translation finishes.
    //
    // Also do this if the message was cached but its args aren't part of the
    // cache key because in that case the cached message may contain outdated
    // arg values. We just set the element's content to the old message above,
    // and when `DOMLocalization` finishes translating the new message, it will
    // set the element's content again. If the old and new args are different,
    // the new content will pop in. If they're the same, nothing will appear to
    // change.
    if (!message || (cacheable && excludeArgsFromCacheKey)) {
      if (attribute) {
        element.setAttribute("data-l10n-attrs", attribute);
      } else {
        element.removeAttribute("data-l10n-attrs");
      }
      element.ownerDocument.l10n.setAttributes(element, id, args);
    }

    if (cacheable) {
      // Cache the string. We specifically do not do this first and await it
      // because the whole point of the l10n cache is to synchronously update
      // the element's content when possible. Here, we return a promise rather
      // than making this function async and awaiting so it's clearer to callers
      // that they probably don't need to wait for caching to finish.
      return this.ensure({ id, args, excludeArgsFromCacheKey });
    }
    return null;
  }

  /**
   * Removes content and attributes set by `setElementL10n()`.
   *
   * @param {Element} element
   *   The content and attributes will be removed from this element.
   * @param {object} options
   *   Options object.
   * @param {string} options.attribute
   *   If you passed an attribute to `setElementL10n()`, pass it here too.
   */
  removeElementL10n(element, { attribute = undefined } = {}) {
    if (attribute) {
      element.removeAttribute(attribute);
      element.removeAttribute("data-l10n-attrs");
    } else {
      element.textContent = "";
    }
    element.removeAttribute("data-l10n-id");
    element.removeAttribute("data-l10n-args");
  }

  /**
   * Observer method from Services.obs.addObserver.
   *
   * @param {nsISupports} subject
   *   The subject of the notification.
   * @param {string} topic
   *   The topic of the notification.
   */
  async observe(subject, topic) {
    switch (topic) {
      case "intl:app-locales-changed": {
        await this.l10n.ready;
        this.clear();
        break;
      }
    }
  }

  /**
   * Cache keys => cached message objects
   */
  _messagesByKey = new Map();

  /**
   * Returns a cache key for a string in `_messagesByKey`.
   *
   * @param {object} options
   *   Options
   * @param {string} options.id
   *   The string's Fluent ID.
   * @param {object} options.args
   *   The Fluent arguments as passed to `l10n.setAttributes`.
   * @param {boolean} options.excludeArgsFromCacheKey
   *   Pass true to exclude the arguments from the key and include only the ID.
   * @returns {string}
   *   The cache key.
   */
  _key({ id, args, excludeArgsFromCacheKey }) {
    // Keys are `id` plus JSON'ed `args` values. `JSON.stringify` doesn't
    // guarantee a particular ordering of object properties, so instead of
    // stringifying `args` as is, sort its entries by key and then pull out the
    // values. The final key is a JSON'ed array of `id` concatenated with the
    // sorted-by-key `args` values.
    args = (!excludeArgsFromCacheKey && args) || [];
    let argValues = Object.entries(args)
      .sort(([key1], [key2]) => key1.localeCompare(key2))
      .map(([_, value]) => value);
    let parts = [id].concat(argValues);
    return JSON.stringify(parts);
  }
}

/**
 * This class provides a way of serializing access to a resource. It's a queue
 * of callbacks (or "tasks") where each callback is called and awaited in order,
 * one at a time.
 */
export class TaskQueue {
  /**
   * @returns {Promise}
   *   Resolves when the queue becomes empty. If the queue is already empty,
   *   then a resolved promise is returned.
   */
  get emptyPromise() {
    return this.#emptyPromise;
  }

  /**
   * Adds a callback function to the task queue. The callback will be called
   * after all other callbacks before it in the queue. This method returns a
   * promise that will be resolved after awaiting the callback. The promise will
   * be resolved with the value returned by the callback.
   *
   * @param {Function} callback
   *   The function to queue.
   * @returns {Promise}
   *   Resolved after the task queue calls and awaits `callback`. It will be
   *   resolved with the value returned by `callback`. If `callback` throws an
   *   error, then it will be rejected with the error.
   */
  queue(callback) {
    return new Promise((resolve, reject) => {
      this.#queue.push({ callback, resolve, reject });
      if (this.#queue.length == 1) {
        this.#emptyDeferred = Promise.withResolvers();
        this.#emptyPromise = this.#emptyDeferred.promise;
        this.#doNextTask();
      }
    });
  }

  /**
   * Adds a callback function to the task queue that will be called on idle.
   *
   * @param {Function} callback
   *   The function to queue.
   * @returns {Promise}
   *   Resolved after the task queue calls and awaits `callback`. It will be
   *   resolved with the value returned by `callback`. If `callback` throws an
   *   error, then it will be rejected with the error.
   */
  queueIdleCallback(callback) {
    return this.queue(async () => {
      await new Promise((resolve, reject) => {
        ChromeUtils.idleDispatch(async () => {
          try {
            let value = await callback();
            resolve(value);
          } catch (error) {
            console.error(error);
            reject(error);
          }
        });
      });
    });
  }

  /**
   * Calls the next function in the task queue and recurses until the queue is
   * empty. Once empty, all empty callback functions are called.
   */
  async #doNextTask() {
    if (!this.#queue.length) {
      this.#emptyDeferred.resolve();
      this.#emptyDeferred = null;
      return;
    }

    // Leave the callback in the queue while awaiting it. If we remove it now
    // the queue could become empty, and if `queue()` were called while we're
    // awaiting the callback, `#doNextTask()` would be re-entered.
    let { callback, resolve, reject } = this.#queue[0];
    try {
      let value = await callback();
      resolve(value);
    } catch (error) {
      console.error(error);
      reject(error);
    }
    this.#queue.shift();
    this.#doNextTask();
  }

  #queue = [];
  #emptyDeferred = null;
  #emptyPromise = Promise.resolve();
}
