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

import {
  computePosition,
  flip,
  shift,
  offset,
  autoUpdate,
  type Boundary,
  type Middleware,
  type MiddlewareState,
  type Placement,
  type ReferenceElement,
  type Strategy
} 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 SelectorEngine from './dom/selector-engine.js'
import type { ComponentConfig } from './util/config.js'
import {
  execute,
  getElement,
  getNextActiveElement,
  getTransitionDurationFromElement,
  isDisabled,
  isElement,
  isRTL,
  isVisible,
  noop
} from './util/index.js'
import {
  parseResponsivePlacement,
  getResponsivePlacement,
  createBreakpointListeners,
  disposeBreakpointListeners,
  toFloatingOffset,
  type BreakpointListener,
  type ResponsivePlacements,
  type FloatingOffsetOption,
  type FloatingConfigOption
} from './util/floating-ui.js'

/**
 * Constants
 */

const NAME = 'menu'
const DATA_KEY = 'bs.menu'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'

const ESCAPE_KEY = 'Escape'
const TAB_KEY = 'Tab'
const ARROW_UP_KEY = 'ArrowUp'
const ARROW_DOWN_KEY = 'ArrowDown'
const ARROW_LEFT_KEY = 'ArrowLeft'
const ARROW_RIGHT_KEY = 'ArrowRight'
const HOME_KEY = 'Home'
const END_KEY = 'End'
const ENTER_KEY = 'Enter'
const SPACE_KEY = ' '
const RIGHT_MOUSE_BUTTON = 2

const SUBMENU_CLOSE_DELAY = 100

const EVENT_HIDE = `hide${EVENT_KEY}`
const EVENT_HIDDEN = `hidden${EVENT_KEY}`
const EVENT_SHOW = `show${EVENT_KEY}`
const EVENT_SHOWN = `shown${EVENT_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
const EVENT_KEYDOWN_DATA_API = `keydown${EVENT_KEY}${DATA_API_KEY}`
const EVENT_KEYUP_DATA_API = `keyup${EVENT_KEY}${DATA_API_KEY}`

const CLASS_NAME_SHOW = 'show'

const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="menu"]:not(.disabled):not(:disabled)'

const SELECTOR_MENU = '.menu'
const SELECTOR_SUBMENU = '.submenu'
const SELECTOR_SUBMENU_TOGGLE = '.submenu > .menu-item'
const SELECTOR_NAVBAR_NAV = '.navbar-nav'
const SELECTOR_VISIBLE_ITEMS = '.menu-item:not(.disabled):not(:disabled)'

const DEFAULT_PLACEMENT = 'bottom-start'
const SUBMENU_PLACEMENT = 'end-start'

const resolveLogicalPlacement = (placement: string): string => {
  if (isRTL()) {
    return placement.replace(/^start(?=-|$)/, 'right').replace(/^end(?=-|$)/, 'left')
  }

  return placement.replace(/^start(?=-|$)/, 'left').replace(/^end(?=-|$)/, 'right')
}

type Point = { x: number, y: number }

const triangleSign = (p1: Point, p2: Point, p3: Point): number =>
  ((p1.x - p3.x) * (p2.y - p3.y)) - ((p2.x - p3.x) * (p1.y - p3.y))

type MenuConfig = {
  autoClose: boolean | 'inside' | 'outside'
  boundary: string | Element
  container: string | Element | boolean
  display: string
  offset: FloatingOffsetOption
  floatingConfig: FloatingConfigOption
  menu: HTMLElement | null
  placement: string
  reference: string | Element | Record<string, any>
  strategy: string
  submenuTrigger: string
  submenuDelay: number
}

const Default: MenuConfig = {
  autoClose: true,
  boundary: 'clippingParents',
  container: false,
  display: 'dynamic',
  offset: [0, 2],
  floatingConfig: null,
  menu: null,
  placement: DEFAULT_PLACEMENT,
  reference: 'toggle',
  strategy: 'absolute',
  submenuTrigger: 'both',
  submenuDelay: SUBMENU_CLOSE_DELAY
}

const DefaultType = {
  autoClose: '(boolean|string)',
  boundary: '(string|element)',
  container: '(string|element|boolean)',
  display: 'string',
  offset: '(array|string|function)',
  floatingConfig: '(null|object|function)',
  menu: '(null|element)',
  placement: 'string',
  reference: '(string|element|object)',
  strategy: 'string',
  submenuTrigger: 'string',
  submenuDelay: 'number'
}

/**
 * Class definition
 */

class Menu extends BaseComponent {
  static _openInstances = new Set<Menu>()
  protected declare _config: MenuConfig
  protected declare _floatingCleanup: (() => void) | null
  protected declare _mediaQueryListeners: BreakpointListener[]
  protected declare _responsivePlacements: ResponsivePlacements | null
  protected declare _parent: HTMLElement
  protected declare _openSubmenus: Map<HTMLElement, () => void>
  protected declare _submenuCloseTimeouts: Map<HTMLElement, number>
  protected declare _hoverIntentData: { x: number, y: number, timestamp: number } | null
  protected declare _menu: HTMLElement
  protected declare _isSubmenu: boolean
  protected declare _menuOriginalParent: ParentNode | null

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

    super(element, config)

    this._floatingCleanup = null
    this._mediaQueryListeners = []
    this._responsivePlacements = null
    this._parent = this._element.parentNode as HTMLElement // menu wrapper
    this._openSubmenus = new Map()
    this._submenuCloseTimeouts = new Map()
    this._hoverIntentData = null

    this._menu = (this._config.menu || this._findMenu()) as HTMLElement

    // When the menu was discovered from the DOM, refine the wrapper to the closest
    // ancestor that actually contains it, so the toggle doesn't have to be a direct
    // sibling of `.menu` (e.g. when wrapped by web components). The wrapper still
    // receives `.show` and acts as the `reference: 'parent'` positioning anchor.
    if (!this._config.menu && this._menu) {
      this._parent = this._findWrapper(this._menu)
    }

    this._isSubmenu = this._parent.classList?.contains('submenu')

    this._menuOriginalParent = this._menu?.parentNode

    this._parseResponsivePlacements()
    this._setupSubmenuListeners()
    this._initSubmenuTriggers()
  }

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

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

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

  // Public
  toggle(): Promise<void> {
    return this._isShown() ? this.hide() : this.show()
  }

  async show(): Promise<void> {
    if (isDisabled(this._element) || this._isShown()) {
      return
    }

    const relatedTarget = {
      relatedTarget: this._element
    }

    const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, relatedTarget)

    if (showEvent.defaultPrevented) {
      return
    }

    this._moveMenuToContainer()
    this._createFloating()

    if ('ontouchstart' in document.documentElement && !this._parent.closest(SELECTOR_NAVBAR_NAV)) {
      for (const element of document.body.children) {
        EventHandler.on(element, 'mouseover', noop)
      }
    }

    this._element.focus({ focusVisible: false })
    this._element.setAttribute('aria-expanded', 'true')

    this._menu.classList.add(CLASS_NAME_SHOW)
    this._element.classList.add(CLASS_NAME_SHOW)

    if (this._parent) {
      this._parent.classList.add(CLASS_NAME_SHOW)
    }

    Menu._openInstances.add(this)

    // Wait for the CSS entry transition (opacity/transform) to finish before
    // announcing `shown`, so listeners run against the settled menu.
    await this._queueCallback(() => {
      // Bail if the menu was hidden again mid-transition.
      if (this._isShown()) {
        EventHandler.trigger(this._element, EVENT_SHOWN, relatedTarget)
      }
    }, this._menu, this._isAnimated())
  }

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

    const relatedTarget = {
      relatedTarget: this._element
    }

    await this._completeHide(relatedTarget)
  }

  override dispose(): void {
    this._disposeFloating()
    this._restoreMenuToOriginalParent()
    this._disposeMediaQueryListeners()
    this._closeAllSubmenus()
    this._clearAllSubmenuTimeouts()
    Menu._openInstances.delete(this)
    super.dispose()
  }

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

  // Private
  protected _findMenu(): Element | null {
    // Fall back to the closest ancestor that contains a menu so the toggle can be
    // nested deeper than a direct sibling of `.menu`.
    const wrapper = SelectorEngine.closest(this._element, `:has(${SELECTOR_MENU})`)
    return SelectorEngine.next(this._element, SELECTOR_MENU)[0] ||
      SelectorEngine.prev(this._element, SELECTOR_MENU)[0] ||
      SelectorEngine.findOne(SELECTOR_MENU, wrapper || this._parent)
  }

  protected _findWrapper(menu: HTMLElement): HTMLElement {
    let wrapper = this._element.parentNode
    while (wrapper instanceof Element && !wrapper.contains(menu)) {
      wrapper = wrapper.parentNode
    }

    return (wrapper instanceof Element ? wrapper : this._element.parentNode) as HTMLElement
  }

  protected async _completeHide(relatedTarget: Record<string, unknown>): Promise<void> {
    const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE, relatedTarget)
    if (hideEvent.defaultPrevented) {
      return
    }

    this._closeAllSubmenus()

    if ('ontouchstart' in document.documentElement) {
      for (const element of document.body.children) {
        EventHandler.off(element, 'mouseover', noop)
      }
    }

    // Start the exit transition by removing `.show`. Keep Floating UI active and
    // the menu in its current DOM location so it animates out in place; the
    // teardown (dispose floating, restore original parent) is deferred until the
    // transition ends. Doing it now would move/reposition the menu mid-fade and
    // make the exit animation appear to snap.
    this._menu.classList.remove(CLASS_NAME_SHOW)
    this._element.classList.remove(CLASS_NAME_SHOW)

    if (this._parent) {
      this._parent.classList.remove(CLASS_NAME_SHOW)
    }

    this._element.setAttribute('aria-expanded', 'false')
    Menu._openInstances.delete(this)

    await this._queueCallback(() => {
      // Bail if the menu was reopened during the exit transition.
      if (this._isShown()) {
        return
      }

      this._disposeFloating()
      this._restoreMenuToOriginalParent()
      Manipulator.removeDataAttribute(this._menu, 'placement')
      Manipulator.removeDataAttribute(this._menu, 'display')
      EventHandler.trigger(this._element, EVENT_HIDDEN, relatedTarget)
    }, this._menu, this._isAnimated())
  }

  protected override _getConfig(config?: ComponentConfig | null): ComponentConfig {
    config = super._getConfig(config)

    if (typeof config.reference === 'object' && !isElement(config.reference) &&
      typeof config.reference.getBoundingClientRect !== 'function'
    ) {
      throw new TypeError(`${NAME.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`)
    }

    return config
  }

  protected _createFloating(): void {
    if (this._config.display === 'static') {
      Manipulator.setDataAttribute(this._menu, 'display', 'static')
      return
    }

    let referenceElement: ReferenceElement = this._element

    if (this._config.reference === 'parent') {
      referenceElement = this._parent
    } else if (isElement(this._config.reference)) {
      referenceElement = getElement(this._config.reference)!
    } else if (typeof this._config.reference === 'object') {
      referenceElement = this._config.reference as ReferenceElement
    }

    this._updateFloatingPosition(referenceElement)

    this._floatingCleanup = autoUpdate(
      referenceElement,
      this._menu,
      () => this._updateFloatingPosition(referenceElement)
    )
  }

  protected async _updateFloatingPosition(referenceElement: ReferenceElement | null = null): Promise<void> {
    if (!this._menu) {
      return
    }

    if (!referenceElement) {
      if (this._config.reference === 'parent') {
        referenceElement = this._parent
      } else if (isElement(this._config.reference)) {
        referenceElement = getElement(this._config.reference)
      } else if (typeof this._config.reference === 'object') {
        referenceElement = this._config.reference as ReferenceElement
      } else {
        referenceElement = this._element
      }
    }

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

    await this._applyFloatingPosition(
      referenceElement!,
      this._menu,
      floatingConfig.placement,
      floatingConfig.middleware,
      floatingConfig.strategy
    )
  }

  protected _isShown(): boolean {
    return this._menu.classList.contains(CLASS_NAME_SHOW)
  }

  protected _isAnimated(): boolean {
    // The menu always defines its transition in CSS; treat a zero computed
    // duration (e.g. reduced motion or disabled transitions) as non-animated so
    // callbacks run synchronously.
    return getTransitionDurationFromElement(this._menu) > 0
  }

  protected _getPlacement(): string {
    const placement = this._responsivePlacements ?
      getResponsivePlacement(this._responsivePlacements, DEFAULT_PLACEMENT) :
      this._config.placement

    return resolveLogicalPlacement(placement)
  }

  protected _parseResponsivePlacements(): void {
    this._responsivePlacements = parseResponsivePlacement(this._config.placement, DEFAULT_PLACEMENT)

    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 _getOffset(): number[] | ((state: MiddlewareState) => any) {
    const { offset: offsetConfig } = this._config

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

    if (typeof offsetConfig === 'function') {
      return ({ placement, rects }) => {
        const result = offsetConfig({ placement, reference: rects.reference, floating: rects.floating }, this._element)
        return toFloatingOffset(result)
      }
    }

    return offsetConfig
  }

  protected _getFloatingMiddleware(): Middleware[] {
    const offsetValue = this._getOffset()

    const middleware = [
      offset(
        typeof offsetValue === 'function' ?
          offsetValue :
          toFloatingOffset(offsetValue)
      ),
      flip({
        fallbackPlacements: this._getFallbackPlacements(),
        // When no placement fits, keep the initial one. Overflow below can be
        // scrolled to; overflow above the scroll origin can never be reached.
        fallbackStrategy: 'initialPlacement'
      }),
      shift({
        boundary: (this._config.boundary === 'clippingParents' ? 'clippingAncestors' : this._config.boundary) as Boundary
      })
    ]

    return middleware
  }

  protected _getFallbackPlacements(): Placement[] {
    const placement = this._getPlacement()

    const fallbackMap: Record<string, Placement[]> = {
      bottom: ['top', 'bottom-start', 'bottom-end', 'top-start', 'top-end'],
      'bottom-start': ['top-start', 'bottom-end', 'top-end'],
      'bottom-end': ['top-end', 'bottom-start', 'top-start'],
      top: ['bottom', 'top-start', 'top-end', 'bottom-start', 'bottom-end'],
      'top-start': ['bottom-start', 'top-end', 'bottom-end'],
      'top-end': ['bottom-end', 'top-start', 'bottom-start'],
      right: ['left', 'right-start', 'right-end', 'left-start', 'left-end'],
      'right-start': ['left-start', 'right-end', 'left-end', 'top-start', 'bottom-start'],
      'right-end': ['left-end', 'right-start', 'left-start', 'top-end', 'bottom-end'],
      left: ['right', 'left-start', 'left-end', 'right-start', 'right-end'],
      'left-start': ['right-start', 'left-end', 'right-end', 'top-start', 'bottom-start'],
      'left-end': ['right-end', 'left-start', 'right-start', 'top-end', 'bottom-end']
    }

    return fallbackMap[placement] || ['top', 'bottom', 'right', 'left']
  }

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

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

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

  protected _getContainer(): HTMLElement | null {
    const { container } = this._config
    if (container === false) {
      return null
    }

    return container === true ? document.body : getElement(container)
  }

  protected _moveMenuToContainer(): void {
    const container = this._getContainer()
    if (!container || !this._menu) {
      return
    }

    if (this._menu.parentNode !== container) {
      container.append(this._menu)
    }
  }

  protected _restoreMenuToOriginalParent(): void {
    if (!this._menuOriginalParent || !this._menu) {
      return
    }

    if (this._menu.parentNode !== this._menuOriginalParent) {
      this._menuOriginalParent.append(this._menu)
    }
  }

  protected async _applyFloatingPosition(reference: ReferenceElement, floating: HTMLElement, placement: Placement, middleware: Middleware[], strategy: Strategy = 'absolute'): Promise<string | null> {
    if (!floating.isConnected) {
      return null
    }

    const { x, y, placement: finalPlacement } = await computePosition(
      reference,
      floating,
      { placement, middleware, strategy }
    )

    if (!floating.isConnected) {
      return null
    }

    Object.assign(floating.style, {
      position: strategy,
      left: `${x}px`,
      top: `${y}px`,
      margin: '0'
    })

    Manipulator.setDataAttribute(floating, 'placement', finalPlacement)
    return finalPlacement
  }

  // -------------------------------------------------------------------------
  // Submenu handling
  // -------------------------------------------------------------------------

  protected _setupSubmenuListeners(): void {
    if (this._config.submenuTrigger === 'hover' || this._config.submenuTrigger === 'both') {
      EventHandler.on(this._menu, 'mouseenter', SELECTOR_SUBMENU_TOGGLE, event => {
        this._onSubmenuTriggerEnter(event)
      })

      EventHandler.on(this._menu, 'mouseleave', SELECTOR_SUBMENU, event => {
        this._onSubmenuLeave(event)
      })

      EventHandler.on(this._menu, 'mousemove', event => {
        this._trackMousePosition(event)
      })
    }

    if (this._config.submenuTrigger === 'click' || this._config.submenuTrigger === 'both') {
      EventHandler.on(this._menu, 'click', SELECTOR_SUBMENU_TOGGLE, event => {
        this._onSubmenuTriggerClick(event)
      })
    }
  }

  protected _onSubmenuTriggerEnter(event: BootstrapEvent): void {
    const trigger = (event.target as Element).closest<HTMLElement>(SELECTOR_SUBMENU_TOGGLE)
    if (!trigger) {
      return
    }

    const submenuWrapper = trigger.closest(SELECTOR_SUBMENU)!
    const submenu = SelectorEngine.findOne(SELECTOR_MENU, submenuWrapper)
    if (!submenu) {
      return
    }

    this._cancelSubmenuCloseTimeout(submenu)
    this._closeSiblingSubmenus(submenuWrapper)
    this._openSubmenu(trigger, submenu, submenuWrapper)
  }

  protected _onSubmenuLeave(event: BootstrapEvent): void {
    const submenuWrapper = (event.target as Element).closest(SELECTOR_SUBMENU)!
    const submenu = SelectorEngine.findOne(SELECTOR_MENU, submenuWrapper)
    if (!submenu || !this._openSubmenus.has(submenu)) {
      return
    }

    if (this._isMovingTowardSubmenu(event, submenu)) {
      return
    }

    this._scheduleSubmenuClose(submenu, submenuWrapper)
  }

  protected _onSubmenuTriggerClick(event: BootstrapEvent): void {
    const trigger = (event.target as Element).closest<HTMLElement>(SELECTOR_SUBMENU_TOGGLE)
    if (!trigger) {
      return
    }

    event.preventDefault()
    event.stopPropagation()

    const submenuWrapper = trigger.closest(SELECTOR_SUBMENU)!
    const submenu = SelectorEngine.findOne(SELECTOR_MENU, submenuWrapper)
    if (!submenu) {
      return
    }

    if (this._openSubmenus.has(submenu)) {
      this._closeSubmenu(submenu, submenuWrapper)
    } else {
      this._closeSiblingSubmenus(submenuWrapper)
      this._openSubmenu(trigger, submenu, submenuWrapper)
    }
  }

  // Announce every submenu trigger up front. Waiting until the submenu opens hides
  // the popup from assistive technology until a user has already found it.
  protected _initSubmenuTriggers(): void {
    if (!this._menu) {
      return
    }

    for (const trigger of SelectorEngine.find(SELECTOR_SUBMENU_TOGGLE, this._menu)) {
      if (!SelectorEngine.findOne(SELECTOR_MENU, trigger.parentElement!)) {
        continue
      }

      trigger.setAttribute('aria-haspopup', 'true')

      if (!trigger.hasAttribute('aria-expanded')) {
        trigger.setAttribute('aria-expanded', 'false')
      }
    }
  }

  protected _openSubmenu(trigger: HTMLElement, submenu: HTMLElement, submenuWrapper: Element): void {
    if (this._openSubmenus.has(submenu)) {
      return
    }

    trigger.setAttribute('aria-expanded', 'true')
    trigger.setAttribute('aria-haspopup', 'true')

    // Keep the submenu transparent until Floating UI applies the first position, so
    // it doesn't flash at its CSS fallback position (top: 0, over the parent menu)
    // before being moved into place. `opacity` (unlike `visibility`/`display`) keeps
    // the submenu measurable for flip/shift and focusable for keyboard navigation.
    submenu.style.opacity = '0'
    submenu.classList.add(CLASS_NAME_SHOW)
    submenuWrapper.classList.add(CLASS_NAME_SHOW)

    const cleanup = this._createSubmenuFloating(trigger, submenu, submenuWrapper)
    this._openSubmenus.set(submenu, cleanup)

    EventHandler.on(submenu, 'mouseenter', () => {
      this._cancelSubmenuCloseTimeout(submenu)
    })
  }

  protected _closeSubmenu(submenu: HTMLElement, submenuWrapper: Element): void {
    if (!this._openSubmenus.has(submenu)) {
      return
    }

    const nestedSubmenus = SelectorEngine.find(`${SELECTOR_SUBMENU} ${SELECTOR_MENU}.${CLASS_NAME_SHOW}`, submenu)
    for (const nested of nestedSubmenus) {
      const nestedWrapper = nested.closest(SELECTOR_SUBMENU)!
      this._closeSubmenu(nested, nestedWrapper)
    }

    const trigger = SelectorEngine.findOne(SELECTOR_SUBMENU_TOGGLE, submenuWrapper)

    const cleanup = this._openSubmenus.get(submenu)
    if (cleanup) {
      cleanup()
    }

    this._openSubmenus.delete(submenu)
    EventHandler.off(submenu, 'mouseenter')

    if (trigger) {
      trigger.setAttribute('aria-expanded', 'false')
    }

    submenu.classList.remove(CLASS_NAME_SHOW)
    submenuWrapper.classList.remove(CLASS_NAME_SHOW)

    // Keep the Floating UI position styles in place while the submenu fades out.
    // Clearing them here would let the submenu snap back to its CSS fallback
    // (`top: 0`, over the parent menu) for the duration of the close transition,
    // causing it to flash over the parent. They get recomputed on the next open
    // (and the opacity gate in `_openSubmenu` hides any stale position until then).
    submenu.style.opacity = ''
  }

  protected _closeAllSubmenus(): void {
    for (const [submenu] of this._openSubmenus) {
      const submenuWrapper = submenu.closest(SELECTOR_SUBMENU)!
      this._closeSubmenu(submenu, submenuWrapper)
    }
  }

  protected _closeSiblingSubmenus(currentSubmenuWrapper: Element): void {
    const parent = currentSubmenuWrapper.parentNode!
    const siblingSubmenus = SelectorEngine.find(`${SELECTOR_SUBMENU} > ${SELECTOR_MENU}.${CLASS_NAME_SHOW}`, parent)

    for (const siblingMenu of siblingSubmenus) {
      const siblingWrapper = siblingMenu.closest(SELECTOR_SUBMENU)!
      if (siblingWrapper !== currentSubmenuWrapper) {
        this._closeSubmenu(siblingMenu, siblingWrapper)
      }
    }
  }

  protected _createSubmenuFloating(trigger: HTMLElement, submenu: HTMLElement, submenuWrapper: Element): () => void {
    const referenceElement = submenuWrapper
    const placement = resolveLogicalPlacement(SUBMENU_PLACEMENT) as Placement
    const middleware = [
      offset({ mainAxis: 0, crossAxis: -4 }),
      flip({
        fallbackPlacements: [
          resolveLogicalPlacement('start-start'),
          resolveLogicalPlacement('end-end'),
          resolveLogicalPlacement('start-end')
        ] as Placement[]
      }),
      shift({ padding: 8 })
    ]

    const updatePosition = () => this._applyFloatingPosition(referenceElement, submenu, placement, middleware)
      .then(finalPlacement => {
        // Reveal the submenu now that it has been positioned (see `_openSubmenu`);
        // clearing the inline opacity lets the CSS fade-in transition take over.
        submenu.style.opacity = ''
        return finalPlacement
      })

    updatePosition()
    return autoUpdate(referenceElement, submenu, updatePosition)
  }

  protected _scheduleSubmenuClose(submenu: HTMLElement, submenuWrapper: Element): void {
    this._cancelSubmenuCloseTimeout(submenu)

    const timeoutId = setTimeout(() => {
      this._closeSubmenu(submenu, submenuWrapper)
      this._submenuCloseTimeouts.delete(submenu)
    }, this._config.submenuDelay)

    this._submenuCloseTimeouts.set(submenu, timeoutId)
  }

  protected _cancelSubmenuCloseTimeout(submenu: HTMLElement): void {
    const timeoutId = this._submenuCloseTimeouts.get(submenu)
    if (timeoutId) {
      clearTimeout(timeoutId)
      this._submenuCloseTimeouts.delete(submenu)
    }
  }

  protected _clearAllSubmenuTimeouts(): void {
    for (const timeoutId of this._submenuCloseTimeouts.values()) {
      clearTimeout(timeoutId)
    }

    this._submenuCloseTimeouts.clear()
  }

  // -------------------------------------------------------------------------
  // Hover intent / Safe triangle
  // -------------------------------------------------------------------------

  protected _trackMousePosition(event: BootstrapEvent): void {
    this._hoverIntentData = {
      x: event.clientX,
      y: event.clientY,
      timestamp: Date.now()
    }
  }

  protected _isMovingTowardSubmenu(event: BootstrapEvent, submenu: HTMLElement): boolean {
    if (!this._hoverIntentData) {
      return false
    }

    const submenuRect = submenu.getBoundingClientRect()
    const currentPos = { x: event.clientX, y: event.clientY }
    const lastPos = { x: this._hoverIntentData.x, y: this._hoverIntentData.y }

    const isRtl = isRTL()
    const targetX = isRtl ? submenuRect.right : submenuRect.left
    const topCorner = { x: targetX, y: submenuRect.top }
    const bottomCorner = { x: targetX, y: submenuRect.bottom }

    return this._pointInTriangle(currentPos, lastPos, topCorner, bottomCorner)
  }

  protected _pointInTriangle(point: Point, v1: Point, v2: Point, v3: Point): boolean {
    const d1 = triangleSign(point, v1, v2)
    const d2 = triangleSign(point, v2, v3)
    const d3 = triangleSign(point, v3, v1)

    const hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0)
    const hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0)

    return !(hasNeg && hasPos)
  }

  // -------------------------------------------------------------------------
  // Keyboard navigation
  // -------------------------------------------------------------------------

  // Items sit either directly in `.menu`, or as a submenu trigger one level deeper
  // (`.menu > .submenu > .menu-item`). Both are navigable at the same level.
  protected _getItemsInMenu(menu: Element): HTMLElement[] {
    return SelectorEngine.find(
      `:scope > ${SELECTOR_VISIBLE_ITEMS}, :scope > ${SELECTOR_SUBMENU} > ${SELECTOR_VISIBLE_ITEMS}`,
      menu
    ).filter(element => isVisible(element))
  }

  protected _selectMenuItem({ key, target }: BootstrapEvent): void {
    const currentMenu = (target as Element).closest(SELECTOR_MENU) || this._menu
    const items = this._getItemsInMenu(currentMenu)

    if (!items.length) {
      return
    }

    const nextItem = getNextActiveElement(items, target as HTMLElement, key === ARROW_DOWN_KEY, !items.includes(target as HTMLElement))
    nextItem.focus()
    this._closeUnrelatedSubmenus(currentMenu, nextItem)
  }

  // Moving focus along one level closes the submenus focus left behind, matching
  // what hover and click already do through `_closeSiblingSubmenus`.
  protected _closeUnrelatedSubmenus(currentMenu: Element, focusedElement: HTMLElement): void {
    for (const [submenu] of this._openSubmenus) {
      const submenuWrapper = submenu.closest(SELECTOR_SUBMENU)

      if (!submenuWrapper || submenuWrapper.parentElement !== currentMenu || submenuWrapper.contains(focusedElement)) {
        continue
      }

      this._closeSubmenu(submenu, submenuWrapper)
    }
  }

  protected _handleSubmenuKeydown(event: BootstrapEvent): boolean {
    const { key, target } = event
    const isRtl = isRTL()

    const enterKey = isRtl ? ARROW_LEFT_KEY : ARROW_RIGHT_KEY
    const exitKey = isRtl ? ARROW_RIGHT_KEY : ARROW_LEFT_KEY

    const submenuWrapper = (target as Element).closest(SELECTOR_SUBMENU)
    const isSubmenuToggle = (target as Element).matches(SELECTOR_SUBMENU_TOGGLE)

    const isOpenKey = key === ENTER_KEY || key === SPACE_KEY || key === enterKey

    if (isOpenKey && submenuWrapper && isSubmenuToggle) {
      event.preventDefault()
      event.stopPropagation()

      const submenu = SelectorEngine.findOne(SELECTOR_MENU, submenuWrapper)
      if (submenu) {
        this._closeSiblingSubmenus(submenuWrapper)
        this._openSubmenu(target as HTMLElement, submenu, submenuWrapper)
        requestAnimationFrame(() => {
          const firstItem = SelectorEngine.findOne(SELECTOR_VISIBLE_ITEMS, submenu)
          if (firstItem) {
            firstItem.focus()
          }
        })
      }

      return true
    }

    if (key === exitKey) {
      const currentMenu = (target as Element).closest(SELECTOR_MENU)
      const parentSubmenuWrapper = currentMenu?.closest(SELECTOR_SUBMENU)

      if (parentSubmenuWrapper) {
        event.preventDefault()
        event.stopPropagation()

        const parentTrigger = SelectorEngine.findOne(SELECTOR_SUBMENU_TOGGLE, parentSubmenuWrapper)
        this._closeSubmenu(currentMenu as HTMLElement, parentSubmenuWrapper)
        if (parentTrigger) {
          parentTrigger.focus()
        }

        return true
      }
    }

    if (key === HOME_KEY || key === END_KEY) {
      event.preventDefault()
      event.stopPropagation()

      const currentMenu = (target as Element).closest(SELECTOR_MENU)!
      const items = this._getItemsInMenu(currentMenu)

      if (items.length) {
        const targetItem = key === HOME_KEY ? items[0] : items.at(-1)!
        targetItem.focus()
        this._closeUnrelatedSubmenus(currentMenu, targetItem)
      }

      return true
    }

    return false
  }

  static clearMenus(event: BootstrapEvent): void {
    if (event.button === RIGHT_MOUSE_BUTTON || (event.type === 'keyup' && event.key !== TAB_KEY)) {
      return
    }

    for (const instance of Menu._openInstances) {
      if (instance._config.autoClose === false) {
        continue
      }

      const composedPath = event.composedPath()
      const isMenuTarget = composedPath.includes(instance._menu)
      if (
        composedPath.includes(instance._element) ||
        (instance._config.autoClose === 'inside' && !isMenuTarget) ||
        (instance._config.autoClose === 'outside' && isMenuTarget)
      ) {
        continue
      }

      // Don't auto-close when interacting with a form inside the menu — clicks
      // on a form's labels, buttons, etc. (not just inputs) should keep it open.
      const formAncestor = (event.target as HTMLElement).closest?.('form')
      const isInsideMenuForm = Boolean(formAncestor) && instance._menu.contains(formAncestor!)
      if (instance._menu.contains(event.target as HTMLElement) && ((event.type === 'keyup' && event.key === TAB_KEY) ||
          /input|select|option|textarea|form/i.test((event.target as HTMLElement).tagName) || isInsideMenuForm)) {
        continue
      }

      const relatedTarget: Record<string, unknown> = { relatedTarget: instance._element }

      if (event.type === 'click') {
        relatedTarget.clickEvent = event
      }

      instance._completeHide(relatedTarget)
    }
  }

  // The keydown handler is delegated on both the toggle and `.menu`, and delegation
  // stops at the innermost match. So for a key pressed inside a submenu panel, or
  // inside a menu moved to `container`, the toggle is not a sibling of the element
  // the handler ran on. Ask the open instances which menu owns the event first, and
  // only then fall back to the sibling and wrapper lookups.
  protected static _getToggleFromKeydownContext(element: HTMLElement, event: BootstrapEvent): HTMLElement | null {
    if (element.matches(SELECTOR_DATA_TOGGLE)) {
      return element
    }

    for (const instance of Menu._openInstances) {
      if (instance._element === event.target || instance._menu?.contains(event.target as Node)) {
        return instance._element
      }
    }

    return SelectorEngine.prev(element, SELECTOR_DATA_TOGGLE)[0] as HTMLElement ||
      SelectorEngine.next(element, SELECTOR_DATA_TOGGLE)[0] as HTMLElement ||
      SelectorEngine.findOne(SELECTOR_DATA_TOGGLE, event.delegateTarget.parentNode)
  }

  static dataApiKeydownHandler(this: HTMLElement, event: BootstrapEvent): void {
    // Treat contenteditable hosts (e.g. rich-text editors) like inputs so the
    // menu doesn't hijack their arrow keys.
    const isInput = /input|textarea/i.test((event.target as HTMLElement).tagName) || (event.target as HTMLElement).isContentEditable
    const isEscapeEvent = event.key === ESCAPE_KEY
    const isUpOrDownEvent = [ARROW_UP_KEY, ARROW_DOWN_KEY].includes(event.key)
    const isLeftOrRightEvent = [ARROW_LEFT_KEY, ARROW_RIGHT_KEY].includes(event.key)
    const isHomeOrEndEvent = [HOME_KEY, END_KEY].includes(event.key)
    const isEnterOrSpaceEvent = [ENTER_KEY, SPACE_KEY].includes(event.key)

    const isSubmenuTrigger = (event.target as Element).matches(SELECTOR_SUBMENU_TOGGLE)

    if (!isUpOrDownEvent && !isEscapeEvent && !isLeftOrRightEvent && !isHomeOrEndEvent &&
        !(isEnterOrSpaceEvent && isSubmenuTrigger)) {
      return
    }

    if (isInput && !isEscapeEvent) {
      return
    }

    const getToggleButton = Menu._getToggleFromKeydownContext(this, event)

    if (!getToggleButton) {
      return
    }

    const instance = Menu.getOrCreateInstance(getToggleButton)

    if ((isLeftOrRightEvent || isHomeOrEndEvent || (isEnterOrSpaceEvent && isSubmenuTrigger)) && instance._handleSubmenuKeydown(event)) {
      return
    }

    if (isUpOrDownEvent) {
      event.preventDefault()
      event.stopPropagation()
      instance.show()
      instance._selectMenuItem(event)
      return
    }

    if (isEscapeEvent && instance._isShown()) {
      event.preventDefault()
      event.stopPropagation()

      const currentMenu = (event.target as Element).closest(SELECTOR_MENU)
      const parentSubmenuWrapper = currentMenu?.closest(SELECTOR_SUBMENU)

      if (parentSubmenuWrapper && instance._openSubmenus.size > 0) {
        const parentTrigger = SelectorEngine.findOne(SELECTOR_SUBMENU_TOGGLE, parentSubmenuWrapper)
        instance._closeSubmenu(currentMenu as HTMLElement, parentSubmenuWrapper)
        if (parentTrigger) {
          parentTrigger.focus()
        }

        return
      }

      instance.hide()
      getToggleButton.focus()
    }
  }
}

/**
 * Data API implementation
 */

EventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_DATA_TOGGLE, Menu.dataApiKeydownHandler)
EventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_MENU, Menu.dataApiKeydownHandler)
EventHandler.on(document, EVENT_CLICK_DATA_API, Menu.clearMenus)
EventHandler.on(document, EVENT_KEYUP_DATA_API, Menu.clearMenus)
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
  event.preventDefault()
  Menu.getOrCreateInstance(this).toggle()
})

export default Menu
export type { MenuConfig }
