/* 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 { AddonManager } = ChromeUtils.importESModule(
  "resource://gre/modules/AddonManager.sys.mjs",
  // AddonManager is a singleton, never create two instances of it.
  { loadInDevToolsLoader: false }
);
const {
  gDevTools,
} = require("resource://devtools/client/framework/devtools.js");
const TABS_REORDERED_SCALAR = "devtools.toolbox.tabs_reordered";
const PREFERENCE_NAME = "devtools.toolbox.tabsOrder";

/**
 * Manage the order of devtools tabs.
 */
class ToolboxTabsOrderManager {
  constructor(toolbox, onOrderUpdated, panelDefinitions) {
    this.toolbox = toolbox;
    this.onOrderUpdated = onOrderUpdated;
    this.currentPanelDefinitions = panelDefinitions || [];

    this.onMouseDown = this.onMouseDown.bind(this);
    this.onMouseMove = this.onMouseMove.bind(this);
    this.onMouseUp = this.onMouseUp.bind(this);

    Services.prefs.addObserver(PREFERENCE_NAME, this.onOrderUpdated);
  }

  async destroy() {
    Services.prefs.removeObserver(PREFERENCE_NAME, this.onOrderUpdated);

    // Call mouseUp() to clear the state to prepare for in case a dragging was in progress
    // when the destroy() was called.
    await this.onMouseUp();
  }

  insertBefore(target) {
    const xBefore = this.dragTarget.offsetLeft;
    this.toolboxTabsElement.insertBefore(this.dragTarget, target);
    const xAfter = this.dragTarget.offsetLeft;
    this.dragStartX += xAfter - xBefore;
    this.isOrderUpdated = true;
  }

  isFirstTab(tabElement) {
    return !tabElement.previousSibling;
  }

  isLastTab(tabElement) {
    return (
      !tabElement.nextSibling ||
      tabElement.nextSibling.id === "tools-chevron-menu-button"
    );
  }

  isRTL() {
    return this.toolbox.direction === "rtl";
  }

  async saveOrderPreference() {
    const tabs = [...this.toolboxTabsElement.querySelectorAll(".devtools-tab")];
    const tabIds = tabs.map(tab => tab.dataset.extensionId || tab.dataset.id);
    // Concat the overflowed tabs id since they are not contained in visible tabs.
    // The overflowed tabs cannot be reordered so we just append the id from current
    // panel definitions on their order.
    const overflowedTabIds = this.currentPanelDefinitions
      .filter(definition => !tabs.some(tab => tab.dataset.id === definition.id))
      .map(definition => definition.extensionId || definition.id);
    const currentTabIds = tabIds.concat(overflowedTabIds);
    const dragTargetId =
      this.dragTarget.dataset.extensionId || this.dragTarget.dataset.id;
    const prefIds = getTabsOrderFromPreference();
    const absoluteIds = toAbsoluteOrder(prefIds, currentTabIds, dragTargetId);

    // Remove panel id which is not in panel definitions and addons list.
    const extensions = await AddonManager.getAllAddons();
    const definitions = gDevTools.getToolDefinitionArray();
    const result = absoluteIds.filter(
      id =>
        definitions.find(d => id === (d.extensionId || d.id)) ||
        extensions.find(e => id === e.id)
    );

    Services.prefs.setCharPref(PREFERENCE_NAME, result.join(","));
  }

  setCurrentPanelDefinitions(currentPanelDefinitions) {
    this.currentPanelDefinitions = currentPanelDefinitions;
  }

  onMouseDown(e) {
    if (!e.target.classList.contains("devtools-tab")) {
      return;
    }

    this.dragStartX = e.pageX;
    this.dragTarget = e.target;
    this.previousPageX = e.pageX;
    this.toolboxContainerElement =
      this.dragTarget.closest("#toolbox-container");
    this.toolboxTabsElement = this.dragTarget.closest(".toolbox-tabs");
    this.isOrderUpdated = false;
    this.eventTarget = this.dragTarget.ownerGlobal.top;

    this.eventTarget.addEventListener("mousemove", this.onMouseMove);
    this.eventTarget.addEventListener("mouseup", this.onMouseUp);

    this.toolboxContainerElement.classList.add("tabs-reordering");
  }

  onMouseMove(e) {
    const diffPageX = e.pageX - this.previousPageX;
    let dragTargetCenterX =
      this.dragTarget.offsetLeft + diffPageX + this.dragTarget.clientWidth / 2;
    let isDragTargetPreviousSibling = false;

    const tabElements =
      this.toolboxTabsElement.querySelectorAll(".devtools-tab");

    // Calculate the minimum and maximum X-offset that can be valid for the drag target.
    const firstElement = tabElements[0];
    const firstElementCenterX =
      firstElement.offsetLeft + firstElement.clientWidth / 2;
    const lastElement = tabElements[tabElements.length - 1];
    const lastElementCenterX =
      lastElement.offsetLeft + lastElement.clientWidth / 2;
    const max = Math.max(firstElementCenterX, lastElementCenterX);
    const min = Math.min(firstElementCenterX, lastElementCenterX);

    // Normalize the target center X so to remain between the first and last tab.
    dragTargetCenterX = Math.min(max, dragTargetCenterX);
    dragTargetCenterX = Math.max(min, dragTargetCenterX);

    for (const tabElement of tabElements) {
      if (tabElement === this.dragTarget) {
        isDragTargetPreviousSibling = true;
        continue;
      }

      // Is the dragTarget near the center of the other tab?
      const anotherCenterX = tabElement.offsetLeft + tabElement.clientWidth / 2;
      const distanceWithDragTarget = Math.abs(
        dragTargetCenterX - anotherCenterX
      );
      const isReplaceable = distanceWithDragTarget < tabElement.clientWidth / 3;

      if (isReplaceable) {
        const replaceableElement = isDragTargetPreviousSibling
          ? tabElement.nextSibling
          : tabElement;
        this.insertBefore(replaceableElement);
        break;
      }
    }

    let distance = e.pageX - this.dragStartX;

    // To accomodate for RTL locales, we cannot rely on the first/last element of the
    // NodeList. We cannot have negative distances for the leftmost tab, and we cannot
    // have positive distances for the rightmost tab.
    const isFirstTab = this.isFirstTab(this.dragTarget);
    const isLastTab = this.isLastTab(this.dragTarget);
    const isLeftmostTab = this.isRTL() ? isLastTab : isFirstTab;
    const isRightmostTab = this.isRTL() ? isFirstTab : isLastTab;

    if ((isLeftmostTab && distance < 0) || (isRightmostTab && distance > 0)) {
      // If the drag target is already edge of the tabs and the mouse will make the
      // element to move to same direction more, keep the position.
      distance = 0;
    }

    this.dragTarget.style.left = `${distance}px`;
    this.previousPageX = e.pageX;
  }

  async onMouseUp() {
    if (!this.dragTarget) {
      // The case in here has two type:
      // 1. Although destroy method was called, it was not during reordering.
      // 2. Although mouse event occur, destroy method was called during reordering.
      return;
    }

    if (this.isOrderUpdated) {
      await this.saveOrderPreference();

      // Log which tabs reordered. The question we want to answer is:
      // "How frequently are the tabs re-ordered, also which tabs get re-ordered?"
      const toolId =
        this.dragTarget.dataset.extensionId || this.dragTarget.dataset.id;
      this.toolbox.telemetry.keyedScalarAdd(TABS_REORDERED_SCALAR, toolId, 1);
    }

    this.eventTarget.removeEventListener("mousemove", this.onMouseMove);
    this.eventTarget.removeEventListener("mouseup", this.onMouseUp);

    this.toolboxContainerElement.classList.remove("tabs-reordering");
    this.dragTarget.style.left = null;
    this.dragTarget = null;
    this.toolboxContainerElement = null;
    this.toolboxTabsElement = null;
    this.eventTarget = null;
  }
}

function getTabsOrderFromPreference() {
  const pref = Services.prefs.getCharPref(PREFERENCE_NAME, "");
  return pref ? pref.split(",") : [];
}

function sortPanelDefinitions(definitions) {
  const toolIds = getTabsOrderFromPreference();

  return definitions.sort((a, b) => {
    let orderA = toolIds.indexOf(a.extensionId || a.id);
    let orderB = toolIds.indexOf(b.extensionId || b.id);
    orderA = orderA < 0 ? Number.MAX_VALUE : orderA;
    orderB = orderB < 0 ? Number.MAX_VALUE : orderB;
    return orderA - orderB;
  });
}

/*
 * This function returns absolute tab ids that were merged the both ids that are in
 * preference and tabs.
 * Some tabs added with add-ons etc show/hide depending on conditions.
 * However, all of tabs that include hidden tab always keep the relationship with
 * left side tab, except in case the left tab was target of dragging. If the left
 * tab has been moved, it keeps its relationship with the tab next to it.
 *
 * Case 1: Drag a tab to left
 *   currentTabIds: [T1, T2, T3, T4, T5]
 *   prefIds      : [T1, T2, T3, E1(hidden), T4, T5]
 *   drag T4      : [T1, T2, T4, T3, T5]
 *   result       : [T1, T2, T4, T3, E1, T5]
 *
 * Case 2: Drag a tab to right
 *   currentTabIds: [T1, T2, T3, T4, T5]
 *   prefIds      : [T1, T2, T3, E1(hidden), T4, T5]
 *   drag T2      : [T1, T3, T4, T2, T5]
 *   result       : [T1, T3, E1, T4, T2, T5]
 *
 * Case 3: Hidden tab was left end and drag a tab to left end
 *   currentTabIds: [T1, T2, T3, T4, T5]
 *   prefIds      : [E1(hidden), T1, T2, T3, T4, T5]
 *   drag T4      : [T4, T1, T2, T3, T5]
 *   result       : [E1, T4, T1, T2, T3, T5]
 *
 * Case 4: Hidden tab was right end and drag a tab to right end
 *   currentTabIds: [T1, T2, T3, T4, T5]
 *   prefIds      : [T1, T2, T3, T4, T5, E1(hidden)]
 *   drag T1      : [T2, T3, T4, T5, T1]
 *   result       : [T2, T3, T4, T5, E1, T1]
 *
 * @param Array - prefIds: id array of preference
 * @param Array - currentTabIds: id array of appearanced tabs
 * @param String - dragTargetId: id of dragged target
 * @return Array
 */
function toAbsoluteOrder(prefIds, currentTabIds, dragTargetId) {
  currentTabIds = [...currentTabIds];
  let indexAtCurrentTabs = 0;

  for (const prefId of prefIds) {
    if (prefId === dragTargetId) {
      // do nothing
    } else if (currentTabIds.includes(prefId)) {
      indexAtCurrentTabs = currentTabIds.indexOf(prefId) + 1;
    } else {
      currentTabIds.splice(indexAtCurrentTabs, 0, prefId);
      indexAtCurrentTabs += 1;
    }
  }

  return currentTabIds;
}

module.exports.ToolboxTabsOrderManager = ToolboxTabsOrderManager;
module.exports.sortPanelDefinitions = sortPanelDefinitions;
module.exports.toAbsoluteOrder = toAbsoluteOrder;
