class_name M2CachedAnimationResourceObserver
extends RefCounted

## Observes or requests the cached-GLB animated phase of one M2 build step.

const M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT := preload(
	"res://src/render/m2/m2_build_resource_snapshot.gd"
)


## Returns a snapshot containing the exact cached prototype or pending state.
## Invalid composition and terminal failures produce a non-pending snapshot.
func observe(
	normalized_relative_path: String,
	cache_directory: String,
	maximum_primitive_count: int,
	allowlist_patterns: PackedStringArray,
	denylist_patterns: PackedStringArray,
	prototype_cache_state: RefCounted,
	load_pipeline_state: RefCounted,
	debug_logging_enabled: bool = false
) -> RefCounted:
	var empty_snapshot: RefCounted = M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(
		normalized_relative_path,
		null,
		false
	)
	if (
		normalized_relative_path.is_empty()
		or prototype_cache_state == null
		or load_pipeline_state == null
	):
		return empty_snapshot

	var cached_prototype := prototype_cache_state.call(
		"find_animated_prototype",
		normalized_relative_path
	) as Node3D
	if cached_prototype != null:
		return M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(
			normalized_relative_path,
			cached_prototype,
			false
		)
	if bool(prototype_cache_state.call(
		"is_animation_static",
		normalized_relative_path
	)):
		return empty_snapshot
	if bool(load_pipeline_state.call("has_request", normalized_relative_path)):
		return M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(
			normalized_relative_path,
			null,
			true
		)

	var cache_resource_path := find_eligible_glb_cache_path(
		normalized_relative_path,
		cache_directory,
		maximum_primitive_count,
		allowlist_patterns,
		denylist_patterns,
		debug_logging_enabled
	)
	if cache_resource_path.is_empty():
		prototype_cache_state.call(
			"mark_animation_static",
			normalized_relative_path
		)
		return empty_snapshot

	var request_error := ResourceLoader.load_threaded_request(
		cache_resource_path,
		"",
		false,
		ResourceLoader.CACHE_MODE_REUSE
	)
	if request_error == OK or request_error == ERR_BUSY:
		load_pipeline_state.call(
			"remember_request",
			normalized_relative_path,
			cache_resource_path
		)
		return M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(
			normalized_relative_path,
			null,
			true
		)

	prototype_cache_state.call("mark_animation_static", normalized_relative_path)
	return empty_snapshot


## Returns the first eligible historical `.glb` cache candidate.
func find_eligible_glb_cache_path(
	normalized_relative_path: String,
	cache_directory: String,
	maximum_primitive_count: int,
	allowlist_patterns: PackedStringArray,
	denylist_patterns: PackedStringArray,
	debug_logging_enabled: bool = false
) -> String:
	if not is_animation_path_allowed(
		normalized_relative_path,
		allowlist_patterns,
		denylist_patterns
	):
		return ""
	for cache_resource_path in cache_resource_paths(
		cache_directory,
		normalized_relative_path
	):
		if not ResourceLoader.exists(cache_resource_path):
			continue
		if glb_cache_is_safe_for_runtime_animation(
			cache_resource_path,
			maximum_primitive_count,
			debug_logging_enabled
		):
			return cache_resource_path
	return ""


## Applies the historical stripped, case-insensitive allow/deny substring policy.
func is_animation_path_allowed(
	normalized_relative_path: String,
	allowlist_patterns: PackedStringArray,
	denylist_patterns: PackedStringArray
) -> bool:
	if normalized_relative_path.is_empty() or allowlist_patterns.is_empty():
		return false
	var lowercase_path := normalized_relative_path.to_lower()
	var allowed := false
	for pattern in allowlist_patterns:
		var needle := String(pattern).strip_edges().to_lower()
		if not needle.is_empty() and lowercase_path.contains(needle):
			allowed = true
			break
	if not allowed:
		return false
	for pattern in denylist_patterns:
		var needle := String(pattern).strip_edges().to_lower()
		if not needle.is_empty() and lowercase_path.contains(needle):
			return false
	return true


## Returns historical nested/lowercase/basename `.glb` candidates without I/O.
func cache_resource_paths(
	cache_directory: String,
	relative_path: String
) -> PackedStringArray:
	var normalized := relative_path.replace("\\", "/")
	var lowercase := normalized.to_lower()
	var stems := [
		normalized.get_basename(),
		lowercase.get_basename(),
		normalized.get_file().get_basename(),
		lowercase.get_file().get_basename(),
	]
	var result := PackedStringArray()
	for stem in stems:
		if stem.is_empty():
			continue
		var cache_resource_path := cache_directory.path_join(stem + ".glb")
		if not result.has(cache_resource_path):
			result.append(cache_resource_path)
	return result


## Checks animation presence, primitive limit and accepted OpenWC schema.
func glb_cache_is_safe_for_runtime_animation(
	cache_resource_path: String,
	maximum_primitive_count: int,
	debug_logging_enabled: bool = false
) -> bool:
	var gltf := read_glb_json(cache_resource_path)
	if gltf.is_empty():
		return false
	var animations: Array = gltf.get("animations", [])
	if animations.is_empty():
		return false
	var primitive_count := glb_primitive_count(gltf)
	if maximum_primitive_count > 0 and primitive_count > maximum_primitive_count:
		return false
	var schema := glb_animation_schema(gltf)
	if schema == "pivot_prefix_v1" or schema.is_empty():
		return true
	if debug_logging_enabled:
		print(
			"M2_ANIM_REJECT schema=%s primitives=%d cache=%s"
			% [schema, primitive_count, cache_resource_path]
		)
	return false


## Reads the JSON chunk of a version-2 GLB or returns an empty Dictionary.
func read_glb_json(cache_resource_path: String) -> Dictionary:
	var absolute_path := ProjectSettings.globalize_path(cache_resource_path)
	if not FileAccess.file_exists(absolute_path):
		return {}
	var file := FileAccess.open(absolute_path, FileAccess.READ)
	if file == null or file.get_length() < 20:
		return {}
	var magic := file.get_32()
	var version := file.get_32()
	file.get_32()
	if magic != 0x46546c67 or version != 2:
		return {}
	var json_length := int(file.get_32())
	var chunk_type := file.get_32()
	if json_length <= 0 or chunk_type != 0x4e4f534a:
		return {}
	var parsed: Variant = JSON.parse_string(
		file.get_buffer(json_length).get_string_from_utf8()
	)
	return parsed as Dictionary if parsed is Dictionary else {}


## Counts all glTF Mesh primitives using the historical safety rule.
func glb_primitive_count(gltf: Dictionary) -> int:
	var count := 0
	var meshes: Array = gltf.get("meshes", [])
	for mesh_variant in meshes:
		if mesh_variant is Dictionary:
			var primitives: Array = (mesh_variant as Dictionary).get(
				"primitives",
				[]
			)
			count += primitives.size()
	return count


## Returns the OpenWC animation schema marker from parsed glTF metadata.
func glb_animation_schema(gltf: Dictionary) -> String:
	var asset: Dictionary = gltf.get("asset", {})
	var extras: Dictionary = asset.get("extras", {})
	return String(extras.get("openwc_m2_anim_schema", ""))
