import axios from 'axios'
import lunr from 'lunr'

import {freshdesk} from './plugin'
import * as util from './util'

import {version as packageVersion} from '../package.json'

import flatten from 'array-flatten'

let documentStorage = {}

/**
 * @typedef Plugin
 * @memberOf LunrSearch
 * @property {Function} plugin a lunr builder plugin
 * @property {Function} fetch a function to fetch documents
 * @property {Function} isSupported checks whether the plugin is supported in the current environment
 */

/**
 * @typedef Result
 * @memberOf LunrSearch
 * @property {Object} document the document that was matched
 * @property {Object} source describes the source of the document
 * @property {string} source.type the type of the document source, examples are 'forum' or 'solution'
 * @property {string} source.url the URL that the document was fetched from
 * @property {lunr.Index~Result} result the lunr search result
 */

/**
 * A class to handle building, importing, exporting, and searching a lunr index.
 * It contains static and instance methods of each function so you can choose how to use it.
 *
 * @example <caption>Instance usage</caption>
 * const builder = new lunr.Builder()   // if you need to,
 * builder.metadataWhitelist = ['tags'] // you can customize the builder that will be used internally
 *
 * const lunrSearch = new LunrSearch('myfreshdeskdomain', 'freshdeskusername', 'freshdeskpassword', {
 *   builder, // pass the custom builder if needed
 *   plugins: [myCustomBuilderPlugin],
 *   editDistance: 2 // in case your users can't type very well :)
 * })
 *
 * lunrSearch.buildIndex().then(() => mySearchBar.addEventListener('input', () => lunrSeach.search(mySearchBar.value))
 *
 * @example <caption>Static usage</caption>
 * const opts = {domain: 'freshdeskdomain', user: 'username', pass: 'password'}
 * const builder = new lunr.Builder() // custom builder, etc.
 * const indexPromise = LunrSearch.buildIndex({builder, ...opts}) // you can pass a custom builder into the static methods too
 *
 * indexPromise.then(() => mySearchBar.addEventListener('input', () => lunrSeach.search(mySearchBar.value))
 *
 * @param  {string} domain                      the domain to use for all fetch requests
 * @param  {string} user                        the username to use for all fetch requests
 * @param  {string} pass                        the password to use for all fetch requests
 * @param  {object} [options={}]
 * @param  {lunr.Builder} [options.builder]     a custom builder to use instead of creating a new instance
 * @param  {Plugin[]} [options.plugins]         a list of plugins to use
 * @param  {number} [options.editDistance=1]    a default edit distance to use in search queries
 * @class LunrSearch
 */
export default class LunrSearch {
  constructor (domain, user, pass, {
    builder = new lunr.Builder(),
    plugins = [freshdesk],
    editDistance = 1
  } = {}) {
    /**
     * The package version.
     * @memberOf LunrSearch
     * @member {string} VERSION
     * @static
     */
    this.constructor.VERSION = packageVersion

    /**
     * the fetched documents
     * the keys are the document ID and the value is the document.
     * useful for getting info about a search result.
     *
     * @memberOf LunrSearch
     * @member {Object} docs
     * @instance
     */
    this.docs = {}

    /**
     * the domain to use for all fetch requests
     *
     * @member {string} domain
     * @memberOf LunrSearch
     * @instance
     */
    this.domain = domain

    /**
     * the username to use for all fetch requests
     *
     * @member {string} user
     * @memberOf LunrSearch
     * @instance
     */
    this.user = user

    /**
     * the password to use for all fetch requests
     *
     * @member {string} pass
     * @memberOf LunrSearch
     * @instance
     */
    this.pass = pass

    /**
     * a custom builder to use
     *
     * @member {lunr.Builder} builder
     * @memberOf LunrSearch
     * @instance
     */
    this.builder = builder

    /**
     * a list of plugins to use
     *
     * @member {Plugin[]} plugins
     * @memberOf LunrSearch
     * @instance
     */
    this.plugins = plugins

    /**
     * a default edit distance to use in search queries
     *
     * @member {number} editDistance
     * @memberOf LunrSearch
     * @default 0
     * @instance
     */
    this.editDistance = editDistance

    /**
     * The built lunr index.
     *
     * @member {lunr.Index} index
     * @memberOf LunrSearch
     * @instance
     */
    this.index = null
  }

  /**
   * Searches the index with the given query.
   *
   * @param  {string} query                   the query to search with
   * @param  {Object} [options={}]
   * @param  {number} [options.editDistance]  the edit distance to use for the search
   * @return {Result[]}                       an array of search results
   * @memberOf LunrSearch
   * @instance
   */
  search (query, {editDistance} = {}) {
    return query ? util.buildResults(this.index.search(`${query}~${editDistance || this.editDistance}`), this.docs) : []
  }

  /**
   * Gets and imports an index fram a URL.
   *
   * @async
   * @param  {string} url       the URL of the endpoint
   * @param  {Object} [auth={}]      authentication details for the API
   * @param  {string} [auth.user]    the username for the API
   * @param  {string} [auth.pass]    the password for the API
   * @return {lunr.Index}
   * @memberOf LunrSearch
   * @instance
   */
  async getIndex (url, {user, pass} = {}) {
    this.index = await this.constructor.getIndex(url, {user, pass})
    return this.index
  }

  /**
   * Imports an exported lunr index and stores it.
   *
   * @param  {string} json the index to import as a json string
   * @return {void}
   * @memberOf LunrSearch
   * @instance
   */
  importIndex (json) {
    this.index = this.constructor.importIndex(json)
  }

  /**
   * Posts index to a URL.
   * If the index exists locally, it will not be built. Call {@link LunrSearch#buildIndex} to build a fresh index.
   * If the index does not already exist, it will be built asynchronously.
   *
   * @async
   * @param  {string} url       the URL of the endpoint
   * @param  {Object} [auth={}]      authentication details for the API
   * @param  {string} [auth.user]    the username for the API
   * @param  {string} [auth.pass]    the password for the API
   * @return {axios.Response}
   * @memberOf LunrSearch
   * @instance
   */
  async postIndex (url, {user, pass} = {}) {
    return this.constructor.postIndex(await this.exportIndex(), url, {user, pass})
  }

  /**
   * Serializes and exports a lunr index.
   * If the index exists locally, it will not be built. Call #buildIndex to build a fresh index.
   * If the index does not already exist, it will be built asynchronously.
   *
   * @async
   * @return {Promise<string>} the serialized index
   * @memberOf LunrSearch
   * @instance
   */
  async exportIndex () {
    return JSON.stringify(this.index || await this.buildIndex())
  }

  /**
   * Builds an index and stores it.
   *
   * @async
   * @return {lunr.Index} the built index
   * @memberOf LunrSearch
   * @instance
   */
  async buildIndex () {
    const docs = Object.values(this.docs)
    this.index = await this.constructor.buildIndex({
      docs: docs.length > 0 ? docs : await this.fetch(),
      builder: this.builder,
      plugins: this.plugins
    })
    return this.index
  }

  /**
   * Fetches some documents that are ready to be indexed.
   *
   * @async
   * @return {Object[]} the array of Objects to be indexed
   * @memberOf LunrSearch
   * @instance
   */
  async fetch () {
    const docs = await this.constructor.fetch({
      domain: this.domain,
      user: this.user,
      pass: this.pass,
      plugins: this.plugins
    })

    this.docs = documentStorage

    return docs
  }

  /**
   * Searches the given index with the given query.
   * If query is falsey no search is performed.
   *
   * @param  {string} query                     the query to search with
   * @param  {lunr.Index} index                 an index to use for the search
   * @param  {Object} [options={}]
   * @param  {number} [options.editDistance=1]  the edit distance to use for the search
   * @return {Result[]}                         an array of search results
   * @memberOf LunrSearch
   * @static
   */
  static search (query, index, {editDistance = 1}) {
    return query ? util.buildResults(index.search(`${query}~${editDistance}`), documentStorage) : []
  }

  /**
   * Gets and imports an index fram a URL.
   *
   * @async
   * @param  {string} url       the URL of the endpoint
   * @param  {Object} [auth={}]      authentication details for the API
   * @param  {string} [auth.user]    the username for the API
   * @param  {string} [auth.pass]    the password for the API
   * @return {lunr.Index}
   * @memberOf LunrSearch
   * @static
   */
  static async getIndex (url, {user, pass} = {}) {
    return axios.get(url, {auth: {username: user, password: pass}}).then(({data}) => this.importIndex(data))
  }

  /**
   * Imports an exported lunr index and returns it.
   *
   * @param  {string} json the stringified index to import
   * @return {lunr.Index}
   * @memberOf LunrSearch
   * @static
   */
  static importIndex (json) {
    return lunr.Index.load(JSON.parse(json))
  }

  /**
   * Posts an exported index to a URL.
   *
   * @async
   * @param  {lunr.Index} index         the index to be posted
   * @param  {string} url       the URL of the endpoint
   * @param  {Object} [auth={}]      authentication details for the API
   * @param  {string} [auth.user]    the username for the API
   * @param  {string} [auth.pass]    the password for the API
   * @return {axios.Response}
   * @memberOf LunrSearch
   * @static
   */
  static async postIndex (index, url, {user, pass} = {}) {
    return axios.post(url, index, {auth: {username: user, password: pass}})
  }

  /**
   * Builds then serializes and exports a lunr index.
   *
   * @async
   * @param  {Object} [options={}]
   * @param  {string} [options.domain]          the domain of the API to make the request to
   * @param  {string} [options.user]            the username for the API
   * @param  {string} [options.pass]            the password for the API
   * @param  {lunr.Builder} [options.builder]   the builder to use for building the index
   * @param  {Function[]} [options.plugins]     a list of plugins for the builder to use
   * @return {string} the serialized index
   * @memberOf LunrSearch
   * @static
   */
  static async exportIndex ({
    domain,
    user,
    pass,
    builder = new lunr.Builder(),
    plugins = [freshdesk]
  }) {
    return JSON.stringify(await this.buildIndex({domain, user, pass, builder, plugins}))
  }

  /**
   * Builds an index and returns it.
   * If no docs are supplied, they will be fetched.
   *
   * @async
   * @param  {Object} [options={}]
   * @param  {string} [options.domain]          the domain of the API to make the request to
   * @param  {string} [options.user]            the username for the API
   * @param  {string} [options.pass]            the password for the API
   * @param  {Object[]} [options.docs]          list of docs to build the index with
   * @param  {lunr.Builder} [options.builder]   the builder to use for building the index
   * @param  {Plugin[]} [options.plugins]       a list of plugins
   * @return {lunr.Index}                       the built index
   * @memberOf LunrSearch
   * @static
   */
  static async buildIndex ({
    domain,
    user,
    pass,
    docs: _docs,
    builder = new lunr.Builder(),
    plugins = [freshdesk]
  }) {
    plugins.forEach(p => builder.use(p.plugin))

    const docs = _docs || await this.fetch({domain, user, pass})

    docs.forEach(doc => builder.add(doc))
    return builder.build()
  }

  /**
   * Fetches some documents that are ready to be indexed.
   *
   * @async
   * @param  {Object} [options={}]
   * @param  {string} [options.domain]          the domain of the API to make the request to
   * @param  {string} [options.user]            the username for the API
   * @param  {string} [options.pass]            the password for the API
   * @param  {Plugin[]} [options.plugins]       a list of plugins
   * @return {Object[]}                         the array of Objects to be indexed
   * @memberOf LunrSearch
   * @static
   */
  static async fetch ({
    domain,
    user,
    pass,
    plugins = [freshdesk]
  }) {
    return new Promise((resolve, reject) => {
      const docs = []
      Promise.all(plugins.map(plugin => plugin.fetch(domain, user, pass).then(d => d.forEach(doc => {
        docs.push(doc)
        documentStorage[doc.id] = doc
      })))).then(() => resolve(docs))
    })
  }

  /**
   * Checks to see if certain functions are defined.
   *
   * @param  {Plugin[]}  [plugins] a list of plugins
   * @return {Boolean}
   * @memberOf LunrSearch
   * @static
   */
  static isSupported (plugins = [freshdesk]) {
    let supported = true
    plugins.forEach(plugin => { supported = supported && plugin.isSupported() })
    supported = supported && util.definedFunction([].forEach)
    return supported
  }
}