-- KiraUI-Plugin_ReadyCheck.lua
-- Consumable bar on ready check: food, flask, augment rune and weapon oils.
--
-- Each slot shows the item icon, a RED X when you are missing that buff, and
-- how many of the item you are carrying. A count of 0 next to an X means you
-- cannot fix it yourself - which is the whole point of showing the number.
--
-- The icons are click-to-use. That is the only genuinely useful part: seeing
-- you have no flask two seconds before a pull does not help unless you can
-- apply one without opening bags.
--
-- 12.1 CONSTRAINTS THAT SHAPE THIS FILE
--   * SecureActionButtonTemplate frames cannot be CREATED or have their
--     attributes rewritten in combat. The whole bar is therefore built once at
--     load, and every attribute write is guarded by InCombatLockdown.
--   * Aura data can be secret in restricted content. Buff detection is wrapped
--     and treated as "cannot tell" rather than "missing", so the bar never
--     claims you are unbuffed when it simply cannot see.

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

local CreateFrame = CreateFrame
local InCombatLockdown = InCombatLockdown
local issecretvalue = issecretvalue
local pairs, ipairs, type, pcall = pairs, ipairs, type, pcall
local mfloor = math.floor

ns.hasReadyCheck = true

local function secret(v) return issecretvalue and issecretvalue(v) end

----------------------------------------------------------------------
-- Settings
----------------------------------------------------------------------
local DEFAULTS = {
	enable = false,
	iconSize = 40,
	spacing = 4,
	scale = 1,
	showFood = true,
	showFlask = true,
	showRune = true,
	showOilMH = true,
	showOilOH = false,
	hideWhenAllGood = false,
	x = 0,
	y = 160,
	lock = true,
	duration = 30,  -- max seconds to stay up; READY_CHECK_FINISHED hides it sooner
	showVariants = true,
	showTooltip = true,
	durationMigrated = false,
}

-- One-time migration. 'duration' used to mean "keep showing this long AFTER
-- the check ends", where 0 was the sensible default. It now means "maximum
-- time on screen", where 0 means NO limit - so an old saved 0 silently
-- became "never auto-hide". Flip it once; changing it back to 0 sticks.
local function Migrate(cfg)
	if not cfg.durationMigrated then
		cfg.durationMigrated = true
		if (cfg.duration or 0) == 0 then cfg.duration = 30 end
	end
end

local function GetCfg() return ns.ModuleCfg('readyCheck', DEFAULTS, Migrate) end
ns.RCGetCfg = GetCfg

----------------------------------------------------------------------
-- What counts as "buffed"
--
-- Food is matched by ICON (no list to maintain); flasks and runes by spell ID.
-- Every path is secrecy-guarded and returns nil rather than false when the
-- data cannot be read.
----------------------------------------------------------------------
-- FOOD: matched by the shared buff ICON, not by ID.
--
-- Every "Well Fed" buff in the game uses fileID 136000, so one icon comparison
-- catches every food including ones added after this was written - which is
-- why food needs no ID list to maintain, unlike flasks and runes. The name
-- check stays as a locale-independent backstop.
local FOOD_ICON = 136000
local WELL_FED_NAME = _G.WELL_FED or 'Well Fed'

-- FLASK buff IDs - Midnight.
local FLASK_IDS = {
	[1236763] = true, [1239355] = true, [1235057] = true, [1239755] = true,
	[1236767] = true, [1235111] = true, [1235110] = true, [1235108] = true,
}

-- AUGMENT RUNE buff IDs - Midnight only.
--
-- Older expansions' runes were removed deliberately: they are no longer usable
-- at current level, so a legacy one sitting in a bank tab would have counted
-- toward "you are covered" and hidden a genuinely missing rune.
local RUNE_IDS = {
	[1234969] = true, [1242347] = true, [1264426] = true,
}

-- Items counted in bags. First one you actually carry is what a click uses.
--
-- MIDNIGHT ONLY. Previous expansions' consumables were dropped on purpose: a
-- Dragonflight flask left in a bag would be counted as "you have one", so the
-- bar would read as covered while the buff it grants is useless at this level -
-- the exact opposite of what this bar is for. Anything here is current.
--
-- Midnight flasks come in two forms - the flask itself and a "can" variant -
-- so both sets are listed. Cans first so a click prefers them when held.
local FOOD_ITEMS  = { 242275, 245000, 244999 }
local FLASK_ITEMS = {
	245926, 245927, 245933, 245932, 245931, 245930, 245929, 245928,  -- cans
	241320, 241321, 241322, 241323, 241324, 241325, 241326, 241327,  -- flasks
}
local RUNE_ITEMS  = { 259085 }
local OIL_ITEMS   = { 243734 }

-- CLASS SAMPLES: one CONFIRMED-CURRENT item, used only to learn what item
-- class that kind of consumable lives in, so a future expansion's version is
-- found without a code change. Only food and flasks get one - their subclasses
-- contain essentially nothing else. Oils and runes sit in a generic consumable
-- class shared with dozens of unrelated items, so they match by ID alone.
local FOOD_SAMPLE  = { 242275 }  -- Royal Roast
local FLASK_SAMPLE = { 241324 }  -- Flask of the Blood Knights

-- Correct Midnight artwork, used only when neither the live buff nor a carried
-- item can supply an icon.
local RUNE_ICON = 4549099

----------------------------------------------------------------------
-- Aura + bag helpers
----------------------------------------------------------------------
-- Returns state, icon, expirationTime.
--   state true  = buff is up   (icon/expiry come from the LIVE aura)
--   state false = missing
--   state nil   = could not read (secret data) - never shown as a red X
--
-- Taking the icon from the aura itself is deliberate: hardcoding an icon per
-- slot goes stale every patch, which is how the augment rune ended up showing
-- the wrong art. Whatever buff you actually have is what gets drawn.
local function ReadAura(aura)
	if not aura then return nil end
	local icon, exp = aura.icon, aura.expirationTime
	if secret(icon) then icon = nil end
	if secret(exp) or type(exp) ~= 'number' then exp = nil end
	return true, icon, exp
end

local function ProbeBuff(idSet, nameMatch)
	local CUA = _G.C_UnitAuras
	if not CUA then return nil end

	if CUA.GetPlayerAuraBySpellID and idSet then
		for id in pairs(idSet) do
			local ok, aura = pcall(CUA.GetPlayerAuraBySpellID, id)
			if ok and aura then return ReadAura(aura) end
		end
	end

	-- name scan: only usable while aura data is readable
	if nameMatch and CUA.GetAuraDataByIndex then
		for i = 1, 40 do
			local ok, data = pcall(CUA.GetAuraDataByIndex, 'player', i, 'HELPFUL')
			if not ok then return nil end
			if not data then break end
			local n = data.name
			if secret(n) then return nil end
			if type(n) == 'string' and n:find(nameMatch, 1, true) then return ReadAura(data) end
		end
		return false
	end
	-- `idSet and false or nil` looks like "false when we had IDs, nil when we
	-- did not" but always evaluates to nil: `and false` makes the whole
	-- expression falsy, so `or nil` always wins. That is why the flask and
	-- rune slots reported "unreadable" forever - never a red X, never dimmed,
	-- even with no buff on you at all.
	if idSet then return false end
	return nil
end

-- Food, matched on the shared Well Fed icon. One scan catches every food in
-- the game, including ones that do not exist yet - no ID list to keep current.
local function ProbeFood()
	local CUA = _G.C_UnitAuras
	if not (CUA and CUA.GetAuraDataByIndex) then return nil end
	for i = 1, 60 do
		local ok, data = pcall(CUA.GetAuraDataByIndex, 'player', i, 'HELPFUL')
		if not ok then return nil end
		if not data then break end
		local ic = data.icon
		if not secret(ic) and ic == FOOD_ICON then return ReadAura(data) end
		local n = data.name
		if not secret(n) and type(n) == 'string'
			and n:find(WELL_FED_NAME, 1, true) then return ReadAura(data) end
	end
	return false
end

-- Can this inventory slot take a weapon oil at all?
--
-- Slot 17 holds an off-hand, but that can be a shield, a held-in-off-hand
-- trinket, or nothing - none of which can be oiled. Only a real WEAPON counts
-- (Enum.ItemClass.Weapon == 2), so a shield user or a two-hander never sees an
-- off-hand slot they cannot act on.
local INV_MAIN, INV_OFF = 16, 17
local function SlotTakesOil(invSlot)
	local getID = _G.GetInventoryItemID
	if not getID then return false end
	local ok, itemID = pcall(getID, 'player', invSlot)
	if not ok or not itemID or secret(itemID) then return false end
	local gii = _G.C_Item and _G.C_Item.GetItemInfoInstant
	if not gii then return true end -- cannot classify: assume usable
	local ok2, _, _, _, _, _, classID = pcall(gii, itemID)
	if not ok2 or type(classID) ~= 'number' then return true end
	local WEAPON = (_G.Enum and _G.Enum.ItemClass and _G.Enum.ItemClass.Weapon) or 2
	return classID == WEAPON
end

-- Weapon enchants come with their own remaining time, in milliseconds.
local function ProbeWeapon(offHand)
	local f = _G.GetWeaponEnchantInfo
	if not f then return nil end
	local ok, mh, mhMs, _, _, oh, ohMs = pcall(f)
	if not ok then return nil end
	local on = offHand and oh or mh
	local ms = offHand and ohMs or mhMs
	if secret(on) then return nil end
	if not on then return false end
	local exp
	if type(ms) == 'number' and ms > 0 and _G.GetTime then
		exp = _G.GetTime() + (ms / 1000)
	end
	return true, nil, exp
end

-- Uses the game's own GARRISON_DURATION_MINUTES ("%d min") so the unit is
-- localized rather than hardcoded English. Rounded UP, because 30 seconds
-- left should read "1 min", not "0".
local MIN_FMT = _G.GARRISON_DURATION_MINUTES or '%d min'
local function FormatRemaining(exp)
	if not exp or not _G.GetTime then return nil end
	local left = exp - _G.GetTime()
	if left <= 0 then return nil end
	if left < 60 then return mfloor(left + 0.5) .. 's' end
	return MIN_FMT:format(math.ceil(left / 60))
end

-- BAG SCAN BY ITEM CLASS.
--
-- A hardcoded list of item IDs goes stale every single patch, which is exactly
-- why the oil slot stopped finding anything: the list holds TWW and Dragonflight
-- oils and none of the current ones. Item CLASS does not go stale - an oil has
-- been Enum.ItemClass.ItemEnhancement for many expansions, whatever this one
-- names the item. So the known-ID lists stay as a PREFERENCE order, and the
-- scan is what guarantees the slot still finds something next patch.
-- LEARNING THE FILTER INSTEAD OF HARDCODING IT.
--
-- Item IDs rot every patch; enum guesses rot differently (I read weapon oils as
-- the ItemEnhancement class and matched a helm enchant). So neither is written
-- down. Each slot names ONE item it is sure about today, this asks the client
-- what class that item is, and anything in bags sharing that exact class pair
-- counts. Next expansion's oil is found with no code change, and a class this
-- code guessed wrong cannot be wrong for long because the sample defines it.
--
-- Resolution is LAZY and re-tried until it succeeds: item data is not
-- guaranteed to be ready when this file loads.
local function ClassPairs(sample)
	local gii = _G.C_Item and _G.C_Item.GetItemInfoInstant
	if not gii then return nil end
	local set, any = {}, false
	for _, id in ipairs(sample) do
		-- GetItemInfoInstant: id, type, subType, equipLoc, icon, classID, subClassID
		local ok, _, _, _, _, _, c, sc = pcall(gii, id)
		if ok and type(c) == 'number' and type(sc) == 'number' then
			set[c * 1000 + sc] = true
			any = true
		end
	end
	return any and set or nil
end

local function MakeAccept(sample, exclude)
	local learned
	return function(classID, subClassID, itemID)
		if type(classID) ~= 'number' or type(subClassID) ~= 'number' then return false end
		if exclude and exclude[itemID] then return false end
		learned = learned or ClassPairs(sample)
		if not learned then return false end
		return learned[classID * 1000 + subClassID] == true
	end
end

-- Walk every bag once and hand each distinct item to `accept`.
--
-- accept(classID, subClassID, itemID) -> true to include
-- skip is an optional set of item IDs already accounted for elsewhere.
local function ScanBags(accept, skip)
	local C = _G.C_Container
	local gii = _G.C_Item and _G.C_Item.GetItemInfoInstant
	local GIC = (_G.C_Item and _G.C_Item.GetItemCount) or _G.GetItemCount
	if not (C and C.GetContainerNumSlots and C.GetContainerItemID and gii and GIC) then return nil end
	local seen, out = {}, {}
	-- 0 = backpack, 1-4 = bags, 5 = reagent bag
	for bag = 0, 5 do
		local okN, slots = pcall(C.GetContainerNumSlots, bag)
		if okN and type(slots) == 'number' then
			for slot = 1, slots do
				local okI, id = pcall(C.GetContainerItemID, bag, slot)
				if okI and id and not secret(id) and not seen[id] and not (skip and skip[id]) then
					seen[id] = true
					local okC, _, _, _, _, _, classID, subClassID = pcall(gii, id)
					if okC and accept(classID, subClassID, id) then
						local okQ, n = pcall(GIC, id)
						if okQ and type(n) == 'number' and n > 0 then
							out[#out + 1] = { id = id, n = n }
						end
					end
				end
			end
		end
	end
	return out
end

-- Only the slots whose item class is NARROW get a scan.
--
-- "Flasks & Phials" and "Food & Drink" are subclasses that contain essentially
-- nothing else, so learning from a sample is safe there. Oils and augment runes
-- sit in a generic consumable class shared with dozens of unrelated items -
-- learning from those samples matched 47 "oils" and 88 "runes" and filled the
-- variant row with junk. For those two the explicit ID list IS the filter;
-- 243734 and 259085 are current, and a stale list is a smaller problem than a
-- bar that lies about what you are carrying.
local AcceptFood  = MakeAccept(FOOD_SAMPLE)
local AcceptFlask = MakeAccept(FLASK_SAMPLE)

-- Total carried across a candidate list, the first item actually held (so the
-- main click button has something concrete to use), and the full list of
-- DISTINCT items you are carrying.
--
-- That third return is what drives the variant row: carrying two different
-- flasks is a choice, and a single merged count hides it. Only items you
-- actually hold are listed, so the row is never longer than your bags justify.
local MAX_VARIANTS = 4
local function CountItems(list, accept)
	local GIC = (_G.C_Item and _G.C_Item.GetItemCount) or _G.GetItemCount
	if not GIC then return 0, nil, nil end
	local total, best, held = 0, nil, nil
	local known = {}
	for _, id in ipairs(list) do
		known[id] = true
		local ok, n = pcall(GIC, id)
		if ok and type(n) == 'number' and n > 0 then
			total = total + n
			if not best then best = id end
			held = held or {}
			held[#held + 1] = { id = id, n = n }
		end
	end
	-- anything of the right CLASS that the list has never heard of, so a new
	-- expansion's consumable works on day one with no code change
	if accept then
		local extra = ScanBags(accept, known)
		if extra then
			for _, e in ipairs(extra) do
				total = total + e.n
				if not best then best = e.id end
				held = held or {}
				held[#held + 1] = e
			end
		end
	end
	return total, best, held
end

----------------------------------------------------------------------
-- Slot definitions. Order here is the order on the bar.
----------------------------------------------------------------------
local SLOTS = {
	{ key = 'showFood',  id = 'food',  icon = 136000,
	  label = 'Food',       items = FOOD_ITEMS,  use = 'item', accept = AcceptFood,
	  has = function() return ProbeFood() end },

	{ key = 'showFlask', id = 'flask', icon = 3566840,
	  label = 'Flask',      items = FLASK_ITEMS, use = 'item', accept = AcceptFlask,
	  has = function() return ProbeBuff(FLASK_IDS, nil) end },

	{ key = 'showRune',  id = 'rune',  icon = RUNE_ICON,
	  label = 'Augment Rune', items = RUNE_ITEMS, use = 'item',
	  has = function() return ProbeBuff(RUNE_IDS, nil) end },

	-- Oils apply to a weapon slot, so the secure button needs target-slot:
	-- 16 = main hand, 17 = off hand.
	{ key = 'showOilMH', id = 'oilmh', icon = 463543,
	  label = 'Weapon Oil (MH)', items = OIL_ITEMS, use = 'item', targetSlot = '16',
	  available = function() return SlotTakesOil(INV_MAIN) end,
	  has = function() return ProbeWeapon(false) end },

	{ key = 'showOilOH', id = 'oiloh', icon = 463543,
	  label = 'Weapon Oil (OH)', items = OIL_ITEMS, use = 'item', targetSlot = '17',
	  available = function() return SlotTakesOil(INV_OFF) end,
	  has = function() return ProbeWeapon(true) end },
}

----------------------------------------------------------------------
-- Frame
----------------------------------------------------------------------
local bar, buttons, preview
local ApplySettings
local Dismiss -- fwd: the close button is built before the driver that hides the bar

-- Combat safety
--
-- The bar parents SecureActionButtons, which makes the bar itself protected:
-- Show, Hide, SetPoint, SetSize and EnableMouse on it OR on any of the buttons
-- are all blocked once combat starts, and attempting one raises
-- ADDON_ACTION_BLOCKED. Every such call in this file is therefore guarded, and
-- anything skipped is replayed on PLAYER_REGEN_ENABLED.
--
-- Visibility is the one thing that MUST still work in combat - a ready check
-- that cannot be dismissed sits on top of the screen for the rest of the pull.
-- It goes through a state driver instead: the driver evaluates inside the
-- secure environment, so it can show and hide a protected frame at any time,
-- and RegisterStateDriver itself is not a protected call.
local combatDirty = false
local function SetBarShown(show)
	if not bar then return end
	bar.wantShown = show and true or false
	if bar.stateDriven then
		_G.RegisterStateDriver(bar, 'kirarc', show and 'show' or 'hide')
	elseif not InCombatLockdown() then
		if show then bar:Show() else bar:Hide() end
	else
		combatDirty = true
	end
end

local function EnsureBar()
	if bar then return end
	-- SecureActionButtons cannot be created in combat. A ready check pulled
	-- mid-fight before the bar ever existed builds it the moment combat drops
	-- instead of raising a blocked action.
	if InCombatLockdown() then combatDirty = true return end
	-- SecureHandlerStateTemplate, so the state driver above can drive Show/Hide
	-- from inside the secure environment. Falls back to plain Show/Hide out of
	-- combat if the template is unavailable for any reason.
	local okB, made = pcall(CreateFrame, 'Frame', 'KiraUIReadyCheckBar', _G.UIParent,
		'SecureHandlerStateTemplate')
	if okB and made then
		bar = made
		bar.stateDriven = pcall(bar.SetAttribute, bar, '_onstate-kirarc', [[
			if newstate == 'show' then self:Show() else self:Hide() end
		]])
		if bar.stateDriven then
			_G.RegisterStateDriver(bar, 'kirarc', 'hide')
		end
	else
		bar = CreateFrame('Frame', 'KiraUIReadyCheckBar', _G.UIParent)
	end
	bar:SetFrameStrata('DIALOG')
	bar:SetClampedToScreen(true)
	bar:SetMovable(true)
	bar:EnableMouse(false)
	bar:RegisterForDrag('RightButton')
	SetBarShown(false)

	-- the preview is the "move me" mode, so it overrides the lock
	-- never draggable in combat: moving a frame that parents SecureActionButtons
	-- is protected, and StartMoving would raise the same blocked-action error
	ns.MakeDraggable(bar, GetCfg, 'x', 'y', function() ApplySettings() end,
		function(cfg) return (not InCombatLockdown()) and (not cfg.lock or preview) end)


	buttons = {}
	for i, def in ipairs(SLOTS) do
		-- SecureActionButtonTemplate: created ONCE, here, never in combat.
		local b = CreateFrame('Button', 'KiraUIReadyCheckBtn' .. i, bar, 'SecureActionButtonTemplate')
		b:RegisterForClicks('AnyUp', 'AnyDown')

		b.border = b:CreateTexture(nil, 'BACKGROUND')
		b.border:SetAllPoints()
		b.border:SetColorTexture(0, 0, 0, 1)

		b.icon = b:CreateTexture(nil, 'ARTWORK')
		b.icon:SetPoint('TOPLEFT', 1, -1)
		b.icon:SetPoint('BOTTOMRIGHT', -1, 1)
		b.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
		b.icon:SetTexture(def.icon)

		-- the red X, drawn over the icon when the buff is missing
		b.miss = b:CreateTexture(nil, 'OVERLAY', nil, 6)
		b.miss:SetTexture([[Interface\RaidFrame\ReadyCheck-NotReady]])
		b.miss:SetPoint('CENTER')
		b.miss:Hide()

		-- green tick when the buff IS up, so a good slot reads at a glance
		b.ok = b:CreateTexture(nil, 'OVERLAY', nil, 6)
		b.ok:SetTexture([[Interface\RaidFrame\ReadyCheck-Ready]])
		b.ok:SetPoint('CENTER')
		b.ok:Hide()

		-- remaining time, above the icon
		-- Both texts are OUTLINEd: they sit on top of busy icon art, and a
		-- plain font disappears against a light consumable icon.
		b.time = b:CreateFontString(nil, 'OVERLAY', 'GameFontNormalSmall')
		b.time:SetPoint('BOTTOM', b, 'TOP', 0, 2)

		b.count = b:CreateFontString(nil, 'OVERLAY', 'GameFontNormalSmall')
		b.count:SetPoint('BOTTOMRIGHT', 2, -1)

		-- VARIANT ROW: up to MAX_VARIANTS small buttons hanging under the icon,
		-- one per distinct item of this type in your bags.
		--
		-- Built here and never in combat, same as the parent - they are real
		-- SecureActionButtons so each one can use its own specific item.
		-- They live OUTSIDE the bar's height on purpose: the bar keeps the
		-- exact size it had before, so turning this on never nudges the bar.
		b.vars = {}
		for j = 1, MAX_VARIANTS do
			local v = CreateFrame('Button', 'KiraUIReadyCheckBtn' .. i .. 'v' .. j, b,
				'SecureActionButtonTemplate')
			v:RegisterForClicks('AnyUp', 'AnyDown')
			v:Hide()

			v.border = v:CreateTexture(nil, 'BACKGROUND')
			v.border:SetAllPoints()
			v.border:SetColorTexture(0, 0, 0, 1)

			v.icon = v:CreateTexture(nil, 'ARTWORK')
			v.icon:SetPoint('TOPLEFT', 1, -1)
			v.icon:SetPoint('BOTTOMRIGHT', -1, 1)
			v.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

			v.count = v:CreateFontString(nil, 'OVERLAY', 'GameFontNormalSmall')
			v.count:SetPoint('BOTTOMRIGHT', 2, -1)

			v:SetScript('OnEnter', function(self)
				if not self.itemID or not GetCfg().showTooltip then return end
				_G.GameTooltip:SetOwner(self, 'ANCHOR_BOTTOM')
				pcall(_G.GameTooltip.SetItemByID, _G.GameTooltip, self.itemID)
				_G.GameTooltip:Show()
			end)
			v:SetScript('OnLeave', function() _G.GameTooltip:Hide() end)

			b.vars[j] = v
		end

		b.def = def
		b:SetScript('OnEnter', function(self)
			if not GetCfg().showTooltip then return end
			_G.GameTooltip:SetOwner(self, 'ANCHOR_TOP')
			_G.GameTooltip:AddLine(self.def.label, 1, 1, 1)
			if self.itemID and _G.C_Item and _G.C_Item.GetItemNameByID then
				local ok, nm = pcall(_G.C_Item.GetItemNameByID, self.itemID)
				if ok and nm then _G.GameTooltip:AddLine(nm, 0.8, 0.8, 0.8) end
			end
			if self.state == false then
				_G.GameTooltip:AddLine('Missing - click to use', 1, 0.3, 0.3)
			elseif self.state == true then
				_G.GameTooltip:AddLine('Active', 0.3, 1, 0.3)
			else
				_G.GameTooltip:AddLine('Cannot read aura data here', 0.7, 0.7, 0.7)
			end
			if (self.have or 0) == 0 then
				_G.GameTooltip:AddLine('None in bags', 1, 0.3, 0.3)
			end
			_G.GameTooltip:Show()
		end)
		b:SetScript('OnLeave', function() _G.GameTooltip:Hide() end)

		buttons[i] = b
	end

	-- Dismiss button: a small red cross on the top-right corner.
	--
	-- It carries its OWN mouse handling rather than relying on the bar's. The
	-- bar is deliberately click-through outside preview (EnableMouse is only
	-- switched on while placing it), and a child button is unaffected by that,
	-- so the cross stays clickable without making the whole bar swallow clicks.
	--
	-- Built LAST and inside a pcall, deliberately. An earlier version put it
	-- before the slot buttons and called SetText on a fontstring that had no
	-- font yet - which raises "Font not set", aborting EnsureBar after `bar` was
	-- already assigned. Every later call then saw `bar` set, returned early, and
	-- `buttons` stayed nil forever. A cosmetic extra must never be able to take
	-- the functional part of this frame with it.
	pcall(function()
		local close = CreateFrame('Button', nil, bar)
		close:SetSize(16, 16)
		close:EnableMouse(true)
		-- anchored by its CENTRE to the bar's TOPRIGHT so it sits on the corner
		-- itself; a point anchor survives the bar resizing every refresh
		close:SetPoint('CENTER', bar, 'TOPRIGHT', 1, 1)
		close:SetFrameLevel(bar:GetFrameLevel() + 10)
		-- template first: a fontstring with no font raises on SetText
		close.text = close:CreateFontString(nil, 'OVERLAY', 'GameFontNormal')
		close.text:SetPoint('CENTER')
		close.text:SetText('x')
		close.text:SetTextColor(0.85, 0.15, 0.15)
		close:SetScript('OnEnter', function(self) self.text:SetTextColor(1, 0.35, 0.35) end)
		close:SetScript('OnLeave', function(self) self.text:SetTextColor(0.85, 0.15, 0.15) end)
		close:SetScript('OnClick', function()
			-- Works in combat: Dismiss hides through the state driver, which is
			-- evaluated inside the secure environment. The old version bailed
			-- out under InCombatLockdown and left the bar stuck on screen for
			-- the rest of the pull.
			if Dismiss then Dismiss() end
		end)
		bar.close = close
	end)
end

----------------------------------------------------------------------
-- Arming a secure button
--
-- The 'item' attribute wants the item NAME. "item:12345" does not resolve for
-- a use-on-slot action, which is why clicking an oil silently did nothing even
-- with the item sitting in bags. If the name is not cached yet the button is
-- left inert and the data is requested, so GET_ITEM_INFO_RECEIVED arms it a
-- moment later rather than leaving a button that looks live but does nothing.
----------------------------------------------------------------------
local function Arm(btn, itemID, targetSlot)
	if InCombatLockdown() then return end
	local itemName
	if itemID then
		local gn = (_G.C_Item and _G.C_Item.GetItemNameByID) or _G.GetItemInfo
		if gn then
			local okN, nm = pcall(gn, itemID)
			if okN and type(nm) == 'string' and nm ~= '' then itemName = nm end
		end
	end
	if itemName then
		btn:SetAttribute('type', 'item')
		btn:SetAttribute('item', itemName)
		btn:SetAttribute('target-slot', targetSlot)  -- 16 MH / 17 OH, nil if consumed
	else
		btn:SetAttribute('type', nil)
		btn:SetAttribute('item', nil)
		btn:SetAttribute('target-slot', nil)
		if itemID and _G.C_Item and _G.C_Item.RequestLoadItemDataByID then
			pcall(_G.C_Item.RequestLoadItemDataByID, itemID)
		end
	end
	btn.itemID, btn.itemName = itemID, itemName
end

----------------------------------------------------------------------
-- Refresh
----------------------------------------------------------------------
local function Refresh()
	if not bar then return end
	local cfg = GetCfg()
	local sz, gap = cfg.iconSize or 40, cfg.spacing or 4
	local shown, allGood = 0, true
	-- In combat every geometry and visibility call on the secure buttons is
	-- blocked. Icons, counts and timers are plain textures/fontstrings and stay
	-- live; only the layout freezes, and it is redone the moment combat ends.
	local locked = InCombatLockdown()
	if locked then combatDirty = true end
	-- text scales with the icon so a big bar does not get tiny labels
	local font = ns.GetFont()
	local countSize = math.max(9, mfloor(sz * 0.34 + 0.5))
	local timeSize  = math.max(9, mfloor(sz * 0.30 + 0.5))

	for i, def in ipairs(SLOTS) do
		local b = buttons[i]
		local on = cfg[def.key] and true or false
		-- a slot that cannot apply here is hidden outright, not shown as a
		-- failure you have no way to fix (no off-hand, or a shield)
		if on and def.available and not preview and not def.available() then
			on = false
		end
		if not on then
			if not locked then
				b:Hide()
				if b.vars then for _, v in ipairs(b.vars) do v:Hide() end end
			end
		else
			local state, auraIcon, exp, have, itemID, held
			if preview then
				state = (i % 2 == 0)
				have = (i == 3) and 0 or (i * 7)
				itemID = def.items[1]
				exp = state and (_G.GetTime() + 60 * (10 + i * 7)) or nil
				-- fake a second flask so the variant row can be seen and
				-- positioned without having to actually carry two
				if def.id == 'flask' and def.items[2] then
					held = { { id = def.items[1], n = 3 }, { id = def.items[2], n = 2 } }
				end
			else
				state, auraIcon, exp = def.has()
				have, itemID, held = CountItems(def.items, def.accept)
			end
			b.state, b.have, b.itemID = state, have, itemID

			-- ICON, best source first: the live buff, then whatever item you are
			-- actually carrying, then the built-in default. Deriving it means a
			-- new patch's consumable draws correctly with no code change - the
			-- hardcoded default is only a last resort.
			local icon = auraIcon
			if not icon and itemID and _G.C_Item and _G.C_Item.GetItemIconByID then
				local okI, ic = pcall(_G.C_Item.GetItemIconByID, itemID)
				if okI and ic and not secret(ic) then icon = ic end
			end
			b.icon:SetTexture(icon or def.icon)

			b.miss:SetShown(state == false)
			b.miss:SetSize(sz * 0.9, sz * 0.9)
			b.ok:SetShown(state == true)
			b.ok:SetSize(sz * 0.55, sz * 0.55)
			b.icon:SetDesaturated(state == false)
			if state == false then allGood = false end

			local left = FormatRemaining(exp)
			b.time:SetText(left or '')
			if left then
				-- amber under 5 minutes: long enough to notice, short enough to act
				local mins = (exp - _G.GetTime()) / 60
				if mins <= 5 then b.time:SetTextColor(1, 0.5, 0.2)
				else b.time:SetTextColor(0.9, 0.9, 0.9) end
			end

			-- always a number, including 0 - "none in bags" is the single most
			-- useful thing this bar tells you
			local n = have or 0
			b.count:SetText(tostring(n))
			if n > 0 then
				b.count:SetTextColor(1, 1, 1)
			else
				b.count:SetTextColor(1, 0.25, 0.25)
			end

			-- Secure attributes: never rewritten in combat. Out of combat the
			-- button is pointed at the first matching item actually carried.
			Arm(b, itemID, def.targetSlot)

			-- Nothing in bags: no action is available, so the ICON is dimmed and
			-- the button carries no click attributes.
			--
			-- Dimming the BUTTON was wrong - it faded the count and the X along
			-- with the art, so the "0" that matters most became the hardest
			-- thing to read. Only the icon fades now.
			local actionable = (have or 0) > 0
			b:SetAlpha(1)
			b.icon:SetAlpha(actionable and 1 or 0.4)
			-- protected on a SecureActionButton once combat starts
			if not locked then b:EnableMouse(true) end

			b.count:SetFont(font, countSize, 'OUTLINE')
			b.time:SetFont(font, timeSize, 'OUTLINE')

			-- VARIANT ROW: one small icon per distinct item of this type in
			-- your bags, but only when there is genuinely a choice to make.
			-- A single variant is just the main icon again, so it is skipped.
			--
			-- k icons plus their gaps span EXACTLY the width of the main icon,
			-- so two flasks occupy one flask's worth of bar. They hang below
			-- the icon, outside the bar's own height, which is why enabling
			-- this never moves the bar.
			local vshow = 0
			if cfg.showVariants and held and #held > 1 then
				vshow = math.min(#held, MAX_VARIANTS)
			end
			local vgap = 1
			local vs = vshow > 0 and ((sz - (vshow - 1) * vgap) / vshow) or 0
			local vcSize = math.max(7, mfloor(vs * 0.44 + 0.5))
			for j = 1, MAX_VARIANTS do
				local v = b.vars[j]
				local e = (j <= vshow) and held[j] or nil
				if not e then
					if not locked then v:Hide() end
				elseif locked then
					-- data only; geometry and Show are protected
					local vi
					if _G.C_Item and _G.C_Item.GetItemIconByID then
						local okV, t = pcall(_G.C_Item.GetItemIconByID, e.id)
						if okV and t and not secret(t) then vi = t end
					end
					v.icon:SetTexture(vi or def.icon)
					v.count:SetText(tostring(e.n))
					v.count:SetFont(font, vcSize, 'OUTLINE')
					v.count:SetTextColor(1, 1, 1)
					v.itemID = e.id
				else
					v:SetSize(vs, vs)
					v:ClearAllPoints()
					-- -3, not -2. Both frames carry a 1px border drawn OUTSIDE their
				-- own bounds, so the big icon's border reaches 1px below the
				-- button and the variant's reaches 1px above itself. At -2 the
				-- two borders land on the same pixel and the rows look welded
				-- together; -3 leaves exactly one pixel of background between.
				v:SetPoint('TOPLEFT', b, 'BOTTOMLEFT', (j - 1) * (vs + vgap), -vgap - 2)

					local vi
					if _G.C_Item and _G.C_Item.GetItemIconByID then
						local okV, t = pcall(_G.C_Item.GetItemIconByID, e.id)
						if okV and t and not secret(t) then vi = t end
					end
					v.icon:SetTexture(vi or def.icon)

					v.count:SetText(tostring(e.n))
					v.count:SetFont(font, vcSize, 'OUTLINE')
					v.count:SetTextColor(1, 1, 1)

					v.itemID = e.id
					-- its own item, so clicking a variant uses THAT one and not
					-- whichever happened to be first in the candidate list
					Arm(v, e.id, def.targetSlot)
					v:Show()
				end
			end

			shown = shown + 1
			if not locked then
				b:SetSize(sz, sz)
				b:ClearAllPoints()
				b:SetPoint('LEFT', bar, 'LEFT', (shown - 1) * (sz + gap), 0)
				b:Show()
			end
		end
	end

	if shown == 0 then SetBarShown(false) return end
	if not locked then bar:SetSize(shown * sz + (shown - 1) * gap, sz) end
	if cfg.hideWhenAllGood and allGood and not preview then SetBarShown(false) end
	return allGood
end

----------------------------------------------------------------------
-- Apply / preview
----------------------------------------------------------------------
ApplySettings = function()
	EnsureBar()
	if not bar then return end -- combat blocked the build; retried on regen
	local cfg = GetCfg()
	-- scale, position and mouse state on the bar are all protected in combat
	if not InCombatLockdown() then
		bar:SetScale(cfg.scale or 1)
		bar:ClearAllPoints()
		bar:SetPoint('CENTER', _G.UIParent, 'CENTER', cfg.x or 0, cfg.y or 160)
		bar:EnableMouse(preview and true or false)
	else
		combatDirty = true
	end
	-- keep the cross proportional to the icons rather than a fixed pixel size.
	-- The cross is a plain Button, so it is never protected.
	if bar.close then
		local csz = math.max(10, mfloor((cfg.iconSize or 40) * 0.36 + 0.5))
		bar.close:SetSize(csz + 4, csz + 4)
		bar.close.text:SetFont(ns.GetFont(), csz, 'OUTLINE')
	end
	Refresh()
	if preview then SetBarShown(true) elseif not bar.active then SetBarShown(false) end
end
ns.RCApply = ApplySettings

local SyncTick -- fwd (defined with the countdown ticker below)

ns.RCIsPreview = function() return preview and true or false end
ns.RCSetPreview = function(state)
	preview = state and true or false
	ApplySettings()
	if SyncTick then SyncTick() end
end

----------------------------------------------------------------------
-- Drivers
----------------------------------------------------------------------
-- The remaining-time text has to tick, and only while the bar is up. One
-- second is plenty when the unit is minutes, and the ticker is cancelled the
-- moment the bar hides so an idle character pays nothing.
local tick
SyncTick = function()
	local want = bar and (bar.active or preview) and true or false
	if want and not tick then
		tick = _G.C_Timer.NewTicker(1, function() Refresh() end)
	elseif not want and tick then
		tick:Cancel()
		tick = nil
	end
end

local hideTimer

Dismiss = function()
	if hideTimer then hideTimer:Cancel() hideTimer = nil end
	if not bar then return end
	bar.active = nil
	if not preview then SetBarShown(false) end
	SyncTick()
end

-- Up for `duration` seconds at most, and gone the instant the check resolves.
-- READY_CHECK_FINISHED is exactly "everyone answered, or it timed out", so it
-- is the right signal for "nobody is waiting on this any more" - the timer is
-- only the ceiling for a check nobody finishes.
local function Show()
	local cfg = GetCfg()
	if not cfg.enable then return end
	EnsureBar()
	if not bar then return end -- combat blocked the build; retried on regen
	bar.active = true
	if hideTimer then hideTimer:Cancel() hideTimer = nil end
	ApplySettings()
	if not (cfg.hideWhenAllGood and Refresh()) then SetBarShown(true) end
	SyncTick()

	local maxUp = cfg.duration or 30
	if maxUp > 0 then
		hideTimer = _G.C_Timer.NewTimer(maxUp, function()
			hideTimer = nil
			Dismiss()
		end)
	end
end

local Hide = Dismiss

local evt = CreateFrame('Frame')
-- RegisterEvent RAISES on an event this client does not have, which would abort
-- the rest of the file - including the slash command below. That is exactly how
-- an earlier module silently failed to load, so every registration here is
-- guarded and a missing one just costs that one refresh trigger.
local function Reg(e, unit)
	if unit then return pcall(evt.RegisterUnitEvent, evt, e, unit) end
	return pcall(evt.RegisterEvent, evt, e)
end
Reg('READY_CHECK')
Reg('READY_CHECK_FINISHED')
Reg('PLAYER_ENTERING_WORLD')
Reg('BAG_UPDATE_DELAYED')
Reg('UNIT_AURA', 'player')
Reg('GET_ITEM_INFO_RECEIVED')
Reg('PLAYER_REGEN_ENABLED')
evt:SetScript('OnEvent', function(_, event)
	if event == 'PLAYER_REGEN_ENABLED' then
		-- replay whatever combat blocked: layout, scale, position, mouse state
		-- and, on the fallback path, visibility
		if combatDirty then
			combatDirty = false
			ApplySettings()
			if bar and not bar.stateDriven then
				if bar.wantShown then bar:Show() else bar:Hide() end
			end
		end
	elseif event == 'READY_CHECK' then
		Show()
	elseif event == 'READY_CHECK_FINISHED' then
		Hide()
	elseif event == 'PLAYER_ENTERING_WORLD' then
		ApplySettings()
	elseif bar and (bar.active or preview) then
		-- bags or buffs changed while the bar is up: keep the counts honest so
		-- a flask you just drank flips the X immediately
		Refresh()
	end
end)
