--[[--------------------------------------------------------------------------
	Boom - Modern Healing & Support Raid Frames
	Engine/Status.lua

	Every non-aura fact a unit button draws: health, heal prediction, absorbs,
	power, life state, phasing, resurrection, summons, threat, ready check and
	raid markers.

	The file has two halves, and the second one is the reason it exists.

	  * Queries. Deliberately uncached. UnitHealth and friends read straight out
	    of the client's unit record - cheaper than the hash lookup a cache would
	    cost us - and a cache is one more thing that can go stale and make the
	    frames lie to a healer. So we always ask the game.

	  * Event routing. The client fires unit events for the entire raid plus
	    every nameplate, target, focus and pet in sight; we draw a handful of
	    those. So the very first thing any handler does is ask the frame registry
	    whether a button is showing that unit, and drop the event if not.
	    Everything that survives becomes dirty flags, and the scheduler in
	    Core/Init collapses a burst of them into one redraw per screen frame.

	This module never touches a texture. UI/ draws, Engine/ answers questions.
----------------------------------------------------------------------------]]

local ADDON_NAME, Boom = ...

local Status = Boom:NewModule("Status")

local L      = Boom.L
local Util   = Boom.Util
local UPDATE = Boom.UPDATE

local pairs = pairs
local bor   = bit.bor

-- Hot globals. Some of these are optional across client flavours, so every use
-- site still guards; localising only saves the environment lookup.
local UnitExists              = UnitExists
local UnitHealth              = UnitHealth
local UnitHealthMax           = UnitHealthMax
local UnitPower               = UnitPower
local UnitPowerMax            = UnitPowerMax
local UnitPowerType           = UnitPowerType
local UnitGetIncomingHeals    = UnitGetIncomingHeals
local UnitGetTotalAbsorbs     = UnitGetTotalAbsorbs
local UnitGetTotalHealAbsorbs = UnitGetTotalHealAbsorbs
local UnitIsConnected         = UnitIsConnected
local UnitIsDead              = UnitIsDead
local UnitIsGhost             = UnitIsGhost
local UnitIsFeignDeath        = UnitIsFeignDeath
local UnitIsAFK               = UnitIsAFK
local UnitIsDND               = UnitIsDND
local UnitIsCharmed           = UnitIsCharmed
local UnitPhaseReason         = UnitPhaseReason
local UnitThreatSituation     = UnitThreatSituation
local UnitHasIncomingResurrection = UnitHasIncomingResurrection
local GetReadyCheckStatus     = GetReadyCheckStatus
local GetReadyCheckTimeLeft   = GetReadyCheckTimeLeft
local GetRaidTargetIndex      = GetRaidTargetIndex

--------------------------------------------------------------------------------
-- Health, prediction, power
--------------------------------------------------------------------------------

--------------------------------------------------------------------------------
-- Secret values
--
-- Midnight added a value type that tainted (addon) code may store and pass
-- around but may NOT do arithmetic on, compare, or use as a table key. Inside
-- instances UnitHealth and friends return exactly that. The naive
--     local pct = UnitHealth(u) / UnitHealthMax(u)
-- therefore throws in precisely the content a raid frame exists for.
--
-- Boom's rule: never divide. Health and power bars are driven with raw values
-- through SetMinMaxValues/SetValue, both of which explicitly accept secrets from
-- tainted code. Anything that genuinely needs a number - percentage text, the
-- pixel offset of an absorb overlay - asks for the fraction, gets nil while the
-- values are secret, and hides itself.
--------------------------------------------------------------------------------

local issecretvalue = _G.issecretvalue or function() return false end

--- True while the client is withholding unit values from addons at all. Only
--- used for messaging; the per-call check below is what logic relies on.
function Status:AreValuesSecret()
	if C_Secrets and C_Secrets.HasSecretRestrictions then
		local ok, restricted = pcall(C_Secrets.HasSecretRestrictions)
		return ok and restricted or false
	end
	return false
end

--- current, max, fraction.
--- `fraction` is nil when the client is hiding the numbers - callers that only
--- draw a bar can ignore that, callers that need arithmetic must check it.
function Status:GetHealth(unit)
	if not unit then return 0, 0, 0 end

	local current = UnitHealth(unit)
	local maximum = UnitHealthMax(unit)

	-- Must come before any comparison: `maximum <= 0` on a secret is an error.
	if issecretvalue(current) or issecretvalue(maximum) then
		return current, maximum, nil
	end

	current = current or 0
	maximum = maximum or 0
	if maximum <= 0 then return current, 0, 0 end

	return current, maximum, Util.Clamp(current / maximum, 0, 1)
end

--- incomingHeal, absorb, healAbsorb, secret.
--- Kept as separate numbers rather than a table: this runs for every button on
--- every health event and must not allocate.
--- `secret` is true when the client is withholding the amounts, in which case
--- the three numbers may be stored and passed on but never compared, formatted
--- or used in arithmetic.
function Status:GetPrediction(unit)
	if not unit then return 0, 0, 0, false end

	local incoming   = (UnitGetIncomingHeals    and UnitGetIncomingHeals(unit))    or 0
	local absorb     = (UnitGetTotalAbsorbs     and UnitGetTotalAbsorbs(unit))     or 0
	local healAbsorb = (UnitGetTotalHealAbsorbs and UnitGetTotalHealAbsorbs(unit)) or 0

	if issecretvalue(incoming) or issecretvalue(absorb) or issecretvalue(healAbsorb) then
		return incoming, absorb, healAbsorb, true
	end

	return incoming, absorb, healAbsorb, false
end

--- current, max, fraction, powerType (an Enum.PowerType value).
--- Same secret-value contract as GetHealth: fraction may be nil.
function Status:GetPower(unit)
	if not unit then return 0, 0, 0, nil end

	local powerType = UnitPowerType(unit)
	local current   = UnitPower(unit)
	local maximum   = UnitPowerMax(unit)

	if issecretvalue(current) or issecretvalue(maximum) then
		return current, maximum, nil, powerType
	end

	current = current or 0
	maximum = maximum or 0
	if maximum <= 0 then return current, 0, 0, powerType end

	return current, maximum, Util.Clamp(current / maximum, 0, 1), powerType
end

--------------------------------------------------------------------------------
-- Life state
--------------------------------------------------------------------------------

-- The English string is the translation key (see Core/Locale.lua), so this
-- table doubles as the list of status words a translator has to supply.
local STATUS_TEXT = {
	DEAD    = "Dead",
	GHOST   = "Ghost",
	OFFLINE = "Offline",
	FEIGN   = "Feign",
	PHASED  = "Phased",
	AFK     = "AFK",
	DND     = "DND",
}

--------------------------------------------------------------------------------
-- Secret booleans
--
-- The unit flag queries are secret-restricted the same way health is: inside
-- instances UnitIsAFK and friends hand back a value that may be stored and
-- passed on but NOT tested. `if UnitIsAFK(unit) then` therefore throws
--     attempt to perform boolean test on a secret boolean value
-- which is a hard error in the middle of a frame update, not a silent nil.
--
-- There is no way to ask what a secret holds, so "cannot know" has to collapse
-- to "do not claim it": the status word simply does not appear. That matches
-- what GetHealth already does when the numbers are withheld - it hides rather
-- than guesses.
--------------------------------------------------------------------------------

-- Shared with UI/UnitButton and Support/Support, which ask the same questions
-- about the same flags; see Core/Util.lua for why isFalse is not `not isTrue`.
local isTrue, isFalse = Util.IsTrue, Util.IsFalse

--- A phased ally cannot be healed, which makes this worth a frame indicator
--- even though the unit looks perfectly healthy.
local function isPhased(unit)
	if UnitPhaseReason then
		local reason = UnitPhaseReason(unit)
		-- Comparing a secret is an error too, not just testing one.
		if issecretvalue(reason) then return false end
		return reason ~= nil
	end

	-- Pre-shadowlands clients expressed the same thing inverted.
	local inPhase = _G.UnitInPhase
	if inPhase then
		return isFalse(inPhase(unit))
	end

	return false
end

--- Ordered most-severe first: a disconnected player's dead/AFK flags are stale,
--- and a feigning hunter reads as dead to the client, so both are tested before
--- the life checks they would otherwise be masked by.
function Status:GetStatus(unit)
	if not unit or not UnitExists(unit) then return nil end

	-- Every one of these can come back secret inside an instance; see the note
	-- above isTrue. Only "offline" uses isFalse, because it is the one whose
	-- meaning is inverted - a withheld connection state must not read as a
	-- disconnected player on every frame in the raid.
	if isFalse(UnitIsConnected(unit)) then return "OFFLINE" end
	if UnitIsFeignDeath and isTrue(UnitIsFeignDeath(unit)) then return "FEIGN" end
	if isTrue(UnitIsGhost(unit)) then return "GHOST" end
	if isTrue(UnitIsDead(unit)) then return "DEAD" end
	if isPhased(unit) then return "PHASED" end
	if UnitIsAFK and isTrue(UnitIsAFK(unit)) then return "AFK" end
	if UnitIsDND and isTrue(UnitIsDND(unit)) then return "DND" end

	return nil
end

function Status:GetStatusText(unit)
	local key = self:GetStatus(unit)
	if not key then return nil end
	return L[STATUS_TEXT[key]]
end

function Status:IsCharmed(unit)
	if not unit or not UnitIsCharmed then return false end
	return isTrue(UnitIsCharmed(unit))
end

--------------------------------------------------------------------------------
-- Incoming rez and summons
--------------------------------------------------------------------------------

function Status:GetResurrect(unit)
	if not unit or not UnitHasIncomingResurrection then return false end
	return isTrue(UnitHasIncomingResurrection(unit))
end

--- nil | "PENDING" | "ACCEPTED" | "DECLINED"
function Status:GetSummon(unit)
	if not unit then return nil end

	local api = C_IncomingSummon
	if not (api and api.HasIncomingSummon) then return nil end
	if not isTrue(api.HasIncomingSummon(unit)) then return nil end

	local summonEnum = Enum and Enum.SummonStatus
	if not (summonEnum and api.IncomingSummonStatus) then
		-- The unit has a summon but we cannot classify it; pending is the state
		-- that matters to a raid leader anyway.
		return "PENDING"
	end

	local status = api.IncomingSummonStatus(unit)
	if status == summonEnum.Pending then
		return "PENDING"
	elseif status == summonEnum.Accepted then
		return "ACCEPTED"
	elseif status == summonEnum.Declined then
		return "DECLINED"
	end

	return nil
end

--------------------------------------------------------------------------------
-- Threat and raid markers
--------------------------------------------------------------------------------

--- 0 = no threat, 3 = securely tanking. nil when the unit has no threat table,
--- and also nil when the client is withholding threat state - callers compare
--- this value, which a secret cannot survive.
function Status:GetThreat(unit)
	if not unit or not UnitThreatSituation then return nil end

	local situation = UnitThreatSituation(unit)
	if issecretvalue(situation) then return nil end

	return situation
end

function Status:GetRaidTargetIndex(unit)
	if not unit or not GetRaidTargetIndex then return nil end

	local index = GetRaidTargetIndex(unit)

	-- The marker index is one of the values the client can withhold. It cannot
	-- be range-checked, but SetRaidTargetIconTexture accepts it, so hand it
	-- straight through rather than dropping the marker entirely.
	if issecretvalue(index) then return index end

	if index and index >= 1 and index <= 8 then return index end
	return nil
end

--------------------------------------------------------------------------------
-- Ready check
--
-- GetReadyCheckStatus keeps answering after a check ends, so the module tracks
-- whether one is actually running. Blizzard leaves the result on screen for a
-- decay period after it finishes and converts unanswered players to "notready";
-- we mirror both so Boom's icons behave the way people already expect.
--------------------------------------------------------------------------------

local READY_CHECK_DECAY = 11

local readyCheckActive     = false
local readyCheckFinished   = false
local readyCheckGeneration = 0

--- nil | "ready" | "notready" | "waiting"
function Status:GetReadyCheck(unit)
	if not readyCheckActive or not unit or not GetReadyCheckStatus then return nil end

	local status = GetReadyCheckStatus(unit)
	if not status then return nil end

	if readyCheckFinished and status == "waiting" then
		return "notready"
	end

	return status
end

--------------------------------------------------------------------------------
-- Event routing
--------------------------------------------------------------------------------

-- Every event in this table delivers the affected unit token as its first
-- payload argument, so they all share one handler.
local UNIT_EVENTS = {
	UNIT_HEALTH                     = UPDATE.HEALTH,
	UNIT_MAXHEALTH                  = UPDATE.HEALTH,
	UNIT_ABSORB_AMOUNT_CHANGED      = UPDATE.HEALTH,
	UNIT_HEAL_ABSORB_AMOUNT_CHANGED = UPDATE.HEALTH,
	UNIT_HEAL_PREDICTION            = UPDATE.HEALTH,

	UNIT_POWER_FREQUENT             = UPDATE.POWER,
	UNIT_MAXPOWER                   = UPDATE.POWER,
	UNIT_DISPLAYPOWER               = UPDATE.POWER,

	UNIT_CONNECTION                 = UPDATE.STATUS,
	UNIT_FLAGS                      = UPDATE.STATUS,
	UNIT_PHASE                      = UPDATE.STATUS,
	PLAYER_FLAGS_CHANGED            = UPDATE.STATUS,
	INCOMING_RESURRECT_CHANGED      = UPDATE.STATUS,
	INCOMING_SUMMON_CHANGED         = UPDATE.STATUS,

	UNIT_THREAT_SITUATION_UPDATE    = UPDATE.THREAT,

	-- A vehicle swap changes which unit the button reads from, so nothing on it
	-- can be trusted until everything has been recomputed.
	UNIT_ENTERED_VEHICLE            = bor(UPDATE.VEHICLE, UPDATE.ALL),
	UNIT_EXITED_VEHICLE             = bor(UPDATE.VEHICLE, UPDATE.ALL),
}

-- While a player drives a vehicle, UI/UnitButton draws them from the matching
-- pet unit, so it is the pet's events that carry their real health. The button
-- is still registered under the owner token, hence this fixed lookup - a table
-- read costs nothing on the thousands of events we reject.
local PET_OWNER = { pet = "player" }
for i = 1, 40 do PET_OWNER["raidpet" .. i]  = "raid" .. i end
for i = 1, 4  do PET_OWNER["partypet" .. i] = "party" .. i end

local function onUnitEvent(self, event, unit)
	local frames = Boom.Frames
	if not frames then return end

	local flags = UNIT_EVENTS[event]
	local marked = false

	-- A pet token can have a button of its own (the pet panel) AND be the
	-- display unit of its owner's button at the same time. Both have to be
	-- dirtied: checking only the first would freeze the owner's frame the
	-- moment the pet panel is switched on.
	if frames:GetButtonsForUnit(unit) then
		Boom:MarkUnitDirty(unit, flags)
		marked = true
	end

	local owner = PET_OWNER[unit]
	if owner and frames:GetButtonsForUnit(owner) then
		Boom:MarkUnitDirty(owner, flags)
		marked = true
	end

	return marked
end

local function onReadyCheck()
	readyCheckActive     = true
	readyCheckFinished   = false
	readyCheckGeneration = readyCheckGeneration + 1
	Boom:MarkAllDirty(UPDATE.READY)
end

local function onReadyCheckConfirm(self, event, unit)
	if not Boom.Frames or not Boom.Frames:GetButtonsForUnit(unit) then return end
	Boom:MarkUnitDirty(unit, UPDATE.READY)
end

local function onReadyCheckFinished()
	if not readyCheckActive then return end

	readyCheckFinished = true
	Boom:MarkAllDirty(UPDATE.READY)

	-- Guarded by a generation stamp so a fresh check started during the decay
	-- window is not wiped by the previous check's timer.
	local generation = readyCheckGeneration
	Boom:After(READY_CHECK_DECAY, function()
		if generation ~= readyCheckGeneration then return end
		readyCheckActive   = false
		readyCheckFinished = false
		Boom:MarkAllDirty(UPDATE.READY)
	end)
end

local function onRaidTargetUpdate()
	Boom:MarkAllDirty(UPDATE.RAIDICON)
end

local function onTargetChanged()
	Boom:MarkAllDirty(UPDATE.TARGET)
end

--- Party-side online/offline, and the local player's own life state.
---
--- NONE of these carry a unit token, so they must not go in UNIT_EVENTS:
--- onUnitEvent would call GetButtonsForUnit(nil), find nothing and drop them
--- silently. They fire a handful of times per wipe rather than per frame, so
--- the raid-wide walk is not worth optimising away.
local function onGroupStateChanged()
	Boom:MarkAllDirty(UPDATE.STATUS)
end

function Status:OnEnable()
	for event in pairs(UNIT_EVENTS) do
		self:RegisterEvent(event, onUnitEvent)
	end

	self:RegisterEvent("READY_CHECK", onReadyCheck)
	self:RegisterEvent("READY_CHECK_CONFIRM", onReadyCheckConfirm)
	self:RegisterEvent("READY_CHECK_FINISHED", onReadyCheckFinished)
	self:RegisterEvent("RAID_TARGET_UPDATE", onRaidTargetUpdate)
	self:RegisterEvent("PLAYER_TARGET_CHANGED", onTargetChanged)

	self:RegisterEvent("PARTY_MEMBER_ENABLE",  onGroupStateChanged)
	self:RegisterEvent("PARTY_MEMBER_DISABLE", onGroupStateChanged)
	self:RegisterEvent("PLAYER_ALIVE",         onGroupStateChanged)
	self:RegisterEvent("PLAYER_UNGHOST",       onGroupStateChanged)
	self:RegisterEvent("PLAYER_DEAD",          onGroupStateChanged)

	-- Reloading in the middle of a ready check should still show the icons.
	if GetReadyCheckTimeLeft and (GetReadyCheckTimeLeft() or 0) > 0 then
		readyCheckActive = true
	end

	Boom:Debug("Status engine online")
end
