/**
 * HoverCard - Rich content popup on hover
 * @countermeasure/web-components/display/hover-card
 */

import { uniqueId } from '../../primitives/utils'
import DOMPurify from 'dompurify'
import { type HoverCardConfig, type HoverCardPlacement } from './types'

export type { HoverCardConfig, HoverCardPlacement } from './types'

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

/** Gap in pixels between trigger and hover card */
const CARD_GAP = 8

export class HoverCard {
  private readonly trigger: HTMLElement
  private readonly placement: HoverCardPlacement
  private readonly openDelay: number
  private readonly closeDelay: number
  private readonly className: string | undefined
  private readonly cardId: string

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

  /** Bound event handlers for cleanup */
  private readonly handleTriggerMouseEnter: () => void
  private readonly handleTriggerMouseLeave: () => void
  private readonly handleCardMouseEnter: () => void
  private readonly handleCardMouseLeave: () => void

  constructor(config: HoverCardConfig) {
    this.trigger = config.trigger
    this.content = config.content
    this.placement = config.placement ?? 'bottom'
    this.openDelay = config.openDelay ?? 300
    this.closeDelay = config.closeDelay ?? 200
    this.className = config.className
    this.cardId = uniqueId('cmm-hover-card')

    this.handleTriggerMouseEnter = () => {
      this.scheduleShow()
    }
    this.handleTriggerMouseLeave = () => {
      this.scheduleHide()
    }
    this.handleCardMouseEnter = () => {
      this.cancelScheduledHide()
    }
    this.handleCardMouseLeave = () => {
      this.scheduleHide()
    }

    this.trigger.setAttribute('aria-haspopup', 'dialog')
    this.trigger.addEventListener('mouseenter', this.handleTriggerMouseEnter)
    this.trigger.addEventListener('mouseleave', this.handleTriggerMouseLeave)
  }

  /** Update the hover card content */
  setContent(content: HTMLElement | string): void {
    this.content = content
    if (this.contentEl) {
      this.renderContent()
    }
  }

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

    this.cancelScheduledShow()
    this.cancelScheduledHide()
    this.createCardElement()
    this.positionCard()

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

  /** Programmatically hide the hover card */
  hide(): void {
    this.cancelScheduledShow()
    this.cancelScheduledHide()

    if (this.cardEl) {
      this.cardEl.classList.remove('cmm-hover-card--visible')
      this.cardEl.removeEventListener('mouseenter', this.handleCardMouseEnter)
      this.cardEl.removeEventListener('mouseleave', this.handleCardMouseLeave)

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

      this.cardEl = null
      this.contentEl = null
      this.arrowEl = null
    }
  }

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

    this.cancelScheduledShow()
    this.cancelScheduledHide()
    this.hide()

    this.trigger.removeEventListener('mouseenter', this.handleTriggerMouseEnter)
    this.trigger.removeEventListener('mouseleave', this.handleTriggerMouseLeave)
    this.trigger.removeAttribute('aria-haspopup')
  }

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

    this.cancelScheduledHide()
    this.cancelScheduledShow()

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

  /** Schedule hiding the hover card after the configured close delay */
  private scheduleHide(): void {
    this.cancelScheduledShow()
    this.cancelScheduledHide()

    if (this.closeDelay > 0) {
      this.hideTimeoutId = setTimeout(() => {
        this.hide()
      }, this.closeDelay)
    } else {
      this.hide()
    }
  }

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

  /** Cancel any pending hide timeout */
  private cancelScheduledHide(): void {
    if (this.hideTimeoutId !== null) {
      clearTimeout(this.hideTimeoutId)
      this.hideTimeoutId = null
    }
  }

  /** Create the hover card DOM element and append to body */
  private createCardElement(): void {
    // Remove existing card if present
    if (this.cardEl) {
      this.cardEl.removeEventListener('mouseenter', this.handleCardMouseEnter)
      this.cardEl.removeEventListener('mouseleave', this.handleCardMouseLeave)
      this.cardEl.remove()
    }

    const card = document.createElement('div')
    card.className = 'cmm-hover-card'
    card.id = this.cardId
    card.setAttribute('role', 'dialog')

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

    // Content container
    const contentEl = document.createElement('div')
    contentEl.className = 'cmm-hover-card__content'
    card.appendChild(contentEl)

    // Arrow
    const arrow = document.createElement('div')
    arrow.className = 'cmm-hover-card__arrow'
    card.appendChild(arrow)

    document.body.appendChild(card)

    this.cardEl = card
    this.contentEl = contentEl
    this.arrowEl = arrow

    this.renderContent()

    // Allow hovering over the card itself
    this.cardEl.addEventListener('mouseenter', this.handleCardMouseEnter)
    this.cardEl.addEventListener('mouseleave', this.handleCardMouseLeave)
  }

  /** Render the content into the content element */
  private renderContent(): void {
    if (!this.contentEl) {
      return
    }

    this.contentEl.innerHTML = ''

    if (typeof this.content === 'string') {
      this.contentEl.innerHTML = DOMPurify.sanitize(this.content)
    } else {
      this.contentEl.appendChild(this.content)
    }
  }

  /** Position the hover card relative to the trigger element */
  private positionCard(): void {
    if (!this.cardEl || !this.arrowEl) {
      return
    }

    const triggerRect = this.trigger.getBoundingClientRect()
    const cardRect = this.cardEl.getBoundingClientRect()
    const viewportWidth = window.innerWidth
    const viewportHeight = window.innerHeight

    let placement = this.placement

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

    let top = 0
    let left = 0

    switch (placement) {
      case 'top': {
        top = triggerRect.top - cardRect.height - CARD_GAP
        left = triggerRect.left + (triggerRect.width - cardRect.width) / 2
        this.arrowEl.className = 'cmm-hover-card__arrow cmm-hover-card__arrow--bottom'
        break
      }
      case 'bottom': {
        top = triggerRect.bottom + CARD_GAP
        left = triggerRect.left + (triggerRect.width - cardRect.width) / 2
        this.arrowEl.className = 'cmm-hover-card__arrow cmm-hover-card__arrow--top'
        break
      }
      case 'left': {
        top = triggerRect.top + (triggerRect.height - cardRect.height) / 2
        left = triggerRect.left - cardRect.width - CARD_GAP
        this.arrowEl.className = 'cmm-hover-card__arrow cmm-hover-card__arrow--right'
        break
      }
      case 'right': {
        top = triggerRect.top + (triggerRect.height - cardRect.height) / 2
        left = triggerRect.right + CARD_GAP
        this.arrowEl.className = 'cmm-hover-card__arrow cmm-hover-card__arrow--left'
        break
      }
    }

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

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