extends SceneTree

## Reports published scene-tree geometry around calibrated renderer cameras.
## Usage: godot --headless --path . --script res://src/tools/probe_render_camera_occluders.gd --
##   [--wait 3.0] [--output user://render_camera_occluders/report.json]

const MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json"
const MAX_REPORTED_INTERSECTIONS := 20


func _initialize() -> void:
	_run_async.call_deferred()


func _run_async() -> void:
	var arguments := OS.get_cmdline_user_args()
	var wait_seconds := float(_argument(arguments, "--wait", "3.0"))
	var output_path := _argument(arguments, "--output", "user://render_camera_occluders/report.json")
	var only_filter := _argument(arguments, "--only", "").to_lower()
	var manifest := _load_json(MANIFEST_PATH)
	var packed_scene: PackedScene = load(String(manifest.get("scene", "")))
	if packed_scene == null:
		push_error("CAMERA_OCCLUDER_PROBE: cannot load streaming scene")
		quit(1)
		return
	var world := packed_scene.instantiate() as Node3D
	var camera := Camera3D.new()
	camera.name = "OccluderProbeCamera"
	camera.current = true
	world.add_child(camera)
	var render_facade := world.get_node_or_null("WorldRenderFacade")
	if render_facade == null:
		push_error("CAMERA_OCCLUDER_PROBE: streaming scene has no WorldRenderFacade")
		quit(1)
		return
	render_facade.set("streaming_focus_source_path", NodePath("../%s" % camera.name))
	world.set("debug_streaming", false)
	get_root().add_child(world)
	await process_frame
	await process_frame

	var results: Array[Dictionary] = []
	for checkpoint_variant in manifest.get("checkpoints", []):
		if not (checkpoint_variant is Dictionary):
			continue
		var checkpoint: Dictionary = checkpoint_variant
		if not checkpoint.has("reference_wow_camera"):
			continue
		var checkpoint_name := String(checkpoint.get("name", "checkpoint"))
		if not only_filter.is_empty() and not checkpoint_name.to_lower().contains(only_filter):
			continue
		var camera_position := _vector3(checkpoint.get("camera", []))
		var target_position := _vector3(checkpoint.get("target", []))
		camera.global_position = camera_position
		render_facade.call("refresh_streaming_focus", true)
		await create_timer(maxf(0.1, wait_seconds)).timeout
		var geometry_nodes: Array[Node3D] = []
		_collect_geometry_nodes(world, geometry_nodes)
		var containing: Array[Dictionary] = []
		var intersecting: Array[Dictionary] = []
		for geometry_node in geometry_nodes:
			var world_aabb := _geometry_world_aabb(geometry_node)
			if world_aabb.size.is_zero_approx():
				continue
			var record := _geometry_record(geometry_node, world_aabb, camera_position)
			if world_aabb.has_point(camera_position):
				containing.append(record)
			var intersection = world_aabb.intersects_segment(camera_position, target_position)
			if intersection != null:
				record["intersection_distance"] = camera_position.distance_to(intersection as Vector3)
				intersecting.append(record)
		intersecting.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
			return float(a.intersection_distance) < float(b.intersection_distance))
		if intersecting.size() > MAX_REPORTED_INTERSECTIONS:
			intersecting.resize(MAX_REPORTED_INTERSECTIONS)
		var result := {
			"name": checkpoint_name,
			"geometry_node_count": geometry_nodes.size(),
			"camera_containing_geometry": containing,
			"segment_intersecting_geometry": intersecting,
		}
		results.append(result)
		print("CAMERA_OCCLUDERS name=%s geometry=%d containing=%d segment=%d" % [
			checkpoint_name, geometry_nodes.size(), containing.size(), intersecting.size()])

	var report := {
		"schema_version": 1,
		"created_utc": Time.get_datetime_string_from_system(true, true),
		"wait_seconds": wait_seconds,
		"coverage": "scene_tree_only; RenderingServer RID-only instances are excluded",
		"results": results,
	}
	if not _write_json(output_path, report):
		quit(1)
		return
	world.queue_free()
	quit(0 if not results.is_empty() else 1)


func _collect_geometry_nodes(node: Node, output: Array[Node3D]) -> void:
	if node is MeshInstance3D:
		var mesh_instance := node as MeshInstance3D
		if mesh_instance.mesh != null and not mesh_instance.name.begins_with("TileLOD"):
			output.append(mesh_instance)
	elif node is MultiMeshInstance3D:
		var multimesh_instance := node as MultiMeshInstance3D
		if multimesh_instance.multimesh != null:
			output.append(multimesh_instance)
	for child in node.get_children():
		_collect_geometry_nodes(child, output)


func _geometry_world_aabb(node: Node3D) -> AABB:
	if node is MeshInstance3D:
		return node.global_transform * (node as MeshInstance3D).mesh.get_aabb()
	if node is MultiMeshInstance3D:
		return node.global_transform * (node as MultiMeshInstance3D).multimesh.get_aabb()
	return AABB()


func _geometry_record(node: Node3D, world_aabb: AABB, camera_position: Vector3) -> Dictionary:
	var node_path := String(node.get_path())
	var category := "geometry"
	if node_path.contains("/M2s/") or node is MultiMeshInstance3D:
		category = "m2"
	elif node_path.contains("/WMOs/"):
		category = "wmo"
	return {
		"category": category,
		"node_path": node_path,
		"distance_to_center": camera_position.distance_to(world_aabb.get_center()),
		"aabb_position": _vector3_array(world_aabb.position),
		"aabb_size": _vector3_array(world_aabb.size),
	}


func _vector3(value_variant) -> Vector3:
	if not (value_variant is Array) or value_variant.size() != 3:
		return Vector3.ZERO
	return Vector3(float(value_variant[0]), float(value_variant[1]), float(value_variant[2]))


func _vector3_array(value: Vector3) -> Array[float]:
	return [value.x, value.y, value.z]


func _argument(arguments: PackedStringArray, name: String, default_value: String) -> String:
	var index := arguments.find(name)
	if index >= 0 and index + 1 < arguments.size():
		return arguments[index + 1]
	return default_value


func _load_json(path: String) -> Dictionary:
	var file := FileAccess.open(path, FileAccess.READ)
	if file == null:
		return {}
	var parsed = JSON.parse_string(file.get_as_text())
	return parsed if parsed is Dictionary else {}


func _write_json(path: String, value: Dictionary) -> bool:
	var absolute_path := ProjectSettings.globalize_path(path)
	if DirAccess.make_dir_recursive_absolute(absolute_path.get_base_dir()) != OK:
		return false
	var file := FileAccess.open(absolute_path, FileAccess.WRITE)
	if file == null:
		return false
	file.store_string(JSON.stringify(value, "  "))
	return true
