-- KiraUI-Plugin_Trinkets.lua
-- Your two equipped trinkets as a small row of icons with cooldown swipes.
--
-- WHY THIS EXISTS SEPARATELY FROM THE COOLDOWN MANAGER
--
-- The CDM shows trinkets, but it decides for itself which ones and where. This
-- is a fixed two-slot row you can park on your player frame, filter down to
-- just the on-use trinket, and hand to Buff Watch as an anchor so a proc buff
-- can sit on the trinket that caused it.
--
-- YOUR OWN EQUIPMENT IS PLAIN
--
-- Item IDs, textures and equipped-item cooldowns are your own data and stay
-- readable everywhere, keys included - so this file compares them directly
-- rather than routing everything through secrecy guards. Same reasoning
-- Blizzard's own CooldownViewer uses for an equipped item.
--
-- The PROC overlay is different: it reads auras, so it goes through an engine
-- AuraContainer that never hands us aura data at all (see the bottom of this
-- file).

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

local CreateFrame = CreateFrame
local UIParent = UIParent
local GetTime = GetTime
local issecretvalue = issecretvalue
local pairs, ipairs, type, pcall = pairs, ipairs, type, pcall

ns.hasTrinkets = true

local function secret(v) return issecretvalue and issecretvalue(v) end
local Round = ns.Round

-- Equipment slot IDs. 13 and 14 are the two trinket slots and have been for
-- the entire life of the game.
local SLOTS = { 13, 14 }

-- AuraContainer and CustomAuraContainerTemplate are 12.1+. The trinket icons
-- themselves work on any client; only the proc overlay needs this.
local IS_121 = (select(4, _G.GetBuildInfo()) or 0) >= 120100
ns.TRProcDisabled = not IS_121

----------------------------------------------------------------------
-- Settings
----------------------------------------------------------------------
local DEFAULTS = {
	enable = false,
	lock = true,
	iconW = 40,
	iconH = 40,
	spacing = 4,
	grow = 'RIGHT',          -- RIGHT / LEFT
	-- Percent trimmed off EACH edge of the icon art. 0 shows the whole file
	-- including its built-in border; 8 is the crop used everywhere else in the
	-- addon; higher zooms further in.
	zoom = 8,
	durSize = 0,             -- 0 = auto from icon height
	durY = 0,                -- nudge the cooldown text up/down
	onUseOnly = true,        -- hide trinkets with no on-use effect
	hideOnCooldown = false,  -- hide a trinket while it is recharging
	desatOnCooldown = true,  -- grey the icon while it is recharging
	-- Proc overlay: buff IDs per trinket slot, as comma-separated text so a
	-- trinket that applies more than one aura can list them all.
	procIDs = { '', '' },
	procEnable = true,
	procDurSize = 0,         -- 0 = auto from icon height
	procDurY = 0,            -- nudge the proc timer up/down
	-- placement: ride a frame (your player frame) or sit at x/y
	attachFrame = 'EllesmereUIUnitFrames_Player',
	attachPoint = 'TOPRIGHT',     -- corner of the TARGET we hang from
	-- Corner of THIS ROW that gets pinned. 'AUTO' derives it from grow
	-- direction, which is what keeps the row collapsing towards the anchor
	-- rather than away from it; anything else is a deliberate override.
	attachSelfPoint = 'AUTO',
	attachX = 4,
	attachY = 0,
	attachFallback = true,
	strata = 'MEDIUM',
	x = 0,
	y = -60,
}

local function Migrate(cfg)
	-- one square size became separate width/height
	if cfg.iconSize then
		cfg.iconW, cfg.iconH = cfg.iconSize, cfg.iconSize
		cfg.iconSize = nil
	end
	-- crop on/off became a zoom percentage; the old 'on' was the 8% crop
	if cfg.crop ~= nil then
		cfg.zoom = cfg.crop and 8 or 0
		cfg.crop = nil
	end
end

local function GetCfg() return ns.ModuleCfg('trinkets', DEFAULTS, Migrate) end
ns.TRGetCfg = GetCfg

-- Zoom -> texture coordinates. A percentage trimmed off each edge, so 8 gives
-- the 0.08-0.92 crop used by every other icon in the addon and 0 shows the
-- whole file. Clamped below half, since 50 would collapse the icon to nothing.
local function ZoomCoords(cfg)
	local z = (cfg.zoom or 0) / 100
	if z < 0 then z = 0 elseif z > 0.45 then z = 0.45 end
	return z, 1 - z
end

-- Whole seconds, no unit suffix: '12', not '12 s'. Same NumericRuleFormatter
-- pattern the Buff Watch and WoG timers use. Built once and reused.
local trDurFmt
local function DurationTextOptions()
	if trDurFmt == nil then
		trDurFmt = false
		local CSU = _G.C_StringUtil
		if CSU and CSU.CreateNumericRuleFormatter then
			local f = CSU.CreateNumericRuleFormatter()
			if f and f.SetBreakpoints then
				local up = (_G.Enum and _G.Enum.NumericRuleFormatRounding
					and _G.Enum.NumericRuleFormatRounding.Up) or 1
				local ok = pcall(f.SetBreakpoints, f, {
					{ threshold = 0, format = '%d', components = { { step = 1, rounding = up } } },
				})
				if ok then trDurFmt = f end
			end
		end
	end
	-- old builds read options.formatter; the 2026-07 PTR build reads
	-- options.textFormatter (old key silently ignored) - pass both
	if trDurFmt then return { formatter = trDurFmt, textFormatter = trDurFmt } end
	if _G.C_AuraContainerUtil then return {} end
	return { textFormat = '%d' }
end

----------------------------------------------------------------------
-- Item reads, all secrecy-guarded
----------------------------------------------------------------------
local function SlotItemID(slot)
	local f = _G.GetInventoryItemID
	if not f then return nil end
	local ok, id = pcall(f, 'player', slot)
	if not ok or type(id) ~= 'number' then return nil end
	return id
end

local function SlotTexture(slot)
	local f = _G.GetInventoryItemTexture
	if not f then return nil end
	local ok, tex = pcall(f, 'player', slot)
	if not ok then return nil end
	return tex
end

-- Has an on-use effect? C_Item.GetItemSpell returns a spell for anything with
-- a "Use:" line and nothing for a purely passive stat stick. Item data can be
-- uncached for a moment after login, and "not loaded yet" reads the same as
-- "passive" - so an unreadable answer counts as on-use. Hiding a trinket you
-- might want to press is a worse failure than briefly showing a stat stick.
local function HasOnUse(itemID)
	if not itemID then return false end
	local f = (_G.C_Item and _G.C_Item.GetItemSpell) or _G.GetItemSpell
	if not f then return true end
	local ok, name, spellID = pcall(f, itemID)
	if not ok then return true end
	return (name ~= nil or spellID ~= nil)
end

----------------------------------------------------------------------
-- Frames
----------------------------------------------------------------------
local holder, buttons
local ApplySettings

-- How many trinkets are actually on screen. Buff Watch reads this so it can
-- stand in for an empty row rather than butt up against a 1px stub.
local shownCount = 0
ns.TRShownCount = function() return shownCount end

local function EnsureFrames()
	if holder then return end
	holder = CreateFrame('Frame', 'KiraUITrinkets', UIParent)
	holder:SetFrameStrata('MEDIUM')
	holder:SetClampedToScreen(true)
	holder:SetMovable(true)
	holder:EnableMouse(false)
	holder:RegisterForDrag('RightButton')
	holder:SetSize(40, 40)

	-- pinned while it is riding another frame
	ns.MakeDraggable(holder, GetCfg, 'x', 'y', function() ApplySettings() end,
		function(cfg) return not cfg.lock and (cfg.attachFrame or '') == '' end)

	buttons = {}
	for i, slot in ipairs(SLOTS) do
		local b = CreateFrame('Frame', 'KiraUITrinket' .. i, holder)
		b.slot = slot
		-- BORDERS ARE DRAWN INSIDE THE FRAME, and the icon is inset to leave
		-- them visible. This is the convention across the whole addon now.
		--
		-- Drawn OUTSIDE (the old TOPLEFT -1 / BOTTOMRIGHT +1 pattern) a frame's
		-- visible footprint is 1px larger than its size on every side, so every
		-- layout and every cross-module anchor is off by 2px - which is why a
		-- 1px gap between this row and Buff Watch needed an offset of -3.
		-- Inside, frame size == what you see, and spacing means what it says.
		b.border = b:CreateTexture(nil, 'BACKGROUND')
		b.border:SetColorTexture(0, 0, 0, 1)
		b.border:SetAllPoints()
		b.icon = b:CreateTexture(nil, 'ARTWORK')
		b.icon:SetPoint('TOPLEFT', 1, -1)
		b.icon:SetPoint('BOTTOMRIGHT', -1, 1)
		b.cd = CreateFrame('Cooldown', nil, b, 'CooldownFrameTemplate')
		b.cd:SetAllPoints()
		b.cd:SetDrawEdge(false)
		b.cd:SetDrawBling(false)
		b.cd:SetHideCountdownNumbers(false)
		b.cd.noCooldownCount = true
		b:Hide()
		buttons[i] = b
	end
end

----------------------------------------------------------------------
-- Placement. Same attach model as Buff Watch: ride a frame, fall back to a
-- free position when it is not there.
----------------------------------------------------------------------
local anchorMode
local function ApplyPosition(force)
	if not holder then return end
	local cfg = GetCfg()
	holder:SetFrameStrata(cfg.strata or 'MEDIUM')

	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

	local mode = 'free'
	if target then
		mode = 'attached'
		if cfg.attachFallback then
			local ok, shown = pcall(target.IsVisible, target)
			if not ok or not shown then mode = 'fallback' end
		end
	end
	-- the pinned edge depends on grow direction, so the cached "nothing changed"
	-- check has to include it - not just the attach mode
	local sig = mode .. '|' .. tostring(cfg.grow) .. '|' .. tostring(cfg.attachPoint)
		.. '|' .. tostring(cfg.attachSelfPoint)
	if not force and sig == anchorMode then return end
	anchorMode = sig

	-- WHICH EDGE OF OUR OWN ROW IS PINNED matters as much as where it is pinned.
	--
	-- A row collapses AWAY from its anchored edge. Anchor it by the left and an
	-- empty row still holds its old left edge, so anything attached to that side
	-- never moves - the trinkets vanish and the gap stays. The pinned edge must
	-- therefore be the one that should not move: growing right pins the left,
	-- growing left pins the right.
	local growLeft = (cfg.grow or 'RIGHT') == 'LEFT'
	local selfPt = cfg.attachSelfPoint
	if not selfPt or selfPt == 'AUTO' then
		selfPt = growLeft and 'TOPRIGHT' or 'TOPLEFT'
	end

	holder:ClearAllPoints()
	if mode == 'attached' then
		local ax, ay = cfg.attachX or 0, cfg.attachY or 0
		holder:SetPoint(selfPt, target, cfg.attachPoint or 'TOPRIGHT', ax, ay)
	else
		holder:SetPoint(growLeft and 'RIGHT' or 'LEFT', UIParent, 'CENTER', cfg.x or 0, cfg.y or -60)
	end
end

local followTicker
local function SyncFollowTicker()
	local cfg = GetCfg()
	local want = (cfg.attachFrame or '') ~= '' and cfg.attachFallback and true or false
	if want and not followTicker then
		followTicker = _G.C_Timer.NewTicker(0.25, function() ApplyPosition(false) end)
	elseif not want and followTicker then
		followTicker:Cancel()
		followTicker = nil
	end
end

----------------------------------------------------------------------
-- Refresh
----------------------------------------------------------------------
-- start, duration, enable - straight from the API, plain numbers.
-- 'Recharging' is endTime > now, exactly as Blizzard's CooldownViewer computes
-- it for an equipped item.
local function CooldownState(slot)
	local f = _G.GetInventoryItemCooldown
	if not f then return 0, 0, 0 end
	local ok, start, dur, enable = pcall(f, 'player', slot)
	if not ok then return 0, 0, 0 end
	return start or 0, dur or 0, enable or 0
end

local function OnCooldown(start, dur, enable)
	if enable ~= 1 then return false end
	if (start or 0) <= 0 or (dur or 0) <= 0 then return false end
	return (start + dur) > GetTime()
end

local function Refresh()
	if not holder then return end
	local cfg = GetCfg()
	if not cfg.enable then holder:Hide() return end

	local w, h = cfg.iconW or 40, cfg.iconH or 40
	local gap = cfg.spacing or 4
	local dsz = cfg.durSize or 0
	if dsz <= 0 then dsz = math.max(9, Round(h * 0.4)) end
	local z0, z1 = ZoomCoords(cfg)

	local shown = 0
	for _, b in ipairs(buttons) do
		local itemID = SlotItemID(b.slot)
		local visible = itemID ~= nil
		if visible and cfg.onUseOnly and not HasOnUse(itemID) then visible = false end

		local start, dur, enable = CooldownState(b.slot)
		local onCd = OnCooldown(start, dur, enable)
		if visible and cfg.hideOnCooldown and onCd then
			visible = false
		end

		if visible then
			b:SetSize(w, h)
			b.icon:SetTexture(SlotTexture(b.slot))
			b.icon:SetTexCoord(z0, z1, z0, z1)
			-- Greyed while recharging, colour the moment it is ready. Reads at a
			-- glance from the corner of your eye, where a swipe angle does not.
			-- Only meaningful when the icon stays visible on cooldown, so it
			-- pairs with 'hide while on cooldown' being off.
			b.icon:SetDesaturated(cfg.desatOnCooldown ~= false and onCd or false)
			if enable == 1 and start > 0 and dur > 0 then
				b.cd:SetCooldown(start, dur)
			else
				b.cd:Clear()
			end
			pcall(function()
				local f = b.cd:GetCountdownFontString()
				if f then
					local _, _, flags = f:GetFont()
					f:SetFont(ns.GetFont(), dsz, flags or 'OUTLINE')
					-- the engine re-centres this string on every cooldown
					-- change, so the nudge has to be re-applied here too
					f:ClearAllPoints()
					f:SetPoint('CENTER', b, 'CENTER', 0, cfg.durY or 0)
				end
			end)
			b:ClearAllPoints()
			local off = shown * (w + gap)
			if (cfg.grow or 'RIGHT') == 'LEFT' then
				b:SetPoint('TOPRIGHT', holder, 'TOPRIGHT', -off, 0)
			else
				b:SetPoint('TOPLEFT', holder, 'TOPLEFT', off, 0)
			end
			b:Show()
			shown = shown + 1
		else
			b:Hide()
		end
	end

	-- THE HOLDER TRACKS WHAT IS ACTUALLY SHOWN, AND STAYS SHOWN WHEN EMPTY.
	--
	-- It draws nothing itself, so leaving it visible costs nothing - and it is
	-- what lets anything anchored to this row slide into the gap. With no
	-- trinket displayed the row collapses to a hairline, so a Buff Watch
	-- container attached to its edge takes the space the trinkets would have
	-- occupied instead of leaving a hole.
	--
	-- Hiding it here would break that: an anchor chain through a hidden frame
	-- still resolves, but Buff Watch's own visibility fallback would see the
	-- target hidden and jump the row back to its free position.
	local w1 = (shown > 0) and (shown * w + (shown - 1) * gap) or 1
	holder:SetSize(w1, h)
	holder:Show()
	shownCount = shown
end


----------------------------------------------------------------------
-- Proc overlay: ONE container, one AURA SLOT per trinket
--
-- AddAuraSlot, not AddAuraGroup. Groups are laid out by the container's flow
-- layout and therefore COMPACT - which is why a group-based version could not
-- keep trinket 2's proc off trinket 1's icon. Slots are not laid out at all:
-- AddAuraSlot hands back the aura frame and its position is entirely ours, so
-- each one is simply pinned to the trinket it belongs to.
--
-- Verified in Blizzard_CustomAuraContainer.lua: AddAuraSlot returns auraFrame,
-- and every layout pass marks only AuraFrameLayoutGroups dirty - slot frames
-- are never touched by it.
--
-- No aura data is read anywhere here. The engine matches the whitelisted spell
-- IDs and fills the widgets itself, which is why it keeps working in a key.
----------------------------------------------------------------------
local procContainer
local procSlots = {}   -- index -> { frame, text, icon }

local function SlotKey(i) return 'trinket' .. i end

local function ParseIDs(str)
	local set, any = {}, false
	for num in tostring(str or ''):gmatch('%d+') do
		local n = tonumber(num)
		if n and n > 0 then set[n] = true any = true end
	end
	return set, any
end

local function ProcInitializer(index)
	return function(auraFrame)
		local cfg = GetCfg()
		local w, h = cfg.iconW or 40, cfg.iconH or 40
		auraFrame:SetSize(w, h)
		-- no hover tooltip: the trinket underneath owns that interaction
		pcall(auraFrame.SetMouseMotionEnabled, auraFrame, false)
		pcall(auraFrame.SetMouseClickEnabled, auraFrame, false)

		local border = auraFrame:CreateTexture(nil, 'BACKGROUND')
		border:SetColorTexture(0, 0, 0, 1)
		border:SetAllPoints()
		local icon = auraFrame:CreateTexture(nil, 'ARTWORK')
		icon:SetPoint('TOPLEFT', 1, -1)
		icon:SetPoint('BOTTOMRIGHT', -1, 1)
		local z0, z1 = ZoomCoords(cfg)
		icon:SetTexCoord(z0, z1, z0, z1)
		auraFrame:SetIcon(icon)

		local cd = CreateFrame('Cooldown', nil, auraFrame, 'CooldownFrameTemplate')
		cd:SetAllPoints()
		cd:SetReverse(true)
		cd:SetDrawEdge(false)
		cd:SetDrawBling(false)
		cd:SetHideCountdownNumbers(true)
		cd.noCooldownCount = true
		auraFrame:SetDurationCooldown(cd)

		local lvl = CreateFrame('Frame', nil, auraFrame)
		lvl:SetAllPoints()
		lvl:SetFrameLevel(cd:GetFrameLevel() + 5)
		local txt = lvl:CreateFontString(nil, 'OVERLAY')
		txt:SetPoint('CENTER', auraFrame, 'CENTER', 0, cfg.procDurY or 0)
		local dsz = cfg.procDurSize or 0
		if dsz <= 0 then dsz = math.max(9, Round(h * 0.4)) end
		txt:SetFont(ns.GetFont(), dsz, 'OUTLINE')
		-- number only, no ' s' suffix - the engine default appends a unit
		auraFrame:SetDurationText(txt, DurationTextOptions())

		procSlots[index] = procSlots[index] or {}
		procSlots[index].text = txt
		procSlots[index].icon = icon
	end
end

local function EnsureProcContainer()
	if not IS_121 or procContainer then return end
	procContainer = CreateFrame('AuraContainer', 'KiraUITrinketProcs', UIParent, 'CustomAuraContainerTemplate')
	procContainer:SetSize(1, 1)
	for i = 1, #SLOTS do
		procSlots[i] = procSlots[i] or {}
		-- [0] never matches. An EMPTY includeSpellIDs table reads as "no filter"
		-- and would let every buff you own through - the slot has no frame count
		-- to starve, so the filter is the only thing holding it shut.
		local okS, frame = pcall(procContainer.AddAuraSlot, procContainer, SlotKey(i), 'HELPFUL', {
			candidateFilters = { includeSpellIDs = { [0] = true } },
			initializeFrame = ProcInitializer(i),
		})
		if okS then procSlots[i].frame = frame end
	end
	procContainer:SetEnabled(false)
	procContainer:SetUnit('player')
end

local function ApplyProcs()
	if not IS_121 then return end
	EnsureProcContainer()
	if not procContainer then return end
	local cfg = GetCfg()
	local w, h = cfg.iconW or 40, cfg.iconH or 40
	local dsz = cfg.procDurSize or 0
	if dsz <= 0 then dsz = math.max(9, Round(h * 0.4)) end
	local z0, z1 = ZoomCoords(cfg)

	local anyOn = false
	for i = 1, #SLOTS do
		local rec = procSlots[i]
		local btn = buttons and buttons[i]
		local set, any = ParseIDs(cfg.procIDs and cfg.procIDs[i])
		local on = (cfg.enable and cfg.procEnable and any) and true or false
		if on then anyOn = true end

		pcall(procContainer.SetAuraSlotCandidateFilters, procContainer, SlotKey(i),
			{ includeSpellIDs = on and set or { [0] = true } })

		if rec and rec.frame and btn then
			-- the slot frame's position is ours: pin it straight onto its trinket
			pcall(function()
				rec.frame:ClearAllPoints()
				rec.frame:SetPoint('CENTER', btn, 'CENTER', 0, 0)
				rec.frame:SetSize(w, h)
				-- above the trinket so the proc covers its cooldown swipe
				rec.frame:SetFrameStrata(cfg.strata or 'MEDIUM')
				rec.frame:SetFrameLevel((btn:GetFrameLevel() or 1) + 10)
			end)
		end
		if rec then
			pcall(function()
				if rec.text then
					rec.text:SetFont(ns.GetFont(), dsz, 'OUTLINE')
					rec.text:ClearAllPoints()
					rec.text:SetPoint('CENTER', rec.frame, 'CENTER', 0, cfg.procDurY or 0)
				end
				if rec.icon then rec.icon:SetTexCoord(z0, z1, z0, z1) end
			end)
		end
	end

	procContainer:SetEnabled(anyOn)
	if anyOn then pcall(procContainer.UpdateAllAuras, procContainer) end
end

ApplySettings = function()
	EnsureFrames()
	ApplyPosition(true)
	SyncFollowTicker()
	Refresh()
	ApplyProcs()
end
ns.TRApply = ApplySettings

----------------------------------------------------------------------
-- Events
----------------------------------------------------------------------
local evt = CreateFrame('Frame')
evt:RegisterEvent('PLAYER_ENTERING_WORLD')
evt:RegisterEvent('PLAYER_EQUIPMENT_CHANGED')
evt:RegisterEvent('ACTIONBAR_UPDATE_COOLDOWN')
evt:RegisterUnitEvent('UNIT_INVENTORY_CHANGED', 'player')
evt:RegisterEvent('BAG_UPDATE_COOLDOWN')
evt:SetScript('OnEvent', function(_, event)
	if event == 'PLAYER_ENTERING_WORLD' then
		ApplySettings()
		-- re-anchor once other addons have built their frames
		_G.C_Timer.After(2, function() if ns.TRApply then ns.TRApply() end end)
		return
	end
	Refresh()
end)

----------------------------------------------------------------------
-- Diagnostic
----------------------------------------------------------------------
_G.SLASH_KIRATRINKET1 = '/kiratrinket'
_G.SlashCmdList.KIRATRINKET = function()
	local cfg = GetCfg()
	local function p(t) print('|cffff7d0aKiraTrinkets|r ' .. t) end
	p(('enabled: %s | on-use only: %s | hide on cooldown: %s'):format(
		cfg.enable and 'yes' or 'NO',
		cfg.onUseOnly and 'yes' or 'no',
		cfg.hideOnCooldown and 'yes' or 'no'))
	local t = (cfg.attachFrame or '') ~= '' and _G[cfg.attachFrame] or nil
	p(('attach: %s -> %s'):format(
		(cfg.attachFrame or '') ~= '' and cfg.attachFrame or 'none (free x/y)',
		(cfg.attachFrame or '') == '' and '-' or (t and 'found' or '|cffff5555NOT FOUND|r')))
	for i, slot in ipairs(SLOTS) do
		local id = SlotItemID(slot)
		p(('  trinket %d (slot %d): %s%s'):format(i, slot,
			id and ('itemID ' .. id) or 'empty / unreadable',
			id and (HasOnUse(id) and ' |cff00ff00on-use|r' or ' |cff888888passive|r') or ''))
	end
	if ns.TRProcDisabled then
		p('proc overlay: needs game version 12.1')
	else
		p(('proc overlay: %s'):format(cfg.procEnable and 'on' or 'off'))
		for i = 1, #SLOTS do
			local set, any = ParseIDs(cfg.procIDs and cfg.procIDs[i])
			local ids = {}
			for id in pairs(set) do ids[#ids + 1] = id end
			table.sort(ids)
			p(('  trinket %d buff IDs: %s'):format(i, any and table.concat(ids, ', ') or 'none set'))
		end
	end
end
