/*!
 * miner.js - inefficient miner for bcoin (because we can)
 * Copyright (c) 2014-2015, Fedor Indutny (MIT License)
 * Copyright (c) 2014-2017, Christopher Jeffrey (MIT License).
 * https://github.com/bcoin-org/bcoin
 */

'use strict';

var assert = require('assert');
var util = require('../utils/util');
var co = require('../utils/co');
var Heap = require('../utils/heap');
var AsyncObject = require('../utils/asyncobject');
var Amount = require('../btc/amount');
var Address = require('../primitives/address');
var MinerBlock = require('./minerblock');
var Network = require('../protocol/network');
var consensus = require('../protocol/consensus');
var policy = require('../protocol/policy');
var BlockEntry = MinerBlock.BlockEntry;

/**
 * A bitcoin miner (supports mining witness blocks).
 * @alias module:mining.Miner
 * @constructor
 * @param {Object} options
 * @param {Address} options.address - Payout address.
 * @param {String} [options.coinbaseFlags="mined by bcoin"]
 * @property {Boolean} running
 * @property {MinerBlock} attempt
 * @emits Miner#block
 * @emits Miner#status
 */

function Miner(options) {
  if (!(this instanceof Miner))
    return new Miner(options);

  AsyncObject.call(this);

  this.options = new MinerOptions(options);

  this.network = this.options.network;
  this.logger = this.options.logger;
  this.chain = this.options.chain;
  this.mempool = this.options.mempool;
  this.addresses = this.options.addresses;

  this.locker = this.chain.locker;

  this.running = false;
  this.stopping = false;
  this.attempt = null;
  this.since = 0;

  this._init();
}

util.inherits(Miner, AsyncObject);

/**
 * Initialize the miner.
 * @private
 */

Miner.prototype._init = function _init() {
  var self = this;

  this.chain.on('tip', function(tip) {
    if (!self.attempt)
      return;

    if (self.attempt.block.prevBlock === tip.prevBlock)
      self.attempt.destroy();
  });

  this.on('block', function(block, entry) {
    // Emit the block hex as a failsafe (in case we can't send it)
    self.logger.info('Found block: %d (%s).', entry.height, entry.rhash());
    self.logger.debug('Raw: %s', block.toRaw().toString('hex'));
  });

  this.on('status', function(stat) {
    self.logger.info(
      'Miner: hashrate=%dkhs hashes=%d target=%d height=%d best=%s',
      stat.hashrate / 1000 | 0,
      stat.hashes,
      stat.target,
      stat.height,
      stat.best);
  });
};

/**
 * Open the miner, wait for the chain and mempool to load.
 * @method
 * @alias module:mining.Miner#open
 * @returns {Promise}
 */

Miner.prototype._open = co(function* open() {
  yield this.chain.open();

  if (this.mempool)
    yield this.mempool.open();

  this.logger.info('Miner loaded (flags=%s).',
    this.options.coinbaseFlags.toString('utf8'));
});

/**
 * Close the miner.
 * @method
 * @alias module:mining.Miner#close
 * @returns {Promise}
 */

Miner.prototype._close = co(function* close() {
  if (!this.running)
    return;

  if (this.stopping) {
    yield this._onStop();
    return;
  }

  yield this.stop();
});

/**
 * Start mining.
 * @method
 * @param {Number?} version - Custom block version.
 * @returns {Promise}
 */

Miner.prototype.start = co(function* start() {
  var self = this;
  var block, entry;

  assert(!this.running, 'Miner is already running.');

  this.running = true;
  this.stopping = false;

  for (;;) {
    this.attempt = null;

    try {
      this.attempt = yield this.createBlock();
    } catch (e) {
      if (this.stopping)
        break;
      this.emit('error', e);
      continue;
    }

    if (this.stopping)
      break;

    this.attempt.on('status', function(status) {
      self.emit('status', status);
    });

    try {
      block = yield this.attempt.mineAsync();
    } catch (e) {
      if (this.stopping)
        break;
      this.emit('error', e);
      continue;
    }

    if (this.stopping)
      break;

    if (!block)
      continue;

    try {
      entry = yield this.chain.add(block);
    } catch (e) {
      if (this.stopping)
        break;
      this.emit('error', e);
      continue;
    }

    if (!entry) {
      this.logger.warning('Mined a bad-prevblk (race condition?)');
      continue;
    }

    if (this.stopping)
      break;

    this.emit('block', block, entry);
  }

  this.emit('done');
});

/**
 * Stop mining.
 * @method
 * @returns {Promise}
 */

Miner.prototype.stop = co(function* stop() {
  assert(this.running, 'Miner is not running.');
  assert(!this.stopping, 'Miner is already stopping.');

  this.stopping = true;

  if (this.attempt)
    this.attempt.destroy();

  yield this._onDone();

  this.running = false;
  this.stopping = false;
  this.attempt = null;

  this.emit('stop');
});

/**
 * Wait for `done` event.
 * @private
 * @returns {Promise}
 */

Miner.prototype._onDone = function _onDone() {
  var self = this;
  return new Promise(function(resolve, reject) {
    self.once('done', resolve);
  });
};

/**
 * Wait for `stop` event.
 * @private
 * @returns {Promise}
 */

Miner.prototype._onStop = function _onStop() {
  var self = this;
  return new Promise(function(resolve, reject) {
    self.once('stop', resolve);
  });
};

/**
 * Create a block "attempt".
 * @method
 * @param {ChainEntry} tip
 * @returns {Promise} - Returns {@link MinerBlock}.
 */

Miner.prototype.createBlock = co(function* createBlock(tip, address) {
  var unlock = yield this.locker.lock();
  try {
    return yield this._createBlock(tip, address);
  } finally {
    unlock();
  }
});

/**
 * Create a block "attempt" (without a lock).
 * @method
 * @private
 * @param {ChainEntry} tip
 * @returns {Promise} - Returns {@link MinerBlock}.
 */

Miner.prototype._createBlock = co(function* createBlock(tip, address) {
  var version = this.options.version;
  var ts, locktime, target, attempt;

  if (!tip)
    tip = this.chain.tip;

  if (!address)
    address = this.getAddress();

  if (version === -1)
    version = yield this.chain.computeBlockVersion(tip);

  if (this.chain.state.hasMTP()) {
    locktime = yield tip.getMedianTime();
    ts = Math.max(this.network.now(), locktime + 1);
  } else {
    ts = Math.max(this.network.now(), tip.ts + 1);
    locktime = ts;
  }

  target = yield this.chain.getTarget(ts, tip);

  attempt = new MinerBlock({
    network: this.network,
    tip: tip,
    version: version,
    ts: ts,
    bits: target,
    locktime: locktime,
    flags: this.chain.state.flags,
    address: address,
    coinbaseFlags: this.options.coinbaseFlags,
    witness: this.chain.state.hasWitness(),
    weight: this.options.reservedWeight,
    sigops: this.options.reservedSigops
  });

  this.build(attempt);

  this.logger.debug(
    'Created miner block (height=%d, weight=%d, fees=%d, txs=%s).',
    attempt.height,
    attempt.weight,
    Amount.btc(attempt.fees),
    attempt.items.length + 1);

  return attempt;
});

/**
 * Mine a single block.
 * @method
 * @param {ChainEntry} tip
 * @returns {Promise} - Returns [{@link Block}].
 */

Miner.prototype.mineBlock = co(function* mineBlock(tip, address) {
  var attempt = yield this.createBlock(tip, address);
  return yield attempt.mineAsync();
});

/**
 * Notify the miner that a new tx has entered the mempool.
 * @param {MempoolEntry} entry
 */

Miner.prototype.notifyEntry = function notifyEntry() {
  if (!this.running)
    return;

  if (!this.attempt)
    return;

  if (++this.since > 20) {
    this.since = 0;
    this.attempt.destroy();
  }
};

/**
 * Add an address to the address list.
 * @param {Address} address
 */

Miner.prototype.addAddress = function addAddress(address) {
  this.addresses.push(Address(address));
};

/**
 * Get a random address from the address list.
 * @returns {Address}
 */

Miner.prototype.getAddress = function getAddress() {
  assert(this.addresses.length !== 0, 'No address passed in for miner.');
  return this.addresses[Math.random() * this.addresses.length | 0];
};

/**
 * Get mempool entries, sort by dependency order.
 * Prioritize by priority and fee rates.
 * @returns {MempoolEntry[]}
 */

Miner.prototype.build = function build(attempt) {
  var depMap = {};
  var block = attempt.block;
  var queue = new Heap(cmpRate);
  var priority = this.options.priorityWeight > 0;
  var i, j, entry, item, tx, hash, input;
  var prev, deps, hashes, weight, sigops;

  if (priority)
    queue.set(cmpPriority);

  if (!this.mempool)
    return [];

  assert(this.mempool.tip === this.chain.tip.hash,
    'Mempool/chain tip mismatch! Unsafe to create block.');

  hashes = this.mempool.getSnapshot();

  for (i = 0; i < hashes.length; i++) {
    hash = hashes[i];
    entry = this.mempool.getEntry(hash);
    item = BlockEntry.fromEntry(entry, attempt);
    tx = item.tx;

    if (tx.isCoinbase())
      throw new Error('Cannot add coinbase to block.');

    for (j = 0; j < tx.inputs.length; j++) {
      input = tx.inputs[j];
      prev = input.prevout.hash;

      if (!this.mempool.hasEntry(prev))
        continue;

      item.depCount += 1;

      if (!depMap[prev])
        depMap[prev] = [];

      depMap[prev].push(item);
    }

    if (item.depCount > 0)
      continue;

    queue.insert(item);
  }

  while (queue.size() > 0) {
    item = queue.shift();
    tx = item.tx;
    hash = item.hash;
    weight = attempt.weight;
    sigops = attempt.sigops;

    if (!tx.isFinal(attempt.height, attempt.locktime))
      continue;

    if (!attempt.witness && tx.hasWitness())
      continue;

    weight += tx.getWeight();

    if (weight > this.options.maxWeight)
      continue;

    sigops += item.sigops;

    if (sigops > this.options.maxSigops)
      continue;

    if (priority) {
      if (weight > this.options.priorityWeight
          || item.priority < this.options.priorityThreshold) {
        priority = false;
        queue.set(cmpRate);
        queue.init();
        queue.insert(item);
        continue;
      }
    } else {
      if (item.free && weight >= this.options.minWeight)
        continue;
    }

    attempt.weight = weight;
    attempt.sigops = sigops;
    attempt.fees += item.fee;
    attempt.items.push(item);

    block.txs.push(tx);

    deps = depMap[hash];

    if (!deps)
      continue;

    for (j = 0; j < deps.length; j++) {
      item = deps[j];
      if (--item.depCount === 0)
        queue.insert(item);
    }
  }

  attempt.refresh();

  assert(block.getWeight() <= attempt.weight,
    'Block exceeds reserved weight!');
  assert(block.getBaseSize() <= consensus.MAX_BLOCK_SIZE,
    'Block exceeds max block size.');
};

/**
 * MinerOptions
 * @alias module:mining.MinerOptions
 * @constructor
 * @param {Object}
 */

function MinerOptions(options) {
  if (!(this instanceof MinerOptions))
    return new MinerOptions(options);

  this.network = Network.primary;
  this.logger = null;
  this.chain = null;
  this.mempool = null;

  this.version = -1;
  this.addresses = [];
  this.coinbaseFlags = new Buffer('mined by bcoin', 'ascii');

  this.minWeight = policy.MIN_BLOCK_WEIGHT;
  this.maxWeight = policy.MAX_BLOCK_WEIGHT;
  this.priorityWeight = policy.BLOCK_PRIORITY_WEIGHT;
  this.priorityThreshold = policy.BLOCK_PRIORITY_THRESHOLD;
  this.maxSigops = consensus.MAX_BLOCK_SIGOPS_COST;
  this.reservedWeight = 4000;
  this.reservedSigops = 400;

  this.fromOptions(options);
}

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

MinerOptions.prototype.fromOptions = function fromOptions(options) {
  var i, flags;

  assert(options, 'Miner requires options.');
  assert(options.chain && typeof options.chain === 'object',
    'Miner requires a blockchain.');

  this.chain = options.chain;
  this.network = options.chain.network;
  this.logger = options.chain.logger;

  if (options.logger != null) {
    assert(typeof options.logger === 'object');
    this.logger = options.logger;
  }

  if (options.mempool != null) {
    assert(typeof options.mempool === 'object');
    this.mempool = options.mempool;
  }

  if (options.version != null) {
    assert(util.isNumber(options.version));
    this.version = options.version;
  }

  if (options.address) {
    if (Array.isArray(options.address)) {
      for (i = 0; i < options.address.length; i++)
        this.addresses.push(new Address(options.address[i]));
    } else {
      this.addresses.push(new Address(options.address));
    }
  }

  if (options.addresses) {
    assert(Array.isArray(options.addresses));
    for (i = 0; i < options.addresses.length; i++)
      this.addresses.push(new Address(options.addresses[i]));
  }

  if (options.coinbaseFlags) {
    flags = options.coinbaseFlags;
    if (typeof flags === 'string')
      flags = new Buffer(flags, 'utf8');
    assert(Buffer.isBuffer(flags));
    assert(flags.length <= 20, 'Coinbase flags > 20 bytes.');
    this.coinbaseFlags = flags;
  }

  if (options.minWeight != null) {
    assert(util.isNumber(options.minWeight));
    this.minWeight = options.minWeight;
  }

  if (options.maxWeight != null) {
    assert(util.isNumber(options.maxWeight));
    assert(options.maxWeight <= consensus.MAX_BLOCK_WEIGHT,
      'Max weight must be below MAX_BLOCK_WEIGHT');
    this.maxWeight = options.maxWeight;
  }

  if (options.maxSigops != null) {
    assert(util.isNumber(options.maxSigops));
    assert(options.maxSigops <= consensus.MAX_BLOCK_SIGOPS_COST,
      'Max sigops must be below MAX_BLOCK_SIGOPS_COST');
    this.maxSigops = options.maxSigops;
  }

  if (options.priorityWeight != null) {
    assert(util.isNumber(options.priorityWeight));
    this.priorityWeight = options.priorityWeight;
  }

  if (options.priorityThreshold != null) {
    assert(util.isNumber(options.priorityThreshold));
    this.priorityThreshold = options.priorityThreshold;
  }

  if (options.reservedWeight != null) {
    assert(util.isNumber(options.reservedWeight));
    this.reservedWeight = options.reservedWeight;
  }

  if (options.reservedSigops != null) {
    assert(util.isNumber(options.reservedSigops));
    this.reservedSigops = options.reservedSigops;
  }

  return this;
};

/**
 * Instantiate miner options from object.
 * @param {Object} options
 * @returns {MinerOptions}
 */

MinerOptions.fromOptions = function fromOptions(options) {
  return new MinerOptions().fromOptions(options);
};

/*
 * Helpers
 */

function cmpPriority(a, b) {
  if (a.priority === b.priority)
    return cmpRate(a, b);
  return b.priority - a.priority;
}

function cmpRate(a, b) {
  var x = a.rate;
  var y = b.rate;

  if (a.descRate > a.rate)
    x = a.descRate;

  if (b.descRate > b.rate)
    y = b.descRate;

  if (x === y) {
    x = a.priority;
    y = b.priority;
  }

  return y - x;
}

/*
 * Expose
 */

module.exports = Miner;
