/* 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 React from "resource://devtools/client/shared/vendor/react.mjs";
import * as dom from "resource://devtools/client/shared/vendor/react-dom-factories.mjs";
import PropTypes from "resource://devtools/client/shared/vendor/react-prop-types.mjs";

const { Component, createRef } = React;

/**
 * Renders simple 'tab' widget.
 *
 * Based on ReactSimpleTabs component
 * https://github.com/pedronauck/react-simpletabs
 *
 * Component markup (+CSS) example:
 *
 * <div class='tabs'>
 *  <nav class='tabs-navigation'>
 *    <ul class='tabs-menu'>
 *      <li class='tabs-menu-item is-active'>Tab #1</li>
 *      <li class='tabs-menu-item'>Tab #2</li>
 *    </ul>
 *  </nav>
 *  <div class='panels'>
 *    The content of active panel here
 *  </div>
 * <div>
 */
class Tabs extends Component {
  static get propTypes() {
    return {
      className: PropTypes.oneOfType([
        PropTypes.array,
        PropTypes.string,
        PropTypes.object,
      ]),
      activeTab: PropTypes.number,
      onMount: PropTypes.func,
      onBeforeChange: PropTypes.func,
      onAfterChange: PropTypes.func,
      children: PropTypes.oneOfType([PropTypes.array, PropTypes.element])
        .isRequired,
      showAllTabsMenu: PropTypes.bool,
      allTabsMenuButtonTooltip: PropTypes.string,
      onAllTabsMenuClick: PropTypes.func,
      tall: PropTypes.bool,

      // To render a sidebar toggle button before the tab menu provide a function that
      // returns a React component for the button.
      renderSidebarToggle: PropTypes.func,
      // To render a toolbar button after the tab menu provide a function that
      // returns a React component for the button.
      renderToolbarButton: PropTypes.func,
      // Set true will only render selected panel on DOM. It's complete
      // opposite of the created array, and it's useful if panels content
      // is unpredictable and update frequently.
      renderOnlySelected: PropTypes.bool,
    };
  }

  static get defaultProps() {
    return {
      activeTab: 0,
      showAllTabsMenu: false,
      renderOnlySelected: false,
    };
  }

  constructor(props) {
    super(props);

    this.state = {
      activeTab: props.activeTab,

      // This array is used to store an object containing information on whether a tab
      // at a specified index has already been created (e.g. selected at least once) and
      // the tab id. An example of the object structure is the following:
      // [{ isCreated: true, tabId: "ruleview" }, { isCreated: false, tabId: "foo" }].
      // If the tab at the specified index has already been created, it's rendered even
      // if not currently selected. This is because in some cases we don't want
      // to re-create tab content when it's being unselected/selected.
      // E.g. in case of an iframe being used as a tab-content we want the iframe to
      // stay in the DOM.
      created: [],

      // True if tabs can't fit into available horizontal space.
      overflow: false,
    };

    this.tabsEl = createRef();

    this.onOverflow = this.onOverflow.bind(this);
    this.onUnderflow = this.onUnderflow.bind(this);
    this.onKeyDown = this.onKeyDown.bind(this);
    this.onClickTab = this.onClickTab.bind(this);
    this.setActive = this.setActive.bind(this);
    this.renderMenuItems = this.renderMenuItems.bind(this);
    this.renderPanels = this.renderPanels.bind(this);
  }

  componentDidMount() {
    const node = this.tabsEl.current;
    node.addEventListener("keydown", this.onKeyDown);

    // Register overflow listeners to manage visibility
    // of all-tabs-menu. This menu is displayed when there
    // is not enough h-space to render all tabs.
    // It allows the user to select a tab even if it's hidden.
    if (this.props.showAllTabsMenu) {
      node.addEventListener("overflow", this.onOverflow);
      node.addEventListener("underflow", this.onUnderflow);
    }

    const index = this.state.activeTab;
    if (this.props.onMount) {
      this.props.onMount(index);
    }
  }

  // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
  UNSAFE_componentWillReceiveProps(nextProps) {
    let { children, activeTab } = nextProps;
    const panels = children.filter(panel => panel);
    let created = [...this.state.created];

    // If the children props has changed due to an addition or removal of a tab,
    // update the state's created array with the latest tab ids and whether or not
    // the tab is already created.
    if (this.state.created.length != panels.length) {
      created = panels.map(panel => {
        // Get whether or not the tab has already been created from the previous state.
        const createdEntry = this.state.created.find(entry => {
          return entry && entry.tabId === panel.props.id;
        });
        const isCreated = !!createdEntry && createdEntry.isCreated;
        const tabId = panel.props.id;

        return {
          isCreated,
          tabId,
        };
      });
    }

    // Check type of 'activeTab' props to see if it's valid (it's 0-based index).
    if (typeof activeTab === "number") {
      // Reset to index 0 if index overflows the range of panel array
      activeTab = activeTab < panels.length && activeTab >= 0 ? activeTab : 0;

      created[activeTab] = Object.assign({}, created[activeTab], {
        isCreated: true,
      });

      this.setState({
        activeTab,
      });
    }

    this.setState({
      created,
    });
  }

  componentWillUnmount() {
    const node = this.tabsEl.current;
    node.removeEventListener("keydown", this.onKeyDown);

    if (this.props.showAllTabsMenu) {
      node.removeEventListener("overflow", this.onOverflow);
      node.removeEventListener("underflow", this.onUnderflow);
    }
  }

  // DOM Events

  onOverflow(event) {
    if (event.target.classList.contains("tabs-menu")) {
      this.setState({
        overflow: true,
      });
    }
  }

  onUnderflow(event) {
    if (event.target.classList.contains("tabs-menu")) {
      this.setState({
        overflow: false,
      });
    }
  }

  onKeyDown(event) {
    // Bail out if the focus isn't on a tab.
    if (!event.target.closest(".tabs-menu-item")) {
      return;
    }

    let activeTab = this.state.activeTab;
    const tabCount = this.props.children.length;

    const ltr = event.target.ownerDocument.dir == "ltr";
    const nextOrLastTab = Math.min(tabCount - 1, activeTab + 1);
    const previousOrFirstTab = Math.max(0, activeTab - 1);

    switch (event.code) {
      case "ArrowRight":
        if (ltr) {
          activeTab = nextOrLastTab;
        } else {
          activeTab = previousOrFirstTab;
        }
        break;
      case "ArrowLeft":
        if (ltr) {
          activeTab = previousOrFirstTab;
        } else {
          activeTab = nextOrLastTab;
        }
        break;
    }

    if (this.state.activeTab != activeTab) {
      this.setActive(activeTab);
    }
  }

  onClickTab(index, event) {
    this.setActive(index, { fromMouseEvent: true });

    if (event) {
      event.preventDefault();
    }
  }

  onMouseDown(event) {
    // Prevents click-dragging the tab headers
    if (event) {
      event.preventDefault();
    }
  }

  // API

  /**
   * Set the active tab from its index
   *
   * @param {Integer} index
   *        Index of the tab that we want to set as the active one
   * @param {object} options
   * @param {boolean} options.fromMouseEvent
   *        Set to true if this is called from a click on the tab
   */
  setActive(index, options = {}) {
    const onAfterChange = this.props.onAfterChange;
    const onBeforeChange = this.props.onBeforeChange;

    if (onBeforeChange) {
      const cancel = onBeforeChange(index);
      if (cancel) {
        return;
      }
    }

    const created = [...this.state.created];
    created[index] = Object.assign({}, created[index], {
      isCreated: true,
    });

    const newState = Object.assign({}, this.state, {
      created,
      activeTab: index,
    });

    this.setState(newState, () => {
      // Properly set focus on selected tab.
      const selectedTab = this.tabsEl.current.querySelector(
        `a[data-tab-index="${index}"]`
      );
      selectedTab.focus({
        // When focus is coming from a mouse event,
        // prevent :focus-visible to be applied to the element
        focusVisible: !options.fromMouseEvent,
      });

      if (onAfterChange) {
        onAfterChange(index);
      }
    });
  }

  // Rendering

  renderMenuItems() {
    if (!this.props.children) {
      throw new Error("There must be at least one Tab");
    }

    if (!Array.isArray(this.props.children)) {
      this.props.children = [this.props.children];
    }

    const tabs = this.props.children
      .map(tab => (typeof tab === "function" ? tab() : tab))
      .filter(tab => tab)
      .map((tab, index) => {
        const {
          id,
          className: tabClassName,
          title,
          tooltip,
          badge,
          showBadge,
        } = tab.props;

        const ref = "tab-menu-" + index;
        const isTabSelected = this.state.activeTab === index;

        const className = [
          "tabs-menu-item",
          tabClassName,
          isTabSelected ? "is-active" : "",
        ].join(" ");

        // Set tabindex to -1 (except the selected tab) so, it's focusable,
        // but not reachable via sequential tab-key navigation.
        // Changing selected tab (and so, moving focus) is done through
        // left and right arrow keys.
        // See also `onKeyDown()` event handler.
        return dom.li(
          {
            className,
            key: index,
            ref,
            role: "presentation",
          },
          dom.span({ className: "devtools-tab-line" }),
          dom.a(
            {
              id: id ? id + "-tab" : "tab-" + index,
              tabIndex: isTabSelected ? 0 : -1,
              title: tooltip || title,
              "aria-controls": id ? id + "-panel" : "panel-" + index,
              "aria-selected": isTabSelected,
              role: "tab",
              onClick: this.onClickTab.bind(this, index),
              onMouseDown: this.onMouseDown.bind(this),
              "data-tab-index": index,
            },
            title,
            badge && !isTabSelected && showBadge()
              ? dom.span({ className: "tab-badge" }, badge)
              : null
          )
        );
      });

    // Display the menu only if there is not enough horizontal
    // space for all tabs (and overflow happened).
    const allTabsMenu = this.state.overflow
      ? dom.button({
          className: "all-tabs-menu",
          title: this.props.allTabsMenuButtonTooltip,
          onClick: this.props.onAllTabsMenuClick,
        })
      : null;

    // Get the sidebar toggle button if a renderSidebarToggle function is provided.
    const sidebarToggle = this.props.renderSidebarToggle
      ? this.props.renderSidebarToggle()
      : null;

    // Get the toolbar button if a renderToolbarButton function is provided.
    const toolbarButton = this.props.renderToolbarButton
      ? this.props.renderToolbarButton()
      : null;

    return dom.nav(
      { className: "tabs-navigation" },
      sidebarToggle,
      dom.ul({ className: "tabs-menu", role: "tablist" }, tabs),
      allTabsMenu,
      toolbarButton
    );
  }

  renderPanels() {
    let { children, renderOnlySelected } = this.props;

    if (!children) {
      throw new Error("There must be at least one Tab");
    }

    if (!Array.isArray(children)) {
      children = [children];
    }

    const selectedIndex = this.state.activeTab;

    const panels = children
      .map(tab => (typeof tab === "function" ? tab() : tab))
      .filter(tab => tab)
      .map((tab, index) => {
        const selected = selectedIndex === index;
        if (renderOnlySelected && !selected) {
          return null;
        }

        const id = tab.props.id;
        const isCreated =
          this.state.created[index] && this.state.created[index].isCreated;

        // Use 'visibility:hidden' + 'height:0' for hiding content of non-selected
        // tab. It's faster than 'display:none' because it avoids triggering frame
        // destruction and reconstruction. 'width' is not changed to avoid relayout.
        const style = {
          visibility: selected ? "visible" : "hidden",
          height: selected ? "100%" : "0",
        };

        // Allows lazy loading panels by creating them only if they are selected,
        // then store a copy of the lazy created panel in `tab.panel`.
        if (typeof tab.panel == "function" && selected) {
          tab.panel = tab.panel(tab);
        }
        const panel = tab.panel || tab;

        return dom.div(
          {
            id: id ? id + "-panel" : "panel-" + index,
            key: id,
            style,
            className: selected ? "tab-panel-box" : "tab-panel-box hidden",
            role: "tabpanel",
            "aria-labelledby": id ? id + "-tab" : "tab-" + index,
          },
          selected || isCreated ? panel : null
        );
      });

    return dom.div({ className: "panels" }, panels);
  }

  render() {
    return dom.div(
      {
        className: [
          "tabs",
          ...(this.props.tall ? ["tabs-tall"] : []),
          this.props.className,
        ].join(" "),
        ref: this.tabsEl,
      },
      this.renderMenuItems(),
      this.renderPanels()
    );
  }
}

/**
 * Renders simple tab 'panel'.
 */
class TabPanel extends Component {
  static get propTypes() {
    return {
      id: PropTypes.string.isRequired,
      className: PropTypes.string,
      title: PropTypes.string.isRequired,
      children: PropTypes.oneOfType([PropTypes.array, PropTypes.element])
        .isRequired,
    };
  }

  render() {
    const { className } = this.props;
    return dom.div(
      { className: `tab-panel ${className || ""}` },
      this.props.children
    );
  }
}

// Exports from this module
export { TabPanel, Tabs };
