class_name Model3DFactory
extends RefCounted

# Construit un avatar purement visuel depuis un GLB. Le pivot rendu reste au
# sol et a l'origine de l'entite 2D; ni le modele ni son squelette ne participent
# aux collisions ou aux deplacements.

const LOOPING_STATES := ["idle", "walk", "eat", "work"]
const STATE_FALLBACKS := {
	"walk": "idle", "attack": "idle", "hurt": "idle", "work": "idle",
	"eat": "idle", "angry": "idle", "death": "hurt", "special": "attack",
	"dash": "walk",
}

static func build(config: Dictionary, tall: float) -> Node3D:
	var path := str(config.get("path", ""))
	if path.is_empty() or not ResourceLoader.exists(path):
		return null
	var packed := load(path) as PackedScene
	if packed == null:
		return null
	var imported := packed.instantiate() as Node3D
	if imported == null:
		return null

	var root := Node3D.new()
	var pivot := Node3D.new()
	pivot.name = "ModelPivot"
	root.add_child(pivot)
	var scaled := Node3D.new()
	scaled.name = "ScaledModel"
	pivot.add_child(scaled)
	scaled.add_child(imported)

	var measured := _bounds_of(imported, Transform3D.IDENTITY)
	if not bool(measured.get("found", false)):
		root.free()
		return null
	var bounds: AABB = measured.bounds
	if bounds.size.y <= 0.0001:
		root.free()
		return null
	var fit := maxf(2.0, tall) / bounds.size.y * float(config.get("scale", 1.0))
	scaled.scale = Vector3.ONE * fit
	var centre := bounds.get_center()
	scaled.position = Vector3(-centre.x * fit, -bounds.position.y * fit, -centre.z * fit)
	pivot.position = Vector3(
		float(config.get("offset_x", 0.0)),
		float(config.get("offset_y", 0.0)),
		float(config.get("offset_z", 0.0)))
	pivot.rotation_degrees = Vector3(
		float(config.get("rotation_x", 0.0)),
		float(config.get("rotation_y", 0.0)),
		float(config.get("rotation_z", 0.0)))

	var player := _find_animation_player(imported)
	var runtime_config := config.duplicate(true)
	var in_place_clips: Array[String] = _prepare_in_place_clips(player, runtime_config)
	var animation_tree := _build_animation_tree(root, player, runtime_config)
	root.set_meta("tall", maxf(2.0, tall))
	root.set_meta("model_3d", true)
	root.set_meta("model_path", path)
	root.set_meta("model_fit", fit)
	root.set_meta("model_config", runtime_config)
	root.set_meta("animation_player", player)
	root.set_meta("animation_tree", animation_tree)
	root.set_meta("in_place_clips", in_place_clips)
	root.set_meta("animation_state", "")
	root.set_meta("animation_clip", "")
	root.set_meta("animation_speed", 1.0)
	root.set_meta("animation_requested_state", "")
	root.set_meta("animation_action_state", "")
	root.set_meta("animation_action_clip", "")
	root.set_meta("animation_base_state", "")
	root.set_meta("animation_base_clip", "")
	return root

## `cut_short` leve le verrou `finish_before_transition`: le clip en cours
## est abandonne au lieu d'aller a son terme. Un geste que le jeu a arrete
## net — une lame bloquee par un corps, une attaque interrompue — ne doit pas
## continuer de fendre l'air.
static func show_state(root: Node3D, state: String, locomotion_state: String = "",
		clip_override: String = "", cut_short: bool = false) -> void:
	if not bool(root.get_meta("model_3d", false)):
		return
	var player := root.get_meta("animation_player", null) as AnimationPlayer
	if player == null or not is_instance_valid(player):
		return
	var animation_tree := (root.get_meta("animation_tree") as AnimationTree) if root.has_meta("animation_tree") else null
	if animation_tree != null and is_instance_valid(animation_tree):
		var base_state := locomotion_state
		if base_state.is_empty():
			base_state = state if LOOPING_STATES.has(state) else "idle"
		_show_layered_state(root, player, animation_tree, state, base_state, clip_override, cut_short)
		return
	_show_direct_state(root, player, state, clip_override, cut_short)

static func _show_direct_state(root: Node3D, player: AnimationPlayer, state: String,
		clip_override: String = "", cut_short: bool = false) -> void:
	var config: Dictionary = root.get_meta("model_config", {})
	var selection := _selection_with_override(player, config.get("animations", {}), state, clip_override)
	var clip := str(selection.get("clip", ""))
	var source_state := str(selection.get("state", state))
	var speed := _speed_for(config.get("animation_speeds", {}), source_state)
	var previous_state := str(root.get_meta("animation_state", ""))
	var previous_clip := str(root.get_meta("animation_clip", ""))
	if not cut_short and previous_state != state \
	and _must_finish_current_clip(player, config, previous_state, previous_clip):
		return
	if previous_state == state and previous_clip == clip:
		if not clip.is_empty() and LOOPING_STATES.has(state) and not player.is_playing():
			player.play(StringName(clip), -1.0, speed)
		return
	root.set_meta("animation_state", state)
	root.set_meta("animation_clip", clip)
	root.set_meta("animation_speed", speed)
	if clip.is_empty():
		_reset_pose(player, config.get("animations", {}))
		return
	player.play(StringName(clip), -1.0, speed)

# Un avatar qui declare au moins une couche « haut du corps » est pilote par un
# AnimationTree propre a son instance. La locomotion continue sous le OneShot;
# les autres avatars conservent le chemin AnimationPlayer historique.
static func _build_animation_tree(root: Node3D, player: AnimationPlayer, config: Dictionary) -> AnimationTree:
	if player == null or not is_instance_valid(player) or not _uses_upper_body_layer(config):
		return null
	var skeleton := _find_skeleton(root)
	var root_bone := str(config.get("upper_body_root_bone", ""))
	if skeleton == null or root_bone.is_empty():
		root.set_meta("animation_layer_error", "missing_upper_body_root")
		return null
	var root_bone_index := skeleton.find_bone(root_bone)
	if root_bone_index < 0:
		root.set_meta("animation_layer_error", "unknown_upper_body_root")
		return null
	var upper_bones := {}
	for bone_index: int in range(skeleton.get_bone_count()):
		if _bone_descends_from(skeleton, bone_index, root_bone_index):
			upper_bones[str(skeleton.get_bone_name(bone_index))] = true

	var locomotion := AnimationNodeAnimation.new()
	var locomotion_speed := AnimationNodeTimeScale.new()
	var locomotion_seek := AnimationNodeTimeSeek.new()
	var action := AnimationNodeAnimation.new()
	var action_speed := AnimationNodeTimeScale.new()
	var one_shot := AnimationNodeOneShot.new()
	one_shot.filter_enabled = true
	one_shot.mix_mode = AnimationNodeOneShot.MIX_MODE_BLEND
	var filter_paths: Array[String] = []
	for raw_clip: StringName in player.get_animation_list():
		var animation := player.get_animation(raw_clip)
		if animation == null:
			continue
		for track_index: int in range(animation.get_track_count()):
			var track_path := animation.track_get_path(track_index)
			if not upper_bones.has(_track_target_name(track_path)):
				continue
			one_shot.set_filter_path(track_path, true)
			var path_text := str(track_path)
			if not filter_paths.has(path_text):
				filter_paths.append(path_text)
	if filter_paths.is_empty():
		root.set_meta("animation_layer_error", "no_upper_body_tracks")
		return null

	var blend_tree := AnimationNodeBlendTree.new()
	blend_tree.add_node(&"Locomotion", locomotion, Vector2(-720.0, -120.0))
	blend_tree.add_node(&"LocomotionSpeed", locomotion_speed, Vector2(-520.0, -120.0))
	blend_tree.add_node(&"LocomotionSeek", locomotion_seek, Vector2(-320.0, -120.0))
	blend_tree.add_node(&"Action", action, Vector2(-720.0, 140.0))
	blend_tree.add_node(&"ActionSpeed", action_speed, Vector2(-480.0, 140.0))
	blend_tree.add_node(&"ActionOneShot", one_shot, Vector2(-80.0, 0.0))
	blend_tree.connect_node(&"LocomotionSpeed", 0, &"Locomotion")
	blend_tree.connect_node(&"LocomotionSeek", 0, &"LocomotionSpeed")
	blend_tree.connect_node(&"ActionSpeed", 0, &"Action")
	blend_tree.connect_node(&"ActionOneShot", 0, &"LocomotionSeek")
	blend_tree.connect_node(&"ActionOneShot", 1, &"ActionSpeed")
	blend_tree.connect_node(&"output", 0, &"ActionOneShot")

	var tree := AnimationTree.new()
	tree.name = "AnimationTree"
	root.add_child(tree)
	tree.anim_player = tree.get_path_to(player)
	tree.tree_root = blend_tree
	player.stop()
	tree.active = true
	root.set_meta("animation_locomotion_node", locomotion)
	root.set_meta("animation_action_node", action)
	root.set_meta("animation_one_shot_node", one_shot)
	root.set_meta("upper_body_filter_paths", filter_paths)
	return tree

static func _show_layered_state(root: Node3D, player: AnimationPlayer, tree: AnimationTree,
		state: String, locomotion_state: String, clip_override: String = "",
		cut_short: bool = false) -> void:
	var config: Dictionary = root.get_meta("model_config", {})
	var selection := _selection_with_override(player, config.get("animations", {}), state, clip_override)
	var clip := str(selection.get("clip", ""))
	var source_state := str(selection.get("state", state))
	var speed := _speed_for(config.get("animation_speeds", {}), source_state)
	var layer := _layer_for(config.get("animation_layers", {}), source_state)
	var previous_requested_state := str(root.get_meta("animation_requested_state", ""))
	var action_request := int(tree.get("parameters/ActionOneShot/request"))
	var action_active := bool(tree.get("parameters/ActionOneShot/active")) \
		or action_request == AnimationNodeOneShot.ONE_SHOT_REQUEST_FIRE

	if layer == "upper_body" and not clip.is_empty():
		_set_tree_base(root, player, tree, config, locomotion_state)
		# Le pont appelle cette methode chaque frame. La transition d'etat, et non
		# la fin du clip, est le declencheur: un OneShot court ne doit jamais se
		# relancer en boucle pendant une longue attaque de gameplay.
		if previous_requested_state != state:
			var action_node := root.get_meta("animation_action_node", null) as AnimationNodeAnimation
			var one_shot := root.get_meta("animation_one_shot_node", null) as AnimationNodeOneShot
			if action_node != null and one_shot != null:
				action_node.animation = StringName(clip)
				one_shot.fadein_time = _blend_for(config.get("animation_blend_in", {}), source_state, 0.08)
				one_shot.fadeout_time = _blend_for(config.get("animation_blend_out", {}), source_state, 0.12)
				tree.set("parameters/ActionSpeed/scale", speed)
				tree.set("parameters/ActionOneShot/request", AnimationNodeOneShot.ONE_SHOT_REQUEST_FIRE)
				root.set_meta("animation_action_state", source_state)
				root.set_meta("animation_action_clip", clip)
	else:
		if previous_requested_state != state and action_active:
			var previous_action_state := str(root.get_meta("animation_action_state", ""))
			var finish_map: Variant = config.get("finish_before_transition", {})
			var must_finish := finish_map is Dictionary \
				and bool((finish_map as Dictionary).get(previous_action_state, false))
			if cut_short or not must_finish:
				tree.set("parameters/ActionOneShot/request", AnimationNodeOneShot.ONE_SHOT_REQUEST_FADE_OUT)
		_set_tree_base(root, player, tree, config, state)
		if not action_active:
			root.set_meta("animation_action_state", "")
			root.set_meta("animation_action_clip", "")

	root.set_meta("animation_requested_state", state)
	root.set_meta("animation_state", state)
	root.set_meta("animation_clip", clip)
	root.set_meta("animation_speed", speed)

static func _set_tree_base(root: Node3D, player: AnimationPlayer, tree: AnimationTree,
		config: Dictionary, state: String) -> void:
	var selection := _selection_for(player, config.get("animations", {}), state)
	var clip := str(selection.get("clip", ""))
	var source_state := str(selection.get("state", state))
	var speed := _speed_for(config.get("animation_speeds", {}), source_state)
	var effective_clip := clip
	if effective_clip.is_empty():
		effective_clip = _resolve_clip(player, "RESET")
	if effective_clip.is_empty():
		var walk_selection := _selection_for(player, config.get("animations", {}), "walk")
		effective_clip = str(walk_selection.get("clip", ""))
		# Sans Idle ni RESET, la premiere pose de marche reste la pose neutre.
		speed = 0.0
	var previous_state := str(root.get_meta("animation_base_state", ""))
	var previous_clip := str(root.get_meta("animation_base_clip", ""))
	var locomotion := root.get_meta("animation_locomotion_node", null) as AnimationNodeAnimation
	if locomotion == null:
		return
	if previous_clip != effective_clip:
		locomotion.animation = StringName(effective_clip)
	tree.set("parameters/LocomotionSpeed/scale", speed)
	if previous_state != state or previous_clip != effective_clip:
		tree.set("parameters/LocomotionSeek/seek_request", 0.0)
	root.set_meta("animation_base_state", state)
	root.set_meta("animation_base_clip", effective_clip)
	root.set_meta("animation_locomotion_clip", clip)
	root.set_meta("animation_locomotion_speed", speed)

static func _uses_upper_body_layer(config: Dictionary) -> bool:
	var layers: Variant = config.get("animation_layers", {})
	if not layers is Dictionary:
		return false
	for raw_state: Variant in layers:
		if str((layers as Dictionary)[raw_state]) == "upper_body":
			return true
	return false

static func _layer_for(raw_map: Variant, state: String) -> String:
	var layers: Dictionary = raw_map if raw_map is Dictionary else {}
	return str(layers.get(state, "full_body"))

static func _blend_for(raw_map: Variant, state: String, fallback: float) -> float:
	var blends: Dictionary = raw_map if raw_map is Dictionary else {}
	return clampf(float(blends.get(state, fallback)), 0.0, 2.0)

static func _bone_descends_from(skeleton: Skeleton3D, bone_index: int, root_bone_index: int) -> bool:
	var current := bone_index
	while current >= 0:
		if current == root_bone_index:
			return true
		current = skeleton.get_bone_parent(current)
	return false

static func _track_target_name(path: NodePath) -> String:
	if path.get_subname_count() > 0:
		return str(path.get_subname(path.get_subname_count() - 1))
	return ""

static func _selection_for(player: AnimationPlayer, raw_map: Variant, state: String) -> Dictionary:
	var animations: Dictionary = raw_map if raw_map is Dictionary else {}
	var candidate := state
	var visited: Array[String] = []
	while not candidate.is_empty() and not visited.has(candidate):
		visited.append(candidate)
		var wanted := str(animations.get(candidate, ""))
		var resolved := _resolve_clip(player, wanted)
		if not resolved.is_empty():
			return {"clip": resolved, "state": candidate}
		candidate = str(STATE_FALLBACKS.get(candidate, ""))
	return {"clip": "", "state": state}

static func _selection_with_override(player: AnimationPlayer, raw_map: Variant, state: String,
		clip_override: String) -> Dictionary:
	var resolved_override := _resolve_clip(player, clip_override)
	if not resolved_override.is_empty():
		return {"clip": resolved_override, "state": state}
	return _selection_for(player, raw_map, state)

static func _speed_for(raw_map: Variant, state: String) -> float:
	var speeds: Dictionary = raw_map if raw_map is Dictionary else {}
	return clampf(float(speeds.get(state, 1.0)), 0.05, 10.0)

static func _must_finish_current_clip(player: AnimationPlayer, config: Dictionary, state: String, clip: String) -> bool:
	var finish_map: Variant = config.get("finish_before_transition", {})
	if not finish_map is Dictionary or not bool((finish_map as Dictionary).get(state, false)):
		return false
	if clip.is_empty() or not player.is_playing() or str(player.current_animation) != clip:
		return false
	var animation := player.get_animation(StringName(clip))
	# Une boucle ne doit jamais verrouiller visuellement l'acteur pour toujours.
	return animation != null and animation.loop_mode == Animation.LOOP_NONE

# Les déplacements réels appartiennent à l'acteur 2D. Une animation dite
# « sur place » garde donc les rotations, les membres et la hauteur du bassin,
# mais neutralise son glissement horizontal. Les bibliothèques sont dupliquées
# par avatar: deux acteurs qui partagent le même GLB peuvent avoir des réglages
# différents sans modifier la ressource importée ni leurs voisins.
static func _prepare_in_place_clips(player: AnimationPlayer, config: Dictionary) -> Array[String]:
	var prepared: Array[String] = []
	if player == null or not is_instance_valid(player):
		return prepared
	var animations: Variant = config.get("animations", {})
	if not animations is Dictionary:
		return prepared
	var in_place: Dictionary = config.get("animation_in_place", {}) \
		if config.get("animation_in_place", {}) is Dictionary else {}
	_make_animation_libraries_unique(player)
	var wanted_clips := {}
	for raw_state: Variant in animations:
		var state := str(raw_state)
		# Le défaut est volontairement sûr: un nouveau modèle ne doit jamais
		# éloigner son dessin de sa collision parce que l'export contient du root motion.
		if not bool(in_place.get(state, true)):
			continue
		var clip := _resolve_clip(player, str((animations as Dictionary)[raw_state]))
		if not clip.is_empty():
			wanted_clips[clip] = true
	for raw_clip: Variant in wanted_clips:
		var clip := str(raw_clip)
		var location := _animation_location(player, clip)
		if location.is_empty():
			continue
		var library: AnimationLibrary = location.get("library")
		var local_name := StringName(str(location.get("name", "")))
		var source := library.get_animation(local_name)
		if source == null:
			continue
		var animation := source.duplicate(true) as Animation
		if animation == null or not _make_animation_in_place(animation):
			continue
		library.remove_animation(local_name)
		library.add_animation(local_name, animation)
		prepared.append(clip)
	return prepared

static func _make_animation_libraries_unique(player: AnimationPlayer) -> void:
	var names: Array[StringName] = player.get_animation_library_list()
	for library_name: StringName in names:
		var source := player.get_animation_library(library_name)
		if source == null:
			continue
		var copy := source.duplicate(true) as AnimationLibrary
		if copy == null:
			continue
		player.remove_animation_library(library_name)
		player.add_animation_library(library_name, copy)

static func _animation_location(player: AnimationPlayer, full_name: String) -> Dictionary:
	for library_name: StringName in player.get_animation_library_list():
		var library := player.get_animation_library(library_name)
		if library == null:
			continue
		for local_name: StringName in library.get_animation_list():
			var qualified := str(local_name) if str(library_name).is_empty() \
				else "%s/%s" % [library_name, local_name]
			if qualified == full_name:
				return {"library": library, "name": local_name}
	return {}

static func _make_animation_in_place(animation: Animation) -> bool:
	var changed := false
	for track_index: int in range(animation.get_track_count()):
		if animation.track_get_type(track_index) != Animation.TYPE_POSITION_3D \
				or not _is_root_translation_path(animation.track_get_path(track_index)):
			continue
		if animation.track_get_key_count(track_index) <= 0:
			continue
		var first_value: Variant = animation.track_get_key_value(track_index, 0)
		if not first_value is Vector3:
			continue
		var anchor: Vector3 = first_value
		for key_index: int in range(animation.track_get_key_count(track_index)):
			var raw_value: Variant = animation.track_get_key_value(track_index, key_index)
			if not raw_value is Vector3:
				continue
			var value: Vector3 = raw_value
			value.x = anchor.x
			value.z = anchor.z
			animation.track_set_key_value(track_index, key_index, value)
			changed = true
	return changed

static func _is_root_translation_path(path: NodePath) -> bool:
	var text := str(path).to_lower()
	var separator := text.rfind(":")
	var target := text.substr(separator + 1) if separator >= 0 else text.get_file()
	var normalized := target.replace("_", "").replace("-", "").replace(" ", "")
	return normalized in ["hips", "pelvis", "root", "rootnode", "rootmotion", "mixamorighips", "armature"]

static func _resolve_clip(player: AnimationPlayer, wanted: String) -> String:
	if wanted.is_empty():
		return ""
	if player.has_animation(StringName(wanted)):
		return wanted
	for raw_name: StringName in player.get_animation_list():
		var available := str(raw_name)
		if available.ends_with("/" + wanted) or available.ends_with("|" + wanted):
			return available
	return ""

static func _reset_pose(player: AnimationPlayer, raw_map: Variant) -> void:
	if player.has_animation(&"RESET"):
		player.play(&"RESET")
		player.seek(0.0, true)
		player.pause()
		return
	# Plusieurs exports Meshy n'ont aucun clip Idle ni RESET. La premiere image
	# du clip de marche est alors une posture neutre bien plus sure que la derniere
	# image d'une attaque, qui laisserait le heros fige en plein coup.
	var animations: Dictionary = raw_map if raw_map is Dictionary else {}
	var rest_clip := _resolve_clip(player, str(animations.get("idle", "")))
	if rest_clip.is_empty():
		rest_clip = _resolve_clip(player, str(animations.get("walk", "")))
	if not rest_clip.is_empty():
		player.play(StringName(rest_clip))
		player.seek(0.0, true)
		player.pause()
		return
	player.stop()

static func _find_animation_player(node: Node) -> AnimationPlayer:
	if node is AnimationPlayer:
		return node as AnimationPlayer
	for child: Node in node.get_children():
		var found := _find_animation_player(child)
		if found != null:
			return found
	return null

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

static func _bounds_of(node: Node, relative_transform: Transform3D) -> Dictionary:
	var found := false
	var bounds := AABB()
	if node is MeshInstance3D:
		var mesh_node := node as MeshInstance3D
		if mesh_node.mesh != null:
			# Un maillage skinné publie deja son AABB dans les unites finales du
			# modele. Rejouer l'echelle 0,01 de l'Armature ici la compte deux fois:
			# le facteur d'ajustement devient 100 fois trop grand lorsque le
			# squelette anime ensuite les sommets.
			bounds = mesh_node.get_aabb() if mesh_node.skin != null \
				else relative_transform * mesh_node.get_aabb()
			found = true
	for child: Node in node.get_children():
		var child_transform := relative_transform
		if child is Node3D:
			child_transform = relative_transform * (child as Node3D).transform
		var measured := _bounds_of(child, child_transform)
		if not bool(measured.get("found", false)):
			continue
		bounds = bounds.merge(measured.bounds) if found else measured.bounds
		found = true
	return {"found": found, "bounds": bounds}
