extends Node
## Runtime debug overlays (autoload "DebugDraw").
##
## Two mechanisms, both gated by DP "Debug" bool params so they cost nothing when off:
##   • Immediate-mode 3D lines — call DebugDraw.line()/ray() each frame; flushed once
##     per frame into a single ImmediateMesh. Used for raycasts and the bone overlay.
##   • Shape overlays — a MeshInstance3D attached to each CollisionShape3D, reconciled
##     incrementally so static geometry is never re-instanced and freshly spawned
##     bodies get picked up within one interval.
##
## Toggle from the console:
##   show_collisions · show_hitboxes · show_bones · show_raycasts   (true / false)
##   show_states          — floating AI-state tags + velocity vectors over each matador
##   show_animation_name  — floating current-clip tag over every AnimationPlayer host
##   show_stats           — 2D corner readout: fps / frame ms / draw calls / matador tally
##   show_grid            — 1 m reference grid on the ground plane around the origin
##   show_axes            — RGB world-axis gizmo at the origin
##   show_wireframe       — render the whole viewport in wireframe

const REBUILD_INTERVAL := 0.5
const STATS_INTERVAL   := 0.2   # stats text is re-composed 5×/sec, not every frame

const COLOR_BODY := Color(0.30, 1.0, 0.45)   # collision wireframes (green)
const COLOR_AREA := Color(0.0, 0.9, 1.0)     # hit-area volumes (cyan, translucent)
const COLOR_BONE := Color(1.0, 0.35, 0.9)    # skeleton bones (magenta)
const COLOR_VEL  := Color(1.0, 0.85, 0.2)    # matador velocity vectors (amber)
const COLOR_ANIM := Color(0.55, 0.85, 1.0)   # animation-name tags (sky blue)
const COLOR_GRID := Color(0.45, 0.45, 0.45, 0.5)
const AREA_ALPHA := 0.25

const GRID_HALF := 20      # grid extends ±GRID_HALF cells from the origin
const GRID_STEP := 1.0     # metres between grid lines
const AXIS_LEN  := 3.0     # length of each world-axis gizmo arm

var _line_mesh: ImmediateMesh
var _lines:  PackedVector3Array = PackedVector3Array()
var _colors: PackedColorArray   = PackedColorArray()

var _overlays:    Dictionary            = {}    # CollisionShape3D instance id -> MeshInstance3D
var _skeletons:   Array[Skeleton3D]     = []
var _anim_players: Array[AnimationPlayer] = []
var _reconcile_t: float                 = 0.0
var _prev_flags:  String                = ""

var _state_labels: Dictionary = {}           # matador instance id -> Label3D
var _anim_labels:  Dictionary = {}           # AnimationPlayer instance id -> Label3D

var _stats_layer: CanvasLayer = null
var _stats_label: Label       = null
var _stats_t:     float       = 0.0


func _ready() -> void:
	process_mode     = Node.PROCESS_MODE_ALWAYS   # keep drawing while the console pauses the game
	process_priority = 1000                       # flush after everyone has queued their lines
	# One-time: wireframe debug-draw needs the wireframe index buffers generated.
	RenderingServer.set_debug_generate_wireframes(true)
	_line_mesh = ImmediateMesh.new()
	var inst := MeshInstance3D.new()
	inst.mesh              = _line_mesh
	inst.material_override = _line_material()
	inst.cast_shadow       = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
	add_child(inst)


# ── Public immediate-mode API ──────────────────────────────────────────────────

func line(from: Vector3, to: Vector3, color: Color = Color.YELLOW) -> void:
	_lines.append(from)
	_lines.append(to)
	_colors.append(color)
	_colors.append(color)


func ray(from: Vector3, to: Vector3, color: Color = Color.YELLOW) -> void:
	line(from, to, color)


# ── Frame flush ────────────────────────────────────────────────────────────────

func _process(delta: float) -> void:
	_reconcile(delta)
	if DP.b("show_bones"):
		_draw_bones()
	if DP.b("show_states"):
		_update_state_labels()
	elif not _state_labels.is_empty():
		_clear_state_labels()
	if DP.b("show_animation_name"):
		_update_anim_labels()
	elif not _anim_labels.is_empty():
		_clear_anim_labels()
	if DP.b("show_grid"):
		_draw_grid()
	if DP.b("show_axes"):
		_draw_axes()
	_update_wireframe()
	_update_stats(delta)
	_flush_lines()


func _flush_lines() -> void:
	_line_mesh.clear_surfaces()
	if not _lines.is_empty():
		_line_mesh.surface_begin(Mesh.PRIMITIVE_LINES)
		for i in _lines.size():
			_line_mesh.surface_set_color(_colors[i])
			_line_mesh.surface_add_vertex(_lines[i])
		_line_mesh.surface_end()
	_lines.clear()
	_colors.clear()


# ── Bone overlay ───────────────────────────────────────────────────────────────

func _draw_bones() -> void:
	for sk in _skeletons:
		if not is_instance_valid(sk):
			continue
		var gx := sk.global_transform
		for b in sk.get_bone_count():
			var parent := sk.get_bone_parent(b)
			if parent < 0:
				continue
			var a := gx * sk.get_bone_global_pose(parent).origin
			var c := gx * sk.get_bone_global_pose(b).origin
			line(a, c, COLOR_BONE)


# ── Matador AI-state overlay ───────────────────────────────────────────────────
# A billboarded state tag hovers over each matador, coloured by state, with an
# amber velocity vector — reads the whole arena's AI at a glance while tuning combat.

func _update_state_labels() -> void:
	var seen: Dictionary = {}
	for node: Node in get_tree().get_nodes_in_group(&"matador"):
		var m := node as Node3D
		if m == null:
			continue
		var id := m.get_instance_id()
		seen[id] = true
		var lbl: Label3D = _state_labels.get(id)
		if lbl == null:
			lbl = _new_state_label()
			_state_labels[id] = lbl
		var state_name := "?"
		if m.has_method(&"ai_state_name"):
			state_name = m.call(&"ai_state_name")
		lbl.text     = state_name
		lbl.modulate = _state_color(state_name)
		lbl.global_position = m.global_position + Vector3.UP * 2.2
		if m is CharacterBody3D:
			var v: Vector3 = (m as CharacterBody3D).velocity
			v.y = 0.0
			if v.length() > 0.5:
				var base := m.global_position + Vector3.UP * 0.15
				line(base, base + v * 0.18, COLOR_VEL)
	for id: int in _state_labels.keys():
		if not seen.has(id):
			var lbl: Label3D = _state_labels[id]
			if is_instance_valid(lbl):
				lbl.queue_free()
			_state_labels.erase(id)


func _new_state_label() -> Label3D:
	var lbl := Label3D.new()
	lbl.billboard        = BaseMaterial3D.BILLBOARD_ENABLED
	lbl.no_depth_test    = true
	lbl.fixed_size       = true
	lbl.pixel_size       = 0.0006
	lbl.font_size        = 64
	lbl.outline_size     = 12
	lbl.outline_modulate = Color(0.0, 0.0, 0.0, 0.9)
	add_child(lbl)
	return lbl


func _clear_state_labels() -> void:
	for id: int in _state_labels:
		var lbl: Label3D = _state_labels[id]
		if is_instance_valid(lbl):
			lbl.queue_free()
	_state_labels.clear()


const _STATE_COLORS := {
	"WANDER":   Color(0.70, 0.70, 0.70),
	"FLEE":     Color(0.40, 0.80, 1.00),
	"BRACE":    Color(1.00, 0.90, 0.30),
	"SIDESTEP": Color(1.00, 0.60, 0.10),
	"ATTACK":   Color(1.00, 0.30, 0.30),
	"ROLL":     Color(0.60, 1.00, 0.60),
	"THROW":    Color(1.00, 0.40, 0.90),
	"RAGDOLL":  Color(0.40, 0.40, 0.40),
}


func _state_color(state_name: String) -> Color:
	return _STATE_COLORS.get(state_name, Color.WHITE)


# ── Animation-name overlay ─────────────────────────────────────────────────────
# A billboarded tag showing the clip each AnimationPlayer is currently playing,
# hovering above its nearest Node3D host — catches wrong / stuck animation states
# at a glance. Players are re-collected on the reconcile tick (see _collect).

func _update_anim_labels() -> void:
	var seen: Dictionary = {}
	for ap in _anim_players:
		if not is_instance_valid(ap):
			continue
		var clip := ap.current_animation
		# Skip finished / dormant sub-players so a host with several AnimationPlayers
		# (e.g. a matador's main + olé + death players) doesn't stack empty tags.
		if clip.is_empty() and not ap.is_playing():
			continue
		var host := _node3d_host(ap)
		if host == null:
			continue
		var id := ap.get_instance_id()
		seen[id] = true
		var lbl: Label3D = _anim_labels.get(id)
		if lbl == null:
			lbl = _new_state_label()
			lbl.modulate = COLOR_ANIM
			_anim_labels[id] = lbl
		lbl.text            = clip if not clip.is_empty() else "—"
		lbl.global_position = host.global_position + Vector3.UP * 2.6
	for id: int in _anim_labels.keys():
		if not seen.has(id):
			var lbl: Label3D = _anim_labels[id]
			if is_instance_valid(lbl):
				lbl.queue_free()
			_anim_labels.erase(id)


func _clear_anim_labels() -> void:
	for id: int in _anim_labels:
		var lbl: Label3D = _anim_labels[id]
		if is_instance_valid(lbl):
			lbl.queue_free()
	_anim_labels.clear()


# Nearest Node3D at or above `node` — the anchor an AnimationPlayer's tag hovers over.
func _node3d_host(node: Node) -> Node3D:
	var n := node
	while n != null:
		if n is Node3D:
			return n as Node3D
		n = n.get_parent()
	return null


# ── World reference overlays ───────────────────────────────────────────────────

func _draw_grid() -> void:
	var extent := GRID_HALF * GRID_STEP
	for i in range(-GRID_HALF, GRID_HALF + 1):
		var o := i * GRID_STEP
		line(Vector3(o, 0.01, -extent), Vector3(o, 0.01, extent), COLOR_GRID)
		line(Vector3(-extent, 0.01, o), Vector3(extent, 0.01, o), COLOR_GRID)


func _draw_axes() -> void:
	line(Vector3.ZERO, Vector3.RIGHT * AXIS_LEN,      Color.RED)     # +X
	line(Vector3.ZERO, Vector3.UP * AXIS_LEN,         Color.GREEN)   # +Y
	line(Vector3.ZERO, Vector3(0.0, 0.0, 1.0) * AXIS_LEN, Color.DODGER_BLUE)  # +Z


func _update_wireframe() -> void:
	var vp := get_viewport()
	var want := Viewport.DEBUG_DRAW_WIREFRAME if DP.b("show_wireframe") else Viewport.DEBUG_DRAW_DISABLED
	if vp.debug_draw != want:
		vp.debug_draw = want


# ── Stats overlay ──────────────────────────────────────────────────────────────
# A 2D corner readout of frame cost + scene load, plus a per-state matador tally so
# a combat slowdown or a stuck AI swarm shows up immediately.

func _update_stats(delta: float) -> void:
	if not DP.b("show_stats"):
		if _stats_layer != null:
			_stats_layer.visible = false
		return
	_ensure_stats_ui()
	_stats_layer.visible = true
	_stats_t -= delta
	if _stats_t > 0.0:
		return
	_stats_t = STATS_INTERVAL
	_stats_label.text = _compose_stats()


func _ensure_stats_ui() -> void:
	if _stats_layer != null:
		return
	var mono := SystemFont.new()
	mono.font_names = PackedStringArray(["JetBrains Mono", "DejaVu Sans Mono", "monospace"])

	_stats_layer = CanvasLayer.new()
	_stats_layer.layer = 64
	add_child(_stats_layer)

	var panel := PanelContainer.new()
	panel.position = Vector2(8.0, 8.0)
	var bg := StyleBoxFlat.new()
	bg.bg_color = Color(0.0, 0.0, 0.0, 0.55)
	bg.set_content_margin_all(6.0)
	panel.add_theme_stylebox_override("panel", bg)
	_stats_layer.add_child(panel)

	_stats_label = Label.new()
	_stats_label.add_theme_font_override("font", mono)
	_stats_label.add_theme_font_size_override("font_size", 13)
	_stats_label.add_theme_color_override("font_color", Color(0.60, 1.0, 0.60))
	panel.add_child(_stats_label)


func _compose_stats() -> String:
	var fps    := int(Performance.get_monitor(Performance.TIME_FPS))
	var proc   := Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
	var phys   := Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
	var draws  := int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME))
	var prims  := int(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME))
	var vmem   := int(Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / 1048576.0)
	var nodes  := int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
	var phys3d := int(Performance.get_monitor(Performance.PHYSICS_3D_ACTIVE_OBJECTS))
	return "fps %d   proc %.1fms  phys %.1fms\ndraws %d  prims %s  vmem %dMB\nnodes %d  phys3d %d\n%s" % [
		fps, proc, phys, draws, _si(prims), vmem, nodes, phys3d, _matador_tally()]


func _matador_tally() -> String:
	var mats := get_tree().get_nodes_in_group(&"matador")
	if mats.is_empty():
		return "matadors 0"
	var counts: Dictionary = {}
	for node: Node in mats:
		var s := "?"
		if node.has_method(&"ai_state_name"):
			s = node.call(&"ai_state_name")
		counts[s] = int(counts.get(s, 0)) + 1
	var parts: PackedStringArray = PackedStringArray()
	for s: String in counts:
		parts.append("%s:%d" % [s.substr(0, 2), counts[s]])
	return "matadors %d   %s" % [mats.size(), " ".join(parts)]


func _si(n: int) -> String:
	if n >= 1000000:
		return "%.1fM" % (n / 1000000.0)
	if n >= 1000:
		return "%.0fk" % (n / 1000.0)
	return str(n)


# ── Collision / hit-area overlays (incremental) ────────────────────────────────

func _reconcile(delta: float) -> void:
	var col  := DP.b("show_collisions")
	var hit  := DP.b("show_hitboxes")
	var bone := DP.b("show_bones")
	var anim := DP.b("show_animation_name")
	if not (col or hit or bone or anim):
		if not _overlays.is_empty():
			_clear_overlays()
		_skeletons.clear()
		_anim_players.clear()
		_prev_flags = ""
		return

	var flags := "%d%d%d%d" % [int(col), int(hit), int(bone), int(anim)]
	_reconcile_t -= delta
	if flags != _prev_flags:      # a toggle just changed — refresh immediately
		_prev_flags  = flags
		_reconcile_t = 0.0
	if _reconcile_t > 0.0:
		return
	_reconcile_t = REBUILD_INTERVAL

	_skeletons.clear()
	_anim_players.clear()
	var wanted: Dictionary = {}
	var scene := get_tree().current_scene
	if scene != null:
		_collect(scene, col, hit, bone, anim, wanted)

	# Drop overlays whose shape is gone or no longer wanted. The overlay is a child of
	# the collision shape, so it's freed with it — hold it as Variant, since assigning a
	# freed instance to any *typed* var (even Object) throws before is_instance_valid()
	# can filter it out.
	for id: int in _overlays.keys():
		var mi: Variant = _overlays[id]
		if not wanted.has(id) or not is_instance_valid(mi):
			if is_instance_valid(mi):
				(mi as Node).queue_free()
			_overlays.erase(id)
	# Add overlays for newly seen shapes.
	for id: int in wanted:
		if not _overlays.has(id):
			var entry: Array = wanted[id]
			_overlays[id] = _attach(entry[0] as CollisionShape3D, entry[1] as bool)


func _collect(node: Node, col: bool, hit: bool, bone: bool, anim: bool, wanted: Dictionary) -> void:
	# The audience ("public") is ambient decoration, not a debug target — skip the whole
	# crowd subtree so its clapping AnimationPlayers/skeletons don't flood the overlays.
	if node.is_in_group(&"public"):
		return
	if bone and node is Skeleton3D and (node as Skeleton3D).is_visible_in_tree():
		_skeletons.append(node as Skeleton3D)
	if anim and node is AnimationPlayer:
		_anim_players.append(node as AnimationPlayer)
	if node is CollisionShape3D:
		var cs := node as CollisionShape3D
		if cs.shape != null and not cs.disabled and _drawable(cs.shape):
			var parent := cs.get_parent()
			var is_area := parent is Area3D
			# Ragdoll bone colliders (PhysicalBone3D) are excluded — use show_bones for
			# the skeleton; otherwise every corpse floods the view with capsules.
			var is_body := parent is PhysicsBody3D and not (parent is PhysicalBone3D)
			if (is_area and hit) or (is_body and col):
				wanted[cs.get_instance_id()] = [cs, is_area]
	for child in node.get_children():
		_collect(child, col, hit, bone, anim, wanted)


# Skip only the unbounded shapes whose debug mesh is meaningless/huge; concave arena
# geometry is fine — Godot caches each shape's debug mesh, so it's built at most once.
func _drawable(shape: Shape3D) -> bool:
	return not (shape is WorldBoundaryShape3D or shape is HeightMapShape3D)


func _attach(cs: CollisionShape3D, is_area: bool) -> MeshInstance3D:
	var mi := MeshInstance3D.new()
	if is_area:
		var solid := _solid_mesh(cs.shape)
		mi.mesh              = solid if solid != null else cs.shape.get_debug_mesh()
		mi.material_override = _translucent_material(COLOR_AREA)
	else:
		mi.mesh              = cs.shape.get_debug_mesh()
		mi.material_override = _wire_material(COLOR_BODY)
	mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
	cs.add_child(mi)
	return mi


func _clear_overlays() -> void:
	for id: int in _overlays:
		var mi: Variant = _overlays[id]
		if is_instance_valid(mi):
			(mi as Node).queue_free()
	_overlays.clear()


# ── Meshes & materials ─────────────────────────────────────────────────────────

# A filled mesh matching a primitive shape, for translucent volume overlays. Returns
# null for shapes without a clean solid equivalent (caller falls back to wireframe).
func _solid_mesh(shape: Shape3D) -> Mesh:
	if shape is BoxShape3D:
		var m := BoxMesh.new()
		m.size = (shape as BoxShape3D).size
		return m
	if shape is SphereShape3D:
		var m := SphereMesh.new()
		m.radius = (shape as SphereShape3D).radius
		m.height = m.radius * 2.0
		return m
	if shape is CapsuleShape3D:
		var m := CapsuleMesh.new()
		m.radius = (shape as CapsuleShape3D).radius
		m.height = (shape as CapsuleShape3D).height
		return m
	if shape is CylinderShape3D:
		var m := CylinderMesh.new()
		m.top_radius    = (shape as CylinderShape3D).radius
		m.bottom_radius = (shape as CylinderShape3D).radius
		m.height        = (shape as CylinderShape3D).height
		return m
	return null


func _line_material() -> StandardMaterial3D:
	var mat := StandardMaterial3D.new()
	mat.shading_mode               = BaseMaterial3D.SHADING_MODE_UNSHADED
	mat.vertex_color_use_as_albedo = true
	mat.no_depth_test              = true
	return mat


func _wire_material(color: Color) -> StandardMaterial3D:
	var mat := StandardMaterial3D.new()
	mat.shading_mode  = BaseMaterial3D.SHADING_MODE_UNSHADED
	mat.albedo_color  = color
	mat.no_depth_test = true
	return mat


func _translucent_material(color: Color) -> StandardMaterial3D:
	var mat := StandardMaterial3D.new()
	mat.shading_mode    = BaseMaterial3D.SHADING_MODE_UNSHADED
	mat.transparency    = BaseMaterial3D.TRANSPARENCY_ALPHA
	mat.albedo_color    = Color(color.r, color.g, color.b, AREA_ALPHA)
	mat.cull_mode       = BaseMaterial3D.CULL_DISABLED
	mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
	return mat
