package li.cil.oc.server.component

import java.io.{BufferedWriter, FileNotFoundException, IOException, OutputStreamWriter}
import java.net._
import java.nio.ByteBuffer
import java.nio.channels.SocketChannel
import java.util.concurrent.{Callable, ExecutionException}

import li.cil.oc.api.Network
import li.cil.oc.api.network._
import li.cil.oc.common.component
import li.cil.oc.util.ExtendedNBT._
import li.cil.oc.util.ThreadPoolFactory
import li.cil.oc.{OpenComputers, Settings, api}
import net.minecraft.nbt.NBTTagCompound
import net.minecraft.server.MinecraftServer

import scala.collection.mutable

class InternetCard extends component.ManagedComponent {
  val node = Network.newNode(this, Visibility.Network).
    withComponent("internet", Visibility.Neighbors).
    create()

  val romInternet = Option(api.FileSystem.asManagedEnvironment(api.FileSystem.
    fromClass(OpenComputers.getClass, Settings.resourceDomain, "lua/component/internet"), "internet"))

  protected var owner: Option[Context] = None

  protected val connections = mutable.Map.empty[Int, InternetCard.Connection]

  private def newHandle() = Iterator.continually((Math.random() * Int.MaxValue).toInt + 1).
    filterNot(connections.contains).
    next()

  // ----------------------------------------------------------------------- //

  @Callback(direct = true, doc = """function():boolean -- Returns whether HTTP requests can be made (config setting).""")
  def isHttpEnabled(context: Context, args: Arguments): Array[AnyRef] = result(Settings.get.httpEnabled)

  @Callback(doc = """function(url:string[, postData:string]):boolean -- Starts an HTTP request. If this returns true, further results will be pushed using `http_response` signals.""")
  def request(context: Context, args: Arguments): Array[AnyRef] = this.synchronized {
    checkOwner(context)
    val address = args.checkString(0)
    if (!Settings.get.httpEnabled) {
      return result(Unit, "http requests are unavailable")
    }
    if (connections.size >= Settings.get.maxConnections) {
      throw new IOException("too many open connections")
    }
    val post = if (args.isString(1)) Option(args.checkString(1)) else None
    val handle = newHandle()
    connections += handle -> new InternetCard.Request(checkAddress(address), post)
    result(handle)
  }

  @Callback(direct = true, doc = """function():boolean -- Returns whether TCP connections can be made (config setting).""")
  def isTcpEnabled(context: Context, args: Arguments): Array[AnyRef] = result(Settings.get.httpEnabled)

  @Callback(doc = """function(address:string[, port:number]):number -- Opens a new TCP connection. Returns the handle of the connection.""")
  def connect(context: Context, args: Arguments): Array[AnyRef] = this.synchronized {
    checkOwner(context)
    val address = args.checkString(0)
    val port = if (args.count > 1) args.checkInteger(1) else -1
    if (!Settings.get.tcpEnabled) {
      return result(Unit, "tcp connections are unavailable")
    }
    if (connections.size >= Settings.get.maxConnections) {
      throw new IOException("too many open connections")
    }
    val uri = checkUri(address, port)
    val handle = newHandle()
    connections += handle -> new InternetCard.Socket(uri, port)
    result(handle)
  }

  @Callback(doc = """function(handle:number):boolean -- Ensures a socket is connected. Errors if the connection failed.""")
  def finishConnect(context: Context, args: Arguments): Array[AnyRef] = this.synchronized {
    checkOwner(context)
    val handle = args.checkInteger(0)
    connections.get(handle) match {
      case Some(connection) => result(connection.checkConnected())
      case _ => throw new IOException("bad connection descriptor")
    }
  }

  @Callback(direct = true, doc = """function(handle:number) -- Closes an open socket stream.""")
  def close(context: Context, args: Arguments): Array[AnyRef] = this.synchronized {
    checkOwner(context)
    val handle = args.checkInteger(0)
    connections.remove(handle) match {
      case Some(socket) => socket.close()
      case _ => throw new IllegalArgumentException("bad connection descriptor")
    }
    null
  }

  @Callback(doc = """function(handle:number, data:string):number -- Tries to write data to the socket stream. Returns the number of bytes written.""")
  def write(context: Context, args: Arguments): Array[AnyRef] = this.synchronized {
    checkOwner(context)
    val handle = args.checkInteger(0)
    val value = args.checkByteArray(1)
    connections.get(handle) match {
      case Some(connection) => result(connection.write(value))
      case _ => throw new IOException("bad connection descriptor")
    }
  }

  @Callback(doc = """function(handle:number[, n:number]):string -- Tries to read data from the socket stream. Returns the read byte array.""")
  def read(context: Context, args: Arguments): Array[AnyRef] = this.synchronized {
    checkOwner(context)
    val handle = args.checkInteger(0)
    val n = math.min(Settings.get.maxReadBuffer, math.max(0, if (args.count > 1) args.checkInteger(1) else Int.MaxValue))
    connections.get(handle) match {
      case Some(connection) => result(connection.read(n))
      case _ => throw new IOException("bad connection descriptor")
    }
  }

  private def checkOwner(context: Context) {
    if (owner.isEmpty || context.node != owner.get.node) {
      throw new IllegalArgumentException("can only be used by the owning computer")
    }
  }

  // ----------------------------------------------------------------------- //

  override def onConnect(node: Node) {
    super.onConnect(node)
    if (owner.isEmpty && node.host.isInstanceOf[Context] && node.isNeighborOf(this.node)) {
      owner = Some(node.host.asInstanceOf[Context])
      romInternet.foreach(fs => node.connect(fs.node))
    }
  }

  override def onDisconnect(node: Node) = this.synchronized {
    super.onDisconnect(node)
    if (owner.isDefined && (node == this.node || node.host.isInstanceOf[Context] && (node.host.asInstanceOf[Context] == owner.get))) {
      owner = None
      this.synchronized {
        for ((_, socket) <- connections) {
          socket.close()
        }
        connections.clear()
      }
      romInternet.foreach(_.node.remove())
    }
  }

  override def onMessage(message: Message) = this.synchronized {
    super.onMessage(message)
    message.data match {
      case Array() if (message.name == "computer.stopped" || message.name == "computer.started") && owner.isDefined && message.source.address == owner.get.node.address =>
        this.synchronized {
          connections.values.foreach(_.close())
          connections.clear()
        }
      case _ =>
    }
  }

  // ----------------------------------------------------------------------- //

  override def load(nbt: NBTTagCompound) {
    super.load(nbt)
    romInternet.foreach(_.load(nbt.getCompoundTag("romInternet")))
  }

  override def save(nbt: NBTTagCompound) {
    super.save(nbt)
    romInternet.foreach(fs => nbt.setNewCompoundTag("romInternet", fs.save))
  }

  // ----------------------------------------------------------------------- //

  private def checkUri(address: String, port: Int): URI = {
    try {
      val parsed = new URI(address)
      if (parsed.getHost != null && (parsed.getPort > 0 || port > 0)) {
        return parsed
      }
    }
    catch {
      case _: Throwable =>
    }

    val simple = new URI("oc://" + address)
    if (simple.getHost != null) {
      if (simple.getPort > 0)
        return simple
      else if (port > 0)
        return new URI(simple.toString + ":" + port)
    }

    throw new IllegalArgumentException("address could not be parsed or no valid port given")
  }

  private def checkAddress(address: String) = {
    val url = try new URL(address)
    catch {
      case e: Throwable => throw new FileNotFoundException("invalid address")
    }
    val protocol = url.getProtocol
    if (!protocol.matches("^https?$")) {
      throw new FileNotFoundException("unsupported protocol")
    }
    url
  }
}

object InternetCard {
  private val threadPool = ThreadPoolFactory.create("Internet", Settings.get.internetThreads)

  def checkLists(inetAddress: InetAddress, host: String) {
    if (Settings.get.httpHostWhitelist.length > 0 && !Settings.get.httpHostWhitelist.exists(_(inetAddress, host))) {
      throw new FileNotFoundException("address is not whitelisted")
    }
    if (Settings.get.httpHostBlacklist.length > 0 && Settings.get.httpHostBlacklist.exists(_(inetAddress, host))) {
      throw new FileNotFoundException("address is blacklisted")
    }
  }

  trait Connection {
    def close()

    def read(n: Int): Array[Byte]

    def write(value: Array[Byte]): Int

    def checkConnected(): Boolean
  }

  class Socket(val uri: URI, val port: Int) extends Connection {
    val address = threadPool.submit(new Callable[InetAddress] {
      override def call() = {
        val resolved = InetAddress.getByName(uri.getHost)
        checkLists(resolved, uri.getHost)
        resolved
      }
    })
    val channel = SocketChannel.open()
    channel.configureBlocking(false)
    var isConnecting = false

    def close() {
      channel.close()
    }

    def read(n: Int) = {
      if (checkConnected()) {
        val buffer = ByteBuffer.allocate(n)
        val read = channel.read(buffer)
        if (read == -1) null
        else buffer.array.view(0, read).toArray
      }
      else Array.empty[Byte]
    }

    def write(value: Array[Byte]) = {
      if (checkConnected()) channel.write(ByteBuffer.wrap(value))
      else 0
    }

    def checkConnected() = {
      if (isConnecting) channel.finishConnect()
      else if (address.isCancelled) {
        // I don't think this can ever happen, Justin Case.
        close()
        throw new IOException("bad connection descriptor")
      }
      else if (address.isDone) try {
        val socketAddress = new InetSocketAddress(address.get, if (uri.getPort != -1) uri.getPort else port)
        channel.connect(socketAddress)
        isConnecting = true
        false
      }
      catch {
        case e: ExecutionException => throw e.getCause
      }
      else false
    }
  }

  class Request(val url: URL, val post: Option[String]) extends Connection with Runnable {
    private var data: Option[Array[Byte]] = None
    private var error: Option[String] = None

    // Perform actual request in a separate thread.
    InternetCard.threadPool.submit(this)

    override def close() {}

    override def read(n: Int) = this.synchronized {
      if (checkConnected()) {
        val buffer = data.get
        if (buffer.length == 0) null
        else {
          val count = math.min(n, buffer.length)
          val result = buffer.take(count)
          data = Option(buffer.drop(count))
          result
        }
      }
      else Array()
    }

    override def write(value: Array[Byte]) = throw new IOException("unsupported operation")

    override def checkConnected() = this.synchronized {
      if (data.isDefined) true
      else error match {
        case Some(reason) => throw new IOException(reason)
        case _ => false
      }
    }

    override def run() = try {
      checkLists(InetAddress.getByName(url.getHost), url.getHost)
      val proxy = Option(MinecraftServer.getServer.getServerProxy).getOrElse(java.net.Proxy.NO_PROXY)
      url.openConnection(proxy) match {
        case http: HttpURLConnection => try {
          http.setDoInput(true)
          if (post.isDefined) {
            http.setRequestMethod("POST")
            http.setDoOutput(true)
            http.setReadTimeout(Settings.get.httpTimeout)

            val out = new BufferedWriter(new OutputStreamWriter(http.getOutputStream))
            out.write(post.get)
            out.close()
          }
          else {
            http.setRequestMethod("GET")
            http.setDoOutput(false)
          }

          val input = http.getInputStream
          val data = mutable.ArrayBuffer.empty[Byte]
          val buffer = Array.fill[Byte](Settings.get.maxNetworkPacketSize)(0)
          var count = 0
          do {
            count = input.read(buffer)
            if (count > 0) {
              data ++= buffer.take(count)
            }
          } while (count != -1 && data.length < Settings.get.httpMaxDownloadSize)
          input.close()
          this.synchronized {
            Request.this.data = Some(data.toArray)
          }
        }
        finally {
          http.disconnect()
        }
        case other => this.synchronized(error = Some("connection failed"))
      }
    }
    catch {
      case e: FileNotFoundException =>
        this.synchronized(error = Some("not found: " + Option(e.getMessage).getOrElse(e.toString)))
      case e: UnknownHostException =>
        this.synchronized(error = Some("unknown host: " + Option(e.getMessage).getOrElse(e.toString)))
      case _: SocketTimeoutException =>
        this.synchronized(error = Some("timeout"))
      case e: Throwable =>
        this.synchronized(error = Some(Option(e.getMessage).getOrElse(e.toString)))
    }
  }

}