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

const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;

this.EXPORTED_SYMBOLS = ["TelemetryStopwatch"];

Cu.import("resource://gre/modules/Log.jsm", this);
var Telemetry = Cc["@mozilla.org/base/telemetry;1"]
                  .getService(Ci.nsITelemetry);

// Weak map does not allow using null objects as keys. These objects are used
// as 'null' placeholders.
const NULL_OBJECT = {};
const NULL_KEY = {};

/**
 * Timers is a variation of a Map used for storing information about running
 * Stopwatches. Timers has the following data structure:
 *
 * {
 *    "HISTOGRAM_NAME": WeakMap {
 *      Object || NULL_OBJECT: Map {
 *        "KEY" || NULL_KEY: startTime
 *        ...
 *      }
 *      ...
 *    }
 *    ...
 * }
 *
 *
 * @example
 * // Stores current time for a keyed histogram "PLAYING_WITH_CUTE_ANIMALS".
 * Timers.put("PLAYING_WITH_CUTE_ANIMALS", null, "CATS", Date.now());
 *
 * @example
 * // Returns information about a simple Stopwatch.
 * let startTime = Timers.get("PLAYING_WITH_CUTE_ANIMALS", null, "CATS");
 */
let Timers = {
  _timers: new Map(),

  _validTypes: function(histogram, obj, key) {
    let nonEmptyString = value => {
      return typeof value === "string" && value !== "" && value.length > 0;
    };
    return  nonEmptyString(histogram) &&
            typeof obj == "object" &&
           (key === NULL_KEY || nonEmptyString(key));
  },

  get: function(histogram, obj, key) {
    key = key === null ? NULL_KEY : key;
    obj = obj || NULL_OBJECT;

    if (!this.has(histogram, obj, key)) {
      return null;
    }

    return this._timers.get(histogram).get(obj).get(key);
  },

  put: function(histogram, obj, key, startTime) {
    key = key === null ? NULL_KEY : key;
    obj = obj || NULL_OBJECT;

    if (!this._validTypes(histogram, obj, key)) {
      return false;
    }

    let objectMap = this._timers.get(histogram) || new WeakMap();
    let keyedInfo = objectMap.get(obj) || new Map();
    keyedInfo.set(key, startTime);
    objectMap.set(obj, keyedInfo);
    this._timers.set(histogram, objectMap);
    return true;
  },

  has: function(histogram, obj, key) {
    key = key === null ? NULL_KEY : key;
    obj = obj || NULL_OBJECT;

    return this._timers.has(histogram) &&
      this._timers.get(histogram).has(obj) &&
      this._timers.get(histogram).get(obj).has(key);
  },

  delete: function(histogram, obj, key) {
    key = key === null ? NULL_KEY : key;
    obj = obj || NULL_OBJECT;

    if (!this.has(histogram, obj, key)) {
      return false;
    }
    let objectMap = this._timers.get(histogram);
    let keyedInfo = objectMap.get(obj);
    if (keyedInfo.size > 1) {
      keyedInfo.delete(key);
      return true;
    }
    objectMap.delete(obj);
    // NOTE:
    // We never delete empty objecMaps from this._timers because there is no
    // nice solution for tracking the number of objects in a WeakMap.
    // WeakMap is not enumerable, so we can't deterministically say when it's
    // empty. We accept that trade-off here, given that entries for short-lived
    // objects will go away when they are no longer referenced
    return true;
  }
};

this.TelemetryStopwatch = {
  /**
   * 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} aHistogram - a string which must be a valid histogram name.
   *
   * @param {Object} aObj - Optional parameter. If specified, the timer is
   *                        associated with this object, meaning that multiple
   *                        timers for the same histogram may be run
   *                        concurrently, as long as they are associated with
   *                        different objects.
   *
   * @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.
   */
  start: function(aHistogram, aObj) {
    return TelemetryStopwatchImpl.start(aHistogram, aObj, null);
  },

  /**
   * Deletes the 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. Important: Only use this method when a legitimate cancellation
   * should be done.
   *
  * @param {String} aHistogram - a string which must be a valid histogram name.
   *
   * @param {Object} aObj - Optional parameter. If specified, the timer is
   *                        associated with this object, meaning that multiple
   *                        timers or a same histogram may be run concurrently,
   *                        as long as they are associated with different
   *                        objects.
   *
   * @returns {Boolean} True if the timer exist and it was cleared, False
   *                   otherwise.
   */
  cancel: function(aHistogram, aObj) {
    return TelemetryStopwatchImpl.cancel(aHistogram, aObj, null);
  },

  /**
   * Returns the elapsed time for a particular stopwatch. Primarily for
   * debugging purposes. Must be called prior to finish.
   *
   * @param {String} aHistogram - a string which must be a valid histogram name.
   *                              If an invalid name is given, the function will
   *                              throw.
   *
   * @param (Object) aObj - Optional parameter which associates the histogram
   *                        timer with the given object.
   *
   * @returns {Integer} time in milliseconds or -1 if the stopwatch was not
   *                   found.
   */
  timeElapsed: function(aHistogram, aObj) {
    return TelemetryStopwatchImpl.timeElapsed(aHistogram, aObj, null);
  },

  /**
   * 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} aHistogram - a string which must be a valid histogram name.
   *
   * @param {Object} aObj - Optional parameter which associates the histogram
   *                        timer with the given object.
   *
   * @returns {Boolean} True if the timer was succesfully stopped and the data
   *                    was added to the histogram, False otherwise.
   */
  finish: function(aHistogram, aObj) {
    return TelemetryStopwatchImpl.finish(aHistogram, aObj, null);
  },

  /**
   * Starts a timer associated with a keyed telemetry histogram. The timer can
   * be directly associated with a histogram and its key. Similarly to
   * @see{TelemetryStopwatch.stat} 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} aHistogram - a string which must be a valid histogram name.
   *
   * @param {String} aKey - a string which must be a valid histgram key.
   *
   * @param {Object} aObj - Optional parameter. If specified, the timer is
   *                        associated with this object, meaning that multiple
   *                        timers for the same histogram may be run
   *                        concurrently,as long as they are associated with
   *                        different objects.
   *
   * @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: function(aHistogram, aKey, aObj) {
    return TelemetryStopwatchImpl.start(aHistogram, aObj, aKey);
  },

  /**
   * Deletes the timer associated with a keyed histogram. Important: Only use
   * this method when a legitimate cancellation should be done.
   *
   * @param {String} aHistogram - a string which must be a valid histogram name.
   *
   * @param {String} aKey - a string which must be a valid histgram key.
   *
   * @param {Object} aObj - Optional parameter. If specified, the timer
   *                        associated with this object is deleted.
   *
   * @return {Boolean} True if the timer exist and it was cleared, False
   *                   otherwise.
   */
  cancelKeyed: function(aHistogram, aKey, aObj) {
    return TelemetryStopwatchImpl.cancel(aHistogram, aObj, aKey);
  },

  /**
   * Returns the elapsed time for a particular stopwatch. Primarily for
   * debugging purposes. Must be called prior to finish.
   *
   * @param {String} aHistogram - a string which must be a valid histogram name.
   *
   * @param {String} aKey - a string which must be a valid histgram key.
   *
   * @param {Object} aObj - Optional parameter. If specified, the timer
   *                        associated with this object is used to calculate
   *                        the elapsed time.
   *
   * @return {Integer} time in milliseconds or -1 if the stopwatch was not
   *                   found.
   */
  timeElapsedKeyed: function(aHistogram, aKey, aObj) {
    return TelemetryStopwatchImpl.timeElapsed(aHistogram, aObj, aKey);
  },

  /**
   * 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} aHistogram - a string which must be a valid histogram name.
   *
   * @param {String} aKey - a string which must be a valid histgram key.
   *
   * @param {Object} aObj - optional parameter which associates the histogram
   *                        timer with the given object.
   *
   * @returns {Boolean} True if the timer was succesfully stopped and the data
   *                   was added to the histogram, False otherwise.
   */
  finishKeyed: function(aHistogram, aKey, aObj) {
    return TelemetryStopwatchImpl.finish(aHistogram, aObj, aKey);
  }
};

this.TelemetryStopwatchImpl = {
  start: function(histogram, object, key) {
    if (Timers.has(histogram, object, key)) {
      Timers.delete(histogram, object, key);
      Cu.reportError(`TelemetryStopwatch: key "${histogram}" was already ` +
                     "initialized");
      return false;
    }

    return Timers.put(histogram, object, key, Components.utils.now());
  },

  cancel: function (histogram, object, key) {
    return Timers.delete(histogram, object, key);
  },

  timeElapsed: function(histogram, object, key) {
    let startTime = Timers.get(histogram, object, key);
    if (startTime === null) {
      Cu.reportError("TelemetryStopwatch: requesting elapsed time for " +
                     `nonexisting stopwatch. Histogram: "${histogram}", ` +
                     `key: "${key}"`);
      return -1;
    }

    try {
      let delta = Components.utils.now() - startTime
      return Math.round(delta);
    } catch (e) {
      Cu.reportError("TelemetryStopwatch: failed to calculate elapsed time " +
                     `for Histogram: "${histogram}", key: "${key}", ` +
                     `exception: ${Log.exceptionStr(e)}`);
      return -1;
    }
  },

  finish: function(histogram, object, key) {
    let delta = this.timeElapsed(histogram, object, key);
    if (delta == -1) {
      return false;
    }

    try {
      if (key) {
        Telemetry.getKeyedHistogramById(histogram).add(key, delta);
      } else {
        Telemetry.getHistogramById(histogram).add(delta);
      }
    } catch (e) {
      Cu.reportError("TelemetryStopwatch: failed to update the Histogram " +
                     `"${histogram}", using key: "${key}", ` +
                     `exception: ${Log.exceptionStr(e)}`);
      return false;
    }

    return Timers.delete(histogram, object, key);
  }
}