-- KiraUI-Plugin - standalone addon (no longer an ElvUI plugin)
--
-- Works with or without ElvUI / EllesmereUI. Four features are fully
-- UI-independent (they build their own frames); two absorb features hook
-- ElvUI's unitframes and only activate when ElvUI is loaded.
--
-- Feature 1: (ElvUI only) hide unitframe absorb text when the amount is 0.
-- Feature 2: (ElvUI only) reversed absorb shield at full health (player).
-- Feature 3: one-click "Reset all instances" button.
-- Feature 4: cooldown pulse - a watched spell pops up when it's ready.
-- Feature 5: spell history - icons of recently cast spells.
-- Feature 6: M+ break timer.
--
-- Configuration: /kiraplugin opens a settings window (built in KiraUI-Plugin_Options.lua).
-- Shared data is exposed on the addon namespace table (the second ... return).

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

----------------------------------------------------------------------
-- ElvUI detection (optional). When absent, the two absorb features stay off.
----------------------------------------------------------------------
local hasElvUI = _G.ElvUI ~= nil
local E, UF, LSM
if hasElvUI then
	E = unpack(_G.ElvUI)
	UF = E:GetModule('UnitFrames', true)
	LSM = E.Libs and E.Libs.LSM
	if not UF then hasElvUI = false end
end

----------------------------------------------------------------------
-- Lua / WoW API
----------------------------------------------------------------------
local format, next, pcall, time = format, next, pcall, time
local ipairs, pairs, tonumber, tostring = ipairs, pairs, tonumber, tostring
local wipe = wipe
local strfind = string.find
local GetTime = GetTime
local CreateFrame = CreateFrame
local hooksecurefunc = hooksecurefunc
local IsInInstance = IsInInstance
local GetInstanceInfo = GetInstanceInfo
local ResetInstances = ResetInstances
local GetGameTime = GetGameTime
local SendChatMessage = SendChatMessage
local IsInGroup = IsInGroup
local IsInRaid = IsInRaid
local UnitClass = UnitClass
local date = date

local issecretvalue = issecretvalue
local UnitHealthMax = UnitHealthMax
local UnitGetTotalAbsorbs = UnitGetTotalAbsorbs
local UnitGetTotalHealAbsorbs = UnitGetTotalHealAbsorbs
local UnitGetDetailedHealPrediction = UnitGetDetailedHealPrediction
local CreateUnitHealPredictionCalculator = CreateUnitHealPredictionCalculator

local StringUtil = C_StringUtil
local TruncateWhenZero = StringUtil and StringUtil.TruncateWhenZero
local StripHyperlinks = StringUtil and StringUtil.StripHyperlinks
local WrapString = StringUtil and StringUtil.WrapString

local GetSpellCooldownDuration = C_Spell and C_Spell.GetSpellCooldownDuration
local GetSpellChargeDuration = C_Spell and C_Spell.GetSpellChargeDuration

local KC = {}

----------------------------------------------------------------------
-- Helpers replacing ElvUI ones (work without ElvUI)
----------------------------------------------------------------------
local PREFIX = '|cffff7d0aKiraUI|r '
local function Msg(text) print(PREFIX .. text) end
local function Round(n) return math.floor((n or 0) + 0.5) end
ns.Round = Round

local PARENT -- assigned in Initialize

local function PlayerClassToken()
	local _, token = UnitClass('player')
	return token or 'UNKNOWN'
end
local function PlayerClassLocalized()
	local localized = UnitClass('player')
	return localized or PlayerClassToken()
end

----------------------------------------------------------------------
-- Saved settings + defaults
----------------------------------------------------------------------
local defaults = {
	-- Addon-wide font. '' = leave every module on whatever it used before,
	-- which is what an existing install keeps until you pick something.
	font = '',
	hideZeroText = false,
	reversedAbsorb = false,
	reset = {
		enable = false, size = 80, lock = true, groupOnly = true, leaderOnly = true,
		keyOnly = true,
		announce = true, announceTime = true, announceText = 'Instances were reset',
		showText = true, text = 'Click to reset', textSize = 20, textAnchor = 'ABOVE',
		textX = 0, textY = 0, pos = { x = 0, y = 130 },
	},
	cdPulse = {
		enable = false, specs = {}, specsDyn = {},
		lead = 0.5, linger = 0, iconSize = 40,
		pulse = false, pulseScale = 1.2, pulseSpeed = 0.1,
		fadeOut = 0.3, fadeScale = 2, lock = true, pos = { x = 0, y = 0 },
	},
	history = {
		enable = false, count = 8, iconSize = 44, spacing = 3,
		direction = 'DOWN', fade = false, lock = true, pos = { x = 837, y = -473 },
	},
	breakTimer = { enable = false },
}

----------------------------------------------------------------------
-- Addon-wide font
--
-- Every module asks for its font through ns.GetFont(fallback). When nothing is
-- chosen the fallback wins, so each module keeps exactly the face it had - a
-- module that deliberately wants Expressway still gets it. Choosing a font
-- overrides all of them at once.
--
-- The name is stored, not the path: a LibSharedMedia path can change between
-- sessions (different media pack, addon updated), while the name is stable.
----------------------------------------------------------------------
local function LSM()
	return _G.LibStub and _G.LibStub('LibSharedMedia-3.0', true)
end
ns.LSM = LSM

function ns.GetFont(fallback)
	local db = _G.KiraUIChangesDB
	local name = db and db.font
	if name and name ~= '' then
		local lib = LSM()
		if lib and lib.Fetch then
			-- the `true` suppresses LSM's "return the default instead" behaviour,
			-- so a font that is no longer registered falls through to the
			-- module's own choice rather than silently becoming Arial
			local ok, path = pcall(lib.Fetch, lib, 'font', name, true)
			if ok and type(path) == 'string' and path ~= '' then return path end
		end
	end
	return fallback or _G.STANDARD_TEXT_FONT
end

-- Options list: every registered LSM font, plus the "leave it alone" entry.
function ns.FontOptions()
	local out = { { value = '', text = 'Default (per module)' } }
	local lib = LSM()
	local hash = lib and lib.HashTable and lib:HashTable('font')
	if hash then
		local names = {}
		for name in pairs(hash) do names[#names + 1] = name end
		table.sort(names, function(a, b) return tostring(a):lower() < tostring(b):lower() end)
		for _, name in ipairs(names) do out[#out + 1] = { value = name, text = name } end
	end
	return out
end

function ns.FontApply()
	ns.ApplyAllSettings()
end

local function DeepCopy(v)
	if type(v) ~= 'table' then return v end
	local r = {}
	for k, val in pairs(v) do r[k] = DeepCopy(val) end
	return r
end

----------------------------------------------------------------------
-- Shared module helpers
----------------------------------------------------------------------

-- For modules that need to reset one setting back to its default without
-- handing out the DEFAULTS table itself.
ns.CopyValue = DeepCopy

-- Hydrate a module's slot in the saved DB.
--
-- Every feature module owns a DEFAULTS table and one root key, and every one
-- of them needs the same three things: fall back to DEFAULTS before the DB
-- exists (settings are read during login, before SavedVariables load), create
-- the slot on first run, and fill in any key a newer version added. Modules
-- keep only their own `migrate`, which renames or retires their own keys.
--
-- Table defaults are copied rather than handed out by reference: sharing the
-- DEFAULTS table means the first option change edits the defaults themselves,
-- and every later profile inherits the edit.
--
-- `migrate` runs only once there is a real saved table to migrate. That is why
-- it belongs here rather than after the call: the pre-DB return hands back the
-- module's own DEFAULTS, and a migration writing its "already migrated" flag
-- onto that table would bake the flag into every config created afterwards.
-- Hydration is NOT free, and GetCfg is not a cold path: filling in missing keys
-- walks the module's whole DEFAULTS table, and modules call GetCfg from ticker
-- callbacks, OnUpdate handlers and combat events - Combat Text alone re-walks
-- 36 keys on every death and interrupt. Since neither the defaults nor the
-- migrations can change within a session, the walk only has to happen once per
-- feature; after that this is a single table lookup.
--
-- The cached value IS the table stored in the DB, not a copy, so option edits
-- and dragged positions still write straight through to SavedVariables.
--
-- The pre-DB result is deliberately not cached: it is the module's own DEFAULTS
-- standing in until SavedVariables load, and caching it would pin every module
-- to its defaults for the rest of the session.
local cfgCache = {}

-- Only needed when a feature's table is REPLACED rather than edited, which is
-- exactly and only a profile load. Editing a setting mutates the cached table
-- in place and needs no invalidation.
function ns.InvalidateModuleCfg() wipe(cfgCache) end

function ns.ModuleCfg(key, moduleDefaults, migrate)
	local cached = cfgCache[key]
	if cached then return cached end
	local db = _G.KiraUIChangesDB
	if not db then return moduleDefaults end
	local cfg = db[key]
	if not cfg then
		cfg = {}
		db[key] = cfg
	end
	for k, v in pairs(moduleDefaults) do
		if cfg[k] == nil then cfg[k] = DeepCopy(v) end
	end
	if migrate then migrate(cfg) end
	cfgCache[key] = cfg
	return cfg
end

-- Drag-to-move, remembering the frame's offset from the screen centre.
--
-- The caller still does its own SetMovable/RegisterForDrag, since which button
-- drags a frame is that module's business. What is identical everywhere is the
-- pair of handlers: refuse while locked, then convert the landing position
-- into a centre offset and save it under the module's own two keys.
--
-- `canDrag(cfg)` decides whether a drag may start. It defaults to "not locked",
-- but modules whose frame has a second reason to stay put pass their own -
-- an attached bar is pinned to its anchor, and the raid-marker panel is a
-- protected frame that must not move in combat.
local function NotLocked(cfg) return not cfg.lock end

function ns.MakeDraggable(frame, getCfg, xKey, yKey, onMoved, canDrag)
	canDrag = canDrag or NotLocked
	frame:SetScript('OnDragStart', function(self)
		if not canDrag(getCfg()) then return end
		self:StartMoving()
	end)
	frame:SetScript('OnDragStop', function(self)
		self:StopMovingOrSizing()
		local cx, cy = self:GetCenter()
		local ux, uy = _G.UIParent:GetCenter()
		if cx and ux then
			local cfg = getCfg()
			cfg[xKey] = Round(cx - ux)
			cfg[yKey] = Round(cy - uy)
		end
		if onMoved then onMoved() end
	end)
end

local function DeepMerge(dst, src)
	for k, v in pairs(src) do
		if type(v) == 'table' then
			if type(dst[k]) ~= 'table' then dst[k] = {} end
			DeepMerge(dst[k], v)
		elseif dst[k] == nil then
			dst[k] = v
		end
	end
end

-- Falls back to defaults so absorb tags that fire before our DB is built
-- (early in login) still read sane values instead of indexing nil.
local function GetCfg() return _G.KiraUIChangesDB or defaults end
local function GetResetCfg() return GetCfg().reset end

------------------------------------------------------------------------
-- Feature 1: absorb tags (ElvUI only)
------------------------------------------------------------------------
local function SecretAbbrev(amount, which)
	local abbr = E:AbbreviateNumbers(amount, E.Abbreviate[which])
	local gate = TruncateWhenZero(amount)
	local out = WrapString(gate, abbr .. '|H', '|h|h')
	return StripHyperlinks(out)
end

local function NonZeroText(amount, which)
	if issecretvalue and issecretvalue(amount) then
		if not GetCfg().hideZeroText then
			if which then return E:AbbreviateNumbers(amount, E.Abbreviate[which])
			else return TruncateWhenZero and TruncateWhenZero(amount) or nil end
		end
		if not TruncateWhenZero then return end
		if WrapString and StripHyperlinks then
			local ok, result = pcall(SecretAbbrev, amount, which or 'short')
			if ok then return result end
		end
		return TruncateWhenZero(amount)
	end
	if amount == nil then return end
	if not GetCfg().hideZeroText then
		if which then return E:AbbreviateNumbers(amount, E.Abbreviate[which])
		else return TruncateWhenZero and TruncateWhenZero(amount) or nil end
	end
	if amount > 0 then
		return E:AbbreviateNumbers(amount, E.Abbreviate[which or 'short'])
	end
end

local function OverrideAbsorbTags()
	E:AddTag('absorbs', 'UNIT_ABSORB_AMOUNT_CHANGED', function(unit)
		return NonZeroText(UnitGetTotalAbsorbs(unit))
	end)
	E:AddTag('healabsorbs', 'UNIT_HEAL_ABSORB_AMOUNT_CHANGED', function(unit)
		return NonZeroText(UnitGetTotalHealAbsorbs(unit))
	end)
	for tagFormat, which in next, { shortvalue = 'short', longvalue = 'long' } do
		E:AddTag(format('absorbs:%s', tagFormat), 'UNIT_ABSORB_AMOUNT_CHANGED', function(unit)
			return NonZeroText(UnitGetTotalAbsorbs(unit), which)
		end)
		E:AddTag(format('healabsorbs:%s', tagFormat), 'UNIT_HEAL_ABSORB_AMOUNT_CHANGED', function(unit)
			return NonZeroText(UnitGetTotalHealAbsorbs(unit), which)
		end)
	end
end

------------------------------------------------------------------------
-- Feature 2: reversed absorb bar at full health (player, ElvUI only)
------------------------------------------------------------------------
local calc, fullCurve, notFullCurve
do
	local ClampMode = Enum and Enum.UnitDamageAbsorbClampMode
	local CreateCurve = C_CurveUtil and C_CurveUtil.CreateCurve
	local stepType = Enum and Enum.LuaCurveType and Enum.LuaCurveType.Step
	if hasElvUI and E.Retail and CreateUnitHealPredictionCalculator and UnitGetDetailedHealPrediction and ClampMode and CreateCurve and stepType ~= nil then
		calc = CreateUnitHealPredictionCalculator()
		calc:SetDamageAbsorbClampMode(ClampMode.MaximumHealth)
		fullCurve = CreateCurve()
		fullCurve:SetType(stepType)
		fullCurve:AddPoint(0, 1)
		fullCurve:AddPoint(0.0000001, 0)
		notFullCurve = CreateCurve()
		notFullCurve:SetType(stepType)
		notFullCurve:AddPoint(0, 0)
		notFullCurve:AddPoint(0.0000001, 1)
	end
end

local function UpdateReversedAbsorb(pred, unit, bar, db)
	local health = pred.health
	local orientation = health:GetOrientation()
	local width, height = health:GetSize()
	if not width or width <= 0 then width = health.WIDTH end
	if not height or height <= 0 then height = health.HEIGHT end
	if orientation == 'HORIZONTAL' then
		local barHeight = db.height or height
		if barHeight == -1 or barHeight > height then barHeight = height end
		bar:SetSize(width, barHeight)
	else
		local barWidth = db.height or width
		if barWidth == -1 or barWidth > width then barWidth = width end
		bar:SetSize(barWidth, height)
	end
	UnitGetDetailedHealPrediction(unit, 'player', calc)
	local totalAbsorb = calc:GetDamageAbsorbs()
	local fullAlpha = calc:EvaluateMissingHealthPercent(fullCurve)
	local notFullAlpha = calc:EvaluateMissingHealthPercent(notFullCurve)
	bar:SetMinMaxValues(0, UnitHealthMax(unit))
	bar:SetValue(totalAbsorb)
	bar:SetAlpha(fullAlpha or 0)
	bar:Show()
	pred.damageAbsorb:SetAlpha(notFullAlpha or 1)
	pred.kiraDimmedNative = true
end

local function KiraPostUpdate(pred, unit, ...)
	UF.UpdateHealComm(pred, unit, ...)
	local bar = pred.kiraReversedAbsorb
	if not bar then return end
	local frame = pred.frame
	local db = frame and frame.db and frame.db.healPrediction
	if calc and unit and db and db.enable and db.absorbStyle == 'OVERFLOW' and pred.anchor and GetCfg().reversedAbsorb then
		UpdateReversedAbsorb(pred, unit, bar, db)
	else
		bar:Hide()
		if pred.kiraDimmedNative then
			pred.damageAbsorb:SetAlpha(1)
			pred.kiraDimmedNative = nil
		end
	end
end

local function ConfigureReversedAbsorb(_, frame)
	if not (calc and frame and frame.unit == 'player') then return end
	local pred = frame.HealthPrediction
	if not pred then return end
	local db = frame.db and frame.db.healPrediction
	local bar = pred.kiraReversedAbsorb
	if not (db and db.enable and db.absorbStyle == 'OVERFLOW' and pred.anchor and pred.anchor2 and GetCfg().reversedAbsorb) then
		if bar then bar:Hide() end
		return
	end
	if not bar then
		bar = CreateFrame('StatusBar', '$parent_KiraReversedAbsorbBar', pred.parent)
		bar:Hide()
		pred.kiraReversedAbsorb = bar
		pred.PostUpdate = KiraPostUpdate
	end
	local health = frame.Health
	bar:SetFrameLevel(pred.damageAbsorb:GetFrameLevel())
	bar:SetOrientation(health:GetOrientation())
	bar:SetReverseFill(not health:GetReverseFill())
	bar:SetStatusBarTexture(UF.db.colors.transparentHealth and E.media.blankTex or LSM:Fetch('statusbar', UF.db.statusbar))
	local c = UF.db.colors.healPrediction.absorbs
	bar:GetStatusBarTexture():SetVertexColor(c.r, c.g, c.b, c.a)
	bar:ClearAllPoints()
	bar:Point(pred.anchor, health)
	bar:Point(pred.anchor2, health, pred.anchor2)
end

local function UpdateAbsorbs()
	if hasElvUI and _G.ElvUF_Player then
		UF:CreateAndUpdateUF('player')
	end
end

-- IMPORTANT: register the ElvUI absorb hooks NOW, at file load (during
-- ADDON_LOADED, before ElvUI builds its unitframes at PLAYER_LOGIN).
-- If we override the absorb tags AFTER the frames are already tagged, oUF's
-- Tags:RefreshEvents re-registers those font strings with the Tags table as
-- their "frame" (a known oUF bug: it passes self instead of the frame), which
-- then errors on every UNIT_ABSORB_AMOUNT_CHANGED. Doing it before any font
-- string uses the tag keeps that refresh path a no-op.
if hasElvUI then
	OverrideAbsorbTags()
	if calc and fullCurve then
		hooksecurefunc(UF, 'Configure_HealComm', ConfigureReversedAbsorb)
	end
end

------------------------------------------------------------------------
-- Feature 3: instance reset button
------------------------------------------------------------------------
local RESET_ICON = 'Interface\\Icons\\Spell_Arcane_PortalSilvermoon'
local PENDING_MAX_AGE = 60 * 60

local SEASON1_FALLBACK = {
	["Magisters' Terrace"] = true, ["Windrunner Spire"] = true,
	["Nexus-Point Xenas"] = true, ["Maisara Caverns"] = true,
	["Algeth'ar Academy"] = true, ["Pit of Saron"] = true,
	["Seat of the Triumvirate"] = true, ["Skyreach"] = true,
}

local charDB
local resetBtn
local resetPreview = false

local seasonNames
local function GetSeasonDungeonNames()
	if seasonNames then return seasonNames end
	local maps = C_ChallengeMode and C_ChallengeMode.GetMapTable()
	if maps and #maps > 0 then
		local names = {}
		for _, id in ipairs(maps) do
			local name = C_ChallengeMode.GetMapUIInfo(id)
			if name then names[name] = true end
		end
		if next(names) then seasonNames = names return seasonNames end
	end
	return SEASON1_FALLBACK
end

local function IsSeasonDungeon(name)
	return name and GetSeasonDungeonNames()[name] or false
end

local function PrunePending()
	if not (charDB and charDB.pending) then return end
	local now = time()
	for name, ts in pairs(charDB.pending) do
		if now - ts > PENDING_MAX_AGE then charDB.pending[name] = nil end
	end
end

local function HasPending()
	PrunePending()
	return charDB and charDB.pending and next(charDB.pending) ~= nil
end

local function UpdateResetVisibility()
	if not resetBtn then return end
	if resetPreview then resetBtn:Show() return end
	local cfg = GetResetCfg()
	-- groupOnly: hide while solo, so the button doesn't greet you after
	-- every reload just because a pending dungeon is still tracked
	-- leaderOnly: in a group only the LEADER can actually reset instances,
	-- so hide the button from everyone else
	local visible = cfg.enable and not IsInInstance() and HasPending()
		and (not cfg.groupOnly or IsInGroup())
	if visible and cfg.leaderOnly and IsInGroup()
		and not UnitIsGroupLeader('player') then
		visible = false
	end
	if visible then resetBtn:Show() else resetBtn:Hide() end
end

-- True while an M+ keystone run is active in the current instance.
local function KeyActive()
	local CM = C_ChallengeMode
	if not CM then return false end
	if CM.IsChallengeModeActive then
		local ok, v = pcall(CM.IsChallengeModeActive)
		if ok and v then return true end
	end
	if CM.GetActiveChallengeMapID then
		local ok, id = pcall(CM.GetActiveChallengeMapID)
		if ok and id then return true end
	end
	return false
end

-- Marks the current season dungeon as needing a reset. With keyOnly (default)
-- a plain Mythic visit does NOT count - only a STARTED keystone marks it
-- (force = called from CHALLENGE_MODE_START, where the event itself is proof).
local function MarkPendingIfEligible(force)
	if not charDB then return end
	local inInstance, instType = IsInInstance()
	if not (inInstance and instType == 'party') then return end
	local name = GetInstanceInfo()
	if not IsSeasonDungeon(name) then return end
	if not force and GetResetCfg().keyOnly and not KeyActive() then return end
	charDB.pending[name] = time()
	UpdateResetVisibility()
end

-- Forget a dungeon the server has already reset for us.
--
-- Abandoning a key by group vote teleports everyone out and resets the
-- instance server-side. No INSTANCE_RESET_SUCCESS message is printed for it,
-- so the CHAT_MSG_SYSTEM path below never fires and the button sat there
-- offering a reset that had already happened.
--
-- pending is keyed by instance NAME while the event hands over a challenge
-- map ID. Those two USUALLY agree, but not always: a mega-dungeon wing is
-- "Operation: Mechagon - Workshop" to GetMapUIInfo and "Operation: Mechagon"
-- to GetInstanceInfo. An exact lookup silently missed those and left the
-- button up after an abandon, which is exactly the symptom being fixed here.
--
-- So: gather every name this reset could plausibly refer to, drop each one,
-- and match loosely enough that a wing clears its parent instance too.
--
-- The other half of the problem is timing. The abandon vote can resolve AFTER
-- the teleport, in which case GetInstanceInfo no longer reports the dungeon at
-- all - it reports the city you just landed in - and the lookup quietly
-- matched nothing. When neither name resolves to a tracked dungeon, the most
-- recently marked entry is dropped instead: that is the run that was just
-- abandoned, and it leaves any earlier dungeon still legitimately pending.
local function SafeStr(v)
	if type(v) ~= 'string' or v == '' then return nil end
	if issecretvalue and issecretvalue(v) then return nil end
	return v
end

local function ClearPendingForMap(mapID)
	if not (charDB and charDB.pending) then return end
	local names = {}
	if mapID and C_ChallengeMode and C_ChallengeMode.GetMapUIInfo then
		local ok, n = pcall(C_ChallengeMode.GetMapUIInfo, mapID)
		if ok then n = SafeStr(n) if n then names[#names + 1] = n end end
	end
	-- Where we are standing. Useful only when the event beats the teleport;
	-- when it does not, this is the destination zone and matches nothing,
	-- which is what the newest-entry fallback below is for.
	local ok, n = pcall(GetInstanceInfo)
	if ok then n = SafeStr(n) if n then names[#names + 1] = n end end

	local cleared = false
	for _, want in ipairs(names) do
		for have in pairs(charDB.pending) do
			-- either direction: the tracked name may be the shorter parent
			-- instance, or the longer wing, depending which side saw it first
			if have == want or strfind(have, want, 1, true) or strfind(want, have, 1, true) then
				charDB.pending[have] = nil
				cleared = true
			end
		end
	end
	if not cleared then
		local newest, newestAt
		for have, ts in pairs(charDB.pending) do
			if not newestAt or (tonumber(ts) or 0) >= newestAt then
				newest, newestAt = have, tonumber(ts) or 0
			end
		end
		if newest then charDB.pending[newest] = nil end
	end
	UpdateResetVisibility()
end

local function ApplyResetSettings()
	if not resetBtn then return end
	local cfg = GetResetCfg()
	cfg.pos = cfg.pos or { x = 0, y = -220 }
	resetBtn:SetSize(cfg.size or 80, cfg.size or 80)
	resetBtn:ClearAllPoints()
	resetBtn:SetPoint('CENTER', PARENT, 'CENTER', cfg.pos.x or 0, cfg.pos.y or -220)
	local label = resetBtn.text
	if label then
		local fontSize = cfg.textSize or 12
		if label.FontTemplate then label:FontTemplate(nil, fontSize, 'OUTLINE')
		else label:SetFont(_G.STANDARD_TEXT_FONT, fontSize, 'OUTLINE') end
		local x, y = cfg.textX or 0, cfg.textY or 0
		label:ClearAllPoints()
		if cfg.textAnchor == 'ABOVE' then label:SetPoint('BOTTOM', resetBtn, 'TOP', x, y + 4)
		elseif cfg.textAnchor == 'CENTER' then label:SetPoint('CENTER', resetBtn, 'CENTER', x, y)
		else label:SetPoint('TOP', resetBtn, 'BOTTOM', x, y - 4) end
		label:SetText(cfg.text or 'Click to reset')
		label:SetShown(cfg.showText ~= false)
	end
	UpdateResetVisibility()
end

local RESET_PATTERN = '^' .. INSTANCE_RESET_SUCCESS
	:gsub('[%(%)%.%+%-%*%?%[%]%^%$%%]', '%%%0')
	:gsub('%%%%s', '(.+)') .. '$'

local lastAnnounce = 0
local function AnnounceReset()
	local cfg = GetResetCfg()
	if not cfg.announce then return end
	local now = time()
	if (now - lastAnnounce) < 5 then return end
	lastAnnounce = now
	local msg = cfg.announceText or 'Instances were reset'
	if cfg.announceTime then
		local h, m = GetGameTime()
		msg = format('%s (%02d:%02d)', msg, h, m)
	end
	local channel
	if IsInGroup(_G.LE_PARTY_CATEGORY_INSTANCE) then channel = 'INSTANCE_CHAT'
	elseif IsInRaid() then channel = 'RAID'
	elseif IsInGroup() then channel = 'PARTY' end
	if channel then SendChatMessage(msg, channel) else Msg(msg) end
end

local MarkBreakStart -- forward decl (feature 6)

local function CreateResetButton()
	resetBtn = CreateFrame('Button', 'KiraUIResetButton', PARENT)
	resetBtn:SetFrameStrata('MEDIUM')
	resetBtn:SetClampedToScreen(true)
	resetBtn:SetMovable(true)
	resetBtn:RegisterForClicks('LeftButtonUp')
	resetBtn:RegisterForDrag('RightButton')
	resetBtn:Hide()
	resetBtn.icon = resetBtn:CreateTexture(nil, 'ARTWORK')
	resetBtn.icon:SetPoint('TOPLEFT', 1, -1)
	resetBtn.icon:SetPoint('BOTTOMRIGHT', -1, 1)
	resetBtn.icon:SetTexture(RESET_ICON)
	resetBtn.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
	resetBtn.border = resetBtn:CreateTexture(nil, 'BACKGROUND')
	resetBtn.border:SetColorTexture(0, 0, 0, 1)
	resetBtn.border:SetAllPoints()
	resetBtn:SetHighlightTexture('Interface\\Buttons\\ButtonHilight-Square', 'ADD')
	resetBtn.text = resetBtn:CreateFontString(nil, 'OVERLAY')
	resetBtn.text:SetPoint('TOP', resetBtn, 'BOTTOM', 0, -4)
	resetBtn.text:SetTextColor(1, 1, 1)
	resetBtn:SetScript('OnClick', function(self)
		if resetPreview then resetPreview = false UpdateResetVisibility() return end
		if MarkBreakStart then MarkBreakStart() end
		ResetInstances()
		AnnounceReset()
		if charDB and charDB.pending then wipe(charDB.pending) end
		self:Hide()
	end)
	resetBtn:SetScript('OnDragStart', function(self)
		if GetResetCfg().lock then return end
		self:StartMoving()
	end)
	resetBtn:SetScript('OnDragStop', function(self)
		self:StopMovingOrSizing()
		local cx, cy = self:GetCenter()
		local ux, uy = PARENT:GetCenter()
		local cfg = GetResetCfg()
		cfg.pos = cfg.pos or {}
		cfg.pos.x = Round(cx - ux)
		cfg.pos.y = Round(cy - uy)
		ApplyResetSettings()
	end)
	resetBtn:SetScript('OnEnter', function(self)
		GameTooltip:SetOwner(self, 'ANCHOR_RIGHT')
		GameTooltip:AddLine('KiraUI Instance Reset', 1, 1, 1)
		if resetPreview then
			GameTooltip:AddLine('Preview - left-click to close', 1, 0.82, 0)
		else
			GameTooltip:AddLine('Instances waiting for reset:', 1, 0.82, 0)
			if charDB and charDB.pending then
				for name in pairs(charDB.pending) do GameTooltip:AddLine('  ' .. name, 0.9, 0.9, 0.9) end
			end
		end
		GameTooltip:AddLine(' ')
		GameTooltip:AddLine('Left-click: reset all instances', 0.5, 0.8, 1)
		if not GetResetCfg().lock then GameTooltip:AddLine('Right-drag: move button', 0.5, 0.8, 1) end
		GameTooltip:Show()
	end)
	resetBtn:SetScript('OnLeave', function() GameTooltip:Hide() end)
	local f = CreateFrame('Frame')
	f:RegisterEvent('PLAYER_ENTERING_WORLD')
	f:RegisterEvent('ZONE_CHANGED')
	f:RegisterEvent('ZONE_CHANGED_INDOORS')
	f:RegisterEvent('ZONE_CHANGED_NEW_AREA')
	f:RegisterEvent('GROUP_ROSTER_UPDATE')
	f:RegisterEvent('PARTY_LEADER_CHANGED')
	f:RegisterEvent('CHAT_MSG_SYSTEM')
	f:RegisterEvent('CHALLENGE_MODE_START')
	f:RegisterEvent('CHALLENGE_MODE_RESET')
	f:SetScript('OnEvent', function(_, event, arg1)
		if not charDB then return end
		if event == 'PLAYER_ENTERING_WORLD' then
			MarkPendingIfEligible()
			-- mid-key /reload: the key state can resolve a moment after PEW
			C_Timer.After(3, MarkPendingIfEligible)
			UpdateResetVisibility()
		elseif event == 'CHALLENGE_MODE_START' then
			MarkPendingIfEligible(true)
		elseif event == 'CHALLENGE_MODE_RESET' then
			-- arg1 = mapID. The key was abandoned or restarted, which resets
			-- the instance for us; nothing is left to click.
			ClearPendingForMap(arg1)
		elseif event == 'CHAT_MSG_SYSTEM' then
			if issecretvalue and issecretvalue(arg1) then return end
			if not arg1 then return end
			local name = string.match(arg1, RESET_PATTERN)
			if name then
				charDB.pending[name] = nil
				UpdateResetVisibility()
				AnnounceReset()
			end
		else
			UpdateResetVisibility()
		end
	end)
	C_Timer.NewTicker(60, UpdateResetVisibility)
end

------------------------------------------------------------------------
-- Feature 4: cooldown pulse
------------------------------------------------------------------------
local CD_FALLBACK_ICON = 134400
local CD_MIN_DURATION = 3

local cdFrame
local cdIcon
local cdState = {}
local cdShownID
local cdPreview = false

local function GetCdCfg() return GetCfg().cdPulse end

local function CdSpecIndex()
	local spec
	if C_SpecializationInfo and C_SpecializationInfo.GetSpecialization then
		spec = C_SpecializationInfo.GetSpecialization()
	elseif GetSpecialization then
		spec = GetSpecialization()
	end
	if not (issecretvalue and issecretvalue(spec)) then return spec end
end

local function CdSpecKey()
	return format('%s-%d', PlayerClassToken(), CdSpecIndex() or 0)
end

local function CdSpecLabel()
	local spec = CdSpecIndex()
	local specName
	if spec then
		local a, b
		if C_SpecializationInfo and C_SpecializationInfo.GetSpecializationInfo then
			a, b = C_SpecializationInfo.GetSpecializationInfo(spec)
		elseif GetSpecializationInfo then
			a, b = GetSpecializationInfo(spec)
		end
		if type(a) == 'table' and type(a.name) == 'string' then specName = a.name
		elseif type(b) == 'string' then specName = b end
	end
	local class = PlayerClassLocalized()
	return specName and format('%s %s', specName, class) or class
end

local function GetCdSpells()
	local cfg = GetCdCfg()
	cfg.specs = cfg.specs or {}
	local key = CdSpecKey()
	local list = cfg.specs[key]
	if not list then list = {} cfg.specs[key] = list end
	return list
end

local function GetCdDynamic()
	local cfg = GetCdCfg()
	cfg.specsDyn = cfg.specsDyn or {}
	local key = CdSpecKey()
	local set = cfg.specsDyn[key]
	if not set then set = {} cfg.specsDyn[key] = set end
	return set
end

local function CdIsDynamic(spellID) return GetCdDynamic()[spellID] == true end

local function CdSpellTexture(spellID)
	local tex = C_Spell.GetSpellTexture(spellID)
	if not (issecretvalue and issecretvalue(tex)) and tex then return tex end
	return CD_FALLBACK_ICON
end

local function CdSpellName(spellID)
	local name = C_Spell.GetSpellName(spellID)
	if not (issecretvalue and issecretvalue(name)) and name then return name end
	return 'Spell ' .. spellID
end

local function CdResolveSpellID(input)
	local num = tonumber(input)
	if num and num > 0 then return math.floor(num) end
	if not input then return end
	local info = C_Spell.GetSpellInfo(input)
	local id = info and info.spellID
	if not (issecretvalue and issecretvalue(id)) and id then return id end
end

-- Is this watched spell something the character ACTUALLY has right now?
--
-- The watch list is stored per SPEC, but a spec is not one loadout: it holds
-- two hero trees and a whole grid of talents. A Blood DK who tracked a
-- Deathbringer ability and then swapped to San'layn still has that ID in the
-- list, and it would pulse "ready" for an ability that is not on the bars.
-- Being IN the list and being AVAILABLE are separate questions, so ask both.
--
-- Unknown counts as USABLE on purpose. If the spellbook API is missing or the
-- answer cannot be read, tracking behaves exactly as it did before - only a
-- definite "you do not have this" suppresses anything. A gate that fails
-- closed would silently kill tracking for everyone the moment an API moved.
local cdUsable = {}
local function CdIsUsable(spellID)
	local cached = cdUsable[spellID]
	if cached ~= nil then return cached end

	local known
	local sb = _G.C_SpellBook
	if sb then
		local bank = (Enum and Enum.SpellBookSpellBank and Enum.SpellBookSpellBank.Player) or 0
		-- "known OR in the spellbook": covers temporarily granted abilities and
		-- talent-linked override spells, both of which are legitimately yours
		if sb.IsSpellKnownOrInSpellBook then
			local ok, r = pcall(sb.IsSpellKnownOrInSpellBook, spellID, bank, true)
			if ok and type(r) == 'boolean' then known = r end
		end
		if known == nil and sb.IsSpellKnown then
			local ok, r = pcall(sb.IsSpellKnown, spellID, bank)
			if ok and type(r) == 'boolean' then known = r end
		end
		-- a base ability replaced by a talent reads as not-known but IS in the
		-- book under its override, so a "no" gets a second opinion
		if known == false and sb.IsSpellInSpellBook then
			local ok, r = pcall(sb.IsSpellInSpellBook, spellID, bank, true)
			if ok and r == true then known = true end
		end
	end
	if known == nil and _G.IsSpellKnownOrOverridesKnown then
		local ok, r = pcall(_G.IsSpellKnownOrOverridesKnown, spellID)
		if ok and type(r) == 'boolean' then known = r end
	end
	if known == nil then known = true end

	cdUsable[spellID] = known
	return known
end

local function CdIsWatched(spellID)
	for _, id in ipairs(GetCdSpells()) do
		if id == spellID then return CdIsUsable(spellID) end
	end
	return false
end

local function EnsureCdIcon()
	if cdIcon then return cdIcon end
	local cfg = GetCdCfg()
	local icon = CreateFrame('Frame', nil, cdFrame)
	icon:SetPoint('CENTER')
	icon:Hide()
	icon.border = icon:CreateTexture(nil, 'BACKGROUND')
	icon.border:SetColorTexture(0, 0, 0, 1)
	icon.border:SetAllPoints()
	icon.tex = icon:CreateTexture(nil, 'ARTWORK')
	icon.tex:SetPoint('TOPLEFT', 1, -1)
	icon.tex:SetPoint('BOTTOMRIGHT', -1, 1)
	icon.tex:SetTexCoord(0.08, 0.92, 0.08, 0.92)
	local pulse = icon:CreateAnimationGroup()
	pulse:SetLooping('BOUNCE')
	local pScale = pulse:CreateAnimation('Scale')
	pScale:SetOrigin('CENTER', 0, 0)
	pScale:SetScaleFrom(1, 1)
	pScale:SetScaleTo(cfg.pulseScale or 1.3, cfg.pulseScale or 1.3)
	pScale:SetDuration(cfg.pulseSpeed or 0.3)
	pScale:SetSmoothing('IN_OUT')
	icon.pulse = pulse
	icon.pulseAnim = pScale
	local fade = icon:CreateAnimationGroup()
	local fAlpha = fade:CreateAnimation('Alpha')
	fAlpha:SetFromAlpha(1)
	fAlpha:SetToAlpha(0)
	fAlpha:SetDuration(cfg.fadeOut or 0.3)
	local fScale = fade:CreateAnimation('Scale')
	fScale:SetOrigin('CENTER', 0, 0)
	fScale:SetScaleFrom(1, 1)
	fScale:SetScaleTo(cfg.fadeScale or 2, cfg.fadeScale or 2)
	fScale:SetDuration(cfg.fadeOut or 0.3)
	fScale:SetSmoothing('OUT')
	fade:SetScript('OnFinished', function() icon:Hide() end)
	icon.fadeOut = fade
	icon.fadeAlpha = fAlpha
	icon.fadeScale = fScale
	cdIcon = icon
	return icon
end

local function UpdateCdDisplay()
	if not cdFrame then return end
	local cfg = GetCdCfg()
	local icon = EnsureCdIcon()
	icon:SetSize(cfg.iconSize or 56, cfg.iconSize or 56)
	cdFrame:SetSize(cfg.iconSize or 56, cfg.iconSize or 56)
	local showID
	if cdPreview then
		showID = GetCdSpells()[1] or 0
	else
		local bestTime
		for _, id in ipairs(GetCdSpells()) do
			local st = cdState[id]
			-- second gate: a popup queued before a talent swap must not draw
			if st and st.active and CdIsUsable(id)
				and (not bestTime or (st.shownAt or 0) > bestTime) then
				showID = id
				bestTime = st.shownAt or 0
			end
		end
	end
	if showID then
		cdShownID = showID
		icon.fadeOut:Stop()
		icon.tex:SetTexture(showID > 0 and CdSpellTexture(showID) or CD_FALLBACK_ICON)
		icon:Show()
		if cfg.pulse then
			if not icon.pulse:IsPlaying() then icon.pulse:Play() end
		else
			icon.pulse:Stop()
		end
	elseif cdShownID then
		cdShownID = nil
		icon.pulse:Stop()
		if icon:IsShown() and not icon.fadeOut:IsPlaying() then icon.fadeOut:Play() end
	end
end

local cdBucket
local cdTrackers = {}

-- Talents changed, so every cached answer about what you have is stale.
--
-- Re-checking is not enough on its own: a tracker armed while you still had the
-- ability would keep counting and fire OnCooldownDone after the swap. Anything
-- you no longer have is therefore disarmed here, not just re-evaluated.
local function CdForgetUsable()
	wipe(cdUsable)
	for id, frame in pairs(cdTrackers) do
		if not CdIsUsable(id) then
			frame._tracking = nil
			frame._castTime = nil
			frame:Clear()
		end
	end
	for id, st in pairs(cdState) do
		if not CdIsUsable(id) then
			st.token = (st.token or 0) + 1  -- strand any pending hide timer
			cdState[id] = nil
		end
	end
	if cdShownID and not CdIsUsable(cdShownID) then cdShownID = nil end
	UpdateCdDisplay()
end

local function CdDebug(...)
	if KC.cdDebug then print('|cffff7d0aKiraUI-CD|r', ...) end
end

local function CdTriggerPopup(spellID)
	local st = cdState[spellID]
	if not st then st = {} cdState[spellID] = st end
	if st.active then return end
	st.active = true
	st.shownAt = GetTime()
	local token = (st.token or 0) + 1
	st.token = token
	CdDebug('popup:', spellID, CdSpellName(spellID))
	UpdateCdDisplay()
	local cfg = GetCdCfg()
	C_Timer.After((cfg.lead or 0.5) + (cfg.linger or 0), function()
		if st.token == token and st.active then
			st.active = nil
			UpdateCdDisplay()
		end
	end)
end

local function CdOnCooldownDone(self)
	local id = self._spellID
	if not id then return end
	self._tracking = nil
	if cdPreview or not GetCdCfg().enable then return end
	local elapsed = self._castTime and (GetTime() - self._castTime)
	CdDebug('cooldown done:', id, '- elapsed:', elapsed and format('%.1fs', elapsed) or '?')
	if elapsed and elapsed <= CD_MIN_DURATION then return end
	if not CdIsWatched(id) then return end
	CdTriggerPopup(id)
end

local function CdFetchDuration(spellID)
	local charges = GetSpellChargeDuration and C_Spell.GetSpellCharges(spellID)
	if charges and charges.isActive then return GetSpellChargeDuration(spellID) end
	return GetSpellCooldownDuration(spellID, true)
end

-- Is this spell's cooldown really running? The old heuristic treated
-- 'duration has secret values' as running - correct on 12.0, but 12.1 marks
-- cooldown data secret for EVERY spell in combat, which false-armed trackers
-- on idle spells and popped them on combat enter/leave. Branch on the
-- NeverSecret booleans instead (isActive / isOnGCD are always readable).
local function CdDurationRunning(spellID, duration)
	if not duration then return false end
	local charges = C_Spell.GetSpellCharges and C_Spell.GetSpellCharges(spellID)
	if charges and charges.isActive then return true end
	local info = C_Spell.GetSpellCooldown and C_Spell.GetSpellCooldown(spellID)
	if info then
		return (info.isActive and not info.isOnGCD) and true or false
	end
	-- no cooldown info at all: fall back to the duration object, but never
	-- interpret secrecy as running
	if duration:HasSecretValues() then return false end
	return not duration:IsZero()
end

local function CdUpdateTracker(spellID, castTime)
	if cdPreview or not GetCdCfg().enable or not CdIsWatched(spellID) then return end
	if not GetSpellCooldownDuration then return end
	if CdIsDynamic(spellID) then
		local info = C_Spell.GetSpellCooldown(spellID)
		local onCD = (info and info.isActive and not info.isOnGCD) or false
		local st = cdState[spellID]
		if not st then st = {} cdState[spellID] = st end
		local was = st.onCD
		st.onCD = onCD
		local frame = cdTrackers[spellID]
		if frame and frame._tracking then
			frame._tracking = nil
			frame:Clear()
		end
		if onCD then
			if st.active then
				st.token = (st.token or 0) + 1
				st.active = nil
				UpdateCdDisplay()
			end
		elseif was then
			CdDebug('dynamic ready:', spellID, CdSpellName(spellID))
			CdTriggerPopup(spellID)
		end
		return
	end
	local duration = CdFetchDuration(spellID)
	local running = CdDurationRunning(spellID, duration)
	local frame = cdTrackers[spellID]
	if not frame then
		if not running then return end
		if not cdBucket then
			cdBucket = CreateFrame('Frame', nil, PARENT)
			cdBucket:SetSize(1, 1)
			cdBucket:SetPoint('BOTTOMLEFT', PARENT, 'BOTTOMLEFT', -10000, -10000)
			cdBucket:Show()
		end
		frame = CreateFrame('Cooldown', nil, cdBucket, 'CooldownFrameTemplate')
		frame:SetAllPoints(cdBucket)
		frame:SetDrawSwipe(false)
		frame:SetDrawEdge(false)
		frame:SetDrawBling(false)
		frame:SetHideCountdownNumbers(true)
		frame.noCooldownCount = true
		frame._spellID = spellID
		frame:SetScript('OnCooldownDone', CdOnCooldownDone)
		cdTrackers[spellID] = frame
	end
	if castTime then frame._castTime = castTime end
	if running then
		local firstArm = not frame._tracking
		frame._tracking = true
		frame:SetCooldownFromDurationObject(duration)
		local st = cdState[spellID]
		if st and st.active then
			st.token = (st.token or 0) + 1
			st.active = nil
			UpdateCdDisplay()
		end
		if firstArm then
			CdDebug('tracker armed:', spellID, CdSpellName(spellID), '- secret:', duration:HasSecretValues() and 'yes' or 'no')
		end
	elseif frame._tracking then
		frame._tracking = nil
		frame:Clear()
		CdDebug('tracker reset early:', spellID, CdSpellName(spellID))
		CdTriggerPopup(spellID)
	end
end

local function ApplyCdPulseSettings()
	if not cdFrame then return end
	local cfg = GetCdCfg()
	cfg.pos = cfg.pos or { x = 0, y = 0 }
	if cfg.spells then
		if #cfg.spells > 0 then
			local list = GetCdSpells()
			local have = {}
			for _, id in ipairs(list) do have[id] = true end
			for _, id in ipairs(cfg.spells) do
				if not have[id] then table.insert(list, id) end
			end
		end
		cfg.spells = nil
	end
	cdFrame:ClearAllPoints()
	cdFrame:SetPoint('CENTER', PARENT, 'CENTER', cfg.pos.x or 0, cfg.pos.y or 0)
	local spells = GetCdSpells()
	local keep = {}
	for _, id in ipairs(spells) do keep[id] = true end
	for id in pairs(cdState) do
		if not keep[id] then cdState[id] = nil end
	end
	if cdShownID and not keep[cdShownID] then cdShownID = nil end
	local icon = EnsureCdIcon()
	local playing = icon.pulse:IsPlaying()
	icon.pulse:Stop()
	icon.pulseAnim:SetScaleTo(cfg.pulseScale or 1.3, cfg.pulseScale or 1.3)
	icon.pulseAnim:SetDuration(cfg.pulseSpeed or 0.3)
	icon.fadeAlpha:SetDuration(cfg.fadeOut or 0.3)
	icon.fadeScale:SetScaleTo(cfg.fadeScale or 2, cfg.fadeScale or 2)
	icon.fadeScale:SetDuration(cfg.fadeOut or 0.3)
	if playing and icon:IsShown() and cfg.pulse then icon.pulse:Play() end
	if (cfg.enable and #spells > 0) or cdPreview then
		cdFrame:Show()
	else
		icon.fadeOut:Stop()
		icon.pulse:Stop()
		icon:Hide()
		cdShownID = nil
		cdFrame:Hide()
	end
	UpdateCdDisplay()
end

local function SetCdPreview(state)
	cdPreview = state
	if not cdFrame then return end
	cdFrame:EnableMouse(state)
	if not state and cdIcon then
		cdIcon.pulse:Stop()
		cdIcon.fadeOut:Stop()
		cdIcon:Hide()
		cdShownID = nil
	end
	ApplyCdPulseSettings()
end

local function CreateCdPulse()
	cdFrame = CreateFrame('Frame', 'KiraUICooldownPulse', PARENT)
	cdFrame:SetFrameStrata('HIGH')
	cdFrame:SetClampedToScreen(true)
	cdFrame:SetMovable(true)
	cdFrame:EnableMouse(false)
	cdFrame:RegisterForDrag('RightButton')
	cdFrame:Hide()
	cdFrame:SetScript('OnDragStart', function(self)
		if GetCdCfg().lock then return end
		self:StartMoving()
	end)
	cdFrame:SetScript('OnDragStop', function(self)
		self:StopMovingOrSizing()
		local cx, cy = self:GetCenter()
		local ux, uy = PARENT:GetCenter()
		local cfg = GetCdCfg()
		cfg.pos = cfg.pos or {}
		cfg.pos.x = Round(cx - ux)
		cfg.pos.y = Round(cy - uy)
		ApplyCdPulseSettings()
	end)
	local f = CreateFrame('Frame')
	f:RegisterUnitEvent('PLAYER_SPECIALIZATION_CHANGED', 'player')
	f:RegisterEvent('PLAYER_ENTERING_WORLD')
	f:SetScript('OnEvent', function()
		wipe(cdState)
		wipe(cdUsable)
		ApplyCdPulseSettings()
	end)
	local caster = CreateFrame('Frame')
	caster:RegisterUnitEvent('UNIT_SPELLCAST_SUCCEEDED', 'player')
	caster:SetScript('OnEvent', function(_, _, _, _, spellID)
		if cdPreview or not GetCdCfg().enable then return end
		local castTime = GetTime()
		local secretID = (issecretvalue and issecretvalue(spellID)) or spellID == nil
		if not secretID then
			if not CdIsWatched(spellID) then return end
			CdDebug('cast seen:', spellID, CdSpellName(spellID))
			C_Timer.After(0.2, function() CdUpdateTracker(spellID, castTime) end)
		else
			CdDebug('cast seen: SECRET id - scanning watched list')
			C_Timer.After(0.2, function()
				for _, id in ipairs(GetCdSpells()) do CdUpdateTracker(id, castTime) end
			end)
		end
	end)
	local pendingSync
	local function SyncTrackers()
		pendingSync = nil
		if cdPreview or not GetCdCfg().enable then return end
		for _, id in ipairs(GetCdSpells()) do CdUpdateTracker(id) end
	end
	-- Talent / hero-tree / spec changes all invalidate "do I have this".
	-- Registration is pcall'd per event: an event name this client does not
	-- have would otherwise raise and abort the rest of this function.
	local talents = CreateFrame('Frame')
	for _, e in ipairs({
		'SPELLS_CHANGED', 'TRAIT_CONFIG_UPDATED', 'ACTIVE_COMBAT_CONFIG_CHANGED',
		'PLAYER_TALENT_UPDATE', 'ACTIVE_TALENT_GROUP_CHANGED',
	}) do
		pcall(talents.RegisterEvent, talents, e)
	end
	local pendingTalents
	talents:SetScript('OnEvent', function()
		-- SPELLS_CHANGED alone fires in bursts; one pass per burst is plenty
		if pendingTalents then return end
		pendingTalents = true
		C_Timer.After(0.5, function()
			pendingTalents = nil
			CdForgetUsable()
		end)
	end)

	local updater = CreateFrame('Frame')
	updater:RegisterEvent('SPELL_UPDATE_COOLDOWN')
	updater:RegisterEvent('SPELL_UPDATE_CHARGES')
	updater:SetScript('OnEvent', function()
		if pendingSync then return end
		pendingSync = true
		C_Timer.After(0.1, SyncTrackers)
	end)
end

------------------------------------------------------------------------
-- Feature 5: spell history
------------------------------------------------------------------------
local FALLBACK_ICON = 134400

local historyFrame
local historyIcons = {}
local historyTextures = {}
local historyPreview = false

local function GetHistoryCfg() return GetCfg().history end

local function LayoutHistoryIcons()
	local cfg = GetHistoryCfg()
	local size = cfg.iconSize or 36
	local gap = cfg.spacing or 4
	local count = cfg.count or 5
	local dir = cfg.direction or 'RIGHT'
	-- Each icon has a 1px border overhanging per side (visible width = size + 2),
	-- so step by size + 2 + gap to make the spacing value the literal pixel gap
	-- between borders (spacing 1 = 1px), not 2px narrower.
	local step = size + 2 + gap
	if dir == 'UP' or dir == 'DOWN' then
		historyFrame:SetSize(size, count * size + (count - 1) * (gap + 2))
	else
		historyFrame:SetSize(count * size + (count - 1) * (gap + 2), size)
	end
	for i, icon in next, historyIcons do
		icon:SetSize(size, size)
		if icon.fail then icon.fail:SetSize(size * 0.7, size * 0.7) end
		icon:ClearAllPoints()
		local offset = (i - 1) * step
		if dir == 'LEFT' then icon:SetPoint('RIGHT', historyFrame, 'RIGHT', -offset, 0)
		elseif dir == 'UP' then icon:SetPoint('BOTTOM', historyFrame, 'BOTTOM', 0, offset)
		elseif dir == 'DOWN' then icon:SetPoint('TOP', historyFrame, 'TOP', 0, -offset)
		else icon:SetPoint('LEFT', historyFrame, 'LEFT', offset, 0) end
		if cfg.fade then icon:SetAlpha(1 - ((i - 1) / count) * 0.65) else icon:SetAlpha(1) end
		icon:SetShown(i <= count)
	end
end

local function EnsureHistoryIcons()
	local count = GetHistoryCfg().count or 5
	for i = #historyIcons + 1, count do
		local icon = CreateFrame('Frame', nil, historyFrame)
		icon.border = icon:CreateTexture(nil, 'BACKGROUND')
		icon.border:SetColorTexture(0, 0, 0, 1)
		icon.border:SetAllPoints()
		icon.tex = icon:CreateTexture(nil, 'ARTWORK')
		icon.tex:SetPoint('TOPLEFT', 1, -1)
		icon.tex:SetPoint('BOTTOMRIGHT', -1, 1)
		icon.tex:SetTexCoord(0.08, 0.92, 0.08, 0.92)
		icon.fail = icon:CreateTexture(nil, 'OVERLAY')
		icon.fail:SetTexture('Interface\\RaidFrame\\ReadyCheck-NotReady')
		icon.fail:SetPoint('CENTER')
		icon.fail:Hide()
		icon:Hide()
		historyIcons[i] = icon
	end
end

local function RedrawHistory()
	local count = GetHistoryCfg().count or 5
	for i = 1, #historyIcons do
		local icon = historyIcons[i]
		local entry = historyTextures[i]
		if i <= count and entry then
			icon.tex:SetTexture(entry.tex)
			icon.fail:SetShown(entry.failed and true or false)
			icon:Show()
		else
			icon:Hide()
		end
	end
end

-- Spell-history cast tracking. One cast = one entry tracked by castGUID through
-- its whole lifecycle (casting -> success / failed / interrupted), with channel +
-- instant handling, override-resolved icons, and an interrupt-vs-success race fix.
-- castGUIDs/spellIDs can be SECRET (e.g. in M+); secret GUIDs fall back to a single
-- legacy slot and secret spells show the fallback icon, never compared.
local pendingByGUID = {}     -- non-secret castGUID -> entry
local pendingLegacy           -- single slot fallback when the GUID is secret/nil
local activeChannelSpell      -- non-secret spellID of the active channel

local function HistResolveOverride(spellID)
	if not spellID or (issecretvalue and issecretvalue(spellID)) then return spellID end
	if C_Spell and C_Spell.GetOverrideSpell then
		local ov = C_Spell.GetOverrideSpell(spellID)
		if ov and ov ~= 0 and ov ~= spellID then return ov end
	end
	if FindSpellOverrideByID then
		local ov = FindSpellOverrideByID(spellID)
		if ov and ov ~= 0 and ov ~= spellID then return ov end
	end
	return spellID
end

local function HistTex(spellID)
	if spellID then
		local result = C_Spell.GetSpellTexture(spellID)
		if result and not (issecretvalue and issecretvalue(result)) then return result end
	end
	return FALLBACK_ICON
end

-- The icon strip reads entry.tex + entry.failed; keep failed in sync with status.
local function SetEntryStatus(entry, status)
	entry.status = status
	entry.failed = (status == 'failed' or status == 'interrupted')
	RedrawHistory()
end

local function MakeHistEntry(spellID, status, isChannel)
	local safeID = (spellID and not (issecretvalue and issecretvalue(spellID))) and spellID or nil
	return {
		tex     = HistTex(spellID),
		spellID = safeID,
		status  = status,
		failed  = (status == 'failed' or status == 'interrupted'),
		channel = isChannel or false,
		timestamp = GetTime(),
	}
end

local function PushHistEntry(entry)
	table.insert(historyTextures, 1, entry)
	-- keep a little slack beyond the visible count so a late status fix can still
	-- find a just-trimmed entry.
	local keep = math.max(GetHistoryCfg().count or 5, 8)
	for i = #historyTextures, keep + 1, -1 do historyTextures[i] = nil end
	RedrawHistory()
	return entry
end

local function HistSafeGUID(guid)
	if guid and not (issecretvalue and issecretvalue(guid)) then return guid end
	return nil
end
local function HistStorePending(guid, entry)
	local key = HistSafeGUID(guid)
	if key then pendingByGUID[key] = entry else pendingLegacy = entry end
end
local function HistGetPending(guid)
	local key = HistSafeGUID(guid)
	if key then return pendingByGUID[key] end
	return pendingLegacy
end
local function HistClearPending(guid)
	local key = HistSafeGUID(guid)
	if key then pendingByGUID[key] = nil else pendingLegacy = nil end
end
local function HistFinishPending(guid, status)
	local entry = HistGetPending(guid)
	if not entry then return end
	-- don't downgrade a success the server already accepted
	if entry.status ~= 'success' then SetEntryStatus(entry, status) end
	HistClearPending(guid)
end

local function ResetHistTracking()
	wipe(pendingByGUID)
	pendingLegacy = nil
	activeChannelSpell = nil
end

local function ApplyHistorySettings()
	if not historyFrame then return end
	local cfg = GetHistoryCfg()
	cfg.pos = cfg.pos or { x = 0, y = -340 }
	historyFrame:ClearAllPoints()
	historyFrame:SetPoint('CENTER', PARENT, 'CENTER', cfg.pos.x or 0, cfg.pos.y or -340)
	EnsureHistoryIcons()
	LayoutHistoryIcons()
	if historyPreview then
		for i = 1, (cfg.count or 5) do
			historyTextures[i] = historyTextures[i] or { tex = FALLBACK_ICON, failed = (i % 3 == 0) }
		end
	end
	RedrawHistory()
	historyFrame:SetShown(cfg.enable or historyPreview)
end

local function CreateSpellHistory()
	historyFrame = CreateFrame('Frame', 'KiraUISpellHistory', PARENT)
	historyFrame:SetFrameStrata('MEDIUM')
	historyFrame:SetClampedToScreen(true)
	historyFrame:SetMovable(true)
	historyFrame:EnableMouse(false)
	historyFrame:RegisterForDrag('RightButton')
	historyFrame:SetScript('OnDragStart', function(self)
		if GetHistoryCfg().lock then return end
		self:StartMoving()
	end)
	historyFrame:SetScript('OnDragStop', function(self)
		self:StopMovingOrSizing()
		local cx, cy = self:GetCenter()
		local ux, uy = PARENT:GetCenter()
		local cfg = GetHistoryCfg()
		cfg.pos = cfg.pos or {}
		cfg.pos.x = Round(cx - ux)
		cfg.pos.y = Round(cy - uy)
		ApplyHistorySettings()
	end)
	local f = CreateFrame('Frame')
	f:RegisterUnitEvent('UNIT_SPELLCAST_START', 'player')
	f:RegisterUnitEvent('UNIT_SPELLCAST_CHANNEL_START', 'player')
	f:RegisterUnitEvent('UNIT_SPELLCAST_SUCCEEDED', 'player')
	f:RegisterUnitEvent('UNIT_SPELLCAST_FAILED', 'player')
	f:RegisterUnitEvent('UNIT_SPELLCAST_FAILED_QUIET', 'player')
	f:RegisterUnitEvent('UNIT_SPELLCAST_INTERRUPTED', 'player')
	f:RegisterUnitEvent('UNIT_SPELLCAST_STOP', 'player')
	f:RegisterUnitEvent('UNIT_SPELLCAST_CHANNEL_STOP', 'player')
	f:SetScript('OnEvent', function(_, event, unit, castGUID, spellID)
		if historyPreview then return end
		if not GetHistoryCfg().enable then return end

		if event == 'UNIT_SPELLCAST_START' then
			local entry = MakeHistEntry(HistResolveOverride(spellID), 'casting', false)
			HistStorePending(castGUID, entry)
			PushHistEntry(entry)

		elseif event == 'UNIT_SPELLCAST_CHANNEL_START' then
			local entry = MakeHistEntry(HistResolveOverride(spellID), 'channeling', true)
			activeChannelSpell = entry.spellID
			HistStorePending(castGUID, entry)
			PushHistEntry(entry)

		elseif event == 'UNIT_SPELLCAST_SUCCEEDED' then
			local entry = HistGetPending(castGUID)
			if entry then
				SetEntryStatus(entry, 'success')
				HistClearPending(castGUID)
			else
				local sid = (spellID and not (issecretvalue and issecretvalue(spellID))) and spellID or nil
				-- channel ticks repeatedly fire SUCCEEDED during a channel; ignore them
				if sid and activeChannelSpell and sid == activeChannelSpell then return end
				-- instant cast (no START): only track the player's real/known spells
				if sid and IsPlayerSpell and not IsPlayerSpell(sid) then
					if not (IsSpellKnownOrOverridesKnown and IsSpellKnownOrOverridesKnown(sid)) then return end
				end
				-- race: a trailing SUCCEEDED after INTERRUPTED/FAILED for the same
				-- spell -> promote that recent entry back to success (no duplicate).
				if sid then
					for i = 1, math.min(#historyTextures, 5) do
						local he = historyTextures[i]
						if he.spellID == sid and (he.status == 'interrupted' or he.status == 'failed') then
							SetEntryStatus(he, 'success')
							return
						end
					end
				end
				PushHistEntry(MakeHistEntry(HistResolveOverride(spellID), 'success', false))
			end

		elseif event == 'UNIT_SPELLCAST_FAILED' or event == 'UNIT_SPELLCAST_FAILED_QUIET' then
			HistFinishPending(castGUID, 'failed')

		elseif event == 'UNIT_SPELLCAST_INTERRUPTED' then
			HistFinishPending(castGUID, 'interrupted')

		elseif event == 'UNIT_SPELLCAST_STOP' then
			local entry = HistGetPending(castGUID)
			if entry and entry.status == 'casting' then
				-- defer one frame so a trailing SUCCEEDED (which can arrive just after
				-- STOP) wins and this is not wrongly marked failed.
				local guid = castGUID
				C_Timer.After(0, function()
					local e = HistGetPending(guid)
					if e and e.status == 'casting' then HistFinishPending(guid, 'failed') end
				end)
			end

		elseif event == 'UNIT_SPELLCAST_CHANNEL_STOP' then
			activeChannelSpell = nil
			local entry = HistGetPending(castGUID)
			if entry then
				if entry.status == 'channeling' then SetEntryStatus(entry, 'success') end
				HistClearPending(castGUID)
			end
		end
	end)
end

local function SetHistoryPreview(state)
	historyPreview = state
	if not historyFrame then return end
	historyFrame:EnableMouse(state)
	wipe(historyTextures)
	ResetHistTracking()
	ApplyHistorySettings()
end

------------------------------------------------------------------------
-- Feature 6: M+ break timer
------------------------------------------------------------------------
local function GetBreakCfg() return GetCfg().breakTimer end

local function EnsureBreakData()
	if not charDB then return end
	local today = date('%Y-%m-%d')
	local data = charDB.breaks
	if not data then data = {} charDB.breaks = data end
	if data.date ~= today then
		data.date = today
		data.keys = 0
		data.breakCount = 0
		data.totalBreak = 0
	end
	return data
end

local function FormatMinutes(seconds)
	seconds = math.floor(seconds or 0)
	return format('%dm %02ds', math.floor(seconds / 60), seconds % 60)
end

function MarkBreakStart()
	if not charDB then return end
	EnsureBreakData()
	if not charDB.breakStart then charDB.breakStart = time() end
end

local function PrintBreakSummary()
	local data = EnsureBreakData()
	if not data then return end
	Msg(format('M+ break stats for |cff69ccf0%s|r:', data.date))
	print(format('  keys started today: |cff69ccf0%d|r', data.keys or 0))
	print(format('  breaks measured: |cff69ccf0%d|r', data.breakCount or 0))
	print(format('  total break time: |cff69ccf0%s|r', FormatMinutes(data.totalBreak)))
	if (data.breakCount or 0) > 0 then
		print(format('  average break: |cff69ccf0%s|r', FormatMinutes((data.totalBreak or 0) / data.breakCount)))
	end
end

local function CreateBreakTimer()
	local f = CreateFrame('Frame')
	f:RegisterEvent('CHALLENGE_MODE_START')
	f:RegisterEvent('CHAT_MSG_SYSTEM')
	f:SetScript('OnEvent', function(_, event, arg1)
		if not charDB then return end
		if event == 'CHAT_MSG_SYSTEM' then
			if issecretvalue and issecretvalue(arg1) then return end
			if not arg1 then return end
			if string.match(arg1, RESET_PATTERN) then MarkBreakStart() end
		elseif event == 'CHALLENGE_MODE_START' then
			local data = EnsureBreakData()
			if not data then return end
			data.keys = (data.keys or 0) + 1
			if charDB.breakStart and GetBreakCfg().enable then
				local breakTime = time() - charDB.breakStart
				data.breakCount = (data.breakCount or 0) + 1
				data.totalBreak = (data.totalBreak or 0) + breakTime
				Msg(format('break before this key: |cff69ccf0%s|r (today: %d keys, %s total break)',
					FormatMinutes(breakTime), data.keys, FormatMinutes(data.totalBreak)))
			end
			charDB.breakStart = nil
		end
	end)
end

------------------------------------------------------------------------
-- Status print
------------------------------------------------------------------------
local function DebugStatus()
	Msg('status:')
	print('  ElvUI detected:', hasElvUI and 'yes' or 'no')
	print('  reset button:', resetBtn and (resetBtn:IsShown() and 'shown' or 'hidden') or 'NOT CREATED')
	print('  cooldown pulse:', cdFrame and (cdFrame:IsShown() and 'active' or 'idle') or 'NOT CREATED', '-', #GetCdSpells(), 'spells for', CdSpecLabel())
	for _, id in ipairs(GetCdSpells()) do
		-- an unavailable entry is kept in the list but never pulses, so say so
		print(('    %d %s - %s'):format(id, CdSpellName(id),
			CdIsUsable(id) and 'available'
				or "|cffff5555not learned - ignored until you talent it|r"))
	end
	print('  duration API:', GetSpellCooldownDuration and 'OK' or 'MISSING (needs 12.0+)')
	print('  spell history:', historyFrame and (historyFrame:IsShown() and 'shown' or 'hidden') or 'NOT CREATED')
end

------------------------------------------------------------------------
-- Expose the shared API on the addon namespace (used by the config window)
------------------------------------------------------------------------
-- Single entry point for opening the settings window, so callers do not each
-- have to guard on ns.OpenConfig existing.
function ns.ShowConfig(page)
	if page and ns.ShowConfigPage then ns.ShowConfigPage(page)
	elseif ns.OpenConfig then ns.OpenConfig()
	else Msg('config window failed to load.') end
end

ns.hasElvUI = hasElvUI
ns.Msg = Msg
ns.GetCfg = GetCfg
ns.GetResetCfg = GetResetCfg
ns.GetCdCfg = GetCdCfg
ns.GetHistoryCfg = GetHistoryCfg
ns.GetBreakCfg = GetBreakCfg
ns.ApplyResetSettings = ApplyResetSettings
ns.ApplyCdPulseSettings = ApplyCdPulseSettings
ns.ApplyHistorySettings = ApplyHistorySettings
ns.UpdateAbsorbs = UpdateAbsorbs
ns.UpdateResetVisibility = UpdateResetVisibility
ns.SetCdPreview = SetCdPreview
ns.SetHistoryPreview = SetHistoryPreview
ns.PrintBreakSummary = PrintBreakSummary
ns.EnsureBreakData = EnsureBreakData
ns.GetCdSpells = GetCdSpells
ns.GetCdDynamic = GetCdDynamic
ns.CdIsDynamic = CdIsDynamic
ns.CdIsUsable = CdIsUsable
ns.CdResolveSpellID = CdResolveSpellID
ns.CdSpellName = CdSpellName
ns.CdSpellTexture = CdSpellTexture
ns.CdSpecLabel = CdSpecLabel
ns.ClearCdState = function(id) if id then cdState[id] = nil else wipe(cdState) end end
ns.GetCharDB = function() return charDB end
ns.IsResetPreview = function() return resetPreview end
ns.ToggleResetPreview = function() resetPreview = not resetPreview UpdateResetVisibility() return resetPreview end
ns.IsCdPreview = function() return cdPreview end
ns.IsHistoryPreview = function() return historyPreview end

------------------------------------------------------------------------
-- Slash commands (window-first; a few text shortcuts kept)
------------------------------------------------------------------------
local function ParseBool(s)
	if not s then return nil end
	s = s:lower()
	if s == 'on' or s == '1' or s == 'true' or s == 'enable' or s == 'enabled' then return true end
	if s == 'off' or s == '0' or s == 'false' or s == 'disable' or s == 'disabled' then return false end
	return nil
end

local function HandleSlash(msg)
	msg = msg or ''
	local cmd, rest = msg:match('^%s*(%S*)%s*(.-)%s*$')
	cmd = cmd and cmd:lower() or ''
	local sub, rest2 = rest:match('^(%S*)%s*(.-)%s*$')
	if sub == '' then sub = nil end

	if cmd == '' or cmd == 'config' or cmd == 'options' then
		ns.ShowConfig()
	elseif cmd == 'status' then
		DebugStatus()
	elseif cmd == 'cd' and sub == 'add' then
		local id = CdResolveSpellID(rest2 and rest2:trim())
		if not id then Msg('spell not found: ' .. tostring(rest2)) return end
		local spells = GetCdSpells()
		for _, v in ipairs(spells) do if v == id then return end end
		spells[#spells + 1] = id
		ApplyCdPulseSettings()
		Msg(format('[%s] now watching %s (%d)', CdSpecLabel(), CdSpellName(id), id))
	elseif cmd == 'cd' and (sub == 'remove' or sub == 'rem') then
		local id = CdResolveSpellID(rest2 and rest2:trim())
		for i, v in ipairs(GetCdSpells()) do
			if v == id then table.remove(GetCdSpells(), i) GetCdDynamic()[id] = nil break end
		end
		ApplyCdPulseSettings()
		Msg('removed.')
	elseif cmd == 'cd' and sub == 'debug' then
		KC.cdDebug = not KC.cdDebug
		Msg('cd debug: ' .. (KC.cdDebug and 'ON' or 'OFF'))
	elseif cmd == 'break' and sub == 'summary' then
		PrintBreakSummary()
	elseif cmd == 'help' or cmd == '?' then
		Msg('commands:')
		print('  /kiraplugin            - open the settings window')
		print('  /kiraplugin status     - print current state')
		print('  /kiraplugin cd add <id|name>     - watch a spell')
		print('  /kiraplugin cd remove <id|name>  - stop watching')
		print('  /kiraplugin cd debug             - toggle debug prints')
		print('  /kiraplugin break summary        - M+ break stats')
		print('  (everything else is in the window)')
	else
		ns.ShowConfig()
	end
end

------------------------------------------------------------------------
-- Lifecycle
------------------------------------------------------------------------
-- Profiles -------------------------------------------------------------
-- Every module reads its settings from the root of KiraUIChangesDB. A profile is
-- a named snapshot of all those settings. Switching copies the snapshot back over
-- the root and re-applies every module, so you can keep e.g. a 1440p and a 1080p
-- layout and flip between them. The active profile name is ACCOUNT-WIDE
-- (db.__activeProfile): pick a profile once and every character/class uses it
-- on login. '__' keys are excluded from snapshots and exports by FeatureKeys.
local PROFILE_DEFAULT = 'Default'

-- Root keys that are feature settings (everything except the profile store and
-- any __meta keys).
local function FeatureKeys(db)
	local keys = {}
	for k in pairs(db) do
		if k ~= 'kiraProfiles' and not (type(k) == 'string' and k:sub(1, 2) == '__') then
			keys[#keys + 1] = k
		end
	end
	return keys
end

local function SnapshotToProfile(name)
	local db = _G.KiraUIChangesDB
	if not db or not name then return end
	db.kiraProfiles = db.kiraProfiles or {}
	local snap = {}
	for _, k in ipairs(FeatureKeys(db)) do snap[k] = DeepCopy(db[k]) end
	db.kiraProfiles[name] = snap
end

local function LoadProfileIntoRoot(name)
	local db = _G.KiraUIChangesDB
	local prof = db and db.kiraProfiles and db.kiraProfiles[name]
	if not prof then return end
	for _, k in ipairs(FeatureKeys(db)) do db[k] = nil end
	for k, v in pairs(prof) do db[k] = DeepCopy(v) end
	DeepMerge(db, defaults) -- heal any keys an older profile is missing
	-- every feature table above was replaced, so the modules' cached handles
	-- now point at the profile we just left
	ns.InvalidateModuleCfg()
end

function ns.GetCurrentProfile()
	local db = _G.KiraUIChangesDB
	if db and db.__activeProfile then return db.__activeProfile end
	-- migration: older versions stored the active profile per character
	local c = _G.KiraUIChangesCharDB
	return (c and c.profile) or PROFILE_DEFAULT
end

function ns.GetProfileNames()
	local db = _G.KiraUIChangesDB
	local out = {}
	if db and db.kiraProfiles then
		for name in pairs(db.kiraProfiles) do out[#out + 1] = name end
	end
	table.sort(out)
	return out
end

-- Re-apply every module after the root settings change wholesale.
function ns.ApplyAllSettings()
	local names = {
		'UpdateAbsorbs', 'ApplyResetSettings', 'ApplyCdPulseSettings', 'ApplyHistorySettings',
		'ExtApply', 'CTApply', 'BWApply', 'CHApply', 'SSApply', 'RMApply', 'UUFApply', 'BLApply', 'PTApply', 'RecupApply', 'ELApply', 'CDSApply', 'SLApply', 'KVApply', 'INSApply', 'RCApply',
		'FBApply', 'MageApply', 'CUApply', 'TSApply', 'CDSColorApply', 'CDSStackOnesApply', 'ToSApply', 'TRApply',
		'EUIApplyFocusMark', 'EUIRefreshTarget', 'EUIApplyDebuffColors',
		'EUIApplyFocusRange', 'EUIApplyNameWidth',
		'EUIIntApply', 'EUINPRefresh', 'EUINPApplyNameAlpha', 'EUINPApplyArrow',
	}
	for _, n in ipairs(names) do
		local f = ns[n]
		if type(f) == 'function' then pcall(f) end
	end
end

function ns.SwitchProfile(name)
	local db = _G.KiraUIChangesDB
	if not db or not name then return end
	local cur = ns.GetCurrentProfile()
	if name == cur then return end
	db.kiraProfiles = db.kiraProfiles or {}
	SnapshotToProfile(cur)                                       -- save edits to the one we leave
	if not db.kiraProfiles[name] then SnapshotToProfile(name) end -- unknown -> seed from current
	LoadProfileIntoRoot(name)
	db.__activeProfile = name -- account-wide: every character follows
	ns.ApplyAllSettings()
end

-- New profile = a copy of the CURRENT settings, then switch to it.
function ns.NewProfile(name)
	local db = _G.KiraUIChangesDB
	if not db or not name or name == '' then return end
	db.kiraProfiles = db.kiraProfiles or {}
	SnapshotToProfile(ns.GetCurrentProfile())  -- keep the current one up to date
	SnapshotToProfile(name)                    -- the new one mirrors the current root
	db.__activeProfile = name
end

function ns.DeleteProfile(name)
	local db = _G.KiraUIChangesDB
	if not db or not db.kiraProfiles then return end
	if name == PROFILE_DEFAULT or name == ns.GetCurrentProfile() then return end
	db.kiraProfiles[name] = nil
end

-- Export / import ------------------------------------------------------
-- Portable profile strings via LibSerialize + LibDeflate
local EXPORT_PREFIX = '!KiraUI:1!'

local function GetSerializeLibs()
	local LS = _G.LibStub and _G.LibStub('LibSerialize', true)
	local LD = _G.LibStub and _G.LibStub('LibDeflate', true)
	return LS, LD
end

-- Returns the current profile's settings as a copy/paste string (or nil, err).
function ns.ExportCurrentProfile()
	local LS, LD = GetSerializeLibs()
	if not (LS and LD) then return nil, 'needs LibSerialize + LibDeflate' end
	local db = _G.KiraUIChangesDB
	if not db then return nil, 'no settings' end
	local data = {}
	for _, k in ipairs(FeatureKeys(db)) do data[k] = DeepCopy(db[k]) end
	local ok, serialized = pcall(function() return LS:Serialize(data) end)
	if not ok or not serialized then return nil, 'serialize failed' end
	local compressed = LD:CompressDeflate(serialized, { level = 9 })
	if not compressed then return nil, 'compress failed' end
	return EXPORT_PREFIX .. LD:EncodeForPrint(compressed)
end

-- Decode + deserialize a string -> settings table (or nil, err). No DB changes.
local function DecodeProfileString(str)
	local LS, LD = GetSerializeLibs()
	if not (LS and LD) then return nil, 'needs LibSerialize + LibDeflate' end
	if type(str) ~= 'string' then return nil, 'no string' end
	str = str:gsub('%s', '')
	local payload = str:match('^!KiraUI:%d+!(.+)$')
	if not payload then return nil, 'not a KiraUI profile string' end
	local decoded = LD:DecodeForPrint(payload)
	if not decoded then return nil, 'decode failed' end
	local decompressed = LD:DecompressDeflate(decoded)
	if not decompressed then return nil, 'decompress failed' end
	local ok, data = LS:Deserialize(decompressed)
	if not ok or type(data) ~= 'table' then return nil, 'deserialize failed' end
	return data
end

-- Import a string as a NEW profile and switch to it. Returns true or (false, err).
function ns.ImportProfile(str, name)
	local data, err = DecodeProfileString(str)
	if not data then return false, err end
	if not name or name == '' then name = 'Imported' end
	local db = _G.KiraUIChangesDB
	if not db then return false, 'no settings' end
	db.kiraProfiles = db.kiraProfiles or {}
	SnapshotToProfile(ns.GetCurrentProfile())  -- preserve current before switching
	db.kiraProfiles[name] = DeepCopy(data)
	LoadProfileIntoRoot(name)
	db.__activeProfile = name
	ns.ApplyAllSettings()
	return true, name
end

-- Public API so the KiraUI installer (or any addon) can import/export a profile
-- string without touching our private namespace.
_G.KiraUIPlugin = _G.KiraUIPlugin or {}
_G.KiraUIPlugin.OpenConfig = function(page) return ns.ShowConfig(page) end
_G.KiraUIPlugin.ImportProfile = function(str, name) return ns.ImportProfile(str, name) end
_G.KiraUIPlugin.ExportCurrentProfile = function() return ns.ExportCurrentProfile() end

local function Initialize()
	_G.KiraUIChangesDB = _G.KiraUIChangesDB or {}
	DeepMerge(_G.KiraUIChangesDB, defaults)

	_G.KiraUIChangesCharDB = _G.KiraUIChangesCharDB or {}
	charDB = _G.KiraUIChangesCharDB
	charDB.pending = charDB.pending or {}

	-- Profiles: the active profile is ACCOUNT-WIDE. First run (or first run
	-- after the per-character era) adopts this character's old choice, then
	-- every character follows the same profile from here on. The live root
	-- settings are account-wide too, so no reload is needed at login - the
	-- root IS the active profile's current state.
	do
		local db = _G.KiraUIChangesDB
		db.kiraProfiles = db.kiraProfiles or {}
		db.__activeProfile = db.__activeProfile or charDB.profile or PROFILE_DEFAULT
		charDB.profile = nil -- retire the per-character pointer
		if not db.kiraProfiles[db.__activeProfile] then SnapshotToProfile(db.__activeProfile) end
	end

	PARENT = (E and E.UIParent) or _G.UIParent

	-- Absorb tags + Configure_HealComm hook were registered at file load (above)
	-- so they were in place before ElvUI built its unitframes. Here we just
	-- refresh the player frame so the reversed-absorb bar gets created.
	if hasElvUI and calc and fullCurve then
		UpdateAbsorbs()
	end

	CreateResetButton()
	ApplyResetSettings()
	CreateCdPulse()
	ApplyCdPulseSettings()
	CreateSpellHistory()
	ApplyHistorySettings()
	CreateBreakTimer()

	-- Apply EVERY module now that SavedVariables actually exist.
	--
	-- Modules run their file-load setup before the DB is populated, so anything
	-- that gates on a saved setting reads the DEFAULTS at that point - and for
	-- a feature that is off by default, that means "off". Modules whose polling
	-- ticker is gated this way would then never start, because nothing asked
	-- them again once the real settings arrived. This is that second ask.
	ns.ApplyAllSettings()

	_G.SLASH_KIRAUIPLUGIN1 = '/kiraplugin'
	_G.SLASH_KIRAUIPLUGIN2 = '/kira'
	_G.SLASH_KIRAUIPLUGIN3 = '/kc'
	_G.SlashCmdList.KIRAUIPLUGIN = HandleSlash
end

local loader = CreateFrame('Frame')
loader:RegisterEvent('PLAYER_LOGIN')
loader:SetScript('OnEvent', function(self)
	self:UnregisterEvent('PLAYER_LOGIN')
	Initialize()
end)
