extends Node
## Quake-style drop-down developer console (autoload "Console"). Toggle with the
## backtick/tilde key (`/~) — matched by physical position so it works on any
## keyboard layout and with or without Shift.
## Query a param: type its name. Set it: "name value".
## Tab = complete top match · ↑↓ = browse suggestions / history · Esc = close.

const CONSOLE_HEIGHT_RATIO := 0.25
const SLIDE_DURATION        := 0.18
const MAX_SUGGESTIONS       := 10
const LOG_MAX_LINES         := 300

# Built-in commands (name → one-line help). Surfaced in autocomplete alongside
# param keys and printed by `help`. None collide with param key names.
const _COMMANDS := {
	"save":   "persist values to disk",
	"reset":  "reset all, or reset <name>",
	"diff":   "list params changed from default",
	"toggle": "flip a bool param",
	"list":   "print params (optional section)",
	"clear":  "clear the log",
	"help":   "show this help",
}

var _canvas:         CanvasLayer
var _panel:          PanelContainer
var _log:            RichTextLabel
var _suggest_panel:  PanelContainer
var _suggest_box:    VBoxContainer
var _suggest_labels: Array[Label] = []
var _input_field:          LineEdit

var _tween:        Tween
var _is_open:      bool              = false
var _prev_mouse:   Input.MouseMode   = Input.MOUSE_MODE_CAPTURED
var _suggestions:  Array[String]     = []
var _selected_idx: int               = -1
var _history:      Array[String]     = []
var _history_idx:  int               = -1   # -1 = editing the live line
var _pending_line: String            = ""   # live line stashed while browsing history


func _ready() -> void:
	# Keep the console (and its slide tween/input) alive while the tree is paused.
	process_mode = Node.PROCESS_MODE_ALWAYS
	_build_ui()


func _build_ui() -> void:
	var mono := SystemFont.new()
	mono.font_names = PackedStringArray([
		"JetBrains Mono", "DejaVu Sans Mono", "Consolas", "Menlo", "monospace",
	])

	_canvas       = CanvasLayer.new()
	_canvas.layer = 128
	add_child(_canvas)

	_panel = PanelContainer.new()
	_panel.anchor_left   = 0.0
	_panel.anchor_right  = 1.0
	_panel.anchor_top    = 0.0
	_panel.anchor_bottom = 0.0
	_panel.offset_bottom = 500.0
	_panel.mouse_filter  = Control.MOUSE_FILTER_STOP
	_panel.visible       = false

	var bg := StyleBoxFlat.new()
	bg.bg_color            = Color(0.04, 0.06, 0.04, 0.94)
	bg.border_width_bottom = 2
	bg.border_color        = Color(0.2, 0.8, 0.2, 1.0)
	_panel.add_theme_stylebox_override("panel", bg)
	_canvas.add_child(_panel)

	var root_vbox := VBoxContainer.new()
	root_vbox.size_flags_horizontal = Control.SIZE_FILL
	root_vbox.size_flags_vertical   = Control.SIZE_FILL
	_panel.add_child(root_vbox)

	# ── Log ───────────────────────────────────────────────────────────────────
	_log = RichTextLabel.new()
	_log.bbcode_enabled        = true
	_log.scroll_following      = true
	_log.size_flags_vertical   = Control.SIZE_EXPAND_FILL
	_log.size_flags_horizontal = Control.SIZE_FILL
	_log.add_theme_font_size_override("normal_font_size", 13)
	_log.add_theme_constant_override("line_separation", 1)
	# Monospace so the rpad-aligned suggestion/list columns actually line up.
	_log.add_theme_font_override("normal_font", mono)
	_log.add_theme_font_override("bold_font", mono)
	_log.add_theme_font_override("mono_font", mono)
	root_vbox.add_child(_log)

	# ── Suggestion panel ──────────────────────────────────────────────────────
	_suggest_panel = PanelContainer.new()
	_suggest_panel.visible      = false
	_suggest_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE

	var sg_bg := StyleBoxFlat.new()
	sg_bg.bg_color         = Color(0.06, 0.10, 0.06, 0.97)
	sg_bg.border_width_top = 1
	sg_bg.border_color     = Color(0.15, 0.55, 0.15, 0.8)
	_suggest_panel.add_theme_stylebox_override("panel", sg_bg)
	root_vbox.add_child(_suggest_panel)

	_suggest_box = VBoxContainer.new()
	_suggest_box.add_theme_constant_override("separation", 0)
	_suggest_panel.add_child(_suggest_box)

	for _i in MAX_SUGGESTIONS:
		var lbl := Label.new()
		lbl.visible = false
		lbl.add_theme_font_size_override("font_size", 13)
		lbl.add_theme_font_override("font", mono)
		_suggest_box.add_child(lbl)
		_suggest_labels.append(lbl)

	root_vbox.add_child(HSeparator.new())

	# ── Input row ─────────────────────────────────────────────────────────────
	var in_margin := MarginContainer.new()
	in_margin.add_theme_constant_override("margin_left",   10)
	in_margin.add_theme_constant_override("margin_right",  10)
	in_margin.add_theme_constant_override("margin_top",     4)
	in_margin.add_theme_constant_override("margin_bottom",  6)
	root_vbox.add_child(in_margin)

	var in_row := HBoxContainer.new()
	in_row.add_theme_constant_override("separation", 6)
	in_margin.add_child(in_row)

	var prompt_lbl := Label.new()
	prompt_lbl.text    = ">"
	prompt_lbl.modulate = Color(0.35, 1.0, 0.35)
	prompt_lbl.add_theme_font_override("font", mono)
	in_row.add_child(prompt_lbl)

	_input_field = LineEdit.new()
	_input_field.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	_input_field.placeholder_text      = "param  ·  param value  ·  toggle <bool>  ·  save reset diff list clear help"
	_input_field.clear_button_enabled  = true
	_input_field.add_theme_font_size_override("font_size", 14)
	_input_field.add_theme_font_override("font", mono)
	# Drop the default LineEdit chrome (the white box border) — the panel is the frame.
	var flat := StyleBoxFlat.new()
	flat.bg_color = Color(0, 0, 0, 0)
	flat.set_border_width_all(0)
	flat.set_content_margin_all(2)
	_input_field.add_theme_stylebox_override("normal", flat)
	_input_field.add_theme_stylebox_override("focus", flat)
	in_row.add_child(_input_field)

	_input_field.text_changed.connect(_on_text_changed)
	_input_field.text_submitted.connect(_on_submitted)


# ── Input handling ────────────────────────────────────────────────────────────

func _input(event: InputEvent) -> void:
	if not (event is InputEventKey and (event as InputEventKey).pressed
			and not (event as InputEventKey).echo):
		return
	var ke := event as InputEventKey

	# Backtick/tilde — toggle regardless of focus. Match by physical position so a
	# non-US layout or a Shift-produced ~ both open the console.
	if ke.physical_keycode == KEY_QUOTELEFT:
		_toggle()
		get_viewport().set_input_as_handled()
		return

	if not _is_open:
		return

	# Ctrl+C clears the input line, Ctrl+L clears the log — familiar shell reflexes.
	if ke.ctrl_pressed:
		match ke.keycode:
			KEY_C:
				_clear_input()
				get_viewport().set_input_as_handled()
				return
			KEY_L:
				_log.clear()
				get_viewport().set_input_as_handled()
				return

	match ke.keycode:
		KEY_TAB:
			_accept_completion()
			get_viewport().set_input_as_handled()
		KEY_UP:
			_arrow(-1)
			get_viewport().set_input_as_handled()
		KEY_DOWN:
			_arrow(1)
			get_viewport().set_input_as_handled()
		KEY_ESCAPE:
			_toggle()
			get_viewport().set_input_as_handled()


# Focus can be stolen a frame or more after we re-grab it (submit, arrows, the tree
# unpausing), so a one-shot deferred grab loses the race. Enforce it every frame
# while the console is open — cheap, and it never lets the command line go dark.
func _process(_delta: float) -> void:
	if _is_open and not _input_field.has_focus():
		# Diagnostic: name whatever currently holds focus before we snatch it back, so
		# a persistent thief (a HUD button, a menu control) shows up in the log instead
		# of us guessing. Remove once the culprit is identified.
		var owner: Control = get_viewport().gui_get_focus_owner()
		var who: String = String(owner.name) if owner != null else "<nothing>"
		_log_warn("focus stolen by %s — reclaiming" % who)
		_input_field.grab_focus()


func _toggle() -> void:
	_is_open = not _is_open
	get_tree().paused = _is_open
	var h := get_viewport().get_visible_rect().size.y * CONSOLE_HEIGHT_RATIO
	_panel.offset_bottom = h

	if _tween:
		_tween.kill()
	_tween = create_tween().set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_QUART)

	if _is_open:
		_prev_mouse       = Input.mouse_mode
		Input.mouse_mode  = Input.MOUSE_MODE_VISIBLE
		_panel.position.y = -h
		_panel.visible    = true
		_tween.tween_property(_panel, "position:y", 0.0, SLIDE_DURATION)
		_tween.tween_callback(_input_field.grab_focus)
	else:
		_tween.tween_property(_panel, "position:y", -h, SLIDE_DURATION)
		_tween.tween_callback(func() -> void: _panel.visible = false)
		Input.mouse_mode = _prev_mouse
		_clear_suggestions()


# ── Fuzzy autocomplete ────────────────────────────────────────────────────────

func _on_text_changed(text: String) -> void:
	_selected_idx = -1
	_history_idx  = -1
	var query := text.get_slice(" ", 0).strip_edges()
	if query.is_empty():
		_clear_suggestions()
		return
	_refresh_suggestions(query)


func _refresh_suggestions(query: String) -> void:
	var ql     := query.to_lower()
	var scored : Array = []
	# Commands rank above params on an equal score so a leading "re" surfaces the
	# `reset` verb, not just params that happen to contain those letters.
	for name: String in _COMMANDS:
		var cs := _fuzzy_score(ql, name)
		if cs > 0:
			scored.append([cs + 1, name])
	for key: String in DP.get_all():
		var s := _fuzzy_score(ql, key.to_lower())
		if s > 0:
			scored.append([s, key])
	scored.sort_custom(func(a: Array, b: Array) -> bool: return a[0] > b[0])

	_suggestions.clear()
	for i in mini(scored.size(), MAX_SUGGESTIONS):
		_suggestions.append(scored[i][1])

	for i in MAX_SUGGESTIONS:
		if i < _suggestions.size():
			_suggest_labels[i].text     = _suggestion_text(_suggestions[i])
			_suggest_labels[i].modulate = _suggestion_color(i)
			_suggest_labels[i].visible  = true
		else:
			_suggest_labels[i].visible = false
	_suggest_panel.visible = not _suggestions.is_empty()


func _is_command(name: String) -> bool:
	return _COMMANDS.has(name)


func _suggestion_text(name: String) -> String:
	if _is_command(name):
		return "  %s  [cmd]  %s" % [name.rpad(10), _COMMANDS[name]]
	var meta: Dictionary = DP.get_all()[name]
	return "  %s  %s  [%s]" % [name.rpad(28), _fmt_val(name, meta).rpad(8), meta["section"]]


func _suggestion_color(idx: int) -> Color:
	if idx == _selected_idx:
		return Color(1.0, 1.0, 0.4)          # selected — yellow
	if idx == 0:
		return Color(0.85, 1.0, 0.70)        # top match — bright green
	return Color(0.60, 0.78, 0.50)           # rest — muted green


func _fuzzy_score(query: String, target: String) -> int:
	var qi    := 0
	var score := 0
	var last  := -1
	for ti in target.length():
		if qi >= query.length():
			break
		if target[ti] == query[qi]:
			score += 10 + (5 if ti == last + 1 else 0)
			last   = ti
			qi    += 1
	if qi < query.length():
		return 0                              # not all query chars matched
	if target.begins_with(query):
		score += 50
	if target == query:
		score += 100
	return score


func _fmt_val(_key: String, meta: Dictionary) -> String:
	if meta["type"] == TYPE_FLOAT:
		return _fmt_num(meta["value"] as float)
	if meta["type"] == TYPE_BOOL:
		var bval: bool = meta["value"]
		return "true" if bval else "false"
	return str(meta["value"])


# Render a float as a plain decimal — trailing zeros trimmed but always at least one
# place, so it always reads as a float (4.0, 0.4, 0.005) never an int (4).
func _fmt_num(v: float) -> String:
	var s := ("%.4f" % v).rstrip("0")
	if s.ends_with("."):
		s += "0"
	return s


func _clear_suggestions() -> void:
	_suggestions.clear()
	_selected_idx = -1
	for lbl in _suggest_labels:
		lbl.visible = false
	_suggest_panel.visible = false


# ↑/↓ move through the suggestion list while it's open; once it's gone they recall
# command history instead. Same keys, context decides — no separate binding to learn.
func _arrow(dir: int) -> void:
	if _suggest_panel.visible and not _suggestions.is_empty():
		_move_selection(dir)
	else:
		_history_step(-dir)


# Highlight the next/prev suggestion and drop "<name> <value>" into the field with the
# value pre-selected, so you just type your number over it.
func _move_selection(dir: int) -> void:
	if _selected_idx < 0:
		_selected_idx = 0 if dir > 0 else _suggestions.size() - 1
	else:
		_selected_idx = wrapi(_selected_idx + dir, 0, _suggestions.size())
	_recolor_suggestions()
	_fill_selected()


# Tab commits the highlighted suggestion (top match if none yet) and closes the list.
func _accept_completion() -> void:
	if _suggestions.is_empty():
		return
	_selected_idx = maxi(_selected_idx, 0)
	_fill_selected()
	_clear_suggestions()


func _fill_selected() -> void:
	var key := _suggestions[_selected_idx]
	# A command drops in as "verb " ready for its argument (or a bare Enter); a
	# param drops in as "key value" with the value pre-selected to type over.
	# Suggestions stay open either way so ↑↓ keeps cycling.
	if _is_command(key):
		_input_field.text         = key + " "
		_input_field.caret_column = _input_field.text.length()
		return
	var meta: Dictionary = DP.get_all()[key]
	_input_field.text         = "%s %s" % [key, _fmt_val(key, meta)]
	_input_field.caret_column = _input_field.text.length()
	_input_field.select(key.length() + 1)


func _recolor_suggestions() -> void:
	for i in MAX_SUGGESTIONS:
		if _suggest_labels[i].visible:
			_suggest_labels[i].modulate = _suggestion_color(i)


# ↑ recalls older commands, ↓ newer ones; index -1 is the live (in-progress) line,
# which is stashed on the first ↑ and restored when you step back down to it.
func _history_step(dir: int) -> void:
	if _history.is_empty():
		return
	var target := _history_idx + dir
	if target < -1 or target >= _history.size():
		return
	if _history_idx == -1 and dir > 0:
		_pending_line = _input_field.text
	_history_idx = target
	_set_input(_pending_line if _history_idx == -1 else _history[_history_idx])


# Wipe the live line (Ctrl+C) without submitting — also drops any suggestion popup
# and resets history browsing so the next ↑ starts fresh.
func _clear_input() -> void:
	_input_field.clear()
	_history_idx  = -1
	_pending_line = ""
	_clear_suggestions()


func _set_input(text: String) -> void:
	_input_field.text         = text
	_input_field.caret_column = text.length()
	_clear_suggestions()


# ── Command execution ─────────────────────────────────────────────────────────

func _on_submitted(raw: String) -> void:
	raw = raw.strip_edges()
	if raw.is_empty():
		return
	if _history.is_empty() or _history[0] != raw:
		_history.push_front(raw)
	_history_idx  = -1
	_pending_line = ""
	_input_field.clear()
	_clear_suggestions()
	_execute(raw)


func _execute(cmd: String) -> void:
	_log_raw("[color=#3a5a3a]> %s[/color]" % _esc(cmd))
	var parts := cmd.split(" ", false)
	if parts.is_empty():
		return

	if _run_command(parts):
		return

	# Not a command — treat as a param key, with fuzzy fall-back for partial names.
	var key := _resolve_key(parts[0])
	if key.is_empty():
		return
	var meta: Dictionary = DP.get_all()[key]
	if parts.size() == 1:
		_read_param(key, meta)
	else:
		_set_param(key, meta, parts[1])


# Run a built-in command; returns false if parts[0] isn't one (caller falls through
# to param handling). Kept separate from _execute to keep each dispatch shallow.
func _run_command(parts: PackedStringArray) -> bool:
	match parts[0].to_lower():
		"save":
			DP.save()
			_log_ok("Saved.")
		"reset":
			_cmd_reset(parts)
		"toggle":
			_cmd_toggle(parts)
		"diff":
			_cmd_diff()
		"list":
			_cmd_list(parts)
		"clear":
			_log.clear()
		"help":
			_cmd_help()
		_:
			return false
	return true


# Resolve a user-typed token to a param key: exact match, else unique fuzzy match.
# Logs an ambiguity / unknown warning (and an arrow on a fuzzy hit) and returns
# "" when it can't narrow to a single key.
func _resolve_key(raw: String) -> String:
	if DP.get_all().has(raw):
		return raw
	var matches := _fuzzy_find_all(raw)
	if matches.size() == 1:
		_log_raw("[color=#8888ff]→ %s[/color]" % matches[0])
		return matches[0]
	if matches.size() > 1:
		_log_warn("Ambiguous '%s': %s" % [_esc(raw), ", ".join(matches.slice(0, 6))])
	else:
		_log_warn("Unknown: %s" % _esc(raw))
	return ""


func _cmd_reset(parts: PackedStringArray) -> void:
	if parts.size() == 1:
		DP.reset_all()
		_log_ok("All params reset to defaults.")
		return
	var key := _resolve_key(parts[1])
	if key.is_empty():
		return
	DP.reset(key)
	_log_ok("[b]%s[/b] reset to [color=#ffe080]%s[/color]" % [key, _fmt_val(key, DP.get_all()[key])])


func _cmd_toggle(parts: PackedStringArray) -> void:
	if parts.size() < 2:
		_log_warn("Usage: toggle <bool param>")
		return
	var key := _resolve_key(parts[1])
	if key.is_empty():
		return
	var meta: Dictionary = DP.get_all()[key]
	if meta["type"] != TYPE_BOOL:
		_log_warn("%s is not a bool — use '%s <value>'" % [key, key])
		return
	var v := not (meta["value"] as bool)
	DP.set_value(key, v)
	_log_ok("[b]%s[/b] ← [color=#ffe080]%s[/color]" % [key, "true" if v else "false"])


# List only params that currently differ from their registered default, grouped by
# section — the "what have I actually changed?" view before a save.
func _cmd_diff() -> void:
	var all := DP.get_all()
	var sections: Dictionary = {}
	for key: String in all:
		if not DP.is_modified(key):
			continue
		var sec: String = all[key]["section"]
		if not sections.has(sec):
			sections[sec] = []
		sections[sec].append(key)
	if sections.is_empty():
		_log_ok("All params at defaults.")
		return
	for sec: String in sections:
		_log_raw("[color=#6699ff][b]%s[/b][/color]" % sec)
		for key: String in sections[sec]:
			var meta: Dictionary = all[key]
			_log_raw("  [color=#777777]%s[/color] = [color=#ffe080]%s[/color]  [color=#484848](def %s)[/color]" % [
				key, _fmt_val(key, meta), _fmt_default(meta)
			])


func _cmd_help() -> void:
	_log_raw("[color=#888888]  <name>[/color]                — read current value + range")
	_log_raw("[color=#888888]  <name> <value>[/color]        — set it (number, or true/false)")
	for name: String in _COMMANDS:
		_log_raw("[color=#888888]  %s[/color] — %s" % [name.rpad(20), _COMMANDS[name]])
	_log_raw("  [color=#555555]↑↓ pick suggestion · Tab complete · ↑↓ history when empty · ` close[/color]")


func _fmt_default(meta: Dictionary) -> String:
	if meta["type"] == TYPE_FLOAT:
		return _fmt_num(meta["default"] as float)
	return "true" if (meta["default"] as bool) else "false"


func _read_param(key: String, meta: Dictionary) -> void:
	if meta["type"] == TYPE_FLOAT:
		_log_raw("[b]%s[/b] = [color=#ffe080]%s[/color]   [color=#484848](%s … %s)[/color]" % [
			key, _fmt_val(key, meta),
			_fmt_num(meta["min"] as float), _fmt_num(meta["max"] as float),
		])
	else:
		_log_raw("[b]%s[/b] = [color=#ffe080]%s[/color]   [color=#484848](true / false)[/color]" % [
			key, _fmt_val(key, meta),
		])


func _set_param(key: String, meta: Dictionary, raw: String) -> void:
	if meta["type"] == TYPE_FLOAT:
		var lo := meta["min"] as float
		var hi := meta["max"] as float
		if not raw.is_valid_float() or not is_finite(raw.to_float()):
			_log_warn("%s wants a number %s … %s" % [key, _fmt_num(lo), _fmt_num(hi)])
			return
		var v := snappedf(clampf(raw.to_float(), lo, hi), meta["step"] as float)
		DP.set_value(key, v)
		_log_ok("[b]%s[/b] ← [color=#ffe080]%s[/color]" % [key, _fmt_num(v)])
	elif meta["type"] == TYPE_BOOL:
		match raw.to_lower():
			"1", "true", "yes", "on":
				DP.set_value(key, true)
				_log_ok("[b]%s[/b] ← [color=#ffe080]true[/color]" % key)
			"0", "false", "no", "off":
				DP.set_value(key, false)
				_log_ok("[b]%s[/b] ← [color=#ffe080]false[/color]" % key)
			_:
				_log_warn("%s wants true or false" % key)
	else:
		_log_warn("Cannot set param of type %d" % meta["type"])


func _cmd_list(parts: PackedStringArray) -> void:
	var filter := parts[1].to_lower() if parts.size() > 1 else ""
	var all    := DP.get_all()
	var sections: Dictionary = {}
	for key: String in all:
		var sec: String = all[key]["section"]
		if filter.length() > 0:
			if not sec.to_lower().begins_with(filter) and not key.to_lower().contains(filter):
				continue
		if not sections.has(sec):
			sections[sec] = []
		sections[sec].append(key)
	for sec: String in sections:
		_log_raw("[color=#6699ff][b]%s[/b][/color]" % sec)
		for key: String in sections[sec]:
			_log_raw("  [color=#777777]%s[/color] = [color=#ffe080]%s[/color]" % [
				key, _fmt_val(key, all[key])
			])


func _fuzzy_find_all(query: String) -> Array[String]:
	var result: Array[String] = []
	var ql := query.to_lower()
	for key: String in DP.get_all():
		if _fuzzy_score(ql, key.to_lower()) > 0:
			result.append(key)
	return result


# ── Logging ───────────────────────────────────────────────────────────────────

## Neutralise BBCode in user-supplied text before it reaches the RichTextLabel,
## so malformed or hostile tags (e.g. [img]) render literally instead of running.
func _esc(text: String) -> String:
	return text.replace("[", "[lb]")


func _log_raw(text: String) -> void:
	if _log.get_line_count() > LOG_MAX_LINES:
		_log.clear()
	_log.append_text(text + "\n")


func _log_ok(text: String) -> void:
	_log_raw("[color=#66ee88]%s[/color]" % text)


func _log_warn(text: String) -> void:
	_log_raw("[color=#ff7744]%s[/color]" % text)
