/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { AsyncShutdown } from "resource://gre/modules/AsyncShutdown.sys.mjs";
import { DeferredTask } from "resource://gre/modules/DeferredTask.sys.mjs";

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

const Utils = TelemetryUtils;

const PING_FORMAT_VERSION = 4;

// Delay before intializing telemetry (ms)
const TELEMETRY_DELAY =
  Services.prefs.getIntPref("toolkit.telemetry.initDelay", 60) * 1000;
// Delay before initializing telemetry if we're testing (ms)
const TELEMETRY_TEST_DELAY = 1;

// How long to wait (ms) before sending the new profile ping on the first
// run of a new profile.
const NEWPROFILE_PING_DEFAULT_DELAY = 30 * 60 * 1000;

// Ping types.
const PING_TYPE_MAIN = "main";
const PING_TYPE_DELETION_REQUEST = "deletion-request";
const PING_TYPE_UNINSTALL = "uninstall";

// Session ping reasons.
const REASON_GATHER_PAYLOAD = "gather-payload";
const REASON_GATHER_SUBSESSION_PAYLOAD = "gather-subsession-payload";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  ClientID: "resource://gre/modules/ClientID.sys.mjs",
  CoveragePing: "resource://gre/modules/CoveragePing.sys.mjs",
  NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
  TelemetryArchive: "resource://gre/modules/TelemetryArchive.sys.mjs",
  TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
  TelemetryEventPing: "resource://gre/modules/EventPing.sys.mjs",
  TelemetryHealthPing: "resource://gre/modules/HealthPing.sys.mjs",
  TelemetryReportingPolicy:
    "resource://gre/modules/TelemetryReportingPolicy.sys.mjs",
  TelemetrySend: "resource://gre/modules/TelemetrySend.sys.mjs",
  TelemetrySession: "resource://gre/modules/TelemetrySession.sys.mjs",
  TelemetryStorage: "resource://gre/modules/TelemetryStorage.sys.mjs",
  TelemetryUntrustedModulesPing:
    "resource://gre/modules/UntrustedModulesPing.sys.mjs",
  UninstallPing: "resource://gre/modules/UninstallPing.sys.mjs",
  UpdatePing: "resource://gre/modules/UpdatePing.sys.mjs",
  UsageReporting: "resource://gre/modules/UsageReporting.sys.mjs",
});

if (
  AppConstants.platform === "win" &&
  AppConstants.MOZ_APP_NAME !== "thunderbird"
) {
  ChromeUtils.defineESModuleGetters(lazy, {
    // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
    BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.sys.mjs",
  });
}

/**
 * This is a policy object used to override behavior for testing.
 */
export var Policy = {
  now: () => new Date(),
  generatePingId: () => Utils.generateUUID(),
  getCachedClientID: () => lazy.ClientID.getCachedClientID(),
};

export var TelemetryController = Object.freeze({
  /**
   * Used only for testing purposes.
   */
  testInitLogging() {
    TelemetryControllerBase.configureLogging();
  },

  /**
   * Used only for testing purposes.
   */
  testReset() {
    return Impl.reset();
  },

  /**
   * Used only for testing purposes.
   */
  testSetup() {
    return Impl.setupTelemetry(true);
  },

  /**
   * Used only for testing purposes.
   */
  testShutdown() {
    return Impl.shutdown();
  },

  /**
   * Used only for testing purposes.
   */
  testPromiseJsProbeRegistration() {
    return Promise.resolve(Impl._probeRegistrationPromise);
  },

  /**
   * Register 'dynamic builtin' probes from the JSON definition files.
   * This is needed to support adding new probes in developer builds
   * without rebuilding the whole codebase.
   *
   * This is not meant to be used outside of local developer builds.
   */
  testRegisterJsProbes() {
    return Impl.registerJsProbes();
  },

  /**
   * Used only for testing purposes.
   */
  testPromiseDeletionRequestPingSubmitted() {
    return Promise.resolve(Impl._deletionRequestPingSubmittedPromise);
  },

  /**
   * Send a notification.
   */
  observe(aSubject, aTopic, aData) {
    return Impl.observe(aSubject, aTopic, aData);
  },

  /**
   * Submit ping payloads to Telemetry. This will assemble a complete ping, adding
   * environment data, client id and some general info.
   * Depending on configuration, the ping will be sent to the server (immediately or later)
   * and archived locally.
   *
   * To identify the different pings and to be able to query them pings have a type.
   * A type is a string identifier that should be unique to the type ping that is being submitted,
   * it should only contain alphanumeric characters and '-' for separation, i.e. satisfy:
   * /^[a-z0-9][a-z0-9-]+[a-z0-9]$/i
   *
   * @param {string} aType The type of the ping.
   * @param {object} aPayload The actual data payload for the ping.
   * @param {object} [aOptions] Options object.
   * @param {boolean} [aOptions.addClientId=false] true if the ping should contain the client
   *                  id, false otherwise.
   * @param {boolean} [aOptions.addEnvironment=false] true if the ping should contain the
   *                  environment data.
   * @param {object}  [aOptions.overrideEnvironment=null] set to override the environment data.
   * @param {boolean} [aOptions.usePingSender=false] if true, send the ping using the PingSender.
   * @param {string} [aOptions.overrideClientId=undefined] if set, override the
   *                 client id to the provided value. Implies aOptions.addClientId=true.
   * @param {string} [aOptions.overrideProfileGroupId=undefined] if set, override the
   *                 profile group id to the provided value. Implies aOptions.addClientId=true.
   * @returns {Promise} Test-only - a promise that resolves with the ping id once the ping is stored or sent.
   */
  submitExternalPing(aType, aPayload, aOptions = {}) {
    aOptions.addClientId = aOptions.addClientId || false;
    aOptions.addEnvironment = aOptions.addEnvironment || false;
    aOptions.usePingSender = aOptions.usePingSender || false;

    return Impl.submitExternalPing(aType, aPayload, aOptions);
  },

  /**
   * Get the current session ping data as it would be sent out or stored.
   *
   * @param {bool} aSubsession Whether to get subsession data. Optional, defaults to false.
   * @return {object} The current ping data if Telemetry is enabled, null otherwise.
   */
  getCurrentPingData(aSubsession = false) {
    return Impl.getCurrentPingData(aSubsession);
  },

  /**
   * Save a ping to disk.
   *
   * @param {string} aType The type of the ping.
   * @param {object} aPayload The actual data payload for the ping.
   * @param {object} [aOptions] Options object.
   * @param {boolean} [aOptions.addClientId=false] true if the ping should contain the client
   *                  id, false otherwise.
   * @param {boolean} [aOptions.addEnvironment=false] true if the ping should contain the
   *                  environment data.
   * @param {boolean} [aOptions.overwrite=false] true overwrites a ping with the same name,
   *                  if found.
   * @param {object}  [aOptions.overrideEnvironment=null] set to override the environment data.
   * @param {string} [aOptions.overrideClientId=undefined] if set, override the
   *                 client id to the provided value. Implies aOptions.addClientId=true.
   * @param {string} [aOptions.overrideProfileGroupId=undefined] if set, override the
   *                 profile group id to the provided value. Implies aOptions.addClientId=true.
   *
   * @returns {Promise} A promise that resolves with the ping id when the ping is saved to
   *                    disk.
   */
  addPendingPing(aType, aPayload, aOptions = {}) {
    let options = aOptions;
    options.addClientId = aOptions.addClientId || false;
    options.addEnvironment = aOptions.addEnvironment || false;
    options.overwrite = aOptions.overwrite || false;

    return Impl.addPendingPing(aType, aPayload, options);
  },

  /**
   * Check if we have an aborted-session ping from a previous session.
   * If so, submit and then remove it.
   *
   * @return {Promise} Promise that is resolved when the ping is saved.
   */
  checkAbortedSessionPing() {
    return Impl.checkAbortedSessionPing();
  },

  /**
   * Save an aborted-session ping to disk without adding it to the pending pings.
   *
   * @param {object} aPayload The ping payload data.
   * @return {Promise} Promise that is resolved when the ping is saved.
   */
  saveAbortedSessionPing(aPayload) {
    return Impl.saveAbortedSessionPing(aPayload);
  },

  /**
   * Remove the aborted-session ping if any exists.
   *
   * @return {Promise} Promise that is resolved when the ping was removed.
   */
  removeAbortedSessionPing() {
    return Impl.removeAbortedSessionPing();
  },

  /**
   * Create an uninstall ping and write it to disk, replacing any already present.
   * This is stored independently from other pings, and only read by
   * the Windows uninstaller.
   *
   * WINDOWS ONLY, does nothing and resolves immediately on other platforms.
   *
   * @return {Promise} Resolved when the ping has been saved.
   */
  saveUninstallPing() {
    return Impl.saveUninstallPing();
  },

  /**
   * Allows the sync ping to tell the controller that it is initializing, so
   * should be included in the orderly shutdown process.
   *
   * @param {Function} aFnShutdown The function to call as telemetry shuts down.
   */
  registerSyncPingShutdown(afnShutdown) {
    Impl.registerSyncPingShutdown(afnShutdown);
  },

  /**
   * Allows waiting for TelemetryControllers delayed initialization to complete.
   * The returned promise is guaranteed to resolve before TelemetryController is shutting down.
   *
   * @return {Promise} Resolved when delayed TelemetryController initialization completed.
   */
  promiseInitialized() {
    return Impl.promiseInitialized();
  },

  /**
   * Allows to trigger TelemetryControllers delayed initialization now and waiting for its completion.
   * The returned promise is guaranteed to resolve before TelemetryController is shutting down.
   *
   * @return {Promise} Resolved when delayed TelemetryController initialization completed.
   */
  ensureInitialized() {
    return Impl.ensureInitialized();
  },
});

var Impl = {
  _initialized: false,
  _initStarted: false, // Whether we started setting up TelemetryController.
  _shuttingDown: false, // Whether the browser is shutting down.
  _shutDown: false, // Whether the browser has shut down.
  _logger: null,
  _prevValues: {},
  // The previous build ID, if this is the first run with a new build.
  // Undefined if this is not the first run, or the previous build ID is unknown.
  _previousBuildID: undefined,
  _clientID: null,
  _profileGroupID: null,
  // A task performing delayed initialization
  _delayedInitTask: null,
  // The deferred promise resolved when the initialization task completes.
  _delayedInitTaskDeferred: null,

  // This is a public barrier Telemetry clients can use to add blockers to the shutdown
  // of TelemetryController.
  // After this barrier, clients can not submit Telemetry pings anymore.
  _shutdownBarrier: new AsyncShutdown.Barrier(
    "TelemetryController: Waiting for clients."
  ),
  // This state is included in the async shutdown annotation for crash pings and reports.
  _shutdownState: "Shutdown not started.",
  // This is a private barrier blocked by pending async ping activity (sending & saving).
  _connectionsBarrier: new AsyncShutdown.Barrier(
    "TelemetryController: Waiting for pending ping activity"
  ),
  // This is true when running in the test infrastructure.
  _testMode: false,
  // The task performing the delayed sending of the "new-profile" ping.
  _delayedNewPingTask: null,
  // The promise used to wait for the JS probe registration (dynamic builtin).
  _probeRegistrationPromise: null,
  // The promise of any outstanding task sending the "deletion-request" ping.
  _deletionRequestPingSubmittedPromise: null,
  // A function to shutdown the sync/fxa ping, or null if that ping has not
  // self-initialized.
  _fnSyncPingShutdown: null,

  get _log() {
    return TelemetryControllerBase.log;
  },

  /**
   * Get the data for the "application" section of the ping.
   */
  _getApplicationSection() {
    // Querying architecture and update channel can throw. Make sure to recover and null
    // those fields.
    let arch = null;
    try {
      arch = Services.sysinfo.get("arch");
    } catch (e) {
      this._log.trace(
        "_getApplicationSection - Unable to get system architecture.",
        e
      );
    }

    let updateChannel = null;
    try {
      updateChannel = Utils.getUpdateChannel();
    } catch (e) {
      this._log.trace(
        "_getApplicationSection - Unable to get update channel.",
        e
      );
    }

    return {
      architecture: arch,
      buildId: Services.appinfo.appBuildID,
      name: Services.appinfo.name,
      version: Services.appinfo.version,
      displayVersion: AppConstants.MOZ_APP_VERSION_DISPLAY,
      vendor: Services.appinfo.vendor,
      platformVersion: Services.appinfo.platformVersion,
      xpcomAbi: Services.appinfo.XPCOMABI,
      channel: updateChannel,
    };
  },

  /**
   * Assemble a complete ping following the common ping format specification.
   *
   * @param {string} aType The type of the ping.
   * @param {object} aPayload The actual data payload for the ping.
   * @param {object} aOptions Options object.
   * @param {boolean} aOptions.addClientId true if the ping should contain the client
   *                  id, false otherwise.
   * @param {boolean} aOptions.addEnvironment true if the ping should contain the
   *                  environment data.
   * @param {object}  [aOptions.overrideEnvironment=null] set to override the environment data.
   * @param {string} [aOptions.overrideClientId=undefined] if set, override the
   *                 client id to the provided value. Implies aOptions.addClientId=true.
   * @param {string} [aOptions.overrideProfileGroupId=undefined] if set, override the
   *                 profile group id to the provided value. Implies aOptions.addClientId=true.
   * @returns {object} An object that contains the assembled ping data.
   */
  assemblePing: function assemblePing(aType, aPayload, aOptions = {}) {
    this._log.trace(
      "assemblePing - Type " + aType + ", aOptions " + JSON.stringify(aOptions)
    );

    // Clone the payload data so we don't race against unexpected changes in subobjects that are
    // still referenced by other code.
    // We can't trust all callers to do this properly on their own.
    let payload = Cu.cloneInto(aPayload, {});

    // Fill the common ping fields.
    let pingData = {
      type: aType,
      id: Policy.generatePingId(),
      creationDate: Policy.now().toISOString(),
      version: PING_FORMAT_VERSION,
      application: this._getApplicationSection(),
      payload,
    };

    if (
      aOptions.addClientId ||
      aOptions.overrideClientId ||
      aOptions.overrideProfileGroupId
    ) {
      pingData.clientId = aOptions.overrideClientId ?? this._clientID;
      pingData.profileGroupId =
        aOptions.overrideProfileGroupId ?? this._profileGroupID;
    }

    if (aOptions.addEnvironment) {
      pingData.environment =
        aOptions.overrideEnvironment ||
        lazy.TelemetryEnvironment.currentEnvironment;
    }

    return pingData;
  },

  /**
   * Track any pending ping send and save tasks through the promise passed here.
   * This is needed to block shutdown on any outstanding ping activity.
   */
  _trackPendingPingTask(aPromise) {
    this._connectionsBarrier.client.addBlocker(
      "Waiting for ping task",
      aPromise
    );
  },

  /**
   * Internal function to assemble a complete ping, adding environment data, client id
   * and some general info. This waits on the client id to be loaded/generated if it's
   * not yet available. Note that this function is synchronous unless we need to load
   * the client id.
   * Depending on configuration, the ping will be sent to the server (immediately or later)
   * and archived locally.
   *
   * @param {string} aType The type of the ping.
   * @param {object} aPayload The actual data payload for the ping.
   * @param {object} [aOptions] Options object.
   * @param {boolean} [aOptions.addClientId=false] true if the ping should contain the client
   *                  id, false otherwise.
   * @param {boolean} [aOptions.addEnvironment=false] true if the ping should contain the
   *                  environment data.
   * @param {object}  [aOptions.overrideEnvironment=null] set to override the environment data.
   * @param {boolean} [aOptions.usePingSender=false] if true, send the ping using the PingSender.
   * @param {string} [aOptions.overrideClientId=undefined] if set, override the
   *                 client id to the provided value. Implies aOptions.addClientId=true.
   * @param {string} [aOptions.overrideProfileGroupId=undefined] if set, override the
   *                 profile group id to the provided value. Implies aOptions.addClientId=true.
   * @returns {Promise} Test-only - a promise that is resolved with the ping id once the ping is stored or sent.
   */
  async _submitPingLogic(aType, aPayload, aOptions) {
    // Make sure to have a clientId if we need one. This cover the case of submitting
    // a ping early during startup, before Telemetry is initialized, if no client id was
    // cached.
    let needsIdentifiers =
      aOptions.addClientId ||
      aOptions.overrideClientId ||
      aOptions.overrideProfileGroupId;
    let hasClientId = aOptions.overrideClientId ?? this._clientID;
    let hasProfileGroupId =
      aOptions.overrideProfileGroupId ?? this._profileGroupID;

    if (needsIdentifiers && !(hasClientId && hasProfileGroupId)) {
      this._log.trace(
        "_submitPingLogic - Waiting on client id or profile group id"
      );
      Glean.telemetry.pingSubmissionWaitingClientid.add(1);
      // We can safely call |getClientID| here and during initialization: we would still
      // spawn and return one single loading task.
      this._clientID = await lazy.ClientID.getClientID();
      this._profileGroupID = await lazy.ClientID.getProfileGroupID();
    }

    let pingData = this.assemblePing(aType, aPayload, aOptions);
    this._log.trace("submitExternalPing - ping assembled, id: " + pingData.id);

    // Always persist the pings if we are allowed to. We should not yield on any of the
    // following operations to keep this function synchronous for the majority of the calls.
    let archivePromise = lazy.TelemetryArchive.promiseArchivePing(
      pingData
    ).catch(e =>
      this._log.error(
        "submitExternalPing - Failed to archive ping " + pingData.id,
        e
      )
    );
    let p = [archivePromise];

    p.push(
      lazy.TelemetrySend.submitPing(pingData, {
        usePingSender: aOptions.usePingSender,
      })
    );

    return Promise.all(p).then(() => pingData.id);
  },

  /**
   * Submit ping payloads to Telemetry.
   *
   * @param {string} aType The type of the ping.
   * @param {object} aPayload The actual data payload for the ping.
   * @param {object} [aOptions] Options object.
   * @param {boolean} [aOptions.addClientId=false] true if the ping should contain the client
   *                  id, false otherwise.
   * @param {boolean} [aOptions.addEnvironment=false] true if the ping should contain the
   *                  environment data.
   * @param {object}  [aOptions.overrideEnvironment=null] set to override the environment data.
   * @param {boolean} [aOptions.usePingSender=false] if true, send the ping using the PingSender.
   * @param {string} [aOptions.overrideClientId=undefined] if set, override the
   *                 client id to the provided value. Implies aOptions.addClientId=true.
   * @param {string} [aOptions.overrideProfileGroupId=undefined] if set, override the
   *                 profile group id to the provided value. Implies aOptions.addClientId=true.
   * @returns {Promise} Test-only - a promise that is resolved with the ping id once the ping is stored or sent.
   */
  submitExternalPing: function send(aType, aPayload, aOptions) {
    this._log.trace(
      "submitExternalPing - type: " +
        aType +
        ", aOptions: " +
        JSON.stringify(aOptions)
    );

    const disabledPings =
      lazy.NimbusFeatures.legacyTelemetry.getVariable("disabledPings") ?? [];
    const UNCONTROLLABLE_PINGS = [
      "main",
      "first-shutdown",
      "new-profile",
      "deletion-request",
    ];
    if (disabledPings.includes(aType)) {
      if (UNCONTROLLABLE_PINGS.includes(aType)) {
        this._log.warn(
          `submitExternalPing - type: ${aType} not controllable, but is in the list of disabledPings ${JSON.stringify(disabledPings)}. Ping will submit as normal. Please remove ping type "${aType}" from the Nimbus config.`
        );
      } else {
        this._log.trace(
          `submitExternalPing - type ${aType} disabled by Nimbus.`
        );
        return Promise.reject(new Error("Ping disabled."));
      }
    }

    // Reject pings sent after shutdown.
    if (this._shutDown) {
      const errorMessage =
        "submitExternalPing - Submission is not allowed after shutdown, discarding ping of type: " +
        aType;
      this._log.error(errorMessage);
      return Promise.reject(new Error(errorMessage));
    }

    // Enforce the type string to only contain sane characters.
    const typeUuid = /^[a-z0-9][a-z0-9-]+[a-z0-9]$/i;
    if (!typeUuid.test(aType)) {
      this._log.error("submitExternalPing - invalid ping type: " + aType);
      Glean.telemetry.invalidPingTypeSubmitted[aType].add(1);
      return Promise.reject(new Error("Invalid type string submitted."));
    }
    // Enforce that the payload is an object.
    if (
      aPayload === null ||
      typeof aPayload !== "object" ||
      Array.isArray(aPayload)
    ) {
      this._log.error(
        "submitExternalPing - invalid payload type: " + typeof aPayload
      );
      Glean.telemetry.invalidPayloadSubmitted.add(1);
      return Promise.reject(new Error("Invalid payload type submitted."));
    }

    let promise = this._submitPingLogic(aType, aPayload, aOptions);
    this._trackPendingPingTask(promise);
    return promise;
  },

  /**
   * Save a ping to disk.
   *
   * @param {string} aType The type of the ping.
   * @param {object} aPayload The actual data payload for the ping.
   * @param {object} aOptions Options object.
   * @param {boolean} aOptions.addClientId true if the ping should contain the client id,
   *                  false otherwise.
   * @param {boolean} aOptions.addEnvironment true if the ping should contain the
   *                  environment data.
   * @param {boolean} aOptions.overwrite true overwrites a ping with the same name, if found.
   * @param {object}  [aOptions.overrideEnvironment=null] set to override the environment data.
   * @param {string} [aOptions.overrideClientId=undefined] if set, override the
   *                 client id to the provided value. Implies aOptions.addClientId=true.
   * @param {string} [aOptions.overrideProfileGroupId=undefined] if set, override the
   *                 profile group id to the provided value. Implies aOptions.addClientId=true.
   *
   * @returns {Promise} A promise that resolves with the ping id when the ping is saved to
   *                    disk.
   */
  addPendingPing: function addPendingPing(aType, aPayload, aOptions) {
    this._log.trace(
      "addPendingPing - Type " +
        aType +
        ", aOptions " +
        JSON.stringify(aOptions)
    );

    let pingData = this.assemblePing(aType, aPayload, aOptions);

    let savePromise = lazy.TelemetryStorage.savePendingPing(pingData);
    let archivePromise = lazy.TelemetryArchive.promiseArchivePing(
      pingData
    ).catch(e => {
      this._log.error(
        "addPendingPing - Failed to archive ping " + pingData.id,
        e
      );
    });

    // Wait for both the archiving and ping persistence to complete.
    let promises = [savePromise, archivePromise];
    return Promise.all(promises).then(() => pingData.id);
  },

  /**
   * Check whether we have an aborted-session ping. If so add it to the pending pings and archive it.
   *
   * @return {Promise} Promise that is resolved when the ping is submitted and archived.
   */
  async checkAbortedSessionPing() {
    let ping = await lazy.TelemetryStorage.loadAbortedSessionPing();
    this._log.trace(
      "checkAbortedSessionPing - found aborted-session ping: " + !!ping
    );
    if (!ping) {
      return;
    }

    try {
      // Previous aborted-session might have been with a canary client ID.
      // Don't send it.
      if (ping.clientId != Utils.knownClientID) {
        await lazy.TelemetryStorage.savePendingPing(ping);
        await lazy.TelemetryArchive.promiseArchivePing(ping);
      }
    } catch (e) {
      this._log.error(
        "checkAbortedSessionPing - Unable to add the pending ping",
        e
      );
    } finally {
      await lazy.TelemetryStorage.removeAbortedSessionPing();
    }
  },

  /**
   * Save an aborted-session ping to disk without adding it to the pending pings.
   *
   * @param {object} aPayload The ping payload data.
   * @return {Promise} Promise that is resolved when the ping is saved.
   */
  saveAbortedSessionPing(aPayload) {
    this._log.trace("saveAbortedSessionPing");
    const options = { addClientId: true, addEnvironment: true };
    const pingData = this.assemblePing(PING_TYPE_MAIN, aPayload, options);
    return lazy.TelemetryStorage.saveAbortedSessionPing(pingData);
  },

  removeAbortedSessionPing() {
    return lazy.TelemetryStorage.removeAbortedSessionPing();
  },

  async saveUninstallPing() {
    if (AppConstants.platform != "win") {
      return undefined;
    }

    this._log.trace("saveUninstallPing");

    let payload = {};
    try {
      payload.otherInstalls = lazy.UninstallPing.getOtherInstallsCount();
      this._log.info(
        "saveUninstallPing - otherInstalls",
        payload.otherInstalls
      );
    } catch (e) {
      this._log.warn("saveUninstallPing - getOtherInstallCount failed", e);
    }
    const options = { addClientId: true, addEnvironment: true };
    const pingData = this.assemblePing(PING_TYPE_UNINSTALL, payload, options);

    return lazy.TelemetryStorage.saveUninstallPing(pingData);
  },

  /**
   * This triggers basic telemetry initialization and schedules a full initialized for later
   * for performance reasons.
   *
   * This delayed initialization means TelemetryController init can be in the following states:
   * 1) setupTelemetry was never called
   * or it was called and
   *   2) _delayedInitTask was scheduled, but didn't run yet.
   *   3) _delayedInitTask is currently running.
   *   4) _delayedInitTask finished running and is nulled out.
   *
   * @return {Promise} Resolved when TelemetryController and TelemetrySession are fully
   *                   initialized. This is only used in tests.
   */
  setupTelemetry: function setupTelemetry(testing) {
    this._initStarted = true;
    this._shuttingDown = false;
    this._shutDown = false;
    this._testMode = testing;

    this._log.trace("setupTelemetry");

    if (this._delayedInitTask) {
      this._log.error("setupTelemetry - init task already running");
      return this._delayedInitTaskDeferred.promise;
    }

    if (this._initialized && !this._testMode) {
      this._log.error("setupTelemetry - already initialized");
      return Promise.resolve();
    }

    // Enable adding scalars in artifact builds and build faster modes.
    // The function is async: we intentionally don't wait for it to complete
    // as we don't want to delay startup.
    this._probeRegistrationPromise = this.registerJsProbes();

    // This will trigger displaying the datachoices infobar.
    lazy.TelemetryReportingPolicy.setup();

    if (!TelemetryControllerBase.enableTelemetryRecording()) {
      this._log.config(
        "setupChromeProcess - Telemetry recording is disabled, skipping Chrome process setup."
      );
      return Promise.resolve();
    }

    this._attachObservers();

    // Perform a lightweight, early initialization for the component, just registering
    // a few observers and initializing the session.
    lazy.TelemetrySession.earlyInit(this._testMode);
    Services.telemetry.earlyInit();

    // Annotate crash reports so that we get pings for startup crashes
    lazy.TelemetrySend.earlyInit();

    // For very short session durations, we may never load the client
    // id from disk.
    // We try to cache it in prefs to avoid this, even though this may
    // lead to some stale client ids.
    this._clientID = lazy.ClientID.getCachedClientID();
    this._profileGroupID = lazy.ClientID.getCachedProfileGroupID();

    // Init the update ping telemetry as early as possible. This won't have
    // an impact on startup.
    lazy.UpdatePing.earlyInit();

    // Delay full telemetry initialization to give the browser time to
    // run various late initializers. Otherwise our gathered memory
    // footprint and other numbers would be too optimistic.
    this._delayedInitTaskDeferred = Promise.withResolvers();
    this._delayedInitTask = new DeferredTask(
      async () => {
        try {
          // TODO: This should probably happen after all the delayed init here.
          this._initialized = true;
          await lazy.TelemetryEnvironment.delayedInit();

          // Load the ClientID.
          this._clientID = await lazy.ClientID.getClientID();
          this._profileGroupID = await lazy.ClientID.getProfileGroupID();

          // Fix-up a canary client ID if detected.
          const uploadEnabled = Services.prefs.getBoolPref(
            TelemetryUtils.Preferences.FhrUploadEnabled,
            false
          );
          if (
            uploadEnabled &&
            (this._clientID == Utils.knownClientID ||
              this._profileGroupID == Utils.knownProfileGroupID)
          ) {
            this._log.trace(
              "Upload enabled, but got canary identifiers. Resetting."
            );
            await lazy.ClientID.resetIdentifiers();
            this._clientID = await lazy.ClientID.getClientID();
            this._profileGroupID = await lazy.ClientID.getProfileGroupID();
          } else if (
            !uploadEnabled &&
            (this._clientID != Utils.knownClientID ||
              this._profileGroupID != Utils.knownProfileGroupID)
          ) {
            this._log.trace(
              "Upload disabled, but got a valid client ID. Setting canary client ID."
            );
            await lazy.ClientID.setCanaryIdentifiers();
            this._clientID = await lazy.ClientID.getClientID();
            this._profileGroupID = await lazy.ClientID.getProfileGroupID();
          }

          await lazy.TelemetrySend.setup(this._testMode);

          // Perform TelemetrySession delayed init.
          await lazy.TelemetrySession.delayedInit();
          await Services.telemetry.delayedInit();

          if (
            Services.prefs.getBoolPref(
              TelemetryUtils.Preferences.NewProfilePingEnabled,
              false
            ) &&
            !lazy.TelemetrySession.newProfilePingSent
          ) {
            // Kick off the scheduling of the new-profile ping.
            this.scheduleNewProfilePing();
          }

          // Purge the pings archive by removing outdated pings. We don't wait for
          // this task to complete, but TelemetryStorage blocks on it during
          // shutdown.
          lazy.TelemetryStorage.runCleanPingArchiveTask();

          // Now that FHR/healthreporter is gone, make sure to remove FHR's DB from
          // the profile directory. This is a temporary measure that we should drop
          // in the future.
          lazy.TelemetryStorage.removeFHRDatabase();

          // The init sequence is forced to run on shutdown for short sessions and
          // we don't want to start everything.
          if (!this._shuttingDown) {
            // Send coverage ping.
            await lazy.CoveragePing.startup();

            // Start the untrusted modules ping, which reports events where
            // untrusted modules were loaded into the Firefox process.
            if (AppConstants.platform == "win") {
              lazy.TelemetryUntrustedModulesPing.start();
            }
          }

          lazy.TelemetryEventPing.startup();

          if (uploadEnabled) {
            await this.saveUninstallPing().catch(e =>
              this._log.warn("_delayedInitTask - saveUninstallPing failed", e)
            );
          } else {
            await lazy.TelemetryStorage.removeUninstallPings().catch(e =>
              this._log.warn("_delayedInitTask - saveUninstallPing", e)
            );
          }

          this._delayedInitTaskDeferred.resolve();
        } catch (e) {
          this._delayedInitTaskDeferred.reject(e);
        } finally {
          this._delayedInitTask = null;
        }
      },
      this._testMode ? TELEMETRY_TEST_DELAY : TELEMETRY_DELAY,
      this._testMode ? 0 : undefined
    );

    IOUtils.sendTelemetry.addBlocker(
      "TelemetryController: shutting down",
      () => this.shutdown(),
      () => this._getState()
    );

    this._delayedInitTask.arm();
    return this._delayedInitTaskDeferred.promise;
  },

  // Do proper shutdown waiting and cleanup.
  async _cleanupOnShutdown() {
    if (!this._initialized) {
      return;
    }

    this._detachObservers();

    // Now do an orderly shutdown.
    try {
      if (this._delayedNewPingTask) {
        await this._delayedNewPingTask.finalize();
      }

      lazy.UpdatePing.shutdown();

      lazy.TelemetryEventPing.shutdown();

      // Shutdown the sync ping if it is initialized - this is likely, but not
      // guaranteed, to submit a "shutdown" sync ping.
      if (this._fnSyncPingShutdown) {
        this._fnSyncPingShutdown();
      }

      // Stop the datachoices infobar display.
      lazy.TelemetryReportingPolicy.shutdown();
      lazy.TelemetryEnvironment.shutdown();

      // Stop any ping sending.
      await lazy.TelemetrySend.shutdown();

      // Send latest data.
      await lazy.TelemetryHealthPing.shutdown();

      await lazy.TelemetrySession.shutdown();
      await Services.telemetry.shutdown();

      // First wait for clients processing shutdown.
      await this._shutdownBarrier.wait();

      // ... and wait for any outstanding async ping activity.
      await this._connectionsBarrier.wait();

      if (AppConstants.platform !== "android") {
        // No PingSender on Android.
        lazy.TelemetrySend.flushPingSenderBatch();
      }

      // Perform final shutdown operations.
      await lazy.TelemetryStorage.shutdown();
    } finally {
      // Reset state.
      this._initialized = false;
      this._initStarted = false;
      this._shutDown = true;
    }
  },

  shutdown() {
    this._log.trace("shutdown");

    this._shuttingDown = true;

    // We can be in one the following states here:
    // 1) setupTelemetry was never called
    // or it was called and
    //   2) _delayedInitTask was scheduled, but didn't run yet.
    //   3) _delayedInitTask is running now.
    //   4) _delayedInitTask finished running already.

    // This handles 1).
    if (!this._initStarted) {
      this._shutDown = true;
      return Promise.resolve();
    }

    // This handles 4).
    if (!this._delayedInitTask) {
      // We already ran the delayed initialization.
      return this._cleanupOnShutdown();
    }

    // This handles 2) and 3).
    return this._delayedInitTask
      .finalize()
      .then(() => this._cleanupOnShutdown());
  },

  /**
   * This observer drives telemetry.
   */
  observe(aSubject, aTopic, aData) {
    // The logger might still be not available at this point.
    if (aTopic == "profile-after-change") {
      // If we don't have a logger, we need to make sure |Log.repository.getLogger()| is
      // called before |getLoggerWithMessagePrefix|. Otherwise logging won't work.
      TelemetryControllerBase.configureLogging();
    }

    this._log.trace(`observe - ${aTopic} notified.`);

    switch (aTopic) {
      case "profile-after-change":
        // profile-after-change is only registered for chrome processes.
        return this.setupTelemetry();
      case "nsPref:changed":
        if (aData == TelemetryUtils.Preferences.FhrUploadEnabled) {
          return this._onUploadPrefChange();
        }
        if (aData == "datareporting.usage.uploadEnabled") {
          return lazy.UsageReporting._onUsagePrefChange();
        }
    }
    return undefined;
  },

  /**
   * Register the sync ping's shutdown handler.
   */
  registerSyncPingShutdown(fnShutdown) {
    if (this._fnSyncPingShutdown) {
      throw new Error("The sync ping shutdown handler is already registered.");
    }
    this._fnSyncPingShutdown = fnShutdown;
  },

  /**
   * Get an object describing the current state of this module for AsyncShutdown diagnostics.
   */
  _getState() {
    return {
      initialized: this._initialized,
      initStarted: this._initStarted,
      haveDelayedInitTask: !!this._delayedInitTask,
      shutdownBarrier: this._shutdownBarrier.state,
      connectionsBarrier: this._connectionsBarrier.state,
      sendModule: lazy.TelemetrySend.getShutdownState(),
      haveDelayedNewProfileTask: !!this._delayedNewPingTask,
    };
  },

  /**
   * Called whenever the FHR Upload preference changes (e.g. when user disables FHR from
   * the preferences panel), this triggers sending the "deletion-request" ping.
   */
  _onUploadPrefChange() {
    const uploadEnabled = Services.prefs.getBoolPref(
      TelemetryUtils.Preferences.FhrUploadEnabled,
      false
    );
    if (uploadEnabled) {
      this._log.trace(
        "_onUploadPrefChange - upload was enabled again. Resetting identifiers"
      );

      // Delete cached identifiers immediately, so other usage is forced to refetch it.
      this._clientID = null;
      this._profileGroupID = null;

      // Generate a new client ID and make sure this module uses the new version
      let p = (async () => {
        await lazy.ClientID.resetIdentifiers();
        this._clientID = await lazy.ClientID.getClientID();
        this._profileGroupID = await lazy.ClientID.getProfileGroupID();
        Glean.telemetry.dataUploadOptin.set(true);

        await this.saveUninstallPing().catch(e =>
          this._log.warn("_onUploadPrefChange - saveUninstallPing failed", e)
        );
      })();

      this._shutdownBarrier.client.addBlocker(
        "TelemetryController: resetting client ID after data upload was enabled",
        p
      );

      return;
    }

    let p = (async () => {
      try {
        // 1. Cancel the current pings.
        // 2. Clear unpersisted pings
        await lazy.TelemetrySend.clearCurrentPings();

        // 3. Remove all pending pings
        await lazy.TelemetryStorage.removeAppDataPings();
        await lazy.TelemetryStorage.runRemovePendingPingsTask();
        await lazy.TelemetryStorage.removeUninstallPings();
      } catch (e) {
        this._log.error(
          "_onUploadPrefChange - error clearing pending pings",
          e
        );
      } finally {
        // 4. Reset session and subsession counter
        lazy.TelemetrySession.resetSubsessionCounter();

        // 5. Collect any additional identifiers we want to send in the
        // deletion request.
        const scalars = Services.telemetry.getSnapshotForScalars(
          "deletion-request",
          /* clear */ true
        );

        // 6. Set identifiers to a known values
        let oldClientId = await lazy.ClientID.getClientID();
        let oldProfileGroupId = await lazy.ClientID.getProfileGroupID();
        await lazy.ClientID.setCanaryIdentifiers();
        this._clientID = await lazy.ClientID.getClientID();
        this._profileGroupID = await lazy.ClientID.getProfileGroupID();

        // 7. Send the deletion-request ping.
        this._log.trace("_onUploadPrefChange - Sending deletion-request ping.");
        this.submitExternalPing(
          PING_TYPE_DELETION_REQUEST,
          { scalars },
          {
            overrideClientId: oldClientId,
            overrideProfileGroupId: oldProfileGroupId,
          }
        );
        this._deletionRequestPingSubmittedPromise = null;
      }
    })();

    this._deletionRequestPingSubmittedPromise = p;
    this._shutdownBarrier.client.addBlocker(
      "TelemetryController: removing pending pings after data upload was disabled",
      p
    );

    Services.obs.notifyObservers(
      null,
      TelemetryUtils.TELEMETRY_UPLOAD_DISABLED_TOPIC
    );
  },

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

  _attachObservers() {
    if (TelemetryControllerBase.IS_UNIFIED_TELEMETRY) {
      // Watch the FHR upload setting to trigger "deletion-request" pings.
      Services.prefs.addObserver(
        TelemetryUtils.Preferences.FhrUploadEnabled,
        this
      );
    }
    if (AppConstants.MOZ_APP_NAME == "firefox") {
      // Firefox-only: watch the usage reporting setting to enable, disable, and
      // trigger "usage-deletion-request" pings.
      Services.prefs.addObserver("datareporting.usage.uploadEnabled", this);
    }
  },

  /**
   * Remove the preference observer to avoid leaks.
   */
  _detachObservers() {
    if (TelemetryControllerBase.IS_UNIFIED_TELEMETRY) {
      Services.prefs.removeObserver(
        TelemetryUtils.Preferences.FhrUploadEnabled,
        this
      );
    }
    if (AppConstants.MOZ_APP_NAME == "firefox") {
      Services.prefs.removeObserver("datareporting.usage.uploadEnabled", this);
    }
  },

  /**
   * Allows waiting for TelemetryControllers delayed initialization to complete.
   * This will complete before TelemetryController is shutting down.
   *
   * @return {Promise} Resolved when delayed TelemetryController initialization completed.
   */
  promiseInitialized() {
    return this._delayedInitTaskDeferred.promise;
  },

  /**
   * Allows to trigger TelemetryControllers delayed initialization now and waiting for its completion.
   * This will complete before TelemetryController is shutting down.
   *
   * @return {Promise} Resolved when delayed TelemetryController initialization completed.
   */
  ensureInitialized() {
    if (this._delayedInitTask) {
      return this._delayedInitTask.finalize();
    }
    return Promise.resolve();
  },

  getCurrentPingData(aSubsession) {
    this._log.trace("getCurrentPingData - subsession: " + aSubsession);

    // Telemetry is disabled, don't gather any data.
    if (!Services.telemetry.canRecordBase) {
      return null;
    }

    const reason = aSubsession
      ? REASON_GATHER_SUBSESSION_PAYLOAD
      : REASON_GATHER_PAYLOAD;
    const type = PING_TYPE_MAIN;
    const payload = lazy.TelemetrySession.getPayload(reason);
    const options = { addClientId: true, addEnvironment: true };
    const ping = this.assemblePing(type, payload, options);

    return ping;
  },

  async reset() {
    this._clientID = null;
    this._profileGroupID = null;
    this._fnSyncPingShutdown = null;
    this._detachObservers();

    let sessionReset = lazy.TelemetrySession.testReset();

    this._connectionsBarrier = new AsyncShutdown.Barrier(
      "TelemetryController: Waiting for pending ping activity"
    );
    this._shutdownBarrier = new AsyncShutdown.Barrier(
      "TelemetryController: Waiting for clients."
    );

    // We need to kick of the controller setup first for tests that check the
    // cached client id.
    let controllerSetup = this.setupTelemetry(true);

    await sessionReset;
    await lazy.TelemetrySend.reset();
    await lazy.TelemetryStorage.reset();
    await lazy.TelemetryEnvironment.testReset();

    await controllerSetup;
  },

  /**
   * Schedule sending the "new-profile" ping.
   */
  scheduleNewProfilePing() {
    this._log.trace("scheduleNewProfilePing");

    const sendDelay = Services.prefs.getIntPref(
      TelemetryUtils.Preferences.NewProfilePingDelay,
      NEWPROFILE_PING_DEFAULT_DELAY
    );

    if (
      AppConstants.platform == "win" &&
      AppConstants.MOZ_APP_NAME !== "thunderbird"
    ) {
      try {
        // This is asynchronous, but we aren't going to await on it now. Just
        // kick it off.
        lazy.BrowserUsageTelemetry.reportInstallationTelemetry();
      } catch (ex) {
        this._log.warn(
          "scheduleNewProfilePing - reportInstallationTelemetry failed",
          ex
        );
      }
    }

    this._delayedNewPingTask = new DeferredTask(async () => {
      try {
        await this.sendNewProfilePing();
      } finally {
        this._delayedNewPingTask = null;
      }
    }, sendDelay);

    this._delayedNewPingTask.arm();
  },

  /**
   * Generate and send the new-profile ping
   */
  async sendNewProfilePing() {
    this._log.trace(
      "sendNewProfilePing - shutting down: " + this._shuttingDown
    );

    if (
      AppConstants.platform == "win" &&
      AppConstants.MOZ_APP_NAME !== "thunderbird"
    ) {
      let failureReason = "UnknownError";
      try {
        await lazy.BrowserUsageTelemetry.reportInstallationTelemetry();
        failureReason = "NoError";
      } catch (ex) {
        this._log.warn(
          "sendNewProfilePing - reportInstallationTelemetry failed",
          ex
        );
        // Overwrite with a more specific error if possible.
        failureReason = ex.name;
      } finally {
        // No dataPathOverride here so we can check the default location
        // for installation_telemetry.json
        let dataPath = Services.dirsvc.get("GreD", Ci.nsIFile);
        dataPath.append("installation_telemetry.json");
        let fileExists = await IOUtils.exists(dataPath.path);
        if (!fileExists) {
          failureReason = "NotFoundError";
        }
        Glean.installationFirstSeen.failureReason.set(failureReason);
      }
    }

    const scalars = Services.telemetry.getSnapshotForScalars(
      "new-profile",
      /* clear */ true
    );

    // Generate the payload.
    const payload = {
      reason: this._shuttingDown ? "shutdown" : "startup",
      processes: {
        parent: {
          scalars: scalars.parent,
        },
      },
    };

    // Generate and send the "new-profile" ping. This uses the
    // pingsender if we're shutting down.
    let options = {
      addClientId: true,
      addEnvironment: true,
      usePingSender: this._shuttingDown,
    };
    // TODO: we need to be smarter about when to send the ping (and save the
    // state to file). |requestIdleCallback| is currently only accessible
    // through DOM. See bug 1361996.
    await TelemetryController.submitExternalPing(
      "new-profile",
      payload,
      options
    ).then(
      () => lazy.TelemetrySession.markNewProfilePingSent(),
      e =>
        this._log.error(
          "sendNewProfilePing - failed to submit new-profile ping",
          e
        )
    );
  },

  /**
   * Register 'dynamic builtin' probes from the JSON definition files.
   * This is needed to support adding new probes in developer builds
   * without rebuilding the whole codebase.
   *
   * This is not meant to be used outside of local developer builds.
   */
  async registerJsProbes() {
    // We don't support this outside of developer builds.
    if (AppConstants.MOZILLA_OFFICIAL && !this._testMode) {
      return;
    }

    this._log.trace("registerJsProbes - registering builtin JS probes");

    await this.registerScalarProbes();
    await this.registerEventProbes();
  },

  _loadProbeDefinitions(filename) {
    let probeFile = Services.dirsvc.get("GreD", Ci.nsIFile);
    probeFile.append(filename);
    if (!probeFile.exists()) {
      this._log.trace(
        `loadProbeDefinitions - no builtin JS probe file ${filename}`
      );
      return null;
    }

    return IOUtils.readUTF8(probeFile.path);
  },

  async registerScalarProbes() {
    this._log.trace(
      "registerScalarProbes - registering scalar builtin JS probes"
    );

    // Load the scalar probes JSON file.
    const scalarProbeFilename = "ScalarArtifactDefinitions.json";
    let scalarJSProbes = {};
    try {
      let fileContent = await this._loadProbeDefinitions(scalarProbeFilename);
      scalarJSProbes = JSON.parse(fileContent, (property, value) => {
        // Fixup the "kind" property: it's a string, and we need the constant
        // coming from nsITelemetry.
        if (property !== "kind" || typeof value != "string") {
          return value;
        }

        let newValue;
        switch (value) {
          case "nsITelemetry::SCALAR_TYPE_COUNT":
            newValue = Services.telemetry.SCALAR_TYPE_COUNT;
            break;
          case "nsITelemetry::SCALAR_TYPE_BOOLEAN":
            newValue = Services.telemetry.SCALAR_TYPE_BOOLEAN;
            break;
          case "nsITelemetry::SCALAR_TYPE_STRING":
            newValue = Services.telemetry.SCALAR_TYPE_STRING;
            break;
        }
        return newValue;
      });
    } catch (ex) {
      this._log.error(
        `registerScalarProbes - there was an error loading ${scalarProbeFilename}`,
        ex
      );
    }

    // Register the builtin probes.
    for (let category in scalarJSProbes) {
      // Expire the expired scalars
      for (let name in scalarJSProbes[category]) {
        let def = scalarJSProbes[category][name];
        if (
          !def ||
          !def.expires ||
          def.expires == "never" ||
          def.expires == "default"
        ) {
          continue;
        }
        if (
          Services.vc.compare(AppConstants.MOZ_APP_VERSION, def.expires) >= 0
        ) {
          def.expired = true;
        }
      }
      Services.telemetry.registerBuiltinScalars(
        category,
        scalarJSProbes[category]
      );
    }
  },

  async registerEventProbes() {
    this._log.trace(
      "registerEventProbes - registering builtin JS Event probes"
    );

    // Load the event probes JSON file.
    const eventProbeFilename = "EventArtifactDefinitions.json";
    let eventJSProbes = {};
    try {
      let fileContent = await this._loadProbeDefinitions(eventProbeFilename);
      eventJSProbes = JSON.parse(fileContent);
    } catch (ex) {
      this._log.error(
        `registerEventProbes - there was an error loading ${eventProbeFilename}`,
        ex
      );
    }

    // Register the builtin probes.
    for (let category in eventJSProbes) {
      for (let name in eventJSProbes[category]) {
        let def = eventJSProbes[category][name];
        if (
          !def ||
          !def.expires ||
          def.expires == "never" ||
          def.expires == "default"
        ) {
          continue;
        }
        if (
          Services.vc.compare(AppConstants.MOZ_APP_VERSION, def.expires) >= 0
        ) {
          def.expired = true;
        }
      }
      Services.telemetry.registerBuiltinEvents(
        category,
        eventJSProbes[category]
      );
    }
  },
};
