package ocelot.desktop

import buildinfo.BuildInfo
import ocelot.desktop.geometry.Size2D
import ocelot.desktop.inventory.Items
import ocelot.desktop.ui.UiHandler
import ocelot.desktop.ui.swing.SplashScreen
import ocelot.desktop.ui.widget._
import ocelot.desktop.ui.widget.modal.notification.{NotificationDialog, NotificationType}
import ocelot.desktop.util.CommandLine.Argument
import ocelot.desktop.util._
import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.SystemUtils
import org.lwjgl.opengl.Display
import totoro.ocelot.brain.Ocelot
import totoro.ocelot.brain.nbt.ExtendedNBT.{extendNBTTagCompound, extendNBTTagList}
import totoro.ocelot.brain.nbt.{CompressedStreamTools, NBT, NBTTagCompound, NBTTagString}
import totoro.ocelot.brain.user.User
import totoro.ocelot.brain.workspace.Workspace

import java.awt.Component
import java.io._
import java.nio.file._
import java.util.concurrent.locks.{Lock, ReentrantLock}
import javax.swing.{JDialog, JFileChooser, UIManager}
import scala.collection.mutable
import scala.collection.mutable.ArrayBuffer
import scala.concurrent.duration.Duration
import scala.io.Source
import scala.jdk.CollectionConverters._
import scala.util.{Failure, Success, Try, Using}

// LoggingConfiguration configures Log4j appenders & loggers, it should come before Logging in inheritance hierarchy
object OcelotDesktop extends LoggingConfiguration with Logging {
  System.setProperty("awt.useSystemAAFontSettings", "on")
  System.setProperty("swing.aatext", "true")
  System.setProperty("LWJGL_WM_CLASS", "Ocelot Desktop")

  // Required to make all subsequent swing components look "native"
  try UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName)
  catch {
    case ex: Exception => logger.error("Unable to perform setLookAndFeel()", ex)
  }

  private val splashScreen = new SplashScreen()
  var root: RootWidget = _
  val players: ArrayBuffer[User] = ArrayBuffer(User("myself"))
  val tpsCounter = new FPSCalculator
  val ticker = new Ticker

  private var _emulationPaused = false

  def emulationPaused: Boolean = _emulationPaused

  def emulationPaused_=(paused: Boolean): Unit = {
    _emulationPaused = paused
    // avoid sudden jumps of TPS counter after a pause
    if (!paused) tpsCounter.skipSecond()
  }

  private val TickerIntervalHistorySize = 5
  val tickerIntervalHistory = new mutable.Queue[Duration](TickerIntervalHistorySize)

  def pushToTickerIntervalHistory(interval: Duration): Unit = {
    if (tickerIntervalHistory.size >= TickerIntervalHistorySize) tickerIntervalHistory.dequeue()
    tickerIntervalHistory.enqueue(interval)
  }

  private val tickLock: Lock = new ReentrantLock()

  def withTickLockAcquired(f: => Unit): Unit = withLockAcquired(tickLock)(f)

  private def mainInner(args: mutable.HashMap[Argument, Option[String]]): Unit = {
    logger.info("Starting up Ocelot Desktop")
    logger.info(s"Version: ${BuildInfo.version} (${BuildInfo.commit.take(7)})")

    splashScreen.setStatus("Loading configuration...", 0.10f)
    val customConfigPath = args.get(CommandLine.ConfigPath).flatten

    val desktopConfigPath: Path = {
      if (customConfigPath.isDefined) {
        Paths.get(customConfigPath.get)
      } else {
        // TODO: migration for old locations of ocelot.conf, can be safely removed later
        // TODO: uncomment this line and delete everything below it when you're ready!
        // OcelotPaths.desktopConfig

        val newConfigPath = OcelotPaths.desktopConfig

        try {
          if (!Files.exists(newConfigPath)) {
            val oldConfigPath = {
              if (SystemUtils.IS_OS_WINDOWS)
                Paths.get(OcelotPaths.windowsAppDataDirectoryName, "Ocelot", "ocelot.conf")
              else
                Paths.get(OcelotPaths.linuxHomeDirectoryName, ".config", "ocelot", "ocelot.conf")
            }

            if (Files.exists(oldConfigPath))
              Files.move(oldConfigPath, newConfigPath)
          }
        } catch {
          case _: Throwable =>
        }

        // TODO: end of upper todo <3
        newConfigPath
      }
    }

    Settings.load(desktopConfigPath)

    Messages.load(Source.fromURL(getClass.getResource("/ocelot/desktop/messages.txt")))
    ColorScheme.load(Source.fromURL(getClass.getResource("/ocelot/desktop/colorscheme.txt")))

    splashScreen.setStatus("Initializing brain...", 0.20f)
    Ocelot.configPath = Settings.get.brainCustomConfigPath.map(Paths.get(_))
    Ocelot.librariesPath = Some(OcelotPaths.libraries)
    Ocelot.isPlayerOnlinePredicate = Some(player => players.exists(_.nickname == player))
    Ocelot.initialize(logger)

    Items.init()

    splashScreen.setStatus("Initializing GUI...", 0.30f)
    createWorkspace()

    UiHandler.loadLibraries()

    splashScreen.setStatus("Loading resources...", 0.60f)
    UiHandler.init()

    val loadRecentWorkspace = Settings.get.recentWorkspace.isDefined && Settings.get.openLastWorkspace

    root = new RootWidget(!loadRecentWorkspace)
    root.size = Size2D(Display.getWidth / Settings.get.scaleFactor, Display.getHeight / Settings.get.scaleFactor)
    UiHandler.setRoot(root)

    splashScreen.setStatus("Loading workspace...", 0.90f)
    val cmdLineWorkspaceArgument = args.get(CommandLine.WorkspacePath).flatten
    if (loadRecentWorkspace || cmdLineWorkspaceArgument.isDefined) {
      val result = cmdLineWorkspaceArgument
        .orElse(Settings.get.recentWorkspace)
        .map(new File(_))
        .toRight(new IllegalArgumentException("Received None as a directory path"))
        .toTry
        .map(load)

      result match {
        case Failure(exception) =>
          val errorMessage = if (cmdLineWorkspaceArgument.isDefined)
            "Could not open the specified workspace..."
          else
            "Could not open the recent workspace..."

          logger.error(errorMessage, exception)

          new NotificationDialog(s"$errorMessage\n($exception)\nI will create a default one", NotificationType.Info)
            .addCloseButton()
            .show()

          root.workspaceView.createDefaultWorkspace()
          Settings.get.recentWorkspace = None
        case Success(_) =>
      }
    }

    val updateThread = new Thread(
      () =>
        try {
          val currentThread = Thread.currentThread()

          while (!currentThread.isInterrupted) {
            if (!emulationPaused) {
              Profiler.measure("tick") {
                withTickLockAcquired {
                  workspace.update()
                  updateThreadTasks.run()

                  tpsCounter.tick()
                }
              }
            }

            ticker.waitNext(!emulationPaused)
          }
        } catch {
          case _: InterruptedException => // ignore
        },
      "update-thread",
    )

    updateThread.start()
    splashScreen.dispose()
    logger.info("Ocelot Desktop is up and ready!")
    UiHandler.start()

    logger.info("Cleaning up")
    updateThread.interrupt()

    try updateThread.join()
    catch {
      case _: InterruptedException =>
    }

    WebcamCapture.cleanup()
    Settings.save(desktopConfigPath)
    UiHandler.terminate()
    ResourceManager.checkEmpty()

    Ocelot.shutdown()

    logger.info("Thanks for using Ocelot Desktop")
    System.exit(0)
  }

  def main(rawArgs: Array[String]): Unit = {
    val args = CommandLine.parse(rawArgs)
    if (args.contains(CommandLine.Help)) {
      println(CommandLine.doc)
    } else {
      try mainInner(args)
      catch {
        case e: Exception =>
          val sw = new StringWriter
          val pw = new PrintWriter(sw)
          e.printStackTrace(pw)
          logger.error(s"${sw.toString}")

          System.exit(1)
      }
    }
  }

  private def saveWorld(nbt: NBTTagCompound): Unit = withTickLockAcquired {
    val backendNBT = new NBTTagCompound
    val frontendNBT = new NBTTagCompound
    workspace.save(backendNBT)
    root.workspaceView.save(frontendNBT)
    frontendNBT.setNewTagList("players", players.map(player => new NBTTagString(player.nickname)))
    nbt.setTag("back", backendNBT)
    nbt.setTag("front", frontendNBT)
  }

  private def loadWorld(nbt: NBTTagCompound): Unit = withTickLockAcquired {
    val backendNBT = nbt.getCompoundTag("back")
    val frontendNBT = nbt.getCompoundTag("front")
    workspace.load(backendNBT)
    root.workspaceView.load(frontendNBT)
    if (frontendNBT.hasKey("players")) {
      players.clear()
      players.addAll(
        frontendNBT.getTagList("players", NBT.TAG_STRING).map((player: NBTTagString) => User(player.getString))
      )
    }
  }

  private var lastAutosave: Long = -1

  def resetAutosave(): Unit = {
    if (Settings.get.autosave) {
      lastAutosave = System.currentTimeMillis()
      logger.debug("Autosave interval was reset.")
    }
  }

  def updateAutosave(): Unit = {
    if (Settings.get.autosave) {
      if (lastAutosave == -1 || System.currentTimeMillis() - lastAutosave > Settings.get.autosavePeriod * 1000) {
        val path = savePath.getOrElse(tmpPath)
        logger.debug(s"Autosave to $path...")
        saveTo(path)
        lastAutosave = System.currentTimeMillis()
      }
    }
  }

  private var _savePath: Option[Path] = None

  private def savePath: Option[Path] = _savePath

  private def savePath_=(path: Option[Path]): Unit = {
    _savePath = path

    UiHandler.windowTitleSuffix = path.map(_.toString)
  }

  private val tmpPath = Files.createTempDirectory("ocelot-save")

  Runtime.getRuntime.addShutdownHook(new Thread(() => {
    // if autosave is turned on and the project is not saved properly, we would like to keep the temporary project
    if (!Settings.get.autosave || savePath.isEmpty) {
      FileUtils.deleteDirectory(tmpPath.toFile)
    }
  }))

  def newWorkspace(): Unit = showCloseConfirmationDialog("Save workspace before opening a new one?") {
    root.workspaceView.newWorkspace()
    savePath = None
    Settings.get.recentWorkspace = None
    resetAutosave()
  }

  private def saveTo(outputPath: Path): Unit = {
    val oldPath = workspace.path

    if (oldPath != outputPath) {
      val (oldFiles, newFiles) = {
        Using.resources(Files.list(oldPath), Files.list(outputPath)) { (oldDirStream, newDirStream) =>
          val oldFiles = oldDirStream.iterator.asScala.toArray
          val newFiles = newDirStream.iterator.asScala.toArray
          (oldFiles, newFiles)
        }
      }

      val toRemove = newFiles.intersect(oldFiles)

      for (path <- toRemove) {
        if (Files.isDirectory(path)) {
          FileUtils.deleteDirectory(path.toFile)
        } else {
          Files.delete(path)
        }
      }

      for (path <- oldFiles) {
        val oldFile = oldPath.resolve(path.getFileName).toFile
        val newFile = outputPath.resolve(path.getFileName).toFile

        if (Files.isDirectory(path)) {
          FileUtils.copyDirectory(oldFile, newFile)
        } else {
          FileUtils.copyFile(oldFile, newFile)
        }
      }

      workspace.path = outputPath
    }

    val path = outputPath.resolve("workspace.nbt")

    try {
      Files.move(path, path.resolveSibling("workspace.nbt.bak"), StandardCopyOption.REPLACE_EXISTING)
    } catch {
      case _: FileNotFoundException | _: NoSuchFileException => // no workspace file to back up -- that's okay
    }

    Using.resource(new DataOutputStream(new FileOutputStream(path.toFile))) { writer =>
      val nbt = new NBTTagCompound
      saveWorld(nbt)
      CompressedStreamTools.writeCompressed(nbt, writer)
    }

    logger.info(s"Saved workspace to: $outputPath")
  }

  def save(continuation: => Unit): Unit = savePath match {
    case Some(savePath) =>
      Try {
        saveTo(savePath)
        continuation
      } match {
        case f @ Failure(_) =>
          this.savePath = None
          showFailureMessage(f)

        case Success(_) =>
      }

    case None => showSaveDialog(continuation)
  }

  def saveAs(): Unit = showSaveDialog()

  def showOpenDialog(): Unit = {
    showFileChooserDialog(JFileChooser.OPEN_DIALOG, JFileChooser.DIRECTORIES_ONLY) {
      case Some(dir) => load(dir)
      case None => Success(())
    }
  }

  def load(dir: File): Try[Unit] = {
    val path = Paths.get(dir.getCanonicalPath, "workspace.nbt")

    if (Files.exists(path)) {
      Using(new DataInputStream(Files.newInputStream(path))) { reader =>
        val nbt = CompressedStreamTools.readCompressed(reader)

        Transaction.run { tx =>
          val oldSavePath = savePath
          savePath = Some(dir.toPath)
          tx.onFailure { savePath = oldSavePath }

          Settings.get.recentWorkspace = Some(dir.getCanonicalPath)
          workspace.path = dir.toPath
          loadWorld(nbt)

          resetAutosave()
          logger.info(s"Workspace successfully loaded from: $path")
        }
      }
    } else Failure(new FileNotFoundException("Specified directory does not contain 'workspace.nbt'"))
  }

  def showFileChooserDialog(dialogType: Int, selectionMode: Int)(f: Option[File] => Try[Unit]): Unit = {
    new Thread(() => {
      val lastFile = savePath.map(_.toFile).orNull

      val chooser = new JFileChooser(lastFile) {
        override def createDialog(parent: Component): JDialog = {
          val dialog = super.createDialog(parent)

          dialog.setModal(true)
          dialog.setAlwaysOnTop(true)

          dialog
        }
      }

      chooser.setFileSelectionMode(selectionMode)
      chooser.setDialogType(dialogType)

      val selectedFile =
        Option.when(chooser.showDialog(null, null) == JFileChooser.APPROVE_OPTION)(chooser.getSelectedFile)

      // users of [showFileChooserDialog] expect that the continuation is run on the main thread.
      // let's meet this expectation.
      UiHandler.UiThreadTasks.add(() => {
        val result = f(selectedFile)

        result match {
          case f @ Failure(_) => showFailureMessage(f)
          case Success(_) =>
        }
      })
    }).start()
  }

  private def showFailureMessage(failure: Failure[Unit]): Unit = {
    val Failure(exception) = failure

    logger.error("File operation failed", exception)

    new NotificationDialog(
      s"Something went wrong!\n($exception)\nCheck the log file for a full stacktrace.",
      NotificationType.Error,
    ).addCloseButton().show()
  }

  def showAddPlayerDialog(): Unit = new InputDialog(
    "Add new player",
    text => OcelotDesktop.selectPlayer(text),
  ).show()

  def player: User = if (players.nonEmpty) players.head else User("myself")

  def selectPlayer(name: String): Unit = {
    players.indexWhere(_.nickname == name) match {
      case -1 => players.prepend(User(name))
      case i =>
        val player = players(i)
        players.remove(i)
        players.prepend(player)
    }
  }

  def removePlayer(name: String): Unit = {
    players.indexWhere(_.nickname == name) match {
      case -1 =>
      case i => players.remove(i)
    }
  }

  def exit(): Unit = showCloseConfirmationDialog() {
    UiHandler.exit()
  }

  var workspace: Workspace = _
  val updateThreadTasks = new TaskQueue()

  private def createWorkspace(): Unit = {
    workspace = new Workspace(tmpPath)
  }

  private def prepareSavePath(path: Path)(continuation: => Unit): Unit = {
    val nonEmpty = {
      try Using.resource(Files.list(path))(_.iterator.asScala.nonEmpty)
      catch {
        case _: FileNotFoundException | _: NoSuchFileException =>
          logger.info(s"Save path $path does not exist: creating a new directory")
          Files.createDirectory(path)

          false
      }
    }

    if (nonEmpty) {
      new NotificationDialog(
        """Chosen save path is not empty.
          |Files in the save directory will be included in the workspace.
          |They may be overwritten, causing loss of data.
          |Proceed with saving anyway?""".stripMargin,
        NotificationType.Warning,
      ) {
        addButton("Cancel") {
          close()
        }
        addButton("Yes") {
          close()
          continuation
        }
      }.show()
    } else continuation
  }

  private def showSaveDialog(continuation: => Unit): Unit = {
    showFileChooserDialog(JFileChooser.SAVE_DIALOG, JFileChooser.DIRECTORIES_ONLY) { dir =>
      Try {
        if (dir.nonEmpty) {
          prepareSavePath(dir.get.toPath) {
            Transaction.run { tx =>
              val oldSavePath = savePath
              savePath = dir.map(_.toPath)
              tx.onFailure {
                savePath = oldSavePath
              }

              save(continuation)
              Settings.get.recentWorkspace = dir.map(_.getCanonicalPath)

              resetAutosave()
            }
          }
        }
      }
    }
  }

  private def showCloseConfirmationDialog(prompt: Option[String])(continuation: => Unit): Unit = {
    if (UiHandler.root.modalDialogPool.children.exists(_.isInstanceOf[CloseConfirmationDialog])) {
      return
    }

    for (savePath <- savePath if Settings.get.saveOnExit) {
      saveTo(savePath)
      continuation

      return
    }

    val prompt_ = prompt

    new CloseConfirmationDialog {
      override def prompt: String = prompt_.getOrElse(super.prompt)

      override def onSaveSelected(): Unit = save {
        close()
        continuation
      }

      override def onNoSaveSelected(): Unit = {
        close()
        continuation
      }
    }.show()
  }

  private def showCloseConfirmationDialog(prompt: String)(continuation: => Unit): Unit =
    showCloseConfirmationDialog(Some(prompt))(continuation)

  private def showCloseConfirmationDialog()(continuation: => Unit): Unit =
    showCloseConfirmationDialog(None)(continuation)
}
