package ru.dbotthepony.kstarbound.server.world

import com.google.gson.JsonObject
import it.unimi.dsi.fastutil.ints.Int2ObjectArrayMap
import it.unimi.dsi.fastutil.io.FastByteArrayInputStream
import it.unimi.dsi.fastutil.io.FastByteArrayOutputStream
import it.unimi.dsi.fastutil.objects.ObjectArrayList
import org.apache.logging.log4j.LogManager
import ru.dbotthepony.kommons.gson.contains
import ru.dbotthepony.kommons.gson.get
import ru.dbotthepony.kommons.gson.set
import ru.dbotthepony.kommons.io.BTreeDB6
import ru.dbotthepony.kommons.io.ByteKey
import ru.dbotthepony.kommons.util.CarriedExecutor
import ru.dbotthepony.kommons.util.IStruct2i
import ru.dbotthepony.kommons.util.KOptional
import ru.dbotthepony.kommons.vector.Vector2i
import ru.dbotthepony.kommons.vector.Vector3i
import ru.dbotthepony.kstarbound.Globals
import ru.dbotthepony.kstarbound.Starbound
import ru.dbotthepony.kstarbound.defs.world.CelestialParameters
import ru.dbotthepony.kstarbound.io.BTreeDB5
import ru.dbotthepony.kstarbound.json.mergeJson
import ru.dbotthepony.kstarbound.json.readJsonElement
import ru.dbotthepony.kstarbound.json.writeJsonElement
import ru.dbotthepony.kstarbound.math.Line2d
import ru.dbotthepony.kstarbound.util.binnedChoice
import ru.dbotthepony.kstarbound.util.paddedNumber
import ru.dbotthepony.kstarbound.util.random.AbstractPerlinNoise
import ru.dbotthepony.kstarbound.util.random.random
import ru.dbotthepony.kstarbound.util.random.staticRandom64
import ru.dbotthepony.kstarbound.world.UniversePos
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.Closeable
import java.io.DataInputStream
import java.io.DataOutputStream
import java.util.concurrent.CompletableFuture
import java.util.concurrent.TimeUnit
import java.util.function.Supplier
import java.util.random.RandomGenerator
import java.util.zip.Deflater
import java.util.zip.DeflaterOutputStream
import java.util.zip.InflaterInputStream

sealed class UniverseSource {
	abstract fun getChunk(chunkPos: IStruct2i): CompletableFuture<KOptional<UniverseChunk>>

	open fun saveChunk(chunkPos: IStruct2i, data: UniverseChunk): Boolean {
		return false
	}
}

// legacy chunks are indexed by... 8 byte keys?
// quite strange.
private fun key(chunkPos: IStruct2i): ByteKey {
	val (x, y) = chunkPos
	val bytes = ByteArray(8)

	bytes[0] = ((x ushr 24) and 0xFF).toByte()
	bytes[1] = ((x ushr 16) and 0xFF).toByte()
	bytes[2] = ((x ushr 8) and 0xFF).toByte()
	bytes[3] = ((x ushr 0) and 0xFF).toByte()

	bytes[4 + 0] = ((y ushr 24) and 0xFF).toByte()
	bytes[4 + 1] = ((y ushr 16) and 0xFF).toByte()
	bytes[4 + 2] = ((y ushr 8) and 0xFF).toByte()
	bytes[4 + 3] = ((y ushr 0) and 0xFF).toByte()

	return ByteKey.wrap(bytes)
}

class LegacyUniverseSource(private val db: BTreeDB5) : UniverseSource(), Closeable {
	private val carried = CarriedExecutor(Starbound.IO_EXECUTOR)

	override fun close() {
		carried.shutdown()
		carried.awaitTermination(Int.MAX_VALUE.toLong(), TimeUnit.MILLISECONDS)
		db.close()
	}

	override fun getChunk(chunkPos: IStruct2i): CompletableFuture<KOptional<UniverseChunk>> {
		val key = key(chunkPos)

		return CompletableFuture.supplyAsync(Supplier { db.read(key) }, carried).thenApplyAsync {
			it.map {
				val stream = BufferedInputStream(InflaterInputStream(FastByteArrayInputStream(it)))
				UniverseChunk(Vector2i(chunkPos))
			}
		}
	}
}

class NativeUniverseSource(private val db: BTreeDB6?, private val universe: ServerUniverse) : Closeable {
	private inner class Reader : UniverseSource() {
		override fun getChunk(chunkPos: IStruct2i): CompletableFuture<KOptional<UniverseChunk>> {
			if (db == null) return CompletableFuture.completedFuture(KOptional())
			val key = key(chunkPos)

			return CompletableFuture.supplyAsync(Supplier {
				db.read(key)
			}, carrier).thenApplyAsync {
				it.flatMap {
					val data = DataInputStream(BufferedInputStream(InflaterInputStream(FastByteArrayInputStream(it)))).readJsonElement()
					val expected = Vector2i(chunkPos)

					try {
						val chunk = Starbound.gson.fromJson(data, UniverseChunk::class.java)

						if (expected == chunk.chunkPos) {
							KOptional(chunk)
						} else {
							throw IllegalArgumentException("Universe chunk at $chunkPos has ${chunk.chunkPos} stored as position!")
						}
					} catch (err: Throwable) {
						LOGGER.error("Error while deserializing universe chunk from disk storage at $chunkPos, it will be regenerated", err)
						KOptional()
					}
				}
			}
		}

		override fun saveChunk(chunkPos: IStruct2i, data: UniverseChunk): Boolean {
			storeChunk(chunkPos, data)
			return true
		}
	}

	// generator will generate different systems than vanilla
	// because of different code flow and ability to use different random number generator
	private inner class Generator : UniverseSource() {
		override fun getChunk(chunkPos: IStruct2i): CompletableFuture<KOptional<UniverseChunk>> {
			val random = random(staticRandom64(chunkPos.component1(), chunkPos.component2(), "ChunkIndexMix"))
			val region = universe.chunkRegion(chunkPos)

			return CompletableFuture.supplyAsync {
				val constellationCandidates = ArrayList<Vector2i>()
				val chunk = UniverseChunk(Vector2i(chunkPos))

				for (x in region.mins.x until region.maxs.x) {
					for (y in region.mins.y until region.maxs.y) {
						if (random.nextFloat() < universe.generationInformation.systemProbability) {
							val z = random.nextInt(universe.baseInformation.zCoordRange.x, universe.baseInformation.zCoordRange.y)
							val pos = Vector3i(x, y, z)

							val system = generateSystem(random, pos) ?: continue
							chunk.systems[pos] = system

							if (
								system.parameters.parameters.get("constellationCapable", true) &&
								system.parameters.parameters.get("magnitude", 0.0) >= universe.generationInformation.minimumConstellationMagnitude
							) {
								constellationCandidates.add(Vector2i(x, y))
							}
						}
					}
				}

				for (pair in generateConstellations(random, constellationCandidates)) {
					chunk.constellations.add(pair)
				}

				storeChunk(chunkPos, chunk)
				KOptional(chunk)
			}
		}

		private val systemPerlin = AbstractPerlinNoise.of(universe.generationInformation.systemTypePerlin)

		init {
			if (!systemPerlin.hasSeedSpecified) {
				systemPerlin.init(staticRandom64("SystemTypePerlin"))
			}
		}

		private fun generateSystem(random: RandomGenerator, location: Vector3i): UniverseChunk.System? {
			val typeSelector = systemPerlin[location.x.toDouble(), location.y.toDouble()]

			val type = universe.generationInformation.systemTypeBins
				.stream()
				.binnedChoice(typeSelector).orElse("")

			if (type.isBlank())
				return null

			val system = universe.generationInformation.systemTypes[type]!!
			val systemPos = UniversePos(location)
			val systemSeed = random.nextLong()

			val prefix = Globals.celestialNames.systemPrefixNames.sample(random).orElse("")
			val mid = Globals.celestialNames.systemNames.sample(random).orElse("missingsystemname $location")
			val suffix = Globals.celestialNames.systemSuffixNames.sample(random).orElse("")

			val systemName = "$prefix $mid $suffix".trim()
				.replace("<onedigit>", random.nextInt(0, 10).toString())
				.replace("<twodigit>", paddedNumber(random.nextInt(0, 100), 2))
				.replace("<threedigit>", paddedNumber(random.nextInt(0, 1000), 3))
				.replace("<fourdigit>", paddedNumber(random.nextInt(0, 10000), 4))

			val systemParams = CelestialParameters(
				systemPos,
				systemSeed,
				systemName,
				mergeJson(system.baseParameters.deepCopy(), system.variationParameters.random(random))
			)

			if ("typeName" !in systemParams.parameters) {
				systemParams.parameters["typeName"] = system.typeName
			}

			if ("constellationCapable" !in systemParams.parameters) {
				systemParams.parameters["constellationCapable"] = system.constellationCapable
			}

			val planets = Int2ObjectArrayMap<UniverseChunk.Planet>()

			for (planetOrbitIndex in 1 .. universe.baseInformation.planetOrbitalLevels) {
				// this looks dumb at first, but then it makes sense
				// in celestial.config, you define orbital region, where planets
				// of only specific type appear.
				val systemOrbitRegion = system.orbitRegions
					.stream()
					.filter { planetOrbitIndex in it.orbitRange.x .. it.orbitRange.y }
					.findFirst().orElse(null) ?: continue

				if (systemOrbitRegion.bodyProbability > random.nextDouble()) continue

				val planetaryTypeO = systemOrbitRegion.planetaryTypes.sample(random).flatMap { KOptional.ofNullable(universe.generationInformation.planetaryTypes[it]) }
				if (!planetaryTypeO.isPresent) continue
				val planetaryType = planetaryTypeO.value

				val planetCoordinate = UniversePos(location, planetOrbitIndex)
				val planetSeed = random.nextLong()
				val planetName = "$systemName ${Globals.celestialNames.planetarySuffixes[planetOrbitIndex]}"

				val planetParams = CelestialParameters(
					planetCoordinate,
					planetSeed,
					planetName,
					mergeJson(planetaryType.baseParameters.deepCopy(), planetaryType.variationParameters.random(random))
				)

				val satellites = Int2ObjectArrayMap<CelestialParameters>()
				val maxSatelliteCount = planetaryType.maxSatelliteCount ?: universe.baseInformation.satelliteOrbitalLevels

				if (maxSatelliteCount > 0) {
					for (satelliteOrbitIndex in 1 .. universe.baseInformation.satelliteOrbitalLevels) {
						if (random.nextDouble() < planetaryType.satelliteProbability) {
							val satelliteTypeO = systemOrbitRegion.satelliteTypes.sample(random).flatMap { KOptional.ofNullable(universe.generationInformation.satelliteTypes[it]) }
							if (!satelliteTypeO.isPresent) continue
							val satelliteType = satelliteTypeO.value
							val satelliteSeed = random.nextLong()
							val satelliteName = "$planetName ${Globals.celestialNames.satelliteSuffixes[satellites.size]}"
							val satelliteCoordinate = UniversePos(location, planetOrbitIndex, satelliteOrbitIndex)

							val merge = JsonObject()
							mergeJson(merge, satelliteType.baseParameters)
							mergeJson(merge, satelliteType.variationParameters.random(random))

							if (systemOrbitRegion.regionName in satelliteType.orbitParameters) {
								mergeJson(merge, satelliteType.orbitParameters[systemOrbitRegion.regionName]!!.random(random))
							}

							satellites[satelliteOrbitIndex] = CelestialParameters(
								satelliteCoordinate,
								satelliteSeed,
								satelliteName,
								merge
							)

							if (satellites.size >= maxSatelliteCount)
								break
						}
					}
				}

				planets[planetOrbitIndex] = UniverseChunk.Planet(planetParams, satellites)
			}

			return UniverseChunk.System(systemParams, planets)
		}

		private fun generateConstellations(random: RandomGenerator, candidates: List<Vector2i>): List<Pair<Vector2i, Vector2i>> {
			if (candidates.size <= 2 || random.nextDouble() > universe.generationInformation.constellationProbability) return listOf()

			val constellations = ArrayList<Pair<Vector2i, Vector2i>>()
			val constellationPoints = ObjectArrayList<Vector2i>()
			val constellationLines = ArrayList<Line2d>()

			val target = random.nextInt(universe.generationInformation.constellationLineCountRange.x, universe.generationInformation.constellationLineCountRange.y)
			var tries = 0

			while (constellationLines.size < target && ++tries < universe.generationInformation.constellationMaxTries) {
				val start = if (constellationPoints.isEmpty)
					candidates.random(random)
				else
					constellationPoints.random(random)

				val end = candidates.random(random)

				if (start == end) continue
				val proposed = Line2d(start.toDoubleVector(), end.toDoubleVector())
				val proposedReversed = proposed.reverse()

				if (proposed in constellationLines || proposedReversed in constellationLines) continue
				if (start.distance(end) !in universe.generationInformation.minimumConstellationLineLength .. universe.generationInformation.maximumConstellationLineLength) continue

				var valid = true

				for (existingLine in constellationLines) {
					val intersection = proposed.intersect(existingLine)

					if (
						intersection.intersects &&
						intersection.point.get() != proposed.p0 &&
						intersection.point.get() != proposed.p1
					) {
						valid = false
						break
					}

					if (
						proposed != existingLine &&
						proposed.distanceTo(existingLine.p0) < universe.generationInformation.minimumConstellationLineCloseness
					) {
						valid = false
						break
					}

					if (
						proposed != existingLine.reverse() &&
						proposed.distanceTo(existingLine.p1) < universe.generationInformation.minimumConstellationLineCloseness
					) {
						valid = false
						break
					}
				}

				if (valid) {
					// TODO: Original engine generates "single" constellation
					// TODO: out of multiple (probably disconnected) point pairs.
					// TODO: Should we do the same? It just doesn't seem to make much sense
					// Side effect: It is now possible to have chunks where only two stars are connected (one line)
					// Original game engine requires at least two lines to be present to form a constellation
					constellations.add(start to end)

					constellationLines.add(proposed)
					constellationPoints.add(start)
					constellationPoints.add(end)
				}
			}

			return constellations
		}
	}

	private fun storeChunk(chunkPos: IStruct2i, chunk: UniverseChunk) {
		if (db == null) return
		val key = key(chunkPos)

		try {
			val data = Starbound.gson.toJsonTree(chunk)
			val binaryData = FastByteArrayOutputStream()
			val stream = DataOutputStream(BufferedOutputStream(DeflaterOutputStream(binaryData, Deflater(2))))
			stream.writeJsonElement(data)
			stream.close()

			carrier.execute {
				db.write(key, binaryData.array, 0, binaryData.length)
			}
		} catch (err: Throwable) {
			LOGGER.error("Error while saving universe chunk at $chunkPos, it will not persist", err)
		}
	}

	private val carrier = CarriedExecutor(Starbound.IO_EXECUTOR)
	val reader: UniverseSource = Reader()
	val generator: UniverseSource = Generator()

	override fun close() {
		carrier.shutdown()
		carrier.awaitTermination(Int.MAX_VALUE.toLong(), TimeUnit.MILLISECONDS)
		db?.close()
	}

	companion object {
		private val LOGGER = LogManager.getLogger()
	}
}
