/* 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 sends Telemetry Events periodically:
 * https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/telemetry/data/event-ping.html
 */

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

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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  Log: "resource://gre/modules/Log.sys.mjs",
  TelemetryController: "resource://gre/modules/TelemetryController.sys.mjs",
  TelemetrySession: "resource://gre/modules/TelemetrySession.sys.mjs",
  clearTimeout: "resource://gre/modules/Timer.sys.mjs",
  setTimeout: "resource://gre/modules/Timer.sys.mjs",
});

const Utils = TelemetryUtils;

const MS_IN_A_MINUTE = 60 * 1000;

const DEFAULT_EVENT_LIMIT = 1000;
const DEFAULT_MIN_FREQUENCY_MS = 60 * MS_IN_A_MINUTE;
const DEFAULT_MAX_FREQUENCY_MS = 10 * MS_IN_A_MINUTE;

const LOGGER_NAME = "Toolkit.Telemetry";
const LOGGER_PREFIX = "TelemetryEventPing::";

const EVENT_LIMIT_REACHED_TOPIC = "event-telemetry-storage-limit-reached";

export var Policy = {
  setTimeout: (callback, delayMs) => lazy.setTimeout(callback, delayMs),
  clearTimeout: id => lazy.clearTimeout(id),
  sendPing: (type, payload, options) =>
    lazy.TelemetryController.submitExternalPing(type, payload, options),
};

export var TelemetryEventPing = {
  Reason: Object.freeze({
    PERIODIC: "periodic", // Sent the ping containing events from the past periodic interval (default one hour).
    MAX: "max", // Sent the ping containing the maximum number (default 1000) of event records, earlier than the periodic interval.
    SHUTDOWN: "shutdown", // Recorded data was sent on shutdown.
  }),

  EVENT_PING_TYPE: "event",

  _logger: null,

  _testing: false,

  // So that if we quickly reach the max limit we can immediately send.
  _lastSendTime: -DEFAULT_MIN_FREQUENCY_MS,

  _processStartTimestamp: 0,

  get dataset() {
    return Services.telemetry.canRecordPrereleaseData
      ? Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS
      : Ci.nsITelemetry.DATASET_ALL_CHANNELS;
  },

  startup() {
    this._log.trace("Starting up.");

    // Calculate process creation once.
    this._processStartTimestamp =
      Math.round(
        (Date.now() - TelemetryUtils.monotonicNow()) / MS_IN_A_MINUTE
      ) * MS_IN_A_MINUTE;

    Services.obs.addObserver(this, EVENT_LIMIT_REACHED_TOPIC);

    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "maxFrequency",
      Utils.Preferences.EventPingMaximumFrequency,
      DEFAULT_MAX_FREQUENCY_MS
    );
    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "minFrequency",
      Utils.Preferences.EventPingMinimumFrequency,
      DEFAULT_MIN_FREQUENCY_MS
    );

    this._startTimer();
  },

  shutdown() {
    this._log.trace("Shutting down.");
    // removeObserver may throw, which could interrupt shutdown.
    try {
      Services.obs.removeObserver(this, EVENT_LIMIT_REACHED_TOPIC);
    } catch (ex) {}

    this._submitPing(this.Reason.SHUTDOWN, true /* discardLeftovers */);
    this._clearTimer();
  },

  observe(aSubject, aTopic) {
    switch (aTopic) {
      case EVENT_LIMIT_REACHED_TOPIC:
        this._log.trace("event limit reached");
        let now = Utils.monotonicNow();
        if (now - this._lastSendTime < this.maxFrequency) {
          this._log.trace("can't submit ping immediately as it's too soon");
          this._startTimer(
            this.maxFrequency - this._lastSendTime,
            this.Reason.MAX,
            true /* discardLeftovers*/
          );
        } else {
          this._log.trace("submitting ping immediately");
          this._submitPing(this.Reason.MAX);
        }
        break;
    }
  },

  _startTimer(
    delay = this.minFrequency,
    reason = this.Reason.PERIODIC,
    discardLeftovers = false
  ) {
    this._clearTimer();
    this._timeoutId = Policy.setTimeout(
      () => TelemetryEventPing._submitPing(reason, discardLeftovers),
      delay
    );
  },

  _clearTimer() {
    if (this._timeoutId) {
      Policy.clearTimeout(this._timeoutId);
      this._timeoutId = null;
    }
  },

  /**
   * Submits an "event" ping and restarts the timer for the next interval.
   *
   * @param {String} reason The reason we're sending the ping. One of TelemetryEventPing.Reason.
   * @param {bool} discardLeftovers Whether to discard event records left over from a previous ping.
   */
  _submitPing(reason, discardLeftovers = false) {
    this._log.trace("_submitPing");

    if (reason !== this.Reason.SHUTDOWN) {
      this._startTimer();
    }

    let snapshot = Services.telemetry.snapshotEvents(
      this.dataset,
      true /* clear */,
      DEFAULT_EVENT_LIMIT
    );

    if (!this._testing) {
      for (let process of Object.keys(snapshot)) {
        snapshot[process] = snapshot[process].filter(
          ([, category]) => !category.startsWith("telemetry.test")
        );
      }
    }

    let eventCount = Object.values(snapshot).reduce(
      (acc, val) => acc + val.length,
      0
    );
    if (eventCount === 0) {
      // Don't send a ping if we haven't any events.
      this._log.trace("not sending event ping due to lack of events");
      return;
    }

    // The reason doesn't matter as it will just be echo'd back.
    let sessionMeta = lazy.TelemetrySession.getMetadata(reason);

    let payload = {
      reason,
      processStartTimestamp: this._processStartTimestamp,
      sessionId: sessionMeta.sessionId,
      subsessionId: sessionMeta.subsessionId,
      lostEventsCount: 0,
      events: snapshot,
    };

    if (discardLeftovers) {
      // Any leftovers must be discarded, the count submitted in the ping.
      // This can happen on shutdown or if our max was reached before faster
      // than our maxFrequency.
      let leftovers = Services.telemetry.snapshotEvents(
        this.dataset,
        true /* clear */
      );
      let leftoverCount = Object.values(leftovers).reduce(
        (acc, val) => acc + val.length,
        0
      );
      payload.lostEventsCount = leftoverCount;
    }

    const options = {
      addClientId: true,
      addEnvironment: true,
      usePingSender: reason == this.Reason.SHUTDOWN,
    };

    this._lastSendTime = Utils.monotonicNow();
    Services.telemetry
      .getHistogramById("TELEMETRY_EVENT_PING_SENT")
      .add(reason);
    Policy.sendPing(this.EVENT_PING_TYPE, payload, options);
  },

  /**
   * Test-only, restore to initial state.
   */
  testReset() {
    this._lastSendTime = -DEFAULT_MIN_FREQUENCY_MS;
    this._clearTimer();
    this._testing = true;
  },

  get _log() {
    if (!this._logger) {
      this._logger = lazy.Log.repository.getLoggerWithMessagePrefix(
        LOGGER_NAME,
        LOGGER_PREFIX + "::"
      );
    }

    return this._logger;
  },
};
