package ru.dbotthepony.kstarbound.json

import com.google.common.collect.ImmutableList
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonNull
import com.google.gson.JsonObject
import it.unimi.dsi.fastutil.objects.ObjectArrayList
import ru.dbotthepony.kommons.gson.contains
import ru.dbotthepony.kommons.gson.set
import ru.dbotthepony.kommons.util.KOptional

class JsonPath private constructor(val pieces: ImmutableList<Piece>) {
	constructor(path: String) : this(ImmutableList.of(Piece(path)))

	enum class Hint {
		OBJECT, ARRAY;
	}

	data class Piece(val value: String, val hint: Hint? = null) {
		val int by lazy { value.toIntOrNull() }
	}

	class TraversalException(message: String) : Exception(message)

	override fun equals(other: Any?): Boolean {
		return this === other || other is JsonPath && pieces == other.pieces
	}

	override fun hashCode(): Int {
		return pieces.hashCode()
	}

	override fun toString(): String {
		if (pieces.isEmpty())
			return "<empty json path>"
		else
			return "/${pieces.joinToString("/") { it.value }}"
	}

	fun startsWith(name: String): Boolean {
		if (isEmpty)
			return true

		return pieces[0].value == name
	}

	val isEmpty: Boolean
		get() = pieces.isEmpty()

	val hasParent: Boolean
		get() = pieces.isNotEmpty()

	fun parent(): JsonPath {
		require(hasParent) { "$this has no parent" }
		val build = ObjectArrayList(pieces)
		build.removeLast()
		return JsonPath(ImmutableList.copyOf(build))
	}

	fun child(key: String): JsonPath {
		val build = ObjectArrayList(pieces)
		build.add(Piece(key))
		return JsonPath(ImmutableList.copyOf(build))
	}

	fun reconstructPath(at: Int = pieces.size - 1): String {
		if (at == pieces.size - 1)
			return toString()

		return "/${pieces.joinToString("/", limit = at + 1, truncated = "") { it.value }}"
	}

	/**
	 * Attempts to find given element along path, if can't, throws [TraversalException]
	 */
	fun get(element: JsonElement): JsonElement {
		return traverse(element, { self, index ->
			if (index == null || index !in 0 until self.size()) {
				throw TraversalException("Path at ${reconstructPath()} points at non-existing index")
			}

			self[index]
		}, { self, index ->
			self[index] ?: throw TraversalException("Path at ${reconstructPath()} points at non-existing element")
		}).orElse(element)
	}

	/**
	 * Attempts to find element along path, if can't, returns null
	 */
	fun find(element: JsonElement): JsonElement? {
		if (isEmpty) {
			return element
		}

		var current = element

		for (piece in pieces) {
			if (current is JsonObject) {
				if (piece.value !in current) {
					return null
				}

				current = current[piece.value]
			} else if (current is JsonArray) {
				if (piece.value == "-") {
					return null
				}

				val key = piece.int

				if (key == null || key < 0 || key >= current.size()) {
					return null
				}

				current = current[key]
			} else {
				return null
			}
		}

		return current
	}

	fun get(element: JsonElement, orElse: () -> JsonElement): JsonElement {
		return find(element) ?: orElse.invoke()
	}

	fun get(element: JsonElement, orElse: JsonElement): JsonElement {
		return find(element) ?: orElse
	}

	fun <T> traverse(element: JsonElement, arrayOperator: (JsonArray, Int?) -> T, objectOperator: (JsonObject, String) -> T): KOptional<T> {
		var current = element

		for ((i, piece) in pieces.withIndex()) {
			if (current is JsonObject) {
				if (i == pieces.size - 1) {
					return KOptional(objectOperator(current, piece.value))
				} else {
					if (piece.value !in current) {
						throw TraversalException("Path at ${reconstructPath(i)} points at non-existing element")
					}

					current = current[piece.value]!!

					if (current !is JsonObject && current !is JsonArray) {
						throw TraversalException("Path at ${reconstructPath(i)} expects to get index-able element from underlying object (for ${pieces[i + 1]}), but there is $current")
					}
				}
			} else if (current is JsonArray) {
				if (piece.value == "-") {
					if (i == pieces.size - 1) {
						return KOptional(arrayOperator(current, null))
					}

					throw TraversalException("Path at ${reconstructPath(i)} points at non-existent index")
				}

				var key = piece.int

				if (key == null) {
					throw TraversalException("Path at ${reconstructPath(i)} can not index an array")
				} else if (key < 0) {
					key += current.size()

					if (key < 0)
						throw TraversalException("Path at ${reconstructPath(i)} points at pseudo index, which is out of range (array size: ${current.size()})")

					if (i == pieces.size - 1) {
						return KOptional(arrayOperator(current, key))
					}
				} else if (i == pieces.size - 1) {
					return KOptional(arrayOperator(current, key))
				} else if (key !in 0 until current.size()) {
					throw TraversalException("Path at ${reconstructPath(i)} points at non-existing index")
				}

				current = current[key]

				if (current !is JsonObject && current !is JsonArray) {
					throw TraversalException("Path at ${reconstructPath(i)} expects to get index-able element from underlying array (for ${pieces[i + 1]}), but there is $current")
				}
			} else {
				throw TraversalException("Path at ${reconstructPath(i)} can not index $current")
			}
		}

		return KOptional()
	}

	fun add(base: JsonElement, value: JsonElement): JsonElement {
		if (isEmpty) {
			check(base.isJsonNull) { "Cannot add a value to the entire document, it is not empty." }
			return value
		}

		traverse(base, { self, index ->
			if (index == null)
				self.add(value)
			else if (index !in 0 .. self.size())
				throw IndexOutOfBoundsException("Element at ${reconstructPath()} trying to insert new value into non-existing index (array size: ${self.size()})")
			else if (index == self.size())
				self.add(value)
			else {
				// jsonarray does not support insertion
				val values = ArrayList<JsonElement>(self.size() - index)

				while (self.size() > index) {
					values.add(self.remove(self.size() - 1))
				}

				self.add(value)

				while (values.isNotEmpty()) {
					self.add(values.removeLast())
				}
			}
		}, { self, index ->
			self[index] = value
		})

		return base
	}

	fun remove(base: JsonElement): Pair<JsonElement, JsonElement> {
		if (isEmpty) {
			return JsonNull.INSTANCE to base
		}

		val removed = traverse(base, { self, index ->
			if (index == null)
				throw NoSuchElementException("Николай протоген")
			else if (index !in 0 until self.size())
				throw NoSuchElementException("Element at ${reconstructPath()} does not exist (array size: ${self.size()})")
			else
				self.remove(index)
		}, { self, index ->
			if (index !in self)
				throw NoSuchElementException("Element at ${reconstructPath()} does not exist")

			self.remove(index)
		}).orThrow { RuntimeException() }

		return base to removed
	}

	companion object {
		val EMPTY = JsonPath(ImmutableList.of())

		// https://datatracker.ietf.org/doc/html/rfc6901
		@JvmStatic
		fun pointer(path: String): JsonPath {
			if (path == "")
				return EMPTY

			val split = path.split('/')

			if (split.first() != "") {
				throw IllegalArgumentException("Invalid JSON pointer: $path")
			}

			val pieces = ImmutableList.Builder<Piece>()

			for (i in 1 until split.size) {
				pieces.add(Piece(split[i].replace("~1", "/").replace("~0", "~")))
			}

			return JsonPath(pieces.build())
		}

		/**
		 * JSON query path doesn't have a standard,
		 * Starbound goes with JavaScript-like query path
		 *
		 * But this parser is different, original parser will throw
		 * an exception when you put non-numbers into `[brackets]`,
		 * like this:
		 *
		 * `someValue.a[bb]`
		 *
		 * This allows to effectively escape dots (`.`), because
		 * when reading array index, everything is read up until closing bracket.
		 *
		 * Another change is - empty keys
		 * (sequences like `..a. .key.value` -> `"" -> "" -> "a" -> " " -> "key" -> "value"`)
		 * are allowed
		 */
		@JvmStatic
		fun query(path: String): JsonPath {
			val pieces = ArrayList<Piece>()

			val current = StringBuilder()
			var readingIndex = false

			for (char in path) {
				if (readingIndex) {
					if (char == ']') {
						val finish = current.toString()
						val finishInt = finish.toIntOrNull()
						pieces.add(Piece(finish, if (finishInt != null) Hint.ARRAY else null))
						current.clear()
						readingIndex = false
					} else {
						current.append(char)
					}
				} else {
					if (char == '.') {
						pieces.add(Piece(current.toString()))
						current.clear()
					} else if (char == '[') {
						readingIndex = true
					} else {
						current.append(char)
					}
				}
			}

			if (readingIndex) {
				throw IllegalArgumentException("Expected closing ] in $path")
			}

			if (current.isNotEmpty()) {
				pieces.add(Piece(current.toString()))
			}

			for ((i, piece) in pieces.withIndex()) {
				if (piece.hint == null && i != pieces.size - 1) {
					pieces[i] = Piece(piece.value, hint = Hint.OBJECT)
				}
			}

			return JsonPath(ImmutableList.copyOf(pieces))
		}
	}
}
