extends SceneTree
## Gameplay regression test — runs the full scene, captures a motion frame strip,
## and asserts that physics/IK stays within sane bounds.
##
## Run (headless, assertions only — screenshots will be blank):
##   godot --headless --script tests/gameplay_test.gd
##
## Run (with display for real screenshots):
##   godot --script tests/gameplay_test.gd
##
## Outputs: tests/output/gameplay/*.png + tests/output/gameplay/report.txt
## Exit code 0 = all assertions passed, 1 = any failure.

const OUTPUT_DIR := "res://tests/output/gameplay"

# Tail bone names mirrored from bull_legs.gd — update both if rig changes.
const TAIL_BONES: Array[StringName] = [
	&"tail_1", &"tail_2", &"tail_3", &"tail_4",
	&"tail_5", &"tail_6", &"tail_tip_1", &"tail_tip_2",
]

var _passed: int = 0
var _failed: int = 0
var _report: PackedStringArray = []
var _matador_scene: PackedScene = null


# Loaded lazily (not preloaded): as a --script SceneTree entry, a top-level
# preload of a matador scene compiles matador.gd before the DP autoload binds.
func _mat_scene() -> PackedScene:
	if _matador_scene == null:
		_matador_scene = load("res://Matador.tscn")
	return _matador_scene


func _init() -> void:
	_run.call_deferred()


func _run() -> void:
	DirAccess.make_dir_recursive_absolute(OUTPUT_DIR)

	var scene_res := load("res://scene.tscn")
	if not scene_res:
		push_error("gameplay_test: failed to load scene.tscn")
		quit(1)
		return

	var scene: Node = scene_res.instantiate()
	root.add_child(scene)
	current_scene = scene   # match runtime: game code (sword throw, overlays) uses current_scene

	# Let physics, AI, and IK warm up.
	await create_timer(0.5).timeout

	# The matador is lethal on contact now, so a wandering one could end the match
	# during the non-combat checks below. Park the bull on a temp pad far out in the
	# void, well out of any matador's reach (the roll tests use this spot too).
	_isolate_bull(scene)

	_check_bull_animation()
	await _check_overlays()

	# The overlay check needed live matadors; now remove them. The aggressive matador
	# pursues the bull, and the ragdoll check spawns its own fresh one, so none should
	# be roaming during the capture / tail / roll phases.
	for m: Node in get_nodes_in_group(&"matador"):
		m.queue_free()
	await physics_frame

	# Capture 5 frames ~0.4 s apart (covers roughly one wander step and walk cycle).
	for i in range(5):
		_capture_frame("motion_%02d.png" % i)
		await create_timer(0.4).timeout

	# After ~2.5 s the tail Verlet chain and IK should be stable.
	_check_tail_integrity()

	# Roll first, in open space, before any matador dies (a kill ends the match).
	await _check_roll_ability(scene)
	await create_timer(0.1).timeout   # let the roll pop-test's throwaway matador free

	# Ragdoll check on a FRESH matador spawned at the origin (the isolated bull is far
	# away, so this one can't reach it). Not wired to the spawner, so its death won't
	# trip the win screen. Ragdoll it immediately and check the bones don't explode.
	var mat_scene := _mat_scene()
	if mat_scene != null:
		var mat: Node3D = mat_scene.instantiate()
		scene.add_child(mat)
		mat.global_position = Vector3(0.0, 1.0, 0.0)
		await create_timer(0.3).timeout
		var start_pos: Vector3 = mat.global_position
		mat._enter_ragdoll(Vector3(0.0, 0.0, 1.0), 10.0)
		_capture_frame("ragdoll_trigger.png")
		await create_timer(0.8).timeout
		_capture_frame("ragdoll_result.png")
		_check_ragdoll_sanity(mat, start_pos)
	else:
		_note("ragdoll check skipped — Matador.tscn missing")

	# Last — a bull hit raises the lose screen and pauses the tree.
	await _check_game_over_wiring(scene)

	_finish()


# Drop a small static floor at (-45, -45) and stand the bull on it — 60+ m from the
# arena, so no matador can close the gap within the test window.
func _isolate_bull(scene: Node) -> void:
	var pad := StaticBody3D.new()
	var cs := CollisionShape3D.new()
	var box := BoxShape3D.new()
	box.size = Vector3(20, 1, 20)
	cs.shape = box
	pad.add_child(cs)
	scene.add_child(pad)
	pad.global_position = Vector3(-45, -0.5, -45)   # top surface at y = 0
	var players := get_nodes_in_group(&"player")
	if not players.is_empty():
		(players[0] as Node3D).global_position = Vector3(-45, 1, -45)


# ── Roll ability (Rammus Powerball) ────────────────────────────────────────────
# Two behaviours: the speed ramps up the longer the ball rolls (base → max), capped
# at roll_max_speed with no wall-boost overshoot; and ramming a matador pops the ball
# (the roll ends). Rolled in empty space so it doesn't touch the real match matadors.

func _check_roll_ability(scene: Node) -> void:
	print("\n-- check_roll_ability --")
	var dp: Node = root.get_node_or_null("/root/DP")
	var players := get_nodes_in_group(&"player")
	if dp == null or players.is_empty():
		_note("roll check: DP / player missing")
		return
	var player: Node = players[0]
	var ap: AnimationPlayer = _find_anim_player(player)
	_assert_true(player.ability_cd.size() == 4, "bull has 4 ability slots (roll added)")

	var restore_dur: float = dp.f("roll_duration")
	var restore_ramp: float = dp.f("roll_rampup_time")
	dp.set_value("roll_duration", 1.2)
	dp.set_value("roll_rampup_time", 0.7)
	player.global_position = Vector3(-45, 1, -45)   # empty void, away from the arena
	player.velocity = Vector3.ZERO
	player.cube_guy.rotation.y = 0.0
	player.ability_cd[3] = 0.0
	player._activate_roll()
	_assert_true(player._active_ability == 3, "roll activates in slot 3")
	if ap != null:
		_assert_true(ap.current_animation == &"Armature|ROLL", "roll plays the ROLL clip")

	var early := -1.0
	var peak := 0.0
	for n in 30:
		await create_timer(0.03).timeout
		var spd := Vector2(player.velocity.x, player.velocity.z).length()
		if n == 1:
			early = spd
		peak = maxf(peak, spd)
	_assert_true(peak > early + 5.0, "roll speed ramps up over time (Powerball)")
	_assert_true(peak <= dp.f("roll_max_speed") + 2.0, "roll speed caps at roll_max_speed (no wall boost)")

	dp.set_value("roll_duration", restore_dur)
	dp.set_value("roll_rampup_time", restore_ramp)
	await _check_roll_pop(scene, player)


# Roll into a throwaway matador (not one of the match spawns, so its death doesn't
# end the game) and confirm the ball pops: the roll ability ends on contact.
func _check_roll_pop(scene: Node, player: Node) -> void:
	var mat_scene := _mat_scene()
	if mat_scene == null:
		_note("roll pop: Matador.tscn missing")
		return
	var mat: Node3D = mat_scene.instantiate()
	scene.add_child(mat)
	mat.global_position = Vector3(-45, 1, -38)   # ~7 m ahead of the bull along +Z
	await create_timer(0.2).timeout

	player.global_position = Vector3(-45, 1, -45)
	player.velocity = Vector3.ZERO
	player.cube_guy.rotation.y = 0.0             # face +Z, toward the matador
	player.ability_cd[3] = 0.0
	player._activate_roll()

	var popped := false
	for _n in 45:
		await create_timer(0.03).timeout
		if player._active_ability != 3:
			popped = true
			break
	_assert_true(popped, "roll pops (ends) on ramming a matador")
	if is_instance_valid(mat):
		mat.queue_free()


# ── Bull animation ────────────────────────────────────────────────────────────
# The bull sits idle in this test (no input), so it must be playing its looping
# IDLE clip — guards the idle wiring and the KICK clip rename in player.gd.

func _check_bull_animation() -> void:
	print("\n-- check_bull_animation --")
	var players := get_nodes_in_group(&"player")
	if players.is_empty():
		_note("bull anim check: no player in scene")
		return
	var ap: AnimationPlayer = _find_anim_player(players[0])
	if ap == null:
		_note("bull anim check: no AnimationPlayer under player")
		return
	_assert_true(ap.has_animation(&"Armature|IDLE"), "bull has IDLE clip")
	_assert_true(ap.has_animation(&"Armature|KICK"), "bull has KICK clip (not stale FOOTKICK)")
	_assert_true(ap.current_animation == &"Armature|IDLE", "bull plays IDLE at rest")
	if ap.has_animation(&"Armature|IDLE"):
		_assert_true(ap.get_animation(&"Armature|IDLE").loop_mode == Animation.LOOP_LINEAR,
			"bull IDLE clip loops")


func _find_anim_player(node: Node) -> AnimationPlayer:
	if node is AnimationPlayer:
		return node
	for child in node.get_children():
		var r := _find_anim_player(child)
		if r != null:
			return r
	return null


# ── Win / lose wiring ─────────────────────────────────────────────────────────
# One clean sword hit must end the run in a loss, and the signal chain must be in
# place for a win (spawner.all_defeated → HUD). Runs last: it pauses the tree.

func _check_game_over_wiring(scene: Node) -> void:
	print("\n-- check_game_over_wiring --")
	var hud: Node = _find_hud(scene)
	var players := get_nodes_in_group(&"player")
	if hud == null or players.is_empty():
		_note("game over check: HUD / player missing")
		return
	var player: Node = players[0]
	_assert_true(player.has_signal(&"died"), "player exposes a died signal")
	var spawners := get_nodes_in_group(&"matador_spawn")
	_assert_true(spawners.is_empty() or spawners[0].has_signal(&"all_defeated"),
		"spawner exposes an all_defeated signal (win route)")

	# The bull now soaks bull_max_hp hits. Zero the i-frame window so the test can
	# land consecutive blades, then confirm it survives every hit but the last.
	var dp: Node = root.get_node_or_null("/root/DP")
	if dp != null:
		dp.set_value("bull_hit_iframes", 0.0)
	var max_hp: int = player.call(&"get_max_hp")
	for i: int in max_hp - 1:
		player.take_sword_hit()
		await create_timer(0.05).timeout
		_assert_true(not hud._game_over, "hit %d of %d does not end the run" % [i + 1, max_hp])
	_assert_true(player.call(&"get_hp") == 1, "bull sits at 1 HP before the killing blow")

	player.take_sword_hit()
	await create_timer(0.05).timeout
	_assert_true(hud._game_over, "draining the last HP raises the game-over screen")
	_assert_true(not hud._result_win, "a bull hit is a loss, not a win")


func _find_hud(node: Node) -> Node:
	if node is CanvasLayer and node.has_method(&"_show_game_over"):
		return node
	for child in node.get_children():
		var r := _find_hud(child)
		if r != null:
			return r
	return null


# ── Debug overlays ────────────────────────────────────────────────────────────
# Turn on every DebugDraw overlay against the live scene, confirm it builds, then
# turn them off and confirm it tears down — guards the reconcile bookkeeping.

func _check_overlays() -> void:
	print("\n-- check_overlays --")
	var dp: Node = root.get_node_or_null("/root/DP")
	var draw: Node = root.get_node_or_null("/root/DebugDraw")
	if dp == null or draw == null:
		_note("overlay check: DP / DebugDraw autoload missing")
		return
	var flags := [
		"show_states", "show_stats", "show_collisions", "show_hitboxes", "show_bones",
		"show_animation_name", "show_wireframe", "show_grid", "show_axes",
	]
	for flag: String in flags:
		dp.set_value(flag, true)
	await create_timer(0.3).timeout
	_assert_true(draw._state_labels.size() > 0, "overlay builds matador state labels")
	_assert_true(draw._overlays.size() > 0, "overlay builds collision meshes")
	_assert_true(draw._anim_labels.size() > 0, "overlay builds animation-name labels")
	_assert_true(draw._stats_layer != null and draw._stats_layer.visible, "stats overlay visible")
	_assert_true(not draw._stats_label.text.is_empty(), "stats overlay composes text")
	for flag: String in flags:
		dp.set_value(flag, false)
	await create_timer(0.3).timeout
	_assert_true(draw._state_labels.is_empty(), "overlay clears state labels when off")
	_assert_true(draw._overlays.is_empty(), "overlay clears collision meshes when off")
	_assert_true(draw._anim_labels.is_empty(), "overlay clears animation-name labels when off")


# ── Tail integrity ────────────────────────────────────────────────────────────

func _check_tail_integrity() -> void:
	print("\n-- check_tail_integrity --")
	var players := get_nodes_in_group(&"player")
	if players.is_empty():
		_note("tail check: no player in scene")
		return

	# Prefer reading from the live Verlet chain in the legs node.
	var legs: Node = _find_legs_node(players[0])
	if legs:
		_check_tail_verlet(legs)
		return

	# Fallback: sample bone global poses from Skeleton3D.
	var skel: Skeleton3D = _find_skeleton(players[0]) as Skeleton3D
	if not skel:
		_note("tail check: no Skeleton3D found under player")
		return
	_check_tail_skeleton(skel)


func _check_tail_verlet(legs: Node) -> void:
	var chain: Array = legs._tail_world
	if chain.size() < 2:
		_note("tail Verlet: chain has fewer than 2 nodes — skipping")
		return

	for i in range(chain.size()):
		var p: Vector3 = chain[i]
		_assert_true(p.is_finite(), "tail Verlet node %d is finite" % i)

	for i in range(1, chain.size()):
		var dist: float = (chain[i] as Vector3).distance_to(chain[i - 1])
		_assert_true(dist > 0.005,
			"tail Verlet segment %d–%d not collapsed (%.4f m)" % [i - 1, i, dist])

	var span: float = (chain[0] as Vector3).distance_to(chain[-1])
	_assert_true(span > 0.05,
		"tail chain root-to-tip span non-zero (%.3f m)" % span)

	# Verify no two nodes are at exactly the same position (bunching symptom).
	for i in range(1, chain.size()):
		for j in range(i + 1, chain.size()):
			var d: float = (chain[i] as Vector3).distance_to(chain[j])
			_assert_true(d > 0.001,
				"tail nodes %d and %d are distinct (%.4f m apart)" % [i, j, d])

	_note("tail Verlet: %d nodes, span %.3f m" % [chain.size(), span])


func _check_tail_skeleton(skel: Skeleton3D) -> void:
	var positions: Array[Vector3] = []
	for bone_name: StringName in TAIL_BONES:
		var idx: int = skel.find_bone(bone_name)
		if idx == -1:
			continue
		positions.append(skel.to_global(skel.get_bone_global_pose(idx).origin))

	if positions.size() < 2:
		_note("tail skeleton: fewer than 2 bones found")
		return

	for i in range(positions.size()):
		_assert_true(positions[i].is_finite(), "tail bone %d finite" % i)

	for i in range(1, positions.size()):
		var dist: float = positions[i].distance_to(positions[i - 1])
		_assert_true(dist > 0.005,
			"tail bone segment %d–%d not collapsed (%.4f m)" % [i - 1, i, dist])

	_note("tail skeleton: %d bones checked" % positions.size())


# ── Ragdoll sanity ────────────────────────────────────────────────────────────

func _check_ragdoll_sanity(matador: Node3D, start_pos: Vector3) -> void:
	print("\n-- check_ragdoll_sanity --")
	var sim: Node = _find_class_recursive(matador, "PhysicalBoneSimulator3D")
	if not sim:
		_note("ragdoll: no PhysicalBoneSimulator3D — check skipped")
		return

	var checked := 0
	var all_at_origin := true
	for child in sim.get_children():
		if not (child is PhysicalBone3D):
			continue
		var bone := child as PhysicalBone3D
		var bp: Vector3 = bone.global_position
		checked += 1

		if bp.distance_to(Vector3.ZERO) > 0.1:
			all_at_origin = false

		_assert_true(bp.is_finite(),
			"ragdoll '%s' position finite" % bone.bone_name)

		if bp.is_finite():
			var dist: float = bp.distance_to(start_pos)
			# 6 m: impulse + 0.8 s of movement should stay inside this.
			# Regression guard: bodies stuck at world origin fail (spawn is 10-20 m away).
			_assert_true(dist < 6.0,
				"ragdoll '%s' near spawn (%.1f m)" % [bone.bone_name, dist])
			_assert_true(bp.y > -1.0,
				"ragdoll '%s' above floor (y = %.2f)" % [bone.bone_name, bp.y])
			_assert_true(bp.y < 3.0,
				"ragdoll '%s' settled below 3 m (y = %.2f)" % [bone.bone_name, bp.y])

	if checked == 0:
		_note("ragdoll: simulator has no PhysicalBone3D children")
	elif all_at_origin:
		_note("ragdoll: all bones at world origin — physics may not have simulated (headless?)")
	else:
		_note("ragdoll: %d bones checked" % checked)


# ── Frame capture ─────────────────────────────────────────────────────────────

func _capture_frame(filename: String) -> void:
	var img: Image = root.get_viewport().get_texture().get_image()
	if img == null:
		_note("frame capture failed: %s" % filename)
		return
	var path := OUTPUT_DIR + "/" + filename
	img.save_png(path)
	_note("captured %s (%d×%d)" % [filename, img.get_width(), img.get_height()])


# ── Scene helpers ─────────────────────────────────────────────────────────────

func _find_legs_node(player: Node) -> Node:
	for child in player.get_children():
		var s: Script = child.get_script() as Script
		if s and s.resource_path.ends_with("bull_legs.gd"):
			return child
	return null


func _find_skeleton(node: Node) -> Node:
	if node is Skeleton3D:
		return node
	for child in node.get_children():
		var r := _find_skeleton(child)
		if r:
			return r
	return null


func _find_class_recursive(node: Node, class_name_str: String) -> Node:
	if node.get_class() == class_name_str:
		return node
	for child in node.get_children():
		var r := _find_class_recursive(child, class_name_str)
		if r:
			return r
	return null


# ── Assertions & report ───────────────────────────────────────────────────────

func _assert_true(condition: bool, desc: String) -> void:
	if condition:
		_passed += 1
		print("  PASS: %s" % desc)
		_report.append("PASS: " + desc)
	else:
		_failed += 1
		print("  FAIL: %s" % desc)
		_report.append("FAIL: " + desc)


func _note(msg: String) -> void:
	print("  NOTE: %s" % msg)
	_report.append("NOTE: " + msg)


func _finish() -> void:
	var f := FileAccess.open(OUTPUT_DIR + "/report.txt", FileAccess.WRITE)
	if f:
		f.store_string("\n".join(Array(_report)))
		f.close()

	print("")
	print("=".repeat(60))
	print("GAMEPLAY TESTS: %d passed, %d failed" % [_passed, _failed])
	print("Frame strip: %s" % OUTPUT_DIR)
	print("=".repeat(60))
	quit(1 if _failed > 0 else 0)
