-- @author DoomTero 2026
-- @copyright 2026 DoomTero - ZomboRut original work. ref:a49524cb
-- @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_Surrender = ZR_Surrender or {}

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

ZR_Surrender.DEFEAT_MODE = { RIP = 1, EXHAUST = 2, BOTH = 3 }

function ZR_Surrender.getDefeatMode()
	local sv = SandboxVars and SandboxVars.ZomboRut
	local m = sv and sv.defeatMode
	if m ~= 1 and m ~= 2 and m ~= 3 then return ZR_Surrender.DEFEAT_MODE.BOTH end
	return m
end

function ZR_Surrender.ripActive()
	local m = ZR_Surrender.getDefeatMode()
	return m == ZR_Surrender.DEFEAT_MODE.RIP or m == ZR_Surrender.DEFEAT_MODE.BOTH
end

function ZR_Surrender.exhaustActive()
	local m = ZR_Surrender.getDefeatMode()
	return m == ZR_Surrender.DEFEAT_MODE.EXHAUST or m == ZR_Surrender.DEFEAT_MODE.BOTH
end

local TRIGGER_RANGE_FIRST = 1.5
local TRIGGER_RANGE_CHAIN = 5

local BANDIT_TRIGGER_RANGE = 8

local PACIFY_RANGE = 8

local COLLAPSE_DURATION_MS = 6000

local GETUP_DURATION_MS = 2000

local COLLAPSE_COUGH_AT_MS = 3000

local GHOST_RELEASE_MIN_MS = 2500

local SCENE_POOL = {
	"DefeatPunch_DefeatPunch_DefeatPunch",
	"DefeatBJ_DefeatBJ_DefeatBJ",
	"DefeatLunge_DefeatLunge_DefeatLunge",
	"DefeatLesbianFaceSit_DefeatLesbianFaceSit_DefeatLesbianFaceSit",
	"DefeatForcedCowGirl_DefeatForcedCowGirl_DefeatForcedCowGirl",
	"DefeatRevLotus_DefeatRevLotus_DefeatRevLotus",
}

local function rollSceneId(player, zombie)
	if ZomboRutScene._sameGenderBlocked(player, zombie) then return nil end
	local realPg = (player and player.isFemale and player:isFemale()) and "F" or "M"
	local spoofMale = realPg == "M"
		and SandboxVars and SandboxVars.ZomboRut
		and SandboxVars.ZomboRut.maleSubmissiveDefeat == true
	local pg = spoofMale and "F" or realPg
	local zg = (zombie and zombie.isFemale and zombie:isFemale()) and "F" or "M"
	local exactSuffix = "_" .. pg .. zg

	local exact = {}
	local fallback = {}
	for i = 1, #SCENE_POOL do
		local id = SCENE_POOL[i]
		if ZomboRutScene.get(id .. exactSuffix) then
			exact[#exact + 1] = spoofMale and (id .. exactSuffix) or id
		elseif ZomboRutScene._resolveGender(player, zombie, id) then
			fallback[#fallback + 1] = id
		end
	end
	local pool = (#exact > 0) and exact or fallback
	if #pool == 0 then return nil end
	return pool[ZombRand(#pool) + 1]
end

local DEFAULT_SURRENDER_DURATION_TICKS = 300

local DEFAULT_ESCAPE_DURATION_TICKS = 600

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

local function nowTick() return tickCounter end

local endSurrender

local fireCallbacks

local function applyPlayerLock(player, sceneActive)
	pcall(function()
		if player.setIgnoreAimingInput then player:setIgnoreAimingInput(true) end
		if player.setForceAim          then player:setForceAim(false)          end
		if player.setIsAiming          then player:setIsAiming(false)          end
		if player.setCanShout          then player:setCanShout(false)          end
	end)
	if sceneActive then
		local sc = ZomboRutScene._active and ZomboRutScene._active[player]
		if sc and sc.cfg and sc.cfg.noMovementLock then
			pcall(function()
				if player.setIgnoreContextKey then player:setIgnoreContextKey(true) end
				if player.setIgnoreAutoVault  then player:setIgnoreAutoVault(true)  end
			end)
			return
		end
	end
	pcall(function()
		if player.setSprinting        then player:setSprinting(false)        end
		if player.setRunning          then player:setRunning(false)          end
		if player.setForceSprint      then player:setForceSprint(false)      end
		if player.setBlockMovement    then player:setBlockMovement(true)     end
		if player.setIgnoreContextKey then player:setIgnoreContextKey(true)  end
		if player.setIgnoreAutoVault  then player:setIgnoreAutoVault(true)   end
	end)
	if not sceneActive then
		pcall(function()
			if player.StopAllActionQueue then player:StopAllActionQueue() end
		end)
	end
end

local function applyEscapeMobility(player)
	pcall(function()
		if player.setIgnoreMovement   then player:setIgnoreMovement(false)   end
		if player.setBlockMovement    then player:setBlockMovement(false)    end
		if player.setIgnoreContextKey then player:setIgnoreContextKey(false) end
		if player.setIgnoreAutoVault  then player:setIgnoreAutoVault(false)  end
		if player.setIgnoreAimingInput then player:setIgnoreAimingInput(false) end
		if player.setCanShout          then player:setCanShout(true)           end
	end)
end

local function clearPlayerEffects(player)
	pcall(function()
		if player.setIgnoreMovement   then player:setIgnoreMovement(false)   end
		if player.setBlockMovement    then player:setBlockMovement(false)    end
		if player.setIgnoreContextKey then player:setIgnoreContextKey(false) end
		if player.setIgnoreAutoVault  then player:setIgnoreAutoVault(false)  end
		if player.setIgnoreAimingInput then player:setIgnoreAimingInput(false) end
		if player.setCanShout          then player:setCanShout(true)           end
	end)
end

local function isHostileBandit(z)
	if not z:getVariableBoolean("Bandit") then return false end
	if not (Bandit and BanditBrain and Bandit.IsHostile) then return false end
	return Bandit.IsHostile(z) and true or false
end

local function isAggressor(z)
	if z:getVariableBoolean("Bandit") then return isHostileBandit(z) end
	return true
end

local function isAggressorFor(z, paciSet)
	if not z:getVariableBoolean("Bandit") then return true end
	if Bandit and Bandit.IsHostile and Bandit.IsHostile(z) then return true end
	local rec = paciSet[z]
	if rec and rec.isBandit
		and (rec.wasHostile or rec.wasHostileP)
	then
		return true
	end
	return false
end

local TEETH_AUDIT = true

local function pacifyOne(z, paciSet)
	local prior = paciSet[z]
	if TEETH_AUDIT and prior then
		local teethBack = false
		pcall(function() teethBack = (z.isNoTeeth and not z:isNoTeeth()) or false end)
		if teethBack and nowTick() - (prior.auditTick or -999) >= 60 then
			prior.auditTick = nowTick()
			local useless, hasTgt, cg = "?", "?", "?"
			pcall(function() useless = tostring(z.isUseless and z:isUseless()) end)
			pcall(function() hasTgt = tostring((z.getTarget and z:getTarget()) ~= nil) end)
			pcall(function()
				cg = tostring(ZR_ClothingGrab and ZR_ClothingGrab.isNeutered
					and ZR_ClothingGrab.isNeutered(z) or false)
			end)
			log("TEETH-AUDIT: zombie id=%s got teeth back mid-give-up (state=%s useless=%s tgt=%s cg=%s) — a restore path is fighting the pacify",
				tostring(z.getID and z:getID()),
				tostring(z.getCurrentStateName and z:getCurrentStateName() or "?"),
				useless, hasTgt, cg)
		end
	end
	if not paciSet[z] then
		local wasNoTeeth = (z.isNoTeeth and z:isNoTeeth()) or false
		if ZR_ClothingGrab and ZR_ClothingGrab.isNeutered
			and ZR_ClothingGrab.isNeutered(z) then
			wasNoTeeth = false
		end
		local snap = {
			wasNoTeeth = wasNoTeeth,
		}
		if z:getVariableBoolean("Bandit") and BanditBrain then
			local brain = BanditBrain.Get(z)
			if brain then
				snap.isBandit      = true
				snap.wasHostile    = brain.hostile
				snap.wasHostileP   = brain.hostileP
				snap.wasStationary = brain.stationary
			end
		end
		paciSet[z] = snap
	end
	pcall(function() if z.setNoTeeth then z:setNoTeeth(true) end end)
	if TEETH_AUDIT then
		pcall(function()
			if z.isNoTeeth and not z:isNoTeeth() then
				local rec = paciSet[z]
				if rec and nowTick() - (rec.writeBackTick or -999) >= 60 then
					rec.writeBackTick = nowTick()
					log("TEETH-WRITEBACK-FAILED: id=%s still has teeth immediately after setNoTeeth(true) (bandit=%s state=%s) — the WRITE is being refused, not reverted",
						tostring(z.getID and z:getID()),
						tostring(z:getVariableBoolean("Bandit")),
						tostring(z.getCurrentStateName and z:getCurrentStateName() or "?"))
				end
			end
		end)
	end
	if z:getVariableBoolean("Bandit") and Bandit then
		pcall(function()
			if Bandit.SetHostile      then Bandit.SetHostile(z, false)     end
			if Bandit.SetHostileP     then Bandit.SetHostileP(z, false)    end
			if Bandit.ForceStationary then Bandit.ForceStationary(z, true) end
			if Bandit.ClearTasks      then Bandit.ClearTasks(z)            end
			pcall(function() z:clearAggroList() end)
			pcall(function() z:setTarget(nil)   end)
		end)
	end
end

local function pacifyNearbyZombies(player, paciSet)
	local cell = getCell()
	local zList = cell and cell:getZombieList()
	if not zList then return end
	for i = 0, zList:size() - 1 do
		local z = zList:get(i)
		if z and z:isAlive() and isAggressorFor(z, paciSet) then
			local inRange    = z:DistTo(player) <= PACIFY_RANGE
			local isAttacker = z.isZombieAttacking and z:isZombieAttacking(player)
			local isTargeter = z.getTarget and z:getTarget() == player
			if inRange or isAttacker or isTargeter then
				pacifyOne(z, paciSet)
			end
		end
	end
end

local function unpacifyZombies(paciSet)
	for z, rec in pairs(paciSet) do
		pcall(function()
			if z and z:isAlive() and z.setNoTeeth then
				z:setNoTeeth(rec.wasNoTeeth)
			end
			if rec.isBandit and z and z:isAlive() and BanditBrain then
				local brain = BanditBrain.Get(z)
				if brain then
					brain.hostile    = rec.wasHostile
					brain.hostileP   = rec.wasHostileP
					brain.stationary = rec.wasStationary
				end
			end
		end)
	end
end

local function ensureEmote(player)
	pcall(function()
		if player.playEmote then player:playEmote("surrender") end
	end)
end

local function stopEmote(player)
	pcall(function()
		if player.setVariable   then player:setVariable("EmotePlaying", false) end
		if player.clearVariable then player:clearVariable("emote")             end
	end)
end

local function forceClearHitReaction(player)
	pcall(function()
		if IdleState and IdleState.instance and player.changeState then
			local state = player.getCurrentStateName and player:getCurrentStateName() or ""
			if state == "PlayerHitReactionState"
				or state == "PlayerHitReactionPVPState"
			then
				player:changeState(IdleState.instance())
			end
		end
	end)
end

local function forceIdleGated(player, tag)
	pcall(function()
		if IdleState and IdleState.instance and player.changeState then
			local cur = player.getCurrentStateName and player:getCurrentStateName() or ""
			local isLegitTransition = cur:find("Climb") or cur:find("Vault") or cur:find("Falling")
			if cur ~= "IdleState" and not isLegitTransition then
				player:changeState(IdleState.instance())
				log("%s: player force-idled (was '%s')", tag or "forceIdle", cur)
			end
		end
	end)
end

local function setCollapsed(player, on)
	pcall(function()
		player:setVariable("ZR_DefeatCollapsed", on and "true" or "false")
	end)
end

local function setGettingUp(player, on)
	pcall(function()
		player:setVariable("ZR_DefeatGetUp", on and "true" or "false")
	end)
end

local function startGetUp(player, st)
	if st.gettingUp then return end
	st.gettingUp = true
	st.phaseEndMs = getTimestampMs() + GETUP_DURATION_MS
	setCollapsed(player, false)
	setGettingUp(player, true)
	log("collapse getup started (%dms)", GETUP_DURATION_MS)
end

local ghostWanted = {}

local function setGhosted(player, on)
	ghostWanted[player] = on and true or nil
	pcall(function()
		if player.setGhostMode then player:setGhostMode(on, true) end
	end)
	pcall(function()
		if player.isGhostMode and player:isGhostMode() ~= on
			and player.setInvisible then
			player:setInvisible(on, true)
		end
	end)
	pcall(function()
		if player.isGhostMode and player:isGhostMode() ~= on then
			log("GHOST WRITE REFUSED: want=%s -> isGhostMode()=%s -- the crowd will NOT be blinded this window",
				tostring(on), tostring(player:isGhostMode()))
		end
	end)
end

local function voiceCue(player, suffix)
	pcall(function()
		if player.playerVoiceSound then player:playerVoiceSound(suffix) end
	end)
end

local function disperseCrowd(player, durationMs)
	pcall(function()
		if ZR_CrowdControl and ZR_CrowdControl.disperse then
			ZR_CrowdControl.disperse(player, durationMs)
		end
	end)
end

local function enterEscapePhase(player, st)
	st.phase = "escape"
	st.startTick = nowTick()
	if not (ZR_CrowdControl and ZR_CrowdControl.isDispersing
		and ZR_CrowdControl.isDispersing(player))
		and getTimestampMs() >= (st.ghostUntilMs or 0) then
		setGhosted(player, false)
	end
	forceIdleGated(player, "enterEscapePhase")
	local escDur = DEFAULT_ESCAPE_DURATION_TICKS
	if SandboxVars and SandboxVars.ZomboRut
		and SandboxVars.ZomboRut.escapeDuration
	then
		escDur = SandboxVars.ZomboRut.escapeDuration * 60
	end
	st.endTick = nowTick() + escDur
	applyEscapeMobility(player)
	stopEmote(player)

	st.participated = {}
	log("ESCAPE phase started (duration=%d ticks); participated[] reset", escDur)
	fireCallbacks(ZR_Surrender._escapeCallbacks, player, st)
end

local function transitionToEscape(player, st, opts)
	if opts and opts.collapse then
		local escTicks = DEFAULT_ESCAPE_DURATION_TICKS
		if SandboxVars and SandboxVars.ZomboRut
			and SandboxVars.ZomboRut.escapeDuration
		then
			escTicks = SandboxVars.ZomboRut.escapeDuration * 60
		end
		disperseCrowd(player, COLLAPSE_DURATION_MS + GETUP_DURATION_MS
			+ math.floor(escTicks * 1000 / 60))
	else
		disperseCrowd(player)
	end
	if opts and opts.ghostRelease then
		setGhosted(player, true)
		st.ghostUntilMs = getTimestampMs() + GHOST_RELEASE_MIN_MS
		st.fromCollapse = false
		log("GHOST-RELEASE: engagement ended with no scene rendered — player freed upright + ghosted (min %dms)",
			GHOST_RELEASE_MIN_MS)
		enterEscapePhase(player, st)
		return
	end
	if not (opts and opts.collapse) then
		st.fromCollapse = false
		enterEscapePhase(player, st)
		return
	end
	st.phase = "collapse"
	st.startTick = nowTick()
	st.phaseEndMs = getTimestampMs() + COLLAPSE_DURATION_MS
	st.coughAtMs = getTimestampMs() + COLLAPSE_COUGH_AT_MS
	st.gettingUp = nil
	forceIdleGated(player, "collapse")
	stopEmote(player)
	setCollapsed(player, true)
	setGhosted(player, true)
	pcall(function()
		if ZR_SceneZombieFade and ZR_SceneZombieFade.flushHold then
			ZR_SceneZombieFade.flushHold("collapse start")
		end
	end)
	voiceCue(player, "PainMoodle")
	log("COLLAPSE phase started (%dms) — player down + ghosted, catching breath",
		COLLAPSE_DURATION_MS)
end

local function defeatEndOpts(st)
	if st.engaged or (st.scenesRendered or 0) > 0 then
		return { collapse = true }
	end
	return nil
end

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

function ZR_Surrender.getPhase(player)
	local st = active[player]
	return st and st.phase or nil
end

function ZR_Surrender.ownsPlayer(player)
	local st = active[player]
	return (st and st.phase == "surrender") and true or false
end

function ZR_Surrender.getSceneFireHoldMs(player)
	local st = active[player]
	if not st or st.phase ~= "surrender" then return 0 end
	local ready = st.sceneFireReadyTick
	if not ready then return 0 end
	local ticksLeft = ready - nowTick()
	if ticksLeft <= 0 then return 0 end
	return math.floor(ticksLeft * 1000 / 60)
end

function ZR_Surrender.holdForScene(player, on)
	local st = active[player]
	if not st then return false end
	st.sceneActive = (on == true)
	if on then
		stopEmote(player)
	end
	log("holdForScene(%s): give-up machine %s", tostring(on),
		on and "HELD (external scene, emote stopped)" or "released")
	return true
end

function ZR_Surrender.setNoHandsUp(player, on)
	local st = active[player]
	if not st then return false end
	st.noHandsUp = (on == true)
	if on then stopEmote(player) end
	return true
end

function ZR_Surrender.deferSceneFire(player, ticks)
	local st = active[player]
	if not st then return end
	st.sceneFireReadyTick = math.max(st.sceneFireReadyTick or nowTick(), nowTick() + (ticks or 60))
end

local function countAggroedZombies(player)
	local cell = getCell()
	local zList = cell and cell:getZombieList()
	if not zList then return 0 end
	local count = 0
	for i = 0, zList:size() - 1 do
		local z = zList:get(i)
		if z and z:isAlive()
			and isAggressor(z)
			and z:DistTo(player) <= 8
		then
			count = count + 1
		end
	end
	return count
end

local function previewEligibility(player, st)
	local cell = getCell()
	local zList = cell and cell:getZombieList()
	if not zList then return 0, 0 end
	local eligible, imminent = 0, 0
	for i = 0, zList:size() - 1 do
		local z = zList:get(i)
		if z and z:isAlive()
			and isAggressor(z)
			and not (st.excluded and st.excluded[z])
			and not (st.participated and st.participated[z])
		then
			local d = z:DistTo(player)
			if d <= PACIFY_RANGE then
				if rollSceneId(player, z) == nil then
					st.excluded[z] = true
					log("preview: aggressor id=%s can never roll a scene (%s) → excluded",
						tostring(z:getID()),
						ZomboRutScene._sameGenderBlocked(player, z)
							and "same-gender blocked" or "empty pool")
				else
					eligible = eligible + 1
					if d <= TRIGGER_RANGE_CHAIN then
						imminent = imminent + 1
					end
				end
			end
		end
	end
	return eligible, imminent
end

local function pickChainTarget(player, st)
	local cell = getCell()
	local zList = cell and cell:getZombieList()
	if not zList then return nil end
	local best, bestD
	for i = 0, zList:size() - 1 do
		local z = zList:get(i)
		if z and z:isAlive()
			and not z:getVariableBoolean("Bandit")
			and not (st.excluded and st.excluded[z])
			and not (st.participated and st.participated[z])
		then
			local d = z:DistTo(player)
			if d <= TRIGGER_RANGE_CHAIN and (not bestD or d < bestD) then
				local cur = z.getCurrentStateName and z:getCurrentStateName() or ""
				if not (cur:find("Climb") or cur:find("Vault")) then
					if rollSceneId(player, z) == nil then
						st.excluded[z] = true
					else
						best, bestD = z, d
					end
				end
			end
		end
	end
	return best
end

local function reassertReseedAggro(player, st)
	pcall(function()
		local cell = getCell()
		local zl = cell and cell:getZombieList()
		if not zl then return end
		for i = 0, zl:size() - 1 do
			local z = zl:get(i)
			if z and z:isAlive()
				and not z:getVariableBoolean("Bandit")
				and not (z.getModData and z:getModData().zrSubdued)
				and not (st.excluded and st.excluded[z])
				and not (st.participated and st.participated[z])
				and z:DistTo(player) <= PACIFY_RANGE
			then
				pcall(function()
					if z.isUseless and z:isUseless() then z:setUseless(false) end
					if z.getTarget and z:getTarget() ~= player then
						z:setTarget(player)
					end
				end)
			end
		end
	end)
end

local function _inTransitionState(player)
	local stateName = player.getCurrentStateName and player:getCurrentStateName() or ""
	return stateName:find("Climb")
		or stateName:find("Vault")
		or stateName:find("Stagger")
		or stateName:find("HitReaction")
		or stateName:find("Bumped")
		or stateName:find("Falling")
		or stateName:find("FallDown")
		or stateName:find("GetUp")
		or stateName == "PlayerOnGroundState"
		or stateName:find("KnockedDown")
		or stateName:find("Thrown")
end

function ZR_Surrender.isInTransitionState(player)
	return _inTransitionState(player) and true or false
end

local DOMINATE_REFUSE_LINES = {
	"IGUI_ZR_Dominate_Refuse1",
	"IGUI_ZR_Dominate_Refuse2",
	"IGUI_ZR_Dominate_Refuse3",
}
local DOMINATE_REFUSE_FALLBACK    = "I'm not done fighting yet!"
local DOMINATE_REFUSE_COOLDOWN_MS = 5000
local _dominateRefuseLastMs = {}

local function sayDominateRefusal(player)
	local now  = getTimestampMs()
	local last = _dominateRefuseLastMs[player]
	if last and (now - last) < DOMINATE_REFUSE_COOLDOWN_MS then return end
	_dominateRefuseLastMs[player] = now
	local key = DOMINATE_REFUSE_LINES[ZombRand(#DOMINATE_REFUSE_LINES) + 1]
	local txt = getText(key)
	if not txt or txt == key then txt = DOMINATE_REFUSE_FALLBACK end
	pcall(function() player:Say(txt) end)
end

function ZR_Surrender.start(player, force, opts)
	if not player or not player:isAlive() then return false end

	if not force then
		local isSprinting   = player.isSprinting    and player:isSprinting()
		local isRunning     = player.isRunning      and player:isRunning()
		local isMoving      = player.isPlayerMoving and player:isPlayerMoving()
		if isSprinting or isRunning or isMoving then
			log("start refused: player still moving (sprinting=%s running=%s isMoving=%s)",
				tostring(isSprinting), tostring(isRunning), tostring(isMoving))
			player:Say(getText("ContextMenu_ZR_Surrender_RefuseStillRunning")
				or "I need to stop first.")
			return false
		end
	else
		if _inTransitionState(player) then
			log("force start deferred: player in transition state '%s'",
				player.getCurrentStateName and player:getCurrentStateName() or "?")
			return false
		end

		pcall(function()
			if IdleState and IdleState.instance and player.changeState then
				player:changeState(IdleState.instance())
			end
			local px, py, pz = player:getX(), player:getY(), player:getZ()
			player:setX(px); player:setY(py); player:setZ(pz)
			if player.setNextX then player:setNextX(px) end
			if player.setNextY then player:setNextY(py) end
		end)
		log("start: force=true — auto-trigger, player force-idled mid-stride")
	end

	if ZR_Trait and ZR_Trait.has and ZR_Trait.has(player, "dominate") then
		local bd = player.getBodyDamage and player:getBodyDamage()
		local hp = bd and bd.getHealth and bd:getHealth() or 100
		if hp > 33 then
			log("start refused: dominate trait + HP %d > 33", hp)
			sayDominateRefusal(player)
			return false
		end
	end

	if active[player] and active[player].phase == "surrender" then
		log("start refused: already in surrender phase")
		return false
	end

	if active[player]
		and (active[player].phase == "escape" or active[player].phase == "collapse")
	then
		log("start: ending active %s phase to re-enter surrender", active[player].phase)
		endSurrender(player, active[player])
	end

	if ZomboRutScene.isPlayerInScene(player) then
		log("start refused: already in a scene")
		return false
	end

	local duration = DEFAULT_SURRENDER_DURATION_TICKS
	if SandboxVars and SandboxVars.ZomboRut
		and SandboxVars.ZomboRut.surrenderDuration
	then
		duration = SandboxVars.ZomboRut.surrenderDuration * 60
	end

	local credits = math.max(1, countAggroedZombies(player))

	local instant = (opts and opts.instant) or false
	local sceneFireReadyTick = nowTick()
	if force then sceneFireReadyTick = nowTick() + 60 end

	active[player] = {
		phase              = "surrender",
		startTick          = nowTick(),
		endTick            = nowTick() + duration,
		pacified           = {},
		sceneActive        = false,
		scenesRemaining    = credits,
		scenesRendered     = 0,
		excluded           = {},
		participated       = {},
		sceneFireReadyTick = sceneFireReadyTick,
		sceneStartTick     = nil,
		fastFails          = 0,
		firstScene         = (opts and opts.firstScene) or nil,
		firstSceneZombie   = (opts and opts.firstSceneZombie) or nil,
		firstSceneDeadline = (opts and opts.firstSceneZombie) and (nowTick() + 240) or nil,
		reseedOnFirstSceneEnd = (opts and opts.reseedOnFirstSceneEnd) or nil,
		noHandsUp          = instant,
	}
	applyPlayerLock(player, false)
	forceClearHitReaction(player)
	pacifyNearbyZombies(player, active[player].pacified)
	if not active[player].noHandsUp then ensureEmote(player) end
	log("SURRENDER started for player (duration=%d ticks, %d aggroed scene credits, force=%s, sceneCooldown=%d ticks)",
		duration, credits, tostring(force or false),
		sceneFireReadyTick - nowTick())
	fireCallbacks(ZR_Surrender._startCallbacks, player, active[player])
	return true
end

function ZR_Surrender.standUp(player)
	local st = active[player]
	if not st then return false end
	if st.phase == "collapse" then
		if st.gettingUp then return false end
		log("collapse cut short by player → getting up")
		startGetUp(player, st)
		return true
	end
	if st.phase ~= "surrender" then return false end
	transitionToEscape(player, st)
	log("SURRENDER manually cancelled by player → ESCAPE")
	return true
end

ZR_Surrender._startCallbacks  = ZR_Surrender._startCallbacks  or {}
ZR_Surrender._escapeCallbacks = ZR_Surrender._escapeCallbacks or {}
ZR_Surrender._endCallbacks    = ZR_Surrender._endCallbacks    or {}

function ZR_Surrender.getScenesRendered(player)
	local st = active[player]
	return (st and st.scenesRendered) or 0
end

function ZR_Surrender.markStruggleWin(player, amnesty)
	local st = active[player]
	if not st then return false end
	st.struggleWon = true
	st.struggleWonAmnesty = amnesty and true or nil
	return true
end

function ZR_Surrender.registerStartCallback(fn)
	if type(fn) ~= "function" then return end
	table.insert(ZR_Surrender._startCallbacks, fn)
end

function ZR_Surrender.registerEscapeCallback(fn)
	if type(fn) ~= "function" then return end
	table.insert(ZR_Surrender._escapeCallbacks, fn)
end

function ZR_Surrender.registerEndCallback(fn)
	if type(fn) ~= "function" then return end
	table.insert(ZR_Surrender._endCallbacks, fn)
end

fireCallbacks = function(list, player, st)
	for i = 1, #list do
		local ok, err = pcall(list[i], player, st)
		if not ok then log("callback error: %s", tostring(err)) end
	end
end

endSurrender = function(player, st)
	fireCallbacks(ZR_Surrender._endCallbacks, player, st)
	clearPlayerEffects(player)
	forceIdleGated(player, "endSurrender")
	stopEmote(player)
	setCollapsed(player, false)
	setGettingUp(player, false)
	setGhosted(player, false)
	pcall(function()
		if ZR_CrowdControl and ZR_CrowdControl.stop then
			ZR_CrowdControl.stop(player)
		end
	end)
	unpacifyZombies(st.pacified or {})
	active[player] = nil
	log("surrender mechanic ended — player back to normal")
end

local function onPlayerUpdate(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 st = active[player]
	if not st then return end

	if ghostWanted[player] then
		pcall(function()
			if player.isGhostMode and not player:isGhostMode() then
				if player.setGhostMode then player:setGhostMode(true, true) end
				if not st.ghostStompLogged then
					st.ghostStompLogged = true
					log("ghost was cleared by something else — holding it up for the rest of the window")
				end
			end
		end)
	end

	if st.phase == "surrender" then
		if TEETH_AUDIT then
			pcall(function()
				local bd = player.getBodyDamage and player:getBodyDamage()
				if not bd then return end
				local hp = bd:getHealth()
				if st.probeHp and hp < st.probeHp - 0.5 then
					local near = {}
					local cell = getCell()
					local zl = cell and cell:getZombieList()
					if zl then
						for i = 0, zl:size() - 1 do
							local z = zl:get(i)
							if z and z:isAlive() and z:DistTo(player) <= 2.0 then
								near[#near + 1] = string.format("id=%s d=%.2f state=%s hasTeeth=%s",
									tostring(z.getID and z:getID()), z:DistTo(player),
									tostring(z.getCurrentStateName and z:getCurrentStateName() or "?"),
									tostring(not (z.isNoTeeth and z:isNoTeeth())))
							end
						end
					end
					local wounds, bleeding = {}, 0
					pcall(function()
						local ps = bd:getBodyParts()
						for i = 0, ps:size() - 1 do
							local b = ps:get(i)
							if b:bitten() or b:scratched() or b:deepWounded() then
								wounds[#wounds + 1] = string.format("%s:%s",
									tostring(BodyPartType.ToString(b:getType())),
									b:bitten() and "BITE" or (b:deepWounded() and "LAC" or "SCR"))
							end
							if b.getBleedingTime and b:getBleedingTime() > 0 then
								bleeding = bleeding + 1
							end
						end
					end)
					local pstate, knocked, dragDown, hitReact = "?", "?", "?", "?"
					pcall(function() pstate = player:getCurrentStateName() end)
					pcall(function() knocked = tostring(player:isKnockedDown()) end)
					pcall(function() dragDown = tostring(player.isDeathDragDown and player:isDeathDragDown()) end)
					pcall(function() hitReact = tostring(player:getHitReaction()) end)
					log("DAMAGE-PROBE: hp %.1f -> %.1f mid-give-up (sceneActive=%s) player[state=%s knocked=%s dragDown=%s hitReact=%s] wounds=[%s] bleedingParts=%d — in reach: [%s]",
						st.probeHp, hp, tostring(st.sceneActive),
						pstate, knocked, dragDown, hitReact,
						table.concat(wounds, ","), bleeding, table.concat(near, " | "))
				end
				st.probeHp = hp
			end)
		end
		applyPlayerLock(player, st.sceneActive)
		pacifyNearbyZombies(player, st.pacified)

		if st.sceneActive then
			st.reseedAggroUntilTick = nil
			st.reseedRecheckTick = nil
			st.chainArmed = nil
			st.chainPick  = nil
			return
		end

		if not st.noHandsUp then ensureEmote(player) end

		if st.reseedAggroUntilTick then
			if nowTick() >= st.reseedAggroUntilTick then
				st.reseedAggroUntilTick = nil
			else
				if not st.sceneFireReadyTick or nowTick() >= st.sceneFireReadyTick then
					reassertReseedAggro(player, st)
				end
				if st.reseedRecheckTick and nowTick() >= st.reseedRecheckTick then
					st.reseedRecheckTick = nil
					local eligible, imminent = previewEligibility(player, st)
					if eligible <= 0 or imminent <= 0 then
						log("reseed recheck: eligible=%d imminent=%d → nothing can fire at the drop, ending defeat now",
							eligible, imminent)
						transitionToEscape(player, st, defeatEndOpts(st))
						return
					end
				end
			end
		end

		if st.chainArmed then
			local z = st.chainPick
			local invalid = (z == nil)
				or (not z:isAlive())
				or (st.excluded and st.excluded[z])
				or (st.participated and st.participated[z])
				or z:DistTo(player) > TRIGGER_RANGE_CHAIN
			if invalid then
				local pick = pickChainTarget(player, st)
				if pick ~= st.chainPick then
					st.chainPick = pick
					if pick then
						log("chain: re-nominated zombie id=%s", tostring(pick:getID()))
					end
				end
				if not pick then
					local eligible, imminent = previewEligibility(player, st)
					if eligible <= 0 or imminent <= 0 then
						log("chain: nothing left to nominate (eligible=%d imminent=%d) -> ending defeat now",
							eligible, imminent)
						transitionToEscape(player, st, defeatEndOpts(st))
						return
					end
				end
			end
		end

		if nowTick() >= st.endTick then
			transitionToEscape(player, st, defeatEndOpts(st))
		end

	elseif st.phase == "collapse" then
		applyPlayerLock(player, false)
		pacifyNearbyZombies(player, st.pacified)
		forceClearHitReaction(player)

		local nowMs = getTimestampMs()
		if st.coughAtMs and nowMs >= st.coughAtMs then
			st.coughAtMs = nil
			voiceCue(player, "Cough")
		end
		if nowMs >= (st.phaseEndMs or 0) then
			if not st.gettingUp then
				startGetUp(player, st)
			else
				setGettingUp(player, false)
				st.gettingUp = nil
				log("getup done → escape")
				st.fromCollapse = true
				enterEscapePhase(player, st)
			end
		end

	elseif st.phase == "escape" then
		applyEscapeMobility(player)
		pacifyNearbyZombies(player, st.pacified)
		forceClearHitReaction(player)

		if player.isGhostMode and player:isGhostMode()
			and not (ZR_CrowdControl and ZR_CrowdControl.isDispersing
				and ZR_CrowdControl.isDispersing(player))
			and getTimestampMs() >= (st.ghostUntilMs or 0) then
			setGhosted(player, false)
		end

		if nowTick() >= st.endTick then
			endSurrender(player, st)
		end
	end
end

Events.OnPlayerUpdate.Add(onPlayerUpdate)

local POST_SCENE_GRACE_TICKS = 120

local FAST_FAIL_TICKS         = 60
local FAST_FAIL_BACKOFF_TICKS = 300
local FAST_FAIL_GIVEUP        = 2

local TRANSIENT_RETRY_BACKOFF_TICKS = 60

local RESEED_TRIP_GRACE_TICKS = 90

ZomboRutScene.registerSceneEndCallback(function(player, _active, result)
	local st = active[player]
	if not st then return end
	st.sceneActive = false

	local sceneStartTick = st.sceneStartTick
	st.sceneStartTick = nil
	local fastFailHit = false
	if sceneStartTick then
		local duration = nowTick() - sceneStartTick
		if duration < FAST_FAIL_TICKS and result == "Released" then
			st.fastFails = (st.fastFails or 0) + 1
			fastFailHit = true
			log("scene-end FAST-FAIL #%d (duration=%d ticks < %d) — engine likely dropped grapple via momentum trap",
				st.fastFails, duration, FAST_FAIL_TICKS)
		else
			if (st.fastFails or 0) > 0 then
				log("scene-end healthy (duration=%d ticks) — fast-fail counter reset", duration)
			end
			st.fastFails = 0
		end
	end

	if st.phase ~= "surrender" then return end

	if result == "PlayerCancel" then
		forceClearHitReaction(player)
		log("scene-end: player hotkey-cancelled mid-scene → endSurrender direct (no pacify, no escape)")
		endSurrender(player, st)
		return
	end

	if st.struggleWon then
		forceClearHitReaction(player)
		log("scene-end: struggle WIN → endSurrender direct (no collapse, no escape, no pacify)")
		endSurrender(player, st)
		return
	end

	forceClearHitReaction(player)
	pacifyNearbyZombies(player, st.pacified)

	if st.reseedOnFirstSceneEnd then
		st.reseedOnFirstSceneEnd = nil
		if countAggroedZombies(player) <= 0 then
			log("takedown drag ended with no nearby aggressors → ending give-up, freeing player")
			endSurrender(player, st)
			return
		end
		st.participated = {}
		local credits = previewEligibility(player, st)
		if credits <= 0 then
			log("reseed: no eligible aggressor exists → ending defeat now (no give-up window)")
			transitionToEscape(player, st, defeatEndOpts(st))
			return
		end
		st.scenesRemaining = credits
		local dur = DEFAULT_SURRENDER_DURATION_TICKS
		if SandboxVars and SandboxVars.ZomboRut and SandboxVars.ZomboRut.surrenderDuration then
			dur = SandboxVars.ZomboRut.surrenderDuration * 60
		end
		st.endTick = nowTick() + dur
		st.sceneFireReadyTick = nowTick() + RESEED_TRIP_GRACE_TICKS
		st.reseedAggroUntilTick = st.endTick
		st.reseedRecheckTick    = nowTick() + RESEED_TRIP_GRACE_TICKS
		log("takedown drag ended → re-seeded give-up with %d credit(s), no escape", credits)
		return
	end

	if fastFailHit and (st.fastFails or 0) >= FAST_FAIL_GIVEUP then
		log("scene fast-fail cascade detected (%d in a row) → ending engagement (%d rendered)",
			st.fastFails, st.scenesRendered or 0)
		transitionToEscape(player, st, defeatEndOpts(st))
		return
	end

	if result == "Released" then
		if fastFailHit then
			if _active and _active.liveOriginal then
				st.participated[_active.liveOriginal] = nil
			end
			log("scene-end: fast-fail — no credit spent, aggressor turn restored")
		else
			st.scenesRendered  = (st.scenesRendered or 0) + 1
			st.scenesRemaining = (st.scenesRemaining or 1) - 1
			log("scene-end: rendered scene #%d, %d scene credits remaining (result=%s)",
				st.scenesRendered, st.scenesRemaining, tostring(result))
		end
	end

	if (st.scenesRemaining or 0) <= 0 then
		log("scene credits exhausted → COLLAPSE (%d rendered), then escape",
			st.scenesRendered or 0)
		transitionToEscape(player, st, defeatEndOpts(st))
	elseif fastFailHit then
		st.sceneFireReadyTick = nowTick() + FAST_FAIL_BACKOFF_TICKS
		st.endTick = math.max(st.endTick, nowTick() + POST_SCENE_GRACE_TICKS)
		log("first fast-fail — scene-fire backed off for %d ticks",
			FAST_FAIL_BACKOFF_TICKS)
	else
		local eligible, imminent = previewEligibility(player, st)
		if eligible <= 0 then
			log("post-scene: no eligible aggressor remains → ending defeat now")
			transitionToEscape(player, st, defeatEndOpts(st))
		elseif imminent <= 0 then
			log("post-scene: %d eligible but none within chain range (%d tiles) → ending defeat now",
				eligible, TRIGGER_RANGE_CHAIN)
			transitionToEscape(player, st, defeatEndOpts(st))
		else
			st.endTick = math.max(st.endTick, nowTick() + POST_SCENE_GRACE_TICKS)

			if eligible > (st.scenesRemaining or 0) then
				log("credits ratcheted %d -> %d (the horde arrived after the give-up started)",
					st.scenesRemaining or 0, eligible)
				st.scenesRemaining = eligible
			end

			st.chainArmed = true
			st.chainPick  = pickChainTarget(player, st)
			if st.chainPick then
				log("chain: nominated zombie id=%s (d=%.1f) for the next scene",
					tostring(st.chainPick:getID()), st.chainPick:DistTo(player))
			else
				log("chain: nothing pickable yet (imminent=%d) — re-picking per tick", imminent)
			end
		end
	end
end)

local function canFireScene(player, zombie)
	local st = active[player]
	if not st or st.phase ~= "surrender" or st.sceneActive then return false end
	if not zombie:isAlive() then return false end

	if st.sceneFireReadyTick and nowTick() < st.sceneFireReadyTick then return false end

	if player.isPlayerMoving and player:isPlayerMoving() then return false end

	if player.isGrappling and player:isGrappling() then return false end
	local pState = player.getCurrentStateName and player:getCurrentStateName() or ""
	if pState == "PlayerDraggingCorpse" then return false end

	if st.participated[zombie] then return false end

	if st.excluded and st.excluded[zombie] then return false end

	if zombie:getVariableBoolean("Bandit") then
		local rec = st.pacified[zombie]
		local wasHostile = rec and rec.isBandit
			and (rec.wasHostile or rec.wasHostileP)
		if not wasHostile then return false end
		if zombie:DistTo(player) > BANDIT_TRIGGER_RANGE then return false end
	else
		local isFirst = true
		for _ in pairs(st.participated) do isFirst = false; break end
		local triggerRange = isFirst and TRIGGER_RANGE_FIRST or TRIGGER_RANGE_CHAIN
		if zombie:DistTo(player) > triggerRange then return false end

		local isAttacking = zombie.isZombieAttacking and zombie:isZombieAttacking(player)
		local isTargeting = zombie.getTarget and zombie:getTarget() == player
		local isNominated = (st.chainPick == zombie)
		if not isAttacking and not isTargeting and not isNominated then return false end
	end

	if SandboxVars and SandboxVars.ZomboRut
		and SandboxVars.ZomboRut.enableMod == false
	then
		return false
	end
	if SandboxVars and SandboxVars.ZomboRut
		and SandboxVars.ZomboRut.zombieDefeatEnabled == false
		and not zombie:getVariableBoolean("Bandit")
	then
		return false
	end

	if _inTransitionState(player) then return false end

	return true
end

local function forcePacify(zombie, st)
	pacifyOne(zombie, st.pacified)
end

local function firstSceneGateOpen(st, zombie)
	if not st.firstScene or not st.firstSceneZombie then return true end
	local pinDead = not st.firstSceneZombie:isAlive()
	local pinTimedOut = st.firstSceneDeadline and nowTick() >= st.firstSceneDeadline
	if pinDead or pinTimedOut then
		st.firstSceneZombie = nil
		return true
	end
	return zombie == st.firstSceneZombie
end

local function onZombieUpdate(zombie)
	if not zombie or not zombie:isAlive() then return end

	for player, st in pairs(active) do
		if player and player:isAlive()
			and zombie:DistTo(player) <= PACIFY_RANGE
			and isAggressorFor(zombie, st.pacified)
		then
			forcePacify(zombie, st)

			if canFireScene(player, zombie) and firstSceneGateOpen(st, zombie) then
				st.sceneActive    = true
				st.engaged        = true
				st.sceneStartTick = nowTick()
				st.participated[zombie] = true
				local sceneId
				if st.firstScene then
					sceneId = st.firstScene
					st.firstScene = nil
					st.firstSceneZombie = nil
				else
					sceneId = rollSceneId(player, zombie)
				end
				local started, failReason = false, nil
				if sceneId then
					local launcher = zombie:getVariableBoolean("Bandit")
						and ZomboRutScene.playSceneFromLive
						or ZomboRutScene.playSceneFromAttacker
					log("aggressor id=%s (%s) attacking surrendering player → firing scene %s",
						tostring(zombie:getID()),
						zombie:getVariableBoolean("Bandit") and "bandit" or "zombie",
						sceneId)
					started, failReason = launcher(player, zombie, sceneId)
				end
				if not started then
					st.sceneActive    = false
					st.sceneStartTick = nil
					forceClearHitReaction(player)
					pacifyNearbyZombies(player, st.pacified)
					if sceneId == nil then
						st.excluded[zombie] = true
						log("aggressor id=%s rolled no scene (%s) → excluded from rotation",
							tostring(zombie:getID()),
							ZomboRutScene._sameGenderBlocked(player, zombie)
								and "same-gender blocked" or "empty pool")
						if previewEligibility(player, st) <= 0 then
							log("no eligible aggressor left who can ever fire → ending engagement now")
							transitionToEscape(player, st, defeatEndOpts(st))
						end
					elseif failReason == "defeat-disabled" then
						log("launcher refused: defeat disabled → ending engagement now")
						transitionToEscape(player, st, defeatEndOpts(st))
					else
						st.participated[zombie] = nil
						st.sceneFireReadyTick = math.max(st.sceneFireReadyTick or nowTick(),
							nowTick() + TRANSIENT_RETRY_BACKOFF_TICKS)
						log("launcher refused (%s) → aggressor turn restored, retry in %d ticks",
							tostring(failReason or "unspecified"), TRANSIENT_RETRY_BACKOFF_TICKS)
					end
				end
				return
			end
		end
	end
end

Events.OnZombieUpdate.Add(onZombieUpdate)

return ZR_Surrender
