package ru.dbotthepony.kstarbound.defs.image

import com.github.benmanes.caffeine.cache.AsyncLoadingCache
import com.github.benmanes.caffeine.cache.CacheLoader
import com.github.benmanes.caffeine.cache.Caffeine
import com.github.benmanes.caffeine.cache.Scheduler
import com.google.common.collect.ImmutableList
import com.google.gson.JsonArray
import com.google.gson.JsonNull
import com.google.gson.JsonObject
import com.google.gson.JsonSyntaxException
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonWriter
import it.unimi.dsi.fastutil.objects.Object2ObjectArrayMap
import it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap
import it.unimi.dsi.fastutil.objects.ObjectArraySet
import org.apache.logging.log4j.LogManager
import org.lwjgl.opengl.GL45
import org.lwjgl.stb.STBIEOFCallback
import org.lwjgl.stb.STBIIOCallbacks
import org.lwjgl.stb.STBIReadCallback
import org.lwjgl.stb.STBIReadCallbackI
import org.lwjgl.stb.STBISkipCallback
import org.lwjgl.stb.STBImage
import org.lwjgl.system.MemoryUtil
import ru.dbotthepony.kommons.vector.Vector2i
import ru.dbotthepony.kommons.vector.Vector4i
import ru.dbotthepony.kstarbound.world.PIXELS_IN_STARBOUND_UNIT
import ru.dbotthepony.kstarbound.world.PIXELS_IN_STARBOUND_UNITi
import ru.dbotthepony.kstarbound.Starbound
import ru.dbotthepony.kstarbound.IStarboundFile
import ru.dbotthepony.kstarbound.client.StarboundClient
import ru.dbotthepony.kstarbound.client.gl.GLTexture2D
import ru.dbotthepony.kommons.gson.consumeNull
import ru.dbotthepony.kommons.gson.contains
import ru.dbotthepony.kommons.gson.get
import ru.dbotthepony.kommons.gson.getObject
import ru.dbotthepony.kstarbound.json.JsonPatch
import java.io.BufferedInputStream
import java.io.FileNotFoundException
import java.lang.ref.Reference
import java.lang.ref.WeakReference
import java.nio.ByteBuffer
import java.time.Duration
import java.util.Collections
import java.util.Optional
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.locks.ReentrantLock

class Image private constructor(
	val source: IStarboundFile,
	val path: String,
	val width: Int,
	val height: Int,
	spritesData: Pair<List<DataSprite>, IStarboundFile>?
) {
	init {
		check(width >= 0) { "Invalid width $width" }
		check(height >= 0) { "Invalid height $height" }
	}

	private val spritesInternal = LinkedHashMap<String, Sprite>()
	private var dataRef: WeakReference<ByteBuffer>? = null
	private val lock = Any()
	//private val _texture = ThreadLocal<WeakReference<GLTexture2D>>()

	init {
		if (spritesData == null) {
			this.spritesInternal["default"] = Sprite("default", 0, 0, width, height)
		} else {
			val (sprites, origin) = spritesData

			for (data in sprites) {
				var sX = data.coordinates.x % width
				var sY = data.coordinates.y % height

				if (sX !in 0 .. width) {
					//LOGGER.warn("Sprite X offset ${data.name} is out of bounds: $sX, clamping to 0 .. $width. (image: $source; frames: $origin)")
					sX = sX.coerceIn(0, width)
				}

				if (sY !in 0 .. height) {
					//LOGGER.warn("Sprite Y offset ${data.name} is out of bounds: $sY, clamping to 0 .. $height. (image: $source; frames: $origin)")
					sY = sY.coerceIn(0, height)
				}

				var sWidth = data.coordinates.z - sX
				var sHeight = data.coordinates.w - sY

				if (sWidth !in 0 .. width) {
					//LOGGER.warn("Sprite width ${data.name} is out of bounds: $sWidth, clamping to 0 .. $width. (image: $source; frames: $origin)")
					sWidth = sWidth.coerceIn(0, width)
				}

				if (sHeight !in 0 .. height) {
					//LOGGER.warn("Sprite height ${data.name} is out of bounds: $sHeight, clamping to 0 .. $height. (image: $source; frames: $origin)")
					sHeight = sHeight.coerceIn(0, height)
				}

				this.spritesInternal[data.name] = Sprite(data.name, sX, sY, sWidth, sHeight)
			}
		}
	}

	val data: CompletableFuture<ByteBuffer> get() {
		var get = dataRef?.get()

		if (get != null)
			return CompletableFuture.completedFuture(get)

		synchronized(lock) {
			get = dataRef?.get()

			if (get != null)
				return CompletableFuture.completedFuture(get)

			val f = dataCache.get(source)

			if (f.isDone)
				dataRef = WeakReference(f.get())

			return f.copy()
		}
	}

	val texture: GLTexture2D get() {
		//val get = _texture.get()?.get()
		val client = StarboundClient.current()

		/*if (get != null) {
			// update access time
			client.named2DTextures0.getIfPresent(this)
			client.named2DTextures1.getIfPresent(this)
			return get
		}*/

		val value = client.named2DTextures0.get(this) {
			client.named2DTextures1.get(this) {
				val tex = GLTexture2D(width, height, GL45.GL_RGBA8)

				data.thenApplyAsync({
					tex.upload(GL45.GL_RGBA, GL45.GL_UNSIGNED_BYTE, it)

					tex.textureMinFilter = GL45.GL_NEAREST
					tex.textureMagFilter = GL45.GL_NEAREST
				}, client)

				tex
			}
		}

		//_texture.set(WeakReference(value))
		return value
	}

	val size = Vector2i(width, height)
	val sprites: Map<String, Sprite> = Collections.unmodifiableMap(this.spritesInternal)
	val first: Sprite = this.spritesInternal.values.first()
	val whole = Sprite("this", 0, 0, width, height)
	val nonEmptyRegion get() = whole.nonEmptyRegion

	/**
	 * returns integer in ABGR format
	 */
	operator fun get(x: Int, y: Int): Int {
		return whole[x, y]
	}

	/**
	 * returns integer in ABGR format
	 */
	operator fun get(x: Int, y: Int, flip: Boolean): Int {
		return whole[x, y, flip]
	}

	operator fun get(name: String): Sprite? {
		return spritesInternal[name]
	}

	fun isTransparent(x: Int, y: Int, flip: Boolean): Boolean {
		return whole.isTransparent(x, y, flip)
	}

	fun worldSpaces(pixelOffset: Vector2i, spaceScan: Double, flip: Boolean): Set<Vector2i> {
		return whole.worldSpaces(pixelOffset, spaceScan, flip)
	}

	private data class DataSprite(val name: String, val coordinates: Vector4i)

	inner class Sprite(val name: String, val x: Int, val y: Int, val width: Int, val height: Int) : IUVCoordinates {
		// flip coordinates to account for opengl
		override val u0: Float = x.toFloat() / this@Image.width
		override val v1: Float = y.toFloat() / this@Image.height
		override val u1: Float = (x.toFloat() + this.width.toFloat()) / this@Image.width
		override val v0: Float = (y.toFloat() + this.height.toFloat()) / this@Image.height

		/**
		 * returns integer in big-endian ABGR format if it is RGB or RGBA picture,
		 * otherwise returns pixels as-is
		 */
		operator fun get(x: Int, y: Int): Int {
			require(x in 0 until width && y in 0 until height) { "Position out of bounds: $x $y" }

			val offset = (this.y + y) * this@Image.width * 4 + (this.x + x) * 4
			val data = data.join()

			return data[offset].toInt().and(0xFF) or // red
				data[offset + 1].toInt().and(0xFF).shl(8) or // green
				data[offset + 2].toInt().and(0xFF).shl(16) or // blue
				data[offset + 3].toInt().and(0xFF).shl(24) // alpha
		}

		/**
		 * returns integer in ABGR format
		 */
		operator fun get(x: Int, y: Int, flip: Boolean): Int {
			if (flip) {
				require(x in 0 until width && y in 0 until height) { "Position out of bounds: $x $y" }
				return this[width - x - 1, y]
			} else {
				return this[x, y]
			}
		}

		fun isTransparent(x: Int, y: Int, flip: Boolean): Boolean {
			if (x !in 0 until width) return true
			if (y !in 0 until height) return true
			return this[x, y, flip] and -0x1000000 == 0x0
		}

		val nonEmptyRegion by lazy {
			var x0 = 0
			var y0 = 0

			search@for (y in 0 until height) {
				for (x in 0 until width) {
					if (!isTransparent(x, y, false)) {
						x0 = x
						y0 = y
						break@search
					}
				}
			}

			var x1 = x0
			var y1 = y0

			search@for (y in height - 1 downTo y0) {
				for (x in width - 1 downTo x0) {
					if (!isTransparent(x, y, false)) {
						x1 = x
						y1 = y
						break@search
					}
				}
			}

			return@lazy Vector4i(x0, y0, x1, y1)
		}

		val imageNonEmptyRegion by lazy {
			nonEmptyRegion + Vector4i(x, y, x, y)
		}

		fun worldSpaces(pixelOffset: Vector2i, spaceScan: Double, flip: Boolean): Set<Vector2i> {
			val minX = pixelOffset.x / PIXELS_IN_STARBOUND_UNITi
			val minY = pixelOffset.y / PIXELS_IN_STARBOUND_UNITi
			val maxX = (width + pixelOffset.x + PIXELS_IN_STARBOUND_UNITi - 1) / PIXELS_IN_STARBOUND_UNITi
			val maxY = (height + pixelOffset.y + PIXELS_IN_STARBOUND_UNITi - 1) / PIXELS_IN_STARBOUND_UNITi

			val result = ObjectArraySet<Vector2i>()

			// this is weird, but that's how original game handles this
			// also we don't cache this info since that's a waste of precious ram

			for (yspace in minY until maxY) {
				for (xspace in minX until maxX) {
					var fillRatio = 0.0

					for (y in 0 until PIXELS_IN_STARBOUND_UNITi) {
						val ypixel = (yspace * PIXELS_IN_STARBOUND_UNITi + y - pixelOffset.y)

						if (ypixel !in 0 until height)
							continue

						for (x in 0 until PIXELS_IN_STARBOUND_UNITi) {
							val xpixel = (xspace * PIXELS_IN_STARBOUND_UNITi + x - pixelOffset.x)

							if (xpixel !in 0 until width)
								continue

							if (!isTransparent(xpixel, height - ypixel - 1, flip)) {
								fillRatio += 1.0 / (PIXELS_IN_STARBOUND_UNIT * PIXELS_IN_STARBOUND_UNIT)
							}
						}
					}

					if (fillRatio >= spaceScan) {
						result.add(Vector2i(xspace, yspace))
					}
				}
			}

			return result
		}

	}

	companion object : TypeAdapter<Image>() {
		private val LOGGER = LogManager.getLogger()

		private val vectors by lazy { Starbound.gson.getAdapter(Vector4i::class.java) }
		private val vectors2 by lazy { Starbound.gson.getAdapter(Vector2i::class.java) }
		private val configCache = ConcurrentHashMap<String, Optional<Pair<List<DataSprite>, IStarboundFile>>>()
		private val imageCache = ConcurrentHashMap<String, Optional<Image>>()
		private val logger = LogManager.getLogger()

		data class ReadDirectData(val data: ByteBuffer, val width: Int, val height: Int, val channels: Int)

		fun readImageDirect(file: IStarboundFile): ReadDirectData {
			val getWidth = intArrayOf(0)
			val getHeight = intArrayOf(0)
			val components = intArrayOf(0)

			val idata = file.readDirect()

			val data = STBImage.stbi_load_from_memory(
				idata,
				getWidth, getHeight,
				components, 4
			) ?: throw IllegalArgumentException("File $file is not an image or it is corrupted")

			Reference.reachabilityFence(idata)

			val address = MemoryUtil.memAddress(data)
			Starbound.CLEANER.register(data) { STBImage.nstbi_image_free(address) }

			return ReadDirectData(data, getWidth[0], getHeight[0], components[0])
		}

		private val dataCache: AsyncLoadingCache<IStarboundFile, ByteBuffer> = Caffeine.newBuilder()
			.expireAfterAccess(Duration.ofMinutes(1))
			.weigher<IStarboundFile, ByteBuffer> { key, value -> value.capacity() }
			.maximumWeight((Runtime.getRuntime().maxMemory() / 4L).coerceIn(1_024L * 1_024L * 32L /* 32 МиБ */, 1_024L * 1_024L * 256L /* 256 МиБ */))
			.scheduler(Starbound)
			.executor(Starbound.EXECUTOR)
			.buildAsync(CacheLoader {
				readImageDirect(it).data
			})

		@JvmStatic
		fun get(path: String): Image? {
			return imageCache.computeIfAbsent(path) {
				try {
					val file = Starbound.locate(it)

					if (!file.exists) {
						throw FileNotFoundException("No such file $it")
					}

					if (!file.isFile) {
						throw FileNotFoundException("File $it is a directory")
					}

					val getWidth = intArrayOf(0)
					val getHeight = intArrayOf(0)
					val components = intArrayOf(0)

					val stream = BufferedInputStream(file.open())
					val callback = STBIIOCallbacks.malloc()

					val readCallback = STBIReadCallback.create(STBIReadCallbackI { _, buf, size ->
						val readBuf = ByteArray(size)
						val read = stream.read(readBuf)

						for (i in 0 until read) {
							MemoryUtil.memPutByte(buf + i, readBuf[i])
						}

						return@STBIReadCallbackI read
					})

					val skipCallback = STBISkipCallback.create { _, n -> stream.skip(n.toLong()) }

					val eofCallback = STBIEOFCallback.create {
						stream.mark(1)
						val empty = stream.read() == -1
						stream.reset()
						if (empty) 1 else 0
					}

					callback.set(readCallback, skipCallback, eofCallback)

					val status = STBImage.stbi_info_from_callbacks(
						callback, 0L,
						getWidth, getHeight,
						components
					)

					readCallback.free()
					skipCallback.free()
					eofCallback.free()
					callback.free()

					if (!status)
						throw IllegalArgumentException("File $file is not an image or it is corrupted")

					Optional.of(Image(file, it, getWidth[0], getHeight[0], getConfig(it)))
				} catch (err: Exception) {
					logger.error("Failed to load image at path $it", err)
					Optional.empty()
				}
			}.orElse(null)
		}

		override fun write(out: JsonWriter, value: Image?) {
			if (value == null)
				out.nullValue()
			else
				out.value(value.path)
		}

		override fun read(`in`: JsonReader): Image? {
			if (`in`.consumeNull())
				return null
			else
				return get(`in`.nextString())
		}

		private fun generateFakeNames(dimensions: Vector2i): JsonArray {
			return JsonArray(dimensions.y).also {
				var stripElem = 0

				for (stripNum in 0 until dimensions.y) {
					val strip = JsonArray(dimensions.x)

					for (i in 0 until dimensions.x) {
						strip.add(stripElem.toString())
						stripElem++
					}

					it.add(strip)
				}
			}
		}

		private fun parseFrames(read: JsonObject): List<DataSprite> {
			val begin = read.get("begin", vectors2) { Vector2i.ZERO }
			val sprites = LinkedHashMap<String, DataSprite>()

			if ("frameGrid" in read) {
				val frameGrid = read.getObject("frameGrid")

				val size = vectors2.fromJsonTree(frameGrid["size"] ?: throw JsonSyntaxException("Missing frameGrid.size"))
				val dimensions = vectors2.fromJsonTree(frameGrid["dimensions"] ?: throw JsonSyntaxException("Missing frameGrid.dimensions"))

				require(size.x >= 0 && size.y >= 0) { "Invalid size: $size" }
				require(dimensions.x > 0 && dimensions.y > 0) { "Invalid dimensions: $dimensions" }

				val names = (frameGrid.get("names") { generateFakeNames(dimensions) })
					.map { (it as? JsonArray)?.map { if (it == JsonNull.INSTANCE) null else it.asString } }.toList()

				for ((y, strip) in names.withIndex()) {
					// разрешаем вставлять null как ленту кадров, что означает что мы должны пропустить её полностью
					if (strip == null) continue

					for ((x, spriteName) in strip.withIndex()) {
						// если кадр не имеет имени...
						if (spriteName == null) continue

						require(y < dimensions.y && x < dimensions.x) { "Sprite at $x $y is out of bounds for frame grid with dimensions of $dimensions" }
						sprites[spriteName] = DataSprite(spriteName, Vector4i(begin.x + x * size.x, begin.y + y * size.y, begin.x + (x + 1) * size.x, begin.y + (y + 1) * size.y))
					}
				}
			}

			if ("frameList" in read) {
				for ((spriteName, coords) in read.getObject("frameList").entrySet()) {
					sprites[spriteName] = DataSprite(spriteName, vectors.fromJsonTree(coords))
				}
			}

			val aliases = Object2ObjectArrayMap<String, String>()

			for ((k, v) in read.get("aliases") { JsonObject() }.entrySet())
				aliases[k] = v.asString

			var changes = true

			while (aliases.isNotEmpty() && changes) {
				changes = false
				val i = aliases.entries.iterator()

				for ((k, v) in i) {
					require(k !in sprites) { "Sprite with name '$k' already defined" }
					val sprite = sprites[v] ?: continue
					sprites[k] = sprite.copy(name = k)
					changes = true
					i.remove()
				}
			}

			for ((k, v) in aliases.entries)
				throw JsonSyntaxException("Alias '$k' want to refer to sprite '$v', but it does not exist")

			return ImmutableList.copyOf(sprites.values)
		}

		private fun compute(it: String): Optional<Pair<List<DataSprite>, IStarboundFile>> {
			val locate = Starbound.locate("$it.frames")

			if (!locate.exists)
				return Optional.empty()

			val json = JsonPatch.apply(Starbound.ELEMENTS_ADAPTER.read(locate.jsonReader()), Starbound.locateAll("$it.frames.patch"))

			if (json !is JsonObject) {
				return Optional.empty()
			} else {
				return Optional.of(parseFrames(json) to locate)
			}
		}

		private fun getConfig(path: String): Pair<List<DataSprite>, IStarboundFile>? {
			var folder = path.substringBefore(':').substringBeforeLast('/')
			val name = path.substringBefore(':').substringAfterLast('/').substringBefore('.')

			while (true) {
				val find = configCache.computeIfAbsent("$folder/$name", ::compute).or { configCache.computeIfAbsent("$folder/default", ::compute) }

				if (find.isPresent)
					return find.get()

				folder = folder.substringBeforeLast('/')

				if (folder.isEmpty()) return null
			}
		}
	}
}
