## CharacterTextureCompositor
##
## Runtime skin-texture compositing for WoW 3.3.5a character models.
##
## Attach as a child of the character's root node (next to
## CharacterGeosetController).  Set [member textures_dir] to the
## {ModelName}_textures/ folder that was exported alongside the GLB.
##
## UV layout of the 512x512 WoW character skin texture:
##   (0,   0) 256x128  NakedTorsoSkin  – bra area
##   (0, 128) 256x128  NakedPelvisSkin – panties area
##   (0, 320) 256x64   FaceUpper       – forehead / upper face
##   (0, 384) 256x128  FaceLower       – eyes, mouth, nose

@tool
extends Node

# ── Layer paste positions (pixels, top-left origin) ─────────────────────────
const NAKED_TORSO_POS  := Vector2i(0,   0)
const NAKED_PELVIS_POS := Vector2i(0, 128)
const FACE_UPPER_POS   := Vector2i(0, 320)
const FACE_LOWER_POS   := Vector2i(0, 384)
const COMPONENT_RECTS := [
	Rect2i(0,   0,   256, 128),
	Rect2i(0,   128, 256, 128),
	Rect2i(0,   256, 256, 64),
	Rect2i(256, 0,   256, 128),
	Rect2i(256, 128, 256, 64),
	Rect2i(256, 192, 256, 128),
	Rect2i(256, 320, 256, 128),
	Rect2i(256, 448, 256, 64),
]

# ── Exports ──────────────────────────────────────────────────────────────────

## Path to the {ModelName}_textures/ folder.
## Example: "res://src/resources/characters/BloodElf/Female/BloodElfFemale_textures"
@export var textures_dir: String = "" : set = _set_textures_dir

## Root folder containing extracted WoW data. Used for DBC item component BLPs.
@export var extracted_dir: String = "res://data/extracted"

## Skin colour index (selects skin_XX.png / naked_torso_XX.png /
## naked_pelvis_XX.png).
@export var skin_color: int = 0 : set = _set_skin_color

## Face style index (first part of face_lower_SS_CC.png filename).
@export var face_style: int = 0 : set = _set_face_style

# ── Internal state ───────────────────────────────────────────────────────────

# MeshInstance3D surfaces to override: Array of {node, surface_idx}
var _skin_surfaces: Array = []
var _equipment_components := PackedStringArray()
var _blp_image_cache: Dictionary = {}

# True once _ready has run (prevents setters from firing before init)
var _ready_done := false


func _ready() -> void:
	_ready_done = true
	if textures_dir.is_empty():
		_auto_detect_textures_dir()
	_scan_skin_surfaces()
	_recomposite()


# ── Setters ──────────────────────────────────────────────────────────────────

func _set_textures_dir(v: String) -> void:
	textures_dir = v
	if _ready_done:
		_scan_skin_surfaces()
		_recomposite()

func _set_skin_color(v: int) -> void:
	skin_color = v
	if _ready_done: _recomposite()

func _set_face_style(v: int) -> void:
	face_style = v
	if _ready_done: _recomposite()


# ── Auto-detection ───────────────────────────────────────────────────────────

func _auto_detect_textures_dir() -> void:
	# Convention: GLB is at res://src/resources/characters/Race/Gender/Model.glb
	# Textures: same folder / Model_textures/
	# We walk the scene tree to find the first MeshInstance3D and infer from
	# the mesh resource path.
	var root := get_parent() if get_parent() else get_tree().current_scene
	for mesh_inst in _iter_mesh_instances(root):
		var mesh := mesh_inst.mesh
		if mesh == null:
			continue
		var rpath := mesh.resource_path  # e.g. res://src/resources/.../BloodElfFemale.glb::Mesh_0
		if rpath.is_empty():
			continue
		# Extract the GLB path (everything before "::")
		var glb_path := rpath.get_slice("::", 0)
		var dir      := glb_path.get_base_dir()
		var stem     := glb_path.get_file().get_basename()
		var candidate := dir.path_join(stem + "_textures")
		if DirAccess.dir_exists_absolute(candidate):
			textures_dir = candidate
			print("CharacterTextureCompositor: auto-detected textures_dir = ", textures_dir)
			return
	push_warning("CharacterTextureCompositor: could not auto-detect textures_dir; set it manually.")


# ── Surface discovery ────────────────────────────────────────────────────────

func _scan_skin_surfaces() -> void:
	_skin_surfaces.clear()
	var root := get_parent() if get_parent() else self
	for mesh_inst in _iter_mesh_instances(root):
		var mesh := mesh_inst.mesh
		if mesh == null:
			continue
		for si in range(mesh.get_surface_count()):
			if _is_skin_surface(mesh_inst, si):
				_skin_surfaces.append({"node": mesh_inst, "surface": si})


## A surface is a "skin surface" if:
##   - its active material is a StandardMaterial3D (not ShaderMaterial = eye glow)
##   - AND its albedo texture is square body texture, usually 256x256 or 512x512
func _is_skin_surface(mesh_inst: MeshInstance3D, si: int) -> bool:
	# Prefer surface override, fall back to mesh material
	var mat := mesh_inst.get_surface_override_material(si)
	if mat == null:
		mat = mesh_inst.mesh.surface_get_material(si)
	if not (mat is StandardMaterial3D):
		return false
	var std := mat as StandardMaterial3D
	var tex := std.albedo_texture
	if tex == null:
		return false
	var img := tex.get_image()
	if img == null:
		return false
	return img.get_width() == img.get_height() and img.get_width() >= 256


# ── Compositing ──────────────────────────────────────────────────────────────

func _recomposite() -> void:
	if textures_dir.is_empty() or _skin_surfaces.is_empty():
		return

	var skin_tex := _build_skin_texture()
	if skin_tex == null:
		return

	_apply_texture(skin_tex)


func _build_skin_texture() -> ImageTexture:
	# 1. Base skin
	var base := _load_layer("skin_%02d.png" % skin_color)
	if base == null:
		push_warning("CharacterTextureCompositor: missing skin_%02d.png" % skin_color)
		return null

	# 2. Naked overlays (underwear)
	_blit_scaled(base, _load_layer("naked_torso_%02d.png"  % skin_color), NAKED_TORSO_POS)
	_blit_scaled(base, _load_layer("naked_pelvis_%02d.png" % skin_color), NAKED_PELVIS_POS)

	# 3. Face
	_blit_scaled(base, _load_layer("face_upper_%02d_%02d.png" % [face_style, skin_color]), FACE_UPPER_POS)
	_blit_scaled(base, _load_layer("face_lower_%02d_%02d.png" % [face_style, skin_color]), FACE_LOWER_POS)

	for slot in mini(_equipment_components.size(), COMPONENT_RECTS.size()):
		var rel_path := String(_equipment_components[slot])
		if rel_path.is_empty():
			continue
		_blit_reference_rect(base, _load_blp_layer(rel_path), COMPONENT_RECTS[slot])

	base.generate_mipmaps()
	return ImageTexture.create_from_image(base)


func _apply_texture(tex: ImageTexture) -> void:
	for entry in _skin_surfaces:
		var mesh_inst: MeshInstance3D = entry["node"]
		var si: int = entry["surface"]
		if not is_instance_valid(mesh_inst):
			continue

		# Clone the existing material so we don't mutate the shared resource
		var orig_mat := mesh_inst.get_surface_override_material(si)
		if orig_mat == null:
			orig_mat = mesh_inst.mesh.surface_get_material(si)
		if not (orig_mat is StandardMaterial3D):
			continue

		var mat := orig_mat.duplicate() as StandardMaterial3D
		mat.albedo_texture = tex
		mesh_inst.set_surface_override_material(si, mat)


# ── Helpers ───────────────────────────────────────────────────────────────────

func _load_layer(filename: String) -> Image:
	var path := textures_dir.path_join(filename)
	if not ResourceLoader.exists(path) and not FileAccess.file_exists(path):
		return null
	var texture := ResourceLoader.load(path, "Texture2D", ResourceLoader.CACHE_MODE_REUSE) as Texture2D
	if texture != null:
		var image := texture.get_image()
		return image.duplicate() if image != null else null
	if FileAccess.file_exists(path):
		return Image.load_from_file(path)
	return null


func _blit(dst: Image, src: Image, pos: Vector2i) -> void:
	if src == null:
		return
	dst.blend_rect(src, Rect2i(Vector2i.ZERO, src.get_size()), pos)


func _blit_scaled(dst: Image, src: Image, reference_pos: Vector2i) -> void:
	if src == null:
		return
	var scale := float(dst.get_width()) / 512.0
	var target_pos := Vector2i(
		int(round(float(reference_pos.x) * scale)),
		int(round(float(reference_pos.y) * scale)))
	if is_equal_approx(scale, 1.0):
		_blit(dst, src, target_pos)
		return
	var target_size := Vector2i(
		maxi(1, int(round(float(src.get_width()) * scale))),
		maxi(1, int(round(float(src.get_height()) * scale))))
	var scaled := src.duplicate()
	scaled.resize(target_size.x, target_size.y, Image.INTERPOLATE_LANCZOS)
	_blit(dst, scaled, target_pos)


func _blit_reference_rect(dst: Image, src: Image, reference_rect: Rect2i) -> void:
	if src == null:
		return
	var scale_x := float(dst.get_width()) / 512.0
	var scale_y := float(dst.get_height()) / 512.0
	var target_rect := Rect2i(
		Vector2i(
			int(round(float(reference_rect.position.x) * scale_x)),
			int(round(float(reference_rect.position.y) * scale_y))),
		Vector2i(
			maxi(1, int(round(float(reference_rect.size.x) * scale_x))),
			maxi(1, int(round(float(reference_rect.size.y) * scale_y)))))
	var scaled := src.duplicate()
	scaled.resize(target_rect.size.x, target_rect.size.y, Image.INTERPOLATE_LANCZOS)
	_blit(dst, scaled, target_rect.position)


func _load_blp_layer(rel_path: String) -> Image:
	if rel_path.is_empty():
		return null
	if _blp_image_cache.has(rel_path):
		return (_blp_image_cache[rel_path] as Image).duplicate() if _blp_image_cache[rel_path] != null else null
	if not ClassDB.class_exists("BLPLoader"):
		_blp_image_cache[rel_path] = null
		return null
	var abs_path := ProjectSettings.globalize_path(extracted_dir.trim_suffix("/").path_join(rel_path.replace("\\", "/")))
	if not FileAccess.file_exists(abs_path):
		_blp_image_cache[rel_path] = null
		return null
	var img: Image = ClassDB.instantiate("BLPLoader").call("load_image", abs_path)
	_blp_image_cache[rel_path] = img
	return img.duplicate() if img != null else null


func _iter_mesh_instances(root: Node) -> Array[MeshInstance3D]:
	var result: Array[MeshInstance3D] = []
	_collect_meshes(root, result)
	return result


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


# ── Public API ────────────────────────────────────────────────────────────────

## Force a full rescan + recomposite (call after swapping the character GLB).
func refresh() -> void:
	_scan_skin_surfaces()
	_recomposite()


func set_equipment_components(components: PackedStringArray) -> void:
	_equipment_components = components
	_recomposite()


## Returns the number of skin surfaces found (useful for debugging).
func get_skin_surface_count() -> int:
	return _skin_surfaces.size()


## Returns how many skin colour variants exist (i.e. max valid skin_color + 1).
func get_skin_color_count() -> int:
	if textures_dir.is_empty():
		return 0
	var n := 0
	while FileAccess.file_exists(textures_dir.path_join("skin_%02d.png" % n)):
		n += 1
	return n


## Returns how many face styles exist (i.e. max valid face_style + 1).
func get_face_style_count() -> int:
	if textures_dir.is_empty():
		return 0
	var n := 0
	while FileAccess.file_exists(textures_dir.path_join("face_lower_%02d_00.png" % n)):
		n += 1
	return n
