-- @author DoomTero 2026
-- @copyright 2026 DoomTero - ZomboRut original work. ref:e8ac6d72
-- @license Proprietary. Not licensed for reuse, redistribution or derivative works without written permission from DoomTero. Ask - permission is obtainable. See ZomboRut/LICENSE.txt

require "ZomboRut/ZomboRutScene"
require "ZomboRut/ZR_SoundProfiles"
require "ZomboRut/ZR_VoiceMoans"

local ZomboRutScene    = require "ZomboRut/ZomboRutScene"
local ZR_SoundProfiles = require "ZomboRut/ZR_SoundProfiles"

local DEBUG = true
local function log(fmt, ...)
	if DEBUG then DebugLog.log(string.format("[ZR Sounds] " .. fmt, ...)) end
end

local M = {}

local active          = {}
local pendingInject   = {}
local overrides       = {}
local stageListeners  = {}
local tickCounter     = 0

local function modEnabled()
	if not SandboxVars or not SandboxVars.ZomboRut then return true end
	return SandboxVars.ZomboRut.enableMod ~= false
end

local function pickFrom(pool)
	if not pool or #pool == 0 then return nil end
	return pool[ZombRand(#pool) + 1]
end

local function pickWeighted(entries)
	if not entries or #entries == 0 then return nil end
	if #entries == 1 then return entries[1].name end
	local total = 0
	for _, e in ipairs(entries) do total = total + (e.weight or 1) end
	if total <= 0 then return entries[1].name end
	local r = ZombRand(total) + 1
	local acc = 0
	for _, e in ipairs(entries) do
		acc = acc + (e.weight or 1)
		if r <= acc then return e.name end
	end
	return entries[#entries].name
end

local function resolveCategoryPool(catName)
	if not catName then return nil end
	return ZR_SoundProfiles.getCategory(catName)
end

local function getStage(player)
	local sgan = player and player.getVariableString
		and player:getVariableString("sharedGrappleAnimNode") or ""
	local varToken = false
	if sgan == "" and player and player.getVariableString then
		local pa = player:getVariableString("PerformingAction") or ""
		if pa:sub(1, 4) == "ZRV_" then sgan = pa; varToken = true end
	end
	if sgan == "" then return nil end
	if string.find(sgan, "Outro") then return "outro" end
	if string.find(sgan, "Main")  then return "main"  end
	if string.find(sgan, "Intro") then return "intro" end
	if varToken then return "main" end
	return nil
end

local function resolveMainPhaseIdx(mainStage, elapsed)
	if not mainStage.phases then return nil end
	local cumulative = 0
	for i, ph in ipairs(mainStage.phases) do
		if not ph.duration then return i end
		cumulative = cumulative + ph.duration
		if elapsed < cumulative then return i end
	end
	return #mainStage.phases
end

local function getChainLetter(player)
	local sceneActive = player and ZomboRutScene._active[player]
	return sceneActive and sceneActive.chain or nil
end

local function isEscapeOutro(player)
	local sceneActive = player and ZomboRutScene._active[player]
	local sceneId = sceneActive and sceneActive.sceneId
	if not sceneId then return false end
	local ov = ""
	pcall(function()
		ov = player:getVariableString("ZR_" .. sceneId .. "OutroVariant") or ""
	end)
	return ov ~= ""
end

local function getMoveDir(player)
	if not player or not player.getVariableString then return nil end
	local md = player:getVariableString("ZR_MoveDir") or ""
	if md == "" then return nil end
	return md
end

local SCREAM_TRAITS = {
	"Wimp",
	"Virgin",
	"fighter",
}

local function hasTrait(player, key)
	return ZR_Trait ~= nil and ZR_Trait.has ~= nil and ZR_Trait.has(player, key)
end

local function resolveScreamsAllowed(player)
	if not player then return false end
	for i = 1, #SCREAM_TRAITS do
		if hasTrait(player, SCREAM_TRAITS[i]) then
			log("scream gate: ON (trait %s)", SCREAM_TRAITS[i])
			return true
		end
	end
	return false
end

local function gateOpen(player, gate, st)
	if not gate then return true end
	return st ~= nil and st.screams == true
end

local function burstSpent(player, sub)
	if not sub or not sub.burst or not sub.gate then return false end
	local st = active[player]
	if not st then return false end
	local used = st.burstUsed and st.burstUsed[sub.gate] or 0
	return used >= sub.burst
end

local function burstConsume(player, sub)
	if not sub or not sub.burst or not sub.gate then return end
	local st = active[player]
	if not st then return end
	st.burstUsed = st.burstUsed or {}
	local used = (st.burstUsed[sub.gate] or 0) + 1
	st.burstUsed[sub.gate] = used
	if used >= sub.burst then
		log("scream burst %q spent (%d/%d) — segment falls back to pulses",
			sub.gate, used, sub.burst)
	end
end

local modeResolver = nil

local function resolveActiveMode(profile)
	if not profile.modes then return nil end
	if modeResolver then
		local ok, r = pcall(modeResolver, profile)
		if ok and type(r) == "string" and profile.modes[r] then
			return r
		end
	end
	return profile.defaultMode
end

local function getStagesFor(profile, player)
	if profile.modes then
		local st = active[player]
		local mode = st and st.mode
		if mode and profile.modes[mode] then
			return profile.modes[mode]
		end
	end
	return profile.stages
end

local function resolveStageDef(profile, stageName, player)
	local stages = getStagesFor(profile, player)
	local stageDef = stages and stages[stageName]
	if not stageDef then return nil end
	local moveDir = getMoveDir(player)
	if moveDir and stageDef.perMoveDir and stageDef.perMoveDir[moveDir] then
		local sub = stageDef.perMoveDir[moveDir]
		if gateOpen(player, sub.gate, active[player]) and not burstSpent(player, sub) then
			return sub, stageDef
		end
	end
	local chain = getChainLetter(player)
	if chain and stageDef.perChain and stageDef.perChain[chain] then
		return stageDef.perChain[chain], stageDef
	end
	return stageDef, stageDef
end

local function getActiveCategoryEntries(profile, st, player)
	if st.stage == "outro" and st.outroCategories then
		return st.outroCategories
	end
	local effective, root = resolveStageDef(profile, st.stage, player)
	if not effective then return nil end

	if st.stage == "main" and effective.phases then
		local idx = resolveMainPhaseIdx(effective, tickCounter - (st.stageEnteredTick or tickCounter))
		if idx ~= st.phaseIdx then
			st.phaseIdx = idx
			st.phaseEnteredTick = tickCounter
			log("main phase → %d", idx)
		end
		local ph = effective.phases[idx]
		local cats = ph and ph.categories
		if cats then return cats end
	end

	if effective.categories then return effective.categories end
	if root and root ~= effective and root.categories then return root.categories end
	return nil
end

local function getPulseTicks(profile, stageName, player)
	local effective, root = resolveStageDef(profile, stageName, player)
	if effective and effective.pulseTicks then return effective.pulseTicks end
	if root and root.pulseTicks then return root.pulseTicks end
	return profile.pulseTicks or 150
end

local function getBreakTicks(profile, stageName, player)
	local effective, root = resolveStageDef(profile, stageName, player)
	if effective and effective.breakTicks then return effective.breakTicks end
	if root and root.breakTicks then return root.breakTicks end
	return profile.breakTicks or 30
end

local function resolveEventName(name, player)
	if not name or not string.find(name, "{Sex}", 1, true) then return name end
	local sex = "Male"
	pcall(function()
		if player and player.isFemale and player:isFemale() then sex = "Female" end
	end)
	return (name:gsub("{Sex}", sex))
end

local lingering = {}

local function stopPick(player, p)
	if not p then return end
	pcall(function()
		if p.kind == "suffix" then
			if player.stopPlayerVoiceSound then
				player:stopPlayerVoiceSound(p.value)
			end
		elseif p.kind == "event" then
			local em = player.getEmitter and player:getEmitter() or nil
			if em and em.stopSoundByName then em:stopSoundByName(p.value) end
		end
	end)
end

local function stopLastPick(player, st)
	if not st then return end
	stopPick(player, st.lastPick)
end

local function getVoicePrefix(player)
	local prefix = ""
	pcall(function()
		if player.getDescriptor then
			local desc = player:getDescriptor()
			if desc and desc.getVoicePrefix then
				prefix = desc:getVoicePrefix() or ""
			end
		end
	end)
	return prefix
end

local function isLastPickStillPlaying(player, st)
	if not st or not st.lastPick then return false end
	local pick = st.lastPick
	local playing = false
	pcall(function()
		local em = player.getEmitter and player:getEmitter() or nil
		if not (em and em.isPlaying) then return end
		local name
		if pick.kind == "suffix" then
			name = getVoicePrefix(player) .. pick.value
		else
			name = pick.value
		end
		playing = em:isPlaying(name) and true or false
	end)
	return playing
end

local function describePick(pick)
	if not pick then return "nil" end
	if type(pick) == "string" then return pick end
	if type(pick) == "table" then
		if pick.event  then return "event:"  .. pick.event end
		if pick.suffix then return "suffix:" .. pick.suffix end
	end
	return "?"
end

local function pushParamsToHandle(player, handle, params)
	if not (handle and params) then return end
	if handle == 0 then return end
	pcall(function()
		local em = player.getEmitter and player:getEmitter() or nil
		if not (em and em.setParameterValueByName) then return end
		for name, value in pairs(params) do
			em:setParameterValueByName(handle, name, value)
		end
	end)
end

local function applyTriggerTimeGate(player, params)
	if not params then return nil end
	local restore = nil
	if params.CharacterOnFire and params.CharacterOnFire >= 1.0 then
		pcall(function()
			if player.isOnFire and not player:isOnFire() and player.setOnFire then
				player:setOnFire(true)
				restore = restore or {}
				restore.onFire = true
			end
		end)
	end
	return restore
end

local function releaseTriggerTimeGate(player, restore)
	if not restore then return end
	if restore.onFire then
		pcall(function()
			if player.setOnFire then player:setOnFire(false) end
		end)
	end
end

local MOAN_GAIN = 1.7

local function applyClipVolume(player, emitter, handle)
	if not (emitter and handle and handle ~= 0) then return end
	pcall(function()
		local slider = 1.0
		local core = getCore and getCore() or nil
		if core and core.getRealOptionSoundVolume then
			slider = core:getRealOptionSoundVolume() or 1.0
		end
		if emitter.setVolume then
			emitter:setVolume(handle, MOAN_GAIN * slider)
		end
	end)
end

local function firePick(player, pick)
	if not pick then return end
	pcall(function()
		local st = active[player]
		stopLastPick(player, st)

		local suffix, eventName, params

		if type(pick) == "string" then
			suffix = pick
		elseif type(pick) == "table" then
			if pick.event then
				eventName = resolveEventName(pick.event, player)
				params    = pick.params
			elseif pick.suffix then
				suffix = pick.suffix
				params = pick.params
			end
		end

		if suffix then
			local gate = applyTriggerTimeGate(player, params)
			local handle = nil
			if player.playerVoiceSound then
				handle = player:playerVoiceSound(suffix)
			end
			releaseTriggerTimeGate(player, gate)
			pushParamsToHandle(player, handle, params)
			local rec = { kind = "suffix", value = suffix }
			lingering[player] = rec
			if st then
				st.lastPick       = rec
				st.lastSuffix     = suffix
				st.lastSuffixTick = tickCounter
			end
		elseif eventName then
			local gate = applyTriggerTimeGate(player, params)
			local handle  = nil
			local emitter = player.getEmitter and player:getEmitter() or nil
			if emitter and emitter.playSound then
				handle = emitter:playSound(eventName)
			end
			releaseTriggerTimeGate(player, gate)
			applyClipVolume(player, emitter, handle)
			pushParamsToHandle(player, handle, params)
			local rec = { kind = "event", value = eventName }
			lingering[player] = rec
			if st then
				st.lastPick       = rec
				st.lastSuffix     = eventName
				st.lastSuffixTick = tickCounter
			end
		end
	end)
end

local FIRE_PARAM = "CharacterOnFire"

local fireOnly = {}

local FIRE_BURST_MS = 0

local function startFireScream(player, st)
	local em = player.getEmitter and player:getEmitter() or nil
	if not em then log("fire-scream START FAILED: no emitter"); return end
	local prefix = getVoicePrefix(player)
	if prefix == "" then log("fire-scream START FAILED: empty voice prefix"); return end
	pcall(function() if em.stopSoundByName then em:stopSoundByName(prefix) end end)
	st.fireHandle = nil
	pcall(function()
		if player.playSoundLocal then st.fireHandle = player:playSoundLocal(prefix) end
	end)
	st.fireScreamActive = true
	st.firePrefix       = prefix
	st.fireStartMs      = getTimestampMs()
	log("fire-scream START (handle=%s)", tostring(st.fireHandle))
end

local function maintainFireScream(player, st)
	if not st.fireScreamActive then
		startFireScream(player, st)
		return
	end
	local em = player.getEmitter and player:getEmitter() or nil
	if em and st.fireHandle and em.setParameterValueByName then
		pcall(function() em:setParameterValueByName(st.fireHandle, FIRE_PARAM, 1.0) end)
	end
end

local function stopFireScream(player, st)
	if not st or not st.fireScreamActive then return end
	st.fireScreamActive = false
	local em = player.getEmitter and player:getEmitter() or nil
	if em and st.firePrefix then
		pcall(function() if em.stopSoundByName then em:stopSoundByName(st.firePrefix) end end)
	end
	st.fireHandle  = nil
	st.firePrefix  = nil
	st.fireStartMs = nil
	log("fire-scream STOP")
end

local function rollFromCategoryEntries(entries, st)
	local catName = pickWeighted(entries)
	if not catName then return nil, nil end
	local pool = resolveCategoryPool(catName)
	if not pool or #pool == 0 then
		log("category %q empty/undefined — no voice", tostring(catName))
		return nil, catName
	end
	return pickFrom(pool), catName
end

local function applyOverrideExpiry(player, st)
	local ov = overrides[player]
	if not ov then return end
	if ov.pulses then
		ov.pulses = ov.pulses - 1
		if ov.pulses <= 0 then
			log("override on player expired (pulse count)")
			overrides[player] = nil
		end
	end
end

local function clearStageBoundOverrides(player, newStage)
	local ov = overrides[player]
	if ov and ov.untilStage and ov.untilStage == newStage then
		log("override on player expired (untilStage=%s reached)", newStage)
		overrides[player] = nil
	end
end

local function rollPulseSuffix(profile, player, st)
	local ov = overrides[player]
	if ov and ov.category then
		local pool = resolveCategoryPool(ov.category)
		if pool and #pool > 0 then
			return pickFrom(pool), ov.category, true
		end
		log("override category %q empty/undefined — falling through to profile",
			tostring(ov.category))
	end

	local entries = getActiveCategoryEntries(profile, st, player)
	if not entries then return nil, nil, false end
	local suffix, cat = rollFromCategoryEntries(entries, st)
	return suffix, cat, false
end

local function moanVoicesEnabled()
	local sv = SandboxVars and SandboxVars.ZomboRut
	return not (sv and sv.moanVoices == false)
end

local function voicePackName(player)
	local name = nil
	pcall(function()
		local desc = player and player.getDescriptor and player:getDescriptor()
		if not desc then return end
		if desc.getVoicePrefix and desc:getVoicePrefix() ~= "VoiceFemale" then return end
		if desc.getVoiceType then
			name = "zr_moans_v" .. tostring(desc:getVoiceType())
		end
	end)
	return name
end

local KEEP = {}
local SWAP_RULES = {
	function(profile, player)
		if not profile.moanSwap then return nil end
		local p = ZR_SoundProfiles.get(profile.moanSwap)
		if p then return p, profile.moanSwap end
		return KEEP
	end,
	function(profile, player)
		if profile.keepVanilla then return KEEP end
		return nil
	end,
	function(profile, player)
		local name = voicePackName(player)
		local p = name and ZR_SoundProfiles.get(name) or nil
		if p then return p, name end
		return nil
	end,
}

local function resolveProfileForScene(sceneActive, player)
	local cfg = sceneActive and sceneActive.cfg
	local requested = cfg and cfg.soundProfile or "default"
	local profile, resolvedName = ZR_SoundProfiles.resolve(requested)
	if profile and moanVoicesEnabled() then
		for _, rule in ipairs(SWAP_RULES) do
			local swap, name = rule(profile, player)
			if swap == KEEP then break end
			if swap then return swap, name end
		end
	end
	return profile, resolvedName
end

local OutroMode = {
	CLIMAX = "climax",
	ESCAPE = "escape",
}

local OUTRO_DETECTORS = {
	{ mode = OutroMode.ESCAPE, test = function(player, st) return isEscapeOutro(player) end },
}
local OUTRO_DEFAULT = OutroMode.CLIMAX

local function intensePoolEntries(profile, player)
	local function dPool(p)
		local stages = p and getStagesFor(p, player) or nil
		local main = stages and stages.main
		local dsub = main and main.perMoveDir and main.perMoveDir.D
		return dsub and dsub.categories or nil
	end
	local own = dPool(profile)
	if own then return own end
	local packName = voicePackName(player)
	local borrowed = dPool(packName and ZR_SoundProfiles.get(packName) or nil)
	if borrowed then return borrowed end
	local stages = getStagesFor(profile, player)
	local main = stages and stages.main
	return main and main.categories or nil
end

local function outroCutIn(player, st, entries, label)
	local pick = entries and rollFromCategoryEntries(entries, st) or nil
	if not pick then return end
	firePick(player, pick)
	st.lastStartTick = tickCounter
	st.lastEndTick   = nil
	st.lastEndMs     = nil
	log("outro cut-in (%s): %s", label, describePick(pick))
end

local OUTRO_HANDLERS = {
	[OutroMode.CLIMAX] = function(player, st)
		local effective = resolveStageDef(st.profile, "outro", player)
		outroCutIn(player, st, effective and effective.categories, "climax")
		st.outroSilent = true
	end,
	[OutroMode.ESCAPE] = function(player, st)
		st.outroCategories = intensePoolEntries(st.profile, player)
		outroCutIn(player, st, st.outroCategories, "escape→D")
	end,
}

local function enterOutro(player, st)
	if not st.profile.useSandboxGap then return end
	local mode = OUTRO_DEFAULT
	for _, d in ipairs(OUTRO_DETECTORS) do
		if d.test(player, st) then mode = d.mode; break end
	end
	st.outroMode = mode
	local handler = OUTRO_HANDLERS[mode]
	if handler then handler(player, st) end
end

local function rollOneShotSuffix(profile, fallbackCategoryName, stageName, player)
	if not profile then return nil end
	local effective, root = resolveStageDef(profile, stageName, player)
	if not effective then return nil end
	if fallbackCategoryName then
		local pool = resolveCategoryPool(fallbackCategoryName)
		local s = pickFrom(pool)
		if s then return s end
	end
	local entries = effective.categories
		or (root and root ~= effective and root.categories)
	if entries then
		local s = rollFromCategoryEntries(entries, { stage = stageName })
		if s then return s end
	end
	return nil
end

local function fireStageListeners(stageName, player, sceneActive)
	local list = stageListeners[stageName]
	if not list then return end
	for _, fn in ipairs(list) do
		local ok, err = pcall(fn, player, sceneActive)
		if not ok then log("stage listener error (%s): %s", stageName, tostring(err)) end
	end
end

function M.inject(player, categoryName)
	if not player or not categoryName then return false end
	local pool = resolveCategoryPool(categoryName)
	if not pool or #pool == 0 then
		log("inject: category %q empty/undefined", tostring(categoryName))
		return false
	end
	pendingInject[player] = { pick = pickFrom(pool), source = "category:" .. categoryName }
	return true
end

function M.injectRaw(player, suffix)
	if not player or type(suffix) ~= "string" or suffix == "" then return false end
	pendingInject[player] = { pick = suffix, source = "raw" }
	return true
end

function M.injectEvent(player, eventName)
	if not player or type(eventName) ~= "string" or eventName == "" then return false end
	pendingInject[player] = { pick = { event = eventName }, source = "event" }
	return true
end

function M.startFireScream(player)
	if not player then return false end
	fireOnly[player] = fireOnly[player] or {}
	startFireScream(player, fireOnly[player])
	return true
end

function M.stopFireScream(player)
	local st = fireOnly[player]
	if not st then return false end
	stopFireScream(player, st)
	fireOnly[player] = nil
	return true
end

function M.pushOverride(player, opts)
	if not player or type(opts) ~= "table" then return false end
	if type(opts.category) ~= "string" or opts.category == "" then return false end
	overrides[player] = {
		category   = opts.category,
		pulses     = (type(opts.pulses) == "number" and opts.pulses > 0) and opts.pulses or nil,
		untilStage = type(opts.untilStage) == "string" and opts.untilStage or nil,
	}
	log("override pushed: category=%s pulses=%s untilStage=%s",
		opts.category, tostring(overrides[player].pulses), tostring(overrides[player].untilStage))
	return true
end

function M.clearOverride(player)
	if not player then return false end
	if overrides[player] then
		overrides[player] = nil
		return true
	end
	return false
end

function M.onStage(stageName, fn)
	if type(stageName) ~= "string" or type(fn) ~= "function" then return false end
	stageListeners[stageName] = stageListeners[stageName] or {}
	table.insert(stageListeners[stageName], fn)
	return true
end

function M.setModeResolver(fn)
	if type(fn) == "function" then
		modeResolver = fn
		return true
	end
	return false
end

function M.getState(player)
	if not player then return nil end
	local st = active[player]
	if not st then return nil end
	local ov = overrides[player]
	return {
		profile        = st.profileName,
		mode           = st.mode,
		stage          = st.stage,
		chain          = getChainLetter(player),
		moveDir        = getMoveDir(player),
		phaseIdx       = st.phaseIdx,
		lastSuffix     = st.lastSuffix,
		lastSuffixTick = st.lastSuffixTick,
		lastStartTick  = st.lastStartTick,
		tick           = tickCounter,
		override       = ov and {
			category   = ov.category,
			pulses     = ov.pulses,
			untilStage = ov.untilStage,
		} or nil,
	}
end

function M.isActive(player)
	return player ~= nil and active[player] ~= nil
end

ZomboRutScene.registerSceneStartCallback(function(player, sceneActive)
	if not modEnabled() then return end
	if not player then return end

	stopPick(player, lingering[player])
	lingering[player] = nil

	if sceneActive and sceneActive.cfg and sceneActive.cfg.isUtility then
		log("scene-start: isUtility scene %q runs silent", tostring(sceneActive.sceneId))
		return
	end

	local profile, profileName = resolveProfileForScene(sceneActive, player)
	if not profile then
		log("scene-start: no profile resolvable, scene runs silent")
		return
	end

	active[player] = {
		profile          = profile,
		profileName      = profileName,
		mode             = resolveActiveMode(profile),
		screams          = resolveScreamsAllowed(player),
		stage            = "intro",
		stageEnteredTick = tickCounter,
		phaseIdx         = nil,
		phaseEnteredTick = tickCounter,
		lastSuffix       = nil,
		lastSuffixTick   = nil,
		lastStartTick    = tickCounter,
	}
	pendingInject[player] = nil
	overrides[player] = nil

	local startSuffix = profile.startSuffix
		or rollOneShotSuffix(profile, profile.startCategory, "intro", player)
	firePick(player, startSuffix)
	log("scene-start vocal: %s (profile=%s, mode=%s, stage=intro, screams=%s)",
		describePick(startSuffix), profileName, tostring(active[player].mode),
		active[player].screams and "YES" or "no")

	fireStageListeners("intro", player, sceneActive)
end)

ZomboRutScene.registerSceneEndCallback(function(player, sceneActive, result)
	if not modEnabled() then return end
	if not player then return end

	local st = active[player]
	if st then stopFireScream(player, st) end
	if st and result == "Released" and not st.outroMode then
		local endSuffix = st.profile.endSuffix
			or rollOneShotSuffix(st.profile, st.profile.endCategory, "outro", player)
		firePick(player, endSuffix)
		log("scene-end vocal: %s (profile=%s, result=Released)",
			describePick(endSuffix), st.profileName)
	end

	active[player]        = nil
	pendingInject[player] = nil
	overrides[player]     = nil
end)

local function onPlayerUpdate(player)
	tickCounter = tickCounter + 1

	local fo = fireOnly[player]
	if fo and fo.fireScreamActive then
		if ZomboRutScene.isPlayerInScene and ZomboRutScene.isPlayerInScene(player) then
			maintainFireScream(player, fo)
		else
			stopFireScream(player, fo)
			fireOnly[player] = nil
		end
	end

	local st = active[player]
	if not st then return end

	if not ZomboRutScene.isPlayerInScene(player) then
		stopFireScream(player, st)
		active[player]        = nil
		pendingInject[player] = nil
		overrides[player]     = nil
		return
	end

	local detected = getStage(player)
	if detected and detected ~= st.stage then
		local prev = st.stage
		st.stage            = detected
		st.stageEnteredTick = tickCounter
		st.phaseIdx         = nil
		st.phaseEnteredTick = tickCounter
		log("stage transition → %s (from %s)", detected, prev)
		clearStageBoundOverrides(player, detected)
		fireStageListeners(detected, player, ZomboRutScene._active[player])
		if detected == "outro" then
			enterOutro(player, st)
		end
	end

	local pending = pendingInject[player]
	if pending then
		firePick(player, pending.pick)
		log("inject fired: %s (%s)", describePick(pending.pick), pending.source)
		pendingInject[player] = nil
		st.lastStartTick = tickCounter
		st.lastEndTick   = nil
		st.lastEndMs     = nil
		return
	end

	if st.outroSilent and st.stage == "outro" then
		return
	end

	local moveDirNow = getMoveDir(player)
	if moveDirNow ~= st.lastMoveDir then
		st.lastMoveDir    = moveDirNow
		st.fireBurstSpent = false
		st.burstUsed      = nil
	end

	local effective = resolveStageDef(st.profile, st.stage, player)
	if effective and effective.sustain == "fire" and not st.fireBurstSpent then
		if st.fireScreamActive and FIRE_BURST_MS > 0
			and (getTimestampMs() - (st.fireStartMs or 0)) >= FIRE_BURST_MS
		then
			stopFireScream(player, st)
			st.fireBurstSpent = true
			st.lastStartTick  = tickCounter
			st.lastEndTick    = tickCounter
			st.lastEndMs      = getTimestampMs()
		else
			maintainFireScream(player, st)
			return
		end
	elseif st.fireScreamActive then
		stopFireScream(player, st)
		st.lastStartTick = tickCounter
		st.lastEndTick   = tickCounter
		st.lastEndMs     = getTimestampMs()
	end

	if isLastPickStillPlaying(player, st) then
		st.lastEndTick = nil
		st.lastEndMs   = nil
		return
	end
	if st.lastEndTick == nil and st.lastStartTick ~= nil then
		st.lastEndTick = tickCounter
		st.lastEndMs   = getTimestampMs()
	end
	local interval = getPulseTicks(st.profile, st.stage, player)
	local fireAt   = (st.lastStartTick or 0) + interval
	if tickCounter < fireAt then
		return
	end
	if st.profile.useSandboxGap then
		local sv = SandboxVars and SandboxVars.ZomboRut
		local gapMs = ((sv and sv.moanGap) or 0.2) * 1000
		if st.lastEndMs and (getTimestampMs() - st.lastEndMs) < gapMs then
			return
		end
	else
		local breakGap   = getBreakTicks(st.profile, st.stage, player)
		local breakReady = (st.lastEndTick or 0) + breakGap
		if tickCounter < breakReady then
			return
		end
	end

	local pick, category, fromOverride = rollPulseSuffix(st.profile, player, st)
	firePick(player, pick)
	if pick then
		burstConsume(player, effective)
		log("pulse: %s (cat=%s%s, stage=%s, chain=%s, moveDir=%s)",
			describePick(pick), tostring(category),
			fromOverride and " OVR" or "", st.stage,
			tostring(getChainLetter(player)),
			tostring(getMoveDir(player)))
	end
	applyOverrideExpiry(player, st)
	st.lastStartTick = tickCounter
	st.lastEndTick   = nil
	st.lastEndMs     = nil
end

Events.OnPlayerUpdate.Add(onPlayerUpdate)

M.setModeResolver(function()
	local sv = SandboxVars and SandboxVars.ZomboRut
	if sv and sv.sceneSoundMode == 1 then return "regular" end
	return "enhanced"
end)

ZR_SceneSounds = M

log("ZR_SceneSounds loaded; profiles=%s ; categories=%s",
	table.concat(ZR_SoundProfiles.list(), ","),
	table.concat(ZR_SoundProfiles.listCategories(), ","))

return M
