-- @author DoomTero 2026
-- @copyright 2026 DoomTero - ZomboRut original work. ref:47186df4
-- @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_ClothingGrab = ZR_ClothingGrab or {}

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

local CONTACT_DELAY_TICKS = 5
local SPRINTER_CONTACT_DELAY_TICKS = 2
local REACH       = 1.0
local Z_TOLERANCE = 0.2
local RIP_SCRATCH_PCT    = 30
local RIP_LACERATION_PCT = 5
local ENDURANCE_PER_RIP = 0.05

local TOP_STRIP_HOLES   = 2
local TOP_STRIP_EXTRA   = 2

local function overwhelmZeds()
	local sv = SandboxVars and SandboxVars.ZomboRut
	return (sv and sv.overwhelmZombieCount) or 5
end
local function ripScenePct()
	local sv = SandboxVars and SandboxVars.ZomboRut
	local v = sv and sv.ripSceneChance
	if v == nil then return 25 end
	return v
end
local RIP_SCENE_EXHAUST_CEIL = 90
local RIP_SCENE_STAM_FRESH   = 0.85
local RIP_SCENE_STAM_TIRED   = 0.50
local function ripScenePctScaled(player)
	local base = ripScenePct()
	if base <= 0 then return 0 end
	if base >= RIP_SCENE_EXHAUST_CEIL then return base end
	local s = 1.0
	pcall(function() s = player:getStats():get(CharacterStat.ENDURANCE) or 1.0 end)
	if s >= RIP_SCENE_STAM_FRESH then return base end
	if s <= RIP_SCENE_STAM_TIRED then return RIP_SCENE_EXHAUST_CEIL end
	local f = (RIP_SCENE_STAM_FRESH - s) / (RIP_SCENE_STAM_FRESH - RIP_SCENE_STAM_TIRED)
	return base + (RIP_SCENE_EXHAUST_CEIL - base) * f
end
local SWARM_DRAG_TILES = 3

local SURROUND_RANGE = 3.5
local function nearbyZombieCount(player, range)
	local cell = getCell()
	if not cell or not player then return 0 end
	local px, py = player:getX(), player:getY()
	local pz = math.floor(player:getZ())
	local r = math.ceil(range)
	local r2 = range * range
	local n = 0
	for dx = -r, r do
		for dy = -r, r do
			local sq = cell:getGridSquare(math.floor(px) + dx, math.floor(py) + dy, pz)
			local mo = sq and sq:getMovingObjects()
			if mo then
				for k = 0, mo:size() - 1 do
					local o = mo:get(k)
					if o and instanceof(o, "IsoZombie") and o:isAlive()
					   and not o:isProne() and not o:isCrawling()
					   and o.getTarget and o:getTarget() == player then
						local zx, zy = o:getX() - px, o:getY() - py
						if (zx * zx + zy * zy) <= r2 then n = n + 1 end
					end
				end
			end
		end
	end
	return n
end

local function isSprinter(zombie)
	local wt
	pcall(function() wt = zombie:getWalkType() end)
	return wt ~= nil and string.sub(wt, 1, 6) == "sprint"
end

local function playRipSound(player)
	pcall(function()
		local sq = player.getCurrentSquare and player:getCurrentSquare()
		if not sq then return end
		local sm = getSoundManager and getSoundManager()
		if sm and sm.PlayWorldSound then
			sm:PlayWorldSound("ZR_ClothRip", true, sq, 0, 6, 1.0, false)
		end
	end)
end

local function ripFeedback(player)
	playRipSound(player)
	pcall(function()
		local txt = getText("IGUI_ZR_ClothingGrabbed")
		if not txt or txt == "IGUI_ZR_ClothingGrabbed" then txt = "Clothing torn off!" end
		if HaloTextHelper and HaloTextHelper.addTextWithArrow then
			HaloTextHelper.addTextWithArrow(player, txt, false, HaloTextHelper.getColorRed())
		end
	end)
end

local function inMeleeReach(zombie, target)
	local d, dz = math.huge, math.huge
	pcall(function() d = zombie:DistTo(target) end)
	pcall(function() dz = math.abs(zombie:getZ() - target:getZ()) end)
	return d <= REACH and dz < Z_TOLERANCE
end

local _hittableIdx = nil
local function hittablePartIndices()
	if _hittableIdx then return _hittableIdx end
	local list = {}
	local function add(bp) pcall(function() if bp then list[#list + 1] = BloodBodyPartType.ToIndex(bp) end end) end
	pcall(function()
		add(BloodBodyPartType.Torso_Upper); add(BloodBodyPartType.Torso_Lower)
		add(BloodBodyPartType.ForeArm_L);   add(BloodBodyPartType.ForeArm_R)
		add(BloodBodyPartType.UpperArm_L);  add(BloodBodyPartType.UpperArm_R)
		add(BloodBodyPartType.UpperLeg_L);  add(BloodBodyPartType.UpperLeg_R)
		add(BloodBodyPartType.LowerLeg_L);  add(BloodBodyPartType.LowerLeg_R)
		add(BloodBodyPartType.Neck)
	end)
	_hittableIdx = list
	return list
end

local function pickHittablePartIdx()
	local list = hittablePartIndices()
	if #list == 0 then return nil end
	return list[ZombRand(#list) + 1]
end

local function itemCoveredPartIdx(item)
	local idx
	pcall(function()
		if not item or not item.getCoveredParts then return end
		local parts = item:getCoveredParts()
		if not parts or not parts.size or parts:size() == 0 then return end
		local bp = parts:get(ZombRand(parts:size()))
		if bp then idx = BloodBodyPartType.ToIndex(bp) end
	end)
	return idx or pickHittablePartIdx()
end

local function woundPart(player, partIdx, laceration)
	if partIdx == nil then return end
	pcall(function()
		local bd = player.getBodyDamage and player:getBodyDamage()
		if not bd or not bd.getBodyPart or not BodyPartType then return end
		local bpt = BodyPartType.FromIndex(partIdx)
		if not bpt then return end
		local part = bd:getBodyPart(bpt)
		if not part then return end
		if laceration then
			if part.generateDeepWound then part:generateDeepWound() end
		elseif part.setScratched then
			part:setScratched(true, false)
		end
	end)
end

local function vanillaWoundOnBarePart(player, zombie, partIdx)
	if partIdx == nil then return end
	pcall(function()
		local bd = player.getBodyDamage and player:getBodyDamage()
		if not bd or not bd.AddRandomDamageFromZombie then return end
		local prevTeeth = (zombie.isNoTeeth and zombie:isNoTeeth()) or false
		local prevSit   = (player.isSitOnGround and player:isSitOnGround()) or false
		if zombie.setNoTeeth then zombie:setNoTeeth(false) end
		if player.setSitOnGround then player:setSitOnGround(true) end
		bd:AddRandomDamageFromZombie(zombie, "Bite", partIdx)
		if player.setSitOnGround then player:setSitOnGround(prevSit) end
		if zombie.setNoTeeth then zombie:setNoTeeth(prevTeeth) end
		if player.isDeathDragDown and player:isDeathDragDown() then
			pcall(function() player:setDeathDragDown(false) end)
		end
	end)
end

local function cancelDragDownDeath(player)
	pcall(function()
		if player.isDeathDragDown and player:isDeathDragDown() then
			player:setDeathDragDown(false)
		end
		if player.getHitReaction and player:getHitReaction() == "EndDeath" then
			if player.setHitReaction   then player:setHitReaction("") end
			if player.setBlockMovement then player:setBlockMovement(false) end
		end
	end)
end

local function drainEndurance(player, amount)
	pcall(function()
		local stats = player.getStats and player:getStats()
		if not stats or not CharacterStat then return end
		local cur = stats:get(CharacterStat.ENDURANCE) or 0
		stats:set(CharacterStat.ENDURANCE, math.max(0, cur - amount))
	end)
end

local cg = {}

function ZR_ClothingGrab.isNeutered(zombie)
	local t = zombie and cg[zombie]
	return (t and t.neutered) and true or false
end

local function restoreAllAndClear()
	for z, rec in pairs(cg) do
		if rec.neutered then pcall(function() z:setNoTeeth(false) end) end
		cg[z] = nil
	end
end

local frame = 0
Events.OnTick.Add(function() frame = frame + 1 end)

local PLAYER_SWING_RIP_PCT      = 20
local PLAYER_SWING_RIP_COOLDOWN = 30

local function applyOverwhelmCarnage(player)
	if not player then return end
	pcall(function() ZomboRutScene._maxDamageAllClothing(player) end)
	pcall(function() ZomboRutScene._dropPantsOnly(player) end)
	if ((SandboxVars and SandboxVars.ZomboRut and SandboxVars.ZomboRut.playerDefeatStripMode) or 1) == 2 then
		pcall(function()
			local idx = BloodBodyPartType.ToIndex(BloodBodyPartType.Torso_Upper)
			ZomboRutScene._grabItemCoveringPart(player, idx, 2)
		end)
	end
end

local lastClothingMax = {}
local function handleOverwhelm(player, dragger)
	if not player then return end
	for z, rec in pairs(cg) do
		pcall(function() z:setNoTeeth(true) end)
		rec.neutered = true
	end
	if frame - (lastClothingMax[player] or (frame - 999)) >= 30 then
		lastClothingMax[player] = frame
		applyOverwhelmCarnage(player)
		log("overwhelm: %d+ attackers → all clothing ripped, key items dropped, short drag → defeat", overwhelmZeds())
	end
	local launched = false
	if dragger and ZR_SprinterTakedown and ZR_SprinterTakedown.launch
	   and not (ZR_SprinterTakedown.isActive and ZR_SprinterTakedown.isActive(player)) then
		pcall(function()
			launched = ZR_SprinterTakedown.launch(player, dragger, { maxTiles = SWARM_DRAG_TILES })
		end)
	end
	if not launched then
		pcall(function() ZR_Surrender.start(player, true) end)
	end
end

local function inUnclearableState(target)
	if not (ZR_Surrender.isInTransitionState
		and ZR_Surrender.isInTransitionState(target))
	then
		return false
	end
	local s = target.getCurrentStateName and target:getCurrentStateName() or ""
	return not (s:find("Bumped") or s:find("HitReaction") or s:find("Stagger"))
end

local function tryRipScene(target)
	if not target then return false end
	if inUnclearableState(target) then return false end
	local sceneChance = ripScenePctScaled(target)
	if ZombRand(100) >= sceneChance then return false end
	pcall(function()
		local s = target.getCurrentStateName and target:getCurrentStateName() or ""
		local stuck = s:find("Bumped") or s:find("HitReaction") or s:find("Stagger")
		local legit = s:find("Climb") or s:find("Vault") or s:find("Falling")
		if stuck and not legit and IdleState and IdleState.instance and target.changeState then
			target:changeState(IdleState.instance())
		end
	end)
	if ZR_Surrender.start(target, true) then
		log("rip → give-up (chance=%.0f%%)", sceneChance)
		return true
	end
	log("rip → overwhelm roll hit (chance=%.0f%%) but start() refused", sceneChance)
	return false
end

local function resolveRip(target, sv)
	local baseMode = (sv.clothingGrabDrop ~= false) and 2 or 1
	local perHit = sv.clothingGrabPerHit or 1
	if perHit < 1 then perHit = 1 end
	local grabbedAny = false
	for _ = 1, perHit do
		local item = ZomboRutScene._grabOneClothingItem(target, baseMode)
		if not item then break end
		grabbedAny = true
		if ZombRand(100) < RIP_LACERATION_PCT then
			woundPart(target, itemCoveredPartIdx(item), true)
		elseif ZombRand(100) < RIP_SCRATCH_PCT then
			woundPart(target, itemCoveredPartIdx(item), false)
		end
	end
	if grabbedAny then
		ripFeedback(target)
		log("RIP: ripped clothing (perHit=%d)", perHit)
	else
		restoreAllAndClear()
		log("RIP: no clothing left → handing crowd off to surrender")
		pcall(function()
			local s = target.getCurrentStateName and target:getCurrentStateName() or ""
			local stuck = s:find("Bumped") or s:find("HitReaction") or s:find("Stagger")
			local legit = s:find("Climb") or s:find("Vault") or s:find("Falling")
			if stuck and not legit and IdleState and IdleState.instance and target.changeState then
				target:changeState(IdleState.instance())
			end
		end)
		if not ZR_Surrender.start(target, true) then
			log("ZR_Surrender.start refused (transition state); retry on next connect")
		end
	end
end

local function giveUpOwnsCrowd(player)
	if not player then return false end
	if ZomboRutScene.isPlayerInScene and ZomboRutScene.isPlayerInScene(player) then return true end
	if ZomboRutScene.isPlayerInPostSceneGrace and ZomboRutScene.isPlayerInPostSceneGrace(player) then return true end
	if ZR_SprinterTakedown and ZR_SprinterTakedown.isActive
		and ZR_SprinterTakedown.isActive(player) then return true end
	local phase = ZR_Surrender and ZR_Surrender.getPhase and ZR_Surrender.getPhase(player)
	return phase == "surrender"
end

local function onZombieUpdateInner(zombie)
	if not zombie or not zombie.isZombieAttacking or not zombie.getAttackOutcome then return end

	local function disengage()
		local t = cg[zombie]
		if not t then return end
		if t.neutered then
			if giveUpOwnsCrowd(getPlayer()) then return end
			pcall(function() zombie:setNoTeeth(false) end)
		end
		cg[zombie] = nil
	end

	local sv = SandboxVars and SandboxVars.ZomboRut
	if not sv or sv.enableMod == false or sv.zombieDefeatEnabled == false
	   or not ZR_Surrender.ripActive() then
		disengage(); return
	end

	if zombie:isUseless() or zombie:isProne() or zombie:isCrawling() or zombie:isStaggerBack() then
		disengage(); return
	end

	local target = zombie:getTarget()
	if not target or not target.isLocalPlayer or not target:isLocalPlayer() or not target:isAlive() then
		disengage(); return
	end

	local surrPhase = ZR_Surrender and ZR_Surrender.getPhase and ZR_Surrender.getPhase(target)
	if ZomboRutScene.isPlayerInScene(target)
	   or surrPhase == "surrender"
	   or (ZR_SprinterTakedown and ZR_SprinterTakedown.isActive and ZR_SprinterTakedown.isActive(target)) then
		cg[zombie] = nil
		return
	end

	if not zombie:isZombieAttacking(target) then
		disengage(); return
	end

	if not ZomboRutScene._attractedToPlayer(target, zombie) then
		disengage(); return
	end

	local t = cg[zombie]
	if not t then t = { covered = true }; cg[zombie] = t end

	local outcome = ""
	pcall(function() outcome = zombie:getAttackOutcome() or "" end)
	local commit = (outcome == "success" and t.outcome ~= "success")
	t.outcome = outcome

	local both = ZR_Surrender.getDefeatMode() == ZR_Surrender.DEFEAT_MODE.BOTH

	if commit then
		local delay = CONTACT_DELAY_TICKS
		local earlyRip = false
		pcall(function() earlyRip = isSprinter(zombie) and not zombie:isBehind(target) end)
		if earlyRip then delay = SPRINTER_CONTACT_DELAY_TICKS end
		t.pendingTick = frame + delay
		if both then
			t.partIdx = pickHittablePartIdx()
			t.covered = (t.partIdx ~= nil) and ZomboRutScene._isPartCovered(target, t.partIdx)
		else
			t.partIdx = nil
			t.covered = true
		end
	end

	t.neutered = true
	pcall(function() zombie:setNoTeeth(true) end)

	if t.pendingTick and frame >= t.pendingTick then
		t.pendingTick = nil
		if not inMeleeReach(zombie, target) then
			log("swing whiffed (out of reach at contact) — no action")
			return
		end
		local escaping = false
		pcall(function()
			escaping = ZR_Struggle and ZR_Struggle.escapeGraceActive
				and ZR_Struggle.escapeGraceActive(target)
		end)
		if not escaping and nearbyZombieCount(target, SURROUND_RANGE) >= overwhelmZeds() then
			handleOverwhelm(target, zombie)
			return
		end
		if not both then
			resolveRip(target, sv)
		elseif t.covered then
			local item = ZomboRutScene._findItemCoveringPart(target, t.partIdx)
			if item then
				local cls = ZomboRutScene._clothingClass(item)
				local baseMode = (sv.clothingGrabDrop ~= false) and 2 or 1
				local didStrip = false
				drainEndurance(target, ENDURANCE_PER_RIP)
				if not ZomboRutScene._canHaveHoles(item) then
					ZomboRutScene._grabItemCoveringPart(target, t.partIdx, baseMode)
					ripFeedback(target)
					didStrip = true
					log("BOTH: %s can't hole → stripped whole, end -%.2f", cls, ENDURANCE_PER_RIP)
				elseif cls == "skirt" or cls == "dress" then
					ZomboRutScene._damageWornItem(target, item)
					playRipSound(target)
					log("BOTH: %s holed (kept on) → endurance -%.2f", cls, ENDURANCE_PER_RIP)
				elseif cls == "pants" or cls == "shorts" then
					local def = ZomboRutScene._garmentDefense(item, t.partIdx)
					if ZombRand(100) >= def then
						ZomboRutScene._grabItemCoveringPart(target, t.partIdx, baseMode)
						ripFeedback(target)
						didStrip = true
						log("BOTH: %s failed (def=%d) → torn off, end -%.2f", cls, def, ENDURANCE_PER_RIP)
					else
						ZomboRutScene._damageWornItem(target, item)
						playRipSound(target)
						log("BOTH: %s held (def=%d) → holed only, end -%.2f", cls, def, ENDURANCE_PER_RIP)
					end
				else
					local holes, tearHits = 0, 0
					pcall(function() if item.getHolesNumber then holes = item:getHolesNumber() end end)
					pcall(function() tearHits = tonumber(item:getModData().ZR_TearHits) or 0 end)
					if math.max(holes, tearHits) >= TOP_STRIP_HOLES then
						pcall(function() item:getModData().ZR_TearHits = nil end)
						for _ = 1, TOP_STRIP_EXTRA do ZomboRutScene._damageWornItem(target, item) end
						ZomboRutScene._grabItemCoveringPart(target, t.partIdx, baseMode)
						ripFeedback(target)
						didStrip = true
						log("BOTH: %s had %d holes / %d tear hits → stripped (+%d), end -%.2f", cls, holes, tearHits, TOP_STRIP_EXTRA, ENDURANCE_PER_RIP)
					else
						pcall(function() item:getModData().ZR_TearHits = tearHits + 1 end)
						ZomboRutScene._damageWornItem(target, item)
						playRipSound(target)
						log("BOTH: %s holed (holes=%d, tearHits=%d) → kept on, end -%.2f", cls, holes, tearHits + 1, ENDURANCE_PER_RIP)
					end
				end
				if didStrip and tryRipScene(target) then return end
			end
		else
			local startedScene = false
			local sceneChance = ripScenePctScaled(target)
			if not inUnclearableState(target) and ZombRand(100) < sceneChance then
				pcall(function()
					local s = target.getCurrentStateName and target:getCurrentStateName() or ""
					local stuck = s:find("Bumped") or s:find("HitReaction") or s:find("Stagger")
					local legit = s:find("Climb") or s:find("Vault") or s:find("Falling")
					if stuck and not legit and IdleState and IdleState.instance and target.changeState then
						target:changeState(IdleState.instance())
					end
				end)
				startedScene = ZR_Surrender.start(target, true)
				if startedScene then
					log("BOTH: bare part=%s → rip-scene give-up (chance=%.0f%%)", tostring(t.partIdx), sceneChance)
				else
					log("BOTH: bare part=%s → rip-scene roll hit (chance=%.0f%%) but start() refused → vanilla wound", tostring(t.partIdx), sceneChance)
				end
			end
			if not startedScene then
				vanillaWoundOnBarePart(target, zombie, t.partIdx)
				log("BOTH: bare part=%s → vanilla wound (pinned)", tostring(t.partIdx))
			end
		end
	end
end

local function onZombieUpdate(zombie)
	local ok, err = pcall(onZombieUpdateInner, zombie)
	if not ok then
		DebugLog.log("[ZR ClothingGrab] onZombieUpdate error: " .. tostring(err))
	end
end

Events.OnZombieUpdate.Add(onZombieUpdate)

local lastPlayerSwingRip = {}
local function onPlayerHitZombie(zombie, attacker, _bodyPart, weapon)
	if not zombie or not attacker then return end
	if not attacker.isLocalPlayer or not attacker:isLocalPlayer() then return end
	if not attacker:isAlive() then return end
	if weapon and weapon.isRanged and weapon:isRanged()
	   and not (attacker.isDoShove and attacker:isDoShove()) then return end

	local sv = SandboxVars and SandboxVars.ZomboRut
	if not sv or sv.enableMod == false or sv.zombieDefeatEnabled == false
	   or ZR_Surrender.getDefeatMode() ~= ZR_Surrender.DEFEAT_MODE.BOTH then return end

	if ZomboRutScene.isPlayerInScene and ZomboRutScene.isPlayerInScene(attacker) then return end
	if ZomboRutScene.isPlayerInPostSceneGrace and ZomboRutScene.isPlayerInPostSceneGrace(attacker) then return end
	if ZR_Surrender.isActive and ZR_Surrender.isActive(attacker) then return end
	if ZR_SprinterTakedown and ZR_SprinterTakedown.isActive and ZR_SprinterTakedown.isActive(attacker) then return end

	if nearbyZombieCount(attacker, SURROUND_RANGE) < overwhelmZeds() then return end

	if frame - (lastPlayerSwingRip[attacker] or (frame - 99999)) < PLAYER_SWING_RIP_COOLDOWN then return end
	if ZombRand(100) >= PLAYER_SWING_RIP_PCT then return end
	lastPlayerSwingRip[attacker] = frame

	local baseMode = (sv.clothingGrabDrop ~= false) and 2 or 1
	local ripped = ZomboRutScene._grabOneClothingItem(attacker, baseMode)
	if ripped then
		ripFeedback(attacker)
		log("player landed hit/shove while surrounded → ripped own garment → rolling overwhelm")
		tryRipScene(attacker)
	end
end
Events.OnHitZombie.Add(onPlayerHitZombie)

Events.OnTick.Add(function()
	local sv = SandboxVars and SandboxVars.ZomboRut
	if not sv or sv.enableMod == false or sv.zombieDefeatEnabled == false then return end
	local p = getPlayer and getPlayer()
	if p and not p:isDead() and p.isDeathDragDown and p:isDeathDragDown() then
		cancelDragDownDeath(p)
	end
end)

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

DebugLog.log("[ZR ClothingGrab] registered (mode-gated, DEBUG=" .. tostring(DEBUG) .. ", build=no-teeth-handback)")
