package ocelot.desktop.ui

import buildinfo.BuildInfo
import ocelot.desktop.audio.{Audio, SoundBuffers, SoundSource}
import ocelot.desktop.geometry.{Rect2D, Size2D, Vector2D}
import ocelot.desktop.graphics.{Font, Graphics}
import ocelot.desktop.ui.event.handlers.HoverHandler
import ocelot.desktop.ui.event.sources.{BrainEvents, KeyEvents, MouseEvents, ScrollEvents}
import ocelot.desktop.ui.event.{Capturing, CapturingEvent, Dispatchable, HoverEvent, MouseEvent}
import ocelot.desktop.ui.widget.{RootWidget, Widget}
import ocelot.desktop.util._
import ocelot.desktop.{OcelotDesktop, Settings}
import org.apache.commons.lang3.SystemUtils
import org.lwjgl.BufferUtils
import org.lwjgl.input.Mouse
import org.lwjgl.opengl._

import java.awt.Toolkit
import java.awt.datatransfer.{DataFlavor, StringSelection, UnsupportedFlavorException}
import java.io.{File, FileOutputStream}
import java.nio.ByteBuffer
import java.nio.channels.Channels
import java.nio.file.Paths
import javax.imageio.ImageIO
import javax.swing.JFileChooser
import scala.collection.Seq
import scala.collection.mutable
import scala.collection.mutable.ArrayBuffer
import scala.concurrent.duration.DurationInt
import scala.util.Try

object UiHandler extends Logging {
  var root: RootWidget = _
  var graphics: Graphics = _

  private var _terminating = false
  def terminating: Boolean = _terminating

  private val hierarchy = mutable.ArrayBuffer(root: Widget)
  private var shouldUpdateHierarchy = true
  private val fpsCalculator = new FPSCalculator
  private val ticker = new Ticker
  var scalingFactor = 1.0f

  ticker.tickInterval = 1.second / 144

  val UiThreadTasks = new TaskQueue()

  def getHierarchy: Array[Widget] = hierarchy.toArray

  def updateHierarchy(): Unit = {
    shouldUpdateHierarchy = true
  }

  private def _updateHierarchy(): Unit = {
    val stack = mutable.Stack(root: Widget)
    hierarchy.clear()
    while (stack.nonEmpty) {
      val widget = stack.pop()
      hierarchy += widget
      for (child <- widget.hierarchy.reverseIterator) {
        stack.push(child)
      }
    }

    shouldUpdateHierarchy = false
  }

  def fps: Float = fpsCalculator.fps

  def dt: Float = fpsCalculator.dt

  def mousePosition: Vector2D = {
    Vector2D(Mouse.getX, Display.getHeight - Mouse.getY) / scalingFactor
  }

  private val _clipboard = Toolkit.getDefaultToolkit.getSystemClipboard

  def clipboard: String = {
    try {
      _clipboard.getData(DataFlavor.stringFlavor).toString
    } catch {
      case _: UnsupportedFlavorException =>
        logger.debug("Trying to paste non-Unicode content from clipboard! Replaced with an empty string.")
        ""
    }
  }

  def clipboard_=(value: String): Unit = {
    val data = new StringSelection(value)
    _clipboard.setContents(data, data)
  }

  def fullScreen: Boolean = Display.isFullscreen

  def fullScreen_=(value: Boolean): Unit = {
    if (value) {
      Display.setDisplayModeAndFullscreen(Display.getDesktopDisplayMode)
    } else {
      // Updating size from settings
      val settingsSize = Settings.get.windowSize

      val unscaledSize = sanitizeWindowSize(
        if (settingsSize.isSet) Size2D(settingsSize.x, settingsSize.y)
        else Size2D(800, 600)
      )

      if (root != null) {
        root.size = unscaledSize / scalingFactor
      }

      // Setting normal display mode (non-fullscreen by default)
      Display.setDisplayMode(new DisplayMode(unscaledSize.width.toInt, unscaledSize.height.toInt))

      // Updating position from settings
      if (Settings.get.windowPosition.isSet) {
        if (Settings.get.windowValidatePosition) {
          // the configuration can have incorrect values for coordinates
          // and that can cause the window to "disappear" from the screen
          // TODO: after upgrading to LWJGL 3 consider the multi-monitor setups
          val desktop = Display.getDesktopDisplayMode
          if (Settings.get.windowPosition.x < -Settings.get.windowSize.x)
            Settings.get.windowPosition.x = 0
          if (Settings.get.windowPosition.x > desktop.getWidth)
            Settings.get.windowPosition.x = desktop.getWidth - Settings.get.windowSize.x
          if (Settings.get.windowPosition.y < -Settings.get.windowSize.y)
            Settings.get.windowPosition.y = 0
          if (Settings.get.windowPosition.y > desktop.getHeight)
            Settings.get.windowPosition.y = desktop.getHeight - Settings.get.windowSize.y
        }
        Display.setLocation(Settings.get.windowPosition.x, Settings.get.windowPosition.y)
      }

      // Window should be resizable after exit from fullscreen mode
      // But it seems like there's lwjgl bug with internal resizable value caching
      // How to reproduce:
      //   1) Enter fullscreen mode via setDisplayModeAndFullscreen(...) or setFullScreen(true)
      //   2) Set window resizable via setResizable(true)
      //   3) Exit from fullscreen mode
      //   4) Set window resizable again
      // Result:
      //   Window is not resizable and can't be resized with mouse
      // Expected behaviour:
      //   Window should be resizable
      Display.setResizable(false)
      Display.setResizable(true)
    }

    Settings.get.windowFullscreen = value
  }

  private def formatRect(rect: Rect2D): String = {
    val Rect2D(x, y, w, h) = rect

    f"$w%.0f×$h%.0f$x%+.0f$y%+.0f"
  }

  private def windowGeometry: Rect2D = new Rect2D(Display.getX, Display.getY, Display.getWidth, Display.getHeight)

  private def sanitizeWindowSize(size: Size2D): Size2D = {
    Size2D(
      if (size.width < 10) 800 else size.width,
      if (size.height < 10) 600 else size.height,
    )
  }

  private def sanitizeWindowGeometry(currentGeometry: Rect2D): Rect2D = {
    val Rect2D(x, y, w, h) = currentGeometry
    val newSize = sanitizeWindowSize(Size2D(w, h))

    Rect2D(
      x max 0,
      y max 0,
      newSize.width,
      newSize.height,
    )
  }

  private def fixInsaneInitialWindowGeometry(): Unit = {
    // what I mean by insane: ocelot.desktop.ui.UiHandler$ - Created window: 0×0 (at 960, -569)

    val currentGeometry = windowGeometry
    val geometry = sanitizeWindowGeometry(currentGeometry)

    if (geometry != currentGeometry) {
      logger.warn(s"Window geometry sanity check failed: ${formatRect(currentGeometry)} is officially insane")
      logger.warn(s"Resetting to ${formatRect(geometry)}")
      Display.setDisplayMode(new DisplayMode(geometry.w.toInt, geometry.h.toInt))
      Display.setLocation(geometry.x.toInt, geometry.y.toInt)
    }
  }

  def init(): Unit = {
    scalingFactor = Settings.get.scaleFactor
    fullScreen = Settings.get.windowFullscreen
    Display.setTitle(windowTitleBase)

    loadIcons()

    if (!Settings.get.disableVsync) {
      logger.info("VSync enabled")
      Display.setVSyncEnabled(true)
    } else {
      logger.info("VSync disabled (via config)")
    }

    val attrs = new ContextAttribs(
      3,
      2,
      ContextAttribs.CONTEXT_CORE_PROFILE_BIT_ARB,
      ContextAttribs.CONTEXT_FORWARD_COMPATIBLE_BIT_ARB,
    )

    val pf = new PixelFormat()
      .withDepthBits(24)
      .withStencilBits(8)
      .withAlphaBits(8)
      .withSRGB(true)

    logger.info(s"Creating an OpenGL context: $attrs")
    Display.create(pf, attrs)

    if (Settings.get.windowValidatePosition)
      fixInsaneInitialWindowGeometry()

    KeyEvents.init()
    MouseEvents.init()

    logger.info(s"Created window: ${formatRect(windowGeometry)}")
    logger.info(s"OpenGL vendor: ${GL11.glGetString(GL11.GL_VENDOR)}")
    logger.info(s"OpenGL renderer: ${GL11.glGetString(GL11.GL_RENDERER)}")
    logger.info(s"OpenGL version: ${GL11.glGetString(GL11.GL_VERSION)}")

    Spritesheet.load()

    graphics = new Graphics(Display.getWidth, Display.getHeight, scalingFactor)

    if (Settings.get.audioDisable) {
      logger.warn("Sound disabled (via config)")
    } else {
      Audio.init()
    }
  }

  def setRoot(widget: RootWidget): Unit = {
    root = widget
    root.relayout()
  }

  def loadLibraries(): Unit = {
    val librariesPath = OcelotPaths.libraries.toString

    val arch = System.getProperty("os.arch")
    val is64bit = arch.startsWith("amd64")
    val isArm64 = arch.startsWith("aarch64") || arch.startsWith("arm64")

    val libs = {
      if (SystemUtils.IS_OS_WINDOWS) {
        if (is64bit)
          Array("jinput-dx8_64.dll", "jinput-raw_64.dll", "jinput-wintab.dll", "lwjgl64.dll", "OpenAL64.dll")
        else
          Array("jinput-dx8.dll", "jinput-raw.dll", "jinput-wintab.dll", "lwjgl.dll", "OpenAL32.dll")
      } else if (SystemUtils.IS_OS_MAC_OSX) {
        if (isArm64)
          Array("liblwjgl-arm64.dylib")
        else
          Array("liblwjgl.dylib")
      } else if (SystemUtils.IS_OS_LINUX) {
        if (is64bit)
          Array("libjinput-linux64.so", "liblwjgl64.so", "libopenal64.so")
        else
          Array("libjinput-linux.so", "liblwjgl.so", "libopenal.so")
      } else
        throw new Exception("Unsupported OS")
    }

    logger.debug(s"Unpacking native libraries to: $librariesPath")

    for (lib <- libs) {
      val destinationFilename = if (SystemUtils.IS_OS_MAC_OSX) "liblwjgl.dylib" else lib
      val dest = new File(Paths.get(librariesPath, destinationFilename).toString)

      if (!dest.exists()) {
        val source = getClass.getResourceAsStream("/" + lib)

        if (!dest.getParentFile.exists())
          dest.getParentFile.mkdirs()

        val output = new FileOutputStream(dest)
        output.getChannel.transferFrom(Channels.newChannel(source), 0, Long.MaxValue)
        output.flush()
        output.close()
        source.close()
      }
    }

    System.setProperty("org.lwjgl.librarypath", librariesPath)

    if (Settings.get.debugLwjgl) {
      logger.info("Enabling LWJGL debug mode")
      System.setProperty("org.lwjgl.util.Debug", true.toString)
    }
  }

  private def loadIcons(): Unit = {
    val sizes = Array(256, 128, 64, 32, 16)
    val list = new Array[ByteBuffer](sizes.length)

    for ((size, i) <- sizes.zipWithIndex) {
      val imageURL = getClass.getResource(s"/ocelot/desktop/images/icon$size.png")
      val image = ImageIO.read(imageURL)
      val pixels = new Array[Int](image.getWidth * image.getHeight)
      image.getRGB(0, 0, image.getWidth, image.getHeight, pixels, 0, image.getWidth)
      val buf = BufferUtils.createByteBuffer(image.getWidth * image.getHeight * 4)

      for (y <- 0 until image.getHeight) {
        for (x <- 0 until image.getWidth) {
          val pixel = pixels(y * image.getWidth + x)
          buf.put(((pixel >> 16) & 0xff).toByte)
          buf.put(((pixel >> 8) & 0xff).toByte)
          buf.put((pixel & 0xff).toByte)
          buf.put(((pixel >> 24) & 0xff).toByte)
        }
      }

      buf.flip()
      list(i) = buf
    }

    Display.setIcon(list)
    logger.info(s"Loaded window icons of sizes ${sizes.mkString(", ")}")
  }

  private def windowTitleBase: String = s"Ocelot Desktop ${BuildInfo.version} (${BuildInfo.commit.take(7)})"

  private var _windowTitleSuffix: Option[String] = None

  def windowTitleSuffix: Option[String] = _windowTitleSuffix

  def windowTitleSuffix_=(value: Option[String]): Unit = {
    if (value == _windowTitleSuffix)
      return

    _windowTitleSuffix = value
    UiThreadTasks.add(updateWindowTitle)
  }

  private def updateWindowTitle(): Unit = {
    Display.setTitle(
      if (_windowTitleSuffix.isEmpty)
        windowTitleBase
      else
        s"$windowTitleBase - ${_windowTitleSuffix.get}"
    )
  }

  private var exitRequested = false

  def exit(): Unit = {
    exitRequested = true
  }

  def start(): Unit = {
    while (!exitRequested) {
      if (Display.isCloseRequested)
        OcelotDesktop.exit()

      OcelotDesktop.withTickLockAcquired {
        updateWindowSizeAndPosition()
        UiThreadTasks.run()

        Audio.update()
        KeyEvents.update()
        MouseEvents.update()

        OcelotDesktop.updateAutosave()

        Profiler.measure("000_update") {
          update()
        }

        Profiler.measure("001_draw") {
          draw()
        }

        // we are processing screenshots here to make sure that
        // the current frame was fully drawn on the screen,
        // but the update for the next frame yet not happened (if the window, for example, is to be resized)
        if (KeyEvents.isReleased(Settings.get.keymap(Keybind.Screenshot))) {
          SoundSource.InterfaceShutter.play()
          val image = graphics.screenshot()
          root.flash.bang()
          OcelotDesktop.showFileChooserDialog(JFileChooser.SAVE_DIALOG, JFileChooser.DIRECTORIES_ONLY) { dir =>
            Try {
              for (dir <- dir) {
                val file = new File(dir, s"ocelot-${System.currentTimeMillis()}.png")
                ImageIO.write(image, "PNG", file)
                logger.debug(s"Saved screenshot to: $file")
              }
            }
          }
        }
      }

      Profiler.measure("002_sleep") {
        ticker.waitNext()
      }

      Display.update()
      fpsCalculator.tick()
    }
  }

  def terminate(): Unit = {
    _terminating = true

    root.workspaceView.dispose()
    BrainEvents.destroy()
    KeyEvents.destroy()
    MouseEvents.destroy()
    graphics.freeResource()
    Font.freeResource()
    Audio.removeAllSources()
    SoundBuffers.freeResource()
    Display.destroy()
    Audio.destroy()
  }

  private def dispatchEvent(iter: => IterableOnce[Widget] = hierarchy.reverseIterator)(event: Dispatchable): Unit = {
    for (widget <- iter if !event.consumed) {
      widget.handleEvent(event)
    }
  }

  private def dispatchCapturing(dispatchOrder: DispatchOrder)(event: CapturingEvent): Unit = {
    dispatchEvent(dispatchOrder.capture)(Capturing(event))
    dispatchEvent(dispatchOrder.targets.reverseIterator)(event)
  }

  private def dispatchBrainEvents(): Unit = {
    import totoro.ocelot.brain

    val cutoff = BrainEvents.events.peekLast()

    if (cutoff == null) {
      return
    }

    // we're taking a snapshot of all events we have received at the point of the call.
    // new events may still be added to the queue, but we'll defer everything after the cutoff until the next update.
    var cutoffReached = false
    val events = ArrayBuffer.empty[brain.event.Event]

    while (!cutoffReached) {
      val event = BrainEvents.events.poll()
      cutoffReached = event eq cutoff
      events += event
    }

    for (event <- events) {
      root.workspaceView.dispatchBrainEvent(event)
    }
  }

  private def update(): Unit = {
    if (shouldUpdateHierarchy) {
      _updateHierarchy()
    }

    dispatchBrainEvents()

    val mousePos = mousePosition
    if (mousePos.x < 0 || mousePos.y < 0 || mousePos.x > root.width || mousePos.y > root.height) {
      KeyEvents.releaseKeys()
      MouseEvents.releaseButtons()
    }

    val broadcastDispatchOrder = DispatchOrder.broadcast

    // TODO: dispatch to the focused widget instead of broadcasting to the entire hierarchy.
    for (event <- KeyEvents.events) {
      dispatchCapturing(broadcastDispatchOrder)(event)
    }

    val scrollTarget = hierarchy.reverseIterator
      .find(w => w.receiveScrollEvents && w.clippedBounds.contains(mousePos))

    val mouseTarget = hierarchy.reverseIterator
      .find(w => w.enabled && w.receiveMouseEvents && w.clippedBounds.contains(mousePos))

    for (scrollTarget <- scrollTarget) {
      ScrollEvents.events.foreach(dispatchCapturing(DispatchOrder.resolve(scrollTarget)))
    }

    val mouseEventDispatchOrder = DispatchOrder.resolve(
      hierarchy
        .iterator
        .filter(w => mouseTarget.contains(w) || w.enabled && w.receiveAllMouseEvents)
        .toSeq
    )

    for (event <- MouseEvents.events) {
      if (event.state == MouseEvent.State.Pressed) {
        dispatchCapturing(mouseEventDispatchOrder)(event)
      } else {
        dispatchCapturing(broadcastDispatchOrder)(event)
      }
    }

    val hoverLeaveDispatchOrder = DispatchOrder.resolve(hierarchy.iterator.collect({
      case h: HoverHandler if !mouseTarget.contains(h) && h._mouseOver.update(false) => h
    }).toSeq)

    dispatchCapturing(hoverLeaveDispatchOrder)(HoverEvent(HoverEvent.State.Leave))

    mouseTarget.foreach {
      case h: HoverHandler if h._mouseOver.update(true) =>
        dispatchCapturing(DispatchOrder.resolve(h))(HoverEvent(HoverEvent.State.Enter))

      case _ =>
    }

    root.update()
  }

  private def updateWindowSizeAndPosition(): Unit = {
    val width = Display.getWidth
    val height = Display.getHeight

    if (graphics.resize(width, height, scalingFactor)) {
      // Checking for window position changes here seems to fail (on linux at least)
      // It instead happens on the previous frame

      root.size = Size2D(width / scalingFactor, height / scalingFactor)
    }

    // Settings fields should be updated only in non-fullscreen mode
    if (!fullScreen) {
      Settings.get.windowSize.set(width, height)
      Settings.get.windowPosition.set(Display.getX, Display.getY)
    }
  }

  private def draw(): Unit = {
    graphics.begin()
    graphics.clear()

    root.draw(graphics)

    graphics.flush()
    graphics.update()
  }

  /**
    * Provides a dispatch order for [[CapturingEvent]]s.
    * @param capture widgets to deliver events during capture phase, in hierarchy pre-order.
    * @param targets widgets to deliver events during target phase, in hierarchy pre-order.
    */
  private case class DispatchOrder(capture: Seq[Widget], targets: Seq[Widget])

  private object DispatchOrder {
    /**
      * Creates a dispatch order for delivering events to multiple targets.
      * @param targets event targets, in hierarchy pre-order.
      */
    def resolve(targets: Seq[Widget]): DispatchOrder = {
      val capture = mutable.Set.empty[Widget]

      for (widget <- targets) {
        capture += widget
        val ancestors = widget.ancestors
        while (ancestors.hasNext && capture.add(ancestors.next())) {}
      }

      DispatchOrder(
        capture = hierarchy.iterator.filter(capture).toSeq,
        targets = targets,
      )
    }

    /**
      * Creates a dispatch order for delivering event to a single `target`.
      */
    def resolve(target: Widget): DispatchOrder = {
      val capture = (Iterator(target) ++ target.ancestors.iterator).toSeq

      DispatchOrder(capture, Seq(target))
    }

    def broadcast: DispatchOrder = DispatchOrder(hierarchy, hierarchy)
  }
}
