@tool
extends Node

signal input_type_changed(input_type: InputType, controller: int)

enum InputType {
	KEYBOARD_MOUSE, ## The input is from the keyboard and/or mouse.
	CONTROLLER ## The input is from a controller.
}

enum PathType {
	INPUT_ACTION, ## The path is an input action.
	JOYPAD_PATH, ## The path is a generic joypad path.
	SPECIFIC_PATH ## The path is a specific path.
}

var _cached_icons := {}
var _custom_input_actions := {}

var _cached_callables_lock := Mutex.new()
var _cached_callables : Array[Callable] = []

var _last_input_type : InputType
var _last_controller : int
var _settings : ControllerSettings
var _base_extension := "png"

# Custom mouse velocity calculation, because Godot
# doesn't implement it on some OSes apparently
const _MOUSE_VELOCITY_DELTA := 0.1
var _t : float
var _mouse_velocity : int

var Mapper = preload("res://addons/controller_icons/Mapper.gd").new()

# Default actions will be the builtin editor actions when
# the script is at editor ("tool") level. To pickup more
# actions available, these have to be queried manually
var _builtin_keys := [
	"input/ui_accept", "input/ui_cancel", "input/ui_copy",
	"input/ui_cut", "input/ui_down", "input/ui_end",
	"input/ui_filedialog_refresh", "input/ui_filedialog_show_hidden",
	"input/ui_filedialog_up_one_level", "input/ui_focus_next",
	"input/ui_focus_prev", "input/ui_graph_delete",
	"input/ui_graph_duplicate", "input/ui_home",
	"input/ui_left", "input/ui_menu", "input/ui_page_down",
	"input/ui_page_up", "input/ui_paste", "input/ui_redo",
	"input/ui_right", "input/ui_select", "input/ui_swap_input_direction",
	"input/ui_text_add_selection_for_next_occurrence",
	"input/ui_text_backspace", "input/ui_text_backspace_all_to_left",
	"input/ui_text_backspace_all_to_left.macos",
	"input/ui_text_backspace_word", "input/ui_text_backspace_word.macos",
	"input/ui_text_caret_add_above", "input/ui_text_caret_add_above.macos",
	"input/ui_text_caret_add_below", "input/ui_text_caret_add_below.macos",
	"input/ui_text_caret_document_end", "input/ui_text_caret_document_end.macos",
	"input/ui_text_caret_document_start", "input/ui_text_caret_document_start.macos",
	"input/ui_text_caret_down", "input/ui_text_caret_left",
	"input/ui_text_caret_line_end", "input/ui_text_caret_line_end.macos",
	"input/ui_text_caret_line_start", "input/ui_text_caret_line_start.macos",
	"input/ui_text_caret_page_down", "input/ui_text_caret_page_up",
	"input/ui_text_caret_right", "input/ui_text_caret_up",
	"input/ui_text_caret_word_left", "input/ui_text_caret_word_left.macos",
	"input/ui_text_caret_word_right", "input/ui_text_caret_word_right.macos",
	"input/ui_text_clear_carets_and_selection", "input/ui_text_completion_accept",
	"input/ui_text_completion_query", "input/ui_text_completion_replace",
	"input/ui_text_dedent", "input/ui_text_delete",
	"input/ui_text_delete_all_to_right", "input/ui_text_delete_all_to_right.macos",
	"input/ui_text_delete_word", "input/ui_text_delete_word.macos",
	"input/ui_text_indent", "input/ui_text_newline", "input/ui_text_newline_above",
	"input/ui_text_newline_blank", "input/ui_text_scroll_down",
	"input/ui_text_scroll_down.macos", "input/ui_text_scroll_up",
	"input/ui_text_scroll_up.macos", "input/ui_text_select_all",
	"input/ui_text_select_word_under_caret", "input/ui_text_select_word_under_caret.macos",
	"input/ui_text_submit", "input/ui_text_toggle_insert_mode", "input/ui_undo",
	"input/ui_up",
]

func _set_last_input_type(__last_input_type, __last_controller):
	_last_input_type = __last_input_type
	_last_controller = __last_controller
	emit_signal("input_type_changed", _last_input_type, _last_controller)

func _enter_tree():
	process_mode = Node.PROCESS_MODE_ALWAYS
	if Engine.is_editor_hint():
		_parse_input_actions()

func _exit_tree():
	Mapper = null

func _parse_input_actions():
	_custom_input_actions.clear()

	for key in _builtin_keys:
		var data : Dictionary = ProjectSettings.get_setting(key)
		if not data.is_empty() and data.has("events") and data["events"] is Array:
			_add_custom_input_action((key as String).trim_prefix("input/"), data)

	# A script running at editor ("tool") level only has
	# the default mappings. The way to get around this is
	# manually parsing the project file and adding the
	# new input actions to lookup.
	var proj_file := ConfigFile.new()
	if proj_file.load("res://project.godot"):
		printerr("Failed to open \"project.godot\"! Custom input actions will not work on editor view!")
		return
	if proj_file.has_section("input"):
		for input_action in proj_file.get_section_keys("input"):
			var data : Dictionary = proj_file.get_value("input", input_action)
			_add_custom_input_action(input_action, data)

func _ready():
	Input.joy_connection_changed.connect(_on_joy_connection_changed)
	_settings = load("res://addons/controller_icons/settings.tres")
	if not _settings:
		_settings = ControllerSettings.new()
	if _settings.custom_mapper:
		Mapper = _settings.custom_mapper.new()
	if _settings.custom_file_extension and not _settings.custom_file_extension.is_empty():
		_base_extension = _settings.custom_file_extension
	# Wait a frame to give a chance for the app to initialize
	await get_tree().process_frame
	# Set input type to what's likely being used currently
	if Input.get_connected_joypads().is_empty():
		_set_last_input_type(InputType.KEYBOARD_MOUSE, -1)
	else:
		_set_last_input_type(InputType.CONTROLLER, Input.get_connected_joypads().front())

func _on_joy_connection_changed(device, connected):
	if connected:
		_set_last_input_type(InputType.CONTROLLER, device)
	else:
		if Input.get_connected_joypads().is_empty():
			_set_last_input_type(InputType.KEYBOARD_MOUSE, -1)
		else:
			_set_last_input_type(InputType.CONTROLLER, Input.get_connected_joypads().front())

func _input(event: InputEvent):
	var input_type = _last_input_type
	var controller = _last_controller
	match event.get_class():
		"InputEventKey", "InputEventMouseButton":
			input_type = InputType.KEYBOARD_MOUSE
		"InputEventMouseMotion":
			if _settings.allow_mouse_remap and _test_mouse_velocity(event.relative):
				input_type = InputType.KEYBOARD_MOUSE
		"InputEventJoypadButton":
			input_type = InputType.CONTROLLER
			controller = event.device
		"InputEventJoypadMotion":
			if abs(event.axis_value) > _settings.joypad_deadzone:
				input_type = InputType.CONTROLLER
				controller = event.device
	if input_type != _last_input_type or controller != _last_controller:
		_set_last_input_type(input_type, controller)

func _test_mouse_velocity(relative_vec: Vector2):
	if _t > _MOUSE_VELOCITY_DELTA:
		_t = 0
		_mouse_velocity = 0

	# We do a component sum instead of a length, to save on a
	# sqrt operation, and because length_squared is negatively
	# affected by low value vectors (<10).
	# It is also good enough for this system, so reliability
	# is sacrificed in favor of speed.
	_mouse_velocity += abs(relative_vec.x) + abs(relative_vec.y)
	return _mouse_velocity / _MOUSE_VELOCITY_DELTA > _settings.mouse_min_movement

func _process(delta: float) -> void:
	_t += delta

	if not _cached_callables.is_empty() and _cached_callables_lock.try_lock():
		# UPGRADE: In Godot 4.2, for-loop variables can be
		# statically typed:
		# for f: Callable in _cached_callables:
		for f in _cached_callables:
			if f.is_valid(): f.call()
		_cached_callables.clear()
		_cached_callables_lock.unlock()

func _add_custom_input_action(input_action: String, data: Dictionary):
	_custom_input_actions[input_action] = data["events"]

func refresh():
	# All it takes is to signal icons to refresh paths
	emit_signal("input_type_changed", _last_input_type, _last_controller)

func get_joypad_type(controller: int = _last_controller) -> ControllerSettings.Devices:
	return Mapper._get_joypad_type(controller, _settings.joypad_fallback)

func get_last_input_type() -> InputType:
	return _last_input_type

func parse_path(path: String, input_type = _last_input_type, last_controller = _last_controller, forced_controller_icon_style = ControllerSettings.Devices.NONE) -> Texture:
	if typeof(input_type) == TYPE_NIL:
		return null
	var root_paths := _expand_path(path, input_type, last_controller, forced_controller_icon_style)
	for root_path in root_paths:
		if _load_icon(root_path):
			continue
		return _cached_icons[root_path]
	return null

func parse_event_modifiers(event: InputEvent) -> Array[Texture]:
	if not event or not event is InputEventWithModifiers:
		return []

	var icons : Array[Texture] = []
	var modifiers : Array[String] = []
	if event.command_or_control_autoremap:
		match OS.get_name():
			"macOS":
				modifiers.push_back("key/command")
			_:
				modifiers.push_back("key/ctrl")
	if event.ctrl_pressed and not event.command_or_control_autoremap:
		modifiers.push_back("key/ctrl")
	if event.shift_pressed:
		modifiers.push_back("key/shift")
	if event.alt_pressed:
		modifiers.push_back("key/alt")
	if event.meta_pressed and not event.command_or_control_autoremap:
		match OS.get_name():
			"macOS":
				modifiers.push_back("key/command")
			_:
				modifiers.push_back("key/win")

	for modifier in modifiers:
		for icon_path in _expand_path(modifier, InputType.KEYBOARD_MOUSE, -1):
			if _load_icon(icon_path) == OK:
				icons.push_back(_cached_icons[icon_path])
				break

	return icons

func parse_path_to_tts(path: String, input_type: int = _last_input_type, controller: int = _last_controller) -> String:
	if input_type == null:
		return ""
	var tts = _convert_path_to_asset_file(path, input_type, controller)
	return _convert_asset_file_to_tts(tts.get_basename().get_file())

func parse_event(event: InputEvent) -> Texture:
	var path = _convert_event_to_path(event)
	if path.is_empty():
		return null

	var base_paths := [
		_settings.custom_asset_dir + "/",
		"res://addons/controller_icons/assets/"
	]
	for base_path in base_paths:
		if base_path.is_empty():
			continue
		base_path += path + "." + _base_extension
		if _load_icon(base_path):
			continue
		return _cached_icons[base_path]
	return null

func get_path_type(path: String) -> PathType:
	if _custom_input_actions.has(path) or InputMap.has_action(path):
		return PathType.INPUT_ACTION
	elif path.get_slice("/", 0) == "joypad":
		return PathType.JOYPAD_PATH
	else:
		return PathType.SPECIFIC_PATH

func get_matching_event(path: String, input_type: InputType = _last_input_type, controller: int = _last_controller) -> InputEvent:
	var events : Array
	if _custom_input_actions.has(path):
		events = _custom_input_actions[path]
	else:
		events = InputMap.action_get_events(path)

	var fallbacks = []
	for event in events:
		if not is_instance_valid(event): continue

		match event.get_class():
			"InputEventKey", "InputEventMouse", "InputEventMouseMotion", "InputEventMouseButton":
				if input_type == InputType.KEYBOARD_MOUSE:
					return event
			"InputEventJoypadButton", "InputEventJoypadMotion":
				if input_type == InputType.CONTROLLER:
					# Use the first device specific mapping if there is one.
					if event.device == controller:
						return event
					# Otherwise, we create a fallback prioritizing events with 'ALL_DEVICE' 
					if event.device < 0: # All-device event
						fallbacks.push_front(event)
					else:
						fallbacks.push_back(event)

	return fallbacks[0] if not fallbacks.is_empty() else null

func _expand_path(path: String, input_type: int, controller: int, forced_controller_icon_style = ControllerSettings.Devices.NONE) -> Array:
	var paths := []
	var base_paths := [
		_settings.custom_asset_dir + "/",
		"res://addons/controller_icons/assets/"
	]
	for base_path in base_paths:
		if base_path.is_empty():
			continue
		base_path += _convert_path_to_asset_file(path, input_type, controller, forced_controller_icon_style)

		paths.push_back(base_path + "." + _base_extension)
	return paths

func _convert_path_to_asset_file(path: String, input_type: int, controller: int, forced_controller_icon_style = ControllerSettings.Devices.NONE) -> String:
	match get_path_type(path):
		PathType.INPUT_ACTION:
			var event := get_matching_event(path, input_type, controller)
			if event:
				return _convert_event_to_path(event, controller, forced_controller_icon_style)
			return path
		PathType.JOYPAD_PATH:
			return Mapper._convert_joypad_path(path, controller, _settings.joypad_fallback, forced_controller_icon_style)
		PathType.SPECIFIC_PATH, _:
			return path

func _convert_asset_file_to_tts(path: String) -> String:
	match path:
		"shift_alt":
			return "shift"
		"esc":
			return "escape"
		"backspace_alt":
			return "backspace"
		"enter_alt":
			return "enter"
		"enter_tall":
			return "keypad enter"
		"arrow_left":
			return "left arrow"
		"arrow_right":
			return "right arrow"
		"del":
			return "delete"
		"arrow_up":
			return "up arrow"
		"arrow_down":
			return "down arrow"
		"shift_alt":
			return "shift"
		"ctrl":
			return "control"
		"kp_add":
			return "keypad plus"
		"mark_left":
			return "left mark"
		"mark_right":
			return "right mark"
		"bracket_left":
			return "left bracket"
		"bracket_right":
			return "right bracket"
		"tilda":
			return "tilde"
		"lb":
			return "left bumper"
		"rb":
			return "right bumper"
		"lt":
			return "left trigger"
		"rt":
			return "right trigger"
		"l_stick_click":
			return "left stick click"
		"r_stick_click":
			return "right stick click"
		"l_stick":
			return "left stick"
		"r_stick":
			return "right stick"
		_:
			return path

func _convert_event_to_path(event: InputEvent, controller: int = _last_controller, forced_controller_icon_style = ControllerSettings.Devices.NONE):
	if event is InputEventKey:
		# If this is a physical key, convert to localized scancode
		if event.keycode == 0:
			return _convert_key_to_path(DisplayServer.keyboard_get_keycode_from_physical(event.physical_keycode))
		return _convert_key_to_path(event.keycode)
	elif event is InputEventMouseButton:
		return _convert_mouse_button_to_path(event.button_index)
	elif event is InputEventJoypadButton:
		return _convert_joypad_button_to_path(event.button_index, controller, forced_controller_icon_style)
	elif event is InputEventJoypadMotion:
		return _convert_joypad_motion_to_path(event.axis, controller, forced_controller_icon_style)

func _convert_key_to_path(scancode: int):
	match scancode:
		KEY_ESCAPE:
			return "key/esc"
		KEY_TAB:
			return "key/tab"
		KEY_BACKSPACE:
			return "key/backspace_alt"
		KEY_ENTER:
			return "key/enter_alt"
		KEY_KP_ENTER:
			return "key/enter_tall"
		KEY_INSERT:
			return "key/insert"
		KEY_DELETE:
			return "key/del"
		KEY_PRINT:
			return "key/print_screen"
		KEY_HOME:
			return "key/home"
		KEY_END:
			return "key/end"
		KEY_LEFT:
			return "key/arrow_left"
		KEY_UP:
			return "key/arrow_up"
		KEY_RIGHT:
			return "key/arrow_right"
		KEY_DOWN:
			return "key/arrow_down"
		KEY_PAGEUP:
			return "key/page_up"
		KEY_PAGEDOWN:
			return "key/page_down"
		KEY_SHIFT:
			return "key/shift_alt"
		KEY_CTRL:
			return "key/ctrl"
		KEY_META:
			match OS.get_name():
				"macOS":
					return "key/command"
				_:
					return "key/meta"
		KEY_ALT:
			return "key/alt"
		KEY_CAPSLOCK:
			return "key/caps_lock"
		KEY_NUMLOCK:
			return "key/num_lock"
		KEY_F1:
			return "key/f1"
		KEY_F2:
			return "key/f2"
		KEY_F3:
			return "key/f3"
		KEY_F4:
			return "key/f4"
		KEY_F5:
			return "key/f5"
		KEY_F6:
			return "key/f6"
		KEY_F7:
			return "key/f7"
		KEY_F8:
			return "key/f8"
		KEY_F9:
			return "key/f9"
		KEY_F10:
			return "key/f10"
		KEY_F11:
			return "key/f11"
		KEY_F12:
			return "key/f12"
		KEY_KP_MULTIPLY, KEY_ASTERISK:
			return "key/asterisk"
		KEY_KP_SUBTRACT, KEY_MINUS:
			return "key/minus"
		KEY_KP_ADD:
			return "key/plus_tall"
		KEY_KP_0:
			return "key/0"
		KEY_KP_1:
			return "key/1"
		KEY_KP_2:
			return "key/2"
		KEY_KP_3:
			return "key/3"
		KEY_KP_4:
			return "key/4"
		KEY_KP_5:
			return "key/5"
		KEY_KP_6:
			return "key/6"
		KEY_KP_7:
			return "key/7"
		KEY_KP_8:
			return "key/8"
		KEY_KP_9:
			return "key/9"
		KEY_UNKNOWN:
			return ""
		KEY_SPACE:
			return "key/space"
		KEY_QUOTEDBL:
			return "key/quote"
		KEY_PLUS:
			return "key/plus"
		KEY_0:
			return "key/0"
		KEY_1:
			return "key/1"
		KEY_2:
			return "key/2"
		KEY_3:
			return "key/3"
		KEY_4:
			return "key/4"
		KEY_5:
			return "key/5"
		KEY_6:
			return "key/6"
		KEY_7:
			return "key/7"
		KEY_8:
			return "key/8"
		KEY_9:
			return "key/9"
		KEY_SEMICOLON:
			return "key/semicolon"
		KEY_LESS:
			return "key/mark_left"
		KEY_GREATER:
			return "key/mark_right"
		KEY_QUESTION:
			return "key/question"
		KEY_A:
			return "key/a"
		KEY_B:
			return "key/b"
		KEY_C:
			return "key/c"
		KEY_D:
			return "key/d"
		KEY_E:
			return "key/e"
		KEY_F:
			return "key/f"
		KEY_G:
			return "key/g"
		KEY_H:
			return "key/h"
		KEY_I:
			return "key/i"
		KEY_J:
			return "key/j"
		KEY_K:
			return "key/k"
		KEY_L:
			return "key/l"
		KEY_M:
			return "key/m"
		KEY_N:
			return "key/n"
		KEY_O:
			return "key/o"
		KEY_P:
			return "key/p"
		KEY_Q:
			return "key/q"
		KEY_R:
			return "key/r"
		KEY_S:
			return "key/s"
		KEY_T:
			return "key/t"
		KEY_U:
			return "key/u"
		KEY_V:
			return "key/v"
		KEY_W:
			return "key/w"
		KEY_X:
			return "key/x"
		KEY_Y:
			return "key/y"
		KEY_Z:
			return "key/z"
		KEY_BRACKETLEFT:
			return "key/bracket_left"
		KEY_BACKSLASH:
			return "key/slash"
		KEY_SLASH:
			return "key/forward_slash"
		KEY_BRACKETRIGHT:
			return "key/bracket_right"
		KEY_ASCIITILDE:
			return "key/tilda"
		KEY_QUOTELEFT:
			return "key/backtick"
		KEY_APOSTROPHE:
			return "key/apostrophe"
		KEY_COMMA:
			return "key/comma"
		KEY_EQUAL:
			return "key/equals"
		KEY_PERIOD, KEY_KP_PERIOD:
			return "key/period"
		_:
			return ""

func _convert_mouse_button_to_path(button_index: int):
	match button_index:
		MOUSE_BUTTON_LEFT:
			return "mouse/left"
		MOUSE_BUTTON_RIGHT:
			return "mouse/right"
		MOUSE_BUTTON_MIDDLE:
			return "mouse/middle"
		MOUSE_BUTTON_WHEEL_UP:
			return "mouse/wheel_up"
		MOUSE_BUTTON_WHEEL_DOWN:
			return "mouse/wheel_down"
		MOUSE_BUTTON_XBUTTON1:
			return "mouse/side_down"
		MOUSE_BUTTON_XBUTTON2:
			return "mouse/side_up"
		_:
			return "mouse/sample"

func _convert_joypad_button_to_path(button_index: int, controller: int, forced_controller_icon_style = ControllerSettings.Devices.NONE):
	var path
	match button_index:
		JOY_BUTTON_A:
			path = "joypad/a"
		JOY_BUTTON_B:
			path = "joypad/b"
		JOY_BUTTON_X:
			path = "joypad/x"
		JOY_BUTTON_Y:
			path = "joypad/y"
		JOY_BUTTON_LEFT_SHOULDER:
			path = "joypad/lb"
		JOY_BUTTON_RIGHT_SHOULDER:
			path = "joypad/rb"
		JOY_BUTTON_LEFT_STICK:
			path = "joypad/l_stick_click"
		JOY_BUTTON_RIGHT_STICK:
			path = "joypad/r_stick_click"
		JOY_BUTTON_BACK:
			path = "joypad/select"
		JOY_BUTTON_START:
			path = "joypad/start"
		JOY_BUTTON_DPAD_UP:
			path = "joypad/dpad_up"
		JOY_BUTTON_DPAD_DOWN:
			path = "joypad/dpad_down"
		JOY_BUTTON_DPAD_LEFT:
			path = "joypad/dpad_left"
		JOY_BUTTON_DPAD_RIGHT:
			path = "joypad/dpad_right"
		JOY_BUTTON_GUIDE:
			path = "joypad/home"
		JOY_BUTTON_MISC1:
			path = "joypad/share"
		_:
			return ""
	return Mapper._convert_joypad_path(path, controller, _settings.joypad_fallback, forced_controller_icon_style)

func _convert_joypad_motion_to_path(axis: int, controller: int, forced_controller_icon_style = ControllerSettings.Devices.NONE):
	var path : String
	match axis:
		JOY_AXIS_LEFT_X, JOY_AXIS_LEFT_Y:
			path = "joypad/l_stick"
		JOY_AXIS_RIGHT_X, JOY_AXIS_RIGHT_Y:
			path = "joypad/r_stick"
		JOY_AXIS_TRIGGER_LEFT:
			path = "joypad/lt"
		JOY_AXIS_TRIGGER_RIGHT:
			path = "joypad/rt"
		_:
			return ""
	return Mapper._convert_joypad_path(path, controller, _settings.joypad_fallback, forced_controller_icon_style)

func _load_icon(path: String) -> int:
	if _cached_icons.has(path): return OK
	var tex = null
	if path.begins_with("res://"):
		if ResourceLoader.exists(path):
			tex = load(path)
			if not tex:
				return ERR_FILE_CORRUPT
		else:
			return ERR_FILE_NOT_FOUND
	else:
		if not FileAccess.file_exists(path):
			return ERR_FILE_NOT_FOUND
		var img := Image.new()
		var err = img.load(path)
		if err != OK:
			return err
		tex = ImageTexture.new()
		tex.create_from_image(img)
	_cached_icons[path] = tex
	return OK

func _defer_texture_load(f: Callable) -> void:
	_cached_callables_lock.lock()
	_cached_callables.push_back(f)
	_cached_callables_lock.unlock()
