interface IndexEntry {
  text: string
  result: string
}

interface Index {
  index: IndexEntry[]
  resultContainerTemplate: string
  resultsContainerTemplate: string
}

interface Configuration{
  enterSearchShortcuts: string
}

class Search {
  private readonly hideSearchKeys = ['Escape']
  private readonly nextResultKeys = ['ArrowDown']
  private readonly previousResultKeys = ['ArrowUp']
  private readonly search: HTMLElement
  private readonly form: HTMLElement
  private readonly queryElement: HTMLInputElement
  private readonly resultsContainer: HTMLElement
  private documentY: number
  private index: Index | null = null

  public constructor () {
    this.search = document.getElementById('search')
    this.form = this.search.getElementsByTagName('form').item(0)
    this.queryElement = document.getElementById('search-query') as HTMLInputElement
    this.resultsContainer = document.getElementById('search-results-container')
    this.documentY = null
  }

  public async initialize (): Promise<void> {
    // Prevent default form submission behaviors, such as HTTP requests.
    this.form.addEventListener('submit', (e) => {
      e.preventDefault()
      e.stopPropagation()
    })

    this.queryElement.addEventListener(
      'keyup',
      () => {
        void (async () :Promise<void> => {
          await this.perform(this.queryElement.value)
        })()
      }
    )

    // Allow keyboard navigation through the results.
    this.queryElement.addEventListener('keydown', (e) => { this.navigateResults(e.key) })
    // Allow navigation into and out of the search.

    this.resultsContainer.addEventListener('keydown', (e) => { this.navigateResults(e.key) })
    const response = await fetch(this.search.dataset.bettySearchConfiguration)
    const configuration = await response.json() as Configuration
    document.addEventListener('keyup', (e) => {
      if (configuration.enterSearchShortcuts.includes(e.key)) {
        this.queryElement.focus()
        this.showSearchResults()
      }
    })
    this.queryElement.addEventListener('focus', () => { this.showSearchResults() })
    this.search.getElementsByClassName('overlay-close')[0].addEventListener('mouseup', () => { this.hideSearchResults() })
    document.addEventListener('keydown', (e) => {
      if (this.hideSearchKeys.includes(e.key)) {
        this.hideSearchResults()
      }
    })
  }

  private navigateResults (keyCode: string): void {
    if (this.previousResultKeys.includes(keyCode)) {
      // If the focus lies on the query input element, do nothing, because there are no previous search results.
      if (document.activeElement === this.queryElement) {
        return
      }

      if (document.activeElement.classList.contains('search-result-target')) {
        // If the focus lies on a search result, focus on the previous search result if there is one.
        const previousSearchResultContainer = document.activeElement.closest('.search-result').previousElementSibling
        if (previousSearchResultContainer) {
          const previousSearchResultTarget = previousSearchResultContainer.querySelector<HTMLElement>('.search-result-target')
          if (previousSearchResultTarget) {
            previousSearchResultTarget.focus()
          }
          return
        }

        // If no previous search result exists, focus on the query input element.
        this.queryElement.focus()
      }
    } else if (this.nextResultKeys.includes(keyCode)) {
      // If the focus lies on the query input element, focus on the first search result.
      if (document.activeElement === this.queryElement) {
        const resultTargets = this.resultsContainer.getElementsByClassName('search-result-target') as HTMLCollectionOf<HTMLElement>
        if (resultTargets.length) {
          resultTargets[0].focus()
        }
        return
      }
      // If the focus lies on a search result, focus on the next search result if there is one.
      if (document.activeElement.classList.contains('search-result-target')) {
        const nextSearchResultContainer = document.activeElement.closest('.search-result').nextElementSibling
        if (nextSearchResultContainer) {
          const nextSearchResultTarget = nextSearchResultContainer.querySelector<HTMLElement>('.search-result-target')
          if (nextSearchResultTarget) {
            nextSearchResultTarget.focus()
          }
        }
      }
    }
  }

  private setSearchEntries (entries: IndexEntry[]): void {
    this.resultsContainer.innerHTML = this.renderResults(entries)
    this.resultsContainer.scrollTop = 0
  }

  private showSearchResults () : void {
    if (!this.documentY) {
      this.documentY = window.scrollY
    }
    this.search.classList.add('overlay')
    document.body.classList.add('has-overlay')
    if (!this.search.contains(document.activeElement)) {
      this.queryElement.focus()
    }
  }

  private hideSearchResults (): void {
    const activeElement = document.activeElement as HTMLElement | null
    if (this.search.contains(activeElement)) {
      activeElement.blur()
    }
    this.search.classList.remove('overlay')
    document.body.classList.remove('has-overlay')
    if (this.documentY) {
      window.scrollTo({
        top: this.documentY
      })
      this.documentY = null
    }
  }

  private async getIndex () :Promise<Index> {
    if (this.index === null) {
      const response = await fetch(this.search.dataset.bettySearchIndex)
      this.index = await response.json() as Index
    }
    return this.index
  }

  private async perform (query: string): Promise<void> {
    const index = await this.getIndex()
    this.setSearchEntries(index.index.filter((entry) => this.match(query, entry.text)))
  }

  private match (query: string, haystack: string):boolean {
    const queryParts = query.toLowerCase().split(/\s/)
    for (const queryPart of queryParts) {
      if (!haystack.includes(queryPart)) {
        return false
      }
    }
    return true
  }

  private renderResults (entries: IndexEntry[]) :string {
    return this.index.resultsContainerTemplate
      .replace('<!-- betty-search-results -->', entries.map((entry) => this.renderResult(entry)).join(''))
  }

  private renderResult (entry: IndexEntry) :string {
    return this.index.resultContainerTemplate
      .replace('<!-- betty-search-result -->', entry.result)
  }
}

export {
  Search,
}
