when not(compileOption("threads")):
  {.fatal: "Please, compile this program with the --threads:on option!".}

import
  strformat, strutils,
  stew/byteutils,
  chronos,
  libp2p

const DefaultAddr = "/ip4/127.0.0.1/tcp/0"

const Help = """
  Commands: /[?|help|connect|disconnect|exit]
  help: Prints this help
  connect: dials a remote peer
  disconnect: ends current session
  exit: closes the chat
"""

type
  Chat = ref object
    switch: Switch          # a single entry point for dialing and listening to peer
    stdinReader: StreamTransport # transport streams between read & write file descriptor
    conn: Connection        # connection to the other peer
    connected: bool         # if the node is connected to another peer

##
# Stdout helpers, to write the prompt
##
proc writePrompt(c: Chat) =
  if c.connected:
    stdout.write '\r' & $c.switch.peerInfo.peerId & ": "
    stdout.flushFile()

proc writeStdout(c: Chat, str: string) =
  echo '\r' & str
  c.writePrompt()

##
# Chat Protocol
##
const ChatCodec = "/nim-libp2p/chat/1.0.0"

type
  ChatProto = ref object of LPProtocol

proc new(T: typedesc[ChatProto], c: Chat): T =
  let chatproto = T()

  # create handler for incoming connection
  proc handle(stream: Connection, proto: string) {.async.} =
    if c.connected and not c.conn.closed:
      c.writeStdout "a chat session is already in progress - refusing incoming peer!"
      await stream.close()
    else:
      await c.handlePeer(stream)
      await stream.close()

  # assign the new handler
  chatproto.handler = handle
  chatproto.codec = ChatCodec
  return chatproto

##
# Chat application
##
proc handlePeer(c: Chat, conn: Connection) {.async.} =
  # Handle a peer (incoming or outgoing)
  try:
    c.conn = conn
    c.connected = true
    c.writeStdout $conn.peerId & " connected"

    # Read loop
    while true:
      let
        strData = await conn.readLp(1024)
        str = string.fromBytes(strData)
      c.writeStdout $conn.peerId & ": " & $str

  except LPStreamEOFError:
    defer: c.writeStdout $conn.peerId & " disconnected"
    await c.conn.close()
    c.connected = false

proc dialPeer(c: Chat, address: string) {.async.} =
  # Parse and dial address
  let
    multiAddr = MultiAddress.init(address).tryGet()
    # split the peerId part /p2p/...
    peerIdBytes = multiAddr[multiCodec("p2p")]
      .tryGet()
      .protoAddress()
      .tryGet()
    remotePeer = PeerId.init(peerIdBytes).tryGet()
    # split the wire address
    ip4Addr = multiAddr[multiCodec("ip4")].tryGet()
    tcpAddr = multiAddr[multiCodec("tcp")].tryGet()
    wireAddr = ip4Addr & tcpAddr

  echo &"dialing peer: {multiAddr}"
  asyncSpawn c.handlePeer(await c.switch.dial(remotePeer, @[wireAddr], ChatCodec))

proc readLoop(c: Chat) {.async.} =
  while true:
    if not c.connected:
      echo "type an address or wait for a connection:"
      echo "type /[help|?] for help"

    c.writePrompt()

    let line = await c.stdinReader.readLine()
    if line.startsWith("/help") or line.startsWith("/?"):
      echo Help
      continue

    if line.startsWith("/disconnect"):
      c.writeStdout "Ending current session"
      if c.connected and c.conn.closed.not:
        await c.conn.close()
      c.connected = false
    elif line.startsWith("/connect"):
      c.writeStdout "enter address of remote peer"
      let address = await c.stdinReader.readLine()
      if address.len > 0:
        await c.dialPeer(address)

    elif line.startsWith("/exit"):
      if c.connected and c.conn.closed.not:
        await c.conn.close()
        c.connected = false

      await c.switch.stop()
      c.writeStdout "quitting..."
      return
    else:
      if c.connected:
        await c.conn.writeLp(line)
      else:
        try:
          if line.startsWith("/") and "p2p" in line:
            await c.dialPeer(line)
        except CatchableError as exc:
          echo &"unable to dial remote peer {line}"
          echo exc.msg

proc readInput(wfd: AsyncFD) {.thread.} =
  ## This thread performs reading from `stdin` and sends data over
  ## pipe to main thread.
  let transp = fromPipe(wfd)

  while true:
    let line = stdin.readLine()
    discard waitFor transp.write(line & "\r\n")

proc main() {.async.} =
  let
    rng = newRng() # Single random number source for the whole application

    # Pipe to read stdin from main thread
    (rfd, wfd) = createAsyncPipe()
    stdinReader = fromPipe(rfd)

  var thread: Thread[AsyncFD]
  try:
    thread.createThread(readInput, wfd)
  except Exception as exc:
    quit("Failed to create thread: " & exc.msg)

  var localAddress = MultiAddress.init(DefaultAddr).tryGet()

  var switch = SwitchBuilder
    .new()
    .withRng(rng)       # Give the application RNG
    .withAddress(localAddress)
    .withTcpTransport() # Use TCP as transport
    .withMplex()        # Use Mplex as muxer
    .withNoise()        # Use Noise as secure manager
    .build()

  let chat = Chat(
    switch: switch,
    stdinReader: stdinReader)

  switch.mount(ChatProto.new(chat))

  await switch.start()

  let id = $switch.peerInfo.peerId
  echo "PeerId: " & id
  echo "listening on: "
  for a in switch.peerInfo.addrs:
    echo &"{a}/p2p/{id}"

  await chat.readLoop()
  quit(0)

waitFor(main())