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

const WOW_M2_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_m2_material.gd")
const MATERIAL_FORMAT_VERSION := 2

static var _texture_cache: Dictionary = {}

static func build(data: Dictionary, extracted_dir: String = "") -> Node3D:
	var root := Node3D.new()
	root.name = "M2"

	var verts:   PackedVector3Array = data.get("vertices", PackedVector3Array())
	var normals: PackedVector3Array = data.get("normals",  PackedVector3Array())
	var uvs:     PackedVector2Array = data.get("uvs",      PackedVector2Array())
	var uvs2:    PackedVector2Array = data.get("uvs2",     PackedVector2Array())
	var indices: PackedInt32Array   = data.get("indices",  PackedInt32Array())
	var batches: Array              = data.get("batches",  [])

	if verts.is_empty() or indices.is_empty() or batches.is_empty():
		return root

	var textures:      PackedStringArray = data.get("textures",       PackedStringArray())
	var texture_types: PackedInt32Array  = data.get("texture_types",   PackedInt32Array())
	var texture_flags: PackedInt32Array  = data.get("texture_flags",   PackedInt32Array())
	var materials:     Array             = data.get("materials",       [])
	var tex_combos:    PackedInt32Array  = data.get("texture_combos",  PackedInt32Array())
	var tex_coord_combos: PackedInt32Array = data.get("texture_coord_combos", PackedInt32Array())
	var tex_weight_combos: PackedInt32Array = data.get("texture_weight_combos", PackedInt32Array())
	var tex_transform_combos: PackedInt32Array = data.get("texture_transform_combos", PackedInt32Array())
	var tex_combiner_combos: PackedInt32Array = data.get("texture_combiner_combos", PackedInt32Array())
	var m2_colors: Array = data.get("m2_colors", [])
	var tex_weights: PackedFloat32Array = data.get("texture_weights", PackedFloat32Array())
	var tex_transforms: Array = data.get("texture_transforms", [])
	var m2_flags: int = int(data.get("m2_flags", 0))
	var model_path:    String            = str(data.get("model_path",  ""))

	var mesh := ArrayMesh.new()

	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))
		var tc_idx: int        = int(batch.get("texture_combo_index", -1))

		if idx_count <= 0 or idx_start + idx_count > indices.size():
			continue

		var batch_indices := indices.slice(idx_start, idx_start + idx_count)
		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 uvs2.size() == verts.size():
			arrays[Mesh.ARRAY_TEX_UV2] = uvs2
		arrays[Mesh.ARRAY_INDEX] = batch_indices

		var surface_flags := _apply_billboard_custom_data(arrays, batch, verts.size())
		mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays, [], {}, surface_flags)

		var surf_idx := mesh.get_surface_count() - 1
		var mat_def: Dictionary = materials[mat_id] if mat_id >= 0 and mat_id < materials.size() else {}
		mesh.surface_set_material(
			surf_idx,
			_build_material(
				mat_def,
				tc_idx,
				textures,
				texture_types,
				texture_flags,
				tex_combos,
				tex_coord_combos,
				tex_weight_combos,
				tex_transform_combos,
				tex_combiner_combos,
				m2_colors,
				tex_weights,
				tex_transforms,
				m2_flags,
				extracted_dir,
				model_path,
				batch))

	if mesh.get_surface_count() == 0:
		return root
	mesh.set_meta("wow_m2_material_refresh_version", MATERIAL_FORMAT_VERSION)
	mesh.set_meta("wow_m2_source_path", model_path)

	var mi := MeshInstance3D.new()
	mi.name = "Mesh"
	mi.mesh = mesh
	root.add_child(mi)
	return root


static func _build_material(
		mat_def: Dictionary,
		texture_combo_index: int,
		textures: PackedStringArray,
		texture_types: PackedInt32Array,
		texture_flags: PackedInt32Array,
		tex_combos: PackedInt32Array,
		tex_coord_combos: PackedInt32Array,
		tex_weight_combos: PackedInt32Array,
		tex_transform_combos: PackedInt32Array,
		tex_combiner_combos: PackedInt32Array,
		m2_colors: Array,
		tex_weights: PackedFloat32Array,
		tex_transforms: Array,
		m2_flags: int,
		extracted_dir: String,
		model_path: String,
		batch: Dictionary = {}) -> Material:

	var flags: int = int(mat_def.get("flags", 0))
	var blend_mode: int = mat_def.get("blend_mode", 0)
	var tex_flags := 0
	var tex: Texture2D = null
	var tex2: Texture2D = null
	var tex3: Texture2D = null
	var tex4: Texture2D = null
	var tex2_flags := 0
	var tex3_flags := 0
	var tex4_flags := 0

	var resolved0 := _resolve_combo_texture(texture_combo_index, textures, texture_types, texture_flags, tex_combos, extracted_dir, model_path)
	tex = resolved0.get("texture", null)
	tex_flags = int(resolved0.get("flags", 0))
	var texture_count := maxi(1, int(batch.get("texture_count", 1)))
	if texture_count > 1:
		var resolved1 := _resolve_combo_texture(texture_combo_index + 1, textures, texture_types, texture_flags, tex_combos, extracted_dir, model_path)
		tex2 = resolved1.get("texture", null)
		tex2_flags = int(resolved1.get("flags", 0))
	if texture_count > 2:
		var resolved2 := _resolve_combo_texture(texture_combo_index + 2, textures, texture_types, texture_flags, tex_combos, extracted_dir, model_path)
		tex3 = resolved2.get("texture", null)
		tex3_flags = int(resolved2.get("flags", 0))
	if texture_count > 3:
		var resolved3 := _resolve_combo_texture(texture_combo_index + 3, textures, texture_types, texture_flags, tex_combos, extracted_dir, model_path)
		tex4 = resolved3.get("texture", null)
		tex4_flags = int(resolved3.get("flags", 0))

	var combiner := _resolve_combiner_state(blend_mode, texture_count, batch, tex_coord_combos, tex_combiner_combos, m2_flags)
	combiner["mesh_color"] = _resolve_mesh_color(batch, m2_colors, tex_weights, tex_weight_combos)
	combiner["tex_sample_alpha"] = _resolve_texture_sample_alpha(batch, tex_weights, tex_weight_combos)
	combiner["stage0_uv_transform"] = _resolve_texture_transform(batch, 0, tex_transform_combos, tex_transforms)
	combiner["stage1_uv_transform"] = _resolve_texture_transform(batch, 1, tex_transform_combos, tex_transforms)
	combiner["stage2_uv_transform"] = _resolve_texture_transform(batch, 2, tex_transform_combos, tex_transforms)
	combiner["stage3_uv_transform"] = _resolve_texture_transform(batch, 3, tex_transform_combos, tex_transforms)
	combiner["stage0_uv_scroll"] = _resolve_texture_scroll(batch, 0, tex_transform_combos, tex_transforms)
	combiner["stage1_uv_scroll"] = _resolve_texture_scroll(batch, 1, tex_transform_combos, tex_transforms)
	combiner["stage2_uv_scroll"] = _resolve_texture_scroll(batch, 2, tex_transform_combos, tex_transforms)
	combiner["stage3_uv_scroll"] = _resolve_texture_scroll(batch, 3, tex_transform_combos, tex_transforms)
	combiner["stage0_uv_rotation"] = _resolve_texture_rotation(batch, 0, tex_transform_combos, tex_transforms)
	combiner["stage1_uv_rotation"] = _resolve_texture_rotation(batch, 1, tex_transform_combos, tex_transforms)
	combiner["stage2_uv_rotation"] = _resolve_texture_rotation(batch, 2, tex_transform_combos, tex_transforms)
	combiner["stage3_uv_rotation"] = _resolve_texture_rotation(batch, 3, tex_transform_combos, tex_transforms)
	combiner["stage0_uv_rotation_speed"] = _resolve_texture_rotation_speed(batch, 0, tex_transform_combos, tex_transforms)
	combiner["stage1_uv_rotation_speed"] = _resolve_texture_rotation_speed(batch, 1, tex_transform_combos, tex_transforms)
	combiner["stage2_uv_rotation_speed"] = _resolve_texture_rotation_speed(batch, 2, tex_transform_combos, tex_transforms)
	combiner["stage3_uv_rotation_speed"] = _resolve_texture_rotation_speed(batch, 3, tex_transform_combos, tex_transforms)
	combiner["vertex_shader_id"] = _m2_vertex_shader_id(texture_count, int(batch.get("shader_id", 0)))
	combiner["pixel_shader_id"] = _m2_pixel_shader_id(texture_count, int(batch.get("shader_id", 0)))
	combiner["priority_plane"] = int(batch.get("priority_plane", 0))
	combiner["billboard_enabled"] = bool(batch.get("has_billboard", false)) or int(batch.get("billboard_vertex_count", 0)) > 0
	if _is_soft_waterfall_material(model_path, blend_mode, combiner):
		combiner["effect_alpha_scale"] = 1.12
		combiner["effect_alpha_power"] = 0.92
		combiner["uv_edge_fade_strength"] = 0.22

	var mat := WOW_M2_MATERIAL.build(
		tex,
		tex2,
		tex3,
		tex4,
		flags,
		blend_mode,
		tex_flags,
		tex2_flags,
		tex3_flags,
		tex4_flags,
		model_path.get_file().get_basename(),
		combiner)
	mat.set_meta("wow_flags", flags)
	mat.set_meta("wow_blend_mode", blend_mode)
	mat.set_meta("wow_m2_shader_id", int(batch.get("shader_id", 0)))
	mat.set_meta("wow_m2_vertex_shader_id", int(combiner.get("vertex_shader_id", 0)))
	mat.set_meta("wow_m2_pixel_shader_id", int(combiner.get("pixel_shader_id", 0)))
	mat.set_meta("wow_priority_plane", int(batch.get("priority_plane", 0)))
	mat.set_meta("wow_m2_billboard_enabled", bool(combiner.get("billboard_enabled", false)))
	mat.set_meta("wow_m2_billboard_bone_flags", int(batch.get("billboard_bone_flags", 0)))
	return mat


static func _apply_billboard_custom_data(arrays: Array, batch: Dictionary, vertex_count: int) -> int:
	var billboard_data: PackedFloat32Array = batch.get("billboard_data", PackedFloat32Array())
	if billboard_data.size() != vertex_count * 4:
		return 0
	arrays[Mesh.ARRAY_CUSTOM0] = billboard_data
	return int(Mesh.ARRAY_CUSTOM_RGBA_FLOAT) << int(Mesh.ARRAY_FORMAT_CUSTOM0_SHIFT)


static func _is_soft_waterfall_material(model_path: String, blend_mode: int, combiner: Dictionary) -> bool:
	if blend_mode != 2:
		return false
	var lower_path := model_path.to_lower()
	if not lower_path.contains("waterfall"):
		return false
	var scroll0: Vector2 = combiner.get("stage0_uv_scroll", Vector2.ZERO)
	var scroll1: Vector2 = combiner.get("stage1_uv_scroll", Vector2.ZERO)
	return scroll0.length_squared() > 0.000001 or scroll1.length_squared() > 0.000001


static func _m2_vertex_shader_id(texture_count: int, shader_id: int) -> int:
	if (shader_id & 0x8000) != 0:
		return _m2_shader_array_vertex_id(shader_id & 0x7fff)
	if texture_count == 1:
		if (shader_id & 0x80) != 0:
			return 1 # Diffuse_Env
		if (shader_id & 0x4000) != 0:
			return 10 # Diffuse_T2
		return 0 # Diffuse_T1

	if (shader_id & 0x80) != 0:
		if (shader_id & 0x8) != 0:
			return 5 # Diffuse_Env_Env
		return 4 # Diffuse_Env_T1
	if (shader_id & 0x8) != 0:
		return 3 # Diffuse_T1_Env
	if (shader_id & 0x4000) != 0:
		return 2 # Diffuse_T1_T2
	return 7 # Diffuse_T1_T1


static func _m2_pixel_shader_id(texture_count: int, shader_id: int) -> int:
	if (shader_id & 0x8000) != 0:
		return _m2_shader_array_pixel_id(shader_id & 0x7fff)
	if texture_count == 1:
		return 1 if (shader_id & 0x70) != 0 else 0

	if (shader_id & 0x70) != 0:
		match shader_id & 7:
			3:
				return 8 # Combiners_Mod_Add
			4:
				return 7 # Combiners_Mod_Mod2x
			6:
				return 9 # Combiners_Mod_Mod2xNA
			7:
				return 10 # Combiners_Mod_AddNA
			_:
				return 6 # Combiners_Mod_Mod

	match shader_id & 7:
		0:
			return 5 # Combiners_Opaque_Opaque
		3, 7:
			return 13 # Combiners_Opaque_AddAlpha
		4:
			return 3 # Combiners_Opaque_Mod2x
		6:
			return 4 # Combiners_Opaque_Mod2xNA
		_:
			return 2 # Combiners_Opaque_Mod


static func _m2_shader_array_vertex_id(shader_array_index: int) -> int:
	match shader_array_index:
		0:
			return 8
		1:
			return 0
		2:
			return 1
		3:
			return 1
		4:
			return 1
		5:
			return 1
		6:
			return 0
		7:
			return 12
		8:
			return 9
		9:
			return 12
		_:
			return 0


static func _m2_shader_array_pixel_id(shader_array_index: int) -> int:
	match shader_array_index:
		0:
			return 35
		1:
			return 0
		2:
			return 7
		3:
			return 1
		4:
			return 6
		5:
			return 4
		6:
			return 0
		7:
			return 7
		8:
			return 1
		9:
			return 6
		_:
			return 0


static func _resolve_combo_texture(
		combo_index: int,
		textures: PackedStringArray,
		texture_types: PackedInt32Array,
		texture_flags: PackedInt32Array,
		tex_combos: PackedInt32Array,
		extracted_dir: String,
		model_path: String) -> Dictionary:

	if combo_index < 0 or combo_index >= tex_combos.size():
		return {}
	var tex_idx: int = tex_combos[combo_index]
	if tex_idx < 0 or tex_idx >= textures.size():
		return {}
	var tex_path: String = str(textures[tex_idx]).replace("\\", "/")
	var tex_type := int(texture_types[tex_idx]) if tex_idx < texture_types.size() else 0
	var tex_flags := int(texture_flags[tex_idx]) if tex_idx < texture_flags.size() else 0
	if tex_path.is_empty():
		tex_path = _default_texture_path(model_path, tex_type)
	var tex: Texture2D = null
	if not tex_path.is_empty():
		tex = _load_texture(tex_path, extracted_dir)
	return {
		"texture": tex,
		"flags": tex_flags,
		"path": tex_path,
	}


static func _resolve_combiner_state(
		blend_mode: int,
		texture_count: int,
		batch: Dictionary,
		tex_coord_combos: PackedInt32Array,
		tex_combiner_combos: PackedInt32Array,
		m2_flags: int) -> Dictionary:

	var state := {
		"op0": 0 if blend_mode == 0 else 1,
		"op1": 1,
		"env0": false,
		"env1": false,
		"use_uv2_0": false,
		"use_uv2_1": false,
	}
	var shader_id := int(batch.get("shader_id", 0))
	var coord_index := int(batch.get("texture_coord_combo_index", 0))
	if (m2_flags & 0x8) != 0:
		for texture_index in mini(texture_count, 2):
			var op := 0 if texture_index == 0 and blend_mode == 0 else 1
			var combiner_index := shader_id + texture_index
			if combiner_index >= 0 and combiner_index < tex_combiner_combos.size():
				op = int(tex_combiner_combos[combiner_index])
			state["op%d" % texture_index] = op
			_apply_texture_coord_state(state, texture_index, coord_index + texture_index, tex_coord_combos)
	else:
		_apply_texture_coord_state(state, 0, coord_index, tex_coord_combos)
	return state


static func _apply_texture_coord_state(state: Dictionary, texture_index: int, combo_index: int, tex_coord_combos: PackedInt32Array) -> void:
	if combo_index < 0 or combo_index >= tex_coord_combos.size():
		return
	var coord := int(tex_coord_combos[combo_index])
	state["use_uv2_%d" % texture_index] = coord == 1
	state["env%d" % texture_index] = coord > 2


static func _resolve_mesh_color(
		batch: Dictionary,
		m2_colors: Array,
		tex_weights: PackedFloat32Array,
		tex_weight_combos: PackedInt32Array) -> Color:

	var color := Color.WHITE
	var color_index := int(batch.get("color_index", -1))
	if color_index >= 0 and color_index < m2_colors.size():
		var color_entry: Dictionary = m2_colors[color_index] if m2_colors[color_index] is Dictionary else {}
		color = color_entry.get("color", Color.WHITE)

	var weight_combo_index := int(batch.get("texture_weight_combo_index", -1))
	if weight_combo_index >= 0 and weight_combo_index < tex_weight_combos.size():
		var weight_index := int(tex_weight_combos[weight_combo_index])
		if weight_index >= 0 and weight_index < tex_weights.size():
			color.a *= clampf(tex_weights[weight_index], 0.0, 1.0)
	return color


static func _resolve_texture_sample_alpha(
		batch: Dictionary,
		tex_weights: PackedFloat32Array,
		tex_weight_combos: PackedInt32Array) -> Vector3:

	var result := Vector3.ONE
	var weight_combo_index := int(batch.get("texture_weight_combo_index", -1))
	for stage in 3:
		var combo_index := weight_combo_index + stage
		if combo_index < 0 or combo_index >= tex_weight_combos.size():
			continue
		var weight_index := int(tex_weight_combos[combo_index])
		if weight_index >= 0 and weight_index < tex_weights.size():
			result[stage] = clampf(tex_weights[weight_index], 0.0, 1.0)
	return result


static func _resolve_texture_transform(
		batch: Dictionary,
		stage: int,
		tex_transform_combos: PackedInt32Array,
		tex_transforms: Array) -> Vector4:

	var combo_index := int(batch.get("texture_transform_combo_index", -1)) + stage
	if combo_index < 0 or combo_index >= tex_transform_combos.size():
		return Vector4(1.0, 1.0, 0.0, 0.0)
	var transform_index := int(tex_transform_combos[combo_index])
	if transform_index < 0 or transform_index >= tex_transforms.size():
		return Vector4(1.0, 1.0, 0.0, 0.0)
	var transform: Dictionary = tex_transforms[transform_index] if tex_transforms[transform_index] is Dictionary else {}
	var scale: Vector2 = transform.get("scale", Vector2.ONE)
	var translation: Vector2 = transform.get("translation", Vector2.ZERO)
	return Vector4(scale.x, scale.y, translation.x, translation.y)


static func _resolve_texture_scroll(
		batch: Dictionary,
		stage: int,
		tex_transform_combos: PackedInt32Array,
		tex_transforms: Array) -> Vector2:

	var combo_index := int(batch.get("texture_transform_combo_index", -1)) + stage
	if combo_index < 0 or combo_index >= tex_transform_combos.size():
		return Vector2.ZERO
	var transform_index := int(tex_transform_combos[combo_index])
	if transform_index < 0 or transform_index >= tex_transforms.size():
		return Vector2.ZERO
	var transform: Dictionary = tex_transforms[transform_index] if tex_transforms[transform_index] is Dictionary else {}
	return transform.get("translation_speed", Vector2.ZERO)


static func _resolve_texture_rotation(
		batch: Dictionary,
		stage: int,
		tex_transform_combos: PackedInt32Array,
		tex_transforms: Array) -> Vector4:

	var combo_index := int(batch.get("texture_transform_combo_index", -1)) + stage
	if combo_index < 0 or combo_index >= tex_transform_combos.size():
		return Vector4(1.0, 0.0, 0.0, 1.0)
	var transform_index := int(tex_transform_combos[combo_index])
	if transform_index < 0 or transform_index >= tex_transforms.size():
		return Vector4(1.0, 0.0, 0.0, 1.0)
	var transform: Dictionary = tex_transforms[transform_index] if tex_transforms[transform_index] is Dictionary else {}
	return transform.get("rotation", Vector4(1.0, 0.0, 0.0, 1.0))


static func _resolve_texture_rotation_speed(
		batch: Dictionary,
		stage: int,
		tex_transform_combos: PackedInt32Array,
		tex_transforms: Array) -> float:

	var combo_index := int(batch.get("texture_transform_combo_index", -1)) + stage
	if combo_index < 0 or combo_index >= tex_transform_combos.size():
		return 0.0
	var transform_index := int(tex_transform_combos[combo_index])
	if transform_index < 0 or transform_index >= tex_transforms.size():
		return 0.0
	var transform: Dictionary = tex_transforms[transform_index] if tex_transforms[transform_index] is Dictionary else {}
	return float(transform.get("rotation_speed", 0.0))


static func _default_texture_path(model_path: String, texture_type: int) -> String:
	var normalized := model_path.replace("\\", "/")
	var rel_model := _relative_extracted_model_path(normalized)
	var rel_lower := rel_model.to_lower()
	if rel_lower.begins_with("item/objectcomponents/weapon/") and texture_type == 2:
		var stem := rel_model.get_file().get_basename()
		if stem.to_lower() == "axe_1h_horde_a_01":
			return "Item/ObjectComponents/Weapon/Axe_1H_Horde_A_01Gray.blp"
		if not stem.is_empty():
			return "Item/ObjectComponents/Weapon/%s.blp" % stem
	if rel_lower == "creature/wolf/wolf.m2" or rel_lower == "creature/wolf/wolf.mdx":
		if texture_type == 11:
			return "Creature/Wolf/WolfSkinCoyote.blp"
		if texture_type == 12:
			return "Creature/Wolf/WolfSkinCoyoteAlpha.blp"
	if rel_lower == "creature/boar/boar.m2" or rel_lower == "creature/boar/boar.mdx":
		if texture_type == 11:
			return "Creature/Boar/BoarSkinIvory.blp"
	if rel_lower == "creature/kobold/kobold.m2" or rel_lower == "creature/kobold/kobold.mdx":
		if texture_type == 11:
			return "Creature/Kobold/koboldskinAlbino.blp"
	if rel_lower == "creature/murloc/murloc.m2" or rel_lower == "creature/murloc/murloc.mdx":
		if texture_type == 11:
			return "Creature/Murloc/SahauginskinBlue.blp"
	return ""


static func _relative_extracted_model_path(path: String) -> String:
	var marker := "/data/extracted/"
	var lower := path.to_lower()
	var marker_pos := lower.find(marker)
	if marker_pos >= 0:
		return path.substr(marker_pos + marker.length())
	return path


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))
	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
