/**
 * --------------------------------------------------------------------------
 * Bootstrap tooltip.ts
 * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
 * --------------------------------------------------------------------------
 */

import {
  computePosition,
  flip,
  shift,
  offset,
  arrow,
  autoUpdate,
  type Boundary,
  type Middleware,
  type Placement
} from '@floating-ui/dom'
import BaseComponent from './base-component.js'
import EventHandler, { type BootstrapEvent } from './dom/event-handler.js'
import Manipulator from './dom/manipulator.js'
import type { ComponentConfig } from './util/config.js'
import {
  execute, findShadowRoot, getElement, getTransitionDurationFromElement, getUID, isRTL, noop
} from './util/index.js'
import { DefaultAllowlist, type SanitizerAllowList } from './util/sanitizer.js'
import TemplateFactory, { type TemplateContentEntry } from './util/template-factory.js'
import {
  parseResponsivePlacement,
  getResponsivePlacement,
  createBreakpointListeners,
  disposeBreakpointListeners,
  toFloatingOffset,
  type BreakpointListener,
  type ResponsivePlacements,
  type FloatingOffsetOption,
  type FloatingConfigOption
} from './util/floating-ui.js'

/**
 * Constants
 */

const NAME = 'tooltip'
const DISALLOWED_ATTRIBUTES = new Set(['sanitize', 'allowList', 'sanitizeFn'])

const ESCAPE_KEY = 'Escape'

const CLASS_NAME_MODAL = 'modal'
const CLASS_NAME_SHOW = 'show'

const SELECTOR_TOOLTIP_INNER = '.tooltip-inner'
const SELECTOR_MODAL = `.${CLASS_NAME_MODAL}`
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="tooltip"]'

const EVENT_MODAL_HIDE = 'hide.bs.modal'

const TRIGGER_HOVER = 'hover'
const TRIGGER_FOCUS = 'focus'
const TRIGGER_CLICK = 'click'
const TRIGGER_MANUAL = 'manual'

const EVENT_HIDE = 'hide'
const EVENT_HIDDEN = 'hidden'
const EVENT_SHOW = 'show'
const EVENT_SHOWN = 'shown'
const EVENT_INSERTED = 'inserted'
const EVENT_CLICK = 'click'
const EVENT_FOCUSIN = 'focusin'
const EVENT_FOCUSOUT = 'focusout'
const EVENT_MOUSEENTER = 'mouseenter'
const EVENT_MOUSELEAVE = 'mouseleave'
const EVENT_POINTERDOWN = 'pointerdown'
const EVENT_POINTERUP = 'pointerup'
const EVENT_KEYDOWN = 'keydown'

const AttachmentMap: Record<string, string> = {
  AUTO: 'auto',
  TOP: 'top',
  RIGHT: isRTL() ? 'left' : 'right',
  BOTTOM: 'bottom',
  LEFT: isRTL() ? 'right' : 'left'
}

type TooltipConfig = {
  allowList: SanitizerAllowList
  animation: boolean
  boundary: string | Element
  container: string | Element | boolean
  customClass: string | ((...args: any[]) => string)
  delay: number | { show: number, hide: number }
  fallbackPlacements: string[]
  html: boolean
  offset: FloatingOffsetOption
  placement: string | ((this: Tooltip, tip: HTMLElement, trigger: HTMLElement) => string)
  floatingConfig: FloatingConfigOption
  sanitize: boolean
  sanitizeFn: ((unsafeHtml: string) => string) | null
  selector: string | boolean
  template: string
  title: string | Element | ((...args: any[]) => string | Element)
  trigger: string
}

// Delegated tooltips are created on the fly with their `trigger` forced to
// `manual`, so the real trigger is stored here for later reference.
type TooltipInternalConfig = TooltipConfig & { _trigger: string }

const Default: TooltipConfig = {
  allowList: DefaultAllowlist,
  animation: true,
  boundary: 'clippingParents',
  container: false,
  customClass: '',
  delay: 0,
  fallbackPlacements: ['top', 'right', 'bottom', 'left'],
  html: false,
  offset: [0, 6],
  placement: 'top',
  floatingConfig: null,
  sanitize: true,
  sanitizeFn: null,
  selector: false,
  template: '<div class="tooltip" role="tooltip">' +
            '<div class="tooltip-arrow"></div>' +
            '<div class="tooltip-inner"></div>' +
            '</div>',
  title: '',
  trigger: 'hover focus'
}

const DefaultType = {
  allowList: 'object',
  animation: 'boolean',
  boundary: '(string|element)',
  container: '(string|element|boolean)',
  customClass: '(string|function)',
  delay: '(number|object)',
  fallbackPlacements: 'array',
  html: 'boolean',
  offset: '(array|string|function)',
  placement: '(string|function)',
  floatingConfig: '(null|object|function)',
  sanitize: 'boolean',
  sanitizeFn: '(null|function)',
  selector: '(string|boolean)',
  template: 'string',
  title: '(string|element|function)',
  trigger: 'string'
}

/**
 * Class definition
 */

class Tooltip extends BaseComponent {
  declare ['constructor']: typeof Tooltip
  protected declare _config: TooltipConfig
  protected declare _isEnabled: boolean
  protected declare _timeout: number
  protected declare _resolveTimeout: (() => void) | null
  protected declare _isHovered: boolean | null
  protected declare _activeTrigger: Record<string, boolean>
  protected declare _floatingCleanup: (() => void) | null
  protected declare _keydownHandler: ((event: KeyboardEvent) => void) | null
  protected declare _tipEventOut: ((event: BootstrapEvent) => void) | null
  protected declare _outsidePointerHandler: ((event: PointerEvent) => void) | null
  protected declare _tipPointerUpHandler: (() => void) | null
  protected declare _tipPointerDown: boolean
  protected declare _templateFactory: TemplateFactory | null
  protected declare _newContent: Record<string, TemplateContentEntry> | null
  protected declare _mediaQueryListeners: BreakpointListener[]
  protected declare _responsivePlacements: ResponsivePlacements | null
  protected declare _hideModalHandler: () => void
  declare tip: HTMLElement | null

  constructor(element?: string | Element | null, config?: Partial<TooltipConfig> | null) {
    if (typeof computePosition === 'undefined') {
      throw new TypeError('Bootstrap\'s tooltips require Floating UI (https://floating-ui.com)')
    }

    super(element, config)

    // Private
    this._isEnabled = true
    this._timeout = 0
    this._resolveTimeout = null
    this._isHovered = null
    this._activeTrigger = {}
    this._floatingCleanup = null
    this._keydownHandler = null
    this._tipEventOut = null
    this._outsidePointerHandler = null
    this._tipPointerUpHandler = null
    this._tipPointerDown = false
    this._templateFactory = null
    this._newContent = null
    this._mediaQueryListeners = []
    this._responsivePlacements = null

    // Protected
    this.tip = null

    this._parseResponsivePlacements()
    this._setListeners()

    if (!this._config.selector) {
      this._fixTitle()
    }
  }

  // Getters
  static override get Default(): TooltipConfig {
    return Default
  }

  static override get DefaultType(): Record<string, string> {
    return DefaultType
  }

  static override get NAME(): string {
    return NAME
  }

  // Public
  enable(): void {
    this._isEnabled = true
  }

  disable(): void {
    this._isEnabled = false
  }

  toggleEnabled(): void {
    this._isEnabled = !this._isEnabled
  }

  toggle(): Promise<void> {
    if (!this._isEnabled) {
      return Promise.resolve()
    }

    return this._isShown() ? this._leave() : this._enter()
  }

  override dispose(): void {
    this._clearTimeout()

    this._removeEscapeListener()
    this._removeFocusOutsideListener()

    EventHandler.off(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)

    if (this._element.getAttribute('data-bs-original-title')) {
      this._element.setAttribute('title', this._element.getAttribute('data-bs-original-title')!)
    }

    this._disposeFloating()
    this._disposeMediaQueryListeners()
    super.dispose()
  }

  async show(): Promise<void> {
    if (this._element.style.display === 'none') {
      throw new Error('Please use show on visible elements')
    }

    if (!(this._isWithContent() && this._isEnabled)) {
      return
    }

    const showEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOW))
    const shadowRoot = findShadowRoot(this._element)
    const isInTheDom = (shadowRoot || this._element.ownerDocument.documentElement).contains(this._element)

    if (showEvent.defaultPrevented || !isInTheDom) {
      // Reset the transient hover/active state so a prevented (or not-in-DOM)
      // show doesn't leave `_isHovered` stuck true — otherwise a click-triggered
      // tip would hit the `_enter()` early-return on every later click and never
      // reopen.
      this._isHovered = false
      return
    }

    this._disposeFloating()

    const tip = this._getTipElement()

    this._element.setAttribute('aria-describedby', tip.getAttribute('id')!)

    let { container } = this._config as TooltipConfig & { container: Element }
    const closestDialog = this._element.closest('dialog[open]')
    if (closestDialog && container === document.body) {
      container = closestDialog
    }

    if (!this._element.ownerDocument.documentElement.contains(this.tip)) {
      container.append(tip)
      EventHandler.trigger(this._element, this.constructor.eventName(EVENT_INSERTED))
      this._setTipListeners(tip)
    }

    await this._createFloating(tip)

    tip.classList.add(CLASS_NAME_SHOW)

    // Allow dismissing the tooltip with the Escape key (WCAG 1.4.13)
    this._setEscapeListener()
    this._setFocusOutsideListener()

    // 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) {
      for (const element of document.body.children) {
        EventHandler.on(element, 'mouseover', noop)
      }
    }

    const complete = () => {
      EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOWN))

      if (this._isHovered === false) {
        this._leave()
      }

      this._isHovered = false
    }

    await this._queueCallback(complete, this.tip!, this._isAnimated())
  }

  async hide(): Promise<void> {
    if (!this._isShown()) {
      return
    }

    const hideEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDE))
    if (hideEvent.defaultPrevented) {
      return
    }

    this._removeEscapeListener()
    this._removeFocusOutsideListener()

    const tip = this._getTipElement()
    tip.classList.remove(CLASS_NAME_SHOW)

    // If this is a touch-enabled device we remove the extra
    // empty mouseover listeners we added for iOS support
    if ('ontouchstart' in document.documentElement) {
      for (const element of document.body.children) {
        EventHandler.off(element, 'mouseover', noop)
      }
    }

    this._activeTrigger[TRIGGER_CLICK] = false
    this._activeTrigger[TRIGGER_FOCUS] = false
    this._activeTrigger[TRIGGER_HOVER] = false
    this._isHovered = null // it is a trick to support manual triggering

    const complete = () => {
      if (this._isWithActiveTrigger()) {
        return
      }

      if (!this._isHovered) {
        this._disposeFloating()
      }

      this._element.removeAttribute('aria-describedby')
      EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDDEN))
    }

    await this._queueCallback(complete, this.tip!, this._isAnimated())
  }

  update(): void {
    if (this._floatingCleanup && this.tip) {
      this._updateFloatingPosition()
    }
  }

  // Protected
  protected _isWithContent(): boolean {
    return Boolean(this._getTitle()) || this._hasNewContent()
  }

  // Content supplied via setContent() (a `{ selector: content }` map) overrides
  // the configured title/content when rendering, so it should also satisfy the
  // show() gate — otherwise a tip whose content is only set via setContent()
  // can never be shown.
  protected _hasNewContent(): boolean {
    return Boolean(this._newContent) && Object.values(this._newContent!).some(Boolean)
  }

  protected _getTipElement(): HTMLElement {
    if (!this.tip) {
      this.tip = this._createTipElement(this._newContent || this._getContentForTemplate())
    }

    return this.tip
  }

  protected _createTipElement(content: Record<string, TemplateContentEntry>): HTMLElement {
    const tip = this._getTemplateFactory(content).toHtml()

    tip.classList.remove(CLASS_NAME_SHOW)
    tip.classList.add(`bs-${this.constructor.NAME}-auto`)

    const tipId = getUID(this.constructor.NAME).toString()

    tip.setAttribute('id', tipId)

    // The tip fades in CSS, from a @starting-style rule. `animation: false`
    // opts out through the same class an author can add.
    if (!this._config.animation) {
      tip.classList.add(this._getInstantClassName())
    }

    this._setFocusTipListeners(tip)

    return tip
  }

  setContent(content: Record<string, TemplateContentEntry>): void {
    this._newContent = content
    if (this._isShown()) {
      this._disposeFloating()
      this.show()
    }
  }

  protected _getTemplateFactory(content: Record<string, TemplateContentEntry>): TemplateFactory {
    if (this._templateFactory) {
      this._templateFactory.changeContent(content)
    } else {
      this._templateFactory = new TemplateFactory({
        ...this._config,
        // the `content` var has to be after `this._config`
        // to override config.content in case of popover
        content,
        extraClass: this._resolvePossibleFunction(this._config.customClass)
      })
    }

    return this._templateFactory
  }

  protected _getContentForTemplate(): Record<string, TemplateContentEntry> {
    return {
      [SELECTOR_TOOLTIP_INNER]: this._getTitle()
    }
  }

  protected _getTitle(): string | Element | null {
    return this._resolvePossibleFunction(this._config.title) || this._element.getAttribute('data-bs-original-title')
  }

  // Private
  protected _initializeOnDelegatedTarget(event: BootstrapEvent): Tooltip {
    return this.constructor.getOrCreateInstance(event.delegateTarget, this._getDelegateConfig())
  }

  protected _getInstantClassName(): string {
    return `${this.constructor.NAME}-instant`
  }

  // The tip declares its transition in CSS, so a zero computed duration (an
  // instant tip, reduced motion, or transitions disabled) means no wait.
  protected _isAnimated(): boolean {
    return getTransitionDurationFromElement(this.tip) > 0
  }

  protected _isShown(): boolean | null {
    return this.tip && this.tip.classList.contains(CLASS_NAME_SHOW)
  }

  protected _getPlacement(tip: HTMLElement): string {
    // If we have responsive placements, get the one for current viewport
    if (this._responsivePlacements) {
      const placement = getResponsivePlacement(this._responsivePlacements, 'top')
      return AttachmentMap[placement.toUpperCase()] || placement
    }

    // Execute placement (can be a function)
    const placement = execute(this._config.placement, [this, tip, this._element])
    return AttachmentMap[placement.toUpperCase()] || placement
  }

  protected _parseResponsivePlacements(): void {
    // Only parse if placement is a string (not a function)
    if (typeof this._config.placement !== 'string') {
      this._responsivePlacements = null
      return
    }

    this._responsivePlacements = parseResponsivePlacement(this._config.placement, 'top')

    if (this._responsivePlacements) {
      this._setupMediaQueryListeners()
    }
  }

  protected _setupMediaQueryListeners(): void {
    this._disposeMediaQueryListeners()
    this._mediaQueryListeners = createBreakpointListeners(() => {
      if (this._isShown()) {
        this._updateFloatingPosition()
      }
    })
  }

  protected _disposeMediaQueryListeners(): void {
    disposeBreakpointListeners(this._mediaQueryListeners)
    this._mediaQueryListeners = []
  }

  protected async _createFloating(tip: HTMLElement): Promise<void> {
    const placement = this._getPlacement(tip)
    const arrowElement = tip.querySelector<HTMLElement>(`.${this.constructor.NAME}-arrow`)

    // Initial position update
    await this._updateFloatingPosition(tip, placement, arrowElement)

    // Set up auto-update for scroll/resize
    this._floatingCleanup = autoUpdate(
      this._element,
      tip,
      () => this._updateFloatingPosition(tip, null, arrowElement)
    )
  }

  protected async _updateFloatingPosition(tip: HTMLElement | null = this.tip, placement: string | null = null, arrowElement: HTMLElement | null = null): Promise<void> {
    if (!tip) {
      return
    }

    if (!placement) {
      placement = this._getPlacement(tip)
    }

    if (!arrowElement) {
      arrowElement = tip.querySelector<HTMLElement>(`.${this.constructor.NAME}-arrow`)
    }

    const middleware = this._getFloatingMiddleware(arrowElement)
    const floatingConfig = this._getFloatingConfig(placement, middleware)

    const { x, y, placement: finalPlacement, middlewareData } = await computePosition(
      this._element,
      tip,
      floatingConfig
    )

    // Apply position to tooltip
    Object.assign(tip.style, {
      position: 'absolute',
      left: `${x}px`,
      top: `${y}px`
    })

    // Ensure arrow is absolutely positioned within tooltip
    if (arrowElement) {
      arrowElement.style.position = 'absolute'
    }

    // Set placement attribute for CSS arrow styling
    Manipulator.setDataAttribute(tip, 'placement', finalPlacement)

    // Position arrow along the edge (center it) if present
    // The CSS handles which edge to place it on via data-bs-placement
    if (arrowElement && middlewareData.arrow) {
      const { x: arrowX, y: arrowY } = middlewareData.arrow
      const isVertical = finalPlacement.startsWith('top') || finalPlacement.startsWith('bottom')

      // Only set the cross-axis position (centering along the edge)
      // The main-axis position (which edge) is handled by CSS
      // Floating UI reports the unused axis as `undefined`, never `null`
      Object.assign(arrowElement.style, {
        left: isVertical && arrowX !== undefined ? `${arrowX}px` : '',
        top: !isVertical && arrowY !== undefined ? `${arrowY}px` : '',
        // Reset the other axis to let CSS handle it
        right: '',
        bottom: ''
      })
    }
  }

  protected _getOffset(): number[] | ((state: any) => any) {
    const { offset } = this._config

    if (typeof offset === 'string') {
      return offset.split(',').map(value => Number.parseInt(value, 10))
    }

    if (typeof offset === 'function') {
      // Floating UI passes different args, adapt the interface for offset function callbacks
      return ({ placement, rects }) => {
        const result = offset({ placement, reference: rects.reference, floating: rects.floating }, this._element)
        return toFloatingOffset(result)
      }
    }

    return offset
  }

  protected _resolvePossibleFunction<T>(arg: T | ((...args: any[]) => T)): T {
    return execute(arg, [this._element, this._element])
  }

  protected _getFloatingMiddleware(arrowElement: HTMLElement | null): Middleware[] {
    const offsetValue = this._getOffset()

    const middleware = [
      // Offset middleware - handles distance from reference
      offset(
        typeof offsetValue === 'function' ?
          offsetValue :
          toFloatingOffset(offsetValue)
      ),
      // Flip middleware - handles fallback placements
      flip({
        fallbackPlacements: this._config.fallbackPlacements as Placement[]
      }),
      // Shift middleware - prevents overflow
      shift({
        boundary: (this._config.boundary === 'clippingParents' ? 'clippingAncestors' : this._config.boundary) as Boundary
      })
    ]

    // Arrow middleware - positions the arrow element
    if (arrowElement) {
      middleware.push(arrow({ element: arrowElement }))
    }

    return middleware
  }

  protected _getFloatingConfig(placement: string, middleware: Middleware[]): Record<string, any> {
    const defaultConfig = {
      placement,
      middleware
    }

    return {
      ...defaultConfig,
      ...execute(this._config.floatingConfig, [undefined, defaultConfig])
    }
  }

  protected _setListeners(): void {
    const triggers = this._config.trigger.split(' ')

    for (const trigger of triggers) {
      if (trigger === 'click') {
        EventHandler.on(this._element, this.constructor.eventName(EVENT_CLICK), this._config.selector as string, event => {
          const context = this._initializeOnDelegatedTarget(event)
          context._activeTrigger[TRIGGER_CLICK] = !(context._isShown() && context._activeTrigger[TRIGGER_CLICK])
          context.toggle()
        })
      } else if (trigger !== TRIGGER_MANUAL) {
        const [eventIn, eventOut] = this._getTriggerEvents(trigger)

        EventHandler.on(this._element, eventIn, this._config.selector as string, event => {
          const context = this._initializeOnDelegatedTarget(event)
          context._activeTrigger[event.type === 'focusin' ? TRIGGER_FOCUS : TRIGGER_HOVER] = true
          context._enter()
        })
        EventHandler.on(this._element, eventOut, this._config.selector as string, event => {
          const context = this._initializeOnDelegatedTarget(event)

          if (event.type === 'focusout') {
            context._activeTrigger[TRIGGER_FOCUS] =
              context._isInside(event.relatedTarget) || context._tipPointerDown
          } else {
            context._activeTrigger[TRIGGER_HOVER] = context._isInside(event.relatedTarget)
          }

          context._leave()
        })
      }
    }

    this._hideModalHandler = () => {
      if (this._element) {
        this.hide()
      }
    }

    EventHandler.on(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)
  }

  // Keep the tooltip open while the pointer or focus moves onto the tip
  // itself, so users can reach interactive content inside it (WCAG 1.4.13).
  // Hover/focus tips get matching leave listeners on the tip element.
  protected _setTipListeners(tip: HTMLElement): void {
    const trigger = this._getTrigger()

    if (trigger === TRIGGER_MANUAL || trigger.includes(TRIGGER_CLICK)) {
      return
    }

    this._tipEventOut = event => {
      this._activeTrigger[event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER] = this._isInside(event.relatedTarget)
      this._leave()
    }

    for (const name of trigger.split(' ')) {
      if (name === TRIGGER_HOVER || name === TRIGGER_FOCUS) {
        const [, eventOut] = this._getTriggerEvents(name)
        EventHandler.on(tip, eventOut, this._tipEventOut)
      }
    }
  }

  protected _removeTipListeners(tip: HTMLElement): void {
    if (!this._tipEventOut) {
      return
    }

    const trigger = this._getTrigger()

    for (const name of trigger.split(' ')) {
      if (name === TRIGGER_HOVER || name === TRIGGER_FOCUS) {
        const [, eventOut] = this._getTriggerEvents(name)
        EventHandler.off(tip, eventOut, this._tipEventOut)
      }
    }

    this._tipEventOut = null
  }

  protected _isInside(target: EventTarget | null): boolean {
    return target instanceof Node &&
      (this._element.contains(target) || Boolean(this.tip?.contains(target)))
  }

  protected _getTrigger(): string {
    return (this._config as TooltipInternalConfig)._trigger
  }

  protected _getTriggerEvents(trigger: string): [string, string] {
    const events: Record<string, [string, string]> = {
      [TRIGGER_HOVER]: [
        this.constructor.eventName(EVENT_MOUSEENTER),
        this.constructor.eventName(EVENT_MOUSELEAVE)
      ],
      [TRIGGER_FOCUS]: [
        this.constructor.eventName(EVENT_FOCUSIN),
        this.constructor.eventName(EVENT_FOCUSOUT)
      ]
    }

    return events[trigger]
  }

  protected _hasFocusTrigger(): boolean {
    return this._getTrigger().split(' ').includes(TRIGGER_FOCUS)
  }

  protected _setFocusTipListeners(tip: HTMLElement): void {
    if (!this._hasFocusTrigger()) {
      return
    }

    EventHandler.on(tip, this.constructor.eventName(EVENT_POINTERDOWN), () => {
      this._tipPointerDown = true
      this._activeTrigger[TRIGGER_FOCUS] = true
    })

    EventHandler.on(tip, this.constructor.eventName(EVENT_FOCUSIN), () => {
      this._activeTrigger[TRIGGER_FOCUS] = true
    })

    EventHandler.on(tip, this.constructor.eventName(EVENT_FOCUSOUT), (event: BootstrapEvent) => {
      this._activeTrigger[TRIGGER_FOCUS] =
        this._isInside(event.relatedTarget) || this._tipPointerDown
      this._leave()
    })
  }

  protected _setFocusOutsideListener(): void {
    if (this._outsidePointerHandler || !this._hasFocusTrigger()) {
      return
    }

    this._tipPointerUpHandler = () => {
      this._tipPointerDown = false
    }

    this._outsidePointerHandler = event => {
      if (!this._isShown() || this._isInside(event.target)) {
        return
      }

      this._activeTrigger[TRIGGER_FOCUS] = false
      this.hide()
    }

    const doc = this._element.ownerDocument
    doc.addEventListener(EVENT_POINTERUP, this._tipPointerUpHandler, true)
    doc.addEventListener(EVENT_POINTERDOWN, this._outsidePointerHandler, true)
  }

  protected _removeFocusOutsideListener(): void {
    const doc = this._element?.ownerDocument

    if (this._tipPointerUpHandler && doc) {
      doc.removeEventListener(EVENT_POINTERUP, this._tipPointerUpHandler, true)
    }

    if (this._outsidePointerHandler && doc) {
      doc.removeEventListener(EVENT_POINTERDOWN, this._outsidePointerHandler, true)
    }

    this._tipPointerUpHandler = null
    this._outsidePointerHandler = null
    this._tipPointerDown = false
  }

  protected _setEscapeListener(): void {
    if (this._keydownHandler) {
      return
    }

    this._keydownHandler = event => {
      if (event.key !== ESCAPE_KEY || !this._isShown() || !this.tip!.isConnected) {
        return
      }

      // Dismiss the tooltip and consume the keystroke so it doesn't reach
      // ancestor components (e.g. a parent dialog). This way the first Escape
      // only closes the tooltip, and a subsequent one can close the dialog —
      // matching the behavior of the dropdown menu.
      event.preventDefault()
      event.stopPropagation()
      this.hide()
    }

    // Listen in the capture phase so this runs before the dialog's own keydown
    // handler, and on the document so it works regardless of where focus is
    // (e.g. for hover-triggered tooltips). EventHandler only uses the capture
    // phase for delegated listeners, so attach natively here.
    this._element.ownerDocument.addEventListener(EVENT_KEYDOWN, this._keydownHandler, true)
  }

  protected _removeEscapeListener(): void {
    if (!this._keydownHandler) {
      return
    }

    this._element.ownerDocument.removeEventListener(EVENT_KEYDOWN, this._keydownHandler, true)
    this._keydownHandler = null
  }

  protected _fixTitle(): void {
    const title = this._element.getAttribute('title')

    if (!title) {
      return
    }

    if (!this._element.getAttribute('aria-label') && !this._element.textContent!.trim()) {
      this._element.setAttribute('aria-label', title)
    }

    this._element.setAttribute('data-bs-original-title', title) // DO NOT USE IT. Is only for backwards compatibility
    this._element.removeAttribute('title')
  }

  protected _enter(): Promise<void> {
    if (this._isShown() || this._isHovered) {
      this._isHovered = true
      return Promise.resolve()
    }

    this._isHovered = true

    return this._setTimeout(
      () => this._isHovered ? this.show() : undefined,
      (this._config.delay as { show: number, hide: number }).show
    )
  }

  protected _leave(): Promise<void> {
    if (this._isWithActiveTrigger()) {
      return Promise.resolve()
    }

    this._isHovered = false

    return this._setTimeout(
      () => this._isHovered ? undefined : this.hide(),
      (this._config.delay as { show: number, hide: number }).hide
    )
  }

  protected _setTimeout(handler: () => void | Promise<void>, timeout: number): Promise<void> {
    this._clearTimeout()

    return new Promise(resolve => {
      this._resolveTimeout = resolve
      this._timeout = setTimeout(() => {
        this._resolveTimeout = null
        resolve(handler())
      }, timeout)
    })
  }

  // Cancels the pending hover delay. The superseded promise resolves right away, so a
  // caller awaiting `toggle()` resumes instead of waiting on a timer that never fires.
  protected _clearTimeout(): void {
    clearTimeout(this._timeout)

    if (this._resolveTimeout) {
      this._resolveTimeout()
      this._resolveTimeout = null
    }
  }

  protected _isWithActiveTrigger(): boolean {
    return Object.values(this._activeTrigger).includes(true)
  }

  protected override _getConfig(config?: ComponentConfig | null): ComponentConfig {
    const dataAttributes = Manipulator.getDataAttributes(this._element)

    for (const dataAttribute of Object.keys(dataAttributes)) {
      if (DISALLOWED_ATTRIBUTES.has(dataAttribute)) {
        delete dataAttributes[dataAttribute]
      }
    }

    config = {
      ...dataAttributes,
      ...(typeof config === 'object' && config ? config : {})
    }
    config = this._mergeConfigObj(config)
    config = this._configAfterMerge(config)
    this._typeCheckConfig(config)
    return config
  }

  protected override _configAfterMerge(config: ComponentConfig): ComponentConfig {
    config.container = config.container === false ? document.body : getElement(config.container)

    // Delegated tooltips are created on the fly with `trigger` set to `manual`,
    // which makes the real trigger hard to check later. Preserve it here so the
    // tip listeners know whether the tooltip is hover- or focus-driven.
    config._trigger = config._trigger || config.trigger

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

    // Coerce number/boolean title and content to strings. `data-bs-title="true"`
    // / `data-bs-content="false"` are auto-converted to booleans by the data-API,
    // which would otherwise fail the (null|string|element|function) type check.
    if (typeof config.title === 'number' || typeof config.title === 'boolean') {
      config.title = config.title.toString()
    }

    if (typeof config.content === 'number' || typeof config.content === 'boolean') {
      config.content = config.content.toString()
    }

    return config
  }

  protected _getDelegateConfig(): ComponentConfig {
    const config: ComponentConfig = {}

    for (const [key, value] of Object.entries(this._config)) {
      if (this.constructor.Default[key as keyof TooltipConfig] !== value) {
        config[key] = value
      }
    }

    config.selector = false
    config.trigger = 'manual'

    // In the future can be replaced with:
    // const keysWithDifferentValues = Object.entries(this._config).filter(entry => this.constructor.Default[entry[0]] !== this._config[entry[0]])
    // `Object.fromEntries(keysWithDifferentValues)`
    return config
  }

  protected _disposeFloating(): void {
    if (this._floatingCleanup) {
      this._floatingCleanup()
      this._floatingCleanup = null
    }

    if (this.tip) {
      this._removeTipListeners(this.tip)
      EventHandler.off(this.tip, this.constructor.EVENT_KEY)
      this.tip.remove()
      this.tip = null
    }
  }
}

/**
 * Data API implementation - auto-initialize tooltips
 */

const initTooltip = (event: BootstrapEvent): void => {
  const target = (event.target as Element).closest(SELECTOR_DATA_TOGGLE)
  if (!target) {
    return
  }

  // Lazily create the instance. The instance's own `_setListeners()` registers
  // the appropriate listeners on the element for the configured triggers
  // (hover/focus by default), so we don't mutate `_activeTrigger` or call
  // `_enter` here — doing so would show tooltips for triggers the user didn't
  // opt into (e.g. `focusin` firing for click-focused buttons in Chromium,
  // even when `trigger="hover"` or `trigger="manual"`) and leave stale state
  // on `_activeTrigger`.
  Tooltip.getOrCreateInstance(target)
}

// Auto-initialize tooltips on first interaction for hover and focus triggers
EventHandler.on(document, EVENT_FOCUSIN, SELECTOR_DATA_TOGGLE, initTooltip)
EventHandler.on(document, EVENT_MOUSEENTER, SELECTOR_DATA_TOGGLE, initTooltip)

export default Tooltip
export type { TooltipConfig }
