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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
  SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs",
  UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs",
});

// `contextId` is a unique identifier used by Contextual Services
const CONTEXT_ID_PREF = "browser.contextual-services.contextId";
ChromeUtils.defineLazyGetter(lazy, "contextId", () => {
  let _contextId = Services.prefs.getStringPref(CONTEXT_ID_PREF, null);
  if (!_contextId) {
    _contextId = Services.uuid.generateUUID().toString();
    Services.prefs.setStringPref(CONTEXT_ID_PREF, _contextId);
  }
  return _contextId;
});

// A map of known search origins.
// The keys of this map are used in the calling code to recordSearch, and in
// the SEARCH_COUNTS histogram.
// The values of this map are used in the names of scalars for the following
// scalar groups:
// browser.engagement.navigation.*
// browser.search.content.*
// browser.search.withads.*
// browser.search.adclicks.*
const KNOWN_SEARCH_SOURCES = new Map([
  ["abouthome", "about_home"],
  ["contextmenu", "contextmenu"],
  ["newtab", "about_newtab"],
  ["searchbar", "searchbar"],
  ["system", "system"],
  ["urlbar", "urlbar"],
  ["urlbar-handoff", "urlbar_handoff"],
  ["urlbar-persisted", "urlbar_persisted"],
  ["urlbar-searchmode", "urlbar_searchmode"],
  ["webextension", "webextension"],
]);

/**
 * This class handles saving search telemetry related to the url bar,
 * search bar and other areas as per the sources above.
 */
class BrowserSearchTelemetryHandler {
  KNOWN_SEARCH_SOURCES = KNOWN_SEARCH_SOURCES;

  /**
   * Determines if we should record a search for this browser instance.
   * Private Browsing mode is normally skipped.
   *
   * @param {browser} browser
   *   The browser where the search was loaded.
   * @returns {boolean}
   *   True if the search should be recorded, false otherwise.
   */
  shouldRecordSearchCount(browser) {
    return (
      !lazy.PrivateBrowsingUtils.isWindowPrivate(browser.ownerGlobal) ||
      !Services.prefs.getBoolPref("browser.engagement.search_counts.pbm", false)
    );
  }

  /**
   * Records the method by which the user selected a result from the urlbar or
   * searchbar.
   *
   * @param {Event} event
   *        The event that triggered the selection.
   * @param {string} source
   *        Either "urlbar" or "searchbar" depending on the source.
   * @param {number} index
   *        The index that the user chose in the popup, or -1 if there wasn't a
   *        selection.
   * @param {string} userSelectionBehavior
   *        How the user cycled through results before picking the current match.
   *        Could be one of "tab", "arrow" or "none".
   */
  recordSearchSuggestionSelectionMethod(
    event,
    source,
    index,
    userSelectionBehavior = "none"
  ) {
    // If the contents of the histogram are changed then
    // `UrlbarTestUtils.SELECTED_RESULT_METHODS` should also be updated.
    if (source == "searchbar" && userSelectionBehavior != "none") {
      throw new Error("Did not expect a selection behavior for the searchbar.");
    }

    let histogram = Services.telemetry.getHistogramById(
      source == "urlbar"
        ? "FX_URLBAR_SELECTED_RESULT_METHOD"
        : "FX_SEARCHBAR_SELECTED_RESULT_METHOD"
    );
    // command events are from the one-off context menu.  Treat them as clicks.
    // Note that we only care about MouseEvent subclasses here when the
    // event type is "click", or else the subclasses are associated with
    // non-click interactions.
    let isClick =
      event &&
      (ChromeUtils.getClassName(event) == "MouseEvent" ||
        event.type == "click" ||
        event.type == "command");
    let category;
    if (isClick) {
      category = "click";
    } else if (index >= 0) {
      switch (userSelectionBehavior) {
        case "tab":
          category = "tabEnterSelection";
          break;
        case "arrow":
          category = "arrowEnterSelection";
          break;
        case "rightClick":
          // Selected by right mouse button.
          category = "rightClickEnter";
          break;
        default:
          category = "enterSelection";
      }
    } else {
      category = "enter";
    }
    histogram.add(category);
  }

  /**
   * Records entry into the Urlbar's search mode.
   *
   * Telemetry records only which search mode is entered and how it was entered.
   * It does not record anything pertaining to searches made within search mode.
   *
   * @param {object} searchMode
   *   A search mode object. See UrlbarInput.setSearchMode documentation for
   *   details.
   */
  recordSearchMode(searchMode) {
    // Search mode preview is not search mode. Recording it would just create
    // noise.
    if (searchMode.isPreview) {
      return;
    }

    let scalarKey = lazy.UrlbarSearchUtils.getSearchModeScalarKey(searchMode);
    Services.telemetry.keyedScalarAdd(
      "urlbar.searchmode." + searchMode.entry,
      scalarKey,
      1
    );
  }

  /**
   * The main entry point for recording search related Telemetry. This includes
   * search counts and engagement measurements.
   *
   * Telemetry records only search counts per engine and action origin, but
   * nothing pertaining to the search contents themselves.
   *
   * @param {browser} browser
   *        The browser where the search originated.
   * @param {nsISearchEngine} engine
   *        The engine handling the search.
   * @param {string} source
   *        Where the search originated from. See KNOWN_SEARCH_SOURCES for allowed
   *        values.
   * @param {object} [details] Options object.
   * @param {boolean} [details.isOneOff=false]
   *        true if this event was generated by a one-off search.
   * @param {boolean} [details.isSuggestion=false]
   *        true if this event was generated by a suggested search.
   * @param {boolean} [details.isFormHistory=false]
   *        true if this event was generated by a form history result.
   * @param {string} [details.alias=null]
   *        The search engine alias used in the search, if any.
   * @param {string} [details.newtabSessionId=undefined]
   *        The newtab session that prompted this search, if any.
   * @throws if source is not in the known sources list.
   */
  recordSearch(browser, engine, source, details = {}) {
    if (engine.clickUrl) {
      this.#reportSearchInGlean(engine.clickUrl);
    }

    try {
      if (!this.shouldRecordSearchCount(browser)) {
        return;
      }
      if (!KNOWN_SEARCH_SOURCES.has(source)) {
        console.error("Unknown source for search: ", source);
        return;
      }

      const countIdPrefix = `${engine.telemetryId}.`;
      const countIdSource = countIdPrefix + source;
      let histogram = Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS");

      if (
        details.alias &&
        engine.isAppProvided &&
        engine.aliases.includes(details.alias)
      ) {
        // This is a keyword search using an AppProvided engine.
        // Record the source as "alias", not "urlbar".
        histogram.add(countIdPrefix + "alias");
      } else {
        histogram.add(countIdSource);
      }

      // Dispatch the search signal to other handlers.
      switch (source) {
        case "urlbar":
        case "searchbar":
        case "urlbar-searchmode":
        case "urlbar-persisted":
        case "urlbar-handoff":
          this._handleSearchAndUrlbar(browser, engine, source, details);
          break;
        case "abouthome":
        case "newtab":
          this._recordSearch(browser, engine, source, "enter");
          break;
        default:
          this._recordSearch(browser, engine, source);
          break;
      }
      if (["urlbar-handoff", "abouthome", "newtab"].includes(source)) {
        Glean.newtabSearch.issued.record({
          newtab_visit_id: details.newtabSessionId,
          search_access_point: KNOWN_SEARCH_SOURCES.get(source),
          telemetry_id: engine.telemetryId,
        });
        lazy.SearchSERPTelemetry.recordBrowserNewtabSession(
          browser,
          details.newtabSessionId
        );
      }
    } catch (ex) {
      // Catch any errors here, so that search actions are not broken if
      // telemetry is broken for some reason.
      console.error(ex);
    }
  }

  /**
   * This function handles the "urlbar", "urlbar-oneoff", "searchbar" and
   * "searchbar-oneoff" sources.
   *
   * @param {browser} browser
   *   The browser where the search originated.
   * @param {nsISearchEngine} engine
   *   The engine handling the search.
   * @param {string} source
   *   Where the search originated from.
   * @param {object} details
   *   See {@link BrowserSearchTelemetryHandler.recordSearch}
   */
  _handleSearchAndUrlbar(browser, engine, source, details) {
    const isOneOff = !!details.isOneOff;
    let action = "enter";
    if (isOneOff) {
      action = "oneoff";
    } else if (details.isFormHistory) {
      action = "formhistory";
    } else if (details.isSuggestion) {
      action = "suggestion";
    } else if (details.alias) {
      action = "alias";
    }

    this._recordSearch(browser, engine, source, action);
  }

  _recordSearch(browser, engine, source, action = null) {
    let scalarSource = KNOWN_SEARCH_SOURCES.get(source);

    lazy.SearchSERPTelemetry.recordBrowserSource(browser, scalarSource);

    let scalarKey = action ? "search_" + action : "search";
    Services.telemetry.keyedScalarAdd(
      "browser.engagement.navigation." + scalarSource,
      scalarKey,
      1
    );
  }

  /**
   * Records the search in Glean for contextual services.
   *
   * @param {string} reportingUrl
   *   The url to be sent to contextual services.
   */
  #reportSearchInGlean(reportingUrl) {
    let defaultValuesByGleanKey = {
      contextId: lazy.contextId,
    };

    let sendGleanPing = valuesByGleanKey => {
      valuesByGleanKey = { ...defaultValuesByGleanKey, ...valuesByGleanKey };
      for (let [gleanKey, value] of Object.entries(valuesByGleanKey)) {
        let glean = Glean.searchWith[gleanKey];
        if (value !== undefined && value !== "") {
          glean.set(value);
        }
      }
      GleanPings.searchWith.submit();
    };

    sendGleanPing({
      reportingUrl,
    });
  }
}

export var BrowserSearchTelemetry = new BrowserSearchTelemetryHandler();
