/*!
 * memdb.js - in-memory database for bcoin
 * Copyright (c) 2016-2017, Christopher Jeffrey (MIT License).
 * https://github.com/bcoin-org/bcoin
 */

'use strict';

var assert = require('assert');
var util = require('../utils/util');
var RBT = require('../utils/rbt');
var DUMMY = new Buffer(0);

/**
 * In memory database for bcoin
 * using a red-black tree backend.
 * @alias module:db.MemDB
 * @constructor
 * @param {String?} location - Phony location.
 * @param {Object?} options
 * @param {Function} options.compare - Comparator.
 */

function MemDB(location) {
  if (!(this instanceof MemDB))
    return new MemDB(location);

  this.location = location || 'memory';
  this.options = {};
  this.tree = new RBT(util.cmp, true);
}

/**
 * Do a key lookup.
 * @private
 * @param {Buffer|String} key
 * @returns {Buffer?} value
 */

MemDB.prototype.search = function search(key) {
  var node;

  if (typeof key === 'string')
    key = new Buffer(key, 'utf8');

  assert(Buffer.isBuffer(key), 'Key must be a Buffer.');

  node = this.tree.search(key);

  if (!node)
    return;

  return node.value;
};

/**
 * Insert a record.
 * @private
 * @param {Buffer|String} key
 * @param {Buffer} value
 */

MemDB.prototype.insert = function insert(key, value) {
  if (typeof key === 'string')
    key = new Buffer(key, 'utf8');

  if (typeof value === 'string')
    value = new Buffer(value, 'utf8');

  if (value == null)
    value = DUMMY;

  assert(Buffer.isBuffer(key), 'Key must be a Buffer.');
  assert(Buffer.isBuffer(value), 'Value must be a Buffer.');

  return this.tree.insert(key, value) != null;
};

/**
 * Remove a record.
 * @private
 * @param {Buffer|String} key
 * @returns {Boolean}
 */

MemDB.prototype.remove = function remove(key) {
  if (typeof key === 'string')
    key = new Buffer(key, 'utf8');

  assert(Buffer.isBuffer(key), 'Key must be a Buffer.');

  return this.tree.remove(key) != null;
};

/**
 * Traverse between a range of keys and collect records.
 * @private
 * @param {Buffer} min
 * @param {Buffer} max
 * @returns {RBTData[]} Records.
 */

MemDB.prototype.range = function range(min, max) {
  if (typeof min === 'string')
    min = new Buffer(min, 'utf8');

  if (typeof max === 'string')
    max = new Buffer(max, 'utf8');

  assert(!min || Buffer.isBuffer(min), 'Key must be a Buffer.');
  assert(!max || Buffer.isBuffer(max), 'Key must be a Buffer.');

  return this.tree.range(min, max);
};

/**
 * Open the database (leveldown method).
 * @param {Object?} options
 * @param {Function} callback
 */

MemDB.prototype.open = function open(options, callback) {
  if (!callback) {
    callback = options;
    options = null;
  }

  if (!options)
    options = {};

  this.options = options;

  util.nextTick(callback);
};

/**
 * Close the database (leveldown method).
 * @param {Function} callback
 */

MemDB.prototype.close = function close(callback) {
  util.nextTick(callback);
};

/**
 * Retrieve a record (leveldown method).
 * @param {Buffer|String} key
 * @param {Object?} options
 * @param {Function} callback - Returns Bufer.
 */

MemDB.prototype.get = function get(key, options, callback) {
  var value, err;

  if (!callback) {
    callback = options;
    options = null;
  }

  if (!options)
    options = {};

  value = this.search(key);

  if (!value) {
    err = new Error('MEMDB_NOTFOUND: Key not found.');
    err.notFound = true;
    err.type = 'NotFoundError';
    util.nextTick(function() {
      callback(err);
    });
    return;
  }

  if (options.asBuffer === false)
    value = value.toString('utf8');

  util.nextTick(function() {
    callback(null, value);
  });
};

/**
 * Insert a record (leveldown method).
 * @param {Buffer|String} key
 * @param {Buffer} value
 * @param {Object?} options
 * @param {Function} callback
 */

MemDB.prototype.put = function put(key, value, options, callback) {
  if (!callback) {
    callback = options;
    options = null;
  }

  this.insert(key, value);

  util.nextTick(callback);
};

/**
 * Remove a record (leveldown method).
 * @param {Buffer|String} key
 * @param {Object?} options
 * @param {Function} callback
 */

MemDB.prototype.del = function del(key, options, callback) {
  if (!callback) {
    callback = options;
    options = null;
  }

  this.remove(key);

  util.nextTick(callback);
};

/**
 * Create an atomic batch (leveldown method).
 * @see Leveldown.Batch
 * @param {Object[]?} ops
 * @param {Object?} options
 * @param {Function} callback
 */

MemDB.prototype.batch = function batch(ops, options, callback) {
  var batch;

  if (!callback) {
    callback = options;
    options = null;
  }

  batch = new Batch(this, options);

  if (ops) {
    batch.ops = ops;
    batch.write(callback);
    return;
  }

  return batch;
};

/**
 * Create an iterator (leveldown method).
 * @param {Object} options - See {Leveldown.Iterator}.
 * @returns {Leveldown.Iterator}.
 */

MemDB.prototype.iterator = function iterator(options) {
  return new Iterator(this, options);
};

/**
 * Get a database property (leveldown method) (NOP).
 * @param {String} name - Property name.
 * @returns {String}
 */

MemDB.prototype.getProperty = function getProperty(name) {
  return '';
};

/**
 * Calculate approximate database size (leveldown method).
 * @param {Buffer|String} start - Start key.
 * @param {Buffer|String} end - End key.
 * @param {Function} callback - Returns Number.
 */

MemDB.prototype.approximateSize = function approximateSize(start, end, callback) {
  var items = this.range(start, end);
  var size = 0;
  var i, item;

  for (i = 0; i < items.length; i++) {
    item = items[i];
    size += item.key.length;
    size += item.value.length;
  }

  util.nextTick(function() {
    callback(null, size);
  });
};

/**
 * Destroy the database (leveldown function) (NOP).
 * @param {String} location
 * @param {Function} callback
 */

MemDB.destroy = function destroy(location, callback) {
  util.nextTick(callback);
};

/**
 * Repair the database (leveldown function) (NOP).
 * @param {String} location
 * @param {Function} callback
 */

MemDB.repair = function repair(location, callback) {
  util.nextTick(callback);
};

/**
 * Batch
 * @constructor
 * @ignore
 * @private
 * @param {MemDB} db
 * @param {Object?} options
 */

function Batch(db, options) {
  this.options = options || {};
  this.ops = [];
  this.db = db;
  this.written = false;
}

/**
 * Insert a record.
 * @param {Buffer|String} key
 * @param {Buffer} value
 */

Batch.prototype.put = function(key, value) {
  assert(!this.written, 'Already written.');
  this.ops.push(new BatchOp('put', key, value));
  return this;
};

/**
 * Remove a record.
 * @param {Buffer|String} key
 */

Batch.prototype.del = function del(key) {
  assert(!this.written, 'Already written.');
  this.ops.push(new BatchOp('del', key));
  return this;
};

/**
 * Commit the batch.
 * @param {Function} callback
 */

Batch.prototype.write = function write(callback) {
  var i, op;

  if (this.written) {
    util.nextTick(function() {
      callback(new Error('Already written.'));
    });
    return;
  }

  for (i = 0; i < this.ops.length; i++) {
    op = this.ops[i];
    switch (op.type) {
      case 'put':
        this.db.insert(op.key, op.value);
        break;
      case 'del':
        this.db.remove(op.key);
        break;
      default:
        util.nextTick(function() {
          callback(new Error('Bad operation: ' + op.type));
        });
        return;
    }
  }

  this.ops = [];
  this.written = true;

  util.nextTick(callback);

  return this;
};

/**
 * Clear batch of all ops.
 */

Batch.prototype.clear = function clear() {
  assert(!this.written, 'Already written.');
  this.ops = [];
  return this;
};

/**
 * Batch Operation
 * @constructor
 * @ignore
 * @private
 * @param {String} type
 * @param {Buffer} key
 * @param {Buffer|null} value
 */

function BatchOp(type, key, value) {
  this.type = type;
  this.key = key;
  this.value = value;
}

/**
 * Iterator
 * @constructor
 * @ignore
 * @private
 * @param {RBT} db
 * @param {Object?} options
 */

function Iterator(db, options) {
  this.db = db;
  this.options = new IteratorOptions(options);
  this.iter = null;
  this.ended = false;
  this.total = 0;
  this.init();
}

/**
 * Initialize the iterator.
 */

Iterator.prototype.init = function init() {
  var snapshot = this.db.tree.snapshot();
  var iter = this.db.tree.iterator(snapshot);

  if (this.options.reverse) {
    if (this.options.end) {
      iter.seekMax(this.options.end);
      if (this.options.lt && iter.valid()) {
        if (iter.compare(this.options.end) === 0)
          iter.prev();
      }
    } else {
      iter.seekLast();
    }
  } else {
    if (this.options.start) {
      iter.seekMin(this.options.start);
      if (this.options.gt && iter.valid()) {
        if (iter.compare(this.options.start) === 0)
          iter.next();
      }
    } else {
      iter.seekFirst();
    }
  }

  this.iter = iter;
};

/**
 * Seek to the next key.
 * @param {Function} callback
 */

Iterator.prototype.next = function(callback) {
  var options = this.options;
  var iter = this.iter;
  var key, value, result;

  if (!this.iter) {
    util.nextTick(function() {
      callback(new Error('Cannot call next after end.'));
    });
    return;
  }

  if (options.reverse) {
    result = iter.prev();

    // Stop once we hit a key below our gte key.
    if (result && options.start) {
      if (options.gt) {
        if (iter.compare(options.start) <= 0)
          result = false;
      } else {
        if (iter.compare(options.start) < 0)
          result = false;
      }
    }
  } else {
    result = iter.next();

    // Stop once we hit a key above our lte key.
    if (result && options.end) {
      if (options.lt) {
        if (iter.compare(options.end) >= 0)
          result = false;
      } else {
        if (iter.compare(options.end) > 0)
          result = false;
      }
    }
  }

  if (!result) {
    this.iter = null;
    util.nextTick(callback);
    return;
  }

  if (options.limit !== -1) {
    if (this.total >= options.limit) {
      this.iter = null;
      util.nextTick(callback);
      return;
    }
    this.total += 1;
  }

  key = iter.key;
  value = iter.value;

  if (!options.keys)
    key = DUMMY;

  if (!options.values)
    value = DUMMY;

  if (!options.keyAsBuffer)
    key = key.toString('utf8');

  if (!options.valueAsBuffer)
    value = value.toString('utf8');

  util.nextTick(function() {
    callback(null, key, value);
  });
};

/**
 * Seek to a key gte to `key`.
 * @param {String|Buffer} key
 */

Iterator.prototype.seek = function seek(key) {
  assert(this.iter, 'Already ended.');

  if (typeof key === 'string')
    key = new Buffer(key, 'utf8');

  assert(Buffer.isBuffer(key), 'Key must be a Buffer.');

  if (this.options.reverse)
    this.iter.seekMax(key);
  else
    this.iter.seekMin(key);
};

/**
 * End the iterator. Free up snapshot.
 * @param {Function} callback
 */

Iterator.prototype.end = function end(callback) {
  if (this.ended) {
    util.nextTick(function() {
      callback(new Error('Already ended.'));
    });
    return;
  }

  this.ended = true;
  this.iter = null;

  util.nextTick(callback);
};

/**
 * Iterator Options
 * @constructor
 * @ignore
 * @param {Object} options
 */

function IteratorOptions(options) {
  this.keys = true;
  this.values = true;
  this.start = null;
  this.end = null;
  this.gt = false;
  this.lt = false;
  this.keyAsBuffer = true;
  this.valueAsBuffer = true;
  this.reverse = false;
  this.limit = -1;

  if (options)
    this.fromOptions(options);
}

/**
 * Inject properties from options.
 * @private
 * @param {Object} options
 * @returns {IteratorOptions}
 */

IteratorOptions.prototype.fromOptions = function fromOptions(options) {
  if (options.keys != null) {
    assert(typeof options.keys === 'boolean');
    this.keys = options.keys;
  }

  if (options.values != null) {
    assert(typeof options.values === 'boolean');
    this.values = options.values;
  }

  if (options.start != null)
    this.start = options.start;

  if (options.end != null)
    this.end = options.end;

  if (options.gte != null)
    this.start = options.gte;

  if (options.lte != null)
    this.end = options.lte;

  if (options.gt != null) {
    this.gt = true;
    this.start = options.gt;
  }

  if (options.lt != null) {
    this.lt = true;
    this.end = options.lt;
  }

  if (this.start != null) {
    if (typeof this.start === 'string')
      this.start = new Buffer(this.start, 'utf8');
    assert(Buffer.isBuffer(this.start), '`start` must be a Buffer.');
  }

  if (this.end != null) {
    if (typeof this.end === 'string')
      this.end = new Buffer(this.end, 'utf8');
    assert(Buffer.isBuffer(this.end), '`end` must be a Buffer.');
  }

  if (options.keyAsBuffer != null) {
    assert(typeof options.keyAsBuffer === 'boolean');
    this.keyAsBuffer = options.keyAsBuffer;
  }

  if (options.valueAsBuffer != null) {
    assert(typeof options.valueAsBuffer === 'boolean');
    this.valueAsBuffer = options.valueAsBuffer;
  }

  if (options.reverse != null) {
    assert(typeof options.reverse === 'boolean');
    this.reverse = options.reverse;
  }

  if (options.limit != null) {
    assert(typeof options.limit === 'number');
    this.limit = options.limit;
  }

  return this;
};

/*
 * Expose
 */

module.exports = MemDB;
