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

/* import-globals-from ../../debugger-controller.js */

const utils = require("../utils");
const {
  getSelectedSource,
  getSourceByURL,
  getBreakpoint,
  getBreakpoints,
  makeLocationId
} = require("../queries");
const actions = Object.assign(
  {},
  require("../actions/sources"),
  require("../actions/breakpoints")
);
const { bindActionCreators } = require("devtools/client/shared/vendor/redux");
const {
  Heritage,
  WidgetMethods,
  setNamedTimeout
} = require("devtools/client/shared/widgets/view-helpers");
const { Task } = require("devtools/shared/task");
const { SideMenuWidget } = require("resource://devtools/client/shared/widgets/SideMenuWidget.jsm");
const { gDevTools } = require("devtools/client/framework/devtools");
const {KeyCodes} = require("devtools/client/shared/keycodes");

const NEW_SOURCE_DISPLAY_DELAY = 200; // ms
const FUNCTION_SEARCH_POPUP_POSITION = "topcenter bottomleft";
const BREAKPOINT_LINE_TOOLTIP_MAX_LENGTH = 1000; // chars
const BREAKPOINT_CONDITIONAL_POPUP_POSITION = "before_start";
const BREAKPOINT_CONDITIONAL_POPUP_OFFSET_X = 7; // px
const BREAKPOINT_CONDITIONAL_POPUP_OFFSET_Y = -3; // px

/**
 * Functions handling the sources UI.
 */
function SourcesView(controller, DebuggerView) {
  dumpn("SourcesView was instantiated");

  utils.onReducerEvents(controller, {
    "source": this.renderSource,
    "blackboxed": this.renderBlackBoxed,
    "prettyprinted": this.updateToolbarButtonsState,
    "source-selected": this.renderSourceSelected,
    "breakpoint-updated": bp => this.renderBreakpoint(bp),
    "breakpoint-enabled": bp => this.renderBreakpoint(bp),
    "breakpoint-disabled": bp => this.renderBreakpoint(bp),
    "breakpoint-removed": bp => this.renderBreakpoint(bp, true),
  }, this);

  this.getState = controller.getState;
  this.actions = bindActionCreators(actions, controller.dispatch);
  this.DebuggerView = DebuggerView;
  this.Parser = DebuggerController.Parser;

  this.togglePrettyPrint = this.togglePrettyPrint.bind(this);
  this.toggleBlackBoxing = this.toggleBlackBoxing.bind(this);
  this.toggleBreakpoints = this.toggleBreakpoints.bind(this);

  this._onEditorCursorActivity = this._onEditorCursorActivity.bind(this);
  this._onMouseDown = this._onMouseDown.bind(this);
  this._onSourceSelect = this._onSourceSelect.bind(this);
  this._onStopBlackBoxing = this._onStopBlackBoxing.bind(this);
  this._onBreakpointRemoved = this._onBreakpointRemoved.bind(this);
  this._onBreakpointClick = this._onBreakpointClick.bind(this);
  this._onBreakpointCheckboxClick = this._onBreakpointCheckboxClick.bind(this);
  this._onConditionalPopupShowing = this._onConditionalPopupShowing.bind(this);
  this._onConditionalPopupShown = this._onConditionalPopupShown.bind(this);
  this._onConditionalPopupHiding = this._onConditionalPopupHiding.bind(this);
  this._onConditionalTextboxKeyPress = this._onConditionalTextboxKeyPress.bind(this);
  this._onEditorContextMenuOpen = this._onEditorContextMenuOpen.bind(this);
  this._onCopyUrlCommand = this._onCopyUrlCommand.bind(this);
  this._onNewTabCommand = this._onNewTabCommand.bind(this);
  this._onConditionalPopupHidden = this._onConditionalPopupHidden.bind(this);
}

SourcesView.prototype = Heritage.extend(WidgetMethods, {
  /**
   * Initialization function, called when the debugger is started.
   */
  initialize: function (isWorker) {
    dumpn("Initializing the SourcesView");

    this.widget = new SideMenuWidget(document.getElementById("sources"), {
      contextMenu: document.getElementById("debuggerSourcesContextMenu"),
      showArrows: true
    });

    this._preferredSourceURL = null;
    this._unnamedSourceIndex = 0;
    this.emptyText = L10N.getStr("noSourcesText");
    this._blackBoxCheckboxTooltip = L10N.getStr("blackBoxCheckboxTooltip");

    this._commandset = document.getElementById("debuggerCommands");
    this._popupset = document.getElementById("debuggerPopupset");
    this._cmPopup = document.getElementById("sourceEditorContextMenu");
    this._cbPanel = document.getElementById("conditional-breakpoint-panel");
    this._cbTextbox = document.getElementById("conditional-breakpoint-panel-textbox");
    this._blackBoxButton = document.getElementById("black-box");
    this._stopBlackBoxButton = document.getElementById("black-boxed-message-button");
    this._prettyPrintButton = document.getElementById("pretty-print");
    this._toggleBreakpointsButton = document.getElementById("toggle-breakpoints");
    this._newTabMenuItem = document.getElementById("debugger-sources-context-newtab");
    this._copyUrlMenuItem = document.getElementById("debugger-sources-context-copyurl");

    this._noResultsFoundToolTip = new Tooltip(document);
    this._noResultsFoundToolTip.defaultPosition = FUNCTION_SEARCH_POPUP_POSITION;

    // We don't show the pretty print button if debugger a worker
    // because it simply doesn't work yet. (bug 1273730)
    if (Prefs.prettyPrintEnabled && !isWorker) {
      this._prettyPrintButton.removeAttribute("hidden");
    }

    this._editorContainer = document.getElementById("editor");
    this._editorContainer.addEventListener("mousedown", this._onMouseDown, false);

    this.widget.addEventListener("select", this._onSourceSelect, false);

    this._stopBlackBoxButton.addEventListener("click", this._onStopBlackBoxing, false);
    this._cbPanel.addEventListener("popupshowing", this._onConditionalPopupShowing, false);
    this._cbPanel.addEventListener("popupshown", this._onConditionalPopupShown, false);
    this._cbPanel.addEventListener("popuphiding", this._onConditionalPopupHiding, false);
    this._cbPanel.addEventListener("popuphidden", this._onConditionalPopupHidden, false);
    this._cbTextbox.addEventListener("keypress", this._onConditionalTextboxKeyPress, false);
    this._copyUrlMenuItem.addEventListener("command", this._onCopyUrlCommand, false);
    this._newTabMenuItem.addEventListener("command", this._onNewTabCommand, false);

    this._cbPanel.hidden = true;
    this.allowFocusOnRightClick = true;
    this.autoFocusOnSelection = false;
    this.autoFocusOnFirstItem = false;

    // Sort the contents by the displayed label.
    this.sortContents((aFirst, aSecond) => {
      return +(aFirst.attachment.label.toLowerCase() >
               aSecond.attachment.label.toLowerCase());
    });

    // Sort known source groups towards the end of the list
    this.widget.groupSortPredicate = function (a, b) {
      if ((a in KNOWN_SOURCE_GROUPS) == (b in KNOWN_SOURCE_GROUPS)) {
        return a.localeCompare(b);
      }
      return (a in KNOWN_SOURCE_GROUPS) ? 1 : -1;
    };

    this.DebuggerView.editor.on("popupOpen", this._onEditorContextMenuOpen);

    this._addCommands();
  },

  /**
   * Destruction function, called when the debugger is closed.
   */
  destroy: function () {
    dumpn("Destroying the SourcesView");

    this.widget.removeEventListener("select", this._onSourceSelect, false);
    this._stopBlackBoxButton.removeEventListener("click", this._onStopBlackBoxing, false);
    this._cbPanel.removeEventListener("popupshowing", this._onConditionalPopupShowing, false);
    this._cbPanel.removeEventListener("popupshown", this._onConditionalPopupShown, false);
    this._cbPanel.removeEventListener("popuphiding", this._onConditionalPopupHiding, false);
    this._cbPanel.removeEventListener("popuphidden", this._onConditionalPopupHidden, false);
    this._cbTextbox.removeEventListener("keypress", this._onConditionalTextboxKeyPress, false);
    this._copyUrlMenuItem.removeEventListener("command", this._onCopyUrlCommand, false);
    this._newTabMenuItem.removeEventListener("command", this._onNewTabCommand, false);
    this.DebuggerView.editor.off("popupOpen", this._onEditorContextMenuOpen, false);
  },

  empty: function () {
    WidgetMethods.empty.call(this);
    this._unnamedSourceIndex = 0;
    this._selectedBreakpoint = null;
  },

  /**
   * Add commands that XUL can fire.
   */
  _addCommands: function () {
    XULUtils.addCommands(this._commandset, {
      addBreakpointCommand: e => this._onCmdAddBreakpoint(e),
      addConditionalBreakpointCommand: e => this._onCmdAddConditionalBreakpoint(e),
      blackBoxCommand: () => this.toggleBlackBoxing(),
      unBlackBoxButton: () => this._onStopBlackBoxing(),
      prettyPrintCommand: () => this.togglePrettyPrint(),
      toggleBreakpointsCommand: () =>this.toggleBreakpoints(),
      nextSourceCommand: () => this.selectNextItem(),
      prevSourceCommand: () => this.selectPrevItem()
    });
  },

  /**
   * Sets the preferred location to be selected in this sources container.
   * @param string aUrl
   */
  set preferredSource(aUrl) {
    this._preferredValue = aUrl;

    // Selects the element with the specified value in this sources container,
    // if already inserted.
    if (this.containsValue(aUrl)) {
      this.selectedValue = aUrl;
    }
  },

  sourcesDidUpdate: function () {
    if (!getSelectedSource(this.getState())) {
      let url = this._preferredSourceURL;
      let source = url && getSourceByURL(this.getState(), url);
      if (source) {
        this.actions.selectSource(source);
      }
      else {
        setNamedTimeout("new-source", NEW_SOURCE_DISPLAY_DELAY, () => {
          if (!getSelectedSource(this.getState()) && this.itemCount > 0) {
            this.actions.selectSource(this.getItemAtIndex(0).attachment.source);
          }
        });
      }
    }
  },

  renderSource: function (source) {
    this.addSource(source, { staged: false });
    for (let bp of getBreakpoints(this.getState())) {
      if (bp.location.actor === source.actor) {
        this.renderBreakpoint(bp);
      }
    }
    this.sourcesDidUpdate();
  },

  /**
   * Adds a source to this sources container.
   *
   * @param object aSource
   *        The source object coming from the active thread.
   * @param object aOptions [optional]
   *        Additional options for adding the source. Supported options:
   *        - staged: true to stage the item to be appended later
   */
  addSource: function (aSource, aOptions = {}) {
    if (!aSource.url && !aOptions.force) {
      // We don't show any unnamed eval scripts yet (see bug 1124106)
      return;
    }

    let { label, group, unicodeUrl } = this._parseUrl(aSource);

    let contents = document.createElement("label");
    contents.className = "plain dbg-source-item";
    contents.setAttribute("value", label);
    contents.setAttribute("crop", "start");
    contents.setAttribute("flex", "1");
    contents.setAttribute("tooltiptext", unicodeUrl);

    if (aSource.introductionType === "wasm") {
      const wasm = document.createElement("box");
      wasm.className = "dbg-wasm-item";
      const icon = document.createElement("box");
      icon.setAttribute("tooltiptext", L10N.getStr("experimental"));
      icon.className = "icon";
      wasm.appendChild(icon);
      wasm.appendChild(contents);

      contents = wasm;
    }

    // If the source is blackboxed, apply the appropriate style.
    if (gThreadClient.source(aSource).isBlackBoxed) {
      contents.classList.add("black-boxed");
    }

    // Append a source item to this container.
    this.push([contents, aSource.actor], {
      staged: aOptions.staged, /* stage the item to be appended later? */
      attachment: {
        label: label,
        group: group,
        checkboxState: !aSource.isBlackBoxed,
        checkboxTooltip: this._blackBoxCheckboxTooltip,
        source: aSource
      }
    });
  },

  _parseUrl: function (aSource) {
    let fullUrl = aSource.url;
    let url, unicodeUrl, label, group;

    if (!fullUrl) {
      unicodeUrl = "SCRIPT" + this._unnamedSourceIndex++;
      label = unicodeUrl;
      group = L10N.getStr("anonymousSourcesLabel");
    }
    else {
      let url = fullUrl.split(" -> ").pop();
      label = aSource.addonPath ? aSource.addonPath : SourceUtils.getSourceLabel(url);
      group = aSource.addonID ? aSource.addonID : SourceUtils.getSourceGroup(url);
      unicodeUrl = NetworkHelper.convertToUnicode(unescape(fullUrl));
    }

    return {
      label: label,
      group: group,
      unicodeUrl: unicodeUrl
    };
  },

  renderBreakpoint: function (breakpoint, removed) {
    if (removed) {
      // Be defensive about the breakpoint not existing.
      if (this._getBreakpoint(breakpoint)) {
        this._removeBreakpoint(breakpoint);
      }
    }
    else {
      if (this._getBreakpoint(breakpoint)) {
        this._updateBreakpointStatus(breakpoint);
      }
      else {
        this._addBreakpoint(breakpoint);
      }
    }
  },

  /**
   * Adds a breakpoint to this sources container.
   *
   * @param object aBreakpointClient
   *               See Breakpoints.prototype._showBreakpoint
   * @param object aOptions [optional]
   *        @see DebuggerController.Breakpoints.addBreakpoint
   */
  _addBreakpoint: function (breakpoint, options = {}) {
    let disabled = breakpoint.disabled;
    let location = breakpoint.location;

    // Get the source item to which the breakpoint should be attached.
    let sourceItem = this.getItemByValue(location.actor);
    if (!sourceItem) {
      return;
    }

    // Create the element node and menu popup for the breakpoint item.
    let breakpointArgs = Heritage.extend(breakpoint.asMutable(), options);
    let breakpointView = this._createBreakpointView.call(this, breakpointArgs);
    let contextMenu = this._createContextMenu.call(this, breakpointArgs);

    // Append a breakpoint child item to the corresponding source item.
    sourceItem.append(breakpointView.container, {
      attachment: Heritage.extend(breakpointArgs, {
        actor: location.actor,
        line: location.line,
        view: breakpointView,
        popup: contextMenu
      }),
      attributes: [
        ["contextmenu", contextMenu.menupopupId]
      ],
      // Make sure that when the breakpoint item is removed, the corresponding
      // menupopup and commandset are also destroyed.
      finalize: this._onBreakpointRemoved
    });

    if (typeof breakpoint.condition === "string") {
      this.highlightBreakpoint(breakpoint.location, {
        openPopup: true,
        noEditorUpdate: true
      });
    }

    window.emit(EVENTS.BREAKPOINT_SHOWN_IN_PANE);
  },

  /**
   * Removes a breakpoint from this sources container.
   * It does not also remove the breakpoint from the controller. Be careful.
   *
   * @param object aLocation
   *        @see DebuggerController.Breakpoints.addBreakpoint
   */
  _removeBreakpoint: function (breakpoint) {
    // When a parent source item is removed, all the child breakpoint items are
    // also automagically removed.
    let sourceItem = this.getItemByValue(breakpoint.location.actor);
    if (!sourceItem) {
      return;
    }

    // Clear the breakpoint view.
    sourceItem.remove(this._getBreakpoint(breakpoint));

    if (this._selectedBreakpoint &&
       (queries.makeLocationId(this._selectedBreakpoint.location) ===
        queries.makeLocationId(breakpoint.location))) {
      this._selectedBreakpoint = null;
    }

    window.emit(EVENTS.BREAKPOINT_HIDDEN_IN_PANE);
  },

  _getBreakpoint: function (bp) {
    return this.getItemForPredicate(item => {
      return item.attachment.actor === bp.location.actor &&
        item.attachment.line === bp.location.line;
    });
  },

  /**
   * Updates a breakpoint.
   *
   * @param object breakpoint
   */
  _updateBreakpointStatus: function (breakpoint) {
    let location = breakpoint.location;
    let breakpointItem = this._getBreakpoint(getBreakpoint(this.getState(), location));
    if (!breakpointItem) {
      return promise.reject(new Error("No breakpoint found."));
    }

    // Breakpoint will now be enabled.
    let attachment = breakpointItem.attachment;

    // Update the corresponding menu items to reflect the enabled state.
    let prefix = "bp-cMenu-"; // "breakpoints context menu"
    let identifier = makeLocationId(location);
    let enableSelfId = prefix + "enableSelf-" + identifier + "-menuitem";
    let disableSelfId = prefix + "disableSelf-" + identifier + "-menuitem";
    let enableSelf = document.getElementById(enableSelfId);
    let disableSelf = document.getElementById(disableSelfId);

    if (breakpoint.disabled) {
      enableSelf.removeAttribute("hidden");
      disableSelf.setAttribute("hidden", true);
      attachment.view.checkbox.removeAttribute("checked");
    }
    else {
      enableSelf.setAttribute("hidden", true);
      disableSelf.removeAttribute("hidden");
      attachment.view.checkbox.setAttribute("checked", "true");

      // Update the breakpoint toggle button checked state.
      this._toggleBreakpointsButton.removeAttribute("checked");
    }

  },

  /**
   * Highlights a breakpoint in this sources container.
   *
   * @param object aLocation
   *        @see DebuggerController.Breakpoints.addBreakpoint
   * @param object aOptions [optional]
   *        An object containing some of the following boolean properties:
   *          - openPopup: tells if the expression popup should be shown.
   *          - noEditorUpdate: tells if you want to skip editor updates.
   */
  highlightBreakpoint: function (aLocation, aOptions = {}) {
    let breakpoint = getBreakpoint(this.getState(), aLocation);
    if (!breakpoint) {
      return;
    }

    // Breakpoint will now be selected.
    this._selectBreakpoint(breakpoint);

    // Update the editor location if necessary.
    if (!aOptions.noEditorUpdate) {
      this.DebuggerView.setEditorLocation(aLocation.actor, aLocation.line, { noDebug: true });
    }

    // If the breakpoint requires a new conditional expression, display
    // the panel to input the corresponding expression.
    if (aOptions.openPopup) {
      return this._openConditionalPopup();
    } else {
      return this._hideConditionalPopup();
    }
  },

  /**
   * Highlight the breakpoint on the current currently focused line/column
   * if it exists.
   */
  highlightBreakpointAtCursor: function () {
    let actor = this.selectedValue;
    let line = this.DebuggerView.editor.getCursor().line + 1;

    let location = { actor: actor, line: line };
    this.highlightBreakpoint(location, { noEditorUpdate: true });
  },

  /**
   * Unhighlights the current breakpoint in this sources container.
   */
  unhighlightBreakpoint: function () {
    this._hideConditionalPopup();
    this._unselectBreakpoint();
  },

   /**
    * Display the message thrown on breakpoint condition
    */
  showBreakpointConditionThrownMessage: function (aLocation, aMessage = "") {
    let breakpointItem = this._getBreakpoint(getBreakpoint(this.getState(), aLocation));
    if (!breakpointItem) {
      return;
    }
    let attachment = breakpointItem.attachment;
    attachment.view.container.classList.add("dbg-breakpoint-condition-thrown");
    attachment.view.message.setAttribute("value", aMessage);
  },

  /**
   * Update the checked/unchecked and enabled/disabled states of the buttons in
   * the sources toolbar based on the currently selected source's state.
   */
  updateToolbarButtonsState: function (source) {
    if (source.isBlackBoxed) {
      this._blackBoxButton.setAttribute("checked", true);
      this._prettyPrintButton.setAttribute("checked", true);
    } else {
      this._blackBoxButton.removeAttribute("checked");
      this._prettyPrintButton.removeAttribute("checked");
    }

    if (source.isPrettyPrinted) {
      this._prettyPrintButton.setAttribute("checked", true);
    } else {
      this._prettyPrintButton.removeAttribute("checked");
    }
  },

  /**
   * Toggle the pretty printing of the selected source.
   */
  togglePrettyPrint: function () {
    if (this._prettyPrintButton.hasAttribute("disabled")) {
      return;
    }

    this.DebuggerView.showProgressBar();
    const source = getSelectedSource(this.getState());
    const sourceClient = gThreadClient.source(source);
    const shouldPrettyPrint = !source.isPrettyPrinted;

    // This is only here to give immediate feedback,
    // `renderPrettyPrinted` will set the final status of the buttons
    if (shouldPrettyPrint) {
      this._prettyPrintButton.setAttribute("checked", true);
    } else {
      this._prettyPrintButton.removeAttribute("checked");
    }

    this.actions.togglePrettyPrint(source);
  },

  /**
   * Toggle the black boxed state of the selected source.
   */
  toggleBlackBoxing: Task.async(function* () {
    const source = getSelectedSource(this.getState());
    const shouldBlackBox = !source.isBlackBoxed;

    // Be optimistic that the (un-)black boxing will succeed, so
    // enable/disable the pretty print button and check/uncheck the
    // black box button immediately.
    if (shouldBlackBox) {
      this._prettyPrintButton.setAttribute("disabled", true);
      this._blackBoxButton.setAttribute("checked", true);
    } else {
      this._prettyPrintButton.removeAttribute("disabled");
      this._blackBoxButton.removeAttribute("checked");
    }

    this.actions.blackbox(source, shouldBlackBox);
  }),

  renderBlackBoxed: function (source) {
    const sourceItem = this.getItemByValue(source.actor);
    sourceItem.prebuiltNode.classList.toggle(
      "black-boxed",
      source.isBlackBoxed
    );

    if (getSelectedSource(this.getState()).actor === source.actor) {
      this.updateToolbarButtonsState(source);
    }
  },

  /**
   * Toggles all breakpoints enabled/disabled.
   */
  toggleBreakpoints: function () {
    let breakpoints = getBreakpoints(this.getState());
    let hasBreakpoints = breakpoints.length > 0;
    let hasEnabledBreakpoints = breakpoints.some(bp => !bp.disabled);

    if (hasBreakpoints && hasEnabledBreakpoints) {
      this._toggleBreakpointsButton.setAttribute("checked", true);
      this._onDisableAll();
    } else {
      this._toggleBreakpointsButton.removeAttribute("checked");
      this._onEnableAll();
    }
  },

  hidePrettyPrinting: function () {
    this._prettyPrintButton.style.display = "none";

    if (this._blackBoxButton.style.display === "none") {
      let sep = document.querySelector("#sources-toolbar .devtools-separator");
      sep.style.display = "none";
    }
  },

  hideBlackBoxing: function () {
    this._blackBoxButton.style.display = "none";

    if (this._prettyPrintButton.style.display === "none") {
      let sep = document.querySelector("#sources-toolbar .devtools-separator");
      sep.style.display = "none";
    }
  },

  getDisplayURL: function (source) {
    if (!source.url) {
      return this.getItemByValue(source.actor).attachment.label;
    }
    return NetworkHelper.convertToUnicode(unescape(source.url));
  },

  /**
   * Marks a breakpoint as selected in this sources container.
   *
   * @param object aItem
   *        The breakpoint item to select.
   */
  _selectBreakpoint: function (bp) {
    if (this._selectedBreakpoint === bp) {
      return;
    }
    this._unselectBreakpoint();
    this._selectedBreakpoint = bp;

    const item = this._getBreakpoint(bp);
    item.target.classList.add("selected");

    // Ensure the currently selected breakpoint is visible.
    this.widget.ensureElementIsVisible(item.target);
  },

  /**
   * Marks the current breakpoint as unselected in this sources container.
   */
  _unselectBreakpoint: function () {
    if (!this._selectedBreakpoint) {
      return;
    }

    const item = this._getBreakpoint(this._selectedBreakpoint);
    item.target.classList.remove("selected");

    this._selectedBreakpoint = null;
  },

  /**
   * Opens a conditional breakpoint's expression input popup.
   */
  _openConditionalPopup: function () {
    let breakpointItem = this._getBreakpoint(this._selectedBreakpoint);
    let attachment = breakpointItem.attachment;
    // Check if this is an enabled conditional breakpoint, and if so,
    // retrieve the current conditional epression.
    let bp = getBreakpoint(this.getState(), attachment);
    let expr = (bp ? (bp.condition || "") : "");
    let cbPanel = this._cbPanel;

    // Update the conditional expression textbox. If no expression was
    // previously set, revert to using an empty string by default.
    this._cbTextbox.value = expr;

    function openPopup() {
      // Show the conditional expression panel. The popup arrow should be pointing
      // at the line number node in the breakpoint item view.
      cbPanel.hidden = false;
      cbPanel.openPopup(breakpointItem.attachment.view.lineNumber,
                              BREAKPOINT_CONDITIONAL_POPUP_POSITION,
                              BREAKPOINT_CONDITIONAL_POPUP_OFFSET_X,
                              BREAKPOINT_CONDITIONAL_POPUP_OFFSET_Y);

      cbPanel.removeEventListener("popuphidden", openPopup, false);
    }

    // Wait until the other cb panel is closed
    if (!this._cbPanel.hidden) {
      this._cbPanel.addEventListener("popuphidden", openPopup, false);
    } else {
      openPopup();
    }
  },

  /**
   * Hides a conditional breakpoint's expression input popup.
   */
  _hideConditionalPopup: function () {
    // Sometimes this._cbPanel doesn't have hidePopup method which doesn't
    // break anything but simply outputs an exception to the console.
    if (this._cbPanel.hidePopup) {
      this._cbPanel.hidePopup();
    }
  },

  /**
   * Customization function for creating a breakpoint item's UI.
   *
   * @param object aOptions
   *        A couple of options or flags supported by this operation:
   *          - location: the breakpoint's source location and line number
   *          - disabled: the breakpoint's disabled state, boolean
   *          - text: the breakpoint's line text to be displayed
   *          - message: thrown string when the breakpoint condition throws
   * @return object
   *         An object containing the breakpoint container, checkbox,
   *         line number and line text nodes.
   */
  _createBreakpointView: function (aOptions) {
    let { location, disabled, text, message } = aOptions;
    let identifier = makeLocationId(location);

    let checkbox = document.createElement("checkbox");
    if (!disabled) {
      checkbox.setAttribute("checked", true);
    }
    checkbox.className = "dbg-breakpoint-checkbox";

    let lineNumberNode = document.createElement("label");
    lineNumberNode.className = "plain dbg-breakpoint-line";
    lineNumberNode.setAttribute("value", location.line);

    let lineTextNode = document.createElement("label");
    lineTextNode.className = "plain dbg-breakpoint-text";
    lineTextNode.setAttribute("value", text);
    lineTextNode.setAttribute("crop", "end");
    lineTextNode.setAttribute("flex", "1");

    let tooltip = text ? text.substr(0, BREAKPOINT_LINE_TOOLTIP_MAX_LENGTH) : "";
    lineTextNode.setAttribute("tooltiptext", tooltip);

    let thrownNode = document.createElement("label");
    thrownNode.className = "plain dbg-breakpoint-condition-thrown-message dbg-breakpoint-text";
    thrownNode.setAttribute("value", message);
    thrownNode.setAttribute("crop", "end");
    thrownNode.setAttribute("flex", "1");

    let bpLineContainer = document.createElement("hbox");
    bpLineContainer.className = "plain dbg-breakpoint-line-container";
    bpLineContainer.setAttribute("flex", "1");

    bpLineContainer.appendChild(lineNumberNode);
    bpLineContainer.appendChild(lineTextNode);

    let bpDetailContainer = document.createElement("vbox");
    bpDetailContainer.className = "plain dbg-breakpoint-detail-container";
    bpDetailContainer.setAttribute("flex", "1");

    bpDetailContainer.appendChild(bpLineContainer);
    bpDetailContainer.appendChild(thrownNode);

    let container = document.createElement("hbox");
    container.id = "breakpoint-" + identifier;
    container.className = "dbg-breakpoint side-menu-widget-item-other";
    container.classList.add("devtools-monospace");
    container.setAttribute("align", "center");
    container.setAttribute("flex", "1");

    container.addEventListener("click", this._onBreakpointClick, false);
    checkbox.addEventListener("click", this._onBreakpointCheckboxClick, false);

    container.appendChild(checkbox);
    container.appendChild(bpDetailContainer);

    return {
      container: container,
      checkbox: checkbox,
      lineNumber: lineNumberNode,
      lineText: lineTextNode,
      message: thrownNode
    };
  },

  /**
   * Creates a context menu for a breakpoint element.
   *
   * @param object aOptions
   *        A couple of options or flags supported by this operation:
   *          - location: the breakpoint's source location and line number
   *          - disabled: the breakpoint's disabled state, boolean
   * @return object
   *         An object containing the breakpoint commandset and menu popup ids.
   */
  _createContextMenu: function (aOptions) {
    let { location, disabled } = aOptions;
    let identifier = makeLocationId(location);

    let commandset = document.createElement("commandset");
    let menupopup = document.createElement("menupopup");
    commandset.id = "bp-cSet-" + identifier;
    menupopup.id = "bp-mPop-" + identifier;

    createMenuItem.call(this, "enableSelf", !disabled);
    createMenuItem.call(this, "disableSelf", disabled);
    createMenuItem.call(this, "deleteSelf");
    createMenuSeparator();
    createMenuItem.call(this, "setConditional");
    createMenuSeparator();
    createMenuItem.call(this, "enableOthers");
    createMenuItem.call(this, "disableOthers");
    createMenuItem.call(this, "deleteOthers");
    createMenuSeparator();
    createMenuItem.call(this, "enableAll");
    createMenuItem.call(this, "disableAll");
    createMenuSeparator();
    createMenuItem.call(this, "deleteAll");

    this._popupset.appendChild(menupopup);
    this._commandset.appendChild(commandset);

    return {
      commandsetId: commandset.id,
      menupopupId: menupopup.id
    };

    /**
     * Creates a menu item specified by a name with the appropriate attributes
     * (label and handler).
     *
     * @param string aName
     *        A global identifier for the menu item.
     * @param boolean aHiddenFlag
     *        True if this menuitem should be hidden.
     */
    function createMenuItem(aName, aHiddenFlag) {
      let menuitem = document.createElement("menuitem");
      let command = document.createElement("command");

      let prefix = "bp-cMenu-"; // "breakpoints context menu"
      let commandId = prefix + aName + "-" + identifier + "-command";
      let menuitemId = prefix + aName + "-" + identifier + "-menuitem";

      let label = L10N.getStr("breakpointMenuItem." + aName);
      let func = "_on" + aName.charAt(0).toUpperCase() + aName.slice(1);

      command.id = commandId;
      command.setAttribute("label", label);
      command.addEventListener("command", () => this[func](location), false);

      menuitem.id = menuitemId;
      menuitem.setAttribute("command", commandId);
      aHiddenFlag && menuitem.setAttribute("hidden", "true");

      commandset.appendChild(command);
      menupopup.appendChild(menuitem);
    }

    /**
     * Creates a simple menu separator element and appends it to the current
     * menupopup hierarchy.
     */
    function createMenuSeparator() {
      let menuseparator = document.createElement("menuseparator");
      menupopup.appendChild(menuseparator);
    }
  },

  /**
   * Copy the source url from the currently selected item.
   */
  _onCopyUrlCommand: function () {
    let selected = this.selectedItem && this.selectedItem.attachment;
    if (!selected) {
      return;
    }
    clipboardHelper.copyString(selected.source.url);
  },

  /**
   * Opens selected item source in a new tab.
   */
  _onNewTabCommand: function () {
    let win = Services.wm.getMostRecentWindow(gDevTools.chromeWindowType);
    let selected = this.selectedItem.attachment;
    win.openUILinkIn(selected.source.url, "tab", { relatedToCurrent: true });
  },

  /**
   * Function called each time a breakpoint item is removed.
   *
   * @param object aItem
   *        The corresponding item.
   */
  _onBreakpointRemoved: function (aItem) {
    dumpn("Finalizing breakpoint item: " + aItem.stringify());

    // Destroy the context menu for the breakpoint.
    let contextMenu = aItem.attachment.popup;
    document.getElementById(contextMenu.commandsetId).remove();
    document.getElementById(contextMenu.menupopupId).remove();
  },

  _onMouseDown: function (e) {
    this.hideNoResultsTooltip();

    if (!e.metaKey) {
      return;
    }

    let editor = this.DebuggerView.editor;
    let identifier = this._findIdentifier(e.clientX, e.clientY);

    if (!identifier) {
      return;
    }

    let foundDefinitions = this._getFunctionDefinitions(identifier);

    if (!foundDefinitions || !foundDefinitions.definitions) {
      return;
    }

    this._showFunctionDefinitionResults(identifier, foundDefinitions.definitions, editor);
  },

  /**
   * Searches for function definition of a function in a given source file
   */

  _findDefinition: function (parsedSource, aName) {
    let functionDefinitions = parsedSource.getNamedFunctionDefinitions(aName);

    let resultList = [];

    if (!functionDefinitions || !functionDefinitions.length || !functionDefinitions[0].length) {
      return {
        definitions: resultList
      };
    }

    // functionDefinitions is a list with an object full of metadata,
    // extract the data and use to construct a more useful, less
    // cluttered, contextual list
    for (let i = 0; i < functionDefinitions.length; i++) {
      let functionDefinition = {
        source: functionDefinitions[i].sourceUrl,
        startLine: functionDefinitions[i][0].functionLocation.start.line,
        startColumn: functionDefinitions[i][0].functionLocation.start.column,
        name: functionDefinitions[i][0].functionName
      };

      resultList.push(functionDefinition);
    }

    return {
      definitions: resultList
    };
  },

  /**
   * Searches for an identifier underneath the specified position in the
   * source editor.
   *
   * @param number x, y
   *        The left/top coordinates where to look for an identifier.
   */
  _findIdentifier: function (x, y) {
    let parsedSource = SourceUtils.parseSource(this.DebuggerView, this.Parser);
    let identifierInfo = SourceUtils.findIdentifier(this.DebuggerView.editor, parsedSource, x, y);

    // Not hovering over an identifier
    if (!identifierInfo) {
      return;
    }

    return identifierInfo;
  },

  /**
   * The selection listener for the source editor.
   */
  _onEditorCursorActivity: function (e) {
    let editor = this.DebuggerView.editor;
    let start = editor.getCursor("start").line + 1;
    let end = editor.getCursor().line + 1;
    let source = getSelectedSource(this.getState());

    if (source) {
      let location = { actor: source.actor, line: start };
      if (getBreakpoint(this.getState(), location) && start == end) {
        this.highlightBreakpoint(location, { noEditorUpdate: true });
      } else {
        this.unhighlightBreakpoint();
      }
    }
  },

  /*
   * Uses function definition data to perform actions in different
   * cases of how many locations were found: zero, one, or multiple definitions
   */
  _showFunctionDefinitionResults: function (aHoveredFunction, aDefinitionList, aEditor) {
    let definitions = aDefinitionList;
    let hoveredFunction = aHoveredFunction;

    // show a popup saying no results were found
    if (definitions.length == 0) {
      this._noResultsFoundToolTip.setTextContent({
        messages: [L10N.getStr("noMatchingStringsText")]
      });

      this._markedIdentifier = aEditor.markText(
        { line: hoveredFunction.location.start.line - 1, ch: hoveredFunction.location.start.column },
        { line: hoveredFunction.location.end.line - 1, ch: hoveredFunction.location.end.column });

      this._noResultsFoundToolTip.show(this._markedIdentifier.anchor);

    } else if (definitions.length == 1) {
      this.DebuggerView.setEditorLocation(definitions[0].source, definitions[0].startLine);
    } else {
      // TODO: multiple definitions found, do something else
      this.DebuggerView.setEditorLocation(definitions[0].source, definitions[0].startLine);
    }
  },

  /**
   * Hides the tooltip and clear marked text popup.
   */
  hideNoResultsTooltip: function () {
    this._noResultsFoundToolTip.hide();
    if (this._markedIdentifier) {
      this._markedIdentifier.clear();
      this._markedIdentifier = null;
    }
  },

  /*
   * Gets the definition locations from function metadata
   */
  _getFunctionDefinitions: function (aIdentifierInfo) {
    let parsedSource = SourceUtils.parseSource(this.DebuggerView, this.Parser);
    let definition_info = this._findDefinition(parsedSource, aIdentifierInfo.name);

    // Did not find any definitions for the identifier
    if (!definition_info) {
      return;
    }

    return definition_info;
  },

  /**
   * The select listener for the sources container.
   */
  _onSourceSelect: function ({ detail: sourceItem }) {
    if (!sourceItem) {
      return;
    }

    const { source } = sourceItem.attachment;
    this.actions.selectSource(source);
  },

  renderSourceSelected: function (source) {
    if (source.url) {
      this._preferredSourceURL = source.url;
    }
    this.updateToolbarButtonsState(source);
    this._selectItem(this.getItemByValue(source.actor));
  },

  /**
   * The click listener for the "stop black boxing" button.
   */
  _onStopBlackBoxing: Task.async(function* () {
    this.actions.blackbox(getSelectedSource(this.getState()), false);
  }),

  /**
   * The source editor's contextmenu handler.
   * - Toggles "Add Conditional Breakpoint" and "Edit Conditional Breakpoint" items
   */
  _onEditorContextMenuOpen: function (message, ev, popup) {
    let actor = this.selectedValue;
    let line = this.DebuggerView.editor.getCursor().line + 1;
    let location = { actor, line };

    let breakpoint = getBreakpoint(this.getState(), location);
    let addConditionalBreakpointMenuItem = popup.querySelector("#se-dbg-cMenu-addConditionalBreakpoint");
    let editConditionalBreakpointMenuItem = popup.querySelector("#se-dbg-cMenu-editConditionalBreakpoint");

    if (breakpoint && !!breakpoint.condition) {
      editConditionalBreakpointMenuItem.removeAttribute("hidden");
      addConditionalBreakpointMenuItem.setAttribute("hidden", true);
    }
    else {
      addConditionalBreakpointMenuItem.removeAttribute("hidden");
      editConditionalBreakpointMenuItem.setAttribute("hidden", true);
    }
  },

  /**
   * The click listener for a breakpoint container.
   */
  _onBreakpointClick: function (e) {
    let sourceItem = this.getItemForElement(e.target);
    let breakpointItem = this.getItemForElement.call(sourceItem, e.target);
    let attachment = breakpointItem.attachment;
    let bp = getBreakpoint(this.getState(), attachment);
    if (bp) {
      this.highlightBreakpoint(bp.location, {
        openPopup: bp.condition && e.button == 0
      });
    } else {
      this.highlightBreakpoint(bp.location);
    }
  },

  /**
   * The click listener for a breakpoint checkbox.
   */
  _onBreakpointCheckboxClick: function (e) {
    let sourceItem = this.getItemForElement(e.target);
    let breakpointItem = this.getItemForElement.call(sourceItem, e.target);
    let bp = getBreakpoint(this.getState(), breakpointItem.attachment);

    if (bp.disabled) {
      this.actions.enableBreakpoint(bp.location);
    }
    else {
      this.actions.disableBreakpoint(bp.location);
    }

    // Don't update the editor location (avoid propagating into _onBreakpointClick).
    e.preventDefault();
    e.stopPropagation();
  },

  /**
   * The popup showing listener for the breakpoints conditional expression panel.
   */
  _onConditionalPopupShowing: function () {
    this._conditionalPopupVisible = true; // Used in tests.
  },

  /**
   * The popup shown listener for the breakpoints conditional expression panel.
   */
  _onConditionalPopupShown: function () {
    this._cbTextbox.focus();
    this._cbTextbox.select();
    window.emit(EVENTS.CONDITIONAL_BREAKPOINT_POPUP_SHOWN);
  },

  /**
   * The popup hiding listener for the breakpoints conditional expression panel.
   */
  _onConditionalPopupHiding: function () {
    this._conditionalPopupVisible = false; // Used in tests.

    // Check if this is an enabled conditional breakpoint, and if so,
    // save the current conditional expression.
    let bp = this._selectedBreakpoint;
    if (bp) {
      let condition = this._cbTextbox.value;
      this.actions.setBreakpointCondition(bp.location, condition);
    }
  },

  /**
   * The popup hidden listener for the breakpoints conditional expression panel.
   */
  _onConditionalPopupHidden: function () {
    this._cbPanel.hidden = true;
    window.emit(EVENTS.CONDITIONAL_BREAKPOINT_POPUP_HIDDEN);
  },

  /**
   * The keypress listener for the breakpoints conditional expression textbox.
   */
  _onConditionalTextboxKeyPress: function (e) {
    if (e.keyCode == KeyCodes.DOM_VK_RETURN) {
      this._hideConditionalPopup();
    }
  },

  /**
   * Called when the add breakpoint key sequence was pressed.
   */
  _onCmdAddBreakpoint: function (e) {
    let actor = this.selectedValue;
    let line = (this.DebuggerView.clickedLine ?
                this.DebuggerView.clickedLine + 1 :
                this.DebuggerView.editor.getCursor().line + 1);
    let location = { actor, line };
    let bp = getBreakpoint(this.getState(), location);

    // If a breakpoint already existed, remove it now.
    if (bp) {
      this.actions.removeBreakpoint(bp.location);
    }
    // No breakpoint existed at the required location, add one now.
    else {
      this.actions.addBreakpoint(location);
    }
  },

  /**
   * Called when the add conditional breakpoint key sequence was pressed.
   */
  _onCmdAddConditionalBreakpoint: function (e) {
    let actor = this.selectedValue;
    let line = (this.DebuggerView.clickedLine ?
                this.DebuggerView.clickedLine + 1 :
                this.DebuggerView.editor.getCursor().line + 1);

    let location = { actor, line };
    let bp = getBreakpoint(this.getState(), location);

    // If a breakpoint already existed or wasn't a conditional, morph it now.
    if (bp) {
      this.highlightBreakpoint(bp.location, { openPopup: true });
    }
    // No breakpoint existed at the required location, add one now.
    else {
      this.actions.addBreakpoint(location, "");
    }
  },

  getOtherBreakpoints: function (location) {
    const bps = getBreakpoints(this.getState());
    if (location) {
      return bps.filter(bp => {
        return (bp.location.actor !== location.actor ||
                bp.location.line !== location.line);
      });
    }
    return bps;
  },

  /**
   * Function invoked on the "setConditional" menuitem command.
   *
   * @param object aLocation
   *        @see DebuggerController.Breakpoints.addBreakpoint
   */
  _onSetConditional: function (aLocation) {
    // Highlight the breakpoint and show a conditional expression popup.
    this.highlightBreakpoint(aLocation, { openPopup: true });
  },

  /**
   * Function invoked on the "enableSelf" menuitem command.
   *
   * @param object aLocation
   *        @see DebuggerController.Breakpoints.addBreakpoint
   */
  _onEnableSelf: function (aLocation) {
    // Enable the breakpoint, in this container and the controller store.
    this.actions.enableBreakpoint(aLocation);
  },

  /**
   * Function invoked on the "disableSelf" menuitem command.
   *
   * @param object aLocation
   *        @see DebuggerController.Breakpoints.addBreakpoint
   */
  _onDisableSelf: function (aLocation) {
    const bp = getBreakpoint(this.getState(), aLocation);
    if (!bp.disabled) {
      this.actions.disableBreakpoint(aLocation);
    }
  },

  /**
   * Function invoked on the "deleteSelf" menuitem command.
   *
   * @param object aLocation
   *        @see DebuggerController.Breakpoints.addBreakpoint
   */
  _onDeleteSelf: function (aLocation) {
    this.actions.removeBreakpoint(aLocation);
  },

  /**
   * Function invoked on the "enableOthers" menuitem command.
   *
   * @param object aLocation
   *        @see DebuggerController.Breakpoints.addBreakpoint
   */
  _onEnableOthers: function (aLocation) {
    let other = this.getOtherBreakpoints(aLocation);
    // TODO(jwl): batch these and interrupt the thread for all of them
    other.forEach(bp => this._onEnableSelf(bp.location));
  },

  /**
   * Function invoked on the "disableOthers" menuitem command.
   *
   * @param object aLocation
   *        @see DebuggerController.Breakpoints.addBreakpoint
   */
  _onDisableOthers: function (aLocation) {
    let other = this.getOtherBreakpoints(aLocation);
    other.forEach(bp => this._onDisableSelf(bp.location));
  },

  /**
   * Function invoked on the "deleteOthers" menuitem command.
   *
   * @param object aLocation
   *        @see DebuggerController.Breakpoints.addBreakpoint
   */
  _onDeleteOthers: function (aLocation) {
    let other = this.getOtherBreakpoints(aLocation);
    other.forEach(bp => this._onDeleteSelf(bp.location));
  },

  /**
   * Function invoked on the "enableAll" menuitem command.
   */
  _onEnableAll: function () {
    this._onEnableOthers(undefined);
  },

  /**
   * Function invoked on the "disableAll" menuitem command.
   */
  _onDisableAll: function () {
    this._onDisableOthers(undefined);
  },

  /**
   * Function invoked on the "deleteAll" menuitem command.
   */
  _onDeleteAll: function () {
    this._onDeleteOthers(undefined);
  },

  _commandset: null,
  _popupset: null,
  _cmPopup: null,
  _cbPanel: null,
  _cbTextbox: null,
  _selectedBreakpointItem: null,
  _conditionalPopupVisible: false,
  _noResultsFoundToolTip: null,
  _markedIdentifier: null,
  _selectedBreakpoint: null,
  _conditionalPopupVisible: false
});

module.exports = SourcesView;
