import { BitField } from '@node-dlc/common';
import { ILogger } from '@node-dlc/logger';
import * as noise from '@node-dlc/noise';
import { NoiseSocket } from '@node-dlc/noise';
import assert from 'assert';
import { Readable } from 'stream';

import { InitFeatureFlags } from './flags/InitFeatureFlags';
import * as MessageFactory from './MessageFactory';
import { InitMessage } from './messages/InitMessage';
import { IWireMessage } from './messages/IWireMessage';
import { PeerState } from './PeerState';
import { PingPongState } from './PingPongState';

export interface IMessageSender {
  send(buf: Buffer): void;
  sendMessage(msg: IWireMessage): void;
}

export interface IMessageReceiver {
  read(): IWireMessage;
  on(event: 'readable', listener: () => void): this;
  off(event: 'readable', listener: () => void): this;
}

export type IMessageSenderReceiver = IMessageSender & IMessageReceiver;

export interface IPeer extends IMessageSenderReceiver {
  send(buf: Buffer): void;
  sendMessage(msg: IWireMessage): void;
  disconnect(): void;

  read(): IWireMessage;
  on(event: 'readable', listener: () => void): this;
  on(event: 'error', listener: (err: Error) => void): this;

  off(event: 'readable', listener: () => void): this;
  off(event: 'error', listener: (err: Error) => void): this;
}

/**
 * Peer is an EventEmitter that layers the Lightning Network wire
 * protocol ontop of an @node-dlc/noise NoiseSocket.
 *
 * Peer itself is a state-machine with three states:
 * 1. pending
 * 2. awaiting_peer_init
 * 3. ready
 *
 * The Peer instance starts in `pending` until the underlying NoiseSocket
 * has connected.
 *
 * It then immediately sends the InitMessage as specified in the Peer
 * constructor.
 *
 * At this point, the Peer transitions to `awaiting_peer_init`.
 *
 * Once the remote peer has sent its InitMessage, the state is
 * transitioned to `ready` and the Peer can be begin sending and
 * receiving messages.
 *
 * Once the peer is in the `ready` state it will begin emitting `message`
 * events when it receives new messages from the peer.
 *
 * The Peer will also start a PingPong state machine to manage sending
 * and receiving Pings and Pongs as defined in BOLT01
 *
 * A choice (probably wrongly) was made to make Peer an EventEmitter
 * instead of a DuplexStream operating in object mode. We need to keep
 * the noise socket in flowing mode (instead of paused) because we will
 * not know the length of messages until after we have deserialized the
 * message. This makes it a challenge to implement a DuplexStream that
 * emits objects (such as messages).
 *
 * @emits ready the underlying socket has performed its handshake and
 * initialization message swap has occurred.
 *
 * @emits message a new message has been received. Only sent after the
 * `ready` event has fired.
 *
 * @emits rawmessage outputs the message as a raw buffer instead of
 * a deserialized message.
 *
 * @emits error emitted when there is an error processing a message.
 * The underlying socket will be closed after this event is emitted.
 *
 * @emits close emitted when the connection to the peer has completedly
 * closed.
 *
 * @emits open emmited when the connection to the peer has been established
 * after the handshake has been performed
 *
 * @emits end emitted when the connection to the peer is ending.
 */
export class Peer extends Readable implements IPeer {
  public static states = PeerState;

  public state: PeerState = PeerState.Disconnected;
  public socket: NoiseSocket;
  public messageCounter = 0;
  public pingPongState: PingPongState;
  public logger: ILogger;
  public remoteFeatures: BitField<InitFeatureFlags>;
  public remoteChains: Buffer[];
  public isInitiator = false;
  public reconnectTimeoutMs = 15000;

  private _id: string;
  private _rpk: Buffer;

  private _host: string;
  private _port: number;

  private _reconnectHandle: NodeJS.Timeout;

  constructor(
    readonly ls: Buffer,
    readonly localFeatures: BitField<InitFeatureFlags>,
    readonly localChains: Buffer[],
    logger: ILogger,
    readonly highWaterMark: number = 2048,
  ) {
    super({ objectMode: true, highWaterMark });

    this.pingPongState = new PingPongState(this);
    this.logger = logger;
  }

  public get id(): string {
    return this._id;
  }

  public get pubkey(): Buffer {
    return this._rpk;
  }

  public get pubkeyHex(): string {
    return this._rpk ? this._rpk.toString('hex') : undefined;
  }

  /**
   * Connect to the remote peer and binds socket events into the Peer.
   */
  public connect(rpk: Buffer, host: string, port: number) {
    // construct logger specific to the peer
    this._rpk = rpk;
    this._id = this._rpk.slice(0, 8).toString('hex');
    this.logger = this.logger.sub('peer', this._id);

    this.logger.info('connecting to peer');
    this.isInitiator = true;

    // store these values if we need to use them for a reconnection event.
    this._host = host;
    this._port = port;

    // create the socket and initiate the connection
    this.socket = noise.connect({
      ls: this.ls,
      host: this._host,
      port: this._port,
      rpk: this._rpk,
      logger: this.logger,
    });
    this.socket.on('readable', this._onSocketReadable.bind(this));
    this.socket.on('ready', this._onSocketReady.bind(this));
    this.socket.on('close', this._onSocketClose.bind(this));
    this.socket.on('error', this._onSocketError.bind(this));
  }

  /**
   *
   * @param socket
   */
  public attach(socket: NoiseSocket) {
    this.isInitiator = false;
    this.socket = socket;
    this.socket.on('readable', this._onSocketReadable.bind(this));
    this.socket.on('ready', this._onSocketReady.bind(this));
    this.socket.on('close', this._onSocketClose.bind(this));
    this.socket.on('error', this._onSocketError.bind(this));

    // Once the socket is ready, we have performed the handshake which allows
    // us to ascertain the identity of the connecting node. Now that we have
    // that identity ascertained we can construct the instance specific logger
    this.socket.once('ready', () => {
      this._rpk = socket.rpk;
      this._id = this._rpk.slice(0, 8).toString('hex');
      this.logger = this.logger.sub('peer', this._id);
      this.logger.info('peer connected');
    });
  }

  /**
   * Writes data on the NoiseSocket. This method allows custom
   * serialization of methods. Use `sendMessage` to send a message
   * using the default message serialization.
   * @param buf
   */
  public send(buf: Buffer): boolean {
    assert.ok(this.state === PeerState.Ready, new Error('Peer is not ready'));
    this.emit('sending', buf);
    return this.socket.write(buf);
  }

  /**
   * Writes the message on the NoiseSocket using the default
   * serialization properties
   */
  public sendMessage(m: IWireMessage): boolean {
    assert.ok(this.state === PeerState.Ready, new Error('Peer is not ready'));
    const buf = m.serialize() as Buffer;
    this.emit('sending', buf);
    return this.socket.write(buf);
  }

  /**
   * Closes the socket
   */
  public disconnect() {
    this.logger.info('disconnecting');
    this.state = PeerState.Disconnecting;
    this.socket.end();
  }

  /**
   * Reconnects the socket
   */
  public reconnect() {
    this.socket.end();
  }

  /////////////////////////////////////////////////////////

  private _onSocketReady() {
    // now that we're connected, we need to wait for the remote reply
    // before any other messages can be receieved or sent
    this.state = PeerState.AwaitingPeerInit;
    this.logger.debug('state: awaiting_peer_init');

    // blast off our init message
    this.emit('open');
    this._sendInitMessage();
  }

  private _onSocketClose() {
    this.logger.debug('socket closed');

    // Clear any existing reconnection handles. We want the logic in
    // this method, and the current state of the peer to dictate
    // what should happen.
    clearTimeout(this._reconnectHandle);

    // Clear the ping/pong status
    if (this.pingPongState) this.pingPongState.onDisconnecting();

    // If socket closed because there was a request to disconnect
    // the underlying socket, we will emit a close event and mark
    // the state as disconnected.
    if (this.state === PeerState.Disconnecting) {
      this.emit('close');
      this.state = PeerState.Disconnected;
      this.logger.debug('permanently disconnected');
      return;
    }

    // Update the state to indicate that the peer is no longer connected.
    // If we don't do this then upon reconnection the Peer will be in
    // invalid state and will not send the initialization message.
    this.state = PeerState.Disconnected;
    this.logger.debug('state: disconnected');

    // If the disconnection was not intentional, we will initiate
    // a reconnection event by creating a reconnection handle
    // and delaying the connection event by the reconnectTimeoutMs
    // value.
    // This is likely to originate from two sources:
    // 1) A reconnection was requeted by the ping/pong manager because
    //    receipt of a pong message timed out
    // 2) A network error occurred on a subsequent connection event
    //    which triggered a socket close event. This can happen if a connection
    //    is disrupted for a longer period of time. The reconnection logic
    //    should continue to fire until a connection can be established.
    if (this.isInitiator) {
      this.logger.debug(
        `reconnecting in ${(this.reconnectTimeoutMs / 1000).toFixed(1)}s`,
      );
      this._reconnectHandle = setTimeout(() => {
        this.connect(this._rpk, this._host, this._port);
      }, this.reconnectTimeoutMs);
    }
  }

  private _onSocketError(err) {
    // emit what error we recieved
    this.emit('error', err);
  }

  public _onSocketReadable() {
    this.logger.trace('socket is readable');
    try {
      let cont = true;
      while (cont) {
        const raw: Buffer = this.socket.read() as Buffer;
        if (!raw) return;

        if (this.state === PeerState.AwaitingPeerInit) {
          this._processPeerInitMessage(raw);
        } else {
          const m = this._processMessage(raw);
          cont = this.push(m);
        }
      }
    } catch (err) {
      // we have a problem, kill connectinon with the client
      this.logger.error(err);
      this.socket.end();
      this.emit('error', err);
      return;
    }
  }

  public _read() {
    // Trigger a read but wait until the end of the event loop.
    // This is necessary when reading in paused mode where
    // _read was triggered by stream.read() originating inside
    // a "readable" event handler. Attempting to push more data
    // synchronously will not trigger another "readable" event.
    setImmediate(() => this._onSocketReadable());
  }

  /**
   * Sends the initialization message to the peer. This message
   * does not matter if it is sent before or after the peer sends
   * there message.
   */
  private _sendInitMessage() {
    // construct the init message
    const msg = new InitMessage();
    msg.features = this.localFeatures;
    msg.chainHashes = this.localChains;

    // fire off the init message to the peer
    const payload = msg.serialize();
    this.emit('sending', payload);
    this.socket.write(payload);
    this.logger.debug('init message sent');
  }

  /**
   * Processes the initialization message sent by the remote peer.
   * Once this is successfully completed, the state is transitioned
   * to `active`
   */
  private _processPeerInitMessage(raw: Buffer) {
    // deserialize message
    const m = MessageFactory.deserialize(raw) as InitMessage;
    if (this.logger) {
      const features: InitFeatureFlags[] = m.features.flags();
      this.logger.info('peer initialized with features', features);
    }

    // ensure we got an InitMessagee
    assert.ok(m instanceof InitMessage, new Error('Expecting InitMessage'));

    // store the init messagee in case we need to refer to it
    this.remoteFeatures = m.features;

    // capture the local chains
    this.remoteChains = m.chainHashes;

    // validate remote chains and if we are unawares of any. We do this by
    // first looking to see if there is any match on the chains. If there is
    // no match and both the remote and our local node declare that we are
    // monitoring a specific chain then we will abort the connnection
    let hasChain = false;
    for (const remoteChain of this.remoteChains) {
      for (const localChain of this.localChains) {
        if (remoteChain.equals(localChain)) hasChain = true;
      }
    }
    if (!hasChain && this.remoteChains.length && this.localChains.length) {
      this.logger.trace('remote chains', ...this.remoteChains);
      this.logger.trace('local chains', ...this.localChains);
      this.logger.warn(
        'remote node does not support any known chains, aborting',
      );
      this.disconnect();
      return;
    }

    // we need to be sure that the remote node supports required features
    // that we care about. If the remote node does not support these feature
    // we will disconnect.
    for (const feature of this.localFeatures.flags()) {
      // we can skip odd features since they are optional
      if (feature % 2 === 1) continue;

      // for even (compulsory) features, we check if the remote node is
      // signalling either optional or compulsory support. This code
      // makes the assumption that the even features are always first
      if (
        !(
          this.remoteFeatures.isSet(feature) ||
          this.remoteFeatures.isSet(feature + 1)
        )
      ) {
        this.disconnect();
        return;
      }
    }

    // start other state now that peer is initialized
    this.pingPongState.start();

    // transition state to ready
    this.state = PeerState.Ready;

    // emit ready event
    this.emit('ready');
  }

  /**
   * Process the raw message sent by the peer. These messages are
   * processed after the initialization message has been received.
   */
  private _processMessage(raw: Buffer): IWireMessage {
    // increment counter first so we know exactly how many messages
    // have been received by the peer regardless of whether they
    // could be processed
    this.messageCounter += 1;

    // emit the rawmessage event first so that if there is a
    // deserialization problem there is a chance that we were
    // able to capture the raw message for further testing
    this.emit('rawmessage', raw);

    // deserialize the message
    const m = MessageFactory.deserialize(raw);

    // ensure pingpong state is updated
    if (m) {
      this.pingPongState.onMessage(m);
    }
    return m;
  }
}
