# Credit to jpate for inspiration to this solution: https://godotengine.org/qa/8656/how-properly-stop-yield-from-resuming-after-the-class-freed?show=86173#a86173
class_name AsyncHelper
extends Node

signal connection_finished

# Data structure to keep track of connections
# "completed": bool    # If the connection's signal has been emitted yet
# "results": Variant   # The value returned by the signal
# "id": int            # Unique ID to keep track of the connection
const TRACKER_EMPTY: Dictionary = \
		{"completed": false, "result": null, "id": -1}

var max_id: int = -1
# Maps connections to trackers, allowing multiple trackers to track the same
# connection. Useful for things like waiting on SceneTree "idle_frame" which
# could be happening in multiple places at the same time
var connection_tracker_map: Dictionary = {}
# Maps connection IDs to if they have been marked as cancelled or not
var cancelled: Dictionary = {}


func _init(parent: Object) -> void:
	parent.connect("tree_exiting", self, "_on_parent_tree_exiting")


func connect_wrapped(source: Object, sig: String, returns_value: bool=true
		) -> Dictionary:
	"""
	Wraps the given signal connection, allowing you to `yield` on this object's
	`wait_until_finished` function instead of `source` which prevents errors in
	the case that `source` out lives the caller due to it returning to a
	non-existant object. For this to work this object must be added as a child
	of the caller, or have some other system to ensure it does not out live the
	caller
	
	Args:
		source: object which will emit the signal `sig`
		sig: signal to listen for
		returns_value: whether or not the signal returns a value
	
	Returns:
		A tracker, see `TRACKER_EMPTY` for its structure
	"""
	var tracker: Dictionary = TRACKER_EMPTY.duplicate()
	var id: int = max_id + 1
	max_id += 1
	tracker["id"] = id
	cancelled[id] = false
	
	var callback: String = "_on_completion"
	if not returns_value:
		callback = "_on_completion_no_return"
	
	var connection: Array = [source, sig, callback]
	if not connection_tracker_map.has(connection):
		connection_tracker_map[connection] = []
	connection_tracker_map[connection].append(tracker)
	
	if not source.is_connected(sig, self, callback):
		source.connect(sig, self, callback, [connection], CONNECT_ONESHOT)
	return tracker


func wait_until_finished(trackers: Array) -> Array:
	"""
	This function completes when all connections identified in `trackers`
	either complete or are cancelled
	
	Args:
		trackers: array of trackers. See `TRACKER_EMPTY` for the structure of a
			tracker
	
	Returns:
		An array of booleans indicating if the connection (the tracker) at the
		respective index was cancelled or not
	"""
	while _are_connections_finished(trackers) == false:
		yield(self, "connection_finished")
	
	var connections_cancelled: Array = []
	for tracker in trackers:
		connections_cancelled.append(cancelled.get(tracker["id"], false))
		cancelled.erase(tracker["id"])
	return connections_cancelled


func cancel(id: int) -> void:
	"""
	Mark a connection as cancelled, meaning `wait_until_finished()` won't
	wait for it to complete
	"""
	cancelled[id] = true
	emit_signal("connection_finished")


func cancel_all() -> void:
	"""
	Mark all connections as cancelled, meaning `wait_until_finished()` won't
	wait for them to complete
	"""
	for id in cancelled.keys():
		cancelled[id] = true
		emit_signal("connection_finished")


func _are_connections_finished(trackers: Array) -> bool:
	"""
	Returns:
		`true` if all connections are completed or cancelled, `false` otherwise
	"""
	var connections_finished = true
	for tracker in trackers:
		if (
				tracker["completed"] == false
				and not cancelled.get(tracker["id"], false)
		):
			connections_finished = false
			break
	return connections_finished


func _on_completion(result, connection: Array) -> void:
	""" Called when a wrapped connection completes """
	for tracker in connection_tracker_map[connection]:
		tracker["result"] = result
		tracker["completed"] = true
		cancelled.erase(tracker["id"])
		emit_signal("connection_finished")
	connection_tracker_map.erase(connection)


func _on_completion_no_return(connection: Array) -> void:
	""" Called when a wrapped connection completes """
	for tracker in connection_tracker_map[connection]:
		tracker["completed"] = true
		cancelled.erase(tracker["id"])
		emit_signal("connection_finished")
	connection_tracker_map.erase(connection)


func _on_parent_tree_exiting() -> void:
	""" Ensure this object doesn't out live its parent """
	queue_free()
