package ocelot.desktop.entity

import ocelot.desktop.audio.SoundSamples.Format
import ocelot.desktop.audio.{Audio, SoundCategory, SoundSamples, SoundSource}
import ocelot.desktop.color.IntColor
import ocelot.desktop.util.Logging
import org.lwjgl.BufferUtils
import totoro.ocelot.brain.entity.machine.{Arguments, Callback, Context}
import totoro.ocelot.brain.entity.traits.{DeviceInfo, Entity, Environment}
import totoro.ocelot.brain.nbt.NBTTagCompound
import totoro.ocelot.brain.network.{Component, Network, Visibility}
import totoro.ocelot.brain.util.ResultWrapper.result
import totoro.ocelot.brain.workspace.Workspace

import java.io.BufferedInputStream
import java.net.{HttpURLConnection, URI}
import java.nio.{ByteBuffer, ByteOrder}
import javax.sound.sampled.AudioFormat.Encoding
import javax.sound.sampled.{AudioFormat, AudioSystem}

class OpenFMRadio extends Entity with Environment with DeviceInfo with Logging {
  override val node: Component = {
    Network
      .newNode(this, Visibility.Network)
      .withComponent("openfm_radio", Visibility.Network)
      .create()
  }

  // --------------------------- URL ---------------------------

  var url: Option[String] = None

  @Callback()
  def setURL(context: Context, args: Arguments): Array[AnyRef] = {
    url = Option(args.checkString(0))

    result(true)
  }

  // --------------------------- Playback ---------------------------

  private var playbackThread: Option[Thread] = None
  private var playbackSoundSource: Option[SoundSource] = None

  private def playSynchronously(): Unit = {
    try {
      // Trying to parse URL and sending request to host
      val connection = {
        new URI(url.get)
          .toURL
          .openConnection
          .asInstanceOf[HttpURLConnection]
      }

      connection.setRequestMethod("GET")
      connection.setRequestProperty(
        "User-Agent",
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36",
      )
      connection.setRequestProperty("Content-Language", "en-US")
      connection.setDoInput(true)
      connection.setDoOutput(true)

      // Wrapping first audio stream to another one with hardcoded format
      val inputStream = AudioSystem.getAudioInputStream(
        new AudioFormat(
          Encoding.PCM_SIGNED,
          44100,
          16,
          2,
          4,
          44100,
          false,
        ),
        // Obtaining audio input stream from HTTP connection
        AudioSystem.getAudioInputStream(new BufferedInputStream(connection.getInputStream)),
      )

      // Keeping input stream format parameters here to offload the reading loop
      val inputStreamFormat = inputStream.getFormat
      val inputStreamSampleRate = inputStreamFormat.getSampleRate.toInt

      val inputStreamSoundSampleFormat = {
        if (inputStreamFormat.getChannels > 1)
          Format.Stereo16
        else
          Format.Mono16
      }

      // Creating Ocelot output sound stream
      val (outputStream, outputSource) = Audio.newStream(SoundCategory.Records, volume = volume)
      playbackSoundSource = Option(outputSource)

      // Reading chunks from input stream and passing them to output
      val buffer = new Array[Byte](65536)
      var bytesRead = 0

      while (!Thread.currentThread().isInterrupted && bytesRead != -1) {
        if (bytesRead > 0) {
          outputStream.enqueue(new SoundSamples(
            BufferUtils
              .createByteBuffer(bytesRead)
              .order(ByteOrder.LITTLE_ENDIAN)
              .put(buffer, 0, bytesRead)
              .flip
              .asInstanceOf[ByteBuffer],
            inputStreamSampleRate,
            inputStreamSoundSampleFormat,
          ))
        }

        bytesRead = inputStream.read(buffer, 0, buffer.length)
      }

      logger.info("OpenFM input audio stream has reached EOF, closing thread")
    } catch {
      case _: InterruptedException =>
      case e: Exception => logger.error("OpenFM playback exception", e)
    }
  }

  def play(): Boolean = {
    if (url.isEmpty)
      return false

    if (isPlaying)
      return true

    this.synchronized {
      playbackThread = Option(new Thread(() => playSynchronously()))
      playbackThread.get.setPriority(Thread.MIN_PRIORITY)
      playbackThread.get.start()
    }

    true
  }

  def stop(): Unit = {
    this.synchronized {
      // Stopping reading data from url
      if (playbackThread.isDefined) {
        playbackThread.get.interrupt()
        playbackThread = None
      }

      // Stopping playback
      if (playbackSoundSource.isDefined) {
        playbackSoundSource.get.stop()
        playbackSoundSource = None
      }
    }
  }

  def isPlaying: Boolean =
    playbackSoundSource.isDefined && playbackSoundSource.get.playing || playbackThread.isDefined

  @Callback()
  def start(context: Context, args: Arguments): Array[AnyRef] =
    result(play())

  @Callback()
  def stop(context: Context, args: Arguments): Array[AnyRef] = {
    stop()

    result(true)
  }

  @Callback()
  def isPlaying(context: Context, args: Arguments): Array[AnyRef] =
    result(isPlaying)

  // --------------------------- Volume ---------------------------

  private var _volume: Float = 1
  def volume: Float = _volume

  def volume_=(value: Float): Unit = {
    _volume = value

    if (playbackSoundSource.isDefined)
      playbackSoundSource.get.volume = value
  }

  private def setVol(value: Float): Array[AnyRef] = {
    if (value > 0 && value <= 1) {
      volume = value

      return result(true)
    }

    result(false)
  }

  private def volUpOrDown(factor: Float): Array[AnyRef] =
    setVol(volume + factor)

  def volUp(): Array[AnyRef] = volUpOrDown(0.1f)

  def volDown(): Array[AnyRef] = volUpOrDown(-0.1f)

  @Callback()
  def getVol(context: Context, args: Arguments): Array[AnyRef] =
    result(volume)

  @Callback()
  def setVol(context: Context, args: Arguments): Array[AnyRef] =
    setVol(args.checkDouble(0).toFloat)

  @Callback()
  def volUp(context: Context, args: Arguments): Array[AnyRef] =
    volUp()

  @Callback()
  def volDown(context: Context, args: Arguments): Array[AnyRef] =
    volDown()

  // --------------------------- Screen color/text ---------------------------

  private val defaultScreenText = "OpenFM"
  private val defaultScreenColor = IntColor(0x0aff0a)

  var screenColor: IntColor = defaultScreenColor
  private var _screenText: String = defaultScreenText
  def screenText: String = _screenText
  def screenText_=(value: String): Unit = _screenText = value.take(32)

  @Callback()
  def setScreenText(context: Context, args: Arguments): Array[AnyRef] = {
    screenText = args.checkString(0)

    result(true)
  }

  @Callback()
  def setScreenColor(context: Context, args: Arguments): Array[AnyRef] = {
    screenColor = IntColor(args.checkInteger(0))

    result(true)
  }

  @Callback()
  def getScreenColor(context: Context, args: Arguments): Array[AnyRef] =
    result(screenColor.color)

  // --------------------------- Redstone ---------------------------

  var isListenRedstone: Boolean = false

  @Callback()
  def getListenRedstone(context: Context, args: Arguments): Array[AnyRef] =
    result(isListenRedstone)

  @Callback()
  def setListenRedstone(context: Context, args: Arguments): Array[AnyRef] = {
    isListenRedstone = args.checkBoolean(0)

    result(true)
  }

  // --------------------------- Speakers ---------------------------

  // In the original mod, the number of speakers was always returned
  // as a fractional number. Who knows why ¯\_(ツ)_/¯

  @Callback()
  def getAttachedSpeakers(context: Context, args: Arguments): Array[AnyRef] =
    result(0.0f)

  @Callback()
  def getAttachedSpeakerCount(context: Context, args: Arguments): Array[AnyRef] =
    result(0.0f)

  // --------------------------- NBT ---------------------------

  override def load(nbt: NBTTagCompound, workspace: Workspace): Unit = {
    super.load(nbt, workspace)

    if (nbt.hasKey("url"))
      url = Option(nbt.getString("url"))

    screenColor = {
      if (nbt.hasKey("screenColor"))
        IntColor(nbt.getInteger("screenColor"))
      else
        defaultScreenColor
    }

    screenText = {
      if (nbt.hasKey("screenText"))
        nbt.getString("screenText")
      else
        defaultScreenText
    }

    volume = {
      if (nbt.hasKey("volume"))
        nbt.getDouble("volume").toFloat
      else
        1
    }

    isListenRedstone = nbt.hasKey("isListenRedstone") && nbt.getBoolean("isListenRedstone")

    // Playing again if previously saved value was true
    if (nbt.hasKey("isPlaying") && nbt.getBoolean("isPlaying"))
      play()
  }

  override def save(nbt: NBTTagCompound): Unit = {
    super.save(nbt)

    if (url.isDefined)
      nbt.setString("url", url.get)
    else
      nbt.removeTag("url")

    nbt.setInteger("screenColor", screenColor.color)
    nbt.setString("screenText", screenText)
    nbt.setDouble("volume", volume)
    nbt.setBoolean("isListenRedstone", isListenRedstone)
    nbt.setBoolean("isPlaying", isPlaying)
  }

  // --------------------------- Miscellaneous ---------------------------

  // Yes, that's the way it should be
  // See https://github.com/PC-Logix/OpenFM/blob/1f364f36fd13655f45e5fe5601004f8b76bb02ec/src/main/java/pcl/OpenFM/TileEntity/TileEntityRadio.java#L527-L528
  @Callback()
  def greet(context: Context, args: Arguments): Array[AnyRef] =
    result("Lasciate ogne speranza, voi ch'intrate")

  override def getDeviceInfo: Map[String, String] =
    null

  override def dispose(): Unit = {
    super.dispose()

    stop()
  }
}
