'use strict'

const EventKit = require('event-kit')
const tooltipComponentsByElement = new WeakMap()
const listen = require('./delegated-listener')

// This tooltip class is derived from Bootstrap 3, but modified to not require
// jQuery, which is an expensive dependency we want to eliminate.

var followThroughTimer = null

var Tooltip = function (element, options, viewRegistry) {
  this.options = null
  this.enabled = null
  this.timeout = null
  this.hoverState = null
  this.element = null
  this.inState = null
  this.viewRegistry = viewRegistry

  this.init(element, options)
}

Tooltip.VERSION = '3.3.5'

Tooltip.FOLLOW_THROUGH_DURATION = 300

Tooltip.DEFAULTS = {
  animation: true,
  placement: 'top',
  selector: false,
  template: '<div class="tooltip" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>',
  trigger: 'hover focus',
  title: '',
  delay: 0,
  html: false,
  container: false,
  viewport: {
    selector: 'body',
    padding: 0
  }
}

Tooltip.prototype.init = function (element, options) {
  this.enabled = true
  this.element = element
  this.options = this.getOptions(options)
  this.disposables = new EventKit.CompositeDisposable()
  this.mutationObserver = new MutationObserver(this.handleMutations.bind(this))

  if (this.options.viewport) {
    if (typeof this.options.viewport === 'function') {
      this.viewport = this.options.viewport.call(this, this.element)
    } else {
      this.viewport = document.querySelector(this.options.viewport.selector || this.options.viewport)
    }
  }
  this.inState = {click: false, hover: false, focus: false}

  if (this.element instanceof document.constructor && !this.options.selector) {
    throw new Error('`selector` option must be specified when initializing tooltip on the window.document object!')
  }

  var triggers = this.options.trigger.split(' ')

  for (var i = triggers.length; i--;) {
    var trigger = triggers[i]

    if (trigger === 'click') {
      this.disposables.add(listen(this.element, 'click', this.options.selector, this.toggle.bind(this)))
      this.hideOnClickOutsideOfTooltip = (event) => {
        const tooltipElement = this.getTooltipElement()
        if (tooltipElement === event.target) return
        if (tooltipElement.contains(event.target)) return
        if (this.element === event.target) return
        if (this.element.contains(event.target)) return
        this.hide()
      }
    } else if (trigger === 'manual') {
      this.show()
    } else {
      var eventIn, eventOut

      if (trigger === 'hover') {
        this.hideOnKeydownOutsideOfTooltip = () => this.hide()
        if (this.options.selector) {
          eventIn = 'mouseover'
          eventOut = 'mouseout'
        } else {
          eventIn = 'mouseenter'
          eventOut = 'mouseleave'
        }
      } else {
        eventIn = 'focusin'
        eventOut = 'focusout'
      }

      this.disposables.add(listen(this.element, eventIn, this.options.selector, this.enter.bind(this)))
      this.disposables.add(listen(this.element, eventOut, this.options.selector, this.leave.bind(this)))
    }
  }

  this.options.selector
    ? (this._options = extend({}, this.options, { trigger: 'manual', selector: '' }))
    : this.fixTitle()
}

Tooltip.prototype.startObservingMutations = function () {
  this.mutationObserver.observe(this.getTooltipElement(), {
    attributes: true, childList: true, characterData: true, subtree: true
  })
}

Tooltip.prototype.stopObservingMutations = function () {
  this.mutationObserver.disconnect()
}

Tooltip.prototype.handleMutations = function () {
  window.requestAnimationFrame(function () {
    this.stopObservingMutations()
    this.recalculatePosition()
    this.startObservingMutations()
  }.bind(this))
}

Tooltip.prototype.getDefaults = function () {
  return Tooltip.DEFAULTS
}

Tooltip.prototype.getOptions = function (options) {
  options = extend({}, this.getDefaults(), options)

  if (options.delay && typeof options.delay === 'number') {
    options.delay = {
      show: options.delay,
      hide: options.delay
    }
  }

  return options
}

Tooltip.prototype.getDelegateOptions = function () {
  var options = {}
  var defaults = this.getDefaults()

  if (this._options) {
    for (var key of Object.getOwnPropertyNames(this._options)) {
      var value = this._options[key]
      if (defaults[key] !== value) options[key] = value
    }
  }

  return options
}

Tooltip.prototype.enter = function (event) {
  if (event) {
    if (event.currentTarget !== this.element) {
      this.getDelegateComponent(event.currentTarget).enter(event)
      return
    }

    this.inState[event.type === 'focusin' ? 'focus' : 'hover'] = true
  }

  if (this.getTooltipElement().classList.contains('in') || this.hoverState === 'in') {
    this.hoverState = 'in'
    return
  }

  clearTimeout(this.timeout)

  this.hoverState = 'in'

  if (!this.options.delay ||
      !this.options.delay.show ||
      followThroughTimer) {
    return this.show()
  }

  this.timeout = setTimeout(function () {
    if (this.hoverState === 'in') this.show()
  }.bind(this), this.options.delay.show)
}

Tooltip.prototype.isInStateTrue = function () {
  for (var key in this.inState) {
    if (this.inState[key]) return true
  }

  return false
}

Tooltip.prototype.leave = function (event) {
  if (event) {
    if (event.currentTarget !== this.element) {
      this.getDelegateComponent(event.currentTarget).leave(event)
      return
    }

    this.inState[event.type === 'focusout' ? 'focus' : 'hover'] = false
  }

  if (this.isInStateTrue()) return

  clearTimeout(this.timeout)

  this.hoverState = 'out'

  if (!this.options.delay || !this.options.delay.hide) return this.hide()

  this.timeout = setTimeout(function () {
    if (this.hoverState === 'out') this.hide()
  }.bind(this), this.options.delay.hide)
}

Tooltip.prototype.show = function () {
  if (this.hasContent() && this.enabled) {
    if (this.hideOnClickOutsideOfTooltip) {
      window.addEventListener('click', this.hideOnClickOutsideOfTooltip, true)
    }

    if (this.hideOnKeydownOutsideOfTooltip) {
      window.addEventListener('keydown', this.hideOnKeydownOutsideOfTooltip, true)
    }

    var tip = this.getTooltipElement()
    this.startObservingMutations()
    var tipId = this.getUID('tooltip')

    this.setContent()
    tip.setAttribute('id', tipId)
    this.element.setAttribute('aria-describedby', tipId)

    if (this.options.animation) tip.classList.add('fade')

    var placement = typeof this.options.placement === 'function'
      ? this.options.placement.call(this, tip, this.element)
      : this.options.placement

    var autoToken = /\s?auto?\s?/i
    var autoPlace = autoToken.test(placement)
    if (autoPlace) placement = placement.replace(autoToken, '') || 'top'

    tip.remove()
    tip.style.top = '0px'
    tip.style.left = '0px'
    tip.style.display = 'block'
    tip.classList.add(placement)

    document.body.appendChild(tip)

    var pos = this.element.getBoundingClientRect()
    var actualWidth = tip.offsetWidth
    var actualHeight = tip.offsetHeight

    if (autoPlace) {
      var orgPlacement = placement
      var viewportDim = this.viewport.getBoundingClientRect()

      placement = placement === 'bottom' && pos.bottom + actualHeight > viewportDim.bottom ? 'top'
                : placement === 'top' && pos.top - actualHeight < viewportDim.top ? 'bottom'
                : placement === 'right' && pos.right + actualWidth > viewportDim.width ? 'left'
                : placement === 'left' && pos.left - actualWidth < viewportDim.left ? 'right'
                : placement

      tip.classList.remove(orgPlacement)
      tip.classList.add(placement)
    }

    var calculatedOffset = this.getCalculatedOffset(placement, pos, actualWidth, actualHeight)

    this.applyPlacement(calculatedOffset, placement)

    var prevHoverState = this.hoverState
    this.hoverState = null

    if (prevHoverState === 'out') this.leave()
  }
}

Tooltip.prototype.applyPlacement = function (offset, placement) {
  var tip = this.getTooltipElement()

  var width = tip.offsetWidth
  var height = tip.offsetHeight

  // manually read margins because getBoundingClientRect includes difference
  var computedStyle = window.getComputedStyle(tip)
  var marginTop = parseInt(computedStyle.marginTop, 10)
  var marginLeft = parseInt(computedStyle.marginLeft, 10)

  offset.top += marginTop
  offset.left += marginLeft

  tip.style.top = offset.top + 'px'
  tip.style.left = offset.left + 'px'

  tip.classList.add('in')

  // check to see if placing tip in new offset caused the tip to resize itself
  var actualWidth = tip.offsetWidth
  var actualHeight = tip.offsetHeight

  if (placement === 'top' && actualHeight !== height) {
    offset.top = offset.top + height - actualHeight
  }

  var delta = this.getViewportAdjustedDelta(placement, offset, actualWidth, actualHeight)

  if (delta.left) offset.left += delta.left
  else offset.top += delta.top

  var isVertical = /top|bottom/.test(placement)
  var arrowDelta = isVertical ? delta.left * 2 - width + actualWidth : delta.top * 2 - height + actualHeight
  var arrowOffsetPosition = isVertical ? 'offsetWidth' : 'offsetHeight'

  tip.style.top = offset.top + 'px'
  tip.style.left = offset.left + 'px'

  this.replaceArrow(arrowDelta, tip[arrowOffsetPosition], isVertical)
}

Tooltip.prototype.replaceArrow = function (delta, dimension, isVertical) {
  var arrow = this.getArrowElement()
  var amount = 50 * (1 - delta / dimension) + '%'

  if (isVertical) {
    arrow.style.left = amount
    arrow.style.top = ''
  } else {
    arrow.style.top = amount
    arrow.style.left = ''
  }
}

Tooltip.prototype.setContent = function () {
  var tip = this.getTooltipElement()

  if (this.options.class) {
    tip.classList.add(this.options.class)
  }

  var inner = tip.querySelector('.tooltip-inner')
  if (this.options.item) {
    inner.appendChild(this.viewRegistry.getView(this.options.item))
  } else {
    var title = this.getTitle()
    if (this.options.html) {
      inner.innerHTML = title
    } else {
      inner.textContent = title
    }
  }

  tip.classList.remove('fade', 'in', 'top', 'bottom', 'left', 'right')
}

Tooltip.prototype.hide = function (callback) {
  this.inState = {}

  if (this.hideOnClickOutsideOfTooltip) {
    window.removeEventListener('click', this.hideOnClickOutsideOfTooltip, true)
  }

  if (this.hideOnKeydownOutsideOfTooltip) {
    window.removeEventListener('keydown', this.hideOnKeydownOutsideOfTooltip, true)
  }

  this.tip && this.tip.classList.remove('in')
  this.stopObservingMutations()

  if (this.hoverState !== 'in') this.tip && this.tip.remove()

  this.element.removeAttribute('aria-describedby')

  callback && callback()

  this.hoverState = null

  clearTimeout(followThroughTimer)
  followThroughTimer = setTimeout(
    function () {
      followThroughTimer = null
    },
    Tooltip.FOLLOW_THROUGH_DURATION
  )

  return this
}

Tooltip.prototype.fixTitle = function () {
  if (this.element.getAttribute('title') || typeof this.element.getAttribute('data-original-title') !== 'string') {
    this.element.setAttribute('data-original-title', this.element.getAttribute('title') || '')
    this.element.setAttribute('title', '')
  }
}

Tooltip.prototype.hasContent = function () {
  return this.getTitle() || this.options.item
}

Tooltip.prototype.getCalculatedOffset = function (placement, pos, actualWidth, actualHeight) {
  return placement === 'bottom' ? { top: pos.top + pos.height, left: pos.left + pos.width / 2 - actualWidth / 2 }
       : placement === 'top' ? { top: pos.top - actualHeight, left: pos.left + pos.width / 2 - actualWidth / 2 }
       : placement === 'left' ? { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth }
       :/* placement === 'right' */ { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width }
}

Tooltip.prototype.getViewportAdjustedDelta = function (placement, pos, actualWidth, actualHeight) {
  var delta = { top: 0, left: 0 }
  if (!this.viewport) return delta

  var viewportPadding = this.options.viewport && this.options.viewport.padding || 0
  var viewportDimensions = this.viewport.getBoundingClientRect()

  if (/right|left/.test(placement)) {
    var topEdgeOffset = pos.top - viewportPadding - viewportDimensions.scroll
    var bottomEdgeOffset = pos.top + viewportPadding - viewportDimensions.scroll + actualHeight
    if (topEdgeOffset < viewportDimensions.top) { // top overflow
      delta.top = viewportDimensions.top - topEdgeOffset
    } else if (bottomEdgeOffset > viewportDimensions.top + viewportDimensions.height) { // bottom overflow
      delta.top = viewportDimensions.top + viewportDimensions.height - bottomEdgeOffset
    }
  } else {
    var leftEdgeOffset = pos.left - viewportPadding
    var rightEdgeOffset = pos.left + viewportPadding + actualWidth
    if (leftEdgeOffset < viewportDimensions.left) { // left overflow
      delta.left = viewportDimensions.left - leftEdgeOffset
    } else if (rightEdgeOffset > viewportDimensions.right) { // right overflow
      delta.left = viewportDimensions.left + viewportDimensions.width - rightEdgeOffset
    }
  }

  return delta
}

Tooltip.prototype.getTitle = function () {
  var title = this.element.getAttribute('data-original-title')
  if (title) {
    return title
  } else {
    return (typeof this.options.title === 'function')
      ? this.options.title.call(this.element)
      : this.options.title
  }
}

Tooltip.prototype.getUID = function (prefix) {
  do prefix += ~~(Math.random() * 1000000)
  while (document.getElementById(prefix))
  return prefix
}

Tooltip.prototype.getTooltipElement = function () {
  if (!this.tip) {
    let div = document.createElement('div')
    div.innerHTML = this.options.template
    if (div.children.length !== 1) {
      throw new Error('Tooltip `template` option must consist of exactly 1 top-level element!')
    }
    this.tip = div.firstChild
  }
  return this.tip
}

Tooltip.prototype.getArrowElement = function () {
  this.arrow = this.arrow || this.getTooltipElement().querySelector('.tooltip-arrow')
  return this.arrow
}

Tooltip.prototype.enable = function () {
  this.enabled = true
}

Tooltip.prototype.disable = function () {
  this.enabled = false
}

Tooltip.prototype.toggleEnabled = function () {
  this.enabled = !this.enabled
}

Tooltip.prototype.toggle = function (event) {
  if (event) {
    if (event.currentTarget !== this.element) {
      this.getDelegateComponent(event.currentTarget).toggle(event)
      return
    }

    this.inState.click = !this.inState.click
    if (this.isInStateTrue()) this.enter()
    else this.leave()
  } else {
    this.getTooltipElement().classList.contains('in') ? this.leave() : this.enter()
  }
}

Tooltip.prototype.destroy = function () {
  clearTimeout(this.timeout)
  this.tip && this.tip.remove()
  this.disposables.dispose()
}

Tooltip.prototype.getDelegateComponent = function (element) {
  var component = tooltipComponentsByElement.get(element)
  if (!component) {
    component = new Tooltip(element, this.getDelegateOptions(), this.viewRegistry)
    tooltipComponentsByElement.set(element, component)
  }
  return component
}

Tooltip.prototype.recalculatePosition = function () {
  var tip = this.getTooltipElement()

  var placement = typeof this.options.placement === 'function'
    ? this.options.placement.call(this, tip, this.element)
    : this.options.placement

  var autoToken = /\s?auto?\s?/i
  var autoPlace = autoToken.test(placement)
  if (autoPlace) placement = placement.replace(autoToken, '') || 'top'

  tip.classList.add(placement)

  var pos = this.element.getBoundingClientRect()
  var actualWidth = tip.offsetWidth
  var actualHeight = tip.offsetHeight

  if (autoPlace) {
    var orgPlacement = placement
    var viewportDim = this.viewport.getBoundingClientRect()

    placement = placement === 'bottom' && pos.bottom + actualHeight > viewportDim.bottom ? 'top'
              : placement === 'top' && pos.top - actualHeight < viewportDim.top ? 'bottom'
              : placement === 'right' && pos.right + actualWidth > viewportDim.width ? 'left'
              : placement === 'left' && pos.left - actualWidth < viewportDim.left ? 'right'
              : placement

    tip.classList.remove(orgPlacement)
    tip.classList.add(placement)
  }

  var calculatedOffset = this.getCalculatedOffset(placement, pos, actualWidth, actualHeight)
  this.applyPlacement(calculatedOffset, placement)
}

function extend () {
  var args = Array.prototype.slice.apply(arguments)
  var target = args.shift()
  var source = args.shift()
  while (source) {
    for (var key of Object.getOwnPropertyNames(source)) {
      target[key] = source[key]
    }
    source = args.shift()
  }
  return target
}

module.exports = Tooltip
