const pull = require('pull-stream');
const Multiaddr = require('multiaddr');
const PeerId = require('peer-id');
const PeerInfo = require('peer-info');
const lp = require('pull-length-prefixed');
const { decode } = require('../metadata/serialize');
const _ = require('lodash');
const { flatMap } = require('../common/util'); // eslint-disable-line no-unused-vars

// Flow signatures for pull-streams


/**
 * A through stream that accepts POJOs and encodes them with the given `protocol-buffers` schema
 * @param codec a `protocol-buffers` schema, containing an `encode` function
 * @returns a pull-stream through function that will output encoded protos, prefixed with thier varint-encoded size
 */
function protoStreamEncode(codec) {
  return pull(pull.map(codec.encode), lp.encode());
}

/**
 * A through-stream that accepts size-prefixed encoded protbufs, decodes with the given decoder function,
 * and emits the decoded POJOs.
 * @param codec a `protocol-buffers` schema, containing a `decode` function
 * @returns a through-stream function that can be wired into a pull-stream pipeline
 */
function protoStreamDecode(codec) {
  return pull(lp.decode(), pull.map(codec.decode));
}

/**
 * Convert a decoded LookupPeerResponse object into a libp2p PeerInfo object
 * @param resp a LookupPeerResponse protobuf, decoded into a POJO
 * @returns a libp2p PeerInfo object, or null if lookup failed
 */
function lookupResponseToPeerInfo(resp) {
  const peer = resp.peer;
  if (peer == null) return null;

  return peerInfoProtoUnmarshal(peer);
}

/**
 * Convert a decoded PeerInfo protobuf message into a libp2p PeerInfo object
 * @param pbPeer a PeerInfo protobuf message, decoded into a POJO
 * @returns {PeerInfo} a libp2p PeerInfo object
 */
function peerInfoProtoUnmarshal(pbPeer) {
  const peerId = PeerId.createFromB58String(pbPeer.id);
  const peerInfo = new PeerInfo(peerId);
  if (pbPeer.addr == null) {
    return peerInfo;
  }
  pbPeer.addr.forEach(addrBytes => {
    const addr = new Multiaddr(addrBytes);
    peerInfo.multiaddr.add(addr);
  });
  return peerInfo;
}

/**
 * Convert a libp2p PeerInfo object into a PeerInfo protobuf message POJO
 * @param peerInfo a libp2p PeerInfo
 * @returns a POJO that's encodable to a PeerInfo protobuf message
 */
function peerInfoProtoMarshal(peerInfo) {
  return {
    id: peerInfo.id.toB58String(),
    addr: peerInfo.multiaddrs.map(a => a.buffer)
  };
}

/**
 * Like a standard pull-stream `pull`, but returns a Promise that will contain the final value.
 * Use when you want a single value out of a stream, not for long-lived connections, etc.
 * @param streams a pull-stream pipeline of source + through streams.  Do not include a sink,
 *        since we're draining to Promise.resolve
 * @returns {Promise} a promise that will resolve to the first value that reaches the end of the pipeline.
 */
function pullToPromise(...streams) {
  return new Promise((resolve, reject) => {
    pull(...streams, pull.take(1), pull.collect((err, values) => {
      if (err) {
        return reject(err);
      }
      resolve(values.pop());
    }));
  });
}

/**
 * A pull-stream source that supplies `value` repeatedly, waiting at least `interval` milliseconds
 * between pulls.
 * @param value whatever you want to send
 * @param interval milliseconds to wait between providing value to consumers
 * @returns a pull-stream source
 */
function pullRepeatedly(value, interval = 1000) {
  let intervalStart = null;
  let timeoutId = null;
  function intervalElapsed() {
    return intervalStart == null || new Date().getTime() - intervalStart >= interval;
  }

  return function send(end, cb) {
    if (end) {
      if (timeoutId != null) {
        clearTimeout(timeoutId);
      }
      return cb(end);
    }

    if (intervalElapsed()) {
      intervalStart = new Date();
      cb(null, value);
      return;
    }
    if (intervalStart == null) intervalStart = new Date();
    const elapsedTime = new Date().getTime() - intervalStart;
    timeoutId = setTimeout(send, interval - elapsedTime, end, cb);
  };
}

/**
 * A pull-stream through stream that will end the stream when it receives a StreamEnd message,
 * and end the stream with an Error object if it receives a StreamError message.  Without this,
 * you need to explicitly `pull.take(n)` from the result stream, or it will never terminate.
 */

const resultStreamThrough = read => {
  return (end, callback) => {
    if (end) return callback(end, null);

    return read(end, (end, data) => {
      if (data == null) return callback(end, null);

      if (data.end !== undefined) {
        return callback(true, data);
      }

      if (data.error !== undefined) {
        const message = data.error.error || 'Unknown error';
        return callback(new Error(message), data);
      }

      return callback(end, data);
    });
  };
};

function valuesFromQueryResult(result) {
  let values = [];

  const simpleResultValue = _.get(result, 'simple');
  if (simpleResultValue != null) {
    values = [simpleResultValue];
  } else {
    const compoundResultBodies = _.get(result, 'compound.body');
    if (compoundResultBodies != null) {
      values = compoundResultBodies.map(b => b.value);
    }
  }
  return values;
}

function statementsFromQueryResult(result) {
  return valuesFromQueryResult(result).map(v => _.get(v, 'stmt')).filter(stmt => stmt != null);
}

function objectIdsFromStatement(stmt) {
  let simpleStatements = [];
  if (stmt.body.simple !== undefined) {
    const simpleStmt = stmt.body.simple;
    simpleStatements = [simpleStmt];
  }
  if (stmt.body.compound !== undefined) {
    const compoundStmt = stmt.body.compound;
    simpleStatements = compoundStmt.body;
  }
  return simpleStatements.map(s => s.object);
}

function objectIdsForQueryResult(result) {
  const statements = statementsFromQueryResult(result);
  return flatMap(statements, objectIdsFromStatement);
}

function expandQueryResult(result, dataObjects) {
  const objectMap = {};

  // convert the array of key/value pairs into a map, attempting to
  // deserialize the raw data Buffer for each object as CBOR along the way
  // ignore decoding errors in case there's non-cbor data
  for (const obj of dataObjects) {
    let val = obj.data;
    try {
      val = decode(obj.data);
    } catch (err) {}
    objectMap[obj.key] = val;
  }

  // for any nested object in `result`, look for keys named "object" whose values
  // are object ids contained in our `objectMap`.  Replace the object id
  // with a new object of this shape:
  // {
  //   key: originalObjectId,
  //   data: decodedObjectOrBuffer
  // }
  function replacer(value) {
    const key = _.get(value, 'object');
    const data = objectMap[key];
    if (data != null) {
      return _.set(value, 'object', { key, data });
    }
  }

  return _.cloneDeepWith(result, replacer);
}

module.exports = {
  protoStreamEncode,
  protoStreamDecode,
  lookupResponseToPeerInfo,
  peerInfoProtoUnmarshal,
  peerInfoProtoMarshal,
  pullToPromise,
  pullRepeatedly,
  resultStreamThrough,
  statementsFromQueryResult,
  objectIdsFromStatement,
  objectIdsForQueryResult,
  expandQueryResult
};