package ru.dbotthepony.kstarbound.world

import com.google.gson.JsonObject
import ru.dbotthepony.kommons.io.koptional
import ru.dbotthepony.kommons.util.KOptional
import ru.dbotthepony.kommons.util.getValue
import ru.dbotthepony.kommons.util.setValue
import ru.dbotthepony.kommons.vector.Vector2d
import ru.dbotthepony.kommons.vector.Vector3i
import ru.dbotthepony.kstarbound.Globals
import ru.dbotthepony.kstarbound.Starbound
import ru.dbotthepony.kstarbound.defs.world.FloatingDungeonWorldParameters
import ru.dbotthepony.kstarbound.defs.world.SystemWorldObjectConfig
import ru.dbotthepony.kstarbound.io.readVector2d
import ru.dbotthepony.kstarbound.io.writeStruct2d
import ru.dbotthepony.kstarbound.json.builder.JsonFactory
import ru.dbotthepony.kstarbound.math.Interpolator
import ru.dbotthepony.kstarbound.network.syncher.MasterElement
import ru.dbotthepony.kstarbound.network.syncher.NetworkedGroup
import ru.dbotthepony.kstarbound.network.syncher.legacyCodec
import ru.dbotthepony.kstarbound.network.syncher.nativeCodec
import ru.dbotthepony.kstarbound.network.syncher.networkedData
import ru.dbotthepony.kstarbound.network.syncher.networkedFloat
import ru.dbotthepony.kstarbound.util.JVMClock
import ru.dbotthepony.kstarbound.util.random.MWCRandom
import ru.dbotthepony.kstarbound.util.random.nextRange
import ru.dbotthepony.kstarbound.util.random.random
import ru.dbotthepony.kstarbound.util.random.staticRandom64
import ru.dbotthepony.kstarbound.util.toStarboundString
import ru.dbotthepony.kstarbound.util.uuidFromStarboundString
import java.io.DataInputStream
import java.io.DataOutputStream
import java.util.UUID
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt

abstract class SystemWorld(val location: Vector3i, val clock: JVMClock, val universe: Universe) {
	val random = random()
	abstract val entities: Map<UUID, Entity>
	abstract val ships: Map<UUID, Ship>
	val systemLocation: UniversePos = UniversePos(location)

	suspend fun planetOrbitDistance(coordinate: UniversePos): Double {
		if (coordinate.isSystem)
			return 0.0

		val random = MWCRandom(compatCoordinateSeed(coordinate, "PlanetOrbitDistance").toULong(), cycle = 256, windupIterations = 32)
		var distance = planetSize(coordinate.parent()) / 2.0

		for (i in 0 until coordinate.orbitNumber) {
			if (i > 0) {
				distance += clusterSize(coordinate.parent().child(i))
			}

			if (coordinate.isPlanet)
				distance += random.nextFloat(Globals.systemWorld.planetaryOrbitPadding.x.toFloat(), Globals.systemWorld.planetaryOrbitPadding.y.toFloat())
			else if (coordinate.isSatellite)
				distance += random.nextFloat(Globals.systemWorld.satelliteOrbitPadding.x.toFloat(), Globals.systemWorld.satelliteOrbitPadding.y.toFloat())
		}

		distance += clusterSize(coordinate) / 2.0
		return distance
	}

	suspend fun clusterSize(coordinate: UniversePos): Double {
		if (coordinate.isPlanet && universe.children(coordinate.parent()).any { it.orbitNumber == coordinate.orbitNumber }) {
			val child = universe.children(coordinate).sorted()

			if (child.isNotEmpty()) {
				val outer = coordinate.child(child.last().orbitNumber)
				return planetOrbitDistance(outer) * 2.0 + planetSize(outer)
			} else {
				return planetSize(coordinate)
			}
		} else {
			return planetSize(coordinate)
		}
	}

	suspend fun planetSize(coordinate: UniversePos): Double {
		if (coordinate.isSystem)
			return Globals.systemWorld.starSize

		if (!universe.children(coordinate.parent()).any { it.orbitNumber == coordinate.orbitNumber })
			return Globals.systemWorld.emptyOrbitSize

		val parameters = universe.parameters(coordinate)

		if (parameters != null) {
			val visitable = parameters.visitableParameters

			if (visitable != null) {
				var size = 0.0

				if (visitable is FloatingDungeonWorldParameters) {
					val getSize = Globals.systemWorld.floatingDungeonWorldSizes[visitable.typeName]

					if (getSize != null) {
						return getSize
					}
				}

				for ((planetSize, orbitSize) in Globals.systemWorld.planetSizes) {
					if (visitable.worldSize.x >= planetSize)
						size = orbitSize
					else
						break
				}

				return size
			}
		}

		return Globals.systemWorld.unvisitablePlanetSize
	}

	fun orbitInterval(distance: Double, isSatellite: Boolean): Double {
		val gravityConstant = if (isSatellite) Globals.systemWorld.planetGravitationalConstant else Globals.systemWorld.starGravitationalConstant
		return distance * 2.0 * PI / sqrt(gravityConstant / distance)
	}

	fun compatCoordinateSeed(coordinate: UniversePos, seedMix: String): Long {
		// original code is utterly broken here

		// consider the following:
		// auto satellite = coordinate.isSatelliteBody() ? coordinate.orbitNumber() : 0;
		// auto planet = coordinate.isSatelliteBody() ? coordinate.parent().orbitNumber() : coordinate.isPlanetaryBody() && coordinate.orbitNumber() || 0;

		// first obvious problem: coordinate.isPlanetaryBody() && coordinate.orbitNumber() || 0
		// this "coalesces" planet orbit into either 0 or 1
		// then, we have coordinate.parent().orbitNumber(), which is correct, but only if we are orbiting a satellite

		// TODO: Use correct logic when there are no legacy clients in this system
		// Correct logic properly randomizes starting planet orbits, and they feel much more natural

		return staticRandom64(coordinate.location.x, coordinate.location.y, coordinate.location.z, if (coordinate.isPlanet) 1 else coordinate.planetOrbit, coordinate.satelliteOrbit, seedMix)
	}

	suspend fun planetPosition(coordinate: UniversePos): Vector2d {
		if (coordinate.isSystem)
			return Vector2d.ZERO

		// this thing must produce EXACT result between legacy client and new server
		val random = MWCRandom(compatCoordinateSeed(coordinate, "PlanetSystemPosition").toULong(), cycle = 256, windupIterations = 32)
		val parentPosition = planetPosition(coordinate.parent())
		val distance = planetOrbitDistance(coordinate)
		val interval = orbitInterval(distance, coordinate.isSatellite)

		val start = random.nextFloat().toDouble()
		val offset = (clock.time % interval) / interval
		val direction = if (random.nextFloat() > 0.5f) 1 else -1
		val angle = (start + direction * offset) * PI * 2.0
		return parentPosition + Vector2d(cos(angle) * distance, sin(angle) * distance)
	}

	suspend fun orbitPosition(orbit: Orbit): Vector2d {
		val targetPosition = if (orbit.target.isPlanet || orbit.target.isSatellite) planetPosition(orbit.target) else Vector2d.ZERO
		val distance = orbit.enterPosition.length
		val interval = orbitInterval(distance, false)
		val timeOffset = ((clock.time - orbit.enterTime) % interval) / interval
		val angle = (orbit.enterPosition * -1).toAngle() + orbit.direction * timeOffset * PI * 2.0
		return targetPosition + Vector2d(cos(angle) * distance, sin(angle) * distance)
	}

	fun randomArrivalPosition(): Vector2d {
		val range = random.nextRange(Globals.systemWorld.arrivalRange)
		val angle = random.nextDouble(0.0, PI * 2.0)
		return Vector2d(cos(angle), sin(angle)) * range
	}

	data class Orbit(val target: UniversePos, val direction: Int, val enterTime: Double, val enterPosition: Vector2d) {
		constructor(stream: DataInputStream, isLegacy: Boolean) : this(
			UniversePos(stream, isLegacy),
			if (isLegacy) stream.readInt() else stream.readByte().toInt(),
			stream.readDouble(),
			stream.readVector2d(isLegacy)
		)

		fun write(stream: DataOutputStream, isLegacy: Boolean) {
			target.write(stream, isLegacy)
			if (isLegacy) stream.writeInt(direction) else stream.writeByte(direction)
			stream.writeDouble(enterTime)
			stream.writeStruct2d(enterPosition, isLegacy)
		}

		companion object {
			val CODEC = nativeCodec(::Orbit, Orbit::write).koptional()
			val LEGACY_CODEC = legacyCodec(::Orbit, Orbit::write).koptional()
		}
	}

	@JsonFactory
	data class EntityJsonData(
		val name: String,
		val uuid: String,
		val parameters: JsonObject = JsonObject(),
		val spawnTime: Double,
		val position: Vector2d,
		val orbit: Orbit? = null,
	) {
		val actualUUID = uuidFromStarboundString(uuid)
	}

	abstract inner class Ship(val uuid: UUID, location: SystemWorldLocation) {
		var speed = Globals.systemWorld.clientShip.speed
		var departTimer = 0.0

		val networkGroup = MasterElement(NetworkedGroup())

		// systemLocation should not be interpolated
		// if it's stale it can point to a removed system object
		var location by networkedData(location, SystemWorldLocation.CODEC, SystemWorldLocation.LEGACY_CODEC).also { networkGroup.upstream.add(it, false) }
		var destination by networkedData(SystemWorldLocation.Transit, SystemWorldLocation.CODEC, SystemWorldLocation.LEGACY_CODEC).also { networkGroup.upstream.add(it, false) }

		var xPosition by networkedFloat().also { networkGroup.upstream.add(it); it.interpolator = Interpolator.Linear }
		var yPosition by networkedFloat().also { networkGroup.upstream.add(it); it.interpolator = Interpolator.Linear }

		var orbit: Orbit? = null

		var position: Vector2d
			get() = Vector2d(xPosition, yPosition)
			set(value) {
				xPosition = value.x
				yPosition = value.y
			}

		init {
			networkGroup.upstream.enableInterpolation()
		}
	}

	abstract inner class Entity(val data: SystemWorldObjectConfig.Data, val uuid: UUID, position: Vector2d, val spawnTime: Double = 0.0, val parameters: JsonObject = JsonObject()) {
		constructor(data: EntityJsonData) : this(
			Globals.systemObjects[data.name]?.create(data.actualUUID, data.name) ?: throw NullPointerException("Tried to create ${data.name} system world object, but there is no such object in /system_objects.config!"),
			data.actualUUID,
			data.position,
			data.spawnTime,
			data.parameters
		) {
			if (data.orbit != null) {
				orbit = KOptional(data.orbit)
			}
		}

		constructor(data: JsonObject) : this(Starbound.gson.fromJson(data, EntityJsonData::class.java))

		val networkGroup = MasterElement(NetworkedGroup())

		var xPosition by networkedFloat().also { networkGroup.upstream.add(it); it.interpolator = Interpolator.Linear }
		var yPosition by networkedFloat().also { networkGroup.upstream.add(it); it.interpolator = Interpolator.Linear }
		var orbit by networkedData(KOptional(), Orbit.CODEC, Orbit.LEGACY_CODEC).also { networkGroup.upstream.add(it) }

		var approach: UniversePos? = null
			protected set

		var position: Vector2d
			get() = Vector2d(xPosition, yPosition)
			set(value) {
				xPosition = value.x
				yPosition = value.y
			}

		init {
			this.position = position
		}

		fun enterOrbit(target: UniversePos, position: Vector2d, time: Double) {
			val direction = if (random.nextBoolean()) -1 else 1
			orbit = KOptional(Orbit(target, direction, time, position - this.position))
			approach = null
		}

		fun toJson(): JsonObject {
			return Starbound.gson.toJsonTree(EntityJsonData(
				name = data.name,
				uuid = uuid.toStarboundString(),
				parameters = parameters,
				spawnTime = spawnTime,
				position = position,
				orbit = orbit.orNull(),
			)) as JsonObject
		}
	}
}
