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

var { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
const { loader, require } = Cu.import("resource://devtools/shared/Loader.jsm", {});

const { Task } = require("resource://gre/modules/Task.jsm");
const { Heritage, ViewHelpers, WidgetMethods } = require("resource://devtools/client/shared/widgets/ViewHelpers.jsm");

// Events emitted by various objects in the panel.
const EVENTS = require("devtools/client/performance/events");
Object.defineProperty(this, "EVENTS", {
  value: EVENTS,
  enumerable: true,
  writable: false
});

loader.lazyRequireGetter(this, "Services");
loader.lazyRequireGetter(this, "promise");
loader.lazyRequireGetter(this, "EventEmitter",
  "devtools/shared/event-emitter");
loader.lazyRequireGetter(this, "DevToolsUtils",
  "devtools/shared/DevToolsUtils");
loader.lazyRequireGetter(this, "system",
  "devtools/shared/system");

// Logic modules

loader.lazyRequireGetter(this, "L10N",
  "devtools/client/performance/modules/global", true);
loader.lazyRequireGetter(this, "PerformanceTelemetry",
  "devtools/client/performance/modules/logic/telemetry", true);
loader.lazyRequireGetter(this, "TIMELINE_BLUEPRINT",
  "devtools/client/performance/modules/markers", true);
loader.lazyRequireGetter(this, "RecordingUtils",
  "devtools/shared/performance/recording-utils");
loader.lazyRequireGetter(this, "GraphsController",
  "devtools/client/performance/modules/widgets/graphs", true);
loader.lazyRequireGetter(this, "OptimizationsGraph",
  "devtools/client/performance/modules/widgets/graphs", true);
loader.lazyRequireGetter(this, "WaterfallHeader",
  "devtools/client/performance/modules/widgets/waterfall-ticks", true);
loader.lazyRequireGetter(this, "MarkerView",
  "devtools/client/performance/modules/widgets/marker-view", true);
loader.lazyRequireGetter(this, "MarkerDetails",
  "devtools/client/performance/modules/widgets/marker-details", true);
loader.lazyRequireGetter(this, "MarkerUtils",
  "devtools/client/performance/modules/logic/marker-utils");
loader.lazyRequireGetter(this, "WaterfallUtils",
  "devtools/client/performance/modules/logic/waterfall-utils");
loader.lazyRequireGetter(this, "FrameUtils",
  "devtools/client/performance/modules/logic/frame-utils");
loader.lazyRequireGetter(this, "CallView",
  "devtools/client/performance/modules/widgets/tree-view", true);
loader.lazyRequireGetter(this, "ThreadNode",
  "devtools/client/performance/modules/logic/tree-model", true);
loader.lazyRequireGetter(this, "FrameNode",
  "devtools/client/performance/modules/logic/tree-model", true);
loader.lazyRequireGetter(this, "JITOptimizations",
  "devtools/client/performance/modules/logic/jit", true);

// Widgets modules

loader.lazyRequireGetter(this, "OptionsView",
  "devtools/client/shared/options-view", true);
loader.lazyRequireGetter(this, "FlameGraphUtils",
  "devtools/client/shared/widgets/FlameGraph", true);
loader.lazyRequireGetter(this, "FlameGraph",
  "devtools/client/shared/widgets/FlameGraph", true);
loader.lazyRequireGetter(this, "TreeWidget",
  "devtools/client/shared/widgets/TreeWidget", true);

loader.lazyImporter(this, "SideMenuWidget",
  "resource://devtools/client/shared/widgets/SideMenuWidget.jsm");
loader.lazyImporter(this, "setNamedTimeout",
  "resource://devtools/client/shared/widgets/ViewHelpers.jsm");
loader.lazyImporter(this, "clearNamedTimeout",
  "resource://devtools/client/shared/widgets/ViewHelpers.jsm");
loader.lazyImporter(this, "PluralForm",
  "resource://gre/modules/PluralForm.jsm");

const BRANCH_NAME = "devtools.performance.ui.";

/**
 * The current target, toolbox and PerformanceFront, set by this tool's host.
 */
var gToolbox, gTarget, gFront;

/**
 * Initializes the profiler controller and views.
 */
var startupPerformance = Task.async(function*() {
  yield PerformanceController.initialize();
  yield PerformanceView.initialize();
  PerformanceController.enableFrontEventListeners();
});

/**
 * Destroys the profiler controller and views.
 */
var shutdownPerformance = Task.async(function*() {
  yield PerformanceController.destroy();
  yield PerformanceView.destroy();
  PerformanceController.disableFrontEventListeners();
});

/**
 * Functions handling target-related lifetime events and
 * UI interaction.
 */
var PerformanceController = {
  _recordings: [],
  _currentRecording: null,

  /**
   * Listen for events emitted by the current tab target and
   * main UI events.
   */
  initialize: Task.async(function* () {
    this._telemetry = new PerformanceTelemetry(this);
    this.startRecording = this.startRecording.bind(this);
    this.stopRecording = this.stopRecording.bind(this);
    this.importRecording = this.importRecording.bind(this);
    this.exportRecording = this.exportRecording.bind(this);
    this.clearRecordings = this.clearRecordings.bind(this);
    this._onRecordingSelectFromView = this._onRecordingSelectFromView.bind(this);
    this._onPrefChanged = this._onPrefChanged.bind(this);
    this._onThemeChanged = this._onThemeChanged.bind(this);
    this._onFrontEvent = this._onFrontEvent.bind(this);
    this._pipe = this._pipe.bind(this);

    // Store data regarding if e10s is enabled.
    this._e10s = Services.appinfo.browserTabsRemoteAutostart;
    this._setMultiprocessAttributes();

    this._prefs = require("devtools/client/performance/modules/global").PREFS;
    this._prefs.on("pref-changed", this._onPrefChanged);

    ToolbarView.on(EVENTS.PREF_CHANGED, this._onPrefChanged);
    PerformanceView.on(EVENTS.UI_START_RECORDING, this.startRecording);
    PerformanceView.on(EVENTS.UI_STOP_RECORDING, this.stopRecording);
    PerformanceView.on(EVENTS.UI_IMPORT_RECORDING, this.importRecording);
    PerformanceView.on(EVENTS.UI_CLEAR_RECORDINGS, this.clearRecordings);
    RecordingsView.on(EVENTS.UI_EXPORT_RECORDING, this.exportRecording);
    RecordingsView.on(EVENTS.RECORDING_SELECTED, this._onRecordingSelectFromView);
    DetailsView.on(EVENTS.DETAILS_VIEW_SELECTED, this._pipe);

    gDevTools.on("pref-changed", this._onThemeChanged);
  }),

  /**
   * Remove events handled by the PerformanceController
   */
  destroy: function() {
    this._telemetry.destroy();
    this._prefs.off("pref-changed", this._onPrefChanged);

    ToolbarView.off(EVENTS.PREF_CHANGED, this._onPrefChanged);
    PerformanceView.off(EVENTS.UI_START_RECORDING, this.startRecording);
    PerformanceView.off(EVENTS.UI_STOP_RECORDING, this.stopRecording);
    PerformanceView.off(EVENTS.UI_IMPORT_RECORDING, this.importRecording);
    PerformanceView.off(EVENTS.UI_CLEAR_RECORDINGS, this.clearRecordings);
    RecordingsView.off(EVENTS.UI_EXPORT_RECORDING, this.exportRecording);
    RecordingsView.off(EVENTS.RECORDING_SELECTED, this._onRecordingSelectFromView);
    DetailsView.off(EVENTS.DETAILS_VIEW_SELECTED, this._pipe);

    gDevTools.off("pref-changed", this._onThemeChanged);
  },

  /**
   * Enables front event listeners.
   *
   * The rationale behind this is given by the async intialization of all the
   * frontend components. Even though the panel is considered "open" only after
   * both the controller and the view are created, and even though their
   * initialization is sequential (controller, then view), the controller might
   * start handling backend events before the view finishes if the event
   * listeners are added too soon.
   */
  enableFrontEventListeners: function() {
    gFront.on("*", this._onFrontEvent);
  },

  /**
   * Disables front event listeners.
   */
  disableFrontEventListeners: function() {
    gFront.off("*", this._onFrontEvent);
  },

  /**
   * Returns the current devtools theme.
   */
  getTheme: function () {
    return Services.prefs.getCharPref("devtools.theme");
  },

  /**
   * Get a boolean preference setting from `prefName` via the underlying
   * OptionsView in the ToolbarView. This preference is guaranteed to be
   * displayed in the UI.
   *
   * @param string prefName
   * @return boolean
   */
  getOption: function (prefName) {
    return ToolbarView.optionsView.getPref(prefName);
  },

  /**
   * Get a preference setting from `prefName`. This preference can be of
   * any type and might not be displayed in the UI.
   *
   * @param string prefName
   * @return any
   */
  getPref: function (prefName) {
    return this._prefs[prefName];
  },

  /**
   * Set a preference setting from `prefName`. This preference can be of
   * any type and might not be displayed in the UI.
   *
   * @param string prefName
   * @param any prefValue
   */
  setPref: function (prefName, prefValue) {
    this._prefs[prefName] = prefValue;
  },

  /**
   * Checks whether or not a new recording is supported by the PerformanceFront.
   * @return Promise:boolean
   */
  canCurrentlyRecord: Task.async(function*() {
    // If we're testing the legacy front, the performance actor will exist,
    // with `canCurrentlyRecord` method; this ensures we test the legacy path.
    if (gFront.LEGACY_FRONT) {
      return true;
    }
    let hasActor = yield gTarget.hasActor("performance");
    if (!hasActor) {
      return true;
    }
    let actorCanCheck = yield gTarget.actorHasMethod("performance", "canCurrentlyRecord");
    if (!actorCanCheck) {
      return true;
    }
    return (yield gFront.canCurrentlyRecord()).success;
  }),

  /**
   * Starts recording with the PerformanceFront.
   */
  startRecording: Task.async(function *() {
    let options = {
      withMarkers: true,
      withTicks: this.getOption("enable-framerate"),
      withMemory: this.getOption("enable-memory"),
      withFrames: true,
      withGCEvents: true,
      withJITOptimizations: this.getOption("enable-jit-optimizations"),
      withAllocations: this.getOption("enable-allocations"),
      allocationsSampleProbability: this.getPref("memory-sample-probability"),
      allocationsMaxLogLength: this.getPref("memory-max-log-length"),
      bufferSize: this.getPref("profiler-buffer-size"),
      sampleFrequency: this.getPref("profiler-sample-frequency")
    };

    // In some cases, like when the target has a private browsing tab,
    // recording is not currently supported because of the profiler module.
    // Present a notification in this case alerting the user of this issue.
    if (!(yield gFront.startRecording(options))) {
      this.emit(EVENTS.NEW_RECORDING_FAILED);
      PerformanceView.setState("unavailable");
    }
  }),

  /**
   * Stops recording with the PerformanceFront.
   */
  stopRecording: Task.async(function *() {
    let recording = this.getLatestManualRecording();
    yield gFront.stopRecording(recording);

    // Emit another stop event here, as a lot of tests use
    // the RECORDING_STOPPED event, but in the case of a UI click on a button,
    // the RECORDING_STOPPED event happens from the server, where this request may
    // not have yet finished, so listen to this in tests that fail because the `stopRecording`
    // request is not yet completed. Should only be used in that scenario.
    this.emit(EVENTS.CONTROLLER_STOPPED_RECORDING);
  }),

  /**
   * Saves the given recording to a file. Emits `EVENTS.RECORDING_EXPORTED`
   * when the file was saved.
   *
   * @param PerformanceRecording recording
   *        The model that holds the recording data.
   * @param nsILocalFile file
   *        The file to stream the data into.
   */
  exportRecording: Task.async(function*(_, recording, file) {
    yield recording.exportRecording(file);
    this.emit(EVENTS.RECORDING_EXPORTED, recording, file);
  }),

   /**
   * Clears all completed recordings from the list as well as the current non-console recording.
   * Emits `EVENTS.RECORDING_DELETED` when complete so other components can clean up.
   */
  clearRecordings: Task.async(function* () {
    for (let i = this._recordings.length - 1; i >= 0; i--) {
      let model = this._recordings[i];
      if (!model.isConsole() && model.isRecording()) {
        yield this.stopRecording();
      }
      // If last recording is not recording, but finalizing itself,
      // wait for that to finish
      if (!model.isRecording() && !model.isCompleted()) {
        yield this.waitForStateChangeOnRecording(model, "recording-stopped");
      }
      // If recording is completed,
      // clean it up from UI and remove it from the _recordings array.
      if (model.isCompleted()) {
        this.emit(EVENTS.RECORDING_DELETED, model);
        this._recordings.splice(i, 1);
      }
    }
    if (this._recordings.length > 0) {
      if (!this._recordings.includes(this.getCurrentRecording())) {
        this.setCurrentRecording(this._recordings[0]);
      }
    }
    else {
      this.setCurrentRecording(null);
    }
  }),

  /**
   * Loads a recording from a file, adding it to the recordings list. Emits
   * `EVENTS.RECORDING_IMPORTED` when the file was loaded.
   *
   * @param nsILocalFile file
   *        The file to import the data from.
   */
  importRecording: Task.async(function*(_, file) {
    let recording = yield gFront.importRecording(file);
    this._addNewRecording(recording);

    this.emit(EVENTS.RECORDING_IMPORTED, recording);
  }),

  /**
   * Sets the currently active PerformanceRecording. Should rarely be called directly,
   * as RecordingsView handles this when manually selected a recording item. Exceptions
   * are when clearing the view.
   * @param PerformanceRecording recording
   */
  setCurrentRecording: function (recording) {
    if (this._currentRecording !== recording) {
      this._currentRecording = recording;
      this.emit(EVENTS.RECORDING_SELECTED, recording);
    }
  },

  /**
   * Gets the currently active PerformanceRecording.
   * @return PerformanceRecording
   */
  getCurrentRecording: function () {
    return this._currentRecording;
  },

  /**
   * Get most recently added recording that was triggered manually (via UI).
   * @return PerformanceRecording
   */
  getLatestManualRecording: function () {
    for (let i = this._recordings.length - 1; i >= 0; i--) {
      let model = this._recordings[i];
      if (!model.isConsole() && !model.isImported()) {
        return this._recordings[i];
      }
    }
    return null;
  },

  /**
   * Fired from RecordingsView, we listen on the PerformanceController so we can
   * set it here and re-emit on the controller, where all views can listen.
   */
  _onRecordingSelectFromView: function (_, recording) {
    this.setCurrentRecording(recording);
  },

  /**
   * Fired when the ToolbarView fires a PREF_CHANGED event.
   * with the value.
   */
  _onPrefChanged: function (_, prefName, prefValue) {
    this.emit(EVENTS.PREF_CHANGED, prefName, prefValue);
  },

  /*
   * Called when the developer tools theme changes.
   */
  _onThemeChanged: function (_, data) {
    // Right now, gDevTools only emits `pref-changed` for the theme,
    // but this could change in the future.
    if (data.pref !== "devtools.theme") {
      return;
    }

    this.emit(EVENTS.THEME_CHANGED, data.newValue);
  },

  /**
   * Fired from the front on any event. Propagates to other handlers from here.
   */
  _onFrontEvent: function (eventName, ...data) {
    if (eventName === "profiler-status") {
      this._onProfilerStatusUpdated(...data);
      return;
    }

    if (["recording-started", "recording-stopped", "recording-stopping"].indexOf(eventName) !== -1) {
      this._onRecordingStateChange(eventName, ...data);
    }
  },

  /**
   * Emitted when the front updates PerformanceRecording's buffer status.
   */
  _onProfilerStatusUpdated: function (data) {
    this.emit(EVENTS.PROFILER_STATUS_UPDATED, data);
  },

  /**
   * Stores a recording internally.
   *
   * @param {PerformanceRecordingFront} recording
   */
  _addNewRecording: function (recording) {
    if (this._recordings.indexOf(recording) === -1) {
      this._recordings.push(recording);
      this.emit(EVENTS.NEW_RECORDING, recording);
    }
  },

  /**
   * Fired when a recording model changes state.
   *
   * @param {string} state
   *        Can be "recording-started", "recording-stopped" or "recording-stopping".
   * @param {PerformanceRecording} model
   */
  _onRecordingStateChange: function (state, model) {
    this._addNewRecording(model);

    this.emit(EVENTS.RECORDING_STATE_CHANGE, state, model);

    // Emit the state specific events for tests that I'm too
    // lazy and frusterated to change right now. These events
    // should only be used in tests and specific rare cases (telemetry),
    // as the rest of the UI should react to general RECORDING_STATE_CHANGE
    // events and NEW_RECORDING events to handle lazy recordings.
    switch (state) {
      case "recording-started":
        this.emit(EVENTS.RECORDING_STARTED, model);
        break;
      case "recording-stopping":
        this.emit(EVENTS.RECORDING_WILL_STOP, model);
        break;
      case "recording-stopped":
        this.emit(EVENTS.RECORDING_STOPPED, model);
        break;
    }
  },

  /**
   * Takes a recording and returns a value between 0 and 1 indicating how much
   * of the buffer is used.
   */
  getBufferUsageForRecording: function (recording) {
    return gFront.getBufferUsageForRecording(recording);
  },

  /**
   * Returns a boolean indicating if any recordings are currently in progress or not.
   */
  isRecording: function () {
    return this._recordings.some(r => r.isRecording());
  },

  /**
   * Returns the internal store of recording models.
   */
  getRecordings: function () {
    return this._recordings;
  },

  /**
   * Returns traits from the front.
   */
  getTraits: function () {
    return gFront.traits;
  },

  /**
   * Utility method taking a string or an array of strings of feature names (like
   * "withAllocations" or "withMarkers"), and returns whether or not the current
   * recording supports that feature, based off of UI preferences and server support.
   *
   * @option {Array<string>|string} features
   *         A string or array of strings indicating what configuration is needed on the recording
   *         model, like `withTicks`, or `withMemory`.
   *
   * @return boolean
   */
  isFeatureSupported: function (features) {
    if (!features) {
      return true;
    }

    let recording = this.getCurrentRecording();
    if (!recording) {
      return false;
    }

    let config = recording.getConfiguration();
    return [].concat(features).every(f => config[f]);
  },

  /**
   * Takes an array of PerformanceRecordingFronts and adds them to the internal
   * store of the UI. Used by the toolbox to lazily seed recordings that
   * were observed before the panel was loaded in the scenario where `console.profile()`
   * is used before the tool is loaded.
   *
   * @param {Array<PerformanceRecordingFront>} recordings
   */
  populateWithRecordings: function (recordings=[]) {
    for (let recording of recordings) {
      PerformanceController._addNewRecording(recording);
    }
    this.emit(EVENTS.RECORDINGS_SEEDED);
  },

  /**
   * Returns an object with `supported` and `enabled` properties indicating
   * whether or not the platform is capable of turning on e10s and whether or not
   * it's already enabled, respectively.
   *
   * @return {object}
   */
  getMultiprocessStatus: function () {
    // If testing, set both supported and enabled to true so we
    // have realtime rendering tests in non-e10s. This function is
    // overridden wholesale in tests when we want to test multiprocess support
    // specifically.
    if (DevToolsUtils.testing) {
      return { supported: true, enabled: true };
    }
    let supported = system.constants.E10S_TESTING_ONLY;
    // This is only checked on tool startup -- requires a restart if
    // e10s subsequently enabled.
    let enabled = this._e10s;
    return { supported, enabled };
  },

  /**
   * Takes a PerformanceRecording and a state, and waits for
   * the event to be emitted from the front for that recording.
   *
   * @param {PerformanceRecordingFront} recording
   * @param {string} expectedState
   * @return {Promise}
   */
  waitForStateChangeOnRecording: Task.async(function *(recording, expectedState) {
    let deferred = promise.defer();
    this.on(EVENTS.RECORDING_STATE_CHANGE, function handler (state, model) {
      if (state === expectedState && model === recording) {
        this.off(EVENTS.RECORDING_STATE_CHANGE, handler);
        deferred.resolve();
      }
    });
    yield deferred.promise;
  }),

  /**
   * Called on init, sets an `e10s` attribute on the main view container with
   * "disabled" if e10s is possible on the platform and just not on, or "unsupported"
   * if e10s is not possible on the platform. If e10s is on, no attribute is set.
   */
  _setMultiprocessAttributes: function () {
    let { enabled, supported } = this.getMultiprocessStatus();
    if (!enabled && supported) {
      $("#performance-view").setAttribute("e10s", "disabled");
    }
    // Could be a chance where the directive goes away yet e10s is still on
    else if (!enabled && !supported) {
      $("#performance-view").setAttribute("e10s", "unsupported");
    }
  },

  /**
   * Pipes an event from some source to the PerformanceController.
   */
  _pipe: function (eventName, ...data) {
    this.emit(eventName, ...data);
  },

  toString: () => "[object PerformanceController]"
};

/**
 * Convenient way of emitting events from the controller.
 */
EventEmitter.decorate(PerformanceController);

/**
 * DOM query helpers.
 */
function $(selector, target = document) {
  return target.querySelector(selector);
}
function $$(selector, target = document) {
  return target.querySelectorAll(selector);
}
