package ru.dbotthepony.kstarbound.defs.dungeon

import com.google.common.collect.ImmutableList
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.ints.Int2IntOpenHashMap
import it.unimi.dsi.fastutil.ints.IntAVLTreeSet
import it.unimi.dsi.fastutil.ints.IntArrayList
import it.unimi.dsi.fastutil.io.FastByteArrayInputStream
import ru.dbotthepony.kommons.gson.contains
import ru.dbotthepony.kommons.gson.set
import ru.dbotthepony.kommons.util.Either
import ru.dbotthepony.kommons.util.KOptional
import ru.dbotthepony.kommons.vector.Vector2i
import ru.dbotthepony.kstarbound.Starbound
import ru.dbotthepony.kstarbound.json.builder.JsonFactory
import ru.dbotthepony.kstarbound.json.jsonArrayOf
import ru.dbotthepony.kstarbound.json.mergeJson
import ru.dbotthepony.kstarbound.world.PIXELS_IN_STARBOUND_UNIT
import ru.dbotthepony.kstarbound.world.PIXELS_IN_STARBOUND_UNITi
import java.io.BufferedInputStream
import java.io.EOFException
import java.util.Base64
import java.util.zip.InflaterInputStream
import kotlin.math.roundToInt

class TiledMap(data: JsonData) : TileMap() {
	@JsonFactory
	data class JsonData(
		val tileheight: Int,
		val tilewidth: Int,

		val width: Int,
		val height: Int,

		val tilesets: ImmutableList<TiledTileSets.Entry>,
		val layers: ImmutableList<JsonObject>,
	)

	init {
		require(data.tilewidth == 8) { "Tile width is not equal to 8 (${data.tilewidth})" }
		require(data.tileheight == 8) { "Tile height is not equal to 8 (${data.tileheight})" }

		require(data.width > 0) { "Non-positive map width: ${data.width}" }
		require(data.height > 0) { "Non-positive map height: ${data.height}" }
	}

	val size = Vector2i(data.width, data.height)

	private val tileSets = TiledTileSets(data.tilesets)

	var frontLayer: TileLayer? = null
		private set
	var backLayer: TileLayer? = null
		private set

	val objectLayers: ImmutableList<ObjectLayer>

	init {
		val objectLayers = ArrayList<ObjectLayer>()

		for (layer in data.layers) {
			val type = layer["type"].asString

			if (type == "tilelayer") {
				val read = Starbound.gson.fromJson(layer, TileLayerData::class.java)

				when (read.name.lowercase()) {
					"front" -> {
						if (frontLayer != null) {
							throw IllegalArgumentException("Duplicate front layer")
						}

						frontLayer = TileLayer(read, false)
					}

					"back" -> {
						if (backLayer != null) {
							throw IllegalArgumentException("Duplicate back layer")
						}

						backLayer = TileLayer(read, true)
					}

					else -> throw UnsupportedOperationException("Unknown tile layer '${read.name}'! Must be either 'front' or 'back'")
				}
			} else if (type == "objectgroup") {
				objectLayers.add(ObjectLayer(Starbound.gson.fromJson(layer, ObjectLayerData::class.java)))
			} else {
				throw UnsupportedOperationException("Unknown layer type $type!")
			}
		}

		this.objectLayers = ImmutableList.copyOf(objectLayers)

		tileSets.free()
	}

	override fun <T> walkTiles(callback: TileCallback<T>): KOptional<T> {
		var result = frontLayer?.walkTiles(callback) ?: KOptional()
		if (result.isPresent) return result
		result = backLayer?.walkTiles(callback) ?: KOptional()
		if (result.isPresent) return result

		for (layer in objectLayers) {
			result = layer.walkTiles(callback)
			if (result.isPresent) return result
		}

		return KOptional()
	}

	override fun <T> walkTilesAt(x: Int, y: Int, callback: TileCallback<T>): KOptional<T> {
		var result = frontLayer?.walkTilesAt(x, y, callback) ?: KOptional()
		if (result.isPresent) return result
		result = backLayer?.walkTilesAt(x, y, callback) ?: KOptional()
		if (result.isPresent) return result

		for (layer in objectLayers) {
			result = layer.walkTilesAt(x, y, callback)
			if (result.isPresent) return result
		}

		return KOptional()
	}

	@JsonFactory
	data class TileLayerData(
		val width: Int,
		val height: Int,
		val x: Int = 0,
		val y: Int = 0,
		val name: String,
		val compression: String? = null,
		val data: JsonElement
	) {
		init {
			require(width > 0) { "Non-positive tile layer width: $width" }
			require(height > 0) { "Non-positive tile layer height: $height" }
		}
	}

	inner class TileLayer(data: TileLayerData, val isBackground: Boolean) : TileMap() {
		val width: Int = data.width
		val height: Int = data.height
		val x: Int = data.x
		val y: Int = data.y

		private val frontPalette: Array<DungeonTile>
		private val backPalette: Array<DungeonTile>
		private val data: VectorizedBitSet

		init {
			val tileData: IntArray

			if (data.compression == "zlib") {
				val stream = BufferedInputStream(InflaterInputStream(FastByteArrayInputStream(Base64.getDecoder().decode(data.data.asString))))

				tileData = IntArray(data.width * data.height) {
					val a = stream.read()
					val b = stream.read()
					val c = stream.read()
					val d = stream.read()

					if (a or b or c or d < 0)
						throw EOFException("Reached end of stream before read all tiles from layer")

					(a or b.shl(8) or c.shl(16) or d.shl(24)) and FLAG_BITS.inv()
				}
			} else if (data.compression == null) {
				require(data.data.asJsonArray.size() == data.width * data.height) {
					"'data' does not contain enough tiles to fill ${data.width} x ${data.height} array"
				}

				tileData = IntArray(data.width * data.height)

				for ((i, index) in data.data.asJsonArray.withIndex()) {
					tileData[i] = index.asInt and FLAG_BITS.inv()
				}
			} else {
				throw IllegalArgumentException("Unsupported compression mode: ${data.compression}")
			}

			// determine unique tiles
			val countUniqueTiles = IntAVLTreeSet()

			for (x in 0 until width) {
				for (y in 0 until height) {
					countUniqueTiles.add(tileData[x + y * width])
				}
			}

			// construct backing memory
			this.data = VectorizedBitSet(32 - Integer.numberOfLeadingZeros(countUniqueTiles.size), width, height)

			// determine palette
			val gidIndices = IntArrayList(countUniqueTiles)
			frontPalette = Array(gidIndices.size) { tileSets.getFront(gidIndices.getInt(it)) }
			backPalette = Array(gidIndices.size) { tileSets.getBack(gidIndices.getInt(it)) }

			// fill backing memory with paletted indices
			for (x in 0 until width) {
				for (y in 0 until height) {
					val index = gidIndices.indexOf(tileData[x + y * width])
					check(index != -1)
					this.data[x, this.height - y - 1] = index
				}
			}
		}

		private fun get0(x: Int, y: Int): DungeonTile {
			if (isBackground)
				return backPalette[data[x - this.x, y - this.y]]
			else
				return frontPalette[data[x - this.x, y - this.y]]
		}

		operator fun get(x: Int, y: Int): DungeonTile {
			if (x !in this.x until this.x + width || y !in this.y until this.y + height)
				return DungeonTile.EMPTY

			return get0(x, height - y + 1)
		}

		override fun <T> walkTiles(callback: TileCallback<T>): KOptional<T> {
			// walk bottom-top first, this way we will place bottom objects/tiles before top ones
			for (y in this.y until this.y + height) {
				for (x in this.x until this.x + width) {
					val result = callback(x, y, get0(x, y))
					if (result.isPresent) return result
				}
			}

			return KOptional()
		}

		override fun <T> walkTilesAt(x: Int, y: Int, callback: TileCallback<T>): KOptional<T> {
			if (x !in this.x until this.x + width || y !in this.y until this.y + height)
				return KOptional()

			return callback(x, y, get0(x, y))
		}
	}

	@JsonFactory
	data class OProperty(val name: String, val value: JsonElement)

	@JsonFactory
	data class ObjectLayerData(
		val name: String,
		val properties: Either<JsonObject, ImmutableList<OProperty>> = Either.left(JsonObject()),
		val objects: ImmutableList<ObjectData>,
	)

	inner class ObjectLayer(data: ObjectLayerData) : TileMap() {
		private val objects: ImmutableList<MapObject>

		init {
			val properties = data.properties.map({ it }, { p -> JsonObject().also { for ((k, v) in p) it[k] = v } })
			val objects = ArrayList<MapObject>()

			for (o in data.objects) {
				objects.add(MapObject(properties, o))
			}

			this.objects = ImmutableList.copyOf(objects)
		}

		override fun <T> walkTiles(callback: TileCallback<T>): KOptional<T> {
			for (o in objects) {
				val result = o.walkTiles(callback)
				if (result.isPresent) return result
			}

			return KOptional()
		}

		override fun <T> walkTilesAt(x: Int, y: Int, callback: TileCallback<T>): KOptional<T> {
			for (o in objects) {
				val result = o.walkTilesAt(x, y, callback)
				if (result.isPresent) return result
			}

			return KOptional()
		}
	}

	@JsonFactory
	data class ObjectData(
		val id: Int,
		val gid: Int? = null,
		val properties: Either<JsonObject, ImmutableList<OProperty>> = Either.left(JsonObject()),
		val x: Int,
		val y: Int,
		val width: Int = 0,
		val height: Int = 0,
		val rotation: Double = 0.0,
		val polyline: ImmutableList<PolylineEntry>? = null,
		val ellipse: JsonElement = JsonNull.INSTANCE,
		val polygon: JsonElement = JsonNull.INSTANCE,
	)

	@JsonFactory
	data class PolylineEntry(val x: Int, val y: Int)

	enum class ObjType {
		STAGEHAND, TILE, WIRING, RECTANGLE;
	}

	inner class MapObject(layerProperties: JsonObject, data: ObjectData) : TileMap() {
		val id = data.id
		val type: ObjType
		val polyline: ImmutableList<Vector2i>
		val pos: Vector2i
		val size: Vector2i
		val tile: DungeonTile

		init {
			val properties = data.properties.map({ it }, { p -> JsonObject().also { for ((k, v) in p) it[k] = v } })
			val merged0 = mergeJson(layerProperties.deepCopy(), properties)
			val isBackground = if ("layer" in merged0) merged0["layer"].asString == "back" else false
			val merged = layerProperties.deepCopy()
			var flipX = false

			if (data.gid != null) {
				if (isBackground) {
					mergeJson(merged, tileSets.getBackData(data.gid and FLAG_BITS.inv()))
				} else {
					mergeJson(merged, tileSets.getFrontData(data.gid and FLAG_BITS.inv()))
				}

				if (data.gid and (VERTICAL_FLIP or DIAGONAL_FLIP) != 0)
					throw UnsupportedOperationException("Object with GID ${data.gid and FLAG_BITS.inv()} (id $id) has either vertical flip or diagonal flip set, which is not supported")

				flipX = data.gid and HORIZONTAL_FLIP != 0
			}

			mergeJson(merged, properties)

			if (isBackground)
				merged["layer"] = "back"
			else
				merged["layer"] = "front"

			if (flipX)
				merged["flipX"] = "true"

			if ("stagehand" in merged)
				type = ObjType.STAGEHAND
			else if (data.gid != null)
				type = ObjType.TILE
			else if (data.polyline != null)
				type = ObjType.WIRING
			else if (!data.ellipse.isJsonNull)
				throw UnsupportedOperationException("Object has ellipse shape, which is not supported")
			else if (!data.polygon.isJsonNull)
				throw UnsupportedOperationException("Object has polygon shape, which is not supported")
			else
				type = ObjType.RECTANGLE

			if (data.rotation != 0.0)
				throw UnsupportedOperationException("Object has rotation: ${merged["rotation"].asDouble}, which is not supported")

			if (data.polyline != null) {
				merged["wire"] = "_polylineWire$id"
				merged["local"] = true

				this.polyline = ImmutableList.copyOf(data.polyline.map { Vector2i(it.x / PIXELS_IN_STARBOUND_UNITi, this@TiledMap.size.y - it.y / PIXELS_IN_STARBOUND_UNITi - 1) })
			} else {
				this.polyline = ImmutableList.of()
			}

			val calcPos = Vector2i(data.x, data.y) / PIXELS_IN_STARBOUND_UNITi + Vector2i(-((merged["imagePositionX"]?.asDouble ?: 0.0) / PIXELS_IN_STARBOUND_UNIT).toInt(), ((merged["imagePositionY"]?.asDouble ?: 0.0) / PIXELS_IN_STARBOUND_UNIT).toInt())

			pos = calcPos.copy(y = this@TiledMap.size.y - calcPos.y - 1)
			size = Vector2i(data.width, data.height) / PIXELS_IN_STARBOUND_UNITi

			if (type == ObjType.STAGEHAND) {
				val center = pos + size / 2
				// TODO: what?
				val broadcastMins = pos - center
				val broadcastMaxs = pos + size - center
				merged["broadcastArea"] = jsonArrayOf(
					JsonPrimitive(broadcastMins.x),
					JsonPrimitive(broadcastMins.y),
					JsonPrimitive(broadcastMaxs.x),
					JsonPrimitive(broadcastMaxs.y))
			}

			tile = TiledTileSet.makeTile(merged)
		}

		private fun stagehandPosition() = Vector2i(pos.x + size.x / 2, pos.y + (size.y.toDouble() / 2.0).roundToInt())

		override fun <T> walkTiles(callback: TileCallback<T>): KOptional<T> {
			return when (type) {
				ObjType.STAGEHAND -> {
					val (x, y) = stagehandPosition()
					callback(x, y, tile)
				}

				ObjType.TILE -> {
					// Used for placing Starbound-Tiles and Starbound-Objects
					callback(pos.x, pos.y + 1, tile) // i don't know why it needs 1 tile offset though
				}

				ObjType.WIRING -> {
					// Used for wiring. Treat each vertex in the polyline as a tile with the
					// wire brush.
					for ((x, y) in polyline) {
						val result = callback(this.pos.x + x, this.pos.y + y, tile)
						if (result.isPresent) return result
					}

					KOptional()
				}

				ObjType.RECTANGLE -> {
					// Used for creating custom brushes and rules
					for (y in this.pos.y until this.pos.y + this.size.y) {
						for (x in this.pos.x until this.pos.x + this.size.x) {
							val result = callback(x, y, tile)
							if (result.isPresent) return result
						}
					}

					KOptional()
				}
			}
		}

		override fun <T> walkTilesAt(x: Int, y: Int, callback: TileCallback<T>): KOptional<T> {
			return when(type) {
				ObjType.STAGEHAND -> {
					val (sx, sy) = stagehandPosition()
					if (sx == x && sy == y) return callback(x, y, tile)
					KOptional()
				}

				ObjType.TILE -> {
					if (x == pos.x && y == pos.y + 1) return callback(x, y, tile)
					KOptional()
				}

				ObjType.WIRING -> {
					for ((px, py) in polyline) {
						val ox = this.pos.x + px
						val oy = this.pos.y + py

						if (ox == x && oy == y) {
							val result = callback(x, y, tile)
							if (result.isPresent) return result
						}
					}

					KOptional()
				}

				ObjType.RECTANGLE -> {
					if (x in this.pos.x until this.pos.x + this.size.x && y in this.pos.y until this.pos.y + this.size.y) {
						return callback(x, y, tile)
					}

					KOptional()
				}
			}
		}
	}

	companion object {
		const val HORIZONTAL_FLIP = 1 shl 31
		const val VERTICAL_FLIP = 1 shl 30
		const val DIAGONAL_FLIP = 1 shl 29
		const val FLAG_BITS = HORIZONTAL_FLIP or VERTICAL_FLIP or DIAGONAL_FLIP
	}
}
