package ru.dbotthepony.kstarbound.defs.world

import com.github.benmanes.caffeine.cache.Caffeine
import com.google.gson.JsonObject
import org.apache.logging.log4j.LogManager
import ru.dbotthepony.kommons.util.AABBi
import ru.dbotthepony.kommons.util.Either
import ru.dbotthepony.kommons.vector.Vector2d
import ru.dbotthepony.kommons.vector.Vector2i
import ru.dbotthepony.kstarbound.Globals
import ru.dbotthepony.kstarbound.Registry
import ru.dbotthepony.kstarbound.Starbound
import ru.dbotthepony.kstarbound.defs.dungeon.DungeonDefinition
import ru.dbotthepony.kstarbound.defs.tile.BuiltinMetaMaterials
import ru.dbotthepony.kstarbound.defs.tile.LiquidDefinition
import ru.dbotthepony.kstarbound.defs.tile.TileDefinition
import ru.dbotthepony.kstarbound.defs.tile.TileModifierDefinition
import ru.dbotthepony.kstarbound.defs.tile.isNotEmptyLiquid
import ru.dbotthepony.kstarbound.json.builder.JsonFactory
import ru.dbotthepony.kstarbound.math.quintic2
import ru.dbotthepony.kstarbound.util.random.random
import ru.dbotthepony.kstarbound.util.random.staticRandom64
import ru.dbotthepony.kstarbound.util.random.staticRandomInt
import ru.dbotthepony.kstarbound.world.Universe
import ru.dbotthepony.kstarbound.world.UniversePos
import ru.dbotthepony.kstarbound.world.WorldGeometry
import ru.dbotthepony.kstarbound.world.physics.Poly
import java.time.Duration
import java.util.concurrent.CopyOnWriteArrayList
import java.util.random.RandomGenerator

class WorldTemplate(val geometry: WorldGeometry) {
	var seed: Long = 0L
		private set
	var worldName: String = ""
		private set
	var worldParameters: VisitableWorldParameters? = null
		private set
	var worldLayout: WorldLayout? = null
		private set
	var skyParameters: SkyParameters = SkyParameters()
		private set
	var celestialParameters: CelestialParameters? = null
		private set

	val threatLevel: Double
		get() = worldParameters?.threatLevel ?: 0.0

	// CoW because these are updated by dungeons across threads (we can't synchronize these updates
	// because tile generation always happen on worker threads)
	private val customTerrainRegions = CopyOnWriteArrayList<CustomTerrainRegion>()

	constructor(worldParameters: VisitableWorldParameters, skyParameters: SkyParameters, seed: Long) : this(WorldGeometry(worldParameters.worldSize, true, false)) {
		this.seed = seed
		this.worldParameters = worldParameters
		this.skyParameters = skyParameters
		this.worldLayout = worldParameters.createLayout(seed)
	}

	constructor(worldParameters: VisitableWorldParameters, skyParameters: SkyParameters, random: RandomGenerator) : this(WorldGeometry(worldParameters.worldSize, true, false)) {
		this.seed = random.nextLong()
		this.worldParameters = worldParameters
		this.skyParameters = skyParameters
		this.worldLayout = worldParameters.createLayout(random)
	}

	fun determineName() {
		if (celestialParameters != null) {
			worldName = celestialParameters!!.name
		} else if (worldParameters is FloatingDungeonWorldParameters) {

		} else {
			worldName = ""
		}
	}

	@JsonFactory
	data class CustomTerrainRegion(
		val region: Poly,
		val solid: Boolean
	) {
		val aabb = region.aabb.enlarge(Globals.worldTemplate.customTerrainBlendSize, Globals.worldTemplate.customTerrainBlendSize)
	}

	fun addCustomTerrainRegion(region: Poly) {
		customTerrainRegions.add(CustomTerrainRegion(region, true))
	}

	fun addCustomSpaceRegion(region: Poly) {
		customTerrainRegions.add(CustomTerrainRegion(region, false))
	}

	@JsonFactory
	data class SerializedForm(
		val celestialParameters: CelestialParameters? = null,
		val worldParameters: VisitableWorldParameters? = null,
		val skyParameters: SkyParameters = SkyParameters(),
		val seed: Long = 0L,
		val size: Either<WorldGeometry, Vector2i>,
		val regionData: WorldLayout? = null,
		val customTerrainRegions: List<CustomTerrainRegion>,
	)

	fun toJson(): JsonObject {
		val data = Starbound.gson.toJsonTree(SerializedForm(
			celestialParameters, worldParameters, skyParameters, seed,
			if (Starbound.IS_LEGACY_JSON) Either.right(geometry.size) else Either.left(geometry),
			worldLayout, customTerrainRegions
		)) as JsonObject

		return data
	}

	fun findSensiblePlayerStart(): Vector2d? {
		val layout = worldLayout ?: return null

		if (layout.playerStartSearchRegions.isEmpty())
			return null

		val random = random()

		for (i in 0 until Globals.worldTemplate.playerStartSearchTries) {
			val region = layout.playerStartSearchRegions.random(random)
			val x = random.nextInt(region.mins.x, region.maxs.x)

			for (y in region.maxs.y - 1 downTo  region.mins.y) {
				val info = cellInfo(x, y)

				if (info.terrain && !cellInfo(x, y + 1).terrain) {
					var isFree = true

					for (x2 in x - Globals.worldTemplate.playerStartFreeBlocksRadius .. x + Globals.worldTemplate.playerStartFreeBlocksRadius) {
						for (y2 in y + 1 .. y + Globals.worldTemplate.playerStartFreeBlocksHeight) {
							isFree = isFree && isFree(x2, y2)
						}
					}

					if (isFree) {
						return Vector2d(x.toDouble(), y + 1.0)
					}
				}
			}
		}

		return null
	}

	fun isFree(x: Int, y: Int): Boolean {
		return !cellInfo(geometry.x.cell(x), geometry.y.cell(y)).terrain
	}

	fun isFree(region: AABBi): Boolean {
		for (x in region.mins.x .. region.maxs.x) {
			for (y in region.mins.y .. region.maxs.y) {
				if (cellInfo(geometry.x.cell(x), geometry.y.cell(y)).terrain) {
					return false
				}
			}
		}

		return true
	}

	fun surfaceLevel(): Int {
		val parameters = worldParameters

		if (parameters is TerrestrialWorldParameters) {
			return parameters.surfaceLayer.layerBaseHeight
		}

		return geometry.size.y / 2
	}

	fun seedFor(x: Int, y: Int) = staticRandom64(geometry.x.cell(x), geometry.y.cell(y), seed, "Block")

	class PotentialBiomeItems(
		// Potential items that would spawn at the given block assuming it is at
		val surfaceBiomeItems: List<BiomePlaceables.Placement>,

		// ... Or on a cave surface.
		val caveSurfaceBiomeItems: List<BiomePlaceables.Placement>,

		// ... Or on a cave ceiling.
		val caveCeilingBiomeItems: List<BiomePlaceables.Placement>,

		// ... Or on a cave background wall.
		val caveBackgroundBiomeItems: List<BiomePlaceables.Placement>,

		// ... Or in the ocean
		val oceanItems: List<BiomePlaceables.Placement>,
	)

	fun potentialBiomeItemsAt(x: Int, y: Int): PotentialBiomeItems {
		val thisBlockBiome = cellInfo(geometry.x.cell(x), geometry.y.cell(y)).blockBiome
		val lowerBlockBiome = cellInfo(geometry.x.cell(x), geometry.y.cell(y - 1)).blockBiome
		val upperBlockBiome = cellInfo(geometry.x.cell(x), geometry.y.cell(y + 1)).blockBiome

		return PotentialBiomeItems(
			surfaceBiomeItems = lowerBlockBiome?.surfacePlaceables?.itemDistributions?.filter { it.mode == BiomePlaceablesDefinition.Placement.FLOOR }?.map { it.itemToPlace(x, y) }?.filterNotNull() ?: listOf(),
			oceanItems = thisBlockBiome?.surfacePlaceables?.itemDistributions?.filter { it.mode == BiomePlaceablesDefinition.Placement.OCEAN }?.map { it.itemToPlace(x, y) }?.filterNotNull() ?: listOf(),

			caveSurfaceBiomeItems = lowerBlockBiome?.undergroundPlaceables?.itemDistributions?.filter { it.mode == BiomePlaceablesDefinition.Placement.FLOOR }?.map { it.itemToPlace(x, y) }?.filterNotNull() ?: listOf(),
			caveCeilingBiomeItems = upperBlockBiome?.undergroundPlaceables?.itemDistributions?.filter { it.mode == BiomePlaceablesDefinition.Placement.CEILING }?.map { it.itemToPlace(x, y) }?.filterNotNull() ?: listOf(),
			caveBackgroundBiomeItems = thisBlockBiome?.undergroundPlaceables?.itemDistributions?.filter { it.mode == BiomePlaceablesDefinition.Placement.BACKGROUND }?.map { it.itemToPlace(x, y) }?.filterNotNull() ?: listOf(),
		)
	}

	fun validBiomeItemsAt(x: Int, y: Int): List<BiomePlaceables.Placement> {
		val thisBlock = cellInfo(geometry.x.cell(x), geometry.y.cell(y))

		if (thisBlock.biomeTransition)
			return emptyList()

		val result = ArrayList<BiomePlaceables.Placement>()
		val lowerBlock = cellInfo(geometry.x.cell(x), geometry.y.cell(y - 1))
		val upperBlock = cellInfo(geometry.x.cell(x), geometry.y.cell(y + 1))
		val potential = potentialBiomeItemsAt(x, y)

		if (!lowerBlock.biomeTransition && lowerBlock.terrain && !thisBlock.terrain && !lowerBlock.foregroundCave)
			result.addAll(potential.surfaceBiomeItems)

		if (!lowerBlock.biomeTransition && lowerBlock.terrain && thisBlock.terrain && !lowerBlock.foregroundCave && thisBlock.foregroundCave)
			result.addAll(potential.caveSurfaceBiomeItems)

		if (!upperBlock.biomeTransition && upperBlock.terrain && thisBlock.terrain && !upperBlock.foregroundCave && thisBlock.foregroundCave)
			result.addAll(potential.caveCeilingBiomeItems)

		if (thisBlock.terrain && thisBlock.foregroundCave && !thisBlock.backgroundCave)
			result.addAll(potential.caveBackgroundBiomeItems)

		if (thisBlock.oceanLiquid.isNotEmptyLiquid && y == thisBlock.oceanLiquidLevel)
			result.addAll(potential.oceanItems)

		return result
	}

	data class Dungeon(
		val dungeon: Registry.Entry<DungeonDefinition>,
		val baseHeight: Int,
		val baseX: Int,
		val xVariance: Int,
		val forcePlacement: Boolean,
		val blendWithTerrain: Boolean,
	)

	fun gatherDungeons(): List<Dungeon> {
		val dungeons = ArrayList<Dungeon>()

		when (val worldParameters = worldParameters) {
			is FloatingDungeonWorldParameters -> {
				if (worldParameters.primaryDungeon.isPresent) {
					dungeons.add(Dungeon(worldParameters.primaryDungeon.entry!!, worldParameters.dungeonBaseHeight, 0, 0, true, false))
				} else {
					LOGGER.error("Floating dungeon world's primary dungeon ${worldParameters.primaryDungeon.key.left()} is missing... What happens now?")
				}
			}

			is TerrestrialWorldParameters -> {
				for (layer in worldParameters.layers) {
					if (layer.dungeons.isNotEmpty()) {
						val dungeonSpacing = geometry.size.x / layer.dungeons.size
						var dungeonOffset = staticRandomInt(0, geometry.size.x, seed, layer.layerBaseHeight, "dungeon")

						for (dungeon in layer.dungeons) {
							if (dungeon.isPresent) {
								dungeons.add(Dungeon(dungeon.entry!!, layer.layerBaseHeight, dungeonOffset, layer.dungeonXVariance, false, true))
								dungeonOffset += dungeonSpacing
								dungeonOffset = geometry.x.cell(dungeonOffset)
							} else {
								LOGGER.error("Primary dungeon ${dungeon.key.left()} at layer Y ${layer.layerBaseHeight} is missing")
							}
						}
					}
				}
			}
		}

		return dungeons
	}

	class CellInfo(val x: Int, val y: Int) {
		var foreground: Registry.Ref<TileDefinition> = BuiltinMetaMaterials.EMPTY.ref
		var foregroundMod: Registry.Ref<TileModifierDefinition> = BuiltinMetaMaterials.EMPTY_MOD.ref
		var background: Registry.Ref<TileDefinition> = BuiltinMetaMaterials.EMPTY.ref
		var backgroundMod: Registry.Ref<TileModifierDefinition> = BuiltinMetaMaterials.EMPTY_MOD.ref
		var fillMicrodungeons: Boolean = false
		var encloseLiquids: Boolean = false
		var oceanLiquidLevel: Int = 0
		var oceanLiquid: Registry.Ref<LiquidDefinition> = BuiltinMetaMaterials.NO_LIQUID.ref
		var caveLiquidSeedDensity: Double = 0.0
		var caveLiquid: Registry.Ref<LiquidDefinition> = BuiltinMetaMaterials.NO_LIQUID.ref
		var blockBiome: Biome? = null
		var blockBiomeIndex: Int = 0
		var environmentBiome: Biome? = null
		var environmentBiomeIndex: Int = 0
		var biomeTransition = false
		var terrain = false
		var foregroundCave = false
		var backgroundCave = false
	}

	private val cellCache = Caffeine.newBuilder()
		.maximumSize(1_500_000L) // plentiful of space, and allows for high hit ratio (around 79%) in most situations
		// downside is memory consumption, but why should it matter when we save 80% of cpu time?
		.expireAfterAccess(Duration.ofSeconds(20))
		.executor(Starbound.EXECUTOR)
		.scheduler(Starbound)
		// .recordStats()
		.build<Vector2i, CellInfo> { (x, y) -> cellInfo0(x, y) }

	fun cellInfo(x: Int, y: Int): CellInfo {
		worldLayout ?: return CellInfo(x, y)
		return cellCache.get(Vector2i(x, y))
	}

	fun cellInfo(pos: Vector2i): CellInfo {
		worldLayout ?: return CellInfo(pos.x, pos.y)
		return cellCache.get(pos)
	}

	private fun cellInfo0(x: Int, y: Int): CellInfo {
		val info = CellInfo(x, y)
		val layout = worldLayout ?: return info

		// The environment biome is calculated with weighting based on the flat coordinates.
		val flatWeighting = layout.getWeighting(x, y)

		// The block biome is calculated optionally with higher frequency noise
		// added to prevent straight lines appearing on the boundaries of
		// regions.
		var blendNoiseOffset = 0

		if (layout.blendNoise != null)
			blendNoiseOffset = layout.blendNoise!![x.toDouble(), y.toDouble()].toInt()

		val blockPos: Vector2i
		val blockWeighting: List<WorldLayout.RegionWeighting>
		val transitionWeighting: List<WorldLayout.RegionWeighting>

		val blockNoise = layout.blockNoise

		if (blockNoise != null) {
			blockPos = blockNoise.apply(x, y, geometry.size.x, geometry.size.y)
			blockWeighting = layout.getWeighting(blockPos.x + blendNoiseOffset, blockPos.y)
			transitionWeighting = layout.getWeighting(blockPos.x, blockPos.y)
		} else {
			blockPos = Vector2i(x, y)
			blockWeighting = flatWeighting
			transitionWeighting = flatWeighting
		}

		if (flatWeighting.isEmpty() || blockWeighting.isEmpty())
			return info

		val primaryFlatWeighting = flatWeighting.first()
		val primaryBlockWeighting = blockWeighting.first()

		info.blockBiome = primaryBlockWeighting.region.blockBiome
		info.blockBiomeIndex = primaryBlockWeighting.region.blockBiomeIndex
		info.environmentBiome = primaryFlatWeighting.region.environmentBiome
 		info.environmentBiomeIndex = primaryFlatWeighting.region.environmentBiomeIndex

		val config = Globals.worldTemplate

		info.biomeTransition = transitionWeighting.isNotEmpty() && transitionWeighting.first().weight < config.biomeTransitionThreshold

		var terrainSelect = 0.0
		var foregroundCaveSelect = 0.0
		var backgroundCaveSelect = 0.0

		// Terrain weighting uses the flat weighting, and weights each selector
		// to blend among them.
		for (weighting in flatWeighting) {
			val terrain = weighting.region.terrainSelector ?: continue
			terrainSelect += terrain[weighting.xValue, y] * weighting.weight
		}

		// This is a bit of a cheat. Since customTerrainWeighting is always flat,
		// there are some odd effects that come from linearly interpolating from
		// the generally non-flat terrain sources to flat regions of space.  By
		// using an interpolator that has an exaggerated S curve between the
		// points, this hides some of these effects.
		var smoothedValue = 0.0
		var smoothFactor = 0.0

		run {
			var minimumDistance = Double.MAX_VALUE
			var finalSolidWeight = 0.0
			var totalWeight = 0.0
			val point = Vector2d(x.toDouble(), y.toDouble())

			for (region in customTerrainRegions) {
				if (!geometry.rectContains(region.aabb, point)) {
					continue
				}

				val distance = geometry.polyDistance(region.region, point)

				if (distance >= config.customTerrainBlendSize)
					continue

				var weight = 1.0 - distance / config.customTerrainBlendSize
				totalWeight += weight

				if (!region.solid)
					weight *= -1.0

				finalSolidWeight += weight
				minimumDistance = minimumDistance.coerceAtMost(distance)
			}

			if (minimumDistance <= config.customTerrainBlendSize) {
				finalSolidWeight /= totalWeight
				smoothedValue = finalSolidWeight * config.customTerrainBlendSize
				smoothFactor = 1.0 - minimumDistance / config.customTerrainBlendSize
			}
		}

		terrainSelect = quintic2(smoothFactor, terrainSelect, smoothedValue)

		if (terrainSelect > 0.0) {
			info.terrain = true

			for (weighting in flatWeighting) {
				val fg = weighting.region.foregroundCaveSelector
				val bg = weighting.region.backgroundCaveSelector

				if (fg != null) {
					foregroundCaveSelect += fg[weighting.xValue, y] * weighting.weight
				}

				if (bg != null) {
					backgroundCaveSelect += bg[weighting.xValue, y] * weighting.weight
				}
			}

			if (terrainSelect < config.surfaceCaveAttenuationDist) {
				val factor = (config.surfaceCaveAttenuationDist - terrainSelect) * config.surfaceCaveAttenuationFactor
				foregroundCaveSelect -= factor
				backgroundCaveSelect -= factor
			}
		}

		info.foregroundCave = foregroundCaveSelect > 0.0
		info.backgroundCave = backgroundCaveSelect > 0.0

		// println("$x $y $terrainSelect $foregroundCaveSelect $backgroundCaveSelect")

		info.caveLiquid = primaryFlatWeighting.region.regionLiquids.caveLiquid
		info.caveLiquidSeedDensity = primaryFlatWeighting.region.regionLiquids.caveLiquidSeedDensity
		info.oceanLiquid = primaryFlatWeighting.region.regionLiquids.oceanLiquid
		info.oceanLiquidLevel = primaryFlatWeighting.region.regionLiquids.oceanLiquidLevel
		info.encloseLiquids = primaryFlatWeighting.region.regionLiquids.encloseLiquids
		info.fillMicrodungeons = primaryFlatWeighting.region.regionLiquids.fillMicrodungeons

		if (!info.terrain && info.encloseLiquids && y < info.oceanLiquidLevel) {
			info.terrain = true
			info.foregroundCave = true
		}

		if (info.terrain) {
			val biome = info.blockBiome

			if (biome != null) {
				if (!info.foregroundCave) {
					info.foreground = biome.mainBlock.native
					info.background = biome.mainBlock.native
				} else if (!info.backgroundCave) {
					info.background = biome.mainBlock.native
				}

				// subBlock, foregroundOre, and backgroundOre selectors can be empty
				// if they are not enabled, otherwise they will always have the
				// correct count
				if (primaryBlockWeighting.region.subBlockSelector.isNotEmpty()) {
					for (i in biome.subBlocks.indices) {
						val block = biome.subBlocks[i].native
						val selector = primaryBlockWeighting.region.subBlockSelector[i]

						if (selector[primaryBlockWeighting.xValue - blendNoiseOffset, blockPos.y] > 0.0) {
							if (!info.foregroundCave) {
								info.foreground = block
								info.background = block
							} else if (!info.backgroundCave) {
								info.background = block
							}

							break
						}
					}
				}

				if (!info.foregroundCave && primaryBlockWeighting.region.foregroundOreSelector.isNotEmpty()) {
					for ((i, ore) in biome.ores.withIndex()) {
						if (primaryBlockWeighting.region.foregroundOreSelector[i][x, y] > 0.0) {
							info.foregroundMod = ore.first.native
							break
						}
					}
				}

				if (!info.backgroundCave && primaryBlockWeighting.region.backgroundOreSelector.isNotEmpty()) {
					for ((i, ore) in biome.ores.withIndex()) {
						if (primaryBlockWeighting.region.backgroundOreSelector[i][x, y] > 0.0) {
							info.backgroundMod = ore.first.native
							break
						}
					}
				}
			}
		}

		return info
	}

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

		suspend fun create(coordinate: UniversePos, universe: Universe): WorldTemplate {
			val params = universe.parameters(coordinate) ?: throw IllegalArgumentException("$universe has nothing at $coordinate!")
			val visitable = params.visitableParameters ?: throw IllegalArgumentException("$coordinate of $universe is not visitable")

			val template = WorldTemplate(WorldGeometry(visitable.worldSize, true, false))

			template.seed = params.seed
			template.worldLayout = visitable.createLayout(params.seed)
			template.skyParameters = SkyParameters.create(coordinate, universe)
			template.celestialParameters = params
			template.worldParameters = visitable

			template.determineName()

			return template
		}

		fun fromJson(data: JsonObject): WorldTemplate {
			val load = Starbound.gson.fromJson(data, SerializedForm::class.java)
			val template = WorldTemplate(load.size.map({ it }, { WorldGeometry(it, true, false) }))

			template.celestialParameters = load.celestialParameters
			template.worldParameters = load.worldParameters
			template.skyParameters = load.skyParameters
			template.seed = load.seed
			template.worldLayout = load.regionData
			template.customTerrainRegions.addAll(load.customTerrainRegions)

			template.determineName()

			return template
		}
	}
}
