@tool
@icon("Icon/reorderable_container_icon.svg")
class_name ReorderableContainer
extends Container
## A container that allows its child to be reorder and arranges horizontally or vertically.
##
## A container similar to [BoxContainer] but extended with drag-and-drop style reordering functionality, 
## and auto-scroll functionality when placed under [ScrollContainer].[br][br]
## [b]Note:[/b] This addon also works with SmoothScroll by SpyrexDE.
##
## @tutorial(SmoothScroll): https://github.com/SpyrexDE/SmoothScroll
## @tutorial(Using Containers): https://docs.godotengine.org/en/4.1/tutorials/ui/gui_containers.html

## Emitted when children have been reordered.
signal reordered(from: int, to: int)
signal dragging(pos: Vector2)
signal drag_started(node)
signal drag_stopped(node)
## Extend the drop zone length at the start and end of the container. 
## This will ensure that drop input is recognized even outside the container itself.
const DROP_ZONE_EXTEND = 2000

## The hold duration time in seconds before the holded child will start being drag.
@export 
var hold_duration := 0.5

## The overall speed of how fast children will move and arrange.
@export_range(3, 30, 0.01, "or_greater", "or_less")
var speed := 10.0

## The space between the container's elements, in pixels.
@export 
var separation := 10: set = set_separation
func set_separation(value):
	if value == separation or value < 0:
		return
	separation = value
	_on_sort_children()


## if [code]true[/code] the container will arrange its children vertically, rather than horizontally.
@export var is_vertical := false: set = set_vertical
func set_vertical(value):
	if value == is_vertical:
		return
	is_vertical = value
	if is_vertical:
		custom_minimum_size.x = 0
	else:
		custom_minimum_size.y = 0
	_on_sort_children()

## (Optional) [ScrollContainer] refference. Normally, the addon will automatically check 
## its parent node for [ScrollContainer]. If this is not the case, you can manually specify it here.
@export
var scroll_container: ScrollContainer

## The maximum speed of auto scroll.
@export 
var auto_scroll_speed := 10.0

## The pacentage of how much space auto scroll will take in [ScrollContainer][br][br]
## [b]Example:[/b] If [code]auto_scroll_range[/code] is 30% (0.3) and [ScrollContainer] height is 100 px, 
## upper part will be 0 to 30 px and lower part will be 70 to 100 px.
@export_range(0, 0.5) 
var auto_scroll_range := 0.3

## The scrolling threshold in pixel. In a nutshell, user will have hard time trying to drag a child if it too low
## and user will accidentally drag a child when scrolling if it too high.
@export 
var scroll_threshold := 30

## Uses when debugging
@export
var is_debugging := false

var _scroll_starting_point := 0
var _is_smooth_scroll := false

var _drop_zones: Array[Rect2] = []
var _drop_zone_index := -1
var _expect_child_rect: Array[Rect2] = []

var _focus_child: Control
var _is_press := false
var _is_hold := false
var _current_duration := 0.0
var _is_using_process := false


func _ready():
	if scroll_container == null and get_parent() is ScrollContainer:
		scroll_container = get_parent()
		
	if scroll_container != null and scroll_container.has_method("handle_overdrag"):
		_is_smooth_scroll = true
	
	process_mode = Node.PROCESS_MODE_PAUSABLE
	_adjust_expected_child_rect()
	if not sort_children.is_connected(_on_sort_children):
		sort_children.connect(_on_sort_children, CONNECT_PERSIST)
	if not get_tree().node_added.is_connected(_on_node_added):
		get_tree().node_added.connect(_on_node_added, CONNECT_PERSIST)


func _gui_input(event):
	if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
		for _child in get_children():
			var child := _child as Control
			if child.get_rect().has_point(get_local_mouse_position()) and event.is_pressed():
				_focus_child = child
				_is_press = true
			elif not event.is_pressed():
				_is_press = false
				_is_hold = false


func _process(delta):
	if Engine.is_editor_hint(): return	
	
	_handle_input(delta)
	if _current_duration >= hold_duration != _is_hold:
		_is_hold = _current_duration >= hold_duration
		if _is_hold:
			_on_start_dragging()
	
	if _is_hold:
		_handle_dragging_child_pos(delta)
		if scroll_container != null:
			_handle_auto_scroll(delta)
	elif not _is_hold and _drop_zone_index != -1:
		_on_stop_dragging()
			
	if _is_using_process :
		_on_sort_children(delta)


func _handle_input(delta): 
	if scroll_container != null and _is_press and not _is_hold:
		var scroll_point = scroll_container.scroll_vertical if is_vertical else scroll_container.scroll_horizontal
		if _current_duration == 0:
			_scroll_starting_point = scroll_point
		else:
			# If user scroll more than scroll_threshold, press is abort.
			_is_press = true if abs(scroll_point - _scroll_starting_point) <= scroll_threshold else false
	_current_duration = _current_duration + delta if _is_press else 0.0


func _on_start_dragging():
	# Force _on_sort_children to use process update for linear interpolation
	_is_using_process = true 
	_focus_child.z_index = 1
	# Workaround for SmoothScroll addon
	if _is_smooth_scroll:
		scroll_container.process_mode = Node.PROCESS_MODE_DISABLED
	for child in _get_visible_children():
		child.propagate_call("set_mouse_filter", [MOUSE_FILTER_IGNORE])
	drag_started.emit(_focus_child)
	


func _on_stop_dragging():
	_focus_child.z_index = 0
	var focus_child_index := _focus_child.get_index()
	move_child(_focus_child, _drop_zone_index)
	reordered.emit(focus_child_index, _drop_zone_index)
	drag_stopped.emit(_focus_child)
	_focus_child = null
	_drop_zone_index = -1
	if _is_smooth_scroll:
		scroll_container.pos = -Vector2(scroll_container.scroll_horizontal, scroll_container.scroll_vertical)
		scroll_container.process_mode = Node.PROCESS_MODE_INHERIT
	for child in _get_visible_children():
		child.propagate_call("set_mouse_filter", [MOUSE_FILTER_PASS])	


func _on_node_added(node):
	if node is Control and not Engine.is_editor_hint():
		node.mouse_filter = Control.MOUSE_FILTER_PASS


func _handle_dragging_child_pos(delta):
	if is_vertical:
		var target_pos = get_local_mouse_position().y - (_focus_child.size.y / 2.0)
		_focus_child.position.y = lerp(_focus_child.position.y, target_pos, delta * speed)
	else:
		var target_pos = get_local_mouse_position().x - (_focus_child.size.x / 2.0)
		_focus_child.position.x = lerp(_focus_child.position.x, target_pos, delta * speed)	
		
	# Update drop zone index
	var child_center_pos: Vector2 = _focus_child.get_rect().get_center()
	for i in range(_drop_zones.size()):
		var drop_zone = _drop_zones[i]
		if drop_zone.has_point(child_center_pos):
			_drop_zone_index = i
			break
		elif i == _drop_zones.size() - 1:
			_drop_zone_index = -1	
	dragging.emit(get_global_mouse_position())

func _handle_auto_scroll(delta):
	var mouse_g_pos = get_global_mouse_position()
	var scroll_g_rect = scroll_container.get_global_rect()
	if is_vertical:
		var upper = scroll_g_rect.position.y + (scroll_g_rect.size.y * auto_scroll_range)
		var lower = scroll_g_rect.position.y + (scroll_g_rect.size.y * (1.0 - auto_scroll_range))
		
		if upper > mouse_g_pos.y:
			var factor = (upper - mouse_g_pos.y) / (upper - scroll_g_rect.position.y)
			scroll_container.scroll_vertical -= delta * float(auto_scroll_speed) * 150.0 * factor
		elif lower < mouse_g_pos.y:
			var factor = (mouse_g_pos.y - lower) / (scroll_g_rect.end.y - lower)
			scroll_container.scroll_vertical += delta * float(auto_scroll_speed) * 150.0 * factor
		else:
			scroll_container.scroll_vertical = scroll_container.scroll_vertical
	else:
		var left = scroll_g_rect.position.x + (scroll_g_rect.size.x * auto_scroll_range)
		var right = scroll_g_rect.position.x + (scroll_g_rect.size.x * (1.0 - auto_scroll_range))
		
		if left > mouse_g_pos.x:
			var factor = (left - mouse_g_pos.x) / (left - scroll_g_rect.position.x)
			scroll_container.scroll_horizontal -= delta * float(auto_scroll_speed) * 150.0 * factor
		elif right < mouse_g_pos.x:
			var factor = (mouse_g_pos.x - right) / (scroll_g_rect.end.x - right)
			scroll_container.scroll_horizontal += delta * float(auto_scroll_speed) * 150.0 * factor
		else:
			scroll_container.scroll_horizontal = scroll_container.scroll_horizontal		


func _on_sort_children(delta := -1.0):
	if _is_using_process and delta == -1.0:
		return
	
	_adjust_expected_child_rect()
	_adjust_child_rect(delta)
	_adjust_drop_zone_rect()


func _adjust_expected_child_rect():
	_expect_child_rect.clear()
	var children := _get_visible_children()
	var end_point = 0.0
	for i in range(children.size()):
		var child := children[i]
		var min_size := child.get_combined_minimum_size()
		if is_vertical:
			if i == _drop_zone_index:
				end_point += _focus_child.size.y + separation
			
			_expect_child_rect.append(Rect2(Vector2(0, end_point), Vector2(size.x, min_size.y)))
			end_point += min_size.y + separation
		else:
			if i == _drop_zone_index:
				end_point += _focus_child.size.x + separation
			
			_expect_child_rect.append(Rect2(Vector2(end_point, 0), Vector2(min_size.x, size.y)))
			end_point += min_size.x + separation
			

func _adjust_child_rect(delta: float = -1.0):
	var children := _get_visible_children()
	if children.is_empty():
		return
	
	var is_animating := false
	var end_point := 0.0
	for i in range(children.size()):
		var child := children[i]
		if child.position == _expect_child_rect[i].position and child.size == _expect_child_rect[i].size:
			continue
		
		if _is_using_process:
			is_animating = true
			child.position = lerp(child.position, _expect_child_rect[i].position, delta * speed)
			child.size = _expect_child_rect[i].size
			if (child.position - _expect_child_rect[i].position).length() <= 1.0:
				child.position = _expect_child_rect[i].position
		else:
			child.position = _expect_child_rect[i].position
			child.size = _expect_child_rect[i].size
	
	var last_child := children[-1]
	if is_vertical:
		if _is_using_process and _drop_zone_index == children.size():
			custom_minimum_size.y = _expect_child_rect[-1].end.y + _focus_child.size.y + separation
		elif not _is_using_process:
			custom_minimum_size.y = last_child.get_rect().end.y
	else:
		if _is_using_process and _drop_zone_index == children.size():
			custom_minimum_size.x = _expect_child_rect[-1].end.x + _focus_child.size.x + separation
		elif not _is_using_process:
			custom_minimum_size.x = last_child.get_rect().end.x

	# Adjust rect every process frame until child is dropped and finished lerping 
	# ( return to adjust when sort_children signal is emitted)
	if not is_animating and _focus_child == null:
		_is_using_process = false


func _adjust_drop_zone_rect():
	_drop_zones.clear()
	var children = _get_visible_children()
	for i in range(children.size()):
		var drop_zone_rect: Rect2
		var child := children[i] as Control
		if is_vertical:
			if i == 0:
				# First child
				drop_zone_rect.position = Vector2(child.position.x, child.position.y - DROP_ZONE_EXTEND)
				drop_zone_rect.end = Vector2(child.size.x, child.get_rect().get_center().y)
				_drop_zones.append(drop_zone_rect)
			else:
				# In between
				var prev_child := children[i - 1] as Control
				drop_zone_rect.position = Vector2(prev_child.position.x, prev_child.get_rect().get_center().y)
				drop_zone_rect.end = Vector2(child.size.x, child.get_rect().get_center().y)
				_drop_zones.append(drop_zone_rect)
			if i == children.size() - 1:
				# Is also last child
				drop_zone_rect.position = Vector2(child.position.x, child.get_rect().get_center().y)
				drop_zone_rect.end = Vector2(child.size.x, child.get_rect().end.y + DROP_ZONE_EXTEND)
				_drop_zones.append(drop_zone_rect)
		else:
			if i == 0:
				# First child
				drop_zone_rect.position = Vector2(child.position.x - DROP_ZONE_EXTEND, child.position.y)
				drop_zone_rect.end = Vector2(child.get_rect().get_center().x, child.size.y)
				_drop_zones.append(drop_zone_rect)
			else:
				# In between
				var prev_child := children[i - 1] as Control
				drop_zone_rect.position = Vector2(prev_child.get_rect().get_center().x, prev_child.position.y)
				drop_zone_rect.end = Vector2(child.get_rect().get_center().x, child.size.y)
				_drop_zones.append(drop_zone_rect)
			if i == children.size() - 1:
				# Is also last child
				drop_zone_rect.position = Vector2(child.get_rect().get_center().x, child.position.y)
				drop_zone_rect.end = Vector2(child.get_rect().end.x + DROP_ZONE_EXTEND, child.size.y)
				_drop_zones.append(drop_zone_rect)


func _get_visible_children() -> Array[Control]:
	var visible_control: Array[Control]
	for _child in get_children():
		var child := _child as Control
		if not child.visible:
			continue
		if child == _focus_child and _is_hold:
			continue
		
		visible_control.append(child)
	return visible_control


func _print_debug(val):
	if is_debugging:
		print(val)
