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

local REANIMATE_TIMER_FOREVER = 99999.0

ZR_Subdued = ZR_Subdued or {}

local ZomboRutScene

function ZR_Subdued.enforce(zombie)
	if not zombie or not zombie.isAlive or not zombie:isAlive() then return end
	if not (zombie.getModData and zombie:getModData().zrSubdued) then return end

	pcall(function()
		if zombie.isCrawling and zombie:isCrawling()
			and ZombieOnGroundState and ZombieOnGroundState.instance
			and zombie.getCurrentStateName
			and zombie:getCurrentStateName() ~= "ZombieOnGroundState"
			and zombie.changeState
		then
			zombie:changeState(ZombieOnGroundState.instance())
		end
	end)

	pcall(function()
		if zombie.setReanimateTimer then
			zombie:setReanimateTimer(REANIMATE_TIMER_FOREVER)
		end
		if zombie.clearAggroList then zombie:clearAggroList() end
		if zombie.setTarget      then zombie:setTarget(nil)     end
		if zombie.setUseless     then zombie:setUseless(true)   end
		if zombie.setNoTeeth     then zombie:setNoTeeth(true)   end
		if zombie.setCanWalk     then zombie:setCanWalk(false)  end
		if zombie.setKnockedDown and not zombie:isKnockedDown() then
			zombie:setKnockedDown(true)
		end
	end)

	pcall(function()
		if zombie.setVariable then zombie:setVariable("zrSubdued", "true") end
	end)

	if zombie:getVariableBoolean("Bandit") and Bandit then
		pcall(function()
			if Bandit.ForceStationary then Bandit.ForceStationary(zombie, true) end
			if Bandit.SetHostile      then Bandit.SetHostile(zombie, false)     end
			if Bandit.SetHostileP     then Bandit.SetHostileP(zombie, false)    end
			if Bandit.ClearTasks      then Bandit.ClearTasks(zombie)            end
		end)
	end
end

local function _player()
	return getSpecificPlayer and getSpecificPlayer(0) or nil
end

local function _registry()
	local p = _player()
	if not p or not p.getModData then return nil end
	local md = p:getModData()
	md.zr_subduedRegistry = md.zr_subduedRegistry or {}
	return md.zr_subduedRegistry
end

local function _nextId()
	local p = _player()
	if not p then return nil end
	local md = p:getModData()
	md.zr_subduedCounter = (md.zr_subduedCounter or 0) + 1
	return "cap" .. md.zr_subduedCounter
end

local function _rgb(c)
	if not c then return nil end
	local r = (c.getRedFloat   and c:getRedFloat())   or c.r
	local g = (c.getGreenFloat and c:getGreenFloat()) or c.g
	local b = (c.getBlueFloat  and c:getBlueFloat())  or c.b
	if r == nil or g == nil or b == nil then return nil end
	return { r = r, g = g, b = b }
end

local function _captureVisual(zombie)
	local data = { items = {} }
	pcall(function()
		data.female = zombie.isFemale and zombie:isFemale() and true or false
		local hv = zombie.getHumanVisual and zombie:getHumanVisual()
		if hv then
			if hv.getSkinTexture then data.skinTex    = hv:getSkinTexture()  end
			if hv.getHairModel   then data.hairModel  = hv:getHairModel()    end
			if hv.getBeardModel  then data.beardModel = hv:getBeardModel()   end
			if hv.getHairColor   then data.hairColor  = _rgb(hv:getHairColor())  end
			if hv.getBeardColor  then data.beardColor = _rgb(hv:getBeardColor()) end
			if hv.getSkinColor   then data.skinColor  = _rgb(hv:getSkinColor())  end
		end
		local ivs = zombie.getItemVisuals and zombie:getItemVisuals()
		if ivs and ivs.size then
			for i = 0, ivs:size() - 1 do
				local iv = ivs:get(i)
				if iv and iv.getItemType then
					local e = { type = iv:getItemType() }
					if iv.getClothingItemName then e.name = iv:getClothingItemName() end
					if iv.getTint            then e.tint = _rgb(iv:getTint())        end
					data.items[#data.items + 1] = e
				end
			end
		end
	end)
	return data
end

local function _applyVisual(zombie, data)
	if not data then return end
	pcall(function()
		if data.female ~= nil then
			if     zombie.setFemaleEtc then zombie:setFemaleEtc(data.female)
			elseif zombie.setFemale    then zombie:setFemale(data.female) end
		end
	end)
	pcall(function()
		local hv = zombie.getHumanVisual and zombie:getHumanVisual()
		if not hv then return end
		if data.skinTex    and hv.setSkinTextureName then hv:setSkinTextureName(data.skinTex)  end
		if data.hairModel  and hv.setHairModel       then hv:setHairModel(data.hairModel)      end
		if data.beardModel and hv.setBeardModel      then hv:setBeardModel(data.beardModel)    end
		if data.hairColor  and hv.setHairColor  then hv:setHairColor(ImmutableColor.new(data.hairColor.r, data.hairColor.g, data.hairColor.b))   end
		if data.beardColor and hv.setBeardColor then hv:setBeardColor(ImmutableColor.new(data.beardColor.r, data.beardColor.g, data.beardColor.b)) end
		if data.skinColor  and hv.setSkinColor  then hv:setSkinColor(ImmutableColor.new(data.skinColor.r, data.skinColor.g, data.skinColor.b))   end
	end)
	pcall(function()
		local ivs = zombie.getItemVisuals and zombie:getItemVisuals()
		if not ivs then return end
		if ivs.clear then ivs:clear() end
		for _, e in ipairs(data.items or {}) do
			local iv = ItemVisual.new()
			iv:setItemType(e.type)
			if e.name and iv.setClothingItemName then iv:setClothingItemName(e.name) end
			if e.tint and iv.setTint then iv:setTint(ImmutableColor.new(e.tint.r, e.tint.g, e.tint.b)) end
			ivs:add(iv)
		end
	end)
	pcall(function()
		if zombie.getPersistentOutfitID and zombie.setPersistentOutfitID then
			local id = zombie:getPersistentOutfitID() or 0
			zombie:setPersistentOutfitID(id, true)
		end
	end)
	pcall(function()
		if zombie.resetModelNextFrame then zombie:resetModelNextFrame() end
	end)
end

function ZR_Subdued.register(zombie, binding)
	if not zombie or not zombie.getX then return end
	local reg = _registry()
	if not reg then return end
	local md = zombie:getModData()
	local id = md.zrSubduedKey
	if not id or not reg[id] then id = _nextId() end
	if not id then return end
	local prev = reg[id]
	reg[id] = {
		x = zombie:getX(), y = zombie:getY(), z = zombie:getZ(),
		onFront = zombie.isFallOnFront and zombie:isFallOnFront() or false,
		visual  = _captureVisual(zombie),
		binding = binding or (prev and prev.binding) or nil,
	}
	pcall(function() md.zrSubduedKey = id end)
	DebugLog.log(string.format("[ZR Subdued] registered captive id=%s female=%s items=%d",
		id, tostring(reg[id].visual and reg[id].visual.female),
		reg[id].visual and #reg[id].visual.items or 0))
end

local function _spawnFresh(entry)
	if not addZombiesInOutfit then return nil end
	local female = entry.visual and entry.visual.female
	local femaleChance = female and 100 or 0
	local outfit = "Naked" .. tostring(1 + ZombRand(101))
	local fresh
	pcall(function()
		local list = addZombiesInOutfit(entry.x, entry.y, entry.z, 1, outfit, femaleChance,
			false, false, false, false, false, false, 1)
		if list and list.size and list:size() > 0 then fresh = list:get(0) end
	end)
	return fresh
end

local ADOPT_RADIUS2 = 16

local function _findAdoptable(cell, entry)
	local zl = cell.getZombieList and cell:getZombieList()
	if not zl then return nil end
	local want = entry.visual and entry.visual.female
	local best, bestD
	for i = 0, zl:size() - 1 do
		local zob = zl:get(i)
		if zob and zob.isAlive and zob:isAlive() and zob.getModData then
			local m = zob:getModData()
			if not m.zrSubdued then
				local genderOk = (want == nil)
					or (zob.isFemale and (zob:isFemale() and true or false) == want)
				if genderOk and math.floor(zob:getZ()) == math.floor(entry.z) then
					local dx, dy = zob:getX() - entry.x, zob:getY() - entry.y
					local d = dx * dx + dy * dy
					if d <= ADOPT_RADIUS2 and (not bestD or d < bestD) then
						best, bestD = zob, d
					end
				end
			end
		end
	end
	return best
end

local function _appearanceMatches(zombie, entry)
	local v = entry.visual
	if not v then return false end
	local ok = false
	pcall(function()
		if v.female ~= nil and zombie.isFemale then
			if (zombie:isFemale() and true or false) ~= v.female then return end
		end
		local hv = zombie.getHumanVisual and zombie:getHumanVisual()
		if hv then
			if v.skinTex   and hv.getSkinTexture and hv:getSkinTexture() ~= v.skinTex   then return end
			if v.hairModel and hv.getHairModel   and hv:getHairModel()   ~= v.hairModel then return end
		end
		local want, nWant = {}, 0
		for _, e in ipairs(v.items or {}) do want[e.type] = true; nWant = nWant + 1 end
		local nHave = 0
		local ivs = zombie.getItemVisuals and zombie:getItemVisuals()
		if ivs and ivs.size then
			for i = 0, ivs:size() - 1 do
				local iv = ivs:get(i)
				local t = iv and iv.getItemType and iv:getItemType()
				if t then
					if not want[t] then return end
					nHave = nHave + 1
				end
			end
		end
		if nHave ~= nWant then return end
		ok = true
	end)
	return ok
end

local function _despawnOrphan(zombie)
	if not zombie then return end
	if zombie.getVariableBoolean and zombie:getVariableBoolean("Bandit") then return end
	pcall(function() if zombie.removeFromSquare then zombie:removeFromSquare() end end)
	pcall(function() if zombie.removeFromWorld  then zombie:removeFromWorld()  end end)
	DebugLog.log("[ZR Subdued] despawned orphan look-alike near a captive")
end

local function _findOrphanLookAlikes(cell, entry)
	local out = {}
	local zl = cell.getZombieList and cell:getZombieList()
	if not zl then return out end
	for i = 0, zl:size() - 1 do
		local zob = zl:get(i)
		if zob and zob.isAlive and zob:isAlive() and zob.getModData then
			local m = zob:getModData()
			if not m.zrSubdued then
				local dx, dy = zob:getX() - entry.x, zob:getY() - entry.y
				local d = dx * dx + dy * dy
				local tagged = (m.zrSubduedCopyOf == entry.key)
				local sameZ  = math.floor(zob:getZ()) == math.floor(entry.z)
				local isCopy = tagged or (sameZ and _appearanceMatches(zob, entry))
				if d <= ADOPT_RADIUS2 and isCopy then
					out[#out + 1] = { z = zob, d = d }
				end
			end
		end
	end
	table.sort(out, function(a, b) return a.d < b.d end)
	return out
end

local function _bindCaptive(zob, entry, adopted)
	pcall(function()
		if zob.teleportTo then zob:teleportTo(entry.x, entry.y, entry.z) end
		if entry.onFront ~= nil and zob.setFallOnFront then zob:setFallOnFront(entry.onFront) end
	end)
	_applyVisual(zob, entry.visual)
	pcall(function()
		local md = zob:getModData()
		md.zrSubdued    = true
		md.zrSubduedKey = entry.key
		md.zrSubduedCopyOf = entry.key
	end)
	ZR_Subdued.enforce(zob)
	DebugLog.log(string.format("[ZR Subdued] re-bound captive id=%s at %.1f,%.1f (adopted=%s)",
		tostring(entry.key), entry.x, entry.y, tostring(adopted)))
end

local function _tryAdopt(cell, entry)
	local zob = _findAdoptable(cell, entry)
	if zob then
		_bindCaptive(zob, entry, true)
		return zob
	end
	return nil
end

local GRACE_PASSES = 12
local _absentPasses = {}

local function _sceneActive(p)
	return ZomboRutScene and ZomboRutScene._active and p and ZomboRutScene._active[p] ~= nil
end

function ZR_Subdued.rematerialize()
	if SandboxVars and SandboxVars.ZomboRut and SandboxVars.ZomboRut.enableMod == false then return end
	if isClient and isClient() then return end
	local reg = _registry()
	if not reg then return end
	local cell = getCell()
	if not cell then return end
	local sceneOn = _sceneActive(_player())

	local liveById, dupsByKey, keylessBound = {}, {}, {}
	local zl = cell.getZombieList 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:isAlive() and z.getModData then
				local m = z:getModData()
				if m.zrSubdued then
					local k = m.zrSubduedKey
					if not k then
						keylessBound[#keylessBound + 1] = z
					elseif liveById[k] then
						dupsByKey[k] = dupsByKey[k] or {}
						dupsByKey[k][#dupsByKey[k] + 1] = z
					else
						liveById[k] = z
					end
				end
			end
		end
	end
	local reclaimedBodies = {}

	for id, entry in pairs(reg) do
		entry.key = id
		local live = liveById[id]
		if live then
			_absentPasses[id] = nil
			if not sceneOn then
				entry.x, entry.y, entry.z = live:getX(), live:getY(), live:getZ()
				for _, o in ipairs(_findOrphanLookAlikes(cell, entry)) do
					if o.z ~= live then _despawnOrphan(o.z) end
				end
				for _, d in ipairs(dupsByKey[id] or {}) do
					if d ~= live then _despawnOrphan(d) end
				end
			end
		elseif not sceneOn then
			local sq = cell.getGridSquare
				and cell:getGridSquare(math.floor(entry.x), math.floor(entry.y), math.floor(entry.z))
			if sq then
				local reclaim
				for _, z in ipairs(keylessBound) do
					local zTagged = z.getModData and z:getModData().zrSubduedCopyOf == id
					local zSameZ  = math.floor(z:getZ()) == math.floor(entry.z)
					if not reclaim and not reclaimedBodies[z]
						and (zTagged or (zSameZ and _appearanceMatches(z, entry)))
					then
						local dx, dy = z:getX() - entry.x, z:getY() - entry.y
						if dx * dx + dy * dy <= ADOPT_RADIUS2 then reclaim = z end
					end
				end
				local orphans = _findOrphanLookAlikes(cell, entry)
				if reclaim then
					reclaimedBodies[reclaim] = true
					_bindCaptive(reclaim, entry, true)
					for _, o in ipairs(orphans) do
						if o.z ~= reclaim then _despawnOrphan(o.z) end
					end
					_absentPasses[id] = nil
				elseif #orphans > 0 then
					_bindCaptive(orphans[1].z, entry, true)
					for i = 2, #orphans do _despawnOrphan(orphans[i].z) end
					_absentPasses[id] = nil
				elseif _tryAdopt(cell, entry) then
					_absentPasses[id] = nil
				else
					local n = (_absentPasses[id] or 0) + 1
					_absentPasses[id] = n
					if n >= GRACE_PASSES then
						local fresh = _spawnFresh(entry)
						if fresh then _bindCaptive(fresh, entry, false) end
						_absentPasses[id] = nil
					end
				end
			else
				_absentPasses[id] = nil
			end
		end
	end
end

function ZR_Subdued.getBinding(zombie)
	if not zombie or not zombie.getModData then return nil end
	local key = zombie:getModData().zrSubduedKey
	if not key then return nil end
	local reg = _registry()
	local entry = reg and reg[key]
	return entry and entry.binding or nil
end

function ZR_Subdued.release(zombie)
	if not zombie or not zombie.getModData then return end
	local md = zombie:getModData()

	local key = md.zrSubduedKey
	local reg = _registry()
	if key then
		if reg then reg[key] = nil end
		_absentPasses[key] = nil
	end
	md.zrSubdued    = nil
	md.zrSubduedKey = nil
	md.zrSubduedCopyOf = nil

	pcall(function()
		if zombie.clearVariable then zombie:clearVariable("zrSubdued") end
	end)

	local isBandit = false
	pcall(function()
		isBandit = zombie.getVariableBoolean and zombie:getVariableBoolean("Bandit") or false
	end)

	pcall(function()
		if zombie.setUseless then zombie:setUseless(false) end
		if zombie.setNoTeeth and not isBandit then zombie:setNoTeeth(false) end
		if zombie.setCanWalk then zombie:setCanWalk(true)  end
		if zombie.setReanimateTimer then
			zombie:setReanimateTimer(ZombRand(60) + 30)
		end
	end)

	pcall(function()
		if ZombieOnGroundState and ZombieOnGroundState.instance and zombie.changeState then
			zombie:changeState(ZombieOnGroundState.instance())
		end
	end)

	if zombie:getVariableBoolean("Bandit") and Bandit then
		pcall(function()
			if Bandit.ForceStationary then Bandit.ForceStationary(zombie, false) end
			if Bandit.SetHostile      then Bandit.SetHostile(zombie, true)       end
			if Bandit.SetHostileP     then Bandit.SetHostileP(zombie, true)      end
		end)
	end

	DebugLog.log(string.format("[ZR Subdued] released captive id=%s", tostring(key)))
end

Events.OnHitZombie.Add(function(zombie, attacker, bodyPart, weapon)
	if zombie and zombie.getModData and zombie:getModData().zrSubdued then
		ZR_Subdued.enforce(zombie)
	end
end)

Events.OnZombieUpdate.Add(function(zombie)
	if not zombie or not zombie.getModData then return end
	local md = zombie:getModData()
	if zombie.isAlive and not zombie:isAlive() then
		if md.zrSubduedKilled then ZR_Subdued.finishKilled(zombie) end
		return
	end
	if not md.zrSubdued then return end
	ZR_Subdued.enforce(zombie)
end)

Events.OnZombieDead.Add(function(zombie)
	if not zombie or not zombie.getModData then return end
	local md = zombie:getModData()
	if not md.zrSubdued then return end

	local key = md.zrSubduedKey
	if key then
		local reg = _registry()
		if reg then reg[key] = nil end
		_absentPasses[key] = nil
	end
	md.zrSubdued       = nil
	md.zrSubduedKey    = nil
	md.zrSubduedCopyOf = nil

	local state = zombie.getCurrentStateName and zombie:getCurrentStateName() or ""
	if state ~= "ZombieOnGroundState" then
		md.zrSubduedKilled = true
	end

	pcall(function()
		if zombie.clearVariable then zombie:clearVariable("zrSubdued") end
	end)
end)

function ZR_Subdued.finishKilled(zombie)
	if not zombie or not zombie.getModData then return end
	zombie:getModData().zrSubduedKilled = nil

	pcall(function()
		if zombie.setUseless       then zombie:setUseless(false)     end
		if zombie.setCanWalk       then zombie:setCanWalk(true)      end
		if zombie.setReanimateTimer then zombie:setReanimateTimer(0) end
	end)

	pcall(function()
		if ZombieOnGroundState and ZombieOnGroundState.instance and zombie.changeState then
			zombie:changeState(ZombieOnGroundState.instance())
		end
	end)

	DebugLog.log("[ZR Subdued] killed captive handed to the vanilla corpse path")
end

local _rematAccumMs = 0
local _rematLastMs
Events.OnPlayerUpdate.Add(function()
	local now = getTimestampMs()
	local gap = _rematLastMs and (now - _rematLastMs) or 0
	_rematLastMs = now
	if gap <= 0 or gap > 400 then return end
	_rematAccumMs = _rematAccumMs + gap
	if _rematAccumMs < 250 then return end
	_rematAccumMs = 0
	ZR_Subdued.rematerialize()
end)

Events.OnGameStart.Add(function()
	local reg = _registry()
	local n = 0
	if reg then for _ in pairs(reg) do n = n + 1 end end
	DebugLog.log(string.format("[ZR Subdued] OnGameStart: %d captive(s) in registry", n))
end)

local _scene_ok
_scene_ok, ZomboRutScene = pcall(require, "ZomboRut/ZomboRutScene")
if _scene_ok and ZomboRutScene and ZomboRutScene.registerSceneEndCallback then
	ZomboRutScene.registerSceneEndCallback(function(player, active, result)
		local original = active and active.liveOriginal
		if original and original.getModData and original:getModData().zrSubdued then
			ZR_Subdued.enforce(original)
			local reg = _registry()
			local key = original:getModData().zrSubduedKey
			if reg and key and reg[key] then
				reg[key].x = original:getX()
				reg[key].y = original:getY()
				reg[key].z = original:getZ()
			end
		end
	end)
end

return ZR_Subdued
