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

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

const lazy = XPCOMUtils.declareLazy({
  GeolocationUtils:
    "moz-src:///browser/components/urlbar/private/GeolocationUtils.sys.mjs",
  ObliviousHTTP: "resource://gre/modules/ObliviousHTTP.sys.mjs",
  SkippableTimer: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs",
  UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
  UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs",
});

/**
 * @import {SkippableTimer} from "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs"
 * @import {OHTTPResponse} from "resource://gre/modules/ObliviousHTTP.sys.mjs"
 */

/**
 * @typedef {object} MerinoClientBaseSuggestion
 * @property {string} request_id
 *   The request id associated with the suggestion.
 * @property {string} source
 *   The source of the suggestion.
 *
 * @typedef {{[key: string]:any} & MerinoClientBaseSuggestion} MerinoClientSuggestion
 *   Details of a suggestion received from Merino. Whilst the base properties are
 *   consistent the suggestion properties may vary depending on the provider.
 */

const SEARCH_PARAMS = Object.freeze({
  CLIENT_VARIANTS: "client_variants",
  PROVIDERS: "providers",
  QUERY: "q",
  SEQUENCE_NUMBER: "seq",
  SESSION_ID: "sid",
});

const SESSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes

/**
 * Client class for querying the Merino server. Each instance maintains its own
 * session state including a session ID and sequence number that is included in
 * its requests to Merino.
 */
export class MerinoClient {
  #lazy = XPCOMUtils.declareLazy({
    logger: () =>
      lazy.UrlbarUtils.getLogger({ prefix: `MerinoClient [${this.#name}]` }),
  });

  /**
   * The names of URL search params.
   */
  static get SEARCH_PARAMS() {
    return { ...SEARCH_PARAMS };
  }

  /**
   * @param {string} [name]
   *   An optional name for the client. It will be included in log messages.
   * @param {object} [options]
   *   Options object
   * @param {boolean} [options.allowOhttp]
   *   Whether the client is allowed to make its requests using OHTTP. When true
   *   and the following prefs are defined, all requests made by the client will
   *   use OHTTP:
   *
   *   browser.urlbar.merino.ohttpConfigURL (Nimbus: merinoOhttpConfigURL)
   *   browser.urlbar.merino.ohttpRelayURL (Nimbus: merinoOhttpRelayURL)
   *
   * @param {number} [options.cachePeriodMs]
   *   Enables caching when nonzero. The client will cache the response
   *   suggestions from its most recent successful request for the specified
   *   period. The client will serve the cached suggestions for all fetches for
   *   the same URL until either the cache period elapses or a successful fetch
   *   for a different URL is made (ignoring session-related URL params like
   *   session ID and sequence number). Caching is per `MerinoClient` instance
   *   and is not shared across instances.
   *
   *   WARNING: Cached suggestions are only ever evicted when new suggestions
   *   are cached. They are not evicted on a timer. If the client has cached
   *   some suggestions and no further fetches are made, they'll stay cached
   *   indefinitely. If your request URLs contain senstive data that should not
   *   stick around in the object graph indefinitely, you should either not use
   *   caching or you should implement an eviction mechanism.
   *
   *   This cache strategy is intentionally simplistic and designed to be used
   *   by the urlbar with very short cache periods to make sure Firefox doesn't
   *   repeatedly call the same Merino URL on each keystroke in a urlbar
   *   session, which is wasteful and can cause a suggestion to flicker out of
   *   and into the urlbar panel as the user matches it again and again,
   *   especially when Merino latency is high. It is not designed to be a
   *   general caching mechanism. If you need more complex or long-lived
   *   caching, try working with the Merino team to add cache headers to the
   *   relevant responses so you can leverage Firefox's HTTP cache.
   */
  constructor(
    name = "anonymous",
    { allowOhttp = false, cachePeriodMs = 0 } = {}
  ) {
    this.#name = name;
    this.#allowOhttp = allowOhttp;
    this.#cachePeriodMs = cachePeriodMs;
  }

  /**
   * @returns {string}
   *   The name of the client.
   */
  get name() {
    return this.#name;
  }

  /**
   * @returns {number}
   *   If `resetSession()` is not called within this timeout period after a
   *   session starts, the session will time out and the next fetch will begin a
   *   new session.
   */
  get sessionTimeoutMs() {
    return this.#sessionTimeoutMs;
  }
  set sessionTimeoutMs(value) {
    this.#sessionTimeoutMs = value;
  }

  // Note: Cannot be JSDoc due to https://github.com/pyodide/sphinx-js/issues/242
  // The current session ID. Null when there is no active session.
  get sessionID() {
    return this.#sessionID;
  }

  /**
   * @returns {number}
   *   The current sequence number in the current session. Zero when there is no
   *   active session.
   */
  get sequenceNumber() {
    return this.#sequenceNumber;
  }

  // Note: Cannot be JSDoc due to https://github.com/pyodide/sphinx-js/issues/242
  // A string that indicates the status of the last fetch. Possible values:
  // success, timeout, network_error, http_error
  get lastFetchStatus() {
    return this.#lastFetchStatus;
  }

  /**
   * Fetches Merino suggestions.
   *
   * @param {object} options
   *   Options object
   * @param {string} options.query
   *   The search string.
   * @param {string[]} options.providers
   *   Array of provider names to request from Merino. If this is given it will
   *   override the `merinoProviders` Nimbus variable and its fallback pref
   *   `browser.urlbar.merino.providers`.
   * @param {number} options.timeoutMs
   *   Timeout in milliseconds. This method will return once the timeout
   *   elapses, a response is received, or an error occurs, whichever happens
   *   first.
   * @param {{[key: string]: string}} options.otherParams
   *   If specified, the otherParams will be added as a query params. Currently
   *   used for accuweather's location autocomplete endpoint
   * @param {string} [options.endpointUrl]
   *   If specified, overrides the default `merinoEndpointURL` pref value.
   * @returns {Promise<MerinoClientSuggestion[]>}
   *   The Merino suggestions or null if there's an error or unexpected
   *   response.
   */
  async fetch({
    query,
    providers = null,
    timeoutMs = lazy.UrlbarPrefs.get("merinoTimeoutMs"),
    otherParams = {},
    endpointUrl = lazy.UrlbarPrefs.get("merinoEndpointURL"),
  }) {
    this.#lazy.logger.debug("Fetch start", { query });

    // If endpointUrl is explicitly provided, use it as-is
    // otherwise fall back to the general Merino endpoint pref
    let endpointString =
      endpointUrl || lazy.UrlbarPrefs.get("merinoEndpointURL");
    if (!endpointString) {
      return [];
    }
    let url = URL.parse(endpointString);
    if (!url) {
      let error = new Error(`${endpointString} is not a valid URL`);
      this.#lazy.logger.error("Error creating endpoint URL", error);
      return [];
    }

    // Start setting search params. Leave session-related params for last.
    url.searchParams.set(SEARCH_PARAMS.QUERY, query);

    let clientVariants = lazy.UrlbarPrefs.get("merinoClientVariants");
    if (clientVariants) {
      url.searchParams.set(SEARCH_PARAMS.CLIENT_VARIANTS, clientVariants);
    }

    let providersString;
    if (providers != null) {
      if (!Array.isArray(providers)) {
        throw new Error("providers must be an array if given");
      }
      providersString = providers.join(",");
    } else {
      let value = lazy.UrlbarPrefs.get("merinoProviders");
      if (value) {
        // The Nimbus variable/pref is used only if it's a non-empty string.
        providersString = value;
      }
    }

    // An empty providers string is a valid value and means Merino should
    // receive the request but not return any suggestions, so do not do a simple
    // `if (providersString)` here.
    if (typeof providersString == "string") {
      url.searchParams.set(SEARCH_PARAMS.PROVIDERS, providersString);
    }

    // if otherParams are present add them to the url
    for (const [param, value] of Object.entries(otherParams)) {
      url.searchParams.set(param, value);
    }

    // At this point, all search params should be set except for session-related
    // params.

    let details = { query, providers, timeoutMs, url: url.toString() };

    // If caching is enabled, generate the cache key for this request URL.
    let cacheKey;
    if (this.#cachePeriodMs && !MerinoClient._test_disableCache) {
      url.searchParams.sort();
      cacheKey = url.toString();

      // If we have cached suggestions and they're still valid, return them.
      if (
        this.#cache.suggestions &&
        Date.now() < this.#cache.dateMs + this.#cachePeriodMs &&
        this.#cache.key == cacheKey
      ) {
        this.#lazy.logger.debug("Fetch served from cache", details);
        return this.#cache.suggestions;
      }
    }

    // At this point, we're calling Merino.

    // Set up the Merino session ID and related state. The session ID is a UUID
    // without leading and trailing braces.
    if (!this.#sessionID) {
      let uuid = Services.uuid.generateUUID().toString();
      this.#sessionID = uuid.substring(1, uuid.length - 1);
      this.#sequenceNumber = 0;
      this.#sessionTimer?.cancel();

      // Per spec, for the user's privacy, the session should time out and a new
      // session ID should be used if the engagement does not end soon.
      this.#sessionTimer = new lazy.SkippableTimer({
        name: "Merino session timeout",
        time: this.#sessionTimeoutMs,
        logger: this.#lazy.logger,
        callback: () => this.resetSession(),
      });
    }
    url.searchParams.set(SEARCH_PARAMS.SESSION_ID, this.#sessionID);
    url.searchParams.set(
      SEARCH_PARAMS.SEQUENCE_NUMBER,
      this.#sequenceNumber.toString()
    );
    this.#sequenceNumber++;

    this.#lazy.logger.debug("Fetch details", {
      ...details,
      url: url.toString(),
    });

    /** @type {(category: string) => void} */
    let recordResponse = category => {
      this.#lazy.logger.debug("Fetch done", { status: category });
      this.#lastFetchStatus = category;
      recordResponse = null;
    };

    // Set up the timeout timer.
    let timer = (this.#timeoutTimer = new lazy.SkippableTimer({
      name: "Merino timeout",
      time: timeoutMs,
      logger: this.#lazy.logger,
      callback: () => {
        // The fetch timed out.
        this.#lazy.logger.debug("Fetch timed out", { timeoutMs });
        recordResponse?.("timeout");
      },
    }));

    // If there's an ongoing fetch, abort it so there's only one at a time. By
    // design we do not abort fetches on timeout or when the query is canceled
    // so we can record their latency.
    try {
      this.#fetchController?.abort();
    } catch (error) {
      this.#lazy.logger.error("Error aborting previous fetch", error);
    }

    // Do the fetch.
    /** @type {?OHTTPResponse|?Response} */
    let response;
    let controller = (this.#fetchController = new AbortController());
    await Promise.race([
      timer.promise,
      (async () => {
        try {
          // Canceling the timer below resolves its promise, which can resolve
          // the outer promise created by `Promise.race`. This inner async
          // function happens not to await anything after canceling the timer,
          // but if it did, `timer.promise` could win the race and resolve the
          // outer promise without a value. For that reason, we declare
          // `response` in the outer scope and set it here instead of returning
          // the response from this inner function and assuming it will also be
          // returned by `Promise.race`.
          let result = await this.#fetch(url, { signal: controller.signal });
          response = result?.response;
          this.#lazy.logger.debug("Got response", {
            status: response?.status,
            elapsedMs: result ? result.elapsedMs : "n/a",
            ...details,
          });
          if (!response?.ok) {
            recordResponse?.("http_error");
          }
        } catch (error) {
          if (error.name != "AbortError") {
            this.#lazy.logger.error("Fetch error", error);
            recordResponse?.("network_error");
          }
        } finally {
          // Now that the fetch is done, cancel the timeout timer so it doesn't
          // fire and record a timeout. If it already fired, which it would have
          // on timeout, or was already canceled, this is a no-op.
          timer.cancel();
          if (controller == this.#fetchController) {
            this.#fetchController = null;
          }
          this.#nextResponseDeferred?.resolve(response);
          this.#nextResponseDeferred = null;
        }
      })(),
    ]);
    if (timer == this.#timeoutTimer) {
      this.#timeoutTimer = null;
    }

    if (!response?.ok) {
      // `recordResponse()` was already called above, no need to call it here.
      return [];
    }

    if (response.status == 204) {
      // No content. We check for this because `response.json()` (below) throws
      // in this case, and since we log the error it can spam the console.
      recordResponse?.("no_suggestion");
      return [];
    }

    // Get the response body as an object.
    /** @type {{suggestions: MerinoClientSuggestion[], request_id: string }} */
    let body;
    try {
      body = /** @type {any} */ (await response.json());
    } catch (error) {
      this.#lazy.logger.error("Error getting response as JSON", error);
    }

    if (body) {
      this.#lazy.logger.debug("Response body", body);
    }

    if (!body?.suggestions?.length) {
      recordResponse?.("no_suggestion");
      return [];
    }

    let { suggestions, request_id } = body;
    if (!Array.isArray(suggestions)) {
      this.#lazy.logger.error("Unexpected response", body);
      recordResponse?.("no_suggestion");
      return [];
    }

    recordResponse?.("success");
    suggestions = suggestions.map(suggestion => ({
      ...suggestion,
      request_id,
      source: "merino",
    }));

    if (cacheKey) {
      this.#cache = {
        suggestions,
        key: cacheKey,
        dateMs: Date.now(),
      };
    }

    return suggestions;
  }

  /**
   * Auto complete the weather location name. This is intended to be used only
   * in conjunction with `fetchWeatherReport()` and causes Merino to call the location
   * autocomplete endpoint of a third-party weather API.
   *
   * @param {object} options
   *   Options object
   * @param {string} options.source
   *   The source that is requesting this fetch.
   * @param {string=} options.query
   *   The string that will be autocompleted.
   * @param {number=} options.timeoutMs
   *   Timeout in milliseconds. This method will return once the timeout
   *   elapses, a response is received, or an error occurs, whichever happens
   *   first.
   * @returns {Promise<MerinoClientSuggestion>}
   *   The Merino suggestions or null if there's an error or unexpected
   *   response.
   */
  async autoCompleteWeatherLocation({ source, query, timeoutMs = undefined }) {
    let response = await this.fetch({
      providers: ["accuweather"],
      query: query || "",
      otherParams: {
        request_type: "location",
        source,
      },
      timeoutMs,
    });
    return response?.[0] ?? null;
  }

  /**
   * Fetch weather report information (current conditions + daily forecast) for
   * a given location. There are three separate options for specifying the
   * location, choose only one:
   *
   * (1) Pass `locationName`
   * (2) Pass `city`, `region`, and `country`
   * (3) Don't pass any of the above, in which case geolocation will be used
   *
   * If the `locationName` is specified, it will be chosen over the other.
   *
   * @param {object} options
   *   Options object
   * @param {string} options.source
   *   The source that is requesting this fetch.
   * @param {string=} options.locationName
   *   The location name that should come from autoCompleteWeatherLocation().
   * @param {string=} options.country
   *   The country that should be a country code.
   * @param {string=} options.region
   *   The region that should be a comma-separated string of administrative
   *   region codes.
   * @param {string=} options.city
   *   The city that should be a city name.
   * @param {number=} options.timeoutMs
   *   Timeout in milliseconds. This method will return once the timeout
   *   elapses, a response is received, or an error occurs, whichever happens
   *   first.
   * @returns {Promise<MerinoClientSuggestion>}
   *   The Merino suggestions or null if there's an error or unexpected
   *   response.
   */
  async fetchWeatherReport({
    source,
    city = undefined,
    country = undefined,
    region = undefined,
    locationName = undefined,
    timeoutMs = undefined,
  }) {
    if (locationName) {
      city = undefined;
      country = undefined;
      region = undefined;
    }

    let otherParams = {
      request_type: "weather",
      source,
    };

    if (!locationName) {
      let geoParams = await this.#resolveGeoParams(city, country, region);
      if (!geoParams) {
        return null;
      }
      Object.assign(otherParams, geoParams);
    }

    let response = await this.fetch({
      providers: ["accuweather"],
      query: locationName ?? "",
      otherParams,
      timeoutMs,
      endpointUrl: lazy.UrlbarPrefs.get("merino.weather.reportEndpointURL"),
    });
    return response?.[0] ?? null;
  }

  /**
   * Fetch hourly weather forecasts for a given location. There are three
   * separate options for specifying the location, choose only one:
   *
   * (1) Pass `locationName`
   * (2) Pass `city`, `region`, and `country`
   * (3) Don't pass any of the above, in which case geolocation will be used
   *
   * If the `locationName` is specified, it will be chosen over the others.
   *
   * Unlike fetchWeatherReport() which returns a single Merino suggestion
   * object, fetchHourlyForecasts() returns an array of
   * hourly forecast slots, so the structure is set to be different. Each
   * slot has: date_time, epoch_date_time, icon_id, temperature ({ c, f }),
   * and url.
   *
   * @param {object} options
   *   Options object
   * @param {string} options.source
   *   The source that is requesting this fetch.
   * @param {string=} options.locationName
   *   The location name that should come from autoCompleteWeatherLocation().
   * @param {string=} options.country
   *   The country that should be a country code.
   * @param {string=} options.region
   *   The region that should be a comma-separated string of administrative
   *   region codes.
   * @param {string=} options.city
   *   The city that should be a city name.
   * @returns {Promise<Array<{date_time: string, epoch_date_time: number, icon_id: number, temperature: {c: number, f: number}, url: string}>|null>}
   *   Array of hourly forecast slots, or null if the endpoint is not
   *   configured or an error occurs.
   */
  async fetchHourlyForecasts({
    source,
    locationName = undefined,
    city = undefined,
    region = undefined,
    country = undefined,
  }) {
    const hourlyEndpointURL = lazy.UrlbarPrefs.get(
      "merino.weather.hourlyEndpointURL"
    );

    if (!hourlyEndpointURL) {
      return null;
    }

    let url = URL.parse(hourlyEndpointURL);
    if (!url) {
      this.#lazy.logger.error(
        "Invalid hourly forecast endpoint URL",
        hourlyEndpointURL
      );
      return null;
    }

    if (locationName) {
      url.searchParams.set("q", locationName);
    }

    // Unlike the suggest endpoint used by fetchWeatherReport(), the hourly
    // endpoint doesn't resolve AccuWeather location keys from q alone.
    // Always include city/region/country so the endpoint can identify the
    // location, falling back to geolocation when they aren't provided.
    let geoParams = await this.#resolveGeoParams(city, country, region);
    if (!geoParams) {
      return null;
    }
    for (let [key, value] of Object.entries(geoParams)) {
      url.searchParams.set(key, value);
    }

    if (source) {
      url.searchParams.set("source", source);
    }

    try {
      const response = await fetch(url);
      /**
       * @type {Array<{
       *   date_time: string,
       *   epoch_date_time: number,
       *   icon_id: number,
       *   temperature: {c: number, f: number},
       *   url: string
       * }>}
       */
      const data = /** @type {any} */ (await response.json());

      this.#lazy.logger.debug("fetchHourlyForecasts response", data);
      return data;
    } catch (e) {
      this.#lazy.logger.error("Hourly forecast fetch error", e);
      return null;
    }
  }

  /**
   * Resolves geo params from the provided values, falling back to geolocation
   * if none are provided.
   *
   * @param {string} city
   * @param {string} country
   * @param {string} region
   * @returns {Promise<{city?: string, country?: string, region?: string}|null>}
   *   An object with the resolved geo params, or null if geolocation failed.
   */
  async #resolveGeoParams(city, country, region) {
    if (!city && !country && !region) {
      let geolocation = await lazy.GeolocationUtils.geolocation();
      if (!geolocation) {
        return null;
      }
      country = geolocation.country_code;
      region =
        geolocation.region_code || geolocation.region || geolocation.city;
      city = geolocation.city || geolocation.region;
    }
    let params = {};
    if (country) {
      params.country = country;
    }
    if (region) {
      params.region = region;
    }
    if (city) {
      params.city = city;
    }
    return params;
  }

  /**
   * Resets the Merino session ID and related state.
   */
  resetSession() {
    this.#sessionID = null;
    this.#sequenceNumber = 0;
    this.#sessionTimer?.cancel();
    this.#sessionTimer = null;
    this.#nextSessionResetDeferred?.resolve();
    this.#nextSessionResetDeferred = null;
  }

  /**
   * Cancels the timeout timer.
   */
  cancelTimeoutTimer() {
    this.#timeoutTimer?.cancel();
  }

  /**
   * Returns a promise that's resolved when the next response is received or a
   * network error occurs.
   *
   * @returns {Promise<?Response|?OHTTPResponse>}
   *   The promise is resolved with the `Response` object or undefined if a
   *   network error occurred.
   */
  waitForNextResponse() {
    if (!this.#nextResponseDeferred) {
      this.#nextResponseDeferred = Promise.withResolvers();
    }
    return this.#nextResponseDeferred.promise;
  }

  /**
   * Returns a promise that's resolved when the session is next reset, including
   * on session timeout.
   *
   * @returns {Promise<void>}
   */
  waitForNextSessionReset() {
    if (!this.#nextSessionResetDeferred) {
      this.#nextSessionResetDeferred = Promise.withResolvers();
    }
    return this.#nextSessionResetDeferred.promise;
  }

  /**
   * Sends the Merino request. Uses OHTTP if `allowOhttp` is true and the Merino
   * OHTTP prefs are defined.
   *
   * @param {URL} url
   *   The request URL.
   * @param {object} options
   *   Options object.
   * @param {AbortSignal} options.signal
   *   An `AbortController.signal` for the fetch.
   * @returns {Promise<?FetchResult>}
   *   The fetch result, or null if the fetch couldn't be started.
   *
   * @typedef {object} FetchResult
   * @property {OHTTPResponse|Response} response
   *   The response object.
   * @property {number} elapsedMs
   *   The duration of the fetch in ms.
   */
  async #fetch(url, { signal }) {
    let configUrl;
    let relayUrl;
    if (this.#allowOhttp) {
      configUrl = lazy.UrlbarPrefs.get("merinoOhttpConfigURL");
      relayUrl = lazy.UrlbarPrefs.get("merinoOhttpRelayURL");
    }

    let useOhttp = configUrl && relayUrl;

    let response;
    let startMs = ChromeUtils.now();
    if (!useOhttp) {
      response = await fetch(url, { signal });
    } else {
      let config = await lazy.ObliviousHTTP.getOHTTPConfig(configUrl);
      if (!config) {
        this.#lazy.logger.error("Couldn't get OHTTP config");
        return null;
      }

      this.#lazy.logger.debug("Sending request using OHTTP", { url });
      response = await lazy.ObliviousHTTP.ohttpRequest(relayUrl, config, url, {
        signal,
        headers: {},
      });
    }

    let elapsedMs = ChromeUtils.now() - startMs;
    let label = response.status.toString();
    if (useOhttp) {
      label += "_ohttp";
    }
    Glean.urlbarMerino.latencyByResponseStatus[label].accumulateSamples([
      elapsedMs,
    ]);

    return { response, elapsedMs };
  }

  static _test_disableCache = false;

  get _test_sessionTimer() {
    return this.#sessionTimer;
  }

  get _test_timeoutTimer() {
    return this.#timeoutTimer;
  }

  get _test_fetchController() {
    return this.#fetchController;
  }

  // State related to the current session.
  /** @type {string} */
  #sessionID = null;
  #sequenceNumber = 0;
  /** @type {SkippableTimer} */
  #sessionTimer = null;
  #sessionTimeoutMs = SESSION_TIMEOUT_MS;

  #name;
  /** @type {SkippableTimer} */
  #timeoutTimer = null;
  /** @type {AbortController} */
  #fetchController = null;
  /** @type {string} */
  #lastFetchStatus = null;
  /** @type {PromiseWithResolvers<?Response|?OHTTPResponse>} */
  #nextResponseDeferred = null;
  /** @type {PromiseWithResolvers<void>} */
  #nextSessionResetDeferred = null;
  #cachePeriodMs = 0;
  #allowOhttp = false;

  // When caching is enabled, we cache response suggestions from the most recent
  // successful request.
  #cache = {
    /**
     * @type {MerinoClientSuggestion[]}
     *   The cached suggestions array.
     */
    suggestions: null,
    /**
     * @type {string}
     *   The cache key: the stringified request URL without session-related
     *   params (session ID and sequence number).
     */
    key: null,
    /**
     * The date the suggestions were cached as returned by `Date.now()`.
     */
    dateMs: 0,
  };
}
