package ru.dbotthepony.mc.otm.capability.drive

import it.unimi.dsi.fastutil.objects.Object2ObjectAVLTreeMap
import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap
import java.util.UUID
import net.minecraft.ReportedException
import net.minecraft.nbt.CompoundTag
import net.minecraft.nbt.NbtIo
import net.minecraft.CrashReport
import java.util.concurrent.locks.LockSupport
import net.minecraftforge.event.TickEvent.ServerTickEvent
import net.minecraftforge.event.TickEvent
import net.minecraftforge.event.server.ServerAboutToStartEvent
import net.minecraftforge.event.server.ServerStoppingEvent
import net.minecraftforge.event.level.LevelEvent
import org.apache.logging.log4j.LogManager
import ru.dbotthepony.mc.otm.NULLABLE_MINECRAFT_SERVER
import ru.dbotthepony.mc.otm.SERVER_IS_LIVE
import java.io.File
import java.lang.ref.WeakReference
import java.util.ArrayList

/**
 * Let me answer everyone's questions about:
 *
 * Why?
 *
 * There are several reasons:
 * 1. This data can get very large very quickly, even when playing singleplayer (and much quicker and bigger when hosting a dedicated server)
 * 2. This data can not be stored inside ItemStack.ForgeCaps due to it's size
 * 3. This data can not be stored inside unshared (server only) nbt tag because, again, mods prone to use and interact with
 * it wrong, causing loss of stored data or mods exposing full content of a drive inside their own tag (which cause very real "NBT size too large"
 * network kicks, often locking players out of server/singleplayer worlds
 * 4. [net.minecraft.world.level.saveddata.SavedData] is for storing everything inside one dat file, which
 * is performance tanking, because we have to write *entire* NBT on each save, not the data of Drives that are dirty
 * 5. Mods which check items for being stack-able even with stack size of 1 gonna compare nbt tag,
 * which will be performance tanking due to clause 1.
 */
object DrivePool {
	private val LOGGER = LogManager.getLogger()
	private val pool = Object2ObjectOpenHashMap<UUID, WeakDriveReference>()
	private var thread: Thread? = null
	private var stopping = false
	private var exception: ReportedException? = null
	private const val DATA_PATH = "data/otm_drives"

	@JvmStatic
	operator fun <T : IMatteryDrive<*>> get(
		id: UUID,
		deserializer: (CompoundTag) -> T,
		factory: () -> T
	): T {
		if (!isLegalAccess())
			throw IllegalAccessException("Can not access drive pool from outside of server thread.")

		if (!SERVER_IS_LIVE)
			throw IllegalStateException("Fail-fast: Server is shutting down")

		val get = pool[id]

		if (get != null) {
			val accessed = get.access()

			if (accessed != null) {
				return accessed as T
			}
		}

		val file = File(baseDirectory, "$id.dat")

		if (file.exists()) {
			try {
				val factorize = deserializer(NbtIo.readCompressed(file))
				pool[id] = WeakDriveReference(factorize)
				return factorize
			} catch (error: Throwable) {
				val report = CrashReport("Reading OTM Drive from disk", error)
				val category = report.addCategory("Corrupt drive")
				category.setDetail("UUID", id)
				category.setDetail("File location", file)
				throw ReportedException(report)
			}
		}

		return factory().also { pool[id] = WeakDriveReference(it) }
	}

	@JvmStatic
	fun put(id: UUID, drive: IMatteryDrive<*>) {
		if (!isLegalAccess())
			throw IllegalAccessException("Can not access drive pool from outside of server thread.")

		if (!SERVER_IS_LIVE)
			throw IllegalStateException("Fail-fast: Server is shutting down")

		pool[id] = WeakDriveReference(drive)
	}

	@JvmStatic
	fun markDirty(id: UUID) {
		if (!isLegalAccess())
			throw IllegalAccessException("Can not access drive pool from outside of server thread.")

		if (!SERVER_IS_LIVE)
			throw IllegalStateException("Fail-fast: Server is shutting down")

		pool[id]?.access()
	}

	private var backlog = ArrayList<BacklogLine>()
	private var knownBaseDirectory: File? = null

	private val baseDirectory: File? get() {
		val server = NULLABLE_MINECRAFT_SERVER ?: return null

		val baseDirectory = File(server.storageSource.worldDir.toFile(), DATA_PATH)

		if (knownBaseDirectory != baseDirectory) {
			baseDirectory.mkdirs()
			knownBaseDirectory = baseDirectory
		}

		return baseDirectory
	}

	private var serverThread: Thread? = null

	private fun thread() {
		while (true) {
			LockSupport.park()

			if (stopping) {
				return
			}

			try {
				// TODO: Syncing status with main thread, since server stop can occur during work.
				sync()
			} catch (error: ReportedException) {
				exception = error
				return
			}
		}
	}

	fun onServerPostTick(event: ServerTickEvent) {
		if (event.phase == TickEvent.Phase.END) {
			if (exception != null) {
				throw exception!!
			}
		}
	}

	/**
	 * Returns whenever running on server thread. Calling [get], [put] or [markDirty] will throw an exception if this returns false.
	 *
	 * If you are making your own drive for your own storage stack, feed code outside of server thread (e.g. client code) dummy disk.
	 */
	@JvmStatic
	fun isLegalAccess(): Boolean {
		if (serverThread == null)
			return false

		return Thread.currentThread() === serverThread
	}

	fun serverStartEvent(event: ServerAboutToStartEvent) {
		if (thread?.isAlive == true) {
			LOGGER.error("FMLServerStartedEvent fired twice.")
			LOGGER.error("Attempting to start another DrivePool I/O thread when already running one!")
			return
		}

		pool.clear()
		serverThread = Thread.currentThread()

		stopping = false
		thread = Thread(null, this::thread, "Overdrive That Matters DrivePool IO").also { it.start() }
	}

	fun serverStopEvent(event: ServerStoppingEvent) {
		val thread = thread

		if (thread != null && thread.isAlive) {
			stopping = true
			LockSupport.unpark(thread)
		}

		if (exception == null) {
			writeBacklog()
			sync()
		}

		pool.clear()
	}

	fun onWorldSave(event: LevelEvent.Save) {
		writeBacklog()
	}

	private fun writeBacklog() {
		var needsSync = false
		var removeKeys: ArrayList<UUID>? = null

		for ((key, value) in pool) {
			val tag = value.sync()

			if (tag != null) {
				LOGGER.info("Serializing OTM Drive {}", key)
				val index = backlog.indexOf<Any>(key)

				if (index != -1) {
					backlog[index] = BacklogLine(key, tag, value.drive()!!)
				} else {
					backlog.add(BacklogLine(key, tag, value.drive()!!))
				}

				needsSync = true
			} else if (!value.valid()) {
				if (removeKeys == null)
					removeKeys = ArrayList()

				removeKeys.add(key)
			}
		}

		if (removeKeys != null) {
			for (key in removeKeys) {
				pool.remove(key)
			}
		}

		if (needsSync) {
			LockSupport.unpark(thread)
		}
	}

	private fun sync() {
		val copy = backlog
		backlog = ArrayList()

		for (entry in copy) {
			try {
				LOGGER.info("Syncing OTM Drive {}", entry.file)

				val oldFile = File(baseDirectory, entry.file.toString() + ".dat_old")

				if (oldFile.exists()) {
					check(oldFile.delete()) { "Unable to delete old dat file" }
				}

				val newFile = File(baseDirectory, entry.file.toString() + ".dat")

				if (newFile.exists()) {
					check(newFile.renameTo(oldFile)) { "Unable to move old dat file" }
				}

				NbtIo.writeCompressed(entry.tag, newFile)
			} catch (error: Throwable) {
				val report = CrashReport("Syncing OTM Drives state to disk", error)
				val category = report.addCategory("Corrupt drive")
				category.setDetail("UUID", entry.file.toString())
				category.setDetail("Stored item count", entry.drive.storedCount)
				category.setDetail("Capacity", entry.drive.driveCapacity)
				throw ReportedException(report)
			}
		}
	}
}

private class WeakDriveReference(drive: IMatteryDrive<*>) {
	private var drive: IMatteryDrive<*>? = drive
	private val weak = WeakReference(drive)

	fun valid(): Boolean {
		return drive != null || weak.get() != null
	}

	fun drive(): IMatteryDrive<*>? {
		return drive ?: weak.get()
	}

	fun sync(): CompoundTag? {
		var drive = drive

		if (drive == null) {
			drive = weak.get()

			if (drive == null)
				return null
		}

		if (!drive.isDirty) {
			this.drive = null
			return null
		}

		val tag = drive.serializeNBT()
		drive.isDirty = false
		this.drive = null

		return tag
	}

	fun access(): IMatteryDrive<*>? {
		this.drive = weak.get()
		return drive
	}
}

@Suppress("EqualsOrHashCode")
private data class BacklogLine(val file: UUID, val tag: CompoundTag, val drive: IMatteryDrive<*>) {
	override fun equals(other: Any?): Boolean {
		if (other is BacklogLine) {
			return other.file == file
		}

		if (other is UUID) {
			return other == file
		}

		return false
	}
}
