-- KiraUI-Plugin_ShiningLight.lua
-- Prot Paladin "WoG Charges" - simple icon mode (default) or an 8-segment bar.
--
-- v4: PURE BUFF TRACKING - no cast counting. The free WoG (327510) can be
-- granted by sources other than 3x Shield of the Righteous, so any cast
-- math is inherently wrong; the buffs themselves are the only truth:
--   * In combat: stack counts come from the UNIT_AURA payload (readable on
--     live 12.0 - the proven SIA path), with instance-ID lookups for stack
--     updates (pcall'd against the 12.1 hard-error).
--   * Out of combat: full re-sync from spell-ID lookups every 2s.
--   * Display timers run on OUR OWN clocks set at event time from the known
--     buff durations - plain arithmetic, no secret times ever read; the
--     real expirationTime overrides them whenever it is readable.
--
-- Icon mode: 182104's icon, stack number (position/size configurable) and an
-- engine Cooldown countdown INSIDE the icon; hides when charges expire/reset.
-- Bar mode: 8 segments left to right - banked free WoGs recolor 3 segments
-- each, charges continue after them - plus plain-second corner timers.

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

local CreateFrame = CreateFrame
local UIParent = UIParent
local GetTime = GetTime
local issecretvalue = issecretvalue
local pairs = pairs
local pcall = pcall
local mmin, mceil = math.min, math.ceil
local format = string.format

local GetPlayerAuraBySpellID = _G.C_UnitAuras and _G.C_UnitAuras.GetPlayerAuraBySpellID

-- File-local, NOT a global. As a global this name is up for grabs by any other
-- addon, and a collision would point the stack reader at a foreign frame.
local BigBuffIconFrame

local hasSL = GetPlayerAuraBySpellID and true or false
ns.hasShiningLight = hasSL
if not hasSL then return end

----------------------------------------------------------------------
-- Version gate: the AuraContainer frame type + CustomAuraContainerTemplate
-- this module is built on only exist on 12.1+. On older clients creating
-- them hard-errors and kills the whole file, so gate BEFORE any CreateFrame.
-- Settings/profiles are kept; the options page shows a notice (ns.SLDisabled)
-- and the feature turns on automatically once the client is 12.1+.
----------------------------------------------------------------------
local IS_121 = (select(4, _G.GetBuildInfo()) or 0) >= 120100
ns.SLDisabled = not IS_121

-- Protection Paladin check, evaluated at RUNTIME (a load-time class check
-- can misfire before player data is ready and silently kill the module).
-- If the spec index is SECRET (restricted), keep the displays on rather
-- than flickering them off mid-combat.
-- CLASS GATE.
--
-- UnitClass can hand back a SECRET string in restricted content, and comparing
-- a secret value RAISES. The raise happened inside the shared ticker, which
-- killed the whole update pass - so the bar simply stopped moving the moment
-- you zoned into a key, with no error pointing here.
--
-- Your class cannot change, so the answer is cached the first time it reads
-- plainly and never asked again. Until then, unknown means "keep running":
-- a gate that fails closed would hide the bar for a Paladin.
local isPaladin
local function IsPaladin()
	if isPaladin == nil then
		local ok, _, cls = pcall(_G.UnitClass, 'player')
		if not ok then return true end
		if issecretvalue and issecretvalue(cls) then return true end
		if cls then isPaladin = (cls == 'PALADIN') end
	end
	return isPaladin ~= false
end

local function IsProt()
	if not IsPaladin() then return false end
	local spec
	if _G.C_SpecializationInfo and _G.C_SpecializationInfo.GetSpecialization then
		local ok, v = pcall(_G.C_SpecializationInfo.GetSpecialization)
		if ok then spec = v end
	elseif _G.GetSpecialization then
		local ok, v = pcall(_G.GetSpecialization)
		if ok then spec = v end
	end
	if issecretvalue and issecretvalue(spec) then return true end
	return spec == 2
end

local FREE_ID   = 327510  -- Shining Light: free Word of Glory buff
local BASE_FREE_ID = 321136  -- Shining Light: free Word of Glory buff
local CHARGE_ID = 182104  -- building charges buff
local SEGMENTS  = 8
local FREE_VAL  = 3

local Round = ns.Round

-- debug logging (toggled from the options page; not saved)
local debugOn
local function Dbg(...) if debugOn then print('|cffff7d0aKiraUI-WoG|r', ...) end end
ns.SLIsDebug = function() return debugOn and true or false end
ns.SLToggleDebug = function()
	debugOn = not debugOn
	print('|cffff7d0aKiraUI-WoG|r debug ' .. (debugOn
		and 'ON - play normally and watch chat for buff/stack corrections'
		or 'OFF'))
	return debugOn
end

----------------------------------------------------------------------
-- Settings
----------------------------------------------------------------------
local DEFAULTS = {
	enable = false,
	alwaysShow = true,   -- keep the boxed bar visible when empty: stable layout
	lock = true,
	-- bar display
	barEnable = false,
	width = 348,
	height = 8,
	barX = 0,
	barY = -220,
	attachFrame = '',        -- frame name to sit on top of ('' = free placement)
	matchWidth = true,       -- adopt the attached frame's width
	barStrata = 'MEDIUM',    -- raise to HIGH/DIALOG to draw above CDM icons
	bgAlpha = 1,             -- opacity of the background (empty boxes + grid)
	freeColor = { 0.23, 0.78, 0.88, 1 },
	chargeColor = { 1, 0.9333, 0.4627, 1 },
	bgColor = { 0.2, 0.2, 0.2, 1 },  -- #333333
	textSize = 12,
	showTimers = true,
	-- internal timings (buff durations; not exposed in options)
	chargeDur = 30,
	freeDur = 30,
}

local function Migrate(cfg)
	-- The icon display was removed. Anyone whose only enabled display was the
	-- icon would otherwise be left with the feature on and nothing drawing, so
	-- they are moved onto the bar exactly once.
	if cfg.iconEnable ~= nil then
		if cfg.iconEnable and cfg.barEnable == false then cfg.barEnable = true end
		cfg.iconEnable, cfg.iconW, cfg.iconH = nil, nil, nil
		cfg.iconAttachFrame, cfg.iconAttachSide, cfg.iconAttachFallback = nil, nil, nil
		cfg.stackSize, cfg.stackX, cfg.stackY, cfg.durSize = nil, nil, nil, nil
		cfg.simpleIcon, cfg.iconSize = nil, nil
	end
	-- one-time flip: always-show became the default with the boxed segments,
	-- so an existing saved 'false' gets upgraded exactly once (still a normal
	-- toggle afterwards - turning it off again sticks)
	if not cfg.alwaysShowFlipped then
		cfg.alwaysShowFlipped = true
		cfg.alwaysShow = true
	end
end

local function GetCfg() return ns.ModuleCfg('shiningLight', DEFAULTS, Migrate) end
ns.SLGetCfg = GetCfg

local function BarTexture()
	local lib = ns.LSM()
	local tex = lib and lib:Fetch('statusbar', 'Clean', true)
	return tex or 'Interface\\Buttons\\WHITE8X8'
end

local function BarFont()
	local lib = ns.LSM()
	local font = lib and lib:Fetch('font', 'Expressway', true)
	-- the addon-wide font wins when one is set; Expressway stays the fallback,
	-- so 'Default (per module)' leaves this exactly as it was
	return ns.GetFont(font or _G.STANDARD_TEXT_FONT)
end

-- The engine's duration text reads "29 s"; the corner timers want plain "29".
-- Secrecy check FIRST: a secret string may not be matched/gsubbed, but it can
-- still go straight into SetText (sanctioned sink), so pass it through as-is.
local function PlainSeconds(txt)
	if issecretvalue and issecretvalue(txt) then return txt end
	if not txt then return '' end
	return txt:match('%d+') or txt
end

----------------------------------------------------------------------
-- The state machine (all plain numbers, all ours)
----------------------------------------------------------------------
local charge, freeN = 0, 0          -- 0..2 each
local chargeExpire, freeExpire = 0, 0
local preview
local RenderAll -- fwd

----------------------------------------------------------------------
-- Bar display (8 segments + corner timers)
----------------------------------------------------------------------
local frame, segs, textFree, textCharge
local ApplySettings -- fwd

local function EnsureFrame()
	if frame then return end
	frame = CreateFrame('Frame', 'KiraUIShiningLight', UIParent)
	frame:SetFrameStrata('MEDIUM')
	frame:SetClampedToScreen(true)
	frame:SetMovable(true)
	frame:EnableMouse(false)
	frame:RegisterForDrag('RightButton')
	frame:Hide()

	-- Outline as four 1px edge LINES + 1px ticks in the segment gaps. The old
	-- full black rect behind the bar made translucent empty cells blend toward
	-- BLACK when bgAlpha < 1; with lines only, empty cells blend with the game
	-- world - so bgAlpha 0.7 genuinely matches EllesmereUI bar backgrounds.
	local function edge()
		local t = frame:CreateTexture(nil, 'BACKGROUND')
		t:SetColorTexture(0, 0, 0, 1)
		return t
	end
	frame.edgeT, frame.edgeB, frame.edgeL, frame.edgeR = edge(), edge(), edge(), edge()
	frame.edgeT:SetPoint('BOTTOMLEFT', frame, 'TOPLEFT', -1, 0)
	frame.edgeT:SetPoint('BOTTOMRIGHT', frame, 'TOPRIGHT', 1, 0)
	frame.edgeT:SetHeight(1)
	frame.edgeB:SetPoint('TOPLEFT', frame, 'BOTTOMLEFT', -1, 0)
	frame.edgeB:SetPoint('TOPRIGHT', frame, 'BOTTOMRIGHT', 1, 0)
	frame.edgeB:SetHeight(1)
	frame.edgeL:SetPoint('TOPRIGHT', frame, 'TOPLEFT', 0, 1)
	frame.edgeL:SetPoint('BOTTOMRIGHT', frame, 'BOTTOMLEFT', 0, -1)
	frame.edgeL:SetWidth(1)
	frame.edgeR:SetPoint('TOPLEFT', frame, 'TOPRIGHT', 0, 1)
	frame.edgeR:SetPoint('BOTTOMLEFT', frame, 'BOTTOMRIGHT', 0, -1)
	frame.edgeR:SetWidth(1)

	frame.ticks = {}
	for i = 1, SEGMENTS - 1 do
		frame.ticks[i] = frame:CreateTexture(nil, 'BACKGROUND')
	end

	segs = {}
	for i = 1, SEGMENTS do
		segs[i] = frame:CreateTexture(nil, 'ARTWORK')
	end

	textFree = frame:CreateFontString(nil, 'OVERLAY')
	textFree:SetDrawLayer("BACKGROUND")
	textFree:SetPoint('LEFT', frame, 'LEFT', 4, 0)
	textFree:SetJustifyH('LEFT')
	textCharge = frame:CreateFontString(nil, 'OVERLAY')
	textCharge:SetDrawLayer("BACKGROUND")
	textCharge:SetPoint('RIGHT', frame, 'RIGHT', -4, 0)
	textCharge:SetJustifyH('RIGHT')

	-- pinned while it is riding another frame
	ns.MakeDraggable(frame, GetCfg, 'barX', 'barY', function() ApplySettings() end,
		function(cfg) return not cfg.lock and (cfg.attachFrame or '') == '' end)
	local acc = 0
	frame:SetScript('OnUpdate', function(_, elapsed)
		acc = acc + elapsed
		if acc < 0.1 then return end
		if chargeExpire > 0 then
			chargeExpire = chargeExpire - acc
		end
		if chargeExpire < 0 then
			charge = 0
			chargeExpire = 0
		end
		acc = 0
		if preview then return end
		local cfg = GetCfg()
		if cfg.showTimers then
			-- every hop is optional: Blizzard's frame may have no Cooldown, and a
			-- Cooldown may have no countdown fontstring yet
			local freeTxt = ''
			if freeN > 0 and BigBuffIconFrame and BigBuffIconFrame.Cooldown then
				local okT, fsCd = pcall(BigBuffIconFrame.Cooldown.GetCountdownFontString, BigBuffIconFrame.Cooldown)
				if okT and fsCd then freeTxt = fsCd:GetText() or '' end
			end
			textFree:SetText(freeTxt)
			textCharge:SetText(chargeExpire > 0 and PlainSeconds(tostring(chargeExpire)) or "")
		else
			-- clear stale numbers when the option is toggled off
			if textFree:GetText() then textFree:SetText('') end
			if textCharge:GetText() then textCharge:SetText('') end
		end
	end)
end

ApplySettings = function()
	EnsureFrame()
	local cfg = GetCfg()
	local w, h = cfg.width or 348, cfg.height or 8

	-- optional anchoring: sit on top of another frame (e.g. the EllesmereUI
	-- resource bar) so the bar stacks with the rest of the UI, no overlap
	local target
	if (cfg.attachFrame or '') ~= '' then
		local t = _G[cfg.attachFrame]
		if type(t) == 'table' and t.GetObjectType and t.GetWidth then target = t end
	end
	frame:ClearAllPoints()
	if target then
		frame:SetPoint('BOTTOM', target, 'TOP', cfg.barX or 0, cfg.barY or 2)
		if cfg.matchWidth then
			local tw = Round(target:GetWidth() or 0)
			if tw >= 50 then w = tw end
		end
	else
		frame:SetPoint('CENTER', UIParent, 'CENTER', cfg.barX or 0, cfg.barY or -220)
	end
	frame:SetSize(w, h)
	frame:SetFrameStrata(cfg.barStrata or 'MEDIUM')

	-- Background opacity fades the outline + gap ticks together with the empty
	-- cells; filled segments stay solid. No black rect behind the cells (see
	-- EnsureFrame), so lowering bgAlpha fades toward the world, not to black.
	local ba = cfg.bgAlpha or 1
	frame.edgeT:SetColorTexture(0, 0, 0, ba)
	frame.edgeB:SetColorTexture(0, 0, 0, ba)
	frame.edgeL:SetColorTexture(0, 0, 0, ba)
	frame.edgeR:SetColorTexture(0, 0, 0, ba)

	local gap = 1
	local segW = (w - gap * (SEGMENTS - 1)) / SEGMENTS
	for i = 1, SEGMENTS do
		local s = segs[i]
		s:ClearAllPoints()
		s:SetSize(segW, h)
		s:SetDrawLayer("BACKGROUND")
		s:SetPoint('LEFT', frame, 'LEFT', (i - 1) * (segW + gap), 0)
	end
	for i = 1, SEGMENTS - 1 do
		local t = frame.ticks[i]
		t:SetColorTexture(0, 0, 0, ba)
		t:ClearAllPoints()
		t:SetSize(gap, h)
		t:SetPoint('LEFT', frame, 'LEFT', i * segW + (i - 1) * gap, 0)
	end

	local font = BarFont()
	textFree:SetFont(font, cfg.textSize or 12, 'OUTLINE')
	textCharge:SetFont(font, cfg.textSize or 12, 'OUTLINE')
	textFree:SetTextColor(1, 1, 1)
	textCharge:SetTextColor(1, 1, 1)
end

local function RenderBar()
	if not frame then return end
	local cfg = GetCfg()
	if not cfg.barEnable or not cfg.enable or not (preview or IsProt()) then frame:Hide() return end
	local fc = cfg.freeColor or { 0.23, 0.78, 0.88, 1 }
	local cc = cfg.chargeColor or { 1, 0.9333, 0.4627, 1 }
	local bgc = cfg.bgColor or { 0.2, 0.2, 0.2, 1 }
	local filledFree = freeN * FREE_VAL
	for i = 1, SEGMENTS do
		local s = segs[i]
		if i <= filledFree then
			s:SetColorTexture(fc[1] or 0, fc[2] or 0.8, fc[3] or 1, fc[4] or 1)
		elseif i <= filledFree + charge then
			s:SetColorTexture(cc[1] or 1, cc[2] or 0.9, cc[3] or 0.4, cc[4] or 1)
		else
			-- empty segments stay visible as boxed cells (ticks between them),
			-- faded by the background opacity setting
			s:SetColorTexture(bgc[1] or 0.2, bgc[2] or 0.2, bgc[3] or 0.2, cfg.bgAlpha or 1)
		end
		s:Show()
	end
	if preview or (filledFree + charge) > 0 or cfg.alwaysShow then
		frame:Show()
	else
		frame:Hide()
	end
end

RenderAll = function()
	RenderBar()
end

----------------------------------------------------------------------
-- Apply / preview / diagnostics
----------------------------------------------------------------------
ns.SLApply = function()
	if ns.SLDisabled then
		if frame then frame:Hide() end
		return
	end
	ApplySettings()
	if preview then RenderAll() return end
	if GetCfg().enable then
		RenderAll()
	else
		charge, freeN, chargeExpire, freeExpire = 0, 0, 0, 0
		if frame then frame:Hide() end
	end
end

ns.SLIsPreview = function() return preview and true or false end
ns.SLSetPreview = function(state)
	if ns.SLDisabled then return end
	preview = state and true or false
	EnsureFrame()
	ApplySettings()
	frame:EnableMouse(preview)
	if preview then
		charge, freeN = 2, 1
		chargeExpire = GetTime() + (29)
		freeExpire = GetTime() + (29)
		RenderAll()
		textFree:SetText('12')
		textCharge:SetText('26')
	else
		-- PREVIEW WROTE FAKE VALUES INTO THE LIVE STATE.
		--
		-- Leaving preview only cleared the TEXT, so charge=2 / freeN=1 and the
		-- fake expiry timestamps stayed behind - the bar carried on drawing two
		-- banked charges and counting down a timer that was never real, until a
		-- reload wiped the locals. That is exactly why /reload "fixed" it.
		--
		-- Zero the state and repaint immediately; the 0.25s poll refills it from
		-- the real buffs on its next pass.
		charge, freeN, chargeExpire, freeExpire = 0, 0, 0, 0
		textFree:SetText('')
		textCharge:SetText('')
		RenderAll()
	end
end

ns.SLTest = function()
	local function p(...) print('|cffff7d0aKiraUI-WoG|r', ...) end
	p('--- WoG Charges diagnostics ---')
	for _, id in pairs({ FREE_ID, CHARGE_ID }) do
		local aura = GetPlayerAuraBySpellID(id)
		if not aura then
			p('lookup', id, '= NOTHING (buff absent, or restricted right now)')
		else
			local apps = aura.applications
			if issecretvalue and issecretvalue(apps) then
				p('lookup', id, '= aura found, applications = SECRET')
			else
				p('lookup', id, '= aura found, applications =', tostring(apps))
			end
		end
	end
	p('state: charges =', charge, '| free =', freeN,
		'| charge timer =', chargeExpire > 0 and format('%.1fs left', chargeExpire - GetTime()) or 'off',
		'| free timer =', freeExpire > 0 and format('%.1fs left', freeExpire - GetTime()) or 'off')
	local cfgT = GetCfg()
	p('bar =', cfgT.barEnable and 'on' or 'off',
		'| in combat =', _G.InCombatLockdown() and 'yes' or 'no')
	p('Run this IN COMBAT right after casting Shield of the Righteous, with live debug on, and send everything to Kira/Claude.')
end

----------------------------------------------------------------------
-- Events
----------------------------------------------------------------------
local evt = CreateFrame('Frame')
evt:RegisterEvent('PLAYER_ENTERING_WORLD')
evt:RegisterUnitEvent('UNIT_SPELLCAST_SUCCEEDED', 'player')
evt:RegisterUnitEvent('PLAYER_SPECIALIZATION_CHANGED', 'player')
evt:SetScript('OnEvent', function(_, event, unit, _, spellID)
	if preview or ns.SLDisabled then return end
	local cfg = GetCfg()
	if not cfg.enable then return end
	if event == 'PLAYER_SPECIALIZATION_CHANGED' then
		charge, freeN, chargeExpire, freeExpire = 0, 0, 0, 0
		RenderAll()
	elseif event == 'UNIT_SPELLCAST_SUCCEEDED' then
		if spellID == 53600 then
			if charge + 1 > 2 then
				charge = 0
				chargeExpire = 0
			elseif charge == 1 then
				charge = 2
				chargeExpire = 29.5
			else
				charge = 1
				chargeExpire = 29.5
			end
		end
	else -- PLAYER_ENTERING_WORLD
		ApplySettings()
		-- re-run once other addons (EllesmereUI) have built their frames
		C_Timer.After(2, function()
			ApplySettings()
			local viewer = _G.BuffIconCooldownViewer
			-- the viewer is Blizzard's CDM buff row: absent if the CDM has not
			-- been built yet, and indexing a nil there would abort this whole
			-- deferred pass
			-- no IsProt gate: finding the frame is harmless on any class, and
			-- gating it behind a spec read that can be nil this early just
			-- means the frame is never found
			if viewer and viewer.GetChildren then
				for _, iframe in ipairs({ viewer:GetChildren() }) do
					local spellID = (type(iframe.GetSpellID) == "function") and iframe:GetSpellID() or nil
					-- on other classes/restricted frames GetSpellID returns a
					-- SECRET number; comparing it with == hard-errors
					if spellID and not (issecretvalue and issecretvalue(spellID))
						and (spellID == FREE_ID or spellID == BASE_FREE_ID) then
						BigBuffIconFrame = iframe
					end
				end
			end
		end)
	end
end)

-- The 0.25s poll.
--
-- 'enable' is read HERE, on every tick, not once when this file loaded.
-- SavedVariables do not exist yet at file-load time, so a load-time read sees
-- the DEFAULTS - where enable is false - and a poll gated on that never starts
-- at all. That is what broke this bar. Reading it live is the only version of
-- this that cannot desync from the saved setting.
--
-- IsProt is deliberately NOT consulted here. It gates the RENDER path
-- (RenderBar) and that is enough; putting it in front of the
-- frame scan means one bad spec read stops the module finding its frame at
-- all, and it never looks again.
local function ShiningLightTick()
	if preview or ns.SLDisabled then return end
	local cfg = GetCfg()
	if not cfg.enable then return end
	local viewer = _G.BuffIconCooldownViewer
	if not BigBuffIconFrame and viewer and viewer.GetChildren then
		-- iterate the varargs directly rather than packing a throwaway table
		-- four times a second while the frame is still being searched for
		local n = select('#', viewer:GetChildren())
		for i = 1, n do
			local iframe = select(i, viewer:GetChildren())
			local spellID = (type(iframe.GetSpellID) == "function") and iframe:GetSpellID() or nil
			-- on other classes/restricted frames GetSpellID returns a SECRET
			-- number; comparing it with == hard-errors
			if spellID and not (issecretvalue and issecretvalue(spellID))
				and (spellID == FREE_ID or spellID == BASE_FREE_ID) then
				BigBuffIconFrame = iframe
			end
		end
	elseif BigBuffIconFrame and BigBuffIconFrame.Applications and BigBuffIconFrame.Applications.Applications then
		if BigBuffIconFrame:IsVisible() then
			if BigBuffIconFrame.Applications.Applications:GetText() then
				freeN = 2
			else
				freeN = 1
			end
		else
			freeN = 0
		end
	end
	RenderAll()
end

-- Always running, because the tick's first three lines already make a disabled
-- module free: two boolean tests and a table lookup, four times a second. That
-- is cheaper than the class of bug a start/stop gate invites, where the gate is
-- evaluated at a moment when the setting it reads does not exist yet.
C_Timer.NewTicker(0.25, ShiningLightTick)