/* 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 isJSM = typeof require !== "function";

// This code is needed because, for whatever reason, mochitest can't
// find any requirejs module so we have to load it old school way. :(

if (isJSM) {
  var Cu = this["Components"].utils;
  let XPCOMUtils = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {}).XPCOMUtils;
  this["loader"] = { lazyGetter: XPCOMUtils.defineLazyGetter.bind(XPCOMUtils) };
  this["require"] = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require;
} else {
  var { Cu } = require("chrome");
}

const { L10N_BUNDLE } = require("devtools/profiler/consts");

var EventEmitter = require("devtools/toolkit/event-emitter");

Cu.import("resource://gre/modules/devtools/dbg-client.jsm");
Cu.import("resource://gre/modules/devtools/Console.jsm");
Cu.import("resource://gre/modules/AddonManager.jsm");
Cu.import("resource:///modules/devtools/ViewHelpers.jsm");

loader.lazyGetter(this, "L10N", () => new ViewHelpers.L10N(L10N_BUNDLE));

loader.lazyGetter(this, "gDevTools",
  () => Cu.import("resource:///modules/devtools/gDevTools.jsm", {}).gDevTools);

loader.lazyGetter(this, "DebuggerServer",
  () => Cu.import("resource:///modules/devtools/dbg-server.jsm", {}).DebuggerServer);

/**
 * Data structure that contains information that has
 * to be shared between separate ProfilerController
 * instances.
 */
const sharedData = {
  data: new WeakMap(),
  controllers: new WeakMap(),
};

/**
 * Makes a structure representing an individual profile.
 */
function makeProfile(name, def={}) {
  if (def.timeStarted == null)
    def.timeStarted = null;

  if (def.timeEnded == null)
    def.timeEnded = null;

  return {
    name: name,
    timeStarted: def.timeStarted,
    timeEnded: def.timeEnded,
    fromConsole: def.fromConsole || false
  };
}

// Three functions below all operate with sharedData
// structure defined above. They should be self-explanatory.

function addTarget(target) {
  sharedData.data.set(target, new Map());
}

function getProfiles(target) {
  return sharedData.data.get(target);
}

/**
 * Object to control the JavaScript Profiler over the remote
 * debugging protocol.
 *
 * @param Target target
 *        A target object as defined in Target.jsm
 */
function ProfilerController(target) {
  if (sharedData.controllers.has(target)) {
    return sharedData.controllers.get(target);
  }

  this.target = target;
  this.client = target.client;
  this.isConnected = false;
  this.consoleProfiles = [];
  this.reservedNames = {};

  addTarget(target);

  // Chrome debugging targets have already obtained a reference
  // to the profiler actor.
  if (target.chrome) {
    this.isConnected = true;
    this.actor = target.form.profilerActor;
  }

  sharedData.controllers.set(target, this);
  EventEmitter.decorate(this);
};

ProfilerController.prototype = {
  target:          null,
  client:          null,
  isConnected:     null,
  consoleProfiles: null,
  reservedNames:   null,

  /**
   * Return a map of profile results for the current target.
   *
   * @return Map
   */
  get profiles() {
    return getProfiles(this.target);
  },

  /**
   * Checks whether the profile is currently recording.
   *
   * @param object profile
   *        An object made by calling makeProfile function.
   * @return boolean
   */
  isProfileRecording: function PC_isProfileRecording(profile) {
    return profile.timeStarted !== null && profile.timeEnded === null;
  },

  getProfileName: function PC_getProfileName() {
    let num = 1;
    let name = L10N.getFormatStr("profiler.profileName", [num]);

    while (this.reservedNames[name]) {
      num += 1;
      name = L10N.getFormatStr("profiler.profileName", [num]);
    }

    this.reservedNames[name] = true;
    return name;
  },

  /**
   * A listener that fires whenever console.profile or console.profileEnd
   * is called.
   *
   * @param string type
   *        Type of a call. Either 'profile' or 'profileEnd'.
   * @param object data
   *        Event data.
   */
  onConsoleEvent: function (type, data) {
    let name = data.extra.name;

    let profileStart = () => {
      if (name && this.profiles.has(name))
        return;

      // Add profile structure to shared data.
      let profile = makeProfile(name || this.getProfileName(), {
        timeStarted: data.extra.currentTime,
        fromConsole: true
      });

      this.profiles.set(profile.name, profile);
      this.consoleProfiles.push(profile.name);
      this.emit("profileStart", profile);
    };

    let profileEnd = () => {
      if (!name && !this.consoleProfiles.length)
        return;

      if (!name)
        name = this.consoleProfiles.pop();
      else
        this.consoleProfiles.filter((n) => n !== name);

      if (!this.profiles.has(name))
        return;

      let profile = this.profiles.get(name);
      if (!this.isProfileRecording(profile))
        return;

      let profileData = data.extra.profile;
      profileData.threads = profileData.threads.map((thread) => {
        let samples = thread.samples.filter((sample) => {
          return sample.time >= profile.timeStarted;
        });

        return { samples: samples };
      });

      profile.timeEnded = data.extra.currentTime;
      profile.data = profileData;

      this.emit("profileEnd", profile);
    };

    if (type === "profile")
      profileStart();

    if (type === "profileEnd")
      profileEnd();
  },

  /**
   * Connects to the client unless we're already connected.
   *
   * @param function cb
   *        Function to be called once we're connected. If
   *        the controller is already connected, this function
   *        will be called immediately (synchronously).
   */
  connect: function (cb=function(){}) {
    if (this.isConnected) {
      return void cb();
    }

    // Check if we already have a grip to the listTabs response object
    // and, if we do, use it to get to the profilerActor. Otherwise,
    // call listTabs. The problem is that if we call listTabs twice
    // webconsole tests fail (see bug 872826).

    let register = () => {
      let data = { events: ["console-api-profiler"] };

      // Check if Gecko Profiler Addon [1] is installed and, if it is,
      // don't register our own console event listeners. Gecko Profiler
      // Addon takes care of console.profile and console.profileEnd methods
      // and we don't want to break it.
      //
      // [1] - https://github.com/bgirard/Gecko-Profiler-Addon/

      AddonManager.getAddonByID("jid0-edalmuivkozlouyij0lpdx548bc@jetpack", (addon) => {
        if (addon && !addon.userDisabled && !addon.softDisabled)
          return void cb();

        this.request("registerEventNotifications", data, (resp) => {
          this.client.addListener("eventNotification", (type, resp) => {
            let toolbox = gDevTools.getToolbox(this.target);
            if (toolbox == null)
              return;

            this.onConsoleEvent(resp.subject.action, resp.data);
          });
        });

        cb();
      });
    };

    if (this.target.root) {
      this.actor = this.target.root.profilerActor;
      this.isConnected = true;
      return void register();
    }

    this.client.listTabs((resp) => {
      this.actor = resp.profilerActor;
      this.isConnected = true;
      register();
    });
  },

  /**
   * Adds actor and type information to data and sends the request over
   * the remote debugging protocol.
   *
   * @param string type
   *        Method to call on the other side
   * @param object data
   *        Data to send with the request
   * @param function cb
   *        A callback function
   */
  request: function (type, data, cb) {
    data.to = this.actor;
    data.type = type;
    this.client.request(data, cb);
  },

  /**
   * Checks whether the profiler is active.
   *
   * @param function cb
   *        Function to be called with a response from the
   *        client. It will be called with two arguments:
   *        an error object (may be null) and a boolean
   *        value indicating if the profiler is active or not.
   */
  isActive: function (cb) {
    this.request("isActive", {}, (resp) => {
      cb(resp.error, resp.isActive, resp.currentTime);
    });
  },

  /**
   * Creates a new profile and starts the profiler, if needed.
   *
   * @param string name
   *        Name of the profile.
   * @param function cb
   *        Function to be called once the profiler is started
   *        or we get an error. It will be called with a single
   *        argument: an error object (may be null).
   */
  start: function PC_start(name, cb) {
    if (this.profiles.has(name)) {
      return;
    }

    let profile = makeProfile(name);
    this.consoleProfiles.push(name);
    this.profiles.set(name, profile);

    // If profile is already running, no need to do anything.
    if (this.isProfileRecording(profile)) {
      return void cb();
    }

    this.isActive((err, isActive, currentTime) => {
      if (isActive) {
        profile.timeStarted = currentTime;
        return void cb();
      }

      let params = {
        entries: 1000000,
        interval: 1,
        features: ["js"],
      };

      this.request("startProfiler", params, (resp) => {
        if (resp.error) {
          return void cb(resp.error);
        }

        profile.timeStarted = 0;
        cb();
      });
    });
  },

  /**
   * Stops the profiler. NOTE, that we don't stop the actual
   * SPS Profiler here. It will be stopped as soon as all
   * clients disconnect from the profiler actor.
   *
   * @param string name
   *        Name of the profile that needs to be stopped.
   * @param function cb
   *        Function to be called once the profiler is stopped
   *        or we get an error. It will be called with a single
   *        argument: an error object (may be null).
   */
  stop: function PC_stop(name, cb) {
    if (!this.profiles.has(name)) {
      return;
    }

    let profile = this.profiles.get(name);
    if (!this.isProfileRecording(profile)) {
      return;
    }

    this.request("getProfile", {}, (resp) => {
      if (resp.error) {
        Cu.reportError("Failed to fetch profile data.");
        return void cb(resp.error, null);
      }

      let data = resp.profile;
      profile.timeEnded = resp.currentTime;

      // Filter out all samples that fall out of current
      // profile's range.

      data.threads = data.threads.map((thread) => {
        let samples = thread.samples.filter((sample) => {
          return sample.time >= profile.timeStarted;
        });

        return { samples: samples };
      });

      cb(null, data);
    });
  },

  /**
   * Cleanup.
   */
  destroy: function PC_destroy() {
    this.client = null;
    this.target = null;
    this.actor = null;
  }
};

if (isJSM) {
  var EXPORTED_SYMBOLS = ["ProfilerController"];
} else {
  module.exports = ProfilerController;
}