@tool
extends SceneTree

# =============================================================================
# validate_textures.gd — Texture Size / Mipmaps / UV2 Checker
# =============================================================================
# Scans all material and texture resources used in a scene and validates:
#   - Texture dimensions ≤ 1024 (1K)
#   - Mipmaps are enabled on all textures
#   - UV2 channel is present on meshes (for lightmapping)
#   - Texture format is appropriate
#
# Usage:
#   godot --headless --script client/scripts/validate_textures.gd -- res://path/to/map.tscn
#
# Exit codes:
#   0  — All texture checks passed
#   1  — One or more texture checks failed
#   2  — Scene file not found or cannot be loaded
# =============================================================================

const MAX_TEXTURE_SIZE := 1024
const MAX_TEXTURE_SIZE_LABEL := "1K (1024x1024)"

var _pass_count := 0
var _fail_count := 0
var _warning_count := 0
var _scene_path := ""
var _scene_instance: Node = null

# Collected data
var _textures_checked: Dictionary = {}   # resource_path -> info dict
var _overbudget_textures: Array[Dictionary] = []
var _no_mipmap_textures: Array[Dictionary] = []
var _meshes_without_uv2: Array[String] = []
var _meshes_with_uv2: Array[String] = []
var _mesh_count := 0
var _total_textures := 0
var _total_materials := 0
var _texture_memory_estimate := 0  # approximate total texture memory in bytes

# Unique set to avoid re-checking the same resource
var _checked_resources: Dictionary = {}


func _init() -> void:
	_parse_arguments()

	if _scene_path.is_empty():
		printerr("ERROR: No scene path provided. Usage: godot --headless --script validate_textures.gd -- res://path/to/map.tscn")
		quit(2)
		return

	print("")
	print("==========================================")
	print("  TEXTURE VALIDATOR")
	print("  Scene: ", _scene_path)
	print("==========================================")
	print("")

	print("  Texture budget:")
	print("    Max dimension:  ", MAX_TEXTURE_SIZE_LABEL)
	print("    Mipmaps:        Required")
	print("    UV2 channel:    Required for lightmapping")
	print("")

	var scene: PackedScene = _load_scene(_scene_path)
	if scene == null:
		printerr("ERROR: Scene not found: ", _scene_path)
		quit(2)
		return

	_scene_instance = _instantiate_scene(scene)
	if _scene_instance == null:
		printerr("ERROR: Failed to instantiate scene: ", _scene_path)
		quit(2)
		return

	root.add_child(_scene_instance)

	# Analyze
	_analyze_node(_scene_instance)

	# Run checks
	_run_texture_checks()
	_run_mipmap_checks()
	_run_uv2_checks()
	_print_texture_report()

	# Summary
	_print_summary()

	if _fail_count > 0:
		quit(1)
	else:
		quit(0)


# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------

func _parse_arguments() -> void:
	"""Extract scene path from user args (after --)."""
	var args: PackedStringArray = OS.get_cmdline_user_args()
	if not args.is_empty():
		_scene_path = args[0].strip_edges()
		return

	var full_args: PackedStringArray = OS.get_cmdline_args()
	var found_sep := false
	for i in range(full_args.size()):
		if full_args[i] == "--":
			found_sep = true
			continue
		if found_sep:
			_scene_path = full_args[i].strip_edges()
			break


# ---------------------------------------------------------------------------
# Scene helpers
# ---------------------------------------------------------------------------

func _load_scene(path: String) -> PackedScene:
	"""Load a .tscn/.scn file."""
	if not ResourceLoader.exists(path):
		if not path.ends_with(".tscn") and not path.ends_with(".scn"):
			path = path + ".tscn"
		if not ResourceLoader.exists(path):
			return null
	return ResourceLoader.load(path, "PackedScene", ResourceLoader.CACHE_MODE_REUSE)


func _instantiate_scene(scene: PackedScene) -> Node:
	return scene.instantiate()


# ---------------------------------------------------------------------------
# Check helpers
# ---------------------------------------------------------------------------

func _pass(msg: String) -> void:
	_pass_count += 1
	print("  ✓ ", msg)


func _fail(msg: String) -> void:
	_fail_count += 1
	print("  ✗ ", msg)


func _warn(msg: String) -> void:
	_warning_count += 1
	print("  ⚠ ", msg)


# ---------------------------------------------------------------------------
# Analysis engine
# ---------------------------------------------------------------------------

func _analyze_node(node: Node) -> void:
	"""Recursively walk scene, check materials on meshes."""
	var class_name := node.get_class()

	# MeshInstance3D — inspect its material overrides
	if class_name == "MeshInstance3D" or node.is_class("MeshInstance3D"):
		_mesh_count += 1
		_analyze_mesh_instance(node)

	# CSG nodes can also have materials / meshes
	if class_name in ["CSGCombiner3D", "CSGSphere3D", "CSGBox3D", "CSGCylinder3D",
		"CSGTorus3D", "CSGPolygon3D", "CSGMesh3D"]:
		_analyze_csg_node(node)

	# GeometryInstance3D base class
	if node.is_class("GeometryInstance3D"):
		var mat_override := node.get("material_override") if "material_override" in node else null
		if mat_override != null:
			_analyze_material(mat_override)

	for child in node.get_children():
		_analyze_node(child)


func _analyze_mesh_instance(mi: Node) -> void:
	"""Inspect a MeshInstance3D node for materials and UV2."""
	# Check mesh
	if "mesh" in mi and mi.mesh != null:
		var mesh = mi.mesh
		_analyze_mesh(mesh, mi.name)

	# Surface material overrides
	if "material_override" in mi and mi.material_override != null:
		_analyze_material(mi.material_override)

	# Per-surface materials
	if mi.has_method("get_surface_override_material"):
		var surface_count := 0
		if "mesh" in mi and mi.mesh != null and mi.mesh.has_method("get_surface_count"):
			surface_count = mi.mesh.get_surface_count()
		for s in range(surface_count):
			var mat := mi.get_surface_override_material(s)
			if mat != null:
				_analyze_material(mat)


func _analyze_csg_node(node: Node) -> void:
	"""Analyze materials on CSG nodes."""
	# CSG nodes have operation and material properties
	if "material" in node and node.material != null:
		_analyze_material(node.material)

	# Check CSGMesh3D which references a mesh
	if node.get_class() == "CSGMesh3D" and "mesh" in node and node.mesh != null:
		_analyze_mesh(node.mesh, node.name)


func _analyze_mesh(mesh, node_name: String) -> void:
	"""Analyze a mesh resource for UV2 and material slots."""
	if mesh == null:
		return

	var mesh_id := mesh.resource_path if "resource_path" in mesh and mesh.resource_path != "" else str(mesh.get_instance_id())
	if _checked_resources.has(mesh_id):
		return
	_checked_resources[mesh_id] = true

	# Check for UV2 channel
	var has_uv2 := false
	if mesh.has_method("get_surface_count"):
		var surface_count := mesh.get_surface_count()
		for s in range(surface_count):
			var arrays := mesh.surface_get_arrays(s)
			if arrays is Array and arrays.size() > 0:
				var uv2 := arrays[Mesh.ARRAY_TEX_UV2] as PackedVector2Array
				if uv2 != null and uv2.size() > 0:
					has_uv2 = true

	if has_uv2:
		_meshes_with_uv2.append(node_name)
	else:
		_meshes_without_uv2.append(node_name)

	# Check materials on the mesh
	if mesh.has_method("surface_get_material"):
		var surface_count := mesh.get_surface_count()
		for s in range(surface_count):
			var mat := mesh.surface_get_material(s)
			if mat != null:
				_analyze_material(mat)


func _analyze_material(mat) -> void:
	"""Recursively analyze a material and its textures."""
	if mat == null:
		return

	var mat_id := mat.resource_path if "resource_path" in mat and mat.resource_path != "" else str(mat.get_instance_id())
	if _checked_resources.has(mat_id):
		return
	_checked_resources[mat_id] = true
	_total_materials += 1

	var mat_class := mat.get_class()

	# StandardMaterial3D — check texture properties
	if mat_class == "StandardMaterial3D" or mat_class == "ORMMaterial3D":
		var tex_properties := [
			"albedo_texture", "metallic_texture", "roughness_texture",
			"normal_texture", "ambient_occlusion_texture", "emission_texture",
			"clearcoat_texture", "rim_texture", "transparency_texture",
			"heightmap_texture", "refraction_texture", "detail_albedo_texture",
			"detail_normal_texture", "detail_mask_texture", "subsurface_scattering_texture",
			"backlight_texture"
		]
		for prop in tex_properties:
			if prop in mat:
				var tex = mat.get(prop)
				if tex != null:
					_analyze_texture(tex)

	# ShaderMaterial — inspect shader parameters for textures
	elif mat_class == "ShaderMaterial":
		if mat.has_method("get_shader_parameter_list"):
			var param_list := mat.get_shader_parameter_list()
			for param in param_list:
				var param_name := param.name if "name" in param else str(param)
				var val = mat.get_shader_parameter(param_name)
				if val != null and (val is Texture2D or val is Texture3D or val is TextureLayered):
					_analyze_texture(val)

	# BaseMaterial3D (Godot 4 fallback)
	elif mat.is_class("BaseMaterial3D"):
		if "albedo_texture" in mat and mat.albedo_texture != null:
			_analyze_texture(mat.albedo_texture)
		if "metallic_texture" in mat and mat.metallic_texture != null:
			_analyze_texture(mat.metallic_texture)
		if "roughness_texture" in mat and mat.roughness_texture != null:
			_analyze_texture(mat.roughness_texture)
		if "normal_texture" in mat and mat.normal_texture != null:
			_analyze_texture(mat.normal_texture)


func _analyze_texture(tex) -> void:
	"""Analyze a single texture resource for size, mipmaps, and format."""
	if tex == null:
		return

	# Use resource path as unique key
	var tex_path := ""
	if "resource_path" in tex and tex.resource_path != "":
		tex_path = tex.resource_path
	else:
		tex_path = str(tex.get_instance_id())

	if _textures_checked.has(tex_path):
		return

	var tex_class := tex.get_class()
	var width := 0
	var height := 0
	var has_mipmaps := false
	var format := -1
	var format_name := "Unknown"

	# Texture2D
	if tex is Texture2D:
		width = tex.get_width()
		height = tex.get_height()
		has_mipmaps = tex.has_mipmaps() if tex.has_method("has_mipmaps") else false
		if tex.has_method("get_format"):
			format = tex.get_format()
			format_name = _get_format_name(format)
		_total_textures += 1

	# Texture3D
	elif tex is Texture3D:
		width = tex.get_width()
		height = tex.get_height()
		if tex.has_method("has_mipmaps"):
			has_mipmaps = tex.has_mipmaps()
		_total_textures += 1

	# TextureLayered
	elif tex is TextureLayered:
		if tex.has_method("get_width"):
			width = tex.get_width()
		if tex.has_method("get_height"):
			height = tex.get_height()
		_total_textures += 1

	var info := {
		"path": tex_path,
		"width": width,
		"height": height,
		"has_mipmaps": has_mipmaps,
		"format": format_name,
		"class": tex_class
	}
	_textures_checked[tex_path] = info

	# Estimate memory
	var bpp := _bits_per_pixel(format)
	_texture_memory_estimate += (width * height * bpp) / 8

	# Check over-budget
	var max_dim := maxi(width, height)
	if max_dim > MAX_TEXTURE_SIZE:
		_overbudget_textures.append(info)

	# Check mipmaps
	if not has_mipmaps:
		_no_mipmap_textures.append(info)


# ---------------------------------------------------------------------------
# Texture checks
# ---------------------------------------------------------------------------

func _run_texture_checks() -> void:
	"""Validate texture size budget."""
	print("--- Texture Size Checks ---")
	if _total_textures == 0:
		_warn("No textures found in scene (may be expected for procedural materials)")
		return

	_pass("Total textures found: " + str(_total_textures))

	if _overbudget_textures.size() == 0:
		_pass("All textures within " + MAX_TEXTURE_SIZE_LABEL + " limit")
	else:
		for tex_info in _overbudget_textures:
			var dim := str(tex_info.width) + "x" + str(tex_info.height)
			_fail("Texture over budget: " + _truncate_path(tex_info.path) + " (" + dim + ", max: " + MAX_TEXTURE_SIZE_LABEL + ")")


func _run_mipmap_checks() -> void:
	"""Validate that all textures have mipmaps enabled."""
	print("")
	print("--- Mipmap Checks ---")

	if _no_mipmap_textures.size() == 0:
		_pass("All textures have mipmaps enabled")
	else:
		for tex_info in _no_mipmap_textures:
			var dim := str(tex_info.width) + "x" + str(tex_info.height)
			_fail("Texture missing mipmaps: " + _truncate_path(tex_info.path) + " (" + dim + ", class: " + tex_info.class + ")")


func _run_uv2_checks() -> void:
	"""Validate UV2 channel presence on meshes."""
	print("")
	print("--- UV2 Channel Checks ---")

	if _mesh_count == 0:
		_warn("No MeshInstance3D nodes found")
		return

	_pass("Meshes found: " + str(_mesh_count))

	if _meshes_without_uv2.size() == 0:
		_pass("All " + str(_mesh_count) + " meshes have UV2 channel (good for lightmapping)")
	else:
		for mesh_name in _meshes_without_uv2:
			_fail("Mesh missing UV2 channel: " + mesh_name + " — lightmapping will not work correctly")


# ---------------------------------------------------------------------------
# Report
# ---------------------------------------------------------------------------

func _print_texture_report() -> void:
	"""Print detailed texture report."""
	print("")
	print("--- Texture Report ---")
	print("  Total materials:          ", _total_materials)
	print("  Total textures:           ", _total_textures)
	print("  Over-budget textures:     ", _overbudget_textures.size())
	print("  Missing mipmaps:          ", _no_mipmap_textures.size())
	print("  Total meshes:             ", _mesh_count)
	print("  Meshes with UV2:          ", _meshes_with_uv2.size())
	print("  Meshes without UV2:       ", _meshes_without_uv2.size())
	print("  Est. texture memory:      ", _format_bytes(_texture_memory_estimate))
	print("")

	# Print texture format distribution
	var format_counts: Dictionary = {}
	for tex_path in _textures_checked.keys():
		var info := _textures_checked[tex_path] as Dictionary
		var fmt := info.format
		if not format_counts.has(fmt):
			format_counts[fmt] = 0
		format_counts[fmt] += 1

	if format_counts.size() > 0:
		print("  Texture format distribution:")
		for fmt in format_counts.keys():
			print("    " + fmt + ": " + str(format_counts[fmt]))
		print("")


func _truncate_path(path: String) -> String:
	"""Truncate a resource path to fit in a single line."""
	if path.length() > 60:
		return "..." + path.substr(path.length() - 57)
	return path


func _get_format_name(format_id: int) -> String:
	"""Map Godot image format enum to a human-readable name."""
	match format_id:
		0: return "L8"
		1: return "LA8"
		2: return "R8"
		3: return "RG8"
		4: return "RGB8"
		5: return "RGBA8"
		6: return "RGBA4444"
		7: return "RGBA5551"
		8: return "RF"
		9: return "RGF"
		10: return "RGBF"
		11: return "RGBAF"
		12: return "RH"
		13: return "RGH"
		14: return "RGBH"
		15: return "RGBAH"
		16: return "RGBE9995"
		17: return "DXT1 (BC1)"
		18: return "DXT3 (BC2)"
		19: return "DXT5 (BC3)"
		20: return "RGTC (BC4)"
		21: return "RGTC (BC5)"
		22: return "BPTC (BC6H)"
		23: return "BPTC (BC7)"
		24: return "ETC"
		25: return "ETC2 R11"
		26: return "ETC2 RG11"
		27: return "ETC2 RGB8"
		28: return "ETC2 RGBA8"
		29: return "PVRTC2"
		30: return "PVRTC4"
		31: return "ASTC 4x4"
		32: return "ASTC 8x8"
		33: return "Max"
	return "Unknown(" + str(format_id) + ")"


func _bits_per_pixel(format_id: int) -> int:
	"""Estimate bits per pixel for a given format."""
	match format_id:
		0, 2: return 8     # L8, R8
		1, 3: return 16    # LA8, RG8
		4: return 24       # RGB8
		5, 6, 7: return 32 # RGBA8, RGBA4444, RGBA5551
		8: return 32       # RF
		9: return 64       # RGF
		10: return 96      # RGBF
		11: return 128     # RGBAF
		12: return 16      # RH (half)
		13: return 32      # RGH
		14: return 48      # RGBH
		15: return 64      # RGBAH
		16: return 32      # RGBE9995
		17, 18, 19: return 4  # DXT/BC compressed — 4bpp
		20, 21: return 4      # BC4/BC5 — 4bpp (BC5 is 8bpp but rare)
		22: return 8          # BC6H — 8bpp
		23: return 8          # BC7 — 8bpp
		24, 25, 26, 27, 28: return 4  # ETC/ETC2 — 4bpp
		29, 30: return 2          # PVRTC — 2bpp
		31: return 8              # ASTC 4x4 — 8bpp
		32: return 2              # ASTC 8x8 — 2bpp
	return 32


func _format_bytes(bytes: int) -> String:
	"""Format bytes into human-readable string."""
	if bytes < 1024:
		return str(bytes) + " B"
	elif bytes < 1024 * 1024:
		return str(stepify(float(bytes) / 1024.0, 0.1)) + " KB"
	else:
		return str(stepify(float(bytes) / (1024.0 * 1024.0), 0.1)) + " MB"


func maxi(a: int, b: int) -> int:
	return a if a > b else b


func mini(a: int, b: int) -> int:
	return a if a < b else b


# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------

func _print_summary() -> void:
	"""Print final summary."""
	print("==========================================")
	print("  TEXTURE VALIDATION SUMMARY")
	print("==========================================")
	print("  Scene:                    ", _scene_path)
	print("  Textures checked:         ", _total_textures)
	print("  Materials checked:        ", _total_materials)
	print("  Over-budget textures:     ", _overbudget_textures.size())
	print("  Missing mipmaps:          ", _no_mipmap_textures.size())
	print("  Meshes w/o UV2:          ", _meshes_without_uv2.size())
	print("------------------------------------------")
	print("  Passed:                   ", _pass_count)
	print("  Failed:                   ", _fail_count)
	print("  Warnings:                 ", _warning_count)
	print("------------------------------------------")
	if _fail_count > 0:
		print("  RESULT:                   FAILED  ❌")
		printerr("Validation FAILED — ", _fail_count, " texture check(s) failed.")
	else:
		print("  RESULT:                   PASSED  ✅")
	print("==========================================")
	print("")
