extends RefCounted

# Pas de `class_name`: un nom global neuf n'existe qu'apres une passe d'editeur.

## Resolution sonore pure.
##
## Ce script ne joue rien, ne cree aucun noeud et ne touche a aucun serveur. Il
## repond a deux questions: « quelle configuration s'applique ici » (heritage)
## et « quelle variante tirer » (poids). Il est donc eprouvable en headless,
## sans peripherique audio ni scene chargee — ce qui est tout l'interet de le
## separer du directeur.
##
## Le tirage utilise un RandomNumberGenerator **injecte**. Jamais `randf()`:
## couper le son ne doit pas decaler d'un seul tirage les degats, le butin ou
## le choix d'une attaque.

const AudioContract := preload("res://scripts/core/audio_contract.gd")

## Aucune configuration trouvee, ou silence voulu. Les deux se distinguent par
## `mode`, parce que l'appelant n'en fait pas la meme chose dans un diagnostic.
const NOTHING := {"mode": "none", "source": ""}


## La chaine d'heritage d'un evenement, du plus precis au defaut terminal.
##
## `layers` est une liste de couples [carte de sons, nom de la source]. La
## premiere couche qui dit autre chose qu'« herite » gagne. `event_key` est la
## cle GLOBALE utilisee au dernier niveau; `short_key` la cle courte des fiches.
static func resolve_cue(layers: Array, short_key: String, event_key: String) -> Dictionary:
	for layer: Variant in layers:
		if not layer is Array or (layer as Array).size() < 2:
			continue
		var pair: Array = layer
		var map: Variant = pair[0]
		if not map is Dictionary:
			continue
		var cue: Variant = (map as Dictionary).get(short_key, null)
		if cue == null or not cue is Dictionary:
			continue
		var mode := str((cue as Dictionary).get("mode", "inherit"))
		if mode == "inherit":
			continue
		# `silent` arrete la resolution: c'est un choix, pas un echec. Sans cet
		# arret, taire un monstre reveillerait le son general a sa place.
		var found: Dictionary = (cue as Dictionary).duplicate(true)
		found["source"] = str(pair[1])
		return found
	# Les valeurs terminales sont consultees en dernier, sous leur cle globale.
	var defaults: Variant = DataCatalog.AUDIO_DEFAULTS.get("events", {})
	if defaults is Dictionary:
		var fallback: Variant = (defaults as Dictionary).get(event_key, null)
		if fallback is Dictionary and str((fallback as Dictionary).get("mode", "inherit")) != "inherit":
			var terminal: Dictionary = (fallback as Dictionary).duplicate(true)
			terminal["source"] = "défauts généraux"
			return terminal
	return NOTHING.duplicate()


## Le meme parcours pour un canal continu, sous son contexte terminal.
static func resolve_channel(layers: Array, channel: String, context: String) -> Dictionary:
	for layer: Variant in layers:
		if not layer is Array or (layer as Array).size() < 2:
			continue
		var pair: Array = layer
		var map: Variant = pair[0]
		if not map is Dictionary:
			continue
		var config: Variant = (map as Dictionary).get(channel, null)
		if config == null or not config is Dictionary:
			continue
		if str((config as Dictionary).get("mode", "inherit")) == "inherit":
			continue
		var found: Dictionary = (config as Dictionary).duplicate(true)
		found["source"] = str(pair[1])
		return found
	var contexts: Variant = DataCatalog.AUDIO_DEFAULTS.get("contexts", {})
	if contexts is Dictionary:
		var terminal: Variant = (contexts as Dictionary).get(context, null)
		if terminal is Dictionary:
			var config: Variant = (terminal as Dictionary).get(channel, null)
			if config is Dictionary and str((config as Dictionary).get("mode", "inherit")) != "inherit":
				var result: Dictionary = (config as Dictionary).duplicate(true)
				result["source"] = "contexte " + context
				return result
	return NOTHING.duplicate()


## Tire une variante. `excluded_clip` est le dernier clip joue: l'anti-repetition
## l'ecarte s'il reste autre chose d'eligible, et le garde sinon — une seule
## variante ne doit pas produire un silence.
##
## `playable` filtre les clips dont le fichier s'est revele injouable. Rendre un
## dictionnaire vide veut dire « rien a jouer », jamais « repli sur le parent ».
static func pick_variant(cue: Dictionary, rng: RandomNumberGenerator,
		excluded_clip: String = "", playable: Dictionary = {}) -> Dictionary:
	if str(cue.get("mode", "")) != "custom":
		return {}
	var variants: Variant = cue.get("variants", [])
	if not variants is Array:
		return {}
	var eligible: Array[Dictionary] = []
	for raw: Variant in variants:
		if not raw is Dictionary:
			continue
		var variant: Dictionary = raw
		var clip := str(variant.get("clip", ""))
		if not playable.is_empty() and not bool(playable.get(clip, true)):
			continue
		if float(variant.get("weight", 1.0)) <= 0.0:
			continue
		eligible.append(variant)
	if eligible.is_empty():
		return {}
	if bool(cue.get("avoid_repeat", true)) and not excluded_clip.is_empty() and eligible.size() > 1:
		var kept: Array[Dictionary] = []
		for variant: Dictionary in eligible:
			if str(variant.get("clip", "")) != excluded_clip:
				kept.append(variant)
		if not kept.is_empty():
			eligible = kept
	var total := 0.0
	for variant: Dictionary in eligible:
		total += float(variant.get("weight", 1.0))
	if total <= 0.0:
		return {}
	var roll := rng.randf() * total
	for variant: Dictionary in eligible:
		roll -= float(variant.get("weight", 1.0))
		if roll <= 0.0:
			return variant
	return eligible[eligible.size() - 1]


## Le pitch effectif d'une variante, tire une fois par lecture.
static func pick_pitch(variant: Dictionary, rng: RandomNumberGenerator) -> float:
	var low := float(variant.get("pitch_min", 1.0))
	var high := float(variant.get("pitch_max", 1.0))
	if high <= low:
		return maxf(0.01, low)
	return clampf(rng.randf_range(low, high), 0.01, 4.0)


## Le dé de probabilité de l'événement. Séparé du tirage de variante: une
## probabilité de 0 ne doit jamais réveiller la configuration du parent.
static func passes_probability(cue: Dictionary, rng: RandomNumberGenerator) -> bool:
	var probability := float(cue.get("probability", 1.0))
	if probability >= 1.0:
		return true
	if probability <= 0.0:
		return false
	return rng.randf() < probability


## Les couches d'heritage pour une attaque de monstre. L'ordre est le contrat:
## la ligne d'attaque, puis la variante de niveau, puis la base du monstre.
static func enemy_attack_layers(enemy_definition: Dictionary, attack: Dictionary) -> Array:
	var layers: Array = []
	if not attack.is_empty():
		layers.append([attack.get("sounds", {}), "attaque"])
	layers.append([enemy_definition.get("sounds", {}), "monstre"])
	return layers


## Les couches pour une attaque du joueur: l'arme d'abord, le profil ensuite.
## Aucune surcharge d'arme sur les etats du joueur lui-meme (blessure, mort).
static func player_attack_layers(weapon_definition: Dictionary, player_definition: Dictionary) -> Array:
	var layers: Array = []
	if not weapon_definition.is_empty():
		layers.append([weapon_definition.get("sounds", {}), "arme"])
	layers.append([player_definition.get("sounds", {}), "joueur"])
	return layers


## Les couches d'une salle: la salle, puis son biome.
static func room_layers(room_definition: Dictionary, biome_definition: Dictionary, key: String) -> Array:
	var layers: Array = []
	if not room_definition.is_empty():
		layers.append([room_definition.get(key, {}), "salle"])
	if not biome_definition.is_empty():
		layers.append([biome_definition.get(key, {}), "biome"])
	return layers
