import Popper from 'popper.js';
import { extend } from '../vue';
import { NAME_DROPDOWN } from '../constants/components';
import { EVENT_NAME_SHOWN, EVENT_NAME_HIDDEN, EVENT_NAME_TOGGLE, EVENT_NAME_CLICK, EVENT_NAME_SHOW, EVENT_NAME_HIDE } from '../constants/events';
import { CODE_ENTER, CODE_SPACE, CODE_DOWN, CODE_ESC, CODE_UP } from '../constants/key-codes';
import { PLACEMENT_TOP_END, PLACEMENT_TOP_START, PLACEMENT_BOTTOM_START, PLACEMENT_RIGHT_START, PLACEMENT_LEFT_START, PLACEMENT_BOTTOM_END } from '../constants/popper';
import { PROP_TYPE_STRING, PROP_TYPE_BOOLEAN, PROP_TYPE_NUMBER_STRING, PROP_TYPE_OBJECT } from '../constants/props';
import { HTMLElement } from '../constants/safe-types';
import { BvEvent } from '../utils/bv-event.class';
import { requestAF, contains, closest, attemptFocus, selectAll, isVisible } from '../utils/dom';
import { getRootEventName, stopEvent } from '../utils/events';
import { sortKeys, mergeDeep } from '../utils/object';
import { makePropsConfigurable, makeProp } from '../utils/props';
import { warn } from '../utils/warn';
import { clickOutMixin } from './click-out';
import { focusInMixin } from './focus-in';
import { props as props$1, idMixin } from './id';
import { listenOnRootMixin } from './listen-on-root';
import { registerElementToInstance, removeElementToInstance } from '../utils/element-to-vue-instance-registry';

// --- Constants ---

const ROOT_EVENT_NAME_SHOWN = getRootEventName(NAME_DROPDOWN, EVENT_NAME_SHOWN);
const ROOT_EVENT_NAME_HIDDEN = getRootEventName(NAME_DROPDOWN, EVENT_NAME_HIDDEN);

// CSS selectors
const SELECTOR_FORM_CHILD = '.dropdown form';
const SELECTOR_ITEM = ['.dropdown-item', '.b-dropdown-form'].map(selector => `${selector}:not(.disabled):not([disabled])`).join(', ');

// --- Helper methods ---

// Return an array of visible items
const filterVisibles = els => (els || []).filter(isVisible);

// --- Props ---

const props = makePropsConfigurable(sortKeys({
  ...props$1,
  // String: `scrollParent`, `window` or `viewport`
  // HTMLElement: HTML Element reference
  boundary: makeProp([HTMLElement, PROP_TYPE_STRING], 'scrollParent'),
  disabled: makeProp(PROP_TYPE_BOOLEAN, false),
  // Place left if possible
  dropleft: makeProp(PROP_TYPE_BOOLEAN, false),
  // Place right if possible
  dropright: makeProp(PROP_TYPE_BOOLEAN, false),
  // Place on top if possible
  dropup: makeProp(PROP_TYPE_BOOLEAN, false),
  // Disable auto-flipping of menu from bottom <=> top
  noFlip: makeProp(PROP_TYPE_BOOLEAN, false),
  // Number of pixels or a CSS unit value to offset menu
  // (i.e. `1px`, `1rem`, etc.)
  offset: makeProp(PROP_TYPE_NUMBER_STRING, 0),
  popperOpts: makeProp(PROP_TYPE_OBJECT, {}),
  // Right align menu (default is left align)
  right: makeProp(PROP_TYPE_BOOLEAN, false)
}), NAME_DROPDOWN);

// --- Mixin ---

// @vue/component
const dropdownMixin = extend({
  mixins: [idMixin, listenOnRootMixin, clickOutMixin, focusInMixin],
  provide() {
    return {
      getBvDropdown: () => this
    };
  },
  props,
  data() {
    return {
      visible: false,
      visibleChangePrevented: false
    };
  },
  computed: {
    toggler() {
      const {
        toggle
      } = this.$refs;
      return toggle ? toggle.$el || toggle : null;
    },
    directionClass() {
      if (this.dropup) {
        return 'dropup';
      } else if (this.dropright) {
        return 'dropright';
      } else if (this.dropleft) {
        return 'dropleft';
      }
      return '';
    },
    boundaryClass() {
      // Position `static` is needed to allow menu to "breakout" of the `scrollParent`
      // boundaries when boundary is anything other than `scrollParent`
      // See: https://github.com/twbs/bootstrap/issues/24251#issuecomment-341413786
      return this.boundary !== 'scrollParent' ? 'gl-static' : '';
    }
  },
  watch: {
    visible(newValue, oldValue) {
      if (this.visibleChangePrevented) {
        this.visibleChangePrevented = false;
        return;
      }
      if (newValue !== oldValue) {
        const eventName = newValue ? EVENT_NAME_SHOW : EVENT_NAME_HIDE;
        const bvEvent = new BvEvent(eventName, {
          cancelable: true,
          vueTarget: this,
          target: this.$refs.menu,
          relatedTarget: null,
          componentId: this.safeId ? this.safeId() : this.id || null
        });
        this.emitEvent(bvEvent);
        if (bvEvent.defaultPrevented) {
          // Reset value and exit if canceled
          this.visibleChangePrevented = true;
          this.visible = oldValue;
          // Just in case a child element triggered `this.hide(true)`
          this.$off(EVENT_NAME_HIDDEN, this.focusToggler);
          return;
        }
        if (newValue) {
          this.showMenu();
        } else {
          this.hideMenu();
        }
      }
    },
    disabled(newValue, oldValue) {
      if (newValue !== oldValue && newValue && this.visible) {
        // Hide dropdown if disabled changes to true
        this.visible = false;
      }
    }
  },
  created() {
    // Create private non-reactive props
    this.$_popper = null;
  },
  /* istanbul ignore next */
  deactivated() {
    // In case we are inside a `<keep-alive>`
    this.visible = false;
    this.whileOpenListen(false);
    this.destroyPopper();
  },
  mounted() {
    registerElementToInstance(this.$el, this);
  },
  beforeDestroy() {
    this.visible = false;
    this.whileOpenListen(false);
    this.destroyPopper();
    removeElementToInstance(this.$el);
  },
  methods: {
    // Event emitter
    emitEvent(bvEvent) {
      const {
        type
      } = bvEvent;
      this.emitOnRoot(getRootEventName(NAME_DROPDOWN, type), bvEvent);
      this.$emit(type, bvEvent);
    },
    showMenu() {
      if (this.disabled) {
        /* istanbul ignore next */
        return;
      }
      if (typeof Popper === 'undefined') {
        /* istanbul ignore next */
        warn('Popper.js not found. Falling back to CSS positioning', NAME_DROPDOWN);
      } else {
        // For dropup with alignment we use the parent element as popper container
        let el = this.dropup && this.right || this.split ? this.$el : this.$refs.toggle;
        // Make sure we have a reference to an element, not a component!
        el = el.$el || el;
        // Instantiate Popper.js
        this.createPopper(el);
      }

      // Ensure other menus are closed
      this.emitOnRoot(ROOT_EVENT_NAME_SHOWN, this);

      // Enable listeners
      this.whileOpenListen(true);

      // Wrap in `$nextTick()` to ensure menu is fully rendered/shown
      this.$nextTick(() => {
        // Focus on the menu container on show
        this.focusMenu();
        // Emit the shown event
        this.$emit(EVENT_NAME_SHOWN);
      });
    },
    hideMenu() {
      this.whileOpenListen(false);
      this.emitOnRoot(ROOT_EVENT_NAME_HIDDEN, this);
      this.$emit(EVENT_NAME_HIDDEN);
      this.destroyPopper();
    },
    createPopper(element) {
      this.destroyPopper();
      this.$_popper = new Popper(element, this.$refs.menu, this.getPopperConfig());
    },
    // Ensure popper event listeners are removed cleanly
    destroyPopper() {
      this.$_popper && this.$_popper.destroy();
      this.$_popper = null;
    },
    // Instructs popper to re-computes the dropdown position
    // useful if the content changes size
    updatePopper() {
      try {
        this.$_popper.scheduleUpdate();
      } catch {}
    },
    getPopperConfig() {
      let placement = PLACEMENT_BOTTOM_START;
      if (this.dropup) {
        placement = this.right ? PLACEMENT_TOP_END : PLACEMENT_TOP_START;
      } else if (this.dropright) {
        placement = PLACEMENT_RIGHT_START;
      } else if (this.dropleft) {
        placement = PLACEMENT_LEFT_START;
      } else if (this.right) {
        placement = PLACEMENT_BOTTOM_END;
      }
      const popperConfig = {
        placement,
        modifiers: {
          offset: {
            offset: this.offset || 0
          },
          flip: {
            enabled: !this.noFlip
          }
        }
      };
      const boundariesElement = this.boundary;
      if (boundariesElement) {
        popperConfig.modifiers.preventOverflow = {
          boundariesElement
        };
      }
      return mergeDeep(popperConfig, this.popperOpts || {});
    },
    // Turn listeners on/off while open
    whileOpenListen(isOpen) {
      // Hide the dropdown when clicked outside
      this.listenForClickOut = isOpen;
      // Hide the dropdown when it loses focus
      this.listenForFocusIn = isOpen;
      // Hide the dropdown when another dropdown is opened
      const method = isOpen ? 'listenOnRoot' : 'listenOffRoot';
      this[method](ROOT_EVENT_NAME_SHOWN, this.rootCloseListener);
    },
    rootCloseListener(vm) {
      if (vm !== this) {
        this.visible = false;
      }
    },
    // Public method to show dropdown
    show() {
      if (this.disabled) {
        return;
      }
      // Wrap in a `requestAF()` to allow any previous
      // click handling to occur first
      requestAF(() => {
        this.visible = true;
      });
    },
    // Public method to hide dropdown
    hide() {
      let refocus = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
      /* istanbul ignore next */
      if (this.disabled) {
        return;
      }
      this.visible = false;
      if (refocus) {
        // Child element is closing the dropdown on click
        this.$once(EVENT_NAME_HIDDEN, this.focusToggler);
      }
    },
    // Called only by a button that toggles the menu
    toggle(event) {
      event = event || {};
      // Early exit when not a click event or ENTER, SPACE or DOWN were pressed
      const {
        type,
        keyCode
      } = event;
      if (type !== 'click' && !(type === 'keydown' && [CODE_ENTER, CODE_SPACE, CODE_DOWN].indexOf(keyCode) !== -1)) {
        /* istanbul ignore next */
        return;
      }
      /* istanbul ignore next */
      if (this.disabled) {
        this.visible = false;
        return;
      }
      this.$emit(EVENT_NAME_TOGGLE, event);
      stopEvent(event);
      // Toggle visibility
      if (this.visible) {
        this.hide(true);
      } else {
        this.show();
      }
    },
    // Mousedown handler for the toggle
    /* istanbul ignore next */
    onMousedown(event) {
      // We prevent the 'mousedown' event for the toggle to stop the
      // 'focusin' event from being fired
      // The event would otherwise be picked up by the global 'focusin'
      // listener and there is no cross-browser solution to detect it
      // relates to the toggle click
      // The 'click' event will still be fired and we handle closing
      // other dropdowns there too
      // See https://github.com/bootstrap-vue/bootstrap-vue/issues/4328
      stopEvent(event, {
        propagation: false
      });
    },
    // Called from dropdown menu context
    onKeydown(event) {
      const {
        keyCode
      } = event;
      if (keyCode === CODE_ESC) {
        // Close on ESC
        this.onEsc(event);
      } else if (keyCode === CODE_DOWN) {
        // Down Arrow
        this.focusNext(event, false);
      } else if (keyCode === CODE_UP) {
        // Up Arrow
        this.focusNext(event, true);
      }
    },
    // If user presses ESC, close the menu
    onEsc(event) {
      if (this.visible) {
        this.visible = false;
        stopEvent(event);
        // Return focus to original trigger button
        this.$once(EVENT_NAME_HIDDEN, this.focusToggler);
      }
    },
    // Called only in split button mode, for the split button
    onSplitClick(event) {
      /* istanbul ignore next */
      if (this.disabled) {
        this.visible = false;
        return;
      }
      this.$emit(EVENT_NAME_CLICK, event);
    },
    // Shared hide handler between click-out and focus-in events
    hideHandler(event) {
      const {
        target
      } = event;
      if (this.visible && !contains(this.$refs.menu, target) && !contains(this.toggler, target)) {
        this.hide();
      }
    },
    // Document click-out listener
    clickOutHandler(event) {
      this.hideHandler(event);
    },
    // Document focus-in listener
    focusInHandler(event) {
      this.hideHandler(event);
    },
    // Keyboard nav
    focusNext(event, up) {
      // Ignore key up/down on form elements
      const {
        target
      } = event;
      if (!this.visible || event && closest(SELECTOR_FORM_CHILD, target)) {
        /* istanbul ignore next: should never happen */
        return;
      }
      stopEvent(event);
      this.$nextTick(() => {
        const items = this.getItems();
        if (items.length < 1) {
          /* istanbul ignore next: should never happen */
          return;
        }
        let index = items.indexOf(target);
        if (up && index > 0) {
          index--;
        } else if (!up && index < items.length - 1) {
          index++;
        }
        if (index < 0) {
          /* istanbul ignore next: should never happen */
          index = 0;
        }
        this.focusItem(index, items);
      });
    },
    focusItem(index, items) {
      const el = items.find((el, i) => i === index);
      attemptFocus(el);
    },
    getItems() {
      // Get all items
      return filterVisibles(selectAll(SELECTOR_ITEM, this.$refs.menu));
    },
    focusMenu() {
      attemptFocus(this.$refs.menu);
    },
    focusToggler() {
      this.$nextTick(() => {
        attemptFocus(this.toggler);
      });
    }
  }
});

export { dropdownMixin, props };
