@tool
class_name RandomizedComposite extends Composite

const WEIGHTS_PREFIX = "Weights/"

## Sets a predicable seed
@export var random_seed: int = 0:
	set(rs):
		random_seed = rs
		if random_seed != 0:
			seed(random_seed)
		else:
			randomize()

## Wether to use weights for every child or not.
@export var use_weights: bool:
	set(value):
		use_weights = value
		if use_weights:
			_update_weights(get_children())
			_connect_children_changing_signals()
		notify_property_list_changed()

var _weights: Dictionary
var _exiting_tree: bool


func _ready():
	_connect_children_changing_signals()


func _connect_children_changing_signals():
	if not child_entered_tree.is_connected(_on_child_entered_tree):
		child_entered_tree.connect(_on_child_entered_tree)

	if not child_exiting_tree.is_connected(_on_child_exiting_tree):
		child_exiting_tree.connect(_on_child_exiting_tree)


func get_shuffled_children() -> Array[Node]:
	var children_bag: Array[Node] = get_children().duplicate()
	if use_weights:
		var weights: Array[int]
		weights.assign(children_bag.map(func(child): return _weights[child.name]))
		children_bag.assign(_weighted_shuffle(children_bag, weights))
	else:
		children_bag.shuffle()
	return children_bag


## Returns a shuffled version of a given array using the supplied array of weights.
## Think of weights as the chance of a given item being the first in the array.
func _weighted_shuffle(items: Array, weights: Array[int]) -> Array:
	if len(items) != len(weights):
		push_error(
			(
				"items and weights size mismatch: expected %d weights, got %d instead."
				% [len(items), len(weights)]
			)
		)
		return items

	# This method is based on the weighted random sampling algorithm
	# by Efraimidis, Spirakis; 2005. This runs in O(n log(n)).

	# For each index, it will calculate random_value^(1/weight).
	var chance_calc = func(i): return [i, randf() ** (1.0 / weights[i])]
	var random_distribuition = range(len(items)).map(chance_calc)

	# Now we just have to order by the calculated value, descending.
	random_distribuition.sort_custom(func(a, b): return a[1] > b[1])

	return random_distribuition.map(func(dist): return items[dist[0]])


func _get_property_list():
	var properties = []

	if use_weights:
		for key in _weights.keys():
			properties.append(
				{
					"name": WEIGHTS_PREFIX + key,
					"type": TYPE_INT,
					"usage": PROPERTY_USAGE_STORAGE | PROPERTY_USAGE_EDITOR,
					"hint": PROPERTY_HINT_RANGE,
					"hint_string": "1,100"
				}
			)

	return properties


func _set(property: StringName, value: Variant) -> bool:
	if property.begins_with(WEIGHTS_PREFIX):
		var weight_name = property.trim_prefix(WEIGHTS_PREFIX)
		_weights[weight_name] = value
		return true

	return false


func _get(property: StringName):
	if property.begins_with(WEIGHTS_PREFIX):
		var weight_name = property.trim_prefix(WEIGHTS_PREFIX)
		return _weights[weight_name]

	return null


func _update_weights(children: Array[Node]) -> void:
	var new_weights = {}
	for c in children:
		if _weights.has(c.name):
			new_weights[c.name] = _weights[c.name]
		else:
			new_weights[c.name] = 1
	_weights = new_weights
	notify_property_list_changed()


func _exit_tree() -> void:
	_exiting_tree = true


func _enter_tree() -> void:
	_exiting_tree = false


func _on_child_entered_tree(node: Node):
	_update_weights(get_children())

	var renamed_callable = _on_child_renamed.bind(node.name, node)
	if not node.renamed.is_connected(renamed_callable):
		node.renamed.connect(renamed_callable)

	if not node.tree_exited.is_connected(_on_child_tree_exited):
		node.tree_exited.connect(_on_child_tree_exited.bind(node))


func _on_child_exiting_tree(node: Node):
	var renamed_callable = _on_child_renamed.bind(node.name, node)
	if node.renamed.is_connected(renamed_callable):
		node.renamed.disconnect(renamed_callable)


func _on_child_tree_exited(node: Node) -> void:
	# don't erase the individual child if the whole tree is exiting together
	if not _exiting_tree:
		var children = get_children()
		children.erase(node)
		_update_weights(children)

	if node.tree_exited.is_connected(_on_child_tree_exited):
		node.tree_exited.disconnect(_on_child_tree_exited)


func _on_child_renamed(old_name: String, renamed_child: Node):
	if old_name == renamed_child.name:
		return  # No need to update the weights.

	# Disconnect signal with old name...
	renamed_child.renamed.disconnect(_on_child_renamed.bind(old_name, renamed_child))
	# ...and connect with the new name.
	renamed_child.renamed.connect(_on_child_renamed.bind(renamed_child.name, renamed_child))

	var original_weight = _weights[old_name]
	_weights.erase(old_name)
	_weights[renamed_child.name] = original_weight
	notify_property_list_changed()


func get_class_name() -> Array[StringName]:
	var classes := super()
	classes.push_back(&"RandomizedComposite")
	return classes
