@tool
class_name DMCodeEdit extends CodeEdit


signal active_title_change(title: String)
signal error_clicked(line_number: int)
signal external_file_requested(path: String, title: String)


const MUTATION_PREFIXES: PackedStringArray = ["$>", "$>>", "do ", "do! ", "set ", "if ", "elif ", "else if ", "match ", "when "]
const INLINE_MUTATION_PREFIXES: PackedStringArray = ["$> ", "$>> ", "do ", "do! ", "set ", "if ", "elif "]

# A link back to the owner `MainView`
var main_view: Control

# Theme overrides for syntax highlighting, etc
var theme_overrides: Dictionary:
	set(value):
		theme_overrides = value

		syntax_highlighter = DMSyntaxHighlighter.new()

		# General UI
		add_theme_color_override("font_color", theme_overrides.text_color)
		add_theme_color_override("background_color", theme_overrides.background_color)
		add_theme_color_override("current_line_color", theme_overrides.current_line_color)
		add_theme_font_override("font", get_theme_font("source", "EditorFonts"))
		add_theme_font_size_override("font_size", theme_overrides.font_size * theme_overrides.scale)
		font_size = round(theme_overrides.font_size)
	get:
		return theme_overrides

# Any parse errors
var errors: Array:
	set(next_errors):
		errors = next_errors
		for i in range(0, get_line_count()):
			var is_error: bool = false
			for error in errors:
				if error.line_number == i:
					is_error = true
			mark_line_as_error(i, is_error)
		_on_code_edit_caret_changed()
	get:
		return errors

# The last selection (if there was one) so we can remember it for refocusing
var last_selected_text: String

var font_size: int:
	set(value):
		font_size = value
		add_theme_font_size_override("font_size", font_size * theme_overrides.scale)
	get:
		return font_size

var WEIGHTED_RANDOM_PREFIX: RegEx = RegEx.create_from_string("^\\%[\\d.]+\\s")
var STATIC_REGEX: RegEx = RegEx.create_from_string("^static var (?<property>[a-zA-Z_0-9]+)(:\\s?(?<type>[a-zA-Z_0-9]+))?")
var STATIC_CONTENT_REGEX: RegEx = RegEx.create_from_string("static (var|func)")

var compiler_regex: DMCompilerRegEx = DMCompilerRegEx.new()
var _autoloads: Dictionary[String, String] = {}
var _autoload_member_cache: Dictionary[String, Dictionary] = {}


func _ready() -> void:
	# Add error gutter
	add_gutter(0)
	set_gutter_type(0, TextEdit.GUTTER_TYPE_ICON)

	# Add comment delimiter
	if not has_comment_delimiter("#"):
		add_comment_delimiter("#", "", true)

	syntax_highlighter = DMSyntaxHighlighter.new()

	# Keep track of any autoloads
	ProjectSettings.settings_changed.connect(_on_project_settings_changed)
	_on_project_settings_changed()


func _gui_input(event: InputEvent) -> void:
	# Handle shortcuts that come from the editor
	if event is InputEventKey and event.is_pressed():
		var shortcut: String = DMPlugin.get_editor_shortcut(event)
		match shortcut:
			"toggle_comment":
				toggle_comment()
				get_viewport().set_input_as_handled()
			"delete_line":
				delete_current_line()
				get_viewport().set_input_as_handled()
			"move_up":
				move_line(-1)
				get_viewport().set_input_as_handled()
			"move_down":
				move_line(1)
				get_viewport().set_input_as_handled()
			"text_size_increase":
				self.font_size += 1
				get_viewport().set_input_as_handled()
			"text_size_decrease":
				self.font_size -= 1
				get_viewport().set_input_as_handled()
			"text_size_reset":
				self.font_size = theme_overrides.font_size
				get_viewport().set_input_as_handled()
			"make_bold":
				insert_bbcode("[b]", "[/b]")
				get_viewport().set_input_as_handled()
			"make_italic":
				insert_bbcode("[i]", "[/i]")
				get_viewport().set_input_as_handled()

	elif event is InputEventMouse:
		match event.as_text():
			"Ctrl+Mouse Wheel Up", "Command+Mouse Wheel Up":
				self.font_size += 1
				get_viewport().set_input_as_handled()
			"Ctrl+Mouse Wheel Down", "Command+Mouse Wheel Down":
				self.font_size -= 1
				get_viewport().set_input_as_handled()


func _can_drop_data(at_position: Vector2, data) -> bool:
	if typeof(data) != TYPE_DICTIONARY: return false
	if data.type != "files": return false

	var files: PackedStringArray = Array(data.files)
	return files.size() > 0


func _drop_data(at_position: Vector2, data: Variant) -> void:
	var replace_regex: RegEx = RegEx.create_from_string("[^a-zA-Z_0-9]+")

	if typeof(data) == TYPE_STRING: return

	var files: PackedStringArray = Array(data.files)
	for file: String in files:
		# Don't import the file into itself
		if file == main_view.current_file_path: continue

		if file.get_extension() == "dialogue":
			var known_aliases: PackedStringArray = []
			var path: String = file.replace("res://", "").replace(".dialogue", "")
			# Find the first non-import line in the file to add our import
			var lines: PackedStringArray = text.split("\n")
			for i: int in range(0, lines.size()):
				if lines[i].begins_with("import "):
					var found: RegExMatch = compiler_regex.IMPORT_REGEX.search(lines[i])
					if found:
						known_aliases.append(found.strings[found.names.prefix])
				else:
					var alias: String = ""
					var bits: PackedStringArray = replace_regex.sub(path, "|", true).split("|")
					bits.reverse()
					for end: int in range(1, bits.size() + 1):
						alias =  "_".join(bits.slice(0, end))
						if not alias in known_aliases:
							break
					insert_line_at(i, "import \"%s\" as %s\n" % [file, alias])
					set_caret_line(i)
					break
		else:
			var cursor: Vector2 = get_line_column_at_pos(at_position)
			if cursor.x > -1 and cursor.y > -1:
				set_cursor(cursor)
				remove_secondary_carets()
				var resource: Resource = load(file)
				# If the dropped file is an audio stream then assume it's a voice reference
				if is_instance_of(resource, AudioStream):
					var current_voice_regex: RegEx = RegEx.create_from_string("\\[#voice=.+\\]")
					var path: String = ResourceUID.call("path_to_uid", file) if ResourceUID.has_method("path_to_uid") else file
					var line_text: String = get_line(cursor.y)
					var voice_text: String = "[#voice=%s]" % [path]
					if current_voice_regex.search(line_text):
						set_line(cursor.y, current_voice_regex.replace(get_line(cursor.y), voice_text))
					else:
						insert_text(" " + voice_text, cursor.y, line_text.length())
				# Other wise it's just a file reference
				else:
					insert_text("\"%s\"" % file, cursor.y, cursor.x)
	grab_focus()


func _request_code_completion(force: bool) -> void:
	var cursor: Vector2 = get_cursor()
	var current_line: String = get_line(cursor.y)

	_add_jump_completions(current_line, cursor)
	_add_character_name_completions(current_line)
	_add_mutation_completions(current_line, cursor)

	update_code_completion_options(true)
	if get_code_completion_options().size() == 0:
		cancel_code_completion()


func _filter_code_completion_candidates(candidates: Array) -> Array:
	# Not sure why but if this method isn't overridden then all completions are wrapped in quotes.
	return candidates


func _confirm_code_completion(replace: bool) -> void:
	var completion: Dictionary = get_code_completion_option(get_code_completion_selected_index())
	begin_complex_operation()
	# Delete any part of the text that we've already typed
	for i: int in range(0, completion.display_text.length() - completion.insert_text.length()):
		backspace()
	# Insert the whole match
	insert_text_at_caret(completion.display_text)
	end_complex_operation()

	if completion.display_text.ends_with("()"):
		set_cursor(get_cursor() - Vector2.RIGHT)

	# Close the autocomplete menu on the next tick
	call_deferred("cancel_code_completion")


#region Completion Helpers


# Add completions for jump targets (=> and =><).
func _add_jump_completions(current_line: String, cursor: Vector2) -> void:
	if not ("=> " in current_line or "=>< " in current_line): return
	if cursor.x <= current_line.find("=>"): return

	var prompt: String = current_line.split("=>")[1]
	if prompt.begins_with("< "):
		prompt = prompt.substr(2)
	else:
		prompt = prompt.substr(1)

	if "=> " in current_line:
		if _matches_prompt(prompt, "end"):
			add_code_completion_option(CodeEdit.KIND_CLASS, "END", "END".substr(prompt.length()), theme_overrides.text_color, get_theme_icon("Stop", "EditorIcons"))
		if _matches_prompt(prompt, "end!"):
			add_code_completion_option(CodeEdit.KIND_CLASS, "END!", "END!".substr(prompt.length()), theme_overrides.text_color, get_theme_icon("Stop", "EditorIcons"))

	# Get all titles, including those in imports
	for title: String in DMCompiler.get_titles_in_text(text, main_view.current_file_path):
		# Ignore any imported titles that aren't resolved to human readable.
		if title.to_int() > 0:
			continue
		elif "/" in title:
			var bits: PackedStringArray = title.split("/")
			if _matches_prompt(prompt, bits[0]) or _matches_prompt(prompt, bits[1]):
				add_code_completion_option(CodeEdit.KIND_CLASS, title, title.substr(prompt.length()), theme_overrides.text_color, get_theme_icon("CombineLines", "EditorIcons"))
		elif _matches_prompt(prompt, title):
			add_code_completion_option(CodeEdit.KIND_CLASS, title, title.substr(prompt.length()), theme_overrides.text_color, get_theme_icon("ArrowRight", "EditorIcons"))


# Add completions for character names at the start of dialogue lines.
func _add_character_name_completions(current_line: String) -> void:
	# Ignore names on mutation lines
	for prefix: String in MUTATION_PREFIXES:
		if current_line.strip_edges().begins_with(prefix):
			return

	var name_so_far: String = WEIGHTED_RANDOM_PREFIX.sub(current_line.strip_edges(), "")

	if name_so_far == "": return

	var names: PackedStringArray = get_character_names(name_so_far)
	for character_name: String in names:
		add_code_completion_option(CodeEdit.KIND_CLASS, character_name + ": ", character_name.substr(name_so_far.length()) + ": ", theme_overrides.text_color, get_theme_icon("Sprite2D", "EditorIcons"))


# Add state/mutation completions.
func _add_mutation_completions(current_line: String, cursor: Vector2) -> void:
	# Check for inline mutation context first (e.g., "Nathan: Hello [$> SomeGlobal.")
	var inline_context: Dictionary = _get_inline_mutation_context(current_line, cursor.x)
	var mutation_expression: String = ""
	var is_inline_mutation: bool = not inline_context.is_empty()
	var is_using_line: bool = false

	if is_inline_mutation:
		mutation_expression = inline_context.get("expression", "")
	else:
		# Match autoloads on full mutation lines (MUTATION_PREFIXES + "using ")
		for prefix in MUTATION_PREFIXES + PackedStringArray(["using "]):
			if current_line.strip_edges().begins_with(prefix) and cursor.x > current_line.find(prefix):
				mutation_expression = current_line.substr(0, cursor.x).strip_edges().substr(3)
				is_using_line = current_line.strip_edges().begins_with("using ")
				break

	if mutation_expression == "" and not is_inline_mutation:
		return

	# Find the last token (the part being typed)
	var possible_prompt: String = mutation_expression.reverse()
	possible_prompt = possible_prompt.substr(0, possible_prompt.find(" "))
	possible_prompt = possible_prompt.substr(0, possible_prompt.find("("))
	possible_prompt = possible_prompt.reverse()
	var segments: PackedStringArray = possible_prompt.split(".")
	var auto_completes: Array[Dictionary] = []

	if segments.size() == 1:
		# Suggest autoloads and state shortcuts
		auto_completes = _get_autoload_completions(segments[0])
	elif not is_using_line:
		if not segments[0] in _autoloads.keys():
			# See if the first segment is a property of a shortcut
			var shortcut: String = _find_shortcut_with_member(segments[0])
			if not shortcut.is_empty():
				segments.insert(0, shortcut)
		# Suggest members of an autoload or nested property
		auto_completes = _get_member_completions(segments)

	var prompt: String = segments[-1].to_lower()

	# Add true/false
	if prompt.length() > 1:
		var icon: Texture2D = _get_icon_for_type("keyword")
		var color: Color = theme_overrides.conditions_color
		if "true".contains(prompt):
			add_code_completion_option(CodeEdit.KIND_CONSTANT, "true", "true".substr(prompt.length()), color, icon)
		if "false".contains(prompt):
			add_code_completion_option(CodeEdit.KIND_CONSTANT, "false", "false".substr(prompt.length()), color, icon)

	# Remove duplicates
	var unique_auto_completes: PackedStringArray = []
	auto_completes = auto_completes.filter(func(auto_complete: Dictionary) -> bool:
		if unique_auto_completes.has(auto_complete.text): return false

		unique_auto_completes.append(auto_complete.text)
		return true
	)

	auto_completes.sort_custom(func(a, b):
		return a.text.to_lower().similarity(prompt) > b.text.to_lower().similarity(prompt)
	)
	for auto_complete: Dictionary in auto_completes:
		var icon: Texture2D = _get_icon_for_type(auto_complete.type)
		var display_text: String = auto_complete.text
		if auto_complete.type == "method":
			display_text += "()"
		var insert: String = display_text.substr(auto_complete.prompt.length())
		add_code_completion_option(CodeEdit.KIND_CLASS, display_text, insert, theme_overrides.text_color, icon)


# Find the shortcut that a member name belongs to.
func _find_shortcut_with_member(member_name: String) -> String:
	for autoload: String in _get_state_shortcuts():
		for member: Dictionary in _get_members_for_base_script(autoload):
			if member.name == member_name:
				return autoload
	return ""


# Get completions for autoload names and state shortcut members.
func _get_autoload_completions(prompt: String) -> Array[Dictionary]:
	var completions: Array[Dictionary] = []
	for autoload: String in _autoloads.keys():
		if _matches_prompt(prompt, autoload):
			completions.append({ prompt = prompt, text = autoload, type = "script" })
	for autoload: String in _get_state_shortcuts():
		for member: Dictionary in _get_members_for_base_script(autoload):
			if _matches_prompt(prompt, member.name):
				completions.append({ prompt = prompt, text = member.name, type = member.type })
	return completions


# Get completions for members of an autoload or nested property chain.
func _get_member_completions(segments: PackedStringArray) -> Array[Dictionary]:
	var completions: Array[Dictionary] = []
	var prompt: String = segments[-1]
	var members: Array[Dictionary] = []

	if segments.size() == 2:
		# Direct autoload property access (e.g., "SomeGlobal.property")
		members = _get_members_for_base_script(segments[0])
	else:
		# Nested property access (e.g., "SomeGlobal.a_class_property.nested")
		var chain_segments: PackedStringArray = segments.slice(0, segments.size() - 1)
		var resolved_script: Variant = _resolve_script_for_property_chain(chain_segments)
		if resolved_script != null:
			members = _get_members_for_script(resolved_script)

	for member: Dictionary in members:
		if _matches_prompt(prompt, member.name):
			completions.append({ prompt = prompt, text = member.name, type = member.type })
	return completions


# Get the appropriate icon for a member type.
func _get_icon_for_type(type: String) -> Texture2D:
	match type:
		"keyword":
			return get_theme_icon("CodeHighlighter", "EditorIcons")
		"script":
			return get_theme_icon("Script", "EditorIcons")
		"property":
			return get_theme_icon("MemberProperty", "EditorIcons")
		"method":
			return get_theme_icon("MemberMethod", "EditorIcons")
		"signal":
			return get_theme_icon("MemberSignal", "EditorIcons")
		"constant":
			return get_theme_icon("MemberConstant", "EditorIcons")
		"enum":
			return get_theme_icon("Enum", "EditorIcons")
	return null


#endregion

#region Cursor Helpers


## Get the current caret position as a Vector2 (x=column, y=line).
func get_cursor() -> Vector2:
	return Vector2(get_caret_column(), get_caret_line())


## Set the caret position from a Vector2 (x=column, y=line).
func set_cursor(from_cursor: Vector2) -> void:
	set_caret_line(from_cursor.y, false)
	set_caret_column(from_cursor.x, false)


# Check if a prompt fuzzy-matches a candidate.
func _matches_prompt(prompt: String, candidate: String) -> bool:
	if prompt.length() > candidate.length(): return false
	if prompt.is_empty(): return true

	# Fuzzy match characters in order
	candidate = candidate.to_lower()
	var next_index: int = 0
	for char: String in prompt.to_lower():
		next_index = candidate.find(char, next_index) + 1
		if next_index == 0:
			return false
	return true


#endregion

#region Autoload and Script Helpers


# Get autoload shortcuts from settings and "using" clauses.
func _get_state_shortcuts() -> PackedStringArray:
	# Get any shortcuts defined in settings
	var shortcuts: PackedStringArray = DMSettings.get_setting(DMSettings.STATE_AUTOLOAD_SHORTCUTS, [])
	# Check for "using" clauses
	for line: String in text.split("\n"):
		var found: RegExMatch = compiler_regex.USING_REGEX.search(line)
		if found:
			shortcuts.append(found.strings[found.names.state])
	# Check for any other script sources
	for extra_script_source: String in DMSettings.get_setting(DMSettings.EXTRA_AUTO_COMPLETE_SCRIPT_SOURCES, []):
		if extra_script_source:
			shortcuts.append(extra_script_source)

	return shortcuts


# Get all members (methods, properties, signals, constants) for an autoload.
func _get_members_for_base_script(base_script_name: String) -> Array[Dictionary]:
	# Debounce method list lookups
	if _autoload_member_cache.has(base_script_name) \
	and _autoload_member_cache.get(base_script_name).get("at") > Time.get_ticks_msec() - 10000:
		return _autoload_member_cache.get(base_script_name).get("members")

	if not _autoloads.has(base_script_name) \
	and not base_script_name.begins_with("res://") \
	and not base_script_name.begins_with("uid://"):
		return []

	var autoload: Variant = load(_autoloads.get(base_script_name, base_script_name))
	if autoload is PackedScene:
		var node: Node = autoload.instantiate()
		autoload = node.get_script()
		node.free()

	if autoload == null: return []

	var script: Script = autoload if autoload is Script else autoload.get_script()

	if not is_instance_valid(script): return []

	var members: Array[Dictionary] = _get_members_for_script(script)

	_autoload_member_cache[base_script_name] = {
		at = Time.get_ticks_msec(),
		members = members
	}

	return members


# Get all members (methods, properties, signals, constants) for a Script.
func _get_members_for_script(script: Variant) -> Array[Dictionary]:
	var members: Array[Dictionary] = []

	# Its an enum:
	if script is Dictionary:
		for key: String in script.keys():
			members.append({
				name = key,
				type = "enum"
			})
		return members

	# Otherwise its a script
	if not is_instance_valid(script): return []

	if script.resource_path.is_empty() or script.resource_path.ends_with(".gd"):
		for m: Dictionary in script.get_script_method_list():
			if not m.name.begins_with("@"):
				members.append({
					name = m.name,
					type = "method"
				})
		for m: Dictionary in script.get_script_property_list():
			if not m.name.ends_with(".gd") and not m.name.contains("Built-in"):
				members.append({
					name = m.name,
					type = "property",
					"class_name" = m.get("class_name", "")
				})
		for m: Dictionary in script.get_script_signal_list():
			members.append({
				name = m.name,
				type = "signal"
			})
		for c: String in script.get_script_constant_map():
			members.append({
				name = c,
				type = "constant"
			})

		# Check for static properties
		for line: String in script.source_code.split("\n"):
			var matching: RegExMatch = STATIC_REGEX.search(line)
			if matching:
				members.append({
					name = matching.strings[matching.names.property],
					type = "property"
				})
	elif script.resource_path.ends_with(".cs"):
		var dotnet: RefCounted = load(DMPlugin.get_plugin_path() + "/DialogueManager.cs").new()
		for m: Dictionary in dotnet.GetMembersForScript(script):
			members.append(m)

	return members


# Get the Script for a given class name.
func _get_script_for_class_name(class_name_to_find: String) -> Script:
	if class_name_to_find == "": return null

	for class_data: Dictionary in ProjectSettings.get_global_class_list():
		if class_data.get(&"class") == class_name_to_find:
			return load(class_data.path)

	return null


# Get method info (args, return type) for a method in a Script.
func _get_method_info_from_script(script: Script, method_name: String) -> Dictionary:
	if not is_instance_valid(script): return {}

	if script.resource_path.ends_with(".gd"):
		for m: Dictionary in script.get_script_method_list():
			if m.name == method_name:
				return m
	elif script.resource_path.ends_with(".cs"):
		var dotnet: RefCounted = load(DMPlugin.get_plugin_path() + "/DialogueManager.cs").new()
		for m: Dictionary in dotnet.GetMembersForScript(script):
			if m.get("name") == method_name and m.get("type") == "method":
				return m

	return {}


# Format method arguments into a hint string for display.
func _format_method_hint(method_info: Dictionary) -> String:
	if method_info.is_empty(): return ""

	var args: Array = method_info.get("args", [])
	if args.size() == 0: return ""

	var hint_parts: PackedStringArray = []
	for arg: Dictionary in args:
		var arg_name: String = arg.get("name", "")
		var arg_type: int = arg.get("type", TYPE_NIL)
		var arg_class_name: String = arg.get("class_name", "")

		var type_name: String = ""
		if arg_class_name != "":
			type_name = arg_class_name
		elif arg_type != TYPE_NIL:
			type_name = type_string(arg_type)

		if type_name != "":
			hint_parts.append("%s: %s" % [arg_name, type_name])
		else:
			hint_parts.append(arg_name)

	return ", ".join(hint_parts)


#endregion

#region Symbol Resolution Helpers


# Find the line number where a member is defined in a script's source code.
func _find_definition_in_script(script: Script, member_name: String) -> int:
	if not is_instance_valid(script): return -1

	var lines: PackedStringArray = script.source_code.split("\n")

	if script is GDScript:
		# Try to find the line in a GDScript file.
		var method_regex: RegEx = RegEx.create_from_string("^\\s*func\\s+" + member_name + "\\s*\\(")
		var property_regex: RegEx = RegEx.create_from_string("^(@.*\\s)?\\s*var\\s+" + member_name + "\\s*[:\\s=]")
		var signal_regex: RegEx = RegEx.create_from_string("^\\s*signal\\s+" + member_name + "\\s*[\\(\\s]")
		var const_regex: RegEx = RegEx.create_from_string("^\\s*const\\s+" + member_name + "\\s*[:\\s=]")
		var enum_regex: RegEx = RegEx.create_from_string("^\\s*enum\\s+" + member_name + "[\\s$]")
		var inner_class_regex: RegEx = RegEx.create_from_string("^\\s*class\\s+" + member_name + ":")

		for i: int in range(lines.size()):
			var line: String = lines[i]
			if method_regex.search(line) \
				or property_regex.search(line) \
				or signal_regex.search(line) \
				or const_regex.search(line) \
				or enum_regex.search(line) \
				or inner_class_regex.search(line):
				# Editor line numbers start at 1
				return i + 1

		# Does the script extend another one?
		var extends_regex: RegEx = RegEx.create_from_string("extends (?<extends>.*)")
		var found_extends: RegExMatch = extends_regex.search(script.source_code)
		if found_extends:
			var extends_name: String = found_extends.get_string(found_extends.names.extends)
			if not "\"" in extends_name:
				script = _get_script_for_class_name(extends_name)
				return _find_definition_in_script(script, member_name)

	elif script.resource_path.ends_with(".cs"):
		# Try to find the line in a C# file.
		var method_regex: RegEx = RegEx.create_from_string("^\\s*public\\s+.*?" + member_name + "\\s*\\(")
		var property_regex: RegEx = RegEx.create_from_string("^\\s*(\\[Export\\] )?public\\s+.*?" + member_name)
		var const_regex: RegEx = RegEx.create_from_string("^\\s*const\\s+.*?" + member_name)
		var enum_regex: RegEx = RegEx.create_from_string("^\\s*public enum\\s+" + member_name)

		for i: int in range(lines.size()):
			var line: String = lines[i]
			if method_regex.search(line) \
				or property_regex.search(line) \
				or const_regex.search(line) \
				or enum_regex.search(line):
				# Editor line numbers start at 1
				return i + 1

	return -1


# Resolve the symbol at a given position in a mutation line for definition lookup.
func _resolve_mutation_symbol_at_position(line_text: String, column: int) -> Dictionary:
	if not _is_in_mutation_context(line_text, column):
		return {}

	var symbol: String = get_word_at_pos(get_local_mouse_pos())
	if symbol.is_empty(): return {}

	# Find the full chain by looking backwards from the token start for dots and identifiers
	var token_start: int = column
	while token_start > 0 and line_text[token_start - 1].is_valid_ascii_identifier():
		token_start -= 1

	var chain_start: int = token_start
	while chain_start > 0:
		var prev_char: String = line_text[chain_start - 1]
		if prev_char == ".":
			chain_start -= 1
			# Continue backwards to get the identifier before the dot
			while chain_start > 0 and line_text[chain_start - 1].is_valid_ascii_identifier():
				chain_start -= 1
		else:
			break

	var full_chain: String = line_text.substr(chain_start, token_start + symbol.length() - chain_start)
	# Remove any trailing parentheses content
	if "(" in full_chain:
		full_chain = full_chain.substr(0, full_chain.find("("))

	var segments: PackedStringArray = full_chain.split(".")

	# Check if it starts with an autoload
	if not segments[0] in _autoloads.keys():
		var shortcut: String = _find_shortcut_with_member(segments[0])
		if shortcut.is_empty():
			return {}
		else:
			segments.insert(0, shortcut)

	# The symbol we clicked on is the last segment
	var member_name: String = segments[-1]

	# Resolve the script that contains this member
	var target_script: Variant = null
	if segments.size() == 1 and segments[0] in _autoloads.keys():
		member_name = "class_name"
		var target: Variant = load(_autoloads.get(segments[0]))
		if target is PackedScene:
			var node: Node = target.instantiate()
			target = node.get_script()
			node.free()
		target_script = target if target is Script else target.get_script()
	else:
		var object_segments: PackedStringArray = segments.slice(0, segments.size() - 1)
		target_script = _resolve_script_for_property_chain(object_segments)

	if target_script == null:
		return {}
	elif target_script is Dictionary:
		return {
			"script": _resolve_script_for_property_chain(segments.slice(0, -2)),
			"member_name": segments.slice(0, -1)[segments.size() - 2],
			"symbol": symbol
		}

	return {
		"script": target_script,
		"member_name": member_name,
		"symbol": symbol
	}


# Update the code hint to show method parameter information.
func _update_code_hint() -> void:
	var cursor: Vector2 = get_cursor()
	var current_line: String = get_line(cursor.y)
	var text_before_cursor: String = current_line.substr(0, cursor.x)

	# Check if we're in a mutation context (inline or full line)
	var inline_context: Dictionary = _get_inline_mutation_context(current_line, cursor.x)
	if not _is_in_mutation_context(current_line, cursor.x):
		set_code_hint("")
		return

	# For inline mutations, scope to the bracket content
	var expression_text: String = text_before_cursor
	if not inline_context.is_empty():
		var bracket_start: int = inline_context.get("bracket_start", 0)
		expression_text = current_line.substr(bracket_start + 1, cursor.x - bracket_start - 1)

	# Check if cursor is inside parentheses by counting unmatched opening parens
	var paren_depth: int = 0
	var last_open_parenthesis_pos: int = -1
	for i: int in range(expression_text.length()):
		if expression_text[i] == "(":
			paren_depth += 1
			last_open_parenthesis_pos = i
		elif expression_text[i] == ")":
			paren_depth -= 1

	if paren_depth <= 0 or last_open_parenthesis_pos == -1:
		set_code_hint("")
		return

	# Extract the expression before the opening parenthesis
	var expression_before_parenthesis: String = expression_text.substr(0, last_open_parenthesis_pos).strip_edges()

	# Find the method chain (last token before the paren)
	var method_chain: String = ""
	for i: int in range(expression_before_parenthesis.length() - 1, -1, -1):
		var c: String = expression_before_parenthesis[i]
		if c == " " or c == "(" or c == "," or c == "=" or c == ">" or c == "!":
			method_chain = expression_before_parenthesis.substr(i + 1)
			break
		if i == 0:
			method_chain = expression_before_parenthesis

	if method_chain == "":
		set_code_hint("")
		return

	# Parse the method chain into segments
	var segments: PackedStringArray = method_chain.split(".")
	if segments.is_empty():
		set_code_hint("")
		return

	# The last segment is the method name
	var method_name: String = segments[-1]

	# Check if it starts with an autoload
	if not segments[0] in _autoloads.keys():
		var shortcut: String = _find_shortcut_with_member(segments[0])
		if shortcut.is_empty():
			set_code_hint("")
			return
		else:
			segments.insert(0, shortcut)

	# Resolve the script for the object the method is called on
	var object_segments: PackedStringArray = segments.slice(0, segments.size() - 1)
	var target_script: Variant = _resolve_script_for_property_chain(object_segments)

	if target_script == null or not target_script is Script:
		set_code_hint("")
		return

	# Get the method info and format the hint
	var method_info: Dictionary = _get_method_info_from_script(target_script, method_name)
	var hint: String = _format_method_hint(method_info)

	set_code_hint(hint)


#endregion

#region Mutation Context Helpers


# Get the inline mutation context if the cursor is inside an inline mutation bracket.
# Returns a dictionary with "expression" key containing the text to autocomplete,
# or an empty dictionary if not in an inline mutation context.
func _get_inline_mutation_context(line: String, cursor_x: int) -> Dictionary:
	# Find all bracket positions and determine if cursor is inside one
	var bracket_depth: int = 0
	var bracket_start: int = -1
	var bracket_content_start: int = -1

	for i: int in range(line.length()):
		if i >= cursor_x:
			break

		if line[i] == "[":
			bracket_depth += 1
			if bracket_depth == 1:
				bracket_start = i
				bracket_content_start = i + 1
		elif line[i] == "]":
			bracket_depth -= 1
			if bracket_depth == 0:
				bracket_start = -1
				bracket_content_start = -1

	# Not inside brackets
	if bracket_start == -1 or bracket_content_start == -1:
		return {}

	# Get the content inside the brackets up to cursor
	var bracket_content: String = line.substr(bracket_content_start, cursor_x - bracket_content_start)

	# Check if this is a mutation tag
	for prefix: String in INLINE_MUTATION_PREFIXES:
		if bracket_content.begins_with(prefix):
			# Return the expression part (after the tag)
			var expression: String = bracket_content.substr(prefix.length())
			return { "expression": expression, "bracket_start": bracket_start }

	return {}


# Check if the cursor is in a mutation context (either inline or full mutation line).
func _is_in_mutation_context(line: String, cursor_x: int) -> bool:
	if not _get_inline_mutation_context(line, cursor_x).is_empty():
		return true
	for prefix: String in MUTATION_PREFIXES:
		if line.strip_edges().begins_with(prefix):
			return true
	return false


# Resolve the Script for a chain of property accesses (e.g., "Autoload.prop1.prop2").
func _resolve_script_for_property_chain(segments: PackedStringArray) -> Variant:
	if segments.size() == 0: return null

	var autoload: Variant = null

	if segments[0].begins_with("uid://") or segments[0].begins_with("res://"):
		autoload = load(segments[0])
	elif _autoloads.has(segments[0]):
		autoload = load(_autoloads.get(segments[0]))
	else:
		return null

	if autoload is PackedScene:
		var node: Node = autoload.instantiate()
		autoload = node.get_script()
		node.free()
	elif autoload == null:
		return null
	elif not autoload is Script:
		autoload = autoload.get_script()

	var current_script: Variant = autoload

	if not is_instance_valid(current_script): return null
	if (segments.size() == 1): return current_script

	# Walk through each property in the chain (except the last one which is what we're completing)
	for i: int in range(1, segments.size()):
		var property_name: String = segments[i]
		var found_property: bool = false

		# Regular properties
		for property_info: Dictionary in current_script.get_script_property_list():
			if property_info.name == property_name:
				var prop_class_name: String = property_info.get("class_name", "")
				if prop_class_name != "":
					current_script = _get_script_for_class_name(prop_class_name)
					if current_script == null:
						return null
					found_property = true
					break
				else:
					# Property doesn't have a class type, can't go deeper
					return null

		# Check for inner classes and enums
		if not found_property:
			for constant: String in current_script.get_script_constant_map():
				if constant == property_name:
					var constant_value: Variant = current_script.get_script_constant_map().get(constant)
					# Inner class
					if constant_value is Script:
						current_script = constant_value
						found_property = true
						break
					# Enum
					if constant_value is Dictionary:
						current_script = constant_value
						found_property = true
						break
					else:
						# Constant isn't an enum or an inner class
						return null

		# Static properties. NOTE: Godot doesn't programatically find static properties
		# so we have to manually find them.
		if not found_property and current_script is Script and current_script.source_code.contains("static var"):
			for line: String in current_script.source_code.split("\n"):
				var matched: RegExMatch = STATIC_REGEX.search(line)
				if matched and matched.strings[matched.names.property] == property_name:
					if matched.names.has("type"):
						var type: String = matched.strings[matched.names.type]
						current_script = _get_script_for_class_name(type)
						if current_script == null:
							return null
						found_property = true
						break
					else:
						return null

		if not found_property:
			return null

	return current_script


#endregion

#region Title and Character Helpers


## Get a list of titles from the current text.
func get_titles() -> PackedStringArray:
	var titles: PackedStringArray = PackedStringArray([])
	var lines: PackedStringArray = text.split("\n")
	for line: String in lines:
		if line.strip_edges().begins_with("~ "):
			titles.append(line.strip_edges().substr(2))

	return titles


## Work out what the next title above the current line is
func check_active_title() -> void:
	var line_number: int = get_caret_line()
	var lines: PackedStringArray = text.split("\n")
	# Look at each line above this one to find the next title line
	for i: int in range(line_number, -1, -1):
		if lines[i].begins_with("~ "):
			active_title_change.emit(lines[i].replace("~ ", ""))
			return

	active_title_change.emit("")


## Move the caret line to match a given title.
func go_to_title(title: String, create_if_none: bool = false) -> void:
	var found_title: bool = false

	var lines = text.split("\n")
	for i: int in range(0, lines.size()):
		if lines[i].strip_edges() == "~ " + title:
			found_title = true
			set_caret_line(i)
			center_viewport_to_caret()

	if create_if_none and not found_title:
		text += "\n\n\n~ %s\n\n=> END" % [title]
		set_caret_line(text.split("\n").size() - 2)
		center_viewport_to_caret()


## Get all character names from the dialogue that match the given prefix.
func get_character_names(beginning_with: String) -> PackedStringArray:
	if beginning_with.is_empty(): return []

	var names: PackedStringArray = []
	var lines = text.split("\n")
	for line: String in lines:
		if line.strip_edges().begins_with("#"): continue # skip comments
		if ": " in line:
			var character_name: String = WEIGHTED_RANDOM_PREFIX.sub(line.split(": ")[0].strip_edges(), "")

			if character_name.is_empty() or character_name in names: continue
			if character_name.to_lower()[0] != beginning_with.to_lower()[0]: continue
			if not _matches_prompt(beginning_with, character_name): continue

			names.append(character_name)

	return names


#endregion

#region Text Editing Helpers


## Mark a line as an error or not.
func mark_line_as_error(line_number: int, is_error: bool) -> void:
	# Lines display counting from 1 but are actually indexed from 0
	line_number -= 1

	if line_number < 0: return

	if is_error:
		set_line_background_color(line_number, theme_overrides.error_line_color)
		set_line_gutter_icon(line_number, 0, get_theme_icon("StatusError", "EditorIcons"))
	else:
		set_line_background_color(line_number, Color(0, 0, 0, 0))
		set_line_gutter_icon(line_number, 0, null)


## Insert or wrap some bbcode at the caret/selection.
func insert_bbcode(open_tag: String, close_tag: String = "") -> void:
	if close_tag == "":
		insert_text_at_caret(open_tag)
		grab_focus()
	else:
		var selected_text: String = get_selected_text()
		insert_text_at_caret("%s%s%s" % [open_tag, selected_text, close_tag])
		grab_focus()
		set_caret_column(get_caret_column() - close_tag.length())


## Insert text at current caret position. Moves caret down 1 line if not "=> END".
func insert_text_at_cursor(text_to_insert: String) -> void:
	if text_to_insert != "=> END":
		insert_text_at_caret(text_to_insert + "\n")
		set_caret_line(get_caret_line() + 1)
	else:
		insert_text_at_caret(text_to_insert)
	grab_focus()


## Toggle the selected lines as comments.
func toggle_comment() -> void:
	begin_complex_operation()

	var comment_delimiter: String = delimiter_comments[0]
	var is_first_line: bool = true
	var will_comment: bool = true
	var selections: Array = []
	var line_offsets: Dictionary = {}

	for caret_index in range(0, get_caret_count()):
		var from_line: int = get_caret_line(caret_index)
		var from_column: int = get_caret_column(caret_index)
		var to_line: int = get_caret_line(caret_index)
		var to_column: int = get_caret_column(caret_index)

		if has_selection(caret_index):
			from_line = get_selection_from_line(caret_index)
			to_line = get_selection_to_line(caret_index)
			from_column = get_selection_from_column(caret_index)
			to_column = get_selection_to_column(caret_index)

		selections.append({
			from_line = from_line,
			from_column = from_column,
			to_line = to_line,
			to_column = to_column
		})

		for line_number: int in range(from_line, to_line + 1):
			if line_offsets.has(line_number): continue

			var line_text: String = get_line(line_number)

			# The first line determines if we are commenting or uncommentingg
			if is_first_line:
				is_first_line = false
				will_comment = not line_text.strip_edges().begins_with(comment_delimiter)

			# Only comment/uncomment if the current line needs to
			if will_comment:
				set_line(line_number, comment_delimiter + line_text)
				line_offsets[line_number] = 1
			elif line_text.begins_with(comment_delimiter):
				set_line(line_number, line_text.substr(comment_delimiter.length()))
				line_offsets[line_number] = -1
			else:
				line_offsets[line_number] = 0

	for caret_index in range(0, get_caret_count()):
		var selection: Dictionary = selections[caret_index]
		select(
			selection.from_line,
			selection.from_column + line_offsets[selection.from_line],
			selection.to_line,
			selection.to_column + line_offsets[selection.to_line],
			caret_index
		)
		set_caret_column(selection.from_column + line_offsets[selection.from_line], false, caret_index)

	end_complex_operation()

	text_set.emit()
	text_changed.emit()


## Remove the current line.
func delete_current_line() -> void:
	var cursor: Vector2 = get_cursor()
	if get_line_count() == 1:
		select_all()
	elif cursor.y == 0:
		select(0, 0, 1, 0)
	else:
		select(cursor.y - 1, get_line_width(cursor.y - 1), cursor.y, get_line_width(cursor.y))
	delete_selection()
	text_changed.emit()


## Move the selected lines up or down.
func move_line(offset: int) -> void:
	offset = clamp(offset, -1, 1)

	var starting_scroll: float = scroll_vertical
	var cursor: Vector2 = get_cursor()
	var reselect: bool = false
	var from: int = cursor.y
	var to: int = cursor.y
	if has_selection():
		reselect = true
		from = get_selection_from_line()
		to = get_selection_to_line()

	var lines: PackedStringArray = text.split("\n")

	# Prevent the lines from being out of bounds
	if from + offset < 0 or to + offset >= lines.size(): return

	var target_from_index: int = from - 1 if offset == -1 else to + 1
	var target_to_index: int = to if offset == -1 else from
	var line_to_move: String = lines[target_from_index]
	lines.remove_at(target_from_index)
	lines.insert(target_to_index, line_to_move)

	text = "\n".join(lines)

	cursor.y += offset
	set_cursor(cursor)
	from += offset
	to += offset
	if reselect:
		select(from, 0, to, get_line_width(to))

	text_changed.emit()
	scroll_vertical = starting_scroll + offset


#endregion

#region Signals


func _on_project_settings_changed() -> void:
	_autoloads = {}

	# Add any actual autoloads
	var project = ConfigFile.new()
	project.load("res://project.godot")
	if project.has_section("autoload"):
		for autoload: String in project.get_section_keys("autoload"):
			if autoload != "DialogueManager":
				_autoloads[autoload] = project.get_value("autoload", autoload).substr(1)

	# Add project-defined classes if they contain static properties or methods
	var plugin_path: String = DMPlugin.get_plugin_path()
	if not plugin_path.is_empty():
		for script_info: Dictionary in ProjectSettings.get_global_class_list():
			if not script_info.path.begins_with(plugin_path):
				var script: Script = load(script_info.path)
				var static_match: RegExMatch = STATIC_CONTENT_REGEX.search(script.source_code)
				if static_match:
					_autoloads[script_info.class] = script_info.path


func _on_code_edit_symbol_validate(symbol: String) -> void:
	if symbol.begins_with("res://") and symbol.ends_with(".dialogue"):
		set_symbol_lookup_word_as_valid(true)
		return

	for title: String in get_titles():
		if symbol == title:
			set_symbol_lookup_word_as_valid(true)
			return

	# Check if it's a mutation line symbol
	var cursor: Vector2 = get_line_column_at_pos(get_local_mouse_pos())
	var line_text: String = get_line(cursor.y)
	var symbol_info: Dictionary = _resolve_mutation_symbol_at_position(line_text, cursor.x)
	if not symbol_info.is_empty() and symbol_info.get("symbol") == symbol:
		var script: Script = symbol_info.get("script")
		var member_name: String = symbol_info.get("member_name")
		if member_name == "class_name":
			set_symbol_lookup_word_as_valid(true)
			return
		else:
			var line_number: int = _find_definition_in_script(script, member_name)
			if line_number > 0:
				set_symbol_lookup_word_as_valid(true)
				return

	set_symbol_lookup_word_as_valid(false)


func _on_code_edit_symbol_lookup(symbol: String, line: int, column: int) -> void:
	if symbol.begins_with("res://") and symbol.ends_with(".dialogue"):
		external_file_requested.emit(symbol, "")
		return

	# Check if it's a title
	for title: String in get_titles():
		if symbol == title:
			go_to_title(symbol)
			return

	# Check if it's a mutation line symbol
	var line_text: String = get_line(line)
	var symbol_info: Dictionary = _resolve_mutation_symbol_at_position(line_text, column)
	if not symbol_info.is_empty() and symbol_info.get("symbol") == symbol:
		var script: Script = symbol_info.get("script")
		var member_name: String = symbol_info.get("member_name")
		if member_name == "class_name":
			EditorInterface.edit_script(script, 1, 0, true)
			EditorInterface.set_main_screen_editor.call_deferred("Script")
		else:
			var line_number: int = _find_definition_in_script(script, member_name)
			if line_number > 0:
				# Open the script in the editor
				EditorInterface.edit_script(script, line_number, 0, true)
				EditorInterface.set_main_screen_editor.call_deferred("Script")
				return


func _on_code_edit_text_changed() -> void:
	request_code_completion(true)
	_update_code_hint()


func _on_code_edit_text_set() -> void:
	queue_redraw()


func _on_code_edit_caret_changed() -> void:
	check_active_title()
	last_selected_text = get_selected_text()
	_update_code_hint()


func _on_code_edit_gutter_clicked(line: int, gutter: int) -> void:
	var line_errors = errors.filter(func(error): return error.line_number == line)
	if line_errors.size() > 0:
		error_clicked.emit(line)


#endregion
