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

local ZomboRutScene = require "ZomboRut/ZomboRutScene"

ZR_BehaviorMeter = ZR_BehaviorMeter or {}

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

local A_BASE          = 1.0
local D_GAIN          = 2.0
local S_BASE          = 0.25
local S_GAIN          = 1.5
local W_BASE          = 0.15
local W_GAIN          = 1.2
local STRENGTH_FLOOR  = 0.5
local PANIC_SAP       = 0.3
local PANIC_TO_SUBMIT = 0.6
local STRESS_TO_SUBMIT= 0.3
local WADD_CAP        = 2.0
local SADD_CAP        = 1.5
local AADD_CAP        = 3.5
local DADD_CAP        = 1.2
local W_LOW_FLOOR     = 0.15
local S_ENDURANCE_RESTORE = 0.10

local DEFAULT_MAIN_MULT = 2

local MAIN_REPEAT_COUNT = 2

local TRAIT_DIRECTION_BIAS = {
	dominate     = { dMult = 1.60, sMult = 0.50 },
	Rapist       = { dMult = 1.40, sMult = 0.70 },
	fighter      = { dMult = 1.50, sMult = 0.70, aAdd = 2.00, dAdd = 0.50 },
	Brave        = { dMult = 1.30, sMult = 0.80 },
	youngBuck    = { dMult = 1.20 },
	inPrime      = { dMult = 1.15 },
	cardioJunkie = { dMult = 1.15 },

	submissive   = { sMult = 2.00, sAdd = 0.60, dMult = 0.50 },
	Wimp         = { sMult = 1.50, sAdd = 0.40, dMult = 0.60, aAdd = 3.00, dAdd = 0.80 },
	Cowardly     = { sMult = 1.40, sAdd = 0.30, dMult = 0.70 },
	Innocent     = { sMult = 1.30, sAdd = 0.30, dMult = 0.70 },
	Traumatized  = { sMult = 1.40, sAdd = 0.30, dMult = 0.70 },
	Virgin       = { sMult = 1.30, sAdd = 0.30, dMult = 0.85, aAdd = 3.00, dAdd = 0.80 },
	AARP         = { sMult = 1.30, sAdd = 0.20, dMult = 0.70 },
	dpress       = { sMult = 1.20, sAdd = 0.20 },
	drained      = { sMult = 1.15, sAdd = 0.15, dMult = 0.85 },
	Inexperienced = { sMult = 1.15, sAdd = 0.10 },

	Nymphomaniac = { wMult = 1.50, wAdd = 1.20, sMult = 0.70 },
	Lewd         = { wMult = 1.30, wAdd = 0.80, sMult = 0.80 },
	Masochist    = { wMult = 1.30, wAdd = 0.80, dMult = 0.80 },
	Sexaddict    = { wMult = 1.20, wAdd = 0.60 },
	succubus     = { wMult = 1.20, wAdd = 0.60 },
	Desensitized = { wMult = 1.15, wAdd = 0.40, sMult = 0.80 },
	Experienced  = { wMult = 1.10, wAdd = 0.30 },
	promiscuous  = { wMult = 1.10, wAdd = 0.30 },
	swinger      = { wMult = 1.10, wAdd = 0.30 },
}

local DIRECTIONS = { "A", "D", "S", "W" }

local function normalized(stats, statKey)
	local stat = CharacterStat[statKey]
	if stat == nil then return 0 end
	local max = stat:getMaximumValue() or 1
	if max <= 0 then return 0 end
	local v = stats:get(stat) or 0
	return v / max
end

local function getStrengthNorm(player)
	local lvl = nil
	pcall(function()
		if Perks and Perks.Strength and player.getPerkLevel then
			lvl = player:getPerkLevel(Perks.Strength)
		end
	end)
	if type(lvl) ~= "number" then return 0.5 end
	if lvl < 0 then lvl = 0 elseif lvl > 10 then lvl = 10 end
	return lvl / 10
end

local function buildMods(player)
	local m = {
		aMult = 1.0, dMult = 1.0, sMult = 1.0, wMult = 1.0,
		aAdd  = 0.0, dAdd  = 0.0, sAdd  = 0.0, wAdd  = 0.0,
	}
	for trait, effects in pairs(TRAIT_DIRECTION_BIAS) do
		if ZR_Trait and ZR_Trait.has and ZR_Trait.has(player, trait) then
			if effects.aMult then m.aMult = m.aMult * effects.aMult end
			if effects.dMult then m.dMult = m.dMult * effects.dMult end
			if effects.sMult then m.sMult = m.sMult * effects.sMult end
			if effects.wMult then m.wMult = m.wMult * effects.wMult end
			if effects.aAdd  then m.aAdd  = m.aAdd  + effects.aAdd  end
			if effects.dAdd  then m.dAdd  = m.dAdd  + effects.dAdd  end
			if effects.sAdd  then m.sAdd  = m.sAdd  + effects.sAdd  end
			if effects.wAdd  then m.wAdd  = m.wAdd  + effects.wAdd  end
		end
	end
	if m.aAdd > AADD_CAP then m.aAdd = AADD_CAP end
	if m.dAdd > DADD_CAP then m.dAdd = DADD_CAP end
	if m.sAdd > SADD_CAP then m.sAdd = SADD_CAP end
	if m.wAdd > WADD_CAP then m.wAdd = WADD_CAP end
	return m
end

local function computeWeights(player, mods)
	local stats = player:getStats()
	if not stats then return { A = A_BASE, D = 0, S = S_BASE, W = W_BASE } end

	local endurance = normalized(stats, "ENDURANCE")
	local panic     = normalized(stats, "PANIC")
	local stress    = normalized(stats, "STRESS")
	local strength  = getStrengthNorm(player)

	local vigor   = endurance * (STRENGTH_FLOOR + (1 - STRENGTH_FLOOR) * strength)
	                         * (1 - PANIC_SAP * panic)
	local exhaust = (1 - endurance) + PANIC_TO_SUBMIT * panic + STRESS_TO_SUBMIT * stress

	if vigor   < 0 then vigor   = 0 end
	if exhaust < 0 then exhaust = 0 elseif exhaust > 1.5 then exhaust = 1.5 end

	return {
		A =  A_BASE                     * mods.aMult + mods.aAdd,
		D = (vigor * D_GAIN)            * mods.dMult + mods.dAdd,
		S = (S_BASE + exhaust * S_GAIN) * mods.sMult + mods.sAdd,
		W = (W_BASE + exhaust * W_GAIN) * mods.wMult + mods.wAdd,
	}
end

local function weightedPick(weights)
	local total = 0
	for _, l in ipairs(DIRECTIONS) do total = total + (weights[l] or 0) end
	if total <= 0 then return "A" end
	local r = ZombRand(math.floor(total * 1000) + 1) / 1000
	local cum = 0
	for _, l in ipairs(DIRECTIONS) do
		cum = cum + (weights[l] or 0)
		if r <= cum then return l end
	end
	return DIRECTIONS[#DIRECTIONS]
end

local function restoreEnduranceIfS(player, dir)
	if dir ~= "S" then return end
	local stats = player:getStats()
	if not stats then return end
	if not CharacterStat or not CharacterStat.ENDURANCE then return end
	local cur, new
	pcall(function()
		cur = stats:get(CharacterStat.ENDURANCE) or 0
		stats:add(CharacterStat.ENDURANCE, S_ENDURANCE_RESTORE)
		new = stats:get(CharacterStat.ENDURANCE) or 0
	end)
	if cur ~= nil and new ~= nil then
		log("  S segment finished — endurance %.2f → %.2f (+%.2f)",
			cur, new, new - cur)
	end
end

local function rollSequence(player, n)
	if n < 1 then n = 1 end
	local mods = buildMods(player)
	local baseWeights = computeWeights(player, mods)

	local seq = { weightedPick(baseWeights) }

	for i = 2, n do
		local w = {}
		for k, v in pairs(baseWeights) do w[k] = v end
		w[seq[i - 1]] = 0
		if (w.W or 0) < W_LOW_FLOOR then w.W = W_LOW_FLOOR end
		seq[i] = weightedPick(w)
	end

	return seq, baseWeights, mods
end

local function logRollContext(player, mods, weights)
	if not DEBUG then return end
	local stats = player:getStats()
	if not stats then return end
	local endurance = normalized(stats, "ENDURANCE")
	local panic     = normalized(stats, "PANIC")
	local stress    = normalized(stats, "STRESS")
	local strength  = getStrengthNorm(player)
	log("  inputs:  endurance=%.2f panic=%.2f stress=%.2f strength=%.2f",
		endurance, panic, stress, strength)
	log("  mods:    dMult=%.2f sMult=%.2f wMult=%.2f sAdd=%.2f wAdd=%.2f",
		mods.dMult, mods.sMult, mods.wMult, mods.sAdd, mods.wAdd)
	log("  weights: A=%.2f D=%.2f S=%.2f W=%.2f",
		weights.A or 0, weights.D or 0, weights.S or 0, weights.W or 0)
end

local state = {}

local function getState(player)
	if not state[player] then
		state[player] = {
			activeScene     = nil,
			sequence        = nil,
			segmentIdx      = 0,
			loopCount       = 0,
			repeatN         = 1,
			introDoneLogged = false,
		}
	end
	return state[player]
end

local function resetState(st)
	st.activeScene     = nil
	st.sequence        = nil
	st.segmentIdx      = 0
	st.loopCount       = 0
	st.repeatN         = 1
	st.introDoneLogged = false
end

ZomboRutScene.registerSceneStartCallback(function(player, active)
	if not player then return end
	local sv = SandboxVars and SandboxVars.ZomboRut
	if not sv then return end
	if sv.enableMod == false then return end

	local st = getState(player)
	resetState(st)
	st.activeScene = active

	local mainMult = sv.mainDurationMultiplier or DEFAULT_MAIN_MULT
	local segs = math.max(1, mainMult)
	st.repeatN   = math.max(1, sv.mainSegmentRepeat or MAIN_REPEAT_COUNT)
	st.loopCount = 0

	local ok, seq, weights, mods = pcall(rollSequence, player, segs)
	if not ok or type(seq) ~= "table" or #seq == 0 then
		log("rollSequence FAILED — falling back to all-A. err=%s", tostring(seq))
		seq = {}
		for i = 1, segs do seq[i] = "A" end
	elseif weights and mods then
		logRollContext(player, mods, weights)
	end
	st.sequence = seq

	st.segmentIdx = 1
	pcall(function()
		player:setVariable("ZR_MoveDir", st.sequence[1])
		player:setVariable("ZR_MainSegmentsRemaining", tostring(#st.sequence))
		player:setVariable("ZR_MainClipEnded", "false")
	end)

	log("scene start: scene=%s distinct=[%s] repeat=%d (%d total clip-loops)",
		tostring(active and active.sceneId),
		table.concat(st.sequence, ","), st.repeatN, #st.sequence * st.repeatN)
	log("  initial ZR_MoveDir=%s ZR_MainSegmentsRemaining=%d",
		st.sequence[1], #st.sequence)
end)

ZomboRutScene.registerSceneEndCallback(function(player, active, result)
	local st = state[player]
	if not st then return end
	log("scene end: scene=%s segsPlayed=%d/%d result=%s",
		tostring(active and active.sceneId),
		st.segmentIdx or 0,
		(st.sequence and #st.sequence) or 0,
		tostring(result))
	resetState(st)
end)

local function onTickInner(player)
	if not player or not player:isAlive() then return end
	if player.isNpc and player:isNpc() then return end

	local sv = SandboxVars and SandboxVars.ZomboRut
	if not sv then return end
	if sv.enableMod == false then return end

	local active = ZomboRutScene._active and ZomboRutScene._active[player]
	local st = state[player]
	if not st then return end

	if not active then
		if st.activeScene ~= nil then resetState(st) end
		return
	end
	if not st.sequence then return end

	if not st.introDoneLogged then
		if player:getVariableString("ZR_IntroDone") == "true" then
			st.introDoneLogged = true
			log("intro done — Main started, engine should be playing variant %s",
				st.sequence[1])
		end
	end

	if player:getVariableString("ZR_MainClipEnded") ~= "true" then return end

	pcall(function()
		player:setVariable("ZR_MainClipEnded", "false")
	end)

	local seq    = st.sequence
	local curDir = seq[st.segmentIdx or 1]
	restoreEnduranceIfS(player, curDir)

	local repeatN = st.repeatN or 1
	st.loopCount  = (st.loopCount or 0) + 1
	if st.loopCount < repeatN then
		log("segment %d/%d dir=%s loop %d/%d (hold)",
			st.segmentIdx or 1, #seq, tostring(curDir), st.loopCount, repeatN)
		return
	end

	st.loopCount  = 0
	st.segmentIdx = (st.segmentIdx or 1) + 1

	if st.segmentIdx <= #seq then
		pcall(function()
			player:setVariable("ZR_MoveDir", seq[st.segmentIdx])
			player:setVariable("ZR_MainSegmentsRemaining",
				tostring(#seq - st.segmentIdx + 1))
		end)
		log("advance to segment %d/%d dir=%s",
			st.segmentIdx, #seq, seq[st.segmentIdx])
	else
		local sceneId = active.sceneId or ""
		local cfg     = active.cfg or {}
		pcall(function()
			player:setVariable("ZR_MainSegmentsRemaining", "0")
			player:setVariable("ZR_" .. sceneId .. "Outro", "true")
		end)
		active.outroActivated = true
		local effDur     = active.effectiveDuration or cfg.duration or 24
		local outroSecs  = cfg.outroDuration or 5
		local outroStart = math.max(0, (effDur - outroSecs) * 60)
		if (active.dragTicks or 0) < outroStart then
			active.dragTicks = outroStart
			active.dragBaseTicks = outroStart
			active.dragAccumMs   = 0
			active.dragLastMs    = getTimestampMs()
		end
		log("sequence exhausted: flipped ZR_%sOutro=true, dragTicks→%d",
			sceneId, outroStart)
	end
end

local function onTick(player)
	pcall(onTickInner, player)
end

Events.OnPlayerUpdate.Add(onTick)

return ZR_BehaviorMeter
