var emptyFn = function() {}
var Mingo = require('mingo')
var es = require('event-stream')

module.exports = function(opts) {
  var options = Object.assign({ idProperty: '_id' }, opts)
  var self = es.map(createOrUpdate)
  var data = []
  var idSeq = 0

  var mingoOpts = Object.freeze({ idKey: options.idProperty })

  function findById(id) {
    var query = {}
    query[options.idProperty] = id
    const cursor = Mingo.find(data, query, {}, mingoOpts)
    return cursor.hasNext() ? cursor.next() : null
  }

  /**
   * Checks that the object has the ID property present, then checks
   * if the data object has that ID value present.e
   *
   * Returns an Error to the callback if either of the above checks fail
   *
   * @param {Object} object to check
   * @param {Function} callback
   * @api private
   */
  function checkForIdAndData(object, callback) {
    var id = object[options.idProperty]
    var foundObject
    if (id === undefined || id === null) {
      return callback(
        new Error("Object has no '" + options.idProperty + "' property")
      )
    }

    foundObject = findById(id)

    if (foundObject === null) {
      return callback(
        new Error(
          "No object found with '" + options.idProperty + "' = '" + id + "'"
        )
      )
    }

    return callback(null, foundObject)
  }

  /**
   * Create a new entity. Emits a 'create' event.
   *
   * @param {Object} object to create
   * @param {Function} callback (optional)
   * @api public
   */
  function create(object, callback) {
    self.emit('create', object)
    callback = callback || emptyFn
    // clone the object
    var extendedObject = Object.assign({}, object)

    if (!extendedObject[options.idProperty]) {
      idSeq += 1
      extendedObject[options.idProperty] = '' + idSeq
    } else {
      if (findById(extendedObject[options.idProperty]) !== null) {
        return callback(
          new Error(
            'Key ' + extendedObject[options.idProperty] + ' already exists'
          )
        )
      }
      // if an id is provided, cast to string.
      extendedObject[options.idProperty] =
        '' + extendedObject[options.idProperty]
    }
    data.push(Object.assign({}, extendedObject))
    self.emit('afterCreate', extendedObject)
    callback(undefined, extendedObject)
  }

  /**
   * Create or update a entity. Emits a 'create' event or a 'update'.
   *
   * @param {Object} object to create or update
   * @param {Function} callback (optional)
   * @api public
   */
  function createOrUpdate(object, callback) {
    if (typeof object[options.idProperty] === 'undefined') {
      // Create a new object
      self.create(object, callback)
    } else {
      // Try and find the object first to update
      var query = {}
      query[options.idProperty] = object[options.idProperty]

      self.findOne(query, function(ignoreError, foundObject) {
        if (foundObject) {
          // We found the object so update
          self.update(object, callback)
        } else {
          // We didn't find the object so create
          self.create(object, callback)
        }
      })
    }
  }

  /**
   * Reads a single entity. Emits a 'read' event.
   *
   * @param {Number} id to read
   * @param {Function} callback (optional)
   * @api public
   */
  function read(id, callback) {
    var query = {}

    self.emit('read', id)
    callback = callback || emptyFn
    query[options.idProperty] = '' + id
    findByQuery(query, {}, function(ignoreError, objects) {
      if (objects[0] !== undefined) {
        var cloned = Object.assign({}, objects[0])
        self.emit('received', cloned)
        callback(undefined, cloned)
      } else {
        callback(undefined, undefined)
      }
    })
  }

  /**
   * Updates a single entity. Emits an 'update' event. Optionally overwrites
   * the entire entity, by default just Object.assigns it with the new values.
   *
   * @param {Object} object to update
   * @param {Boolean} whether to overwrite or Object.assign the existing entity
   * @param {Function} callback (optional)
   * @api public
   */
  function update(object, overwrite, callback) {
    if (typeof overwrite === 'function') {
      callback = overwrite
      overwrite = false
    }
    self.emit('update', object, overwrite)
    callback = callback || emptyFn
    var id = '' + object[options.idProperty]
    var updatedObject

    checkForIdAndData(object, function(error, foundObject) {
      if (error) {
        return callback(error)
      }

      if (overwrite) {
        updatedObject = Object.assign({}, object)
      } else {
        updatedObject = Object.assign({}, foundObject, object)
      }

      var query = {}
      var copy = Object.assign({}, updatedObject)
      query[options.idProperty] = id
      data = Mingo.remove(data, query, mingoOpts)
      data.push(updatedObject)
      self.emit('afterUpdate', copy, overwrite)
      callback(undefined, copy)
    })
  }

  /**
   * Deletes entities based on a query. Emits a 'delete' event. Performs a find
   * by query, then calls delete for each item returned. Returns an error if no
   * items match the query.
   *
   * @param {Object} query to delete on
   * @param {Function} callback (optional)
   * @api public
   */
  function deleteMany(query, callback) {
    callback = callback || emptyFn
    self.emit('deleteMany', query)
    data = Mingo.remove(data, query, mingoOpts)
    self.emit('afterDeleteMany', query)
    callback()
  }

  /**
   * Deletes one entity. Emits a 'delete' event. Returns an error if the
   * object can not be found or if the ID property is not present.
   *
   * @param {Object} object to delete
   * @param {Function} callback (optional)
   * @api public
   */
  function del(id, callback) {
    callback = callback || emptyFn

    if (typeof callback !== 'function') {
      throw new TypeError('callback must be a function or empty')
    }

    self.emit('delete', id)
    var query = {}
    query[options.idProperty] = id
    deleteMany(query, function() {
      self.emit('afterDelete', '' + id)
      callback(undefined)
    })
  }

  /**
   * Performs a find on the data by search query.
   *
   * Sorting can be done similarly to mongo by providing a $sort option to
   * the options object.
   *
   * The query can target fields in a subdocument similarly to mongo by passing
   * a string reference to the subdocument in dot notation.
   *
   * @param {Object} query to search by
   * @param {Object} search options
   * @param {Function} callback
   * @api private
   */
  function findByQuery(query, options, callback) {
    if (typeof options === 'function') {
      callback = options
      options = {}
    }

    var cursor = Mingo.find(data, query, options && options.fields, mingoOpts)
    if (options && options.sort) cursor = cursor.sort(options.sort)
    if (options && options.limit) cursor = cursor.limit(options.limit)

    var allData = getObjectCopies(cursor.all())

    if (callback === undefined) {
      return es.readArray(allData).pipe(
        es.map(function(data, cb) {
          self.emit('received', data)
          cb(null, data)
        })
      )
    } else {
      callback(null, allData)
    }
  }

  function getObjectCopies(objects) {
    var copies = []
    objects.forEach(function(object) {
      copies.push(Object.assign({}, object))
    })
    return copies
  }

  /**
   * Performs a find on the data. Emits a 'find' event.
   *
   * @param {Object} query to search by
   * @param {Object} options
   * @param {Function} callback
   * @api public
   */
  function find(query, options, callback) {
    if (typeof options === 'function') {
      callback = options
      options = {}
    }

    self.emit('find', query, options)
    if (callback !== undefined) {
      findByQuery(query, options, function(error, data) {
        if (error) return callback(error)
        self.emit('received', data)
        callback(null, data)
      })
    } else {
      return findByQuery(query, options)
    }
  }

  /**
   * Performs a find on the data and limits the result set to 1.
   * Emits a 'findOne' event.
   *
   * @param {Object} query to search by
   * @param {Object} options
   * @param {Function} callback
   * @api public
   */
  function findOne(query, options, callback) {
    if (typeof options === 'function') {
      callback = options
      options = {}
    }
    self.emit('findOne', query, options)
    findByQuery(query, options, function(ignoreError, objects) {
      self.emit('received', objects[0])
      callback(undefined, objects[0])
    })
  }

  /**
   * Performs a count by query. Emits a 'count' event.
   *
   * @param {Object} query to search by
   * @param {Function} callback
   * @api public
   */
  function count(query, callback) {
    self.emit('count', query)
    findByQuery(query, options, function(ignoreError, objects) {
      self.emit('received', objects.length)
      callback(undefined, objects.length)
    })
  }

  Object.assign(self, {
    create: create,
    read: read,
    update: update,
    delete: del,
    deleteMany: deleteMany,
    find: find,
    findOne: findOne,
    count: count,
    idProperty: options.idProperty,
    createOrUpdate: createOrUpdate
  })

  return self
}
