import { extend, COMPONENT_UID_KEY } from '../../../vue';
import { NAME_MODAL, NAME_TOOLTIP_HELPER } from '../../../constants/components';
import { EVENT_NAME_HIDDEN, EVENT_NAME_SHOW, EVENT_NAME_SHOWN, EVENT_NAME_HIDE, EVENT_NAME_FOCUSIN, EVENT_NAME_FOCUSOUT, EVENT_NAME_MOUSEENTER, EVENT_NAME_MOUSELEAVE, EVENT_NAME_ENABLED, EVENT_NAME_DISABLED, EVENT_OPTIONS_NO_CAPTURE, EVENT_NAME_DISABLE, EVENT_NAME_ENABLE } from '../../../constants/events';
import { onInstanceDestroy } from '../../../utils/on-instance-destroy';
import { useParentMixin } from '../../../mixins/use-parent';
import { concat, arrayIncludes, from } from '../../../utils/array';
import { getInstanceFromElement } from '../../../utils/element-to-vue-instance-registry';
import { requestAF, contains, isVisible, getById, isElement, closest, hasClass, select, getAttr, setAttr, removeAttr, hasAttr, isDisabled, attemptFocus } from '../../../utils/dom';
import { getRootEventName, eventOn, eventOff, getRootActionEventName, eventOnOff } from '../../../utils/events';
import { getScopeId } from '../../../utils/get-scope-id';
import { identity } from '../../../utils/identity';
import { isPlainObject, isNumber, isString, isUndefined, isUndefinedOrNull, isFunction } from '../../../utils/inspect';
import { looseEqual } from '../../../utils/loose-equal';
import { mathMax } from '../../../utils/math';
import { noop } from '../../../utils/noop';
import { toInteger } from '../../../utils/number';
import { keys } from '../../../utils/object';
import { warn } from '../../../utils/warn';
import { BvEvent } from '../../../utils/bv-event.class';
import { createNewChildComponent, eventProp } from '../../../utils/create-new-child-component';
import { listenOnRootMixin } from '../../../mixins/listen-on-root';
import { BVTooltipTemplate } from './bv-tooltip-template';

// Tooltip "Class" (Built as a renderless Vue instance)
//
// Handles trigger events, etc.
// Instantiates template on demand


// --- Constants ---

// Modal container selector for appending tooltip/popover
const MODAL_SELECTOR = '.modal-content';

// Modal `$root` hidden event
const ROOT_EVENT_NAME_MODAL_HIDDEN = getRootEventName(NAME_MODAL, EVENT_NAME_HIDDEN);

// For finding the container to append to
const CONTAINER_SELECTOR = MODAL_SELECTOR;

// For dropdown sniffing
const DROPDOWN_CLASS = 'dropdown';
const DROPDOWN_OPEN_SELECTOR = '.dropdown-menu.show';

// Data attribute to temporary store the `title` attribute's value
const DATA_TITLE_ATTR = 'data-original-title';

// Data specific to popper and template
// We don't use props, as we need reactivity (we can't pass reactive props)
const templateData = {
  // Text string or Scoped slot function
  title: '',
  // Text string or Scoped slot function
  content: '',
  // String
  variant: null,
  // String, Array, Object
  customClass: null,
  // String or array of Strings (overwritten by BVPopper)
  triggers: '',
  // String (overwritten by BVPopper)
  placement: 'auto',
  // String or array of strings
  fallbackPlacement: 'flip',
  // Element or Component reference (or function that returns element) of
  // the element that will have the trigger events bound, and is also
  // default element for positioning
  target: null,
  // HTML ID, Element or Component reference
  container: null,
  // 'body'
  // Boolean
  noFade: false,
  // 'scrollParent', 'viewport', 'window', Element, or Component reference
  boundary: 'scrollParent',
  // Tooltip/popover will try and stay away from
  // boundary edge by this many pixels (Number)
  boundaryPadding: 5,
  // Arrow offset (Number)
  offset: 0,
  // Hover/focus delay (Number or Object)
  delay: 0,
  // Arrow of Tooltip/popover will try and stay away from
  // the edge of tooltip/popover edge by this many pixels
  arrowPadding: 6,
  // Interactive state (Boolean)
  interactive: true,
  // Disabled state (Boolean)
  disabled: false,
  // ID to use for tooltip/popover
  id: null,
  // Flag used by directives only, for HTML content
  html: false
};

// --- Main component ---

// @vue/component
const BVTooltip = /*#__PURE__*/extend({
  name: NAME_TOOLTIP_HELPER,
  mixins: [listenOnRootMixin, useParentMixin],
  data() {
    return {
      // BTooltip/BPopover/VBTooltip will update this data
      // Via the exposed updateData() method on this instance
      // BVPopover will override some of these defaults
      ...templateData,
      // State management data
      activeTrigger: {
        // manual: false,
        hover: false,
        click: false,
        focus: false
      },
      localShow: false
    };
  },
  computed: {
    templateType() {
      // Overwritten by BVPopover
      return 'tooltip';
    },
    computedId() {
      return this.id || `__bv_${this.templateType}_${this[COMPONENT_UID_KEY]}__`;
    },
    computedDelay() {
      // Normalizes delay into object form
      const delay = {
        show: 0,
        hide: 0
      };
      if (isPlainObject(this.delay)) {
        delay.show = mathMax(toInteger(this.delay.show, 0), 0);
        delay.hide = mathMax(toInteger(this.delay.hide, 0), 0);
      } else if (isNumber(this.delay) || isString(this.delay)) {
        delay.show = delay.hide = mathMax(toInteger(this.delay, 0), 0);
      }
      return delay;
    },
    computedTriggers() {
      // Returns the triggers in sorted array form
      // TODO: Switch this to object form for easier lookup
      return concat(this.triggers).filter(identity).join(' ').trim().toLowerCase().split(/\s+/).sort();
    },
    isWithActiveTrigger() {
      for (const trigger in this.activeTrigger) {
        if (this.activeTrigger[trigger]) {
          return true;
        }
      }
      return false;
    },
    computedTemplateData() {
      const {
        title,
        content,
        variant,
        customClass,
        noFade,
        interactive
      } = this;
      return {
        title,
        content,
        variant,
        customClass,
        noFade,
        interactive
      };
    }
  },
  watch: {
    computedTriggers(newTriggers, oldTriggers) {
      // Triggers have changed, so re-register them
      /* istanbul ignore next */
      if (!looseEqual(newTriggers, oldTriggers)) {
        this.$nextTick(() => {
          // Disable trigger listeners
          this.unListen();
          // Clear any active triggers that are no longer in the list of triggers
          oldTriggers.forEach(trigger => {
            if (!arrayIncludes(newTriggers, trigger)) {
              if (this.activeTrigger[trigger]) {
                this.activeTrigger[trigger] = false;
              }
            }
          });
          // Re-enable the trigger listeners
          this.listen();
        });
      }
    },
    computedTemplateData() {
      // If any of the while open reactive "props" change,
      // ensure that the template updates accordingly
      this.handleTemplateUpdate();
    },
    title(newValue, oldValue) {
      // Make sure to hide the tooltip when the title is set empty
      if (newValue !== oldValue && !newValue) {
        this.hide();
      }
    },
    disabled(newValue) {
      if (newValue) {
        this.disable();
      } else {
        this.enable();
      }
    }
  },
  created() {
    // Create non-reactive properties
    this.$_tip = null;
    this.$_hoverTimeout = null;
    this.$_hoverState = '';
    this.$_visibleInterval = null;
    this.$_enabled = !this.disabled;
    this.$_noop = noop.bind(this);

    // Destroy ourselves when the parent is destroyed
    if (this.bvParent) {
      onInstanceDestroy(this.bvParent, () => {
        this.$nextTick(() => {
          // In a `requestAF()` to release control back to application
          requestAF(() => {
            this.$destroy();
          });
        });
      });
    }
    this.$nextTick(() => {
      const target = this.getTarget();
      if (target && contains(document.body, target)) {
        // Copy the parent's scoped style attribute
        this.scopeId = getScopeId(this.bvParent);
        // Set up all trigger handlers and listeners
        this.listen();
      } else {
        /* istanbul ignore next */
        warn(isString(this.target) ? `Unable to find target element by ID "#${this.target}" in document.` : 'The provided target is no valid HTML element.', this.templateType);
      }
    });
  },
  /* istanbul ignore next */
  updated() {
    // Usually called when the slots/data changes
    this.$nextTick(this.handleTemplateUpdate);
  },
  /* istanbul ignore next */
  deactivated() {
    // In a keepalive that has been deactivated, so hide
    // the tooltip/popover if it is showing
    this.forceHide();
  },
  beforeDestroy() {
    // Remove all handler/listeners
    this.unListen();
    this.setWhileOpenListeners(false);
    // Clear any timeouts/intervals
    this.clearHoverTimeout();
    this.clearVisibilityInterval();
    // Destroy the template
    this.destroyTemplate();
    // Remove any other private properties created during create
    this.$_noop = null;
  },
  methods: {
    // --- Methods for creating and destroying the template ---
    getTemplate() {
      // Overridden by BVPopover
      return BVTooltipTemplate;
    },
    updateData() {
      let data = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
      // Method for updating popper/template data
      // We only update data if it exists, and has not changed
      let titleUpdated = false;
      keys(templateData).forEach(prop => {
        if (!isUndefined(data[prop]) && this[prop] !== data[prop]) {
          this[prop] = data[prop];
          if (prop === 'title') {
            titleUpdated = true;
          }
        }
      });
      // If the title has updated, we may need to handle the `title`
      // attribute on the trigger target
      // We only do this while the template is open
      if (titleUpdated && this.localShow) {
        this.fixTitle();
      }
    },
    createTemplateAndShow() {
      // Creates the template instance and show it
      const container = this.getContainer();
      const Template = this.getTemplate();
      const $tip = this.$_tip = createNewChildComponent(this, Template, {
        // The following is not reactive to changes in the props data
        propsData: {
          // These values cannot be changed while template is showing
          id: this.computedId,
          html: this.html,
          placement: this.placement,
          fallbackPlacement: this.fallbackPlacement,
          target: this.getPlacementTarget(),
          boundary: this.getBoundary(),
          // Ensure the following are integers
          offset: toInteger(this.offset, 0),
          arrowPadding: toInteger(this.arrowPadding, 0),
          boundaryPadding: toInteger(this.boundaryPadding, 0),
          // Template transition phase events (handled once only)
          [eventProp(EVENT_NAME_SHOW, {
            once: true
          })]: this.onTemplateShow,
          [eventProp(EVENT_NAME_SHOWN, {
            once: true
          })]: this.onTemplateShown,
          [eventProp(EVENT_NAME_HIDE, {
            once: true
          })]: this.onTemplateHide,
          [eventProp(EVENT_NAME_HIDDEN, {
            once: true
          })]: this.onTemplateHidden,
          // Convenience events from template
          [eventProp(EVENT_NAME_FOCUSIN)]: this.handleEvent,
          [eventProp(EVENT_NAME_FOCUSOUT)]: this.handleEvent,
          [eventProp(EVENT_NAME_MOUSEENTER)]: this.handleEvent,
          [eventProp(EVENT_NAME_MOUSELEAVE)]: this.handleEvent
        }
      });
      // We set the initial reactive data (values that can be changed while open)
      this.handleTemplateUpdate();
      // When the template gets destroyed for any reason
      onInstanceDestroy($tip, this.destroyTemplate);
      // Mount (which triggers the `show`)
      $tip.$mount(container.appendChild(document.createElement('div')));
      // Template will automatically remove its markup from DOM when hidden
    },
    hideTemplate() {
      // Trigger the template to start hiding
      // The template will emit the `hide` event after this and
      // then emit the `hidden` event once it is fully hidden
      // The `hook:destroyed` will also be called (safety measure)
      this.$_tip && this.$_tip.hide();
      // Clear out any stragging active triggers
      this.clearActiveTriggers();
      // Reset the hover state
      this.$_hoverState = '';
    },
    // Destroy the template instance and reset state
    destroyTemplate() {
      this.setWhileOpenListeners(false);
      this.clearHoverTimeout();
      this.$_hoverState = '';
      this.clearActiveTriggers();
      this.localPlacementTarget = null;
      try {
        this.$_tip.$destroy();
      } catch {}
      this.$_tip = null;
      this.removeAriaDescribedby();
      this.restoreTitle();
      this.localShow = false;
    },
    getTemplateElement() {
      return this.$_tip ? this.$_tip.$el : null;
    },
    handleTemplateUpdate() {
      // Update our template title/content "props"
      // So that the template updates accordingly
      const $tip = this.$_tip;
      if ($tip) {
        const props = ['title', 'content', 'variant', 'customClass', 'noFade', 'interactive'];
        // Only update the values if they have changed
        props.forEach(prop => {
          if ($tip[prop] !== this[prop]) {
            $tip[prop] = this[prop];
          }
        });
      }
    },
    // --- Show/Hide handlers ---
    // Show the tooltip
    show() {
      const target = this.getTarget();
      if (!target || !contains(document.body, target) || !isVisible(target) || this.dropdownOpen() || (isUndefinedOrNull(this.title) || this.title === '') && (isUndefinedOrNull(this.content) || this.content === '')) {
        // If trigger element isn't in the DOM or is not visible, or
        // is on an open dropdown toggle, or has no content, then
        // we exit without showing
        return;
      }
      // If tip already exists, exit early
      if (this.$_tip || this.localShow) {
        /* istanbul ignore next */
        return;
      }
      // In the process of showing
      this.localShow = true;
      // Create a cancelable BvEvent
      const showEvent = this.buildEvent(EVENT_NAME_SHOW, {
        cancelable: true
      });
      this.emitEvent(showEvent);
      // Don't show if event cancelled
      /* istanbul ignore if */
      if (showEvent.defaultPrevented) {
        // Destroy the template (if for some reason it was created)
        this.destroyTemplate();
        return;
      }
      // Fix the title attribute on target
      this.fixTitle();
      // Set aria-describedby on target
      this.addAriaDescribedby();
      // Create and show the tooltip
      this.createTemplateAndShow();
    },
    hide() {
      let force = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
      // Hide the tooltip
      const tip = this.getTemplateElement();
      /* istanbul ignore if */
      if (!tip || !this.localShow) {
        this.restoreTitle();
        return;
      }

      // Emit cancelable BvEvent 'hide'
      // We disable cancelling if `force` is true
      const hideEvent = this.buildEvent(EVENT_NAME_HIDE, {
        cancelable: !force
      });
      this.emitEvent(hideEvent);
      /* istanbul ignore if: ignore for now */
      if (hideEvent.defaultPrevented) {
        // Don't hide if event cancelled
        return;
      }

      // Tell the template to hide
      this.hideTemplate();
    },
    forceHide() {
      // Forcefully hides/destroys the template, regardless of any active triggers
      const tip = this.getTemplateElement();
      if (!tip || !this.localShow) {
        /* istanbul ignore next */
        return;
      }
      // Disable while open listeners/watchers
      // This is also done in the template `hide` event handler
      this.setWhileOpenListeners(false);
      // Clear any hover enter/leave event
      this.clearHoverTimeout();
      this.$_hoverState = '';
      this.clearActiveTriggers();
      // Disable the fade animation on the template
      if (this.$_tip) {
        this.$_tip.noFade = true;
      }
      // Hide the tip (with force = true)
      this.hide(true);
    },
    enable() {
      this.$_enabled = true;
      // Create a non-cancelable BvEvent
      this.emitEvent(this.buildEvent(EVENT_NAME_ENABLED));
    },
    disable() {
      this.$_enabled = false;
      // Create a non-cancelable BvEvent
      this.emitEvent(this.buildEvent(EVENT_NAME_DISABLED));
    },
    // --- Handlers for template events ---
    // When template is inserted into DOM, but not yet shown
    onTemplateShow() {
      // Enable while open listeners/watchers
      this.setWhileOpenListeners(true);
    },
    // When template show transition completes
    onTemplateShown() {
      const prevHoverState = this.$_hoverState;
      this.$_hoverState = '';
      /* istanbul ignore next: occasional Node 10 coverage error */
      if (prevHoverState === 'out') {
        this.leave(null);
      }
      // Emit a non-cancelable BvEvent 'shown'
      this.emitEvent(this.buildEvent(EVENT_NAME_SHOWN));
    },
    // When template is starting to hide
    onTemplateHide() {
      // Disable while open listeners/watchers
      this.setWhileOpenListeners(false);
    },
    // When template has completed closing (just before it self destructs)
    onTemplateHidden() {
      // Destroy the template
      this.destroyTemplate();
      // Emit a non-cancelable BvEvent 'shown'
      this.emitEvent(this.buildEvent(EVENT_NAME_HIDDEN));
    },
    // --- Helper methods ---
    getTarget() {
      let {
        target
      } = this;
      if (isString(target)) {
        target = getById(target.replace(/^#/, ''));
      } else if (isFunction(target)) {
        target = target();
      } else if (target) {
        target = target.$el || target;
      }
      return isElement(target) ? target : null;
    },
    getPlacementTarget() {
      // This is the target that the tooltip will be placed on, which may not
      // necessarily be the same element that has the trigger event listeners
      // For now, this is the same as target
      // TODO:
      //   Add in child selector support
      //   Add in visibility checks for this element
      //   Fallback to target if not found
      return this.getTarget();
    },
    getTargetId() {
      // Returns the ID of the trigger element
      const target = this.getTarget();
      return target && target.id ? target.id : null;
    },
    getContainer() {
      // Handle case where container may be a component ref
      const container = this.container ? this.container.$el || this.container : false;
      const body = document.body;
      const target = this.getTarget();
      // If we are in a modal, we append to the modal, else append
      // to body, unless a container is specified
      // TODO:
      //   Template should periodically check to see if it is in dom
      //   And if not, self destruct (if container got v-if'ed out of DOM)
      //   Or this could possibly be part of the visibility check
      return container === false ? closest(CONTAINER_SELECTOR, target) || body : /*istanbul ignore next */isString(container) ? /*istanbul ignore next */getById(container.replace(/^#/, '')) || body : /*istanbul ignore next */body;
    },
    getBoundary() {
      return this.boundary ? this.boundary.$el || this.boundary : 'scrollParent';
    },
    isInModal() {
      const target = this.getTarget();
      return target && closest(MODAL_SELECTOR, target);
    },
    isDropdown() {
      // Returns true if trigger is a dropdown
      const target = this.getTarget();
      return target && hasClass(target, DROPDOWN_CLASS);
    },
    dropdownOpen() {
      // Returns true if trigger is a dropdown and the dropdown menu is open
      const target = this.getTarget();
      return this.isDropdown() && target && select(DROPDOWN_OPEN_SELECTOR, target);
    },
    clearHoverTimeout() {
      clearTimeout(this.$_hoverTimeout);
      this.$_hoverTimeout = null;
    },
    clearVisibilityInterval() {
      clearInterval(this.$_visibleInterval);
      this.$_visibleInterval = null;
    },
    clearActiveTriggers() {
      for (const trigger in this.activeTrigger) {
        this.activeTrigger[trigger] = false;
      }
    },
    addAriaDescribedby() {
      // Add aria-describedby on trigger element, without removing any other IDs
      const target = this.getTarget();
      let desc = getAttr(target, 'aria-describedby') || '';
      desc = desc.split(/\s+/).concat(this.computedId).join(' ').trim();
      // Update/add aria-described by
      setAttr(target, 'aria-describedby', desc);
    },
    removeAriaDescribedby() {
      // Remove aria-describedby on trigger element, without removing any other IDs
      const target = this.getTarget();
      let desc = getAttr(target, 'aria-describedby') || '';
      desc = desc.split(/\s+/).filter(d => d !== this.computedId).join(' ').trim();
      // Update or remove aria-describedby
      if (desc) {
        /* istanbul ignore next */
        setAttr(target, 'aria-describedby', desc);
      } else {
        removeAttr(target, 'aria-describedby');
      }
    },
    fixTitle() {
      // If the target has a `title` attribute,
      // remove it and store it on a data attribute
      const target = this.getTarget();
      if (hasAttr(target, 'title')) {
        // Get `title` attribute value and remove it from target
        const title = getAttr(target, 'title');
        setAttr(target, 'title', '');
        // Only set the data attribute when the value is truthy
        if (title) {
          setAttr(target, DATA_TITLE_ATTR, title);
        }
      }
    },
    restoreTitle() {
      // If the target had a `title` attribute,
      // restore it and remove the data attribute
      const target = this.getTarget();
      if (hasAttr(target, DATA_TITLE_ATTR)) {
        // Get data attribute value and remove it from target
        const title = getAttr(target, DATA_TITLE_ATTR);
        removeAttr(target, DATA_TITLE_ATTR);
        // Only restore the `title` attribute when the value is truthy
        if (title) {
          setAttr(target, 'title', title);
        }
      }
    },
    // --- BvEvent helpers ---
    buildEvent(type) {
      let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
      // Defaults to a non-cancellable event
      return new BvEvent(type, {
        cancelable: false,
        target: this.getTarget(),
        relatedTarget: this.getTemplateElement() || null,
        componentId: this.computedId,
        vueTarget: this,
        // Add in option overrides
        ...options
      });
    },
    emitEvent(bvEvent) {
      const {
        type
      } = bvEvent;
      this.emitOnRoot(getRootEventName(this.templateType, type), bvEvent);
      this.$emit(type, bvEvent);
    },
    // --- Event handler setup methods ---
    listen() {
      // Enable trigger event handlers
      const el = this.getTarget();
      if (!el) {
        /* istanbul ignore next */
        return;
      }
      // Listen for global show/hide events
      this.setRootListener(true);
      // Set up our listeners on the target trigger element
      this.computedTriggers.forEach(trigger => {
        if (trigger === 'click') {
          eventOn(el, 'click', this.handleEvent, EVENT_OPTIONS_NO_CAPTURE);
        } else if (trigger === 'focus') {
          eventOn(el, 'focusin', this.handleEvent, EVENT_OPTIONS_NO_CAPTURE);
          eventOn(el, 'focusout', this.handleEvent, EVENT_OPTIONS_NO_CAPTURE);
        } else if (trigger === 'blur') {
          // Used to close $tip when element loses focus
          /* istanbul ignore next */
          eventOn(el, 'focusout', this.handleEvent, EVENT_OPTIONS_NO_CAPTURE);
        } else if (trigger === 'hover') {
          eventOn(el, 'mouseenter', this.handleEvent, EVENT_OPTIONS_NO_CAPTURE);
          eventOn(el, 'mouseleave', this.handleEvent, EVENT_OPTIONS_NO_CAPTURE);
        }
      }, this);
    },
    /* istanbul ignore next */
    unListen() {
      // Remove trigger event handlers
      const events = ['click', 'focusin', 'focusout', 'mouseenter', 'mouseleave'];
      const target = this.getTarget();

      // Stop listening for global show/hide/enable/disable events
      this.setRootListener(false);

      // Clear out any active target listeners
      events.forEach(event => {
        target && eventOff(target, event, this.handleEvent, EVENT_OPTIONS_NO_CAPTURE);
      }, this);
    },
    setRootListener(on) {
      // Listen for global `bv::{hide|show}::{tooltip|popover}` hide request event
      const method = on ? 'listenOnRoot' : 'listenOffRoot';
      const type = this.templateType;
      this[method](getRootActionEventName(type, EVENT_NAME_HIDE), this.doHide);
      this[method](getRootActionEventName(type, EVENT_NAME_SHOW), this.doShow);
      this[method](getRootActionEventName(type, EVENT_NAME_DISABLE), this.doDisable);
      this[method](getRootActionEventName(type, EVENT_NAME_ENABLE), this.doEnable);
    },
    setWhileOpenListeners(on) {
      // Events that are only registered when the template is showing
      // Modal close events
      this.setModalListener(on);
      // Dropdown open events (if we are attached to a dropdown)
      this.setDropdownListener(on);
      // Periodic $element visibility check
      // For handling when tip target is in <keepalive>, tabs, etc
      this.visibleCheck(on);
      // On-touch start listeners
      this.setOnTouchStartListener(on);
    },
    // Handler for periodic visibility check
    visibleCheck(on) {
      this.clearVisibilityInterval();
      const target = this.getTarget();
      if (on) {
        this.$_visibleInterval = setInterval(() => {
          const tip = this.getTemplateElement();
          if (tip && this.localShow && (!target.parentNode || !isVisible(target))) {
            // Target element is no longer visible or not in DOM, so force-hide the tooltip
            this.forceHide();
          }
        }, 100);
      }
    },
    setModalListener(on) {
      // Handle case where tooltip/target is in a modal
      if (this.isInModal()) {
        // We can listen for modal hidden events on `$root`
        this[on ? 'listenOnRoot' : 'listenOffRoot'](ROOT_EVENT_NAME_MODAL_HIDDEN, this.forceHide);
      }
    },
    /* istanbul ignore next: JSDOM doesn't support `ontouchstart` */
    setOnTouchStartListener(on) {
      // If this is a touch-enabled device we add extra empty
      // `mouseover` listeners to the body's immediate children
      // Only needed because of broken event delegation on iOS
      // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html
      if ('ontouchstart' in document.documentElement) {
        from(document.body.children).forEach(el => {
          eventOnOff(on, el, 'mouseover', this.$_noop);
        });
      }
    },
    setDropdownListener(on) {
      const target = this.getTarget();
      if (!target || !this.bvEventRoot || !this.isDropdown) {
        return;
      }
      // We can listen for dropdown shown events on its instance
      // TODO:
      //   We could grab the ID from the dropdown, and listen for
      //   $root events for that particular dropdown id
      //   Dropdown shown and hidden events will need to emit
      //   Note: Dropdown auto-ID happens in a `$nextTick()` after mount
      //         So the ID lookup would need to be done in a `$nextTick()`
      const instance = getInstanceFromElement(target);
      if (instance) {
        instance[on ? '$on' : '$off'](EVENT_NAME_SHOWN, this.forceHide);
      }
    },
    // --- Event handlers ---
    handleEvent(event) {
      // General trigger event handler
      // target is the trigger element
      const target = this.getTarget();
      if (!target || isDisabled(target) || !this.$_enabled || this.dropdownOpen()) {
        // If disabled or not enabled, or if a dropdown that is open, don't do anything
        // If tip is shown before element gets disabled, then tip will not
        // close until no longer disabled or forcefully closed
        return;
      }
      const type = event.type;
      const triggers = this.computedTriggers;
      if (type === 'click' && arrayIncludes(triggers, 'click')) {
        this.click(event);
      } else if (type === 'mouseenter' && arrayIncludes(triggers, 'hover')) {
        // `mouseenter` is a non-bubbling event
        this.enter(event);
      } else if (type === 'focusin' && arrayIncludes(triggers, 'focus')) {
        // `focusin` is a bubbling event
        // `event` includes `relatedTarget` (element losing focus)
        this.enter(event);
      } else if (type === 'focusout' && (arrayIncludes(triggers, 'focus') || arrayIncludes(triggers, 'blur')) || type === 'mouseleave' && arrayIncludes(triggers, 'hover')) {
        // `focusout` is a bubbling event
        // `mouseleave` is a non-bubbling event
        // `tip` is the template (will be null if not open)
        const tip = this.getTemplateElement();
        // `eventTarget` is the element which is losing focus/hover and
        const eventTarget = event.target;
        // `relatedTarget` is the element gaining focus/hover
        const relatedTarget = event.relatedTarget;
        /* istanbul ignore next */
        if (
        // From tip to target
        tip && contains(tip, eventTarget) && contains(target, relatedTarget) ||
        // From target to tip
        tip && contains(target, eventTarget) && contains(tip, relatedTarget) ||
        // Within tip
        tip && contains(tip, eventTarget) && contains(tip, relatedTarget) ||
        // Within target
        contains(target, eventTarget) && contains(target, relatedTarget)) {
          // If focus/hover moves within `tip` and `target`, don't trigger a leave
          return;
        }
        // Otherwise trigger a leave
        this.leave(event);
      }
    },
    doHide(id) {
      // Programmatically hide tooltip or popover
      if (!id || this.getTargetId() === id || this.computedId === id) {
        // Close all tooltips or popovers, or this specific tip (with ID)
        this.forceHide();
      }
    },
    doShow(id) {
      // Programmatically show tooltip or popover
      if (!id || this.getTargetId() === id || this.computedId === id) {
        // Open all tooltips or popovers, or this specific tip (with ID)
        this.show();
      }
    },
    /*istanbul ignore next: ignore for now */
    doDisable(id) /*istanbul ignore next: ignore for now */{
      // Programmatically disable tooltip or popover
      if (!id || this.getTargetId() === id || this.computedId === id) {
        // Disable all tooltips or popovers (no ID), or this specific tip (with ID)
        this.disable();
      }
    },
    /*istanbul ignore next: ignore for now */
    doEnable(id) /*istanbul ignore next: ignore for now */{
      // Programmatically enable tooltip or popover
      if (!id || this.getTargetId() === id || this.computedId === id) {
        // Enable all tooltips or popovers (no ID), or this specific tip (with ID)
        this.enable();
      }
    },
    click(event) {
      if (!this.$_enabled || this.dropdownOpen()) {
        /* istanbul ignore next */
        return;
      }
      // Get around a WebKit bug where `click` does not trigger focus events
      // On most browsers, `click` triggers a `focusin`/`focus` event first
      // Needed so that trigger 'click blur' works on iOS
      // https://github.com/bootstrap-vue/bootstrap-vue/issues/5099
      // We use `currentTarget` rather than `target` to trigger on the
      // element, not the inner content
      attemptFocus(event.currentTarget);
      this.activeTrigger.click = !this.activeTrigger.click;
      if (this.isWithActiveTrigger) {
        this.enter(null);
      } else {
        /* istanbul ignore next */
        this.leave(null);
      }
    },
    /* istanbul ignore next */
    toggle() {
      // Manual toggle handler
      if (!this.$_enabled || this.dropdownOpen()) {
        /* istanbul ignore next */
        return;
      }
      // Should we register as an active trigger?
      // this.activeTrigger.manual = !this.activeTrigger.manual
      if (this.localShow) {
        this.leave(null);
      } else {
        this.enter(null);
      }
    },
    enter() {
      let event = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null;
      // Opening trigger handler
      // Note: Click events are sent with event === null
      if (event) {
        this.activeTrigger[event.type === 'focusin' ? 'focus' : 'hover'] = true;
      }
      /* istanbul ignore next */
      if (this.localShow || this.$_hoverState === 'in') {
        this.$_hoverState = 'in';
        return;
      }
      this.clearHoverTimeout();
      this.$_hoverState = 'in';
      if (!this.computedDelay.show) {
        this.show();
      } else {
        // Hide any title attribute while enter delay is active
        this.fixTitle();
        this.$_hoverTimeout = setTimeout(() => {
          /* istanbul ignore else */
          if (this.$_hoverState === 'in') {
            this.show();
          } else if (!this.localShow) {
            this.restoreTitle();
          }
        }, this.computedDelay.show);
      }
    },
    leave() {
      let event = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null;
      // Closing trigger handler
      // Note: Click events are sent with event === null
      if (event) {
        this.activeTrigger[event.type === 'focusout' ? 'focus' : 'hover'] = false;
        /* istanbul ignore next */
        if (event.type === 'focusout' && arrayIncludes(this.computedTriggers, 'blur')) {
          // Special case for `blur`: we clear out the other triggers
          this.activeTrigger.click = false;
          this.activeTrigger.hover = false;
        }
      }
      /* istanbul ignore next: ignore for now */
      if (this.isWithActiveTrigger) {
        return;
      }
      this.clearHoverTimeout();
      this.$_hoverState = 'out';
      if (!this.computedDelay.hide) {
        this.hide();
      } else {
        this.$_hoverTimeout = setTimeout(() => {
          if (this.$_hoverState === 'out') {
            this.hide();
          }
        }, this.computedDelay.hide);
      }
    }
  }
});

export { BVTooltip };
