extends CanvasLayer
## LimboConsole

signal toggled(is_shown)

const THEME_DEFAULT := "res://addons/limbo_console/res/default_theme.tres"

const AsciiArt := preload("res://addons/limbo_console/ascii_art.gd")
const BuiltinCommands := preload("res://addons/limbo_console/builtin_commands.gd")
const CommandEntry := preload("res://addons/limbo_console/command_entry.gd")
const ConfigMapper := preload("res://addons/limbo_console/config_mapper.gd")
const ConsoleOptions := preload("res://addons/limbo_console/console_options.gd")
const Util := preload("res://addons/limbo_console/util.gd")
const CommandHistory := preload("res://addons/limbo_console/command_history.gd")
const HistoryGui := preload("res://addons/limbo_console/history_gui.gd")

const MAX_SUBCOMMANDS: int = 4

## If false, prevents console from being shown. Commands can still be executed from code.
var enabled: bool = true:
	set(value):
		enabled = value
		set_process_input(enabled)
		if not enabled and _control.visible:
			_is_open = false
			set_process(false)
			_hide_console()

var _control: Control
var _history_gui: HistoryGui
var _control_block: Control
var _output: RichTextLabel
var _entry: CommandEntry
var _previous_gui_focus: Control

# Theme colors
var _output_command_color: Color
var _output_command_mention_color: Color
var _output_error_color: Color
var _output_warning_color: Color
var _output_text_color: Color
var _output_debug_color: Color
var _entry_text_color: Color
var _entry_hint_color: Color
var _entry_command_found_color: Color
var _entry_subcommand_color: Color
var _entry_command_not_found_color: Color

var _options: ConsoleOptions
var _commands: Dictionary # "command" => Callable, or "command sub1 sub2" =>  Callable
var _aliases: Dictionary # "alias" => command_to_run: PackedStringArray (alias may contain subcommands)
var _command_descriptions: Dictionary # command_name => description_text
var _argument_autocomplete_sources: Dictionary # [command_name, arg_idx] => Callable
var _history: CommandHistory
var _history_iter: CommandHistory.WrappingIterator
var _autocomplete_matches: PackedStringArray
var _eval_inputs: Dictionary
var _silent: bool = false
var _was_already_paused: bool = false

var _open_t: float = 0.0
var _open_speed: float = 5.0
var _is_open: bool = false


func _init() -> void:
	layer = 9999
	process_mode = ProcessMode.PROCESS_MODE_ALWAYS

	_options = ConsoleOptions.new()
	ConfigMapper.load_from_config(_options)

	_history = CommandHistory.new()
	if _options.persist_history:
		_history.load()
	_history_iter = _history.create_iterator()

	_build_gui()
	_init_theme()
	_control.hide()
	_control_block.hide()

	_open_speed = _options.open_speed

	if _options.disable_in_release_build:
		enabled = OS.is_debug_build()


func _ready() -> void:
	set_process(false) # Note, if you do it in _init(), it won't actually stop it for some reason.
	BuiltinCommands.register_commands()
	if _options.greet_user:
		_greet()
	_add_aliases_from_config.call_deferred()
	_run_autoexec_script.call_deferred()

	_entry.autocomplete_requested.connect(_autocomplete)
	_entry.text_submitted.connect(_on_entry_text_submitted)
	_entry.text_changed.connect(_on_entry_text_changed)


func _exit_tree() -> void:
	if _options.persist_history:
		_history.trim(_options.history_lines)
		_history.save()


func _handle_command_input(p_event: InputEvent) -> void:
	var handled := true
	if not _is_open:
		pass  # Don't accept input while closing console.
	elif p_event.keycode == KEY_UP:
		_fill_entry(_history_iter.prev())
		_clear_autocomplete()
		_update_autocomplete()
	elif p_event.keycode == KEY_DOWN:
		_fill_entry(_history_iter.next())
		_clear_autocomplete()
		_update_autocomplete()
	elif p_event.is_action_pressed("limbo_auto_complete_reverse"):
		_reverse_autocomplete()
	elif p_event.keycode == KEY_TAB:
		_autocomplete()
	elif p_event.keycode == KEY_PAGEUP:
		var scroll_bar: VScrollBar = _output.get_v_scroll_bar()
		scroll_bar.value -= scroll_bar.page
	elif p_event.keycode == KEY_PAGEDOWN:
		var scroll_bar: VScrollBar = _output.get_v_scroll_bar()
		scroll_bar.value += scroll_bar.page
	else:
		handled = false
	if handled:
		get_viewport().set_input_as_handled()


func _handle_history_input(p_event: InputEvent):
	# Allow tab complete (reverse)
	if p_event.is_action_pressed("limbo_auto_complete_reverse"):
		_reverse_autocomplete()
		get_viewport().set_input_as_handled()
	# Allow tab complete (forward)
	elif p_event.keycode == KEY_TAB and p_event.is_pressed():
		_autocomplete()
		get_viewport().set_input_as_handled()
	# Perform search
	elif p_event is InputEventKey:
		_history_gui.search(_entry.text)
		_entry.grab_focus()

	# Make sure entry is always focused
	_entry.grab_focus()


func _input(p_event: InputEvent) -> void:
	if p_event.is_action_pressed("limbo_console_toggle"):
		toggle_console()
		get_viewport().set_input_as_handled()
	# Check to see if the history gui should open
	elif _control.visible and p_event.is_action_pressed("limbo_console_search_history"):
		toggle_history()
		get_viewport().set_input_as_handled()
	elif _history_gui.visible and p_event is InputEventKey:
		_handle_history_input(p_event)
	elif _control.visible and p_event is InputEventKey and p_event.is_pressed():
		_handle_command_input(p_event)


func _process(delta: float) -> void:
	var done_sliding := false
	if _is_open:
		_open_t = move_toward(_open_t, 1.0, _open_speed * delta * 1.0/Engine.time_scale)
		if _open_t == 1.0:
			done_sliding = true
	else: # We close faster than opening.
		_open_t = move_toward(_open_t, 0.0, _open_speed * delta * 1.5 * 1.0/Engine.time_scale)
		if is_zero_approx(_open_t):
			done_sliding = true

	var eased := ease(_open_t, -1.75)
	var new_y := remap(eased, 0, 1, -_control.size.y, 0)
	_control.position.y = new_y

	if done_sliding:
		set_process(false)
		if not _is_open:
			_hide_console()


# *** PUBLIC INTERFACE


func open_console() -> void:
	if enabled:
		_is_open = true
		set_process(true)
		_show_console()


func close_console() -> void:
	if enabled:
		_is_open = false
		set_process(true)
		_history_gui.visible = false
		if _options.persist_history:
			_history.save()
		# _hide_console() is called in _process()


func is_open() -> bool:
	return _is_open


func toggle_console() -> void:
	if _is_open:
		close_console()
	else:
		open_console()


func toggle_history() -> void:
	_history_gui.set_visibility(not _history_gui.visible)
	# Whenever the history gui becomes visible, make sure it has the latest
	# history and do an initial search
	if _history_gui.visible:
		_history_gui.search(_entry.text)


## Clears all messages in the console.
func clear_console() -> void:
	_output.text = ""


## Erases the history that is persisted to the disk
func erase_history() -> void:
	_history.clear()
	var file := FileAccess.open(CommandHistory.HISTORY_FILE, FileAccess.WRITE)
	if file:
		file.store_string("")
		file.close()


## Prints an info message to the console and the output.
func info(p_line: String) -> void:
	print_line(p_line)


## Prints an error message to the console and the output.
func error(p_line: String) -> void:
	print_line("[color=%s]ERROR:[/color] %s" % [_output_error_color.to_html(), p_line])


## Prints a warning message to the console and the output.
func warn(p_line: String) -> void:
	print_line("[color=%s]WARNING:[/color] %s" % [_output_warning_color.to_html(), p_line])


## Prints a debug message to the console and the output.
func debug(p_line: String) -> void:
	print_line("[color=%s]DEBUG: %s[/color]" % [_output_debug_color.to_html(), p_line])


## Prints a line using boxed ASCII art style.
func print_boxed(p_line: String) -> void:
	for line in AsciiArt.str_to_boxed_art(p_line):
		print_line(line)


## Prints a line to the console, and optionally to standard output.
func print_line(p_line: String, p_stdout: bool = _options.print_to_stdout) -> void:
	if _silent:
		return
	_output.text += p_line + "\n"
	if p_stdout:
		print(Util.bbcode_strip(p_line))


## Registers a callable as a command, with optional name and description.
## Name can have up to 4 space-separated identifiers (e.g., "command sub1 sub2 sub3"),
## using letters, digits, or underscores, starting with a non-digit.
func register_command(p_func: Callable, p_name: String = "", p_desc: String = "") -> void:
	if p_name and not Util.is_valid_command_sequence(p_name):
		push_error("LimboConsole: Failed to register command: %s. Name can have up to 4 space-separated identifiers, using letters, digits, or underscores, starting with non-digit." % [p_name])
		return

	if not _validate_callable(p_func):
		push_error("LimboConsole: Failed to register command: %s" % [p_func if p_name.is_empty() else p_name])
		return
	var name: String = p_name
	if name.is_empty():
		if p_func.is_custom():
			push_error("LimboConsole: Failed to register command: Callable is not method and no name was provided")
			return
		name = p_func.get_method().trim_prefix("_").trim_prefix("cmd_")
	if not OS.is_debug_build() and _options.commands_disabled_in_release.has(name):
		return
	if _commands.has(name):
		push_error("LimboConsole: Command already registered: " + p_name)
		return
	# Note: It should be possible to have an alias with the same name.
	_commands[name] = p_func
	_command_descriptions[name] = p_desc


## Unregisters the command specified by its name or a callable.
func unregister_command(p_func_or_name) -> void:
	var cmd_name: String
	if p_func_or_name is Callable:
		var key = _commands.find_key(p_func_or_name)
		if key != null:
			cmd_name = key
	elif p_func_or_name is String:
		cmd_name = p_func_or_name
	if cmd_name.is_empty() or not _commands.has(cmd_name):
		push_error("LimboConsole: Unregister failed - command not found: " % [p_func_or_name])
		return

	_commands.erase(cmd_name)
	_command_descriptions.erase(cmd_name)

	for i in range(1, 5):
		_argument_autocomplete_sources.erase([cmd_name, i])


## Is a command or an alias registered by the given name.
func has_command(p_name: String) -> bool:
	return _commands.has(p_name)


func get_command_names(p_include_aliases: bool = false) -> PackedStringArray:
	var names: PackedStringArray = _commands.keys()
	if p_include_aliases:
		names.append_array(_aliases.keys())
	names.sort()
	return names


func get_command_description(p_name: String) -> String:
	return _command_descriptions.get(p_name, "")


## Registers an alias for command line. [br]
## Alias may contain space-separated parts, e.g. "command sub1" which must match
## against two subsequent arguments on the command line.
func add_alias(p_alias: String, p_command_to_run: String) -> void:
	# It should be possible to override commands and existing aliases.
	# It should be possible to create aliases for commands that are not yet registered,
	# because some commands may be registered by local-to-scene scripts.
	_aliases[p_alias] = _parse_command_line(p_command_to_run)


## Removes an alias by name.
func remove_alias(p_name: String) -> void:
	_aliases.erase(p_name)


## Is an alias registered by the given name.
func has_alias(p_name: String) -> bool:
	return _aliases.has(p_name)


## Lists all registered aliases.
func get_aliases() -> PackedStringArray:
	return PackedStringArray(_aliases.keys())


## Returns the alias's actual command as an argument vector.
func get_alias_argv(p_alias: String) -> PackedStringArray:
	# TODO: I believe _aliases values are stored as an array so this iis unneccessary?
	return _aliases.get(p_alias, [p_alias]).duplicate()


## Registers a callable that should return an array of possible values for the given argument and command.
## It will be used for autocompletion.
func add_argument_autocomplete_source(p_command: String, p_argument: int, p_source: Callable) -> void:
	if not p_source.is_valid():
		push_error("LimboConsole: Can't add autocomplete source: source callable is not valid")
		return
	if not has_command(p_command):
		push_error("LimboConsole: Can't add autocomplete source: command doesn't exist: ", p_command)
		return
	if p_argument < 0 or p_argument > 4:
		push_error("LimboConsole: Can't add autocomplete source: argument index out of bounds: ", p_argument)
		return
	var argument_values = p_source.call()
	if not _validate_autocomplete_result(argument_values, p_command):
		push_error("LimboConsole: Failed to add argument autocomplete source: Callable must return an array.")
		return
	var key := [p_command, p_argument]
	_argument_autocomplete_sources[key] = p_source


## Parses the command line and executes the command if it's valid.
func execute_command(p_command_line: String, p_silent: bool = false) -> void:
	p_command_line = p_command_line.strip_edges()
	if p_command_line.is_empty() or p_command_line.strip_edges().begins_with('#'):
		return

	var argv: PackedStringArray = _parse_command_line(p_command_line)
	var expanded_argv: PackedStringArray = _join_subcommands(_expand_alias(argv))
	var command_name: String = expanded_argv[0]
	var command_args: Array = []

	_silent = p_silent
	if not p_silent:
		var history_line: String = " ".join(argv)
		_history.push_entry(history_line)
		info("[color=%s][b]>[/b] %s[/color] %s" %
				[_output_command_color.to_html(), argv[0], " ".join(argv.slice(1))])

	if not has_command(command_name):
		error("Unknown command: " + command_name)
		_suggest_similar_command(expanded_argv)
		_silent = false
		return

	var cmd: Callable = _commands.get(command_name)
	var valid: bool = _parse_argv(expanded_argv, cmd, command_args)
	if valid:
		var err = cmd.callv(command_args)
		var failed: bool = typeof(err) == TYPE_INT and err > 0
		if failed:
			_suggest_argument_corrections(expanded_argv)
	else:
		usage(argv[0])
	if _options.sparse_mode:
		print_line("")
	_silent = false


## Execute commands from file.
func execute_script(p_file: String, p_silent: bool = true) -> void:
	if FileAccess.file_exists(p_file):
		if not p_silent:
			LimboConsole.info("Executing " + p_file);
		var fa := FileAccess.open(p_file, FileAccess.READ)
		while not fa.eof_reached():
			var line: String = fa.get_line()
			execute_command(line, p_silent)
	else:
		error("File not found: " + p_file.trim_prefix("user://"))


## Formats the tip text (hopefully useful ;).
func format_tip(p_text: String) -> String:
	return "[i][color=" + _output_debug_color.to_html() + "]" + p_text + "[/color][/i]"


## Formats the command name for display.
func format_name(p_name: String) -> String:
	return "[color=" + _output_command_mention_color.to_html() + "]" + p_name + "[/color]"


## Prints the help text for the given command.
func usage(p_command: String) -> Error:
	if _aliases.has(p_command):
		var alias_argv: PackedStringArray = get_alias_argv(p_command)
		var formatted_cmd := "%s %s" % [format_name(alias_argv[0]), ' '.join(alias_argv.slice(1))]
		print_line("Alias of: " + formatted_cmd)
		p_command = alias_argv[0]

	if not has_command(p_command):
		error("Command not found: " + p_command)
		return ERR_INVALID_PARAMETER

	var callable: Callable = _commands[p_command]
	var method_info: Dictionary = Util.get_method_info(callable)
	if method_info.is_empty():
		error("Couldn't find method info for: " + callable.get_method())
		print_line("Usage: ???")

	var usage_line: String = "Usage: %s" % [p_command]
	var arg_lines: String = ""
	var values_lines: String = ""
	var required_args: int = method_info.args.size() - method_info.default_args.size()

	for i in range(method_info.args.size() - callable.get_bound_arguments_count()):
		var arg_name: String = method_info.args[i].name.trim_prefix("p_")
		var arg_type: int = method_info.args[i].type
		if i < required_args:
			usage_line += " " + arg_name
		else:
			usage_line += " [lb]" + arg_name + "[rb]"
		var def_spec: String = ""
		var num_required_args: int = method_info.args.size() - method_info.default_args.size()
		if i >= num_required_args:
			var def_value = method_info.default_args[i - num_required_args]
			if typeof(def_value) == TYPE_STRING:
				def_value = "\"" + def_value + "\""
			def_spec = " = %s" % [def_value]
		arg_lines += "  %s: %s%s\n" % [arg_name, type_string(arg_type) if arg_type != TYPE_NIL else "Variant", def_spec]
		if _argument_autocomplete_sources.has([p_command, i]):
			var auto_complete_callable: Callable = _argument_autocomplete_sources[[p_command, i]]
			var arg_autocompletes = auto_complete_callable.call()
			if len(arg_autocompletes) > 0:
				var values: String = str(arg_autocompletes).replace("[", "").replace("]", "")
				values_lines += " %s: %s\n" % [arg_name, values]
	arg_lines = arg_lines.trim_suffix('\n')

	print_line(usage_line)

	var desc_line: String = ""
	desc_line = _command_descriptions.get(p_command, "")
	if not desc_line.is_empty():
		desc_line[0] = desc_line[0].capitalize()
		if desc_line.right(1) != ".":
			desc_line += "."
		print_line(desc_line)

	if not arg_lines.is_empty():
		print_line("Arguments:")
		print_line(arg_lines)
	if not values_lines.is_empty():
		print_line("Values:")
		print_line(values_lines)
	return OK


## Define an input variable for "eval" command.
func add_eval_input(p_name: String, p_value) -> void:
	_eval_inputs[p_name] = p_value


## Remove specified input variable from "eval" command.
func remove_eval_input(p_name) -> void:
	_eval_inputs.erase(p_name)


## List the defined input variables used in "eval" command.
func get_eval_input_names() -> PackedStringArray:
	return _eval_inputs.keys()


## Get input variable values used in "eval" command, listed in the same order as names.
func get_eval_inputs() -> Array:
	return _eval_inputs.values()


## Define the object that will be used as the base instance for "eval" command.
## When defined, this object will be the "self" for expressions.
## Can be null (the default) to not use any base instance.
func set_eval_base_instance(object):
	_eval_inputs["_base_instance"] = object


## Get the object that will be used as the base instance for "eval" command.
## Null by default.
func get_eval_base_instance():
	return _eval_inputs.get("_base_instance")


# *** PRIVATE

# *** INITIALIZATION


func _build_gui() -> void:
	var con := Control.new() # To block mouse input.
	_control_block = con
	con.set_anchors_preset(Control.PRESET_FULL_RECT)
	add_child(con)

	var panel := PanelContainer.new()
	_control = panel
	panel.anchor_bottom = _options.height_ratio
	panel.anchor_right = 1.0
	add_child(panel)

	var vbox := VBoxContainer.new()
	vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
	panel.add_child(vbox)

	_output = RichTextLabel.new()
	_output.size_flags_vertical = Control.SIZE_EXPAND_FILL
	_output.scroll_active = true
	_output.scroll_following = true
	_output.bbcode_enabled = true
	_output.focus_mode = Control.FOCUS_CLICK
	vbox.add_child(_output)

	_entry = CommandEntry.new()
	vbox.add_child(_entry)

	_control.modulate = Color(1.0, 1.0, 1.0, _options.opacity)

	_history_gui = HistoryGui.new(_history)
	_output.add_child(_history_gui)
	_history_gui.visible = false


func _init_theme() -> void:
	var theme: Theme
	if ResourceLoader.exists(_options.custom_theme, "Theme"):
		theme = load(_options.custom_theme)
	else:
		theme = load(THEME_DEFAULT)
	_control.theme = theme

	const CONSOLE_COLORS_THEME_TYPE := &"ConsoleColors"
	_output_command_color = theme.get_color(&"output_command_color", CONSOLE_COLORS_THEME_TYPE)
	_output_command_mention_color = theme.get_color(&"output_command_mention_color", CONSOLE_COLORS_THEME_TYPE)
	_output_text_color = theme.get_color(&"output_text_color", CONSOLE_COLORS_THEME_TYPE)
	_output_error_color = theme.get_color(&"output_error_color", CONSOLE_COLORS_THEME_TYPE)
	_output_warning_color = theme.get_color(&"output_warning_color", CONSOLE_COLORS_THEME_TYPE)
	_output_debug_color = theme.get_color(&"output_debug_color", CONSOLE_COLORS_THEME_TYPE)
	_entry_text_color = theme.get_color(&"entry_text_color", CONSOLE_COLORS_THEME_TYPE)
	_entry_hint_color = theme.get_color(&"entry_hint_color", CONSOLE_COLORS_THEME_TYPE)
	_entry_command_found_color = theme.get_color(&"entry_command_found_color", CONSOLE_COLORS_THEME_TYPE)
	_entry_subcommand_color = theme.get_color(&"entry_subcommand_color", CONSOLE_COLORS_THEME_TYPE)
	_entry_command_not_found_color = theme.get_color(&"entry_command_not_found_color", CONSOLE_COLORS_THEME_TYPE)

	_output.add_theme_color_override(&"default_color", _output_text_color)
	_entry.add_theme_color_override(&"font_color", _entry_text_color)
	_entry.add_theme_color_override(&"hint_color", _entry_hint_color)
	_entry.syntax_highlighter.command_found_color = _entry_command_found_color
	_entry.syntax_highlighter.command_not_found_color = _entry_command_not_found_color
	_entry.syntax_highlighter.subcommand_color = _entry_subcommand_color
	_entry.syntax_highlighter.text_color = _entry_text_color


func _greet() -> void:
	var message: String = _options.greeting_message
	message = message.format({
		"project_name": ProjectSettings.get_setting("application/config/name"),
		"project_version": ProjectSettings.get_setting("application/config/version"),
		})
	if not message.is_empty():
		if _options.greet_using_ascii_art and AsciiArt.is_boxed_art_supported(message):
			print_boxed(message)
			info("")
		else:
			info("[b]" + message + "[/b]")
	BuiltinCommands.cmd_help()
	info(format_tip("-----"))


func _add_aliases_from_config() -> void:
	for alias in _options.aliases:
		var target = _options.aliases[alias]
		if not alias is String:
			push_error("LimboConsole: Config error: Alias name should be String")
		elif not target is String:
			push_error("LimboConsole: Config error: Alias target should be String")
		elif has_command(alias):
			push_error("LimboConsole: Config error: Alias or command already registered: ", alias)
		elif not has_command(target):
			push_error("LimboConsole: Config error: Alias target not found: ", target)
		else:
			add_alias(alias, target)


func _run_autoexec_script() -> void:
	if _options.autoexec_script.is_empty():
		return
	if _options.autoexec_auto_create and not FileAccess.file_exists(_options.autoexec_script):
		FileAccess.open(_options.autoexec_script, FileAccess.WRITE)
	if FileAccess.file_exists(_options.autoexec_script):
		execute_script(_options.autoexec_script)


# *** PARSING


## Splits the command line string into an array of arguments (aka argv).
func _parse_command_line(p_line: String) -> PackedStringArray:
	var argv: PackedStringArray = []
	var arg: String = ""
	var in_quotes: bool = false
	var in_brackets: bool = false
	var line: String = p_line.strip_edges()
	var start: int = 0
	var cur: int = 0
	for char in line:
		if char == '"':
			in_quotes = not in_quotes
		elif char == '(':
			in_brackets = true
		elif char == ')':
			in_brackets = false
		elif char == ' ' and not in_quotes and not in_brackets:
			if cur > start:
				argv.append(line.substr(start, cur - start))
			start = cur + 1
		cur += 1
	if cur > start:
		argv.append(line.substr(start, cur))
	return argv


## Joins recognized subcommands in the argument vector into a single
## space-separated command sequence at index zero.
func _join_subcommands(p_argv: PackedStringArray) -> PackedStringArray:
	for num_parts in range(MAX_SUBCOMMANDS, 1, -1):
		if p_argv.size() >= num_parts:
			var cmd: String = ' '.join(p_argv.slice(0, num_parts))
			if has_command(cmd) or has_alias(cmd):
				var argv: PackedStringArray = [cmd]
				return argv + p_argv.slice(num_parts)
	return p_argv


## Substitutes an array of strings with its real command in argv.
## Will recursively expand aliases until no aliases are left.
func _expand_alias(p_argv: PackedStringArray) -> PackedStringArray:
	var argv: PackedStringArray = p_argv.duplicate()
	var result := PackedStringArray()
	const max_depth: int = 1000
	var current_depth: int = 0
	while not argv.is_empty() and current_depth != max_depth:
		argv = _join_subcommands(argv)
		var current: String = argv[0]
		argv.remove_at(0)
		var alias_argv: PackedStringArray = _aliases.get(current, [])
		current_depth += 1
		if not alias_argv.is_empty():
			argv = alias_argv + argv
		else:
			result.append(current)
	if current_depth >= max_depth:
		push_error("LimboConsole: Max depth for alias reached. Is there a loop in your aliasing?")
		return p_argv
	return result


## Converts arguments from String to types expected by the callable, and returns true if successful.
## The converted values are placed into a separate r_args array.
func _parse_argv(p_argv: PackedStringArray, p_callable: Callable, r_args: Array) -> bool:
	var passed := true

	var method_info: Dictionary = Util.get_method_info(p_callable)
	if method_info.is_empty():
		error("Couldn't find method info for: " + p_callable.get_method())
		return false
	var num_bound_args: int = p_callable.get_bound_arguments_count()
	var num_args: int = p_argv.size() + num_bound_args - 1
	var max_args: int = method_info.args.size()
	var num_with_defaults: int = method_info.default_args.size()
	var required_args: int = max_args - num_with_defaults

	# Join all arguments into a single string if the callable accepts a single string argument.
	if max_args - num_bound_args == 1 and method_info.args[0].type == TYPE_STRING:
		var a: String = " ".join(p_argv.slice(1))
		if a.left(1) == '"' and a.right(1) == '"':
			a = a.trim_prefix('"').trim_suffix('"')
		r_args.append(a)
		return true
	if num_args < required_args:
		error("Missing arguments.")
		return false
	if num_args > max_args:
		error("Too many arguments.")
		return false

	r_args.resize(p_argv.size() - 1)
	for i in range(1, p_argv.size()):
		var a: String = p_argv[i]
		var incorrect_type := false
		var expected_type: int = method_info.args[i - 1].type

		if expected_type == TYPE_STRING:
			if a.left(1) == '"' and a.right(1) == '"':
				a = a.trim_prefix('"').trim_suffix('"')
			r_args[i - 1] = a
		elif a.begins_with('(') and a.ends_with(')'):
			var vec = _parse_vector_arg(a)
			if vec != null:
				r_args[i - 1] = vec
			else:
				r_args[i - 1] = a
				passed = false
		elif a.is_valid_float():
			r_args[i - 1] = a.to_float()
		elif a.is_valid_int():
			r_args[i - 1] = a.to_int()
		elif a == "true" or a == "1" or a == "yes":
			r_args[i - 1] = true
		elif a == "false" or a == "0" or a == "no":
			r_args[i - 1] = false
		else:
			r_args[i - 1] = a.trim_prefix('"').trim_suffix('"')

		var parsed_type: int = typeof(r_args[i - 1])

		if not _are_compatible_types(expected_type, parsed_type):
			error("Argument %d expects %s, but %s provided." % [i, type_string(expected_type), type_string(parsed_type)])
			passed = false

	return passed


## Returns true if the parsed type is compatible with the expected type.
func _are_compatible_types(p_expected_type: int, p_parsed_type: int) -> bool:
	return p_expected_type == p_parsed_type or \
		p_expected_type == TYPE_NIL or \
		p_expected_type == TYPE_STRING or \
		(p_expected_type in [TYPE_BOOL, TYPE_INT, TYPE_FLOAT] and p_parsed_type in [TYPE_BOOL, TYPE_INT, TYPE_FLOAT]) or \
		(p_expected_type in [TYPE_VECTOR2, TYPE_VECTOR2I] and p_parsed_type in [TYPE_VECTOR2, TYPE_VECTOR2I]) or \
		(p_expected_type in [TYPE_VECTOR3, TYPE_VECTOR3I] and p_parsed_type in [TYPE_VECTOR3, TYPE_VECTOR3I]) or \
		(p_expected_type in [TYPE_VECTOR4, TYPE_VECTOR4I] and p_parsed_type in [TYPE_VECTOR4, TYPE_VECTOR4I])


func _parse_vector_arg(p_text):
	assert(p_text.begins_with('(') and p_text.ends_with(')'), "Vector string presentation must begin and end with round brackets")
	var comp: Array
	var token: String
	for i in range(1, p_text.length()):
		var c: String = p_text[i]
		if c.is_valid_int() or c == '.' or c == '-':
			token += c
		elif c == ',' or c == ' ' or c == ')':
			if token.is_empty() and c == ',' and p_text[i - 1] in [',', '(']:
				# Support shorthand notation: (,,1) => (0,0,1)
				token = '0'
			if token.is_valid_float():
				comp.append(token.to_float())
				token = ""
			elif not token.is_empty():
				error("Failed to parse vector argument: Not a number: \"" + token + "\"")
				info(format_tip("Tip: Supported formats are (1, 2, 3) and (1 2 3) with 2, 3 and 4 elements."))
				return null
		else:
			error("Failed to parse vector argument: Bad formatting: \"" + p_text + "\"")
			info(format_tip("Tip: Supported formats are (1, 2, 3) and (1 2 3) with 2, 3 and 4 elements."))
			return null
	if comp.size() == 2:
		return Vector2(comp[0], comp[1])
	elif comp.size() == 3:
		return Vector3(comp[0], comp[1], comp[2])
	elif comp.size() == 4:
		return Vector4(comp[0], comp[1], comp[2], comp[3])
	else:
		error("LimboConsole supports 2,3,4-element vectors, but %d-element vector given." % [comp.size()])
		return null


# *** AUTOCOMPLETE

## Auto-completes a command or auto-correction on TAB.
func _autocomplete() -> void:
	if not _autocomplete_matches.is_empty():
		var match_str: String = _autocomplete_matches[0]
		_fill_entry(match_str)
		_autocomplete_matches.remove_at(0)
		_autocomplete_matches.push_back(match_str)
		_update_autocomplete()


## Goes in the opposite direction for the autocomplete suggestion
func _reverse_autocomplete():
	if not _autocomplete_matches.is_empty():
		var match_str = _autocomplete_matches[_autocomplete_matches.size() - 1]
		_autocomplete_matches.remove_at(_autocomplete_matches.size() - 1)
		_autocomplete_matches.insert(0, match_str)
		match_str = _autocomplete_matches[_autocomplete_matches.size() - 1]
		_fill_entry(match_str)
		_update_autocomplete()


## Updates autocomplete suggestions and hint based on user input.
func _update_autocomplete() -> void:
	var argv: PackedStringArray = _expand_alias(_parse_command_line(_entry.text))
	if _entry.text.right(1) == ' ' or argv.size() == 0:
		argv.append("")
	var command_name: String = argv[0]
	var last_arg: int = argv.size() - 1
	if _autocomplete_matches.is_empty() and not _entry.text.is_empty():
		if last_arg == 0 and not argv[0].is_empty() \
			and len(argv[0].split(" ")) <= 1:
			_add_first_input_autocompletes(command_name)
		elif last_arg != 0:
			_add_argument_autocompletes(argv)
			_add_subcommand_autocompletes(_entry.text)
			_add_history_autocompletes()

	if _autocomplete_matches.size() > 0 \
			and _autocomplete_matches[0].length() > _entry.text.length() \
			and _autocomplete_matches[0].begins_with(_entry.text):
		_entry.autocomplete_hint = _autocomplete_matches[0].substr(_entry.text.length())
	else:
		_entry.autocomplete_hint = ""


## Adds auto completes for the first index of a registered
## commands when the command is split on " "
func _add_first_input_autocompletes(command_name: String) -> void:
	for cmd_name in get_command_names(true):
		var first_input: String = cmd_name.split(" ")[0]
		if first_input.begins_with(command_name) and \
			 first_input not in _autocomplete_matches:
				_autocomplete_matches.append(first_input)
	_autocomplete_matches.sort()


## Adds auto-completes based on user added arguments for a command. [br]
## p_argv is expected to contain full command as the first element (including subcommands).
func _add_argument_autocompletes(p_argv: PackedStringArray) -> void:
	if p_argv.is_empty():
		return
	var command: String = p_argv[0]
	var last_arg: int = p_argv.size() - 1
	var key := [command, last_arg - 1] # Argument indices are 0-based.
	if _argument_autocomplete_sources.has(key):
		var argument_values = _argument_autocomplete_sources[key].call()
		if not _validate_autocomplete_result(argument_values, command):
			argument_values = []
		var matches: PackedStringArray = []
		for value in argument_values:
			if str(value).begins_with(p_argv[last_arg]):
				matches.append(_entry.text.substr(0, _entry.text.length() - p_argv[last_arg].length()) + str(value))
		matches.sort()
		_autocomplete_matches.append_array(matches)


## Adds auto-completes based on the history
func _add_history_autocompletes() -> void:
	if _options.autocomplete_use_history_with_matches or \
			len(_autocomplete_matches) == 0:
		for i in range(_history.size() - 1, -1, -1):
			if _history.get_entry(i).begins_with(_entry.text):
				_autocomplete_matches.append(_history.get_entry(i))


## Adds subcommand auto-complete suggestions based on registered commands
## and the current user input
func _add_subcommand_autocompletes(typed_val: String) -> void:
	var command_names: PackedStringArray = get_command_names(true)
	var typed_val_tokens: PackedStringArray = typed_val.split(" ")
	var result: Dictionary = {} # Hashset. "autocomplete" => N/A
	for cmd in command_names:
		var cmd_split = cmd.split(" ")
		if len(cmd_split) < len(typed_val_tokens):
			continue

		var last_match: int = 0
		for i in len(typed_val_tokens):
			if cmd_split[i] != typed_val_tokens[i]:
				break
			last_match += 1

		if last_match < len(typed_val_tokens) - 1:
			continue

		if len(cmd_split) >= len(typed_val_tokens) \
			and cmd_split[last_match].begins_with(typed_val_tokens[-1]):
			var partial_cmd_arr: PackedStringArray = cmd_split.slice(0, last_match + 1)
			result.get_or_add(" ".join(partial_cmd_arr))

	var matches = result.keys()
	matches.sort()
	_autocomplete_matches.append_array(matches)


func _clear_autocomplete() -> void:
	_autocomplete_matches.clear()
	_entry.autocomplete_hint = ""


## Suggests corrections to user input based on similar command names.
func _suggest_similar_command(p_argv: PackedStringArray) -> void:
	if _silent:
		return
	var fuzzy_hit: String = Util.fuzzy_match_string(p_argv[0], 2, get_command_names(true))
	if fuzzy_hit:
		info(format_tip("Did you mean %s? ([b]TAB[/b] to fill)" % [format_name(fuzzy_hit)]))
		var argv := p_argv.duplicate()
		argv[0] = fuzzy_hit
		var suggest_command: String = " ".join(argv)
		suggest_command = suggest_command.strip_edges()
		_autocomplete_matches.append(suggest_command)


## Suggests corrections to user input based on similar autocomplete argument values.
func _suggest_argument_corrections(p_argv: PackedStringArray) -> void:
	if _silent:
		return
	var argv: PackedStringArray
	var command_name: String = p_argv[0]
	command_name = get_alias_argv(command_name)[0]
	var corrected := false

	argv.resize(p_argv.size())
	argv[0] = command_name
	for i in range(1, p_argv.size()):
		var accepted_values = []
		var key := [command_name, i]
		var source: Callable = _argument_autocomplete_sources.get(key, Callable())
		if source.is_valid():
			accepted_values = source.call()
		if accepted_values == null or not _validate_autocomplete_result(accepted_values, command_name):
			continue
		var fuzzy_hit: String = Util.fuzzy_match_string(p_argv[i], 2, accepted_values)
		if not fuzzy_hit.is_empty():
			argv[i] = fuzzy_hit
			corrected = true
		else:
			argv[i] = p_argv[i]
	if corrected:
		info(format_tip("Did you mean \"%s %s\"? ([b]TAB[/b] to fill)" % [format_name(command_name), " ".join(argv.slice(1))]))
		var suggest_command: String = " ".join(argv)
		suggest_command = suggest_command.strip_edges()
		_autocomplete_matches.append(suggest_command)


# *** MISC


func _show_console() -> void:
	if not _control.visible and enabled:
		_control.show()
		_control_block.show()
		if _options.pause_when_open:
			_was_already_paused = get_tree().paused
			if not _was_already_paused:
				get_tree().paused = true
		_previous_gui_focus = get_viewport().gui_get_focus_owner()
		_entry.grab_focus()
		toggled.emit(true)


func _hide_console() -> void:
	if _control.visible:
		_control.hide()
		_control_block.hide()

		if _options.pause_when_open:
			if not _was_already_paused:
				get_tree().paused = false
		if is_instance_valid(_previous_gui_focus):
			_previous_gui_focus.grab_focus()
		toggled.emit(false)


## Returns true if the callable can be registered as a command.
func _validate_callable(p_callable: Callable) -> bool:
	var method_info: Dictionary = Util.get_method_info(p_callable)
	if p_callable.is_standard() and method_info.is_empty():
		push_error("LimboConsole: Couldn't find method info for: " + p_callable.get_method())
		return false
	if p_callable.is_custom() and not method_info.is_empty() \
		and method_info.get("name") == "<anonymous lambda>" \
		and p_callable.get_bound_arguments_count() > 0:
			push_error("LimboConsole: bound anonymous functions are unsupported")
			return false

	var ret := true
	for arg in method_info.args:
		if not arg.type in [TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_VECTOR2, TYPE_VECTOR2I, TYPE_VECTOR3, TYPE_VECTOR3I, TYPE_VECTOR4, TYPE_VECTOR4I]:
			push_error("LimboConsole: Unsupported argument type: %s is %s" % [arg.name, type_string(arg.type)])
			ret = false
	return ret


func _validate_autocomplete_result(p_result: Variant, p_command: String) -> bool:
	if typeof(p_result) < TYPE_ARRAY:
		push_error("LimboConsole: Argument autocomplete source failed: Expecting array but got: ",
				type_string(typeof(p_result)), " command: ", p_command)
		return false
	return true


func _fill_entry(p_line: String) -> void:
	_entry.text = p_line
	_entry.set_caret_column(p_line.length())


func _on_entry_text_submitted(p_command: String) -> void:
	if _history_gui.visible:
		_history_gui.visible = false
		_clear_autocomplete()
		_fill_entry(_history_gui.get_current_text())
		_update_autocomplete()
	else:
		_clear_autocomplete()
		_fill_entry("")
		execute_command(p_command)
		_update_autocomplete()


func _on_entry_text_changed() -> void:
	_clear_autocomplete()
	if not _entry.text.is_empty():
		_update_autocomplete()
	else:
		_history_iter.reset()
