class_name Palette
extends RefCounted

signal data_changed

const DEFAULT_WIDTH := 8
const DEFAULT_HEIGHT := 8

# Metadata
var name := "Custom Palette":
	set(value):
		name = value.strip_edges()
var comment := ""
var path := ""

## The width of the grid
var width := DEFAULT_WIDTH
## The height of the grid
var height := DEFAULT_HEIGHT
## Sparse colors dictionary of [int] and [PaletteColor]
## Actual color position in the palette is determined by its index
var colors := {}
## How many colors fit in palette grid
var colors_max := 0


class PaletteColor:
	var color := Color.TRANSPARENT
	var index := -1

	func _init(init_color := Color.BLACK, init_index := -1) -> void:
		color = init_color
		index = init_index

	func duplicate() -> PaletteColor:
		return PaletteColor.new(color, index)

	func serialize() -> Dictionary:
		return {"color": color, "index": index}

	func deserialize(dict: Dictionary) -> void:
		if dict.has("color"):
			color = dict["color"]
		if dict.has("index"):
			color = dict["index"]


func _init(
	_name := "Custom Palette", _width := DEFAULT_WIDTH, _height := DEFAULT_HEIGHT, _comment := ""
) -> void:
	name = _name
	comment = _comment
	width = _width
	height = _height
	colors_max = _width * _height
	colors = {}


func edit(new_name: String, new_width: int, new_height: int, new_comment: String) -> void:
	var old_width := width
	var old_height := height
	width = new_width
	height = new_height
	name = new_name
	comment = new_comment

	var old_colors_max := colors_max
	colors_max = width * height

	if colors_max < old_colors_max:
		# If size was reduced colors must be reindexed to fit into new smaller size
		reindex_colors_on_size_reduce(true)

	if old_width < new_width and height >= old_height:
		# If width increases colors have to be reindexed so they keep same grid positions
		# unless the height has become smaller and we have to re-position the colors
		# so that they won't get erased
		reindex_colors_on_width_increase(old_width)


func duplicate() -> Palette:
	var new_palette := Palette.new(name, width, height, comment)
	var new_colors := colors.duplicate(true)
	new_palette.colors = new_colors
	return new_palette


func _serialize() -> String:
	var serialize_data := {"comment": comment, "colors": [], "width": width, "height": height}
	for color in colors:
		serialize_data.colors.push_back(colors[color].serialize())

	return JSON.stringify(serialize_data, " ")


func deserialize(json_string: String) -> void:
	var test_json_conv := JSON.new()
	var err := test_json_conv.parse(json_string)
	if err != OK:  # If parse has errors
		printerr("JSON palette import error")
		printerr("Error: ", err)
		printerr("Error Line: ", test_json_conv.get_error_line())
		printerr("Error String: ", test_json_conv.get_error_message())
		return

	var data = test_json_conv.get_data()
	if not typeof(data) == TYPE_DICTIONARY:
		return
	deserialize_from_dictionary(data)


func deserialize_from_dictionary(data: Dictionary) -> void:
	if data.has("comment"):
		comment = data.comment
	if data.has("colors"):
		for color_data in data.colors:
			var color: Color
			if typeof(color_data["color"]) == TYPE_STRING:
				color = str_to_var("Color" + color_data["color"])
			elif typeof(color_data["color"]) == TYPE_COLOR:
				color = color_data["color"]
			var index := color_data["index"] as int
			var palette_color := PaletteColor.new(color, index)
			colors[index] = palette_color
	if data.has("width"):
		width = data.width
	if data.has("height"):
		height = data.height
	colors_max = width * height


func save_to_file() -> Error:
	var file := FileAccess.open(path, FileAccess.WRITE)
	if not is_instance_valid(file):
		return FileAccess.get_open_error()
	file.store_string(_serialize())
	file.close()
	return OK


## Iterates all colors from lowest index and reindexes them so they start at zero index
## Remove trailing removes all colors that are over colors_max limit and thus don't fit into grid
func reindex_colors_on_size_reduce(remove_trailing: bool) -> void:
	var sorted_colors_indexes := colors.keys()
	sorted_colors_indexes.sort()
	var new_index := 0
	for old_index: int in sorted_colors_indexes:
		# Color cannot fit into grid anymore - erase it
		if remove_trailing and new_index >= colors_max:
			colors.erase(old_index)

		# Move color to new lower index - erase it from its original index
		elif new_index < old_index:
			colors[new_index] = colors[old_index]
			colors[new_index].index = new_index
			colors.erase(old_index)

		new_index += 1


## Adds difference of old and new width to color indexes
## so they remain on the same position as before resize
func reindex_colors_on_width_increase(old_width: int) -> void:
	var sorted_colors_indices := colors.keys()
	sorted_colors_indices.sort()
	var new_colors := {}
	for old_index: int in sorted_colors_indices:
		var new_index := old_index + (width - old_width) * (old_index / old_width)
		new_colors[new_index] = colors[old_index]
		new_colors[new_index].index = new_index

	colors = new_colors


## Adds new color to the first empty swatch
func add_color(new_color: Color, start_index := 0) -> void:
	if start_index >= colors_max:
		return

	# If palette is full automatically increase the palette height
	if is_full():
		height += 1

	# Find the first empty index since start index and insert a new color
	for i in range(start_index, colors_max):
		if not colors.has(i):
			colors[i] = PaletteColor.new(new_color, i)
			break
	data_changed.emit()


## Returns color at index or null if no color exists
func get_color(index: int):
	var palette_color: PaletteColor = colors.get(index)
	if palette_color != null:
		return palette_color.color
	return null


## Changes color data
func set_color(index: int, new_color: Color) -> void:
	if colors.has(index):
		colors[index].color = new_color
		data_changed.emit()


## Removes a color at the specified index
func remove_color(index: int) -> void:
	colors.erase(index)
	data_changed.emit()


## Inserts a color to the specified index
## If index is already occupied move the original color to right
func insert_color(index: int, new_color: Color) -> void:
	var c := PaletteColor.new(new_color, index)
	# If insert happens on non empty swatch recursively move the original color
	# and every other color to its right one swatch to right
	if colors.has(index):
		_move_right(index)
	colors[index] = c
	data_changed.emit()


## Recursive function that moves every color to right until one of them is moved to empty swatch
func _move_right(index: int) -> void:
	# Moving colors to right would overflow the size of the palette
	# so increase its height automatically
	if index + 1 == colors_max:
		height += 1
		colors_max = width * height

	# If swatch to right to this color is not empty move that color right too
	if colors.has(index + 1):
		_move_right(index + 1)

	colors[index + 1] = colors[index]


## Swaps two colors
func swap_colors(from_index: int, to_index: int) -> void:
	var from_color: PaletteColor = colors.get(from_index)
	var to_color: PaletteColor = colors.get(to_index)

	if not from_color and to_color:
		colors[from_index] = to_color
		colors[from_index].index = from_index
		colors.erase(to_index)
	elif from_color and not to_color:
		colors[to_index] = from_color
		colors[to_index].index = to_index
		colors.erase(from_index)
	elif from_color and to_color:
		colors[to_index] = from_color
		colors[to_index].index = to_index
		colors[from_index] = to_color
		colors[from_index].index = from_index
	data_changed.emit()


## Copies color
func copy_colors(from_index: int, to_index: int) -> void:
	# Only allow copy of existing colors
	if colors[from_index] != null:
		colors[to_index] = colors[from_index].duplicate()
		colors[to_index].index = to_index
	data_changed.emit()


func reverse_colors() -> void:
	var reversed_colors := colors.values()
	reversed_colors.reverse()
	colors.clear()
	for i in reversed_colors.size():
		reversed_colors[i].index = i
		colors[i] = reversed_colors[i]
	data_changed.emit()


func sort(option: Palettes.SortOptions) -> void:
	var sorted_colors := colors.values()
	var sort_method: Callable
	match option:
		Palettes.SortOptions.HUE:
			sort_method = func(a: PaletteColor, b: PaletteColor): return a.color.h < b.color.h
		Palettes.SortOptions.SATURATION:
			sort_method = func(a: PaletteColor, b: PaletteColor): return a.color.s < b.color.s
		Palettes.SortOptions.VALUE:
			sort_method = func(a: PaletteColor, b: PaletteColor): return a.color.v < b.color.v
		Palettes.SortOptions.LIGHTNESS:
			# Code inspired from:
			# gdlint: ignore=max-line-length
			# https://github.com/bottosson/bottosson.github.io/blob/master/misc/colorpicker/colorconversion.js#L519
			sort_method = func(a: PaletteColor, b: PaletteColor):
				# function that returns OKHSL lightness
				var lum: Callable = func(c: Color):
					var l = 0.4122214708 * (c.r) + 0.5363325363 * (c.g) + 0.0514459929 * (c.b)
					var m = 0.2119034982 * (c.r) + 0.6806995451 * (c.g) + 0.1073969566 * (c.b)
					var s = 0.0883024619 * (c.r) + 0.2817188376 * (c.g) + 0.6299787005 * (c.b)
					var l_cr = pow(l, 1 / 3.0)
					var m_cr = pow(m, 1 / 3.0)
					var s_cr = pow(s, 1 / 3.0)
					var oklab_l = 0.2104542553 * l_cr + 0.7936177850 * m_cr - 0.0040720468 * s_cr
					# calculating toe
					var k_1 = 0.206
					var k_2 = 0.03
					var k_3 = (1 + k_1) / (1 + k_2)
					return (
						0.5
						* (
							k_3 * oklab_l
							- k_1
							+ sqrt(
								(
									(k_3 * oklab_l - k_1) * (k_3 * oklab_l - k_1)
									+ 4 * k_2 * k_3 * oklab_l
								)
							)
						)
					)
				return lum.call(a.color.srgb_to_linear()) < lum.call(b.color.srgb_to_linear())
		Palettes.SortOptions.RED:
			sort_method = func(a: PaletteColor, b: PaletteColor): return a.color.r < b.color.r
		Palettes.SortOptions.GREEN:
			sort_method = func(a: PaletteColor, b: PaletteColor): return a.color.g < b.color.g
		Palettes.SortOptions.BLUE:
			sort_method = func(a: PaletteColor, b: PaletteColor): return a.color.b < b.color.b
		Palettes.SortOptions.ALPHA:
			sort_method = func(a: PaletteColor, b: PaletteColor): return a.color.a < b.color.a
	sorted_colors.sort_custom(sort_method)
	colors.clear()
	for i in sorted_colors.size():
		sorted_colors[i].index = i
		colors[i] = sorted_colors[i]
	data_changed.emit()


## True if all swatches are occupied
func is_full() -> bool:
	return colors.size() >= colors_max


## True if palette has no colors
func is_empty() -> bool:
	return colors.size() == 0


func has_theme_color(color: Color) -> bool:
	for palette_color in colors.values():
		if palette_color.color == color:
			return true
	return false


static func strip_unvalid_characters(string_to_strip: String) -> String:
	var regex := RegEx.new()
	regex.compile("[^a-zA-Z0-9_]+")
	return regex.sub(string_to_strip, "", true)


func convert_to_image() -> Image:
	var image := Image.create(colors_max, 1, false, Image.FORMAT_RGBA8)
	for i in colors_max:
		if colors.has(i):
			image.set_pixel(i, 0, colors[i].color)
	return image
