import { uniqueId } from 'lodash';
import { historyReplaceState, NO_SCROLL_TO_HASH_CLASS } from '~/lib/utils/common_utils';
import {
  ACTIVE_TAB_CLASSES,
  ATTR_ROLE,
  ATTR_ARIA_CONTROLS,
  ATTR_TABINDEX,
  ATTR_ARIA_SELECTED,
  ATTR_ARIA_LABELLEDBY,
  ACTIVE_PANEL_CLASS,
  KEY_CODE_LEFT,
  KEY_CODE_UP,
  KEY_CODE_RIGHT,
  KEY_CODE_DOWN,
  TAB_SHOWN_EVENT,
  HISTORY_TYPE_HASH,
  ALLOWED_HISTORY_TYPES,
} from './constants';

export { TAB_SHOWN_EVENT, HISTORY_TYPE_HASH };

/**
 * The `GlTabsBehavior` class adds interactivity to tabs created by the `gl_tabs_nav` and
 * `gl_tab_link_to` Rails helpers.
 *
 * Example using `href` references:
 *
 *     ```haml
 *     = gl_tabs_nav({ class: 'js-my-tabs' }) do
 *       = gl_tab_link_to '#foo', item_active: true do
 *         = _('Foo')
 *       = gl_tab_link_to '#bar' do
 *         = _('Bar')
 *
 *     .tab-content
 *       .tab-pane.active#foo
 *       .tab-pane#bar
 *     ```
 *
 *     ```javascript
 *     import { GlTabsBehavior } from '~/tabs';
 *
 *     const glTabs = new GlTabsBehavior(document.querySelector('.js-my-tabs'));
 *     ```
 *
 * Example using `aria-controls` references:
 *
 *     ```haml
 *     = gl_tabs_nav({ class: 'js-my-tabs' }) do
 *       = gl_tab_link_to '#', item_active: true, 'aria-controls': 'foo' do
 *         = _('Foo')
 *       = gl_tab_link_to '#', 'aria-controls': 'bar' do
 *         = _('Bar')
 *
 *     .tab-content
 *       .tab-pane.active#foo
 *       .tab-pane#bar
 *     ```
 *
 *     ```javascript
 *     import { GlTabsBehavior } from '~/tabs';
 *
 *     const glTabs = new GlTabsBehavior(document.querySelector('.js-my-tabs'));
 *     ```
 *
 * `GlTabsBehavior` can be used to replace Bootstrap tab implementations that cannot
 * easily be rewritten in Vue.
 *
 * NOTE: Do *not* use `GlTabsBehavior` with markup generated by other means, as it may not
 * work correctly.
 *
 * Tab panels must exist somewhere in the page for the tabs to control. Tab panels
 * must:
 *  - be immediate children of a `.tab-content` element
 *  - have the `tab-pane` class
 *  - if the panel is active, have the `active` class
 *  - have a unique `id` attribute
 *
 * In order to associate tabs with panels, the tabs must reference their panel's
 * `id` by having one of the following attributes:
 *  - `href`, e.g., `href="#the-panel-id"` (note the leading `#` in the value)
 *  - `aria-controls`, e.g., `aria-controls="the-panel-id"` (no leading `#`)
 *
 * Exactly one tab/panel must be active in the original markup.
 *
 * Call the `destroy` method on an instance to remove event listeners that were
 * added during construction. Other DOM mutations (like ARIA attributes) are
 * _not_ reverted.
 */
export class GlTabsBehavior {
  /**
   * Create a GlTabsBehavior instance.
   *
   * @param {HTMLElement} el - The element created by the Rails `gl_tabs_nav` helper.
   * @param {Object} [options]
   * @param {'hash' | null} [options.history=null] - Sets the type of routing GlTabs will use when navigating between tabs.
   *    'hash': Updates the URL hash with the current tab ID.
   *    null: No routing mechanism will be used.
   */
  constructor(el, { history = null } = {}) {
    if (!el) {
      throw new Error('Cannot instantiate GlTabsBehavior without an element');
    }

    this.destroyFns = [];
    this.tabList = el;
    this.tabs = this.getTabs();
    this.activeTab = null;

    this.history = ALLOWED_HISTORY_TYPES.includes(history) ? history : null;

    this.setAccessibilityAttrs();
    this.bindEvents();
    if (this.history === HISTORY_TYPE_HASH) this.loadInitialTab();
  }

  setAccessibilityAttrs() {
    this.tabList.setAttribute(ATTR_ROLE, 'tablist');
    this.tabs.forEach((tab) => {
      if (!tab.hasAttribute('id')) {
        tab.setAttribute('id', uniqueId('gl_tab_nav__tab_'));
      }

      if (!this.activeTab && tab.classList.contains(ACTIVE_TAB_CLASSES[0])) {
        this.activeTab = tab;
        tab.setAttribute(ATTR_ARIA_SELECTED, 'true');
        tab.removeAttribute(ATTR_TABINDEX);
      } else {
        tab.setAttribute(ATTR_ARIA_SELECTED, 'false');
        tab.setAttribute(ATTR_TABINDEX, '-1');
      }

      tab.setAttribute(ATTR_ROLE, 'tab');
      tab.closest('.nav-item').setAttribute(ATTR_ROLE, 'presentation');

      const tabPanel = this.getPanelForTab(tab);
      if (!tab.hasAttribute(ATTR_ARIA_CONTROLS)) {
        tab.setAttribute(ATTR_ARIA_CONTROLS, tabPanel.id);
      }

      tabPanel.classList.add(NO_SCROLL_TO_HASH_CLASS);
      tabPanel.setAttribute(ATTR_ROLE, 'tabpanel');
      tabPanel.setAttribute(ATTR_ARIA_LABELLEDBY, tab.id);
    });
  }

  bindEvents() {
    this.tabs.forEach((tab) => {
      this.bindEvent(tab, 'click', (event) => {
        event.preventDefault();

        if (tab !== this.activeTab) {
          this.activateTab(tab);
        }
      });

      this.bindEvent(tab, 'keydown', (event) => {
        const { code } = event;
        if (code === KEY_CODE_UP || code === KEY_CODE_LEFT) {
          event.preventDefault();
          this.activatePreviousTab();
        } else if (code === KEY_CODE_DOWN || code === KEY_CODE_RIGHT) {
          event.preventDefault();
          this.activateNextTab();
        }
      });
    });
  }

  bindEvent(el, ...args) {
    el.addEventListener(...args);

    this.destroyFns.push(() => {
      el.removeEventListener(...args);
    });
  }

  loadInitialTab() {
    const tab = this.tabList.querySelector(`a[href="${CSS.escape(window.location.hash)}"]`);
    this.activateTab(tab || this.activeTab);
  }

  activatePreviousTab() {
    const currentTabIndex = this.tabs.indexOf(this.activeTab);

    if (currentTabIndex <= 0) return;

    const previousTab = this.tabs[currentTabIndex - 1];
    this.activateTab(previousTab);
    previousTab.focus();
  }

  activateNextTab() {
    const currentTabIndex = this.tabs.indexOf(this.activeTab);

    if (currentTabIndex >= this.tabs.length - 1) return;

    const nextTab = this.tabs[currentTabIndex + 1];
    this.activateTab(nextTab);
    nextTab.focus();
  }

  getTabs() {
    return Array.from(this.tabList.querySelectorAll('.gl-tab-nav-item'));
  }

  // eslint-disable-next-line class-methods-use-this
  getPanelForTab(tab) {
    const ariaControls = tab.getAttribute(ATTR_ARIA_CONTROLS);

    if (ariaControls) {
      return document.querySelector(`#${ariaControls}`);
    }

    return document.querySelector(tab.getAttribute('href'));
  }

  activateTab(tabToActivate) {
    // Deactivate active tab first
    this.activeTab.setAttribute(ATTR_ARIA_SELECTED, 'false');
    this.activeTab.setAttribute(ATTR_TABINDEX, '-1');
    this.activeTab.classList.remove(...ACTIVE_TAB_CLASSES);

    const activePanel = this.getPanelForTab(this.activeTab);
    activePanel.classList.remove(ACTIVE_PANEL_CLASS);

    // Now activate the given tab/panel
    tabToActivate.setAttribute(ATTR_ARIA_SELECTED, 'true');
    tabToActivate.removeAttribute(ATTR_TABINDEX);
    tabToActivate.classList.add(...ACTIVE_TAB_CLASSES);

    const tabPanel = this.getPanelForTab(tabToActivate);
    tabPanel.classList.add(ACTIVE_PANEL_CLASS);

    if (this.history === HISTORY_TYPE_HASH) historyReplaceState(tabToActivate.getAttribute('href'));
    this.activeTab = tabToActivate;

    this.dispatchTabShown(tabToActivate, tabPanel);
  }

  // eslint-disable-next-line class-methods-use-this
  dispatchTabShown(tab, activeTabPanel) {
    const event = new CustomEvent(TAB_SHOWN_EVENT, {
      bubbles: true,
      detail: {
        activeTabPanel,
      },
    });

    tab.dispatchEvent(event);
  }

  destroy() {
    this.destroyFns.forEach((destroy) => destroy());
  }
}
