package ru.dbotthepony.kstarbound.defs.dungeon

import com.google.gson.JsonObject
import it.unimi.dsi.fastutil.longs.Long2ObjectFunction
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap
import kotlinx.coroutines.future.await
import org.apache.logging.log4j.LogManager
import ru.dbotthepony.kommons.util.AABBi
import ru.dbotthepony.kommons.vector.Vector2d
import ru.dbotthepony.kommons.vector.Vector2i
import ru.dbotthepony.kstarbound.Registry
import ru.dbotthepony.kstarbound.defs.item.ItemDescriptor
import ru.dbotthepony.kstarbound.defs.`object`.ObjectDefinition
import ru.dbotthepony.kstarbound.defs.tile.BuiltinMetaMaterials
import ru.dbotthepony.kstarbound.defs.tile.LiquidDefinition
import ru.dbotthepony.kstarbound.defs.tile.NO_DUNGEON_ID
import ru.dbotthepony.kstarbound.defs.tile.TileDefinition
import ru.dbotthepony.kstarbound.defs.tile.TileModifierDefinition
import ru.dbotthepony.kstarbound.defs.tile.isNotEmptyTile
import ru.dbotthepony.kstarbound.defs.world.FloatingDungeonWorldParameters
import ru.dbotthepony.kstarbound.server.world.ServerChunk
import ru.dbotthepony.kstarbound.server.world.ServerWorld
import ru.dbotthepony.kstarbound.world.ChunkPos
import ru.dbotthepony.kstarbound.world.ChunkState
import ru.dbotthepony.kstarbound.world.Direction
import ru.dbotthepony.kstarbound.world.api.AbstractLiquidState
import ru.dbotthepony.kstarbound.world.api.MutableTileState
import ru.dbotthepony.kstarbound.world.api.TileColor
import ru.dbotthepony.kstarbound.world.entities.tile.TileEntity
import ru.dbotthepony.kstarbound.world.entities.tile.WorldObject
import ru.dbotthepony.kstarbound.world.physics.Poly
import java.util.Collections
import java.util.LinkedHashSet
import java.util.concurrent.CompletableFuture
import java.util.function.Consumer
import java.util.random.RandomGenerator

// Facade world for generating dungeons, so generation can be performed without affecting world state,
// and if error occurs, won't require world's rollback, as well allowing dungeon to be generated mostly
// off world's thread.
class DungeonWorld(val parent: ServerWorld, val random: RandomGenerator, val markSurfaceLevel: Int? = null, val terrainSurfaceSpaceExtends: Int = 0) {
	val geometry = parent.geometry

	data class Material(
		val material: Registry.Entry<TileDefinition> = BuiltinMetaMaterials.EMPTY,
		val hueShift: Float = 0f,
		val color: TileColor = TileColor.DEFAULT,
	) {
		fun apply(to: MutableTileState) {
			to.material = material
			to.hueShift = hueShift
			to.color = color
		}
	}

	data class Modifier(
		val modifier: Registry.Entry<TileModifierDefinition> = BuiltinMetaMaterials.EMPTY_MOD,
		val hueShift: Float = 0f
	) {
		fun apply(to: MutableTileState) {
			to.modifier = modifier
			to.modifierHueShift = hueShift
		}
	}

	data class PlacedObject(
		val prototype: Registry.Entry<ObjectDefinition>,
		val direction: Direction = Direction.LEFT,
		val parameters: JsonObject = JsonObject()
	)

	var hasGenerated = false
		private set

	val targetChunkState = if (parent.template.worldParameters is FloatingDungeonWorldParameters) ChunkState.FULL else ChunkState.TERRAIN

	private val liquid = HashMap<Vector2i, AbstractLiquidState>(8192, 0.5f)
	private val foregroundMaterial = HashMap<Vector2i, Material>(8192, 0.5f)
	private val foregroundModifier = HashMap<Vector2i, Modifier>(8192, 0.5f)
	private val backgroundMaterial = HashMap<Vector2i, Material>(8192, 0.5f)
	private val backgroundModifier = HashMap<Vector2i, Modifier>(8192, 0.5f)

	// for entity spaces which should be considered empty if they
	// are occupied by tile entity
	private val clearTileEntitiesAt = HashSet<Vector2i>(8192, 0.5f)

	// entities themselves to be removed
	private val tileEntitiesToRemove = HashSet<TileEntity>(2048, 0.5f)

	private val touchedTiles = HashSet<Vector2i>(16384, 0.5f)
	private val protectTile = HashSet<Vector2i>(16384, 0.5f)

	private val boundingBoxes = ArrayList<AABBi>()

	fun clearTileEntityAt(x: Int, y: Int) {
		clearTileEntitiesAt.add(geometry.wrap(Vector2i(x, y)))
	}

	fun clearTileEntityAt(position: Vector2i) {
		clearTileEntitiesAt.add(geometry.wrap(position))
	}

	fun isClearingTileEntityAt(x: Int, y: Int): Boolean {
		return geometry.wrap(Vector2i(x, y)) in clearTileEntitiesAt
	}

	fun clearTileEntity(entity: TileEntity) {
		tileEntitiesToRemove.add(entity)
	}

	private var currentBoundingBox: AABBi? = null

	fun touched(): Set<Vector2i> = Collections.unmodifiableSet(touchedTiles)

	fun touch(x: Int, y: Int) {
		val wrapped = geometry.wrap(Vector2i(x, y))
		touchedTiles.add(wrapped)

		if (currentBoundingBox == null) {
			currentBoundingBox = AABBi(wrapped, wrapped)
		} else {
			currentBoundingBox = currentBoundingBox!!.expand(wrapped)
		}

		if (dungeonID != -1 && wrapped !in dungeonIDs) {
			dungeonIDs[wrapped] = dungeonID
		}
	}

	fun isTouched(x: Int, y: Int): Boolean {
		return touchedTiles.contains(geometry.wrap(Vector2i(x, y)))
	}

	fun touchPlacement(x: Int, y: Int) {
		protectTile.add(geometry.wrap(Vector2i(x, y)))
	}

	fun hasPlacement(x: Int, y: Int): Boolean {
		return protectTile.contains(geometry.wrap(Vector2i(x, y)))
	}

	private val biomeItems = HashSet<Vector2i>(8192, 0.5f)
	private val biomeTrees = HashSet<Vector2i>()
	private val itemDrops = HashMap<Vector2i, ArrayList<ItemDescriptor>>()
	private val randomizedItemDrops = HashMap<Vector2i, ArrayList<ItemDescriptor>>()

	private val dungeonIDs = HashMap<Vector2i, Int>(16384, 0.5f)
	private var dungeonID = -1

	private val pendingLiquids = HashMap<Vector2i, AbstractLiquidState>(8192, 0.5f)

	private val openLocalWires = LinkedHashMap<String, LinkedHashSet<Vector2i>>()
	private val globalWires = LinkedHashMap<String, LinkedHashSet<Vector2i>>()
	private val localWires = ArrayList<HashSet<Vector2i>>()

	private val placedObjects = LinkedHashMap<Vector2i, PlacedObject>()

	var playerStart: Vector2d? = null

	fun finishPart() {
		localWires.addAll(openLocalWires.values)
		openLocalWires.clear()

		if (currentBoundingBox != null) {
			boundingBoxes.add(currentBoundingBox!!)
			currentBoundingBox = null
		}
	}

	fun placeObject(x: Int, y: Int, prototype: Registry.Entry<ObjectDefinition>, direction: Direction = Direction.LEFT, parameters: JsonObject = JsonObject()) {
		placedObjects[geometry.wrap(Vector2i(x, y))] = PlacedObject(prototype, direction, parameters)
	}

	fun placeWiring(x: Int, y: Int, group: String, partLocal: Boolean) {
		val table = if (partLocal) openLocalWires else globalWires
		table.computeIfAbsent(group) { LinkedHashSet() }.add(geometry.wrap(Vector2i(x, y)))
	}

	fun requestLiquid(x: Int, y: Int, liquid: AbstractLiquidState) {
		pendingLiquids[geometry.wrap(Vector2i(x, y))] = liquid
	}

	fun setDungeonID(x: Int, y: Int, id: Int) {
		dungeonIDs[geometry.wrap(Vector2i(x, y))] = id
	}

	fun setDungeonID(x: Int, y: Int) {
		dungeonIDs.remove(geometry.wrap(Vector2i(x, y)))
	}

	fun setDungeonID(id: Int) {
		require(id in 0 .. NO_DUNGEON_ID) { "Dungeon ID out of range: $id" }
		dungeonID = id
	}

	fun setDungeonID() {
		dungeonID = -1
	}

	fun placeBiomeTree(x: Int, y: Int) {
		biomeTrees.add(geometry.wrap(Vector2i(x, y)))
	}

	fun placeBiomeItems(x: Int, y: Int) {
		biomeItems.add(geometry.wrap(Vector2i(x, y)))
	}

	fun dropItem(x: Int, y: Int, item: ItemDescriptor) {
		itemDrops.computeIfAbsent(geometry.wrap(Vector2i(x, y))) { ArrayList() }.add(item)
	}

	fun dropRandomizedItem(x: Int, y: Int, item: ItemDescriptor) {
		randomizedItemDrops.computeIfAbsent(geometry.wrap(Vector2i(x, y))) { ArrayList() }.add(item)
	}

	fun setLiquid(x: Int, y: Int, liquid: AbstractLiquidState) {
		this.liquid[geometry.wrap(Vector2i(x, y))] = liquid
	}

	fun getLiquid(x: Int, y: Int): AbstractLiquidState {
		return this.liquid[geometry.wrap(Vector2i(x, y))] ?: AbstractLiquidState.EMPTY
	}

	fun setForeground(x: Int, y: Int, tile: Material) {
		this.foregroundMaterial[geometry.wrap(Vector2i(x, y))] = tile
	}

	fun setForeground(x: Int, y: Int, tile: Modifier) {
		this.foregroundModifier[geometry.wrap(Vector2i(x, y))] = tile
	}

	fun setForeground(x: Int, y: Int, material: Registry.Entry<TileDefinition>, hueShift: Float = 0f, color: TileColor = TileColor.DEFAULT) {
		setForeground(x, y, Material(material, hueShift, color))
	}

	fun setForeground(x: Int, y: Int, modifier: Registry.Entry<TileModifierDefinition>, hueShift: Float = 0f) {
		setForeground(x, y, Modifier(modifier, hueShift))
	}

	fun setBackground(x: Int, y: Int, tile: Material) {
		this.backgroundMaterial[geometry.wrap(Vector2i(x, y))] = tile
	}

	fun setBackground(x: Int, y: Int, tile: Modifier) {
		this.backgroundModifier[geometry.wrap(Vector2i(x, y))] = tile
	}

	fun setBackground(x: Int, y: Int, material: Registry.Entry<TileDefinition>, hueShift: Float = 0f, color: TileColor = TileColor.DEFAULT) {
		setBackground(x, y, Material(material, hueShift, color))
	}

	fun setBackground(x: Int, y: Int, modifier: Registry.Entry<TileModifierDefinition>, hueShift: Float = 0f) {
		setBackground(x, y, Modifier(modifier, hueShift))
	}

	fun needsForegroundBiomeMod(x: Int, y: Int): Boolean {
		val pos = geometry.wrap(Vector2i(x, y))
		val material = foregroundMaterial[pos] ?: return false

		if (material.material !in BuiltinMetaMaterials.BIOME_META_MATERIALS)
			return false

		val above = geometry.wrap(Vector2i(x, y + 1))
		return foregroundMaterial[above]?.material?.isNotEmptyTile == false
	}

	fun needsBackgroundBiomeMod(x: Int, y: Int): Boolean {
		val pos = geometry.wrap(Vector2i(x, y))
		val material = backgroundMaterial[pos] ?: return false

		if (material.material !in BuiltinMetaMaterials.BIOME_META_MATERIALS)
			return false

		val above = geometry.wrap(Vector2i(x, y + 1))
		return backgroundMaterial[above]?.material?.isNotEmptyTile == false
	}

	suspend inline fun <T> waitForRegion(region: AABBi, block: () -> T): T {
		val tickets = ArrayList<ServerChunk.ITicket>()

		return try {
			tickets.addAll(parent.permanentChunkTicket(region, targetChunkState).await())
			tickets.forEach { it.chunk.await() }
			block()
		} finally {
			tickets.forEach { it.cancel() }
		}
	}

	suspend inline fun <T> waitForRegionAndJoin(region: AABBi, crossinline block: () -> T): T {
		return waitForRegion(region) {
			parent.eventLoop.supplyAsync { block() }.await()
		}
	}

	suspend inline fun <T> waitForRegion(position: Vector2i, size: Vector2i, block: () -> T): T {
		return waitForRegion(AABBi(position, position + size), block)
	}

	suspend inline fun <T> waitForRegionAndJoin(position: Vector2i, size: Vector2i, crossinline block: () -> T): T {
		return waitForRegionAndJoin(AABBi(position, position + size), block)
	}

	fun applyFinalTouches() {
		// For each liquid type, find each contiguous region of liquid, then
		// pressurize that region based on the highest position in the region

		val unpressurizedLiquids = HashMap<Registry.Entry<LiquidDefinition>, HashSet<Vector2i>>()

		for ((pos, liquid) in pendingLiquids) {
			unpressurizedLiquids.computeIfAbsent(liquid.state) { HashSet() }.add(pos)
		}

		for (unpressurized in unpressurizedLiquids.values) {
			while (unpressurized.isNotEmpty()) {
				// Start with the first unpressurized block as the open set.
				val firstBlock = unpressurized.first()
				unpressurized.remove(firstBlock)

				var openSet = ArrayList<Vector2i>()
				val contiguousRegion = HashSet<Vector2i>()

				openSet.add(firstBlock)
				contiguousRegion.add(firstBlock)

				// For each element in the previous open set, add all connected blocks
				// in
				// the unpressurized set to the new open set and to the total contiguous
				// region, taking them from the unpressurized set.
				while (openSet.isNotEmpty()) {
					val oldOpenSet = openSet
					openSet = ArrayList()

					for (node in oldOpenSet) {
						for (dir in offsets) {
							val pos = node + dir

							if (unpressurized.remove(pos)) {
								contiguousRegion.add(pos)
								openSet.add(pos)
							}
						}
					}
				}

				// Once we have found no more blocks in the unpressurized set to add to
				// the open set, then we have taken a contiguous region out of the
				// unpressurized set.  Pressurize it based on the highest point.
				val highestPoint = contiguousRegion.maxOf { it.y }
				contiguousRegion.forEach {
					val state = pendingLiquids[it]!!.mutable()
					state.pressure = 1f + highestPoint - it.y
					pendingLiquids[it] = state.immutable()
				}
			}
		}

		for ((pos, liquid) in pendingLiquids.entries) {
			setLiquid(pos.x, pos.y, liquid)
		}

		pendingLiquids.clear()

		hasGenerated = true
	}

	private fun applyCellChangesAt(pos: Vector2i, chunk: ServerChunk) {
		val cell = chunk.getCell(pos - chunk.pos.tile).mutable()

		backgroundMaterial[pos]?.apply(cell.background)
		foregroundMaterial[pos]?.apply(cell.foreground)
		backgroundModifier[pos]?.apply(cell.background)
		foregroundModifier[pos]?.apply(cell.foreground)

		val dungeonId = dungeonIDs[pos]
		val liquid = liquid[pos]

		if (liquid != null) {
			cell.liquid.from(liquid)
		}

		if (dungeonId != null) {
			cell.dungeonId = dungeonId
		}

		chunk.replaceBiomeBlocks(cell, parent.template.cellInfo(pos.x, pos.y))
		chunk.setCell(pos - chunk.pos.tile, cell)
	}

	suspend fun commit() {
		val tickets = ArrayList<ServerChunk.ITicket>()

		try {
			val terrainBlendingVertexes = ArrayList<Vector2d>()
			val spaceBlendingVertexes = ArrayList<Vector2d>()

			for (box in boundingBoxes) {
				// don't schedule generating terrain until we have specified custom terrain regions! (they affect terrain generation)
				// tickets.addAll(parent.permanentChunkTicket(box, ServerChunk.State.TERRAIN))

				if (markSurfaceLevel != null) {
					// Mark the regions of the dungeon above the dungeon surface as needing
					// space, and the regions below the surface as needing terrain

					if (box.mins.y < markSurfaceLevel) {
						val mins = box.mins
						val maxs = box.maxs.copy(y = box.maxs.y.coerceAtMost(markSurfaceLevel))

						terrainBlendingVertexes.add(Vector2d(mins.x.toDouble(), mins.y.toDouble()))
						terrainBlendingVertexes.add(Vector2d(maxs.x.toDouble(), mins.y.toDouble()))
						terrainBlendingVertexes.add(Vector2d(maxs.x.toDouble(), maxs.y.toDouble()))
						terrainBlendingVertexes.add(Vector2d(mins.x.toDouble(), maxs.y.toDouble()))
					}

					if (box.maxs.y > markSurfaceLevel) {
						val mins = box.mins.copy(y = box.mins.y.coerceAtLeast(markSurfaceLevel))
						val maxs = box.maxs

						spaceBlendingVertexes.add(Vector2d(mins.x.toDouble(), mins.y.toDouble()))
						spaceBlendingVertexes.add(Vector2d(maxs.x.toDouble(), mins.y.toDouble()))
						spaceBlendingVertexes.add(Vector2d(maxs.x.toDouble(), maxs.y.toDouble() + terrainSurfaceSpaceExtends))
						spaceBlendingVertexes.add(Vector2d(mins.x.toDouble(), maxs.y.toDouble() + terrainSurfaceSpaceExtends))
					}
				}
			}

			parent.eventLoop.supplyAsync {
				if (terrainBlendingVertexes.isNotEmpty()) {
					parent.template.addCustomTerrainRegion(Poly.quickhull(terrainBlendingVertexes))
				}

				if (spaceBlendingVertexes.isNotEmpty()) {
					parent.template.addCustomSpaceRegion(Poly.quickhull(spaceBlendingVertexes))
				}
			}.await()

			for (box in boundingBoxes) {
				tickets.addAll(parent.permanentChunkTicket(box, targetChunkState).await())
			}

			// apply tiles to world per-chunk
			// this way we don't need to wait on all chunks to be loaded
			// and apply changes chunks which have been loaded right away
			val tilePositions = HashSet<Vector2i>(
				foregroundMaterial.keys.size
					.coerceAtLeast(foregroundModifier.keys.size)
					.coerceAtLeast(backgroundMaterial.keys.size)
					.coerceAtLeast(backgroundModifier.keys.size)
					.coerceAtLeast(liquid.keys.size)
			)

			tilePositions.addAll(foregroundMaterial.keys)
			tilePositions.addAll(foregroundModifier.keys)
			tilePositions.addAll(backgroundMaterial.keys)
			tilePositions.addAll(backgroundModifier.keys)
			tilePositions.addAll(liquid.keys)

			val regions = Long2ObjectOpenHashMap<ArrayList<Vector2i>>()

			for (pos in tilePositions) {
				regions.computeIfAbsent(ChunkPos.toLong(geometry.x.chunkFromCell(pos.x), geometry.y.chunkFromCell(pos.y)), Long2ObjectFunction { ArrayList() }).add(pos)
			}

			val seenTickets = HashSet<ChunkPos>()
			val waiters = ArrayList<CompletableFuture<*>>()

			for (ticket in tickets.filter { seenTickets.add(it.pos) }) {
				// make changes to chunk only inside world's thread once it has reached TILES state
				waiters.add(ticket.chunk.thenAcceptAsync(Consumer {
					regions.get(ticket.pos.toLong())?.forEach {
						applyCellChangesAt(it, ticket.chunk.get())
					}
				}, parent.eventLoop))
			}

			val placedObjects = placedObjects.entries
				.map { (pos, data) ->
					try {
						WorldObject.create(data.prototype, pos, data.parameters).also { it?.randomize(random, parent.template.threatLevel) } to data.direction
					} catch (err: Throwable) {
						LOGGER.error("Exception while creating dungeon object ${data.prototype} at $pos", err)
						null to data.direction
					}
				}
				.filter { it.first != null }

			// wait for all chunks to be loaded (and cell changes to be applied)
			// if any of cell change operation fails, entire generation fails... leaving world in inconsistent state,
			// but also limiting damage by exiting early.
			waiters.forEach { it.await() }

			// at this point all chunks are available, and we applied changes to tiles
			if (playerStart != null)
				parent.setPlayerSpawn(playerStart!!, false)

			parent.eventLoop.supplyAsync {
				for ((obj, direction) in placedObjects) {
					try {
						val orientation = obj!!.config.value.findValidOrientation(parent, obj.tilePosition, direction, true)

						if (orientation != -1) {
							obj.orientationIndex = orientation.toLong()
							obj.joinWorld(parent)
						} else {
							LOGGER.error("Tried to place object ${obj.config.key} at ${obj.tilePosition}, but it can't be placed there!")
						}
					} catch (err: Throwable) {
						LOGGER.error("Exception while putting dungeon object $obj at ${obj!!.tilePosition}", err)
					}
				}
			}.await()

			if (targetChunkState != ChunkState.FULL) {
				// and finally, schedule chunks to be loaded into FULL state
				// this way, dungeons won't get cut off when chunks being saved
				// to disk because of dungeon bleeding into neighbour chunks who
				// never get promoted further
				// But this might trigger cascading world generation
				// (dungeon generates another dungeon, and another, and so on),
				// tough, so need to take care!
				for (box in boundingBoxes) {
					// specify timer as 0 so ticket gets removed on next world tick
					parent.temporaryChunkTicket(box, 0, ChunkState.FULL)
				}
			}
		} finally {
			tickets.forEach { it.cancel() }
		}
	}

	companion object {
		private val LOGGER = LogManager.getLogger()
		private val offsets = listOf(Vector2i.POSITIVE_Y, Vector2i.NEGATIVE_Y, Vector2i.POSITIVE_X, Vector2i.NEGATIVE_X)
	}
}
