package ru.dbotthepony.kstarbound.server.world

import com.google.common.collect.ImmutableList
import com.google.common.collect.ImmutableSet
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonNull
import com.google.gson.JsonObject
import com.google.gson.JsonPrimitive
import it.unimi.dsi.fastutil.objects.Object2ObjectArrayMap
import it.unimi.dsi.fastutil.objects.Object2ObjectFunction
import it.unimi.dsi.fastutil.objects.ObjectArraySet
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.future.asCompletableFuture
import kotlinx.coroutines.future.await
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.apache.logging.log4j.LogManager
import ru.dbotthepony.kommons.util.Either
import ru.dbotthepony.kstarbound.math.AABB
import ru.dbotthepony.kstarbound.math.AABBi
import ru.dbotthepony.kommons.util.IStruct2i
import ru.dbotthepony.kstarbound.math.vector.Vector2d
import ru.dbotthepony.kstarbound.math.vector.Vector2i
import ru.dbotthepony.kstarbound.Globals
import ru.dbotthepony.kstarbound.Starbound
import ru.dbotthepony.kstarbound.VersionRegistry
import ru.dbotthepony.kstarbound.defs.WarpAction
import ru.dbotthepony.kstarbound.defs.WarpAlias
import ru.dbotthepony.kstarbound.defs.WorldID
import ru.dbotthepony.kstarbound.defs.actor.player.ShipUpgrades
import ru.dbotthepony.kstarbound.defs.tile.BuiltinMetaMaterials
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.tile.isEmptyTile
import ru.dbotthepony.kstarbound.defs.world.BiomePlaceables
import ru.dbotthepony.kstarbound.defs.world.BiomePlaceablesDefinition
import ru.dbotthepony.kstarbound.defs.world.WorldStructure
import ru.dbotthepony.kstarbound.defs.world.WorldTemplate
import ru.dbotthepony.kstarbound.fromJson
import ru.dbotthepony.kstarbound.json.builder.JsonFactory
import ru.dbotthepony.kstarbound.json.jsonArrayOf
import ru.dbotthepony.kstarbound.network.Connection
import ru.dbotthepony.kstarbound.network.IPacket
import ru.dbotthepony.kstarbound.network.packets.DamageNotificationPacket
import ru.dbotthepony.kstarbound.network.packets.DamageRequestPacket
import ru.dbotthepony.kstarbound.network.packets.HitRequestPacket
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.UpdateDungeonBreathablePacket
import ru.dbotthepony.kstarbound.network.packets.clientbound.UpdateDungeonGravityPacket
import ru.dbotthepony.kstarbound.network.packets.clientbound.UpdateDungeonProtectionPacket
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.ActionPacer
import ru.dbotthepony.kstarbound.util.ExecutionTimePacer
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.Collections
import java.util.PriorityQueue
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.RejectedExecutionException
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
import java.util.function.Supplier
import kotlin.coroutines.Continuation
import kotlin.coroutines.suspendCoroutine
import kotlin.math.max

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 suspend 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["naturalcave"]?.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.scope.async { doAcceptClient(player, action) }.asCompletableFuture()

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

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

	fun saveMetadata() {
		val metadata = MetadataJson(
			playerStart = playerSpawnPosition,
			respawnInWorld = respawnInWorld,
			adjustPlayerStart = adjustPlayerSpawn,
			worldTemplate = if (storage is LegacyWorldStorage) Starbound.legacyStoreJson { template.toJson() } else template.toJson(),
			centralStructure = centralStructure,
			protectedDungeonIds = ImmutableSet.copyOf(protectedDungeonIDsInternal),
			worldProperties = copyProperties(),
			spawningEnabled = true,
			dungeonIdBreathable = dungeonBreathableInternal.entries.stream().map { it.key to it.value }.collect(ImmutableList.toImmutableList()),
			dungeonIdGravity = if (storage is LegacyWorldStorage) {
				dungeonGravityInternal.entries.stream().map { it.key to Either.left<Double, Vector2d>(it.value.y) }.collect(ImmutableList.toImmutableList())
			} else {
				dungeonGravityInternal.entries.stream().map { it.key to Either.right<Double, Vector2d>(it.value) }.collect(ImmutableList.toImmutableList())
			}
		)

		if (storage is LegacyWorldStorage) {
			Starbound.legacyStoreJson {
				storage.saveMetadata(WorldStorage.Metadata(geometry, VersionRegistry.make("WorldMetadata", Starbound.gson.toJsonTree(metadata))))
			}
		} else {
			Starbound.storeJson {
				storage.saveMetadata(WorldStorage.Metadata(geometry, VersionRegistry.make("WorldMetadata", Starbound.gson.toJsonTree(metadata))))
			}
		}
	}

	private var uncleanShutdown = false

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

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

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

			if (!uncleanShutdown) {
				for (chunk in chunkMap.chunks()) {
					try {
						chunk.unload(isUnloadingWorld = true)
					} catch (err: Throwable) {
						LOGGER.error("Exception while saving chunk ${chunk.pos}", err)
						uncleanShutdown = true
						break
					}
				}
			}

			clients.forEach {
				LOGGER.info("Kicking ${it.client.alias()} off dying world")
				it.remove("World shutting down", setReturnWarp = !uncleanShutdown)

				if (!server.isShutdown)
					it.client.enqueueWarp(WarpAlias.Return)
			}

			if (!uncleanShutdown) {
				saveMetadata()
				storage.commit()
			}

			storage.close()
		}
	}

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

		if (worldID is WorldID.ShipWorld) {
			// due to dumb logic on legacy client side, committing frequently causes very noticeable performance degradation
			eventLoop.scheduleWithFixedDelay(Runnable {
				if (!uncleanShutdown)
					storage.commit()
			}, 20L, 20L, TimeUnit.SECONDS)
		}
	}

	private var placementTaskID = 0L

	private data class PlacementElement<T>(val x: Int, val y: Int, val id: Long, val callback: suspend (ExecutionTimePacer) -> T, val future: Continuation<T>) : Comparable<PlacementElement<*>> {
		override fun compareTo(other: PlacementElement<*>): Int {
			var cmp = x.compareTo(other.x)
			if (cmp == 0) cmp = y.compareTo(other.y)
			if (cmp == 0) cmp = id.compareTo(other.id)
			return cmp
		}
	}

	// not Channel because we must be able to select elements by priority
	private val placementQueue = PriorityQueue<PlacementElement<*>>()
	private var placementQueueIsActive = false
	private val placementPacer = ExecutionTimePacer(5_000_000L, 16L)

	private suspend fun placementQueueLoop() {
		placementQueueIsActive = true

		while (placementQueue.isNotEmpty()) {
			val next = placementQueue.remove() as PlacementElement<Any?>
			next.future.resumeWith(Result.success(next.callback(if (isInPreparation || clients.isEmpty()) ExecutionTimePacer.UNLIMITED else placementPacer)))
		}

		placementQueueIsActive = false
	}

	// this is used for scheduling and resolving microdungeon placement
	// tries to early-resolve artifacts like this: https://i.dbotthepony.ru/2024/04/28/gb6GdbLox7.png
	suspend fun <T> scheduleMicrodungeonPlacement(x: Int, y: Int, callback: suspend (ExecutionTimePacer) -> T): T {
		return suspendCoroutine {
			placementQueue.add(PlacementElement(x, y, placementTaskID++, callback, it))

			if (!placementQueueIsActive) {
				eventLoop.scope.launch { placementQueueLoop() }
			}
		}
	}

	// not data class for identity equals()
	class QueuedPlacement(
		val placeables: ImmutableList<BiomePlaceables.DistributionItem>,
		val future: CompletableFuture<Vector2i>,
		val dungeonId: Int?
	) {
		fun evaluate(x: Int, y: Int): WorldTemplate.PotentialBiomeItems {
			val surfaceBiomeItems = placeables.filter { it.mode == BiomePlaceablesDefinition.Placement.FLOOR }.map { it.itemToPlace(x, y) }.filterNotNull()
			val oceanItems = placeables.filter { it.mode == BiomePlaceablesDefinition.Placement.OCEAN }.map { it.itemToPlace(x, y) }.filterNotNull()

			return WorldTemplate.PotentialBiomeItems(
				surfaceBiomeItems = surfaceBiomeItems,
				oceanItems = oceanItems,
			)
		}
	}

	// we don't care about other threads reading stale data, because they will check future's completion
	// before executing actual placement
	private val queuedPlacementsInternal = CopyOnWriteArrayList<QueuedPlacement>()
	val queuedPlacements: List<QueuedPlacement> = Collections.unmodifiableList(queuedPlacementsInternal)

	fun enqueuePlacement(placement: List<BiomePlaceables.DistributionItem>, dungeonId: Int? = null): CompletableFuture<Vector2i> {
		val future = CompletableFuture<Vector2i>()
		val entry = QueuedPlacement(placeables = ImmutableList.copyOf(placement), future = future, dungeonId = dungeonId)
		queuedPlacementsInternal.add(entry)

		future.thenRun {
			queuedPlacementsInternal.remove(entry)
		}.exceptionally {
			queuedPlacementsInternal.remove(entry)
			null
		}

		return future
	}

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

	private var idleTicks = 0
	private var isBusy = 0

	private val wireProcessor = LegacyWireProcessor(this)

	override val connectionID: Int
		get() = 0

	private suspend fun replaceCentralStructure0(structure: WorldStructure) {
		val tickets = ArrayList<ServerChunk.ITicket>()

		try {
			var region = AABB
				.ofPoints(centralStructure.objects.map { it.position })
				.combine(AABB.ofPoints(centralStructure.foregroundBlocks.map { it.position }))
				.combine(AABB.ofPoints(centralStructure.backgroundBlocks.map { it.position }))
				.enlarge(4.0, 4.0)

			run {
				val getTickets = permanentChunkTicket(region, ChunkState.FULL).await()
				tickets.addAll(getTickets)
				getTickets.forEach { it.chunk.await() }
			}

			// remove old structure
			for (obj in centralStructure.objects) {
				if (!obj.residual) {
					val entities = entityIndex.tileEntitiesAt(obj.position)

					for (entity in entities) {
						if (entity is WorldObject && entity.config == obj.name.entry && entity.tilePosition == obj.position) {
							entity.remove(AbstractEntity.RemovalReason.REMOVED)
						}
					}
				}
			}

			for (block in centralStructure.backgroundBlocks) {
				if (!block.residual) {
					val cell = getCell(block.position).mutable()

					if (cell.background.material.ref == block.materialId.native) {
						cell.background.empty()
						check(setCell(block.position, cell))
					}
				}
			}

			for (block in centralStructure.foregroundBlocks) {
				if (!block.residual) {
					val cell = getCell(block.position).mutable()

					if (cell.foreground.material.ref == block.materialId.native) {
						cell.foreground.empty()
						check(setCell(block.position, cell))
					}
				}
			}

			// put new structure
			centralStructure = structure.resolve(geometry.size / 2)

			region = AABB
				.ofPoints(centralStructure.objects.map { it.position })
				.combine(AABB.ofPoints(centralStructure.foregroundBlocks.map { it.position }))
				.combine(AABB.ofPoints(centralStructure.backgroundBlocks.map { it.position }))
				.enlarge(4.0, 4.0)

			run {
				val getTickets = permanentChunkTicket(region, ChunkState.FULL).await()
				tickets.addAll(getTickets)
				getTickets.forEach { it.chunk.await() }
			}

			playerSpawnPosition = centralStructure.flaggedBlocks["playerSpawn"]?.firstOrNull()?.toDoubleVector() ?: (geometry.size.toDoubleVector() / 2.0)

			for (block in centralStructure.backgroundBlocks) {
				val cell = getCell(block.position).mutable()

				if (cell.background.material.isEmptyTile) {
					cell.background.material = block.materialId.native.entry ?: BuiltinMetaMaterials.EMPTY
					check(setCell(block.position, cell))
				}
			}

			for (block in centralStructure.foregroundBlocks) {
				val cell = getCell(block.position).mutable()

				if (cell.foreground.material.isEmptyTile) {
					cell.foreground.material = block.materialId.native.entry ?: BuiltinMetaMaterials.EMPTY
					check(setCell(block.position, cell))
				}
			}

			for (obj in centralStructure.objects) {
				val config = obj.name.entry ?: continue
				val orientation = config.value.findValidOrientation(this, obj.position, obj.direction, true)

				if (orientation == -1) {
					if (obj.residual) {
						LOGGER.debug("Tried to put residual object '{}' at {} for central structure, but it can't be placed there!", config.key, obj.position)
					} else {
						LOGGER.error("Tried to put object '${config.key}' at ${obj.position} for central structure, but it can't be placed there!")
					}
				} else {
					val create = WorldObject.create(config, obj.position, obj.parameters.deepCopy()) ?: continue
					create.orientationIndex = orientation.toLong()
					create.joinWorld(this)
				}
			}
		} finally {
			tickets.forEach { it.cancel() }
		}
	}

	fun replaceCentralStructure(structure: WorldStructure): Job {
		return eventLoop.scope.launch { replaceCentralStructure0(structure) }
	}

	override fun switchDungeonIDProtection(id: Int, enable: Boolean): Boolean {
		val updated = super.switchDungeonIDProtection(id, enable)

		if (updated) {
			if (enable) {
				LOGGER.info("Dungeon ID $id is now protected")
			} else {
				LOGGER.info("Dungeon ID $id is no longer protected")
			}

			broadcast(UpdateDungeonProtectionPacket(id, enable))
		}

		return updated
	}

	override fun setDungeonGravity(id: Int, gravity: Vector2d?): Boolean {
		val updated = super.setDungeonGravity(id, gravity)

		if (updated) {
			if (gravity != null) {
				LOGGER.info("Dungeon ID $id now has gravity: $gravity")
			} else {
				LOGGER.info("Dungeon ID $id no longer has special gravity")
			}

			broadcast(UpdateDungeonGravityPacket(id, gravity))
		}

		return updated
	}

	override fun setDungeonBreathable(id: Int, breathable: Boolean?): Boolean {
		val updated = super.setDungeonBreathable(id, breathable)

		if (updated) {
			if (breathable != null) {
				LOGGER.info("Dungeon ID $id breathable set to: $breathable")
			} else {
				LOGGER.info("Dungeon ID $id no longer has special breathable flag")
			}

			broadcast(UpdateDungeonBreathablePacket(id, breathable))
		}

		return updated
	}

	override fun damageTiles(
		positions: Collection<IStruct2i>,
		isBackground: Boolean,
		sourcePosition: Vector2d,
		damage: TileDamage,
		source: AbstractEntity?
	): CompletableFuture<TileDamageResult> {
		return runBlocking {
			CompletableFuture.completedFuture(damageTiles(positions, isBackground, sourcePosition, damage, source, null))
		}
	}

	/**
	 * this method does not block if pacer is null (safe to use with runBlocking {})
	 */
	suspend fun damageTiles(positions: Collection<IStruct2i>, isBackground: Boolean, sourcePosition: Vector2d, damage: TileDamage, source: AbstractEntity? = null, pacer: ActionPacer? = null): TileDamageResult {
		if (damage.amount <= 0.0)
			return TileDamageResult.NONE

		data class TileToDamage(val position: Vector2i, val chunk: ServerChunk?, val roots: MutableList<TileEntity> = ArrayList(0))

		val actualPositions = ObjectArraySet<TileToDamage>()

		for (pos in positions) {
			val wrapped = geometry.wrap(pos)
			val chunk = chunkMap[geometry.chunkFromCell(wrapped)]

			if (chunk != null) {
				val cell = chunk.getCell(wrapped - chunk.pos.tile)

				if (cell.rootSource != null) {
					actualPositions.add(TileToDamage(wrapped, chunk, entityIndex.tileEntitiesAt(geometry.wrap(cell.rootSource!!))))
				} else {
					actualPositions.add(TileToDamage(wrapped, chunk))
				}
			} else {
				actualPositions.add(TileToDamage(wrapped, chunk))
			}
		}

		var topMost = TileDamageResult.NONE
		val toDamageEntities = Object2ObjectArrayMap<TileEntity, ObjectArraySet<Vector2i>>()
		val entityDamageResults = HashMap<Vector2i, TileDamageResult>()

		for ((pos, chunk, roots) in actualPositions) {
			for (root in roots) {
				val posList = toDamageEntities.computeIfAbsent(root, Object2ObjectFunction { ObjectArraySet() })
				posList.add(pos)
				posList.addAll(root.roots)
				posList.add(root.tilePosition)
			}

			if (!isBackground) {
				for (entity in entitiesAtTile(pos)) {
					toDamageEntities.computeIfAbsent(entity, Object2ObjectFunction { ObjectArraySet() }).add(pos)
				}
			}
		}

		val killedEntities = ObjectArraySet<TileEntity>()

		for ((entity, damagePositions) in toDamageEntities.entries) {
			var actualDamage = damage

			for (pos in damagePositions) {
				val cell = getCell(pos)

				if (cell.dungeonId in protectedDungeonIDsInternal) {
					actualDamage = actualDamage.copy(type = TileDamageType.PROTECTED)
					entityDamageResults[pos] = TileDamageResult.PROTECTED
				} else {
					entityDamageResults[pos] = TileDamageResult.NORMAL
				}
			}

			val occupySpaces = entity.occupySpaces.stream()
				.filter { p -> p in damagePositions }
				.toList()

			pacer?.consume(10)
			val broken = entity.damageTileEntity(occupySpaces, sourcePosition, actualDamage)

			if (source != null && broken) {
				source.dispatchMessage(Connection.SERVER_CONNECTION_ID, "tileEntityBroken", jsonArrayOf(
					damagePositions.firstOrNull { p -> actualPositions.any { it.position == p } } ?: entity.tilePosition,
					entity.type.jsonName,
					(entity as? WorldObject)?.config?.key))
			}

			if (broken) {
				killedEntities.add(entity)
				entityDamageResults.keys.removeAll(damagePositions)
			}
		}

		actualPositions.removeIf {
			it.roots.removeIf { it in killedEntities }
			it.roots.isNotEmpty()
		}

		for ((pos, chunk) in actualPositions) {
			var actualDamage = damage
			val tileEntityResult = entityDamageResults[pos] ?: TileDamageResult.NONE
			val getCell = chunk?.getCell(pos - chunk.pos.tile)

			if (getCell != null) {
				if (getCell.dungeonId in protectedDungeonIDsInternal) {
					actualDamage = actualDamage.copy(type = TileDamageType.PROTECTED)
				}
			}

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

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

			topMost = topMost.coerceAtLeast(tileEntityResult)
		}

		return topMost
	}

	override fun applyTileModifications(modifications: Collection<Pair<Vector2i, TileModification>>, allowEntityOverlap: Boolean, ignoreTileProtection: Boolean): CompletableFuture<List<Pair<Vector2i, TileModification>>> {
		return runBlocking {
			CompletableFuture.completedFuture(applyTileModifications(modifications, allowEntityOverlap, ignoreTileProtection, null))
		}
	}

	suspend fun applyTileModifications(modifications: Collection<Pair<Vector2i, TileModification>>, allowEntityOverlap: Boolean, ignoreTileProtection: Boolean = false, pacer: ActionPacer?): 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 && isDungeonIDProtected(cell.dungeonId))
					continue

				if (modification.allowed(this, pos, allowEntityOverlap)) {
					pacer?.consume()
					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
			}

			if (!Globals.universeServer.useNewWireProcessing) {
				wireProcessor.tick()
			}

			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)
			uncleanShutdown = true
			eventLoop.shutdown()

			if (worldID is WorldID.ShipWorld) {
				server.channels.connectionByUUID(worldID.uuid)?.disconnect("Exception in shipworld tick loop: $err")
			}
		}
	}

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

	var isInPreparation = false
		private set

	private suspend fun prepare0(firstTime: Boolean) {
		try {
			isInPreparation = true
			isBusy++

			if (firstTime) {
				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)

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

							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) {
								protectedDungeonIDsInternal.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--
			isInPreparation = false
		}
	}

	// 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(firstTime: Boolean): CompletableFuture<*> {
		return eventLoop.scope.async { prepare0(firstTime) }.asCompletableFuture()
	}

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

		val tickets = ArrayList<ServerChunk.ITicket>()
		val random = if (hint == null) random(template.seed) else random()

		try {
			LOGGER.debug("Trying to find player spawn position...")
			var pos = hint ?: CompletableFuture.supplyAsync(Supplier { template.findSensiblePlayerStart(random) }, Starbound.EXECUTOR).await() ?: Vector2d(0.0, template.surfaceLevel.toDouble())
			var previous = pos
			LOGGER.debug("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.debug("Found appropriate spawn position at {}", pos)
							return pos
						}

						pos += Vector2d.POSITIVE_Y
					}
				}

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

				if (previous != pos) {
					LOGGER.debug("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() }
		}
	}

	private var scheduledMetadataSave: ScheduledFuture<*>? = null

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

		if (scheduledMetadataSave == null || scheduledMetadataSave!!.isDone) {
			scheduledMetadataSave = eventLoop.schedule(Runnable { saveMetadata() }, 10L, TimeUnit.SECONDS)
		}
	}

	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() } }
	}

	override fun networkDamageNotification(notification: DamageNotificationPacket) {
		for (client in clients) {
			if (notification.shouldNetworkTo(client)) {
				client.client.send(notification)
			}
		}
	}

	override fun networkHitRequest(data: HitRequestPacket) {
		server.channels.connectionByID(data.destinationConnection)?.send(data)
	}

	override fun networkDamageRequest(data: DamageRequestPacket) {
		server.channels.connectionByID(data.destinationConnection)?.send(data)
	}

	override fun remote(connectionID: Int): Connection? {
		return server.channels.connectionByID(connectionID)
	}

	override fun findUniqueEntity(id: String): CompletableFuture<Vector2d?> {
		val loaded = uniqueEntities[id]

		if (loaded != null) {
			return CompletableFuture.completedFuture(loaded.position)
		}

		return storage.findUniqueEntity(id).thenApply { it?.pos }
	}

	fun loadUniqueEntity(entityID: String): CompletableFuture<AbstractEntity?> {
		var loaded = uniqueEntities[entityID]

		if (loaded != null) {
			return CompletableFuture.completedFuture(loaded)
		}

		return eventLoop.scope.async {
			val (chunk) = storage.findUniqueEntity(entityID).await() ?: return@async null
			val ticket = permanentChunkTicket(chunk).await() ?: throw RuntimeException("how did we end up here")

			try {
				ticket.chunk.await()
				loaded = uniqueEntities[entityID]

				if (loaded == null) {
					// How?
					LOGGER.warn("Expected unique entity $entityID to be present inside $chunk, but after loading said chunk required entity is missing; world storage might be in corrupt state after unclean shutdown")
				}

				loaded
			} finally {
				ticket.cancel()
			}
		}.asCompletableFuture()
	}

	override fun dispatchEntityMessage(
		sourceConnection: Int,
		entityID: String,
		message: String,
		arguments: JsonArray
	): CompletableFuture<JsonElement> {
		return loadUniqueEntity(entityID).thenCompose {
			it ?: throw MessageCallException("No such unique entity $entityID")
			it.dispatchMessage(sourceConnection, message, arguments)
		}
	}

	@JsonFactory
	data class MetadataJson(
		val playerStart: Vector2d,
		val respawnInWorld: Boolean,
		val adjustPlayerStart: Boolean,
		val worldTemplate: JsonObject,
		val centralStructure: WorldStructure,
		val protectedDungeonIds: ImmutableSet<Int>,
		val worldProperties: JsonObject,
		val spawningEnabled: Boolean,
		val dungeonIdGravity: ImmutableList<Pair<Int, Either<Double, Vector2d>>> = ImmutableList.of(),
		val dungeonIdBreathable: ImmutableList<Pair<Int, Boolean>> = ImmutableList.of(),
	)

	class WorldMetadataMissingException : NoSuchElementException("No world metadata is present")

	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.protectedDungeonIDsInternal.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 {
				it ?: throw WorldMetadataMissingException()

				LOGGER.info("Loading world at $worldID")
				AssetPathStack("/") { _ ->
					val meta = Starbound.gson.fromJson(it.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.protectedDungeonIDsInternal.addAll(meta.protectedDungeonIds)
					world
				}
			}.also {
				it.exceptionally {
					LOGGER.error("Error while instancing world $worldID", it)
					null
				}
			}
		}
	}
}
