extends SceneTree

const DEFAULT_MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json"
const M2_NATIVE_ANIMATED_BUILDER := preload("res://addons/mpq_extractor/loaders/m2_native_animated_builder.gd")
const WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT := preload("res://src/render/environment/world_environment_snapshot.gd")
const SHUTDOWN_DRAIN_FRAMES := 2


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


func _capture_async() -> void:
	var args := OS.get_cmdline_user_args()
	var manifest_path := _arg(args, "--manifest", DEFAULT_MANIFEST_PATH)
	var manifest := _load_json(manifest_path)
	if manifest.is_empty():
		quit(1)
		return

	var output_dir := _arg(args, "--output", "user://render_baseline")
	var only := _arg(args, "--only", "").to_lower()
	var wait_seconds := float(_arg(args, "--wait", str(manifest.get("default_wait_seconds", 8.0))))
	var measure_seconds := float(_arg(args, "--measure", str(manifest.get("default_measure_seconds", 3.0))))
	var revision := _arg(args, "--revision", "worktree")
	var cache_state := _arg(args, "--cache-state", "existing")
	var camera_fov_override := float(_arg(args, "--camera-fov", str(manifest.get("camera_fov", 62.0))))
	var camera_yaw_offset_degrees := float(_arg(args, "--camera-yaw-offset", "0.0"))
	var camera_pitch_offset_degrees := float(_arg(args, "--camera-pitch-offset", "0.0"))
	var single_pass := args.has("--single-pass")
	var headless := DisplayServer.get_name().to_lower() == "headless"
	var dry_run := args.has("--dry-run") or headless
	var viewport: Array = manifest.get("viewport", [1280, 900])
	var viewport_width := int(_arg(args, "--viewport-width", str(viewport[0])))
	var viewport_height := int(_arg(args, "--viewport-height", str(viewport[1])))
	get_root().size = Vector2i(maxi(16, viewport_width), maxi(16, viewport_height))

	var abs_output_dir := ProjectSettings.globalize_path(output_dir)
	DirAccess.make_dir_recursive_absolute(abs_output_dir)
	if headless and not args.has("--dry-run"):
		print("RENDER_CHECKPOINT dry-run: headless display cannot capture viewport PNGs.")

	var scene_path := String(manifest.get("scene", ""))
	var packed: PackedScene = load(scene_path)
	if packed == null:
		push_error("Cannot load streaming scene: %s" % scene_path)
		quit(1)
		return
	var world = packed.instantiate()
	if not (world is Node3D):
		push_error("Streaming scene root is not Node3D: %s" % scene_path)
		quit(1)
		return

	var checkpoints: Array = manifest.get("checkpoints", [])
	if checkpoints.is_empty():
		push_error("Render baseline manifest has no checkpoints")
		quit(1)
		return
	var first: Dictionary = checkpoints[0]
	var camera := Camera3D.new()
	camera.name = "CheckpointCamera"
	camera.current = true
	camera.fov = camera_fov_override
	camera.far = 50000.0
	camera.position = _vector3(first.get("camera", [0.0, 0.0, 0.0]))
	(world as Node3D).add_child(camera)
	var render_facade := world.get_node_or_null("WorldRenderFacade")
	if render_facade == null:
		push_error("Streaming scene has no WorldRenderFacade")
		quit(1)
		return
	render_facade.set("streaming_focus_source_path", NodePath("../CheckpointCamera"))
	world.set("debug_streaming", true)
	world.set("runtime_stats_enabled", true)
	get_root().add_child(world)
	await process_frame
	await process_frame
	camera.make_current()

	var player := world.get_node_or_null("ThirdPersonPlayer") as Node3D
	if player != null:
		player.set_process(false)
		player.set_physics_process(false)
		var visual := player.get_node_or_null("Visual") as Node3D
		if visual != null:
			visual.visible = false

	var report := {
		"schema_version": 1,
		"manifest": manifest_path,
		"profile": manifest.get("profile", "Blizzlike335"),
		"revision": revision,
		"created_utc": Time.get_datetime_string_from_system(true, true),
		"dry_run": dry_run,
		"cache_state": cache_state,
		"camera_fov": camera_fov_override,
		"camera_yaw_offset_degrees": camera_yaw_offset_degrees,
		"camera_pitch_offset_degrees": camera_pitch_offset_degrees,
		"environment": _environment_metadata(),
		"comparison_budgets": manifest.get("comparison_budgets", {}),
		"cache_contract": manifest.get("cache_contract", {}),
		"cache_inventory": _cache_inventory(),
		"results": [],
	}
	var captured := 0
	var passes := ["cold_process", "warm_revisit"]
	if single_pass:
		passes = ["cold_process"]
	if dry_run:
		passes = ["dry_run"]

	for pass_name in passes:
		for checkpoint_variant in checkpoints:
			if not (checkpoint_variant is Dictionary):
				continue
			var checkpoint: Dictionary = checkpoint_variant
			var checkpoint_name := String(checkpoint.get("name", "checkpoint"))
			if not only.is_empty() and not checkpoint_name.to_lower().contains(only):
				continue

			var probe := _create_probe(checkpoint, dry_run)
			if probe != null:
				(world as Node3D).add_child(probe)
				probe.global_position = _vector3(checkpoint.get("target", [0.0, 0.0, 0.0]))
				probe.scale = Vector3.ONE * float(checkpoint.get("probe_scale", 1.0))

			camera.global_position = _vector3(checkpoint.get("camera", [0.0, 0.0, 0.0]))
			_orient_camera_without_roll(camera, _vector3(checkpoint.get("target", [0.0, 0.0, 0.0])))
			_apply_camera_pose_offsets(camera, camera_yaw_offset_degrees, camera_pitch_offset_degrees)
			camera.make_current()
			if player != null:
				player.global_position = _vector3(checkpoint.get("player", checkpoint.get("target", [0.0, 0.0, 0.0])))
			_apply_environment_time(render_facade, float(checkpoint.get("time_hours", 13.0)))
			render_facade.call("refresh_streaming_focus", true)

			if dry_run:
				print("RENDER_CHECKPOINT dry_run name=%s coverage=%s camera=%s target=%s yaw_offset=%.2f pitch_offset=%.2f time=%.2f" % [
					checkpoint_name,
					str(checkpoint.get("coverage", [])),
					camera.global_position,
					_vector3(checkpoint.get("target", [0.0, 0.0, 0.0])),
					camera_yaw_offset_degrees,
					camera_pitch_offset_degrees,
					float(checkpoint.get("time_hours", 13.0)),
				])
				(report["results"] as Array).append(_result_record(checkpoint, pass_name, 0.0, {}, ""))
				captured += 1
				if probe != null:
					probe.queue_free()
				continue

			var load_started := Time.get_ticks_usec()
			await create_timer(maxf(wait_seconds, 0.0)).timeout
			var load_time_ms := float(Time.get_ticks_usec() - load_started) / 1000.0
			var metrics := await _measure_frames(maxf(measure_seconds, 0.1), render_facade)
			await RenderingServer.frame_post_draw

			var image := get_root().get_texture().get_image()
			var file_name := "%s__%s.png" % [checkpoint_name, pass_name]
			var abs_path := abs_output_dir.path_join(file_name)
			var err := image.save_png(abs_path)
			if err != OK:
				push_error("Failed to save checkpoint %s to %s (err=%d)" % [checkpoint_name, abs_path, err])
				quit(1)
				return
			var sha256 := FileAccess.get_sha256(abs_path)
			(report["results"] as Array).append(_result_record(checkpoint, pass_name, load_time_ms, metrics, sha256))
			print("RENDER_CHECKPOINT saved %s sha256=%s" % [abs_path, sha256])
			captured += 1
			if probe != null:
				probe.queue_free()
				await process_frame

	var report_path := abs_output_dir.path_join("report.json")
	var report_file := FileAccess.open(report_path, FileAccess.WRITE)
	if report_file == null:
		push_error("Cannot write render baseline report: %s" % report_path)
		quit(1)
		return
	report_file.store_string(JSON.stringify(report, "  "))
	report_file.close()
	print("RENDER_BASELINE report=%s results=%d" % [report_path, captured])

	world.queue_free()
	# SceneTree deletion is deferred. Allow the world and its queued children
	# to run their shutdown paths before engine teardown.
	for unused_frame in SHUTDOWN_DRAIN_FRAMES:
		await process_frame
	if captured <= 0:
		push_error("No checkpoints selected. --only filter was: %s" % only)
		quit(1)
		return
	quit(0)


func _measure_frames(seconds: float, render_facade: Node) -> Dictionary:
	var frame_times: Array[float] = []
	var deadline := Time.get_ticks_usec() + int(seconds * 1000000.0)
	var previous := Time.get_ticks_usec()
	while Time.get_ticks_usec() < deadline:
		await process_frame
		var now := Time.get_ticks_usec()
		frame_times.append(float(now - previous) / 1000.0)
		previous = now
	frame_times.sort()
	var snapshot := {}
	if render_facade.has_method("renderer_metrics_snapshot"):
		snapshot = render_facade.call("renderer_metrics_snapshot")
	return {
		"frames": frame_times.size(),
		"frame_ms_p50": _percentile(frame_times, 0.50),
		"frame_ms_p95": _percentile(frame_times, 0.95),
		"frame_ms_p99": _percentile(frame_times, 0.99),
		"max_hitch_ms": frame_times[-1] if not frame_times.is_empty() else 0.0,
		"memory_static_bytes": int(Performance.get_monitor(Performance.MEMORY_STATIC)),
		"video_memory_bytes": int(Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED)),
		"draw_calls": int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)),
		"objects": int(Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME)),
		"streaming": snapshot,
	}


func _orient_camera_without_roll(camera: Camera3D, target_position: Vector3) -> void:
	var forward := (target_position - camera.global_position).normalized()
	var right := forward.cross(Vector3.UP).normalized()
	if right.is_zero_approx():
		right = Vector3.RIGHT
	var corrected_up := right.cross(forward).normalized()
	camera.global_basis = Basis(right, corrected_up, -forward)


func _apply_camera_pose_offsets(camera: Camera3D, yaw_offset_degrees: float, pitch_offset_degrees: float) -> void:
	camera.global_rotate(Vector3.UP, deg_to_rad(yaw_offset_degrees))
	camera.rotate_object_local(Vector3.RIGHT, deg_to_rad(pitch_offset_degrees))


func _result_record(checkpoint: Dictionary, pass_name: String, load_time_ms: float, metrics: Dictionary, sha256: String) -> Dictionary:
	return {
		"name": checkpoint.get("name", "checkpoint"),
		"coverage": checkpoint.get("coverage", []),
		"pass": pass_name,
		"camera": checkpoint.get("camera", []),
		"target": checkpoint.get("target", []),
		"player": checkpoint.get("player", []),
		"time_hours": checkpoint.get("time_hours", 13.0),
		"load_time_ms": load_time_ms,
		"metrics": metrics,
		"png_sha256": sha256,
	}


func _create_probe(checkpoint: Dictionary, dry_run: bool) -> Node3D:
	if bool(checkpoint.get("diagnostic_spawn_marker", false)):
		return null if dry_run else _create_diagnostic_spawn_marker(checkpoint)
	var rel_path := String(checkpoint.get("probe_model", ""))
	if rel_path.is_empty() or dry_run:
		return null
	if not ClassDB.class_exists("M2Loader"):
		push_warning("M2Loader unavailable; skipping probe model %s" % rel_path)
		return null
	var extracted_dir := "res://data/extracted"
	var abs_path := ProjectSettings.globalize_path(extracted_dir.path_join(rel_path))
	if not FileAccess.file_exists(abs_path):
		push_warning("Probe model missing: %s" % abs_path)
		return null
	var loader = ClassDB.instantiate("M2Loader")
	var data: Dictionary = loader.call("load_m2_animated", abs_path)
	if data.is_empty():
		push_warning("Probe model could not be parsed: %s" % rel_path)
		return null
	return M2_NATIVE_ANIMATED_BUILDER.build(data, extracted_dir)


## Creates a renderer-native marker whose origin is the exact mapped server
## spawn. The vertical mast grows upward from that origin, so terrain occlusion
## remains visible instead of being hidden by an always-on-top overlay.
func _create_diagnostic_spawn_marker(checkpoint: Dictionary) -> Node3D:
	var marker_root := Node3D.new()
	marker_root.name = "DiagnosticServerSpawnMarker"
	var marker_height := maxf(float(checkpoint.get("diagnostic_marker_height", 6.0)), 0.5)
	var marker_radius := maxf(float(checkpoint.get("diagnostic_marker_radius", 0.35)), 0.05)

	var marker_material := StandardMaterial3D.new()
	marker_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	marker_material.albedo_color = Color(1.0, 0.05, 0.7, 1.0)
	marker_material.emission_enabled = true
	marker_material.emission = Color(1.0, 0.01, 0.35, 1.0)

	var mast_mesh := CylinderMesh.new()
	mast_mesh.height = marker_height
	mast_mesh.top_radius = marker_radius
	mast_mesh.bottom_radius = marker_radius
	mast_mesh.material = marker_material
	var mast := MeshInstance3D.new()
	mast.name = "SpawnOriginMast"
	mast.mesh = mast_mesh
	mast.position.y = marker_height * 0.5
	marker_root.add_child(mast)

	var origin_mesh := SphereMesh.new()
	origin_mesh.radius = marker_radius * 1.8
	origin_mesh.height = marker_radius * 3.6
	origin_mesh.material = marker_material
	var origin := MeshInstance3D.new()
	origin.name = "SpawnOrigin"
	origin.mesh = origin_mesh
	marker_root.add_child(origin)
	return marker_root


func _apply_environment_time(render_facade: Node, time_hours: float) -> void:
	var environment_snapshot: RefCounted = WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT.at_time_of_day(time_hours)
	if not bool(render_facade.call("apply_environment_snapshot", environment_snapshot)):
		push_error("Render checkpoint could not apply environment time %.3f" % time_hours)


func _environment_metadata() -> Dictionary:
	return {
		"godot_version": Engine.get_version_info(),
		"os": OS.get_name(),
		"os_version": OS.get_version(),
		"cpu": OS.get_processor_name(),
		"logical_cpu_count": OS.get_processor_count(),
		"rendering_method": RenderingServer.get_current_rendering_method(),
		"rendering_driver": RenderingServer.get_current_rendering_driver_name(),
		"video_adapter": RenderingServer.get_video_adapter_name(),
		"video_vendor": RenderingServer.get_video_adapter_vendor(),
		"video_api": RenderingServer.get_video_adapter_api_version(),
		"memory": OS.get_memory_info(),
		"viewport": [get_root().size.x, get_root().size.y],
	}


func _cache_inventory() -> Dictionary:
	var paths := [
		"res://data/cache/baked_terrain_v2",
		"res://data/cache/baked_terrain_stream_v1",
		"res://data/cache/terrain_splat_v1",
		"res://data/cache/terrain_control_splat_v3",
		"res://data/cache/m2_glb",
		"res://data/cache/wmo_tscn",
		"res://data/cache/wmo_render_v1",
	]
	var inventory := {}
	for path in paths:
		var dir := DirAccess.open(path)
		inventory[path] = {
			"present": dir != null,
			"file_count": _count_files(path) if dir != null else 0,
		}
	return inventory


func _count_files(path: String) -> int:
	var dir := DirAccess.open(path)
	if dir == null:
		return 0
	var count := dir.get_files().size()
	for child in dir.get_directories():
		count += _count_files(path.path_join(child))
	return count


func _percentile(sorted_values: Array[float], fraction: float) -> float:
	if sorted_values.is_empty():
		return 0.0
	var index := clampi(int(ceil(fraction * sorted_values.size())) - 1, 0, sorted_values.size() - 1)
	return sorted_values[index]


func _load_json(path: String) -> Dictionary:
	var file := FileAccess.open(path, FileAccess.READ)
	if file == null:
		push_error("Cannot open JSON file: %s" % path)
		return {}
	var parsed = JSON.parse_string(file.get_as_text())
	if not (parsed is Dictionary):
		push_error("JSON root must be an object: %s" % path)
		return {}
	return parsed


func _vector3(value: Variant) -> Vector3:
	if value is Array and value.size() >= 3:
		return Vector3(float(value[0]), float(value[1]), float(value[2]))
	return Vector3.ZERO


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