/**
 * SearchInput - Accessible search input with dropdown suggestions
 * @countermeasure/web-components/components/search
 *
 * Implements ARIA Combobox pattern with listbox popup.
 * WCAG 2.1 AA compliant.
 */

import { type SearchInputConfig, type SearchResult } from './types'
import { uniqueId } from '../../primitives/utils'
import { announce } from '../../utils/a11y'
import { Keys, listNavigate } from '../../utils/keyboard'

export {
  type SearchResult,
  type SearchInputConfig,
  type CommandPaletteConfig,
  type CommandEntry,
} from './types'
export { CommandPalette } from './command-palette'

function resolveContainer(container: HTMLElement | string): HTMLElement {
  if (typeof container === 'string') {
    const el = document.querySelector(container)
    if (!el) {
      throw new Error(`SearchInput: container not found for selector "${container}"`)
    }
    return el as HTMLElement
  }
  return container
}

export class SearchInput {
  private readonly config: SearchInputConfig
  private readonly containerEl: HTMLElement
  private readonly rootEl: HTMLElement
  private readonly inputEl: HTMLInputElement
  private readonly resultsEl: HTMLElement
  private results: SearchResult[] = []
  private activeIndex = -1
  private debounceTimer: ReturnType<typeof setTimeout> | null = null
  private destroyed = false

  // Accessibility IDs
  private readonly inputId: string
  private readonly listboxId: string
  private readonly labelId: string
  private optionIds: string[] = []

  constructor(config: SearchInputConfig) {
    this.config = config
    this.containerEl = resolveContainer(config.container)

    // Generate unique IDs for ARIA relationships
    this.inputId = uniqueId('cmm-search-input')
    this.listboxId = uniqueId('cmm-search-listbox')
    this.labelId = uniqueId('cmm-search-label')

    this.rootEl = document.createElement('div')
    this.rootEl.className = 'cmm-search'

    // Visually hidden label for screen readers
    const label = document.createElement('label')
    label.id = this.labelId
    label.className = 'cmm-sr-only'
    label.setAttribute('for', this.inputId)
    label.textContent = config.placeholder ?? 'Search'
    this.rootEl.appendChild(label)

    // Input with combobox role
    this.inputEl = document.createElement('input')
    this.inputEl.type = 'text'
    this.inputEl.id = this.inputId
    this.inputEl.className = 'cmm-search__input'
    this.inputEl.setAttribute('role', 'combobox')
    this.inputEl.setAttribute('aria-autocomplete', 'list')
    this.inputEl.setAttribute('aria-expanded', 'false')
    this.inputEl.setAttribute('aria-controls', this.listboxId)
    this.inputEl.setAttribute('aria-haspopup', 'listbox')
    this.inputEl.setAttribute('autocomplete', 'off')
    this.inputEl.setAttribute('autocorrect', 'off')
    this.inputEl.setAttribute('autocapitalize', 'off')
    this.inputEl.setAttribute('spellcheck', 'false')

    if (config.placeholder !== undefined) {
      this.inputEl.placeholder = config.placeholder
    }

    // Results dropdown (listbox)
    this.resultsEl = document.createElement('div')
    this.resultsEl.id = this.listboxId
    this.resultsEl.className = 'cmm-search__results'
    this.resultsEl.setAttribute('role', 'listbox')
    this.resultsEl.setAttribute('aria-label', 'Search suggestions')
    this.resultsEl.style.display = 'none'

    this.rootEl.appendChild(this.inputEl)
    this.rootEl.appendChild(this.resultsEl)
    this.containerEl.appendChild(this.rootEl)

    this.inputEl.addEventListener('input', this.handleInput)
    this.inputEl.addEventListener('keydown', this.handleKeyDown)
    this.inputEl.addEventListener('focus', this.handleFocus)
    this.inputEl.addEventListener('blur', this.handleBlur)
    document.addEventListener('click', this.handleDocumentClick)
  }

  setValue(value: string): void {
    this.inputEl.value = value
  }

  getValue(): string {
    return this.inputEl.value
  }

  focus(): void {
    this.inputEl.focus()
  }

  clear(): void {
    this.inputEl.value = ''
    this.hideResults()
  }

  destroy(): void {
    if (this.destroyed) {
      return
    }
    this.destroyed = true

    if (this.debounceTimer !== null) {
      clearTimeout(this.debounceTimer)
    }

    this.inputEl.removeEventListener('input', this.handleInput)
    this.inputEl.removeEventListener('keydown', this.handleKeyDown)
    this.inputEl.removeEventListener('focus', this.handleFocus)
    this.inputEl.removeEventListener('blur', this.handleBlur)
    document.removeEventListener('click', this.handleDocumentClick)

    if (this.rootEl.parentNode) {
      this.rootEl.parentNode.removeChild(this.rootEl)
    }
  }

  private isDestroyed(): boolean {
    return this.destroyed
  }

  private readonly handleFocus = (): void => {
    // Show results if there are any when focused
    if (this.results.length > 0) {
      this.showResults()
    }
  }

  private readonly handleBlur = (): void => {
    // Delay hiding to allow click events on results
    setTimeout(() => {
      if (!this.rootEl.contains(document.activeElement)) {
        this.hideResults()
      }
    }, 150)
  }

  private readonly handleInput = (): void => {
    const query = this.inputEl.value

    if (this.debounceTimer !== null) {
      clearTimeout(this.debounceTimer)
    }

    const delay = this.config.debounceMs ?? 200
    this.debounceTimer = setTimeout(() => {
      void this.performSuggest(query)
    }, delay)
  }

  private async performSuggest(query: string): Promise<void> {
    if (this.destroyed) {
      return
    }

    if (!this.config.suggest) {
      if (this.config.onSearch) {
        this.config.onSearch(query)
      }
      return
    }

    if (query.trim().length === 0) {
      this.hideResults()
      return
    }

    const resultOrPromise = this.config.suggest(query)
    const results = resultOrPromise instanceof Promise ? await resultOrPromise : resultOrPromise

    // Re-check after async operation since state may have changed
    if (this.isDestroyed()) {
      return
    }

    this.results = results
    this.activeIndex = -1
    this.renderResults()

    // Announce results count to screen readers
    if (results.length === 0) {
      announce('No results found', 'polite')
    } else if (results.length === 1) {
      announce('1 result available', 'polite')
    } else {
      announce(`${String(results.length)} results available`, 'polite')
    }
  }

  private showResults(): void {
    this.resultsEl.style.display = ''
    this.inputEl.setAttribute('aria-expanded', 'true')
  }

  private renderResults(): void {
    this.resultsEl.innerHTML = ''
    this.optionIds = []

    if (this.results.length === 0) {
      this.hideResults()
      return
    }

    const groups = new Map<string, SearchResult[]>()
    const ungrouped: SearchResult[] = []

    for (const result of this.results) {
      if (result.group) {
        let groupList = groups.get(result.group)
        if (!groupList) {
          groupList = []
          groups.set(result.group, groupList)
        }
        groupList.push(result)
      } else {
        ungrouped.push(result)
      }
    }

    let index = 0

    const renderItem = (result: SearchResult, itemIndex: number): HTMLElement => {
      const optionId = uniqueId('cmm-search-option')
      this.optionIds[itemIndex] = optionId

      const item = document.createElement('div')
      item.className = 'cmm-search__result'
      item.id = optionId
      item.setAttribute('role', 'option')
      item.setAttribute('aria-selected', itemIndex === this.activeIndex ? 'true' : 'false')
      item.dataset.index = String(itemIndex)

      if (itemIndex === this.activeIndex) {
        item.classList.add('cmm-search__result--active')
      }

      const labelEl = document.createElement('span')
      labelEl.className = 'cmm-search__result-label'
      labelEl.textContent = result.label
      item.appendChild(labelEl)

      if (result.description) {
        const descEl = document.createElement('span')
        descEl.className = 'cmm-search__result-description'
        descEl.textContent = result.description
        item.appendChild(descEl)
      }

      // Mouse events
      item.addEventListener('click', () => {
        this.selectResult(result)
      })

      item.addEventListener('mouseenter', () => {
        this.setActiveIndex(itemIndex)
      })

      return item
    }

    if (ungrouped.length > 0) {
      for (const result of ungrouped) {
        this.resultsEl.appendChild(renderItem(result, index))
        index += 1
      }
    }

    for (const [groupName, groupResults] of groups) {
      // Group header (not selectable)
      const groupHeader = document.createElement('div')
      groupHeader.className = 'cmm-search__group'
      groupHeader.setAttribute('role', 'presentation')
      groupHeader.setAttribute('aria-hidden', 'true')
      groupHeader.textContent = groupName
      this.resultsEl.appendChild(groupHeader)

      for (const result of groupResults) {
        this.resultsEl.appendChild(renderItem(result, index))
        index += 1
      }
    }

    this.showResults()
  }

  private setActiveIndex(index: number): void {
    // Clear previous active
    const allOptions = this.resultsEl.querySelectorAll<HTMLElement>('[role="option"]')
    for (const option of allOptions) {
      option.classList.remove('cmm-search__result--active')
      option.setAttribute('aria-selected', 'false')
    }

    this.activeIndex = index

    // Set new active
    if (index >= 0 && index < this.results.length) {
      const optionId = this.optionIds[index]
      if (optionId !== undefined) {
        const activeOption = this.resultsEl.querySelector<HTMLElement>(`#${optionId}`)
        if (activeOption !== null) {
          activeOption.classList.add('cmm-search__result--active')
          activeOption.setAttribute('aria-selected', 'true')
          activeOption.scrollIntoView({ block: 'nearest' })

          // Update aria-activedescendant
          this.inputEl.setAttribute('aria-activedescendant', optionId)

          // Announce the active option
          const result = this.results[index]
          if (result !== undefined) {
            const announcement = result.description
              ? `${result.label}, ${result.description}`
              : result.label
            announce(announcement, 'polite')
          }
        }
      }
    } else {
      this.inputEl.removeAttribute('aria-activedescendant')
    }
  }

  private selectResult(result: SearchResult): void {
    this.inputEl.value = result.label
    this.hideResults()
    this.inputEl.focus()

    if (this.config.onSelect) {
      this.config.onSelect(result)
    }

    announce(`${result.label} selected`, 'polite')
  }

  private hideResults(): void {
    this.results = []
    this.activeIndex = -1
    this.optionIds = []
    this.resultsEl.innerHTML = ''
    this.resultsEl.style.display = 'none'
    this.inputEl.setAttribute('aria-expanded', 'false')
    this.inputEl.removeAttribute('aria-activedescendant')
  }

  private readonly handleKeyDown = (event: KeyboardEvent): void => {
    // Handle navigation when results are shown
    if (this.results.length > 0) {
      const navResult = listNavigate(event, {
        activeIndex: this.activeIndex,
        itemCount: this.results.length,
        wrap: true,
        orientation: 'vertical',
      })

      if (navResult.handled) {
        event.preventDefault()

        if (navResult.activated) {
          // Enter/Space pressed
          if (this.activeIndex >= 0) {
            const selected = this.results[this.activeIndex]
            if (selected) {
              this.selectResult(selected)
            }
          } else if (this.config.onSearch) {
            this.hideResults()
            this.config.onSearch(this.inputEl.value)
          }
        } else {
          // Arrow navigation
          this.setActiveIndex(navResult.index)
        }
        return
      }
    }

    // Handle specific keys
    switch (event.key) {
      case Keys.ENTER:
        if (this.results.length === 0 && this.config.onSearch) {
          this.config.onSearch(this.inputEl.value)
        }
        break
      case Keys.ESCAPE:
        if (this.results.length > 0) {
          event.preventDefault()
          this.hideResults()
          announce('Search suggestions dismissed', 'polite')
        }
        break
      case Keys.TAB:
        // Allow normal tab behavior but close results
        this.hideResults()
        break
    }
  }

  private readonly handleDocumentClick = (event: MouseEvent): void => {
    const target = event.target
    if (target instanceof Node && !this.rootEl.contains(target)) {
      this.hideResults()
    }
  }
}
