-- KiraUI-Plugin_Elixir.lua
-- Brewmaster displays: Expel Harm orbs and a dodge readout.
--
-- The file keeps its name for continuity - the saved-variable key is still
-- `elixirICD` and renaming it would orphan everyone's saved positions for no
-- gain. What it no longer contains is the Elixir of Determination ICD
-- tracker, removed on request.

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

local CreateFrame = CreateFrame
local GetTime = GetTime
local UIParent = UIParent
local issecretvalue = issecretvalue
local issecrettable = issecrettable
local pairs = pairs

ns.hasElixir = true


----------------------------------------------------------------------
-- Settings
----------------------------------------------------------------------
local DEFAULTS = {
	-- Expel Harm orbs (Gift of the Ox)
	orbEnable = false,
	orbSize = 12,           -- the WA's 30px texture was ~12px of visible orb
	orbRadius = 25,         -- arc radius: distance of the orbs from the center
	orbSpread = 120,        -- arc width in degrees (evenly spaced across it)
	orbColor = { 0, 1, 0.588, 1 },  -- WA green
	orbLock = false,
	orbX = 0,
	orbY = -4,              -- WA group offset: just above screen center / your char
	-- Dodge % display (Elusive Brawler makes dodge THE Brewmaster stat)
	dodgeEnable = false,
	dodgeCombatOnly = true, -- only show while in combat
	dodgeSize = 18,
	dodgeColor = { 0, 1, 0.588, 1 }, -- monk green
	dodgeLock = false,
	dodgeX = 0,
	dodgeY = -120,
}

local function Migrate(cfg)
	-- migrate the orbs' first-version default position (row far below center)
	-- to the WA arc position above the character
	if cfg.orbY == -260 then cfg.orbY = -4 end
	-- first version drew solid 30px circles (the WA's 30px texture is mostly
	-- transparent - its visible orb is ~12px); shrink old saved defaults
	if cfg.orbSize == 30 then cfg.orbSize = 12 end
end

local function GetCfg() return ns.ModuleCfg('elixirICD', DEFAULTS, Migrate) end
ns.ELGetCfg = GetCfg


----------------------------------------------------------------------
-- Expel Harm orbs (Gift of the Ox): the spell COUNT shown on Expel Harm
-- (115072) equals the number of orbs around you. Read via
-- GetSpellCastCount - plain player spell data, secret-guarded anyway.
-- 5 green orbs on black backing dots.
----------------------------------------------------------------------
local EXPEL_HARM = 322101 -- current Expel Harm spell ID (115072 is the legacy one)
local ORBS = 5
local GetCastCount = (_G.C_Spell and _G.C_Spell.GetSpellCastCount) or _G.GetSpellCount

local orbFrame, orbs, orbPreview
local ApplyOrbs, RenderOrbs -- fwd

local function IsBrew()
	local _, cls = _G.UnitClass('player')
	if issecretvalue and issecretvalue(cls) then return true end -- permissive
	if cls ~= 'MONK' then return false end
	local spec = _G.GetSpecialization and _G.GetSpecialization()
	if issecretvalue and issecretvalue(spec) then return true end
	return spec == 1 -- Brewmaster
end

-- Each orb is a tiny circular StatusBar. WHY: GetSpellCastCount is
-- SecretWhenCooldownsRestricted - in instanced combat it returns a SECRET
-- number that Lua may not compare. StatusBar:SetValue is a sanctioned sink
-- that accepts secrets: with SetMinMaxValues(i-1, i) the ENGINE clamps the
-- count, so orb i renders full exactly when count >= i - no Lua branching.
local function MakeOrbBar(parent, level)
	local b = CreateFrame('StatusBar', nil, parent)
	b:SetFrameLevel(parent:GetFrameLevel() + level)
	b:SetStatusBarTexture('Interface\\Buttons\\WHITE8x8')
	local m = b:CreateMaskTexture()
	m:SetTexture('Interface\\Masks\\CircleMaskScalable',
		'CLAMPTOBLACKADDITIVE', 'CLAMPTOBLACKADDITIVE')
	m:SetAllPoints(b) -- anchored to the BAR: the circle stays put while the fill slides under it
	b:GetStatusBarTexture():AddMaskTexture(m)
	return b
end

local function EnsureOrbs()
	if orbFrame then return end
	orbFrame = CreateFrame('Frame', 'KiraUIExpelHarmOrbs', UIParent)
	orbFrame:SetFrameStrata('MEDIUM')
	orbFrame:SetClampedToScreen(true)
	orbFrame:SetMovable(true)
	orbFrame:EnableMouse(false)
	orbFrame:RegisterForDrag('RightButton')
	orbFrame:Hide()
	orbs = {}
	for i = 1, ORBS do
		-- green orb with a 1px black ring (a black circle 2px bigger behind
		-- it). Both are threshold StatusBars fed the same (possibly secret)
		-- count, so they appear and vanish together - empty slots render
		-- NOTHING, and it works even when the count is secret in combat.
		local ring = MakeOrbBar(orbFrame, 1)
		ring:SetStatusBarColor(0, 0, 0, 1)
		ring:SetMinMaxValues(i - 1, i)
		local fill = MakeOrbBar(orbFrame, 2)
		fill:SetMinMaxValues(i - 1, i)
		orbs[i] = { ring = ring, fill = fill }
	end
	ns.MakeDraggable(orbFrame, GetCfg, 'orbX', 'orbY', function() ApplyOrbs() end,
		function(cfg) return not cfg.orbLock end)
end

-- Even arc over the character: 5 orbs spread across 'orbSpread' degrees,
-- centered on straight-up, all at 'orbRadius' px from the arc's center.
-- Even angle steps keep every orb-to-orb distance identical, whatever the
-- radius/spread the user picks. Defaults (25 / 120) = the WA look.
local mcos, msin, mrad = math.cos, math.sin, math.rad

ApplyOrbs = function()
	EnsureOrbs()
	local cfg = GetCfg()
	local sz = cfg.orbSize or 12
	local r = cfg.orbRadius or 25
	local span = cfg.orbSpread or 120
	local c = cfg.orbColor or { 0, 1, 0.588, 1 }
	local ymin = r * msin(mrad(90 - span / 2))
	local midY = (r + ymin) / 2 -- middle orb sits at 90 deg (y = r)
	orbFrame:SetSize(2 * r + sz + 2, (r - ymin) + sz + 2)
	orbFrame:ClearAllPoints()
	orbFrame:SetPoint('CENTER', UIParent, 'CENTER', cfg.orbX or 0, cfg.orbY or -4)
	local step = span / (ORBS - 1)
	for i = 1, ORBS do
		local a = mrad(90 + span / 2 - (i - 1) * step)
		local o = orbs[i]
		o.ring:SetSize(sz + 2, sz + 2) -- 1px black border all around
		o.ring:ClearAllPoints()
		o.ring:SetPoint('CENTER', orbFrame, 'CENTER', r * mcos(a), r * msin(a) - midY)
		o.fill:SetSize(sz, sz)
		o.fill:ClearAllPoints()
		o.fill:SetPoint('CENTER', o.ring, 'CENTER', 0, 0)
		o.fill:SetStatusBarColor(c[1] or 0, c[2] or 1, c[3] or 0.588, c[4] or 1)
		o.ring:Show()
		o.fill:Show()
	end
end

-- Safety net for missing events: some things (orb spawns included) may not
-- fire ANY registered event, so a light 0.2s poll keeps the count fresh.
-- One C call + engine-clamped SetValues per tick - effectively free.
local orbTicker
local function StopOrbTicker()
	if orbTicker then orbTicker:Cancel() orbTicker = nil end
end
local function StartOrbTicker()
	if orbTicker then return end
	orbTicker = _G.C_Timer.NewTicker(0.2, RenderOrbs)
end

RenderOrbs = function()
	if not orbFrame then return end
	local cfg = GetCfg()
	if not cfg.orbEnable or (not orbPreview and not IsBrew()) then
		orbFrame:Hide()
		StopOrbTicker()
		return
	end
	StartOrbTicker()
	local n
	if orbPreview then
		n = ORBS
	else
		n = GetCastCount and GetCastCount(EXPEL_HARM)
		-- secrecy check FIRST. A secret count is FINE here: it goes straight
		-- into StatusBar:SetValue (sanctioned sink) and the engine renders
		-- the right number of orbs. Only plain nil needs the 0 fallback.
		if not (issecretvalue and issecretvalue(n)) then
			n = n or 0
		end
	end
	for i = 1, ORBS do
		orbs[i].ring:SetValue(n)
		orbs[i].fill:SetValue(n)
	end
	orbFrame:Show() -- empty orbs draw nothing, so 0 stacks shows nothing
end

local orbEvt = CreateFrame('Frame')
-- SPELL_UPDATE_USES is THE event that fires when a spell's count changes -
-- it keeps Vengeance soul fragments live in combat with the
-- same GetSpellCastCount read. The others are belt-and-braces.
orbEvt:RegisterEvent('SPELL_UPDATE_USES')
orbEvt:RegisterEvent('SPELL_UPDATE_USABLE')
orbEvt:RegisterEvent('SPELL_UPDATE_COOLDOWN')
orbEvt:RegisterUnitEvent('UNIT_AURA', 'player')
orbEvt:RegisterEvent('PLAYER_SPECIALIZATION_CHANGED')
orbEvt:RegisterEvent('PLAYER_ENTERING_WORLD')
orbEvt:SetScript('OnEvent', function(_, event)
	if event == 'PLAYER_ENTERING_WORLD' or event == 'PLAYER_SPECIALIZATION_CHANGED' then
		ApplyOrbs()
	end
	RenderOrbs()
end)

----------------------------------------------------------------------
-- Dodge % display. GetDodgeChance is SecretWhenUnitStatsRestricted, but
-- string.format('%.2f', secret) renders fine and SetText is a sanctioned
-- sink (same pattern as the Secondary Stats module) - so the text stays
-- live even in restricted instanced combat.
----------------------------------------------------------------------
local dodgeFrame, dodgePreview
local ApplyDodge, RenderDodge -- fwd

local function EnsureDodge()
	if dodgeFrame then return end
	dodgeFrame = CreateFrame('Frame', 'KiraUIBrewDodge', UIParent)
	dodgeFrame:SetFrameStrata('MEDIUM')
	dodgeFrame:SetClampedToScreen(true)
	dodgeFrame:SetMovable(true)
	dodgeFrame:EnableMouse(false)
	dodgeFrame:RegisterForDrag('RightButton')
	dodgeFrame:SetSize(120, 32) -- drag hitbox; the text centers inside
	dodgeFrame:Hide()
	dodgeFrame.text = dodgeFrame:CreateFontString(nil, 'OVERLAY')
	dodgeFrame.text:SetPoint('CENTER')
	ns.MakeDraggable(dodgeFrame, GetCfg, 'dodgeX', 'dodgeY', function() ApplyDodge() end,
		function(cfg) return not cfg.dodgeLock end)
end

ApplyDodge = function()
	EnsureDodge()
	local cfg = GetCfg()
	local c = cfg.dodgeColor or { 0, 1, 0.588, 1 }
	dodgeFrame.text:SetFont(ns.GetFont(), cfg.dodgeSize or 18, 'OUTLINE')
	dodgeFrame.text:SetTextColor(c[1] or 0, c[2] or 1, c[3] or 0.588, c[4] or 1)
	dodgeFrame:ClearAllPoints()
	dodgeFrame:SetPoint('CENTER', UIParent, 'CENTER', cfg.dodgeX or 0, cfg.dodgeY or -120)
	dodgeFrame:EnableMouse(dodgePreview and true or false)
end

local dodgeInCombat = false

local dodgeTicker
local function StopDodgeTicker()
	if dodgeTicker then dodgeTicker:Cancel() dodgeTicker = nil end
end
local function StartDodgeTicker()
	if dodgeTicker then return end
	-- Elusive Brawler stacks move dodge on every hit taken - poll fast but
	-- cheap (one C call + a SetText) while the display is visible
	dodgeTicker = _G.C_Timer.NewTicker(0.2, function() RenderDodge() end)
end

RenderDodge = function()
	if not dodgeFrame then return end
	local cfg = GetCfg()
	local show = cfg.dodgeEnable and (dodgePreview or IsBrew())
	-- combat state comes from the REGEN events (dodgeInCombat), NOT from
	-- polling InCombatLockdown() alone: at the moment PLAYER_REGEN_DISABLED
	-- fires the lockdown flag can still read false, which used to hide the
	-- frame and kill the ticker - and nothing ever woke the display again.
	if show and not dodgePreview and cfg.dodgeCombatOnly
		and not (dodgeInCombat or _G.InCombatLockdown()) then
		show = false
	end
	if not show then
		dodgeFrame:Hide()
		StopDodgeTicker()
		return
	end
	-- pcall'd: restricted APIs can hard-error instead of returning secrets,
	-- and one error here would silently freeze the whole display
	local okRead, v = pcall(_G.GetDodgeChance)
	if okRead then
		-- secrecy check FIRST: only a plain value may use the 'or 0' fallback
		if not (issecretvalue and issecretvalue(v)) then v = v or 0 end
		-- SetFormattedText is 'AllowedWhenTainted': the ENGINE formats the
		-- (possibly secret) number for us
		pcall(dodgeFrame.text.SetFormattedText, dodgeFrame.text, '%.0f%%', v)
	end
	dodgeFrame:Show()
	StartDodgeTicker()
end

local dodgeEvt = CreateFrame('Frame')
dodgeEvt:RegisterEvent('PLAYER_REGEN_DISABLED')
dodgeEvt:RegisterEvent('PLAYER_REGEN_ENABLED')
dodgeEvt:RegisterEvent('PLAYER_SPECIALIZATION_CHANGED')
dodgeEvt:RegisterEvent('PLAYER_ENTERING_WORLD')
-- Midnight's explicit combat-state event (payload: inCombat) as belt-and-
-- braces; pcall'd in case an older client doesn't know it
pcall(dodgeEvt.RegisterEvent, dodgeEvt, 'PLAYER_IN_COMBAT_CHANGED')
dodgeEvt:SetScript('OnEvent', function(_, event, arg1)
	if event == 'PLAYER_REGEN_DISABLED' then
		dodgeInCombat = true
	elseif event == 'PLAYER_REGEN_ENABLED' then
		dodgeInCombat = false
	elseif event == 'PLAYER_IN_COMBAT_CHANGED' then
		-- secrecy check FIRST, then use the plain payload
		if not (issecretvalue and issecretvalue(arg1)) then
			dodgeInCombat = arg1 and true or false
		end
	else -- PLAYER_ENTERING_WORLD / PLAYER_SPECIALIZATION_CHANGED
		dodgeInCombat = _G.InCombatLockdown() and true or false
		ApplyDodge()
	end
	RenderDodge()
end)

-- The module's apply entry point. This used to chain onto the ICD tracker's
-- own apply; with that gone it is the whole of it.
ns.ELApply = function()
	ApplyOrbs()
	RenderOrbs()
	ApplyDodge()
	RenderDodge()
end
ns.ELOrbIsPreview = function() return orbPreview and true or false end
ns.ELOrbSetPreview = function(state)
	orbPreview = state and true or false
	EnsureOrbs()
	ApplyOrbs()
	orbFrame:EnableMouse(orbPreview)
	RenderOrbs()
end
ns.ELDodgeIsPreview = function() return dodgePreview and true or false end
ns.ELDodgeSetPreview = function(state)
	dodgePreview = state and true or false
	EnsureDodge()
	ApplyDodge()
	RenderDodge()
end
