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

/**
 * Functions handling the recordings UI.
 */
let RecordingsListView = Heritage.extend(WidgetMethods, {
  /**
   * Initialization function, called when the tool is started.
   */
  initialize: function() {
    this.widget = new SideMenuWidget($("#recordings-list"));

    this._onSelect = this._onSelect.bind(this);
    this._onClearButtonClick = this._onClearButtonClick.bind(this);
    this._onRecordButtonClick = this._onRecordButtonClick.bind(this);
    this._onImportButtonClick = this._onImportButtonClick.bind(this);
    this._onSaveButtonClick = this._onSaveButtonClick.bind(this);

    this.emptyText = L10N.getStr("noRecordingsText");
    this.widget.addEventListener("select", this._onSelect, false);
  },

  /**
   * Destruction function, called when the tool is closed.
   */
  destroy: function() {
    this.widget.removeEventListener("select", this._onSelect, false);
  },

  /**
   * Adds an empty recording to this container.
   *
   * @param string profileLabel [optional]
   *        A custom label for the newly created recording item.
   */
  addEmptyRecording: function(profileLabel) {
    let titleNode = document.createElement("label");
    titleNode.className = "plain recording-item-title";
    titleNode.setAttribute("value", profileLabel ||
      L10N.getFormatStr("recordingsList.itemLabel", this.itemCount + 1));

    let durationNode = document.createElement("label");
    durationNode.className = "plain recording-item-duration";
    durationNode.setAttribute("value",
      L10N.getStr("recordingsList.recordingLabel"));

    let saveNode = document.createElement("label");
    saveNode.className = "plain recording-item-save";
    saveNode.addEventListener("click", this._onSaveButtonClick);

    let hspacer = document.createElement("spacer");
    hspacer.setAttribute("flex", "1");

    let footerNode = document.createElement("hbox");
    footerNode.className = "recording-item-footer";
    footerNode.appendChild(durationNode);
    footerNode.appendChild(hspacer);
    footerNode.appendChild(saveNode);

    let vspacer = document.createElement("spacer");
    vspacer.setAttribute("flex", "1");

    let contentsNode = document.createElement("vbox");
    contentsNode.className = "recording-item";
    contentsNode.setAttribute("flex", "1");
    contentsNode.appendChild(titleNode);
    contentsNode.appendChild(vspacer);
    contentsNode.appendChild(footerNode);

    // Append a recording item to this container.
    return this.push([contentsNode], {
      attachment: {
        // The profiler and refresh driver ticks data will be available
        // as soon as recording finishes.
        profilerData: { profileLabel },
        ticksData: null
      }
    });
  },

  /**
   * Signals that a recording session has started.
   *
   * @param string profileLabel
   *        The provided string argument if available, undefined otherwise.
   */
  handleRecordingStarted: function(profileLabel) {
    // Insert a "dummy" recording item, to hint that recording has now started.
    let recordingItem;

    // If a label is specified (e.g due to a call to `console.profile`),
    // then try reusing a pre-existing recording item, if there is one.
    // This is symmetrical to how `this.handleRecordingEnded` works.
    if (profileLabel) {
      recordingItem = this.getItemForAttachment(e =>
        e.profilerData.profileLabel == profileLabel);
    }
    // Otherwise, create a new empty recording item.
    if (!recordingItem) {
      recordingItem = this.addEmptyRecording(profileLabel);
    }

    // Mark the corresponding item as being a "record in progress".
    recordingItem.isRecording = true;

    // If this is the first item, immediately select it.
    if (this.itemCount == 1) {
      this.selectedItem = recordingItem;
    }

    window.emit(EVENTS.RECORDING_STARTED, profileLabel);
  },

  /**
   * Signals that a recording session has ended.
   *
   * @param object recordingData
   *        The profiler and refresh driver ticks data received from the front.
   */
  handleRecordingEnded: function(recordingData) {
    let profileLabel = recordingData.profilerData.profileLabel;
    let recordingItem;

    // If a label is specified (e.g due to a call to `console.profileEnd`),
    // then try reusing a pre-existing recording item, if there is one.
    // This is symmetrical to how `this.handleRecordingStarted` works.
    if (profileLabel) {
      recordingItem = this.getItemForAttachment(e =>
        e.profilerData.profileLabel == profileLabel);
    }
    // Otherwise, just use the first available recording item.
    if (!recordingItem) {
      recordingItem = this.getItemForPredicate(e => e.isRecording);
    }

    // Mark the corresponding item as being a "finished recording".
    recordingItem.isRecording = false;

    // Store the recording data, customize and select this recording item.
    this.customizeRecording(recordingItem, recordingData);
    this.forceSelect(recordingItem);

    window.emit(EVENTS.RECORDING_ENDED, recordingData);
  },

  /**
   * Signals that a recording session has ended abruptly and the accumulated
   * data should be discarded.
   */
  handleRecordingCancelled: Task.async(function*() {
    if ($("#record-button").hasAttribute("checked")) {
      $("#record-button").removeAttribute("checked");
      yield gFront.cancelRecording();
    }
    ProfileView.showEmptyNotice();

    window.emit(EVENTS.RECORDING_LOST);
  }),

  /**
   * Adds recording data to a recording item in this container.
   *
   * @param Item recordingItem
   *        An item inserted via `RecordingsListView.addEmptyRecording`.
   * @param object recordingData
   *        The profiler and refresh driver ticks data received from the front.
   */
  customizeRecording: function(recordingItem, recordingData) {
    recordingItem.attachment = recordingData;

    let saveNode = $(".recording-item-save", recordingItem.target);
    saveNode.setAttribute("value",
      L10N.getStr("recordingsList.saveLabel"));

    let durationMillis = recordingData.recordingDuration;
    let durationNode = $(".recording-item-duration", recordingItem.target);
    durationNode.setAttribute("value",
      L10N.getFormatStr("recordingsList.durationLabel", durationMillis));
  },

  /**
   * The select listener for this container.
   */
  _onSelect: Task.async(function*({ detail: recordingItem }) {
    if (!recordingItem) {
      ProfileView.showEmptyNotice();
      return;
    }
    if (recordingItem.isRecording) {
      ProfileView.showRecordingNotice();
      return;
    }

    ProfileView.showLoadingNotice();
    ProfileView.removeAllTabs();

    let recordingData = recordingItem.attachment;
    let durationMillis = recordingData.recordingDuration;
    yield ProfileView.addTabAndPopulate(recordingData, 0, durationMillis);
    ProfileView.showTabbedBrowser();

    // Only clear the checked state if there's nothing recording.
    if (!this.getItemForPredicate(e => e.isRecording)) {
      $("#record-button").removeAttribute("checked");
    }

    // But don't leave it locked in any case.
    $("#record-button").removeAttribute("locked");

    window.emit(EVENTS.RECORDING_DISPLAYED);
  }),

  /**
   * The click listener for the "clear" button in this container.
   */
  _onClearButtonClick: Task.async(function*() {
    this.empty();
    yield this.handleRecordingCancelled();
  }),

  /**
   * The click listener for the "record" button in this container.
   */
  _onRecordButtonClick: Task.async(function*() {
    if (!$("#record-button").hasAttribute("checked")) {
      $("#record-button").setAttribute("checked", "true");
      yield gFront.startRecording();
      this.handleRecordingStarted();
    } else {
      $("#record-button").setAttribute("locked", "");
      let recordingData = yield gFront.stopRecording();
      this.handleRecordingEnded(recordingData);
    }
  }),

  /**
   * The click listener for the "import" button in this container.
   */
  _onImportButtonClick: Task.async(function*() {
    let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
    fp.init(window, L10N.getStr("recordingsList.saveDialogTitle"), Ci.nsIFilePicker.modeOpen);
    fp.appendFilter(L10N.getStr("recordingsList.saveDialogJSONFilter"), "*.json");
    fp.appendFilter(L10N.getStr("recordingsList.saveDialogAllFilter"), "*.*");

    if (fp.show() == Ci.nsIFilePicker.returnOK) {
      loadRecordingFromFile(fp.file);
    }
  }),

  /**
   * The click listener for the "save" button of each item in this container.
   */
  _onSaveButtonClick: function(e) {
    let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
    fp.init(window, L10N.getStr("recordingsList.saveDialogTitle"), Ci.nsIFilePicker.modeSave);
    fp.appendFilter(L10N.getStr("recordingsList.saveDialogJSONFilter"), "*.json");
    fp.appendFilter(L10N.getStr("recordingsList.saveDialogAllFilter"), "*.*");
    fp.defaultString = "profile.json";

    fp.open({ done: result => {
      if (result == Ci.nsIFilePicker.returnCancel) {
        return;
      }
      let recordingItem = this.getItemForElement(e.target);
      saveRecordingToFile(recordingItem, fp.file);
    }});
  }
});

/**
 * Gets a nsIScriptableUnicodeConverter instance with a default UTF-8 charset.
 * @return object
 */
function getUnicodeConverter() {
  let className = "@mozilla.org/intl/scriptableunicodeconverter";
  let converter = Cc[className].createInstance(Ci.nsIScriptableUnicodeConverter);
  converter.charset = "UTF-8";
  return converter;
}

/**
 * Saves a recording as JSON to a file. The provided data is assumed to be
 * acyclical, so that it can be properly serialized.
 *
 * @param Item recordingItem
 *        The recording item containing the data to stream as JSON.
 * @param nsILocalFile file
 *        The file to stream the data into.
 * @return object
 *         A promise that is resolved once streaming finishes, or rejected
 *         if there was an error.
 */
function saveRecordingToFile(recordingItem, file) {
  let deferred = promise.defer();

  let recordingData = recordingItem.attachment;
  recordingData.fileType = PROFILE_SERIALIZER_IDENTIFIER;
  recordingData.version = PROFILE_SERIALIZER_VERSION;

  let string = JSON.stringify(recordingData);
  let inputStream = getUnicodeConverter().convertToInputStream(string);
  let outputStream = FileUtils.openSafeFileOutputStream(file);

  NetUtil.asyncCopy(inputStream, outputStream, status => {
    if (!Components.isSuccessCode(status)) {
      deferred.reject(new Error("Could not save recording data file."));
    }
    deferred.resolve();
  });

  return deferred.promise;
}

/**
 * Loads a recording stored as JSON from a file.
 *
 * @param nsILocalFile file
 *        The file to import the data from.
 * @return object
 *         A promise that is resolved once importing finishes, or rejected
 *         if there was an error.
 */
function loadRecordingFromFile(file) {
  let deferred = promise.defer();

  let channel = NetUtil.newChannel2(file,
                                    null,
                                    null,
                                    window.document,
                                    null,  // aLoadingPrincipal
                                    null,  // aTriggeringPrincipal
                                    Ci.nsILoadInfo.SEC_NORMAL,
                                    Ci.nsIContentPolicy.TYPE_OTHER);
  channel.contentType = "text/plain";

  NetUtil.asyncFetch2(channel, (inputStream, status) => {
    if (!Components.isSuccessCode(status)) {
      deferred.reject(new Error("Could not import recording data file."));
      return;
    }
    try {
      let string = NetUtil.readInputStreamToString(inputStream, inputStream.available());
      var recordingData = JSON.parse(string);
    } catch (e) {
      deferred.reject(new Error("Could not read recording data file."));
      return;
    }
    if (recordingData.fileType != PROFILE_SERIALIZER_IDENTIFIER) {
      deferred.reject(new Error("Unrecognized recording data file."));
      return;
    }

    let profileLabel = recordingData.profilerData.profileLabel;
    let recordingItem = RecordingsListView.addEmptyRecording(profileLabel);
    RecordingsListView.customizeRecording(recordingItem, recordingData);

    // If this is the first item, immediately select it.
    if (RecordingsListView.itemCount == 1) {
      RecordingsListView.selectedItem = recordingItem;
    }

    deferred.resolve();
  });

  return deferred.promise;
}
