package ru.dbotthepony.kstarbound.server.world

import it.unimi.dsi.fastutil.bytes.ByteArrayList
import it.unimi.dsi.fastutil.ints.Int2LongOpenHashMap
import it.unimi.dsi.fastutil.ints.Int2ObjectFunction
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap
import it.unimi.dsi.fastutil.ints.IntArrayList
import it.unimi.dsi.fastutil.io.FastByteArrayOutputStream
import it.unimi.dsi.fastutil.objects.ObjectAVLTreeSet
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import org.apache.logging.log4j.LogManager
import ru.dbotthepony.kstarbound.math.vector.Vector2d
import ru.dbotthepony.kstarbound.math.vector.Vector2i
import ru.dbotthepony.kstarbound.Starbound
import ru.dbotthepony.kstarbound.client.network.packets.ChunkCellsPacket
import ru.dbotthepony.kstarbound.defs.SpawnTarget
import ru.dbotthepony.kstarbound.defs.WarpAction
import ru.dbotthepony.kstarbound.defs.WorldID
import ru.dbotthepony.kstarbound.defs.tile.TileDamage
import ru.dbotthepony.kstarbound.defs.world.FlyingType
import ru.dbotthepony.kstarbound.math.AABBi
import ru.dbotthepony.kstarbound.network.IPacket
import ru.dbotthepony.kstarbound.network.packets.EntityCreatePacket
import ru.dbotthepony.kstarbound.network.packets.EntityDestroyPacket
import ru.dbotthepony.kstarbound.network.packets.EntityUpdateSetPacket
import ru.dbotthepony.kstarbound.network.packets.clientbound.CentralStructureUpdatePacket
import ru.dbotthepony.kstarbound.network.packets.clientbound.EnvironmentUpdatePacket
import ru.dbotthepony.kstarbound.network.packets.clientbound.LegacyTileArrayUpdatePacket
import ru.dbotthepony.kstarbound.network.packets.clientbound.LegacyTileUpdatePacket
import ru.dbotthepony.kstarbound.network.packets.clientbound.TileDamageUpdatePacket
import ru.dbotthepony.kstarbound.network.packets.clientbound.TileModificationFailurePacket
import ru.dbotthepony.kstarbound.network.packets.clientbound.WorldStartPacket
import ru.dbotthepony.kstarbound.network.packets.clientbound.WorldStopPacket
import ru.dbotthepony.kstarbound.server.ServerConnection
import ru.dbotthepony.kstarbound.util.ActionPacer
import ru.dbotthepony.kstarbound.world.ChunkPos
import ru.dbotthepony.kstarbound.world.IChunkListener
import ru.dbotthepony.kstarbound.world.TileHealth
import ru.dbotthepony.kstarbound.world.TileModification
import ru.dbotthepony.kstarbound.world.api.ImmutableCell
import ru.dbotthepony.kstarbound.world.entities.AbstractEntity
import ru.dbotthepony.kstarbound.world.entities.player.PlayerEntity
import java.io.DataOutputStream
import java.util.*
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.collections.ArrayList

// couples ServerWorld and ServerConnection together,
// allowing ServerConnection client to track ServerWorld state
class ServerWorldTracker(val world: ServerWorld, val client: ServerConnection, playerStart: Vector2d) {
	private var skyVersion = 0L
	// this is required because of dumb shit regarding flash time
	// if we network sky state on each tick then it will guarantee epilepsy attack
	private var skyUpdateWaitTicks = 0

	private val isRemoved = AtomicBoolean()
	private var isActuallyRemoved = false
	private val tickets = HashMap<ChunkPos, Ticket>()

	private val tasksQueue = ConcurrentLinkedQueue<CompletableFuture<*>>()
	private val tasks = ArrayList<CompletableFuture<*>>()

	private val entityVersions = Int2LongOpenHashMap()
	private val scope = CoroutineScope(world.eventLoop.coroutines + SupervisorJob())

	init {
		entityVersions.defaultReturnValue(-1L)

		LOGGER.info("Accepted ${client.alias()}")

		client.worldStartAcknowledged = false
		client.tracker = this
		client.worldID = world.worldID
	}

	private data class DamageTileEntry(val positions: Collection<Vector2i>, val isBackground: Boolean, val sourcePosition: Vector2d, val damage: TileDamage, val source: AbstractEntity? = null)
	private val damageTilesQueue = Channel<DamageTileEntry>(64) // 64 pending tile group damage requests should be more than enough
	private val tileModificationBudget = ActionPacer(actions = 512, handicap = 2048) // TODO: make this configurable
	private val modifyTilesQueue = Channel<Pair<Collection<Pair<Vector2i, TileModification>>, Boolean>>(64)

	private suspend fun damageTilesLoop() {
		while (true) {
			val (positions, isBackground, sourcePosition, damage, source) = damageTilesQueue.receive()

			try {
				world.damageTiles(positions, isBackground, sourcePosition, damage, source, tileModificationBudget)
			} catch (err: Throwable) {
				LOGGER.error("Exception in player damage tiles loop", err)
				client.disconnect("Exception in player damage tiles loop: $err")
			}
		}
	}

	fun damageTiles(positions: Collection<Vector2i>, isBackground: Boolean, sourcePosition: Vector2d, damage: TileDamage, source: AbstractEntity? = null) {
		damageTilesQueue.trySend(DamageTileEntry(positions, isBackground, sourcePosition, damage, source))
	}

	private suspend fun modifyTilesLoop() {
		while (true) {
			val (modifications, allowEntityOverlap) = modifyTilesQueue.receive()

			try {
				val unapplied = world.applyTileModifications(modifications, allowEntityOverlap, pacer = tileModificationBudget)

				if (unapplied.isNotEmpty()) {
					client.send(TileModificationFailurePacket(unapplied))
				}
			} catch (err: CancellationException) {
				client.send(TileModificationFailurePacket(modifications))
			} catch (err: Throwable) {
				client.send(TileModificationFailurePacket(modifications))
				LOGGER.error("Exception in player modify tiles loop", err)
				client.disconnect("Exception in player modify tiles loop: $err")
			}
		}
	}

	fun modifyTiles(modifications: Collection<Pair<Vector2i, TileModification>>, allowEntityOverlap: Boolean) {
		modifyTilesQueue.trySend(modifications to allowEntityOverlap)
	}

	init {
		scope.launch { damageTilesLoop() }
		scope.launch { modifyTilesLoop() }
	}

	fun send(packet: IPacket) = client.send(packet)

	// packets which interact with world must be
	// executed on world's thread
	fun enqueue(task: ServerWorld.() -> Unit) {
		if (!isRemoved.get()) {
			tasksQueue.add(world.eventLoop.supplyAsync {
				if (!isRemoved.get()) {
					try {
						task(world)
					} catch (err: Throwable) {
						LOGGER.error("Exception executing queued player task", err)
						client.disconnect("Exception executing queued player task: $err")
					}
				}
			})
		}
	}

	private inner class Ticket(val ticket: ServerChunk.ITicket, val pos: ChunkPos) : IChunkListener {
		override fun onCellChanges(x: Int, y: Int, cell: ImmutableCell) {
			send(LegacyTileUpdatePacket(pos.tile + Vector2i(x, y), cell.toLegacyNet()))
		}

		override fun onTileHealthUpdate(x: Int, y: Int, isBackground: Boolean, health: TileHealth) {
			// let's hope nothing bad happens from referencing live data
			send(TileDamageUpdatePacket(pos.tileX + x, pos.tileY + y, isBackground, health))
		}
	}

	init {
		val (skyData, skyVersion) = world.sky.networkedGroup.write(isLegacy = client.isLegacy)
		this.skyVersion = skyVersion

		if (client.isLegacy) {
			client.send(WorldStartPacket(
				templateData = Starbound.legacyJson { world.template.toJson() },
				skyData = skyData.toByteArray(),
				weatherData = ByteArray(0),
				playerStart = playerStart,
				playerRespawn = world.playerSpawnPosition,
				respawnInWorld = world.respawnInWorld,
				dungeonGravity = mapOf(),
				dungeonBreathable = mapOf(),
				protectedDungeonIDs = world.protectedDungeonIDs,
				worldProperties = world.copyProperties(),
				connectionID = client.connectionID,
				localInterpolationMode = false,
			))

			Starbound.legacyJson {
				client.send(CentralStructureUpdatePacket(Starbound.gson.toJsonTree(world.centralStructure)))
			}
		}
	}

	fun isTracking(pos: ChunkPos): Boolean {
		return pos in tickets
	}

	fun isTracking(entity: AbstractEntity): Boolean {
		return entityVersions.containsKey(entity.entityID)
	}

	fun forget(entity: AbstractEntity, reason: AbstractEntity.RemovalReason) {
		val version = entityVersions.remove(entity.entityID)

		if (version != -1L) {
			send(EntityDestroyPacket(entity.entityID, entity.networkGroup.write(version, isLegacy = client.isLegacy).first, reason.dying))
		}
	}

	private var lastSeenFlyingType = FlyingType.NONE
	private var currentlyTrackingRegions: List<AABBi> = listOf()

	private fun networkEntityForFirstTime(entity: AbstractEntity) {
		val initial = FastByteArrayOutputStream()
		entity.writeNetwork(DataOutputStream(initial), client.isLegacy)
		val (data, version) = entity.networkGroup.write(isLegacy = client.isLegacy)

		entityVersions.put(entity.entityID, version)

		send(EntityCreatePacket(
			entity.type,
			ByteArrayList.wrap(initial.array, initial.length),
			data,
			entity.entityID
		))
	}

	// called when entities join world, to allow instant tracking
	fun evaluateShouldTrack(entity: AbstractEntity) {
		if (entity.connectionID == client.connectionID)
			return

		if (currentlyTrackingRegions.any { it.toDoubleAABB().intersect(entity.metaBoundingBox) }) {
			networkEntityForFirstTime(entity)
		}
	}

	fun tick() {
		if (!client.worldStartAcknowledged)
			return

		if (!client.channel.isOpen) {
			// ???
			remove()
			remove0()
			return
		}

		if (isRemoved.get()) {
			remove0()
			return
		}

		run {
			if (skyUpdateWaitTicks++ >= 20 || world.sky.flyingType != lastSeenFlyingType) {
				val (data, version) = world.sky.networkedGroup.write(skyVersion, isLegacy = client.isLegacy)
				skyVersion = version
				send(EnvironmentUpdatePacket(data, ByteArrayList()))
				skyUpdateWaitTicks = 0
				lastSeenFlyingType = world.sky.flyingType
			}
		}

		run {
			var next = tasksQueue.poll()

			while (next != null) {
				tasks.add(next)
				next = tasksQueue.poll()
			}
		}

		tasks.removeIf { it.isDone }
		client.playerEntity = world.entities[client.playerID] as? PlayerEntity

		val trackingRegions = client.trackingTileRegions()
		this.currentlyTrackingRegions = trackingRegions

		run {
			val newTrackedChunks = ArrayList<ChunkPos>()

			for (region in trackingRegions) {
				newTrackedChunks.addAll(world.geometry.region2Chunks(region))
			}

			val itr = tickets.entries.iterator()

			for ((pos, ticket) in itr) {
				if (pos !in newTrackedChunks) {
					ticket.ticket.cancel()
					itr.remove()
				}
			}

			for (pos in newTrackedChunks) {
				if (pos !in tickets) {
					val ticket = world.permanentChunkTicket(pos).get() ?: continue
					val thisTicket = Ticket(ticket, pos)

					tickets[pos] = thisTicket
					ticket.listener = thisTicket

					ticket.chunk.thenAccept {
						if (client.isLegacy) {
							send(LegacyTileArrayUpdatePacket(it))
							it.tileDamagePackets().forEach { send(it) }
						} else {
							send(ChunkCellsPacket(it))
						}
					}
				}
			}
		}

		run {
			val trackingEntities = ObjectAVLTreeSet<AbstractEntity>()

			for (region in trackingRegions) {
				trackingEntities.addAll(world.entityIndex.query(region.toDoubleAABB(), filter = { it.connectionID != client.connectionID }))
			}

			val unseen = IntArrayList(entityVersions.keys)
			val changePackets = Int2ObjectOpenHashMap<Int2ObjectOpenHashMap<ByteArrayList>>()

			for (entity in trackingEntities) {
				val id = entity.entityID
				unseen.rem(id)

				if (entityVersions.get(id) == -1L) {
					// never networked
					networkEntityForFirstTime(entity)
				} else if (entity.networkGroup.upstream.hasChangedSince(entityVersions.get(id))) {
					val (data, version) = entity.networkGroup.write(remoteVersion = entityVersions.get(id), isLegacy = client.isLegacy)
					entityVersions.put(id, version)
					changePackets.computeIfAbsent(entity.connectionID, Int2ObjectFunction { Int2ObjectOpenHashMap() }).put(entity.entityID, data)
				}
			}

			if (changePackets.isNotEmpty()) {
				for ((connectionID, map) in changePackets) {
					send(EntityUpdateSetPacket(connectionID, map))
				}
			}

			val itr = unseen.iterator()

			while (itr.hasNext()) {
				val id = itr.nextInt()
				val entity = world.entities[id]
				val version = entityVersions.remove(id)

				if (entity == null) {
					send(EntityDestroyPacket(id, ByteArrayList(), false))
				} else {
					send(EntityDestroyPacket(id, entity.networkGroup.write(version, isLegacy = client.isLegacy).first, false))
				}
			}
		}
	}

	private fun remove0() {
		if (isActuallyRemoved) return

		isActuallyRemoved = true
		world.clients.remove(this)
		tickets.values.forEach { it.ticket.cancel() }

		val itr = world.entities.int2ObjectEntrySet().iterator()

		for ((id, entity) in itr) {
			if (id in client.entityIDRange) {
				// remove entities owned by that player
				entity.remove(AbstractEntity.RemovalReason.REMOTE_REMOVAL)
			}
		}
	}

	fun remove(reason: String = "ServerWorldTracker got removed", nullifyVariables: Boolean = true, setReturnWarp: Boolean = true) {
		if (isRemoved.compareAndSet(false, true)) {
			tasks.forEach { it.cancel(false) }
			tasksQueue.forEach { it.cancel(false) }
			tasks.clear()
			tasksQueue.clear()

			val playerEntity = client.playerEntity

			if (playerEntity != null && world.worldID is WorldID.Celestial && setReturnWarp) {
				client.returnWarp = WarpAction.World(world.worldID, SpawnTarget.Position(playerEntity.position))
			}

			if (nullifyVariables) {
				client.tracker = null
				client.playerEntity = null
				client.worldID = WorldID.Limbo
			}

			client.send(WorldStopPacket(reason))

			// this handles case where player is removed from world and
			// instantly added back because new world rejected us
			world.eventLoop.execute { remove0() }

			scope.cancel()
			damageTilesQueue.close()
			modifyTilesQueue.close()
		}
	}

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