package ru.dbotthepony.kstarbound.server.world

import com.google.gson.JsonElement
import com.google.gson.JsonObject
import it.unimi.dsi.fastutil.ints.IntArraySet
import it.unimi.dsi.fastutil.objects.ObjectArraySet
import kotlinx.coroutines.async
import kotlinx.coroutines.future.asCompletableFuture
import kotlinx.coroutines.future.await
import kotlinx.coroutines.launch
import org.apache.logging.log4j.LogManager
import ru.dbotthepony.kommons.util.AABB
import ru.dbotthepony.kommons.util.AABBi
import ru.dbotthepony.kommons.util.IStruct2i
import ru.dbotthepony.kommons.vector.Vector2d
import ru.dbotthepony.kommons.vector.Vector2i
import ru.dbotthepony.kstarbound.Globals
import ru.dbotthepony.kstarbound.Starbound
import ru.dbotthepony.kstarbound.defs.WarpAction
import ru.dbotthepony.kstarbound.defs.WarpAlias
import ru.dbotthepony.kstarbound.defs.WorldID
import ru.dbotthepony.kstarbound.defs.tile.TileDamage
import ru.dbotthepony.kstarbound.defs.tile.TileDamageResult
import ru.dbotthepony.kstarbound.defs.tile.TileDamageType
import ru.dbotthepony.kstarbound.defs.tile.isEmptyLiquid
import ru.dbotthepony.kstarbound.defs.world.WorldStructure
import ru.dbotthepony.kstarbound.defs.world.WorldTemplate
import ru.dbotthepony.kstarbound.json.builder.JsonFactory
import ru.dbotthepony.kstarbound.json.jsonArrayOf
import ru.dbotthepony.kstarbound.network.IPacket
import ru.dbotthepony.kstarbound.world.TileModification
import ru.dbotthepony.kstarbound.network.packets.StepUpdatePacket
import ru.dbotthepony.kstarbound.network.packets.clientbound.PlayerWarpResultPacket
import ru.dbotthepony.kstarbound.network.packets.clientbound.UpdateWorldPropertiesPacket
import ru.dbotthepony.kstarbound.server.StarboundServer
import ru.dbotthepony.kstarbound.server.ServerConnection
import ru.dbotthepony.kstarbound.util.AssetPathStack
import ru.dbotthepony.kstarbound.util.BlockableEventLoop
import ru.dbotthepony.kstarbound.util.random.random
import ru.dbotthepony.kstarbound.world.ChunkPos
import ru.dbotthepony.kstarbound.world.ChunkState
import ru.dbotthepony.kstarbound.world.World
import ru.dbotthepony.kstarbound.world.WorldGeometry
import ru.dbotthepony.kstarbound.world.entities.AbstractEntity
import ru.dbotthepony.kstarbound.world.entities.tile.TileEntity
import ru.dbotthepony.kstarbound.world.entities.tile.WorldObject
import ru.dbotthepony.kstarbound.world.physics.CollisionType
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.RejectedExecutionException
import java.util.concurrent.TimeUnit
import java.util.function.Supplier

class ServerWorld private constructor(
	val server: StarboundServer,
	template: WorldTemplate,
	val storage: WorldStorage,
	val worldID: WorldID,
) : World<ServerWorld, ServerChunk>(template) {
	init {
		if (server.isClosed)
			throw RuntimeException()
	}

	val clients = CopyOnWriteArrayList<ServerWorldTracker>()
	val shouldStopOnIdle = worldID !is WorldID.ShipWorld

	private fun doAcceptClient(client: ServerConnection, action: WarpAction?) {
		try {
			isBusy++

			if (clients.any { it.client == client })
				throw IllegalStateException("$client is already in $this")

			if (!client.isConnected)
				throw IllegalStateException("$client disconnected while joining $this")

			val start = if (action is WarpAction.Player)
				clients.firstOrNull { it.client.uuid == action.uuid }?.client?.playerEntity?.position
			else if (action is WarpAction.World)
				action.target.resolve(this)
			else
				playerSpawnPosition

			if (start == null) {
				client.send(PlayerWarpResultPacket(false, action!!, true))
				throw IllegalStateException("Not a valid spawn target: $action")
			}

			if (action != null)
				client.send(PlayerWarpResultPacket(true, action, false))

			client.tracker?.remove("Transiting to new world", false)
			clients.add(ServerWorldTracker(this, client, start))

			//if (worldID is WorldID.Celestial)
				//Registries.dungeons["terrainfeatures"]?.value?.generate(this@ServerWorld, random(), start.x.toInt(), start.y.toInt(), markSurfaceAndTerrain = false, forcePlacement = true)
		} finally {
			isBusy--
		}
	}

	fun acceptClient(player: ServerConnection, action: WarpAction? = null): CompletableFuture<Unit> {
		check(!eventLoop.isShutdown) { "$this is invalid" }

		try {
			val future = eventLoop.supplyAsync { doAcceptClient(player, action) }

			future.exceptionally {
				LOGGER.error("Error while accepting new player into world", it)
			}

			return future
		} catch (err: RejectedExecutionException) {
			return CompletableFuture.failedFuture(err)
		}
	}

	override val eventLoop = object : BlockableEventLoop("Server World $worldID") {
		init {
			isDaemon = true
		}

		override fun performShutdown() {
			LOGGER.info("Shutting down ${this@ServerWorld}")

			try {
				server.notifyWorldUnloaded(worldID)
			} catch (err: RejectedExecutionException) {
				// do nothing
			}

			chunkMap.chunks().forEach {
				it.cancelLoadJob()
			}

			clients.forEach {
				it.remove()
				it.client.enqueueWarp(WarpAlias.Return)
			}
		}
	}

	init {
		eventLoop.scheduleAtFixedRate(Runnable {
			tick(Starbound.TIMESTEP)
		}, 0L, Starbound.TIMESTEP_NANOS, TimeUnit.NANOSECONDS)
	}

	override fun toString(): String {
		return "Server World $worldID"
	}

	private var idleTicks = 0
	private var isBusy = 0

	override val isClient: Boolean
		get() = false

	fun damageTiles(positions: Collection<IStruct2i>, isBackground: Boolean, sourcePosition: Vector2d, damage: TileDamage, source: AbstractEntity? = null): TileDamageResult {
		if (damage.amount <= 0.0)
			return TileDamageResult.NONE

		val actualPositions = positions.stream()
			.map { geometry.wrap(it) }
			.distinct()
			.map { it to chunkMap[geometry.chunkFromCell(it)] }
			.toList()

		var topMost = TileDamageResult.NONE

		val damagedEntities = ObjectArraySet<TileEntity>()

		for ((pos, chunk) in actualPositions) {
			var damage = damage
			var tileEntityResult = TileDamageResult.NONE

			if (chunk?.getCell(pos - chunk.pos.tile)?.dungeonId in protectedDungeonIDs)
				damage = damage.copy(type = TileDamageType.PROTECTED)

			if (!isBackground) {
				for (entity in entitiesAtTile(pos)) {
					if (!damagedEntities.add(entity)) continue

					val occupySpaces = entity.occupySpaces.stream()
						.filter { p -> actualPositions.any { it.first == p } }
						.toList()

					val broken = entity.damage(occupySpaces, sourcePosition, damage)

					if (source != null && broken) {
						source.receiveMessage("tileEntityBroken", jsonArrayOf(pos, entity.type.jsonName, (entity as? WorldObject)?.config?.key))
					}

					if (damage.type == TileDamageType.PROTECTED)
						tileEntityResult = TileDamageResult.PROTECTED
					else
						tileEntityResult = TileDamageResult.NORMAL
				}
			}

			// Penetrating damage should carry through to the blocks behind this
			// entity.
			if (tileEntityResult == TileDamageResult.NONE || damage.type.isPenetrating) {
				chunk ?: continue
				val (result, health, tile) = chunk.damageTile(pos - chunk.pos.tile, isBackground, sourcePosition, damage, source)
				topMost = topMost.coerceAtLeast(result)

				if (source != null && health?.isDead == true) {
					source.receiveMessage("tileBroken", jsonArrayOf(
						pos, if (isBackground) "background" else "foreground",
						tile!!.tile(isBackground).material.id ?: 0, // TODO: string identifiers support
						tile.dungeonId,
						health.isHarvested
					))
				}
			}

			topMost = topMost.coerceAtLeast(tileEntityResult)
		}

		return topMost
	}

	fun applyTileModifications(modifications: Collection<Pair<Vector2i, TileModification>>, allowEntityOverlap: Boolean, ignoreTileProtection: Boolean = false): List<Pair<Vector2i, TileModification>> {
		val unapplied = ArrayList(modifications)
		var size: Int

		do {
			size = unapplied.size
			val itr = unapplied.iterator()

			for ((pos, modification) in itr) {
				val cell = getCell(pos)

				if (!ignoreTileProtection && cell.dungeonId in protectedDungeonIDs)
					continue

				if (modification.allowed(this, pos, allowEntityOverlap)) {
					modification.apply(this, pos, allowEntityOverlap)
					itr.remove()
				}
			}
		} while(unapplied.isNotEmpty() && size != unapplied.size)

		return unapplied
	}

	override fun tick(delta: Double) {
		try {
			if (clients.isEmpty() && isBusy <= 0) {
				idleTicks++
			} else {
				idleTicks = 0
			}

			if (idleTicks >= 600) {
				if (shouldStopOnIdle) {
					eventLoop.shutdown()
				}

				return
			}

			super.tick(delta)

			val packet = StepUpdatePacket(ticks)

			clients.forEach {
				it.send(packet)

				try {
					it.tick()
				} catch (err: Throwable) {
					LOGGER.error("Exception while ticking player ${it.client}", err)
					//it.disconnect("Exception while ticking player: $err")
				}
			}
		} catch (err: Throwable) {
			LOGGER.fatal("Exception in world tick loop", err)
			eventLoop.shutdown()
		}
	}

	override fun broadcast(packet: IPacket) {
		clients.forEach {
			it.send(packet)
		}
	}

	private suspend fun prepare0() {
		try {
			isBusy++

			val random = random(template.seed)
			var currentDungeonID = 0

			// primary dungeons must be generated sequentially since they can get very large
			// and to avoid dungeons colliding with each other, we must generate them first, one by one
			for (dungeon in template.gatherDungeons()) {
				var spawnDungeonRetries = Globals.worldServer.spawnDungeonRetries

				do {
					var x = dungeon.baseX

					if (dungeon.xVariance != 0)
						x += random.nextInt(dungeon.xVariance)

					x = geometry.x.cell(x)

					LOGGER.info("Trying to place dungeon ${dungeon.dungeon.key} at $x, ${dungeon.baseHeight}...")

					val dungeonWorld = try {
						dungeon.dungeon.value.generate(this, random(random.nextLong()), x, dungeon.baseHeight, dungeon.blendWithTerrain, dungeon.forcePlacement, dungeonID = currentDungeonID).await()
					} catch (err: Throwable) {
						LOGGER.error("Exception while placing dungeon ${dungeon.dungeon.key} at $x, ${dungeon.baseHeight}", err)
						// continue
						break
					}

					if (dungeonWorld.hasGenerated) {
						LOGGER.info("Placed dungeon ${dungeon.dungeon.key} at $x, ${dungeon.baseHeight}")

						if (dungeon.dungeon.value.metadata.protected) {
							protectedDungeonIDs.add(currentDungeonID)
						}

						if (dungeon.dungeon.value.metadata.gravity != null) {
							// TODO: set gravity here
						}

						if (dungeon.dungeon.value.metadata.breathable != null) {
							// TODO: set "breathable" here
						}

						currentDungeonID++
						break
					}
				} while (--spawnDungeonRetries > 0)
			}

			if (playerSpawnPosition == Vector2d.ZERO) {
				playerSpawnPosition = findPlayerStart()
			}
		} finally {
			isBusy--
		}
	}

	// done as completable future because we must do
	// everything inside our own thread, not anywhere else
	// This way, external callers can properly wait for preparations to complete
	fun prepare(): CompletableFuture<*> {
		return eventLoop.scope.async { prepare0() }.asCompletableFuture()
	}

	private suspend fun findPlayerStart(hint: Vector2d? = null): Vector2d {
		//if (true) {
		//	return Vector2d(10.0, template.surfaceLevel().toDouble())
		//}

		val tickets = ArrayList<ServerChunk.ITicket>()

		try {
			LOGGER.info("Trying to find player spawn position...")
			var pos = hint ?: CompletableFuture.supplyAsync(Supplier { template.findSensiblePlayerStart() }, Starbound.EXECUTOR).await() ?: Vector2d(0.0, template.surfaceLevel().toDouble())
			var previous = pos
			LOGGER.info("Trying to find player spawn position near $pos...")

			for (t in 0 until Globals.worldServer.playerStartRegionMaximumTries) {
				var foundGround = false

				// First go downward until we collide with terrain
				for (i in 0 until Globals.worldServer.playerStartRegionMaximumVerticalSearch) {
					val spawnRect = AABB(
						Vector2d(pos.x - Globals.worldServer.playerStartRegionSize.x / 2, pos.y),
						Vector2d(pos.x + Globals.worldServer.playerStartRegionSize.x / 2, pos.y + Globals.worldServer.playerStartRegionSize.y),
					)

					val region = permanentChunkTicket(spawnRect).await()
					tickets.addAll(region)
					region.forEach { it.chunk.await() }

					foundGround = anyCellSatisfies(spawnRect) { tx, ty, tcell ->
						tcell.foreground.material.value.collisionKind != CollisionType.NONE
					}

					if (foundGround) {
						break
					} else {
						pos += Vector2d.NEGATIVE_Y
					}
				}

				if (foundGround) {
					// Then go up until our spawn region is no longer in the terrain, but bail
					// out and try again if we can't signal the region or we are stuck in a
					// dungeon.

					for (i in 0 until Globals.worldServer.playerStartRegionMaximumVerticalSearch) {
						if (!getCell(pos.x.toInt(), pos.y.toInt()).liquid.state.isEmptyLiquid) {
							break
						}

						val spawnRect = AABB(
							Vector2d(pos.x - Globals.worldServer.playerStartRegionSize.x / 2, pos.y),
							Vector2d(pos.x + Globals.worldServer.playerStartRegionSize.x / 2, pos.y + Globals.worldServer.playerStartRegionSize.y),
						)

						val region = permanentChunkTicket(spawnRect).await()
						tickets.addAll(region)
						region.forEach { it.chunk.await() }

						if (!anyCellSatisfies(spawnRect) { tx, ty, tcell -> tcell.foreground.material.value.collisionKind != CollisionType.NONE } && spawnRect.maxs.y < geometry.size.y) {
							LOGGER.info("Found appropriate spawn position at $pos")
							return pos
						}

						pos += Vector2d.POSITIVE_Y
					}
				}

				pos = CompletableFuture.supplyAsync(Supplier { template.findSensiblePlayerStart() }, Starbound.EXECUTOR).await() ?: Vector2d(0.0, template.surfaceLevel().toDouble())

				if (previous != pos) {
					LOGGER.info("Still trying to find player spawn position near $pos...")
					previous = pos
				} else {
					break
				}
			}

			LOGGER.warn("Unable to find proper player start location, will use $pos")
			return pos
		} finally {
			tickets.forEach { it.cancel() }
		}
	}

	override fun setProperty0(key: String, value: JsonElement) {
		super.setProperty0(key, value)
		broadcast(UpdateWorldPropertiesPacket(JsonObject().apply { add(key, value) }))
	}

	override fun chunkFactory(pos: ChunkPos): ServerChunk {
		return ServerChunk(this, pos)
	}

	fun permanentChunkTicket(pos: ChunkPos, target: ChunkState = ChunkState.FULL): CompletableFuture<ServerChunk.ITicket?> {
		return eventLoop.supplyAsync { chunkMap.compute(pos)?.permanentTicket(target) }
	}

	fun permanentChunkTicket(region: AABBi, target: ChunkState = ChunkState.FULL): CompletableFuture<List<ServerChunk.ITicket>> {
		return eventLoop.supplyAsync { geometry.region2Chunks(region).mapNotNull { permanentChunkTicket(it, target).get() } }
	}

	fun permanentChunkTicket(region: AABB, target: ChunkState = ChunkState.FULL): CompletableFuture<List<ServerChunk.ITicket>> {
		return eventLoop.supplyAsync { geometry.region2Chunks(region).mapNotNull { permanentChunkTicket(it, target).get() } }
	}

	fun temporaryChunkTicket(pos: ChunkPos, time: Int, target: ChunkState = ChunkState.FULL): CompletableFuture<ServerChunk.ITimedTicket?> {
		return eventLoop.supplyAsync { chunkMap.compute(pos)?.temporaryTicket(time, target) }
	}

	fun temporaryChunkTicket(region: AABBi, time: Int, target: ChunkState = ChunkState.FULL): CompletableFuture<List<ServerChunk.ITimedTicket>> {
		require(time >= 0) { "Invalid ticket time: $time" }

		return eventLoop.supplyAsync { geometry.region2Chunks(region).mapNotNull { temporaryChunkTicket(it, time, target).get() } }
	}

	fun temporaryChunkTicket(region: AABB, time: Int, target: ChunkState = ChunkState.FULL): CompletableFuture<List<ServerChunk.ITimedTicket>> {
		require(time >= 0) { "Invalid ticket time: $time" }

		return eventLoop.supplyAsync { geometry.region2Chunks(region).mapNotNull { temporaryChunkTicket(it, time, target).get() } }
	}

	@JsonFactory
	data class MetadataJson(
		val playerStart: Vector2d,
		val respawnInWorld: Boolean,
		val adjustPlayerStart: Boolean,
		val worldTemplate: JsonObject,
		val centralStructure: WorldStructure,
		val protectedDungeonIds: IntArraySet,
		val worldProperties: JsonObject,
		val spawningEnabled: Boolean
	)

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

		fun create(server: StarboundServer, template: WorldTemplate, storage: WorldStorage, worldID: WorldID = WorldID.Limbo): ServerWorld {
			return ServerWorld(server, template, storage, worldID)
		}

		fun create(server: StarboundServer, geometry: WorldGeometry, storage: WorldStorage, worldID: WorldID = WorldID.Limbo): ServerWorld {
			return ServerWorld(server, WorldTemplate(geometry), storage, worldID)
		}

		fun create(server: StarboundServer, metadata: WorldStorage.Metadata, storage: WorldStorage, worldID: WorldID = WorldID.Limbo): ServerWorld {
			return AssetPathStack("/") { _ ->
				val meta = Starbound.gson.fromJson(metadata.data.content, MetadataJson::class.java)

				val world = ServerWorld(server, WorldTemplate.fromJson(meta.worldTemplate), storage, worldID)
				world.playerSpawnPosition = meta.playerStart
				world.respawnInWorld = meta.respawnInWorld
				world.adjustPlayerSpawn = meta.adjustPlayerStart
				world.centralStructure = meta.centralStructure

				for ((k, v) in meta.worldProperties.entrySet()) {
					world.setProperty(k, v)
				}

				world.protectedDungeonIDs.addAll(meta.protectedDungeonIds)
				world
			}
		}

		fun load(server: StarboundServer, storage: WorldStorage, worldID: WorldID = WorldID.Limbo): CompletableFuture<ServerWorld> {
			LOGGER.info("Attempting to load world at $worldID")

			return storage.loadMetadata().thenApply {
				LOGGER.info("Loading world at $worldID")
				AssetPathStack("/") { _ ->
					val meta = it.map { Starbound.gson.fromJson(it.data.content, MetadataJson::class.java) }.orThrow { NoSuchElementException("No world metadata is present") }

					val world = ServerWorld(server, WorldTemplate.fromJson(meta.worldTemplate), storage, worldID)
					world.playerSpawnPosition = meta.playerStart
					world.respawnInWorld = meta.respawnInWorld
					world.adjustPlayerSpawn = meta.adjustPlayerStart
					world.centralStructure = meta.centralStructure

					for ((k, v) in meta.worldProperties.entrySet()) {
						world.setProperty(k, v)
					}

					world.protectedDungeonIDs.addAll(meta.protectedDungeonIds)
					world
				}
			}.exceptionally {
				LOGGER.error("Error while instancing world $worldID", it)
				null
			}
		}
	}
}
