extends Node
## AudioManager — Phase 18.
##
## Owns three AudioServer buses (Master, Music, SFX), a small catalog of music
## loops + one-shot SFX, and a single AudioStreamPlayer for the music director.
## SFX play through pooled short-lived AudioStreamPlayers.
##
## Music swaps on Clock.phase_changed (day/dusk/night/dawn). SFX fire on
## EventBus signals (combat, storyteller stings) and direct calls from Tree /
## Rock entities for chop / mine completion sounds.
##
## Volume sliders in SettingsMenu drive set_*_db() at the Master/Music/SFX bus
## level. App-pause mutes everything (NOTIFICATION_APPLICATION_PAUSED / focus
## loss) to match the existing "no background simulation" rule.

const BUS_MASTER: StringName = &"Master"
const BUS_MUSIC: StringName = &"Music"
const BUS_SFX: StringName = &"SFX"

# Slider range is 0.0..1.0 (linear); we map to dB on the bus.
const SILENT_DB: float = -80.0

# How long the chop/mine SFX cooldown is to avoid spam when many entities work
# simultaneously. Measured in milliseconds.
const SFX_THROTTLE_MS: int = 80

## Catalog: StringName → AudioStream. Loaded lazily on first access; once a
## key resolves we cache the stream so the second play is a Dictionary lookup.
const SFX_FILES: Dictionary = {
	&"ui_click": "res://audio/sfx/ui_click.ogg",
	&"ui_confirm": "res://audio/sfx/ui_confirm.ogg",
	&"chop_tick": "res://audio/sfx/chop_tick.ogg",
	&"tree_fell": "res://audio/sfx/tree_fell.ogg",
	&"mine_tick": "res://audio/sfx/mine_tick.ogg",
	&"combat_hit": "res://audio/sfx/combat_hit.ogg",
}

const MUSIC_FILES: Dictionary = {
	&"day_loop": "res://audio/music/day_loop.ogg",
	&"night_loop": "res://audio/music/night_loop.ogg",
}

# Loaded streams. Populated on demand.
var _sfx_streams: Dictionary = {}
var _music_streams: Dictionary = {}

# Single persistent music player. Stream is swapped on phase change.
var _music_player: AudioStreamPlayer = null
var _current_music_key: StringName = &""

# Per-SFX cooldowns to avoid spam. Last-played epoch ms keyed by SFX key.
var _sfx_last_played_ms: Dictionary = {}

# Cached slider values (linear 0..1). Applied to buses on _ready and on
# settings change. Public so SettingsMenu can rebind after creation.
var master_linear: float = 1.0
var music_linear: float = 1.0
var sfx_linear: float = 1.0


# ── lifecycle ───────────────────────────────────────────────────────────────

func _ready() -> void:
	_ensure_buses()
	# Music director: track stays on a single persistent player; stream swaps.
	_music_player = AudioStreamPlayer.new()
	_music_player.bus = BUS_MUSIC
	_music_player.name = "MusicPlayer"
	add_child(_music_player)

	# SFX → world event wiring. Keep this list short and obvious.
	if EventBus.has_signal("pawn_took_damage"):
		EventBus.pawn_took_damage.connect(_on_pawn_took_damage)
	if EventBus.has_signal("storyteller_event_fired"):
		EventBus.storyteller_event_fired.connect(_on_storyteller_event_fired)
	if EventBus.has_signal("alert_added"):
		EventBus.alert_added.connect(_on_alert_added)

	# Music director: swap day/night loops as Clock.phase_changed fires.
	if Clock.has_signal("phase_changed"):
		Clock.phase_changed.connect(_on_clock_phase_changed)

	# Kick off the right initial track based on current phase, with no fade.
	var initial_phase: StringName = &"day"
	if Clock.has_method("current_phase"):
		initial_phase = Clock.current_phase()
	_apply_phase_to_music(initial_phase)

	Audit.log("audio", "AudioManager ready (master/music/sfx buses online)")


## NOTIFICATION_APPLICATION_PAUSED fires when the app is suspended (mobile
## background, Steam Deck sleep). Mute Master so any in-flight SFX go silent;
## resume on focus.
func _notification(what: int) -> void:
	match what:
		NOTIFICATION_APPLICATION_PAUSED, NOTIFICATION_APPLICATION_FOCUS_OUT:
			AudioServer.set_bus_mute(_bus_idx(BUS_MASTER), true)
		NOTIFICATION_APPLICATION_RESUMED, NOTIFICATION_APPLICATION_FOCUS_IN:
			AudioServer.set_bus_mute(_bus_idx(BUS_MASTER), false)


# ── public API ──────────────────────────────────────────────────────────────

## Play a one-shot SFX by catalog key. Quietly no-ops on unknown keys.
## Rate-limits by key so chop/mine tick spam doesn't clip the audio mixer.
func play_sfx(key: StringName) -> void:
	if not SFX_FILES.has(key):
		return
	var now: int = Time.get_ticks_msec()
	var last: int = int(_sfx_last_played_ms.get(key, -SFX_THROTTLE_MS))
	if now - last < SFX_THROTTLE_MS:
		return
	_sfx_last_played_ms[key] = now

	var stream: AudioStream = _load_sfx(key)
	if stream == null:
		return
	var p := AudioStreamPlayer.new()
	p.bus = BUS_SFX
	p.stream = stream
	add_child(p)
	p.finished.connect(p.queue_free)
	p.play()


## Switch the music track. `key` resolves through MUSIC_FILES. Same key as
## currently playing is a no-op so phase ticks don't restart the loop.
func play_music(key: StringName) -> void:
	if key == _current_music_key:
		return
	if not MUSIC_FILES.has(key):
		return
	var stream: AudioStream = _load_music(key)
	if stream == null:
		return
	_current_music_key = key
	_music_player.stream = stream
	_music_player.play()


## Slider → bus volume helpers. Linear 0..1; 0 mutes the bus.
func set_master_linear(linear: float) -> void:
	master_linear = clampf(linear, 0.0, 1.0)
	_apply_bus_db(BUS_MASTER, master_linear)


func set_music_linear(linear: float) -> void:
	music_linear = clampf(linear, 0.0, 1.0)
	_apply_bus_db(BUS_MUSIC, music_linear)


func set_sfx_linear(linear: float) -> void:
	sfx_linear = clampf(linear, 0.0, 1.0)
	_apply_bus_db(BUS_SFX, sfx_linear)


# ── internal ────────────────────────────────────────────────────────────────

func _ensure_buses() -> void:
	# Godot ships with a single "Master" bus. We add Music + SFX as children
	# routed to Master, so the master slider scales everything together.
	_add_bus_if_missing(BUS_MUSIC, BUS_MASTER)
	_add_bus_if_missing(BUS_SFX, BUS_MASTER)


func _add_bus_if_missing(bus_name: StringName, send_to: StringName) -> void:
	if AudioServer.get_bus_index(bus_name) != -1:
		return
	var idx: int = AudioServer.bus_count
	AudioServer.add_bus(idx)
	AudioServer.set_bus_name(idx, bus_name)
	AudioServer.set_bus_send(idx, send_to)


func _bus_idx(bus_name: StringName) -> int:
	return AudioServer.get_bus_index(bus_name)


func _apply_bus_db(bus_name: StringName, linear: float) -> void:
	var idx: int = _bus_idx(bus_name)
	if idx < 0:
		return
	var db: float = linear_to_db(linear) if linear > 0.0 else SILENT_DB
	AudioServer.set_bus_volume_db(idx, db)


func _load_sfx(key: StringName) -> AudioStream:
	if _sfx_streams.has(key):
		return _sfx_streams[key]
	var path: String = String(SFX_FILES[key])
	var stream: AudioStream = load(path)
	if stream != null:
		_sfx_streams[key] = stream
	return stream


func _load_music(key: StringName) -> AudioStream:
	if _music_streams.has(key):
		return _music_streams[key]
	var path: String = String(MUSIC_FILES[key])
	var stream: AudioStream = load(path)
	if stream != null:
		# Loopable variants of the source tracks are pre-cut; tell the import
		# layer to loop in case the .import wasn't flagged.
		if stream is AudioStreamOggVorbis:
			stream.loop = true
		_music_streams[key] = stream
	return stream


func _apply_phase_to_music(phase: StringName) -> void:
	# Day / dawn / dusk all share the day loop; only night gets the night loop.
	# Phase 20 may split dusk into a distinct track once we have one.
	var key: StringName = &"night_loop" if phase == &"night" else &"day_loop"
	play_music(key)


# ── signal handlers ─────────────────────────────────────────────────────────

func _on_pawn_took_damage(_pawn, _amount: float) -> void:
	play_sfx(&"combat_hit")


func _on_storyteller_event_fired(_event) -> void:
	play_sfx(&"ui_confirm")


func _on_alert_added(_severity: StringName, _text: String, _focus_tile: Vector2i) -> void:
	play_sfx(&"ui_click")


func _on_clock_phase_changed(new_phase: StringName) -> void:
	_apply_phase_to_music(new_phase)
