class_name M2AnimationPlaybackController
extends RefCounted

## Applies deterministic playback to one animated M2 instance and copies the
## runtime fields of exact-script native animators. The materializer owns
## instance duplication/attachment; its caller owns diagnostic log formatting.


## Copies the five historical runtime fields between matching native animators
## in depth-first order. References are intentionally retained exactly.
func copy_native_animator_data(
	source_root: Node,
	target_root: Node,
	native_animator_script: Script
) -> void:
	var source_animators := native_animators_in_subtree(source_root, native_animator_script)
	var target_animators := native_animators_in_subtree(target_root, native_animator_script)
	for animator_index in range(mini(source_animators.size(), target_animators.size())):
		var source := source_animators[animator_index]
		var target := target_animators[animator_index]
		target.mesh_instance_path = source.mesh_instance_path
		target.bones = source.bones
		target.surfaces = source.surfaces
		target.animation_length = source.animation_length
		target.playback_speed = source.playback_speed


## Prepares native animators, applies deterministic phase, and starts supplied
## AnimationPlayers with the historical loop/name/seek rules. Runtime debug
## state is sampled only when requested and returned as detached Dictionaries.
func start_instance_playback(
	root: Node,
	relative_path: String,
	instance_index: int,
	native_animator_script: Script,
	animation_players: Array[AnimationPlayer],
	collect_native_diagnostics: bool
) -> Array[Dictionary]:
	var phase := phase_for_instance(relative_path, instance_index)
	var native_diagnostics: Array[Dictionary] = []
	for animator in native_animators_in_subtree(root, native_animator_script):
		if animator.has_method("prepare_runtime"):
			animator.prepare_runtime()
		animator.set_phase(phase)
		if collect_native_diagnostics and animator.has_method("runtime_debug_state"):
			var diagnostic_variant = animator.runtime_debug_state()
			if diagnostic_variant is Dictionary:
				native_diagnostics.append((diagnostic_variant as Dictionary).duplicate(true))

	for player in animation_players:
		if player == null:
			continue
		var animation_name := choose_default_animation(player, relative_path)
		if animation_name.is_empty():
			continue
		for available_name in player.get_animation_list():
			var available_animation := player.get_animation(available_name)
			if available_animation != null:
				available_animation.loop_mode = Animation.LOOP_LINEAR
		player.play(animation_name)
		var selected_animation := player.get_animation(animation_name)
		if selected_animation != null and selected_animation.length > 0.0:
			player.seek(selected_animation.length * phase, true)
	return native_diagnostics


## Returns exact-script native animators in depth-first preorder.
func native_animators_in_subtree(
	root: Node,
	native_animator_script: Script
) -> Array[Node]:
	var native_animators: Array[Node] = []
	if root != null and native_animator_script != null:
		_collect_native_animators(root, native_animator_script, native_animators)
	return native_animators


## Returns the existing stable path/index phase in the inclusive-lower,
## exclusive-upper range [0, 1).
func phase_for_instance(relative_path: String, instance_index: int) -> float:
	return float(abs(("%s:%d" % [relative_path, instance_index]).hash()) % 1000) / 1000.0


## Chooses the historical default animation name for ordinary, fish and bird
## paths, then case-insensitive substring and first-name fallbacks.
func choose_default_animation(player: AnimationPlayer, relative_path: String = "") -> String:
	if player == null:
		return ""
	var lower_path := relative_path.to_lower()
	var candidates: Array[String] = ["Stand", "Idle", "Run", "Walk"]
	if lower_path.contains("fish"):
		candidates = ["Run", "Walk", "Swim", "Stand", "Idle", "Death"]
	elif lower_path.contains("eagle") or lower_path.contains("bird") or lower_path.contains("gull"):
		candidates = ["Run", "Walk", "Swim", "Stand", "Idle"]
	for candidate in candidates:
		if player.has_animation(candidate):
			return candidate
	var available_names := player.get_animation_list()
	for candidate in ["run", "walk", "swim", "stand", "idle", "loop"]:
		for available_name in available_names:
			if String(available_name).to_lower().contains(candidate):
				return String(available_name)
	return String(available_names[0]) if not available_names.is_empty() else ""


func _collect_native_animators(
	node: Node,
	native_animator_script: Script,
	result: Array[Node]
) -> void:
	if node.get_script() == native_animator_script:
		result.append(node)
	for child in node.get_children():
		_collect_native_animators(child, native_animator_script, result)
