import Component from '@glimmer/component';
import { isArray } from '@ember/array';
import { action } from '@ember/object';
import { cancel, later, next, schedule } from '@ember/runloop';
import transitionEnd from 'ember-bootstrap/utils/transition-end';
import { getDestinationElement } from 'ember-bootstrap/utils/dom';
import usesTransition from 'ember-bootstrap/utils/decorators/uses-transition';
import { assert } from '@ember/debug';
import Ember from 'ember';
import arg from '../utils/decorators/arg';
import { tracked } from '@glimmer/tracking';
import { ref } from 'ember-ref-bucket';

const HOVERSTATE_NONE = 'none';
const HOVERSTATE_IN = 'in';
const HOVERSTATE_OUT = 'out';

function noop() {}

/**
  @class ContextualHelp
  @namespace Components
  @extends Glimmer.Component
  @private
*/
export default class ContextualHelp extends Component {
  /**
   * @property title
   * @type string
   * @public
   */

  /**
   * How to position the tooltip/popover - top | bottom | left | right
   *
   * @property title
   * @type string
   * @default 'top'
   * @public
   */
  @arg
  placement = 'top';

  /**
   * By default it will dynamically reorient the tooltip/popover based on the available space in the viewport. For
   * example, if `placement` is "left", the tooltip/popover will display to the left when possible, otherwise it will
   * display right. Set to `false` to force placement according to the `placement` property
   *
   * @property autoPlacement
   * @type boolean
   * @default true
   * @public
   */
  @arg
  autoPlacement = true;

  /**
   * You can programmatically show the tooltip/popover by setting this to `true`
   *
   * @property visible
   * @type boolean
   * @default false
   * @public
   */
  @arg
  visible = false;

  /**
   * @property inDom
   * @type boolean
   * @private
   */
  @tracked
  _inDom;

  get inDom() {
    return this._inDom ?? !!(this.visible && this.triggerTargetElement);
  }
  set inDom(value) {
    if (this._inDom === value) {
      return;
    }

    this._inDom = value;
  }

  /**
   * Set to false to disable fade animations.
   *
   * @property fade
   * @type boolean
   * @default true
   * @public
   */
  @arg
  fade = true;

  /**
   * Used to apply Bootstrap's visibility class
   *
   * @property showHelp
   * @type boolean
   * @default false
   * @private
   */
  @tracked
  showHelp = this.visible;

  /**
   * Delay showing and hiding the tooltip/popover (ms). Individual delays for showing and hiding can be specified by using the
   * `delayShow` and `delayHide` properties.
   *
   * @property delay
   * @type number
   * @default 0
   * @public
   */
  @arg
  delay = 0;

  /**
   * Delay showing the tooltip/popover. This property overrides the general delay set with the `delay` property.
   *
   * @property delayShow
   * @type number
   * @default 0
   * @public
   */
  @arg
  delayShow = this.args.delay ?? 0;

  /**
   * Delay hiding the tooltip/popover. This property overrides the general delay set with the `delay` property.
   *
   * @property delayHide
   * @type number
   * @default 0
   * @public
   */
  @arg
  delayHide = this.args.delay ?? 0;

  /**
   * The duration of the fade transition
   *
   * @property transitionDuration
   * @type number
   * @default 150
   * @public
   */
  @arg
  transitionDuration = 150;

  /**
   * Keeps the tooltip/popover within the bounds of this element when `autoPlacement` is true. Can be any valid CSS selector.
   *
   * @property viewportSelector
   * @type string
   * @default 'body'
   * @see viewportPadding
   * @see autoPlacement
   * @public
   */
  @arg
  viewportSelector = 'body';

  /**
   * Take a padding into account for keeping the tooltip/popover within the bounds of the element given by `viewportSelector`.
   *
   * @property viewportPadding
   * @type number
   * @default 0
   * @see viewportSelector
   * @see autoPlacement
   * @public
   */
  @arg
  viewportPadding = 0;

  _parentFinder = self.document ? self.document.createTextNode('') : '';

  /**
   * The DOM element of the arrow element.
   *
   * @property arrowElement
   * @type object
   * @readonly
   * @private
   */

  /**
   * The wormhole destinationElement
   *
   * @property destinationElement
   * @type object
   * @readonly
   * @private
   */
  get destinationElement() {
    return getDestinationElement(this);
  }

  /**
   * The DOM element of the viewport element.
   *
   * @property viewportElement
   * @type object
   * @readonly
   * @private
   */
  get viewportElement() {
    return document.querySelector(this.viewportSelector);
  }

  /**
   * The DOM element that triggers the tooltip/popover. By default it is the parent element of this component.
   * You can set this to any CSS selector to have any other element trigger the tooltip/popover.
   *
   * @property triggerElement
   * @type string | null
   * @public
   */
  @arg
  triggerElement = null;

  /**
   * @method getTriggerTargetElement
   * @private
   */
  getTriggerTargetElement() {
    let triggerElement = this.triggerElement;
    let el;

    if (!triggerElement) {
      el = this._parent;
    } else {
      el = document.querySelector(triggerElement);
    }

    assert('Could not find trigger element for tooltip/popover component', el);
    return el;
  }

  /**
   * The event(s) that should trigger the tooltip/popover - click | hover | focus.
   * You can set this to a single event or multiple events, given as an array or a string separated by spaces.
   *
   * @property triggerEvents
   * @type array|string
   * @default 'hover focus'
   * @public
   */
  @arg
  triggerEvents = 'hover focus';

  get _triggerEvents() {
    let events = this.triggerEvents;
    if (!isArray(events)) {
      events = events.split(' ');
    }

    return events.map((event) => {
      switch (event) {
        case 'hover':
          return ['mouseenter', 'mouseleave'];
        case 'focus':
          return ['focusin', 'focusout'];
        default:
          return event;
      }
    });
  }

  /**
   * If true component will render in place, rather than be wormholed.
   *
   * @property renderInPlace
   * @type boolean
   * @default false
   * @public
   */

  /**
   * @property _renderInPlace
   * @type boolean
   * @private
   */
  get _renderInPlace() {
    return this.args.renderInPlace || !this.destinationElement;
  }

  /**
   * Current hover state, 'in', 'out' or null
   *
   * @property hoverState
   * @type number
   * @private
   */
  hoverState = HOVERSTATE_NONE;

  /**
   * Current state for events
   */
  hover = false;
  focus = false;
  click = false;

  get shouldShowHelp() {
    return this.hover || this.focus || this.click;
  }

  /**
   * Ember.run timer
   *
   * @property timer
   * @private
   */
  timer = null;

  /**
   * Use CSS transitions?
   *
   * @property usesTransition
   * @type boolean
   * @readonly
   * @private
   */
  @usesTransition('fade')
  usesTransition;

  /**
   * The DOM element of the overlay element.
   *
   * @property overlayElement
   * @type object
   * @readonly
   * @private
   */
  @ref('overlayElement') overlayElement;

  /**
   * This action is called immediately when the tooltip/popover is about to be shown.
   *
   * @event onShow
   * @public
   */

  /**
   * This action will be called when the tooltip/popover has been made visible to the user (will wait for CSS transitions to complete).
   *
   * @event onShown
   * @public
   */

  /**
   * This action is called immediately when the tooltip/popover is about to be hidden.
   *
   * @event onHide
   * @public
   */

  /**
   * This action is called when the tooltip/popover has finished being hidden from the user (will wait for CSS transitions to complete).
   *
   * @event onHidden
   * @public
   */

  /**
   * Called when a show event has been received
   *
   * @method enter
   * @param e
   * @private
   */
  enter(e) {
    if (e) {
      let eventType = e.type === 'focusin' ? 'focus' : 'hover';
      this[eventType] = true;
    }

    if (this.showHelp || this.hoverState === HOVERSTATE_IN) {
      this.hoverState = HOVERSTATE_IN;
      return;
    }

    cancel(this.timer);

    this.hoverState = HOVERSTATE_IN;

    if (!this.delayShow) {
      return this.show();
    }

    this.timer = later(
      this,
      function () {
        if (this.hoverState === HOVERSTATE_IN) {
          this.show();
        }
      },
      this.delayShow
    );
  }

  /**
   * Called when a hide event has been received
   *
   * @method leave
   * @param e
   * @private
   */
  leave(e) {
    if (e) {
      let eventType = e.type === 'focusout' ? 'focus' : 'hover';
      this[eventType] = false;
    }

    if (this.shouldShowHelp) {
      return;
    }

    cancel(this.timer);

    this.hoverState = HOVERSTATE_OUT;

    if (!this.delayHide) {
      return this.hide();
    }

    this.timer = later(() => {
      if (this.hoverState === HOVERSTATE_OUT) {
        this.hide();
      }
    }, this.delayHide);
  }

  /**
   * Called for a click event
   *
   * @method toggle
   * @private
   */
  toggle() {
    this.click = !this.click;
    if (this.shouldShowHelp) {
      this.enter();
    } else {
      this.leave();
    }
  }

  /**
   * Show the tooltip/popover
   *
   * @method show
   * @private
   */
  show() {
    if (this.isDestroyed || this.isDestroying) {
      return;
    }

    if (false === this.args.onShow?.(this)) {
      return;
    }

    this.inDom = true;
    schedule('afterRender', this, this._show);
  }

  _show(skipTransition = false) {
    if (this.isDestroyed || this.isDestroying) {
      return;
    }
    this.showHelp = true;

    // 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

    // See https://github.com/twbs/bootstrap/pull/22481
    if ('ontouchstart' in document.documentElement) {
      let { children } = document.body;
      for (let i = 0; i < children.length; i++) {
        children[i].addEventListener('mouseover', noop);
      }
    }

    let tooltipShowComplete = () => {
      if (this.isDestroyed) {
        return;
      }
      let prevHoverState = this.hoverState;

      this.args.onShown?.(this);
      this.hoverState = HOVERSTATE_NONE;

      if (prevHoverState === HOVERSTATE_OUT) {
        this.leave();
      }
    };

    if (skipTransition === false && this.usesTransition) {
      transitionEnd(this.overlayElement, this.transitionDuration).then(tooltipShowComplete);
    } else {
      tooltipShowComplete();
    }
  }

  /**
   * Position the tooltip/popover's arrow
   *
   * @method replaceArrow
   * @param delta
   * @param dimension
   * @param isVertical
   * @private
   */
  replaceArrow(delta, dimension, isVertical) {
    let el = this.arrowElement;
    el.style[isVertical ? 'left' : 'top'] = `${50 * (1 - delta / dimension)}%`;
    el.style[isVertical ? 'top' : 'left'] = null;
  }

  /**
   * Hide the tooltip/popover
   *
   * @method hide
   * @private
   */
  hide() {
    if (this.isDestroyed) {
      return;
    }

    if (false === this.args.onHide?.(this)) {
      return;
    }

    let tooltipHideComplete = () => {
      if (this.isDestroyed) {
        return;
      }
      if (this.hoverState !== HOVERSTATE_IN) {
        this.inDom = false;
      }
      this.args.onHidden?.(this);
    };

    this.showHelp = false;

    // if this is a touch-enabled device we remove the extra
    // empty mouseover listeners we added for iOS support
    if ('ontouchstart' in document.documentElement) {
      let { children } = document.body;
      for (let i = 0; i < children.length; i++) {
        children[i].removeEventListener('mouseover', noop);
      }
    }

    if (this.usesTransition) {
      transitionEnd(this.overlayElement, this.transitionDuration).then(tooltipHideComplete);
    } else {
      tooltipHideComplete();
    }

    this.hoverState = HOVERSTATE_NONE;
  }

  /**
   * @method addListeners
   * @private
   */
  addListeners() {
    let target = this.triggerTargetElement;

    this._triggerEvents.forEach((event) => {
      if (isArray(event)) {
        let [inEvent, outEvent] = event;
        target.addEventListener(inEvent, this._handleEnter);
        target.addEventListener(outEvent, this._handleLeave);
      } else {
        target.addEventListener(event, this._handleToggle);
      }
    });
  }

  /**
   * @method removeListeners
   * @private
   */
  removeListeners() {
    try {
      let target = this.triggerTargetElement;
      this._triggerEvents.forEach((event) => {
        if (isArray(event)) {
          let [inEvent, outEvent] = event;
          target.removeEventListener(inEvent, this._handleEnter);
          target.removeEventListener(outEvent, this._handleLeave);
        } else {
          target.removeEventListener(event, this._handleToggle);
        }
      });
    } catch (e) {} // eslint-disable-line no-empty
  }

  /**
   * @method handleTriggerEvent
   * @private
   */
  handleTriggerEvent(handler, e) {
    let overlayElement = this.overlayElement;
    if (overlayElement && overlayElement.contains(e.target)) {
      return;
    }
    return handler.call(this, e);
  }

  @action
  _handleEnter(e) {
    this.handleTriggerEvent(this.enter, e);
  }

  @action
  _handleLeave(e) {
    this.handleTriggerEvent(this.leave, e);
  }

  @action
  _handleToggle(e) {
    this.handleTriggerEvent(this.toggle, e);
  }

  @action
  close() {
    // Make sure our click state is off, otherwise the next click would
    // close the already-closed tooltip/popover. We don't need to worry
    // about this for hover/focus because those aren't "stateful" toggle
    // events like click.
    this.click = false;
    this.hide();
  }

  @action
  setup() {
    if (typeof FastBoot !== 'undefined') {
      // ember-render-helpers calls this also in FastBoot, so guard against this
      return;
    }
    let parent = this._parentFinder.parentNode;
    // In the rare case of using FastBoot w/ rehydration, the parent finder TextNode rendered by FastBoot will be reused,
    // so our own instance on the component is not rendered, only exists here as detached from DOM and thus has no parent.
    // In this case we try to use Ember's private API as a fallback.
    // Related: https://github.com/emberjs/rfcs/issues/168
    if (!parent) {
      try {
        parent = Ember.ViewUtils.getViewBounds(this).parentElement;
      } catch (e) {
        // we catch the possibly broken private API call, the component can still work if the trigger element is defined
        // using a CSS selector.
      }
    }
    this._parent = parent;

    // Look for target element after rendering has finished, in case the target DOM element is rendered *after* us
    // see https://github.com/kaliber5/ember-bootstrap/issues/1329
    schedule('afterRender', () => {
      this.triggerTargetElement = this.getTriggerTargetElement();
      this.addListeners();
      if (this.visible) {
        next(this, this.show);
      }
    });
  }

  @action
  showOrHide() {
    if (this.args.visible) {
      next(this, this.show);
    } else {
      next(this, this.hide);
    }
  }

  willDestroy() {
    super.willDestroy(...arguments);
    this.removeListeners();
  }
}