-- @author DoomTero 2026
-- @copyright 2026 DoomTero - ZomboRut original work. ref:1248bfde
-- @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/ZR_Surrender"

local ZomboRutScene = require "ZomboRut/ZomboRutScene"

ZR_AutoSurrender = ZR_AutoSurrender or {}

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

local THREAT_FOR_FULL = 2

local EXHAUST_AMPLIFIER_BASE = 1.05

local STRESS_WEIGHT = 0.23

local PANIC_EXHAUSTION_WEIGHT = 0.05

local BUMP_BONUS_PER_HIT    = 0.08
local BUMP_BONUS_MAX        = 0.15
local BUMP_BONUS_GATE       = 0.40
local BUMP_BONUS_DECAY_SECS = 5
local BUMP_COOLDOWN_TICKS   = 30
local BUMP_BONUS_DECAY_PER_TICK = BUMP_BONUS_PER_HIT / (BUMP_BONUS_DECAY_SECS * 60)

local RESET_HYSTERESIS = 0.08

local MONOLOGUE_COOLDOWN_MS = 12000

local RESET_DEBOUNCE_MS = 1500

local TRAIT_MODIFIERS = {
	dominate     = { stressMult = 0.4,  loadMult   = 0.85 },
	Rapist       = { stressMult = 0.7 },
	fighter      = { loadMult   = 0.90, bumpMult   = 0.70 },
	Brave        = { stressMult = 0.7 },
	Desensitized = { stressMult = 0.7 },

	Wimp         = { stressMult = 1.5,  loadMult   = 1.15 },
	Cowardly     = { stressMult = 1.4 },
	Innocent     = { stressMult = 1.3 },
	Traumatized  = { stressMult = 1.4 },
	submissive   = { loadMult   = 1.15 },
	dpress       = { stressMult = 1.15 },

	Virgin        = { stressMult = 0.6, loadMult = 0.85 },
	Inexperienced = { loadMult = 0.95 },
	Nymphomaniac  = { loadMult = 1.10 },
	Lewd          = { loadMult = 1.10 },
	Masochist     = { loadMult = 1.10 },

	glassBones   = { bumpMult = 1.40 },
}

local MODS_CACHE_TICKS = 300

local TIER_LINES = {
	[2] = {
		"UI_ZR_AutoSurrender_Tier2",
		"UI_ZR_AutoSurrender_Tier2_B",
		"UI_ZR_AutoSurrender_Tier2_C",
		"UI_ZR_AutoSurrender_Tier2_D",
		"UI_ZR_AutoSurrender_Tier2_E",
		"UI_ZR_AutoSurrender_Tier2_F",
		"UI_ZR_AutoSurrender_Tier2_G",
		"UI_ZR_AutoSurrender_Tier2_H",
		"UI_ZR_AutoSurrender_Tier2_I",
		"UI_ZR_AutoSurrender_Tier2_J",
		"UI_ZR_AutoSurrender_Tier2_K",
		"UI_ZR_AutoSurrender_Tier2_L",
		"UI_ZR_AutoSurrender_Tier2_M",
		"UI_ZR_AutoSurrender_Tier2_N",
		"UI_ZR_AutoSurrender_Tier2_O",
		"UI_ZR_AutoSurrender_Tier2_P",
		"UI_ZR_AutoSurrender_Tier2_Q",
		"UI_ZR_AutoSurrender_Tier2_R",
		"UI_ZR_AutoSurrender_Tier2_S",
		"UI_ZR_AutoSurrender_Tier2_T",
	},
	[3] = {
		"UI_ZR_AutoSurrender_Tier3",
		"UI_ZR_AutoSurrender_Tier3_B",
		"UI_ZR_AutoSurrender_Tier3_C",
		"UI_ZR_AutoSurrender_Tier3_D",
		"UI_ZR_AutoSurrender_Tier3_E",
		"UI_ZR_AutoSurrender_Tier3_F",
		"UI_ZR_AutoSurrender_Tier3_G",
		"UI_ZR_AutoSurrender_Tier3_H",
		"UI_ZR_AutoSurrender_Tier3_I",
	},
}
local TIER_FALLBACKS = {
	[2] = "...arms feel like lead.",
	[3] = "...I'm about to give up.",
}

local function pickLine(tier)
	local list = TIER_LINES[tier]
	local key  = list[ZombRand(#list) + 1]
	local txt  = getText(key)
	if not txt or txt == key then return TIER_FALLBACKS[tier] end
	return txt
end

local function sayTier(player, st, tier)
	local now = getTimestampMs()
	if (now - (st.lastSaidMs or 0)) < MONOLOGUE_COOLDOWN_MS then
		return false
	end
	pcall(function() player:Say(pickLine(tier)) end)
	st.lastSaidMs = now
	return true
end

local STUMBLE_DEFEAT_PCT = 25

local FIRE_STAMINA = {
	0.05, 0.15, 0.26, 0.30, 0.34, 0.38, 0.43, 0.47, 0.51, 0.55,
}

local TIER1_FRAC = 0.63
local TIER2_FRAC = 0.84

local function thresholdsFor(sensitivity)
	sensitivity = math.floor(tonumber(sensitivity) or 5)
	if sensitivity < 1  then sensitivity = 1  end
	if sensitivity > 10 then sensitivity = 10 end
	local t3 = math.min(1.0, (1 - FIRE_STAMINA[sensitivity]) * EXHAUST_AMPLIFIER_BASE)
	return t3 * TIER1_FRAC, t3 * TIER2_FRAC, t3
end

local state = {}
local tickCounter = 0
local _tickLastMs = nil
local _tickAccumMs = 0

local function getState(player)
	if not state[player] then
		state[player] = {
			lastTier = 0, bumpBonus = 0,
			pendingBump = false, lastBumpTick = -math.huge,
			mods = nil, modsExpireAtTick = -1,
			armed = false,
		}
	end
	return state[player]
end

function ZR_AutoSurrender.isArmed(player)
	local st = state[player]
	return st and st.armed or false
end

local function ensureMods(player, st)
	if st.mods and tickCounter < st.modsExpireAtTick then
		return st.mods
	end
	local m = { stressMult = 1.0, exhaustMult = 1.0, bumpMult = 1.0, loadMult = 1.0 }
	for trait, effects in pairs(TRAIT_MODIFIERS) do
		if ZR_Trait and ZR_Trait.has and ZR_Trait.has(player, trait) then
			if effects.stressMult  then m.stressMult  = m.stressMult  * effects.stressMult  end
			if effects.exhaustMult then m.exhaustMult = m.exhaustMult * effects.exhaustMult end
			if effects.bumpMult    then m.bumpMult    = m.bumpMult    * effects.bumpMult    end
			if effects.loadMult    then m.loadMult    = m.loadMult    * effects.loadMult    end
		end
	end
	st.mods = m
	st.modsExpireAtTick = tickCounter + MODS_CACHE_TICKS
	return m
end

function ZR_AutoSurrender.invalidateMods(player)
	local st = state[player]
	if st then st.modsExpireAtTick = -1 end
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
	if sv.zombieDefeatEnabled == false then return end
	if not ZR_Surrender.exhaustActive() then return end

	if ZomboRutScene.isPlayerInScene(player) then return end
	if ZR_Surrender and ZR_Surrender.isActive and ZR_Surrender.isActive(player) then return end

	if ZR_ZombieCloak and ZR_ZombieCloak.isActive and ZR_ZombieCloak.isActive(player) then
		local st = state[player]
		if st and st.armed then
			st.armed = false
			st.lastTier = 0
		end
		return
	end

	local stats = player:getStats()
	if not stats then return end

	local st = getState(player)
	local now = getTimestampMs()

	local _dDecay = tickCounter - (st.lastDecayTick or tickCounter)
	st.lastDecayTick = tickCounter
	st.bumpBonus = math.max(0, (st.bumpBonus or 0) - BUMP_BONUS_DECAY_PER_TICK * _dDecay)

	local function normalized(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 mods = ensureMods(player, st)

	local stress     = math.min(1.0, normalized("STRESS") * mods.stressMult)
	local exhaustion = math.min(1.0, (1 - normalized("ENDURANCE")) * mods.exhaustMult)

	local panic = normalized("PANIC")
	exhaustion = math.min(1.0, exhaustion + panic * PANIC_EXHAUSTION_WEIGHT)

	local chasing   = (stats.getNumChasingZombies   and stats:getNumChasingZombies())   or 0
	local close     = (stats.getNumVeryCloseZombies and stats:getNumVeryCloseZombies()) or 0
	local threat    = math.max(chasing, close)
	local threatFactor = math.min(1.0, threat / THREAT_FOR_FULL)

	local base = math.min(1.0, exhaustion * (EXHAUST_AMPLIFIER_BASE + stress * STRESS_WEIGHT))

	local load = math.min(1.0, (base + st.bumpBonus) * threatFactor * mods.loadMult)

	if st.pendingBump and load >= BUMP_BONUS_GATE
		and (tickCounter - st.lastBumpTick) >= BUMP_COOLDOWN_TICKS
	then
		local credit = BUMP_BONUS_PER_HIT * mods.bumpMult
		st.bumpBonus = math.min(BUMP_BONUS_MAX, st.bumpBonus + credit)
		st.lastBumpTick = tickCounter
	end
	st.pendingBump = false

	local t1, t2, t3 = thresholdsFor(sv.autoGiveUpSensitivity)

	if load < (t1 - RESET_HYSTERESIS) then
		st.lowSinceMs = st.lowSinceMs or now
		if st.lastTier > 0 and (now - st.lowSinceMs) >= RESET_DEBOUNCE_MS then
			st.lastTier = 0
		end
	else
		st.lowSinceMs = nil
	end

	if st.armed and load < (t3 - RESET_HYSTERESIS) then
		log("disarmed: load %.2f dropped below %.2f", load, t3 - RESET_HYSTERESIS)
		st.armed = false
		if st.lastTier > 2 then st.lastTier = 2 end
	end

	local targetTier = 0
	if load >= t3 then targetTier = 3
	elseif load >= t2 then targetTier = 2
	elseif load >= t1 then targetTier = 1
	end

	local tripping = false
	pcall(function()
		if stats.isTripping then tripping = stats:isTripping() end
	end)
	if tripping and not st.wasTripping then
		if load >= t2 and ZombRand(100) < STUMBLE_DEFEAT_PCT then
			st.defeatOnStumbleEnd = true
			log("natural stumble at tier-2 load %.2f → defeat armed for recovery", load)
		end
	end
	if (not tripping) and st.wasTripping and st.defeatOnStumbleEnd then
		st.defeatOnStumbleEnd = false
		log("stumble ended → arming (next zombie strike triggers surrender)")
		sayTier(player, st, 3)
		st.lastTier = 3
		st.armed = true
		st.wasTripping = tripping
		return
	end
	st.wasTripping = tripping

	if targetTier <= st.lastTier then return end

	if targetTier == 3 then
		log("tier 3 fire (load=%.2f, base=%.2f, bonus=%.2f, factor=%.2f, threat=%d) → arming",
			load, base, st.bumpBonus, threatFactor, threat)
		sayTier(player, st, 3)
		st.lastTier = 3
		st.armed = true
		return
	end

	if targetTier == 2 then
		log("tier 2 (load=%.2f, threat=%d) — natural-stumble defeat armed", load, threat)
		sayTier(player, st, 2)
	else
		log("tier 1 (load=%.2f, threat=%d) — silent", load, threat)
	end
	st.lastTier = targetTier
end

local function onTick(player)
	do
		local now = getTimestampMs()
		local gap = _tickLastMs and (now - _tickLastMs) or 0
		_tickLastMs = now
		if gap > 0 and gap <= 400 then
			_tickAccumMs = _tickAccumMs + gap
			tickCounter = math.floor(_tickAccumMs * 0.06)
		end
	end
	local ok, err = pcall(onTickInner, player)
	if not ok then
		DebugLog.log("[ZR AutoSurrender] onTick error: " .. tostring(err))
	end
end

Events.OnPlayerUpdate.Add(onTick)

Events.OnCharacterDeath.Add(function(character)
	state[character] = nil
end)

if ZR_Surrender and ZR_Surrender.registerStartCallback then
	ZR_Surrender.registerStartCallback(function(player, _st)
		local mySt = state[player]
		if mySt then
			mySt.defeatOnStumbleEnd = false
			mySt.wasTripping = false
			mySt.armed = false
		end
	end)
end

local function onCharacterCollide(character, collidedCharacter)
	if not character or not collidedCharacter then return end

	local player, other
	if character.isPlayer and character:isPlayer() then
		player, other = character, collidedCharacter
	elseif collidedCharacter.isPlayer and collidedCharacter:isPlayer() then
		player, other = collidedCharacter, character
	else
		return
	end

	if not other.isZombie or not other:isZombie() then return end

	local st = state[player]
	if st then st.pendingBump = true end
end

Events.OnCharacterCollide.Add(onCharacterCollide)

local STRIKE_RANGE = 1.5

local _armedHeartbeatLastTick = {}

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

	local st = state[player]
	if not st or not st.armed then return end

	if ZomboRutScene.isPlayerInScene(player) then return end
	if ZR_Surrender and ZR_Surrender.isActive and ZR_Surrender.isActive(player) then return end

	if ZR_ZombieCloak and ZR_ZombieCloak.isActive and ZR_ZombieCloak.isActive(player) then
		st.armed = false
		st.lastTier = 0
		return
	end

	local cell = getCell()
	local zList = cell and cell:getZombieList()
	if not zList then return end

	local now = tickCounter
	local lastBeat = _armedHeartbeatLastTick[player] or 0
	local doHeartbeat = (now - lastBeat) >= 60
	if doHeartbeat then _armedHeartbeatLastTick[player] = now end

	local strikerCount = 0
	local nearestDist = math.huge
	local striker = nil
	for i = 0, zList:size() - 1 do
		local z = zList:get(i)
		if z and z:isAlive() then
			local d = z:DistTo(player)
			if d <= STRIKE_RANGE then
				strikerCount = strikerCount + 1
				if d < nearestDist then
					nearestDist = d
					striker = z
				end
			end
		end
	end

	if doHeartbeat then
		log("armed-scan heartbeat: armed=true, zombies-in-strike-range=%d (nearest dist=%.2f)",
			strikerCount, (striker and nearestDist) or -1)
	end

	if not striker then return end

	pcall(function() if striker.setNoTeeth then striker:setNoTeeth(true) end end)

	log("FIRING surrender: armed player has zombie id=%s within strike range (dist=%.2f)",
		tostring(striker.getID and striker:getID()), nearestDist)

	st.armed = false
	_armedHeartbeatLastTick[player] = nil
	if not ZR_Surrender.start(player, true) then
		st.armed = true
		log("start() refused (player likely in transition state); re-armed for retry")
	end
end

Events.OnPlayerUpdate.Add(onPlayerUpdate_ArmedScan)
DebugLog.log("[ZR AutoSurrender] onPlayerUpdate_ArmedScan registered (DEBUG=" .. tostring(DEBUG) .. ")")
