/* 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/. */
"use strict";

const { Cc, Ci, Cu } = require("chrome");
const Services = require("Services");
const { Class } = require("sdk/core/heritage");
loader.lazyRequireGetter(this, "events", "sdk/event/core");
loader.lazyRequireGetter(this, "EventTarget", "sdk/event/target", true);
loader.lazyRequireGetter(this, "DevToolsUtils", "devtools/shared/DevToolsUtils");
loader.lazyRequireGetter(this, "DeferredTask", "resource://gre/modules/DeferredTask.jsm", true);
loader.lazyRequireGetter(this, "Task", "resource://gre/modules/Task.jsm", true);

// Events piped from system observers to Profiler instances.
const PROFILER_SYSTEM_EVENTS = [
  "console-api-profiler",
  "profiler-started",
  "profiler-stopped"
];

// How often the "profiler-status" is emitted by default
const BUFFER_STATUS_INTERVAL_DEFAULT = 5000; // ms

loader.lazyGetter(this, "nsIProfilerModule", () => {
  return Cc["@mozilla.org/tools/profiler;1"].getService(Ci.nsIProfiler);
});

var DEFAULT_PROFILER_OPTIONS = {
  // When using the DevTools Performance Tools, this will be overridden
  // by the pref `devtools.performance.profiler.buffer-size`.
  entries: Math.pow(10, 7),
  // When using the DevTools Performance Tools, this will be overridden
  // by the pref `devtools.performance.profiler.sample-rate-khz`.
  interval: 1,
  features: ["js"],
  threadFilters: ["GeckoMain"]
};

/**
 * Main interface for interacting with nsIProfiler
 */
const ProfilerManager = (function () {
  let consumers = new Set();

  return {

    // How often the "profiler-status" is emitted
    _profilerStatusInterval: BUFFER_STATUS_INTERVAL_DEFAULT,

    // How many subscribers there
    _profilerStatusSubscribers: 0,

    /**
     * The nsIProfiler is target agnostic and interacts with the whole platform.
     * Therefore, special care needs to be given to make sure different profiler
     * consumers (i.e. "toolboxes") don't interfere with each other. Register
     * the profiler actor instances here.
     *
     * @param Profiler instance
     *        A profiler actor class.
     */
    addInstance: function (instance) {
      consumers.add(instance);

      // Lazily register events
      this.registerEventListeners();
    },

    /**
     * Remove the profiler actor instances here.
     *
     * @param Profiler instance
     *        A profiler actor class.
     */
    removeInstance: function (instance) {
      consumers.delete(instance);

      if (this.length < 0) {
        let msg = "Somehow the number of started profilers is now negative.";
        DevToolsUtils.reportException("Profiler", msg);
      }

      if (this.length === 0) {
        this.unregisterEventListeners();
        this.stop();
      }
    },

    /**
     * Starts the nsIProfiler module. Doing so will discard any samples
     * that might have been accumulated so far.
     *
     * @param {number} entries [optional]
     * @param {number} interval [optional]
     * @param {Array<string>} features [optional]
     * @param {Array<string>} threadFilters [description]
     *
     * @return {object}
     */
    start: function (options = {}) {
      let config = this._profilerStartOptions = {
        entries: options.entries || DEFAULT_PROFILER_OPTIONS.entries,
        interval: options.interval || DEFAULT_PROFILER_OPTIONS.interval,
        features: options.features || DEFAULT_PROFILER_OPTIONS.features,
        threadFilters: options.threadFilters || DEFAULT_PROFILER_OPTIONS.threadFilters,
      };

      // The start time should be before any samples we might be
      // interested in.
      let currentTime = nsIProfilerModule.getElapsedTime();

      try {
        nsIProfilerModule.StartProfiler(
          config.entries,
          config.interval,
          config.features,
          config.features.length,
          config.threadFilters,
          config.threadFilters.length
        );
      } catch (e) {
        // For some reason, the profiler couldn't be started. This could happen,
        // for example, when in private browsing mode.
        Cu.reportError(`Could not start the profiler module: ${e.message}`);
        return { started: false, reason: e, currentTime };
      }

      this._updateProfilerStatusPolling();

      let { position, totalSize, generation } = this.getBufferInfo();
      return { started: true, position, totalSize, generation, currentTime };
    },

    /**
     * Attempts to stop the nsIProfiler module.
     */
    stop: function () {
      // Actually stop the profiler only if the last client has stopped profiling.
      // Since this is used as a root actor, and the profiler module interacts
      // with the whole platform, we need to avoid a case in which the profiler
      // is stopped when there might be other clients still profiling.
      if (this.length <= 1) {
        nsIProfilerModule.StopProfiler();
      }
      this._updateProfilerStatusPolling();
      return { started: false };
    },

    /**
     * Returns all the samples accumulated since the profiler was started,
     * along with the current time. The data has the following format:
     * {
     *   libs: string,
     *   meta: {
     *     interval: number,
     *     platform: string,
     *     ...
     *   },
     *   threads: [{
     *     samples: [{
     *       frames: [{
     *         line: number,
     *         location: string,
     *         category: number
     *       } ... ],
     *       name: string
     *       responsiveness: number
     *       time: number
     *     } ... ]
     *   } ... ]
     * }
     *
     *
     * @param number startTime
     *        Since the circular buffer will only grow as long as the profiler lives,
     *        the buffer can contain unwanted samples. Pass in a `startTime` to only retrieve
     *        samples that took place after the `startTime`, with 0 being when the profiler
     *        just started.
     * @param boolean stringify
     *        Whether or not the returned profile object should be a string or not to save
     *        JSON parse/stringify cycle if emitting over RDP.
     */
    getProfile: function (options) {
      let startTime = options.startTime || 0;
      let profile = options.stringify ?
        nsIProfilerModule.GetProfile(startTime) :
        nsIProfilerModule.getProfileData(startTime);

      return { profile: profile, currentTime: nsIProfilerModule.getElapsedTime() };
    },

    /**
     * Returns an array of feature strings, describing the profiler features
     * that are available on this platform. Can be called while the profiler
     * is stopped.
     *
     * @return {object}
     */
    getFeatures: function () {
      return { features: nsIProfilerModule.GetFeatures([]) };
    },

    /**
     * Returns an object with the values of the current status of the
     * circular buffer in the profiler, returning `position`, `totalSize`,
     * and the current `generation` of the buffer.
     *
     * @return {object}
     */
    getBufferInfo: function() {
      let position = {}, totalSize = {}, generation = {};
      nsIProfilerModule.GetBufferInfo(position, totalSize, generation);
      return {
        position: position.value,
        totalSize: totalSize.value,
        generation: generation.value
      }
    },

    /**
     * Returns the configuration used that was originally passed in to start up the
     * profiler. Used for tests, and does not account for others using nsIProfiler.
     *
     * @param {object}
     */
    getStartOptions: function() {
      return this._profilerStartOptions || {};
    },

    /**
     * Verifies whether or not the nsIProfiler module has started.
     * If already active, the current time is also returned.
     *
     * @return {object}
     */
    isActive: function() {
      let isActive = nsIProfilerModule.IsActive();
      let elapsedTime = isActive ? nsIProfilerModule.getElapsedTime() : undefined;
      let { position, totalSize, generation } = this.getBufferInfo();
      return { isActive: isActive, currentTime: elapsedTime, position, totalSize, generation };
    },

    /**
     * Returns a stringified JSON object that describes the shared libraries
     * which are currently loaded into our process. Can be called while the
     * profiler is stopped.
     */
    getSharedLibraryInformation: function() {
      return { sharedLibraryInformation: nsIProfilerModule.getSharedLibraryInformation() };
    },

    /**
     * Number of profiler instances.
     *
     * @return {number}
     */
    get length() {
      return consumers.size;
    },

    /**
     * Callback for all observed notifications.
     * @param object subject
     * @param string topic
     * @param object data
     */
    observe: sanitizeHandler(function (subject, topic, data) {
      let details;

      // An optional label may be specified when calling `console.profile`.
      // If that's the case, stringify it and send it over with the response.
      let { action, arguments: args } = subject || {};
      let profileLabel = args && args.length > 0 ? `${args[0]}` : void 0;

      // If the event was generated from `console.profile` or `console.profileEnd`
      // we need to start the profiler right away and then just notify the client.
      // Otherwise, we'll lose precious samples.
      if (topic === "console-api-profiler" && (action === "profile" || action === "profileEnd")) {
        let { isActive, currentTime } = this.isActive();

        // Start the profiler only if it wasn't already active. Otherwise, any
        // samples that might have been accumulated so far will be discarded.
        if (!isActive && action === "profile") {
          this.start();
          details = { profileLabel, currentTime: 0 };
        }
        // Otherwise, if inactive and a call to profile end, do nothing
        // and don't emit event.
        else if (!isActive) {
          return;
        }

        // Otherwise, the profiler is already active, so just send
        // to the front the current time, label, and the notification
        // adds the action as well.
        details = { profileLabel, currentTime };
      }

      // Propagate the event to the profiler instances that
      // are subscribed to this event.
      this.emitEvent(topic, { subject, topic, data, details });
    }, "ProfilerManager.observe"),

    /**
     * Registers handlers for the following events to be emitted
     * on active Profiler instances:
     *   - "console-api-profiler"
     *   - "profiler-started"
     *   - "profiler-stopped"
     *   - "profiler-status"
     *
     * The ProfilerManager listens to all events, and individual
     * consumers filter which events they are interested in.
     */
    registerEventListeners: function () {
      if (!this._eventsRegistered) {
        PROFILER_SYSTEM_EVENTS.forEach(eventName =>
          Services.obs.addObserver(this, eventName, false));
        this._eventsRegistered = true;
      }
    },

    /**
     * Unregisters handlers for all system events.
     */
    unregisterEventListeners: function () {
      if (this._eventsRegistered) {
        PROFILER_SYSTEM_EVENTS.forEach(eventName =>
          Services.obs.removeObserver(this, eventName));
        this._eventsRegistered = false;
      }
    },

    /**
     * Takes an event name and additional data and emits them
     * through each profiler instance that is subscribed to the event.
     *
     * @param {string} eventName
     * @param {object} data
     */
    emitEvent: function (eventName, data) {
      let subscribers = Array.from(consumers).filter(c => c.subscribedEvents.has(eventName));

      for (let subscriber of subscribers) {
        events.emit(subscriber, eventName, data);
      }
    },

    /**
     * Updates the frequency that the "profiler-status" event is emitted
     * during recording.
     *
     * @param {number} interval
     */
    setProfilerStatusInterval: function (interval) {
      this._profilerStatusInterval = interval;
      if (this._poller) {
        this._poller._delayMs = interval;
      }
    },

    subscribeToProfilerStatusEvents: function () {
      this._profilerStatusSubscribers++;
      this._updateProfilerStatusPolling();
    },

    unsubscribeToProfilerStatusEvents: function () {
      this._profilerStatusSubscribers--;
      this._updateProfilerStatusPolling();
    },

    /**
     * Will enable or disable "profiler-status" events depending on
     * if there are subscribers and if the profiler is current recording.
     */
    _updateProfilerStatusPolling: function () {
      if (this._profilerStatusSubscribers > 0 && nsIProfilerModule.IsActive()) {
        if (!this._poller) {
          this._poller = new DeferredTask(this._emitProfilerStatus.bind(this), this._profilerStatusInterval);
        }
        this._poller.arm();
      }
      // No subscribers; turn off if it exists.
      else if (this._poller) {
        this._poller.disarm();
      }
    },

    _emitProfilerStatus: function () {
      this.emitEvent("profiler-status", this.isActive());
      this._poller.arm();
    }
  };
})();

/**
 * The profiler actor provides remote access to the built-in nsIProfiler module.
 */
var Profiler = exports.Profiler = Class({
  extends: EventTarget,

  initialize: function () {
    this.subscribedEvents = new Set();
    ProfilerManager.addInstance(this);
  },

  destroy: function() {
    this.unregisterEventNotifications({ events: Array.from(this.subscribedEvents) });
    this.subscribedEvents = null;
    ProfilerManager.removeInstance(this);
  },

  /**
   * @see ProfilerManager.start
   */
  start: function (options) { return ProfilerManager.start(options); },

  /**
   * @see ProfilerManager.stop
   */
  stop: function () { return ProfilerManager.stop(); },

  /**
   * @see ProfilerManager.getProfile
   */
  getProfile: function (request={}) { return ProfilerManager.getProfile(request); },

  /**
   * @see ProfilerManager.getFeatures
   */
  getFeatures: function() { return ProfilerManager.getFeatures(); },

  /**
   * @see ProfilerManager.getBufferInfo
   */
  getBufferInfo: function() { return ProfilerManager.getBufferInfo(); },

  /**
   * @see ProfilerManager.getStartOptions
   */
  getStartOptions: function() { return ProfilerManager.getStartOptions(); },

  /**
   * @see ProfilerManager.isActive
   */
  isActive: function() { return ProfilerManager.isActive(); },

  /**
   * @see ProfilerManager.isActive
   */
  getSharedLibraryInformation: function() { return ProfilerManager.getSharedLibraryInformation(); },

  /**
   * @see ProfilerManager.setProfilerStatusInterval
   */
  setProfilerStatusInterval: function(interval) { return ProfilerManager.setProfilerStatusInterval(interval); },

  /**
   * Subscribes this instance to one of several events defined in
   * an events array.
   * - "console-api-profiler",
   * - "profiler-started",
   * - "profiler-stopped"
   * - "profiler-status"
   *
   * @param {Array<string>} data.event
   * @return {object}
   */
  registerEventNotifications: function(data={}) {
    let response = [];
    (data.events || []).forEach(e => {
      if (!this.subscribedEvents.has(e)) {
        if (e === "profiler-status") {
          ProfilerManager.subscribeToProfilerStatusEvents();
        }
        this.subscribedEvents.add(e);
        response.push(e);
      }
    });
    return { registered: response };
  },

  /**
   * Unsubscribes this instance to one of several events defined in
   * an events array.
   *
   * @param {Array<string>} data.event
   * @return {object}
   */
  unregisterEventNotifications: function(data={}) {
    let response = [];
    (data.events || []).forEach(e => {
      if (this.subscribedEvents.has(e)) {
        if (e === "profiler-status") {
          ProfilerManager.unsubscribeToProfilerStatusEvents();
        }
        this.subscribedEvents.delete(e);
        response.push(e);
      }
    });
    return { registered: response };
  },
});

/**
 * Checks whether or not the profiler module can currently run.
 * @return boolean
 */
Profiler.canProfile = function() {
  return nsIProfilerModule.CanProfile();
};

/**
 * JSON.stringify callback used in Profiler.prototype.observe.
 */
function cycleBreaker(key, value) {
  if (key == "wrappedJSObject") {
    return undefined;
  }
  return value;
}

/**
 * Create JSON objects suitable for transportation across the RDP,
 * by breaking cycles and making a copy of the `subject` and `data` via
 * JSON.stringifying those values with a replacer that omits properties
 * known to introduce cycles, and then JSON.parsing the result.
 * This spends some CPU cycles, but it's simple.
 *
 * @TODO Also wraps it in a `makeInfallible` -- is this still necessary?
 *
 * @param {function} handler
 * @return {function}
 */
function sanitizeHandler (handler, identifier) {
  return DevToolsUtils.makeInfallible(function (subject, topic, data) {
    subject = (subject && !Cu.isXrayWrapper(subject) && subject.wrappedJSObject) || subject;
    subject = JSON.parse(JSON.stringify(subject, cycleBreaker));
    data = (data && !Cu.isXrayWrapper(data) && data.wrappedJSObject) || data;
    data = JSON.parse(JSON.stringify(data, cycleBreaker));

    // Pass in clean data to the underlying handler
    return handler.call(this, subject, topic, data);
  }, identifier);
}
