/**
 * --------------------------------------------------------------------------
 * search.js
 * Provide a combobox with a search suggestions dropdown
 * --------------------------------------------------------------------------
 */

import Dropdown from 'bootstrap/js/src/dropdown.js'
import {
  defineJQueryPlugin,
  isVisible
} from 'bootstrap/js/src/util/index.js'
import SelectorEngine from 'bootstrap/js/src/dom/selector-engine.js'
import EventHandler from 'bootstrap/js/src/dom/event-handler.js'
import EdGELBaseComponent from './edgel-base-component.js'
import { uoeUID } from './helpers.js'

/**
 * Constants
 */

const NAME = 'search'
const DATA_KEY = 'uoe.search'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'

const SEARCH_API = 'https://api.search.ed.ac.uk/s/suggest.json'
const SEARCH_ACTION = 'https://search.ed.ac.uk/'
const SEARCH_COLLECTION = 'edi-public-meta'
const SUGGESTIONS_MAX = 10
const SUGGESTIONS_DEFAULT = 4

const CLASS_NAME_DROPDOWN = 'dropdown-menu dropdown-uoe-search'
const CLASS_NAME_DROPDOWN_ITEM = 'dropdown-item'
const CLASS_NAME_DISABLED = 'disabled'
const SELECTOR_INPUT = 'input[data-provide="uoe-search"]'
const SELECTOR_MENU = '.dropdown-menu'
const SELECTOR_NOT_SEARCH_DROPDOWN = '.dropdown-menu:not(.dropdown-uoe-search)'
const SELECTOR_VISIBLE_ITEMS = '.dropdown-item:not(.disabled):not(:disabled)'
const SELECTOR_DROPDOWN_ITEM = '.dropdown-uoe-search .dropdown-item'

const ESCAPE_KEY = 'Escape|Esc'
const SPACE_KEY = 'Spacebar| '
const ENTER_KEY = 'Enter'
const ARROW_UP_KEY = 'ArrowUp|Up'
const ARROW_DOWN_KEY = 'ArrowDown|Down'
const TAB_KEY = 'Tab'

const REGEX_KEYS_OPEN = new RegExp(`^${ARROW_DOWN_KEY}$`)
const REGEX_KEYS_CLOSE = new RegExp(`^${ARROW_UP_KEY}|${ESCAPE_KEY}$`)
const REGEX_KEYS_DOWN = new RegExp(`^${ARROW_DOWN_KEY}$`)
const REGEX_KEYS_UP = new RegExp(`^${ARROW_UP_KEY}$`)
const REGEX_KEYS_MENU_ACCEPT = new RegExp(`^${SPACE_KEY}|${ENTER_KEY}$`)
const REGEX_KEYS_MENU_CANCEL = new RegExp(`^${ESCAPE_KEY}|${TAB_KEY}$`)

const EVENT_KEYDOWN = `keydown${EVENT_KEY}`
const EVENT_CLICK = `click${EVENT_KEY}`
const EVENT_INPUT = 'input'
const EVENT_BLUR = `blur${EVENT_KEY}`
const EVENT_SHOWN = 'shown.bs.dropdown'
const EVENT_HIDDEN = 'hidden.bs.dropdown'
const DROPDOWN_EVENT_KEYDOWN_DATA_API = `keydown.bs.dropdown${DATA_API_KEY}`
const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`

const Default = {
  api: SEARCH_API,
  action: SEARCH_ACTION,
  collection: SEARCH_COLLECTION,
  items: SUGGESTIONS_DEFAULT
}

const DefaultType = {
  api: 'string',
  action: 'string',
  collection: 'string',
  items: 'number'
}

class Search extends EdGELBaseComponent {
  constructor(element, config) {
    super(element, config)

    if (this._element.tagName !== 'INPUT') {
      // eslint-disable-next-line no-console
      console.error('Invalid EdGEL markup for search input.')
      return
    }

    this._query = ''
    this._suggestions = []

    this._addDropdown()
    this._addEventListeners()
  }

  // Getters
  static get Default() {
    return Default
  }

  static get DefaultType() {
    return DefaultType
  }

  static get NAME() {
    return NAME
  }

  // Public
  hide() {
    this._dropdown.hide()
  }

  show() {
    if (this._suggestions.length) {
      this._dropdown.show()
    }
  }

  // Private
  _configAfterMerge(config) {
    // Limit items
    config.items = Number.parseInt(Math.min(config.items, SUGGESTIONS_MAX), 10)

    // Validate URL
    let url
    try {
      url = new URL(config.api)
    } catch {
      throw new TypeError(`Invalid URL provided for search API endpoint "${config.api}"`)
    }

    if (url.protocol !== 'https:') {
      throw new TypeError(`Only https protocol accepted for search API endpoint "${config.api}"`)
    }

    return config
  }

  // Event listeners on the search input and menu
  _addEventListeners() {
    // Changing the input value updates the list of suggestions.
    EventHandler.on(this._element, EVENT_INPUT, () => {
      this._fetchSuggestions(this._element.value)
    })

    // Key down on input opens the dropdown, escape and key up closes it.
    EventHandler.on(this._element, EVENT_KEYDOWN, event => {
      const isDisabled = this._element.disabled || this._element.classList.contains(CLASS_NAME_DISABLED)

      if (isDisabled || !this._menu.childElementCount || REGEX_KEYS_CLOSE.test(event.key)) {
        this._dropdown.hide()
        return
      }

      if (REGEX_KEYS_OPEN.test(event.key)) {
        this._dropdown.show()

        const items = SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, this._menu).filter(isVisible)
        if (items.length) {
          items[0].focus()
          this.setActiveOption(items[0].parentNode.id)
        } else {
          this.setActiveOption('')
        }
      }
    })

    // Losing focus closes the dropdown.
    EventHandler.on(this._element, EVENT_BLUR, () => {
      // We have to wait a tick for the activeElement to be updated with the new
      // focussed element.
      setTimeout(() => {
        const isActive = this._menu.contains(document.activeElement) || this._element === document.activeElement

        if (!isActive) {
          this._dropdown.hide()
        }
      }, 1)
    })

    // Navigating listbox items.
    EventHandler.on(this._menu, EVENT_KEYDOWN, event => {
      const moveUp = REGEX_KEYS_UP.test(event.key)
      const moveDown = REGEX_KEYS_DOWN.test(event.key)
      const cancel = REGEX_KEYS_MENU_CANCEL.test(event.key)
      const accept = REGEX_KEYS_MENU_ACCEPT.test(event.key)

      if (!moveUp && !moveDown && !cancel && !accept) {
        return
      }

      event.preventDefault()
      event.stopPropagation()

      if (cancel) {
        this._dropdown.hide()
        this._element.focus()
        this.setActiveOption('')
        return
      }

      if (accept) {
        if (event.key === ENTER_KEY) {
          this._element.value = event.target.textContent
          this._element.form.submit()
        } else {
          this._element.value = `${event.target.textContent} `
          this._dropdown.hide()
          this._element.focus()
          this.setActiveOption('')
        }
      }

      const items = SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, this._menu).filter(isVisible)

      if (!items.length) {
        return
      }

      let index = items.indexOf(event.target)

      // Allow navigation back to the input using the up arrow.
      if (moveUp && index === 0) {
        this._element.focus()
        this.setActiveOption('')
        return
      }

      if (moveUp && index > 0) {
        index--
      }

      if (moveDown && index < items.length - 1) {
        index++
      }

      index = index === -1 ? 0 : index
      items[index].focus()
      this.setActiveOption(items[index].parentNode.id)
    })

    // Clicking on menu items
    EventHandler.on(document, EVENT_CLICK, SELECTOR_DROPDOWN_ITEM, event => {
      event.preventDefault()
      event.stopPropagation()

      this._element.value = event.target.textContent
      this._dropdown.hide()
      this._element.focus()
      this.setActiveOption('')
    })

    // Tidy up aria attributes on our dropdown element since we cannot have
    // two plugins active on the same element in the Bootstrap API
    EventHandler.on(this._dropdown._element, EVENT_SHOWN, () => {
      this._element.setAttribute('aria-expanded', 'true')
      this._dropdown._element.removeAttribute('aria-expanded')
    })

    EventHandler.on(this._dropdown._element, EVENT_HIDDEN, () => {
      this._element.setAttribute('aria-expanded', 'false')
      this._dropdown._element.removeAttribute('aria-expanded')
    })
  }

  _addDropdown() {
    this._element.id ||= uoeUID()

    const listId = `${this._element.id}-list`
    const statusId = `${this._element.id}-status`
    const describedId = `${this._element.id}-desc`

    // Use the input as our custom combobox element per ARIA 1.0 and draft 1.2
    // (which supersedes the 1.1 pattern that uses a wrapper to the input for
    // the combobox role)
    const cb = this._element
    cb.setAttribute('role', 'combobox')
    cb.setAttribute('aria-autocomplete', 'both')
    cb.setAttribute('aria-controls', listId)
    cb.setAttribute('aria-haspopup', 'listbox')
    cb.setAttribute('aria-owns', listId)
    cb.setAttribute('aria-expanded', 'false')
    cb.setAttribute('aria-describedby', describedId)
    cb.setAttribute('aria-activedescendant', '')

    // Add instructions for use.  Also use this element for the Bootstrap
    // Dropdown component since you can't assign two Bootstrap components to
    // the same element.
    const db = document.createElement('div')
    db.id = describedId
    // We add the Boostrap Toggle selector somewhere so dismissing the dropdown
    // by clicking elsewhere works.
    db.dataset.bsToggle = 'dropdown'
    db.className = 'visually-hidden'
    db.textContent = 'When search suggestions are available, use up and down arrows to review and Enter to select.'
    cb.parentNode.after(db)

    // Add the drop-down menu.
    const lb = document.createElement('ul')
    lb.id = listId
    lb.className = CLASS_NAME_DROPDOWN
    lb.setAttribute('role', 'listbox')
    lb.setAttribute('aria-label', 'Suggested search terms')
    db.after(lb)
    this._menu = lb
    this._dropdown = new Dropdown(db, {
      reference: this._element,
      target: `#${listId}`
    })

    // Add a status message container for ARIA live announcements.
    const st = document.createElement('div')
    st.id = statusId
    st.className = 'visually-hidden'
    st.setAttribute('aria-live', 'polite')
    st.textContent = ''
    lb.after(st)
    this._status = st
  }

  _fetchSuggestions(query) {
    this._query = query

    if (query.length > 1) {
      const qs = [
        `collection=${encodeURIComponent(this._config.collection)}`,
        `fmt=${encodeURIComponent('json++')}`,
        `partial_query=${encodeURIComponent(this._query)}`
      ]
      const url = `${this._config.api}?${qs.join('&')}`

      const request = new XMLHttpRequest()
      request.open('GET', url, true)
      request.responseType = 'json'
      request.addEventListener('load', () => {
        if (request.status === 200) {
          const data = request.response
          let value
          this._suggestions = []

          for (const datum of data) {
            switch (datum.action_t) {
              case 'Q': {
                value = datum.action
                break
              }

              case 'S': {
                value = datum.disp
                break
              }

              default: {
                value = datum.key
              }
            }

            if (value.trim().length) {
              this._suggestions.push(value)
            }
          }

          this._buildListbox()
        }
      })

      request.send()
    } else {
      this._suggestions = []
      this._buildListbox()
    }
  }

  // Build the listbox.  Bind a search context object to provide values for
  // combobox, dropdown, search and statusbox nodes.
  _buildListbox() {
    // If the input has changed since we ran this query, do nothing: the
    // list of options we've just got is out of date.
    if (this._query !== this._element.value.trim()) {
      return
    }

    if (this._suggestions.length) {
      this._dropdown.show()
    } else {
      this._dropdown.hide()
    }

    this._menu.textContent = ''
    const options = this._suggestions.slice(0, this._config.items)
    for (const [i, suggestion] of options.entries()) {
      const escaped = this._query.trim().replaceAll(/[$()*+.?[\\\]^{|}-]/g, String.raw`\$&`)
      const re = new RegExp(escaped, 'gi')
      const html = suggestion
        .replaceAll('&', '&amp;')
        .replaceAll('<', '&lt;')
        .replaceAll('>', '&gt;')
        .replaceAll(re, '<strong>$&</strong>')

      const item = document.createElement('li')
      item.setAttribute('role', 'option')
      item.id = `${this._menu.id}-${i}`

      const option = document.createElement('a')
      option.setAttribute('role', 'button')
      option.setAttribute('tabindex', '-1')
      option.className = CLASS_NAME_DROPDOWN_ITEM
      option.innerHTML = html
      item.append(option)

      this._menu.append(item)
    }

    this._status.textContent = `${options.length} suggestion${options.length === 1 ? '' : 's'} available`
  }

  // Keep aria-activedescendant and the option's aria-selected attributes in step
  setActiveOption(opt) {
    const items = SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, this._menu).filter(isVisible)

    for (const item of items) {
      if (item.parentNode.id === opt) {
        item.parentNode.setAttribute('aria-selected', 'true')
      } else {
        item.parentNode.removeAttribute('aria-selected')
      }
    }

    this._element.setAttribute('aria-activedescendant', opt)
  }

  // Static
  static jQueryInterface(config) {
    return this.each(function () {
      Search.searchInterface(this, config)
    })
  }
}

/**
 * Data API implementation
 * Re-add the Dropdown KeyDown Data API to not include our search dropdowns
 */
EventHandler.off(document, DROPDOWN_EVENT_KEYDOWN_DATA_API, SELECTOR_MENU, Dropdown.dataApiKeydownHandler)
EventHandler.on(document, DROPDOWN_EVENT_KEYDOWN_DATA_API, SELECTOR_NOT_SEARCH_DROPDOWN, Dropdown.dataApiKeydownHandler)

EventHandler.on(globalThis, EVENT_LOAD_DATA_API, () => {
  const searchInputs = SelectorEngine.find(SELECTOR_INPUT)

  for (const searchInput of searchInputs) {
    Search.getOrCreateInstance(searchInput)
  }
})

/**
 * jQuery
 */

defineJQueryPlugin(Search)

export default Search
