import _ from "lodash"
import * as utils from "./utils"
import jQueryHttpClient from "./jQueryHttpClient"
import * as quickfind from "./quickfind"
import {
  MinimongoDb,
  MinimongoCollection,
  Doc,
  MinimongoCollectionFindOptions,
  MinimongoCollectionFindOneOptions,
  HttpClient
} from "./types"
import { MinimongoBaseCollection } from "."
import sha1 from "js-sha1"

export default class RemoteDb implements MinimongoDb {
  collections: { [collectionName: string]: Collection<any> }
  url: string | string[]
  client: string | null | undefined
  httpClient: HttpClient
  useQuickFind: boolean
  usePostFind: boolean

  /** Url must have trailing /, can be an arrau of URLs
   * useQuickFind enables the quickfind protocol for finds
   * usePostFind enables POST for find
   */
  constructor(
    url: string | string[],
    client?: string | null,
    httpClient?: any,
    useQuickFind = false,
    usePostFind = false
  ) {
    this.url = url
    this.client = client
    this.collections = {}
    this.httpClient = httpClient
    this.useQuickFind = useQuickFind
    this.usePostFind = usePostFind
  }

  // Can specify url of specific collection as option.
  // useQuickFind can be overridden in options
  // usePostFind can be overridden in options
  addCollection(
    name: string,
    options: { url?: string; useQuickFind?: boolean; usePostFind?: boolean } = {},
    success?: any,
    error?: any
  ) {
    let url: string | string[]
    if (_.isFunction(options)) {
      ;[options, success, error] = [{}, options, success]
    }

    if (options.url) {
      ;({ url } = options)
    } else {
      if (_.isArray(this.url)) {
        url = _.map(this.url, (url: any) => url + name)
      } else {
        url = this.url + name
      }
    }

    let { useQuickFind } = this
    if (options.useQuickFind != null) {
      ;({ useQuickFind } = options)
    }

    let { usePostFind } = this
    if (options.usePostFind != null) {
      ;({ usePostFind } = options)
    }

    const collection = new Collection(name, url, this.client, this.httpClient, useQuickFind, usePostFind)
    this[name] = collection
    this.collections[name] = collection
    if (success != null) {
      return success()
    }
  }

  removeCollection(name: any, success: any, error: any) {
    delete this[name]
    delete this.collections[name]
    if (success != null) {
      return success()
    }
  }

  getCollectionNames() {
    return _.keys(this.collections)
  }
}

// Remote collection on server
class Collection<T extends Doc> implements MinimongoBaseCollection<T> {
  name: string
  url: string | string[]
  client?: string | null
  httpClient: HttpClient
  useQuickFind: any
  usePostFind: any

  /** Cycles through urls if array and not GET request */
  urlIndex: number

  // usePostFind allows POST to <collection>/find for long selectors
  constructor(name: string, url: string | string[], client: string | null | undefined, httpClient: any, useQuickFind: any, usePostFind: any) {
    this.name = name
    this.url = url
    this.client = client
    this.httpClient = httpClient || jQueryHttpClient
    this.useQuickFind = useQuickFind
    this.usePostFind = usePostFind
    this.urlIndex = 0
  }

  /** Get a URL to use from an array, if present.
   * Stable if a GET request, but not if a POST, etc request
   * to allow caching. Accomplished by passing in a key
   * to use for hashing for GET only.
   */
  getUrl(key?: string) {
    if (typeof this.url === "string") {
      return this.url
    }

    // If no key, use next URL
    if (!key) {
      const url = this.url[this.urlIndex]
      this.urlIndex = (this.urlIndex + 1) % this.url.length
      return url
    }

    // Hash key to get index
    const hash = sha1.create()
    hash.update(key)
    
    // Get 4 hex digits
    const partial = hash.hex().substr(0, 4)

    // Convert to integer
    const index = parseInt(partial, 16)

    // Get URL
    return this.url[index % this.url.length]
  }

  // error is called with jqXHR
  find(selector: any, options: MinimongoCollectionFindOptions = {}) {
    return {
      fetch: (success?: any, error?: any) => {
        return this._findFetch(selector, options, success, error)
      }
    }
  }

  _findFetch(selector: any, options: MinimongoCollectionFindOptions, success: any, error: any): any {
    // If promise case
    if (success == null) {
      return new Promise((resolve, reject) => {
        this._findFetch(selector, options, resolve, reject)
      })
    }

    // Determine method: "get", "post" or "quickfind"
    // If in quickfind and localData present and (no fields option or _rev included) and not (limit with no sort), use quickfind
    let method
    if (
      this.useQuickFind &&
      options.localData &&
      (!options.fields || options.fields._rev) &&
      !(options.limit && !options.sort && !options.orderByExprs)
    ) {
      method = "quickfind"
      // If selector or fields or sort is too big, use post
    } else if (
      this.usePostFind &&
      JSON.stringify({ selector, sort: options.sort, fields: options.fields }).length > 500
    ) {
      method = "post"
    } else {
      method = "get"
    }

    if (method === "get") {
      // Create url
      const params: any = {}
      params.selector = JSON.stringify(selector || {})
      if (options.sort) {
        params.sort = JSON.stringify(options.sort)
      }
      if (options.limit) {
        params.limit = options.limit
      }
      if (options.skip) {
        params.skip = options.skip
      }
      if (options.fields) {
        params.fields = JSON.stringify(options.fields)
      }

      // Advanced options for mwater-expression-based filtering and ordering
      if (options.whereExpr) {
        params.whereExpr = JSON.stringify(options.whereExpr)
      }
      if (options.orderByExprs) {
        params.orderByExprs = JSON.stringify(options.orderByExprs)
      }

      if (this.client) {
        params.client = this.client
      }
      this.httpClient("GET", this.getUrl(this.name + JSON.stringify(params)), params, null, success, error)
      return
    }

    // Create body + params for quickfind and post
    const body = {
      selector: selector || {}
    } as any
    if (options.sort) {
      body.sort = options.sort
    }
    if (options.limit != null) {
      body.limit = options.limit
    }
    if (options.skip != null) {
      body.skip = options.skip
    }
    if (options.fields) {
      body.fields = options.fields
    }

    // Advanced options for mwater-expression-based filtering and ordering
    if (options.whereExpr) {
      body.whereExpr = options.whereExpr
    }
    if (options.orderByExprs) {
      body.orderByExprs = options.orderByExprs
    }

    const params: any = {}
    if (this.client) {
      params.client = this.client
    }

    if (method === "quickfind") {
      // Send quickfind data
      body.quickfind = quickfind.encodeRequest(options.localData)

      this.httpClient(
        "POST",
        this.getUrl() + "/quickfind",
        params,
        body,
        (encodedResponse: any) => {
          return success(quickfind.decodeResponse(encodedResponse, options.localData, options.sort))
        },
        error
      )
      return
    }

    // POST method
    this.httpClient(
      "POST",
      this.getUrl() + "/find",
      params,
      body,
      (response: any) => {
        return success(response)
      },
      error
    )
    return
  }

  // error is called with jqXHR
  // Note that findOne is not used by HybridDb, but rather find with limit is used
  findOne(selector: any, options?: MinimongoCollectionFindOneOptions): Promise<T | null>
  findOne(
    selector: any,
    options: MinimongoCollectionFindOneOptions,
    success: (doc: T | null) => void,
    error: (err: any) => void
  ): void
  findOne(selector: any, success: (doc: T | null) => void, error: (err: any) => void): void
  findOne(selector: any, options?: any, success?: any, error?: any) {
    if (_.isFunction(options)) {
      ;[options, success, error] = [{}, options, success]
    }
    options = options || {}

    // If promise case
    if (success == null) {
      return new Promise((resolve, reject) => {
        this.findOne(selector, options, resolve, reject)
      })
    }

    // TODO would require documentation change and major bump
    // // Use simple GET if no options and selector is by _id only
    // if (_.isEmpty(options) && _.isEqual(selector, { _id: selector._id })) {
    //   return this.httpClient(
    //     "GET",
    //     this.getUrl(this.name + "/" + selector._id),
    //     { client: this.client },
    //     null,
    //     function (result: any) {
    //       success(result ?? null)
    //     },
    //     (jqXHR) => {
    //       if (jqXHR.status === 404) {
    //         return success(null)
    //       } else {
    //         return error(jqXHR)
    //       }
    //     }
    //   )
    // }

    // Create url
    const params: any = {}
    if (options.sort) {
      params.sort = JSON.stringify(options.sort)
    }
    params.limit = 1
    if (this.client) {
      params.client = this.client
    }
    params.selector = JSON.stringify(selector || {})

    return this.httpClient(
      "GET",
      this.getUrl(this.name + "?" + JSON.stringify(params)),
      params,
      null,
      function (results: any) {
        if (results && results.length > 0) {
          return success(results[0])
        } else {
          return success(null)
        }
      },
      error
    )
  }

  upsert(doc: T): Promise<T | null>
  upsert(doc: T, base: T | null | undefined): Promise<T | null>
  upsert(docs: T[]): Promise<(T | null)[]>
  upsert(docs: T[], bases: (T | null | undefined)[]): Promise<(T | null)[]>
  upsert(doc: T, success: (doc: T | null) => void, error: (err: any) => void): void
  upsert(doc: T, base: T | null | undefined, success: (doc: T | null) => void, error: (err: any) => void): void
  upsert(docs: T[], success: (docs: (T | null)[]) => void, error: (err: any) => void): void
  upsert(
    docs: T[],
    bases: (T | null | undefined)[],
    success: (item: (T | null)[]) => void,
    error: (err: any) => void
  ): void
  upsert(docs: any, bases?: any, success?: any, error?: any): any {
    // If promise case
    if (!success && !_.isFunction(bases)) {
      return new Promise((resolve, reject) => {
        this.upsert(
          docs,
          bases,
          resolve,
          reject
        )
      })
    }

    let items: { doc: T; base?: T }[]
    ;[items, success, error] = utils.regularizeUpsert(docs, bases, success, error)

    const results = []

    // Check if bases present
    const basesPresent = _.compact(_.map(items, "base")).length > 0

    const params: any = {}
    if (this.client) {
      params.client = this.client
    }

    // Handle single case
    if (items.length === 1) {
      // POST if no base, PATCH otherwise
      if (basesPresent) {
        return this.httpClient(
          "PATCH",
          this.getUrl(),
          params,
          items[0],
          function (result: any) {
            if (_.isArray(docs)) {
              return success([result])
            } else {
              return success(result)
            }
          },
          function (err: any) {
            if (error) {
              return error(err)
            }
          }
        )
      } else {
        return this.httpClient(
          "POST",
          this.getUrl(),
          params,
          items[0].doc,
          function (result: any) {
            if (_.isArray(docs)) {
              return success([result])
            } else {
              return success(result)
            }
          },
          function (err: any) {
            if (error) {
              return error(err)
            }
          }
        )
      }
    } else {
      // POST if no base, PATCH otherwise
      if (basesPresent) {
        return this.httpClient(
          "PATCH",
          this.getUrl(),
          params,
          { doc: _.map(items, "doc"), base: _.map(items, "base") },
          (result: any) => success(result),
          function (err: any) {
            if (error) {
              return error(err)
            }
          }
        )
      } else {
        return this.httpClient(
          "POST",
          this.getUrl(),
          params,
          _.map(items, "doc"),
          (result: any) => success(result),
          function (err: any) {
            if (error) {
              return error(err)
            }
          }
        )
      }
    }
  }

  // error is called with jqXHR
  remove(id: any): Promise<void>
  remove(id: any, success: () => void, error: (err: any) => void): void
  remove(id: any, success?: () => void, error?: (err: any) => void): any {
    if (!success) {
      return new Promise<void>((resolve, reject) => {
        this.remove(id, resolve, reject)
      })
    }

    if (!this.client) {
      throw new Error("Client required to remove")
    }

    const params = { client: this.client }
    return this.httpClient("DELETE", this.getUrl() + "/" + id, params, null, success, function (err: any) {
      // 410 is an acceptable delete status
      if (err.status === 410) {
        return success()
      } else {
        return error!(err)
      }
    })
  }
}
