/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* 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/. */

const {Cu} = require("chrome");

Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Task.jsm");

var {Promise: promise} = require("resource://gre/modules/Promise.jsm");
var EventEmitter = require("devtools/toolkit/event-emitter");
var Telemetry = require("devtools/shared/telemetry");

const XULNS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";

/**
 * ToolSidebar provides methods to register tabs in the sidebar.
 * It's assumed that the sidebar contains a xul:tabbox.
 * Typically, you'll want the tabbox parameter to be a XUL tabbox like this:
 *
 * <tabbox id="inspector-sidebar" handleCtrlTab="false" class="devtools-sidebar-tabs">
 *   <tabs/>
 *   <tabpanels flex="1"/>
 * </tabbox>
 *
 * The ToolSidebar API has a method to add new tabs, so the tabs and tabpanels
 * nodes can be empty. But they can also already contain items before the
 * ToolSidebar is created.
 *
 * Tabs added through the addTab method are only identified by an ID and a URL
 * which is used as the href of an iframe node that is inserted in the newly
 * created tabpanel.
 * Tabs already present before the ToolSidebar is created may contain anything.
 * However, these tabs must have ID attributes if it is required for the various
 * methods that accept an ID as argument to work here.
 *
 * @param {Node} tabbox
 *  <tabbox> node;
 * @param {ToolPanel} panel
 *  Related ToolPanel instance;
 * @param {String} uid
 *  Unique ID
 * @param {Object} options
 *  - hideTabstripe: Should the tabs be hidden. Defaults to false
 *  - showAllTabsMenu: Should a drop-down menu be displayed in case tabs
 *    become hidden. Defaults to false.
 *  - disableTelemetry: By default, switching tabs on and off in the sidebar
 *    will record tool usage in telemetry, pass this option to true to avoid it.
 *
 * Events raised:
 * - new-tab-registered : After a tab has been added via addTab. The tab ID
 *   is passed with the event. This however, is raised before the tab iframe
 *   is fully loaded.
 * - <tabid>-ready : After the tab iframe has been loaded
 * - <tabid>-selected : After tab <tabid> was selected
 * - select : Same as above, but for any tab, the ID is passed with the event
 * - <tabid>-unselected : After tab <tabid> is unselected
 */
function ToolSidebar(tabbox, panel, uid, options={}) {
  EventEmitter.decorate(this);

  this._tabbox = tabbox;
  this._uid = uid;
  this._panelDoc = this._tabbox.ownerDocument;
  this._toolPanel = panel;
  this._options = options;

  this._onTabBoxOverflow = this._onTabBoxOverflow.bind(this);
  this._onTabBoxUnderflow = this._onTabBoxUnderflow.bind(this);

  try {
    this._width = Services.prefs.getIntPref("devtools.toolsidebar-width." + this._uid);
  } catch(e) {}

  if (!options.disableTelemetry) {
    this._telemetry = new Telemetry();
  }

  this._tabbox.tabpanels.addEventListener("select", this, true);

  this._tabs = new Map();

  // Check for existing tabs in the DOM and add them.
  this.addExistingTabs();

  if (this._options.hideTabstripe) {
    this._tabbox.setAttribute("hidetabs", "true");
  }

  if (this._options.showAllTabsMenu) {
    this.addAllTabsMenu();
  }

  this._toolPanel.emit("sidebar-created", this);
}

exports.ToolSidebar = ToolSidebar;

ToolSidebar.prototype = {
  TAB_ID_PREFIX: "sidebar-tab-",

  TABPANEL_ID_PREFIX: "sidebar-panel-",

  /**
   * Add a "…" button at the end of the tabstripe that toggles a dropdown menu
   * containing the list of all tabs if any become hidden due to lack of room.
   *
   * If the ToolSidebar was created with the "showAllTabsMenu" option set to
   * true, this is already done automatically. If not, you may call this
   * function at any time to add the menu.
   */
  addAllTabsMenu: function() {
    if (this._allTabsBtn) {
      return;
    }

    let tabs = this._tabbox.tabs;

    // Create a container and insert it first in the tabbox
    let allTabsContainer = this._panelDoc.createElementNS(XULNS, "box");
    this._tabbox.insertBefore(allTabsContainer, tabs);

    // Move the tabs inside and make them flex
    allTabsContainer.appendChild(tabs);
    tabs.setAttribute("flex", "1");

    // Create the dropdown menu next to the tabs
    this._allTabsBtn = this._panelDoc.createElementNS(XULNS, "toolbarbutton");
    this._allTabsBtn.setAttribute("class", "devtools-sidebar-alltabs");
    this._allTabsBtn.setAttribute("type", "menu");
    this._allTabsBtn.setAttribute("label", l10n("sidebar.showAllTabs.label"));
    this._allTabsBtn.setAttribute("tooltiptext", l10n("sidebar.showAllTabs.tooltip"));
    this._allTabsBtn.setAttribute("hidden", "true");
    allTabsContainer.appendChild(this._allTabsBtn);

    let menuPopup = this._panelDoc.createElementNS(XULNS, "menupopup");
    this._allTabsBtn.appendChild(menuPopup);

    // Listening to tabs overflow event to toggle the alltabs button
    tabs.addEventListener("overflow", this._onTabBoxOverflow, false);
    tabs.addEventListener("underflow", this._onTabBoxUnderflow, false);

    // Add menuitems to the alltabs menu if there are already tabs in the
    // sidebar
    for (let [id, tab] of this._tabs) {
      this._addItemToAllTabsMenu(id, tab, tab.hasAttribute("selected"));
    }
  },

  removeAllTabsMenu: function() {
    if (!this._allTabsBtn) {
      return;
    }

    let tabs = this._tabbox.tabs;

    tabs.removeEventListener("overflow", this._onTabBoxOverflow, false);
    tabs.removeEventListener("underflow", this._onTabBoxUnderflow, false);

    // Moving back the tabs as a first child of the tabbox
    this._tabbox.insertBefore(tabs, this._tabbox.tabpanels);
    this._tabbox.querySelector("box").remove();

    this._allTabsBtn = null;
  },

  _onTabBoxOverflow: function() {
    this._allTabsBtn.removeAttribute("hidden");
  },

  _onTabBoxUnderflow: function() {
    this._allTabsBtn.setAttribute("hidden", "true");
  },

  /**
   * Add an item in the allTabs menu for a given tab.
   */
  _addItemToAllTabsMenu: function(id, tab, selected=false) {
    if (!this._allTabsBtn) {
      return;
    }

    let item = this._panelDoc.createElementNS(XULNS, "menuitem");
    item.setAttribute("id", "sidebar-alltabs-item-" + id);
    item.setAttribute("label", tab.getAttribute("label"));
    item.setAttribute("type", "checkbox");
    if (selected) {
      item.setAttribute("checked", true);
    }
    // The auto-checking of menuitems in this menu doesn't work, so let's do
    // it manually
    item.setAttribute("autocheck", false);

    this._allTabsBtn.querySelector("menupopup").appendChild(item);

    item.addEventListener("click", () => {
      this._tabbox.selectedTab = tab;
    }, false);

    tab.allTabsMenuItem = item;

    return item;
  },

  /**
   * Register a tab. A tab is a document.
   * The document must have a title, which will be used as the name of the tab.
   *
   * @param {string} tab uniq id
   * @param {string} url
   */
  addTab: function(id, url, selected=false) {
    let iframe = this._panelDoc.createElementNS(XULNS, "iframe");
    iframe.className = "iframe-" + id;
    iframe.setAttribute("flex", "1");
    iframe.setAttribute("src", url);
    iframe.tooltip = "aHTMLTooltip";

    // Creating the tab and adding it to the tabbox
    let tab = this._panelDoc.createElementNS(XULNS, "tab");
    this._tabbox.tabs.appendChild(tab);
    tab.setAttribute("label", ""); // Avoid showing "undefined" while the tab is loading
    tab.setAttribute("id", this.TAB_ID_PREFIX + id);

    // Add the tab to the allTabs menu if exists
    let allTabsItem = this._addItemToAllTabsMenu(id, tab, selected);

    let onIFrameLoaded = (event) => {
      let doc = event.target;
      let win = doc.defaultView;
      tab.setAttribute("label", doc.title);

      if (allTabsItem) {
        allTabsItem.setAttribute("label", doc.title);
      }

      iframe.removeEventListener("load", onIFrameLoaded, true);
      if ("setPanel" in win) {
        win.setPanel(this._toolPanel, iframe);
      }
      this.emit(id + "-ready");
    };

    iframe.addEventListener("load", onIFrameLoaded, true);

    let tabpanel = this._panelDoc.createElementNS(XULNS, "tabpanel");
    tabpanel.setAttribute("id", this.TABPANEL_ID_PREFIX + id);
    tabpanel.appendChild(iframe);
    this._tabbox.tabpanels.appendChild(tabpanel);

    this._tooltip = this._panelDoc.createElementNS(XULNS, "tooltip");
    this._tooltip.id = "aHTMLTooltip";
    tabpanel.appendChild(this._tooltip);
    this._tooltip.page = true;

    tab.linkedPanel = this.TABPANEL_ID_PREFIX + id;

    // We store the index of this tab.
    this._tabs.set(id, tab);

    if (selected) {
      // For some reason I don't understand, if we call this.select in this
      // event loop (after inserting the tab), the tab will never get the
      // the "selected" attribute set to true.
      this._panelDoc.defaultView.setTimeout(() => {
        this.select(id);
      }, 10);
    }

    this.emit("new-tab-registered", id);
  },

  untitledTabsIndex: 0,

  /**
   * Search for existing tabs in the markup that aren't know yet and add them.
   */
  addExistingTabs: function() {
    let knownTabs = [...this._tabs.values()];

    for (let tab of this._tabbox.tabs.querySelectorAll("tab")) {
      if (knownTabs.indexOf(tab) !== -1) {
        continue;
      }

      // Find an ID for this unknown tab
      let id = tab.getAttribute("id") || "untitled-tab-" + (this.untitledTabsIndex++);

      // Register the tab
      this._tabs.set(id, tab);
      this.emit("new-tab-registered", id);
    }
  },

  /**
   * Remove an existing tab.
   * @param {String} tabId The ID of the tab that was used to register it, or
   * the tab id attribute value if the tab existed before the sidebar got created.
   * @param {String} tabPanelId Optional. If provided, this ID will be used
   * instead of the tabId to retrieve and remove the corresponding <tabpanel>
   */
  removeTab: Task.async(function*(tabId, tabPanelId) {
    // Remove the tab if it can be found
    let tab = this.getTab(tabId);
    if (!tab) {
      return;
    }

    let win = this.getWindowForTab(tabId);
    if (win && ("destroy" in win)) {
      yield win.destroy();
    }

    tab.remove();

    // Also remove the tabpanel
    let panel = this.getTabPanel(tabPanelId || tabId);
    if (panel) {
      panel.remove();
    }

    this._tabs.delete(tabId);
    this.emit("tab-unregistered", tabId);
  }),

  /**
   * Show or hide a specific tab and tabpanel.
   * @param {Boolean} isVisible True to show the tab/tabpanel, False to hide it.
   * @param {String} id The ID of the tab to be hidden.
   * @param {String} tabPanelId Optionally pass the ID for the tabPanel if it
   * can't be retrieved using the tab ID. This is useful when tabs and tabpanels
   * existed before the widget was created.
   */
  toggleTab: function(isVisible, id, tabPanelId) {
    // Toggle the tab.
    let tab = this.getTab(id);
    if (!tab) {
      return;
    }
    tab.hidden = !isVisible;

    // Toggle the item in the allTabs menu.
    if (this._allTabsBtn) {
      this._allTabsBtn.querySelector("#sidebar-alltabs-item-" + id).hidden = !isVisible;
    }

    // Toggle the corresponding tabPanel, if one can be found either with the id
    // or the provided tabPanelId.
    let tabPanel = this.getTabPanel(id);
    if (!tabPanel && tabPanelId) {
      tabPanel = this.getTabPanel(tabPanelId);
    }
    if (tabPanel) {
      tabPanel.hidden = !isVisible;
    }
  },

  /**
   * Select a specific tab.
   */
  select: function(id) {
    let tab = this.getTab(id);
    if (tab) {
      this._tabbox.selectedTab = tab;
    }
  },

  /**
   * Return the id of the selected tab.
   */
  getCurrentTabID: function() {
    let currentID = null;
    for (let [id, tab] of this._tabs) {
      if (this._tabbox.tabs.selectedItem == tab) {
        currentID = id;
        break;
      }
    }
    return currentID;
  },

  /**
   * Returns the requested tab panel based on the id.
   * @param {String} id
   * @return {DOMNode}
   */
  getTabPanel: function(id) {
    // Search with and without the ID prefix as there might have been existing
    // tabpanels by the time the sidebar got created
    return this._tabbox.tabpanels.querySelector("#" + this.TABPANEL_ID_PREFIX + id + ", #" + id);
  },

  /**
   * Return the tab based on the provided id, if one was registered with this id.
   * @param {String} id
   * @return {DOMNode}
   */
  getTab: function(id) {
    return this._tabs.get(id);
  },

  /**
   * Event handler.
   */
  handleEvent: function(event) {
    if (event.type !== "select" || this._destroyed) {
      return;
    }

    if (this._currentTool == this.getCurrentTabID()) {
      // Tool hasn't changed.
      return;
    }

    let previousTool = this._currentTool;
    this._currentTool = this.getCurrentTabID();
    if (previousTool) {
      if (this._telemetry) {
        this._telemetry.toolClosed(previousTool);
      }
      this.emit(previousTool + "-unselected");
    }

    if (this._telemetry) {
      this._telemetry.toolOpened(this._currentTool);
    }

    this.emit(this._currentTool + "-selected");
    this.emit("select", this._currentTool);

    // Handlers for "select"/"...-selected"/"...-unselected" events might have
    // destroyed the sidebar in the meantime.
    if (this._destroyed) {
      return;
    }

    // Handle menuitem selection if the allTabsMenu is there by unchecking all
    // items except the selected one.
    let tab = this._tabbox.selectedTab;
    if (tab.allTabsMenuItem) {
      for (let otherItem of this._allTabsBtn.querySelectorAll("menuitem")) {
        otherItem.removeAttribute("checked");
      }
      tab.allTabsMenuItem.setAttribute("checked", true);
    }
  },

  /**
   * Toggle sidebar's visibility state.
   */
  toggle: function() {
    if (this._tabbox.hasAttribute("hidden")) {
      this.show();
    } else {
      this.hide();
    }
  },

  /**
   * Show the sidebar.
   */
  show: function() {
    if (this._width) {
      this._tabbox.width = this._width;
    }
    this._tabbox.removeAttribute("hidden");

    this.emit("show");
  },

  /**
   * Show the sidebar.
   */
  hide: function() {
    Services.prefs.setIntPref("devtools.toolsidebar-width." + this._uid, this._tabbox.width);
    this._tabbox.setAttribute("hidden", "true");

    this.emit("hide");
  },

  /**
   * Return the window containing the tab content.
   */
  getWindowForTab: function(id) {
    if (!this._tabs.has(id)) {
      return null;
    }

    // Get the tabpanel and make sure it contains an iframe
    let panel = this.getTabPanel(id);
    if (!panel || !panel.firstChild || !panel.firstChild.contentWindow) {
      return;
    }
    return panel.firstChild.contentWindow;
  },

  /**
   * Clean-up.
   */
  destroy: Task.async(function*() {
    if (this._destroyed) {
      return;
    }
    this._destroyed = true;

    Services.prefs.setIntPref("devtools.toolsidebar-width." + this._uid, this._tabbox.width);

    if (this._allTabsBtn) {
      this.removeAllTabsMenu();
    }

    this._tabbox.tabpanels.removeEventListener("select", this, true);

    // Note that we check for the existence of this._tabbox.tabpanels at each
    // step as the container window may have been closed by the time one of the
    // panel's destroy promise resolves.
    while (this._tabbox.tabpanels && this._tabbox.tabpanels.hasChildNodes()) {
      let panel = this._tabbox.tabpanels.firstChild;
      let win = panel.firstChild.contentWindow;
      if (win && ("destroy" in win)) {
        yield win.destroy();
      }
      panel.remove();
    }

    while (this._tabbox.tabs && this._tabbox.tabs.hasChildNodes()) {
      this._tabbox.tabs.removeChild(this._tabbox.tabs.firstChild);
    }

    if (this._currentTool && this._telemetry) {
      this._telemetry.toolClosed(this._currentTool);
    }

    this._toolPanel.emit("sidebar-destroyed", this);

    this._tabs = null;
    this._tabbox = null;
    this._panelDoc = null;
    this._toolPanel = null;
  })
}

XPCOMUtils.defineLazyGetter(this, "l10n", function() {
  let bundle = Services.strings.createBundle("chrome://browser/locale/devtools/toolbox.properties");
  let l10n = function(aName, ...aArgs) {
    try {
      if (aArgs.length == 0) {
        return bundle.GetStringFromName(aName);
      } else {
        return bundle.formatStringFromName(aName, aArgs, aArgs.length);
      }
    } catch (ex) {
      Services.console.logStringMessage("Error reading '" + aName + "'");
    }
  };
  return l10n;
});
