-- @author DoomTero 2026
-- @copyright 2026 DoomTero - ZomboRut original work. ref:2a2579b5
-- @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"
local ZR_StruggleModes = require "ZomboRut/ZR_StruggleModes"
local ZR_StruggleUI    = require "ZomboRut/ZR_StruggleUI"

ZR_Struggle = ZR_Struggle or {}

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

local PAUSE_GAP_MS = 400

local FLASH_MS = 1000

local STAGE_GAP_MS = 200

local LEADIN_MS = 700

local STRUGGLE_EVERY_N = 5

local STRUGGLE_WIN_STAMINA = 0.40

local KEYS = ZR_StruggleModes.DIRS

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

local function enabled()
	local s = sv()
	if not s then return false end
	if s.enableMod == false then return false end
	return s.struggleEnabled ~= false
end

ZR_Struggle.debugMode = nil

local function randomId()
	return ZR_StruggleModes.count() + 1
end

local function modeId()
	if ZR_Struggle.debugMode then return ZR_Struggle.debugMode end
	local v = sv() and sv().struggleMode
	if type(v) ~= "number" or v <= 1 then return randomId() end
	local id = v - 1
	if id > ZR_StruggleModes.count() then return randomId() end
	return id
end

local function rollModeId()
	local id = modeId()
	if id == randomId() then
		return ZombRand(ZR_StruggleModes.count()) + 1
	end
	return id
end

ZR_Struggle.debugDifficulty = nil

local function difficulty()
	local d = ZR_Struggle.debugDifficulty
	if not d then
		local s = sv()
		d = s and s.struggleDifficulty or 3
	end
	if type(d) ~= "number" then d = 3 end
	if d < 1 then d = 1 elseif d > 10 then d = 10 end
	return d
end

local runs = {}

local function keyFor(player)
	return (player and player.getPlayerNum and player:getPlayerNum()) or 0
end

local function clearRun(player)
	local k = keyFor(player)
	if runs[k] then
		runs[k] = nil
		ZR_StruggleUI.hide()
	end
end

function ZR_Struggle.isPending(player)
	return runs[keyFor(player)] ~= nil
end

local function computeDiff(player)
	local endurance = 1.0
	pcall(function()
		endurance = player:getStats():get(CharacterStat.ENDURANCE) or 1.0
	end)

	local strength = 5
	pcall(function()
		if Perks and Perks.Strength and player.getPerkLevel then
			strength = player:getPerkLevel(Perks.Strength) or 5
		end
	end)

	local zeds = 0
	pcall(function()
		local stats = player:getStats()
		if stats then
			local chasing = (stats.getNumChasingZombies and stats:getNumChasingZombies()) or 0
			local close   = (stats.getNumVeryCloseZombies and stats:getNumVeryCloseZombies()) or 0
			zeds = math.max(chasing, close)
		end
	end)
	if zeds > 6 then zeds = 6 end

	local curve
	if endurance >= 0.50 then
		curve = 1 + 2 * ((1.0 - endurance) / 0.50)
	elseif endurance >= 0.30 then
		curve = 4
	else
		local t = (0.30 - endurance) / 0.30
		curve = 4 + 2 * (t * t)
	end

	curve = curve - (strength - 5) * 0.12
	curve = curve + math.min(1.5, 0.25 * math.max(0, zeds - 1))

	local sens = difficulty()
	local diff
	if ZR_Struggle.debugDifficulty then
		diff = sens
	else
		diff = curve + (sens - 3)
	end
	if diff < 1 then diff = 1 elseif diff > 10 then diff = 10 end

	local widthMul = 1.60 - 0.11  * diff
	local timeMul  = 1.52 - 0.095 * diff
	if widthMul < 0.60 then widthMul = 0.60 elseif widthMul > 1.60 then widthMul = 1.60 end
	if timeMul  < 0.65 then timeMul  = 0.65 elseif timeMul  > 1.45 then timeMul  = 1.45 end

	log("diff: str=%d end=%.2f (%.0f%%) zeds=%d curve=%.1f sens=%d -> diff=%.1f widthMul=%.2f timeMul=%.2f",
		strength, endurance, endurance * 100, zeds, curve, sens, diff, widthMul, timeMul)

	return { widthMul = widthMul, timeMul = timeMul, diff = diff,
	         strength = strength, endurance = endurance, zeds = zeds }
end

local function readKey(k, chatFocused)
	if chatFocused then return false end
	local now = false
	pcall(function() now = isKeyDown(k) and true or false end)
	return now
end

local function primeInput(run)
	local chatFocused = ISChat and ISChat.instance and ISChat.instance.focused
	for i = 1, #KEYS do
		local k = KEYS[i]
		run.input.down[k]     = readKey(k, chatFocused)
		run.input.pressed[k]  = false
		run.input.released[k] = false
	end
	run.input.anyPressed = false
end

local function sampleInput(run)
	local inp = run.input
	local chatFocused = ISChat and ISChat.instance and ISChat.instance.focused

	inp.anyPressed = false
	for i = 1, #KEYS do
		local k = KEYS[i]
		local now = readKey(k, chatFocused)
		local was = inp.down[k]
		inp.pressed[k]  = (now and not was)
		inp.released[k] = (was and not now)
		inp.down[k]     = now
		if inp.pressed[k] then inp.anyPressed = true end
	end
end

local function jumpToOutro(player, active, amnesty)
	if not active then return end
	local sceneId = active.sceneId or ""
	local cfg     = active.cfg or {}

	local escapeVar = ZR_Struggle.debugEscapeVariant or cfg.escapeOutroVariant

	pcall(function()
		player:setVariable("ZR_IntroDone", "true")
		player:setVariable("ZR_MainSegmentsRemaining", "0")
		player:setVariable("ZR_" .. sceneId .. "Outro", "true")
		if escapeVar then
			player:setVariable("ZR_" .. sceneId .. "OutroVariant", escapeVar)
		end
	end)

	active.outroActivated = true
	if escapeVar then
		active.outroClipExpected = true
	end

	if amnesty then
		active.struggleEscaped = true
	end

	local effDur     = active.effectiveDuration or cfg.duration or 24
	local outroSecs  = cfg.outroDuration or 5
	local outroStart = math.max(0, (effDur - outroSecs) * 60)
	if (active.dragTicks or 0) < outroStart then
		active.dragTicks     = outroStart
		active.dragBaseTicks = outroStart
		active.dragAccumMs   = 0
		active.dragLastMs    = getTimestampMs()
	end

	log("WIN -> jumped %s to Outro (dragTicks=%d, outroVariant=%s)",
		sceneId, outroStart, escapeVar or "<unchanged>")
end

local SHOVE_RADIUS         = 1.2
local SPRINTER_DROP_RADIUS = 8.0

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

local wonPending = {}

local ESCAPE_GRACE_MS = 5000

local escapeGrace = {}

function ZR_Struggle.escapeGraceActive(player)
	local until_ = escapeGrace[keyFor(player)]
	if not until_ then return false end
	if getTimestampMs() >= until_ then
		escapeGrace[keyFor(player)] = nil
		return false
	end
	return true
end

local ESCAPE_LINES = {
	"UI_ZR_Struggle_Escaped",
	"UI_ZR_Struggle_Escaped_B",
	"UI_ZR_Struggle_Escaped_C",
	"UI_ZR_Struggle_Escaped_D",
	"UI_ZR_Struggle_Escaped_E",
	"UI_ZR_Struggle_Escaped_F",
	"UI_ZR_Struggle_Escaped_G",
	"UI_ZR_Struggle_Escaped_H",
}
local ESCAPE_FALLBACK = "I have to escape NOW!"

local function sayEscaped(player)
	pcall(function()
		local key = ESCAPE_LINES[ZombRand(#ESCAPE_LINES) + 1]
		local txt = getText(key)
		if not txt or txt == key then txt = ESCAPE_FALLBACK end
		player:Say(txt)
	end)
end

local function startShove(player)
	local ok, err = pcall(function()
		local cell = getCell(); if not cell then return end
		local zl = cell:getZombieList(); if not zl then return end
		local px, py = player:getX(), player:getY()
		local nShove, nDrop = 0, 0
		for i = 0, zl:size() - 1 do
			local z = zl:get(i)
			if z and z.isAlive and z:isAlive() then
				local dx, dy = z:getX() - px, z:getY() - py
				local d2 = dx * dx + dy * dy
				if isSprinter(z) and d2 <= (SPRINTER_DROP_RADIUS * SPRINTER_DROP_RADIUS) then
					pcall(function()
						if z.knockDown then z:knockDown(false) end
						if z.setPlayerAttackPosition then z:setPlayerAttackPosition("FRONT") end
						nDrop = nDrop + 1
					end)
				elseif d2 <= (SHOVE_RADIUS * SHOVE_RADIUS) then
					pcall(function()
						if z.setStaggerBack then z:setStaggerBack(true) end
						if z.setHitReaction then z:setHitReaction("") end
						nShove = nShove + 1
					end)
				end
			end
		end
		if nShove > 0 or nDrop > 0 then
			log("escape: %d shoved (<=%.1f tiles), %d sprinter(s) knocked down (<=%.1f tiles)",
				nShove, SHOVE_RADIUS, nDrop, SPRINTER_DROP_RADIUS)
		end
	end)
	if not ok then log("shove FAILED: %s", tostring(err)) end
end

local function beginStage(run)
	local sd = run.mode.stages[run.stageIdx]
	if not sd then return end
	run.stageStartedMs = run.nowMs
	run.leadIn = false
	run.s = {}
	pcall(run.mode.startStage, run, sd)
	log("stage %d/%d begin", run.stageIdx, run.stageCount)
end

local function startRun(player, active, preview, forceModeId, queue)
	local mode = ZR_StruggleModes.get(forceModeId or rollModeId())
	local nowMs = getTimestampMs()
	local run = {
		player       = player,
		active       = active,
		preview      = preview and true or false,
		mode         = mode,
		stageIdx     = 1,
		stageCount   = #mode.stages,
		nowMs        = nowMs,
		lastMs       = nowMs,
		startedMs    = nowMs,
		diff         = computeDiff(player),
		s            = {},
		input        = { down = {}, pressed = {}, released = {} },
		result       = nil,
		resolvedMs   = nil,
		queue        = queue,
	}
	runs[keyFor(player)] = run
	primeInput(run)
	run.gapUntil  = nowMs + LEADIN_MS
	run.leadIn    = true
	run.leadInMs  = LEADIN_MS
	ZR_StruggleUI.show(run)
	log("run start: mode=%s (%d) stages=%d preview=%s queued=%d",
		mode.name, mode.id, run.stageCount, tostring(run.preview),
		queue and #queue or 0)
	return run
end

local function resolve(run, result)
	if run.result then return end
	run.result     = result
	run.resolvedMs = run.nowMs
	log("run resolved: %s (stage %d/%d)", result, run.stageIdx, run.stageCount)

	if run.preview then
		pcall(function()
			run.player:Say(string.format("[Struggle test] %s at difficulty %.1f (stage %d/%d)",
				result == "win" and "WIN" or "LOSE",
				(run.diff and run.diff.diff) or 0,
				math.min(run.stageIdx, run.stageCount), run.stageCount))
		end)
	end

	if run.standalone then
		if run.onResolve then pcall(run.onResolve, result) end
	elseif result == "win" and not run.preview then
		jumpToOutro(run.player, run.active, run.amnesty)
		local marked = false
		pcall(function()
			if ZR_Surrender and ZR_Surrender.markStruggleWin then
				marked = ZR_Surrender.markStruggleWin(run.player, run.amnesty)
			end
		end)
		pcall(function()
			local stats = run.player:getStats()
			if stats then
				local cur     = stats:get(CharacterStat.ENDURANCE) or 0
				local target  = math.min(1.0, cur + STRUGGLE_WIN_STAMINA)
				local granted = target - cur
				stats:set(CharacterStat.ENDURANCE, target)
				if not run.amnesty and run.active
					and type(run.active.zrEndAtStart) == "number" then
					run.active.zrEndAtStart = run.active.zrEndAtStart + granted
				end
				log("WIN: stamina %.0f%% -> %.0f%% (+%.0f%% break-free refund, amnesty=%s)",
					cur * 100, target * 100, granted * 100, tostring(run.amnesty))
			end
		end)
		wonPending[keyFor(run.player)] = true
		log("WIN: surrender marked=%s, shove armed for scene end", tostring(marked))
	end
end

ZomboRutScene.registerSceneStartCallback(function(player, active)
	if not player or not active then return end
	if not enabled() then return end
	if isClient() then return end

	local cfg = active.cfg or {}
	if not cfg.isDefeat then return end
	if cfg.isUtility then return end

	local rendered = 0
	pcall(function()
		if ZR_Surrender and ZR_Surrender.getScenesRendered then
			rendered = ZR_Surrender.getScenesRendered(player) or 0
		end
	end)
	if rendered % STRUGGLE_EVERY_N ~= 0 then
		log("scene %s is #%d of this give-up — next escape chance at #%d",
			tostring(active.sceneId), rendered + 1,
			(math.floor(rendered / STRUGGLE_EVERY_N) + 1) * STRUGGLE_EVERY_N + 1)
		return
	end

	clearRun(player)
	runs[keyFor(player)] = { armed = true, active = active, player = player,
		amnesty = (rendered == 0) }
	log("armed for scene %s (%s)", tostring(active.sceneId),
		(rendered == 0) and "amnesty chance" or "escape-only chance")
end)

ZomboRutScene.registerSceneEndCallback(function(player, active, result)
	if wonPending[keyFor(player)] then
		wonPending[keyFor(player)] = nil
		startShove(player)
		escapeGrace[keyFor(player)] = getTimestampMs() + ESCAPE_GRACE_MS
		sayEscaped(player)
	end
	local run = runs[keyFor(player)]
	if not run then return end
	log("scene end (%s) — clearing run", tostring(result))
	clearRun(player)
end)

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

	local run = runs[keyFor(player)]
	if not run then return end

	if not run.preview and not run.standalone then
		local active = ZomboRutScene._active and ZomboRutScene._active[player]
		if not active then clearRun(player); return end

		if run.armed then
			if not active.draggingObserved then return end
			local amnesty = run.amnesty
			local started = startRun(player, active, false)
			if started then started.amnesty = amnesty end
			log("promoted armed run -> live (amnesty=%s)", tostring(amnesty))
			return
		end
		run.active = active
	end

	if run.armed then return end

	local nowMs = getTimestampMs()
	local dtMs  = nowMs - (run.lastMs or nowMs)
	if dtMs < 0 then dtMs = 0 end
	if dtMs > PAUSE_GAP_MS then
		local skip = dtMs
		dtMs = 0
		run.startedMs      = run.startedMs + skip
		run.stageStartedMs = (run.stageStartedMs or nowMs) + skip
		local s = run.s
		if s then
			if s.deadline  then s.deadline  = s.deadline  + skip end
			if s.endMs     then s.endMs     = s.endMs     + skip end
			if s.lastMs    then s.lastMs    = s.lastMs    + skip end
			if s.grabBy    then s.grabBy    = s.grabBy    + skip end
			if s.lockUntil then s.lockUntil = s.lockUntil + skip end
		end
		if run.resolvedMs then run.resolvedMs = run.resolvedMs + skip end
		if run.gapUntil   then run.gapUntil   = run.gapUntil   + skip end
	end
	run.lastMs = nowMs
	run.nowMs  = nowMs

	if run.result then
		if nowMs - run.resolvedMs >= FLASH_MS then
			local q = run.queue
			clearRun(player)
			if q and #q > 0 then
				local nextId = table.remove(q, 1)
				startRun(player, nil, true, nextId, q)
			end
		end
		return
	end

	sampleInput(run)

	if run.gapUntil then
		if nowMs < run.gapUntil then return end
		run.gapUntil = nil
		beginStage(run)
		return
	end

	local sd = run.mode.stages[run.stageIdx]
	if not sd then resolve(run, "win"); return end

	local ok, outcome = pcall(run.mode.tick, run, sd, nowMs, dtMs)
	if not ok then
		log("mode tick ERROR: %s", tostring(outcome))
		resolve(run, "lose")
		return
	end

	if outcome == "clear" then
		run.stageIdx = run.stageIdx + 1
		if run.stageIdx > run.stageCount then
			resolve(run, "win")
		else
			run.gapUntil = nowMs + STAGE_GAP_MS
		end
	elseif outcome == "fail" then
		resolve(run, "lose")
	end
end

local function onTick(player)
	pcall(onTickInner, player)
end

Events.OnPlayerUpdate.Add(onTick)

Events.OnPlayerDeath.Add(function(player)
	pcall(function()
		wonPending[keyFor(player)] = nil
	end)
end)

function ZR_Struggle.preview(player, id)
	player = player or getPlayer()
	if not player then return end
	clearRun(player)
	startRun(player, nil, true, id)
end

function ZR_Struggle.beginStandalone(player, onResolve, forceModeId)
	player = player or getPlayer()
	if not player then return false end
	if not enabled() then
		log("standalone refused: mini-game disabled by sandbox")
		return false
	end
	if ZR_Struggle.isRunning(player) then
		log("standalone refused: a run is already live")
		return false
	end
	clearRun(player)
	local run = startRun(player, nil, false, forceModeId)
	if not run then return false end
	run.standalone = true
	run.onResolve  = onResolve
	log("standalone run started")
	return true
end

function ZR_Struggle.previewAll(player)
	player = player or getPlayer()
	if not player then return end
	local queue = {}
	for id = 2, ZR_StruggleModes.count() do queue[#queue + 1] = id end
	clearRun(player)
	startRun(player, nil, true, 1, queue)
end

function ZR_Struggle.setMode(id)
	ZR_Struggle.debugMode = id
	if not id then
		log("mode override cleared — using sandbox struggleMode=%d", modeId())
	elseif id == randomId() then
		log("mode override -> RANDOM")
	else
		log("mode override -> %d (%s)", id, ZR_StruggleModes.get(id).name)
	end
end

function ZR_Struggle.randomModeId()
	return randomId()
end

ZR_Struggle.debugEscapeVariant = nil

function ZR_Struggle.setEscapeVariant(letter)
	ZR_Struggle.debugEscapeVariant = letter
	log("escape outro override -> %s", letter or "off (using cfg)")
end

function ZR_Struggle.getEscapeVariant()
	return ZR_Struggle.debugEscapeVariant
end

function ZR_Struggle.getModeId()
	return modeId()
end

function ZR_Struggle.setDifficulty(d)
	ZR_Struggle.debugDifficulty = d
	log("difficulty override -> %s", tostring(d or "sandbox"))
end

function ZR_Struggle.getDifficulty()
	return difficulty()
end

function ZR_Struggle.isRunning(player)
	local r = runs[keyFor(player or getPlayer())]
	return r ~= nil and not r.armed
end

return ZR_Struggle
