/**
 * Tooltip - Lightweight text tooltip on hover/focus
 * @countermeasure/web-components/display/tooltip
 */

import { uniqueId } from '../../primitives/utils'
import { type TooltipConfig, type TooltipPlacement } from './types'

export type { TooltipConfig, TooltipPlacement } from './types'

/** Determine the opposite placement for auto-flip */
function getOppositePlacement(placement: TooltipPlacement): TooltipPlacement {
  switch (placement) {
    case 'top': {
      return 'bottom'
    }
    case 'bottom': {
      return 'top'
    }
    case 'left': {
      return 'right'
    }
    case 'right': {
      return 'left'
    }
  }
}

/** Gap in pixels between trigger and tooltip */
const TOOLTIP_GAP = 8

export class Tooltip {
  private readonly trigger: HTMLElement
  private readonly placement: TooltipPlacement
  private readonly delay: number
  private readonly className: string | undefined
  private readonly tooltipId: string

  private tooltipEl: HTMLElement | null = null
  private arrowEl: HTMLElement | null = null
  private showTimeoutId: ReturnType<typeof setTimeout> | null = null
  private content: string
  private destroyed = false

  /** Bound event handlers for cleanup */
  private readonly handleMouseEnter: () => void
  private readonly handleMouseLeave: () => void
  private readonly handleFocusIn: () => void
  private readonly handleFocusOut: () => void

  constructor(config: TooltipConfig) {
    this.trigger = config.trigger
    this.content = config.content
    this.placement = config.placement ?? 'top'
    this.delay = config.delay ?? 200
    this.className = config.className
    this.tooltipId = uniqueId('cmm-tooltip')

    this.handleMouseEnter = () => {
      this.scheduleShow()
    }
    this.handleMouseLeave = () => {
      this.hide()
    }
    this.handleFocusIn = () => {
      this.scheduleShow()
    }
    this.handleFocusOut = () => {
      this.hide()
    }

    this.trigger.addEventListener('mouseenter', this.handleMouseEnter)
    this.trigger.addEventListener('mouseleave', this.handleMouseLeave)
    this.trigger.addEventListener('focusin', this.handleFocusIn)
    this.trigger.addEventListener('focusout', this.handleFocusOut)
  }

  /** Update the tooltip text content */
  setContent(content: string): void {
    this.content = content
    if (this.tooltipEl) {
      const textNode = this.tooltipEl.firstChild
      if (textNode) {
        textNode.textContent = content
      }
    }
  }

  /** Programmatically show the tooltip */
  show(): void {
    if (this.destroyed) {
      return
    }

    this.cancelScheduledShow()
    this.createTooltipElement()
    this.positionTooltip()

    if (this.tooltipEl) {
      // Force reflow before adding visible class for transition
      void this.tooltipEl.offsetHeight
      this.tooltipEl.classList.add('cmm-tooltip--visible')
    }
  }

  /** Programmatically hide the tooltip */
  hide(): void {
    this.cancelScheduledShow()

    if (this.tooltipEl) {
      this.tooltipEl.classList.remove('cmm-tooltip--visible')

      const el = this.tooltipEl
      // Remove after transition
      setTimeout(() => {
        el.remove()
      }, 150)

      this.tooltipEl = null
      this.arrowEl = null
      this.trigger.removeAttribute('aria-describedby')
    }
  }

  /** Remove listeners from trigger and clean up tooltip */
  destroy(): void {
    if (this.destroyed) {
      return
    }
    this.destroyed = true

    this.cancelScheduledShow()
    this.hide()

    this.trigger.removeEventListener('mouseenter', this.handleMouseEnter)
    this.trigger.removeEventListener('mouseleave', this.handleMouseLeave)
    this.trigger.removeEventListener('focusin', this.handleFocusIn)
    this.trigger.removeEventListener('focusout', this.handleFocusOut)
    this.trigger.removeAttribute('aria-describedby')
  }

  /** Schedule showing the tooltip after the configured delay */
  private scheduleShow(): void {
    if (this.destroyed) {
      return
    }

    this.cancelScheduledShow()

    if (this.delay > 0) {
      this.showTimeoutId = setTimeout(() => {
        this.show()
      }, this.delay)
    } else {
      this.show()
    }
  }

  /** Cancel any pending show timeout */
  private cancelScheduledShow(): void {
    if (this.showTimeoutId !== null) {
      clearTimeout(this.showTimeoutId)
      this.showTimeoutId = null
    }
  }

  /** Create the tooltip DOM element and append to body */
  private createTooltipElement(): void {
    // Remove existing tooltip if present
    if (this.tooltipEl) {
      this.tooltipEl.remove()
    }

    const tooltip = document.createElement('div')
    tooltip.className = 'cmm-tooltip'
    tooltip.id = this.tooltipId
    tooltip.setAttribute('role', 'tooltip')
    tooltip.textContent = this.content

    if (this.className) {
      tooltip.classList.add(this.className)
    }

    // Arrow
    const arrow = document.createElement('div')
    arrow.className = 'cmm-tooltip__arrow'
    tooltip.appendChild(arrow)

    document.body.appendChild(tooltip)

    this.tooltipEl = tooltip
    this.arrowEl = arrow
    this.trigger.setAttribute('aria-describedby', this.tooltipId)
  }

  /** Position the tooltip relative to the trigger element */
  private positionTooltip(): void {
    if (!this.tooltipEl || !this.arrowEl) {
      return
    }

    const triggerRect = this.trigger.getBoundingClientRect()
    const tooltipRect = this.tooltipEl.getBoundingClientRect()
    const viewportWidth = window.innerWidth
    const viewportHeight = window.innerHeight

    let placement = this.placement

    // Auto-flip if not enough space
    if (placement === 'top' && triggerRect.top - tooltipRect.height - TOOLTIP_GAP < 0) {
      placement = getOppositePlacement(placement)
    } else if (
      placement === 'bottom' &&
      triggerRect.bottom + tooltipRect.height + TOOLTIP_GAP > viewportHeight
    ) {
      placement = getOppositePlacement(placement)
    } else if (placement === 'left' && triggerRect.left - tooltipRect.width - TOOLTIP_GAP < 0) {
      placement = getOppositePlacement(placement)
    } else if (
      placement === 'right' &&
      triggerRect.right + tooltipRect.width + TOOLTIP_GAP > viewportWidth
    ) {
      placement = getOppositePlacement(placement)
    }

    let top = 0
    let left = 0

    switch (placement) {
      case 'top': {
        top = triggerRect.top - tooltipRect.height - TOOLTIP_GAP
        left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2
        this.arrowEl.className = 'cmm-tooltip__arrow cmm-tooltip__arrow--bottom'
        break
      }
      case 'bottom': {
        top = triggerRect.bottom + TOOLTIP_GAP
        left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2
        this.arrowEl.className = 'cmm-tooltip__arrow cmm-tooltip__arrow--top'
        break
      }
      case 'left': {
        top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2
        left = triggerRect.left - tooltipRect.width - TOOLTIP_GAP
        this.arrowEl.className = 'cmm-tooltip__arrow cmm-tooltip__arrow--right'
        break
      }
      case 'right': {
        top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2
        left = triggerRect.right + TOOLTIP_GAP
        this.arrowEl.className = 'cmm-tooltip__arrow cmm-tooltip__arrow--left'
        break
      }
    }

    // Clamp to viewport
    left = Math.max(4, Math.min(left, viewportWidth - tooltipRect.width - 4))
    top = Math.max(4, Math.min(top, viewportHeight - tooltipRect.height - 4))

    this.tooltipEl.style.left = `${String(left)}px`
    this.tooltipEl.style.top = `${String(top)}px`
  }
}
