/* 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 { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;

Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource:///modules/devtools/SideMenuWidget.jsm");
Cu.import("resource:///modules/devtools/ViewHelpers.jsm");

const require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require;
const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
const EventEmitter = require("devtools/toolkit/event-emitter");
const { CallWatcherFront } = require("devtools/server/actors/call-watcher");
const { CanvasFront } = require("devtools/server/actors/canvas");
const Telemetry = require("devtools/shared/telemetry");
const telemetry = new Telemetry();

XPCOMUtils.defineLazyModuleGetter(this, "Task",
  "resource://gre/modules/Task.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
  "resource://gre/modules/PluralForm.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
  "resource://gre/modules/FileUtils.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
  "resource://gre/modules/NetUtil.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "DevToolsUtils",
  "resource://gre/modules/devtools/DevToolsUtils.jsm");

// The panel's window global is an EventEmitter firing the following events:
const EVENTS = {
  // When the UI is reset from tab navigation.
  UI_RESET: "CanvasDebugger:UIReset",

  // When all the animation frame snapshots are removed by the user.
  SNAPSHOTS_LIST_CLEARED: "CanvasDebugger:SnapshotsListCleared",

  // When an animation frame snapshot starts/finishes being recorded.
  SNAPSHOT_RECORDING_STARTED: "CanvasDebugger:SnapshotRecordingStarted",
  SNAPSHOT_RECORDING_FINISHED: "CanvasDebugger:SnapshotRecordingFinished",

  // When an animation frame snapshot was selected and all its data displayed.
  SNAPSHOT_RECORDING_SELECTED: "CanvasDebugger:SnapshotRecordingSelected",

  // After all the function calls associated with an animation frame snapshot
  // are displayed in the UI.
  CALL_LIST_POPULATED: "CanvasDebugger:CallListPopulated",

  // After the stack associated with a call in an animation frame snapshot
  // is displayed in the UI.
  CALL_STACK_DISPLAYED: "CanvasDebugger:CallStackDisplayed",

  // After a screenshot associated with a call in an animation frame snapshot
  // is displayed in the UI.
  CALL_SCREENSHOT_DISPLAYED: "CanvasDebugger:ScreenshotDisplayed",

  // After all the thumbnails associated with an animation frame snapshot
  // are displayed in the UI.
  THUMBNAILS_DISPLAYED: "CanvasDebugger:ThumbnailsDisplayed",

  // When a source is shown in the JavaScript Debugger at a specific location.
  SOURCE_SHOWN_IN_JS_DEBUGGER: "CanvasDebugger:SourceShownInJsDebugger",
  SOURCE_NOT_FOUND_IN_JS_DEBUGGER: "CanvasDebugger:SourceNotFoundInJsDebugger"
};

const HTML_NS = "http://www.w3.org/1999/xhtml";
const STRINGS_URI = "chrome://browser/locale/devtools/canvasdebugger.properties";
const SHARED_STRINGS_URI = "chrome://browser/locale/devtools/shared.properties";

const SNAPSHOT_START_RECORDING_DELAY = 10; // ms
const SNAPSHOT_DATA_EXPORT_MAX_BLOCK = 1000; // ms
const SNAPSHOT_DATA_DISPLAY_DELAY = 10; // ms
const SCREENSHOT_DISPLAY_DELAY = 100; // ms
const STACK_FUNC_INDENTATION = 14; // px

// This identifier string is simply used to tentatively ascertain whether or not
// a JSON loaded from disk is actually something generated by this tool or not.
// It isn't, of course, a definitive verification, but a Good Enough™
// approximation before continuing the import. Don't localize this.
const CALLS_LIST_SERIALIZER_IDENTIFIER = "Recorded Animation Frame Snapshot";
const CALLS_LIST_SERIALIZER_VERSION = 1;
const CALLS_LIST_SLOW_SAVE_DELAY = 100; // ms

/**
 * The current target and the Canvas front, set by this tool's host.
 */
let gToolbox, gTarget, gFront;

/**
 * Initializes the canvas debugger controller and views.
 */
function startupCanvasDebugger() {
  return promise.all([
    EventsHandler.initialize(),
    SnapshotsListView.initialize(),
    CallsListView.initialize()
  ]);
}

/**
 * Destroys the canvas debugger controller and views.
 */
function shutdownCanvasDebugger() {
  return promise.all([
    EventsHandler.destroy(),
    SnapshotsListView.destroy(),
    CallsListView.destroy()
  ]);
}

/**
 * Functions handling target-related lifetime events.
 */
let EventsHandler = {
  /**
   * Listen for events emitted by the current tab target.
   */
  initialize: function() {
    telemetry.toolOpened("canvasdebugger");
    this._onTabNavigated = this._onTabNavigated.bind(this);
    gTarget.on("will-navigate", this._onTabNavigated);
    gTarget.on("navigate", this._onTabNavigated);
  },

  /**
   * Remove events emitted by the current tab target.
   */
  destroy: function() {
    telemetry.toolClosed("canvasdebugger");
    gTarget.off("will-navigate", this._onTabNavigated);
    gTarget.off("navigate", this._onTabNavigated);
  },

  /**
   * Called for each location change in the debugged tab.
   */
  _onTabNavigated: function(event) {
    if (event != "will-navigate") {
      return;
    }
    // Make sure the backend is prepared to handle <canvas> contexts.
    gFront.setup({ reload: false });

    // Reset UI.
    SnapshotsListView.empty();
    CallsListView.empty();

    $("#record-snapshot").removeAttribute("checked");
    $("#record-snapshot").removeAttribute("disabled");
    $("#record-snapshot").hidden = false;

    $("#reload-notice").hidden = true;
    $("#empty-notice").hidden = false;
    $("#import-notice").hidden = true;

    $("#debugging-pane-contents").hidden = true;
    $("#screenshot-container").hidden = true;
    $("#snapshot-filmstrip").hidden = true;

    window.emit(EVENTS.UI_RESET);
  }
};

/**
 * Functions handling the recorded animation frame snapshots UI.
 */
let SnapshotsListView = Heritage.extend(WidgetMethods, {
  /**
   * Initialization function, called when the tool is started.
   */
  initialize: function() {
    this.widget = new SideMenuWidget($("#snapshots-list"), {
      showArrows: true
    });

    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("noSnapshotsText");
    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 a snapshot entry to this container.
   *
   * @return object
   *         The newly inserted item.
   */
  addSnapshot: function() {
    let contents = document.createElement("hbox");
    contents.className = "snapshot-item";

    let thumbnail = document.createElementNS(HTML_NS, "canvas");
    thumbnail.className = "snapshot-item-thumbnail";
    thumbnail.width = CanvasFront.THUMBNAIL_SIZE;
    thumbnail.height = CanvasFront.THUMBNAIL_SIZE;

    let title = document.createElement("label");
    title.className = "plain snapshot-item-title";
    title.setAttribute("value",
      L10N.getFormatStr("snapshotsList.itemLabel", this.itemCount + 1));

    let calls = document.createElement("label");
    calls.className = "plain snapshot-item-calls";
    calls.setAttribute("value",
      L10N.getStr("snapshotsList.loadingLabel"));

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

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

    let footer = document.createElement("hbox");
    footer.className = "snapshot-item-footer";
    footer.appendChild(save);

    let details = document.createElement("vbox");
    details.className = "snapshot-item-details";
    details.appendChild(title);
    details.appendChild(calls);
    details.appendChild(spacer);
    details.appendChild(footer);

    contents.appendChild(thumbnail);
    contents.appendChild(details);

    // Append a recorded snapshot item to this container.
    return this.push([contents], {
      attachment: {
        // The snapshot and function call actors, along with the thumbnails
        // will be available as soon as recording finishes.
        actor: null,
        calls: null,
        thumbnails: null,
        screenshot: null
      }
    });
  },

  /**
   * Customizes a shapshot in this container.
   *
   * @param Item snapshotItem
   *        An item inserted via `SnapshotsListView.addSnapshot`.
   * @param object snapshotActor
   *        The frame snapshot actor received from the backend.
   * @param object snapshotOverview
   *        Additional data about the snapshot received from the backend.
   */
  customizeSnapshot: function(snapshotItem, snapshotActor, snapshotOverview) {
    // Make sure the function call actors are stored on the item,
    // to be used when populating the CallsListView.
    snapshotItem.attachment.actor = snapshotActor;
    let functionCalls = snapshotItem.attachment.calls = snapshotOverview.calls;
    let thumbnails = snapshotItem.attachment.thumbnails = snapshotOverview.thumbnails;
    let screenshot = snapshotItem.attachment.screenshot = snapshotOverview.screenshot;

    let lastThumbnail = thumbnails[thumbnails.length - 1];
    let { width, height, flipped, pixels } = lastThumbnail;

    let thumbnailNode = $(".snapshot-item-thumbnail", snapshotItem.target);
    thumbnailNode.setAttribute("flipped", flipped);
    drawImage(thumbnailNode, width, height, pixels, { centered: true });

    let callsNode = $(".snapshot-item-calls", snapshotItem.target);
    let drawCalls = functionCalls.filter(e => CanvasFront.DRAW_CALLS.has(e.name));

    let drawCallsStr = PluralForm.get(drawCalls.length,
      L10N.getStr("snapshotsList.drawCallsLabel"));
    let funcCallsStr = PluralForm.get(functionCalls.length,
      L10N.getStr("snapshotsList.functionCallsLabel"));

    callsNode.setAttribute("value",
      drawCallsStr.replace("#1", drawCalls.length) + ", " +
      funcCallsStr.replace("#1", functionCalls.length));

    let saveNode = $(".snapshot-item-save", snapshotItem.target);
    saveNode.setAttribute("disabled", !!snapshotItem.isLoadedFromDisk);
    saveNode.setAttribute("value", snapshotItem.isLoadedFromDisk
      ? L10N.getStr("snapshotsList.loadedLabel")
      : L10N.getStr("snapshotsList.saveLabel"));

    // Make sure there's always a selected item available.
    if (!this.selectedItem) {
      this.selectedIndex = 0;
    }
  },

  /**
   * The select listener for this container.
   */
  _onSelect: function({ detail: snapshotItem }) {
    if (!snapshotItem) {
      return;
    }
    let { calls, thumbnails, screenshot } = snapshotItem.attachment;

    $("#reload-notice").hidden = true;
    $("#empty-notice").hidden = true;
    $("#import-notice").hidden = false;

    $("#debugging-pane-contents").hidden = true;
    $("#screenshot-container").hidden = true;
    $("#snapshot-filmstrip").hidden = true;

    Task.spawn(function*() {
      // Wait for a few milliseconds between presenting the function calls,
      // screenshot and thumbnails, to allow each component being
      // sequentially drawn. This gives the illusion of snappiness.

      yield DevToolsUtils.waitForTime(SNAPSHOT_DATA_DISPLAY_DELAY);
      CallsListView.showCalls(calls);
      $("#debugging-pane-contents").hidden = false;
      $("#import-notice").hidden = true;

      yield DevToolsUtils.waitForTime(SNAPSHOT_DATA_DISPLAY_DELAY);
      CallsListView.showThumbnails(thumbnails);
      $("#snapshot-filmstrip").hidden = false;

      yield DevToolsUtils.waitForTime(SNAPSHOT_DATA_DISPLAY_DELAY);
      CallsListView.showScreenshot(screenshot);
      $("#screenshot-container").hidden = false;

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

  /**
   * The click listener for the "clear" button in this container.
   */
  _onClearButtonClick: function() {
    Task.spawn(function*() {
      SnapshotsListView.empty();
      CallsListView.empty();

      $("#reload-notice").hidden = true;
      $("#empty-notice").hidden = true;
      $("#import-notice").hidden = true;

      if (yield gFront.isInitialized()) {
        $("#empty-notice").hidden = false;
      } else {
        $("#reload-notice").hidden = false;
      }

      $("#debugging-pane-contents").hidden = true;
      $("#screenshot-container").hidden = true;
      $("#snapshot-filmstrip").hidden = true;

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

  /**
   * The click listener for the "record" button in this container.
   */
  _onRecordButtonClick: function() {
    Task.spawn(function*() {
      $("#record-snapshot").setAttribute("checked", "true");
      $("#record-snapshot").setAttribute("disabled", "true");

      // Insert a "dummy" snapshot item in the view, to hint that recording
      // has now started. However, wait for a few milliseconds before actually
      // starting the recording, since that might block rendering and prevent
      // the dummy snapshot item from being drawn.
      let snapshotItem = this.addSnapshot();

      // If this is the first item, immediately show the "Loading…" notice.
      if (this.itemCount == 1) {
        $("#empty-notice").hidden = true;
        $("#import-notice").hidden = false;
      }

      yield DevToolsUtils.waitForTime(SNAPSHOT_START_RECORDING_DELAY);
      window.emit(EVENTS.SNAPSHOT_RECORDING_STARTED);

      let snapshotActor = yield gFront.recordAnimationFrame();
      let snapshotOverview = yield snapshotActor.getOverview();
      this.customizeSnapshot(snapshotItem, snapshotActor, snapshotOverview);

      $("#record-snapshot").removeAttribute("checked");
      $("#record-snapshot").removeAttribute("disabled");

      window.emit(EVENTS.SNAPSHOT_RECORDING_FINISHED);
    }.bind(this));
  },

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

    if (fp.show() != Ci.nsIFilePicker.returnOK) {
      return;
    }

    let channel = NetUtil.newChannel2(fp.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)) {
        console.error("Could not import recorded animation frame snapshot file.");
        return;
      }
      try {
        let string = NetUtil.readInputStreamToString(inputStream, inputStream.available());
        var data = JSON.parse(string);
      } catch (e) {
        console.error("Could not read animation frame snapshot file.");
        return;
      }
      if (data.fileType != CALLS_LIST_SERIALIZER_IDENTIFIER) {
        console.error("Unrecognized animation frame snapshot file.");
        return;
      }

      // Add a `isLoadedFromDisk` flag on everything to avoid sending invalid
      // requests to the backend, since we're not dealing with actors anymore.
      let snapshotItem = this.addSnapshot();
      snapshotItem.isLoadedFromDisk = true;
      data.calls.forEach(e => e.isLoadedFromDisk = true);

      this.customizeSnapshot(snapshotItem, data.calls, data);
    });
  },

  /**
   * The click listener for the "save" button of each item in this container.
   */
  _onSaveButtonClick: function(e) {
    let snapshotItem = this.getItemForElement(e.target);

    let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
    fp.init(window, L10N.getStr("snapshotsList.saveDialogTitle"), Ci.nsIFilePicker.modeSave);
    fp.appendFilter(L10N.getStr("snapshotsList.saveDialogJSONFilter"), "*.json");
    fp.appendFilter(L10N.getStr("snapshotsList.saveDialogAllFilter"), "*.*");
    fp.defaultString = "snapshot.json";

    // Start serializing all the function call actors for the specified snapshot,
    // while the nsIFilePicker dialog is being opened. Snappy.
    let serialized = Task.spawn(function*() {
      let data = {
        fileType: CALLS_LIST_SERIALIZER_IDENTIFIER,
        version: CALLS_LIST_SERIALIZER_VERSION,
        calls: [],
        thumbnails: [],
        screenshot: null
      };
      let functionCalls = snapshotItem.attachment.calls;
      let thumbnails = snapshotItem.attachment.thumbnails;
      let screenshot = snapshotItem.attachment.screenshot;

      // Prepare all the function calls for serialization.
      yield DevToolsUtils.yieldingEach(functionCalls, (call, i) => {
        let { type, name, file, line, argsPreview, callerPreview } = call;
        return call.getDetails().then(({ stack }) => {
          data.calls[i] = {
            type: type,
            name: name,
            file: file,
            line: line,
            stack: stack,
            argsPreview: argsPreview,
            callerPreview: callerPreview
          };
        });
      });

      // Prepare all the thumbnails for serialization.
      yield DevToolsUtils.yieldingEach(thumbnails, (thumbnail, i) => {
        let { index, width, height, flipped, pixels } = thumbnail;
        data.thumbnails.push({ index, width, height, flipped, pixels });
      });

      // Prepare the screenshot for serialization.
      let { index, width, height, flipped, pixels } = screenshot;
      data.screenshot = { index, width, height, flipped, pixels };

      let string = JSON.stringify(data);
      let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"].
        createInstance(Ci.nsIScriptableUnicodeConverter);

      converter.charset = "UTF-8";
      return converter.convertToInputStream(string);
    });

    // Open the nsIFilePicker and wait for the function call actors to finish
    // being serialized, in order to save the generated JSON data to disk.
    fp.open({ done: result => {
      if (result == Ci.nsIFilePicker.returnCancel) {
        return;
      }
      let footer = $(".snapshot-item-footer", snapshotItem.target);
      let save = $(".snapshot-item-save", snapshotItem.target);

      // Show a throbber and a "Saving…" label if serializing isn't immediate.
      setNamedTimeout("call-list-save", CALLS_LIST_SLOW_SAVE_DELAY, () => {
        footer.classList.add("devtools-throbber");
        save.setAttribute("disabled", "true");
        save.setAttribute("value", L10N.getStr("snapshotsList.savingLabel"));
      });

      serialized.then(inputStream => {
        let outputStream = FileUtils.openSafeFileOutputStream(fp.file);

        NetUtil.asyncCopy(inputStream, outputStream, status => {
          if (!Components.isSuccessCode(status)) {
            console.error("Could not save recorded animation frame snapshot file.");
          }
          clearNamedTimeout("call-list-save");
          footer.classList.remove("devtools-throbber");
          save.removeAttribute("disabled");
          save.setAttribute("value", L10N.getStr("snapshotsList.saveLabel"));
        });
      });
    }});
  }
});

/**
 * Functions handling details about a single recorded animation frame snapshot
 * (the calls list, rendering preview, thumbnails filmstrip etc.).
 */
let CallsListView = Heritage.extend(WidgetMethods, {
  /**
   * Initialization function, called when the tool is started.
   */
  initialize: function() {
    this.widget = new SideMenuWidget($("#calls-list"));
    this._slider = $("#calls-slider");
    this._searchbox = $("#calls-searchbox");
    this._filmstrip = $("#snapshot-filmstrip");

    this._onSelect = this._onSelect.bind(this);
    this._onSlideMouseDown = this._onSlideMouseDown.bind(this);
    this._onSlideMouseUp = this._onSlideMouseUp.bind(this);
    this._onSlide = this._onSlide.bind(this);
    this._onSearch = this._onSearch.bind(this);
    this._onScroll = this._onScroll.bind(this);
    this._onExpand = this._onExpand.bind(this);
    this._onStackFileClick = this._onStackFileClick.bind(this);
    this._onThumbnailClick = this._onThumbnailClick.bind(this);

    this.widget.addEventListener("select", this._onSelect, false);
    this._slider.addEventListener("mousedown", this._onSlideMouseDown, false);
    this._slider.addEventListener("mouseup", this._onSlideMouseUp, false);
    this._slider.addEventListener("change", this._onSlide, false);
    this._searchbox.addEventListener("input", this._onSearch, false);
    this._filmstrip.addEventListener("wheel", this._onScroll, false);
  },

  /**
   * Destruction function, called when the tool is closed.
   */
  destroy: function() {
    this.widget.removeEventListener("select", this._onSelect, false);
    this._slider.removeEventListener("mousedown", this._onSlideMouseDown, false);
    this._slider.removeEventListener("mouseup", this._onSlideMouseUp, false);
    this._slider.removeEventListener("change", this._onSlide, false);
    this._searchbox.removeEventListener("input", this._onSearch, false);
    this._filmstrip.removeEventListener("wheel", this._onScroll, false);
  },

  /**
   * Populates this container with a list of function calls.
   *
   * @param array functionCalls
   *        A list of function call actors received from the backend.
   */
  showCalls: function(functionCalls) {
    this.empty();

    for (let i = 0, len = functionCalls.length; i < len; i++) {
      let call = functionCalls[i];

      let view = document.createElement("vbox");
      view.className = "call-item-view devtools-monospace";
      view.setAttribute("flex", "1");

      let contents = document.createElement("hbox");
      contents.className = "call-item-contents";
      contents.setAttribute("align", "center");
      contents.addEventListener("dblclick", this._onExpand);
      view.appendChild(contents);

      let index = document.createElement("label");
      index.className = "plain call-item-index";
      index.setAttribute("flex", "1");
      index.setAttribute("value", i + 1);

      let gutter = document.createElement("hbox");
      gutter.className = "call-item-gutter";
      gutter.appendChild(index);
      contents.appendChild(gutter);

      // Not all function calls have a caller that was stringified (e.g.
      // context calls have a "gl" or "ctx" caller preview).
      if (call.callerPreview) {
        let context = document.createElement("label");
        context.className = "plain call-item-context";
        context.setAttribute("value", call.callerPreview);
        contents.appendChild(context);

        let separator = document.createElement("label");
        separator.className = "plain call-item-separator";
        separator.setAttribute("value", ".");
        contents.appendChild(separator);
      }

      let name = document.createElement("label");
      name.className = "plain call-item-name";
      name.setAttribute("value", call.name);
      contents.appendChild(name);

      let argsPreview = document.createElement("label");
      argsPreview.className = "plain call-item-args";
      argsPreview.setAttribute("crop", "end");
      argsPreview.setAttribute("flex", "100");
      // Getters and setters are displayed differently from regular methods.
      if (call.type == CallWatcherFront.METHOD_FUNCTION) {
        argsPreview.setAttribute("value", "(" + call.argsPreview + ")");
      } else {
        argsPreview.setAttribute("value", " = " + call.argsPreview);
      }
      contents.appendChild(argsPreview);

      let location = document.createElement("label");
      location.className = "plain call-item-location";
      location.setAttribute("value", getFileName(call.file) + ":" + call.line);
      location.setAttribute("crop", "start");
      location.setAttribute("flex", "1");
      location.addEventListener("mousedown", this._onExpand);
      contents.appendChild(location);

      // Append a function call item to this container.
      this.push([view], {
        staged: true,
        attachment: {
          actor: call
        }
      });

      // Highlight certain calls that are probably more interesting than
      // everything else, making it easier to quickly glance over them.
      if (CanvasFront.DRAW_CALLS.has(call.name)) {
        view.setAttribute("draw-call", "");
      }
      if (CanvasFront.INTERESTING_CALLS.has(call.name)) {
        view.setAttribute("interesting-call", "");
      }
    }

    // Flushes all the prepared function call items into this container.
    this.commit();
    window.emit(EVENTS.CALL_LIST_POPULATED);

    // Resetting the function selection slider's value (shown in this
    // container's toolbar) would trigger a selection event, which should be
    // ignored in this case.
    this._ignoreSliderChanges = true;
    this._slider.value = 0;
    this._slider.max = functionCalls.length - 1;
    this._ignoreSliderChanges = false;
  },

  /**
   * Displays an image in the rendering preview of this container, generated
   * for the specified draw call in the recorded animation frame snapshot.
   *
   * @param array screenshot
   *        A single "snapshot-image" instance received from the backend.
   */
  showScreenshot: function(screenshot) {
    let { index, width, height, scaling, flipped, pixels } = screenshot;

    let screenshotNode = $("#screenshot-image");
    screenshotNode.setAttribute("flipped", flipped);
    drawBackground("screenshot-rendering", width, height, pixels);

    let dimensionsNode = $("#screenshot-dimensions");
    let actualWidth = (width / scaling) | 0;
    let actualHeight = (height / scaling) | 0;
    dimensionsNode.setAttribute("value",
      SHARED_L10N.getFormatStr("dimensions", actualWidth, actualHeight));

    window.emit(EVENTS.CALL_SCREENSHOT_DISPLAYED);
  },

  /**
   * Populates this container's footer with a list of thumbnails, one generated
   * for each draw call in the recorded animation frame snapshot.
   *
   * @param array thumbnails
   *        An array of "snapshot-image" instances received from the backend.
   */
  showThumbnails: function(thumbnails) {
    while (this._filmstrip.hasChildNodes()) {
      this._filmstrip.firstChild.remove();
    }
    for (let thumbnail of thumbnails) {
      this.appendThumbnail(thumbnail);
    }

    window.emit(EVENTS.THUMBNAILS_DISPLAYED);
  },

  /**
   * Displays an image in the thumbnails list of this container, generated
   * for the specified draw call in the recorded animation frame snapshot.
   *
   * @param array thumbnail
   *        A single "snapshot-image" instance received from the backend.
   */
  appendThumbnail: function(thumbnail) {
    let { index, width, height, flipped, pixels } = thumbnail;

    let thumbnailNode = document.createElementNS(HTML_NS, "canvas");
    thumbnailNode.setAttribute("flipped", flipped);
    thumbnailNode.width = Math.max(CanvasFront.THUMBNAIL_SIZE, width);
    thumbnailNode.height = Math.max(CanvasFront.THUMBNAIL_SIZE, height);
    drawImage(thumbnailNode, width, height, pixels, { centered: true });

    thumbnailNode.className = "filmstrip-thumbnail";
    thumbnailNode.onmousedown = e => this._onThumbnailClick(e, index);
    thumbnailNode.setAttribute("index", index);
    this._filmstrip.appendChild(thumbnailNode);
  },

  /**
   * Sets the currently highlighted thumbnail in this container.
   * A screenshot will always correlate to a thumbnail in the filmstrip,
   * both being identified by the same 'index' of the context function call.
   *
   * @param number index
   *        The context function call's index.
   */
  set highlightedThumbnail(index) {
    let currHighlightedThumbnail = $(".filmstrip-thumbnail[index='" + index + "']");
    if (currHighlightedThumbnail == null) {
      return;
    }

    let prevIndex = this._highlightedThumbnailIndex
    let prevHighlightedThumbnail = $(".filmstrip-thumbnail[index='" + prevIndex + "']");
    if (prevHighlightedThumbnail) {
      prevHighlightedThumbnail.removeAttribute("highlighted");
    }

    currHighlightedThumbnail.setAttribute("highlighted", "");
    currHighlightedThumbnail.scrollIntoView();
    this._highlightedThumbnailIndex = index;
  },

  /**
   * Gets the currently highlighted thumbnail in this container.
   * @return number
   */
  get highlightedThumbnail() {
    return this._highlightedThumbnailIndex;
  },

  /**
   * The select listener for this container.
   */
  _onSelect: function({ detail: callItem }) {
    if (!callItem) {
      return;
    }

    // Some of the stepping buttons don't make sense specifically while the
    // last function call is selected.
    if (this.selectedIndex == this.itemCount - 1) {
      $("#resume").setAttribute("disabled", "true");
      $("#step-over").setAttribute("disabled", "true");
      $("#step-out").setAttribute("disabled", "true");
    } else {
      $("#resume").removeAttribute("disabled");
      $("#step-over").removeAttribute("disabled");
      $("#step-out").removeAttribute("disabled");
    }

    // Correlate the currently selected item with the function selection
    // slider's value. Avoid triggering a redundant selection event.
    this._ignoreSliderChanges = true;
    this._slider.value = this.selectedIndex;
    this._ignoreSliderChanges = false;

    // Can't generate screenshots for function call actors loaded from disk.
    // XXX: Bug 984844.
    if (callItem.attachment.actor.isLoadedFromDisk) {
      return;
    }

    // To keep continuous selection buttery smooth (for example, while pressing
    // the DOWN key or moving the slider), only display the screenshot after
    // any kind of user input stops.
    setConditionalTimeout("screenshot-display", SCREENSHOT_DISPLAY_DELAY, () => {
      return !this._isSliding;
    }, () => {
      let frameSnapshot = SnapshotsListView.selectedItem.attachment.actor
      let functionCall = callItem.attachment.actor;
      frameSnapshot.generateScreenshotFor(functionCall).then(screenshot => {
        this.showScreenshot(screenshot);
        this.highlightedThumbnail = screenshot.index;
      }).catch(Cu.reportError);
    });
  },

  /**
   * The mousedown listener for the call selection slider.
   */
  _onSlideMouseDown: function() {
    this._isSliding = true;
  },

  /**
   * The mouseup listener for the call selection slider.
   */
  _onSlideMouseUp: function() {
    this._isSliding = false;
  },

  /**
   * The change listener for the call selection slider.
   */
  _onSlide: function() {
    // Avoid performing any operations when programatically changing the value.
    if (this._ignoreSliderChanges) {
      return;
    }
    let selectedFunctionCallIndex = this.selectedIndex = this._slider.value;

    // While sliding, immediately show the most relevant thumbnail for a
    // function call, for a nice diff-like animation effect between draws.
    let thumbnails = SnapshotsListView.selectedItem.attachment.thumbnails;
    let thumbnail = getThumbnailForCall(thumbnails, selectedFunctionCallIndex);

    // Avoid drawing and highlighting if the selected function call has the
    // same thumbnail as the last one.
    if (thumbnail.index == this.highlightedThumbnail) {
      return;
    }
    // If a thumbnail wasn't found (e.g. the backend avoids creating thumbnails
    // when rendering offscreen), simply defer to the first available one.
    if (thumbnail.index == -1) {
      thumbnail = thumbnails[0];
    }

    let { index, width, height, flipped, pixels } = thumbnail;
    this.highlightedThumbnail = index;

    let screenshotNode = $("#screenshot-image");
    screenshotNode.setAttribute("flipped", flipped);
    drawBackground("screenshot-rendering", width, height, pixels);
  },

  /**
   * The input listener for the calls searchbox.
   */
  _onSearch: function(e) {
    let lowerCaseSearchToken = this._searchbox.value.toLowerCase();

    this.filterContents(e => {
      let call = e.attachment.actor;
      let name = call.name.toLowerCase();
      let file = call.file.toLowerCase();
      let line = call.line.toString().toLowerCase();
      let args = call.argsPreview.toLowerCase();

      return name.contains(lowerCaseSearchToken) ||
             file.contains(lowerCaseSearchToken) ||
             line.contains(lowerCaseSearchToken) ||
             args.contains(lowerCaseSearchToken);
    });
  },

  /**
   * The wheel listener for the filmstrip that contains all the thumbnails.
   */
  _onScroll: function(e) {
    this._filmstrip.scrollLeft += e.deltaX;
  },

  /**
   * The click/dblclick listener for an item or location url in this container.
   * When expanding an item, it's corresponding call stack will be displayed.
   */
  _onExpand: function(e) {
    let callItem = this.getItemForElement(e.target);
    let view = $(".call-item-view", callItem.target);

    // If the call stack nodes were already created, simply re-show them
    // or jump to the corresponding file and line in the Debugger if a
    // location link was clicked.
    if (view.hasAttribute("call-stack-populated")) {
      let isExpanded = view.getAttribute("call-stack-expanded") == "true";

      // If clicking on the location, jump to the Debugger.
      if (e.target.classList.contains("call-item-location")) {
        let { file, line } = callItem.attachment.actor;
        viewSourceInDebugger(file, line);
        return;
      }
      // Otherwise hide the call stack.
      else {
        view.setAttribute("call-stack-expanded", !isExpanded);
        $(".call-item-stack", view).hidden = isExpanded;
        return;
      }
    }

    let list = document.createElement("vbox");
    list.className = "call-item-stack";
    view.setAttribute("call-stack-populated", "");
    view.setAttribute("call-stack-expanded", "true");
    view.appendChild(list);

    /**
     * Creates a function call nodes in this container for a stack.
     */
    let display = stack => {
      for (let i = 1; i < stack.length; i++) {
        let call = stack[i];

        let contents = document.createElement("hbox");
        contents.className = "call-item-stack-fn";
        contents.style.MozPaddingStart = (i * STACK_FUNC_INDENTATION) + "px";

        let name = document.createElement("label");
        name.className = "plain call-item-stack-fn-name";
        name.setAttribute("value", "↳ " + call.name + "()");
        contents.appendChild(name);

        let spacer = document.createElement("spacer");
        spacer.setAttribute("flex", "100");
        contents.appendChild(spacer);

        let location = document.createElement("label");
        location.className = "plain call-item-stack-fn-location";
        location.setAttribute("value", getFileName(call.file) + ":" + call.line);
        location.setAttribute("crop", "start");
        location.setAttribute("flex", "1");
        location.addEventListener("mousedown", e => this._onStackFileClick(e, call));
        contents.appendChild(location);

        list.appendChild(contents);
      }

      window.emit(EVENTS.CALL_STACK_DISPLAYED);
    };

    // If this animation snapshot is loaded from disk, there are no corresponding
    // backend actors available and the data is immediately available.
    let functionCall = callItem.attachment.actor;
    if (functionCall.isLoadedFromDisk) {
      display(functionCall.stack);
    }
    // ..otherwise we need to request the function call stack from the backend.
    else {
      callItem.attachment.actor.getDetails().then(fn => display(fn.stack));
    }
  },

  /**
   * The click listener for a location link in the call stack.
   *
   * @param string file
   *        The url of the source owning the function.
   * @param number line
   *        The line of the respective function.
   */
  _onStackFileClick: function(e, { file, line }) {
    viewSourceInDebugger(file, line);
  },

  /**
   * The click listener for a thumbnail in the filmstrip.
   *
   * @param number index
   *        The function index in the recorded animation frame snapshot.
   */
  _onThumbnailClick: function(e, index) {
    this.selectedIndex = index;
  },

  /**
   * The click listener for the "resume" button in this container's toolbar.
   */
  _onResume: function() {
    // Jump to the next draw call in the recorded animation frame snapshot.
    let drawCall = getNextDrawCall(this.items, this.selectedItem);
    if (drawCall) {
      this.selectedItem = drawCall;
      return;
    }

    // If there are no more draw calls, just jump to the last context call.
    this._onStepOut();
  },

  /**
   * The click listener for the "step over" button in this container's toolbar.
   */
  _onStepOver: function() {
    this.selectedIndex++;
  },

  /**
   * The click listener for the "step in" button in this container's toolbar.
   */
  _onStepIn: function() {
    if (this.selectedIndex == -1) {
      this._onResume();
      return;
    }
    let callItem = this.selectedItem;
    let { file, line } = callItem.attachment.actor;
    viewSourceInDebugger(file, line);
  },

  /**
   * The click listener for the "step out" button in this container's toolbar.
   */
  _onStepOut: function() {
    this.selectedIndex = this.itemCount - 1;
  }
});

/**
 * Localization convenience methods.
 */
let L10N = new ViewHelpers.L10N(STRINGS_URI);
let SHARED_L10N = new ViewHelpers.L10N(SHARED_STRINGS_URI);

/**
 * Convenient way of emitting events from the panel window.
 */
EventEmitter.decorate(this);

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

/**
 * Helper for getting an nsIURL instance out of a string.
 */
function nsIURL(url, store = nsIURL.store) {
  if (store.has(url)) {
    return store.get(url);
  }
  let uri = Services.io.newURI(url, null, null).QueryInterface(Ci.nsIURL);
  store.set(url, uri);
  return uri;
}

// The cache used in the `nsIURL` function.
nsIURL.store = new Map();

/**
 * Gets the fileName part of a string which happens to be an URL.
 */
function getFileName(url) {
  try {
    let { fileName } = nsIURL(url);
    return fileName || "/";
  } catch (e) {
    // This doesn't look like a url, or nsIURL can't handle it.
    return "";
  }
}

/**
 * Gets an image data object containing a buffer large enough to hold
 * width * height pixels.
 *
 * This method avoids allocating memory and tries to reuse a common buffer
 * as much as possible.
 *
 * @param number w
 *        The desired image data storage width.
 * @param number h
 *        The desired image data storage height.
 * @return ImageData
 *         The requested image data buffer.
 */
function getImageDataStorage(ctx, w, h) {
  let storage = getImageDataStorage.cache;
  if (storage && storage.width == w && storage.height == h) {
    return storage;
  }
  return getImageDataStorage.cache = ctx.createImageData(w, h);
}

// The cache used in the `getImageDataStorage` function.
getImageDataStorage.cache = null;

/**
 * Draws image data into a canvas.
 *
 * This method makes absolutely no assumptions about the canvas element
 * dimensions, or pre-existing rendering. It's a dumb proxy that copies pixels.
 *
 * @param HTMLCanvasElement canvas
 *        The canvas element to put the image data into.
 * @param number width
 *        The image data width.
 * @param number height
 *        The image data height.
 * @param array pixels
 *        An array buffer view of the image data.
 * @param object options
 *        Additional options supported by this operation:
 *          - centered: specifies whether the image data should be centered
 *                      when copied in the canvas; this is useful when the
 *                      supplied pixels don't completely cover the canvas.
 */
function drawImage(canvas, width, height, pixels, options = {}) {
  let ctx = canvas.getContext("2d");

  // FrameSnapshot actors return "snapshot-image" type instances with just an
  // empty pixel array if the source image is completely transparent.
  if (pixels.length <= 1) {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    return;
  }

  let imageData = getImageDataStorage(ctx, width, height);
  imageData.data.set(pixels);

  if (options.centered) {
    let left = (canvas.width - width) / 2;
    let top = (canvas.height - height) / 2;
    ctx.putImageData(imageData, left, top);
  } else {
    ctx.putImageData(imageData, 0, 0);
  }
}

/**
 * Draws image data into a canvas, and sets that as the rendering source for
 * an element with the specified id as the -moz-element background image.
 *
 * @param string id
 *        The id of the -moz-element background image.
 * @param number width
 *        The image data width.
 * @param number height
 *        The image data height.
 * @param array pixels
 *        An array buffer view of the image data.
 */
function drawBackground(id, width, height, pixels) {
  let canvas = document.createElementNS(HTML_NS, "canvas");
  canvas.width = width;
  canvas.height = height;

  drawImage(canvas, width, height, pixels);
  document.mozSetImageElement(id, canvas);

  // Used in tests. Not emitting an event because this shouldn't be "interesting".
  if (window._onMozSetImageElement) {
    window._onMozSetImageElement(pixels);
  }
}

/**
 * Iterates forward to find the next draw call in a snapshot.
 */
function getNextDrawCall(calls, call) {
  for (let i = calls.indexOf(call) + 1, len = calls.length; i < len; i++) {
    let nextCall = calls[i];
    let name = nextCall.attachment.actor.name;
    if (CanvasFront.DRAW_CALLS.has(name)) {
      return nextCall;
    }
  }
  return null;
}

/**
 * Iterates backwards to find the most recent screenshot for a function call
 * in a snapshot loaded from disk.
 */
function getScreenshotFromCallLoadedFromDisk(calls, call) {
  for (let i = calls.indexOf(call); i >= 0; i--) {
    let prevCall = calls[i];
    let screenshot = prevCall.screenshot;
    if (screenshot) {
      return screenshot;
    }
  }
  return CanvasFront.INVALID_SNAPSHOT_IMAGE;
}

/**
 * Iterates backwards to find the most recent thumbnail for a function call.
 */
function getThumbnailForCall(thumbnails, index) {
  for (let i = thumbnails.length - 1; i >= 0; i--) {
    let thumbnail = thumbnails[i];
    if (thumbnail.index <= index) {
      return thumbnail;
    }
  }
  return CanvasFront.INVALID_SNAPSHOT_IMAGE;
}

/**
 * Opens/selects the debugger in this toolbox and jumps to the specified
 * file name and line number.
 */
function viewSourceInDebugger(url, line) {
  let showSource = ({ DebuggerView }) => {
    let item = DebuggerView.Sources.getItemForAttachment(a => a.source.url === url);
    if (item) {
      DebuggerView.setEditorLocation(item.attachment.source.actor, line, { noDebug: true }).then(() => {
        window.emit(EVENTS.SOURCE_SHOWN_IN_JS_DEBUGGER);
      }, () => {
        window.emit(EVENTS.SOURCE_NOT_FOUND_IN_JS_DEBUGGER);
      });
    }
  }

  // If the Debugger was already open, switch to it and try to show the
  // source immediately. Otherwise, initialize it and wait for the sources
  // to be added first.
  let debuggerAlreadyOpen = gToolbox.getPanel("jsdebugger");
  gToolbox.selectTool("jsdebugger").then(({ panelWin: dbg }) => {
    if (debuggerAlreadyOpen) {
      showSource(dbg);
    } else {
      dbg.once(dbg.EVENTS.SOURCES_ADDED, () => showSource(dbg));
    }
  });
}
