## CharacterGeosetController – WoW 3.3.5a (build 12340) character geoset manager.
##
## Attach to the root of an imported character GLB scene.
##
## Correct geoset ID ranges (from M2SkinMeshPartID, WotLK 3.3.5):
##   0          skin     – base body, always visible
##   1  – 34    hair     – hair style variants
##   101 – 124  facial1  – beard
##   201 – 219  facial2  – mustache
##   301 – 319  facial3  – sideburns
##   401 – 405  gloves
##   501 – 510  boots
##   601 – 614  shirt
##   701 – 711  ears
##   801 – 804  wristbands   (801 = bare wrists)
##   901 – 905  kneepads
##   1001– 1004 chest
##   1101– 1105 pants
##   1201– 1204 tabard
##   1301– 1303 legs
##   1401– 1414 shirt_doublet
##   1501– 1524 cape
##   1601– 1614 facial_jewelry
##   1701– 1705 eye_effects
##   1801– 1804 belt
##   1901– 1914 trail
##   2001– 2008 feet

@tool
extends Node3D

const EYE_GLOW_SHADER := preload("res://src/scenes/character/eye_glow.gdshader")

## Customisation (0 = auto-select first available on _ready)
@export var hair_style:    int  = 0 : set = _set_hair
@export var facial1:       int  = 0 : set = _set_facial1
@export var facial2:       int  = 0 : set = _set_facial2
@export var facial3:       int  = 0 : set = _set_facial3
@export var ears:          int  = 0 : set = _set_ears
@export var eye_effects:   int  = 0 : set = _set_eye_effects
@export var eye_glow_intensity: float = 1.3

## Skin / face customisation – forwarded to CharacterTextureCompositor sibling/child.
@export var skin_color: int = 0 : set = _set_skin_color
@export var face_style: int = 0 : set = _set_face_style

## Internal: available geoset IDs per category (populated in _ready from actual model).
var _available: Dictionary = {}   # category_name -> Array[int]  (sorted)
## Internal: valid ranges for texture-based customisation (from compositor).
var _skin_color_count: int = 0
var _face_style_count: int = 0

## Equipment toggles (off by default = naked character)
@export var show_gloves:       bool = false : set = _set_gloves
@export var show_boots:        bool = false : set = _set_boots
@export var show_shirt:        bool = false : set = _set_shirt
## Wristband geoset ID: 801 = bare wrists (no bracer), 802+ = equipped bracers.
## 0 = auto (picks first available in model).
@export var wristband_style:   int  = 0 : set = _set_wristbands
@export var show_kneepads:     bool = false : set = _set_kneepads
@export var show_chest:        bool = false : set = _set_chest
@export var show_pants:        bool = false : set = _set_pants
@export var show_tabard:       bool = false : set = _set_tabard
@export var show_legs:         bool = false : set = _set_legs
@export var show_cape:         bool = false : set = _set_cape
@export var show_belt:         bool = false : set = _set_belt
@export var show_feet:         bool = false : set = _set_feet

# ── WoW 3.3.5 geoset ID ranges ───────────────────────────────────────────────
# Each entry: [start, end_exclusive, category_name]
const RANGES: Array = [
	[0,    1,    "skin"],
	[1,    35,   "hair"],
	[101,  125,  "facial1"],
	[201,  220,  "facial2"],
	[301,  320,  "facial3"],
	[401,  406,  "gloves"],
	[501,  511,  "boots"],
	[601,  615,  "shirt"],
	[701,  712,  "ears"],
	[801,  805,  "wristbands"],
	[901,  906,  "kneepads"],
	[1001, 1005, "chest"],
	[1101, 1106, "pants"],
	[1201, 1205, "tabard"],
	[1301, 1304, "legs"],
	[1401, 1415, "shirt_doublet"],
	[1501, 1525, "cape"],
	[1601, 1615, "facial_jewelry"],
	[1701, 1706, "eye_effects"],
	[1801, 1805, "belt"],
	[1901, 1915, "trail"],
	[2001, 2009, "feet"],
]

func _ready() -> void:
	var nodes := _geoset_nodes()
	if nodes.is_empty():
		return

	# Build _available: actual geoset IDs present in the model, grouped by category.
	_available.clear()
	for child in nodes:
		var gid := _geoset_id(child.name)
		var cat := _category(gid)
		if not _available.has(cat):
			_available[cat] = []
		_available[cat].append(gid)
	for cat in _available:
		(_available[cat] as Array).sort()

	# Query compositor for skin/face counts (it knows what PNGs are on disk).
	var comp := _get_compositor()
	if comp:
		_skin_color_count = comp.get_skin_color_count()
		_face_style_count = comp.get_face_style_count()

	# Clamp each export to the nearest valid value for this model.
	hair_style      = _clamp_geoset("hair",        hair_style)
	facial1         = _clamp_geoset("facial1",     facial1)
	facial2         = _clamp_geoset("facial2",     facial2)
	facial3         = _clamp_geoset("facial3",     facial3)
	ears            = _clamp_geoset("ears",        ears)
	eye_effects     = _clamp_geoset("eye_effects", eye_effects)
	wristband_style = _clamp_geoset("wristbands",  wristband_style)
	skin_color      = clampi(skin_color, 0, maxi(_skin_color_count - 1, 0))
	face_style      = clampi(face_style, 0, maxi(_face_style_count - 1, 0))

	_apply_all(nodes)
	_apply_eye_glow_shader(nodes)

# ── setters ───────────────────────────────────────────────────────────────────

func _set_hair(v):        hair_style   = _clamp_geoset("hair",        v); _apply_variant("hair",        hair_style)
func _set_facial1(v):     facial1      = _clamp_geoset("facial1",     v); _apply_variant("facial1",     facial1)
func _set_facial2(v):     facial2      = _clamp_geoset("facial2",     v); _apply_variant("facial2",     facial2)
func _set_facial3(v):     facial3      = _clamp_geoset("facial3",     v); _apply_variant("facial3",     facial3)
func _set_ears(v):        ears         = _clamp_geoset("ears",        v); _apply_variant("ears",        ears)
func _set_eye_effects(v): eye_effects  = _clamp_geoset("eye_effects", v); _apply_variant("eye_effects", eye_effects)

func _set_skin_color(v: int) -> void:
	skin_color = clampi(v, 0, maxi(_skin_color_count - 1, 0))
	var c := _get_compositor()
	if c: c.skin_color = skin_color

func _set_face_style(v: int) -> void:
	face_style = clampi(v, 0, maxi(_face_style_count - 1, 0))
	var c := _get_compositor()
	if c: c.face_style = face_style

func _get_compositor() -> Node:
	# Look for CharacterTextureCompositor as a direct child first,
	# then as a sibling (child of our parent).
	for child in get_children():
		if child.get_script() and child.has_method("refresh"):
			return child
	var p := get_parent()
	if p:
		for sibling in p.get_children():
			if sibling != self and sibling.get_script() and sibling.has_method("refresh"):
				return sibling
	return null

func _set_gloves(v):      show_gloves     = v; _apply_toggle("gloves",      v)
func _set_boots(v):       show_boots      = v; _apply_toggle("boots",       v)
func _set_shirt(v):       show_shirt      = v; _apply_toggle("shirt",       v)
func _set_wristbands(v):  wristband_style = v; _apply_variant("wristbands", v)
func _set_kneepads(v):    show_kneepads   = v; _apply_toggle("kneepads",    v)
func _set_chest(v):       show_chest      = v; _apply_toggle("chest",       v)
func _set_pants(v):       show_pants      = v; _apply_toggle("pants",       v)
func _set_tabard(v):      show_tabard     = v; _apply_toggle("tabard",      v)
func _set_legs(v):        show_legs       = v; _apply_toggle("legs",        v)
func _set_cape(v):        show_cape       = v; _apply_toggle("cape",        v)
func _set_belt(v):        show_belt       = v; _apply_toggle("belt",        v)
func _set_feet(v):        show_feet       = v; _apply_toggle("feet",        v)

# ── visibility ────────────────────────────────────────────────────────────────

## Show the geoset matching exact ID `wanted`; hide all others in the category.
func _apply_variant(cat: String, wanted: int) -> void:
	for child in _geoset_nodes():
		var gid := _geoset_id(child.name)
		if _category(gid) == cat:
			child.visible = (gid == wanted) and _geoset_renderable(child, cat)

## Show or hide all geosets in a category.
func _apply_toggle(cat: String, on: bool) -> void:
	for child in _geoset_nodes():
		if _category(_geoset_id(child.name)) == cat:
			child.visible = on

func _apply_all(nodes: Array[Node] = []) -> void:
	if nodes.is_empty():
		nodes = _geoset_nodes()

	for child in nodes:
		var gid := _geoset_id(child.name)
		var cat := _category(gid)

		match cat:
			"skin":          child.visible = true
			"hair":          child.visible = (gid == hair_style)
			"facial1":       child.visible = (gid == facial1)
			"facial2":       child.visible = (gid == facial2)
			"facial3":       child.visible = (gid == facial3)
			"ears":          child.visible = (gid == ears)
			"eye_effects":   child.visible = (gid == eye_effects) and _geoset_renderable(child, cat)
			"gloves":        child.visible = show_gloves
			"boots":         child.visible = show_boots
			"shirt":         child.visible = show_shirt
			"wristbands":    child.visible = (gid == wristband_style)
			"kneepads":      child.visible = show_kneepads
			"chest":         child.visible = show_chest
			"pants":         child.visible = show_pants
			"tabard":        child.visible = show_tabard
			"legs":          child.visible = show_legs
			"cape":          child.visible = show_cape
			"belt":          child.visible = show_belt
			"feet":          child.visible = show_feet
			# shirt_doublet, facial_jewelry, trail: follow shirt / facial / trail
			"shirt_doublet": child.visible = show_shirt
			"facial_jewelry":child.visible = (gid == facial1 + 1500)  # rough guess
			"trail":         child.visible = true
			_:               child.visible = true  # unknown – leave visible

# ── helpers ───────────────────────────────────────────────────────────────────

func _geoset_nodes() -> Array[Node]:
	var result: Array[Node] = []
	_collect(self, result)
	return result

func _collect(node: Node, result: Array[Node]) -> void:
	for child in node.get_children():
		if child.name.begins_with("geoset_") and child is MeshInstance3D:
			result.append(child)
		else:
			_collect(child, result)

## Extract geoset_id from node name "geoset_NNNN_category_vNN"
func _geoset_id(node_name: String) -> int:
	var parts := node_name.split("_")
	if parts.size() >= 2:
		return parts[1].to_int()
	return -1

## Map a geoset ID to its category name using the WoW 3.3.5 ranges.
func _category(gid: int) -> String:
	for r in RANGES:
		if gid >= r[0] and gid < r[1]:
			return r[2]
	return "unknown"

## Replace materials on eye-glow geosets with the multiplicative glow shader.
## Detects eye-glow blend in two ways (in priority order):
##   1. GLTF node extras["blend_mode"] == 4 (Mod) or 3 (Add) from M2 material data
##   2. Fallback: geoset ID in the eye_effects range 1701-1705
## Call once after the scene loads.
func _apply_eye_glow_shader(nodes: Array[Node] = []) -> void:
	if nodes.is_empty():
		nodes = _geoset_nodes()

	# Per-race glow colour defaults (Mod blending: tints the eye behind it).
	# With blend_mul: result = ALBEDO * background.
	# Use saturated hues so the tint is strong; 1.0 on the dominant channel
	# keeps that channel of the background intact while suppressing others.
	var race_colors := {
		"bloodelf":    Color(0.1, 1.0, 0.2),   # green
		"nightelf":    Color(0.6, 0.9, 1.0),   # silver-blue
		"deathknight": Color(0.1, 0.4, 1.0),   # icy blue
		"scourge":     Color(0.1, 0.4, 1.0),   # icy blue (undead DKs)
	}

	# Guess race from scene/model name
	var scene_name := get_name().to_lower()
	var glow_color := Color(0.2, 1.0, 0.3)   # default green
	for race in race_colors:
		if race in scene_name:
			glow_color = race_colors[race]
			break

	for child in nodes:
		var mesh_inst := child as MeshInstance3D
		if not mesh_inst:
			continue

		# Determine whether this geoset uses WoW eye-glow blending.
		# Primary: check blend_mode from M2 material stored in GLTF extras.
		#   blend_mode=4 → Mod (DST_COLOR * src)  — most races (Blood Elf, Night Elf, etc.)
		#   blend_mode=3 → Add (SRC_ALPHA + ONE)  — pure additive (rare; DK particles etc.)
		# Fallback: geoset ID in the eye_effects range (1701-1705) for older GLBs.
		var is_eye_glow := false
		if mesh_inst.has_meta("extras"):
			var extras = mesh_inst.get_meta("extras")
			if extras is Dictionary and extras.has("blend_mode"):
				var bm: int = extras["blend_mode"]
				is_eye_glow = (bm == 4 or bm == 3)
		if not is_eye_glow:
			is_eye_glow = (_category(_geoset_id(child.name)) == "eye_effects")

		if not is_eye_glow:
			continue

		if not _geoset_renderable(mesh_inst, "eye_effects"):
			mesh_inst.visible = false
			continue

		for si in range(mesh_inst.mesh.get_surface_count()):
			var orig_mat := _surface_material(mesh_inst, si)
			if not _material_has_albedo_texture(orig_mat):
				continue
			var shader_mat := ShaderMaterial.new()
			shader_mat.shader = EYE_GLOW_SHADER
			shader_mat.set_shader_parameter("glow_color", Vector3(glow_color.r, glow_color.g, glow_color.b))
			shader_mat.set_shader_parameter("glow_intensity", eye_glow_intensity)
			var std := orig_mat as StandardMaterial3D
			shader_mat.set_shader_parameter("albedo_texture", std.albedo_texture)
			mesh_inst.set_surface_override_material(si, shader_mat)

## Returns sorted list of geoset IDs available for a given category.
## After _ready() this uses the cached _available dict; before _ready() it scans nodes.
func get_ids(cat: String) -> Array[int]:
	if not _available.is_empty():
		var ids: Array = _available.get(cat, [])
		var result: Array[int] = []
		for id in ids:
			result.append(id)
		return result
	# Fallback: scan live (called before _ready in edge cases)
	var result: Array[int] = []
	for child in _geoset_nodes():
		var gid := _geoset_id(child.name)
		if _category(gid) == cat and not gid in result:
			result.append(gid)
	result.sort()
	return result

## Returns valid skin colour count for this model (0 if compositor not found yet).
func get_skin_color_count() -> int: return _skin_color_count
## Returns valid face style count for this model (0 if compositor not found yet).
func get_face_style_count() -> int: return _face_style_count


func apply_outfit_defaults(outfit: Dictionary = {}) -> void:
	show_gloves = true
	show_boots = true
	show_shirt = false
	show_kneepads = true
	show_chest = false
	show_pants = false
	show_tabard = false
	show_legs = (int(outfit.get("flags", 0)) & 0x4) == 0
	show_cape = true
	show_belt = false
	show_feet = false
	wristband_style = _clamp_geoset("wristbands", 802)
	_apply_exact_or_first("gloves", 401)
	_apply_exact_or_first("boots", 501)
	_apply_exact_or_first("ears", 702)
	_apply_exact_or_first("wristbands", wristband_style)
	_apply_exact_or_first("kneepads", 902)
	_apply_exact_or_first("legs", 1301)
	_apply_exact_or_first("cape", 1501)
	_apply_toggle("shirt", false)
	_apply_toggle("chest", false)
	_apply_toggle("pants", false)
	_apply_toggle("tabard", false)
	_apply_toggle("belt", false)
	_apply_toggle("feet", false)

## Clamp/snap v to the nearest available geoset ID in cat.
## Returns v unchanged if _available is not populated yet or cat is missing.
func _clamp_geoset(cat: String, v: int) -> int:
	if not _available.has(cat):
		return v
	var ids: Array = _available[cat]   # already sorted
	if ids.is_empty():
		return v
	if v in ids:
		return v
	# Pick the nearest available ID
	var best: int = ids[0]
	var best_dist: int = abs(v - best)
	for id in ids:
		var d: int = abs(v - id)
		if d < best_dist:
			best_dist = d
			best = id
	return best


func _apply_exact_or_first(cat: String, wanted: int) -> void:
	var id := _clamp_geoset(cat, wanted)
	_apply_variant(cat, id)


func _geoset_renderable(node: Node, cat: String) -> bool:
	if cat != "eye_effects":
		return true
	var mesh_inst := node as MeshInstance3D
	if mesh_inst == null or mesh_inst.mesh == null:
		return false
	for si in range(mesh_inst.mesh.get_surface_count()):
		if _material_has_albedo_texture(_surface_material(mesh_inst, si)):
			return true
	return false


func _surface_material(mesh_inst: MeshInstance3D, surface: int) -> Material:
	var mat := mesh_inst.get_surface_override_material(surface)
	if mat == null and mesh_inst.mesh != null:
		mat = mesh_inst.mesh.surface_get_material(surface)
	return mat


func _material_has_albedo_texture(mat: Material) -> bool:
	return mat is StandardMaterial3D and (mat as StandardMaterial3D).albedo_texture != null
