package ru.dbotthepony.kstarbound.world.entities.tile

import com.google.common.collect.ImmutableSet
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonPrimitive
import com.google.gson.TypeAdapter
import it.unimi.dsi.fastutil.ints.Int2ObjectAVLTreeMap
import it.unimi.dsi.fastutil.ints.Int2ObjectArrayMap
import it.unimi.dsi.fastutil.ints.Int2ObjectFunction
import it.unimi.dsi.fastutil.ints.IntArrayList
import it.unimi.dsi.fastutil.io.FastByteArrayInputStream
import it.unimi.dsi.fastutil.objects.ObjectArraySet
import org.apache.logging.log4j.LogManager
import ru.dbotthepony.kommons.gson.JsonArrayCollector
import ru.dbotthepony.kommons.gson.contains
import ru.dbotthepony.kommons.gson.get
import ru.dbotthepony.kommons.gson.set
import ru.dbotthepony.kommons.io.readByteArray
import ru.dbotthepony.kommons.io.readSignedVarInt
import ru.dbotthepony.kommons.io.writeBinaryString
import ru.dbotthepony.kommons.io.writeSignedVarInt
import ru.dbotthepony.kommons.util.getValue
import ru.dbotthepony.kommons.util.setValue
import ru.dbotthepony.kstarbound.Registry
import ru.dbotthepony.kstarbound.Starbound
import ru.dbotthepony.kstarbound.defs.EntityType
import ru.dbotthepony.kstarbound.defs.image.Image
import ru.dbotthepony.kstarbound.defs.tile.NO_DUNGEON_ID
import ru.dbotthepony.kstarbound.defs.tile.TileDamage
import ru.dbotthepony.kstarbound.defs.tile.TileDamageParameters
import ru.dbotthepony.kstarbound.defs.tile.TileDefinition
import ru.dbotthepony.kstarbound.defs.tile.isEmptyTile
import ru.dbotthepony.kstarbound.defs.tile.isMetaTile
import ru.dbotthepony.kstarbound.defs.tile.isNotEmptyTile
import ru.dbotthepony.kstarbound.defs.tile.isNullTile
import ru.dbotthepony.kstarbound.defs.world.BushVariant
import ru.dbotthepony.kstarbound.defs.world.GrassVariant
import ru.dbotthepony.kstarbound.defs.world.TreeVariant
import ru.dbotthepony.kstarbound.io.readDouble
import ru.dbotthepony.kstarbound.io.readEnumStupid
import ru.dbotthepony.kstarbound.io.readIntStupid
import ru.dbotthepony.kstarbound.io.readInternedString
import ru.dbotthepony.kstarbound.io.readVector2d
import ru.dbotthepony.kstarbound.io.writeDouble
import ru.dbotthepony.kstarbound.io.writeEnumStupid
import ru.dbotthepony.kstarbound.io.writeIntStupid
import ru.dbotthepony.kstarbound.io.writeStruct2d
import ru.dbotthepony.kstarbound.json.builder.IStringSerializable
import ru.dbotthepony.kstarbound.json.builder.JsonFactory
import ru.dbotthepony.kstarbound.json.readJsonElement
import ru.dbotthepony.kstarbound.json.writeJsonElement
import ru.dbotthepony.kstarbound.math.AABB
import ru.dbotthepony.kstarbound.math.vector.Vector2d
import ru.dbotthepony.kstarbound.math.vector.Vector2i
import ru.dbotthepony.kstarbound.network.syncher.NetworkedList
import ru.dbotthepony.kstarbound.network.syncher.legacyCodec
import ru.dbotthepony.kstarbound.network.syncher.nativeCodec
import ru.dbotthepony.kstarbound.network.syncher.networkedEventCounter
import ru.dbotthepony.kstarbound.network.syncher.networkedFloat
import ru.dbotthepony.kstarbound.server.world.ServerWorld
import ru.dbotthepony.kstarbound.util.AssetPathStack
import ru.dbotthepony.kstarbound.util.random.nextRange
import ru.dbotthepony.kstarbound.util.random.random
import ru.dbotthepony.kstarbound.world.PIXELS_IN_STARBOUND_UNIT
import ru.dbotthepony.kstarbound.world.TileHealth
import java.io.DataInputStream
import java.io.DataOutputStream
import java.util.*
import java.util.random.RandomGenerator
import kotlin.collections.ArrayList
import kotlin.math.absoluteValue

class PlantEntity() : TileEntity() {
	@JsonFactory
	data class Piece(
		val image: String,
		var offset: Vector2d,
		var segmentIdx: Int,
		val isStructuralSegment: Boolean,
		val kind: Kind,
		val rotationType: Rotation = Rotation.DONT_ROTATE,
		val rotationOffset: Double = 0.0,
		val flip: Boolean = false,
	) {
		// no need to serialize
		var imageSize: Vector2i = Vector2i.ZERO
		var spaces: Set<Vector2i> = setOf()
		var zLevel: Double = 0.0

		// int32_t
		enum class Kind {
			NONE, STEM, FOLIAGE
		}

		// int32_t
		enum class Rotation(override val jsonName: String) : IStringSerializable {
			DONT_ROTATE("dontRotate"),
			ROTATE_BRANCH("rotateBranch"),
			ROTATE_LEAVES("rotateLeaves"),
			ROTATE_CROWN_BRANCH("rotateCrownBranch"),
			ROTATE_CROWN_LEAVES("rotateCrownLeaves")
		}

		// lmao this order of writing has almost zero correlation with how those
		// fields are declared in struct {} itself
		fun write(stream: DataOutputStream, isLegacy: Boolean) {
			stream.writeBinaryString(image)
			stream.writeStruct2d(offset, isLegacy)

			stream.writeEnumStupid(rotationType.ordinal, isLegacy)
			stream.writeDouble(rotationOffset, isLegacy)
			stream.writeBoolean(isStructuralSegment)
			stream.writeEnumStupid(kind.ordinal, isLegacy)
			stream.writeIntStupid(segmentIdx, isLegacy)
			stream.writeBoolean(flip)
		}

		companion object {
			val CODEC = nativeCodec(::read, Piece::write)
			val LEGACY_CODEC = legacyCodec(::read, Piece::write)

			val ADAPTER: TypeAdapter<Piece> by lazy { Starbound.gson.getAdapter(Piece::class.java) }

			fun read(stream: DataInputStream, isLegacy: Boolean): Piece {
				val image = stream.readInternedString()
				val offset = stream.readVector2d(isLegacy)
				val rotationType = Rotation.entries[stream.readEnumStupid(isLegacy)]
				val rotationOffset = stream.readDouble(isLegacy)
				val isStructuralSegment = stream.readBoolean()
				val kind = Kind.entries[stream.readEnumStupid(isLegacy)]
				val segmentIndex = stream.readIntStupid(isLegacy)
				val flip = stream.readBoolean()

				return Piece(
					image = image,
					offset = offset,
					rotationType = rotationType,
					rotationOffset = rotationOffset,
					isStructuralSegment = isStructuralSegment,
					kind = kind,
					segmentIdx = segmentIndex,
					flip = flip,
				)
			}
		}
	}

	override fun deserialize(data: JsonObject) {
		super.deserialize(data)

		isCeiling = data.get("ceiling", false)
		stemDropConfig = data["stemDropConfig"] as? JsonObject ?: JsonObject()
		foliageDropConfig = data["foliageDropConfig"] as? JsonObject ?: JsonObject()
		saplingDropConfig = data["saplingDropConfig"] ?: JsonObject()
		descriptions = data["descriptions"] as? JsonObject ?: JsonObject()
		isEphemeral = data.get("ephemeral", false)
		fallsWhenDead = data.get("fallsWhenDead", false)
		tileDamageParameters = data.get("tileDamageParameters", damageParameters)

		this.piecesInternal.clear()

		for (v in data.get("pieces", JsonArray())) {
			this.piecesInternal.add(Piece.ADAPTER.fromJsonTree((v as JsonObject).apply {
				val kind = this["kind"]

				// holy shiiiiieeeet
				if (kind is JsonPrimitive && kind.isNumber) {
					this["kind"] = Piece.Kind.entries[kind.asInt].name
				}
			}))
		}

		scanSpacesAndRoots()
	}

	override fun serialize(data: JsonObject) {
		super.serialize(data)

		data["ceiling"] = isCeiling
		data["stemDropConfig"] = stemDropConfig.deepCopy()
		data["foliageDropConfig"] = foliageDropConfig.deepCopy()
		data["saplingDropConfig"] = saplingDropConfig.deepCopy()
		data["descriptions"] = descriptions.deepCopy()
		data["ephemeral"] = isEphemeral
		data["fallsWhenDead"] = fallsWhenDead
		data["tileDamageParameters"] = damageParameters.toJsonTree(tileDamageParameters)

		// holy shiiiiieeeet
		data["pieces"] = piecesInternal.stream()
			.map { Piece.ADAPTER.toJsonTree(it) as JsonObject }
			.peek { it["kind"] = Piece.Kind.valueOf(it["kind"].asString).ordinal }
			.collect(JsonArrayCollector)
	}

	override val type: EntityType
		get() = EntityType.PLANT

	val health = TileHealth.TileEntity().also { networkGroup.upstream.add(it.networkGroup) }

	private val piecesInternal = NetworkedList(Piece.CODEC, Piece.LEGACY_CODEC, extraStupid = true).also { networkGroup.upstream.add(it) }
	private var piecesDirty = false

	init {
		piecesInternal.addListener(Runnable {
			piecesDirty = true
		})
	}

	val pieces: List<Piece> = Collections.unmodifiableList(piecesInternal)

	var tileDamageX by networkedFloat().also { networkGroup.upstream.add(it) }
		private set
	var tileDamageY by networkedFloat().also { networkGroup.upstream.add(it) }
		private set
	val tileDamageEvent = networkedEventCounter().also { networkGroup.upstream.add(it) }

	var isCeiling = false
		private set
	var fallsWhenDead = false
		private set
	var stemDropConfig: JsonObject = JsonObject()
		private set
	var foliageDropConfig: JsonObject = JsonObject()
		private set
	var saplingDropConfig: JsonElement = JsonObject()
		private set
	var descriptions: JsonObject = JsonObject()
		private set
	var tileDamageParameters = TileDamageParameters.EMPTY
		private set

	constructor(config: TreeVariant, random: RandomGenerator) : this() {
		isCeiling = config.ceiling
		fallsWhenDead = true

		stemDropConfig = (config.stemDropConfig as? JsonObject)?.deepCopy() ?:  JsonObject()
		foliageDropConfig = (config.foliageDropConfig as? JsonObject)?.deepCopy() ?:  JsonObject()

		if (stemDropConfig.isJsonNull)
			stemDropConfig = JsonObject()

		if (foliageDropConfig.isJsonNull)
			foliageDropConfig = JsonObject()

		stemDropConfig["hueshift"] = config.stemHueShift
		foliageDropConfig["hueshift"] = config.foliageHueShift

		val saplingDropConfig = JsonObject()
		saplingDropConfig["stemName"] = config.stemName
		saplingDropConfig["stemHueShift"] = config.stemHueShift

		// original engine has "always true" condition because it checks against field "foliageDropConfig"
		// which is coalesced to json object if config.foliageDropConfig is null
		if (!config.foliageDropConfig.isJsonNull && config.foliageName.isNotBlank()) {
			saplingDropConfig["foliageName"] = config.foliageName
			saplingDropConfig["foliageHueShift"] = config.foliageHueShift
		}

		this.saplingDropConfig = saplingDropConfig

		var xOffset = 0.0
		var yOffset = 0.0
		val roffset = random.nextDouble(0.5)

		descriptions = config.descriptions.deepCopy()
		isEphemeral = config.ephemeral
		tileDamageParameters = config.tileDamageParameters

		var segment = 0

		fun leaf(key: String, leaves: JsonObject, xOff: Double = xOffset, yOff: Double = yOffset, rotationOffset: Double? = null, rotationType: Piece.Rotation = Piece.Rotation.ROTATE_LEAVES) {
			if (key in leaves) {
				val settings = leaves[key].asJsonObject
				val attachment = settings.get("attachment", JsonObject())

				val xOf = xOff + attachment.get("bx", 0.0) / PIXELS_IN_STARBOUND_UNIT
				val yOf = yOff + attachment.get("by", 0.0) / PIXELS_IN_STARBOUND_UNIT

				if ("image" in settings && settings["image"].asString.isNotBlank()) {
					val file = AssetPathStack.relativeTo(config.foliageDirectory, settings["image"].asString)

					piecesInternal.add(Piece(
						image = "$file?hueshift=${config.foliageHueShift.toInt()}",
						offset = Vector2d(xOf, yOf),
						segmentIdx = segment,
						isStructuralSegment = false,
						kind = Piece.Kind.FOLIAGE,
						rotationType = if (isCeiling) Piece.Rotation.DONT_ROTATE else rotationType,
						rotationOffset = rotationOffset ?: (random.nextDouble() + roffset)
					).apply { zLevel = 3.0 })
				}

				if ("backimage" in settings && settings["backimage"].asString.isNotBlank()) {
					val file = AssetPathStack.relativeTo(config.foliageDirectory, settings["backimage"].asString)

					piecesInternal.add(Piece(
						image = "$file?hueshift=${config.foliageHueShift.toInt()}",
						offset = Vector2d(xOf, yOf),
						segmentIdx = segment,
						isStructuralSegment = false,
						kind = Piece.Kind.FOLIAGE,
						rotationType = if (isCeiling) Piece.Rotation.DONT_ROTATE else rotationType,
						rotationOffset = rotationOffset ?: (random.nextDouble() + roffset)
					).apply { zLevel = -1.0 })
				}
			}
		}

		// base
		run {
			val bases = config.stemSettings.asJsonObject["base"].asJsonObject
			val baseKey = bases.keySet().random(random)
			val baseSettings = bases[baseKey].asJsonObject
			val attachmentSettings = baseSettings.get("attachment", JsonObject())

			xOffset += attachmentSettings.get("bx", 0.0) / PIXELS_IN_STARBOUND_UNIT
			yOffset += attachmentSettings.get("by", 0.0) / PIXELS_IN_STARBOUND_UNIT

			val baseFile = AssetPathStack.relativeTo(config.stemDirectory, baseSettings["image"].asString)

			if (isCeiling) {
				val img = Image.get(baseFile)

				if (img == null) {
					LOGGER.error("Unable to load Tree's base stem image $baseFile, expect bad things to happen!")
					return
				}

				yOffset = 1.0 - img.size.y / PIXELS_IN_STARBOUND_UNIT
			}

			piecesInternal.add(Piece(
				image = "$baseFile?hueshift=${config.stemHueShift.toInt()}",
				offset = Vector2d(xOffset, yOffset),
				segmentIdx = segment,
				isStructuralSegment = true,
				kind = Piece.Kind.STEM,
				rotationType = Piece.Rotation.DONT_ROTATE,
				rotationOffset = random.nextDouble() + roffset
			))

			// base leaves
			leaf(baseKey, config.foliageSettings.get("baseLeaves", JsonObject()))

			xOffset += attachmentSettings.get("x", 0.0) / PIXELS_IN_STARBOUND_UNIT
			yOffset += attachmentSettings.get("y", 0.0) / PIXELS_IN_STARBOUND_UNIT // trunk height

			segment++
		}

		var branchYOffset = yOffset

		// trunk
		run {
			val middles = config.stemSettings.get("middle").asJsonObject
			val middleHeight = random.nextRange(config.stemSettings.get("middleMinSize", 1), config.stemSettings.get("middleMaxSize", 6))

			val branches = config.stemSettings["branch"]?.asJsonObject ?: JsonObject()

			for (i in 0 until middleHeight) {
				val middleKey = middles.keySet().random(random)
				val middleSettings = middles[middleKey].asJsonObject
				val attachmentSettings = middleSettings.get("attachment", JsonObject())

				xOffset += attachmentSettings.get("bx", 0.0) / PIXELS_IN_STARBOUND_UNIT
				yOffset += attachmentSettings.get("by", 0.0) / PIXELS_IN_STARBOUND_UNIT

				val middleFile = AssetPathStack.relativeTo(config.stemDirectory, middleSettings["image"].asString)

				piecesInternal.add(Piece(
					image = "$middleFile?hueshift=${config.stemHueShift.toInt()}",
					offset = Vector2d(xOffset, yOffset),
					segmentIdx = segment,
					isStructuralSegment = true,
					kind = Piece.Kind.STEM,
					rotationType = Piece.Rotation.DONT_ROTATE,
					rotationOffset = random.nextDouble() + roffset
				).apply { zLevel = 1.0 })

				// trunk leaves
				leaf(middleKey, config.foliageSettings.get("trunkLeaves", JsonObject()))

				xOffset += attachmentSettings.get("x", 0.0) / PIXELS_IN_STARBOUND_UNIT
				yOffset += attachmentSettings.get("y", 0.0) / PIXELS_IN_STARBOUND_UNIT

				// branch
				while (branches.size() != 0 && yOffset >= branchYOffset && middleHeight - i > 0) {
					val branchKey = branches.keySet().random(random)

					val branchSettings = branches[branchKey].asJsonObject
					val attachmentSettings = branchSettings.get("attachment", JsonObject())

					val h = attachmentSettings.get("h", 0.0) / PIXELS_IN_STARBOUND_UNIT

					if (yOffset < branchYOffset + h / 2.0)
						break

					val xO = xOffset + attachmentSettings.get("bx", 0.0) / PIXELS_IN_STARBOUND_UNIT
					val yO = branchYOffset + attachmentSettings.get("by", 0.0) / PIXELS_IN_STARBOUND_UNIT

					if (config.stemSettings.get("alwaysBranch", false) || random.nextInt(2 + i) != 0) {
						val boffset = random.nextDouble() + roffset
						val branchFile = AssetPathStack.relativeTo(config.stemDirectory, branchSettings["image"].asString)

						piecesInternal.add(Piece(
							image = "$branchFile?hueshift=${config.stemHueShift.toInt()}",
							offset = Vector2d(xO, yO),
							segmentIdx = segment,
							isStructuralSegment = false,
							kind = Piece.Kind.STEM,
							rotationType = if (isCeiling) Piece.Rotation.DONT_ROTATE else Piece.Rotation.ROTATE_BRANCH,
							rotationOffset = boffset
						))

						branchYOffset += h

						// branch leaves
						leaf(branchKey, config.foliageSettings.get("branchLeaves", JsonObject()), xO, yO, boffset)
					} else {
						branchYOffset += h / random.nextDouble(1.0, 4.0)
					}
				}

				segment++
			}
		}

		// crown
		run {
			val crowns = config.stemSettings.get("crown", JsonObject())

			if (crowns.size() != 0) {
				val crownKey = crowns.keySet().random(random)
				val crownSettings = crowns[crownKey].asJsonObject
				val attachmentSettings = crownSettings.get("attachment", JsonObject())

				xOffset += attachmentSettings.get("bx", 0.0) / PIXELS_IN_STARBOUND_UNIT
				yOffset += attachmentSettings.get("by", 0.0) / PIXELS_IN_STARBOUND_UNIT

				val coffset = random.nextDouble() + roffset
				val crownFile = AssetPathStack.relativeTo(config.stemDirectory, crownSettings["image"].asString)

				piecesInternal.add(Piece(
					image = "$crownFile?hueshift=${config.stemHueShift.toInt()}",
					offset = Vector2d(xOffset, yOffset),
					segmentIdx = segment,
					isStructuralSegment = false,
					kind = Piece.Kind.STEM,
					rotationType = if (isCeiling) Piece.Rotation.DONT_ROTATE else Piece.Rotation.ROTATE_CROWN_BRANCH,
					rotationOffset = coffset
				))

				// crown leaves
				leaf(crownKey, config.foliageSettings.get("crownLeaves", JsonObject()), rotationOffset = coffset, rotationType = Piece.Rotation.ROTATE_CROWN_LEAVES)
			}
		}

		piecesInternal.sortBy { it.zLevel }
		scanSpacesAndRoots()
	}

	constructor(config: BushVariant, random: RandomGenerator) : this() {
		val shape = config.shapes.random(random)
		val shapeImageName = AssetPathStack.relativeTo(config.directory, shape.image)
		var offset = Vector2d.ZERO
		isCeiling = config.ceiling

		if (isCeiling) {
			val img = Image.get(shapeImageName)

			if (img == null) {
				LOGGER.error("Unable to load Bush variant's image $shapeImageName, expect bad things to happen!")
				return
			}

			offset = Vector2d(y = 1.0 - img.size.y / PIXELS_IN_STARBOUND_UNIT)
		}

		piecesInternal.add(Piece(
			image = "$shapeImageName?hueshift=${config.baseHueShift.toInt()}",
			offset = offset,
			segmentIdx = 0,
			isStructuralSegment = true,
			kind = Piece.Kind.NONE
		))

		if (shape.mods.isNotEmpty()) {
			val mod = shape.mods.random(random)

			piecesInternal.add(Piece(
				image = "${AssetPathStack.relativeTo(config.directory, mod)}?hueshift=${config.modHueShift.toInt()}",
				offset = offset,
				segmentIdx = 0,
				isStructuralSegment = false,
				kind = Piece.Kind.NONE
			))
		}

		scanSpacesAndRoots()
	}

	constructor(config: GrassVariant, random: RandomGenerator) : this() {
		var offset = Vector2d.ZERO
		val image = AssetPathStack.relativeTo(config.directory, config.images.random(random))
		isCeiling = config.ceiling

		if (isCeiling) {
			// If this is a ceiling plant, offset the image so that the [0, 0] space is at the top
			val img = Image.get(image)

			if (img == null) {
				LOGGER.error("Unable to load Grass variant's image $image, expect bad things to happen!")
				return
			}

			offset = Vector2d(y = 1.0 - img.size.y / PIXELS_IN_STARBOUND_UNIT)
		}

		val piece = Piece(
			image = "$image?hueshift=${config.hueShift.toInt()}",
			offset = offset,
			segmentIdx = 0,
			isStructuralSegment = true,
			kind = Piece.Kind.NONE,
		)

		piecesInternal.add(piece)
		scanSpacesAndRoots()
	}

	constructor(stream: DataInputStream, isLegacy: Boolean) : this() {
		xTilePosition = stream.readSignedVarInt()
		yTilePosition = stream.readSignedVarInt()

		isCeiling = stream.readBoolean()
		stemDropConfig = stream.readJsonElement() as JsonObject
		foliageDropConfig = stream.readJsonElement() as JsonObject
		saplingDropConfig = stream.readJsonElement()
		descriptions = stream.readJsonElement() as JsonObject

		isEphemeral = stream.readBoolean()
		tileDamageParameters = TileDamageParameters(stream, isLegacy)
		fallsWhenDead = stream.readBoolean()
		health.read(stream, isLegacy)

		val readPieces = if (isLegacy) {
			DataInputStream(FastByteArrayInputStream(stream.readByteArray()))
		} else {
			stream
		}

		piecesInternal.readInitial(stream, isLegacy)
		scanSpacesAndRoots()
	}

	constructor(data: JsonObject) : this() {
		deserialize(data)
	}

	override fun writeNetwork(stream: DataOutputStream, isLegacy: Boolean) {
		stream.writeSignedVarInt(xTilePosition)
		stream.writeSignedVarInt(yTilePosition)

		stream.writeBoolean(isCeiling)
		stream.writeJsonElement(stemDropConfig)
		stream.writeJsonElement(foliageDropConfig)
		stream.writeJsonElement(saplingDropConfig)
		stream.writeJsonElement(descriptions)
		stream.writeBoolean(isEphemeral)
		tileDamageParameters.write(stream, isLegacy)
		stream.writeBoolean(fallsWhenDead)
		health.write(stream, isLegacy)
		piecesInternal.writeInitial(stream, isLegacy)
	}

	private var calculatedBoundingBox = AABB.ZERO
	private var calculatedOccupySpaces: Set<Vector2i> = setOf()
	private var calculatedRoots: Set<Vector2i> = setOf()

	override var metaBoundingBox: AABB = calculatedBoundingBox
		private set

	override var occupySpaces: ImmutableSet<Vector2i> = ImmutableSet.of()
		private set

	override val materialSpaces: Collection<Pair<Vector2i, Registry.Ref<TileDefinition>>>
		get() = setOf()

	override var roots: ImmutableSet<Vector2i> = ImmutableSet.of()
		private set

	val primaryRoot: Vector2i
		get() = if (isCeiling) Vector2i(xTilePosition, yTilePosition + 1) else Vector2i(xTilePosition, yTilePosition - 1)

	private fun scanSpacesAndRoots() {
		if (!piecesDirty) return
		piecesDirty = false
		val spaces = ObjectArraySet<Vector2i>()
		spaces.add(Vector2i.ZERO)

		for (piece in piecesInternal) {
			val image = Image.get(piece.image)

			if (image == null) {
				LOGGER.error("Unable to load image ${piece.image} for $this, expect bad things to happen!")
				continue
			}

			piece.imageSize = image.size
			piece.spaces = image.worldSpaces(piece.offset * PIXELS_IN_STARBOUND_UNIT, 0.1, piece.flip)
			spaces.addAll(piece.spaces)
		}

		this.calculatedOccupySpaces = spaces

		val minX = spaces.minOf { it.x }
		val maxX = spaces.maxOf { it.x }
		val minY = spaces.minOf { it.y }
		val maxY = spaces.maxOf { it.y }

		this.calculatedBoundingBox = AABB(
			Vector2d(minX - 1.0, minY - 1.0),
			Vector2d(maxX + 2.0, maxY + 2.0),
		)

		val roots = ObjectArraySet<Vector2i>()

		for (space in spaces) {
			if (space.y == 0) {
				if (isCeiling) {
					roots.add(Vector2i(space.x, 1))
				} else {
					roots.add(Vector2i(space.x, -1))
				}
			}
		}

		this.calculatedRoots = roots

		moveSpaces()
	}

	override fun tick(delta: Double) {
		super.tick(delta)

		if (world.isServer) {
			if (piecesInternal.isEmpty()) {
				remove(RemovalReason.REMOVED)
			} else if (roots.isNotEmpty()) {
				for (root in roots) {
					val cell = world.getCell(root)

					// avoid timber on edges of chunks which are being unloaded / loaded
					if (cell.foreground.material.isEmptyTile && !cell.foreground.material.isNullTile) {
						if (fallsWhenDead) {
							breakAtPosition(tilePosition, position)
						}

						remove(RemovalReason.DYING)
						return
					}
				}
			}
		}

		if (!isRemote) {
			health.tick(tileDamageParameters, delta)
		}
	}

	private fun moveSpaces() {
		scanSpacesAndRoots()
		this.metaBoundingBox = this.calculatedBoundingBox + this.position
		this.occupySpaces = this.calculatedOccupySpaces.stream().map { it + tilePosition }.collect(ImmutableSet.toImmutableSet())
		this.roots = this.calculatedRoots.stream().map { it + tilePosition }.collect(ImmutableSet.toImmutableSet())
		markRootsDirty()
		updateSpatialPosition()
	}

	override fun onPositionUpdated() {
		super.onPositionUpdated()
		moveSpaces()
	}

	override fun damageTileEntity(damageSpaces: List<Vector2i>, source: Vector2d, damage: TileDamage): Boolean {
		if (damageSpaces.isEmpty())
			return false

		var baseDamagePosition: Vector2i = damageSpaces.first()

		for (piece in pieces) {
			if (piece.isStructuralSegment) {
				for (space in piece.spaces) {
					for (pos in damageSpaces) {
						if (world.geometry.wrap(space + tilePosition) == pos && baseDamagePosition.y < pos.y == isCeiling) {
							// if this space is a "better match" for the root of the plant
							baseDamagePosition = pos
						}
					}
				}
			}
		}

		val (x, y) = world.geometry.diff(baseDamagePosition, tilePosition)

		// TODO: this is unnatural solution to tree damage,
		//  each tree piece should have its own damage status
		health.damage(tileDamageParameters, source, damage)
		tileDamageX = x.toDouble()
		tileDamageY = y.toDouble()
		tileDamageEvent.trigger()

		if (health.isDead) {
			if (fallsWhenDead) {
				health.reset()
				breakAtPosition(baseDamagePosition, source)
			}

			remove(RemovalReason.DYING)
			return true
		}

		return false
	}

	private fun breakAtPosition(position: Vector2i, source: Vector2d) {
		val internalPos = world.geometry.diff(position, tilePosition)
		var breakAtPiece = pieces.lastOrNull { it.isStructuralSegment && internalPos in it.spaces }

		// default to highest structural piece
		if (breakAtPiece == null) {
			breakAtPiece = pieces.lastOrNull { it.isStructuralSegment }
		}

		// plant has no structural segments? this is a terrible fallback because it
		// prevents destruction
		breakAtPiece ?: return

		var breakPoint = position.toDoubleVector() - tilePosition

		if (breakAtPiece.spaces.isNotEmpty()) {
			val bounds = AABB.ofPoints(breakAtPiece.spaces)

			breakPoint = Vector2d(
				bounds.mins.x + bounds.width / 2.0,
				if (isCeiling) bounds.maxs.y else bounds.mins.y
			)
		}

		val droppedPieces = ArrayList<Piece>()

		var idx = 0

		while (idx < pieces.size) {
			if (piecesInternal[idx].segmentIdx >= breakAtPiece.segmentIdx) {
				droppedPieces.add(piecesInternal.removeAt(idx))
			} else {
				idx++
			}
		}

		val breakPointI = Vector2i((breakPoint.x + 0.5).toInt(), (breakPoint.y + 0.5).toInt())

		// Calculate a new origin for the droppedPieces
		for (piece in droppedPieces) {
			piece.offset -= breakPoint
			piece.spaces = piece.spaces.map { it - breakPointI }.toSet()
		}

		val worldSpaceBreakPoint = breakPoint + tilePosition
		val segments = Int2ObjectAVLTreeMap<ArrayList<Piece>>()

		for (piece in droppedPieces) {
			segments.computeIfAbsent(piece.segmentIdx, Int2ObjectFunction { ArrayList() }).add(piece)
		}

		val angle = world.random.nextDouble(-0.3, 0.3)
		val itr = segments.keys.iterator(segments.keys.lastInt())
		val fallVector = (source - worldSpaceBreakPoint).unitVector
		var first = true

		while (itr.hasPrevious()) {
			val index = itr.previousInt()
			val segment = segments[index]!!

			val entity = PlantPieceEntity(
				segment,
				worldSpaceBreakPoint,
				fallVector,
				description,
				isCeiling,
				stemDropConfig,
				foliageDropConfig,
				saplingDropConfig,
				first,
				angle
			)

			entity.joinWorld(world)
			first = false
		}
	}

	override fun toString(): String {
		return "PlantEntity[at=$tilePosition, pieces=${pieces.size}]"
	}

	fun plant(world: ServerWorld, position: Vector2i, ignoreDungeonID: Int = NO_DUNGEON_ID): Boolean {
		if (isInWorld)
			throw IllegalStateException("Already in world")

		world.eventLoop.ensureSameThread()
		tilePosition = position

		val primaryCell = world.getCell(position)
		val adjustBackground = primaryCell.background.material.isEmptyTile

		// Bail out if we don't have at least one free space, and root in the primary
		// root position, or if we're in a dungeon region.
		val rootCell = world.getCell(primaryRoot).mutable()

		if (
			primaryCell.dungeonId != ignoreDungeonID ||
			rootCell.dungeonId != ignoreDungeonID ||
			primaryCell.foreground.material.value.isConnectable ||
			!rootCell.foreground.material.value.isConnectable
		) return false

		// First bail out if we can't fit anything we're not adjusting
		for (space in occupySpaces) {
			// TODO: conditions seems to be inverted
			if (withinAdjustments(space, position) && world.entityIndex.tileEntityAt(space, PlantEntity::class) != null) {
				return false
			}

			// Bail out if we hit a different plant's root tile, or if we're not in the
			// adjustment space and we hit a non-empty tile.
			val cell = world.getCell(space)

			if (cell.rootSource != null || (!withinAdjustments(space, position) && cell.foreground.material.isNotEmptyTile)) {
				return false
			}
		}

		// Check all the roots outside of the adjustment limit
		for (root in roots) {
			if (!withinAdjustments(root, position) && !world.getCell(root).foreground.material.value.isConnectable) {
				return false
			}
		}

		// Clear all the necessary blocks within the adjustment limit
		for (space in occupySpaces) {
			if (!withinAdjustments(space, position))
				continue

			var cell = world.getCell(space).mutable()

			if (cell.foreground.material.value.isConnectable) {
				cell = primaryCell.mutable()
			}

			if (adjustBackground) {
				cell.background.empty()
			}

			world.setCell(space, cell)
		}

		// Make all the root blocks a real material based on the primary root.
		for (root in roots) {
			val cell = world.getCell(root)

			if (cell.foreground.material.isMetaTile) {
				// what the hell original engine does here?
				world.setCell(root, rootCell.copy(rootSource = tilePosition))
			}
		}

		joinWorld(world)
		return true
	}

	companion object {
		private fun withinAdjustments(root: Vector2i, position: Vector2i): Boolean {
			return (root.x - position.x).absoluteValue <= PLANT_ADJUSTMENT_LIMIT && (root.y - position.y).absoluteValue <= PLANT_ADJUSTMENT_LIMIT
		}

		const val PLANT_ADJUSTMENT_LIMIT = 2
		private val LOGGER = LogManager.getLogger()
		private val damageParameters by lazy { Starbound.gson.getAdapter(TileDamageParameters::class.java) }
	}
}
