class_name CharacterAppearancePresenter
extends Node3D

## Composes one sandbox character model, texture compositor and starter outfit.
## The presenter owns only nodes below the existing Visual scene boundary.

const GEOSET_CONTROLLER_SCRIPT := preload("res://src/scenes/character/character_geoset_controller.gd")
const TEXTURE_COMPOSITOR_SCRIPT := preload("res://src/scenes/character/character_texture_compositor.gd")
const OUTFIT_RESOLVER_SCRIPT := preload("res://src/scenes/character/wow_character_outfit_resolver.gd")

signal character_appearance_ready(character_root: Node3D)

## Packed character scene used by the current render sandbox.
@export var character_model_path: String = "res://src/resources/characters/HUMAN/MALE/HumanMale.glb"
## Character class used when resolving the current starter outfit.
@export var character_class_id: int = 1
## Uniform scale applied to the composed character root.
@export var character_scale: float = 1.0
## Model-space yaw correction retained from the current sandbox.
@export var character_yaw_offset_degrees: float = 90.0
## Root containing extracted DBC and item texture inputs.
@export var extracted_data_directory: String = "res://data/extracted"

var character_root: Node3D:
	get:
		return _character_root

var _character_root: Node3D


## Loads and composes the configured character model below this Visual node.
## A non-empty directory argument keeps the player's terrain/content root aligned.
## Returns false immediately when no model is configured or the resource is invalid.
func load_character_appearance(content_root_override: String = "") -> bool:
	if not content_root_override.is_empty():
		extracted_data_directory = content_root_override
	_clear_character_root()
	if character_model_path.is_empty():
		return false
	var character_scene := load(character_model_path) as PackedScene
	if character_scene == null:
		push_warning("CharacterAppearancePresenter: cannot load character model: %s" % character_model_path)
		return false

	if is_class("MeshInstance3D"):
		set("mesh", null)
	position = Vector3.ZERO

	_character_root = Node3D.new()
	_character_root.name = "CharacterModel"
	_character_root.set_script(GEOSET_CONTROLLER_SCRIPT)
	_character_root.scale = Vector3.ONE * maxf(character_scale, 0.001)
	_character_root.rotation_degrees.y = character_yaw_offset_degrees

	var character_instance := character_scene.instantiate()
	character_instance.name = character_model_path.get_file().get_basename()
	_character_root.add_child(character_instance)

	var texture_compositor := Node.new()
	texture_compositor.name = "CharacterTextureCompositor"
	texture_compositor.set_script(TEXTURE_COMPOSITOR_SCRIPT)
	texture_compositor.set("textures_dir", _character_textures_directory(character_model_path))
	texture_compositor.set("extracted_dir", extracted_data_directory)
	_character_root.add_child(texture_compositor)
	add_child(_character_root)
	_complete_character_composition.call_deferred(_character_root, texture_compositor)
	return true


func _complete_character_composition(expected_character_root: Node3D, texture_compositor: Node) -> void:
	if expected_character_root != _character_root or not is_instance_valid(expected_character_root):
		return
	_fit_character_to_ground(expected_character_root)
	_apply_character_starter_outfit(expected_character_root, texture_compositor)
	character_appearance_ready.emit(expected_character_root)


func _clear_character_root() -> void:
	if _character_root == null or not is_instance_valid(_character_root):
		_character_root = null
		return
	remove_child(_character_root)
	_character_root.queue_free()
	_character_root = null


func _fit_character_to_ground(root: Node3D) -> void:
	var character_bounds := _calculate_global_bounds(root)
	if character_bounds.size == Vector3.ZERO:
		return
	var local_minimum_y := character_bounds.position.y - root.global_position.y
	root.position.y -= local_minimum_y


func _apply_character_starter_outfit(root: Node3D, texture_compositor: Node) -> void:
	if texture_compositor == null:
		return
	var outfit_resolver: RefCounted = OUTFIT_RESOLVER_SCRIPT.new()
	if not outfit_resolver.call("load_dbcs", extracted_data_directory):
		return
	var inferred_identity: Dictionary = outfit_resolver.call(
		"infer_race_gender_from_model_path",
		character_model_path
	)
	var race_name := String(inferred_identity.get("race", "Human"))
	var gender_name := String(inferred_identity.get("gender", "Male"))
	var starter_outfit: Dictionary = outfit_resolver.call(
		"resolve_start_outfit",
		race_name,
		gender_name,
		character_class_id
	)
	if starter_outfit.is_empty():
		return
	if texture_compositor.has_method("set_equipment_components"):
		texture_compositor.call(
			"set_equipment_components",
			starter_outfit.get("textures", PackedStringArray())
		)
	if root.has_method("apply_outfit_defaults"):
		root.call("apply_outfit_defaults", starter_outfit)


func _calculate_global_bounds(root: Node) -> AABB:
	var combined_bounds := AABB()
	var has_bounds := false
	for mesh_instance in _mesh_instances_below(root):
		if mesh_instance.mesh == null:
			continue
		var global_bounds := mesh_instance.global_transform * mesh_instance.mesh.get_aabb()
		combined_bounds = global_bounds if not has_bounds else combined_bounds.merge(global_bounds)
		has_bounds = true
	return combined_bounds if has_bounds else AABB()


func _mesh_instances_below(root: Node) -> Array[MeshInstance3D]:
	var mesh_instances: Array[MeshInstance3D] = []
	_collect_mesh_instances(root, mesh_instances)
	return mesh_instances


func _collect_mesh_instances(node: Node, mesh_instances: Array[MeshInstance3D]) -> void:
	if node is MeshInstance3D:
		mesh_instances.append(node)
	for child in node.get_children():
		_collect_mesh_instances(child, mesh_instances)


func _character_textures_directory(model_path: String) -> String:
	return model_path.get_base_dir().path_join(model_path.get_file().get_basename() + "_textures")
