-- KiraUI-Plugin_Mage.lua
-- Mage: Alter Time health snapshot. When Alter Time (342245) is cast, shows
-- the health percentage you had AT THAT MOMENT - the value Alter Time will
-- return you to - and keeps it on screen until the Alter Time buff (342246)
-- is gone: it expired, you activated the return (342247), or you died.
--
-- Restriction-proof display: health values can be SECRET in restricted
-- combat. They are never compared - the percent math stays in secret space
-- and lands directly in SetFormattedText (the sanctioned format-sink, same
-- pattern as the Brewmaster dodge display). The buff check only acts on a
-- PLAIN nil (verifiably gone); a secret read keeps the display and the
-- 10.2s cap bounds the worst case (the buff lasts exactly 10s).

local addonName, ns = ...
local _G = _G

local _, playerClass = _G.UnitClass('player')
-- Options/display load for EVERYONE (so the Mage category is always visible,
-- like the Demon Hunter one); the Alter Time snapshot only actually runs when
-- you are a Mage - its event frame is gated on isMage further down.
local isMage = (playerClass == 'MAGE')
ns.hasMage = true

local CreateFrame = CreateFrame
local C_Timer = C_Timer
local GetTime = GetTime
local UnitHealth, UnitHealthMax = UnitHealth, UnitHealthMax
local issecretvalue = issecretvalue
local pairs = pairs
local pcall = pcall

local ALTER_TIME_CAST   = 342245  -- initial cast (NOT an aura - never a buff)
-- Casting 342245 applies TWO helpful auras on 12.1 (confirmed via /kiramage
-- watch: 342246 and 444754 both appear on cast and clear together). Either
-- present means Alter Time is armed, so both are checked, with a name-scan
-- self-heal covering any other build.
local ALTER_TIME_BUFF   = 342246  -- primary 10s buff aura
local ALTER_TIME_BUFF_2 = 444754  -- companion aura applied alongside on 12.1
local ALTER_TIME_RETURN = 342247  -- manual return activation
local MAX_WINDOW = 10.2           -- hard cap (buff duration + margin)

local DEFAULTS = {
	enable = false,
	lock = false,
	size = 24,
	x = 0,
	y = -180,
	-- anchor mode: ride the Alter Time BUFF icon in the Cooldown Manager
	-- (falls back to the free x/y position when the icon can't be found)
	anchorCDM = true,
	anchorX = 0,
	anchorY = 0,
}


local function GetCfg() return ns.ModuleCfg('mageAlterTime', DEFAULTS) end
ns.MageGetCfg = GetCfg

----------------------------------------------------------------------
-- Display frame
----------------------------------------------------------------------
local frame, preview
local activeUntil          -- GetTime() cap while a snapshot is armed
local armedAt = 0          -- grace so the buff check never races the cast
local capToken             -- identity token for the scheduled cap-hide
local ApplySettings        -- fwd
local TryAnchor            -- fwd (defined after ApplySettings)
local sawBuff              -- aura seen this window (guards the nil -> hide)

local function EnsureFrame()
	if frame then return end
	frame = CreateFrame('Frame', 'KiraUIAlterTimeHP', _G.UIParent)
	frame:SetSize(120, 40)
	frame:SetFrameStrata('MEDIUM')
	frame:SetClampedToScreen(true)
	frame:SetMovable(true)
	frame:EnableMouse(false)
	frame:RegisterForDrag('RightButton')
	frame:Hide()
	frame.text = frame:CreateFontString(nil, 'OVERLAY')
	frame.text:SetPoint('CENTER')
	ns.MakeDraggable(frame, GetCfg, 'x', 'y', function() ApplySettings() end)
end

ApplySettings = function()
	EnsureFrame()
	local cfg = GetCfg()
	frame.text:SetFont(ns.GetFont(), cfg.size or 24, 'OUTLINE')
	frame.text:SetTextColor(0.25, 0.78, 0.92, 1) -- mage class color
	frame:SetFrameStrata('MEDIUM')
	frame:ClearAllPoints()
	frame:SetPoint('CENTER', _G.UIParent, 'CENTER', cfg.x or 0, cfg.y or -180)
	frame:EnableMouse((preview and not cfg.lock) and true or false)
	if not cfg.enable and not preview then
		activeUntil = nil
		frame:Hide()
	end
	-- live snapshot while options change: keep it riding the icon
	if activeUntil and TryAnchor then TryAnchor() end
end
ns.MageApply = ApplySettings

-- Anchor mode: parent the text onto the CDM buff icon for Alter Time so it
-- rides the icon exactly (and inherits the icon's show/hide and bar alpha).
-- CDSFindBuffFrame comes from the CDM Changes module; the buff icon can
-- appear a beat AFTER the cast, so a short retry re-attaches once it exists.
TryAnchor = function()
	if not activeUntil then return false end
	local cfg = GetCfg()
	if not cfg.anchorCDM or not ns.CDSFindBuffFrame then return false end
	local icon = ns.CDSFindBuffFrame(ALTER_TIME_BUFF)
		or ns.CDSFindBuffFrame(ALTER_TIME_BUFF_2)
		or ns.CDSFindBuffFrame(ALTER_TIME_CAST)
	if not icon then return false end
	frame:SetParent(icon)
	frame:SetFrameLevel(icon:GetFrameLevel() + 30)
	frame:ClearAllPoints()
	frame:SetPoint('CENTER', icon, 'CENTER', cfg.anchorX or 0, cfg.anchorY or 0)
	return true
end

local function Detach()
	if not frame then return end
	frame:SetParent(_G.UIParent)
	ApplySettings() -- restores the free screen position + strata
end

local function HideSnapshot()
	activeUntil = nil
	if frame then
		if not preview then frame:Hide() end
		Detach()
	end
end

-- UnitHealthPercent returns a 0-1 FRACTION (0.88 -> "1%" with plain %.0f),
-- and multiplying a SECRET number errors - so the x100 scaling must happen
-- ENGINE-SIDE: the API's curve parameter returns curve(fraction). A linear
-- curve through (0,0) and (1,100) is exactly "multiply by 100".
local pctCurve -- built once; false = unavailable on this client
local function GetPctCurve()
	if pctCurve ~= nil then return pctCurve or nil end
	pctCurve = false
	-- Prefer Blizzard's sanctioned global scale curve (exactly what
	-- EllesmereUI feeds UnitHealthPercent): a linear 0->0, 1->100 curve that
	-- the engine is validated to evaluate on SECRET health values in combat.
	if _G.CurveConstants and _G.CurveConstants.ScaleTo100 then
		pctCurve = _G.CurveConstants.ScaleTo100
		return pctCurve
	end
	local CU = _G.C_CurveUtil
	if CU and CU.CreateCurve then
		local ok, c = pcall(function()
			local c = CU.CreateCurve()
			if c.SetType and _G.Enum and _G.Enum.LuaCurveType then
				c:SetType(_G.Enum.LuaCurveType.Linear)
			end
			c:AddPoint(0, 0)
			c:AddPoint(1, 100)
			return c
		end)
		if ok and c then pctCurve = c end
	end
	return pctCurve or nil
end

local function StartSnapshot()
	local cfg = GetCfg()
	if not cfg.enable or preview then return end
	ApplySettings()
	-- Health values are SECRET for addons here - even arithmetic on them
	-- errors. UnitHealthPercent + the x100 curve hands us a ready display
	-- value, and SetFormattedText is a sanctioned sink that renders secret
	-- numbers - so the value displays even in restricted combat.
	-- usePredicted=false: Alter Time stores your ACTUAL health.
	local shown = false
	local UHP = _G.UnitHealthPercent
	local curve = UHP and GetPctCurve()
	if UHP and curve then
		shown = pcall(function()
			frame.text:SetFormattedText('%.0f%%', UHP('player', false, curve))
		end)
	end
	if not shown then
		-- pre-12.1 client fallback: plain values, plain math
		shown = pcall(function()
			frame.text:SetFormattedText('%.0f%%',
				UnitHealth('player') / UnitHealthMax('player') * 100)
		end)
	end
	if not shown then frame.text:SetText('?') end
	armedAt = GetTime()
	activeUntil = armedAt + MAX_WINDOW
	frame:Show()
	-- the buff icon usually exists a moment after the cast: retry briefly,
	-- falling back to the free screen position if it never shows up
	if not TryAnchor() then
		C_Timer.After(0.3, TryAnchor)
	end
	-- hard cap: the buff lasts exactly 10s; if every aura read stayed
	-- secret the whole time, this still takes the display down on time
	local token = {}
	capToken = token
	C_Timer.After(MAX_WINDOW, function()
		if capToken == token then HideSnapshot() end
	end)
end

----------------------------------------------------------------------
-- Buff watch: this is BOTH the combat-safe trigger and the hide check.
-- The cast event's spellID is SECRET in restricted combat, so the cast
-- path never fires there - but the Alter Time buff (342246) appearing is
-- observable without any secret id, and it lands the instant you press, so
-- snapshotting health on buff-gain captures the same press-moment value.
----------------------------------------------------------------------
local GetPlayerAura = _G.C_UnitAuras and _G.C_UnitAuras.GetPlayerAuraBySpellID

-- The 342246 buff id is unverified against 12.1 spell data, so detection can
-- self-heal: scan the player's HELPFUL auras for one NAMED "Alter Time" and
-- cache its real spellId. (Names/ids are readable while unrestricted - e.g.
-- at a training dummy - which is enough to learn the id once; it then works
-- everywhere via GetPlayerAuraBySpellID.)
local resolvedBuffId
local function ScanForAlterTimeBuff()
	local AU = _G.AuraUtil
	if not (AU and AU.ForEachAura) then return nil end
	local found
	pcall(AU.ForEachAura, 'player', 'HELPFUL', nil, function(a)
		if a and type(a.name) == 'string' and a.name:lower():find('alter time') then
			found = a.spellId
			return true
		end
	end, true)
	if found then resolvedBuffId = found end
	return found
end

local function AuraById(id)
	if not (GetPlayerAura and id) then return nil, false end
	local ok, aura = pcall(GetPlayerAura, id)
	if not ok then return nil, false end
	return aura, true
end

-- Detection via AuraUtil.ForEachAura - the same enumerator the /kiramage watch
-- uses. Walk the player's HELPFUL auras and match Alter Time by spell id
-- (342246 / 444754) or, as a fallback, by name. This replaces the per-id
-- GetPlayerAuraBySpellID lookups with one scan.
--   returns true = present, false = verifiably gone, nil = unknown (no API).
-- Secret-safe: a secret spell id can't be compared, so such an aura is matched
-- by name instead; if both id and name are secret it is skipped (neither
-- confirms nor denies), and the MAX_WINDOW cap still bounds the display.
local TARGET_BUFFS = { [ALTER_TIME_BUFF] = true, [ALTER_TIME_BUFF_2] = true }
local function BuffPresent()
	local AU = _G.AuraUtil
	if not (AU and AU.ForEachAura) then return nil end
	local sawAny, found = false, false
	local ok = pcall(AU.ForEachAura, 'player', 'HELPFUL', nil, function(a)
		if not a then return end
		sawAny = true
		local id = a.spellId
		if id ~= nil and not (issecretvalue and issecretvalue(id)) and TARGET_BUFFS[id] then
			found = true
			return true -- stop iterating early
		end
		local nm = a.name
		if type(nm) == 'string' and nm:lower():find('alter time') then
			found = true
			return true
		end
	end, true)
	if not ok then return nil end
	if found then return true end
	if sawAny then return false end
	return nil
end

local function OnAuraUpdate()
	local cfg = GetCfg()
	if not cfg.enable or preview then return end
	local present = BuffPresent()
	if not activeUntil then
		-- not showing yet: START the moment the buff appears. No spellID
		-- needed, so this works even when the cast event is secret.
		if present == true then StartSnapshot() end
		return
	end
	-- already showing: take it down once the buff is VERIFIABLY gone
	local now = GetTime()
	if now >= activeUntil then HideSnapshot() return end
	if now - armedAt < 0.5 then return end -- buff application can lag the cast
	if present == false then HideSnapshot() end
end

----------------------------------------------------------------------
-- Preview
----------------------------------------------------------------------
ns.MageIsPreview = function() return preview and true or false end
ns.MageSetPreview = function(v)
	preview = v and true or false
	EnsureFrame()
	ApplySettings()
	if preview then
		frame.text:SetText('87%')
		frame:Show()
	elseif not activeUntil then
		frame:Hide()
	end
end

----------------------------------------------------------------------
-- Events (Mage only): the display and its options exist for everyone, but
-- the Alter Time snapshot logic only registers when you are actually a Mage.
----------------------------------------------------------------------
if isMage then
local evt = CreateFrame('Frame')
evt:RegisterUnitEvent('UNIT_SPELLCAST_SUCCEEDED', 'player')
evt:RegisterUnitEvent('UNIT_AURA', 'player')
evt:RegisterEvent('PLAYER_ENTERING_WORLD')
evt:RegisterEvent('PLAYER_DEAD')
evt:SetScript('OnEvent', function(_, event, _, _, arg3)
	if event == 'UNIT_SPELLCAST_SUCCEEDED' then
		-- secrecy check FIRST (own casts are normally plain)
		local sid = arg3
		if issecretvalue and issecretvalue(sid) then return end
		if sid == ALTER_TIME_CAST then
			StartSnapshot()
		elseif sid == ALTER_TIME_RETURN then
			HideSnapshot()
		end
	elseif event == 'UNIT_AURA' then
		OnAuraUpdate()
	else
		-- loading screen / death: the snapshot is moot either way
		HideSnapshot()
	end
end)
end -- if isMage

----------------------------------------------------------------------
-- Watch mode (armed by /kiramage): for 12s, log exactly what the game
-- exposes at cast time - the cast id (and whether it is secret) plus every
-- buff id on you - so the REAL Alter Time buff id is visible even when the
-- aura name is secret. The id that newly appears right after the cast IS
-- the buff to detect.
----------------------------------------------------------------------
local watchUntil = 0
local watchLastDump = 0
local watchFrame
local function DumpHelpfulIds(tag)
	local AU = _G.AuraUtil
	if not (AU and AU.ForEachAura) then
		print('|cff33ccffKiraMage|r ['..tag..'] AuraUtil.ForEachAura unavailable')
		return
	end
	local ids, secretNames = {}, 0
	pcall(AU.ForEachAura, 'player', 'HELPFUL', nil, function(a)
		if a then
			if a.spellId ~= nil then ids[#ids + 1] = tostring(a.spellId) end
			if type(a.name) ~= 'string' then secretNames = secretNames + 1 end
		end
	end, true)
	print('|cff33ccffKiraMage|r ['..tag..'] helpful ids:',
		(#ids > 0 and table.concat(ids, ', ') or '(none/secret)'),
		'| unreadable names:', secretNames)
end
local function MageWatchArm()
	watchUntil = GetTime() + 12
	if not watchFrame then
		watchFrame = CreateFrame('Frame')
		watchFrame:RegisterUnitEvent('UNIT_SPELLCAST_SUCCEEDED', 'player')
		watchFrame:RegisterUnitEvent('UNIT_AURA', 'player')
		watchFrame:SetScript('OnEvent', function(_, ev, _, _, sid)
			if GetTime() > watchUntil then return end
			if ev == 'UNIT_SPELLCAST_SUCCEEDED' then
				local isSecret = issecretvalue and issecretvalue(sid)
				print('|cff33ccffKiraMage|r CAST success | secret:', isSecret and 'YES' or 'no',
					'| id:', isSecret and '<secret>' or tostring(sid))
				DumpHelpfulIds('post-cast')
			else
				local now = GetTime()
				if now - watchLastDump >= 0.3 then
					watchLastDump = now
					DumpHelpfulIds('aura-change')
				end
			end
		end)
	end
	DumpHelpfulIds('before-cast')
end

----------------------------------------------------------------------
-- Diagnostic: /kiramage - reports every link in the Alter Time chain,
-- force-renders a 5s test display, and arms a 12s watch. Cast Alter Time
-- right after running it for the most useful output.
----------------------------------------------------------------------
SLASH_KIRAMAGE1 = '/kiramage'
SlashCmdList.KIRAMAGE = function()
	local function p(...) print('|cff33ccffKiraMage|r', ...) end
	local cfg = GetCfg()
	p('enable:', tostring(cfg.enable), '| preview:', tostring(preview),
		'| anchorCDM:', tostring(cfg.anchorCDM), '| showing now:', activeUntil and 'YES' or 'no')

	-- buff detection by the hard-coded id, plus a name scan to learn the real one
	local present = BuffPresent()
	p('buff id', ALTER_TIME_BUFF, 'present:',
		present == true and 'YES' or (present == false and 'no' or 'unknown (secret/no API)'))
	local disc = ScanForAlterTimeBuff()
	if disc then
		p('name scan found active "Alter Time" buff -> real id:', disc,
			disc ~= ALTER_TIME_BUFF and '|cffff6600(differs from '..ALTER_TIME_BUFF..'!)|r' or '(matches)')
	else
		p('name scan: no active "Alter Time" buff found (cast it first, stay unrestricted)')
	end

	-- health read test (the value that gets displayed)
	local UHP = _G.UnitHealthPercent
	local curve = UHP and GetPctCurve()
	p('UnitHealthPercent api:', UHP and 'yes' or 'NO', '| scale curve:', curve and 'yes' or 'NO')
	local ok, val = pcall(function() return UHP and curve and UHP('player', false, curve) end)
	local secret = (issecretvalue and val ~= nil and issecretvalue(val)) and 'yes' or 'no'
	p('health read ok:', tostring(ok), '| value secret:', secret)

	-- CDM anchor icon
	local icon = ns.CDSFindBuffFrame and (ns.CDSFindBuffFrame(ALTER_TIME_BUFF)
		or (disc and ns.CDSFindBuffFrame(disc)) or ns.CDSFindBuffFrame(ALTER_TIME_CAST))
	p('CDM buff icon found:', icon and 'YES' or 'no (will use free screen position)')

	-- force a visible test at the free position so we know the frame renders
	p('=> forcing a 5s TEST display at the free position (x='..(cfg.x or 0)..', y='..(cfg.y or -180)..')')
	EnsureFrame()
	frame:SetParent(_G.UIParent)
	frame.text:SetFont(ns.GetFont(), cfg.size or 24, 'OUTLINE')
	frame.text:SetTextColor(0.25, 0.78, 0.92, 1)
	if ok and val ~= nil then
		frame.text:SetFormattedText('%.0f%%', val)
	else
		frame.text:SetText('TEST')
	end
	frame:ClearAllPoints()
	frame:SetPoint('CENTER', _G.UIParent, 'CENTER', cfg.x or 0, cfg.y or -180)
	frame:SetFrameStrata('FULLSCREEN_DIALOG')
	frame:Show()
	local tok = {}
	capToken = tok
	C_Timer.After(5, function()
		if capToken == tok and not activeUntil then
			frame:SetFrameStrata('MEDIUM')
			if not preview then frame:Hide() end
			ApplySettings()
		end
	end)
	p('=> if you see a number/TEST in the screen centre, the display works and the issue is the trigger/anchor above')

	MageWatchArm()
	p('=> WATCHING 12s: cast Alter Time NOW - I will log the cast id and your buff ids so we find the real one')
end
