extends RefCounted

# Pas de `class_name`: un nom global neuf n'existe qu'apres une passe d'editeur,
# et l'editeur de l'auteur peut etre ouvert. Les appelants preloadent ce script.

## Contrat sonore, cote Godot.
##
## La liste des evenements, leurs bus et leurs defauts techniques ne sont PAS
## ecrits ici: ils viennent de `res://data/audio_contract.json`, que le
## gestionnaire PHP lit aussi. Une seule liste, donc aucune divergence possible
## entre l'outil qui ecrit les donnees et le jeu qui les lit.
##
## Ce script ne joue rien. Il repond a « cet evenement existe-t-il, sur quel bus,
## avec quels defauts » et normalise les donnees d'auteur. La lecture appartient
## a AudioDirector, la selection a AudioResolver.

const CONTRACT_PATH := "res://data/audio_contract.json"

static var _contract: Dictionary = {}
static var _load_error := ""


## Le contrat, charge une fois. Un echec n'est pas fatal: le jeu doit rester
## jouable sans son, donc on rend un contrat vide et on note l'erreur.
static func contract() -> Dictionary:
	if not _contract.is_empty():
		return _contract
	var file := FileAccess.open(CONTRACT_PATH, FileAccess.READ)
	if file == null:
		_load_error = "Contrat sonore introuvable: " + CONTRACT_PATH
		return {}
	var json := JSON.new()
	if json.parse(file.get_as_text()) != OK:
		_load_error = "Contrat sonore illisible ligne %d: %s" % [json.get_error_line(), json.get_error_message()]
		return {}
	if not json.data is Dictionary or not (json.data as Dictionary).get("events", null) is Dictionary:
		_load_error = "Contrat sonore sans table d'evenements."
		return {}
	_contract = json.data
	return _contract


static func load_error() -> String:
	return _load_error


static func events() -> Dictionary:
	return contract().get("events", {})


static func has_event(event_key: String) -> bool:
	return events().has(event_key)


static func event_info(event_key: String) -> Dictionary:
	var found: Variant = events().get(event_key, {})
	return found if found is Dictionary else {}


static func event_bus(event_key: String) -> String:
	return str(event_info(event_key).get("bus", "SFX"))


static func event_is_spatial(event_key: String) -> bool:
	return bool(event_info(event_key).get("spatial", false))


static func event_is_loop(event_key: String) -> bool:
	return bool(event_info(event_key).get("loop", false))


static func event_priority(event_key: String) -> int:
	return int(event_info(event_key).get("priority", 50))


static func channels() -> Array:
	return contract().get("channels", [])


static func contexts() -> Array:
	return contract().get("contexts", [])


static func scope_keys(scope: String) -> Array:
	var scopes: Dictionary = contract().get("scopes", {})
	var found: Variant = scopes.get(scope, {})
	return (found as Dictionary).get("keys", []) if found is Dictionary else []


## La cle globale sur laquelle retombe une cle courte de cette portee.
static func scope_event(scope: String, key: String) -> String:
	var scopes: Dictionary = contract().get("scopes", {})
	var found: Variant = scopes.get(scope, {})
	var prefix := str((found as Dictionary).get("prefix", "")) if found is Dictionary else ""
	return prefix + key


## Defaut technique d'un champ: reglage propre a l'evenement, sinon defaut global.
static func cue_default(event_key: String, field: String) -> Variant:
	var per_event: Variant = event_info(event_key).get("defaults", {})
	if per_event is Dictionary and (per_event as Dictionary).has(field):
		return (per_event as Dictionary)[field]
	var defaults: Dictionary = contract().get("cue_defaults", {})
	return defaults.get(field, null)


static func channel_default(field: String) -> Variant:
	var defaults: Dictionary = contract().get("channel_defaults", {})
	return defaults.get(field, null)


static func limit(name: String) -> Array:
	var limits: Dictionary = contract().get("limits", {})
	var found: Variant = limits.get(name, null)
	return found if found is Array and (found as Array).size() == 2 else [-INF, INF]


# ------------------------------------------------------------------ lecture

## Le catalogue de clips. `null` signale une donnee invalide: le catalogue
## refuse alors le fichier entier, comme pour les visuels.
static func normalize_clips(value: Variant) -> Variant:
	if value is Array and (value as Array).is_empty():
		value = {}
	if not value is Dictionary:
		return null
	var kinds: Array = contract().get("clip_kinds", [])
	var root := str(contract().get("asset_root", "res://assets/audio/"))
	var result := {}
	for raw_id: Variant in value:
		var id := str(raw_id)
		var raw: Variant = (value as Dictionary)[raw_id]
		if not raw is Dictionary:
			return null
		var entry: Dictionary = raw
		var path := str(entry.get("path", ""))
		if not path.begins_with(root) or path.contains(".."):
			return null
		var kind := str(entry.get("kind", "sfx"))
		if not kinds.has(kind):
			return null
		var tags: Array[String] = []
		var raw_tags: Variant = entry.get("tags", [])
		if raw_tags is Array:
			for raw_tag: Variant in raw_tags:
				tags.append(str(raw_tag))
		result[id] = {
			"display_name": str(entry.get("display_name", id)),
			"path": path,
			"kind": kind,
			"tags": tags,
			"duration_seconds": float(entry.get("duration_seconds", 0.0)),
		}
	return result


static func _normalize_number(value: Variant, field: String, fallback: float) -> Variant:
	if value == null:
		return fallback
	if not (value is float or value is int):
		return null
	var number := float(value)
	if not is_finite(number):
		return null
	var bounds := limit(field)
	if number < float(bounds[0]) or number > float(bounds[1]):
		return null
	return number


static func _normalize_variant(value: Variant, clips: Dictionary, continuous: bool) -> Variant:
	if not value is Dictionary:
		return null
	var entry: Dictionary = value
	var clip := str(entry.get("clip", ""))
	if clip.is_empty() or not clips.has(clip):
		return null
	var weight: Variant = _normalize_number(entry.get("weight", null), "weight", 1.0)
	var volume: Variant = _normalize_number(entry.get("volume_db", null), "volume_db",
		float(channel_default("volume_db")) if continuous else -6.0)
	if weight == null or volume == null:
		return null
	var result := {"clip": clip, "weight": float(weight), "volume_db": float(volume)}
	if continuous:
		return result
	var pitch_min: Variant = _normalize_number(entry.get("pitch_min", null), "pitch", 1.0)
	var pitch_max: Variant = _normalize_number(entry.get("pitch_max", null), "pitch", 1.0)
	if pitch_min == null or pitch_max == null or float(pitch_min) > float(pitch_max):
		return null
	result["pitch_min"] = float(pitch_min)
	result["pitch_max"] = float(pitch_max)
	return result


static func _normalize_variants(value: Variant, clips: Dictionary, continuous: bool) -> Variant:
	if not value is Array or (value as Array).is_empty():
		return null
	var variants: Array[Dictionary] = []
	var seen := {}
	var weight_total := 0.0
	for raw: Variant in value:
		var entry: Variant = _normalize_variant(raw, clips, continuous)
		if entry == null:
			return null
		var clip := str((entry as Dictionary)["clip"])
		if seen.has(clip):
			return null
		seen[clip] = true
		weight_total += float((entry as Dictionary)["weight"])
		variants.append(entry)
	if weight_total <= 0.0:
		return null
	return variants


## Un evenement ponctuel. `inherit` et `silent` ne portent rien d'autre.
static func normalize_cue(value: Variant, event_key: String, clips: Dictionary) -> Variant:
	if not value is Dictionary:
		return null
	var entry: Dictionary = value
	var mode := str(entry.get("mode", "inherit"))
	if not (contract().get("cue_modes", []) as Array).has(mode):
		return null
	if mode != "custom":
		return {"mode": mode}
	var variants: Variant = _normalize_variants(entry.get("variants", null), clips, false)
	var probability: Variant = _normalize_number(entry.get("probability", null), "probability",
		float(cue_default(event_key, "probability")))
	var cooldown: Variant = _normalize_number(entry.get("cooldown_seconds", null), "cooldown_seconds",
		float(cue_default(event_key, "cooldown_seconds")))
	var instances: Variant = _normalize_number(entry.get("max_instances", null), "max_instances",
		float(cue_default(event_key, "max_instances")))
	if variants == null or probability == null or cooldown == null or instances == null:
		return null
	var cue := {
		"mode": "custom",
		"variants": variants,
		"probability": float(probability),
		"cooldown_seconds": float(cooldown),
		"max_instances": int(instances),
		"avoid_repeat": bool(entry.get("avoid_repeat", cue_default(event_key, "avoid_repeat"))),
	}
	if event_is_loop(event_key):
		var fade: Variant = _normalize_number(entry.get("fade_seconds", null), "fade_seconds", 0.2)
		if fade == null:
			return null
		cue["fade_seconds"] = float(fade)
	return cue


## Un canal continu: musique ou ambiance.
static func normalize_channel(value: Variant, clips: Dictionary) -> Variant:
	if not value is Dictionary:
		return null
	var entry: Dictionary = value
	var mode := str(entry.get("mode", "inherit"))
	if not (contract().get("cue_modes", []) as Array).has(mode):
		return null
	if mode != "custom":
		return {"mode": mode}
	var playback := str(entry.get("playback", str(channel_default("playback"))))
	if not (contract().get("playback_modes", []) as Array).has(playback):
		return null
	var variants: Variant = _normalize_variants(entry.get("variants", null), clips, true)
	var fade: Variant = _normalize_number(entry.get("fade_seconds", null), "fade_seconds",
		float(channel_default("fade_seconds")))
	if variants == null or fade == null:
		return null
	return {
		"mode": "custom",
		"variants": variants,
		"playback": playback,
		"fade_seconds": float(fade),
		"avoid_repeat": bool(entry.get("avoid_repeat", channel_default("avoid_repeat"))),
	}


## La carte `sounds` d'une fiche: seules les cles autorisees par la portee.
static func normalize_sound_map(value: Variant, scope: String, clips: Dictionary) -> Variant:
	if value is Array and (value as Array).is_empty():
		value = {}
	if not value is Dictionary:
		return null
	var allowed := scope_keys(scope)
	var result := {}
	for raw_key: Variant in value:
		var key := str(raw_key)
		if not allowed.has(key):
			return null
		var cue: Variant = normalize_cue((value as Dictionary)[raw_key], scope_event(scope, key), clips)
		if cue == null:
			return null
		result[key] = cue
	return result


## La carte `audio` d'une salle ou d'un biome: deux canaux independants.
static func normalize_channel_map(value: Variant, clips: Dictionary) -> Variant:
	if value is Array and (value as Array).is_empty():
		value = {}
	if not value is Dictionary:
		return null
	var allowed := channels()
	var result := {}
	for raw_channel: Variant in value:
		var channel := str(raw_channel)
		if not allowed.has(channel):
			return null
		var config: Variant = normalize_channel((value as Dictionary)[raw_channel], clips)
		if config == null:
			return null
		result[channel] = config
	return result


## Les valeurs terminales. C'est le seul niveau ou `inherit` n'a plus de parent:
## il y vaut silence, sans cascade imprevisible.
static func normalize_defaults(value: Variant, clips: Dictionary) -> Variant:
	if not value is Dictionary:
		value = {}
	var source: Dictionary = value
	var raw_events: Variant = source.get("events", {})
	if raw_events is Array and (raw_events as Array).is_empty():
		raw_events = {}
	if not raw_events is Dictionary:
		return null
	var normalized_events := {}
	for raw_key: Variant in raw_events:
		var key := str(raw_key)
		if not has_event(key):
			return null
		var cue: Variant = normalize_cue((raw_events as Dictionary)[raw_key], key, clips)
		if cue == null:
			return null
		normalized_events[key] = cue
	var raw_contexts: Variant = source.get("contexts", {})
	if raw_contexts is Array and (raw_contexts as Array).is_empty():
		raw_contexts = {}
	if not raw_contexts is Dictionary:
		return null
	var normalized_contexts := {}
	for raw_context: Variant in raw_contexts:
		var context := str(raw_context)
		if not contexts().has(context):
			return null
		var map: Variant = normalize_channel_map((raw_contexts as Dictionary)[raw_context], clips)
		if map == null:
			return null
		normalized_contexts[context] = map
	return {"events": normalized_events, "contexts": normalized_contexts}
