-- @author DoomTero 2026
-- @copyright 2026 DoomTero - ZomboRut original work. ref:1767b026
-- @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 ZR_Moodles = require "ZomboRut/ZR_Moodles"

ZR_ZombieCloak = ZR_ZombieCloak or {}

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

local MOODLE_NAME = "ZombieScent"
ZR_Moodles.create(MOODLE_NAME)

local state = {}

local function _sandbox()
	return SandboxVars and SandboxVars.ZomboRut
end

local function _cloakHours()
	local sv = _sandbox()
	local h = (sv and sv.cloakDurationGameHours) or 2
	if h < 0 then h = 0 end
	return h
end

local function _enabled()
	local sv = _sandbox()
	if not sv then return false end
	if sv.enableMod == false then return false end
	if sv.cloakEnabled == false then return false end
	if _cloakHours() <= 0 then return false end
	return true
end

local function _nowHour()
	local gt = getGameTime and getGameTime()
	if not gt then return 0 end
	return gt:getWorldAgeHours()
end

local CLOAK_HOURS_PER_SCENE = 0.5

local _tickCounter = 0
local _tickLastMs = nil
local _tickAccumMs = 0

local BUMP_EXPOSE_GRACE_TICKS = 300

local function _stateFor(player)
	if state[player] then return state[player] end
	for p, st in pairs(state) do
		if p == player then return st end
	end
	return nil
end

local function _isPlayerCharacter(c)
	if not c then return false end
	if instanceof and instanceof(c, "IsoPlayer") then return true end
	local sp = getSpecificPlayer and getSpecificPlayer(0)
	if sp and c == sp then return true end
	return false
end

local function _moodleValueFor(remainingFrac)
	if remainingFrac <= 0 then return 0.5 end
	if remainingFrac > 0.66 then return 0.85 end
	if remainingFrac > 0.33 then return 0.7  end
	return 0.6
end

local function _setMoodle(player, value)
	ZR_Moodles.set(MOODLE_NAME, player, value)
end

local _suspendedCloak = {}

local function _suspendedFor(player)
	if _suspendedCloak[player] then return _suspendedCloak[player] end
	for p, s in pairs(_suspendedCloak) do
		if p == player then return s end
	end
	return nil
end

local function _clearSuspended(player)
	for p in pairs(_suspendedCloak) do
		if p == player then _suspendedCloak[p] = nil end
	end
end

local function _restoreSuspended(player, reason)
	local susp = _suspendedFor(player)
	if not susp then return end
	_clearSuspended(player)
	if not _enabled() then
		log("suspended cloak dropped (%s) — cloak disabled", reason)
		return
	end
	if susp.remaining <= 0 or not susp.st then return end
	if _stateFor(player) then
		log("suspended cloak dropped (%s) — a fresh cloak is already active", reason)
		return
	end
	if ZomboRutScene and ZomboRutScene.isPlayerInScene
		and ZomboRutScene.isPlayerInScene(player)
	then
		log("suspended cloak dropped (%s) — a scene is still active", reason)
		return
	end
	local st = susp.st
	st.expireHour     = _nowHour() + susp.remaining
	st.cloakedZombies = {}
	st.bumpGraceUntil = _tickCounter + BUMP_EXPOSE_GRACE_TICKS
	state[player] = st
	log("suspended cloak restored (%s): %.2fh remaining of %.2fh",
		reason, susp.remaining, st.totalHours or susp.remaining)
	pcall(function()
		_setMoodle(player, _moodleValueFor(
			susp.remaining / (st.totalHours or susp.remaining)))
	end)
end

function ZR_ZombieCloak.isActive(player)
	local st = _stateFor(player)
	if not st then return false end
	if _nowHour() >= st.expireHour then return false end
	return true
end

function ZR_ZombieCloak.getRemainingHours(player)
	local st = _stateFor(player)
	if not st then return 0 end
	local r = st.expireHour - _nowHour()
	if r < 0 then return 0 end
	return r
end

function ZR_ZombieCloak.activate(player, hours)
	if not player or not player:isAlive() then return end
	hours = hours or _cloakHours()
	if hours <= 0 then return end
	_clearSuspended(player)
	state[player] = {
		expireHour     = _nowHour() + hours,
		totalHours     = hours,
		exposed        = {},
		cloakedZombies = {},
	}
	log("activated for %.2f game hours (expires at hour %.2f)",
		hours, state[player].expireHour)
	pcall(function() _setMoodle(player, _moodleValueFor(1.0)) end)
	pcall(function()
		local msg = getText("UI_ZR_ZombieCloak_Active")
		if msg and msg ~= "UI_ZR_ZombieCloak_Active" then
			player:Say(msg)
		end
	end)
end

function ZR_ZombieCloak.accumulate(player, hours)
	if not player or not player:isAlive() then return end
	hours = hours or CLOAK_HOURS_PER_SCENE
	if hours <= 0 then return end
	local cap = _cloakHours()
	if cap <= 0 then return end
	local now = _nowHour()
	local st = _stateFor(player)
	if not st then
		local newDur = math.min(hours, cap)
		state[player] = {
			expireHour     = now + newDur,
			totalHours     = newDur,
			budgetHours    = newDur,
			bumpGraceUntil = _tickCounter + BUMP_EXPOSE_GRACE_TICKS,
			exposed        = {},
			cloakedZombies = {},
		}
		log("cloak started (first scene) +%.2fh, remaining=%.2fh (cap=%.2fh)",
			hours, newDur, cap)
		pcall(function() _setMoodle(player, _moodleValueFor(1.0)) end)
		pcall(function()
			local msg = getText("UI_ZR_ZombieCloak_Active")
			if msg and msg ~= "UI_ZR_ZombieCloak_Active" then
				player:Say(msg)
			end
		end)
	else
		local currentRem = math.max(0, st.expireHour - now)
		local newRem = math.min(cap, currentRem + hours)
		st.expireHour = now + newRem
		st.totalHours = math.max(st.totalHours or newRem, newRem)
		st.budgetHours = newRem
		st.bumpGraceUntil = _tickCounter + BUMP_EXPOSE_GRACE_TICKS
		st.exposed = {}
		log("cloak extended +%.2fh, remaining=%.2fh (cap=%.2fh, exposed cleared)",
			hours, newRem, cap)
		pcall(function() _setMoodle(player, _moodleValueFor(1.0)) end)
	end
end

function ZR_ZombieCloak.deactivate(player)
	local storedKey, st = nil, nil
	for p, s in pairs(state) do
		if p == player then storedKey = p; st = s; break end
	end
	if storedKey then
		log("deactivated for player")
		state[storedKey] = nil
		pcall(function() _setMoodle(player, 0.5) end)
	end
end

function ZR_ZombieCloak.expose(player, zombie)
	local st = _stateFor(player)
	if not st then return end
	if not zombie or not zombie.getID then return end
	local id = zombie:getID()
	if id == nil then return end
	if not st.exposed[id] then
		st.exposed[id] = true
		log("zombie id=%s exposed (no longer cloaked from this one)", tostring(id))
	end
	pcall(function()
		if zombie.setUseless then zombie:setUseless(false) end
		local phase = ZR_Surrender and ZR_Surrender.getPhase
			and ZR_Surrender.getPhase(player)
		if phase ~= "surrender" and zombie.setNoTeeth then
			zombie:setNoTeeth(false)
		end
	end)
	if st.cloakedZombies then st.cloakedZombies[id] = nil end
end

local _encCloakScenes = {}

local ZomboRutScene = require "ZomboRut/ZomboRutScene"
if ZomboRutScene and ZomboRutScene.registerSceneEndCallback then
	ZomboRutScene.registerSceneEndCallback(function(player, _active, result)
		if not _enabled() then return end
		if result ~= "Released" then return end
		if _active and _active.struggleEscaped then
			log("scene-end Released but struggle ESCAPED → skip cloak accumulate")
			return
		end
		if not (_active and _active.cfg and _active.cfg.isUtility) then
			_clearSuspended(player)
		end
		if _active and _active.cfg and _active.cfg.isUtility then
			log("scene-end Released for isUtility scene %q → skip cloak accumulate",
				tostring(_active.sceneId))
			return
		end
		if not (_active and _active.outroFinished) then
			log("scene-end Released but outro never finished (outroA=%s) → skip cloak accumulate",
				tostring(_active and _active.outroActivated))
			return
		end
		local partner = _active and (_active.liveOriginal or _active.partnerRef)
		if (_active and _active.partnerIsBandit)
			or (partner and partner.getVariableBoolean
				and partner:getVariableBoolean("Bandit"))
		then
			log("scene-end Released for bandit-partner scene %q → skip cloak accumulate",
				tostring(_active.sceneId))
			return
		end
		if ZR_Surrender and ZR_Surrender.isActive and ZR_Surrender.isActive(player) then
			_encCloakScenes[player] = (_encCloakScenes[player] or 0) + 1
			log("scene-end Released mid give-up → cloak deferred (encounter scenes=%d)",
				_encCloakScenes[player])
			return
		end
		log("scene-end Released → +%.2fh cloak", CLOAK_HOURS_PER_SCENE)
		ZR_ZombieCloak.accumulate(player)
	end)
	log("scene-end callback registered (per-scene accumulation active)")
else
	log("WARNING: ZomboRutScene.registerSceneEndCallback unavailable — cloak will NEVER auto-activate")
end

local function registerCloakSurrenderHooks()
	if not ZR_Surrender or not ZR_Surrender.registerEndCallback then return end
	if ZR_Surrender._zombieCloakRegistered then return end
	ZR_Surrender._zombieCloakRegistered = true

	ZR_Surrender.registerStartCallback(function(player, _st)
		_encCloakScenes[player] = nil
	end)

	local function settleCloak(player)
		local n = _encCloakScenes[player] or 0
		_encCloakScenes[player] = nil
		if n <= 0 then return end
		if not _enabled() then return end
		log("encounter over → cloak settles once: %d scene(s) x %.2fh", n, CLOAK_HOURS_PER_SCENE)
		ZR_ZombieCloak.accumulate(player, n * CLOAK_HOURS_PER_SCENE)
	end

	ZR_Surrender.registerEscapeCallback(function(player, _st) settleCloak(player) end)
	ZR_Surrender.registerEndCallback(function(player, _st) settleCloak(player) end)
end

registerCloakSurrenderHooks()
Events.OnGameStart.Add(registerCloakSurrenderHooks)

if ZR_Surrender and ZR_Surrender.registerStartCallback then
	ZR_Surrender.registerStartCallback(function(player, _st)
		if ZR_ZombieCloak.isActive(player) then
			log("surrender started during active cloak — suspending cloak so aggressors can engage")
			_suspendedCloak[player] = {
				remaining = ZR_ZombieCloak.getRemainingHours(player),
				st        = _stateFor(player),
			}
			ZR_ZombieCloak.deactivate(player)
		end
	end)
end

if ZR_Surrender and ZR_Surrender.registerEscapeCallback then
	ZR_Surrender.registerEscapeCallback(function(player, _st)
		_restoreSuspended(player, "escape")
	end)
end
if ZR_Surrender and ZR_Surrender.registerEndCallback then
	ZR_Surrender.registerEndCallback(function(player, _st)
		_restoreSuspended(player, "surrender end")
	end)
end

Events.OnCharacterDeath.Add(function(character)
	for p in pairs(state) do
		if p == character then state[p] = nil end
	end
	_clearSuspended(character)
end)

local function onPlayerUpdate_Expire(player)
	local st = _stateFor(player)
	if not st then return end
	local now = _nowHour()

	local phase = ZR_Surrender and ZR_Surrender.getPhase and ZR_Surrender.getPhase(player)
	if phase == "surrender" and st.budgetHours and st.budgetHours > 0 then
		st.expireHour = now + st.budgetHours
		pcall(function() _setMoodle(player, _moodleValueFor(1.0)) end)
		return
	end

	if now >= st.expireHour then
		log("expired naturally (hour %.2f reached)", st.expireHour)
		pcall(function()
			local msg = getText("UI_ZR_ZombieCloak_Wear_Off")
			if msg and msg ~= "UI_ZR_ZombieCloak_Wear_Off" then
				player:Say(msg)
			end
		end)
		ZR_ZombieCloak.deactivate(player)
		return
	end
	local total = st.totalHours
	if total and total > 0 then
		local frac = (st.expireHour - now) / total
		pcall(function() _setMoodle(player, _moodleValueFor(frac)) end)
	end
end
Events.OnPlayerUpdate.Add(onPlayerUpdate_Expire)

Events.OnCharacterCollide.Add(function(character, collidedCharacter)
	if not character or not collidedCharacter then return end
	local player, other
	if _isPlayerCharacter(character) then
		player, other = character, collidedCharacter
	elseif _isPlayerCharacter(collidedCharacter) then
		player, other = collidedCharacter, character
	else
		return
	end
	if not other.isZombie or not other:isZombie() then return end
	if other.getVariableBoolean and other:getVariableBoolean("Bandit") then return end
	if not ZR_ZombieCloak.isActive(player) then return end
	if ZomboRutScene.isPlayerInScene(player) then return end
	if ZomboRutScene.isPlayerInPostSceneGrace
		and ZomboRutScene.isPlayerInPostSceneGrace(player) then return end
	local st = _stateFor(player)
	if st and st.bumpGraceUntil and _tickCounter < st.bumpGraceUntil then return end
	local ph = ZR_Surrender and ZR_Surrender.getPhase
		and ZR_Surrender.getPhase(player)
	if ph == "collapse" or ph == "escape" then return end
	if ZR_CrowdControl and ZR_CrowdControl.isDispersing
		and ZR_CrowdControl.isDispersing(player) then return end
	if player.isGhostMode and player:isGhostMode() then return end
	ZR_ZombieCloak.expose(player, other)
end)

Events.OnHitZombie.Add(function(zombie, attacker, _bodyPart, _weapon)
	if not zombie or not attacker then return end
	if not _isPlayerCharacter(attacker) then return end
	if zombie.getVariableBoolean and zombie:getVariableBoolean("Bandit") then return end
	if not ZR_ZombieCloak.isActive(attacker) then return end
	if ZomboRutScene.isPlayerInScene(attacker) then return end
	if ZomboRutScene.isPlayerInPostSceneGrace
		and ZomboRutScene.isPlayerInPostSceneGrace(attacker) then return end
	ZR_ZombieCloak.expose(attacker, zombie)
end)

local CLOAK_SCAN_RANGE = 18
local _setsThisSecond = 0
local _lastHeartbeatTick = 0

local function onPlayerUpdate_CloakScan(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 = _stateFor(player)
	if not st then return end
	if _nowHour() >= st.expireHour then return end

	local phase = ZR_Surrender and ZR_Surrender.getPhase and ZR_Surrender.getPhase(player)
	if phase == "surrender" then return end

	if player.isGhostMode and player:isGhostMode() then return end

	if ZR_CrowdControl and ZR_CrowdControl.isDispersing
		and ZR_CrowdControl.isDispersing(player) then return end

	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 not (z.getVariableBoolean and z:getVariableBoolean("Bandit"))
			and not (z.getModData and z:getModData().zrSubdued)
			and z:DistTo(player) <= CLOAK_SCAN_RANGE
		then
			local zid = z.getID and z:getID()
			if zid ~= nil and not st.exposed[zid] then
				pcall(function()
					if not st.cloakedZombies[zid] then
						st.cloakedZombies[zid] = true
						_setsThisSecond = _setsThisSecond + 1
					end
					local cur = z.getCurrentStateName and z:getCurrentStateName() or ""
					if (cur:find("Lunge") or cur:find("Attack")
						or cur:find("Thump") or cur:find("TurnAlerted"))
						and z.Wander
					then
						z:Wander()
					end
					if z.setUseless    then z:setUseless(true)    end
					if z.setNoTeeth    then z:setNoTeeth(true)    end
					if z.clearAggroList then z:clearAggroList() end
					if z.setTarget       then z:setTarget(nil)   end
				end)
			end
		end
	end

	if (_tickCounter - _lastHeartbeatTick) >= 60 then
		_lastHeartbeatTick = _tickCounter
		if _setsThisSecond > 0 then
			local rem = ZR_ZombieCloak.getRemainingHours(player)
			local cloaked = 0
			for _ in pairs(st.cloakedZombies) do cloaked = cloaked + 1 end
			log("heartbeat: cloaked=%d zombies, new this sec=%d, remaining=%.2fh",
				cloaked, _setsThisSecond, rem)
		end
		_setsThisSecond = 0
	end
end
Events.OnPlayerUpdate.Add(onPlayerUpdate_CloakScan)

local function _findCloakedPlayerForTarget(tgt)
	for p, st in pairs(state) do
		if p == tgt and _nowHour() < st.expireHour then
			return p, st
		end
	end
	return nil, nil
end

Events.OnZombieUpdate.Add(function(zombie)
	if not zombie or not zombie:isAlive() then return end
	if zombie.getVariableBoolean and zombie:getVariableBoolean("Bandit") then return end
	if zombie.getModData and zombie:getModData().zrSubdued then return end

	local tgt = zombie.getTarget and zombie:getTarget()
	if not tgt then return end

	local _, st = _findCloakedPlayerForTarget(tgt)
	if not st then return end

	local phase = ZR_Surrender and ZR_Surrender.getPhase and ZR_Surrender.getPhase(tgt)
	if phase == "surrender" then return end

	if tgt.isGhostMode and tgt:isGhostMode() then return end

	local zid = zombie.getID and zombie:getID()
	if zid ~= nil and st.exposed[zid] then return end

	pcall(function()
		if zid ~= nil and not st.cloakedZombies[zid] then
			st.cloakedZombies[zid] = true
		end
		local cur = zombie.getCurrentStateName and zombie:getCurrentStateName() or ""
		if (cur:find("Lunge") or cur:find("Attack")
			or cur:find("Thump") or cur:find("TurnAlerted"))
			and zombie.Wander
		then
			zombie:Wander()
		end
		if zombie.setUseless    then zombie:setUseless(true)   end
		if zombie.setNoTeeth    then zombie:setNoTeeth(true)   end
		if zombie.clearAggroList then zombie:clearAggroList() end
		if zombie.setTarget     then zombie:setTarget(nil)     end
	end)
end)

local _origDeactivate = ZR_ZombieCloak.deactivate
function ZR_ZombieCloak.deactivate(player)
	local st = _stateFor(player)
	if st and st.cloakedZombies then
		local cell = getCell()
		local zList = cell and cell:getZombieList()
		if zList then
			for i = 0, zList:size() - 1 do
				local z = zList:get(i)
				if z and z.getID then
					local zid = z:getID()
					if st.cloakedZombies[zid] and z:isAlive()
						and not (z.getModData and z:getModData().zrSubdued)
					then
						pcall(function()
							if z.setUseless then z:setUseless(false) end
							local phase = ZR_Surrender and ZR_Surrender.getPhase
								and ZR_Surrender.getPhase(player)
							if phase ~= "surrender" and z.setNoTeeth then
								z:setNoTeeth(false)
							end
						end)
					end
				end
			end
		end
	end
	_origDeactivate(player)
end

return ZR_ZombieCloak
