extends Node
## Tripo model checks + screenshot poses (added by main.gd when one of these flags is given):
##   --tripotest   loads hero_swordman + every static mon_* model, spawns them, prints TRIPOTEST PASS/FAIL, quits
##   --tripopose=<job>:<weapon id>:<clip>:<0..1>:<yaw deg>   with --shot=...: the hero becomes <job>
##       holding <weapon id>, frozen at that point of <clip> (idle, slash, run, cast, sit, death, cheer...)
##   --tripofov=<deg>   narrower camera for close-ups

const ModelLoader = preload("res://scripts/model_loader.gd")
const MonsterScript = preload("res://scripts/monster.gd")

const HERO_CLIPS := ["idle", "walk", "slash", "hurt", "run", "cast", "sit", "death", "cheer"]
const STATIC_MONS := {"forest_wolf": "forest_wolf", "desert_fox_wolf": "desert_fox_wolf", "desert_pup": "desert_fox_wolf",
	"dune_condor": "dune_condor", "sand_scorpion": "sand_scorpion",
	"drowsy_ghoul": "drowsy_ghoul", "bone_guard": "bone_guard", "bone_archer": "bone_archer",
	"talisman_hopper": "munny", "hopping_jiang": "bonbon", "pale_maiden": "pale_maiden", "grand_whisper": "grand_whisper"}

var main: Node
var fails := 0
var _pose: PackedStringArray = []
var _frame := 0
var _aw_frame := 0
var _awoken := false


func _ready() -> void:
	main = get_parent()
	main.test_mode = true
	var p: String = main._arg_value("--tripopose")
	if p != "":
		_pose = p.split(":")
	if "--tripotest" in main.args:
		_run.call_deferred()


func _check(ok: bool, what: String) -> void:
	print(("  ok   " if ok else "  FAIL ") + what)
	if not ok:
		fails += 1


func _frames(n: int) -> void:
	for i in n:
		await get_tree().process_frame


func _run() -> void:
	await _frames(5)
	print("TRIPOTEST start")
	# rigged Swordman: every clip merged
	var hero: Node3D = ModelLoader.build("hero_swordman", 1.75)
	_check(hero != null, "hero_swordman loads")
	if hero:
		var ap: AnimationPlayer = hero.get_meta("anim")
		var missing := []
		for c in HERO_CLIPS:
			if not ap.has_animation(c):
				missing.append(c)
		_check(missing.is_empty(), "hero_swordman has all 9 clips (missing %s)" % str(missing))
		_check(hero.has_meta("hand_r"), "hero_swordman has an R_Hand attachment")
		hero.free()
	# Defend&Counter knight (hero_dc_knight): 9 clips, both hands, steel toon_metal look, round shield on L_Hand
	var dk: Node3D = ModelLoader.build("hero_dc_knight", 1.75)
	_check(dk != null, "hero_dc_knight loads")
	if dk:
		var ap: AnimationPlayer = dk.get_meta("anim")
		var missing := []
		for c in HERO_CLIPS:
			if not ap.has_animation(c):
				missing.append(c)
		_check(missing.is_empty(), "hero_dc_knight has all 9 clips (missing %s)" % str(missing))
		_check(dk.has_meta("hand_r") and dk.has_meta("hand_l") and dk.has_meta("shield"), "hero_dc_knight: R_Hand + L_Hand shield")
		var hl = dk.get_meta("hand_l", null)
		_check(hl is BoneAttachment3D and (hl as BoneAttachment3D).get_parent().get_bone_name(hl.bone_idx) == "L_Hand",
			"hero_dc_knight shield rides the L_Hand bone")
		var mi: MeshInstance3D = dk.find_children("*", "MeshInstance3D", true, false)[0]
		var mat = mi.get_surface_override_material(0)
		_check(mat is ShaderMaterial and mat.shader == ModelLoader.TOON_METAL and float(mat.get_shader_parameter("metal_silver")) == 1.0,
			"hero_dc_knight uses the steel toon_metal look")
		await _anim_pilot(dk, ap)
		dk.free()
	# Art v3: the tank's awakened form (class heroes join this list once built: own Tripo models, untinted)
	for id in ["hero_magic_control", "hero_hunt", "hero_musician", "hero_shadow_assassin", "hero_dc_knight_awaken"]:
		var h: Node3D = ModelLoader.build(id, 1.75)
		_check(h != null, "%s loads" % id)
		if h == null:
			continue
		var hap: AnimationPlayer = h.get_meta("anim")
		var miss := []
		for c in HERO_CLIPS:
			if not hap.has_animation(c):
				miss.append(c)
		_check(miss.is_empty() and h.has_meta("hand_r"), "%s: 9 clips + R_Hand (missing %s)" % [id, str(miss)])
		if not id.ends_with("_awaken"):
			var job: String = str(id).substr(5)
			_check(ModelLoader.resolve("hero_" + job) == id and ModelLoader.tint_of("hero_" + job) == Color.WHITE,
				"%s class wears %s (untinted)" % [job, id])
		h.free()
	if GameData.jobs.has("defend_counter"):
		_check(ModelLoader.resolve("hero_defend_counter") == "hero_dc_knight" and ModelLoader.tint_of("hero_defend_counter") == Color.WHITE,
			"Defend&Counter class wears hero_dc_knight (untinted)")
	# static monster meshes
	for id in ["forest_wolf", "desert_fox_wolf", "dune_condor", "sand_scorpion",
			"drowsy_ghoul", "bone_guard", "bone_archer", "munny", "bonbon", "pale_maiden", "grand_whisper"]:
		var m: Node3D = ModelLoader.build_static(id, 1.3)
		_check(m != null, "mon_%s loads" % id)
		if m == null:
			continue
		add_child(m)
		var meshes := m.find_children("*", "MeshInstance3D", true, false)
		var box := AABB()
		for mi in meshes:
			box = box.merge((mi as MeshInstance3D).global_transform * (mi as MeshInstance3D).get_aabb()) if box.size != Vector3.ZERO else (mi as MeshInstance3D).global_transform * (mi as MeshInstance3D).get_aabb()
		_check(not meshes.is_empty() and absf(box.size.y - 1.3) < 0.02 and absf(box.position.y) < 0.02,
			"mon_%s: %d mesh, height %.2f m, feet at %.2f" % [id, meshes.size(), box.size.y, box.position.y])
		m.free()
	_check(ModelLoader.build_static("__missing__", 1.0) == null and not ModelLoader.has_static("__missing__"), "missing static model -> null")
	var fake = MonsterScript.new()
	fake.data = {"model": "tripo:__missing__", "fallback_model": "wolf"}
	_check(fake.model_kind() == "wolf", "tripo:<missing> falls back to fallback_model")
	fake.free()
	# spawned monsters: static Tripo mesh, height by size x scale, hit flash + freeze tint
	var at: Vector3 = main.player.global_position + Vector3(4, 0, 4)
	for mid in STATIC_MONS:
		# server monster + its view (N1: the view only reacts to the server's events)
		var ent = main._spawn(mid, at, 0.0, false)
		await _frames(2)
		var mon = main.view(ent.id)
		var want: float = float(MonsterScript.TRIPO_H[str(ent.data.get("size", "medium"))]) * ent.scl
		_check(mon.model_kind() == "tripo:" + STATIC_MONS[mid] and mon._static and mon.anim == null,
			"%s uses tripo:%s (static)" % [mid, STATIC_MONS[mid]])
		_check(absf(mon._model_h() - want) < 0.01, "%s height %.2f m" % [mid, mon._model_h()])
		var mi: MeshInstance3D = mon._meshes[0]
		var c0: Color = mon._get_color(mi)
		ent.take_damage(1, null)
		var c1: Color = mon._get_color(mi)
		_check(c1.g < c0.g * 0.5 and c1.r > 0.9, "%s hit flash turns red (%s)" % [mid, str(c1)])
		await get_tree().create_timer(0.2).timeout
		_check(mon._get_color(mi).is_equal_approx(c0), "%s flash restores" % mid)
		ent.add_status("frozen", 0.3)
		mon._restore_colors()
		var ci: Color = mon._get_color(mi)
		_check(ci.b > ci.r, "%s frozen -> icy tint" % mid)
		ent.map.remove_mob(ent)
	# weapon in the rigged hand: grip near the hand, blade pointing forward in idle
	var pl = main.player
	var ps = PlayerState
	ps.new_character("Tripo")
	ps.gain_exp(0, 100000)
	ps.change_job("swordman")
	ps.gain_exp(15000, 5000000)
	ps.add_item("iron_blade")
	ps.equip("iron_blade")
	await _frames(3)
	pl.anim.play("idle")
	pl.anim.seek(0.0, true)
	await _frames(2)
	_check(pl.weapon_mesh != null and pl.weapon_parent != pl.arm_r, "swordman holds the sword in the rigged hand")
	if pl.weapon_mesh:
		var hand: Node3D = pl.model.get_meta("hand_r")
		var wx: Transform3D = pl.model.global_transform.affine_inverse() * pl.weapon_mesh.global_transform
		var hx: Vector3 = pl.model.global_transform.affine_inverse() * hand.global_position
		var blade: Vector3 = wx.basis.z.normalized()
		var grip: Vector3 = wx * Vector3(0, 0, -0.42)
		_check(grip.distance_to(hx) < 0.2, "sword grip %.2f m from the wrist" % grip.distance_to(hx))
		_check(blade.z > 0.5, "idle: blade points forward %s" % str(blade))
	print("TRIPOTEST ", "PASS" if fails == 0 else "FAIL (%d)" % fails)
	get_tree().quit(0 if fails == 0 else 1)


## Animation pilot (docs/ANIMATION_PILOT.md): extra clips (baked .res + Tripo GLB), the 3-hit combo, measured strike
## clips, skill clip sync, squash motion, transformation sequence.
func _anim_pilot(dk: Node3D, ap: AnimationPlayer) -> void:
	const HitFeel = preload("res://scripts/hit_feel.gd")
	const SkillFx = preload("res://scripts/skill_fx.gd")
	var look: Dictionary = ModelLoader.HERO_LOOK["hero_dc_knight"]
	var missing := []
	for c in look["clips"]:
		if not ap.has_animation(c):
			missing.append(c)
	_check(missing.is_empty(), "hero_dc_knight has the pilot clips (missing %s)" % str(missing))
	_check((dk.get_meta("combo", []) as Array) == ["slash", "atk2", "atk3"], "hero_dc_knight 3-hit combo %s" % str(dk.get_meta("combo", [])))
	_check(ResourceLoader.exists("res://assets/models/hero_dc_knight/anim_slash.res") and absf(ap.get_animation("slash").length - 1.5) < 0.05,
		"hero_dc_knight slash is the baked Mixamo clip (%.2f s)" % ap.get_animation("slash").length)
	add_child(dk)
	var hand: Node3D = dk.get_meta("hand_r")
	var mount := Node3D.new()
	hand.add_child(mount)
	for c in ["slash", "atk2", "atk3", "bash", "thrust", "whirl", "leap", "throw"]:
		var tr := HitFeel.slash_track("__pilot__", dk, ap, hand, mount, c)
		var f := float(tr.get("impact", -1.0))
		_check(f >= HitFeel.IMPACT_MIN and f <= HitFeel.IMPACT_MAX, "strike clip %s: blade connects at %d%%" % [c, int(f * 100)])
	remove_child(dk)
	var t := SkillFx.sync_time(0.5, 0.0, 0.3, 0.9)
	_check(is_equal_approx(t, 0.6), "sync: strike at 50%% lands 0.3 s in -> 0.6 s clip (%.2f)" % t)
	var sq := SkillFx.motion_xf("leap_slam", 0.61)
	_check(sq.basis.get_scale().y < 0.8 and sq.basis.get_scale().x > 1.1, "leap_slam squashes on the slam %s" % str(sq.basis.get_scale()))
	var sfx = main.get("skillfx")
	if sfx:
		var swapped := [false]
		var len: float = sfx.transform_seq(main.player, func(): swapped[0] = true)
		_check(is_equal_approx(len, SkillFx.TRANSFORM_LEN), "transform_seq runs (%.2f s)" % len)
		await get_tree().create_timer(SkillFx.TRANSFORM_FLASH + 0.2).timeout
		_check(swapped[0], "transform_seq swaps the model at the flash")
		# golden aura for every class (the test hero has no awakened model: aura only)
		var pl = main.player
		pl.awaken(3.0)
		await _frames(3)
		_check(pl._aura != null and is_instance_valid(pl._aura) and pl._aura.get_child_count() >= 3, "awaken: golden aura on (%s)" % str(pl._aura))
		var au = pl._aura
		pl.awaken(0.0)
		_check(pl._aura == null and is_instance_valid(au) and au._ending, "awaken off: aura fades out")
		# Skyfall Charge "leap" event: arc + dive, the rig node goes back to rest after landing
		var p0: Vector3 = pl.global_position
		sfx.on_leap({"id": pl.id, "from": p0 + Vector3(6, 0, 0), "to": p0, "time": 0.45})
		await get_tree().create_timer(0.2).timeout
		var mid_h: float = (pl.model.get_child(0) as Node3D).position.y if pl.model and pl.model.get_child_count() > 0 else 0.0
		await get_tree().create_timer(0.45).timeout
		_check(mid_h > 0.8 and not sfx._leaps.has(pl.id), "leap: body %.2f m up mid-arc, landed" % mid_h)
		var ev_ok: bool = sfx.has_method("on_awaken") and sfx.has_method("awaken_full") and sfx.has_method("on_leap")
		_check(ev_ok, "event 'awaken' handler + awaken_full entry point exist")


## --tripostrip=<leap|awaken>:<png path prefix>: plays the Skyfall Charge leap (from 7 m behind) or the awakening
## on the fresh hero and saves 8 frames <prefix>_<n>.png ~0.09 s apart (leap) / 0.2 s apart (awaken), then quits.
var _strip_f := 0
var _strip_saved := 0
var _strip_t := 0.0

## --tripolineup=<id,id,...>:<png>: the monsters side by side in front of the camera (idle), one screenshot, quit.
var _line_f := 0

func _lineup_step() -> bool:
	var spec: String = main._arg_value("--tripolineup")
	if spec == "":
		return false
	_line_f += 1
	if PlayerState.ch and "class_pick" in PlayerState.ch:
		PlayerState.ch.class_pick = false
	if main.get("hud") and main.hud.has_method("_close_all") and _line_f < 80:
		main.hud._close_all()
	var ids := spec.get_slice(":", 0).split(",")
	var png := spec.substr(spec.get_slice(":", 0).length() + 1)
	var pl = main.player
	if _line_f == 40:
		var at: Vector3 = pl.global_position
		for m in main.mobs():
			if m.global_position.distance_to(at) < 14.0:
				m.map.remove_mob(m)
		var yaw: float = deg_to_rad(main.rig.yaw)
		var right := Vector3(cos(yaw), 0, -sin(yaw))
		var fwd := Vector3(-sin(yaw), 0, -cos(yaw))
		for i in ids.size():
			var p: Vector3 = at + fwd * 3.0 + right * (float(i) - (ids.size() - 1) * 0.5) * 2.3
			var mon = main._spawn(ids[i], p, 0.0, false)
			if mon:
				mon.data = mon.data.duplicate(true)
				mon.data["speed"] = 0.0
				mon.data["aggressive"] = false
				mon.roam = 0.0
				mon.think_t = 1e6
		pl.visible = false
		main.rig.dist = 9.0
		main.rig.pitch = -12.0
		main.rig.snap()
	if _line_f == 46:
		for mv in main.get_tree().get_nodes_in_group("monsters"):
			if mv.model:
				mv.model.rotation.y = deg_to_rad(main.rig.yaw)
	if _line_f == 90:
		get_viewport().get_texture().get_image().save_png(png)
		print("LINEUP saved ", png)
		get_tree().quit(0)
	return true


func _strip_step() -> void:
	if _lineup_step():
		return
	var spec: String = main._arg_value("--tripostrip")
	if spec == "":
		return
	var kind := spec.get_slice(":", 0)
	var prefix := spec.substr(kind.length() + 1)
	_strip_f += 1
	if PlayerState.ch and "class_pick" in PlayerState.ch:
		PlayerState.ch.class_pick = false
	if _strip_f == 50:
		# a clear stage: no monsters around the hero
		for m in main.mobs():
			if m.global_position.distance_to(main.player.global_position) < 16.0:
				m.map.remove_mob(m)
	if _strip_f == 55 and main.get("rig"):
		# close side view of the hero, away from props (the leap comes in from its back)
		var mp = main.player
		main.rig.dist = 7.5 if kind == "leap" else 5.0
		main.rig.pitch = -12.0
		main.rig.yaw = rad_to_deg(mp.model.rotation.y) + (100.0 if kind == "leap" else 200.0)
		main.rig.snap()
	if _strip_f < 60:
		if main.get("hud") and main.hud.has_method("_close_all"):
			main.hud._close_all()
		return
	var pl = main.player
	var sfx = main.get("skillfx")
	if _strip_f == 60 and sfx:
		if kind == "leap":
			var fwd := Vector3(sin(pl.model.rotation.y), 0, cos(pl.model.rotation.y))
			sfx.on_leap({"id": pl.id, "from": pl.global_position - fwd * 7.0, "to": pl.global_position, "time": 0.45})
		else:
			sfx.awaken_full(pl, 15.0)
		_strip_t = 0.0
		return
	_strip_t += get_process_delta_time()
	var step := 0.075 if kind == "leap" else 0.2
	if _strip_t >= step * float(_strip_saved) and _strip_saved < 8:
		var img := get_viewport().get_texture().get_image()
		img.save_png("%s_%d.png" % [prefix, _strip_saved])
		_strip_saved += 1
		if _strip_saved == 8:
			print("STRIP saved %s_0..7.png" % prefix)
			get_tree().quit(0)


func _process(_delta: float) -> void:
	# --tripoawaken (screenshots): the fresh hero (Defend&Counter) awakens: model swap + golden aura
	if "--tripoawaken" in main.args and not _awoken:
		_aw_frame += 1
		# no "choose your class" window over the screenshot
		if PlayerState.ch and "class_pick" in PlayerState.ch:
			PlayerState.ch.class_pick = false
		if _aw_frame >= 20 and main.player and main.player.has_method("awaken"):
			_awoken = true
			main.player.awaken(99.0)
	if "--tripoawaken" in main.args and _awoken and _aw_frame < 90:
		_aw_frame += 1
		if main.get("hud") and main.hud.has_method("_close_all"):
			main.hud._close_all()
	_strip_step()
	if _pose.size() < 3:
		return
	_frame += 1
	var pl = main.player
	# no window (class pick / job slots) over the screenshot
	if _frame > 3 and _frame < 60 and main.get("hud") and main.hud.has_method("_close_all"):
		main.hud._close_all()
	if _frame == 3:
		var ps = PlayerState
		ps.new_character("Boss")
		ps.gain_exp(0, 100000)
		if _pose[0] != "novice":
			# a second job (knight...): through its first job at Job Lv 50, points spent, fee paid
			var parent := str(GameData.jobs.get(_pose[0], {}).get("parent", ""))
			if parent != "":
				ps.change_job(parent)
				ps.gain_exp(0, 50000000)
				ps.ch.skill_points = 0
				ps.ch.zeny += int(GameData.jobs[_pose[0]].get("change_fee", 0))
			if not ps.change_job(_pose[0]) and bool(GameData.jobs.get(_pose[0], {}).get("v2", false)):
				# v2 classes are picked through Job Slots: set it directly for the screenshot
				ps.ch.job = _pose[0]
				ps.ch.class_pick = false
				ps.changed.emit()
		ps.gain_exp(15000, 0)
		if _pose[1] != "":
			ps.add_item(_pose[1])
			ps.equip(_pose[1])
		var fov: String = main._arg_value("--tripofov")
		if fov != "" and main.rig:
			main.rig.cam.fov = float(fov)
	if _frame < 5 or pl.anim == null or not pl.anim.has_animation(_pose[2]):
		return
	# hold the pose every frame (level-up cheer / idle logic would take over otherwise)
	pl._anim_lock = 99.0
	pl.swing = 0.0
	var ap: AnimationPlayer = pl.anim
	var clip_len := ap.get_animation(_pose[2]).length
	ap.play(_pose[2])
	ap.seek(clip_len * (float(_pose[3]) if _pose.size() > 3 else 0.0), true)
	ap.pause()
	if _pose.size() > 4:
		pl.model.rotation.y = deg_to_rad(float(_pose[4]))
