## Converts raw WMOLoader data into a Godot Node3D scene tree.
## Usage:
##   var data = WMOLoader.new().load_wmo(abs_path)
##   var node = WMOBuilder.build(data, "res://data/extracted")
##   add_child(node)
class_name WMOBuilder

const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
const WOW_LIQUID_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_liquid_material.gd")
const WOW_WMO_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_wmo_material.gd")
const WMO_BUILDER_FORMAT_VERSION := 2
const WMO_BUILDER_FORMAT_META := "openwc_wmo_builder_format_version"
const BUILD_OCCLUDERS := false
const OCCLUDER_MIN_TRIANGLES := 16

static var _texture_cache: Dictionary = {}
# rel_path → Node3D prototype (built once, duplicated per usage)
static var _m2_prototype_cache: Dictionary = {}
# rel_path → true (resolved missing — skip)
static var _m2_missing_cache: Dictionary = {}


# Bake scripts should call this before SceneTree.quit() to release cached
# Node3D prototypes (Node-derived objects aren't RefCounted, so the static
# dict alone won't free them at exit).
static func clear_caches() -> void:
	for proto in _m2_prototype_cache.values():
		if is_instance_valid(proto):
			(proto as Node).free()
	_m2_prototype_cache.clear()
	_m2_missing_cache.clear()
	_texture_cache.clear()
	WOW_LIQUID_MATERIAL.clear_cache()
	WOW_WMO_MATERIAL.clear_cache()


static func is_scene_cache_current(root: Node) -> bool:
	if root == null:
		return false
	return int(root.get_meta(WMO_BUILDER_FORMAT_META, 0)) >= WMO_BUILDER_FORMAT_VERSION


# Returns a Node3D containing one MeshInstance3D per WMO group.
static func build(data: Dictionary, extracted_dir: String = "") -> Node3D:
	var root := Node3D.new()
	root.name = "WMO"
	root.set_meta(WMO_BUILDER_FORMAT_META, WMO_BUILDER_FORMAT_VERSION)

	if not data.has("groups"):
		return root

	var textures: PackedStringArray  = data.get("textures", PackedStringArray())
	var materials: Array             = data.get("materials", [])

	# Build Godot materials from WMO material definitions
	var godot_materials: Array[Material] = []
	for mat_def in materials:
		godot_materials.append(_build_material(mat_def, textures, extracted_dir))

	# Build one MeshInstance3D per group, plus optional liquid mesh as sibling
	var groups: Array = data.get("groups", [])
	var occluder_root: Node3D = null
	for gi in groups.size():
		var g: Dictionary = groups[gi]
		if g.is_empty():
			continue
		var mesh_inst := _build_group_mesh(g, godot_materials)
		mesh_inst.name = "Group_%d" % gi
		root.add_child(mesh_inst)

		if BUILD_OCCLUDERS:
			var occluder_inst := _build_group_occluder(g, materials)
			if occluder_inst != null:
				if occluder_root == null:
					occluder_root = Node3D.new()
					occluder_root.name = "Occluders"
					root.add_child(occluder_root)
				occluder_inst.name = "Group_%d_Occluder" % gi
				occluder_root.add_child(occluder_inst)

		var liquid_dict: Variant = g.get("liquid", null)
		if liquid_dict is Dictionary and not (liquid_dict as Dictionary).is_empty():
			var liquid_inst := _build_group_liquid(liquid_dict)
			if liquid_inst != null:
				liquid_inst.name = "Group_%d_Liquid" % gi
				root.add_child(liquid_inst)

	# Doodad set 0 ($DefaultGlobal) — global decorations always active.
	# Non-default sets (Day/Night/etc) are not baked yet — see TODO below.
	_build_default_doodads(root, data, extracted_dir)

	return root


# Instantiates M2 doodads for set 0 as one MultiMesh per M2 path.
static func _build_default_doodads(root: Node3D, data: Dictionary,
		extracted_dir: String) -> void:

	var sets: Array = data.get("doodad_sets", [])
	var placements: Array = data.get("doodad_placements", [])
	if placements.is_empty() or sets.is_empty():
		return

	var first_set: Dictionary = sets[0]
	var start: int = int(first_set.get("start", 0))
	var count: int = int(first_set.get("count", 0))
	if count <= 0:
		return

	var groups: Dictionary = {}
	var end: int = mini(start + count, placements.size())
	for i in range(start, end):
		var p: Dictionary = placements[i]
		var rel_path: String = str(p.get("name", "")).replace("\\", "/").to_lower()
		if rel_path.is_empty():
			continue
		# WoW 3.3.5 stores .mdx/.mdl in MODN; the actual file on disk is .m2.
		if rel_path.ends_with(".mdx") or rel_path.ends_with(".mdl"):
			rel_path = rel_path.get_basename() + ".m2"

		var pos: Vector3 = p.get("pos", Vector3.ZERO)
		var rot_q: Quaternion = p.get("rot", Quaternion.IDENTITY)
		var scl: float = float(p.get("scale", 1.0))
		# MODD is already a local WMO doodad transform. Do not apply the ADT M2
		# yaw correction here; it rotates anchored effects like waterfalls away
		# from their authored cliff attachment points.
		var basis := Basis(rot_q).scaled(Vector3.ONE * maxf(scl, 0.001))
		var xform := Transform3D(basis, pos)
		if not groups.has(rel_path):
			groups[rel_path] = []
		(groups[rel_path] as Array).append(xform)

	if groups.is_empty():
		return

	var doodad_root := Node3D.new()
	doodad_root.name = "Doodads"
	root.add_child(doodad_root)

	for rel_path in groups.keys():
		var mesh := _get_or_load_m2_mesh(rel_path, extracted_dir)
		if mesh == null:
			continue
		var transforms: Array = groups[rel_path]
		var mm := MultiMesh.new()
		mm.transform_format = MultiMesh.TRANSFORM_3D
		mm.mesh = mesh
		mm.instance_count = transforms.size()
		for i in transforms.size():
			mm.set_instance_transform(i, transforms[i])

		var inst := MultiMeshInstance3D.new()
		inst.name = rel_path.get_file().get_basename()
		inst.multimesh = mm
		inst.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
		doodad_root.add_child(inst)

	if doodad_root.get_child_count() == 0:
		doodad_root.queue_free()


static func _get_or_load_m2_mesh(rel_path: String, extracted_dir: String) -> Mesh:
	var prototype: Node3D = _get_or_load_m2_prototype(rel_path, extracted_dir)
	if prototype == null:
		return null
	for child in prototype.get_children():
		if child is MeshInstance3D:
			return (child as MeshInstance3D).mesh
	return null


static func _get_or_load_m2_prototype(rel_path: String,
		extracted_dir: String) -> Node3D:

	if _m2_prototype_cache.has(rel_path):
		return _m2_prototype_cache[rel_path]
	if _m2_missing_cache.has(rel_path):
		return null

	var prototype: Node3D = _build_raw_m2_prototype(rel_path, extracted_dir)
	if prototype != null:
		_m2_prototype_cache[rel_path] = prototype
		return prototype

	# Fall back to pre-baked cache when the raw M2 is unavailable.
	var cache_paths := [
		"res://data/cache/m2_glb".path_join(rel_path.get_basename() + ".tscn"),
		"res://data/cache/m2_glb".path_join(rel_path.get_basename() + ".glb"),
	]
	for cache_path in cache_paths:
		if not ResourceLoader.exists(cache_path):
			continue
		var resource: Resource = load(cache_path)
		if resource is PackedScene:
			var node = (resource as PackedScene).instantiate()
			if node is Node3D:
				_m2_prototype_cache[rel_path] = node as Node3D
				return node as Node3D
		break

	_m2_missing_cache[rel_path] = true
	return null


static func _build_raw_m2_prototype(rel_path: String, extracted_dir: String) -> Node3D:
	if not ClassDB.class_exists("M2Loader"):
		return null

	var abs_path := ProjectSettings.globalize_path(extracted_dir.path_join(rel_path))
	if not FileAccess.file_exists(abs_path):
		return null

	var loader = ClassDB.instantiate("M2Loader")
	var m2_data: Dictionary = loader.call("load_m2", abs_path)
	if m2_data.is_empty() or (m2_data.get("vertices", PackedVector3Array()) as PackedVector3Array).is_empty():
		return null

	var prototype: Node3D = M2_BUILDER_SCRIPT.build(m2_data, extracted_dir)
	return prototype


static func _build_group_mesh(
		g: Dictionary,
		godot_mats: Array[Material]) -> MeshInstance3D:

	var verts:   PackedVector3Array = g.get("vertices", PackedVector3Array())
	var normals: PackedVector3Array = g.get("normals",  PackedVector3Array())
	var uvs:     PackedVector2Array = g.get("uvs",      PackedVector2Array())
	var colors:  PackedColorArray   = g.get("colors",   PackedColorArray())
	var indices: PackedInt32Array   = g.get("indices",  PackedInt32Array())
	var batches: Array              = g.get("batches",  [])
	var mesh := ArrayMesh.new()
	if verts.is_empty() or indices.is_empty():
		var empty := MeshInstance3D.new()
		empty.mesh = mesh
		empty.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
		return empty

	if batches.is_empty():
		batches = [{
			"index_start": 0,
			"index_count": indices.size(),
			"material_id": -1,
		}]

	for batch_variant in batches:
		if not (batch_variant is Dictionary):
			continue
		var batch: Dictionary = batch_variant
		var idx_start: int = int(batch.get("index_start", 0))
		var idx_count: int = int(batch.get("index_count", 0))
		var mat_id: int = int(batch.get("material_id", -1))
		if idx_count <= 0 or idx_start >= indices.size():
			continue

		var batch_indices := PackedInt32Array()
		for i in range(idx_start, mini(idx_start + idx_count, indices.size()), 3):
			if i + 2 >= indices.size():
				break
			# Reverse winding for the current local basis conversion.
			batch_indices.append(indices[i + 0])
			batch_indices.append(indices[i + 2])
			batch_indices.append(indices[i + 1])
		if batch_indices.is_empty():
			continue

		var arrays := []
		arrays.resize(Mesh.ARRAY_MAX)
		arrays[Mesh.ARRAY_VERTEX] = verts
		if normals.size() == verts.size():
			arrays[Mesh.ARRAY_NORMAL] = normals
		if uvs.size() == verts.size():
			arrays[Mesh.ARRAY_TEX_UV] = uvs
		if colors.size() == verts.size():
			arrays[Mesh.ARRAY_COLOR] = colors
		arrays[Mesh.ARRAY_INDEX] = batch_indices

		mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)

		var surf_idx := mesh.get_surface_count() - 1
		if mat_id >= 0 and mat_id < godot_mats.size():
			mesh.surface_set_material(surf_idx, godot_mats[mat_id])

	var mi := MeshInstance3D.new()
	mi.mesh = mesh
	mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
	return mi


static func _build_group_occluder(g: Dictionary, material_defs: Array) -> OccluderInstance3D:
	var verts: PackedVector3Array = g.get("vertices", PackedVector3Array())
	var indices: PackedInt32Array = g.get("indices", PackedInt32Array())
	if verts.is_empty() or indices.is_empty():
		return null

	var batches: Array = g.get("batches", [])
	var occluder_indices := PackedInt32Array()
	if batches.is_empty():
		for i in range(0, indices.size(), 3):
			if i + 2 >= indices.size():
				break
			occluder_indices.append(indices[i + 0])
			occluder_indices.append(indices[i + 2])
			occluder_indices.append(indices[i + 1])
	else:
		for batch_variant in batches:
			if not (batch_variant is Dictionary):
				continue
			var batch: Dictionary = batch_variant
			if not _material_can_occlude(int(batch.get("material_id", -1)), material_defs):
				continue
			var idx_start: int = int(batch.get("index_start", 0))
			var idx_count: int = int(batch.get("index_count", 0))
			if idx_count <= 0 or idx_start >= indices.size():
				continue
			for i in range(idx_start, mini(idx_start + idx_count, indices.size()), 3):
				if i + 2 >= indices.size():
					break
				occluder_indices.append(indices[i + 0])
				occluder_indices.append(indices[i + 2])
				occluder_indices.append(indices[i + 1])

	if occluder_indices.size() < OCCLUDER_MIN_TRIANGLES * 3:
		return null

	var occluder := ArrayOccluder3D.new()
	occluder.set_arrays(verts, occluder_indices)

	var inst := OccluderInstance3D.new()
	inst.set_occluder(occluder)
	return inst


static func _material_can_occlude(mat_id: int, material_defs: Array) -> bool:
	if mat_id < 0:
		return true
	if mat_id >= material_defs.size():
		return false
	var mat_def: Variant = material_defs[mat_id]
	if not (mat_def is Dictionary):
		return false
	return int((mat_def as Dictionary).get("blend_mode", 0)) == 0


static func _build_material(
		mat_def: Dictionary,
		textures: PackedStringArray,
		extracted_dir: String = "") -> Material:

	var blend_mode: int = mat_def.get("blend_mode", 0)
	var flags: int = int(mat_def.get("flags", 0))
	var shader_id: int = int(mat_def.get("shader", 0))
	var tex: Texture2D = null
	var tex2: Texture2D = null
	var tex3: Texture2D = null
	var tex_path := ""
	var tex2_path := ""
	var tex3_path := ""

	# Texture is loaded later by the scene assembler (needs BLP→Image conversion)
	# Store the path as metadata for the assembler to pick up
	var tex0_id: int = mat_def.get("texture0", -1)
	if tex0_id >= 0 and tex0_id < textures.size():
		tex_path = str(textures[tex0_id]).replace("\\", "/")
		tex = _load_texture(tex_path, extracted_dir)
	var tex1_id: int = mat_def.get("texture1", -1)
	if tex1_id >= 0 and tex1_id < textures.size():
		tex2_path = str(textures[tex1_id]).replace("\\", "/")
		tex2 = _load_texture(tex2_path, extracted_dir)
	var tex2_id: int = mat_def.get("texture2", -1)
	if tex2_id >= 0 and tex2_id < textures.size():
		tex3_path = str(textures[tex2_id]).replace("\\", "/")
		tex3 = _load_texture(tex3_path, extracted_dir)

	var diffuse_color: Color = mat_def.get("diffuse_color", Color.WHITE)
	var emissive_color: Color = mat_def.get("emissive_color", Color.BLACK)
	var secondary_color: Color = mat_def.get("color2", Color.WHITE)
	var mat := WOW_WMO_MATERIAL.build(
		tex,
		tex2,
		flags,
		shader_id,
		blend_mode,
		tex_path,
		tex2_path,
		diffuse_color,
		emissive_color,
		secondary_color,
		tex3,
		tex3_path)
	mat.set_meta("texture0_path", tex_path)
	mat.set_meta("texture1_path", tex2_path)
	mat.set_meta("texture2_path", tex3_path)
	mat.set_meta("wow_flags", flags)
	mat.set_meta("wow_shader", shader_id)

	return mat



# WMO MLIQ → MeshInstance3D in WMO-local coordinates.
# Corner & vertex grid are in WMO-local (Z-up); convert to Godot Y-up using the
# same basis change as the WMO geometry: (x, y, z)_local → (-y, z, -x)_godot.
static func _build_group_liquid(liquid: Dictionary) -> MeshInstance3D:
	var xverts: int = int(liquid.get("xverts", 0))
	var yverts: int = int(liquid.get("yverts", 0))
	var xtiles: int = int(liquid.get("xtiles", 0))
	var ytiles: int = int(liquid.get("ytiles", 0))
	if xverts <= 1 or yverts <= 1 or xtiles <= 0 or ytiles <= 0:
		return null

	var corner: Vector3 = liquid.get("corner", Vector3.ZERO)
	var unit: float = float(liquid.get("unit_size", 4.1666666))
	var heights: PackedFloat32Array = liquid.get("heights", PackedFloat32Array())
	var tiles: PackedByteArray = liquid.get("tiles", PackedByteArray())
	if heights.size() < xverts * yverts:
		return null

	var verts := PackedVector3Array()
	var nrms := PackedVector3Array()
	var uvs := PackedVector2Array()
	var indices := PackedInt32Array()

	# Pre-compute Godot-space position for each grid vertex.
	# WMO local (lx, ly, lz) → Godot (-ly, lz, -lx).
	var pos := PackedVector3Array()
	pos.resize(xverts * yverts)
	for vy in yverts:
		for vx in xverts:
			var lx := corner.x + float(vx) * unit
			var ly := corner.y + float(vy) * unit
			var lz := heights[vy * xverts + vx]
			pos[vy * xverts + vx] = Vector3(-ly, lz, -lx)

	for ty in ytiles:
		for tx in xtiles:
			var flag: int = 0
			if tiles.size() > ty * xtiles + tx:
				flag = int(tiles[ty * xtiles + tx])
			# wowdev: high bit (0x80) marks "don't render". Some sources also
			# treat 0x0F lower-nibble as "no liquid" — skip both cases.
			if (flag & 0x80) != 0:
				continue
			if (flag & 0x0F) == 0x0F:
				continue
			var i00 := ty * xverts + tx
			var i10 := ty * xverts + tx + 1
			var i01 := (ty + 1) * xverts + tx
			var i11 := (ty + 1) * xverts + tx + 1
			var base := verts.size()
			verts.append(pos[i00])
			verts.append(pos[i10])
			verts.append(pos[i11])
			verts.append(pos[i01])
			nrms.append(Vector3.UP)
			nrms.append(Vector3.UP)
			nrms.append(Vector3.UP)
			nrms.append(Vector3.UP)
			uvs.append(Vector2(float(tx) / maxf(1.0, float(xtiles)), float(ty) / maxf(1.0, float(ytiles))))
			uvs.append(Vector2(float(tx + 1) / maxf(1.0, float(xtiles)), float(ty) / maxf(1.0, float(ytiles))))
			uvs.append(Vector2(float(tx + 1) / maxf(1.0, float(xtiles)), float(ty + 1) / maxf(1.0, float(ytiles))))
			uvs.append(Vector2(float(tx) / maxf(1.0, float(xtiles)), float(ty + 1) / maxf(1.0, float(ytiles))))
			# Two triangles, winding chosen to face up after the (-y, z, -x) flip.
			indices.append(base + 0)
			indices.append(base + 1)
			indices.append(base + 2)
			indices.append(base + 0)
			indices.append(base + 2)
			indices.append(base + 3)

	if verts.is_empty() or indices.is_empty():
		return null

	var arrays := []
	arrays.resize(Mesh.ARRAY_MAX)
	arrays[Mesh.ARRAY_VERTEX] = verts
	arrays[Mesh.ARRAY_NORMAL] = nrms
	arrays[Mesh.ARRAY_TEX_UV] = uvs
	arrays[Mesh.ARRAY_INDEX]  = indices

	var mesh := ArrayMesh.new()
	mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
	mesh.surface_set_material(0, _wmo_liquid_material(int(liquid.get("material_id", 0))))

	var mi := MeshInstance3D.new()
	mi.mesh = mesh
	mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
	return mi


static func _wmo_liquid_material(material_id: int) -> Material:
	return WOW_LIQUID_MATERIAL.build_wmo(material_id)


static func _load_texture(rel_path: String, extracted_dir: String) -> Texture2D:
	if rel_path.is_empty() or extracted_dir.is_empty():
		return null

	var abs_path := ProjectSettings.globalize_path(extracted_dir.path_join(rel_path.replace("\\", "/")))
	if _texture_cache.has(abs_path):
		return _texture_cache[abs_path]

	if not ClassDB.class_exists("BLPLoader"):
		return null

	var loader = ClassDB.instantiate("BLPLoader")
	if loader == null:
		return null

	var img: Image = loader.call("load_image", abs_path)
	if img == null or img.is_empty():
		_texture_cache[abs_path] = null
		return null

	img.generate_mipmaps()
	var tex := ImageTexture.create_from_image(img)
	_texture_cache[abs_path] = tex
	return tex
