extends SceneTree

const OBSERVER_SCRIPT := preload(
	"res://src/render/m2/m2_cached_animation_resource_observer.gd"
)
const PROTOTYPE_CACHE_SCRIPT := preload(
	"res://src/render/m2/m2_prototype_cache_state.gd"
)
const PIPELINE_SCRIPT := preload(
	"res://src/render/m2/m2_animation_load_pipeline_state.gd"
)
const OBSERVER_PATH := (
	"res://src/render/m2/m2_cached_animation_resource_observer.gd"
)
const NATIVE_OBSERVER_PATH := (
	"res://src/render/m2/m2_native_animation_resource_observer.gd"
)
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
const FIXTURE_DIRECTORY := "user://verify_m2_cached_animation_observer"

var _fixture_paths: PackedStringArray = PackedStringArray()


func _initialize() -> void:
	var failures: Array[String] = []
	_verify_observation_lifecycle(failures)
	_verify_path_policy_and_order(failures)
	_verify_glb_safety(failures)
	_verify_source_boundaries(failures)
	var elapsed_milliseconds := _verify_bounded_timing(failures)
	_remove_fixtures()
	if not failures.is_empty():
		for failure in failures:
			push_error("M2_CACHED_ANIMATION_RESOURCE_OBSERVER: %s" % failure)
		quit(1)
		return
	print(
		"M2_CACHED_ANIMATION_RESOURCE_OBSERVER PASS "
		+ "cases=33 iterations=20000 elapsed_ms=%.3f" % elapsed_milliseconds
	)
	quit(0)


func _verify_observation_lifecycle(failures: Array[String]) -> void:
	var observer: RefCounted = OBSERVER_SCRIPT.new()
	var prototype_cache: RefCounted = PROTOTYPE_CACHE_SCRIPT.new()
	var pipeline: RefCounted = PIPELINE_SCRIPT.new()
	var invalid_snapshot: RefCounted = observer.call(
		"observe", "", "res://none", 3, PackedStringArray(),
		PackedStringArray(), prototype_cache, pipeline
	)
	_expect_false(
		bool(invalid_snapshot.call("animation_request_pending")),
		"invalid path is not pending",
		failures
	)
	var prototype := Node3D.new()
	prototype_cache.call(
		"adopt_animated_prototype",
		"creature/fish/a.m2",
		prototype
	)
	var cached_snapshot: RefCounted = observer.call(
		"observe", "creature/fish/a.m2", "res://none", 3,
		PackedStringArray(["creature/fish/"]), PackedStringArray(),
		prototype_cache, pipeline
	)
	_expect_true(
		is_same(cached_snapshot.call("animated_prototype"), prototype),
		"cached prototype identity",
		failures
	)
	prototype_cache.call("mark_animation_static", "creature/fish/static.m2")
	var static_snapshot: RefCounted = observer.call(
		"observe", "creature/fish/static.m2", "res://none", 3,
		PackedStringArray(["creature/fish/"]), PackedStringArray(),
		prototype_cache, pipeline
	)
	_expect_false(
		bool(static_snapshot.call("animation_request_pending")),
		"static outcome is not pending",
		failures
	)
	pipeline.call(
		"remember_request",
		"creature/fish/pending.m2",
		"res://pending.glb"
	)
	var pending_snapshot: RefCounted = observer.call(
		"observe", "creature/fish/pending.m2", "res://none", 3,
		PackedStringArray(["creature/fish/"]), PackedStringArray(),
		prototype_cache, pipeline
	)
	_expect_true(
		bool(pending_snapshot.call("animation_request_pending")),
		"existing request remains pending",
		failures
	)
	var unavailable_snapshot: RefCounted = observer.call(
		"observe", "creature/fish/unavailable.m2", "res://none", 3,
		PackedStringArray(["creature/fish/"]), PackedStringArray(),
		prototype_cache, pipeline
	)
	_expect_false(
		bool(unavailable_snapshot.call("animation_request_pending")),
		"missing candidate is not pending",
		failures
	)
	_expect_true(
		bool(prototype_cache.call(
			"is_animation_static",
			"creature/fish/unavailable.m2"
		)),
		"missing candidate marks static",
		failures
	)
	pipeline.call("clear")
	prototype_cache.call("clear_and_release")


func _verify_path_policy_and_order(failures: Array[String]) -> void:
	var observer: RefCounted = OBSERVER_SCRIPT.new()
	_expect_false(
		bool(observer.call(
			"is_animation_path_allowed", "creature/fish/a.m2",
			PackedStringArray(), PackedStringArray()
		)),
		"empty allowlist rejects",
		failures
	)
	_expect_true(
		bool(observer.call(
			"is_animation_path_allowed", "Creature/Fish/A.M2",
			PackedStringArray(["  creature/fish/  "]), PackedStringArray()
		)),
		"allowlist strips and ignores case",
		failures
	)
	_expect_false(
		bool(observer.call(
			"is_animation_path_allowed", "creature/fish/boss/a.m2",
			PackedStringArray(["creature/fish/"]),
			PackedStringArray([" FISH/BOSS "])
		)),
		"denylist wins",
		failures
	)
	var paths: PackedStringArray = observer.call(
		"cache_resource_paths",
		"res://cache",
		"World/Critter/Foo.M2"
	)
	_expect_true(
		paths == PackedStringArray([
			"res://cache/World/Critter/Foo.glb",
			"res://cache/world/critter/foo.glb",
			"res://cache/Foo.glb",
			"res://cache/foo.glb",
		]),
		"historical GLB candidate order",
		failures
	)


func _verify_glb_safety(failures: Array[String]) -> void:
	var observer: RefCounted = OBSERVER_SCRIPT.new()
	_expect_false(
		bool(observer.call(
			"glb_cache_is_safe_for_runtime_animation",
			"user://missing.glb",
			3
		)),
		"missing GLB rejects",
		failures
	)
	var pivot_path := _write_glb_fixture(
		"pivot.glb",
		_make_gltf(2, "pivot_prefix_v1", true)
	)
	_expect_true(
		bool(observer.call(
			"glb_cache_is_safe_for_runtime_animation", pivot_path, 3
		)),
		"pivot-prefix schema accepts",
		failures
	)
	var legacy_path := _write_glb_fixture(
		"legacy.glb",
		_make_gltf(1, "", true)
	)
	_expect_true(
		bool(observer.call(
			"glb_cache_is_safe_for_runtime_animation", legacy_path, 3
		)),
		"legacy empty schema accepts",
		failures
	)
	var unknown_path := _write_glb_fixture(
		"unknown.glb",
		_make_gltf(1, "future_schema", true)
	)
	_expect_false(
		bool(observer.call(
			"glb_cache_is_safe_for_runtime_animation", unknown_path, 3
		)),
		"unknown schema rejects",
		failures
	)
	var complex_path := _write_glb_fixture(
		"complex.glb",
		_make_gltf(4, "pivot_prefix_v1", true)
	)
	_expect_false(
		bool(observer.call(
			"glb_cache_is_safe_for_runtime_animation", complex_path, 3
		)),
		"primitive limit rejects",
		failures
	)
	_expect_true(
		bool(observer.call(
			"glb_cache_is_safe_for_runtime_animation", complex_path, 0
		)),
		"non-positive primitive limit disables cap",
		failures
	)
	var static_path := _write_glb_fixture(
		"static.glb",
		_make_gltf(1, "pivot_prefix_v1", false)
	)
	_expect_false(
		bool(observer.call(
			"glb_cache_is_safe_for_runtime_animation", static_path, 3
		)),
		"GLB without animations rejects",
		failures
	)
	_expect_equal(
		int(observer.call("glb_primitive_count", _make_gltf(5, "", true))),
		5,
		"primitive count retained",
		failures
	)
	_expect_string_equal(
		String(observer.call(
			"glb_animation_schema",
			_make_gltf(1, "pivot_prefix_v1", true)
		)),
		"pivot_prefix_v1",
		"schema retained",
		failures
	)


func _verify_source_boundaries(failures: Array[String]) -> void:
	var loader_source := FileAccess.get_file_as_string(LOADER_PATH)
	var observer_source := FileAccess.get_file_as_string(OBSERVER_PATH)
	var native_observer_source := FileAccess.get_file_as_string(NATIVE_OBSERVER_PATH)
	_expect_true(
		loader_source.contains("_m2_animation_resource_observer.observe("),
		"loader delegates cached observation",
		failures
	)
	for removed_function in [
		"func _get_or_load_m2_animated_prototype",
		"func _request_m2_animation_load",
		"func _find_m2_animated_glb_cache_path",
		"func _glb_cache_is_safe_for_runtime_animation",
	]:
		_expect_false(
			loader_source.contains(removed_function),
			"loader omits %s" % removed_function,
			failures
		)
	_expect_true(
		loader_source.contains("_m2_native_animation_resource_observer.observe("),
		"loader delegates native observation",
		failures
	)
	_expect_true(
		native_observer_source.contains("func is_native_animation_candidate("),
		"native observer retains native candidate policy",
		failures
	)
	_expect_true(
		observer_source.contains("ResourceLoader.load_threaded_request("),
		"observer owns request admission",
		failures
	)
	for forbidden_token in [
		"queue_free",
		"RenderingServer",
		"WorkerThreadPool",
		"M2_NATIVE_ANIMATED_BUILDER",
		"M2RawModelRepository",
		"RenderBudgetScheduler",
	]:
		_expect_false(
			observer_source.contains(forbidden_token),
			"observer omits %s" % forbidden_token,
			failures
		)


func _verify_bounded_timing(failures: Array[String]) -> float:
	var observer: RefCounted = OBSERVER_SCRIPT.new()
	var allowlist := PackedStringArray(["creature/fish/", "world/critter/"])
	var denylist := PackedStringArray(["boss"])
	var started_microseconds := Time.get_ticks_usec()
	for iteration in range(20000):
		var path := "Creature/Fish/model_%d.m2" % (iteration % 64)
		observer.call(
			"is_animation_path_allowed",
			path,
			allowlist,
			denylist
		)
		observer.call("cache_resource_paths", "res://cache", path)
	var elapsed_milliseconds := (
		float(Time.get_ticks_usec() - started_microseconds) / 1000.0
	)
	_expect_true(
		elapsed_milliseconds < 1000.0,
		"20,000 policy/path observations remain bounded",
		failures
	)
	return elapsed_milliseconds


func _make_gltf(
	primitive_count: int,
	schema: String,
	has_animation: bool
) -> Dictionary:
	var primitives: Array[Dictionary] = []
	for _index in range(primitive_count):
		primitives.append({})
	var gltf := {
		"asset": {"version": "2.0", "extras": {}},
		"meshes": [{"primitives": primitives}],
		"animations": [{}] if has_animation else [],
	}
	if not schema.is_empty():
		(gltf["asset"]["extras"] as Dictionary)[
			"openwc_m2_anim_schema"
		] = schema
	return gltf


func _write_glb_fixture(file_name: String, gltf: Dictionary) -> String:
	var absolute_directory := ProjectSettings.globalize_path(FIXTURE_DIRECTORY)
	DirAccess.make_dir_recursive_absolute(absolute_directory)
	var resource_path := FIXTURE_DIRECTORY.path_join(file_name)
	var absolute_path := ProjectSettings.globalize_path(resource_path)
	var json_bytes := JSON.stringify(gltf).to_utf8_buffer()
	while json_bytes.size() % 4 != 0:
		json_bytes.append(0x20)
	var file := FileAccess.open(absolute_path, FileAccess.WRITE)
	if file == null:
		return resource_path
	file.store_32(0x46546c67)
	file.store_32(2)
	file.store_32(20 + json_bytes.size())
	file.store_32(json_bytes.size())
	file.store_32(0x4e4f534a)
	file.store_buffer(json_bytes)
	file.close()
	_fixture_paths.append(absolute_path)
	return resource_path


func _remove_fixtures() -> void:
	for absolute_path in _fixture_paths:
		if FileAccess.file_exists(absolute_path):
			DirAccess.remove_absolute(absolute_path)
	var absolute_directory := ProjectSettings.globalize_path(FIXTURE_DIRECTORY)
	if DirAccess.dir_exists_absolute(absolute_directory):
		DirAccess.remove_absolute(absolute_directory)


func _expect_true(
	condition: bool,
	label: String,
	failures: Array[String]
) -> void:
	if not condition:
		failures.append(label)


func _expect_false(
	condition: bool,
	label: String,
	failures: Array[String]
) -> void:
	_expect_true(not condition, label, failures)


func _expect_equal(
	actual: int,
	expected: int,
	label: String,
	failures: Array[String]
) -> void:
	if actual != expected:
		failures.append("%s expected=%d actual=%d" % [label, expected, actual])


func _expect_string_equal(
	actual: String,
	expected: String,
	label: String,
	failures: Array[String]
) -> void:
	if actual != expected:
		failures.append("%s expected=%s actual=%s" % [label, expected, actual])
