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

import BaseComponent from './base-component.js'
import EventHandler, { type BootstrapEvent } from './dom/event-handler.js'
import SelectorEngine from './dom/selector-engine.js'
import Menu from './menu.js'
import type { FloatingOffsetOption } from './util/floating-ui.js'
import {
  getNextActiveElement, isDisabled, isVisible, setAriaAttribute
} from './util/index.js'

/**
 * Constants
 */

const NAME = 'combobox'
const DATA_KEY = 'bs.combobox'
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 HOME_KEY = 'Home'
const END_KEY = 'End'
const ENTER_KEY = 'Enter'
const SPACE_KEY = ' '

const EVENT_CHANGE = `change${EVENT_KEY}`
const EVENT_SHOW = `show${EVENT_KEY}`
const EVENT_SHOWN = `shown${EVENT_KEY}`
const EVENT_HIDE = `hide${EVENT_KEY}`
const EVENT_HIDDEN = `hidden${EVENT_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`

const CLASS_NAME_SHOW = 'show'
const CLASS_NAME_SELECTED = 'selected'
const CLASS_NAME_PLACEHOLDER = 'combobox-placeholder'

const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="combobox"]'
const SELECTOR_MENU = '.menu'
const SELECTOR_MENU_ITEM = '.menu-item[data-bs-value]'
const SELECTOR_VISIBLE_ITEMS = '.menu-item[data-bs-value]:not(.disabled):not(:disabled)'
const SELECTOR_VALUE = '.combobox-value'
const SELECTOR_SEARCH_INPUT = '.combobox-search-input'
const SELECTOR_NO_RESULTS = '.combobox-no-results'

type ComboboxConfig = {
  boundary: string | Element
  multiple: boolean
  name: string | null
  offset: FloatingOffsetOption
  placeholder: string
  placement: string
  search: boolean
  searchNormalize: boolean
}

const Default: ComboboxConfig = {
  boundary: 'clippingParents',
  multiple: false,
  name: null,
  offset: [0, 2],
  placeholder: '',
  placement: 'bottom-start',
  search: false,
  searchNormalize: false
}

const DefaultType = {
  boundary: '(string|element)',
  multiple: 'boolean',
  name: '(string|null)',
  offset: '(array|string|function)',
  placeholder: 'string',
  placement: 'string',
  search: 'boolean',
  searchNormalize: 'boolean'
}

/**
 * Class definition
 */

class Combobox extends BaseComponent {
  protected declare _config: ComboboxConfig
  protected declare _toggle: HTMLElement
  protected declare _menu: HTMLElement
  protected declare _valueDisplay: HTMLElement
  protected declare _searchInput: HTMLInputElement | null
  protected declare _noResults: HTMLElement | null
  protected declare _hiddenInput: HTMLInputElement | null
  protected declare _menuInstance: Menu | null

  constructor(element?: string | Element | null, config?: Partial<ComboboxConfig> | null) {
    super(element, config)

    this._toggle = this._element
    this._menu = SelectorEngine.next(this._toggle, SELECTOR_MENU)[0] as HTMLElement
    this._valueDisplay = SelectorEngine.findOne(SELECTOR_VALUE, this._toggle)!
    this._searchInput = SelectorEngine.findOne<HTMLInputElement>(SELECTOR_SEARCH_INPUT, this._menu)
    this._noResults = SelectorEngine.findOne(SELECTOR_NO_RESULTS, this._menu)
    this._hiddenInput = null
    this._menuInstance = null

    this._createHiddenInput()
    this._createMenuInstance()
    this._syncInitialSelection()
    this._addEventListeners()
  }

  // Getters
  static override get Default(): ComboboxConfig {
    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._toggle) || this._isShown()) {
      return
    }

    const showEvent = EventHandler.trigger(this._toggle, EVENT_SHOW)
    if (showEvent.defaultPrevented) {
      return
    }

    this._menuInstance!.show()

    if (this._searchInput) {
      this._searchInput.value = ''
      this._filterItems('')
      requestAnimationFrame(() => this._searchInput!.focus())
    }

    EventHandler.trigger(this._toggle, EVENT_SHOWN)
  }

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

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

    this._menuInstance!.hide()
    EventHandler.trigger(this._toggle, EVENT_HIDDEN)
  }

  override dispose(): void {
    if (this._menuInstance) {
      this._menuInstance.dispose()
      this._menuInstance = null
    }

    if (this._hiddenInput) {
      this._hiddenInput.remove()
      this._hiddenInput = null
    }

    EventHandler.off(this._menu, EVENT_KEY)
    EventHandler.off(this._toggle, EVENT_KEY)
    EventHandler.off(this._searchInput, EVENT_KEY)

    super.dispose()
  }

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

  protected _createHiddenInput(): void {
    const { name } = this._config
    if (!name) {
      return
    }

    this._hiddenInput = document.createElement('input')
    this._hiddenInput.type = 'hidden'
    this._hiddenInput.name = name
    this._hiddenInput.value = ''
    this._toggle.parentNode!.insertBefore(this._hiddenInput, this._toggle)
  }

  protected _createMenuInstance(): void {
    this._menuInstance = new Menu(this._toggle, {
      menu: this._menu,
      autoClose: this._config.multiple ? 'outside' : true,
      boundary: this._config.boundary,
      offset: this._config.offset,
      placement: this._config.placement
    })
  }

  protected _syncInitialSelection(): void {
    const selectedItems = this._getSelectedItems()
    if (selectedItems.length > 0) {
      this._updateToggleText()
      this._updateHiddenInput()
    } else {
      this._showPlaceholder()
    }
  }

  protected _addEventListeners(): void {
    EventHandler.on(this._menu, `click${EVENT_KEY}`, SELECTOR_MENU_ITEM, event => {
      const item = (event.target as Element).closest<HTMLElement>(SELECTOR_MENU_ITEM)
      if (!item || isDisabled(item)) {
        return
      }

      event.preventDefault()
      event.stopPropagation()
      this._selectItem(item)
    })

    EventHandler.on(this._toggle, `keydown${EVENT_KEY}`, event => {
      this._handleToggleKeydown(event)
    })

    EventHandler.on(this._menu, `keydown${EVENT_KEY}`, event => {
      this._handleMenuKeydown(event)
    })

    if (this._searchInput) {
      EventHandler.on(this._searchInput, `input${EVENT_KEY}`, () => {
        this._filterItems(this._searchInput!.value)
      })

      EventHandler.on(this._searchInput, `keydown${EVENT_KEY}`, event => {
        if (event.key === ARROW_DOWN_KEY) {
          event.preventDefault()
          const items = this._getVisibleItems()
          if (items.length > 0) {
            items[0].focus()
          }
        }

        if (event.key === ESCAPE_KEY) {
          this.hide()
          this._toggle.focus()
        }
      })
    }
  }

  protected _selectItem(item: HTMLElement): void {
    if (this._config.multiple) {
      item.classList.toggle(CLASS_NAME_SELECTED)
      setAriaAttribute(item, 'aria-selected', item.classList.contains(CLASS_NAME_SELECTED))
    } else {
      const previouslySelected = SelectorEngine.find(`.${CLASS_NAME_SELECTED}`, this._menu)
      for (const prev of previouslySelected) {
        prev.classList.remove(CLASS_NAME_SELECTED)
        prev.setAttribute('aria-selected', 'false')
      }

      item.classList.add(CLASS_NAME_SELECTED)
      item.setAttribute('aria-selected', 'true')
    }

    this._updateToggleText()
    this._updateHiddenInput()

    const value = this._config.multiple ?
      this._getSelectedItems().map(el => el.dataset.bsValue) :
      item.dataset.bsValue

    EventHandler.trigger(this._toggle, EVENT_CHANGE, {
      value,
      item
    })

    if (!this._config.multiple) {
      this.hide()
      this._toggle.focus()
    }
  }

  protected _updateToggleText(): void {
    const selectedItems = this._getSelectedItems()

    if (selectedItems.length === 0) {
      this._showPlaceholder()
      return
    }

    this._valueDisplay.classList.remove(CLASS_NAME_PLACEHOLDER)

    if (this._config.multiple && selectedItems.length > 1) {
      this._valueDisplay.textContent = `${selectedItems.length} selected`
    } else {
      const item = selectedItems[0]
      const label = SelectorEngine.findOne('.menu-item-content > span:first-child', item)
      this._valueDisplay.textContent = label ? label.textContent : item.textContent!.trim()
    }
  }

  protected _showPlaceholder(): void {
    const { placeholder } = this._config
    if (placeholder) {
      this._valueDisplay.textContent = placeholder
      this._valueDisplay.classList.add(CLASS_NAME_PLACEHOLDER)
    }
  }

  protected _updateHiddenInput(): void {
    if (!this._hiddenInput) {
      return
    }

    const selectedItems = this._getSelectedItems()
    const values = selectedItems.map(el => el.dataset.bsValue)
    this._hiddenInput.value = this._config.multiple ? values.join(',') : (values[0] || '')
  }

  protected _getSelectedItems(): HTMLElement[] {
    return SelectorEngine.find(`.${CLASS_NAME_SELECTED}`, this._menu)
  }

  protected _getVisibleItems(): HTMLElement[] {
    return SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, this._menu)
      .filter(item => isVisible(item))
  }

  protected _filterItems(query: string): void {
    const normalizedQuery = this._normalizeText(query.toLowerCase().trim())
    const items = SelectorEngine.find(SELECTOR_MENU_ITEM, this._menu)
    let visibleCount = 0

    for (const item of items) {
      const text = this._normalizeText(item.textContent!.toLowerCase().trim())
      const matches = !normalizedQuery || text.includes(normalizedQuery)
      item.style.display = matches ? '' : 'none'
      if (matches) {
        visibleCount++
      }
    }

    if (this._noResults) {
      this._noResults.classList.toggle('d-none', visibleCount > 0)
    }
  }

  protected _normalizeText(text: string): string {
    if (this._config.searchNormalize) {
      return text.normalize('NFD').replace(/[\u0300-\u036F]/g, '')
    }

    return text
  }

  protected _handleToggleKeydown(event: BootstrapEvent): void {
    const { key } = event

    if (key === ARROW_DOWN_KEY || key === ARROW_UP_KEY) {
      event.preventDefault()
      if (!this._isShown()) {
        this.show()
      }

      const items = this._getVisibleItems()
      if (items.length > 0) {
        const target = key === ARROW_DOWN_KEY ? items[0] : items.at(-1)!
        target.focus()
      }

      return
    }

    if ((key === ENTER_KEY || key === SPACE_KEY) && !this._isShown()) {
      event.preventDefault()
      this.show()
    }
  }

  protected _handleMenuKeydown(event: BootstrapEvent): void {
    const { key, target } = event

    if (key === ESCAPE_KEY) {
      event.preventDefault()
      event.stopPropagation()
      this.hide()
      this._toggle.focus()
      return
    }

    if (key === TAB_KEY) {
      this.hide()
      return
    }

    const isInput = (target as Element).matches('input')

    if (key === ARROW_DOWN_KEY || key === ARROW_UP_KEY) {
      event.preventDefault()
      const items = this._getVisibleItems()
      if (items.length > 0) {
        getNextActiveElement(items, target as HTMLElement, key === ARROW_DOWN_KEY, !items.includes(target as HTMLElement)).focus()
      }

      return
    }

    if (key === HOME_KEY || key === END_KEY) {
      event.preventDefault()
      const items = this._getVisibleItems()
      if (items.length > 0) {
        const targetItem = key === HOME_KEY ? items[0] : items.at(-1)!
        targetItem.focus()
      }

      return
    }

    if ((key === ENTER_KEY || key === SPACE_KEY) && !isInput) {
      event.preventDefault()
      const item = (target as Element).closest<HTMLElement>(SELECTOR_MENU_ITEM)
      if (item && !isDisabled(item)) {
        this._selectItem(item)
      }
    }
  }
}

/**
 * Data API implementation
 */

EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
  event.preventDefault()
  Combobox.getOrCreateInstance(this).toggle()
})

EventHandler.on(document, 'DOMContentLoaded', () => {
  for (const toggle of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) {
    Combobox.getOrCreateInstance(toggle)
  }
})

export default Combobox
export type { ComboboxConfig }
