const BinarySearchTree = require('@seald-io/binary-search-tree').AVLTree
const model = require('./model.js')
const { uniq, isDate } = require('./utils.js')

/**
 * Two indexed pointers are equal if they point to the same place
 * @param {*} a
 * @param {*} b
 * @return {boolean}
 * @private
 */
const checkValueEquality = (a, b) => a === b

/**
 * Type-aware projection
 * @param {*} elt
 * @return {string|*}
 * @private
 */
const projectForUnique = elt => {
  if (elt === null) return '$null'
  if (typeof elt === 'string') return '$string' + elt
  if (typeof elt === 'boolean') return '$boolean' + elt
  if (typeof elt === 'number') return '$number' + elt
  if (isDate(elt)) return '$date' + elt.getTime()

  return elt // Arrays and objects, will check for pointer equality
}

/**
 * Indexes on field names, with atomic operations and which can optionally enforce a unique constraint or allow indexed
 * fields to be undefined
 * @private
 */
class Index {
  /**
   * Create a new index
   * All methods on an index guarantee that either the whole operation was successful and the index changed
   * or the operation was unsuccessful and an error is thrown while the index is unchanged
   * @param {object} options
   * @param {string} options.fieldName On which field should the index apply, can use dot notation to index on sub fields, can use comma-separated notation to use compound indexes
   * @param {boolean} [options.unique = false] Enforces a unique constraint
   * @param {boolean} [options.sparse = false] Allows a sparse index (we can have documents for which fieldName is `undefined`)
   */
  constructor (options) {
    /**
     * On which field the index applies to, can use dot notation to index on sub fields, can use comma-separated notation to use compound indexes.
     * @type {string}
     */
    this.fieldName = options.fieldName

    if (typeof this.fieldName !== 'string') throw new Error('fieldName must be a string')

    /**
     * Internal property which is an Array representing the fieldName split with `,`, useful only for compound indexes.
     * @type {string[]}
     * @private
     */
    this._fields = this.fieldName.split(',')

    /**
     * Defines if the index enforces a unique constraint for this index.
     * @type {boolean}
     */
    this.unique = options.unique || false
    /**
     * Defines if we can have documents for which fieldName is `undefined`
     * @type {boolean}
     */
    this.sparse = options.sparse || false

    /**
     * Options object given to the underlying BinarySearchTree.
     * @type {{unique: boolean, checkValueEquality: (function(*, *): boolean), compareKeys: ((function(*, *, compareStrings): (number|number))|*)}}
     */
    this.treeOptions = { unique: this.unique, compareKeys: model.compareThings, checkValueEquality }

    /**
     * Underlying BinarySearchTree for this index. Uses an AVLTree for optimization.
     * @type {AVLTree}
     */
    this.tree = new BinarySearchTree(this.treeOptions)
  }

  /**
   * Reset an index
   * @param {?document|?document[]} [newData] Data to initialize the index with. If an error is thrown during
   * insertion, the index is not modified.
   */
  reset (newData) {
    this.tree = new BinarySearchTree(this.treeOptions)

    if (newData) this.insert(newData)
  }

  /**
   * Insert a new document in the index
   * If an array is passed, we insert all its elements (if one insertion fails the index is not modified)
   * O(log(n))
   * @param {document|document[]} doc The document, or array of documents, to insert.
   */
  insert (doc) {
    let keys
    let failingIndex
    let error

    if (Array.isArray(doc)) {
      this.insertMultipleDocs(doc)
      return
    }

    const key = model.getDotValues(doc, this._fields)

    // We don't index documents that don't contain the field if the index is sparse
    if ((key === undefined || (typeof key === 'object' && key !== null && Object.values(key).every(el => el === undefined))) && this.sparse) return

    if (!Array.isArray(key)) this.tree.insert(key, doc)
    else {
      // If an insert fails due to a unique constraint, roll back all inserts before it
      keys = uniq(key, projectForUnique)

      for (let i = 0; i < keys.length; i += 1) {
        try {
          this.tree.insert(keys[i], doc)
        } catch (e) {
          error = e
          failingIndex = i
          break
        }
      }

      if (error) {
        for (let i = 0; i < failingIndex; i += 1) {
          this.tree.delete(keys[i], doc)
        }

        throw error
      }
    }
  }

  /**
   * Insert an array of documents in the index
   * If a constraint is violated, the changes should be rolled back and an error thrown
   * @param {document[]} docs Array of documents to insert.
   * @private
   */
  insertMultipleDocs (docs) {
    let error
    let failingIndex

    for (let i = 0; i < docs.length; i += 1) {
      try {
        this.insert(docs[i])
      } catch (e) {
        error = e
        failingIndex = i
        break
      }
    }

    if (error) {
      for (let i = 0; i < failingIndex; i += 1) {
        this.remove(docs[i])
      }

      throw error
    }
  }

  /**
   * Removes a document from the index.
   * If an array is passed, we remove all its elements
   * The remove operation is safe with regards to the 'unique' constraint
   * O(log(n))
   * @param {document[]|document} doc The document, or Array of documents, to remove.
   */
  remove (doc) {
    if (Array.isArray(doc)) {
      doc.forEach(d => { this.remove(d) })
      return
    }

    const key = model.getDotValues(doc, this._fields)
    if (key === undefined && this.sparse) return

    if (!Array.isArray(key)) {
      this.tree.delete(key, doc)
    } else {
      uniq(key, projectForUnique).forEach(_key => {
        this.tree.delete(_key, doc)
      })
    }
  }

  /**
   * Update a document in the index
   * If a constraint is violated, changes are rolled back and an error thrown
   * Naive implementation, still in O(log(n))
   * @param {document|Array.<{oldDoc: document, newDoc: document}>} oldDoc Document to update, or an `Array` of
   * `{oldDoc, newDoc}` pairs.
   * @param {document} [newDoc] Document to replace the oldDoc with. If the first argument is an `Array` of
   * `{oldDoc, newDoc}` pairs, this second argument is ignored.
   */
  update (oldDoc, newDoc) {
    if (Array.isArray(oldDoc)) {
      this.updateMultipleDocs(oldDoc)
      return
    }

    this.remove(oldDoc)

    try {
      this.insert(newDoc)
    } catch (e) {
      this.insert(oldDoc)
      throw e
    }
  }

  /**
   * Update multiple documents in the index
   * If a constraint is violated, the changes need to be rolled back
   * and an error thrown
   * @param {Array.<{oldDoc: document, newDoc: document}>} pairs
   *
   * @private
   */
  updateMultipleDocs (pairs) {
    let failingIndex
    let error

    for (let i = 0; i < pairs.length; i += 1) {
      this.remove(pairs[i].oldDoc)
    }

    for (let i = 0; i < pairs.length; i += 1) {
      try {
        this.insert(pairs[i].newDoc)
      } catch (e) {
        error = e
        failingIndex = i
        break
      }
    }

    // If an error was raised, roll back changes in the inverse order
    if (error) {
      for (let i = 0; i < failingIndex; i += 1) {
        this.remove(pairs[i].newDoc)
      }

      for (let i = 0; i < pairs.length; i += 1) {
        this.insert(pairs[i].oldDoc)
      }

      throw error
    }
  }

  /**
   * Revert an update
   * @param {document|Array.<{oldDoc: document, newDoc: document}>} oldDoc Document to revert to, or an `Array` of `{oldDoc, newDoc}` pairs.
   * @param {document} [newDoc] Document to revert from. If the first argument is an Array of {oldDoc, newDoc}, this second argument is ignored.
   */
  revertUpdate (oldDoc, newDoc) {
    const revert = []

    if (!Array.isArray(oldDoc)) this.update(newDoc, oldDoc)
    else {
      oldDoc.forEach(pair => {
        revert.push({ oldDoc: pair.newDoc, newDoc: pair.oldDoc })
      })
      this.update(revert)
    }
  }

  /**
   * Get all documents in index whose key match value (if it is a Thing) or one of the elements of value (if it is an array of Things)
   * @param {Array.<*>|*} value Value to match the key against
   * @return {document[]}
   */
  getMatching (value) {
    if (!Array.isArray(value)) return this.tree.search(value)
    else {
      const _res = {}
      const res = []

      value.forEach(v => {
        this.getMatching(v).forEach(doc => {
          _res[doc._id] = doc
        })
      })

      Object.keys(_res).forEach(_id => {
        res.push(_res[_id])
      })

      return res
    }
  }

  /**
   * Get all documents in index whose key is between bounds are they are defined by query
   * Documents are sorted by key
   * @param {object} query An object with at least one matcher among $gt, $gte, $lt, $lte.
   * @param {*} [query.$gt] Greater than matcher.
   * @param {*} [query.$gte] Greater than or equal matcher.
   * @param {*} [query.$lt] Lower than matcher.
   * @param {*} [query.$lte] Lower than or equal matcher.
   * @return {document[]}
   */
  getBetweenBounds (query) {
    return this.tree.betweenBounds(query)
  }

  /**
   * Get all elements in the index
   * @return {document[]}
   */
  getAll () {
    const res = []

    this.tree.executeOnEveryNode(node => {
      res.push(...node.data)
    })

    return res
  }
}

// Interface
module.exports = Index
