/* 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/. */
/* import-globals-from canvasdebugger.js */
/* globals window, document */
"use strict";

/**
 * Functions handling details about a single recorded animation frame snapshot
 * (the calls list, rendering preview, thumbnails filmstrip etc.).
 */
var 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);

      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(e => console.error(e));
    });
  },

  /**
   * 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.includes(lowerCaseSearchToken) ||
             file.includes(lowerCaseSearchToken) ||
             line.includes(lowerCaseSearchToken) ||
             args.includes(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;
        this._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.paddingInlineStart = (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 }) {
    this._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;
    this._viewSourceInDebugger(file, line);
  },

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

  /**
   * Opens the specified file and line in the debugger. Falls back to Firefox's View Source.
   */
  _viewSourceInDebugger: function (file, line) {
    gToolbox.viewSourceInDebugger(file, line).then(success => {
      if (success) {
        window.emit(EVENTS.SOURCE_SHOWN_IN_JS_DEBUGGER);
      } else {
        window.emit(EVENTS.SOURCE_NOT_FOUND_IN_JS_DEBUGGER);
      }
    });
  }
});