const Swarm = require('libp2p-swarm');
const TCP = require('libp2p-tcp');
// const UTP = require('libp2p-utp')
const WS = require('libp2p-websockets');
const spdy = require('libp2p-spdy');
const secio = require('libp2p-secio');
const PeerId = require('peer-id');
const PeerInfo = require('peer-info');
const PeerBook = require('peer-book');
const multiaddr = require('multiaddr');
const Ping = require('libp2p-ping');
const mafmt = require('mafmt');
const Abortable = require('pull-abortable');
const { promiseTimeout } = require('../common/util');

const OFFLINE_ERROR_MESSAGE = 'The libp2p node is not started yet';
const IPFS_CODE = 421;
const DEFAULT_DIAL_TIMEOUT = 10000;

class P2PNode {

  constructor(options) {
    let { peerInfo, peerBook, disableSecureIO, dialTimeout } = options;
    this.isOnline = false;
    this.dialTimeout = dialTimeout != null ? dialTimeout : DEFAULT_DIAL_TIMEOUT;

    if (!peerBook) peerBook = new PeerBook();

    if (!disableSecureIO) disableSecureIO = false;

    this.peerInfo = peerInfo;
    this.peerBook = peerBook;

    // Swarm
    this.swarm = new Swarm(peerInfo);
    this.swarm.connection.addStreamMuxer(spdy);
    this.swarm.connection.reuse();

    this.setSecureIOEnabled(!disableSecureIO);

    this.swarm.on('peer-mux-established', peerInfo => {
      this.peerBook.put(peerInfo);
    });

    this.swarm.on('peer-mux-closed', peerInfo => {
      this.peerBook.removeByB58String(peerInfo.id.toB58String());
    });

    Ping.mount(this.swarm);

    this.abortables = new Set();
  }

  newAbortable() {
    const abortable = Abortable();
    this.abortables.add(abortable);
    return abortable;
  }

  start() {
    if (this.isOnline) return Promise.resolve();

    const ws = new WS();
    const tcp = new TCP();

    // Do not activate the dialer if no listener is going to be present
    if (ws.filter(this.peerInfo.multiaddrs).length > 0) {
      this.swarm.transport.add('ws', new WS());
    }
    if (tcp.filter(this.peerInfo.multiaddrs).length > 0) {
      this.swarm.transport.add('tcp', new TCP());
    }

    return new Promise((resolve, reject) => {
      this.swarm.listen(err => {
        if (err) {
          return reject(err);
        }

        this.isOnline = true;
        resolve();
      });
    });
  }

  stop() {
    if (!this.isOnline) return Promise.resolve();

    this.isOnline = false;
    return new Promise(resolve => {
      // abort any ongoing pull-stream connections
      this.abortables.forEach(a => {
        a.abort();
      });
      this.abortables.clear();

      this.swarm.close(resolve);
    });
  }

  setSecureIOEnabled(use = true) {
    if (use) {
      this.swarm.connection.crypto(secio.tag, secio.encrypt);
    } else {
      this.swarm.connection.crypto();
    }
  }

  dialById(id, protocol) {
    if (!this.isOnline) {
      return Promise.reject(new Error(OFFLINE_ERROR_MESSAGE));
    }
    // NOTE, these dialById only works if a previous dial
    // was made until we have PeerRouting
    // TODO support PeerRouting when it is Ready
    return Promise.reject(new Error('not implemented yet'));
  }

  dialByMultiaddr(maddr, protocol) {
    if (!this.isOnline) {
      return Promise.reject(new Error(OFFLINE_ERROR_MESSAGE));
    }

    if (typeof maddr === 'string') {
      maddr = multiaddr(maddr);
    }

    if (!mafmt.IPFS.matches(maddr.toString())) {
      return Promise.reject(new Error('multiaddr not valid'));
    }

    const ipfsIdB58String = maddr.stringTuples().filter(tuple => {
      if (tuple[0] === IPFS_CODE) {
        return true;
      }
    })[0][1];

    let peer;
    try {
      peer = this.peerBook.getByB58String(ipfsIdB58String);
    } catch (err) {
      peer = new PeerInfo(PeerId.createFromB58String(ipfsIdB58String));
    }

    peer.multiaddr.add(maddr);
    return this.dialByPeerInfo(peer, protocol);
  }

  dialByPeerInfo(peer, protocol) {
    if (!this.isOnline) {
      return Promise.reject(new Error(OFFLINE_ERROR_MESSAGE));
    }

    const dialPromise = new Promise((resolve, reject) => {
      this.swarm.dial(peer, protocol, (err, conn) => {
        if (err) {
          return reject(err);
        }
        this.peerBook.put(peer);
        resolve(conn);
      });
    });

    return promiseTimeout(this.dialTimeout, dialPromise);
  }

  hangUpById(id) {
    return Promise.reject(new Error('not implemented yet'));
    // TODO
  }

  hangUpByMultiaddr(maddr) {
    if (!this.isOnline) {
      return Promise.reject(new Error(OFFLINE_ERROR_MESSAGE));
    }

    if (typeof maddr === 'string') {
      maddr = multiaddr(maddr);
    }

    if (!mafmt.IPFS.matches(maddr.toString())) {
      return Promise.reject(new Error('multiaddr not valid'));
    }

    const ipfsIdB58String = maddr.stringTuples().filter(tuple => {
      if (tuple[0] === IPFS_CODE) {
        return true;
      }
    })[0][1];

    try {
      const peerInfo = this.peerBook.getByB58String(ipfsIdB58String);
      return this.hangUpByPeerInfo(peerInfo);
    } catch (err) {
      // already disconnected
      return Promise.resolve();
    }
  }

  hangUpByPeerInfo(peer) {
    if (!this.isOnline) {
      return Promise.reject(new Error(OFFLINE_ERROR_MESSAGE));
    }

    this.peerBook.removeByB58String(peer.id.toB58String());
    return new Promise(resolve => {
      this.swarm.hangUp(peer, resolve);
    });
  }

  handle(protocol, handler) {
    return this.swarm.handle(protocol, handler);
  }

  unhandle(protocol) {
    return this.swarm.unhandle(protocol);
  }

  ping(peer) {
    const pingPromise = new Promise((resolve, reject) => {
      const p = new Ping(this.swarm, peer);
      p.on('error', err => {
        p.stop();
        reject(err);
      });

      p.on('ping', latency => {
        p.stop();
        resolve(latency);
      });
    });
    return promiseTimeout(this.dialTimeout, pingPromise);
  }
}

module.exports = P2PNode;