/* 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 is the telemetry module to report metrics for tools.
 *
 * Comprehensive documentation is in docs/frontend/telemetry.md
 */

"use strict";

const TelemetryStopwatch = require("TelemetryStopwatch");
const {
  getNthPathExcluding,
} = require("resource://devtools/shared/platform/stack.js");
const { TelemetryEnvironment } = ChromeUtils.importESModule(
  "resource://gre/modules/TelemetryEnvironment.sys.mjs"
);
const WeakMapMap = require("resource://devtools/client/shared/WeakMapMap.js");

const CATEGORY = "devtools.main";

// Object to be shared among all instances.
const PENDING_EVENT_PROPERTIES = new WeakMapMap();
const PENDING_EVENTS = new WeakMapMap();

/**
 * Instantiate a new Telemetry helper class.
 *
 * @param {Object} options [optional]
 * @param {Boolean} options.useSessionId [optional]
 *        If true, this instance will automatically generate a unique "sessionId"
 *        and use it to aggregate all records against this unique session.
 *        This helps aggregate all data coming from a single toolbox instance for ex.
 */
class Telemetry {
  constructor({ useSessionId = false } = {}) {
    // Note that native telemetry APIs expect a string
    this.sessionId = String(
      useSessionId ? parseInt(this.msSinceProcessStart(), 10) : -1
    );

    // Bind pretty much all functions so that callers do not need to.
    this.msSystemNow = this.msSystemNow.bind(this);
    this.getHistogramById = this.getHistogramById.bind(this);
    this.getKeyedHistogramById = this.getKeyedHistogramById.bind(this);
    this.scalarSet = this.scalarSet.bind(this);
    this.scalarAdd = this.scalarAdd.bind(this);
    this.keyedScalarAdd = this.keyedScalarAdd.bind(this);
    this.keyedScalarSet = this.keyedScalarSet.bind(this);
    this.recordEvent = this.recordEvent.bind(this);
    this.setEventRecordingEnabled = this.setEventRecordingEnabled.bind(this);
    this.preparePendingEvent = this.preparePendingEvent.bind(this);
    this.addEventProperty = this.addEventProperty.bind(this);
    this.addEventProperties = this.addEventProperties.bind(this);
    this.toolOpened = this.toolOpened.bind(this);
    this.toolClosed = this.toolClosed.bind(this);
  }

  get osNameAndVersion() {
    const osInfo = TelemetryEnvironment.currentEnvironment.system.os;

    if (!osInfo) {
      return "Unknown OS";
    }

    let osVersion = `${osInfo.name} ${osInfo.version}`;

    if (osInfo.windowsBuildNumber) {
      osVersion += `.${osInfo.windowsBuildNumber}`;
    }

    return osVersion;
  }

  /**
   * Time since the system wide epoch. This is not a monotonic timer but
   * can be used across process boundaries.
   */
  msSystemNow() {
    return Services.telemetry.msSystemNow();
  }

  /**
   * The number of milliseconds since process start using monotonic
   * timestamps (unaffected by system clock changes).
   */
  msSinceProcessStart() {
    return Services.telemetry.msSinceProcessStart();
  }

  /**
   * Starts a timer associated with a telemetry histogram. The timer can be
   * directly associated with a histogram, or with a pair of a histogram and
   * an object.
   *
   * @param {String} histogramId
   *        A string which must be a valid histogram name.
   * @param {Object} obj
   *        The telemetry event or ping is associated with this object, meaning
   *        that multiple events or pings for the same histogram may be run
   *        concurrently, as long as they are associated with different objects.
   * @param {Object}  [options.inSeconds=false]
   *        Record elapsed time for this histogram in seconds instead of
   *        milliseconds. Defaults to false.
   * @returns {Boolean}
   *          True if the timer was successfully started, false otherwise. If a
   *          timer already exists, it can't be started again.
   */
  start(histogramId, obj, { inSeconds } = {}) {
    if (TelemetryStopwatch.running(histogramId, obj)) {
      return false;
    }

    return TelemetryStopwatch.start(histogramId, obj, { inSeconds });
  }

  /**
   * Starts a timer associated with a keyed telemetry histogram. The timer can
   * be directly associated with a histogram and its key. Similarly to
   * TelemetryStopwatch.start the histogram and its key can be associated
   * with an object. Each key may have multiple associated objects and each
   * object can be associated with multiple keys.
   *
   * @param {String} histogramId
   *        A string which must be a valid histogram name.
   * @param {String} key
   *        A string which must be a valid histgram key.
   * @param {Object} obj
   *        The telemetry event or ping is associated with this object, meaning
   *        that multiple events or pings for the same histogram may be run
   *        concurrently, as long as they are associated with different objects.
   * @param {Object}  [options.inSeconds=false]
   *        Record elapsed time for this histogram in seconds instead of
   *        milliseconds. Defaults to false.
   *
   * @returns {Boolean}
   *          True if the timer was successfully started, false otherwise. If a
   *          timer already exists, it can't be started again, and the existing
   *          one will be cleared in order to avoid measurements errors.
   */
  startKeyed(histogramId, key, obj, { inSeconds } = {}) {
    return TelemetryStopwatch.startKeyed(histogramId, key, obj, { inSeconds });
  }

  /**
   * Stops the timer associated with the given histogram (and object),
   * calculates the time delta between start and finish, and adds the value
   * to the histogram.
   *
   * @param {String} histogramId
   *        A string which must be a valid histogram name.
   * @param {Object} obj
   *        The telemetry event or ping is associated with this object, meaning
   *        that multiple events or pings for the same histogram may be run
   *        concurrently, as long as they are associated with different objects.
   * @param {Boolean} canceledOkay
   *        Optional parameter which will suppress any warnings that normally
   *        fire when a stopwatch is finished after being canceled.
   *        Defaults to false.
   *
   * @returns {Boolean}
   *          True if the timer was succesfully stopped and the data was added
   *          to the histogram, False otherwise.
   */
  finish(histogramId, obj, canceledOkay) {
    return TelemetryStopwatch.finish(histogramId, obj, canceledOkay);
  }

  /**
   * Stops the timer associated with the given keyed histogram (and object),
   * calculates the time delta between start and finish, and adds the value
   * to the keyed histogram.
   *
   * @param {String} histogramId
   *        A string which must be a valid histogram name.
   * @param {String} key
   *        A string which must be a valid histogram key.
   * @param {Object} obj
   *        The telemetry event or ping is associated with this object, meaning
   *        that multiple events or pings for the same histogram may be run
   *        concurrently, as long as they are associated with different objects.
   * @param {Boolean} canceledOkay
   *        Optional parameter which will suppress any warnings that normally
   *        fire when a stopwatch is finished after being canceled.
   *        Defaults to false.
   *
   * @returns {Boolean}
   *          True if the timer was succesfully stopped and the data was added
   *          to the histogram, False otherwise.
   */
  finishKeyed(histogramId, key, obj, canceledOkay) {
    return TelemetryStopwatch.finishKeyed(histogramId, key, obj, canceledOkay);
  }

  /**
   * Log a value to a histogram.
   *
   * @param  {String} histogramId
   *         Histogram in which the data is to be stored.
   */
  getHistogramById(histogramId) {
    let histogram = null;

    if (histogramId) {
      try {
        histogram = Services.telemetry.getHistogramById(histogramId);
      } catch (e) {
        dump(
          `Warning: An attempt was made to write to the ${histogramId} ` +
            `histogram, which is not defined in Histograms.json\n` +
            `CALLER: ${getCaller()}`
        );
      }
    }

    return (
      histogram || {
        add: () => {},
      }
    );
  }

  /**
   * Get a keyed histogram.
   *
   * @param  {String} histogramId
   *         Histogram in which the data is to be stored.
   */
  getKeyedHistogramById(histogramId) {
    let histogram = null;

    if (histogramId) {
      try {
        histogram = Services.telemetry.getKeyedHistogramById(histogramId);
      } catch (e) {
        dump(
          `Warning: An attempt was made to write to the ${histogramId} ` +
            `histogram, which is not defined in Histograms.json\n` +
            `CALLER: ${getCaller()}`
        );
      }
    }
    return (
      histogram || {
        add: () => {},
      }
    );
  }

  /**
   * Log a value to a scalar.
   *
   * @param  {String} scalarId
   *         Scalar in which the data is to be stored.
   * @param  value
   *         Value to store.
   */
  scalarSet(scalarId, value) {
    if (!scalarId) {
      return;
    }

    try {
      if (isNaN(value) && typeof value !== "boolean") {
        dump(
          `Warning: An attempt was made to write a non-numeric and ` +
            `non-boolean value ${value} to the ${scalarId} scalar. Only ` +
            `numeric and boolean values are allowed.\n` +
            `CALLER: ${getCaller()}`
        );

        return;
      }
      Services.telemetry.scalarSet(scalarId, value);
    } catch (e) {
      dump(
        `Warning: An attempt was made to write to the ${scalarId} ` +
          `scalar, which is not defined in Scalars.yaml\n` +
          `CALLER: ${getCaller()}`
      );
    }
  }

  /**
   * Log a value to a count scalar.
   *
   * @param  {String} scalarId
   *         Scalar in which the data is to be stored.
   * @param  value
   *         Value to store.
   */
  scalarAdd(scalarId, value) {
    if (!scalarId) {
      return;
    }

    try {
      if (isNaN(value)) {
        dump(
          `Warning: An attempt was made to write a non-numeric value ` +
            `${value} to the ${scalarId} scalar. Only numeric values are ` +
            `allowed.\n` +
            `CALLER: ${getCaller()}`
        );

        return;
      }
      Services.telemetry.scalarAdd(scalarId, value);
    } catch (e) {
      dump(
        `Warning: An attempt was made to write to the ${scalarId} ` +
          `scalar, which is not defined in Scalars.yaml\n` +
          `CALLER: ${getCaller()}`
      );
    }
  }

  /**
   * Log a value to a keyed scalar.
   *
   * @param  {String} scalarId
   *         Scalar in which the data is to be stored.
   * @param  {String} key
   *         The key within the  scalar.
   * @param  value
   *         Value to store.
   */
  keyedScalarSet(scalarId, key, value) {
    if (!scalarId) {
      return;
    }

    try {
      if (isNaN(value) && typeof value !== "boolean") {
        dump(
          `Warning: An attempt was made to write a non-numeric and ` +
            `non-boolean value ${value} to the ${scalarId} scalar. Only ` +
            `numeric and boolean values are allowed.\n` +
            `CALLER: ${getCaller()}`
        );

        return;
      }
      Services.telemetry.keyedScalarSet(scalarId, key, value);
    } catch (e) {
      dump(
        `Warning: An attempt was made to write to the ${scalarId} ` +
          `scalar, which is not defined in Scalars.yaml\n` +
          `CALLER: ${getCaller()}`
      );
    }
  }

  /**
   * Log a value to a keyed count scalar.
   *
   * @param  {String} scalarId
   *         Scalar in which the data is to be stored.
   * @param  {String} key
   *         The key within the  scalar.
   * @param  value
   *         Value to store.
   */
  keyedScalarAdd(scalarId, key, value) {
    if (!scalarId) {
      return;
    }

    try {
      if (isNaN(value)) {
        dump(
          `Warning: An attempt was made to write a non-numeric value ` +
            `${value} to the ${scalarId} scalar. Only numeric values are ` +
            `allowed.\n` +
            `CALLER: ${getCaller()}`
        );

        return;
      }
      Services.telemetry.keyedScalarAdd(scalarId, key, value);
    } catch (e) {
      dump(
        `Warning: An attempt was made to write to the ${scalarId} ` +
          `scalar, which is not defined in Scalars.yaml\n` +
          `CALLER: ${getCaller()}`
      );
    }
  }

  /**
   * Event telemetry is disabled by default. Use this method to enable or
   * disable it.
   *
   * @param {Boolean} enabled
   *        Enabled: true or false.
   */
  setEventRecordingEnabled(enabled) {
    return Services.telemetry.setEventRecordingEnabled(CATEGORY, enabled);
  }

  /**
   * Telemetry events often need to make use of a number of properties from
   * completely different codepaths. To make this possible we create a
   * "pending event" along with an array of property names that we need to wait
   * for before sending the event.
   *
   * As each property is received via addEventProperty() we check if all
   * properties have been received. Once they have all been received we send the
   * telemetry event.
   *
   * @param {Object} obj
   *        The telemetry event or ping is associated with this object, meaning
   *        that multiple events or pings for the same histogram may be run
   *        concurrently, as long as they are associated with different objects.
   * @param {String} method
   *        The telemetry event method (describes the type of event that
   *        occurred e.g. "open")
   * @param {String} object
   *        The telemetry event object name (the name of the object the event
   *        occurred on) e.g. "tools" or "setting"
   * @param {String|null} value
   *        The telemetry event value (a user defined value, providing context
   *        for the event) e.g. "console"
   * @param {Array} expected
   *        An array of the properties needed before sending the telemetry
   *        event e.g.
   *        [
   *          "host",
   *          "width"
   *        ]
   */
  preparePendingEvent(obj, method, object, value, expected = []) {
    const sig = `${method},${object},${value}`;

    if (expected.length === 0) {
      throw new Error(
        `preparePendingEvent() was called without any expected ` +
          `properties.\n` +
          `CALLER: ${getCaller()}`
      );
    }

    const data = {
      extra: {},
      expected: new Set(expected),
    };

    PENDING_EVENTS.set(obj, sig, data);

    const props = PENDING_EVENT_PROPERTIES.get(obj, sig);
    if (props) {
      for (const [name, val] of Object.entries(props)) {
        this.addEventProperty(obj, method, object, value, name, val);
      }
      PENDING_EVENT_PROPERTIES.delete(obj, sig);
    }
  }

  /**
   * Adds an expected property for either a current or future pending event.
   * This means that if preparePendingEvent() is called before or after sending
   * the event properties they will automatically added to the event.
   *
   * @param {Object} obj
   *        The telemetry event or ping is associated with this object, meaning
   *        that multiple events or pings for the same histogram may be run
   *        concurrently, as long as they are associated with different objects.
   * @param {String} method
   *        The telemetry event method (describes the type of event that
   *        occurred e.g. "open")
   * @param {String} object
   *        The telemetry event object name (the name of the object the event
   *        occurred on) e.g. "tools" or "setting"
   * @param {String|null} value
   *        The telemetry event value (a user defined value, providing context
   *        for the event) e.g. "console"
   * @param {String} pendingPropName
   *        The pending property name
   * @param {String} pendingPropValue
   *        The pending property value
   */
  addEventProperty(
    obj,
    method,
    object,
    value,
    pendingPropName,
    pendingPropValue
  ) {
    const sig = `${method},${object},${value}`;
    const events = PENDING_EVENTS.get(obj, sig);

    // If the pending event has not been created add the property to the pending
    // list.
    if (!events) {
      const props = PENDING_EVENT_PROPERTIES.get(obj, sig);

      if (props) {
        props[pendingPropName] = pendingPropValue;
      } else {
        PENDING_EVENT_PROPERTIES.set(obj, sig, {
          [pendingPropName]: pendingPropValue,
        });
      }
      return;
    }

    const { expected, extra } = events;

    if (expected.has(pendingPropName)) {
      extra[pendingPropName] = pendingPropValue;

      if (expected.size === Object.keys(extra).length) {
        this._sendPendingEvent(obj, method, object, value);
      }
    } else {
      // The property was not expected, warn and bail.
      throw new Error(
        `An attempt was made to add the unexpected property ` +
          `"${pendingPropName}" to a telemetry event with the ` +
          `signature "${sig}"\n` +
          `CALLER: ${getCaller()}`
      );
    }
  }

  /**
   * Adds expected properties for either a current or future pending event.
   * This means that if preparePendingEvent() is called before or after sending
   * the event properties they will automatically added to the event.
   *
   * @param {Object} obj
   *        The telemetry event or ping is associated with this object, meaning
   *        that multiple events or pings for the same histogram may be run
   *        concurrently, as long as they are associated with different objects.
   * @param {String} method
   *        The telemetry event method (describes the type of event that
   *        occurred e.g. "open")
   * @param {String} object
   *        The telemetry event object name (the name of the object the event
   *        occurred on) e.g. "tools" or "setting"
   * @param {String|null} value
   *        The telemetry event value (a user defined value, providing context
   *        for the event) e.g. "console"
   * @param {String} pendingObject
   *        An object containing key, value pairs that should be added to the
   *        event as properties.
   */
  addEventProperties(obj, method, object, value, pendingObject) {
    for (const [key, val] of Object.entries(pendingObject)) {
      this.addEventProperty(obj, method, object, value, key, val);
    }
  }

  /**
   * A private method that is not to be used externally. This method is used to
   * prepare a pending telemetry event for sending and then send it via
   * recordEvent().
   *
   * @param {Object} obj
   *        The telemetry event or ping is associated with this object, meaning
   *        that multiple events or pings for the same histogram may be run
   *        concurrently, as long as they are associated with different objects.
   * @param {String} method
   *        The telemetry event method (describes the type of event that
   *        occurred e.g. "open")
   * @param {String} object
   *        The telemetry event object name (the name of the object the event
   *        occurred on) e.g. "tools" or "setting"
   * @param {String|null} value
   *        The telemetry event value (a user defined value, providing context
   *        for the event) e.g. "console"
   */
  _sendPendingEvent(obj, method, object, value) {
    const sig = `${method},${object},${value}`;
    const { extra } = PENDING_EVENTS.get(obj, sig);

    PENDING_EVENTS.delete(obj, sig);
    PENDING_EVENT_PROPERTIES.delete(obj, sig);
    this.recordEvent(method, object, value, extra);
  }

  /**
   * Send a telemetry event.
   *
   * @param {String} method
   *        The telemetry event method (describes the type of event that
   *        occurred e.g. "open")
   * @param {String} object
   *        The telemetry event object name (the name of the object the event
   *        occurred on) e.g. "tools" or "setting"
   * @param {String|null} [value]
   *        Optional telemetry event value (a user defined value, providing
   *        context for the event) e.g. "console"
   * @param {Object} [extra]
   *        Optional telemetry event extra object containing the properties that
   *        will be sent with the event e.g.
   *        {
   *          host: "bottom",
   *          width: "1024"
   *        }
   */
  recordEvent(method, object, value = null, extra = null) {
    // Only string values are allowed so cast all values to strings.
    if (extra) {
      for (let [name, val] of Object.entries(extra)) {
        val = val + "";

        if (val.length > 80) {
          const sig = `${method},${object},${value}`;

          dump(
            `Warning: The property "${name}" was added to a telemetry ` +
              `event with the signature ${sig} but it's value "${val}" is ` +
              `longer than the maximum allowed length of 80 characters.\n` +
              `The property value has been trimmed to 80 characters before ` +
              `sending.\nCALLER: ${getCaller()}`
          );

          val = val.substring(0, 80);
        }

        extra[name] = val;
      }
    }
    // Automatically flag the record with the session ID
    // if the current Telemetry instance relates to a toolbox
    // so that data can be aggregated per toolbox instance.
    // Note that we also aggregate data per about:debugging instance.
    if (!extra) {
      extra = {};
    }
    extra.session_id = this.sessionId;

    Services.telemetry.recordEvent(CATEGORY, method, object, value, extra);
  }

  /**
   * Sends telemetry pings to indicate that a tool has been opened.
   *
   * @param {String} id
   *        The ID of the tool opened.
   * @param {Object} obj
   *        The telemetry event or ping is associated with this object, meaning
   *        that multiple events or pings for the same histogram may be run
   *        concurrently, as long as they are associated with different objects.
   *
   * NOTE: This method is designed for tools that send multiple probes on open,
   *       one of those probes being a counter and the other a timer. If you
   *       only have one probe you should be using another method.
   */
  toolOpened(id, obj) {
    const charts = getChartsFromToolId(id);

    if (!charts) {
      return;
    }

    if (charts.useTimedEvent) {
      this.preparePendingEvent(obj, "tool_timer", id, null, [
        "os",
        "time_open",
      ]);
      this.addEventProperty(
        obj,
        "tool_timer",
        id,
        null,
        "time_open",
        this.msSystemNow()
      );
    }
    if (charts.timerHist) {
      this.start(charts.timerHist, obj, { inSeconds: true });
    }
    if (charts.countHist) {
      this.getHistogramById(charts.countHist).add(true);
    }
    if (charts.countScalar) {
      this.scalarAdd(charts.countScalar, 1);
    }
  }

  /**
   * Sends telemetry pings to indicate that a tool has been closed.
   *
   * @param {String} id
   *        The ID of the tool opened.
   * @param {Object} obj
   *        The telemetry event or ping is associated with this object, meaning
   *        that multiple events or pings for the same histogram may be run
   *        concurrently, as long as they are associated with different objects.
   *
   * NOTE: This method is designed for tools that send multiple probes on open,
   *       one of those probes being a counter and the other a timer. If you
   *       only have one probe you should be using another method.
   */
  toolClosed(id, obj) {
    const charts = getChartsFromToolId(id);

    if (!charts) {
      return;
    }

    if (charts.useTimedEvent) {
      const sig = `tool_timer,${id},null`;
      const event = PENDING_EVENTS.get(obj, sig);
      const time = this.msSystemNow() - event.extra.time_open;

      this.addEventProperties(obj, "tool_timer", id, null, {
        time_open: time,
        os: this.osNameAndVersion,
      });
    }

    if (charts.timerHist) {
      this.finish(charts.timerHist, obj, false);
    }
  }
}

/**
 * Returns the telemetry charts for a specific tool.
 *
 * @param {String} id
 *        The ID of the tool that has been opened.
 *
 */
// eslint-disable-next-line complexity
function getChartsFromToolId(id) {
  if (!id) {
    return null;
  }

  const lowerCaseId = id.toLowerCase();

  let useTimedEvent = null;
  let timerHist = null;
  let countHist = null;
  let countScalar = null;

  id = id.toUpperCase();

  if (id === "PERFORMANCE") {
    id = "JSPROFILER";
  }

  switch (id) {
    case "ABOUTDEBUGGING":
    case "BROWSERCONSOLE":
    case "DOM":
    case "INSPECTOR":
    case "JSBROWSERDEBUGGER":
    case "JSDEBUGGER":
    case "JSPROFILER":
    case "MEMORY":
    case "NETMONITOR":
    case "OPTIONS":
    case "RESPONSIVE":
    case "STORAGE":
    case "STYLEEDITOR":
    case "TOOLBOX":
    case "WEBCONSOLE":
      timerHist = `DEVTOOLS_${id}_TIME_ACTIVE_SECONDS`;
      countHist = `DEVTOOLS_${id}_OPENED_COUNT`;
      break;
    case "ACCESSIBILITY":
    case "APPLICATION":
      timerHist = `DEVTOOLS_${id}_TIME_ACTIVE_SECONDS`;
      countScalar = `devtools.${lowerCaseId}.opened_count`;
      break;
    case "ACCESSIBILITY_PICKER":
      timerHist = `DEVTOOLS_${id}_TIME_ACTIVE_SECONDS`;
      countScalar = `devtools.accessibility.picker_used_count`;
      break;
    case "CHANGESVIEW":
      useTimedEvent = true;
      timerHist = `DEVTOOLS_${id}_TIME_ACTIVE_SECONDS`;
      countScalar = `devtools.${lowerCaseId}.opened_count`;
      break;
    case "ANIMATIONINSPECTOR":
    case "COMPATIBILITYVIEW":
    case "COMPUTEDVIEW":
    case "FONTINSPECTOR":
    case "LAYOUTVIEW":
    case "RULEVIEW":
      useTimedEvent = true;
      timerHist = `DEVTOOLS_${id}_TIME_ACTIVE_SECONDS`;
      countHist = `DEVTOOLS_${id}_OPENED_COUNT`;
      break;
    case "FLEXBOX_HIGHLIGHTER":
    case "GRID_HIGHLIGHTER":
      timerHist = `DEVTOOLS_${id}_TIME_ACTIVE_SECONDS`;
      break;
    default:
      timerHist = `DEVTOOLS_CUSTOM_TIME_ACTIVE_SECONDS`;
      countHist = `DEVTOOLS_CUSTOM_OPENED_COUNT`;
  }

  return {
    useTimedEvent,
    timerHist,
    countHist,
    countScalar,
  };
}

/**
 * Displays the first caller and calling line outside of this file in the
 * event of an error. This is the line that made the call that produced the
 * error.
 */
function getCaller() {
  return getNthPathExcluding(0, "/telemetry.js");
}

module.exports = Telemetry;
