/*
 * Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to
 * deal in the Software without restriction, including without limitation the
 * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
 * sell copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
 * IN THE SOFTWARE.
 */

import {
  SearchDocument,
  SearchDocumentMap,
  setupSearchDocumentMap
} from "../document"
import {
  SearchHighlightFactoryFn,
  setupSearchHighlighter
} from "../highlighter"
import {
  SearchQueryTerms,
  getSearchQueryTerms,
  parseSearchQuery
} from "../query"

/* ----------------------------------------------------------------------------
 * Types
 * ------------------------------------------------------------------------- */

/**
 * Search index configuration
 */
export interface SearchIndexConfig {
  lang: string[]                       /* Search languages */
  separator: string                    /* Search separator */
}

/**
 * Search index document
 */
export interface SearchIndexDocument {
  location: string                     /* Document location */
  title: string                        /* Document title */
  text: string                         /* Document text */
}

/* ------------------------------------------------------------------------- */

/**
 * Search index pipeline function
 */
export type SearchIndexPipelineFn =
  | "trimmer"                          /* Trimmer */
  | "stopWordFilter"                   /* Stop word filter */
  | "stemmer"                          /* Stemmer */

/**
 * Search index pipeline
 */
export type SearchIndexPipeline = SearchIndexPipelineFn[]

/* ------------------------------------------------------------------------- */

/**
 * Search index
 *
 * This interfaces describes the format of the `search_index.json` file which
 * is automatically built by the MkDocs search plugin.
 */
export interface SearchIndex {
  config: SearchIndexConfig            /* Search index configuration */
  docs: SearchIndexDocument[]          /* Search index documents */
  index?: object | string              /* Prebuilt or serialized index */
  pipeline?: SearchIndexPipeline       /* Search index pipeline */
}

/* ------------------------------------------------------------------------- */

/**
 * Search metadata
 */
export interface SearchMetadata {
  score: number                        /* Score (relevance) */
  terms: SearchQueryTerms              /* Search query terms */
}

/* ------------------------------------------------------------------------- */

/**
 * Search result
 */
export type SearchResult = Array<
  SearchDocument & SearchMetadata
> // tslint:disable-line

/* ----------------------------------------------------------------------------
 * Functions
 * ------------------------------------------------------------------------- */

/**
 * Compute the difference of two lists of strings
 *
 * @param a - 1st list of strings
 * @param b - 2nd list of strings
 *
 * @return Difference
 */
function difference(a: string[], b: string[]): string[] {
  const [x, y] = [new Set(a), new Set(b)]
  return [
    ...new Set([...x].filter(value => !y.has(value)))
  ]
}

/* ----------------------------------------------------------------------------
 * Class
 * ------------------------------------------------------------------------- */

/**
 * Search index
 *
 * Note that `lunr` is injected via Webpack, as it will otherwise also be
 * bundled in the application bundle.
 */
export class Search {

  /**
   * Search document mapping
   *
   * A mapping of URLs (including hash fragments) to the actual articles and
   * sections of the documentation. The search document mapping must be created
   * regardless of whether the index was prebuilt or not, as `lunr` itself will
   * only store the actual index.
   */
  protected documents: SearchDocumentMap

  /**
   * Search highlight factory function
   */
  protected highlight: SearchHighlightFactoryFn

  /**
   * The underlying `lunr` search index
   */
  protected index: lunr.Index

  /**
   * Create the search integration
   *
   * @param data - Search index
   */
  public constructor({ config, docs, pipeline, index }: SearchIndex) {
    this.documents = setupSearchDocumentMap(docs)
    this.highlight = setupSearchHighlighter(config)

    /* Set separator for tokenizer */
    lunr.tokenizer.separator = new RegExp(config.separator)

    /* If no index was given, create it */
    if (typeof index === "undefined") {
      this.index = lunr(function() {

        /* Set up multi-language support */
        if (config.lang.length === 1 && config.lang[0] !== "en") {
          this.use((lunr as any)[config.lang[0]])
        } else if (config.lang.length > 1) {
          this.use((lunr as any).multiLanguage(...config.lang))
        }

        /* Compute functions to be removed from the pipeline */
        const fns = difference([
          "trimmer", "stopWordFilter", "stemmer"
        ], pipeline!)

        /* Remove functions from the pipeline for registered languages */
        for (const lang of config.lang.map(language => (
          language === "en" ? lunr : (lunr as any)[language]
        ))) {
          for (const fn of fns) {
            this.pipeline.remove(lang[fn])
            this.searchPipeline.remove(lang[fn])
          }
        }

        /* Set up fields and reference */
        this.field("title", { boost: 1000 })
        this.field("text")
        this.ref("location")

        /* Index documents */
        for (const doc of docs)
          this.add(doc)
      })

    /* Handle prebuilt or serialized index */
    } else {
      this.index = lunr.Index.load(
        typeof index === "string"
          ? JSON.parse(index)
          : index
      )
    }
  }

  /**
   * Search for matching documents
   *
   * The search index which MkDocs provides is divided up into articles, which
   * contain the whole content of the individual pages, and sections, which only
   * contain the contents of the subsections obtained by breaking the individual
   * pages up at `h1` ... `h6`. As there may be many sections on different pages
   * with identical titles (for example within this very project, e.g. "Usage"
   * or "Installation"), they need to be put into the context of the containing
   * page. For this reason, section results are grouped within their respective
   * articles which are the top-level results that are returned.
   *
   * @param query - Query value
   *
   * @return Search results
   */
  public search(query: string): SearchResult[] {
    if (query) {
      try {
        const highlight = this.highlight(query)

        /* Parse query to extract clauses for analysis */
        const clauses = parseSearchQuery(query)
          .filter(clause => (
            clause.presence !== lunr.Query.presence.PROHIBITED
          ))

        /* Perform search and post-process results */
        const groups = this.index.search(`${query}*`)

          /* Apply post-query boosts based on title and search query terms */
          .reduce<SearchResult>((results, { ref, score, matchData }) => {
            const document = this.documents.get(ref)
            if (typeof document !== "undefined") {
              const { location, title, text, parent } = document

              /* Compute and analyze search query terms */
              const terms = getSearchQueryTerms(
                clauses,
                Object.keys(matchData.metadata)
              )

              /* Highlight title and text and apply post-query boosts */
              const boost = +!parent + +Object.values(terms).every(t => t)
              results.push({
                location,
                title: highlight(title),
                text: highlight(text),
                score: score * (1 + boost),
                terms
              })
            }
            return results
          }, [])

          /* Sort search results again after applying boosts */
          .sort((a, b) => b.score - a.score)

          /* Group search results by page */
          .reduce((results, result) => {
            const document = this.documents.get(result.location)
            if (typeof document !== "undefined") {
              const ref = "parent" in document
                ? document.parent!.location
                : document.location
              results.set(ref, [...results.get(ref) || [], result])
            }
            return results
          }, new Map<string, SearchResult>())

        /* Expand grouped search results */
        return [...groups.values()]

      /* Log errors to console (for now) */
      } catch {
        // tslint:disable-next-line no-console
        console.warn(`Invalid query: ${query} – see https://bit.ly/2s3ChXG`)
      }
    }

    /* Return nothing in case of error or empty query */
    return []
  }
}
