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

ZR_SoundProfiles = ZR_SoundProfiles or {}

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

ZR_SoundProfiles._categories = ZR_SoundProfiles._categories or {}
ZR_SoundProfiles._registry   = ZR_SoundProfiles._registry   or {}

local function _validatePoolEntry(entry, ctx, i)
	if type(entry) == "string" then
		return entry ~= ""
	end
	if type(entry) == "table" then
		if type(entry.event) == "string" and entry.event ~= "" then return true end
		if type(entry.suffix) == "string" and entry.suffix ~= "" then return true end
	end
	DebugLog.log(string.format(
		"[ZR SoundProfiles] %s pool[%d]: invalid (expected string suffix, { suffix=,params=? } or { event=,params=? })",
		ctx, i))
	return false
end

local function _copyParams(params)
	if type(params) ~= "table" then return nil end
	local out = nil
	for k, v in pairs(params) do
		if type(k) == "string" and type(v) == "number" then
			out = out or {}
			out[k] = v
		end
	end
	return out
end

local function _validateAndCopyPool(pool, ctx)
	if type(pool) ~= "table" or #pool == 0 then return nil end
	local out = {}
	for i, v in ipairs(pool) do
		if _validatePoolEntry(v, ctx, i) then
			if type(v) == "string" then
				out[#out + 1] = v
			elseif v.event then
				out[#out + 1] = { event = v.event, params = _copyParams(v.params) }
			else
				out[#out + 1] = { suffix = v.suffix, params = _copyParams(v.params) }
			end
		end
	end
	if #out == 0 then return nil end
	return out
end

function ZR_SoundProfiles.defineCategory(name, pool)
	if type(name) ~= "string" or name == "" then
		DebugLog.log("[ZR SoundProfiles] defineCategory: bad name")
		return false
	end
	local copy = _validateAndCopyPool(pool, "defineCategory(" .. name .. ")")
	if not copy then
		DebugLog.log(string.format(
			"[ZR SoundProfiles] defineCategory: pool must be a non-empty list (category=%s)",
			name))
		return false
	end
	ZR_SoundProfiles._categories[name] = copy
	log("defined category: %s (%d entr%s)",
		name, #copy, #copy == 1 and "y" or "ies")
	return true
end

function ZR_SoundProfiles.extendCategory(name, extra)
	if type(name) ~= "string" or name == "" then return false end
	local extraCopy = _validateAndCopyPool(extra, "extendCategory(" .. name .. ")")
	if not extraCopy then return false end
	local cat = ZR_SoundProfiles._categories[name]
	if not cat then
		ZR_SoundProfiles._categories[name] = extraCopy
		log("defined category: %s (%d entries, via extend)", name, #extraCopy)
		return true
	end
	for _, v in ipairs(extraCopy) do cat[#cat + 1] = v end
	log("extended category: %s (+%d → %d)", name, #extraCopy, #cat)
	return true
end

function ZR_SoundProfiles.getCategory(name)
	return name and ZR_SoundProfiles._categories[name] or nil
end

function ZR_SoundProfiles.listCategories()
	local names = {}
	for k in pairs(ZR_SoundProfiles._categories) do names[#names + 1] = k end
	table.sort(names)
	return names
end

local function _normalizeCategoriesField(catField, ctx)
	if type(catField) ~= "table" then return nil end
	local out = {}
	for i, entry in ipairs(catField) do
		if type(entry) == "string" then
			out[#out + 1] = { name = entry, weight = 1 }
		elseif type(entry) == "table" and type(entry.name) == "string" then
			out[#out + 1] = {
				name   = entry.name,
				weight = (type(entry.weight) == "number" and entry.weight > 0)
					and entry.weight or 1,
			}
		else
			DebugLog.log(string.format(
				"[ZR SoundProfiles] %s: categories[%d] is invalid (expected string or {name=,weight=})",
				ctx, i))
		end
	end
	return out
end

local _anonymousCategoryCounter = 0
local function _adoptInlinePool(pool, ownerName, stageName)
	_anonymousCategoryCounter = _anonymousCategoryCounter + 1
	local anonName = string.format("_inline_%s_%s_%d",
		ownerName or "profile", stageName or "stage", _anonymousCategoryCounter)
	ZR_SoundProfiles.defineCategory(anonName, pool)
	return anonName
end

local function _bakeStage(stageDef, ownerName, stageName)
	if type(stageDef) ~= "table" then return nil, "not a table" end

	local hasCats     = type(stageDef.categories) == "table" and #stageDef.categories > 0
	local hasPool     = type(stageDef.pool) == "table" and #stageDef.pool > 0
	local hasPhases   = type(stageDef.phases) == "table" and #stageDef.phases > 0
	local hasChain    = type(stageDef.perChain) == "table"
	local hasMoveDir  = type(stageDef.perMoveDir) == "table"
	local hasSustain  = type(stageDef.sustain) == "string" and stageDef.sustain ~= ""

	if not (hasCats or hasPool or hasPhases or hasChain or hasMoveDir or hasSustain) then
		return nil, "no categories / pool / phases / perChain / perMoveDir / sustain"
	end

	local baked = {
		categories = nil,
		phases     = nil,
		perChain   = nil,
		perMoveDir = nil,
		sustain    = stageDef.sustain,
		gate       = stageDef.gate,
		burst      = stageDef.burst,
		pulseTicks = stageDef.pulseTicks,
		breakTicks = stageDef.breakTicks,
	}

	if hasCats then
		baked.categories = _normalizeCategoriesField(stageDef.categories,
			ownerName .. "." .. stageName)
	elseif hasPool then
		local anon = _adoptInlinePool(stageDef.pool, ownerName, stageName)
		baked.categories = { { name = anon, weight = 1 } }
	end

	if hasPhases then
		baked.phases = {}
		for i, ph in ipairs(stageDef.phases) do
			local phaseCtx = string.format("%s.%s.phases[%d]", ownerName, stageName, i)
			local phHasCats = type(ph.categories) == "table" and #ph.categories > 0
			local phHasPool = type(ph.pool) == "table" and #ph.pool > 0
			local phBaked = { duration = ph.duration, pulseTicks = ph.pulseTicks }
			if phHasCats then
				phBaked.categories = _normalizeCategoriesField(ph.categories, phaseCtx)
			elseif phHasPool then
				local anon = _adoptInlinePool(ph.pool, ownerName,
					stageName .. "_p" .. i)
				phBaked.categories = { { name = anon, weight = 1 } }
			else
				DebugLog.log(string.format("[ZR SoundProfiles] %s: phase has neither categories nor pool",
					phaseCtx))
			end
			baked.phases[#baked.phases + 1] = phBaked
		end
	end

	if hasChain then
		baked.perChain = {}
		for chainLetter, chainStage in pairs(stageDef.perChain) do
			if type(chainLetter) ~= "string" or chainLetter == "" then
				DebugLog.log(string.format(
					"[ZR SoundProfiles] %s.%s.perChain: bad chain key %s",
					ownerName, stageName, tostring(chainLetter)))
			else
				local sub, err = _bakeStage(chainStage,
					ownerName, stageName .. "@" .. chainLetter)
				if sub then
					baked.perChain[chainLetter] = sub
				else
					DebugLog.log(string.format(
						"[ZR SoundProfiles] %s.%s.perChain[%s]: %s",
						ownerName, stageName, chainLetter, tostring(err)))
				end
			end
		end
	end

	if hasMoveDir then
		baked.perMoveDir = {}
		for mdLetter, mdStage in pairs(stageDef.perMoveDir) do
			if type(mdLetter) ~= "string" or mdLetter == "" then
				DebugLog.log(string.format(
					"[ZR SoundProfiles] %s.%s.perMoveDir: bad key %s",
					ownerName, stageName, tostring(mdLetter)))
			else
				local sub, err = _bakeStage(mdStage,
					ownerName, stageName .. "#" .. mdLetter)
				if sub then
					baked.perMoveDir[mdLetter] = sub
				else
					DebugLog.log(string.format(
						"[ZR SoundProfiles] %s.%s.perMoveDir[%s]: %s",
						ownerName, stageName, mdLetter, tostring(err)))
				end
			end
		end
	end

	return baked
end

local function _bakeStageMap(stagesTbl, ownerName)
	if type(stagesTbl) ~= "table" then return nil, "stages is not a table" end
	local out = {}
	for stageName, stage in pairs(stagesTbl) do
		local s, err = _bakeStage(stage, ownerName, stageName)
		if not s then
			return nil, string.format("stage %q — %s", stageName, tostring(err))
		end
		out[stageName] = s
	end
	return out
end

function ZR_SoundProfiles.register(name, profile)
	if type(name) ~= "string" or name == "" then
		DebugLog.log("[ZR SoundProfiles] register: bad name")
		return false
	end
	if type(profile) ~= "table" then
		DebugLog.log("[ZR SoundProfiles] register: profile must be a table for " .. name)
		return false
	end

	local baked = {
		pulseTicks    = profile.pulseTicks or 90,
		breakTicks    = profile.breakTicks or 30,
		startCategory = profile.startCategory,
		startSuffix   = profile.startSuffix,
		endCategory   = profile.endCategory,
		endSuffix     = profile.endSuffix,
		useSandboxGap = profile.useSandboxGap == true or nil,
		keepVanilla   = profile.keepVanilla == true or nil,
		moanSwap      = type(profile.moanSwap) == "string" and profile.moanSwap or nil,
		stages        = nil,
		modes         = nil,
		defaultMode   = nil,
	}

	if type(profile.modes) == "table" then
		baked.modes = {}
		for modeName, modeDef in pairs(profile.modes) do
			if type(modeName) ~= "string" or modeName == "" then
				DebugLog.log(string.format("[ZR SoundProfiles] register: bad mode key in %s", name))
			elseif type(modeDef) ~= "table" or type(modeDef.stages) ~= "table" then
				DebugLog.log(string.format("[ZR SoundProfiles] register: mode %q in %s needs a stages table",
					modeName, name))
			else
				local stages, err = _bakeStageMap(modeDef.stages, name .. "[" .. modeName .. "]")
				if stages then
					baked.modes[modeName] = stages
				else
					DebugLog.log(string.format("[ZR SoundProfiles] register: mode %q in %s — %s",
						modeName, name, tostring(err)))
				end
			end
		end

		baked.defaultMode = profile.defaultMode
		if not (baked.defaultMode and baked.modes[baked.defaultMode]) then
			if baked.modes.enhanced then
				baked.defaultMode = "enhanced"
			elseif baked.modes.regular then
				baked.defaultMode = "regular"
			else
				for k in pairs(baked.modes) do baked.defaultMode = k; break end
			end
		end
		baked.stages = baked.defaultMode and baked.modes[baked.defaultMode] or nil
		if not baked.stages then
			DebugLog.log("[ZR SoundProfiles] register: no valid modes for " .. name)
			return false
		end
	elseif type(profile.stages) == "table" then
		local stages, err = _bakeStageMap(profile.stages, name)
		if not stages then
			DebugLog.log(string.format("[ZR SoundProfiles] register: %s — %s", name, tostring(err)))
			return false
		end
		baked.stages = stages
	else
		DebugLog.log("[ZR SoundProfiles] register: missing stages/modes table for " .. name)
		return false
	end

	ZR_SoundProfiles._registry[name] = baked
	log("registered profile: %s%s", name,
		baked.modes and (" (modes: " .. (function()
			local ms = {}
			for k in pairs(baked.modes) do ms[#ms + 1] = k end
			table.sort(ms)
			return table.concat(ms, "/")
		end)() .. ", default=" .. tostring(baked.defaultMode) .. ")") or "")
	return true
end

function ZR_SoundProfiles.get(name)
	return name and ZR_SoundProfiles._registry[name] or nil
end

function ZR_SoundProfiles.resolve(name)
	local p = ZR_SoundProfiles.get(name)
	if p then return p, name end
	if name and name ~= "default" then
		log("profile %q not found, falling back to 'default'", name)
	end
	return ZR_SoundProfiles.get("default"), "default"
end

function ZR_SoundProfiles.list()
	local names = {}
	for k in pairs(ZR_SoundProfiles._registry) do names[#names + 1] = k end
	table.sort(names)
	return names
end

ZR_SoundProfiles.defineCategory("pain", {
	"PainFromBite",
	"PainFromLacerate",
	"PainFromScratch",
	"PainFromHitBlunt",
})

ZR_SoundProfiles.defineCategory("painSharp", {
	"PainMoodle",
	"PainFromGlassCut",
	"PainFromScratch",
})

ZR_SoundProfiles.defineCategory("exhaust", {
	"PainMoodle",
	{ suffix = "Exercise", params = { Exercising = 1.0 } },
})

ZR_SoundProfiles.defineCategory("exhaustSoft", {
	"PainMoodle",
	"CorpseLowEffort",
	{ suffix = "Exercise", params = { Exercising = 1.0 } },
})

ZR_SoundProfiles.defineCategory("pleasure", {
	"CorpseLowEffort",
})

ZR_SoundProfiles.defineCategory("relief", {
	"SighReliefed",
	"SighSad",
	"PainMoodle",
	"CorpseLowEffort",
})

ZR_SoundProfiles.defineCategory("reliefSoft", {
	"SighReliefed",
	"SighSad",
})

ZR_SoundProfiles.defineCategory("sad", {
	"SighSad",
})

ZR_SoundProfiles.defineCategory("surprise", {
	"PainFromHitBlunt",
})

ZR_SoundProfiles.defineCategory("painBlunt", {
	"PainFromHitBlunt",
})

ZR_SoundProfiles.defineCategory("painFall", {
	"PainFromFallHigh",
	"PainFromFallLow",
})

ZR_SoundProfiles.defineCategory("painMix", {
	"PainFromBite",
	"PainFromLacerate",
	"PainFromScratch",
	"PainFromHitBlunt",
	"PainFromGlassCut",
	"PainFromFallHigh",
	"PainFromFallLow",
})

ZR_SoundProfiles.defineCategory("scream", {
	"DeathFall",
})

ZR_SoundProfiles.defineCategory("vomit", {
	{ event = "Voice{Sex}Vomit" },
})

ZR_SoundProfiles.defineCategory("choke", {
	"MuffledCough",
})

ZR_SoundProfiles.register("default", {
	pulseTicks  = 90,
	defaultMode = "enhanced",
	modes = {
		regular = {
			stages = {
				intro = { categories = { "pain" } },
				main  = { categories = { "painSharp" } },
				outro = { categories = { "relief" }, pulseTicks = 60 },
			},
		},
		enhanced = {
			stages = {
				intro = { categories = { "painBlunt", "painFall" } },
				main  = { categories = { "painMix" } },
				outro = { categories = { "relief" }, pulseTicks = 60 },
			},
		},
	},
})

ZR_SoundProfiles.register("defeatpunch_default", {
	pulseTicks  = 90,
	defaultMode = "enhanced",
	modes = {
		regular = {
			stages = {
				intro = { categories = { "pain" } },
				main  = { categories = { {name="painSharp", weight=3}, {name="pain", weight=1} } },
				outro = { categories = { "relief" }, pulseTicks = 60 },
			},
		},
		enhanced = {
			stages = {
				intro = { categories = { "painBlunt", "painFall" } },
				main  = {
					perMoveDir = {
						A = { categories = { "scream" }, gate = "scream", burst = 2 },
						D = { sustain  = "fire",         gate = "fire"   },
					},
					categories = { "painMix" },
				},
				outro = { categories = { "relief" }, pulseTicks = 60 },
			},
		},
	},
})

ZR_SoundProfiles.register("bj_default", {
	pulseTicks  = 90,
	moanSwap    = "zr_moans_bj",
	defaultMode = "enhanced",
	modes = {
		regular = {
			stages = {
				intro = { categories = { "pain" } },
				main  = { categories = { "painSharp" } },
				outro = { categories = { "relief" }, pulseTicks = 60 },
			},
		},
		enhanced = {
			stages = {
				intro = { categories = { "painBlunt", "painFall" } },
				main  = { categories = { {name="vomit",weight=2}, {name="choke",weight=1} }, pulseTicks = 420, breakTicks = 300 },
				outro = { categories = { {name="vomit",weight=2}, {name="choke",weight=1} }, pulseTicks = 420, breakTicks = 300 },
			},
		},
	},
})

ZR_SoundProfiles.register("facesit_default", {
	pulseTicks  = 90,
	moanSwap    = "zr_moans_bj",
	defaultMode = "enhanced",
	modes = {
		regular = {
			stages = {
				intro = { categories = { "pain" } },
				main  = { categories = { "painSharp" } },
				outro = { categories = { "relief" }, pulseTicks = 60 },
			},
		},
		enhanced = {
			stages = {
				intro = { categories = { "painBlunt", "painFall" } },
				main  = { categories = { {name="vomit",weight=2}, {name="choke",weight=1} }, pulseTicks = 420, breakTicks = 300 },
				outro = { categories = { "relief" }, pulseTicks = 60 },
			},
		},
	},
})

ZR_SoundProfiles.register("soft_moan", {
	pulseTicks = 120,
	stages = {
		intro = { categories = { "exhaust" } },
		main  = { categories = { {name="exhaustSoft", weight=2}, {name="pleasure", weight=1} } },
		outro = { categories = { "reliefSoft" }, pulseTicks = 90 },
	},
})

ZR_SoundProfiles.register("rough", {
	pulseTicks = 120,
	stages = {
		intro = { categories = { "pain" }, pulseTicks = 90 },
		main  = { categories = { "pain" } },
		outro = { categories = { {name="painSharp", weight=1}, {name="sad", weight=1} } },
	},
})

return ZR_SoundProfiles
