package ru.dbotthepony.kstarbound.world

import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonNull
import com.google.gson.JsonObject
import it.unimi.dsi.fastutil.ints.Int2BooleanOpenHashMap
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap
import it.unimi.dsi.fastutil.ints.IntOpenHashSet
import it.unimi.dsi.fastutil.ints.IntSet
import it.unimi.dsi.fastutil.ints.IntSets
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap
import it.unimi.dsi.fastutil.objects.Object2DoubleOpenHashMap
import it.unimi.dsi.fastutil.objects.ObjectArrayList
import org.apache.logging.log4j.LogManager
import ru.dbotthepony.kommons.arrays.Object2DArray
import ru.dbotthepony.kommons.collect.filterNotNull
import ru.dbotthepony.kommons.gson.set
import ru.dbotthepony.kommons.util.Either
import ru.dbotthepony.kommons.util.IStruct2d
import ru.dbotthepony.kommons.util.IStruct2i
import ru.dbotthepony.kstarbound.Registry
import ru.dbotthepony.kstarbound.math.AABB
import ru.dbotthepony.kstarbound.math.AABBi
import ru.dbotthepony.kstarbound.math.vector.Vector2d
import ru.dbotthepony.kstarbound.math.vector.Vector2i
import ru.dbotthepony.kstarbound.Starbound
import ru.dbotthepony.kstarbound.defs.tile.ARTIFICIAL_DUNGEON_ID
import ru.dbotthepony.kstarbound.defs.tile.DESTROYED_DUNGEON_ID
import ru.dbotthepony.kstarbound.defs.tile.LiquidDefinition
import ru.dbotthepony.kstarbound.defs.tile.TileDamage
import ru.dbotthepony.kstarbound.defs.tile.TileDamageResult
import ru.dbotthepony.kstarbound.defs.tile.isNotEmptyLiquid
import ru.dbotthepony.kstarbound.defs.world.WorldStructure
import ru.dbotthepony.kstarbound.defs.world.WorldTemplate
import ru.dbotthepony.kstarbound.json.mergeJson
import ru.dbotthepony.kstarbound.math.*
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.EntityMessagePacket
import ru.dbotthepony.kstarbound.network.packets.HitRequestPacket
import ru.dbotthepony.kstarbound.network.packets.clientbound.SetPlayerStartPacket
import ru.dbotthepony.kstarbound.util.ActionPacer
import ru.dbotthepony.kstarbound.util.BlockableEventLoop
import ru.dbotthepony.kstarbound.util.random.random
import ru.dbotthepony.kstarbound.world.api.ICellAccess
import ru.dbotthepony.kstarbound.world.api.AbstractCell
import ru.dbotthepony.kstarbound.world.api.TileView
import ru.dbotthepony.kstarbound.world.entities.AbstractEntity
import ru.dbotthepony.kstarbound.world.entities.DynamicEntity
import ru.dbotthepony.kstarbound.world.entities.MovementController
import ru.dbotthepony.kstarbound.world.entities.tile.TileEntity
import ru.dbotthepony.kstarbound.world.physics.CollisionPoly
import ru.dbotthepony.kstarbound.world.physics.CollisionType
import ru.dbotthepony.kstarbound.world.physics.Poly
import java.util.*
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.locks.ReentrantLock
import java.util.function.Predicate
import java.util.random.RandomGenerator
import java.util.stream.Stream
import kotlin.collections.ArrayList
import kotlin.collections.HashMap
import kotlin.math.roundToInt

abstract class World<This : World<This, ChunkType>, ChunkType : Chunk<This, ChunkType>>(val template: WorldTemplate) : ICellAccess {
	val background = TileView.Background(this)
	val foreground = TileView.Foreground(this)
	val sky = Sky(template.skyParameters)
	val geometry: WorldGeometry = template.geometry

	val nextEntityID = AtomicInteger()

	override fun getCell(x: Int, y: Int): AbstractCell {
		return chunkMap.getCell(x, y)
	}

	override fun setCell(x: Int, y: Int, cell: AbstractCell): Boolean {
		return chunkMap.setCell(x, y, cell, ChunkState.EMPTY)
	}

	override fun setCell(x: Int, y: Int, cell: AbstractCell, chunkState: ChunkState): Boolean {
		return chunkMap.setCell(x, y, cell, chunkState)
	}

	protected open fun onChunkCreated(chunk: ChunkType) { }
	protected open fun onChunkRemoved(chunk: ChunkType) { }

	abstract inner class ChunkMap {
		abstract operator fun get(x: Int, y: Int): ChunkType?
		abstract fun compute(x: Int, y: Int): ChunkType?
		abstract fun chunks(): List<ChunkType>
		abstract fun remove(x: Int, y: Int)

		operator fun get(pos: ChunkPos) = get(pos.x, pos.y)

		fun compute(pos: ChunkPos) = compute(pos.x, pos.y)
		fun remove(pos: ChunkPos) = remove(pos.x, pos.y)

		fun getCell(x: Int, y: Int): AbstractCell {
			val ix = geometry.x.cell(x)
			val iy = geometry.y.cell(y)
			val chunk = get(geometry.x.chunkFromCell(ix), geometry.y.chunkFromCell(iy)) ?: return AbstractCell.NULL
			return chunk.getCell(ix and CHUNK_SIZE_MASK, iy and CHUNK_SIZE_MASK)
		}

		fun getCellDirect(ix: Int, iy: Int): AbstractCell {
			val chunk = get(geometry.x.chunkFromCell(ix), geometry.y.chunkFromCell(iy)) ?: return AbstractCell.NULL
			return chunk.getCell(ix and CHUNK_SIZE_MASK, iy and CHUNK_SIZE_MASK)
		}

		fun setCell(x: Int, y: Int, cell: AbstractCell, state: ChunkState): Boolean {
			val ix = geometry.x.cell(x)
			val iy = geometry.y.cell(y)
			val chunk = get(geometry.x.chunkFromCell(ix), geometry.y.chunkFromCell(iy)) ?: return false
			return chunk.setCell(ix and CHUNK_SIZE_MASK, iy and CHUNK_SIZE_MASK, cell, state)
		}

		fun getCell(position: IStruct2i) = getCell(position.component1(), position.component2())
		fun setCell(position: IStruct2i, cell: AbstractCell) = setCell(position.component1(), position.component2(), cell)

		abstract val size: Int
	}

	// around 30% slower than ArrayChunkMap, but can support insanely large worlds
	inner class SparseChunkMap : ChunkMap() {
		private val map = Long2ObjectOpenHashMap<ChunkType>()
		// see CONCURRENT_SPARSE_CHUNK_MAP
		private val lock = Any()
		private val list = CopyOnWriteArrayList<ChunkType>()

		override fun get(x: Int, y: Int): ChunkType? {
			if (!geometry.x.inBoundsChunk(x) || !geometry.y.inBoundsChunk(y)) return null

			if (CONCURRENT_SPARSE_CHUNK_MAP) {
				synchronized(lock) {
					return map[ChunkPos.toLong(x, y)]
				}
			} else {
				return map[ChunkPos.toLong(x, y)]
			}
		}

		override fun compute(x: Int, y: Int): ChunkType? {
			if (!geometry.x.inBoundsChunk(x) || !geometry.y.inBoundsChunk(y)) return null
			val index = ChunkPos.toLong(x, y)

			if (CONCURRENT_SPARSE_CHUNK_MAP) {
				synchronized(lock) {
					return map[index] ?: chunkFactory(ChunkPos(x, y)).also {
						list.add(it)
						map[index] = it
						onChunkCreated(it)
					}
				}
			} else {
				return map[index] ?: chunkFactory(ChunkPos(x, y)).also {
					list.add(it)
					map[index] = it
					onChunkCreated(it)
				}
			}
		}

		override fun remove(x: Int, y: Int) {
			val index = ChunkPos.toLong(geometry.x.chunk(x), geometry.y.chunk(y))

			if (CONCURRENT_SPARSE_CHUNK_MAP) {
				synchronized(lock) {
					val chunk = map.get(index)

					if (chunk != null) {
						chunk.remove()
						onChunkRemoved(chunk)
						list.add(chunk)
						map.remove(index)
					}
				}
			} else {
				val chunk = map.get(index)

				if (chunk != null) {
					chunk.remove()
					onChunkRemoved(chunk)
					list.add(chunk)
					map.remove(index)
				}
			}
		}

		override fun chunks(): List<ChunkType> {
			return list
		}

		override val size: Int
			get() = map.size
	}

	inner class ArrayChunkMap : ChunkMap() {
		private val map = Object2DArray.nulls<ChunkType>(divideUp(geometry.size.x, CHUNK_SIZE), divideUp(geometry.size.y, CHUNK_SIZE))
		private val list = CopyOnWriteArrayList<ChunkType>()

		override fun compute(x: Int, y: Int): ChunkType? {
			if (!geometry.x.inBoundsChunk(x) || !geometry.y.inBoundsChunk(y)) return null
			return map[x, y] ?: chunkFactory(ChunkPos(x, y)).also {
				list.add(it)
				map[x, y] = it
				onChunkCreated(it)
			}
		}

		override fun get(x: Int, y: Int): ChunkType? {
			if (!geometry.x.inBoundsChunk(x) || !geometry.y.inBoundsChunk(y)) return null
			return map[x, y]
		}

		@Suppress("NAME_SHADOWING")
		override fun remove(x: Int, y: Int) {
			val x = geometry.x.chunk(x)
			val y = geometry.y.chunk(y)
			val chunk = map[x, y]

			if (chunk != null) {
				chunk.remove()
				onChunkRemoved(chunk)
				list.remove(chunk)
				map[x, y] = null
			}
		}

		override fun chunks(): List<ChunkType> {
			return list
		}

		override val size: Int
			get() = list.size
	}

	val chunkMap: ChunkMap = if (geometry.size.x <= 32000 && geometry.size.y <= 32000) ArrayChunkMap() else SparseChunkMap()

	/**
	 * Random generator for in-world events
 	 */
	val random: RandomGenerator = random()

	val isClient: Boolean
		get() = connectionID != 0
	val isServer: Boolean
		get() = connectionID == 0

	/**
	 * 0 means server, anything else represent client's connection ID
	 */
	abstract val connectionID: Int

	// generic lock
	val lock = ReentrantLock()

	/**
	 * All entities existing in this world, local and remote alike
	 */
	val entities = Int2ObjectOpenHashMap<AbstractEntity>()

	/**
	 * Entities with set unique (symbolic) id in this world
	 */
	val uniqueEntities = HashMap<String, AbstractEntity>()

	/**
	 * [entities] values but in fast traversal and copy on write list
	 */
	val entityList = CopyOnWriteArrayList<AbstractEntity>()

	/**
	 * Entity spatial index
	 */
	val entityIndex = EntityIndex(geometry)

	/**
	 * Entities with movement controller, to be simulated in parallel
	 */
	val dynamicEntities = ArrayList<DynamicEntity>()

	var playerSpawnPosition = Vector2d.ZERO
		protected set

	var respawnInWorld = false
		protected set

	var adjustPlayerSpawn = false

	var ticks = 0L
		private set

	abstract val eventLoop: BlockableEventLoop

	open fun broadcast(packet: IPacket) {

	}

	open var centralStructure: WorldStructure = WorldStructure()
		protected set

	protected val protectedDungeonIDsInternal = IntOpenHashSet()
	protected val dungeonGravityInternal = Int2ObjectOpenHashMap<Vector2d>()
	protected val dungeonBreathableInternal = Int2BooleanOpenHashMap()
	protected val worldProperties = JsonObject()

	val protectedDungeonIDs: IntSet = IntSets.unmodifiable(protectedDungeonIDsInternal)

	var enableDungeonTileProtection = true

	fun isDungeonIDProtected(id: Int): Boolean {
		if (!enableDungeonTileProtection)
			return false

		return id in protectedDungeonIDsInternal
	}

	fun isDungeonIDActuallyProtected(id: Int): Boolean {
		return id in protectedDungeonIDsInternal
	}

	open fun switchDungeonIDProtection(id: Int, enable: Boolean): Boolean {
		return if (enable) {
			protectedDungeonIDsInternal.remove(id)
		} else {
			protectedDungeonIDsInternal.add(id)
		}
	}

	fun enableDungeonIDProtection(id: Int): Boolean {
		return switchDungeonIDProtection(id, true)
	}

	fun disableDungeonIDProtection(id: Int): Boolean {
		return switchDungeonIDProtection(id, false)
	}

	open fun setDungeonGravity(id: Int, gravity: Vector2d?): Boolean {
		if (gravity == null) {
			return dungeonGravityInternal.remove(id) != null
		} else {
			return dungeonGravityInternal.put(id, gravity) != gravity
		}
	}

	fun setDungeonGravity(id: Int, gravity: Double): Boolean {
		return setDungeonGravity(id, Vector2d(y = gravity))
	}

	fun unsetDungeonGravity(id: Int): Boolean {
		return setDungeonGravity(id, null)
	}

	open fun setDungeonBreathable(id: Int, breathable: Boolean?): Boolean {
		if (breathable == null) {
			val had = dungeonBreathableInternal.containsKey(id)
			if (had) dungeonBreathableInternal.remove(id)
			return had
		} else {
			return dungeonBreathableInternal.put(id, breathable) != breathable
		}
	}

	fun unsetDungeonBreathable(id: Int): Boolean {
		return setDungeonBreathable(id, null)
	}

	fun copyProperties(): JsonObject = worldProperties.deepCopy()

	fun updateProperties(properties: JsonObject) {
		mergeJson(worldProperties, properties)
	}

	fun getProperty(name: String): JsonElement {
		return worldProperties[name] ?: JsonNull.INSTANCE
	}

	fun getProperty(name: String, orElse: JsonElement): JsonElement {
		return worldProperties[name] ?: orElse
	}

	fun getProperty(name: String, orElse: () -> JsonElement): JsonElement {
		return worldProperties[name] ?: orElse()
	}

	protected open fun setProperty0(key: String, value: JsonElement) {

	}

	fun setProperty(key: String, value: JsonElement) {
		if (worldProperties[key] == value)
			return

		val copy = value.deepCopy()
		worldProperties[key] = copy
		setProperty0(key, copy)
	}

	open fun setPlayerSpawn(position: Vector2d, respawnInWorld: Boolean) {
		playerSpawnPosition = position
		this.respawnInWorld = respawnInWorld
		broadcast(SetPlayerStartPacket(position, respawnInWorld))
	}

	open fun tick(delta: Double) {
		ticks++

		if (dynamicEntities.size < 128) {
			dynamicEntities.forEach {
				if (!it.isRemote) {
					it.movement.move(delta)
				} else {
					it.movement.tickRemote(delta)
				}
			}
		} else {
			val tasks = ArrayList<CompletableFuture<*>>()
			var batch = ArrayList<MovementController>()

			for (entity in dynamicEntities) {
				batch.add(entity.movement)

				if (batch.size == 32) {
					val b = batch
					tasks.add(CompletableFuture.runAsync(Runnable { b.forEach { it.move(delta) } }, Starbound.EXECUTOR))
					batch = ArrayList()
				}
			}

			if (batch.isNotEmpty()) {
				tasks.add(CompletableFuture.runAsync(Runnable { batch.forEach { it.move(delta) } }, Starbound.EXECUTOR))
			}

			CompletableFuture.allOf(*tasks.toTypedArray()).join()
		}

		// FIXME: this will throw an exception if entity is removing another entity
		//  which haven't ticked yet
		entityList.forEach {
			try {
				if (it.isInWorld) // entities might remove other entities during tick
					it.tick(delta)
			} catch (err: Throwable) {
				if (it.isRemote && isServer) {
					LOGGER.error("Exception ticking client owned entity $it, removing", err)
					it.remove(AbstractEntity.RemovalReason.REMOVED)
				} else {
					throw err
				}
			}
		}

		for (chunk in chunkMap.chunks())
			chunk.tick(delta)

		sky.tick(delta)
	}

	protected abstract fun chunkFactory(pos: ChunkPos): ChunkType

	fun entitiesAtTile(pos: Vector2i, filter: Predicate<TileEntity> = Predicate { true }): List<TileEntity> {
		return entityIndex.query(
			AABBi(pos, pos + Vector2i.POSITIVE_XY).toDoubleAABB(),
			filter = { it is TileEntity && pos in it.occupySpaces && filter.test(it) }
		) as List<TileEntity>
	}

	fun interface CellPredicate {
		fun test(x: Int, y: Int, cell: AbstractCell): Boolean
	}

	fun anyCellSatisfies(aabb: AABBi, predicate: CellPredicate): Boolean {
		for (x in aabb.mins.x .. aabb.maxs.x) {
			for (y in aabb.mins.x .. aabb.maxs.x) {
				val ix = geometry.x.cell(x)
				val iy = geometry.x.cell(y)


				if (predicate.test(ix, iy, chunkMap.getCellDirect(ix, iy))) {
					return true
				}
			}
		}

		return false
	}

	fun anyCellSatisfies(aabb: AABB, predicate: CellPredicate): Boolean {
		for (x in aabb.mins.x.toInt() .. aabb.maxs.x.roundToInt()) {
			for (y in aabb.mins.y.toInt() .. aabb.maxs.y.roundToInt()) {
				val ix = geometry.x.cell(x)
				val iy = geometry.x.cell(y)

				if (predicate.test(ix, iy, chunkMap.getCellDirect(ix, iy))) {
					return true
				}
			}
		}

		return false
	}

	fun anyCellSatisfies(x: Int, y: Int, distance: Int, predicate: CellPredicate): Boolean {
		for (tx in (x - distance) .. (x + distance)) {
			for (ty in (y - distance) .. (y + distance)) {
				val ix = geometry.x.cell(tx)
				val iy = geometry.y.cell(ty)

				if (predicate.test(ix, iy, chunkMap.getCellDirect(ix, iy))) {
					return true
				}
			}
		}

		return false
	}

	fun queryTileCollisions(aabb: AABB): MutableList<CollisionPoly> {
		val result = ObjectArrayList<CollisionPoly>() // no CME checks
		val tiles = aabb.encasingIntAABB()

		for (x in tiles.mins.x .. tiles.maxs.x) {
			for (y in tiles.mins.y .. tiles.maxs.y) {
				val cx = geometry.x.cell(x)
				val cy = geometry.y.cell(y)

				val chunk = chunkMap[geometry.x.chunkFromCell(cx), geometry.y.chunkFromCell(cy)] ?: continue
				chunk.getCollisions(cx - chunk.pos.tileX, cy - chunk.pos.tileY, result)
			}
		}

		return result
	}

	fun collide(with: Poly, filter: Predicate<CollisionPoly>): Stream<Poly.Penetration> {
		return queryTileCollisions(with.aabb.enlarge(1.0, 1.0)).stream()
			.filter(filter)
			.map { with.intersect(it.poly) }
			.filterNotNull()
	}

	fun collide(point: Vector2d, filter: Predicate<CollisionPoly>): Boolean {
		return queryTileCollisions(AABB.withSide(point, 2.0)).any { filter.test(it) && point in it.poly }
	}

	data class LineCollisionResult(val poly: CollisionPoly, val border: Vector2d, val normal: Vector2d)

	// ugly.
	// but original code is way worse than this, because it considers A FUCKING RECTANGLE
	// holy shiet
	// we, on other hand, consider only tiles along line
	fun collide(line: Line2d, filter: Predicate<CollisionPoly>): LineCollisionResult? {
		var found: LineCollisionResult? = null

		castRay(line.p0, line.p1) { cell, fraction, x, y, normal, borderX, borderY ->
			val query = queryTileCollisions(AABB.withSide(Vector2d(borderX, borderY), 2.0))

			for (poly in query) {
				if (filter.test(poly)) {
					val intersect = poly.poly.intersect(line)

					if (intersect != null) {
						found = LineCollisionResult(poly, intersect.second.point!!, intersect.first)
						return@castRay RayFilterResult.BREAK
					}
				}
			}

			RayFilterResult.SKIP
		}

		return found
	}

	fun polyIntersects(with: Poly, filter: Predicate<CollisionPoly> = Predicate { true }, tolerance: Double = 0.0): Boolean {
		return collide(with, filter).anyMatch { it.penetration >= tolerance }
	}

	fun polyIntersects(with: Poly, filter: Collection<CollisionType>, tolerance: Double = 0.0): Boolean {
		return polyIntersects(with, Predicate { it.type in filter }, tolerance)
	}

	fun gravityAt(pos: IStruct2i): Vector2d {
		val cell = getCell(pos)
		return dungeonGravityInternal[cell.dungeonId] ?: template.worldParameters?.gravity ?: Vector2d.ZERO
	}

	fun gravityAt(pos: IStruct2d): Vector2d {
		val cell = getCell(pos.component1().toInt(), pos.component2().toInt())
		return dungeonGravityInternal[cell.dungeonId] ?: template.worldParameters?.gravity ?: Vector2d.ZERO
	}

	fun isBreathable(pos: IStruct2i): Boolean {
		val cell = getCell(pos)
		return dungeonBreathableInternal.getOrDefault(cell.dungeonId, template.worldParameters?.airless != true)
	}

	fun isBreathable(pos: IStruct2d): Boolean {
		val cell = getCell(pos.component1().toInt(), pos.component2().toInt())
		return dungeonBreathableInternal.getOrDefault(cell.dungeonId, template.worldParameters?.airless != true)
	}

	data class LiquidLevel(val type: Registry.Entry<LiquidDefinition>, val average: Double)

	fun averageLiquidLevel(rect: AABB): LiquidLevel? {
		val liquidLevels = Object2DoubleOpenHashMap<Registry.Entry<LiquidDefinition>>()
		var area = 0.0

		anyCellSatisfies(rect) { x, y, cell ->
			val blockIncidence = AABB.leftCorner(Vector2d(x.toDouble(), y.toDouble()), 1.0, 1.0).overlap(rect).volume
			area += blockIncidence

			if (cell.liquid.state.isNotEmptyLiquid && cell.liquid.level > 0f) {
				liquidLevels.put(cell.liquid.state, liquidLevels.getDouble(cell.liquid.state) + cell.liquid.level.coerceAtMost(1f) * blockIncidence)
			}

			false
		}

		if (liquidLevels.isEmpty()) {
			return null
		}

		val max = liquidLevels.object2DoubleEntrySet().maxBy { it.doubleValue }!!
		return LiquidLevel(max.key, max.doubleValue / area)
	}

	/**
	 * returns unapplied tile modifications
	 */
	abstract fun applyTileModifications(modifications: Collection<Pair<Vector2i, TileModification>>, allowEntityOverlap: Boolean, ignoreTileProtection: Boolean = false): CompletableFuture<List<Pair<Vector2i, TileModification>>>

	fun addDamageNotification(notification: DamageNotificationPacket) {
		pushRemoteDamageNotification(notification)
		networkDamageNotification(notification)
	}

	fun addHitRequest(data: HitRequestPacket) {
		if (data.destinationConnection == connectionID)
			pushRemoteHitRequest(data)
		else
			networkHitRequest(data)
	}

	fun addDamageRequest(data: DamageRequestPacket) {
		if (data.destinationConnection == connectionID)
			pushRemoteDamageRequest(data)
		else
			networkDamageRequest(data)
	}

	open fun pushRemoteDamageNotification(notification: DamageNotificationPacket) {
		val entity = entities[notification.source]

		if (entity != null && !entity.isRemote && entity.entityID != notification.notification.targetEntityId) {
			entity.damagedOther(notification)
		}
	}

	fun pushRemoteHitRequest(data: HitRequestPacket) {
		require(data.destinationConnection == connectionID) { "RemoteDamageRequest routed to wrong DamageManager" }
		val inflictor = entities[data.inflictor] ?: return
		check(!inflictor.isRemote)
		inflictor.hitOther(data)
	}

	fun pushRemoteDamageRequest(data: DamageRequestPacket) {
		require(data.destinationConnection == connectionID) { "RemoteDamageRequest routed to wrong DamageManager" }
		val target = entities[data.target] ?: return
		check(!target.isRemote)

		for (notification in target.experienceDamage(data)) {
			addDamageNotification(DamageNotificationPacket(data.request.sourceEntityId, notification))
		}
	}

	protected abstract fun networkDamageNotification(notification: DamageNotificationPacket)
	protected abstract fun networkHitRequest(data: HitRequestPacket)
	protected abstract fun networkDamageRequest(data: DamageRequestPacket)

	abstract fun remote(connectionID: Int): Connection?

	// doesn't write stacktrace
	class MessageCallException(message: String) : RuntimeException(message, null, true, false)

	protected fun createRemoteEntityMessageFuture(sourceConnection: Int, entityID: Either<Int, String>, message: String, arguments: JsonArray): Pair<CompletableFuture<JsonElement>, EntityMessagePacket> {
		val future = CompletableFuture<JsonElement>()
		val uuid = UUID(random.nextLong(), random.nextLong())
		pendingEntityMessages.put(uuid, future)
		val packet = EntityMessagePacket(entityID, message, arguments, uuid, sourceConnection)
		return future to packet
	}

	fun dispatchEntityMessage(sourceConnection: Int, entityID: Int, message: String, arguments: JsonArray): CompletableFuture<JsonElement> {
		val connectionID = Connection.connectionForEntityID(entityID)

		if (connectionID == this.connectionID) {
			val entity = entities[entityID] ?: return CompletableFuture.failedFuture(MessageCallException("No such entity $entityID"))
			return entity.tryHandleMessage(sourceConnection, message, arguments)
		} else {
			val connection = remote(connectionID) ?: return CompletableFuture.failedFuture(NoSuchElementException("Can't dispatch entity message, no such connection $connectionID"))
			val (future, packet) = createRemoteEntityMessageFuture(sourceConnection, Either.left(entityID), message, arguments)
			connection.send(packet)
			return future
		}
	}

	abstract fun dispatchEntityMessage(sourceConnection: Int, entityID: String, message: String, arguments: JsonArray): CompletableFuture<JsonElement>

	// this *could* have been divided into per-entity map and beheaded world's map
	//  but we can't, because response packets contain only message UUID, and don't contain entity ID
	val pendingEntityMessages: Cache<UUID, CompletableFuture<JsonElement>> = Caffeine.newBuilder()
		.maximumSize(200_000L) // some unreachable value unless there is a malicious actor
		.expireAfterWrite(1L, TimeUnit.MINUTES)
		.executor(Starbound.EXECUTOR)
		.scheduler(Starbound)
		.removalListener<UUID, CompletableFuture<JsonElement>> { key, value, cause -> if (cause.wasEvicted()) value?.completeExceptionally(TimeoutException("Did not receive response from remote in time")) }
		.build()

	abstract fun findUniqueEntity(id: String): CompletableFuture<Vector2d?>

	fun isPlayerModified(region: AABBi): Boolean {
		return anyCellSatisfies(region) { _, _, cell ->
			cell.dungeonId == ARTIFICIAL_DUNGEON_ID || cell.dungeonId == DESTROYED_DUNGEON_ID
		}
	}

	fun environmentStatusEffects(pos: Vector2i): Set<String> {
		// TODO: judging by original code, they wanted to allow definition
		//  of custom environmental effects per dungeon id
		//  But it never happened
		return template.worldParameters?.environmentStatusEffects ?: setOf()
	}

	abstract fun damageTiles(positions: Collection<IStruct2i>, isBackground: Boolean, sourcePosition: Vector2d, damage: TileDamage, source: AbstractEntity? = null): CompletableFuture<TileDamageResult>

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

		/**
		 * Employ locking since concurrent reads can
		 * cause random exceptions in event of concurrent write
		 * (due to hash table resize)
		 *
		 * This obviously seriously impacts performance,
		 * but there is no other way unless we sacrifice concurrent
		 * chunk generation
		 *
		 * On other hand, currently, terrain is the real performance hog
		 * of world generation, everything else is kinda okay to be performed
		 * on main world thread, so concurrent access is not needed for now.
		 *
		 * ArrayChunkMap does ~not~ need synchronization, because 2D array is thread-safe to be read
		 * by multiple thread while one is writing to it (but it might lead to race condition if
		 * we try to read chunks which are currently being initialized).
		 */
		private const val CONCURRENT_SPARSE_CHUNK_MAP = false
	}
}
