-- FontMagic.lua
local addonName = ...
-- IMPORTANT: WoW's Lua 5.1 parser has a hard local-variable limit per chunk/function.
-- Do not keep adding new top-level `local` variables in this file (practical cap ~200).
-- Reuse existing tables for helper state/functions (for example `frame.__fmMenuCtx`).

-- safe loader: C_AddOns.LoadAddOn (Retail ≥11.0.2) or legacy LoadAddOn
local safeLoadAddOn
if type(C_AddOns) == "table" and type(C_AddOns.LoadAddOn) == "function" then
    safeLoadAddOn = C_AddOns.LoadAddOn
elseif type(LoadAddOn) == "function" then
    safeLoadAddOn = LoadAddOn
else
    safeLoadAddOn = function() end
end
local ADDON_PATH = "Interface\\AddOns\\" .. addonName .. "\\"
-- Name of the optional addon supplying user fonts
local CUSTOM_ADDON = "FontMagicCustomFonts"
-- Default path for custom fonts shipped by the companion addon
local CUSTOM_PATH  = "Interface\\AddOns\\" .. CUSTOM_ADDON .. "\\Custom\\"

-- Try to load the companion addon so its global table becomes available
pcall(safeLoadAddOn, CUSTOM_ADDON)
-- If loaded, prefer its reported path in case the folder was moved/renamed
if type(FontMagicCustomFonts) == "table" and type(FontMagicCustomFonts.PATH) == "string" then
    CUSTOM_PATH = FontMagicCustomFonts.PATH
end
-- detect whether the BackdropTemplate mixin exists (Retail only)
local backdropTemplate = (BackdropTemplateMixin and "BackdropTemplate") or nil

-- String helpers ------------------------------------------------------------
local function __fmTrim(s)
    if type(s) ~= "string" then return "" end
    return (s:match("^%s*(.-)%s*$")) or s
end

local function __fmStripFontExt(fname)
    if type(fname) ~= "string" then return "" end
    local base = fname
    base = base:gsub("%.[Tt][Tt][Ff]$", ""):gsub("%.[Oo][Tt][Ff]$", ""):gsub("%.[Tt][Tt][Cc]$", "")
    base = base:gsub("%s+$", "")
    return base
end

-- Make font labels shorter by removing common style/weight suffixes like Bold/Italic/etc.
-- This only affects what users SEE in dropdowns; the underlying font file selection remains unchanged.
local __fmSTYLE_TOKENS = {
    ["regular"] = true, ["normal"] = true, ["book"] = true, ["roman"] = true,
    ["medium"] = true, ["semibold"] = true, ["demibold"] = true, ["demi"] = true, ["semi"] = true,
    ["bold"] = true, ["extrabold"] = true, ["ultrabold"] = true, ["extra"] = true, ["ultra"] = true,
    ["black"] = true, ["heavy"] = true,
    ["light"] = true, ["thin"] = true, ["hairline"] = true,
    ["italic"] = true, ["oblique"] = true,
    ["condensed"] = true, ["narrow"] = true, ["compressed"] = true,
    ["expanded"] = true, ["extended"] = true,
    ["outline"] = true,
}

local function __fmShortenFontLabel(label)
    if type(label) ~= "string" or label == "" then return "" end

    local s = label

    -- Strip extension if someone passed a filename by mistake
    s = s:gsub("%.[Tt][Tt][Ff]$", ""):gsub("%.[Oo][Tt][Ff]$", ""):gsub("%.[Tt][Tt][Cc]$", "")

    -- Normalize separators
    s = s:gsub("[_%-]+", " ")

    -- Add spaces for common CamelCase boundaries (e.g. CalibriBold -> Calibri Bold)
    s = s:gsub("([%l])([%u])", "%1 %2")
    s = s:gsub("([%a])(%d)", "%1 %2")
    s = s:gsub("(%d)([%a])", "%1 %2")

    -- Treat parentheses as plain separators (e.g. "Roboto (Bold)" -> "Roboto  Bold")
    s = s:gsub("[%(%)]", " ")

    -- Collapse whitespace
    s = s:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")

    -- Tokenize and remove trailing style tokens
    local tokens = {}
    for w in s:gmatch("%S+") do
        tokens[#tokens + 1] = w
    end

    local function isStyleToken(tok)
        if not tok then return false end
        local t = tok:lower():gsub("[^%w]+", "")
        if t == "" then return true end
        if __fmSTYLE_TOKENS[t] then return true end
        if t:match("^%d+$") then return true end
        return false
    end

    while #tokens > 0 and isStyleToken(tokens[#tokens]) do
        table.remove(tokens)
    end

    local out = table.concat(tokens, " ")
    if out == "" then
        -- If we stripped everything somehow, fall back to the normalized label
        out = s
    end
    return out
end


local function GetCVarInfoCompat(name)
    -- WoW has shipped multiple shapes for GetCVarInfo over the years:
    --  1) multiple return values (value, defaultValue, ...)
    --  2) a single info table with fields like .value and .defaultValue
    -- This normalizes both into (value, defaultValue, ...)
    if type(C_CVar) == "table" and type(C_CVar.GetCVarInfo) == "function" then
        local ok, a, b, c, d, e, f, g = pcall(C_CVar.GetCVarInfo, name)
        if ok then
            if type(a) == "table" then
                return a.value, a.defaultValue, a.isStoredServerAccount, a.isStoredServerCharacter, a.isLockedFromUser, a.isSecure, a.isReadOnly
            end
            return a, b, c, d, e, f, g
        end
    end
    if type(GetCVarInfo) == "function" then
        local ok, a, b, c, d, e, f, g = pcall(GetCVarInfo, name)
        if ok then
            return a, b, c, d, e, f, g
        end
    end
    return nil
end

local function GetCVarString(cvar)
    -- Prefer GetCVarInfo so we can fall back to the default value when the
    -- current value hasn't been initialized/synced yet (common with server-stored CVars).
    local value, defaultValue = GetCVarInfoCompat(cvar)
    if value ~= nil then
        return value
    end
    if defaultValue ~= nil then
        return defaultValue
    end

    if type(C_CVar) == "table" and type(C_CVar.GetCVar) == "function" then
        local ok, v = pcall(C_CVar.GetCVar, cvar)
        if ok and v ~= nil then
            return v
        end
    end

    if type(GetCVar) == "function" then
        local ok, v = pcall(GetCVar, cvar)
        if ok and v ~= nil then
            return v
        end
    end

    if type(C_CVar) == "table" and type(C_CVar.GetCVarDefault) == "function" then
        local ok, def = pcall(C_CVar.GetCVarDefault, cvar)
        if ok and def ~= nil then
            return def
        end
    end

    if type(GetCVarDefault) == "function" then
        local ok, def = pcall(GetCVarDefault, cvar)
        if ok and def ~= nil then
            return def
        end
    end

    return nil
end

local function DoesCVarExist(cvar)
    local value, defaultValue = GetCVarInfoCompat(cvar)
    if value ~= nil or defaultValue ~= nil then
        return true
    end

    if type(C_CVar) == "table" and type(C_CVar.GetCVarDefault) == "function" then
        local ok, def = pcall(C_CVar.GetCVarDefault, cvar)
        if ok and def ~= nil then
            return true
        end
    end

    if type(GetCVarDefault) == "function" then
        local ok, def = pcall(GetCVarDefault, cvar)
        if ok and def ~= nil then
            return true
        end
    end

    if type(C_CVar) == "table" and type(C_CVar.GetCVar) == "function" then
        local ok, v = pcall(C_CVar.GetCVar, cvar)
        if ok and v ~= nil then
            return true
        end
    end

    if type(GetCVar) == "function" then
        local ok, v = pcall(GetCVar, cvar)
        if ok and v ~= nil then
            return true
        end
    end

    return false
end

-- Console command / CVar resolution ------------------------------------------
-- In some Retail builds (notably late 12.0 hotfixes), a number of Floating Combat
-- Text settings appear as renamed CVars (often with a _v2 suffix) and/or as
-- console commands. To keep FontMagic working across Retail + Classic-era
-- clients, we resolve by consulting the console command list when available,
-- and fall back to traditional CVar queries otherwise.

local _consoleCommandIndex = nil  -- [commandName] = commandType (0 == CVar)

local function BuildConsoleCommandIndex()
    if _consoleCommandIndex then
        return _consoleCommandIndex
    end

    _consoleCommandIndex = {}

    local getAll = nil
    if type(C_Console) == "table" and type(C_Console.GetAllCommands) == "function" then
        getAll = C_Console.GetAllCommands
    elseif type(ConsoleGetAllCommands) == "function" then
        getAll = ConsoleGetAllCommands
    end

    if getAll then
        local ok, list = pcall(getAll)
        if ok and type(list) == "table" then
            for _, info in ipairs(list) do
                if type(info) == "table" then
                    local name = info.command or info.name
                    local ct = info.commandType
                    if type(name) == "string" then
                        _consoleCommandIndex[name] = ct
                    end
                elseif type(info) == "string" then
                    -- Very old clients sometimes return names only; assume CVar.
                    _consoleCommandIndex[info] = 0
                end
            end
        end
    end

    return _consoleCommandIndex
end

local function RefreshConsoleCommandIndex()
    _consoleCommandIndex = nil
    return BuildConsoleCommandIndex()
end

_G.FontMagicConsoleResolver = _G.FontMagicConsoleResolver or {}

function _G.FontMagicConsoleResolver:BuildNameVariants(name)
    local out, seen = {}, {}
    local function add(v)
        if type(v) ~= "string" or v == "" or seen[v] then return end
        seen[v] = true
        out[#out + 1] = v
    end

    if type(name) ~= "string" or name == "" then
        return out
    end

    local base = name:gsub("_[vV]%d+$", "")
    -- Prefer the newest discovered spelling for a setting family. If Blizzard
    -- adds another suffix later, this remains useful without another hardcoded
    -- rename pass.
    for v = 9, 2, -1 do
        add(base .. "_v" .. v)
        add(base .. "_V" .. v)
    end
    add(base)
    add(name)

    return out
end

function _G.FontMagicConsoleResolver:TryName(name)
    local idx = BuildConsoleCommandIndex()
    if type(name) ~= "string" or name == "" then return nil, nil end

    -- Prefer console index when available, but if the CVar API can see the name,
    -- treat it as a CVar. The console list can misclassify real CVars as commands.
    if idx and idx[name] ~= nil then
        if DoesCVarExist(name) then
            return name, 0
        end
        return name, idx[name]
    end

    -- Fallback: treat as CVar if the CVar API can see it.
    if DoesCVarExist(name) then
        return name, 0
    end

    return nil, nil
end

function _G.FontMagicConsoleResolver:ResolveBestName(name)
    for _, candidate in ipairs(self:BuildNameVariants(name)) do
        local n, ct = self:TryName(candidate)
        if n then return n, ct end
    end

    return nil, nil
end

local function ResolveConsoleSetting(cvarOrCandidates)
    -- Returns: (name, commandType) where commandType is from Enum.ConsoleCommandType
    -- (0 == CVar). Candidate lists are ordered by alias preference, while each
    -- alias family prefers the newest available _v# spelling on the current client.
    BuildConsoleCommandIndex()

    if type(cvarOrCandidates) == "string" then
        return _G.FontMagicConsoleResolver:ResolveBestName(cvarOrCandidates)
    end

    if type(cvarOrCandidates) == "table" then
        for _, candidate in ipairs(cvarOrCandidates) do
            local n, ct = _G.FontMagicConsoleResolver:ResolveBestName(candidate)
            if n then return n, ct end
        end
    end

    return nil, nil
end

local SetCVarString -- forward declaration (used before definition)
local function ApplyConsoleSetting(name, commandType, value)
    if not name then return end
    value = tostring(value)

    if commandType == nil or commandType == 0 then
        -- CVar
        SetCVarString(name, value)
        return
    end

    -- Console command/script (ConsoleExec is /console)
    if type(ConsoleExec) == "function" then
        pcall(ConsoleExec, name .. " " .. value, false)
    end
end

local function ResolveCVarName(cvarOrCandidates)
    -- Backwards-compatible wrapper: returns the resolved name (CVar OR console command),
    -- or nil if nothing appropriate can be found on this client.
    local n = ResolveConsoleSetting(cvarOrCandidates)
    return n
end

local function ResolveConsoleSettingTargets(cvarOrCandidates)
    local out, seen = {}, {}

    local function addResolved(name, commandType)
        if type(name) ~= "string" or name == "" then return end
        local key = name .. "#" .. tostring(commandType or "nil")
        if seen[key] then return end
        seen[key] = true
        out[#out + 1] = { name = name, commandType = commandType }
    end

    local function addFrom(candidate)
        for _, variant in ipairs(_G.FontMagicConsoleResolver:BuildNameVariants(candidate)) do
            local n, ct = _G.FontMagicConsoleResolver:TryName(variant)
            if n then addResolved(n, ct) end
        end
    end

    if type(cvarOrCandidates) == "string" then
        addFrom(cvarOrCandidates)
    elseif type(cvarOrCandidates) == "table" then
        for _, candidate in ipairs(cvarOrCandidates) do
            addFrom(candidate)
        end
    end

    return out
end

local function GetResolvedSettingNumber(candidates, fallback)
    local n = ResolveCVarName(candidates)
    if not n then return fallback end
    local v = tonumber(GetCVarString(n))
    if v == nil then return fallback end
    return v
end


SetCVarString = function(cvar, value)
    if type(C_CVar) == "table" and type(C_CVar.SetCVar) == "function" then
        local ok = pcall(C_CVar.SetCVar, cvar, value)
        if ok then return end
    end
    if type(SetCVar) == "function" then
        pcall(SetCVar, cvar, value)
    end
end

--[[
    ---------------------------------------------------------------------------
    Compatibility shim for Dragonflight/Midnight settings API (patch ≥10.0.0).

    Starting in Dragonflight (10.0) and continuing through the Midnight 12.0
    pre‑patch, Blizzard removed the old Interface Options panel APIs such as
    InterfaceOptions_AddCategory.  Addons must now register their option
    panels using the new Settings API.  To maintain backwards compatibility
    with older clients and automatically support retail 12.0, we define
    InterfaceOptions_AddCategory when it is missing.  The implementation
    mirrors an example from the official forums: when the function does not
    exist we construct a canvas layout category via Settings.RegisterCanvasLayoutCategory
    and register it using Settings.RegisterAddOnCategory.

    This wrapper returns the newly created category object so callers can
    inspect or store it if desired.  On older clients where the original
    InterfaceOptions_AddCategory exists, we simply call through.
    ---------------------------------------------------------------------------
--]]
do
    local originalAddCategory = InterfaceOptions_AddCategory
    if originalAddCategory == nil and type(Settings) == "table" and type(Settings.RegisterCanvasLayoutCategory) == "function" then
        -- Define a shim for InterfaceOptions_AddCategory using the Settings API
        InterfaceOptions_AddCategory = function(frame)
            -- Respect missing frame inputs
            if not frame then return end

            -- If this panel has a parent, register it as a sub‑category of
            -- the parent's settings entry.  Otherwise register a top‑level
            -- category.  The new API requires us to supply both a name and
            -- display name; we use frame.name for both as per the forums example
            --.
            local category, parentCategory
            if frame.parent and type(Settings.GetCategory) == "function" and type(Settings.RegisterCanvasLayoutSubcategory) == "function" then
                parentCategory = Settings.GetCategory(frame.parent)
                if parentCategory then
                    local subcategory, layout = Settings.RegisterCanvasLayoutSubcategory(parentCategory, frame, frame.name, frame.name)
                    subcategory.ID = frame.name
                    return subcategory
                end
            end
            -- Register a top‑level category.  RegisterAddOnCategory makes it
            -- appear in the "AddOns" section of the Settings panel
            category = select(1, Settings.RegisterCanvasLayoutCategory(frame, frame.name, frame.name))
            if category then
                category.ID = frame.name
                if type(Settings.RegisterAddOnCategory) == "function" then
                    Settings.RegisterAddOnCategory(category)
                end
            end
            return category
        end
    end
end
--[[
  ---------------------------------------------------------------------------
    Font Detection
    Instead of checking for numbered fonts like 1.ttf, 2.ttf, etc. we now look
    for actual font files organised into folders. Each folder corresponds to a
    themed group of fonts. The table below lists the relative font file names
    shipped with the addon. Detection is performed at load time so missing
    files are ignored gracefully.
  ---------------------------------------------------------------------------
--]]

-- SavedDB defaults
if not FontMagicDB then
    -- Default placement for the minimap button is the bottom-left corner
    -- (approximately 225 degrees around the minimap).  This avoids the
    -- initial position being hidden beneath default UI elements on a fresh
    -- installation, particularly in WoW Classic.
    FontMagicDB = { minimapAngle = 225, minimapHide = false }
elseif FontMagicDB.minimapAngle == nil then
    -- In case a previous version created the DB without this key, ensure a
    -- sensible default.
    FontMagicDB.minimapAngle = 225
    if FontMagicDB.minimapHide == nil then FontMagicDB.minimapHide = false end
elseif FontMagicDB.minimapHide == nil then
    -- Ensure the hide flag exists for old DB versions
    FontMagicDB.minimapHide = false
end
-- Favorites (account-wide): a set of saved font keys like "Fun/Pepsi.ttf".
-- Stored in the main DB so favorites persist across characters.
if type(FontMagicDB.favorites) ~= "table" then
    FontMagicDB.favorites = {}
end

-- Debug (off by default). Stored account-wide so it can be toggled quickly on any character.
if FontMagicDB.__fmDebugCombatText == nil then
    FontMagicDB.__fmDebugCombatText = false
end

FontMagicPCDB = FontMagicPCDB or {}

-- forward declare so slash commands can toggle visibility before creation
local minimapButton

-- Combat text settings are account-wide in WoW (CVars), so store overrides account-wide too.
-- These are treated as *overrides*: if a value is absent/nil, FontMagic will not change the
-- game's setting and the UI will simply reflect whatever WoW is currently set to.
if type(FontMagicDB.combatOverrides) ~= "table" then FontMagicDB.combatOverrides = {} end
if type(FontMagicDB.extraCombatOverrides) ~= "table" then FontMagicDB.extraCombatOverrides = {} end
if type(FontMagicDB.incomingOverrides) ~= "table" then FontMagicDB.incomingOverrides = {} end
if FontMagicDB.showExtraCombatToggles == nil then FontMagicDB.showExtraCombatToggles = false end
if FontMagicDB.combatTextOutlineTouched == nil then
    FontMagicDB.combatTextOutlineTouched = (type(FontMagicDB.combatTextOutlineMode) == "string" and FontMagicDB.combatTextOutlineMode ~= "") and true or false
end
do
    local v = (type(FontMagicDB.combatTextOutlineMode) == "string") and FontMagicDB.combatTextOutlineMode:upper() or "NONE"
    if v == "NONE" or v == "NO" or v == "0" then
        FontMagicDB.combatTextOutlineMode = "NONE"
    elseif v == "THICK" or v == "THICKOUTLINE" or v == "2" then
        FontMagicDB.combatTextOutlineMode = "THICKOUTLINE"
    else
        FontMagicDB.combatTextOutlineMode = "OUTLINE"
    end
end
if type(FontMagicDB.floatingTextGravity) ~= "number" then
    FontMagicDB.floatingTextGravity = GetResolvedSettingNumber({ "WorldTextGravity_v2", "WorldTextGravity_V2", "WorldTextGravity", "floatingCombatTextGravity_v2", "floatingCombatTextGravity_V2", "floatingCombatTextGravity" }, 1.0)
end
if type(FontMagicDB.floatingTextFadeDuration) ~= "number" then
    FontMagicDB.floatingTextFadeDuration = GetResolvedSettingNumber({ "WorldTextRampDuration_v2", "WorldTextRampDuration_V2", "WorldTextRampDuration", "floatingCombatTextRampDuration_v2", "floatingCombatTextRampDuration_V2", "floatingCombatTextRampDuration" }, 1.0)
end

-- Migration: older versions stored combat overrides per-character. Move them into the account DB.
do
    local migrated = false
    local function MigratePerCharacterTable(field)
        local src = FontMagicPCDB and FontMagicPCDB[field]
        if type(src) ~= "table" then return end

        local dst = FontMagicDB[field]
        if type(dst) ~= "table" then
            dst = {}
            FontMagicDB[field] = dst
        end

        for k, v in pairs(src) do
            if dst[k] == nil then
                dst[k] = v
            end
        end

        FontMagicPCDB[field] = nil
        migrated = true
    end

    MigratePerCharacterTable("combatOverrides")
    MigratePerCharacterTable("extraCombatOverrides")
    MigratePerCharacterTable("incomingOverrides")

    -- If combat text was previously hidden by the old main checkbox (per-character override),
    -- clear it so we don't unexpectedly keep combat text disabled after upgrade.
    if migrated and type(FontMagicDB.combatOverrides) == "table" then
        FontMagicDB.combatOverrides["enableFloatingCombatText"] = nil
    end
end


local COMBAT_FONT_GROUPS = {
    ["Popular"] = {
        path  = ADDON_PATH .. "Popular\\",
        fonts = {
            "Pepsi.ttf", "bignoodletitling.ttf", "Expressway.ttf", "Bangers.ttf", "PTSansNarrow-Bold.ttf", "Roboto Condensed Bold.ttf",
            "NotoSans_Condensed-Bold.ttf", "Roboto-Bold.ttf", "AlteHaasGroteskBold.ttf", "CalibriBold.ttf", "Orbitron.ttf", "Prototype.ttf",
            "914Solid.ttf", "Halo.ttf", "Proxima Nova Condensed Bold.ttf", "Comfortaa-Bold.ttf", "Andika-Bold.ttf", "lemon-milk.ttf",
            "Good Brush.ttf", "KG HAPPY.ttf"
        },
    },
    ["Clean & Readable"] = {
        path  = ADDON_PATH .. "Easy-to-Read\\",
        fonts = {
            "BauhausRegular.ttf", "Butterpop.ttf", "Diogenes.ttf", "Junegull.ttf", "Pantalone.ttf", "Resoft.ttf",
            "Retro Amour.ttf", "SF-Pro.ttf", "Solange.ttf", "Takeaway.ttf"
        },
    },
    ["Bold & Impact"] = {
        path  = ADDON_PATH .. "BoldImpact\\",
        fonts = {
            "airstrikebold.ttf", "Blazed.ttf", "DieDieDie.ttf", "graff.ttf", "Green Fuz.otf", "Love Craft.ttf",
            "modernwarfare.ttf", "Showpop.ttf", "Skratchpunk.ttf", "Skullphabet.ttf", "Trashco.ttf", "Whiplash.ttf"
        },
    },
    ["Fantasy & RP"] = {
        path  = ADDON_PATH .. "Fun\\",
        fonts = {
            "Acadian.ttf", "akash.ttf", "Caesar.ttf", "ComicRunes.ttf", "crygords.ttf", "Deltarune.ttf",
            "Elven.ttf", "Gunung.ttf", "Guroes.ttf", "HarryP.ttf", "Hobbit.ttf", "Kting.ttf",
            "leviathans.ttf", "MystikOrbs.ttf", "Odinson.ttf", "ParryHotter.ttf", "Pau.ttf", "Pokemon.ttf",
            "Runic.ttf", "Runy.ttf", "Ruritania.ttf", "Spongebob.ttf", "Starborn.ttf", "Starshines.ttf",
            "The Centurion .ttf", "Vampire Wars.ttf", "VTKS.ttf", "WaltographUI.ttf", "Wasser.ttf", "Wickedmouse.ttf",
            "WKnight.ttf", "Zombie.ttf"
        },
    },
    ["Sci-Fi & Tech"] = {
        path  = ADDON_PATH .. "Future\\",
        fonts = {
            "04b.ttf", "albra.TTF", "Audiowide.ttf", "continuum.ttf", "dalek.ttf", "digital-7.ttf",
            "Digital.ttf", "Exocet.ttf", "Galaxyone.ttf", "Minecrafter.Reg.ttf", "pf_tempesta_seven.ttf", "Price.ttf",
            "RaceSpace.ttf", "RushDriver.ttf", "space age.ttf", "Terminator.ttf"
        },
    },
    ["Random"] = {
        path  = ADDON_PATH .. "Random\\",
        fonts = {
            "accidentalpres.ttf", "animeace.ttf", "Barriecito.ttf", "baskethammer.ttf", "ChopSic.ttf", "college.ttf",
            "Disko.ttf", "Dmagic.ttf", "edgyh.ttf", "edkies.ttf", "FastHand.ttf", "figtoen.ttf",
            "font2.ttf", "Fraks.ttf", "Ginko.ttf", "Homespun.ttf", "IKARRG.TTF", "JJSTS.TTF",
            "KOMIKAX_.ttf", "Ktingw.ttf", "Melted.ttf", "Midorima.ttf", "Munsteria.ttf", "Rebuffed.TTF",
            "Shiruken.ttf", "shog.ttf", "Starcine.ttf", "Stentiga.ttf", "tsuchigumo.ttf", "WhoAsksSatan.ttf"
        },
    },
    ["Custom"] = {
        path  = CUSTOM_PATH,
        -- custom fonts are provided by the optional FontMagicCustomFonts addon
        -- they reside in its Custom folder rather than this addon's
        fonts = {}, -- populated below
    },
}


-- Populate the expected custom font names (1.ttf .. 20.ttf)
do
    local t = COMBAT_FONT_GROUPS["Custom"].fonts
    for i = 1, 20 do
        t[i] = i .. ".ttf"
    end
end

-- Cache loaded fonts to allow previewing in the options panel
local cachedFonts, existsFonts = {}, {}
local __fmFontCacheId = 0
local hasCustomFonts = false -- tracks whether any custom font could be loaded
for group, data in pairs(COMBAT_FONT_GROUPS) do
    cachedFonts[group] = {}
    existsFonts[group] = {}
    for _, fname in ipairs(data.fonts) do
        local path = data.path .. fname
        __fmFontCacheId = __fmFontCacheId + 1
        local f = CreateFont(addonName .. "CacheFont" .. __fmFontCacheId)
        -- safely attempt to load the font; missing or invalid files
        -- should not throw errors during detection
        local ok = pcall(function() f:SetFont(path, 32, "") end)
        local loaded = ok and f:GetFont()
        if loaded and loaded:lower():find(fname:lower(), 1, true) then
            existsFonts[group][fname] = true
            cachedFonts[group][fname] = { font = f, path = path }
            if group == "Custom" then
                hasCustomFonts = true
            end
        else
            existsFonts[group][fname] = false
        end
    end
end

local originalInfo = {}

-- ---------------------------------------------------------------------------
-- Combat text font application
-- ---------------------------------------------------------------------------

-- Capture Blizzard's default combat text font paths so we can restore them later.
-- In some clients these globals aren't populated until Blizzard_CombatText loads,
-- so we also refresh them during PLAYER_LOGIN / Blizzard_CombatText load.
local DEFAULT_DAMAGE_TEXT_FONT = (type(DAMAGE_TEXT_FONT) == "string" and DAMAGE_TEXT_FONT) or nil
local DEFAULT_COMBAT_TEXT_FONT = (type(COMBAT_TEXT_FONT)  == "string" and COMBAT_TEXT_FONT)  or nil
local DEFAULT_FONT_PATH = DEFAULT_DAMAGE_TEXT_FONT or DEFAULT_COMBAT_TEXT_FONT or "Fonts\\FRIZQT__.TTF"

_G.FontMagicCombatTextGlobalLock = _G.FontMagicCombatTextGlobalLock or {}

function _G.FontMagicCombatTextGlobalLock:GetGlobalForDefaultCapture(name)
    if type(name) ~= "string" or name == "" then return nil end

    local lock = rawget(_G, "FontMagicCombatTextGlobalLock")
    local locked = nil
    if type(lock) == "table" then
        local values = rawget(lock, "values")
        locked = (type(values) == "table" and values[name]) or rawget(lock, "path")
    end

    local raw = rawget(_G, name)
    if type(raw) == "string" and raw ~= "" then
        if type(locked) == "string" and locked ~= "" and raw == locked then
            return nil
        end
        return raw
    end

    local v = _G and _G[name]
    if type(v) ~= "string" or v == "" then return nil end
    if type(locked) == "string" and locked ~= "" and v == locked then
        return nil
    end
    return v
end

local function CaptureBlizzardDefaultFonts()
    local damageFont = _G.FontMagicCombatTextGlobalLock:GetGlobalForDefaultCapture("DAMAGE_TEXT_FONT")
    if not DEFAULT_DAMAGE_TEXT_FONT and damageFont then
        DEFAULT_DAMAGE_TEXT_FONT = damageFont
    end
    local combatFont = _G.FontMagicCombatTextGlobalLock:GetGlobalForDefaultCapture("COMBAT_TEXT_FONT")
    if not DEFAULT_COMBAT_TEXT_FONT and combatFont then
        DEFAULT_COMBAT_TEXT_FONT = combatFont
    end
    if not DEFAULT_FONT_PATH or DEFAULT_FONT_PATH == "" then
        DEFAULT_FONT_PATH = DEFAULT_DAMAGE_TEXT_FONT or DEFAULT_COMBAT_TEXT_FONT or "Fonts\\FRIZQT__.TTF"
    end
end

local function GetDefaultCombatTextFontPath()
    CaptureBlizzardDefaultFonts()
    return DEFAULT_DAMAGE_TEXT_FONT or DEFAULT_COMBAT_TEXT_FONT or DEFAULT_FONT_PATH or "Fonts\\FRIZQT__.TTF"
end

local function GetConfiguredCombatTextOutlineFlags()
    local mode = tostring(FontMagicDB and FontMagicDB.combatTextOutlineMode or "NONE"):upper()
    if mode == "NONE" or mode == "NO" or mode == "0" then
        return ""
    end
    if mode == "THICK" or mode == "THICKOUTLINE" or mode == "2" then
        return "THICKOUTLINE"
    end
    return "OUTLINE"
end

local function GetConfiguredCombatTextCompatFlags()
    -- Stay on Blizzard's normal font renderer. SLUG can make some clients switch
    -- to a stricter glyph path after the first draw, which shows valid patched
    -- punctuation as missing boxes.
    local outlineFlags = GetConfiguredCombatTextOutlineFlags()
    return outlineFlags, outlineFlags
end

local function IsManagedCombatTextOutlineName(name)
    local lname = tostring(name or ""):lower()
    if lname:find("^combattextfont") or lname:find("^damagefont") or lname:find("^damagetextfont") then
        return true
    end

    if lname == "damagenumberfont" or lname == "combatdamagefont" or lname == "combathealingabsorbglowfont" then
        return true
    end

    if lname == "worldfont" or lname == "systemfont_world" or lname == "systemfont_world_thickoutline" then
        return true
    end

    return false
end

local function GroupHasFont(grp, fname)
    local data = COMBAT_FONT_GROUPS and COMBAT_FONT_GROUPS[grp]
    if type(data) == "table" and type(data.fonts) == "table" then
        for _, f in ipairs(data.fonts) do
            if f == fname then return true end
        end
    end
    return false
end

-- Convert a saved key like "Fun/Pepsi.ttf" to an on-disk font path.
-- Also supports legacy keys that only stored the filename.
local function ResolveFontPathFromKey(key)
    if type(key) ~= "string" or key == "" then return nil end

    local grp, fname = key:match("^(.*)/([^/]+)$")
    if grp and fname and COMBAT_FONT_GROUPS[grp] and type(COMBAT_FONT_GROUPS[grp].path) == "string" then
        -- If the group still owns this filename, use it. Otherwise treat as legacy and try to migrate by filename.
        if GroupHasFont(grp, fname) then
            return COMBAT_FONT_GROUPS[grp].path .. fname, grp, fname
        end
    end

    -- Legacy / migration: key may be only a filename (or a group that no longer exists).
    local only = key:match("([^/]+)$")
    if only and (only:lower():match("%.ttf$") or only:lower():match("%.otf$")) then
        for g, data in pairs(COMBAT_FONT_GROUPS) do
            if type(data) == "table" and type(data.path) == "string" and type(data.fonts) == "table" then
                for _, f in ipairs(data.fonts) do
                    if f == only then
                        return data.path .. f, g, f
                    end
                end
            end
        end
    end

    return nil
end

-- Try to apply the combat text font immediately
local function CanonicalizeFontKey(key)
    local _, grp, fname = ResolveFontPathFromKey(key)
    if grp and fname then
        return grp .. "/" .. fname
    end
    return nil
end

local function MigrateSavedFontKeys()
    if type(FontMagicDB) ~= "table" then return end
    if FontMagicDB.__fmPopularFolderMigrationDone then return end

    -- Selected font
    if type(FontMagicDB.selectedFont) == "string" and FontMagicDB.selectedFont ~= "" then
        local canon = CanonicalizeFontKey(FontMagicDB.selectedFont)
        if canon then
            FontMagicDB.selectedFont = canon
        else
            -- Saved selection no longer exists; fall back safely to Default
            FontMagicDB.selectedFont = ""
        end
    end

    -- Favorites
    if type(FontMagicDB.favorites) == "table" then
        local out = {}
        for key, on in pairs(FontMagicDB.favorites) do
            if on == true then
                local canon = CanonicalizeFontKey(key)
                if canon then
                    out[canon] = true
                end
            end
        end
        FontMagicDB.favorites = out
    end

    FontMagicDB.__fmPopularFolderMigrationDone = true
end

_G.FontMagicCombatTextFontObjects = _G.FontMagicCombatTextFontObjects or {
    -- Common across many clients/eras
    { name = "CombatTextFont",        defaultSize = 25 },
    { name = "CombatTextFontOutline", defaultSize = 25 },
    { name = "DamageFont",            defaultSize = 25 },
    { name = "DamageFontOutline",     defaultSize = 25 },
    { name = "CombatTextFontNormal",  defaultSize = 25 },
    { name = "CombatTextFontSmall",   defaultSize = 20 },
    { name = "DamageTextFont",        defaultSize = 25 },
    { name = "DamageTextFontOutline", defaultSize = 25 },

    -- Present on some modern/Retail builds and can control outgoing damage numbers.
    { name = "DamageNumberFont",              defaultSize = 25 },
    { name = "CombatDamageFont",              defaultSize = 25 },
    { name = "CombatHealingAbsorbGlowFont",   defaultSize = 25 },
    { name = "WorldFont",                     defaultSize = 25 },

    -- Classic-era floating combat text often uses these FontObjects.
    -- SUI also rewrites these during its font bootstrap, so FontMagic must own them too.
    { name = "SystemFont_World",              defaultSize = 25 },
    { name = "SystemFont_World_ThickOutline", defaultSize = 25 },
}

_G.FontMagicCombatTextApplying = false

function _G.FontMagicCombatTextGlobalLock:ShouldLockCombatTextFont()
    if type(FontMagicDB) ~= "table" then return false end
    if type(FontMagicDB.selectedFont) == "string" and FontMagicDB.selectedFont ~= "" then
        return true
    end
    return FontMagicDB.combatTextOutlineTouched and true or false
end

function _G.FontMagicCombatTextGlobalLock:IsManagedGlobal(name)
    return name == "DAMAGE_TEXT_FONT" or name == "COMBAT_TEXT_FONT"
end

function _G.FontMagicCombatTextGlobalLock:GetLockedPath(name)
    if not self:IsManagedGlobal(name) then return nil end

    local values = rawget(self, "values")
    local path = type(values) == "table" and values[name] or rawget(self, "path")
    if type(path) ~= "string" or path == "" then return nil end

    local ok, shouldLock = pcall(self.ShouldLockCombatTextFont, self)
    if not ok or not shouldLock then return nil end

    return path
end

function _G.FontMagicCombatTextGlobalLock:OwnCombatTextFontGlobals(path)
    if type(path) ~= "string" or path == "" then return end

    self.path = path
    self.values = self.values or {}
    self.values.DAMAGE_TEXT_FONT = path
    self.values.COMBAT_TEXT_FONT = path

    -- Do not alter _G's metatable here. That can taint protected Blizzard UI
    -- paths on Classic clients and block actions such as logout.
    rawset(_G, "DAMAGE_TEXT_FONT", path)
    rawset(_G, "COMBAT_TEXT_FONT", path)
end

function _G.FontMagicCombatTextGlobalLock:ReleaseCombatTextFontGlobals(path)
    self.path = nil
    if type(self.values) == "table" then
        self.values.DAMAGE_TEXT_FONT = nil
        self.values.COMBAT_TEXT_FONT = nil
    end

    if type(path) == "string" and path ~= "" then
        rawset(_G, "DAMAGE_TEXT_FONT", path)
        rawset(_G, "COMBAT_TEXT_FONT", path)
    end
end

function _G.FontMagicCombatTextGlobalLock:SetCombatTextFontGlobals(path)
    if type(path) ~= "string" or path == "" then return end

    if self:ShouldLockCombatTextFont() then
        self:OwnCombatTextFontGlobals(path)
    else
        self:ReleaseCombatTextFontGlobals(path)
    end
end

-- by updating the globals and
-- any common FontObjects that already exist in the current client.
--
-- IMPORTANT: older FontMagic builds allowed changing extra rendering properties
-- (shadow offset, monochrome flags, etc.) for Blizzard combat-text FontObjects.
-- Those changes can persist for engine-defined FontObjects in some clients.
-- To avoid "spaced out" / odd-looking healing text after upgrading, we treat
-- Blizzard's rendering as authoritative and only change the font face.
local function ApplyCombatTextFontPath(path)
    if type(path) ~= "string" or path == "" then return end

    _G.FontMagicCombatTextApplying = true
    local okApply, applyErr = pcall(function()

    -- Set globals unconditionally. In some clients these may not be initialized yet
    -- when our file first runs; assigning now ensures Blizzard uses our path once
    -- combat text initializes.
    if _G.FontMagicCombatTextGlobalLock and type(_G.FontMagicCombatTextGlobalLock.SetCombatTextFontGlobals) == "function" then
        _G.FontMagicCombatTextGlobalLock:SetCombatTextFontGlobals(path)
    else
        rawset(_G, "DAMAGE_TEXT_FONT", path)
        rawset(_G, "COMBAT_TEXT_FONT", path)
    end

    local function TrySetFontCompat(obj, fontPath, size, flags, fallbackFlags)
        if not (obj and type(obj.SetFont) == "function") then return false end

        local ok = pcall(obj.SetFont, obj, fontPath, size, flags)
        if ok then
            return true
        end

        if type(fallbackFlags) == "string" and fallbackFlags ~= flags then
            ok = pcall(obj.SetFont, obj, fontPath, size, fallbackFlags)
            if ok then
                return true
            end
        end

        return false
    end

    local function NormalizeCombatTextStyle(name, obj)
        if not obj then return end

        -- Reset letter-spacing to Blizzard defaults (0) to prevent "+1 2 3 4" style output.
        if type(obj.SetSpacing) == "function" then
            pcall(obj.SetSpacing, obj, 0)
        end

        -- Reset shadow to Blizzard defaults used by SystemFont_World.
        -- Restrict this to the scrolling combat text FontObjects we know Blizzard defines.
        if name == "CombatTextFont" or name == "CombatTextFontOutline" then
            if type(obj.SetShadowOffset) == "function" then
                pcall(obj.SetShadowOffset, obj, 1, -1)
            end
            if type(obj.SetShadowColor) == "function" then
                -- Alpha is optional on some clients.
                local ok = pcall(obj.SetShadowColor, obj, 0, 0, 0, 1)
                if not ok then
                    pcall(obj.SetShadowColor, obj, 0, 0, 0)
                end
            end
        end
    end

    local function NormalizeFontFlagsConservative(flags)
        -- Default behavior: preserve existing flags, but remove MONOCHROME leftovers
        -- and avoid conflicting outline tokens.
        if type(flags) ~= "string" or flags == "" then return "" end

        local out = {}
        local seen = {}
        local hasOutline, hasThick = false, false

        for token in tostring(flags):gmatch("[^,]+") do
            local t = token:match("^%s*(.-)%s*$") or token
            if t ~= "" then
                local u = t:upper()
                if u == "MONOCHROME" then
                    -- drop
                elseif u == "OUTLINE" then
                    hasOutline = true
                elseif u == "THICKOUTLINE" then
                    hasThick = true
                elseif not seen[u] then
                    seen[u] = true
                    out[#out + 1] = t
                end
            end
        end

        if hasThick then
            out[#out + 1] = "THICKOUTLINE"
        elseif hasOutline then
            out[#out + 1] = "OUTLINE"
        end

        return table.concat(out, ",")
    end

    local function SafeCombatTextFlags(name, flags)
        -- For scrolling combat text objects, apply the user-selected outline style.
        -- Keep this constrained to Blizzard combat-text font objects to avoid collateral.
        if IsManagedCombatTextOutlineName(name) then
            return GetConfiguredCombatTextOutlineFlags()
        end

        return NormalizeFontFlagsConservative(flags)
    end

    local function TrySetFontObjectFont(name, obj, defaultSize)
        if not (obj and type(obj.SetFont) == "function") then return end

        local size, flags
        if type(obj.GetFont) == "function" then
            local ok, _, s, f = pcall(obj.GetFont, obj)
            if ok then
                size, flags = s, f
            end
        end

        local lname = tostring(name or ""):lower()
        -- Use stable baseline sizes for Blizzard combat text font objects.
        if lname:find("^combattextfont") or lname:find("^damagefont") or lname:find("^damagetextfont") then
            size = tonumber(defaultSize) or 25
        else
            if type(size) ~= "number" then
                size = tonumber(size)
            end
            if type(size) ~= "number" then
                size = tonumber(defaultSize) or 25
            end
        end
        if type(flags) ~= "string" then
            -- If the object doesn't report flags, don't force any.
            flags = ""
        end
        flags = SafeCombatTextFlags(name, flags)

        TrySetFontCompat(obj, path, size, flags, "")
        NormalizeCombatTextStyle(name, obj)
    end

    -- Best-effort: update existing FontObjects immediately. Some clients initialize
    -- these when Blizzard_CombatText loads, so we also re-apply during ADDON_LOADED.
    for _, def in ipairs(_G.FontMagicCombatTextFontObjects or {}) do
        local obj = _G and _G[def.name]
        TrySetFontObjectFont(def.name, obj, def.defaultSize)
    end

    local function ApplyNarrowCombatTextFontObjects()
        -- Some clients and UI addons drive outline/font changes through this
        -- narrower set of Blizzard combat-text FontObjects. Re-apply them here
        -- without replacing FontMagic's wider cross-version coverage.
        local compatFlags, fallbackFlags = GetConfiguredCombatTextCompatFlags()
        local targets = {
            { name = "CombatTextFont",  defaultSize = 16 },
            { name = "DamageNumberFont", defaultSize = 16 },
            { name = "WorldFont",       defaultSize = 16 },
            { name = "CombatDamageFont", defaultSize = 16 },
        }

        for _, def in ipairs(targets) do
            local obj = _G and _G[def.name]
            if obj and type(obj.SetFont) == "function" then
                local size = tonumber(def.defaultSize) or 16
                if type(obj.GetFont) == "function" then
                    local ok, _, s = pcall(obj.GetFont, obj)
                    if ok and tonumber(s) then
                        size = tonumber(s)
                    end
                end

                TrySetFontCompat(obj, path, size, compatFlags, fallbackFlags)

                if type(obj.SetShadowOffset) == "function" then
                    pcall(obj.SetShadowOffset, obj, 1, -1)
                end
                if type(obj.SetShadowColor) == "function" then
                    local ok = pcall(obj.SetShadowColor, obj, 0, 0, 0, 1)
                    if not ok then
                        pcall(obj.SetShadowColor, obj, 0, 0, 0)
                    end
                end
            end
        end
    end

    -- Also update live FontStrings under the combat text frames (some Classic clients
    -- do not fully respect global DAMAGE_TEXT_FONT/COMBAT_TEXT_FONT changes at runtime).
    local function ApplyToFrameFontStrings(rootName)
        local root = _G and _G[rootName]
        if not root then return end

        local seen = {}
        local maxDepth = 5

        local function visit(f, depth)
            if not f or depth > maxDepth then return end
            if seen[f] then return end
            seen[f] = true

            if type(f.GetRegions) == "function" then
                local okRegions, regions = pcall(function() return { f:GetRegions() } end)
                if okRegions and type(regions) == "table" then
                    for _, r in ipairs(regions) do
                        if r and type(r.GetObjectType) == "function" then
                            local ok, typ = pcall(r.GetObjectType, r)
                            if ok and typ == "FontString" and type(r.GetFont) == "function" and type(r.SetFont) == "function" then
                                local ok2, _, size, flags = pcall(r.GetFont, r)
                                if ok2 then
                                    local compatFlags, fallbackFlags = GetConfiguredCombatTextCompatFlags()
                                    if type(size) ~= "number" then size = 25 end
                                    if type(flags) ~= "string" then flags = "" end
                                    if not TrySetFontCompat(r, path, size, compatFlags, fallbackFlags) then
                                        TrySetFontCompat(r, path, size, flags, "")
                                    end
                                    if type(r.SetSpacing) == "function" then
                                        pcall(r.SetSpacing, r, 0)
                                    end
                                end
                            end
                        end
                    end
                end
            end

            if type(f.GetChildren) == "function" then
                local okChildren, children = pcall(function() return { f:GetChildren() } end)
                if okChildren and type(children) == "table" then
                    for _, child in ipairs(children) do
                        visit(child, depth + 1)
                    end
                end
            end
        end

        visit(root, 0)
    end

    ApplyToFrameFontStrings("CombatText")
    ApplyToFrameFontStrings("CombatTextFrame")
    ApplyToFrameFontStrings("FloatingCombatTextFrame")
    ApplyNarrowCombatTextFontObjects()

    end)
    _G.FontMagicCombatTextApplying = false
    if not okApply and _G and _G.FontMagicCombatTextFix and type(_G.FontMagicCombatTextFix.IsDebugEnabled) == "function" then
        local okDebug, isDebug = pcall(_G.FontMagicCombatTextFix.IsDebugEnabled, _G.FontMagicCombatTextFix)
        if okDebug and isDebug and type(_G.FontMagicCombatTextFix.DbgLine) == "function" then
            pcall(_G.FontMagicCombatTextFix.DbgLine, _G.FontMagicCombatTextFix, "combat font apply error: " .. tostring(applyErr))
        end
    end
end

-- ---------------------------------------------------------------------------
-- Combat text spacing fix + debug
-- ---------------------------------------------------------------------------
-- In some setups (including upgrades from older builds), Blizzard scrolling
-- combat text can end up with non-zero *letter spacing* on the FontStrings
-- used for self events (heals, damage taken, etc.), producing "+1 2 3" output.
--
-- This block:
--  1) forces letter spacing to 0 on the actual combat-text FontStrings
--  2) safely removes only whitespace-like digit group separators (no CVar changes)
--  3) provides gated debug tooling via `/fontmagic debug on|off|toggle|dump`

_G.FontMagicCombatTextFix = (type(_G) == "table" and type(_G.FontMagicCombatTextFix) == "table") and _G.FontMagicCombatTextFix or {}
_G.FontMagicCombatTextFix.wrap = _G.FontMagicCombatTextFix.wrap or { depth = 0, wrapped = {}, orig = {}, logCount = 0, logLimit = 150, lastSpacingScan = 0 }
if type(_G.FontMagicCombatTextFix.spaceSeps) ~= "table" then
    _G.FontMagicCombatTextFix.spaceSeps = { " ", "\194\160", "\226\128\175", "\226\128\137", "\226\128\135" } -- SP, NBSP, NNBSP, THIN, FIG
end

function _G.FontMagicCombatTextFix:GetSelectedCombatTextFontPath()
    if type(FontMagicDB) ~= "table" then return nil end
    local key = FontMagicDB.selectedFont
    if type(key) ~= "string" or key == "" then return nil end

    local path = ResolveFontPathFromKey(key)
    if type(path) == "string" and path ~= "" then
        return path
    end
    return nil
end

function _G.FontMagicCombatTextFix:GetManagedCombatTextFontPath()
    local selected = self:GetSelectedCombatTextFontPath()
    if selected then return selected end

    if type(FontMagicDB) == "table" and FontMagicDB.combatTextOutlineTouched then
        return GetDefaultCombatTextFontPath()
    end
    return nil
end

function _G.FontMagicCombatTextFix:IsSelectedCombatTextFontApplied()
    local want = self:GetManagedCombatTextFontPath()
    if not want then return true end

    local function normalize(path)
        if type(path) ~= "string" or path == "" then return nil end
        return path:gsub("/", "\\"):lower()
    end

    local wantNorm = normalize(want)
    if not wantNorm then return true end

    local function objectMatches(name, obj)
        if not (obj and type(obj.GetFont) == "function") then return nil end
        local ok, current, _, flags = pcall(obj.GetFont, obj)
        if not ok then return nil end
        local currentNorm = normalize(current)
        if not currentNorm then return nil end
        if currentNorm ~= wantNorm then return false end

        local uflags = type(flags) == "string" and flags:upper() or ""
        if uflags:find("SLUG", 1, true) then return false end

        if IsManagedCombatTextOutlineName(name) then
            local wantFlags = GetConfiguredCombatTextOutlineFlags()
            local hasThick = uflags:find("THICKOUTLINE", 1, true) and true or false
            local hasOutline = (hasThick or uflags:find("OUTLINE", 1, true)) and true or false

            if wantFlags == "THICKOUTLINE" then
                if not hasThick then return false end
            elseif wantFlags == "OUTLINE" then
                if hasThick or not hasOutline then return false end
            else
                if hasOutline then return false end
            end
        end

        return true
    end

    local gDamage = normalize(_G and _G.DAMAGE_TEXT_FONT)
    if gDamage then
        if gDamage ~= wantNorm then
            return false
        end
    end

    local gCombat = normalize(_G and _G.COMBAT_TEXT_FONT)
    if gCombat then
        if gCombat ~= wantNorm then
            return false
        end
    end

    local known = {
        "CombatTextFont",
        "CombatTextFontOutline",
        "DamageFont",
        "DamageFontOutline",
        "DamageTextFont",
        "DamageTextFontOutline",
        "DamageNumberFont",
        "CombatDamageFont",
        "CombatHealingAbsorbGlowFont",
        "WorldFont",
        "SystemFont_World",
        "SystemFont_World_ThickOutline",
    }

    local sawManagedObject = false
    for _, name in ipairs(known) do
        local matched = objectMatches(name, _G and _G[name])
        if matched ~= nil then
            if not matched then
                return false
            end
            sawManagedObject = true
        end
    end

    if sawManagedObject or gDamage or gCombat then
        return true
    end

    return false
end

function _G.FontMagicCombatTextFix:MaybeRepairSelectedCombatTextFont(throttleSeconds)
    local want = self:GetManagedCombatTextFontPath()
    if not want then return end

    local w = self.wrap
    if type(w) ~= "table" then return end

    local now = (type(GetTime) == "function") and GetTime() or 0
    local throttle = tonumber(throttleSeconds) or 0
    local last = tonumber(w.lastFontRepair) or 0
    if throttle > 0 and now > 0 and (now - last) < throttle then
        return
    end

    if self:IsSelectedCombatTextFontApplied() then
        return
    end

    w.lastFontRepair = now
    ApplyCombatTextFontPath(want)

    if self:IsDebugEnabled() then
        self:DbgLine("combat font repair: reapplied saved font after it was overwritten")
    end
end

function _G.FontMagicCombatTextFix:IsDebugEnabled()
    return (FontMagicDB and FontMagicDB.__fmDebugCombatText) and true or false
end

function _G.FontMagicCombatTextFix:DbgPrint(line)
    line = tostring(line or "")
    if DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then
        DEFAULT_CHAT_FRAME:AddMessage(line)
        return
    end
    if type(print) == "function" then
        print(line)
    end
end

function _G.FontMagicCombatTextFix:DbgLine(line)
    self:DbgPrint("|cFF00FF00[FontMagic]|r " .. tostring(line or ""))
end

function _G.FontMagicCombatTextFix:PatternEscape(s)
    if type(s) ~= "string" then return "" end
    return (s:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1"))
end

function _G.FontMagicCombatTextFix:IsNumericish(msg)
    if type(msg) ~= "string" or msg == "" then return false end
    if not msg:match("%d") then return false end

    local s = msg
    s = s:gsub("%d", "")
    s = s:gsub("[+%-]", "")
    s = s:gsub("[%(%)]", "")
    s = s:gsub("[%[%]]", "")
    s = s:gsub("[%{%}]", "")
    s = s:gsub("%s", "")
    if type(self.spaceSeps) == "table" then
        for _, sep in ipairs(self.spaceSeps) do
            if type(sep) == "string" and sep ~= "" then
                s = s:gsub(sep, "")
            end
        end
    end
    s = s:gsub("[,%.']", "")
    return s == ""
end

function _G.FontMagicCombatTextFix:SanitizeMessage(msg)
    if type(msg) ~= "string" or msg == "" then return msg, false end
    if not msg:match("%d") then return msg, false end
    if not self:IsNumericish(msg) then return msg, false end

    local out = msg
    -- If the combat-text pipeline produces numeric strings with extra whitespace-like
    -- separators (including NBSP/thin spaces), remove them. This is low-collateral
    -- because we only apply it to numeric-only messages (digits/punct/signs).
    if type(self.spaceSeps) == "table" then
        for _, sep in ipairs(self.spaceSeps) do
            if type(sep) == "string" and sep ~= "" then
                out = out:gsub(self:PatternEscape(sep), "")
            end
        end
    end
    return out, out ~= msg
end

function _G.FontMagicCombatTextFix:VisibleWhitespace(msg)
    if type(msg) ~= "string" then return tostring(msg) end
    local out = msg
    out = out:gsub("\194\160", "[NBSP]")
    out = out:gsub("\226\128\175", "[NNBSP]")
    out = out:gsub("\226\128\137", "[THIN]")
    out = out:gsub("\226\128\135", "[FIG]")
    out = out:gsub(" ", "[SP]")
    out = out:gsub(",", "[,]")
    out = out:gsub("%.", "[.]")
    out = out:gsub("'", "[']")
    return out
end

function _G.FontMagicCombatTextFix:DescribeSeparatorBytes(msg)
    if type(msg) ~= "string" or msg == "" then return "" end
    local parts = {}
    local function add(label, bytes, needle)
        if msg:find(needle, 1, true) then
            parts[#parts + 1] = label .. "=" .. bytes
        end
    end
    add("SP", "20", " ")
    add("NBSP", "C2 A0", "\194\160")
    add("NNBSP", "E2 80 AF", "\226\128\175")
    add("THIN", "E2 80 89", "\226\128\137")
    add("FIG", "E2 80 87", "\226\128\135")
    add("COMMA", "2C", ",")
    add("DOT", "2E", ".")
    add("APOS", "27", "'")
    return table.concat(parts, ", ")
end

function _G.FontMagicCombatTextFix:MaybeLogMessage(source, rawMsg, sanitizedMsg, changed)
    if not self:IsDebugEnabled() then return end

    local w = self.wrap
    if type(w) ~= "table" then return end
    w.logCount = (w.logCount or 0) + 1
    local limit = tonumber(w.logLimit) or 150
    if w.logCount > limit then
        if w.logCount == (limit + 1) then
            self:DbgLine("debug: combat-text log limit reached (" .. limit .. "); toggle '/fontmagic debug off' then on to reset.")
        end
        return
    end

    local raw = tostring(rawMsg or "")
    self:DbgLine("CT[" .. tostring(source or "?") .. "] raw=" .. raw)

    local vis = self:VisibleWhitespace(rawMsg)
    local seps = self:DescribeSeparatorBytes(rawMsg)
    if vis ~= raw or seps ~= "" then
        self:DbgLine("CT vis=" .. tostring(vis) .. (seps ~= "" and (" seps=" .. seps) or ""))
    end

    if changed and sanitizedMsg ~= rawMsg then
        self:DbgLine("CT sanitized=" .. tostring(sanitizedMsg))
    end
end

function _G.FontMagicCombatTextFix:DumpFontInstance(name, obj)
    if not obj then
        self:DbgLine(tostring(name or "?") .. ": nil")
        return
    end

    local otype = nil
    if type(obj.GetObjectType) == "function" then
        local ok, v = pcall(obj.GetObjectType, obj)
        if ok then otype = v end
    end

    local path, size, flags = nil, nil, nil
    if type(obj.GetFont) == "function" then
        local ok, a, b, c = pcall(obj.GetFont, obj)
        if ok then path, size, flags = a, b, c end
    end

    local spacing = nil
    if type(obj.GetSpacing) == "function" then
        local ok, v = pcall(obj.GetSpacing, obj)
        if ok then spacing = v end
    end

    self:DbgLine(string.format(
        "%s: type=%s font=%s size=%s flags=%s spacing=%s",
        tostring(name or "?"),
        tostring(otype),
        tostring(path),
        tostring(size),
        tostring(flags),
        tostring(spacing)
    ))
end

function _G.FontMagicCombatTextFix:DumpCombatTextRegions(frameName, maxFS)
    local f = _G and _G[frameName]
    if not (f and type(f.GetRegions) == "function") then
        self:DbgLine("CT regions: " .. tostring(frameName) .. " (no GetRegions)")
        return
    end

    local okRegions, regions = pcall(function() return { f:GetRegions() } end)
    if not okRegions or type(regions) ~= "table" then
        self:DbgLine("CT regions: " .. tostring(frameName) .. " (GetRegions failed)")
        return
    end
    local found = 0
    local limit = tonumber(maxFS) or 6
    for _, r in ipairs(regions) do
        if r and type(r.GetObjectType) == "function" then
            local ok, typ = pcall(r.GetObjectType, r)
            if ok and typ == "FontString" then
                found = found + 1
                self:DumpFontInstance(frameName .. ":FontString" .. found, r)
                if found >= limit then break end
            end
        end
    end
    if found == 0 then
        self:DbgLine("CT regions: " .. tostring(frameName) .. " (no FontString regions found)")
    end
end

function _G.FontMagicCombatTextFix:ZeroFontStringSpacingOnFrame(frameName)
    local root = _G and _G[frameName]
    if not root then return end

    local seenFrames = {}
    local maxDepth = 4

    local function visit(f, depth)
        if not f or depth > maxDepth then return end
        if seenFrames[f] then return end
        seenFrames[f] = true

        if type(f.GetRegions) == "function" then
            local okRegions, regions = pcall(function() return { f:GetRegions() } end)
            if okRegions and type(regions) == "table" then
                for _, r in ipairs(regions) do
                    if r and type(r.GetObjectType) == "function" then
                        local ok, typ = pcall(r.GetObjectType, r)
                        if ok and typ == "FontString" then
                            if type(r.GetFont) == "function" and type(r.SetFont) == "function" then
                                local okFont, currentPath, size, flags = pcall(r.GetFont, r)
                                if okFont and type(currentPath) == "string" and currentPath ~= "" then
                                    local compatFlags, fallbackFlags = GetConfiguredCombatTextCompatFlags()
                                    if type(size) ~= "number" then size = 25 end
                                    if type(flags) ~= "string" then flags = "" end

                                    local wantPath = self:GetSelectedCombatTextFontPath() or currentPath
                                    local wantFlags = compatFlags
                                    if wantPath ~= currentPath or flags ~= wantFlags then
                                        if not pcall(r.SetFont, r, wantPath, size, wantFlags) and fallbackFlags ~= wantFlags then
                                            pcall(r.SetFont, r, wantPath, size, fallbackFlags)
                                        end
                                    end
                                end
                            end

                            if type(r.SetSpacing) == "function" then
                                local before = nil
                                if self:IsDebugEnabled() and type(r.GetSpacing) == "function" then
                                    local ok2, v = pcall(r.GetSpacing, r)
                                    if ok2 then before = v end
                                end
                                pcall(r.SetSpacing, r, 0)
                                if self:IsDebugEnabled() and before ~= nil and tonumber(before) and tonumber(before) ~= 0 then
                                    self:DbgLine("CT spacing fix: " .. tostring(frameName) .. " FontString spacing was " .. tostring(before) .. " -> 0")
                                end
                            end

                            -- If the *text itself* contains whitespace-like separators between digits,
                            -- remove them. This fixes cases where the message string includes thin/NB
                            -- spaces that look like "digit spacing".
                            if type(r.GetText) == "function" and type(r.SetText) == "function" then
                                local okT, txt = pcall(r.GetText, r)
                                if okT and type(txt) == "string" and txt:match("%d") then
                                    local sanitized, changed = self:SanitizeMessage(txt)
                                    if changed and type(sanitized) == "string" and sanitized ~= txt then
                                        pcall(r.SetText, r, sanitized)
                                        if self:IsDebugEnabled() then
                                            self:DbgLine("CT text sanitize: " .. self:VisibleWhitespace(txt) .. " -> " .. self:VisibleWhitespace(sanitized))
                                        end
                                    end
                                end
                            end
                        end
                    end
                end
            end
        end

        if type(f.GetChildren) == "function" then
            local okChildren, children = pcall(function() return { f:GetChildren() } end)
            if okChildren and type(children) == "table" then
                for _, child in ipairs(children) do
                    visit(child, depth + 1)
                end
            end
        end
    end

    visit(root, 0)
end

function _G.FontMagicCombatTextFix:MaybeFixCombatTextFontStringSpacing(throttleSeconds)
    local w = self.wrap
    if type(w) ~= "table" then return end

    local now = (type(GetTime) == "function") and GetTime() or 0
    local throttle = tonumber(throttleSeconds) or 0
    local last = tonumber(w.lastSpacingScan) or 0
    if throttle > 0 and now > 0 and (now - last) < throttle then
        return
    end
    w.lastSpacingScan = now

    self:ZeroFontStringSpacingOnFrame("CombatText")
    self:ZeroFontStringSpacingOnFrame("CombatTextFrame")
    self:ZeroFontStringSpacingOnFrame("FloatingCombatTextFrame")
end

function _G.FontMagicCombatTextFix:MaybeLogDisplayedCombatText(frameName, source)
    if not self:IsDebugEnabled() then return end

    local w = self.wrap
    if type(w) ~= "table" then return end

    local now = (type(GetTime) == "function") and GetTime() or 0
    local last = tonumber(w.lastDisplayedScan) or 0
    if now > 0 and (now - last) < 0.05 then
        return
    end
    w.lastDisplayedScan = now

    local root = _G and _G[frameName]
    if not root then return end

    w.lastFSText = w.lastFSText or {}
    local maxDepth = 4
    local maxFS = 12
    local seen = {}
    local count = 0

    local function visit(f, depth)
        if not f or depth > maxDepth then return end
        if seen[f] then return end
        seen[f] = true

        if type(f.GetRegions) == "function" then
            local okRegions, regions = pcall(function() return { f:GetRegions() } end)
            if okRegions and type(regions) == "table" then
                for _, r in ipairs(regions) do
                    if r and type(r.GetObjectType) == "function" then
                        local ok, typ = pcall(r.GetObjectType, r)
                        if ok and typ == "FontString" then
                            count = count + 1
                            if count > maxFS then return end

                            local txt = nil
                            if type(r.GetText) == "function" then
                                local okT, v = pcall(r.GetText, r)
                                if okT then txt = v end
                            end
                            if type(txt) == "string" and txt ~= "" and txt:match("%d") then
                                local lastTxt = w.lastFSText[r]
                                if lastTxt ~= txt then
                                    w.lastFSText[r] = txt

                                    local p, s, fl = nil, nil, nil
                                    if type(r.GetFont) == "function" then
                                        local okF, a, b, c = pcall(r.GetFont, r)
                                        if okF then p, s, fl = a, b, c end
                                    end
                                    local sp = nil
                                    if type(r.GetSpacing) == "function" then
                                        local okS, v = pcall(r.GetSpacing, r)
                                        if okS then sp = v end
                                    end
                                    local sc = nil
                                    if type(r.GetScale) == "function" then
                                        local okSc, v = pcall(r.GetScale, r)
                                        if okSc then sc = v end
                                    end

                                    self:DbgLine("CT displayed[" .. tostring(source or "?") .. "] " .. tostring(frameName) .. " FS" .. tostring(count) .. " text=" .. tostring(txt))
                                    self:DbgLine("CT displayed vis=" .. self:VisibleWhitespace(txt) .. " seps=" .. self:DescribeSeparatorBytes(txt))
                                    self:DbgLine("CT displayed font=" .. tostring(p) .. " size=" .. tostring(s) .. " flags=" .. tostring(fl) .. " spacing=" .. tostring(sp) .. " scale=" .. tostring(sc))
                                end
                            end
                        end
                    end
                end
            end
        end

        if type(f.GetChildren) == "function" then
            local okChildren, children = pcall(function() return { f:GetChildren() } end)
            if okChildren and type(children) == "table" then
                for _, child in ipairs(children) do
                    visit(child, depth + 1)
                    if count > maxFS then return end
                end
            end
        end
    end

    visit(root, 0)
end

function _G.FontMagicCombatTextFix:OnCombatTextMessage(source, message, ...)
    self:MaybeRepairSelectedCombatTextFont(0.15)
    if FontMagicDB and FontMagicDB.combatTextAboveNameplates and type(self.ApplyCombatTextAboveNameplatesNow) == "function" then
        pcall(self.ApplyCombatTextAboveNameplatesNow, self)
    end

    -- Always keep spacing/flags sanitized. CombatText.AddMessage may be invoked with
    -- a non-string payload on some clients, so don't gate the fixer on arg types.
    self:MaybeFixCombatTextFontStringSpacing(0.15)

    if not self:IsDebugEnabled() then return end

    local t = type(message)
    if t == "string" and message ~= "" and message:match("%d") then
        local sanitized, changed = self:SanitizeMessage(message)
        if sanitized == nil then sanitized = message end
        self:MaybeLogMessage(source, message, sanitized, changed)
    else
        -- Still log that something happened (helps diagnose missing output).
        self:DbgLine("CT[" .. tostring(source or "?") .. "] argType=" .. tostring(t) .. " arg=" .. tostring(message))
    end

    -- Post-scan the live FontStrings to capture the *actual* displayed text/state.
    self:MaybeLogDisplayedCombatText("CombatText", source)
end

function _G.FontMagicCombatTextFix:SendTestCombatText()
    local msg = "FontMagic TEST +1,234,567"
    local targets = { "CombatText", "CombatTextFrame", "FloatingCombatTextFrame" }

    local function tryCall(label, fn, ...)
        if type(fn) ~= "function" then return false end
        local ok, err = pcall(fn, ...)
        if ok then
            self:DbgLine("debug test: " .. tostring(label) .. " ok")
            return true
        end
        self:DbgLine("debug test: " .. tostring(label) .. " failed: " .. tostring(err))
        return false
    end

    for _, frameName in ipairs(targets) do
        local f = _G and _G[frameName]
        if f and type(f.AddMessage) == "function" then
            -- Retail 12.x CombatText expects a ColorMixin in some call paths; passing
            -- only (r,g,b) can error. Try the most compatible forms first.
            if tryCall(frameName .. ":AddMessage(msg,r,g,b,a)", f.AddMessage, f, msg, 0, 1, 0, 1) then return true end
            if tryCall(frameName .. ":AddMessage(msg,r,g,b)", f.AddMessage, f, msg, 0, 1, 0) then return true end
            if tryCall(frameName .. ":AddMessage(msg)", f.AddMessage, f, msg) then return true end
        end
    end

    self:DbgLine("debug test: no CombatText* frame accepted AddMessage()")
    return false
end

function _G.FontMagicCombatTextFix:PrintDebugSnapshot()
    self:DbgLine("debug snapshot begin")

    local loc = (type(GetLocale) == "function") and GetLocale() or nil
    self:DbgLine("locale=" .. tostring(loc))

    if type(FontMagicDB) == "table" then
        self:DbgLine("FontMagicDB.selectedFont=" .. tostring(FontMagicDB.selectedFont))
        local masterOff = (FontMagicDB.combatMasterOffByFontMagic and type(FontMagicDB.combatMasterSnapshot) == "table") and true or false
        self:DbgLine("FontMagicDB.combatMasterOffByFontMagic=" .. tostring(masterOff))
    end
    self:DbgLine("globals: COMBAT_TEXT_FONT=" .. tostring(_G and _G.COMBAT_TEXT_FONT) .. " DAMAGE_TEXT_FONT=" .. tostring(_G and _G.DAMAGE_TEXT_FONT))

    local breakUp = DoesCVarExist("breakUpLargeNumbers") and GetCVarString("breakUpLargeNumbers") or nil
    local localeFmt = DoesCVarExist("useLocaleNumberFormat") and GetCVarString("useLocaleNumberFormat") or nil
    self:DbgLine("CVar breakUpLargeNumbers=" .. tostring(breakUp))
    self:DbgLine("CVar useLocaleNumberFormat=" .. tostring(localeFmt))

    self:DbgLine("LARGE_NUMBER_SEPERATOR=" .. tostring(_G and _G.LARGE_NUMBER_SEPERATOR))
    self:DbgLine("LARGE_NUMBER_SEPARATOR=" .. tostring(_G and _G.LARGE_NUMBER_SEPARATOR))

    if type(BreakUpLargeNumbers) == "function" then
        local ok, ex = pcall(BreakUpLargeNumbers, 1234567)
        if ok then
            self:DbgLine("BreakUpLargeNumbers(1234567)=" .. tostring(ex) .. " vis=" .. self:VisibleWhitespace(ex) .. " seps=" .. self:DescribeSeparatorBytes(ex))
        else
            self:DbgLine("BreakUpLargeNumbers(1234567)=<error> " .. tostring(ex))
        end
    else
        self:DbgLine("BreakUpLargeNumbers=<missing>")
    end

    self:DumpFontInstance("CombatTextFont", _G and _G.CombatTextFont)
    self:DumpFontInstance("CombatTextFontOutline", _G and _G.CombatTextFontOutline)
    self:DumpFontInstance("DamageFont", _G and _G.DamageFont)
    self:DumpFontInstance("DamageFontOutline", _G and _G.DamageFontOutline)
    self:DumpFontInstance("DamageNumberFont", _G and _G.DamageNumberFont)
    self:DumpFontInstance("CombatDamageFont", _G and _G.CombatDamageFont)
    self:DumpFontInstance("WorldFont", _G and _G.WorldFont)
    self:DumpFontInstance("SystemFont_World", _G and _G.SystemFont_World)
    self:DumpFontInstance("SystemFont_World_ThickOutline", _G and _G.SystemFont_World_ThickOutline)
    self:DumpFontInstance("CombatText", _G and _G.CombatText)
    self:DumpFontInstance("CombatTextFrame", _G and _G.CombatTextFrame)
    self:DumpFontInstance("FloatingCombatTextFrame", _G and _G.FloatingCombatTextFrame)

    local w = self.wrap
    self:DbgLine("hooks: CombatText_AddMessage=" .. tostring(w and w.wrapped and w.wrapped["CombatText_AddMessage"])
        .. " CombatText.AddMessage=" .. tostring(w and w.wrapped and w.wrapped["CombatText.AddMessage"])
        .. " CombatTextFrame.AddMessage=" .. tostring(w and w.wrapped and w.wrapped["CombatTextFrame.AddMessage"])
        .. " FloatingCombatTextFrame.AddMessage=" .. tostring(w and w.wrapped and w.wrapped["FloatingCombatTextFrame.AddMessage"]))

    -- Key combat-text CVars (helps explain when messages don't show).
    local function cvarLine(name)
        if DoesCVarExist(name) then
            self:DbgLine("CVar " .. tostring(name) .. "=" .. tostring(GetCVarString(name)))
        end
    end
    local function cvarGroup(base)
        if type(base) ~= "string" or base == "" then return end
        cvarLine(base)
        cvarLine(base .. "_v2")
        cvarLine(base .. "_V2")
        cvarLine(base .. "_v3")
        cvarLine(base .. "_V3")
    end
    cvarLine("enableFloatingCombatText")
    cvarLine("enableCombatText")
    cvarGroup("WorldTextScale")
    cvarGroup("WorldTextGravity")
    cvarGroup("WorldTextRampDuration")
    cvarGroup("floatingCombatTextCombatHealing")
    cvarGroup("floatingCombatTextCombatDamage")
    cvarGroup("floatingCombatTextCombatLogPeriodicSpells")

    if type(FontMagicDB) == "table" and type(FontMagicDB.incomingOverrides) == "table" then
        self:DbgLine("FontMagicDB.incomingOverrides.incomingDamage=" .. tostring(FontMagicDB.incomingOverrides.incomingDamage))
        self:DbgLine("FontMagicDB.incomingOverrides.incomingHealing=" .. tostring(FontMagicDB.incomingOverrides.incomingHealing))
    end
    if type(FontMagicDB) == "table" then
        self:DbgLine("FontMagicDB.combatTextOutlineMode=" .. tostring(FontMagicDB.combatTextOutlineMode))
    end

    if type(COMBAT_TEXT_TYPE_INFO) == "table" then
        local function ctInfoLine(k)
            local t = COMBAT_TEXT_TYPE_INFO[k]
            if t == nil then
                self:DbgLine("CT_INFO[" .. tostring(k) .. "]=nil")
                return
            end
            if type(t) == "table" then
                self:DbgLine("CT_INFO[" .. tostring(k) .. "].cvar=" .. tostring(t.cvar))
                return
            end
            self:DbgLine("CT_INFO[" .. tostring(k) .. "] type=" .. type(t))
        end
        ctInfoLine("HEAL")
        ctInfoLine("HEAL_CRIT")
        ctInfoLine("PERIODIC_HEAL")
        ctInfoLine("PERIODIC_HEAL_CRIT")
        self:DbgLine("CT incoming healing present=" .. tostring((COMBAT_TEXT_TYPE_INFO.HEAL ~= nil or COMBAT_TEXT_TYPE_INFO.PERIODIC_HEAL ~= nil) and true or false))
    else
        self:DbgLine("COMBAT_TEXT_TYPE_INFO=nil")
    end

    self:DumpCombatTextRegions("CombatText", 6)
    self:DumpCombatTextRegions("CombatTextFrame", 6)
    self:DumpCombatTextRegions("FloatingCombatTextFrame", 6)

    self:DbgLine("debug snapshot end")
end

function _G.FontMagicCombatTextFix:WrapGlobalCombatTextFunction(fnName)
    local w = self.wrap
    if not (w and w.wrapped) then return end
    if w.wrapped[fnName] then return end

    local selfRef = self
    if type(hooksecurefunc) ~= "function" then return end
    if not (_G and type(_G[fnName]) == "function") then return end

    local ok = pcall(hooksecurefunc, fnName, function(message, ...)
        pcall(selfRef.OnCombatTextMessage, selfRef, fnName, message, ...)
    end)
    if ok then
        w.wrapped[fnName] = true
    end
end

function _G.FontMagicCombatTextFix:WrapCombatTextFrameMethod(frameName, methodName)
    local w = self.wrap
    if not (w and w.wrapped) then return end

    local key = tostring(frameName) .. "." .. tostring(methodName)
    if w.wrapped[key] then return end

    local f = _G and _G[frameName]
    if not (f and type(f[methodName]) == "function") then return end

    local selfRef = self
    if type(hooksecurefunc) ~= "function" then return end

    local ok = pcall(hooksecurefunc, f, methodName, function(_, message, ...)
        pcall(selfRef.OnCombatTextMessage, selfRef, key, message, ...)
    end)
    if ok then
        w.wrapped[key] = true
    end
end

function _G.FontMagicCombatTextFix:EnsureCombatTextPatches()
    self:WrapGlobalCombatTextFunction("CombatText_AddMessage")
    self:WrapCombatTextFrameMethod("CombatText", "AddMessage")
    self:WrapCombatTextFrameMethod("CombatTextFrame", "AddMessage")
    self:WrapCombatTextFrameMethod("FloatingCombatTextFrame", "AddMessage")

    self:MaybeFixCombatTextFontStringSpacing(0)
    if type(C_Timer) == "table" and type(C_Timer.After) == "function" then
        local selfRef = self
        C_Timer.After(0, function()
            pcall(selfRef.MaybeFixCombatTextFontStringSpacing, selfRef, 0)
            pcall(selfRef.WrapCombatTextFrameMethod, selfRef, "CombatText", "AddMessage")
            pcall(selfRef.WrapCombatTextFrameMethod, selfRef, "CombatTextFrame", "AddMessage")
            pcall(selfRef.WrapCombatTextFrameMethod, selfRef, "FloatingCombatTextFrame", "AddMessage")
            pcall(selfRef.WrapGlobalCombatTextFunction, selfRef, "CombatText_AddMessage")
        end)
        C_Timer.After(0.2, function()
            pcall(selfRef.MaybeFixCombatTextFontStringSpacing, selfRef, 0)
            pcall(selfRef.WrapCombatTextFrameMethod, selfRef, "CombatText", "AddMessage")
            pcall(selfRef.WrapCombatTextFrameMethod, selfRef, "CombatTextFrame", "AddMessage")
            pcall(selfRef.WrapCombatTextFrameMethod, selfRef, "FloatingCombatTextFrame", "AddMessage")
            pcall(selfRef.WrapGlobalCombatTextFunction, selfRef, "CombatText_AddMessage")
        end)
        C_Timer.After(1.0, function()
            pcall(selfRef.MaybeFixCombatTextFontStringSpacing, selfRef, 0)
            pcall(selfRef.WrapCombatTextFrameMethod, selfRef, "CombatText", "AddMessage")
            pcall(selfRef.WrapCombatTextFrameMethod, selfRef, "CombatTextFrame", "AddMessage")
            pcall(selfRef.WrapCombatTextFrameMethod, selfRef, "FloatingCombatTextFrame", "AddMessage")
            pcall(selfRef.WrapGlobalCombatTextFunction, selfRef, "CombatText_AddMessage")
        end)
    end
end

local function ApplyFloatingTextMotionSettings()
    local function clamp(v, lo, hi)
        if v < lo then return lo end
        if v > hi then return hi end
        return v
    end

    local gravityTargets = ResolveConsoleSettingTargets({ "WorldTextGravity_v2", "WorldTextGravity_V2", "WorldTextGravity", "floatingCombatTextGravity_v2", "floatingCombatTextGravity_V2", "floatingCombatTextGravity" })
    local fadeTargets = ResolveConsoleSettingTargets({ "WorldTextRampDuration_v2", "WorldTextRampDuration_V2", "WorldTextRampDuration", "floatingCombatTextRampDuration_v2", "floatingCombatTextRampDuration_V2", "floatingCombatTextRampDuration" })

    if #gravityTargets > 0 and FontMagicDB and type(FontMagicDB.floatingTextGravity) == "number" then
        local g = clamp(FontMagicDB.floatingTextGravity, 0.00, 2.00)
        FontMagicDB.floatingTextGravity = g
        local formatted = string.format("%.2f", g)
        for _, target in ipairs(gravityTargets) do
            ApplyConsoleSetting(target.name, target.commandType, formatted)
        end
    end
    if #fadeTargets > 0 and FontMagicDB and type(FontMagicDB.floatingTextFadeDuration) == "number" then
        local f = clamp(FontMagicDB.floatingTextFadeDuration, 0.10, 3.00)
        FontMagicDB.floatingTextFadeDuration = f
        local formatted = string.format("%.2f", f)
        for _, target in ipairs(fadeTargets) do
            ApplyConsoleSetting(target.name, target.commandType, formatted)
        end
    end
end


local function ApplySavedCombatFont()
    if type(FontMagicDB) ~= "table" then return end
    local key = FontMagicDB.selectedFont
    if type(key) ~= "string" or key == "" then
        if not FontMagicDB.combatTextOutlineTouched then
            return
        end
        ApplyCombatTextFontPath(GetDefaultCombatTextFontPath())
        return
    end

    local path, grp, fname = ResolveFontPathFromKey(key)
    if not path then
        -- Saved selection no longer exists; fall back to Default safely.
        FontMagicDB.selectedFont = ""
        ApplyCombatTextFontPath(GetDefaultCombatTextFontPath())
        return
    end

    -- If we resolved via migration, update the stored key to the canonical form.
    if grp and fname then
        local canon = grp .. "/" .. fname
        if key ~= canon then
            FontMagicDB.selectedFont = canon
        end
    end

    ApplyCombatTextFontPath(path)
end

function _G.FontMagicCombatTextFix:HasManagedCombatTextFont()
    if type(FontMagicDB) ~= "table" then return false end
    if type(FontMagicDB.selectedFont) == "string" and FontMagicDB.selectedFont ~= "" then
        return true
    end
    return FontMagicDB.combatTextOutlineTouched and true or false
end

function _G.FontMagicCombatTextFix:ApplySavedCombatFontNow()
    if not self:HasManagedCombatTextFont() then return end
    MigrateSavedFontKeys()
    ApplySavedCombatFont()
    if type(self.EnsureCombatTextPatches) == "function" then
        pcall(self.EnsureCombatTextPatches, self)
    end
    if type(self.StartCombatTextOwnershipMonitor) == "function" then
        pcall(self.StartCombatTextOwnershipMonitor, self)
    end
end

function _G.FontMagicCombatTextFix:QueueSavedCombatFontApply(delay, force)
    if not self:HasManagedCombatTextFont() then return end

    self.externalHooks = self.externalHooks or {}
    self.externalHooks.queuedApply = self.externalHooks.queuedApply or {}

    local d = tonumber(delay) or 0
    local key = tostring(d)
    if force then
        self.externalHooks.forceQueueId = (tonumber(self.externalHooks.forceQueueId) or 0) + 1
        key = key .. "#" .. tostring(self.externalHooks.forceQueueId)
    else
        if self.externalHooks.queuedApply[key] then return end
    end
    self.externalHooks.queuedApply[key] = true

    local fix = self
    local function run()
        if fix.externalHooks and fix.externalHooks.queuedApply then
            fix.externalHooks.queuedApply[key] = nil
        end
        if type(fix.InstallExternalCombatFontHooks) == "function" then
            pcall(fix.InstallExternalCombatFontHooks, fix)
        end
        pcall(fix.ApplySavedCombatFontNow, fix)
    end

    if type(C_Timer) == "table" and type(C_Timer.After) == "function" then
        C_Timer.After(d, run)
    else
        run()
    end
end

function _G.FontMagicCombatTextFix:QueueSavedCombatFontApplyBurst()
    self:QueueSavedCombatFontApply(0, true)
    self:QueueSavedCombatFontApply(0.05, true)
    self:QueueSavedCombatFontApply(0.5, true)
    self:QueueSavedCombatFontApply(2.0, true)
    self:QueueSavedCombatFontApply(5.0, true)
end

function _G.FontMagicCombatTextFix:StartStartupCombatFontWatcher(seconds)
    if not self:HasManagedCombatTextFont() then return end

    self.externalHooks = self.externalHooks or {}

    local duration = tonumber(seconds) or 8
    if duration < 1 then duration = 1 end
    if duration > 15 then duration = 15 end

    if self.externalHooks.startupWatcher then
        local active = self.externalHooks.startupWatcher
        active.elapsed = 0
        active.duration = math.max(tonumber(active.duration) or 0, duration)
        if active.Show then active:Show() end
        return
    end

    local watcher = CreateFrame("Frame")
    watcher.elapsed = 0
    watcher.duration = duration
    watcher.lastApply = -1
    watcher.fix = self
    watcher:SetScript("OnUpdate", function(frameObj, elapsed)
        frameObj.elapsed = (frameObj.elapsed or 0) + (tonumber(elapsed) or 0)
        local now = frameObj.elapsed
        if now >= (tonumber(frameObj.duration) or duration) then
            frameObj:SetScript("OnUpdate", nil)
            frameObj:Hide()
            if frameObj.fix and frameObj.fix.externalHooks then
                frameObj.fix.externalHooks.startupWatcher = nil
            end
            return
        end

        local last = tonumber(frameObj.lastApply) or -1
        if last < 0 or (now - last) >= 0.25 then
            frameObj.lastApply = now
            pcall(frameObj.fix.ApplySavedCombatFontNow, frameObj.fix)
        end
    end)

    self.externalHooks.startupWatcher = watcher
end

function _G.FontMagicCombatTextFix:StartCombatTextOwnershipMonitor()
    if not self:HasManagedCombatTextFont() then return end

    self.externalHooks = self.externalHooks or {}
    if self.externalHooks.ownershipMonitor then
        local active = self.externalHooks.ownershipMonitor
        if active.Show then active:Show() end
        return
    end

    local monitor = CreateFrame("Frame")
    monitor.elapsed = 0
    monitor.fix = self
    monitor:SetScript("OnUpdate", function(frameObj, elapsed)
        frameObj.elapsed = (frameObj.elapsed or 0) + (tonumber(elapsed) or 0)
        if frameObj.elapsed < 0.75 then return end
        frameObj.elapsed = 0

        local fix = frameObj.fix
        if not (fix and type(fix.HasManagedCombatTextFont) == "function" and fix:HasManagedCombatTextFont()) then
            frameObj:SetScript("OnUpdate", nil)
            frameObj:Hide()
            if fix and fix.externalHooks then
                fix.externalHooks.ownershipMonitor = nil
            end
            return
        end

        if type(fix.MaybeRepairSelectedCombatTextFont) == "function" then
            pcall(fix.MaybeRepairSelectedCombatTextFont, fix, 0.75)
        end
    end)

    self.externalHooks.ownershipMonitor = monitor
end

function _G.FontMagicCombatTextFix:InstallManagedCombatFontObjectHooks()
    if type(hooksecurefunc) ~= "function" then return end

    self.externalHooks = self.externalHooks or {}
    self.externalHooks.fontObjects = self.externalHooks.fontObjects or {}

    for _, def in ipairs(_G.FontMagicCombatTextFontObjects or {}) do
        local name = def and def.name
        local obj = name and _G and _G[name]
        if name and obj and type(obj.SetFont) == "function" and not self.externalHooks.fontObjects[name] then
            local fix = self
            local ok = pcall(hooksecurefunc, obj, "SetFont", function()
                if _G and _G.FontMagicCombatTextApplying then return end
                if type(fix.ApplySavedCombatFontNow) == "function" then
                    pcall(fix.ApplySavedCombatFontNow, fix)
                elseif type(fix.QueueSavedCombatFontApply) == "function" then
                    pcall(fix.QueueSavedCombatFontApply, fix, 0, true)
                end
            end)
            if ok then
                self.externalHooks.fontObjects[name] = true
            end
        end
    end
end

function _G.FontMagicCombatTextFix:InstallExternalCombatFontHooks()
    self.externalHooks = self.externalHooks or {}
    self:InstallManagedCombatFontObjectHooks()

    if type(hooksecurefunc) ~= "function" then return end

    if not self.externalHooks.elvuiUpdateBlizzardFonts then
        local engine = _G and _G.ElvUI
        local E
        if type(engine) == "table" then
            local unpackFn = _G.unpack or (table and table.unpack)
            if type(unpackFn) == "function" then
                local ok, e = pcall(unpackFn, engine)
                if ok and type(e) == "table" then E = e end
            end
            if not E and type(engine[1]) == "table" then
                E = engine[1]
            end
        end
        if E and type(E.UpdateBlizzardFonts) == "function" then
            local fix = self
            local ok = pcall(hooksecurefunc, E, "UpdateBlizzardFonts", function()
                if _G and _G.FontMagicCombatTextApplying then return end
                fix:QueueSavedCombatFontApply(0)
            end)
            if ok then
                self.externalHooks.elvuiUpdateBlizzardFonts = true
            end
        end
    end

    if not self.externalHooks.suiGeneralFonts then
        local SUI = _G and _G.SUI
        if type(SUI) == "table" and type(SUI.GetModule) == "function" then
            local okMod, mod = pcall(SUI.GetModule, SUI, "General.Fonts", true)
            if okMod and mod and type(mod.OnInitialize) == "function" then
                local fix = self
                local okHook = pcall(hooksecurefunc, mod, "OnInitialize", function()
                    if _G and _G.FontMagicCombatTextApplying then return end
                    fix:QueueSavedCombatFontApply(0)
                end)
                if okHook then
                    self.externalHooks.suiGeneralFonts = true
                end
            end
        end
    end

    if not self.externalHooks.libSharedMediaRegister then
        local libstub = _G and _G.LibStub
        if type(libstub) == "function" then
            local okLSM, lsm = pcall(libstub, "LibSharedMedia-3.0", true)
            if okLSM and lsm and type(lsm.Register) == "function" then
                local fix = self
                local okHook = pcall(hooksecurefunc, lsm, "Register", function(_, mediaType)
                    if tostring(mediaType or ""):lower() == "font" then
                        fix:QueueSavedCombatFontApply(0)
                    end
                end)
                if okHook then
                    self.externalHooks.libSharedMediaRegister = true
                end
            end
        end
    end
end

-- Initial capture + apply as early as possible
CaptureBlizzardDefaultFonts()
ApplySavedCombatFont()
if _G and _G.FontMagicCombatTextFix then
    pcall(_G.FontMagicCombatTextFix.InstallExternalCombatFontHooks, _G.FontMagicCombatTextFix)
    pcall(_G.FontMagicCombatTextFix.QueueSavedCombatFontApplyBurst, _G.FontMagicCombatTextFix)
    pcall(_G.FontMagicCombatTextFix.StartStartupCombatFontWatcher, _G.FontMagicCombatTextFix, 8)
    pcall(_G.FontMagicCombatTextFix.StartCombatTextOwnershipMonitor, _G.FontMagicCombatTextFix)
end


-- 2) MAIN WINDOW
local frame = CreateFrame("Frame", addonName .. "Frame", UIParent, backdropTemplate)
--
-- The options window previously lacked a border and used uneven padding
-- around its contents.  Here we keep all the widgets exactly where they
-- were defined and instead only change the outer frame so that it provides
-- an even buffer on every side and a simple border for clarity.
--
-- Constants control the visual spacing and border width so adjustments can
-- be made in a single location.
local PAD       = 20   -- distance from the outer edge to the nearest widget
local BORDER    = 12   -- thickness of the decorative border
local HEADER_H  = 40   -- space reserved for the InterfaceOptions header
local PREVIEW_W = 320  -- width of preview and edit boxes
-- Checkbox column width. Adjusted to ensure the combined width of the two
-- columns plus the spacing between them equals the preview width. The
-- preview box is 320px wide and we want 16px of spacing between the two
-- columns, leaving 304px for the columns themselves. 304/2 = 152.
local CB_COL_W  = 152  -- checkbox column width
local DD_COL_W  = 200  -- width allocated for each dropdown column

-- Dropdown layout (shared by the window and menu builders)
local DD_COLS      = 2    -- number of dropdowns per row
local DD_MARGIN_X  = 12   -- left/right inner margin for dropdown columns
local DD_WIDTH     = 160  -- UIDropDownMenu_SetWidth value

-- UI spacing tweaks
-- These values are intentionally centralized so we can keep the layout
-- balanced while ensuring the lowest checkbox row never collides with the
-- bottom action buttons.
local CONTENT_NUDGE_Y = 12  -- shifts the whole lower section up slightly
local CHECK_BASE_Y    = -12 -- offset of the first checkbox row under the edit box
local CHECK_ROW_H     = 30  -- vertical spacing between checkbox rows
local CHECK_COL_GAP   = 16  -- spacing between left/right checkbox columns

-- The existing widgets already expect roughly 20px from the top-left
-- of the frame, so we simply expand the overall frame to ensure the same
-- distance on the remaining sides as well.
-- Widen the frame slightly so dropdown menus and other widgets have
-- adequate padding on both sides. This prevents the second column of
-- dropdowns from touching or overlapping the right border when the
-- window is scaled.
-- Compute the window width from the dropdown grid so the columns are
-- perfectly padded on both sides when using 3 dropdowns per row.
local INNER_W = DD_WIDTH + (DD_MARGIN_X * 2) + ((DD_COLS - 1) * DD_COL_W)
local COLLAPSED_W = INNER_W + PAD * 2
local LEFT_PANEL_X = math.floor((COLLAPSED_W - PREVIEW_W) / 2 + 0.5)
frame.__fmCollapsedW = COLLAPSED_W
frame:SetSize(COLLAPSED_W, 500 + PAD * 2)
frame:SetPoint("CENTER")
frame:SetBackdrop({
    bgFile   = "Interface\\DialogFrame\\UI-DialogBox-Background",
    edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
    tile     = true, tileSize = 32,
    edgeSize = BORDER,
    insets   = { left = PAD - BORDER, right = PAD - BORDER,
                 top  = PAD - BORDER, bottom = PAD - BORDER },
})
-- Darker, more opaque background so text stays readable even over bright scenes.
frame:SetBackdropColor(0, 0, 0, 0.92)
frame:EnableMouse(true)
frame:SetMovable(true)
if frame.SetClampedToScreen then pcall(frame.SetClampedToScreen, frame, true) end
frame:RegisterForDrag("LeftButton")
frame:SetScript("OnDragStart", frame.StartMoving)
frame:SetScript("OnDragStop", function(self)
    self:StopMovingOrSizing()
    if self.GetParent and self:GetParent() == UIParent and FontMagicDB and self.GetLeft and self.GetTop then
        local l, t = self:GetLeft(), self:GetTop()
        if type(l) == "number" and type(t) == "number" then
            FontMagicDB.windowPos = { left = l, top = t }
        end
    end
end)
frame:Hide()
frame.name = "FontMagic"

-- Anchor frame used to keep the left-side controls (Apply/Reset/Close/Expand) fixed
-- when the window expands to show the optional Combat Options panel.
local leftAnchor = CreateFrame("Frame", nil, frame)
leftAnchor:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0)
leftAnchor:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0)
leftAnchor:SetWidth(frame.__fmCollapsedW or frame:GetWidth() or 0)
frame.__fmLeftAnchor = leftAnchor

-- allow ESC key to close the frame
if UISpecialFrames then
    tinsert(UISpecialFrames, frame:GetName())
end

-- Register the options panel only once (prevents duplicate categories in Retail Settings).
local _optionsRegistered = false
local function RegisterOptionsCategory()
    if _optionsRegistered then return end
    _optionsRegistered = true
    if type(InterfaceOptions_AddCategory) == "function" then
        pcall(InterfaceOptions_AddCategory, frame)
    end
end

-- 3) COMMON WIDGET HELPERS ---------------------------------------------------
local _cbId = 0

local function GetCheckButtonLabelFS(cb)
    if not cb then return nil end
    -- Modern clients: cb.Text. Older templates: cb.text.
    local fs = cb.Text or cb.text
    if fs and fs.SetText then return fs end

    -- Fallback: scan regions for the first FontString.
    if cb.GetRegions then
        local okRegions, regions = pcall(function() return { cb:GetRegions() } end)
        if okRegions and type(regions) == "table" then
            for _, r in ipairs(regions) do
                if r and r.GetObjectType then
                    local okType, typ = pcall(r.GetObjectType, r)
                    if okType and typ == "FontString" then
                        return r
                    end
                end
            end
        end
    end
    return nil
end

local function SetCheckButtonLabel(cb, label)
    local fs = GetCheckButtonLabelFS(cb)
    if fs then
        fs:SetText(label or "")
        cb.__fmLabelFS = fs
    end
end

local function SetCheckButtonLabelColor(cb, r, g, b)
    local fs = cb and (cb.__fmLabelFS or GetCheckButtonLabelFS(cb))
    if fs and fs.SetTextColor then
        fs:SetTextColor(r, g, b)
        cb.__fmLabelFS = fs
    end
end

local function CreateCheckbox(parent, label, x, y, checked, onClick, anonymous)
    local name = nil
    if not anonymous then
        _cbId = _cbId + 1
        name = addonName .. "CB" .. _cbId
    end
    local cb = CreateFrame("CheckButton", name, parent, "UICheckButtonTemplate")
    cb:SetPoint("TOPLEFT", x, y)
    SetCheckButtonLabel(cb, label)

    -- Tighten label spacing: keep the text directly to the right of the checkbox on all clients.
    do
        local fs = cb and (cb.__fmLabelFS or GetCheckButtonLabelFS(cb))
        if fs and fs.ClearAllPoints and fs.SetPoint then
            fs:ClearAllPoints()
            fs:SetPoint("LEFT", cb, "RIGHT", 2, 0)
            if fs.SetJustifyH then fs:SetJustifyH("LEFT") end
            cb.__fmLabelFS = fs
        end
    end

    cb:SetChecked(checked and true or false)
    if onClick then cb:SetScript("OnClick", onClick) end
    return cb
end


-- 4) FONT DROPDOWNS ---------------------------------------------------------
local dropdowns = {}
local preview
local editBox
local mainShowCombatTextCB
local previewHeaderFS
-- holds a font selection made via the dropdowns but not yet applied
local pendingFont


-- Favorites helpers ---------------------------------------------------------
local function EnsureFavorites()
    if type(FontMagicDB) ~= "table" then FontMagicDB = {} end
    if type(FontMagicDB.favorites) ~= "table" then
        FontMagicDB.favorites = {}
    end
    return FontMagicDB.favorites
end

local function IsFavorite(key)
    local favs = EnsureFavorites()
    return favs[key] == true
end

local function ToggleFavorite(key)
    local favs = EnsureFavorites()
    if favs[key] == true then
        favs[key] = nil
    else
        favs[key] = true
    end
end

-- Select a font key as the current (pending) selection and update the preview.
-- Used when a user clicks the favorite star so the UI still previews what they just favorited.
local SetPreviewFont
local UpdatePreviewHeaderText
-- Incremented on every preview font change. Used to cancel any queued next-frame
-- sizing pass so a stale preview (e.g. from a font you only *previewed*) can't
-- overwrite the currently-applied font after the window is closed/reopened.
local __fmPreviewToken = 0
local function SelectFontKey(key)
    if type(key) ~= "string" or key == "" then return end

    -- Parse "Group/File.ttf" (groups can contain slashes, so match the last slash)
    local grp, fname = key:match("^(.*)/([^/]+)$")
    if not grp or not fname then return end

    local display = __fmStripFontExt(tostring(fname))

    -- Clear other dropdown labels for clarity.
    if type(UIDropDownMenu_SetText) == "function" then
        for _, d2 in pairs(dropdowns) do
            pcall(UIDropDownMenu_SetText, d2, "Select Font")
        end
    end

    pendingFont = key

    -- Prefer updating the dropdown that is currently open (if it's ours), otherwise the group dropdown.
    local targetDD
    local open = _G and _G.UIDROPDOWNMENU_OPEN_MENU
    if open and open.__fmGroup and dropdowns[open.__fmGroup] == open then
        targetDD = open
    else
        targetDD = dropdowns[grp]
    end

    if targetDD and type(UIDropDownMenu_SetText) == "function" then
        if targetDD.__fmGroup == "Favorites" then
            pcall(UIDropDownMenu_SetText, targetDD, display .. " (" .. grp .. ")")
        else
            pcall(UIDropDownMenu_SetText, targetDD, display)
        end
    elseif targetDD and targetDD.SetText then
        if targetDD.__fmGroup == "Favorites" then
            targetDD:SetText(display .. " (" .. grp .. ")")
        else
            targetDD:SetText(display)
        end
    end

    -- Update preview font.
    local cache = cachedFonts and cachedFonts[grp] and cachedFonts[grp][fname]
    if cache then
        SetPreviewFont(cache)
        if UpdatePreviewHeaderText then UpdatePreviewHeaderText(display, cache.path, false) end
    else
        local path = ResolveFontPathFromKey(key)
        if path then
            SetPreviewFont(GameFontNormalLarge or (preview and preview.GetFontObject and preview:GetFontObject()), path)
            if UpdatePreviewHeaderText then UpdatePreviewHeaderText(display, path, false) end
        end
    end

    -- Keep the preview text the same; only the font should change.
    if editBox and preview and editBox.GetText and preview.SetText then
        local txt = editBox:GetText() or ""
        preview:SetText("")
        preview:SetText(txt)
    end
end

-- Safely add buttons to UIDropDownMenu across Classic/Retail variants.
local function SafeAddDropDownButton(info, level)
    if type(UIDropDownMenu_AddButton) ~= "function" then return end
    local ok = pcall(UIDropDownMenu_AddButton, info, level)
    if not ok then
        pcall(UIDropDownMenu_AddButton, info)
    end
end

-- Attach a small clickable star to a dropdown list entry button.
local FM_FAV_TEX_ON  = "Interface\\AddOns\\FontMagic\\Media\\FM_FavStar_Filled.tga"
local FM_FAV_TEX_OFF = "Interface\\AddOns\\FontMagic\\Media\\FM_FavStar_Outline.tga"

local UpdateFavoriteStar

-- UIDropDownMenu list buttons are global/reused across addons.
-- Track only rows FontMagic decorates so we can clean them when our dropdown closes.
frame.__fmMenuCtx = frame.__fmMenuCtx or {}
if type(frame.__fmMenuCtx.touchedButtons) ~= "table" then
    frame.__fmMenuCtx.touchedButtons = setmetatable({}, { __mode = "k" })
end

function frame.__fmMenuCtx.GetDropdownForMenuButton(menuButton)
    if not (menuButton and menuButton.GetParent) then return nil end
    local list = menuButton:GetParent()
    return list and list.dropdown or nil
end

function frame.__fmMenuCtx.IsFontMagicDropdown(dropdown)
    return (dropdown and dropdown.__fmOwner == true) and true or false
end

function frame.__fmMenuCtx.IsFontMagicMenuButton(menuButton)
    local dropdown = frame.__fmMenuCtx.GetDropdownForMenuButton(menuButton)
    if dropdown then
        return frame.__fmMenuCtx.IsFontMagicDropdown(dropdown)
    end
    local open = _G and _G.UIDROPDOWNMENU_OPEN_MENU
    return frame.__fmMenuCtx.IsFontMagicDropdown(open)
end

function frame.__fmMenuCtx.GetOpenFontMagicDropdown()
    local open = _G and _G.UIDROPDOWNMENU_OPEN_MENU
    if frame.__fmMenuCtx.IsFontMagicDropdown(open) then
        return open
    end
    return nil
end

function frame.__fmMenuCtx.MarkTouchedMenuButton(menuButton)
    if menuButton then
        frame.__fmMenuCtx.touchedButtons[menuButton] = true
    end
end

function frame.__fmMenuCtx.BringDropDownListToFront(level)
    local list = _G and _G["DropDownList" .. tostring(level or 1)]
    if not list then return end

    if list.GetFrameStrata and list.__fmOrigStrata == nil then
        list.__fmOrigStrata = list:GetFrameStrata()
    end
    if list.GetFrameLevel and list.__fmOrigLevel == nil then
        list.__fmOrigLevel = list:GetFrameLevel()
    end

    if list.SetFrameStrata then
        list:SetFrameStrata("TOOLTIP")
    end
    if list.SetFrameLevel and frame and frame.GetFrameLevel then
        local target = (frame:GetFrameLevel() or 0) + 120
        if (list:GetFrameLevel() or 0) < target then
            list:SetFrameLevel(target)
        end
    end
    if list.Raise then
        list:Raise()
    end
end

function frame.__fmMenuCtx.BringHoverPreviewToFront(previewFrame)
    if not previewFrame then return end

    local topLevel = 0
    for i = 1, 2 do
        local list = _G and _G["DropDownList" .. tostring(i)]
        if list and list.IsShown and list:IsShown() and list.GetFrameLevel then
            local lvl = list:GetFrameLevel() or 0
            if lvl > topLevel then
                topLevel = lvl
            end
        end
    end
    if topLevel <= 0 and frame and frame.GetFrameLevel then
        topLevel = frame:GetFrameLevel() or 0
    end

    if previewFrame.SetFrameStrata then
        previewFrame:SetFrameStrata("TOOLTIP")
    end
    if previewFrame.SetFrameLevel then
        previewFrame:SetFrameLevel(topLevel + 80)
    end
    if previewFrame.SetToplevel then
        previewFrame:SetToplevel(true)
    end
    if previewFrame.Raise then
        previewFrame:Raise()
    end
end

function frame.__fmMenuCtx.RestoreDropDownListZOrder(level)
    local list = _G and _G["DropDownList" .. tostring(level or 1)]
    if not list then return end

    if list.__fmOrigStrata ~= nil and list.SetFrameStrata then
        pcall(list.SetFrameStrata, list, list.__fmOrigStrata)
    end
    if list.__fmOrigLevel ~= nil and list.SetFrameLevel then
        pcall(list.SetFrameLevel, list, list.__fmOrigLevel)
    end

    list.__fmOrigStrata = nil
    list.__fmOrigLevel = nil
end

function frame.__fmMenuCtx.TouchMenuActivity()
    local now = (type(GetTime) == "function") and GetTime() or 0
    frame.__fmMenuCtx.lastActivityTime = now
    frame.__fmMenuCtx.lastMouseDown = nil
    if frame.__fmMenuCtx.idleWatcher and frame.__fmMenuCtx.idleWatcher.Show then
        frame.__fmMenuCtx.idleWatcher:Show()
    end
end

function frame.__fmMenuCtx.StopIdleWatcher()
    frame.__fmMenuCtx.lastActivityTime = nil
    frame.__fmMenuCtx.lastMouseDown = nil
    if frame.__fmMenuCtx.idleWatcher and frame.__fmMenuCtx.idleWatcher.Hide then
        frame.__fmMenuCtx.idleWatcher:Hide()
    end
end

function frame.__fmMenuCtx.EnsureIdleWatcher()
    if frame.__fmMenuCtx.idleWatcher then return end

    local watcher = CreateFrame("Frame")
    watcher:Hide()
    watcher:SetScript("OnUpdate", function(self)
        local open = frame.__fmMenuCtx.GetOpenFontMagicDropdown()
        if not open then
            frame.__fmMenuCtx.StopIdleWatcher()
            return
        end

        local now = (type(GetTime) == "function") and GetTime() or 0
        local isOverList = false
        if type(MouseIsOver) == "function" then
            for i = 1, 2 do
                local list = _G and _G["DropDownList" .. tostring(i)]
                if list and list.IsShown and list:IsShown() and MouseIsOver(list) then
                    isOverList = true
                    break
                end
            end
            if (not isOverList) and MouseIsOver(open) then
                isOverList = true
            end
        end

        if isOverList then
            frame.__fmMenuCtx.lastActivityTime = now
            frame.__fmMenuCtx.lastMouseDown = nil
            return
        end

        local mouseDown = false
        if type(IsMouseButtonDown) == "function" then
            mouseDown = (IsMouseButtonDown("LeftButton") or IsMouseButtonDown("RightButton")) and true or false
        end
        if mouseDown and (not frame.__fmMenuCtx.lastMouseDown) then
            if type(CloseDropDownMenus) == "function" then
                pcall(CloseDropDownMenus)
            end
            frame.__fmMenuCtx.lastActivityTime = now
            frame.__fmMenuCtx.lastMouseDown = true
            return
        end
        frame.__fmMenuCtx.lastMouseDown = mouseDown

        local last = frame.__fmMenuCtx.lastActivityTime or now
        if (now - last) >= 2.5 then
            if type(CloseDropDownMenus) == "function" then
                pcall(CloseDropDownMenus)
            end
            frame.__fmMenuCtx.lastActivityTime = now
        end
    end)

    frame.__fmMenuCtx.idleWatcher = watcher
end

function frame.__fmMenuCtx.CapturePoints(region)
    local points = {}
    if not (region and region.GetNumPoints and region.GetPoint) then
        return points
    end
    local num = region:GetNumPoints() or 0
    for i = 1, num do
        local p, rel, rp, x, y = region:GetPoint(i)
        points[i] = { p, rel, rp, x, y }
    end
    return points
end

function frame.__fmMenuCtx.RestorePoints(region, points)
    if not (region and region.ClearAllPoints and region.SetPoint) then return end
    if type(points) ~= "table" or #points == 0 then return end
    region:ClearAllPoints()
    for i = 1, #points do
        local pt = points[i]
        if pt then
            region:SetPoint(pt[1], pt[2], pt[3], pt[4], pt[5])
        end
    end
end

function frame.__fmMenuCtx.CleanupDecoratedMenuButton(menuButton, preserveLayout)
    if not menuButton then return end

    menuButton.__fmHoverPrevName = nil
    menuButton.__fmHoverPrevPath = nil
    menuButton.__fmHoverPrevFlags = nil

    local star = menuButton.__fmStar
    if star then
        star.__fmKey = nil
        star.__fmHover = false
        if star.Hide then star:Hide() end
        if GameTooltip and GameTooltip.GetOwner and GameTooltip:GetOwner() == star then
            GameTooltip:Hide()
        end
    end

    if menuButton.__fmTextConstrained and (not preserveLayout) then
        local labelFS = menuButton.GetFontString and menuButton:GetFontString()
        if labelFS then
            frame.__fmMenuCtx.RestorePoints(labelFS, menuButton.__fmOrigLabelPoints)
            if menuButton.__fmOrigLabelJustifyH and labelFS.SetJustifyH then
                labelFS:SetJustifyH(menuButton.__fmOrigLabelJustifyH)
            end
        end
        menuButton.__fmTextConstrained = nil
        menuButton.__fmOrigLabelPoints = nil
        menuButton.__fmOrigLabelJustifyH = nil
    end
end

function frame.__fmMenuCtx.CleanupTouchedMenuButtons()
    for btn in pairs(frame.__fmMenuCtx.touchedButtons) do
        -- Keep FontMagic row layout intact across close/reopen; restore only if a
        -- non-FontMagic dropdown later reuses the same global menu button.
        frame.__fmMenuCtx.CleanupDecoratedMenuButton(btn, true)
        frame.__fmMenuCtx.touchedButtons[btn] = nil
    end
end

function frame.__fmMenuCtx.EnsureDropdownCleanupHooks()
    if frame.__fmMenuCtx.cleanupHooked then return end
    local list = _G and _G["DropDownList1"]
    if list and list.HookScript then
        list:HookScript("OnHide", function()
            if type(frame.__fmMenuCtx.HideHoverPreview) == "function" then
                frame.__fmMenuCtx.HideHoverPreview()
            end
            frame.__fmMenuCtx.CleanupTouchedMenuButtons()
            frame.__fmMenuCtx.RestoreDropDownListZOrder(1)
            frame.__fmMenuCtx.RestoreDropDownListZOrder(2)
            frame.__fmMenuCtx.StopIdleWatcher()
        end)
        frame.__fmMenuCtx.cleanupHooked = true
    end
end

local function AttachFavoriteStar(menuButton, ownerDropdown)
    if not menuButton then return end
    frame.__fmMenuCtx.EnsureDropdownCleanupHooks()

    local activeDropdown = frame.__fmMenuCtx.GetDropdownForMenuButton(menuButton) or ownerDropdown
    if not frame.__fmMenuCtx.IsFontMagicDropdown(activeDropdown) then
        frame.__fmMenuCtx.CleanupDecoratedMenuButton(menuButton)
        return
    end

    local star = menuButton.__fmStar
    if not star then
        star = CreateFrame("Button", nil, menuButton)
        star:SetSize(16, 16)
        star:SetPoint("RIGHT", menuButton, "RIGHT", -6, 0)
        star:RegisterForClicks("LeftButtonUp")

        local icon = star:CreateTexture(nil, "ARTWORK")
        icon:SetAllPoints()
        star.icon = icon

        -- Slightly increase visibility on hover without relying on unicode glyphs
        -- (many default UI fonts do not contain ★/☆ on older clients).
        star:SetScript("OnEnter", function(self)
            local parent = self.GetParent and self:GetParent()
            if not frame.__fmMenuCtx.IsFontMagicMenuButton(parent) then
                self.__fmHover = false
                if GameTooltip and GameTooltip.GetOwner and GameTooltip:GetOwner() == self then
                    GameTooltip:Hide()
                end
                return
            end
            self.__fmHover = true
            if GameTooltip and GameTooltip.SetOwner and GameTooltip.SetText and GameTooltip.Show then
                GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                local key = self.__fmKey
                GameTooltip:SetText((key and IsFavorite(key)) and "Remove from Favorites" or "Add to Favorites")
                GameTooltip:Show()
            end
            if self.icon then
                self.icon:SetAlpha(1)
            end
        end)
        star:SetScript("OnLeave", function(self)
            local parent = self.GetParent and self:GetParent()
            if not frame.__fmMenuCtx.IsFontMagicMenuButton(parent) then
                self.__fmHover = false
                if GameTooltip and GameTooltip.GetOwner and GameTooltip:GetOwner() == self then
                    GameTooltip:Hide()
                end
                return
            end
            self.__fmHover = false
            if GameTooltip and GameTooltip.Hide then GameTooltip:Hide() end
            -- restore the non-hover alpha for the current state
            local key = self.__fmKey
            if self.icon then
                if key and IsFavorite(key) then
                    self.icon:SetAlpha(1)
                else
                    self.icon:SetAlpha(0.75)
                end
            end
        end)

        star:SetScript("OnClick", function(self)
            local parent = self.GetParent and self:GetParent()
            if not frame.__fmMenuCtx.IsFontMagicMenuButton(parent) then return end

            local key = self.__fmKey
            if not key then return end

            ToggleFavorite(key)
            local nowFav = IsFavorite(key)

            -- If we removed a favorite while browsing the Favorites dropdown, do not
            -- force-select it (that makes it look like it still belongs there).
            local open = _G and _G.UIDROPDOWNMENU_OPEN_MENU
            local inFavMenu = (open and open.__fmGroup == "Favorites") and true or false

            if nowFav or not inFavMenu then
                -- Adding a favorite (or toggling anywhere outside Favorites): keep the
                -- existing "select + preview" behavior so the change is visible.
                SelectFontKey(key)
            else
                -- Removed from Favorites while inside Favorites: clear the Favorites
                -- dropdown label only if it was showing this key.
                local shouldClear = (pendingFont == key) or (FontMagicDB and FontMagicDB.selectedFont == key)

                if pendingFont == key then
                    pendingFont = nil
                end

                if shouldClear and dropdowns and dropdowns["Favorites"] then
                    if type(UIDropDownMenu_SetText) == "function" then
                        pcall(UIDropDownMenu_SetText, dropdowns["Favorites"], "Select Font")
                    elseif dropdowns["Favorites"].SetText then
                        dropdowns["Favorites"]:SetText("Select Font")
                    end
                end
            end

            -- refresh this button's star state immediately
            if parent then
                UpdateFavoriteStar(parent, key, frame.__fmMenuCtx.GetDropdownForMenuButton(parent))
            end

            -- Favorites list is built on dropdown open; closing is the safest way to ensure it refreshes everywhere.
            if type(CloseDropDownMenus) == "function" then
                CloseDropDownMenus()
            end
        end)

        menuButton.__fmStar = star
    end

    if menuButton.HookScript and not menuButton.__fmContainmentHooked then
        menuButton:HookScript("OnShow", function(self)
            local dd = frame.__fmMenuCtx.GetDropdownForMenuButton(self)
            if dd and not frame.__fmMenuCtx.IsFontMagicDropdown(dd) then
                frame.__fmMenuCtx.CleanupDecoratedMenuButton(self, false)
            end
        end)
        menuButton.__fmContainmentHooked = true
    end

    -- Constrain the entry label so it does not run underneath the star.
    local labelFS = menuButton.GetFontString and menuButton:GetFontString()
    if labelFS and not menuButton.__fmTextConstrained then
        menuButton.__fmOrigLabelPoints = frame.__fmMenuCtx.CapturePoints(labelFS)
        if labelFS.GetJustifyH then
            menuButton.__fmOrigLabelJustifyH = labelFS:GetJustifyH()
        end
        labelFS:ClearAllPoints()
        labelFS:SetPoint("LEFT", menuButton, "LEFT", 24, 0)
        labelFS:SetPoint("RIGHT", star, "LEFT", -4, 0)
        if labelFS.SetJustifyH then
            labelFS:SetJustifyH("LEFT")
        end
        menuButton.__fmTextConstrained = true
    end

    frame.__fmMenuCtx.MarkTouchedMenuButton(menuButton)
end

UpdateFavoriteStar = function(menuButton, key, ownerDropdown)
    if not menuButton or not menuButton.__fmStar then return end
    local activeDropdown = frame.__fmMenuCtx.GetDropdownForMenuButton(menuButton) or ownerDropdown
    if not frame.__fmMenuCtx.IsFontMagicDropdown(activeDropdown) then
        frame.__fmMenuCtx.CleanupDecoratedMenuButton(menuButton)
        return
    end

    frame.__fmMenuCtx.MarkTouchedMenuButton(menuButton)

    local star = menuButton.__fmStar
    star.__fmKey = key

    -- UIDropDownMenu recycles list buttons. If this row is a disabled placeholder
    -- (e.g. "No favorites yet"), hide any previously-attached star.
    if not key then
        if star.Hide then star:Hide() end
        return
    end
    if star.Show then star:Show() end

    local fav = (IsFavorite(key)) and true or false
    if star.icon then
        if fav then
            star.icon:SetTexture(FM_FAV_TEX_ON)
            star.icon:SetVertexColor(1, 0.82, 0)
            star.icon:SetAlpha(1)
        else
            star.icon:SetTexture(FM_FAV_TEX_OFF)
            star.icon:SetVertexColor(0.75, 0.75, 0.75)
            star.icon:SetAlpha(star.__fmHover and 1 or 0.75)
        end
    end
end

local function GetDropDownListButton(level, index)
    return _G and _G["DropDownList" .. tostring(level) .. "Button" .. tostring(index)] or nil
end

--[[
    Apply a font to the preview field.

    The original implementation assumed fontObj was always valid. During early
    loading however, "GameFontNormalLarge" may not yet exist which triggered
    the reported "attempt to index local 'fontObj'" error. To make the function
    robust we guard against nil values and gracefully fall back when loading via
    the font path fails.

    NOTE: Some font files report incorrect metrics on the same frame they are
    first applied (the client may still be loading the face). To ensure the
    preview size normalizes immediately (no "select twice" behavior), we do the
    fit-to-bounds sizing pass on the next frame.
--]]

-- Next-frame helper (compatible across Classic/Retail; avoids relying on C_Timer).
local __fmNextFrameFrame
local __fmNextFrameQueue
local function __fmRunNextFrame(fn)
    if type(fn) ~= "function" then return end
    if not __fmNextFrameFrame then
        __fmNextFrameQueue = {}
        __fmNextFrameFrame = CreateFrame("Frame")
        __fmNextFrameFrame:Hide()
        __fmNextFrameFrame:SetScript("OnUpdate", function(self)
            self:Hide()
            local q = __fmNextFrameQueue or {}
            __fmNextFrameQueue = {}
            for i = 1, #q do
                pcall(q[i])
            end
        end)
    end
    table.insert(__fmNextFrameQueue, fn)
    __fmNextFrameFrame:Show()
end




-- Hover preview for font dropdown items --------------------------------------
-- Shows a small tooltip-like window near the cursor rendering the hovered font name
-- in its own face, so users can browse without clicking each entry.

local __fmHoverPrevFrame, __fmHoverPrevText
local __fmHoverPrevToken = 0

local function __fmGetCursorScaled()
    if type(GetCursorPosition) ~= "function" then return 0, 0 end
    local x, y = GetCursorPosition()
    local scale = 1
    if UIParent and UIParent.GetEffectiveScale then
        scale = UIParent:GetEffectiveScale() or 1
    end
    if scale == 0 then scale = 1 end
    return (x or 0) / scale, (y or 0) / scale
end

local function __fmPositionHoverPreview(f)
    if not (f and UIParent and UIParent.GetWidth and UIParent.GetHeight) then return end
    local x, y = __fmGetCursorScaled()
    local uiW = UIParent:GetWidth() or 0
    local uiH = UIParent:GetHeight() or 0
    local w  = f:GetWidth()  or 0
    local h  = f:GetHeight() or 0

    -- Preferred placement: to the right and slightly above the cursor.
    local offsetX, offsetY = 18, 18
    local left = x + offsetX
    local top  = y + offsetY

    -- If near the right edge, flip to the left of the cursor.
    if (left + w) > (uiW - 6) then
        left = x - offsetX - w
    end
    -- Clamp to screen bounds (a little inset keeps border visible).
    if left < 6 then left = 6 end
    if (left + w) > (uiW - 6) then left = (uiW - w - 6) end

    -- Keep within vertical bounds.
    if top > (uiH - 6) then
        top = uiH - 6
    end
    if (top - h) < 6 then
        -- Try placing below the cursor if we're too high.
        top = y - offsetY
        if (top - h) < 6 then
            top = h + 6
        end
    end

    f:ClearAllPoints()
    f:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", left, top)
end

local function __fmEnsureHoverPreviewFrame()
    if __fmHoverPrevFrame then return __fmHoverPrevFrame end

    local f = CreateFrame("Frame", addonName .. "HoverPreview", UIParent, backdropTemplate)
    f:SetSize(260, 46)
    f:SetFrameStrata("TOOLTIP")
    f:SetClampedToScreen(true)
    f:EnableMouse(false)

    if f.SetBackdrop then
        f:SetBackdrop({
            bgFile   = "Interface\\ChatFrame\\ChatFrameBackground",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            tile     = true, tileSize = 16,
            edgeSize = 12,
            insets   = { left = 3, right = 3, top = 3, bottom = 3 },
        })
        f:SetBackdropColor(0, 0, 0, 0.92)
        if f.SetBackdropBorderColor then
            f:SetBackdropBorderColor(0.35, 0.35, 0.35, 1)
        end
    end

    local fs = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    fs:ClearAllPoints()
    fs:SetPoint("CENTER", f, "CENTER", 0, -1)
    fs:SetWidth((f:GetWidth() or 260) - 14)
    fs:SetJustifyH("CENTER")
    if fs.SetJustifyV then pcall(fs.SetJustifyV, fs, "MIDDLE") end

    f.text = fs
    f:Hide()

    f.__fmFollowCursor = true
    f:SetScript("OnUpdate", function(self)
        if self:IsShown() and self.__fmFollowCursor then
            if frame and frame.__fmMenuCtx and type(frame.__fmMenuCtx.BringHoverPreviewToFront) == "function" then
                frame.__fmMenuCtx.BringHoverPreviewToFront(self)
            end
            __fmPositionHoverPreview(self)
        end
    end)

    __fmHoverPrevFrame = f
    __fmHoverPrevText  = fs
    return f
end

frame.__fmMenuCtx.HideHoverPreview = function()
    __fmHoverPrevToken = (__fmHoverPrevToken or 0) + 1
    if __fmHoverPrevFrame then
        __fmHoverPrevFrame:Hide()
    end
end

local function __fmShowHoverPreview(display, path, flags)
    if type(display) ~= "string" or display == "" then return end
    if type(path) ~= "string" or path == "" then return end

    local f = __fmEnsureHoverPreviewFrame()
    if not (f and f.text) then return end

    local fs = f.text
    fs:SetText(display)

    -- Use a conservative starter size; we'll fit-to-bounds next frame.
    local baseObj = GameFontNormalLarge or GameFontNormal or GameFontHighlight
    local _, _, baseFlags = (baseObj and baseObj.GetFont and baseObj:GetFont())
    local flagsToUse = flags or baseFlags or ""

    local ok = false
    if fs.SetFont then
        ok = pcall(fs.SetFont, fs, path, 18, flagsToUse)
        if (not ok) and flagsToUse ~= "" then
            ok = pcall(fs.SetFont, fs, path, 18, "")
            if ok then flagsToUse = "" end
        end
    end
    if (not ok) and fs.SetFontObject and baseObj then
        pcall(fs.SetFontObject, fs, baseObj)
    end

    __fmHoverPrevToken = (__fmHoverPrevToken or 0) + 1
    local myToken = __fmHoverPrevToken

    __fmRunNextFrame(function()
        if myToken ~= __fmHoverPrevToken then return end
        if not (__fmHoverPrevFrame and __fmHoverPrevFrame.IsShown and __fmHoverPrevFrame:IsShown()) then return end
        if not (__fmHoverPrevText and __fmHoverPrevText.SetFont and __fmHoverPrevText.GetStringWidth and __fmHoverPrevText.GetStringHeight) then return end

        local maxW = (__fmHoverPrevFrame:GetWidth() or 260) - 16
        local maxH = (__fmHoverPrevFrame:GetHeight() or 46) - 16
        if maxW < 40 then maxW = 200 end
        if maxH < 16 then maxH = 24 end

        local function trySize(sz)
            local ok2 = pcall(__fmHoverPrevText.SetFont, __fmHoverPrevText, path, sz, flagsToUse)
            if not ok2 and flagsToUse ~= "" then
                ok2 = pcall(__fmHoverPrevText.SetFont, __fmHoverPrevText, path, sz, "")
                if ok2 then flagsToUse = "" end
            end
            if not ok2 then return false end
            __fmHoverPrevText:SetText(display)
            local w = __fmHoverPrevText:GetStringWidth() or 0
            local h = __fmHoverPrevText:GetStringHeight() or 0
            return (w <= maxW and h <= maxH)
        end

        local low, high = 10, 28
        local best = 14
        while low <= high do
            local mid = math.floor((low + high) / 2)
            if trySize(mid) then
                best = mid
                low = mid + 1
            else
                high = mid - 1
            end
        end
        trySize(best)
    end)

    if frame and frame.__fmMenuCtx and type(frame.__fmMenuCtx.BringHoverPreviewToFront) == "function" then
        frame.__fmMenuCtx.BringHoverPreviewToFront(f)
    end
    __fmPositionHoverPreview(f)
    f:Show()
end

local function __fmAttachHoverPreviewToMenuButton(btn, display, cache, ownerDropdown)
    if not btn then return end
    if type(display) ~= "string" or display == "" then return end
    frame.__fmMenuCtx.EnsureDropdownCleanupHooks()

    local activeDropdown = frame.__fmMenuCtx.GetDropdownForMenuButton(btn) or ownerDropdown
    if not frame.__fmMenuCtx.IsFontMagicDropdown(activeDropdown) then
        frame.__fmMenuCtx.CleanupDecoratedMenuButton(btn)
        return
    end

    local path, flags
    if type(cache) == "table" then
        path  = cache.path
        flags = cache.flags
    end
    if type(path) ~= "string" or path == "" then return end

    btn.__fmHoverPrevName  = display
    btn.__fmHoverPrevPath  = path
    btn.__fmHoverPrevFlags = flags
    frame.__fmMenuCtx.MarkTouchedMenuButton(btn)

    if btn.HookScript and not btn.__fmHoverPrevHooked then
        btn:HookScript("OnEnter", function(self)
            if not frame.__fmMenuCtx.IsFontMagicMenuButton(self) then
                frame.__fmMenuCtx.HideHoverPreview()
                return
            end
            if self.__fmHoverPrevPath then
                __fmShowHoverPreview(self.__fmHoverPrevName or "", self.__fmHoverPrevPath, self.__fmHoverPrevFlags)
            end
        end)
        btn:HookScript("OnLeave", function()
            frame.__fmMenuCtx.HideHoverPreview()
        end)
        btn.__fmHoverPrevHooked = true
    end
end

-- Ensure we never leave the hover preview orphaned if menus close without an OnLeave (rare but possible).
if type(hooksecurefunc) == "function" then
    if type(CloseDropDownMenus) == "function" then
        pcall(hooksecurefunc, "CloseDropDownMenus", frame.__fmMenuCtx.HideHoverPreview)
    end
    if type(UIDropDownMenu_HideDropDownMenu) == "function" then
        pcall(hooksecurefunc, "UIDropDownMenu_HideDropDownMenu", frame.__fmMenuCtx.HideHoverPreview)
    end
end

-- Force a redraw of the preview text (some clients won't repaint an unchanged string
-- immediately after SetFont/SetFontObject until the text changes).
local function ForcePreviewTextRedraw()
    if not (preview and type(preview.SetText) == "function") then return end

    local txt = ""
    if editBox and type(editBox.GetText) == "function" then
        txt = tostring(editBox:GetText() or "")
    elseif type(preview.GetText) == "function" then
        txt = tostring(preview:GetText() or "")
    end

    local cur = ""
    if type(preview.GetText) == "function" then
        cur = tostring(preview:GetText() or "")
    end

    if txt ~= cur then
        preview:SetText(txt)
    else
        preview:SetText(txt .. " ")
        preview:SetText(txt)
    end
end

SetPreviewFont = function(fontObj, path)
    -- allow passing the cached font table directly
    if type(fontObj) == "table" then
        path    = fontObj.path or path
        fontObj = fontObj.font
    end

    -- Some clients (or earlier calls to SetFont) can leave preview:GetFontObject() nil.
    -- We still want preview updates to work even when no FontObject is available.
    local baseObj = fontObj
    if not (baseObj and type(baseObj.GetFont) == "function") then
        baseObj = GameFontNormalLarge or GameFontNormal or GameFontHighlight or (preview and preview.GetFontObject and preview:GetFontObject())
    end

    local baseFlags = ""
    if baseObj and type(baseObj.GetFont) == "function" then
        local _, _, f = baseObj:GetFont()
        if type(f) == "string" then
            baseFlags = f
        end
    end

    local function GetCurrentPreviewText()
        if editBox and editBox.GetText then
            return tostring(editBox:GetText() or "")
        end
        if preview and preview.GetText then
            return tostring(preview:GetText() or "")
        end
        return ""
    end

    -- If we have an explicit file path, apply it and normalize size so all fonts
    -- appear visually consistent inside the preview box (fit-to-bounds).
    if path and preview and type(preview.SetFont) == "function" then
        local flagsToUse = baseFlags


        local function tryApply(sz, flags)
            local ok = pcall(preview.SetFont, preview, path, sz, flags)
            if not ok then return false end

            -- Do not trust the return value from SetFont: different clients / objects
            -- return different values (or nothing), and some apply the face asynchronously.
            -- If we can't confirm immediately, we still proceed and normalize next frame.
            if preview and type(preview.GetFont) == "function" then
                local curPath = preview:GetFont()
                if type(curPath) == "string" then
                    local want = tostring(path)
                    if curPath:lower() == want:lower() then
                        return true
                    end
                    local wantFile = want:match("([^\\/:]+)$")
                    if wantFile and curPath:lower():find(wantFile:lower(), 1, true) then
                        return true
                    end
                end
            end

            return true
        end

        -- Force the face to load immediately with a sensible starter size.
        local applied = tryApply(24, flagsToUse)
        if not applied then
            applied = tryApply(24, "")
            if applied then
                flagsToUse = ""
            end
        end

        if not applied then
            -- If the client refuses to load this font path, fall back to a base FontObject.
            if preview and type(preview.SetFontObject) == "function" and baseObj then
                pcall(preview.SetFontObject, preview, baseObj)
            end
            -- Ensure text is still visible.
            if preview and preview.SetText then
                ForcePreviewTextRedraw()
            end
            return
        end

        -- Ensure the current preview text is displayed immediately.
        if preview and preview.SetText then
            ForcePreviewTextRedraw()
        end

        __fmPreviewToken = (__fmPreviewToken or 0) + 1
        local myToken = __fmPreviewToken

        __fmRunNextFrame(function()
            if myToken ~= __fmPreviewToken then return end
            if not preview or type(preview.SetFont) ~= "function" then return end

            -- Use the preview's parent (the preview box) for sizing.
            local parent = (preview.GetParent and preview:GetParent()) or nil
            local maxW, maxH = 0, 0
            local padX, padY = 14, 16
            if parent and parent.GetWidth and parent.GetHeight then
                maxW = (parent:GetWidth() or 0) - padX
                maxH = (parent:GetHeight() or 0) - padY
            end
            if maxW <= 0 then maxW = (preview.GetWidth and preview:GetWidth()) or 300 end
            if maxH <= 0 then maxH = (preview.__fmMaxH or 44) end
            if maxH < 18 then maxH = 18 end

            if preview.SetWordWrap then
                pcall(preview.SetWordWrap, preview, false)
            end

            -- Measure off-screen so we never blank or replace the user's preview text.
            local measure = preview.__fmMeasure
            if not measure and parent and parent.CreateFontString then
                measure = parent:CreateFontString(nil, "OVERLAY")
                measure:Hide()
                preview.__fmMeasure = measure
            end

            local sample = "H8g"

            local function trySize(sz)
                if myToken ~= __fmPreviewToken then return false end

                if measure and type(measure.SetFont) == "function" then
                    local ok, ret = pcall(measure.SetFont, measure, path, sz, flagsToUse)
                    if not ok or ret == nil or ret == false then return false end
                    if measure.SetText then
                        measure:SetText(sample)
                    end
                    local w = (measure.GetStringWidth and measure:GetStringWidth()) or 0
                    local h = (measure.GetStringHeight and measure:GetStringHeight()) or 0
                    return (w <= maxW and h <= (maxH - 2))
                end

                -- Fallback: if we cannot create a measure string, temporarily measure on the preview.
                local old = GetCurrentPreviewText()
                if preview.SetText then preview:SetText(sample) end
                local ok, ret = pcall(preview.SetFont, preview, path, sz, flagsToUse)
                local w = (preview.GetStringWidth and preview:GetStringWidth()) or 0
                local h = (preview.GetStringHeight and preview:GetStringHeight()) or 0
                if preview.SetText then preview:SetText(old) end
                if not ok or ret == nil or ret == false then return false end
                return (w <= maxW and h <= (maxH - 2))
            end

            -- Find the largest size that fits (binary search).
            local low, high = 8, 90
            local best = 8
            if not trySize(best) then best = 8 end
            while low <= high do
                local mid = math.floor((low + high) / 2)
                if trySize(mid) then
                    best = mid
                    low = mid + 1
                else
                    high = mid - 1
                end
            end

            if myToken ~= __fmPreviewToken then return end
            pcall(preview.SetFont, preview, path, best, flagsToUse)

            -- Force a redraw of the preview text even if it hasn't changed.
            if preview and preview.SetText then
                local finalText = GetCurrentPreviewText()
                local curText = ""
                if preview.GetText then curText = tostring(preview:GetText() or "") end
                if finalText ~= curText then
                    preview:SetText(finalText)
                else
                    -- Some clients won't redraw if the string is identical.
                    preview:SetText(finalText .. " ")
                    preview:SetText(finalText)
                end
            end
        end)

        return
    end

    -- Fall back to using the font object if explicit path loading is not available.
    if preview and type(preview.SetFontObject) == "function" and baseObj then
        pcall(preview.SetFontObject, preview, baseObj)
        if preview and preview.SetText then
            ForcePreviewTextRedraw()
        end
    end
end



-- Update the font-name label above the preview box so it always reflects what is being previewed.
local __fmHeaderToken = 0
UpdatePreviewHeaderText = function(displayText, fontPath, isDefault)
    if not previewHeaderFS then return end

    local text = "Default"
    if not isDefault and type(displayText) == "string" and displayText ~= "" then
        text = displayText
    end
    if previewHeaderFS.SetText then
        previewHeaderFS:SetText(text)
    end

    local path = fontPath
    if type(path) ~= "string" or path == "" then
        CaptureBlizzardDefaultFonts()
        path = DEFAULT_DAMAGE_TEXT_FONT or DEFAULT_COMBAT_TEXT_FONT or DEFAULT_FONT_PATH or "Fonts\\FRIZQT__.TTF"
    end
    if type(path) ~= "string" or path == "" then return end

    local baseObj = GameFontNormal or GameFontNormalLarge or (preview and preview.GetFontObject and preview:GetFontObject())
    local _, baseSize, baseFlags = (baseObj and baseObj.GetFont and baseObj:GetFont())
    local flagsToUse = baseFlags or ""
    local starterSize = tonumber(baseSize) or 14
    if starterSize < 12 then starterSize = 12 end
    if starterSize > 18 then starterSize = 18 end

    if type(previewHeaderFS.SetFont) ~= "function" then
        if previewHeaderFS.SetFontObject and baseObj then
            pcall(previewHeaderFS.SetFontObject, previewHeaderFS, baseObj)
        end
        return
    end

    -- Attempt an immediate apply so it doesn't remain at a tiny fallback size.
    do
        local ok = pcall(previewHeaderFS.SetFont, previewHeaderFS, path, starterSize, flagsToUse)
        if not ok and flagsToUse ~= "" then
            ok = pcall(previewHeaderFS.SetFont, previewHeaderFS, path, starterSize, "")
            if ok then
                flagsToUse = ""
            end
        end
        if not ok and previewHeaderFS.SetFontObject and baseObj then
            pcall(previewHeaderFS.SetFontObject, previewHeaderFS, baseObj)
        end
    end

    __fmHeaderToken = (__fmHeaderToken or 0) + 1
    local myToken = __fmHeaderToken

    __fmRunNextFrame(function()
        if myToken ~= __fmHeaderToken then return end
        if not previewHeaderFS or type(previewHeaderFS.SetFont) ~= "function" then return end

        local maxW = ((previewHeaderFS.GetWidth and previewHeaderFS:GetWidth()) or (PREVIEW_W or 320)) - 12
        if maxW < 40 then maxW = (PREVIEW_W or 320) - 12 end
        local maxH = 18

        local function trySize(sz)
            local ok = pcall(previewHeaderFS.SetFont, previewHeaderFS, path, sz, flagsToUse)
            if not ok and flagsToUse ~= "" then
                ok = pcall(previewHeaderFS.SetFont, previewHeaderFS, path, sz, "")
                if ok then
                    flagsToUse = ""
                end
            end
            if not ok then return false end

            local w = (previewHeaderFS.GetStringWidth and previewHeaderFS:GetStringWidth()) or 0
            local h = (previewHeaderFS.GetStringHeight and previewHeaderFS:GetStringHeight()) or 0
            return (w <= maxW and h <= maxH)
        end

        local low, high = 8, 22
        local best = 12
        while low <= high do
            local mid = math.floor((low + high) / 2)
            if trySize(mid) then
                best = mid
                low = mid + 1
            else
                high = mid - 1
            end
        end
        trySize(best)
    end)
end

-- Dropdowns for each font group arranged neatly in two columns
-- Displayed dropdown order; the "Custom" category replaces the old "Default"
-- dropdown to allow users to provide their own fonts.
local order = {"Favorites", "Popular", "Clean & Readable", "Bold & Impact", "Fantasy & RP", "Sci-Fi & Tech", "Custom", "Random"}
-- "Popular" is driven by the /Popular folder (authoritative).
-- Optional display-name overrides for cleaner dropdown labels.
local FONT_LABEL_OVERRIDES = {
    ["bignoodletitling.ttf"]             = "Big Noodle Titling",
    ["PTSansNarrow-Bold.ttf"]            = "PT Sans Narrow (Bold)",
    ["NotoSans_Condensed-Bold.ttf"]      = "Noto Sans Condensed (Bold)",
    ["Roboto Condensed Bold.ttf"]        = "Roboto Condensed (Bold)",
    ["Roboto-Bold.ttf"]                  = "Roboto (Bold)",
    ["CalibriBold.ttf"]                  = "Calibri (Bold)",
    ["AlteHaasGroteskBold.ttf"]          = "Alte Haas Grotesk (Bold)",
    ["Proxima Nova Condensed Bold.ttf"]  = "Proxima Nova Condensed (Bold)",
    ["lemon-milk.ttf"]                   = "Lemon Milk",
}

local FONT_LABEL_OVERRIDES_L = {}
for k, v in pairs(FONT_LABEL_OVERRIDES) do
    if type(k) == "string" then
        FONT_LABEL_OVERRIDES_L[k:lower()] = v
    end
end

local function DisplayNameForFont(fname)
    if type(fname) ~= "string" or fname == "" then return "" end
    local base = __fmStripFontExt(fname)
    local lower = fname:lower()
    local label = FONT_LABEL_OVERRIDES[fname] or FONT_LABEL_OVERRIDES_L[lower] or base
    return __fmShortenFontLabel(label)
end



local lastDropdown
for idx, grp in ipairs(order) do
    local dd = CreateFrame("Frame", addonName .. grp:gsub("[^%w]", "") .. "DD", frame, "UIDropDownMenuTemplate")
    dd.__fmOwner = true
    dd.__fmGroup = grp
    local row = math.floor((idx-1)/DD_COLS)
    local col = (idx-1) % DD_COLS

    -- If the last row is not full, center the remaining dropdown for a cleaner look.
    local remainder = #order % DD_COLS
    local x = DD_MARGIN_X + col * DD_COL_W
    if remainder == 1 and idx == #order and grp ~= "Custom" then
        x = DD_MARGIN_X + (DD_COL_W / 2)
    end

-- place dropdowns below the InterfaceOptions title text
    -- shift dropdowns slightly so left and right margins are even
    -- Position each dropdown in its own column.  We reduce the default
    -- horizontal margin from 16px to 12px so that the combined width of the
    -- two dropdowns, the spacing between them and the left/right margins add
    -- up symmetrically across the options window.  Without this adjustment
    -- the rightmost dropdown sits flush against the frame border.
    dd:SetPoint("TOPLEFT", frame, "TOPLEFT", x, -(HEADER_H + row*50) + 8)
    UIDropDownMenu_SetWidth(dd, DD_WIDTH)
    dropdowns[grp] = dd
    if idx == #order then
        lastDropdown = dd -- remember last dropdown for layout anchoring
    end

    local label = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    -- position label so its left edge aligns with the dropdown
    label:ClearAllPoints()
    label:SetPoint("BOTTOM", dd, "TOP", 0, 3)
    -- Use the frame's GetWidth() method to obtain the dropdown width.
    -- UIDropDownMenu_GetWidth has been removed in Dragonflight and older
    -- globals may not return a valid number. GetWidth() is available on all
    -- frames and ensures a numeric width is returned.
    label:SetWidth(dd:GetWidth())
    label:SetJustifyH("CENTER")
    label:SetText(grp)

    if grp == "Custom" then
        dd:SetScript("OnEnter", function(self)
            if not hasCustomFonts then
                GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                GameTooltip:SetText("Install FontMagicCustomFonts to use custom fonts", nil, nil, nil, nil, true)
                GameTooltip:Show()
            end
        end)
        dd:SetScript("OnLeave", function() GameTooltip:Hide() end)
    end

    UIDropDownMenu_Initialize(dd, function()
        local level = UIDROPDOWNMENU_MENU_LEVEL or 1
        frame.__fmMenuCtx.EnsureDropdownCleanupHooks()
        frame.__fmMenuCtx.EnsureIdleWatcher()
        frame.__fmMenuCtx.BringDropDownListToFront(level)
        frame.__fmMenuCtx.TouchMenuActivity()
        local added = false
        local buttonIndex = 0

        if grp == "Favorites" then
            local favs = EnsureFavorites()
            local favList = {}
            for key, on in pairs(favs) do
        if on == true then
            -- Parse using the last '/' so legacy groups with slashes are handled.
            local g, f = key:match("^(.*)/([^/]+)$")
            if g and f and existsFonts[g] and existsFonts[g][f] and cachedFonts[g] and cachedFonts[g][f] then
                local display = (g == "Custom") and __fmStripFontExt(f) or DisplayNameForFont(f)
                table.insert(favList, { key = key, grp = g, fname = f, display = display })
            end
        end
            end
            table.sort(favList, function(a, b)
                return (a.display:lower() .. a.key) < (b.display:lower() .. b.key)
            end)

            for _, item in ipairs(favList) do
                added = true
                buttonIndex = buttonIndex + 1

                local info = UIDropDownMenu_CreateInfo()
                local cache = cachedFonts[item.grp][item.fname]
                info.text = item.display .. " (" .. item.grp .. ")"
                info.func = function()
                    for g2, d2 in pairs(dropdowns) do UIDropDownMenu_SetText(d2, "Select Font") end
                    -- store the selected font locally until the user clicks Apply
                    pendingFont = item.key
                    UIDropDownMenu_SetText(dd, item.display)
                    SetPreviewFont(cache)
                    if UpdatePreviewHeaderText then UpdatePreviewHeaderText(item.display, cache.path, false) end
                    local txt = editBox:GetText()
                    preview:SetText("")
                    preview:SetText(txt)
                end
                local selected = pendingFont or FontMagicDB.selectedFont
                info.checked = (selected == item.key)
                SafeAddDropDownButton(info, level)

                local mb = GetDropDownListButton(level, buttonIndex)
                if mb then
                    AttachFavoriteStar(mb, dd)
                    UpdateFavoriteStar(mb, item.key, dd)
                    __fmAttachHoverPreviewToMenuButton(mb, item.display, cache, dd)
                end
            end
        else
            local groupData = COMBAT_FONT_GROUPS[grp]
            if type(groupData) == "table" and type(groupData.fonts) == "table" then
                for _, fname in ipairs(groupData.fonts) do
                if existsFonts[grp][fname] then
                    added = true
                    buttonIndex = buttonIndex + 1

                    local info = UIDropDownMenu_CreateInfo()
                    local display = (grp == "Custom") and __fmStripFontExt(fname) or DisplayNameForFont(fname)
                    local cache  = cachedFonts[grp][fname]
                    local key    = grp .. "/" .. fname

                    info.text = display
                    info.func = function()
                        for g2, d2 in pairs(dropdowns) do UIDropDownMenu_SetText(d2, "Select Font") end
                        -- store the selected font locally until the user clicks Apply
                        pendingFont = key
                        UIDropDownMenu_SetText(dd, display)
                        SetPreviewFont(cache)
                        if UpdatePreviewHeaderText then UpdatePreviewHeaderText(display, cache.path, false) end
                        local txt = editBox:GetText()
                        preview:SetText("")
                        preview:SetText(txt)
                    end
                    local selected = pendingFont or FontMagicDB.selectedFont
                    info.checked = (selected == key)
                    SafeAddDropDownButton(info, level)

                    local mb = GetDropDownListButton(level, buttonIndex)
                    if mb then
                        AttachFavoriteStar(mb, dd)
                        UpdateFavoriteStar(mb, key, dd)
                        __fmAttachHoverPreviewToMenuButton(mb, display, cache, dd)
                    end
                end
            end
            end
        end
        if not added then
            local info = UIDropDownMenu_CreateInfo()
            info.text = (grp == "Favorites") and "No favorites yet" or "No font detected"
            info.disabled = true
            SafeAddDropDownButton(info, level)

            -- This entry is a disabled placeholder; ensure any recycled menu button
            -- doesn't show a leftover favorite star from a previous dropdown open.
            local mb = GetDropDownListButton(level, buttonIndex + 1)
            if mb and mb.__fmStar then
                UpdateFavoriteStar(mb, nil, dd)
            end
        end
    end)
end

-- 5) SCALE SLIDER -----------------------------------------------------------
local scaleLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
-- Centre the combat text size label beneath the last row of dropdowns.  We
-- calculate how many rows of dropdowns exist (three columns per row) and
-- position the label relative to the top of the frame so it always sits
-- centred horizontally regardless of whether the last dropdown falls in the
-- left or right column.  The vertical offset accounts for the header
-- height, the number of dropdown rows and additional padding.
do
    local rows = math.ceil(#order / DD_COLS)
    -- Nudge the whole lower section up slightly so the checkbox block stays
    -- comfortably above the bottom action buttons.
    local y = -(HEADER_H + rows * 50) - 20 + CONTENT_NUDGE_Y + 6
    scaleLabel:SetPoint("TOPLEFT", frame, "TOPLEFT", LEFT_PANEL_X, y)
end
scaleLabel:SetWidth(PREVIEW_W)
scaleLabel:SetJustifyH("CENTER")
scaleLabel:SetText("Combat Text Size:")

local slider = CreateFrame("Slider", addonName .. "ScaleSlider", frame, "OptionsSliderTemplate")
-- Match the width of the preview/edit boxes so the slider aligns perfectly
-- with the elements below.  A wider slider improves balance within the
-- options window.
slider:SetSize(PREVIEW_W, 16)
-- Anchor the slider below the combat text size label and centre it
-- horizontally.  This eliminates the previous left‑anchored layout which
-- appeared off centre.
slider:SetPoint("TOP", scaleLabel, "BOTTOM", 0, -8) -- temporary; we'll re-anchor after creating the value label

-- The OptionsSliderTemplate adds its own "Text" label which can overlap with
-- our custom scale value and the "Combat Text Size" label. Hide it for a
-- cleaner, more predictable layout across versions.
do
    local sliderText = slider.Text or _G[slider:GetName() .. "Text"]
    if sliderText and sliderText.Hide then
        sliderText:Hide()
    end

    -- Re-anchor the built-in Low/High labels flush with the ends of the bar.
    local low = _G[slider:GetName() .. "Low"]
    local high = _G[slider:GetName() .. "High"]
    if low and high then
        if low.ClearAllPoints then low:ClearAllPoints() end
        if high.ClearAllPoints then high:ClearAllPoints() end
        if low.SetPoint then low:SetPoint("TOPLEFT", slider, "BOTTOMLEFT", 0, -2) end
        if high.SetPoint then high:SetPoint("TOPRIGHT", slider, "BOTTOMRIGHT", 0, -2) end
    end
end

local function GetCombatTextScaleCVar()
    -- Blizzard renamed a number of Floating Combat Text settings in late 12.0 hotfixes
    -- (often appending a _v2 suffix). In some builds these also appear as console
    -- commands. Resolve the correct name/type at runtime.
    local candidates = {
        "WorldTextScale_v2",
        "WorldTextScale_V2",
        "WorldTextScale",
        "floatingCombatTextScale_v2",
        "floatingCombatTextScale_V2",
        "floatingCombatTextScale",
    }

    local name, commandType = ResolveConsoleSetting(candidates)
    if name then
        local value = GetCVarString(name)
        return name, commandType, value
    end

    return nil, nil, nil
end


local scaleCVar, scaleCVarCommandType, scaleCVarValue = GetCombatTextScaleCVar()
local scaleSupported = scaleCVar ~= nil
local COMBAT_TEXT_SCALE_MIN = 0.1
local COMBAT_TEXT_SCALE_MAX = 5.0

local scaleValue = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
scaleValue:ClearAllPoints()
scaleValue:SetWidth(PREVIEW_W)
scaleValue:SetJustifyH("CENTER")

-- Put the numeric value on its own line between the label and the slider so it
-- can never overlap the label text (the overlap shown in your screenshot).
scaleValue:SetPoint("TOP", scaleLabel, "BOTTOM", 0, -6)

-- Now that the value label exists, anchor the slider beneath it.
slider:ClearAllPoints()
slider:SetPoint("TOP", scaleValue, "BOTTOM", 0, -10)

local function UpdateScale(val)
    if not scaleSupported then return end
    if val < COMBAT_TEXT_SCALE_MIN then
        val = COMBAT_TEXT_SCALE_MIN
    elseif val > COMBAT_TEXT_SCALE_MAX then
        val = COMBAT_TEXT_SCALE_MAX
    end
    val = math.floor(val * 10 + 0.5) / 10
    ApplyConsoleSetting(scaleCVar, scaleCVarCommandType, tostring(val))
    scaleValue:SetText(string.format("%.1f", val))
end

local function RefreshScaleControl()
    scaleCVar, scaleCVarCommandType, scaleCVarValue = GetCombatTextScaleCVar()
    scaleSupported = scaleCVar ~= nil

    if scaleSupported then
        slider:SetMinMaxValues(COMBAT_TEXT_SCALE_MIN, COMBAT_TEXT_SCALE_MAX)
        slider:SetValueStep(0.1)
        slider:SetObeyStepOnDrag(true)
        local low = _G[slider:GetName() .. "Low"]
        local high = _G[slider:GetName() .. "High"]
        if low and low.SetText then low:SetText(string.format("%.1f", COMBAT_TEXT_SCALE_MIN)) end
        if high and high.SetText then high:SetText(string.format("%.1f", COMBAT_TEXT_SCALE_MAX)) end
        slider:Enable()
        scaleLabel:SetTextColor(1, 1, 1)
        local currentScale = tonumber(scaleCVarValue)
            or tonumber(GetCVarString(scaleCVar)) or 1.0
        if currentScale < COMBAT_TEXT_SCALE_MIN then
            currentScale = COMBAT_TEXT_SCALE_MIN
        elseif currentScale > COMBAT_TEXT_SCALE_MAX then
            currentScale = COMBAT_TEXT_SCALE_MAX
        end
        slider:SetValue(currentScale)
        scaleValue:SetText(string.format("%.1f", currentScale))
        slider:SetScript("OnValueChanged", function(self, val) UpdateScale(val) end)
        slider:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:AddLine("Drag to adjust combat text size", 1, 1, 1)
            GameTooltip:Show()
        end)
    else
        slider:Disable()
        scaleLabel:SetTextColor(0.5, 0.5, 0.5)
        local low = _G[slider:GetName() .. "Low"]
        local high = _G[slider:GetName() .. "High"]
        if low and low.SetText then low:SetText("") end
        if high and high.SetText then high:SetText("") end
        scaleValue:SetText("|cff888888N/A|r")
        slider:SetScript("OnValueChanged", nil)
        slider:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText("Not available in this version of WoW", nil, nil, nil, nil, true)
            GameTooltip:Show()
        end)
    end
    slider:SetScript("OnLeave", GameTooltip_Hide)
end

RefreshScaleControl()

-- 6) PREVIEW & EDIT ---------------------------------------------------------
local PREVIEW_BOX_H = 84

-- Fixed-size preview "textbox" so the preview area stays consistent between fonts.
local previewBox = CreateFrame("Frame", addonName .. "PreviewBox", frame, backdropTemplate)
previewBox:SetSize(PREVIEW_W, PREVIEW_BOX_H)
previewBox:ClearAllPoints()
previewBox:SetPoint("TOP", slider, "BOTTOM", 0, -30)
previewBox:SetBackdrop({
    bgFile   = "Interface\\ChatFrame\\ChatFrameBackground",
    edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
    tile     = true, tileSize = 16,
    edgeSize = 12,
    insets   = { left = 3, right = 3, top = 3, bottom = 3 },
})
-- Darker preview background improves contrast against bright scenes.
previewBox:SetBackdropColor(0, 0, 0, 0.55)
previewBox:SetBackdropBorderColor(0.35, 0.35, 0.35, 1)

-- Font name label above the preview box (renders using the selected font)
previewHeaderFS = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
previewHeaderFS:SetPoint("BOTTOM", previewBox, "TOP", 0, 6)
previewHeaderFS:SetWidth(PREVIEW_W - 10)
previewHeaderFS:SetJustifyH("CENTER")
previewHeaderFS:SetText("Default")

-- Ensure no preview text bleeds outside the box on any client
if previewBox.SetClipsChildren then pcall(previewBox.SetClipsChildren, previewBox, true) end

preview = previewBox:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
preview:ClearAllPoints()
-- Nudge down slightly: some decorative fonts draw a few pixels above their
-- reported ascent and can otherwise clip at the very top when the box clips.
preview:SetPoint("CENTER", previewBox, "CENTER", 0, -1)
preview:SetWidth(PREVIEW_W - 10)
preview:SetJustifyH("CENTER")
if preview.SetJustifyV then pcall(preview.SetJustifyV, preview, "MIDDLE") end
preview:SetText("12345")
-- Conservative fallback height budget for clients that return 0 sizes while hidden.
preview.__fmMaxH = PREVIEW_BOX_H - 16

-- Apply a default preview font. GameFontNormalLarge should normally exist,
-- but we fall back to the current FontObject if it does not to avoid errors.
SetPreviewFont(GameFontNormalLarge or (preview.GetFontObject and preview:GetFontObject()), DEFAULT_FONT_PATH)
if UpdatePreviewHeaderText then UpdatePreviewHeaderText("Default", DEFAULT_FONT_PATH, true) end

editBox = CreateFrame("EditBox", addonName .. "PreviewEdit", frame, "InputBoxTemplate")
editBox:SetSize(PREVIEW_W, 24)
editBox:ClearAllPoints()
editBox:SetPoint("TOP", previewBox, "BOTTOM", 0, -8)
editBox:SetAutoFocus(false)
editBox:SetText("12345")
editBox:SetScript("OnTextChanged", function(self) preview:SetText(self:GetText()) end)

-- Discard un-applied font previews whenever the window closes.
-- This ensures that reopening the addon always reflects the *currently applied*
-- combat text font (saved in FontMagicDB, or Blizzard default when none is saved),
-- and prevents a rare race where a queued next-frame sizing pass from a preview
-- can overwrite the correct font after reopening.
local function SyncPreviewToAppliedFont()
    if not (preview and editBox) then return end

    local key = FontMagicDB and FontMagicDB.selectedFont
    local path, grp, fname
    local label, isDef = "Default", true

    if type(key) == "string" and key ~= "" then
        path, grp, fname = ResolveFontPathFromKey(key)
        if fname then
            label = DisplayNameForFont(fname)
            isDef = false
        end
    end

    if type(path) ~= "string" or path == "" then
        CaptureBlizzardDefaultFonts()
        path = DEFAULT_DAMAGE_TEXT_FONT or DEFAULT_COMBAT_TEXT_FONT or DEFAULT_FONT_PATH or "Fonts\\FRIZQT__.TTF"
        label, isDef = "Default", true
    end

    if SetPreviewFont then
        SetPreviewFont(GameFontNormalLarge or (preview.GetFontObject and preview:GetFontObject()), path)
    end
    if UpdatePreviewHeaderText then
        UpdatePreviewHeaderText(label, path, isDef)
    end
    if preview.SetText and editBox.GetText then
        preview:SetText(editBox:GetText() or "")
    end
end

frame:SetScript("OnHide", function()
    -- Close any open FontMagic dropdown list immediately when the window hides.
    if type(CloseDropDownMenus) == "function" then
        pcall(CloseDropDownMenus)
    end
    if frame.__fmMenuCtx then
        if type(frame.__fmMenuCtx.HideHoverPreview) == "function" then
            frame.__fmMenuCtx.HideHoverPreview()
        end
        if type(frame.__fmMenuCtx.CleanupTouchedMenuButtons) == "function" then
            frame.__fmMenuCtx.CleanupTouchedMenuButtons()
        end
        if type(frame.__fmMenuCtx.StopIdleWatcher) == "function" then
            frame.__fmMenuCtx.StopIdleWatcher()
        end
    end

    pendingFont = nil
    -- Cancel any queued sizing pass that was scheduled by a previewed font.
    __fmPreviewToken = (__fmPreviewToken or 0) + 1

    if frame.__fmResetDialog and frame.__fmResetDialog.Hide then
        frame.__fmResetDialog:Hide()
    end

    -- Put the preview back to the actually applied font so reopening is consistent.
    pcall(SyncPreviewToAppliedFont)
end)
-- 7) COMBAT TEXT OPTIONS (EXPANDABLE PANEL) --------------------------------

-- Right-side expandable panel
local isExpanded = false
local RIGHT_PANEL_W = PREVIEW_W + 26
local PANEL_GAP = 16
local incomingInit = false

-- Collapsible combat text options button (integrated label + arrow for a cleaner look)
local expandBtn = CreateFrame("Button", addonName .. "ExpandBtn", frame, "UIPanelButtonTemplate")
expandBtn:SetSize(260, 24)
expandBtn:SetPoint("BOTTOMRIGHT", (frame.__fmLeftAnchor or frame), "BOTTOMRIGHT", -16, 44)

local function UpdateExpandToggleText()
    local txt = isExpanded and "Hide Combat Text Options <" or "Show Combat Text Options >"
    if expandBtn and expandBtn.SetText then
        expandBtn:SetText(txt)
    end
    -- If the tooltip is currently shown for this button, update immediately.
    if GameTooltip and GameTooltip.GetOwner and GameTooltip:GetOwner() == expandBtn then
        GameTooltip:SetText(txt, 1, 1, 1, 1, true)
        GameTooltip:Show()
    end
end

UpdateExpandToggleText()

expandBtn:SetFrameStrata("HIGH")
expandBtn:SetFrameLevel((frame:GetFrameLevel() or 0) + 50)
expandBtn:SetScript("OnEnter", function(self)
    if GameTooltip then
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(isExpanded and "Combat Text Options <" or "Combat Text Options >", 1, 1, 1, 1, true)
        GameTooltip:Show()
    end
end)
expandBtn:SetScript("OnLeave", function() if GameTooltip then GameTooltip:Hide() end end)

-- Container + scroll area (hidden when collapsed)
local combatPanel = CreateFrame("Frame", addonName .. "CombatPanel", frame, backdropTemplate)
combatPanel:ClearAllPoints()
combatPanel:SetPoint("TOPLEFT", frame, "TOPRIGHT", PANEL_GAP, -HEADER_H)
combatPanel:SetPoint("BOTTOMLEFT", frame, "BOTTOMRIGHT", PANEL_GAP, 80) -- leave room for the expand toggle above Close
combatPanel:SetWidth(RIGHT_PANEL_W)
combatPanel:SetBackdrop({
    bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
    edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
    tile = true, tileSize = 16, edgeSize = 12,
    insets = { left = 3, right = 3, top = 3, bottom = 3 }
})
-- Darker/less translucent so the list remains readable over bright scenes.
combatPanel:SetBackdropColor(0, 0, 0, 0.92)
combatPanel:SetFrameStrata("HIGH")
combatPanel:SetFrameLevel((frame:GetFrameLevel() or 0) + 40)
combatPanel:Hide()


local combatPopShadow = frame:CreateTexture(nil, "BACKGROUND")
combatPopShadow:SetPoint("TOPLEFT", combatPanel, "TOPLEFT", -PANEL_GAP, 0)
combatPopShadow:SetPoint("BOTTOMLEFT", combatPanel, "BOTTOMLEFT", -PANEL_GAP, 0)
combatPopShadow:SetWidth(PANEL_GAP)
combatPopShadow:SetColorTexture(0, 0, 0, 0.25)
combatPopShadow:Hide()

local combatDivider = combatPanel:CreateTexture(nil, "ARTWORK")
combatDivider:SetPoint("TOPLEFT", combatPanel, "TOPLEFT", 0, 0)
combatDivider:SetPoint("BOTTOMLEFT", combatPanel, "BOTTOMLEFT", 0, 0)
combatDivider:SetWidth(1)
combatDivider:SetColorTexture(1, 1, 1, 0.12)


local combatTitle = combatPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
combatTitle:SetPoint("TOPLEFT", combatPanel, "TOPLEFT", 10, -8)
combatTitle:SetText("Combat Text Options")


local combatScroll = CreateFrame("ScrollFrame", addonName .. "CombatScroll", combatPanel, "UIPanelScrollFrameTemplate")
combatScroll:SetPoint("TOPLEFT", combatPanel, "TOPLEFT", 8, -30)
combatScroll:SetPoint("BOTTOMRIGHT", combatPanel, "BOTTOMRIGHT", -26, 10)

local combatContent = CreateFrame("Frame", nil, combatScroll)
combatContent:SetPoint("TOPLEFT", 0, 0)
combatContent:SetSize(PREVIEW_W, 1)
combatScroll:SetScrollChild(combatContent)

combatScroll:EnableMouseWheel(true)
combatScroll:SetScript("OnMouseWheel", function(self, delta)
    local step = 40
    local newOffset = self:GetVerticalScroll() - delta * step
    if newOffset < 0 then newOffset = 0 end
    local max = self:GetVerticalScrollRange()
    if newOffset > max then newOffset = max end
    self:SetVerticalScroll(newOffset)
end)

-- ---------------------------------------------------------------------------
-- Combat text toggle definitions
-- ---------------------------------------------------------------------------

local function MakeCVarCandidates(primary, ...)
    local t = { primary }
    for i = 1, select("#", ...) do
        local v = select(i, ...)
        if v and v ~= "" then table.insert(t, v) end
    end
    if type(primary) == "string" then
        for v = 9, 2, -1 do
            table.insert(t, primary .. "_v" .. v)
            table.insert(t, primary .. "_V" .. v)
        end
    end
    return t
end

local COMBAT_BOOL_DEFS = {
    -- Core
    { key="enableFloatingCombatText",  candidates={ "enableFloatingCombatText", "enableCombatText" },
      label="Show combat text",
      tip="Turns Blizzard\'s floating combat text on or off (numbers and messages shown in the world).\n\nThis does not affect the combat log." },

    -- Outgoing (numbers shown over enemies / targets)
    { key="combatDamage",             candidates=MakeCVarCandidates("floatingCombatTextCombatDamage"),
      label="Outgoing Damage",
      tip="Shows damage numbers over hostile targets when you deal damage." },

    { key="combatHealing",            candidates=MakeCVarCandidates("floatingCombatTextCombatHealing"),
      label="Outgoing Healing",
      tip="Shows healing numbers over the target when you heal them." },

    { key="combatHealingAbsorbSelf",  candidates=MakeCVarCandidates("floatingCombatTextCombatHealingAbsorbSelf"),
      label="Shields (On You)",
      tip="Shows a message when you gain an absorb shield (e.g., Power Word: Shield)." },

    { key="combatHealingAbsorbTarget", candidates=MakeCVarCandidates("floatingCombatTextCombatHealingAbsorbTarget"),
      label="Shields (On Target)",
      tip="Shows the amount of absorb shield you apply to your target." },

    { key="periodicDamage",           candidates={ "floatingCombatTextCombatLogPeriodicSpells", "floatingCombatTextPeriodicDamage" },
      label="Periodic Damage",
      tip="Shows damage from periodic effects (DoTs) like Rend or Shadow Word: Pain." },

    { key="petDamage",                candidates={ "floatingCombatTextPetMeleeDamage", "floatingCombatTextPetDamage" },
      label="Pet Damage (Melee)",
      tip="Shows damage caused by your pet\'s melee attacks." },

    { key="petSpellDamage",           candidates=MakeCVarCandidates("floatingCombatTextPetSpellDamage"),
      label="Pet Damage (Spells)",
      tip="Shows damage caused by your pet\'s spells and special abilities." },

    -- Defensive / informational
    { key="dodgeParryMiss",           candidates=MakeCVarCandidates("floatingCombatTextDodgeParryMiss"),
      label="Avoidance (Dodge/Parry/Miss)",
      tip="Shows when attacks against you are dodged, parried, or miss." },

    { key="damageReduction",          candidates=MakeCVarCandidates("floatingCombatTextDamageReduction"),
      label="Damage Reduction (Resists)",
      tip="Shows when you resist taking damage from attacks or spells." },

    { key="reactives",                candidates=MakeCVarCandidates("floatingCombatTextReactives"),
      label="Spell Alerts",
      tip="Shows alerts when certain important events occur.\n\n(If you already use a modern proc-alert system, this may be redundant.)" },

    { key="combatState",              candidates=MakeCVarCandidates("floatingCombatTextCombatState"),
      label="Enter/Leave Combat",
      tip="Shows a message when you enter or leave combat." },

    { key="lowManaHealth",            candidates=MakeCVarCandidates("floatingCombatTextLowManaHealth"),
      label="Low Mana & Health",
      tip="Shows a warning when you fall below 20% mana or health." },

    { key="energyGains",              candidates=MakeCVarCandidates("floatingCombatTextEnergyGains"),
      label="Resource Gains",
      tip="Shows instant gains of resources like mana, rage, energy, chi, and similar." },

    { key="periodicEnergyGains",      candidates=MakeCVarCandidates("floatingCombatTextPeriodicEnergyGains"),
      label="Periodic Resource Gains",
      tip="Shows periodic (regen/tick) gains of resources like mana, rage, and energy." },

    { key="honorGains",               candidates=MakeCVarCandidates("floatingCombatTextHonorGains"),
      label="Honor Gains",
      tip="Shows the honor you gain from killing other players." },

    { key="repChanges",               candidates=MakeCVarCandidates("floatingCombatTextRepChanges"),
      label="Reputation Changes",
      tip="Shows a message when you gain or lose reputation with a faction." },

    { key="comboPoints",              candidates=MakeCVarCandidates("floatingCombatTextComboPoints"),
      label="Combo Points",
      tip="Shows your current combo point count each time you gain a combo point." },

    { key="auras",                    candidates=MakeCVarCandidates("floatingCombatTextAuras"),
      label="Auras Gained/Lost",
      tip="Shows a message when you gain or lose a buff or debuff." },

    { key="friendlyHealers",          candidates=MakeCVarCandidates("floatingCombatTextFriendlyHealers"),
      label="Friendly Healer Names",
      tip="Shows the name of a friendly caster when they heal you." },

    { key="spellMechanics",           candidates=MakeCVarCandidates("floatingCombatTextSpellMechanics"),
      label="Effects (On Your Target)",
      tip="Displays effect messages (e.g., Silence and Snare) on your current target." },

    { key="spellMechanicsOther",      candidates=MakeCVarCandidates("floatingCombatTextSpellMechanicsOther"),
      label="Effects (Other Players\' Targets)",
      tip="Displays effect messages (e.g., Silence and Snare) on other players\' targets.\n\nThis can add a lot of extra text in groups/raids." },

    { key="allSpellMechanics",        candidates=MakeCVarCandidates("floatingCombatTextAllSpellMechanics"),
      label="Effects (All)",
      tip="Shows all available effect messages related to spell mechanics.\n\nIf you\'re seeing duplicates, disable this and use the more specific Effects options instead." },
}

local DEF_BY_KEY = {}
for _, d in ipairs(COMBAT_BOOL_DEFS) do DEF_BY_KEY[d.key] = d end

-- extra boolean CVars that match floatingCombatText* but aren't in our curated list
local EXTRA_CVAR_PREFIX = "floatingCombatText"
local EXTRA_BLACKLIST = {
    -- curated / already exposed (avoid duplicates)
    enableFloatingCombatText = true,
    enableCombatText = true,

    floatingCombatTextCombatDamage = true,
    floatingCombatTextCombatHealing = true,
    floatingCombatTextCombatHealingAbsorbSelf = true,
    floatingCombatTextCombatHealingAbsorbTarget = true,

    floatingCombatTextCombatLogPeriodicSpells = true,
    floatingCombatTextPeriodicDamage = true,

    floatingCombatTextPetMeleeDamage = true,
    floatingCombatTextPetDamage = true,
    floatingCombatTextPetSpellDamage = true,

    floatingCombatTextDodgeParryMiss = true,
    floatingCombatTextDamageReduction = true,
    floatingCombatTextReactives = true,
    floatingCombatTextCombatState = true,
    floatingCombatTextLowManaHealth = true,
    floatingCombatTextEnergyGains = true,
    floatingCombatTextPeriodicEnergyGains = true,
    floatingCombatTextHonorGains = true,
    floatingCombatTextRepChanges = true,
    floatingCombatTextComboPoints = true,
    floatingCombatTextAuras = true,
    floatingCombatTextFriendlyHealers = true,
    floatingCombatTextSpellMechanics = true,
    floatingCombatTextSpellMechanicsOther = true,
    floatingCombatTextAllSpellMechanics = true,

    -- known non-useful / off-topic for this add-on
    floatingCombatTextCombatDamageStyle = true, -- no longer used in modern clients
    enablePetBattleCombatText = true,
    enablePetBattleFloatingCombatText = true,
}

-- Forward declaration so earlier functions can call this helper before its definition below.
local CollectExtraBoolCombatTextCVars

local function PrettyCVarLabel(cvar)
    local s = tostring(cvar or "")
    -- Hide internal version suffixes like _v2/_v3 from user-facing labels.
    s = s:gsub("_[vV]%d+$", "")
    s = s:gsub("^floatingCombatText", "")
    s = s:gsub("([A-Z])", " %1")
    s = s:gsub("_", " ")
    s = s:gsub("^%s+", ""):gsub("%s+$", "")
    s = s:gsub("%s+", " ")
    if s == "" then s = tostring(cvar) end
    return s
end

-- Human-readable explanations for extra floatingCombatText* CVars (advanced section).
-- These are not guaranteed to exist on every WoW client; anything unmapped will fall back to a generic explanation.
local EXTRA_CVAR_EXPLANATIONS = {
    floatingCombatTextDodgeParryMiss      = "Shows Dodge / Parry / Miss messages as floating combat text.",
    floatingCombatTextDamageReduction     = "Shows absorbs, blocks, resists, and other damage reduction messages as floating combat text.",
    floatingCombatTextReactives           = "Shows reactive proc messages (for example: 'Overpower!' / 'Revenge!') as floating combat text (when supported).",
    floatingCombatTextCombatState         = "Shows combat state notifications (entering/leaving combat) as floating combat text.",
    floatingCombatTextLowManaHealth       = "Shows low health/mana warnings as floating combat text.",
    floatingCombatTextEnergyGains         = "Shows resource gains (mana/rage/energy/runic power, etc.) as floating combat text.",
    floatingCombatTextPeriodicEnergyGains = "Shows periodic resource gains (from buffs/regen) as floating combat text.",
    floatingCombatTextHonorGains          = "Shows honor gains as floating combat text (when supported).",
    floatingCombatTextRepChanges          = "Shows reputation changes as floating combat text (when supported).",
    floatingCombatTextComboPoints         = "Shows combo point changes as floating combat text (when supported).",
    floatingCombatTextAuras               = "Shows aura notifications (gains/fades) as floating combat text (when supported).",
    floatingCombatTextFriendlyHealers     = "Shows friendly healer messages as floating combat text (when supported).",
    floatingCombatTextSpellMechanics      = "Shows spell mechanic messages (like 'Interrupted', 'Immune', 'Dispelled') as floating combat text (when supported).",
    floatingCombatTextSpellMechanicsOther = "Shows additional spell mechanic messages as floating combat text (when supported).",
    floatingCombatTextAllSpellMechanics   = "Shows a broader set of spell mechanic messages as floating combat text (when supported).",
}

local function GetExtraCombatTextCVarTooltip(cvar)
    local name = tostring(cvar or "")
    local base = name:gsub("_[vV]%d+$", "")
    local expl = EXTRA_CVAR_EXPLANATIONS[name] or EXTRA_CVAR_EXPLANATIONS[base]

    if not expl then
        local pretty = PrettyCVarLabel(base)
        if pretty and pretty ~= "" then
            expl = "Advanced combat text option detected on this client: toggles \"" .. pretty .. "\"."
        else
            expl = "Advanced combat text option detected on this client."
        end
    end

    expl = expl .. "\n\nAvailability and behavior vary by WoW version and UI mods. If you're unsure, leave this at Blizzard's default."
    if name ~= "" then
        expl = expl .. "\n\nTechnical name: " .. name
    end
    return expl
end


local function GetLiveBoolCVar(name)
    local v = GetCVarString(name)
    if v == nil then return nil end
    local n = tonumber(v)
    if n ~= nil then return n == 1 end
    v = tostring(v):lower()
    return (v == "1" or v == "true" or v == "on")
end

local function AttachTooltip(widget, header, body)
    if not GameTooltip then return end
    widget:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(header or "", 1, 1, 1, 1, true)
        if body and body ~= "" then
            GameTooltip:AddLine(body, nil, nil, nil, true)
        end
        GameTooltip:Show()
    end)
    widget:SetScript("OnLeave", function() GameTooltip:Hide() end)
end

-- Incoming damage/healing toggles (implemented via COMBAT_TEXT_TYPE_INFO filtering where available)
local function EnsureIncomingReady()
    if incomingInit then return true end

    -- try to load Blizzard_CombatText so COMBAT_TEXT_TYPE_INFO exists on clients that ship it as a load-on-demand addon
    if type(safeLoadAddOn) == "function" then
        pcall(safeLoadAddOn, "Blizzard_CombatText")
    end

    if type(COMBAT_TEXT_TYPE_INFO) ~= "table" then
        return false
    end

    incomingInit = true

    -- capture Blizzard's baseline tables so we can restore after temporary overrides
    originalInfo = originalInfo or {}
    for k, v in pairs(COMBAT_TEXT_TYPE_INFO) do
        originalInfo[k] = v
    end
    originalInfo.PERIODIC_HEAL      = COMBAT_TEXT_TYPE_INFO.PERIODIC_HEAL
    originalInfo.PERIODIC_HEAL_CRIT = COMBAT_TEXT_TYPE_INFO.PERIODIC_HEAL_CRIT

    return true
end

local function SetIncomingDamageEnabled(enabled)
    if not EnsureIncomingReady() then return end
    if type(COMBAT_TEXT_TYPE_INFO) ~= "table" then return end

    if not enabled then
        COMBAT_TEXT_TYPE_INFO.DAMAGE = nil
        COMBAT_TEXT_TYPE_INFO.DAMAGE_CRIT = nil
        COMBAT_TEXT_TYPE_INFO.SPELL_DAMAGE = nil
        COMBAT_TEXT_TYPE_INFO.SPELL_DAMAGE_CRIT = nil
    else
        COMBAT_TEXT_TYPE_INFO.DAMAGE = originalInfo.DAMAGE
        COMBAT_TEXT_TYPE_INFO.DAMAGE_CRIT = originalInfo.DAMAGE_CRIT
        COMBAT_TEXT_TYPE_INFO.SPELL_DAMAGE = originalInfo.SPELL_DAMAGE
        COMBAT_TEXT_TYPE_INFO.SPELL_DAMAGE_CRIT = originalInfo.SPELL_DAMAGE_CRIT
    end
end

local function SetIncomingHealingEnabled(enabled)
    if not EnsureIncomingReady() then return end
    if type(COMBAT_TEXT_TYPE_INFO) ~= "table" then return end

    if not enabled then
        COMBAT_TEXT_TYPE_INFO.HEAL = nil
        COMBAT_TEXT_TYPE_INFO.HEAL_CRIT = nil
        COMBAT_TEXT_TYPE_INFO.PERIODIC_HEAL = nil
        COMBAT_TEXT_TYPE_INFO.PERIODIC_HEAL_CRIT = nil
    else
        COMBAT_TEXT_TYPE_INFO.HEAL = originalInfo.HEAL
        COMBAT_TEXT_TYPE_INFO.HEAL_CRIT = originalInfo.HEAL_CRIT
        COMBAT_TEXT_TYPE_INFO.PERIODIC_HEAL = originalInfo.PERIODIC_HEAL
        COMBAT_TEXT_TYPE_INFO.PERIODIC_HEAL_CRIT = originalInfo.PERIODIC_HEAL_CRIT
    end
end

local function ApplyIncomingOverrides()
    if type(FontMagicDB) ~= "table" or type(FontMagicDB.incomingOverrides) ~= "table" then return end
    if not EnsureIncomingReady() then return end

    if FontMagicDB.incomingOverrides.incomingDamage ~= nil then
        SetIncomingDamageEnabled(FontMagicDB.incomingOverrides.incomingDamage and true or false)
    end
    if FontMagicDB.incomingOverrides.incomingHealing ~= nil then
        SetIncomingHealingEnabled(FontMagicDB.incomingOverrides.incomingHealing and true or false)
    end
end

local function ApplyCombatOverrides()
    if type(FontMagicDB) == "table" and type(FontMagicDB.combatOverrides) == "table" then
        for key, val in pairs(FontMagicDB.combatOverrides) do
            local def = DEF_BY_KEY[key]
            if def then
                for _, target in ipairs(ResolveConsoleSettingTargets(def.candidates)) do
                    if target.name and target.commandType ~= nil then
                        ApplyConsoleSetting(target.name, target.commandType, val and "1" or "0")
                    end
                end
            end
        end
    end

    if type(FontMagicDB) == "table" and type(FontMagicDB.extraCombatOverrides) == "table" then
        for cvar, val in pairs(FontMagicDB.extraCombatOverrides) do
            for _, target in ipairs(ResolveConsoleSettingTargets(cvar)) do
                if target.name and target.commandType == 0 then
                    ApplyConsoleSetting(target.name, target.commandType, val and "1" or "0")
                end
            end
        end
    end
end



-- Best-effort: raise Blizzard floating combat text above nameplates.
-- This is not a CVar; it adjusts frame strata/level at runtime and restores it when disabled.
local combatTextLayerBackup = {}

local function VisitCombatTextFrames(root, callback)
    if not (root and callback) then return end

    local seen = {}
    local maxDepth = 5

    local function visit(frameObj, depth)
        if not frameObj or depth > maxDepth or seen[frameObj] then return end
        seen[frameObj] = true
        callback(frameObj)

        if type(frameObj.GetChildren) == "function" then
            local okChildren, children = pcall(function() return { frameObj:GetChildren() } end)
            if okChildren and type(children) == "table" then
                for _, child in ipairs(children) do
                    visit(child, depth + 1)
                end
            end
        end
    end

    visit(root, 0)
end

local function ApplyCombatTextAboveNameplatesNow(want)
    local names = { "CombatText", "CombatTextFrame", "FloatingCombatTextFrame" }

    for _, n in ipairs(names) do
        local f = _G and _G[n]
        VisitCombatTextFrames(f, function(frameObj)
            if type(frameObj.SetFrameStrata) ~= "function" then return end

            if want then
                if not combatTextLayerBackup[frameObj] then
                    local okS, s = pcall(frameObj.GetFrameStrata, frameObj)
                    local okL, l = pcall(frameObj.GetFrameLevel, frameObj)
                    combatTextLayerBackup[frameObj] = { strata = okS and s or nil, level = okL and l or nil }
                end

                -- Put combat text above most nameplate add-ons (best-effort) while
                -- preserving relative ordering between nested combat-text frames.
                pcall(frameObj.SetFrameStrata, frameObj, "TOOLTIP")
                if type(frameObj.SetFrameLevel) == "function" and type(frameObj.GetFrameLevel) == "function" then
                    local lvl = frameObj:GetFrameLevel() or 0
                    if lvl < 10000 then
                        pcall(frameObj.SetFrameLevel, frameObj, 10000 + lvl)
                    end
                end
            else
                local b = combatTextLayerBackup[frameObj]
                if not b then return end
                if b.strata then pcall(frameObj.SetFrameStrata, frameObj, b.strata) end
                if b.level ~= nil and type(frameObj.SetFrameLevel) == "function" then
                    pcall(frameObj.SetFrameLevel, frameObj, b.level)
                end
            end
        end)
    end
end

local function ApplyCombatTextAboveNameplates(enabled)
    local want = (enabled and true or false)
    ApplyCombatTextAboveNameplatesNow(want)

    -- Some nameplate add-ons (and Blizzard nameplates) adjust strata after load.
    -- Re-apply shortly after enabling so combat text reliably stays in front.
    if want and type(C_Timer) == "table" and type(C_Timer.After) == "function" then
        C_Timer.After(0.2, function()
            if FontMagicDB and FontMagicDB.combatTextAboveNameplates then
                ApplyCombatTextAboveNameplatesNow(true)
            end
        end)
        C_Timer.After(1.0, function()
            if FontMagicDB and FontMagicDB.combatTextAboveNameplates then
                ApplyCombatTextAboveNameplatesNow(true)
            end
        end)
    end
end

function _G.FontMagicCombatTextFix:ApplyCombatTextAboveNameplatesNow()
    ApplyCombatTextAboveNameplatesNow(true)
end

-- ---------------------------------------------------------------------------
-- Combat text master toggle (Show/Hide combat text without losing per-type settings)
-- ---------------------------------------------------------------------------

local function GetCombatTextMasterSettingList()
    -- Returns a list of settings we can toggle for the "Show combat text" master checkbox.
    -- These are usually CVars (commandType == 0), but on some clients the console index
    -- can classify real CVars as non-CVar console commands. We still include those so the
    -- master toggle stays usable across versions (/console can set them).
    local list, seen = {}, {}

    local function addSetting(name, commandType)
        if not name or commandType == nil then return end
        if seen[name] then return end
        seen[name] = true
        table.insert(list, { name = name, ct = commandType })
    end

    for _, def in ipairs(COMBAT_BOOL_DEFS) do
        for _, target in ipairs(ResolveConsoleSettingTargets(def.candidates)) do
            if target.name and target.commandType ~= nil then
                addSetting(target.name, target.commandType)
            end
        end
    end

    local extras = CollectExtraBoolCombatTextCVars()
    for _, cvar in ipairs(extras) do
        for _, target in ipairs(ResolveConsoleSettingTargets(cvar)) do
            if target.name and target.commandType ~= nil then
                addSetting(target.name, target.commandType)
            end
        end
    end

    return list
end

local function IsCombatTextMasterSupported()
    if EnsureIncomingReady() then
        return true
    end
    local list = GetCombatTextMasterSettingList()
    return (#list > 0)
end

local function IsCombatTextMasterCurrentlyEnabled()
    -- If FontMagic hid combat text via the master toggle, reflect that immediately.
    if FontMagicDB and FontMagicDB.combatMasterOffByFontMagic and type(FontMagicDB.combatMasterSnapshot) == "table" then
        return false
    end

    local gateIsTrue = false
    local gateIsFalse = false
    local sawAny = false

    -- Some clients use enableFloatingCombatText while others use enableCombatText.
    -- Treat the gate as a strong signal, but not a hard requirement (some builds can still
    -- show certain combat text types even when the gate is off).
    do
        local gateDef = DEF_BY_KEY and DEF_BY_KEY["enableFloatingCombatText"]
        local candidates = (gateDef and gateDef.candidates) or MakeCVarCandidates("enableFloatingCombatText", "enableCombatText")
        local n, ct = ResolveConsoleSetting(candidates)
        if n and ct == 0 then
            local v = GetLiveBoolCVar(n)
            if v ~= nil then
                sawAny = true
                if v == true then gateIsTrue = true end
                if v == false then gateIsFalse = true end
            end
        end
    end

    -- If any readable combat text toggle is on, we consider combat text enabled.
    for _, entry in ipairs(GetCombatTextMasterSettingList()) do
        if entry and entry.ct == 0 and entry.name then
            local v = GetLiveBoolCVar(entry.name)
            if v ~= nil then
                sawAny = true
            end
            if v == true then
                return true
            end
        end
    end

    -- Incoming combat text can be implemented via COMBAT_TEXT_TYPE_INFO on some clients.
    if EnsureIncomingReady() and type(COMBAT_TEXT_TYPE_INFO) == "table" then
        local dmgEnabled = (COMBAT_TEXT_TYPE_INFO.DAMAGE ~= nil or COMBAT_TEXT_TYPE_INFO.SPELL_DAMAGE ~= nil)
        local healEnabled = (COMBAT_TEXT_TYPE_INFO.HEAL ~= nil or COMBAT_TEXT_TYPE_INFO.PERIODIC_HEAL ~= nil)
        if dmgEnabled or healEnabled then
            return true
        end
    end

    if gateIsTrue then
        return true
    end

    -- If we observed at least one relevant value and none were on, treat as disabled.
    if gateIsFalse and sawAny then
        return false
    end

    -- If no values are reportable yet (or we only have non-readable console commands),
    -- default to enabled so the UI doesn't show an unchecked box while combat text is visible.
    if not sawAny then
        return true
    end

    return false
end

local function CaptureCombatTextMasterSnapshot()
    FontMagicDB = FontMagicDB or {}
    local snap = { cvars = {}, commandTypes = {}, incoming = {} }

    -- Best-effort: if we can't read a setting (console-command style), store a value that
    -- will turn it back on when the user re-enables combat text.
    local wasEnabled = IsCombatTextMasterCurrentlyEnabled() and true or false

    for _, entry in ipairs(GetCombatTextMasterSettingList()) do
        if entry and entry.name then
            local val = GetCVarString(entry.name)

            -- If it isn't readable but it's toggle-able via /console, preserve "on" vs "off"
            -- at the coarse level so we can restore something sensible.
            if val == nil and entry.ct ~= 0 then
                val = wasEnabled and "1" or "0"
            end

            snap.cvars[entry.name] = val
            snap.commandTypes[entry.name] = entry.ct
        end
    end

    if EnsureIncomingReady() and type(COMBAT_TEXT_TYPE_INFO) == "table" then
        snap.incoming.damage = (COMBAT_TEXT_TYPE_INFO.DAMAGE ~= nil or COMBAT_TEXT_TYPE_INFO.SPELL_DAMAGE ~= nil) and true or false
        snap.incoming.heal   = (COMBAT_TEXT_TYPE_INFO.HEAL ~= nil or COMBAT_TEXT_TYPE_INFO.PERIODIC_HEAL ~= nil) and true or false
    end

    FontMagicDB.combatMasterSnapshot = snap
    FontMagicDB.combatMasterOffByFontMagic = true
end

local function ApplyCombatTextMasterDisabled()
    for _, entry in ipairs(GetCombatTextMasterSettingList()) do
        if entry and entry.name then
            if entry.ct ~= nil then
                ApplyConsoleSetting(entry.name, entry.ct, "0")
            end
        end
    end

    if EnsureIncomingReady() then
        SetIncomingDamageEnabled(false)
        SetIncomingHealingEnabled(false)
    end
end

local function ApplyCombatTextMasterEnabledFromSnapshot(snap)
    if type(snap) ~= "table" or type(snap.cvars) ~= "table" then
        return false
    end

    local appliedAny = false

    for name, val in pairs(snap.cvars) do
        if val ~= nil then
            local n, ct = name, nil
            if type(snap.commandTypes) == "table" then
                ct = snap.commandTypes[name]
                if ct ~= nil then
                    local exactName, exactCt = _G.FontMagicConsoleResolver:TryName(name)
                    if exactName then
                        n, ct = exactName, exactCt
                    else
                        n, ct = nil, nil
                    end
                end
            end
            if ct == nil then
                n, ct = _G.FontMagicConsoleResolver:TryName(name)
            end
            if not n or ct == nil then
                n, ct = ResolveConsoleSetting(name)
            end
            if n and ct ~= nil then
                ApplyConsoleSetting(n, ct, tostring(val))
                appliedAny = true
            end
        end
    end

    if EnsureIncomingReady() and type(snap.incoming) == "table" then
        local did = false
        if snap.incoming.damage ~= nil then
            SetIncomingDamageEnabled(snap.incoming.damage and true or false)
            did = true
        end
        if snap.incoming.heal ~= nil then
            SetIncomingHealingEnabled(snap.incoming.heal and true or false)
            did = true
        end
        if did then
            appliedAny = true
        end
    end

    return appliedAny
end

local function ApplyCombatTextMasterEnabledBaseline()
    -- Best-effort baseline so "Show combat text" actually shows something on most clients.
    do
        local gateDef = DEF_BY_KEY and DEF_BY_KEY["enableFloatingCombatText"]
        local candidates = (gateDef and gateDef.candidates) or MakeCVarCandidates("enableFloatingCombatText", "enableCombatText")
        for _, target in ipairs(ResolveConsoleSettingTargets(candidates)) do
            if target.name and target.commandType ~= nil then
                ApplyConsoleSetting(target.name, target.commandType, "1")
            end
        end
    end

    local dmgDef = DEF_BY_KEY and DEF_BY_KEY["combatDamage"]
    if dmgDef then
        for _, target in ipairs(ResolveConsoleSettingTargets(dmgDef.candidates)) do
            if target.name and target.commandType ~= nil then
                ApplyConsoleSetting(target.name, target.commandType, "1")
            end
        end
    end

    local healDef = DEF_BY_KEY and DEF_BY_KEY["combatHealing"]
    if healDef then
        for _, target in ipairs(ResolveConsoleSettingTargets(healDef.candidates)) do
            if target.name and target.commandType ~= nil then
                ApplyConsoleSetting(target.name, target.commandType, "1")
            end
        end
    end
end

local function SetCombatTextMasterEnabled(wantEnabled)
    if not IsCombatTextMasterSupported() then
        return
    end

    FontMagicDB = FontMagicDB or {}

    if wantEnabled then
        local snap = FontMagicDB.combatMasterSnapshot
        local restored = ApplyCombatTextMasterEnabledFromSnapshot(snap)
        if not restored then
            ApplyCombatTextMasterEnabledBaseline()
        end
        FontMagicDB.combatMasterSnapshot = nil
        FontMagicDB.combatMasterOffByFontMagic = nil
    else
        if not (FontMagicDB.combatMasterOffByFontMagic and type(FontMagicDB.combatMasterSnapshot) == "table") then
            CaptureCombatTextMasterSnapshot()
        end
        ApplyCombatTextMasterDisabled()
    end
end

local function UpdateMainCombatCheckboxes()
    if mainShowCombatTextCB and mainShowCombatTextCB.SetChecked then
        if not IsCombatTextMasterSupported() then
            if mainShowCombatTextCB.Disable then pcall(mainShowCombatTextCB.Disable, mainShowCombatTextCB) end
            -- If we can't control it on this client, default to showing it as enabled so the
            -- checkbox doesn't look "wrong" on first load.
            mainShowCombatTextCB:SetChecked(IsCombatTextMasterCurrentlyEnabled() and true or false)
        else
            if mainShowCombatTextCB.Enable then pcall(mainShowCombatTextCB.Enable, mainShowCombatTextCB) end
            mainShowCombatTextCB:SetChecked(IsCombatTextMasterCurrentlyEnabled() and true or false)
        end
    end
end

-- ---------------------------------------------------------------------------
-- UI builder
-- ---------------------------------------------------------------------------

local combatWidgets = {}
local combatSliderPool = {}

local function ClearCombatWidgets()
    for _, w in ipairs(combatWidgets) do
        if w and w.Hide then w:Hide() end
        if w and w.SetParent then w:SetParent(nil) end
    end
    combatWidgets = {}
    if combatContent then
        combatContent.__fmCheckboxPoolCursor = 0
    end
end

local function AddHeader(textLine, y)
    local fs = combatContent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    fs:SetPoint("TOPLEFT", combatContent, "TOPLEFT", 0, y)
    fs:SetPoint("TOPRIGHT", combatContent, "TOPRIGHT", -10, 0)
    fs:SetJustifyH("LEFT")
    fs:SetText(textLine)
    table.insert(combatWidgets, fs)
    return y - 18
end


local function AddSectionNote(y, textLine)
    local fs = combatContent:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    fs:SetPoint("TOPLEFT", combatContent, "TOPLEFT", 0, y)
    fs:SetPoint("TOPRIGHT", combatContent, "TOPRIGHT", -10, 0)
    fs:SetJustifyH("LEFT")
    fs:SetJustifyV("TOP")
    fs:SetTextColor(0.72, 0.72, 0.72)
    fs:SetText(textLine)
    table.insert(combatWidgets, fs)
    local h = 0
    if fs.GetStringHeight then
        h = tonumber(fs:GetStringHeight()) or 0
    end
    if h < 18 then h = 18 end
    return y - h - 6
end

local function CreateOptionSlider(y, key, label, minVal, maxVal, step, value, onChange, tip, enabled, disabledHint, liveApply)
    if enabled == nil then enabled = true end
    if liveApply == nil then liveApply = true end

    local fs = combatContent:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    fs:SetPoint("TOPLEFT", combatContent, "TOPLEFT", 0, y)
    fs:SetText(label)
    if not enabled then
        fs:SetTextColor(0.5, 0.5, 0.5)
    end
    table.insert(combatWidgets, fs)

    local valText = combatContent:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    valText:SetPoint("TOPRIGHT", combatContent, "TOPRIGHT", -10, y)
    table.insert(combatWidgets, valText)

    -- Reuse slider frames by key so UI rebuilds never attempt to recreate the same
    -- globally named frame (which would error once the name is already taken).
    local s = combatSliderPool[key]
    if not s then
        s = CreateFrame("Slider", addonName .. "Combat" .. key .. "Slider", combatContent, "OptionsSliderTemplate")
        combatSliderPool[key] = s
    else
        s:SetParent(combatContent)
        s:Show()
    end
    s:ClearAllPoints()
    s:SetPoint("TOPLEFT", fs, "BOTTOMLEFT", 8, -6)
    s:SetWidth(CB_COL_W * 2 + CHECK_COL_GAP - 30)
    s:SetMinMaxValues(minVal, maxVal)
    s:SetValueStep(step)
    s:SetObeyStepOnDrag(false)
    s:SetScript("OnValueChanged", nil)
    s:SetScript("OnMouseUp", nil)
    s:SetScript("OnEnter", nil)
    s:SetScript("OnLeave", nil)

    local low = _G[s:GetName() .. "Low"]
    local high = _G[s:GetName() .. "High"]
    if low then
        low:SetText(string.format("%.2f", minVal))
        low:ClearAllPoints()
        low:SetPoint("TOPLEFT", s, "BOTTOMLEFT", 0, -2)
    end
    if high then
        high:SetText(string.format("%.2f", maxVal))
        high:ClearAllPoints()
        high:SetPoint("TOPRIGHT", s, "BOTTOMRIGHT", -8, -2)
    end
    local t = s.Text or _G[s:GetName() .. "Text"]
    if t and t.Hide then t:Hide() end

    local function setVal(v)
        local rounded = math.floor((v / step) + 0.5) * step
        rounded = math.max(minVal, math.min(maxVal, rounded))
        valText:SetText(string.format("%.2f", rounded))
        return rounded
    end

    s:SetScript("OnValueChanged", function(_, v)
        if not enabled then return end
        local rounded = setVal(v)
        if liveApply then
            onChange(rounded)
        end
    end)

    s:EnableMouse(true)
    s:SetScript("OnMouseUp", function(self)
        if not enabled then return end
        local rounded = setVal(self:GetValue())
        self:SetValue(rounded)
        onChange(rounded)
    end)

    s:SetValue(value)
    setVal(value)

    if enabled then
        s:Enable()
        if tip then AttachTooltip(s, label, tip) end
    else
        s:Disable()
        if tip then AttachTooltip(s, label, tip .. "\n\n" .. (disabledHint or "Not available on this client.")) end
    end

    table.insert(combatWidgets, s)
    return s
end

local function CreateOptionCheckbox(col, y, label, checked, onClick, tip)
    local x = (col == 0) and 0 or (CB_COL_W + CHECK_COL_GAP)
    combatContent.__fmCheckboxPool = combatContent.__fmCheckboxPool or {}
    combatContent.__fmCheckboxPoolCursor = (combatContent.__fmCheckboxPoolCursor or 0) + 1

    local poolIndex = combatContent.__fmCheckboxPoolCursor
    local cb = combatContent.__fmCheckboxPool[poolIndex]
    if not cb then
        cb = CreateCheckbox(combatContent, label, 0, 0, checked, onClick, true)
        combatContent.__fmCheckboxPool[poolIndex] = cb
    else
        cb:SetParent(combatContent)
        cb:Show()
        if cb.Enable then pcall(cb.Enable, cb) end
        if cb.EnableMouse then cb:EnableMouse(true) end
        SetCheckButtonLabel(cb, label)
        cb:SetChecked(checked and true or false)
        cb:SetScript("OnClick", onClick)
    end

    cb:ClearAllPoints()
    cb:SetPoint("TOPLEFT", combatContent, "TOPLEFT", x, y)
    cb:SetScript("OnEnter", nil)
    cb:SetScript("OnLeave", nil)

    -- Constrain the label so the two checkbox columns never overlap.
    local fs = cb and (cb.__fmLabelFS or GetCheckButtonLabelFS(cb))
    if fs then
        -- Use a slightly smaller font so longer labels still fit nicely.
        if fs.SetFontObject and type(GameFontHighlightSmall) ~= "nil" then
            pcall(fs.SetFontObject, fs, GameFontHighlightSmall)
        end
        if fs.SetWidth then fs:SetWidth(CB_COL_W - 26) end
        if fs.SetJustifyH then fs:SetJustifyH("LEFT") end
        if fs.SetWordWrap then fs:SetWordWrap(true) end
    end

    if tip then AttachTooltip(cb, label, tip) end
    table.insert(combatWidgets, cb)
    return cb
end

CollectExtraBoolCombatTextCVars = function()
    -- Collect additional floatingCombatText* boolean CVars that exist on this client
    -- but are not already represented by our curated definitions. We only include
    -- CVars that are real (commandType == 0) and whose current value is clearly
    -- boolean ("0" or "1") so we don't surface unrelated numeric settings.
    --
    -- Some clients expose both a base CVar and a version-suffixed variant (e.g. _v2).
    -- When multiple variants exist for the same base, prefer the highest version.
    local idx = BuildConsoleCommandIndex()
    local chosen = {} -- [baseName] = bestVariantName

    local function baseName(name)
        return (tostring(name or ""):gsub("_[vV]%d+$", ""))
    end

    local function versionNum(name)
        local v = tostring(name or ""):match("_[vV](%d+)$")
        return tonumber(v) or 0
    end

    if type(idx) == "table" then
        for name, ct in pairs(idx) do
            if type(name) == "string"
                and ct == 0
                and name:sub(1, #EXTRA_CVAR_PREFIX) == EXTRA_CVAR_PREFIX
            then
                local base = baseName(name)

                -- If the base CVar is already covered by our curated list, don't surface
                -- any of its variants here either.
                if not EXTRA_BLACKLIST[name] and not EXTRA_BLACKLIST[base] then
                    local v = GetCVarString(name)
                    if v == "0" or v == "1" then
                        local prev = chosen[base]
                        if not prev then
                            chosen[base] = name
                        else
                            if versionNum(name) > versionNum(prev) then
                                chosen[base] = name
                            end
                        end
                    end
                end
            end
        end
    end

    local extras = {}
    for _, name in pairs(chosen) do
        table.insert(extras, name)
    end
    table.sort(extras)
    return extras
end

local function BuildCombatOptionsUI()
    ClearCombatWidgets()

    local y = 0
    local row = 0

    local function place()
        local col = (row % 2 == 0) and 0 or 1
        local yy = y - math.floor(row / 2) * CHECK_ROW_H
        row = row + 1
        return col, yy
    end

    local masterOff = (FontMagicDB and FontMagicDB.combatMasterOffByFontMagic and type(FontMagicDB.combatMasterSnapshot) == "table") and true or false
    local snap = masterOff and FontMagicDB and FontMagicDB.combatMasterSnapshot or nil

    if masterOff then
        y = AddHeader("Note", y - 2)
        local note = combatContent:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        note:SetPoint("TOPLEFT", combatContent, "TOPLEFT", 0, y)
        note:SetWidth(CB_COL_W * 2 + CHECK_COL_GAP - 10)
        note:SetJustifyH("LEFT")
        note:SetJustifyV("TOP")
        note:SetText("Combat text is currently hidden by the main \"Show combat text\" toggle. Enable it in the main panel to edit these options.")
        table.insert(combatWidgets, note)
        y = y - 26
    end

    y = AddHeader("Combat text visibility", y - 2)
    row = 0

    for _, def in ipairs(COMBAT_BOOL_DEFS) do
        if def.key ~= "enableFloatingCombatText" then
            local n, ct = ResolveConsoleSetting(def.candidates)
            if n and ct ~= nil then
                local override = FontMagicDB.combatOverrides and FontMagicDB.combatOverrides[def.key]
                local live = (ct == 0) and GetLiveBoolCVar(n) or nil
                local checked
                if override ~= nil then
                    checked = override and true or false
                elseif live ~= nil then
                    checked = live and true or false
                else
                    checked = true
                end

                if masterOff and type(snap) == "table" and type(snap.cvars) == "table" and snap.cvars[n] ~= nil then
                    checked = (tonumber(snap.cvars[n]) == 1) and true or false
                end

                local c, yy = place()
                local cb = CreateOptionCheckbox(c, yy, def.label, checked, function(self)
                    local newVal = self:GetChecked() and true or false
                    if type(FontMagicDB.combatOverrides) ~= "table" then FontMagicDB.combatOverrides = {} end
                    FontMagicDB.combatOverrides[def.key] = newVal
                    for _, target in ipairs(ResolveConsoleSettingTargets(def.candidates)) do
                        if target.name and target.commandType ~= nil then
                            ApplyConsoleSetting(target.name, target.commandType, newVal and "1" or "0")
                        end
                    end
                end, def.tip)

                if masterOff and cb and cb.Disable then pcall(cb.Disable, cb) end
            end
        end
    end

    y = y - (math.ceil(row / 2) * CHECK_ROW_H) - 10

    if EnsureIncomingReady() then
        y = AddHeader("Incoming message filters", y)

        local dmgEnabled = (type(COMBAT_TEXT_TYPE_INFO) == "table") and
            (COMBAT_TEXT_TYPE_INFO.DAMAGE ~= nil or COMBAT_TEXT_TYPE_INFO.SPELL_DAMAGE ~= nil)
        local healEnabled = (type(COMBAT_TEXT_TYPE_INFO) == "table") and
            (COMBAT_TEXT_TYPE_INFO.HEAL ~= nil or COMBAT_TEXT_TYPE_INFO.PERIODIC_HEAL ~= nil)

        local oDmg = FontMagicDB.incomingOverrides and FontMagicDB.incomingOverrides.incomingDamage
        local oHeal = FontMagicDB.incomingOverrides and FontMagicDB.incomingOverrides.incomingHealing

        local showDmg = (oDmg ~= nil) and oDmg or dmgEnabled
        local showHeal = (oHeal ~= nil) and oHeal or healEnabled

        if masterOff and type(snap) == "table" and type(snap.incoming) == "table" then
            if snap.incoming.damage ~= nil then showDmg = snap.incoming.damage and true or false end
            if snap.incoming.heal ~= nil then showHeal = snap.incoming.heal and true or false end
        end

        local cb1 = CreateOptionCheckbox(0, y, "Show Incoming Damage", showDmg, function(self)
            local newVal = self:GetChecked() and true or false
            if type(FontMagicDB.incomingOverrides) ~= "table" then FontMagicDB.incomingOverrides = {} end
            FontMagicDB.incomingOverrides.incomingDamage = newVal
            SetIncomingDamageEnabled(newVal)
        end, "Shows or hides scrolling combat text for damage you take.\n\n(Only available on clients that load Blizzard_CombatText.)")

        local cb2 = CreateOptionCheckbox(1, y, "Show Incoming Healing", showHeal, function(self)
            local newVal = self:GetChecked() and true or false
            if type(FontMagicDB.incomingOverrides) ~= "table" then FontMagicDB.incomingOverrides = {} end
            FontMagicDB.incomingOverrides.incomingHealing = newVal
            SetIncomingHealingEnabled(newVal)
        end, "Shows or hides scrolling combat text for healing you receive.\n\n(Only available on clients that load Blizzard_CombatText.)")

        if masterOff then
            if cb1 and cb1.Disable then pcall(cb1.Disable, cb1) end
            if cb2 and cb2.Disable then pcall(cb2.Disable, cb2) end
        end

        y = y - CHECK_ROW_H - 10
    end

    y = AddHeader("Scrolling text style", y)
    y = AddSectionNote(y, "Affects self/player scrolling combat text. Outgoing world-space numbers above targets may ignore outline changes depending on the WoW client because Blizzard renders them separately.")
    do
        local function outlineLabel(mode)
            local m = tostring(mode or "NONE"):upper()
            if m == "NONE" or m == "NO" or m == "0" then return "No Outline" end
            if m == "THICK" or m == "THICKOUTLINE" or m == "2" then return "Thick Outline" end
            return "Thin Outline"
        end

        local btn = CreateFrame("Button", nil, combatContent, "UIPanelButtonTemplate")
        btn:SetSize(220, 22)
        btn:SetPoint("TOPLEFT", combatContent, "TOPLEFT", 0, y)
        btn:SetText("Self Text Outline: " .. outlineLabel(FontMagicDB and FontMagicDB.combatTextOutlineMode))
        btn:SetScript("OnClick", function(self)
            FontMagicDB = FontMagicDB or {}
            local current = tostring(FontMagicDB.combatTextOutlineMode or "NONE"):upper()
            local nextMode
            if current == "NONE" or current == "NO" or current == "0" then
                nextMode = "OUTLINE"
            elseif current == "OUTLINE" or current == "1" then
                nextMode = "THICKOUTLINE"
            else
                nextMode = "NONE"
            end
            FontMagicDB.combatTextOutlineMode = nextMode
            FontMagicDB.combatTextOutlineTouched = true
            self:SetText("Self Text Outline: " .. outlineLabel(nextMode))

            local path = nil
            local key = FontMagicDB.selectedFont
            if type(key) == "string" and key ~= "" then
                path = ResolveFontPathFromKey(key)
            end
            if not path then
                CaptureBlizzardDefaultFonts()
                path = DEFAULT_DAMAGE_TEXT_FONT or DEFAULT_COMBAT_TEXT_FONT or DEFAULT_FONT_PATH or "Fonts\\FRIZQT__.TTF"
            end
            ApplyCombatTextFontPath(path)
        end)
        AttachTooltip(btn, "Self combat text outline style", "Cycles self/player scrolling combat text between No Outline, Thin Outline, and Thick Outline.\n\nThis does not reliably control outgoing world-space damage/healing numbers above targets; many WoW clients render those separately and ignore outline changes from addons.\n\nDoes not touch global number-format CVars.")
        table.insert(combatWidgets, btn)
        y = y - 30
    end

    y = AddHeader("Floating text motion", y)
    y = AddSectionNote(y, "Tune movement behavior for world-space floating numbers. Changes apply immediately when supported.")
    local gravityName = ResolveCVarName({ "WorldTextGravity_v2", "WorldTextGravity_V2", "WorldTextGravity", "floatingCombatTextGravity_v2", "floatingCombatTextGravity_V2", "floatingCombatTextGravity" })
    local fadeName = ResolveCVarName({ "WorldTextRampDuration_v2", "WorldTextRampDuration_V2", "WorldTextRampDuration", "floatingCombatTextRampDuration_v2", "floatingCombatTextRampDuration_V2", "floatingCombatTextRampDuration" })
    local gravitySupported = (gravityName ~= nil)
    local fadeSupported = (fadeName ~= nil)

    if not gravitySupported or not fadeSupported then
        local unavailable = combatContent:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        unavailable:SetPoint("TOPLEFT", combatContent, "TOPLEFT", 0, y)
        if not gravitySupported and not fadeSupported then
            unavailable:SetText("These motion controls are unavailable in this game client.")
        elseif not gravitySupported then
            unavailable:SetText("Gravity is unavailable in this game client.")
        else
            unavailable:SetText("Fade timing is unavailable in this game client.")
        end
        unavailable:SetTextColor(0.7, 0.7, 0.7)
        table.insert(combatWidgets, unavailable)
        y = y - 20
    end

    CreateOptionSlider(y, "Gravity", "Motion gravity", 0.00, 2.00, 0.05,
        tonumber(FontMagicDB and FontMagicDB.floatingTextGravity) or 1.0,
        function(v)
            FontMagicDB = FontMagicDB or {}
            FontMagicDB.floatingTextGravity = v
            ApplyFloatingTextMotionSettings()
        end,
        "Controls arc intensity while world-space numbers move upward and settle.",
        gravitySupported,
        nil,
        false
    )

    CreateOptionSlider(y - 54, "Fade", "Fade duration", 0.10, 3.00, 0.05,
        tonumber(FontMagicDB and FontMagicDB.floatingTextFadeDuration) or 1.0,
        function(v)
            FontMagicDB = FontMagicDB or {}
            FontMagicDB.floatingTextFadeDuration = v
            ApplyFloatingTextMotionSettings()
        end,
        "Controls how long world-space numbers remain visible before fading.",
        fadeSupported,
        nil,
        false
    )

    y = y - 120

    local extras = CollectExtraBoolCombatTextCVars()
    if #extras > 0 then
        local showExtras = (FontMagicDB and FontMagicDB.showExtraCombatToggles) and true or false

        -- Keep the panel tidy by default. Users can opt in to showing extra/experimental toggles.
        local toggle = CreateOptionCheckbox(0, y, "Show extra combat text toggles", showExtras, function(self)
            FontMagicDB = FontMagicDB or {}
            FontMagicDB.showExtraCombatToggles = self:GetChecked() and true or false
            BuildCombatOptionsUI()
        end, "Reveals additional, less common combat text options detected on this client.\n\nThese vary by WoW version and may do nothing in some clients. If you\'re unsure, leave this off.")

        if masterOff and toggle and toggle.Disable then pcall(toggle.Disable, toggle) end

        y = y - CHECK_ROW_H - 6

        if showExtras then
            y = AddHeader("Other (Advanced)", y)
            row = 0
            for _, cvar in ipairs(extras) do
                local n, ct = ResolveConsoleSetting(cvar)
                if n and ct == 0 then
                    local override = FontMagicDB.extraCombatOverrides and FontMagicDB.extraCombatOverrides[cvar]
                    local live = GetLiveBoolCVar(n)
                    local checked = (override ~= nil) and (override and true or false) or (live and true or false)

                    if masterOff and type(snap) == "table" and type(snap.cvars) == "table" and snap.cvars[n] ~= nil then
                        checked = (tonumber(snap.cvars[n]) == 1) and true or false
                    end

                    local c, yy = place()
                    local tip = GetExtraCombatTextCVarTooltip(cvar)
                    local cb = CreateOptionCheckbox(c, yy, PrettyCVarLabel(cvar), checked, function(self)
                        local newVal = self:GetChecked() and true or false
                        if type(FontMagicDB.extraCombatOverrides) ~= "table" then FontMagicDB.extraCombatOverrides = {} end
                        FontMagicDB.extraCombatOverrides[cvar] = newVal
                        for _, target in ipairs(ResolveConsoleSettingTargets(cvar)) do
                            if target.name and target.commandType == 0 then
                                ApplyConsoleSetting(target.name, target.commandType, newVal and "1" or "0")
                            end
                        end
                    end, tip)

                    if masterOff and cb and cb.Disable then pcall(cb.Disable, cb) end
                end
            end

            y = y - (math.ceil(row / 2) * CHECK_ROW_H) - 10
        end
    end

-- UI / layering helpers
    y = AddHeader("UI (Advanced)", y)
    local above = (FontMagicDB and FontMagicDB.combatTextAboveNameplates) and true or false
    CreateOptionCheckbox(0, y, "Combat text in front of nameplates", above, function(self)
        FontMagicDB = FontMagicDB or {}
        local v = self:GetChecked() and true or false
        FontMagicDB.combatTextAboveNameplates = v
        ApplyCombatTextAboveNameplates(v)
    end, "Makes Blizzard floating combat text draw in front of nameplates by raising its frame strata/level.\n\nBest-effort: works best with third-party nameplate add-ons. This only changes draw order (layering), not where the text appears. On many clients it also affects Blizzard nameplates, but some versions/UI setups may show little or no change. Some UI/add-ons may still cover it.")

    y = y - CHECK_ROW_H - 6

    local needed = -y + 20
    combatContent:SetHeight(math.max(needed, combatScroll:GetHeight()))
end

local function RefreshCombatTextCVars()
    if combatPanel and combatPanel:IsShown() then
        BuildCombatOptionsUI()
    end
end

local function SetExpanded(expand)
    local want = (expand and true or false)
    if want == isExpanded then return end

    isExpanded = want
    if UpdateExpandToggleText then UpdateExpandToggleText() end

    if isExpanded then
        if combatPanel and combatPanel.Show then combatPanel:Show() end
        BuildCombatOptionsUI()
    else
        if combatPanel and combatPanel.Hide then combatPanel:Hide() end
    end
end

expandBtn:SetScript("OnClick", function()
    SetExpanded(not isExpanded)
end)

-- 8) APPLY & DEFAULT BUTTONS ----------------------------------------------- -----------------------------------------------
local applyBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
-- Match the width of the Default button for consistency
applyBtn:SetSize(100, 22)
-- Position slightly closer to the bottom border now that the
-- overall frame height has been reduced
-- keep the action buttons in place or slightly lower so they no longer
-- overlap the lowest checkboxes
applyBtn:SetPoint("BOTTOMLEFT", 16, 12)
applyBtn:SetText("Apply")


-- Main quick toggles (kept out of the expanded panel for clarity)
mainShowCombatTextCB = CreateCheckbox(frame, "Show Combat Text", 0, 0, true,
    function(self)
        if not IsCombatTextMasterSupported() then
            UpdateMainCombatCheckboxes()
            return
        end
        local want = self:GetChecked() and true or false
        SetCombatTextMasterEnabled(want)
        RefreshCombatTextCVars()
    end
)
mainShowCombatTextCB:ClearAllPoints()
-- Keep this quick toggle above the action buttons and away from the expandable panel toggle.
mainShowCombatTextCB:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 16, 44)
do
    local fs = mainShowCombatTextCB and (mainShowCombatTextCB.__fmLabelFS or GetCheckButtonLabelFS(mainShowCombatTextCB))
    if fs and fs.SetWidth then fs:SetWidth(240) end
    if fs and fs.SetJustifyH then fs:SetJustifyH("LEFT") end
    if fs and fs.SetWordWrap then fs:SetWordWrap(false) end
end
AttachTooltip(mainShowCombatTextCB, "Show Combat Text",
    "Shows or hides Blizzard floating combat text without losing your per-type settings.\n\n" ..
    "When you hide it, FontMagic stores your current combat text settings and restores them when you enable it again.")

applyBtn:SetScript("OnClick", function()
    -- use the pending selection if the user picked a font but hasn't applied yet
    local selection = pendingFont or (FontMagicDB and FontMagicDB.selectedFont)
    if selection and type(selection) == "string" and selection ~= "" then
        FontMagicDB.selectedFont = selection
        pendingFont = nil

        local path = ResolveFontPathFromKey(selection)
        if path then
            -- Best-effort immediate apply (helps on /reload). A relog is still the
            -- most reliable way to ensure combat text picks it up across clients.
            ApplyCombatTextFontPath(path)
            if _G and _G.FontMagicCombatTextFix and type(_G.FontMagicCombatTextFix.StartCombatTextOwnershipMonitor) == "function" then
                pcall(_G.FontMagicCombatTextFix.StartCombatTextOwnershipMonitor, _G.FontMagicCombatTextFix)
            end
            print("|cFF00FF00[FontMagic]|r Combat font saved. Log out to character select and back in to apply.")
            if UIErrorsFrame and UIErrorsFrame.AddMessage then
                UIErrorsFrame:AddMessage("FontMagic: relog to apply the new combat font.", 1, 1, 0)
            end
        else
            print("|cFFFF3333[FontMagic]|r Could not apply that font (file not found).")
            if UIErrorsFrame and UIErrorsFrame.AddMessage then
                UIErrorsFrame:AddMessage("FontMagic: that font could not be loaded.", 1, 0.2, 0.2)
            end
        end
        return
    end

    -- No selection: clear and restore defaults
    if FontMagicDB then FontMagicDB.selectedFont = nil end
    CaptureBlizzardDefaultFonts()
    local defaultPath = DEFAULT_DAMAGE_TEXT_FONT or DEFAULT_COMBAT_TEXT_FONT or DEFAULT_FONT_PATH or "Fonts\\FRIZQT__.TTF"
    ApplyCombatTextFontPath(defaultPath)
    -- Also update the preview + header so the UI matches what will be applied on relog.
    SetPreviewFont(GameFontNormalLarge or (preview and preview.GetFontObject and preview:GetFontObject()), defaultPath)
    if UpdatePreviewHeaderText then UpdatePreviewHeaderText("Default", defaultPath, true) end
    if preview and editBox and preview.SetText and editBox.GetText then
        preview:SetText(editBox:GetText() or "")
    end
    print("|cFF00FF00[FontMagic]|r Default combat font restored. Log out to character select and back in to apply.")
end)

local defaultBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
defaultBtn:SetSize(100,22)
defaultBtn:SetPoint("LEFT", applyBtn, "RIGHT", 8, 0)
defaultBtn:SetText("Reset")

AttachTooltip(defaultBtn, "Reset", "Restore Blizzard defaults. Choose what to reset.")

-- Reset dialog (font-only / combat-options-only / everything)
local resetDialog = CreateFrame("Frame", addonName .. "ResetDialog", UIParent, backdropTemplate)
resetDialog:SetFrameStrata("DIALOG")
resetDialog:SetToplevel(true)
resetDialog:SetSize(420, 220)
resetDialog:SetPoint("CENTER")
resetDialog:SetBackdrop({
    bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
    edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
    tile = true, tileSize = 32, edgeSize = 32,
    insets = { left = 8, right = 8, top = 8, bottom = 8 }
})
-- More opaque so the dialog remains readable when something bright is behind it.
resetDialog:SetBackdropColor(0, 0, 0, 0.95)
if resetDialog.SetBackdropBorderColor then
    resetDialog:SetBackdropBorderColor(0.8, 0.8, 0.8, 1)
end
resetDialog:EnableMouse(true)
resetDialog:SetMovable(true)
resetDialog:RegisterForDrag("LeftButton")
resetDialog:SetScript("OnDragStart", function(self) self:StartMoving() end)
resetDialog:SetScript("OnDragStop",  function(self) self:StopMovingOrSizing() end)
resetDialog:Hide()
frame.__fmResetDialog = resetDialog

-- Allow closing the dialog with Escape and keep it on-screen
if resetDialog and resetDialog.GetName and type(UISpecialFrames) == "table" then
    local n = resetDialog:GetName()
    if n then
        local exists = false
        for _, v in ipairs(UISpecialFrames) do
            if v == n then exists = true break end
        end
        if not exists then
            table.insert(UISpecialFrames, n)
        end
    end
end
if resetDialog and resetDialog.SetClampedToScreen then
    resetDialog:SetClampedToScreen(true)
end


local resetTitle = resetDialog:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
resetTitle:SetPoint("TOP", 0, -18)
resetTitle:SetText("Reset FontMagic")

local resetBody = resetDialog:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
resetBody:SetPoint("TOPLEFT", 18, -52)
resetBody:SetPoint("TOPRIGHT", -18, -52)
resetBody:SetPoint("BOTTOMLEFT", 18, 128)
resetBody:SetPoint("BOTTOMRIGHT", -18, 128)
resetBody:SetJustifyH("LEFT")
resetBody:SetJustifyV("TOP")
resetBody:SetText("Choose what you want to reset.\n\n• Font only: clears your selected font and preview state.\n• Combat options only: removes FontMagic overrides so WoW settings behave normally.\n• Everything: does both.")

local function ResetFontOnly()
    -- Preserve minimap settings
    local keepAngle = FontMagicDB and FontMagicDB.minimapAngle
    local keepHide  = FontMagicDB and FontMagicDB.minimapHide

    FontMagicDB = FontMagicDB or {}
    FontMagicDB.selectedFont = nil
    FontMagicDB.selectedGroup = nil
    FontMagicDB.minimapAngle = keepAngle
    FontMagicDB.minimapHide  = keepHide
    FontMagicDB.combatTextOutlineMode = "NONE"
    FontMagicDB.combatTextOutlineTouched = false

    FontMagicPCDB = FontMagicPCDB or {}
    FontMagicPCDB.selectedGroup = nil
    FontMagicPCDB.selectedFont  = nil

    pendingFont = nil

    -- Ensure we know the true Blizzard defaults for THIS client.
    CaptureBlizzardDefaultFonts()
    local defaultPath = DEFAULT_DAMAGE_TEXT_FONT or DEFAULT_COMBAT_TEXT_FONT or DEFAULT_FONT_PATH or "Fonts\\FRIZQT__.TTF"

    -- Reset dropdown labels
    for _, group in ipairs(order) do
        local dd = dropdowns[group]
        if dd then
            if type(UIDropDownMenu_SetText) == "function" then
                pcall(UIDropDownMenu_SetText, dd, "Select Font")
            elseif dd.SetText then
                dd:SetText("Select Font")
            end
        end
    end

    -- Reset preview font (keep the preview text as-is).
    SetPreviewFont(GameFontNormalLarge or (preview and preview.GetFontObject and preview:GetFontObject()), defaultPath)
    if UpdatePreviewHeaderText then UpdatePreviewHeaderText("Default", defaultPath, true) end
    __fmRunNextFrame(function()
        SetPreviewFont(GameFontNormalLarge or (preview and preview.GetFontObject and preview:GetFontObject()), defaultPath)
        if UpdatePreviewHeaderText then UpdatePreviewHeaderText("Default", defaultPath, true) end
        if preview and editBox and preview.SetText and editBox.GetText then
            preview:SetText(editBox:GetText() or "")
        end
    end)
    if editBox and editBox.ClearFocus then
        editBox:ClearFocus()
    end
    if editBox and preview and editBox.GetText and preview.SetText then
        local txt = editBox:GetText() or ""
        preview:SetText("")
        preview:SetText(txt)
    end

    -- Reset the actual combat text font back to Blizzard's default for this client.
    ApplyCombatTextFontPath(defaultPath)

    -- If expanded options are currently visible, refresh labels/buttons (including
    -- Text Style) so they immediately reflect reset defaults.
    RefreshCombatTextCVars()
end

local function ResetCombatOptionsOnly()
    FontMagicDB = FontMagicDB or {}

    -- Clear saved overrides so they no longer re-apply on login.
    FontMagicDB.combatOverrides = {}
    FontMagicDB.extraCombatOverrides = {}
    FontMagicDB.incomingOverrides = {}
    FontMagicDB.combatTextOutlineMode = "NONE"
    FontMagicDB.combatTextOutlineTouched = false
    FontMagicDB.combatMasterSnapshot = nil
    FontMagicDB.combatMasterOffByFontMagic = nil

    local function ResetCVarToDefault(name, commandType)
        if not name then return end
        if not DoesCVarExist(name) then return end

        -- Prefer the engine reset (most accurate to this client build)
        if type(C_CVar) == "table" and type(C_CVar.ResetCVar) == "function" then
            local ok = pcall(C_CVar.ResetCVar, name)
            if ok then return end
        end

        -- Fallback: set to reported default
        local d
        if type(C_CVar) == "table" and type(C_CVar.GetCVarDefault) == "function" then
            local ok, v = pcall(C_CVar.GetCVarDefault, name)
            if ok then d = v end
        end
        if d == nil and type(GetCVarDefault) == "function" then
            local ok, v = pcall(GetCVarDefault, name)
            if ok then d = v end
        end
        if d ~= nil then
            ApplyConsoleSetting(name, commandType or 0, d)
        end
    end

    local function ResetBoolCVarToDefault(name)
        ResetCVarToDefault(name, 0)
    end

    -- Reset curated combat text toggles to their real defaults (this client).
    for _, def in ipairs(COMBAT_BOOL_DEFS) do
        for _, target in ipairs(ResolveConsoleSettingTargets(def.candidates)) do
            if target.name and target.commandType == 0 then
                ResetBoolCVarToDefault(target.name)
            end
        end
    end

    -- Reset any additional floatingCombatText* boolean CVars we surfaced under Advanced.
    local extras = CollectExtraBoolCombatTextCVars()
    for _, cvar in ipairs(extras) do
        for _, target in ipairs(ResolveConsoleSettingTargets(cvar)) do
            if target.name and target.commandType == 0 then
                ResetBoolCVarToDefault(target.name)
            end
        end
    end

    -- Reset combat text scale (slider) to its real default (if supported on this client).
    do
        local scaleCandidates = {
            "WorldTextScale_v2",
            "WorldTextScale_V2",
            "WorldTextScale",
            "floatingCombatTextScale_v2",
            "floatingCombatTextScale_V2",
            "floatingCombatTextScale",
        }

        for _, target in ipairs(ResolveConsoleSettingTargets(scaleCandidates)) do
            local name, ct = target.name, target.commandType
            if name and ct == 0 then
                ResetCVarToDefault(name, ct)
            end
        end
    end

    do
        local function ResetNumericCVarToDefault(candidates)
            local bestName = ResolveCVarName(candidates)

            for _, target in ipairs(ResolveConsoleSettingTargets(candidates)) do
                local name, ct = target.name, target.commandType
                if name and ct == 0 then
                    ResetCVarToDefault(name, ct)
                end
            end

            if bestName then
                return tonumber(GetCVarString(bestName))
            end
            return nil
        end

        local gravityCandidates = { "WorldTextGravity_v2", "WorldTextGravity_V2", "WorldTextGravity", "floatingCombatTextGravity_v2", "floatingCombatTextGravity_V2", "floatingCombatTextGravity" }
        local fadeCandidates = { "WorldTextRampDuration_v2", "WorldTextRampDuration_V2", "WorldTextRampDuration", "floatingCombatTextRampDuration_v2", "floatingCombatTextRampDuration_V2", "floatingCombatTextRampDuration" }

        local g = ResetNumericCVarToDefault(gravityCandidates)
        local f = ResetNumericCVarToDefault(fadeCandidates)

        FontMagicDB.floatingTextGravity = g or GetResolvedSettingNumber(gravityCandidates, 1.0)
        FontMagicDB.floatingTextFadeDuration = f or GetResolvedSettingNumber(fadeCandidates, 1.0)
    end

    -- Restore Blizzard's default incoming damage/healing tables (where supported).
    if EnsureIncomingReady() and type(COMBAT_TEXT_TYPE_INFO) == "table" and type(originalInfo) == "table" then
        local keys = {
            "DAMAGE", "DAMAGE_CRIT", "SPELL_DAMAGE", "SPELL_DAMAGE_CRIT",
            "HEAL", "HEAL_CRIT", "PERIODIC_HEAL", "PERIODIC_HEAL_CRIT",
        }
        for _, k in ipairs(keys) do
            COMBAT_TEXT_TYPE_INFO[k] = originalInfo[k]
        end
    end

    -- Reset account-wide layering tweak (combat text above nameplates).
    FontMagicDB.combatTextAboveNameplates = false
    ApplyCombatTextAboveNameplates(false)

    -- Refresh UI + slider values immediately.
    do
        local path = nil
        local key = FontMagicDB and FontMagicDB.selectedFont
        if type(key) == "string" and key ~= "" then
            path = ResolveFontPathFromKey(key)
        end
        if not path then
            CaptureBlizzardDefaultFonts()
            path = DEFAULT_DAMAGE_TEXT_FONT or DEFAULT_COMBAT_TEXT_FONT or DEFAULT_FONT_PATH or "Fonts\\FRIZQT__.TTF"
        end
        ApplyCombatTextFontPath(path)
    end

    RefreshScaleControl()
    RefreshCombatTextCVars()
    UpdateMainCombatCheckboxes()
end

local function ResetEverything()
    ResetFontOnly()
    ResetCombatOptionsOnly()
end

local resetFontBtn = CreateFrame("Button", nil, resetDialog, "UIPanelButtonTemplate")
resetFontBtn:SetSize(160, 22)
resetFontBtn:SetPoint("BOTTOMLEFT", 18, 54)
resetFontBtn:SetText("Reset Font Only")
AttachTooltip(resetFontBtn, "Reset font only", "Restore Blizzard\'s default combat text font.")
resetFontBtn:SetScript("OnClick", function()
    ResetFontOnly()
    resetDialog:Hide()
end)

local resetCombatBtn = CreateFrame("Button", nil, resetDialog, "UIPanelButtonTemplate")
resetCombatBtn:SetSize(160, 22)
resetCombatBtn:SetPoint("BOTTOMRIGHT", -18, 54)
resetCombatBtn:SetText("Reset Combat Options")
AttachTooltip(resetCombatBtn, "Reset combat options", "Clear FontMagic combat text overrides and restore Blizzard defaults.")
resetCombatBtn:SetScript("OnClick", function()
    ResetCombatOptionsOnly()
    resetDialog:Hide()
end)

local resetAllBtn = CreateFrame("Button", nil, resetDialog, "UIPanelButtonTemplate")
resetAllBtn:SetSize(160, 22)
resetAllBtn:SetPoint("BOTTOM", 0, 92)
resetAllBtn:SetText("Reset Everything")
AttachTooltip(resetAllBtn, "Reset everything", "Restore Blizzard defaults for both fonts and combat text options.")
resetAllBtn:SetScript("OnClick", function()
    ResetEverything()
    resetDialog:Hide()
end)

local resetCancelBtn = CreateFrame("Button", nil, resetDialog, "UIPanelButtonTemplate")
resetCancelBtn:SetSize(120, 22)
resetCancelBtn:SetPoint("BOTTOM", 0, 18)
resetCancelBtn:SetText(CANCEL or "Cancel")
AttachTooltip(resetCancelBtn, "Cancel", "Close this dialog without making changes.")
resetCancelBtn:SetScript("OnClick", function() resetDialog:Hide() end)

defaultBtn:SetScript("OnClick", function()
    if resetDialog:IsShown() then
        resetDialog:Hide()
    else
        resetDialog:Show()
    end
end)

-- 11) CLOSE BUTTON
local closeBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
closeBtn:SetSize(80, 24)
-- Align with the Apply button and use the reduced
-- bottom spacing
closeBtn:SetPoint("BOTTOMRIGHT", (frame.__fmLeftAnchor or frame), "BOTTOMRIGHT", -16, 12)
-- Keep the expand toggle neatly above Close
if expandBtn and expandBtn.ClearAllPoints and expandBtn.SetPoint then
    expandBtn:ClearAllPoints()
    expandBtn:SetPoint("BOTTOMRIGHT", closeBtn, "TOPRIGHT", 0, 6)
end
closeBtn:SetText("Close")
closeBtn:SetScript("OnClick", function() frame:Hide() end)
closeBtn:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
    GameTooltip:SetText("Close this window", nil, nil, nil, nil, true)
    GameTooltip:Show()
end)
closeBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)

-- 12) SLASH COMMANDS & DROPDOWN
SLASH_FCT1, SLASH_FCT2 = "/FCT", "/fct"
SLASH_FCT3, SLASH_FCT4 = "/FLOAT", "/float"
SLASH_FCT5, SLASH_FCT6 = "/FLOATING", "/floating"
SLASH_FCT7, SLASH_FCT8 = "/FLOATINGTEXT", "/floatingtext"
SLASH_FCT9, SLASH_FCT10 = "/FONTMAGIC", "/fontmagic"

local function PrepareFontMagicWindowForDisplay()
    if not frame then return end

    -- When opened from the Retail Settings/AddOns panel, this frame can be
    -- parented into that container. Minimap/slash opens are standalone and must
    -- be detached back to UIParent or the frame can remain effectively hidden.
    if frame.GetParent and frame:GetParent() ~= UIParent then
        frame:SetParent(UIParent)
        frame:ClearAllPoints()
        frame:SetPoint("CENTER")
    end

    -- Restore saved position (standalone only)
    if frame.GetParent and frame:GetParent() == UIParent and FontMagicDB and type(FontMagicDB.windowPos) == "table" then
        local p = FontMagicDB.windowPos
        if type(p.left) == "number" and type(p.top) == "number" then
            frame:ClearAllPoints()
            frame:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", p.left, p.top)
        end
    end

    -- Ensure the config window is on top when opened standalone.
    if frame.GetParent and frame:GetParent() == UIParent then
        if frame.SetToplevel then frame:SetToplevel(true) end
        if frame.SetFrameStrata then frame:SetFrameStrata("FULLSCREEN_DIALOG") end
        if frame.SetFrameLevel then frame:SetFrameLevel(1000) end
        if frame.Raise then frame:Raise() end

        if combatPanel and combatPanel.SetFrameStrata then
            combatPanel:SetFrameStrata("FULLSCREEN_DIALOG")
            if combatPanel.SetFrameLevel and frame.GetFrameLevel then
                combatPanel:SetFrameLevel((frame:GetFrameLevel() or 1000) + 20)
            end
        end
        if expandBtn and expandBtn.SetFrameStrata then
            expandBtn:SetFrameStrata("FULLSCREEN_DIALOG")
            if expandBtn.SetFrameLevel and frame.GetFrameLevel then
                expandBtn:SetFrameLevel((frame:GetFrameLevel() or 1000) + 30)
            end
        end
        if resetDialog and resetDialog.SetFrameStrata then
            resetDialog:SetFrameStrata("FULLSCREEN_DIALOG")
        end
    end

    -- Apply account-wide combat text layer preference immediately.
    if FontMagicDB and FontMagicDB.combatTextAboveNameplates then
        ApplyCombatTextAboveNameplates(true)
    else
        ApplyCombatTextAboveNameplates(false)
    end

    UpdateMainCombatCheckboxes()
end

SlashCmdList["FCT"] = function(msg)
    -- normalise message for case-insensitive matching and remove whitespace
    msg = tostring(msg or ""):match("^%s*(.-)%s*$"):lower()

    if msg:match("^debug") then
        local arg = msg:match("^debug%s*(.-)%s*$") or ""
        local ct = _G and _G.FontMagicCombatTextFix

        if arg == "test" then
            if ct then
                if type(ct.EnsureCombatTextPatches) == "function" then
                    pcall(ct.EnsureCombatTextPatches, ct)
                end
                if type(ct.SendTestCombatText) == "function" then
                    pcall(ct.SendTestCombatText, ct)
                end
                if type(ct.PrintDebugSnapshot) == "function" then
                    pcall(ct.PrintDebugSnapshot, ct)
                end
            end
            return
        end

        if arg == "" or arg == "toggle" then
            FontMagicDB.__fmDebugCombatText = not FontMagicDB.__fmDebugCombatText
        elseif arg == "on" or arg == "1" or arg == "true" then
            FontMagicDB.__fmDebugCombatText = true
        elseif arg == "off" or arg == "0" or arg == "false" then
            FontMagicDB.__fmDebugCombatText = false
        elseif arg == "dump" then
            -- no state change
        else
            if ct and ct.DbgLine then
                ct:DbgLine("usage: /fontmagic debug [on|off|toggle|dump|test]")
            else
                print("FontMagic: usage: /fontmagic debug [on|off|toggle|dump|test]")
            end
        end

        -- Reset per-session counters so the next enable gives fresh logs.
        if FontMagicDB.__fmDebugCombatText then
            if ct and type(ct.wrap) == "table" then
                ct.wrap.logCount = 0
                ct.wrap.lastFSText = {}
                ct.wrap.lastDisplayedScan = 0
            end
            if ct and ct.DbgLine then
                ct:DbgLine("combat-text debug ON (logging numeric-ish messages)")
            end
        else
            if ct and ct.DbgLine then
                ct:DbgLine("combat-text debug OFF")
            end
        end

        if ct then
            if type(ct.EnsureCombatTextPatches) == "function" then
                pcall(ct.EnsureCombatTextPatches, ct)
            end
            if type(ct.PrintDebugSnapshot) == "function" then
                pcall(ct.PrintDebugSnapshot, ct)
            end
        end
        return
    end

    if msg == "hide" then
        if type(FontMagicDB) ~= "table" then FontMagicDB = {} end
        FontMagicDB.minimapHide = true
        if minimapButton then minimapButton:Hide() end
        print("|cFF00FF00[FontMagic]|r Minimap icon hidden. Use /float show to restore.")
        return
    elseif msg == "show" then
        if type(FontMagicDB) ~= "table" then FontMagicDB = {} end
        FontMagicDB.minimapHide = false
        if minimapButton then minimapButton:Show() end
        print("|cFF00FF00[FontMagic]|r Minimap icon shown.")
        return
    end

    if frame:IsVisible() then
        frame:Hide()
        return
    end

    pendingFont = nil -- discard any un-applied selection

    for g,d in pairs(dropdowns) do UIDropDownMenu_SetText(d, "Select Font") end
    if FontMagicDB.selectedFont then
        -- parse using the last '/' to support group names that contain
        -- slashes from older categories.
        local grp,fname = FontMagicDB.selectedFont:match("^(.*)/([^/]+)$")
        if grp and fname and dropdowns[grp] and existsFonts[grp][fname] then
            UIDropDownMenu_SetText(dropdowns[grp], __fmStripFontExt(fname))
            -- If this font is favorited, also reflect it in the Favorites dropdown.
            if dropdowns["Favorites"] and IsFavorite(FontMagicDB.selectedFont) then
                UIDropDownMenu_SetText(dropdowns["Favorites"], __fmStripFontExt(fname))
            end
            local cache = cachedFonts[grp][fname]
            if cache then
                SetPreviewFont(cache)
                if UpdatePreviewHeaderText then
                    UpdatePreviewHeaderText(__fmStripFontExt(fname), cache.path, false)
                end
            end
        end
    else
        -- no saved font means use the UI's default
        SetPreviewFont(GameFontNormalLarge or preview:GetFontObject(), DEFAULT_FONT_PATH)
        if UpdatePreviewHeaderText then UpdatePreviewHeaderText("Default", DEFAULT_FONT_PATH, true) end
    end
    preview:SetText(editBox:GetText())
    PrepareFontMagicWindowForDisplay()
    frame:Show()

    -- Ensure the preview is correctly sized the first time the window is shown.
    -- Some clients report widget sizes slightly differently before the frame is visible.
    __fmRunNextFrame(function()
        if not (frame and frame.IsShown and frame:IsShown()) then return end

        local key = FontMagicDB and FontMagicDB.selectedFont
        local path
        if key and type(key) == "string" and key ~= "" then
            path = ResolveFontPathFromKey(key)
        end
        if not path then
            CaptureBlizzardDefaultFonts()
            path = DEFAULT_DAMAGE_TEXT_FONT or DEFAULT_COMBAT_TEXT_FONT or DEFAULT_FONT_PATH or "Fonts\\FRIZQT__.TTF"
        end

        SetPreviewFont(GameFontNormalLarge or (preview and preview.GetFontObject and preview:GetFontObject()), path)

        if UpdatePreviewHeaderText then
            local label = "Default"
            local isDef = true
            if key and type(key) == "string" and key ~= "" then
                local _, f = key:match("^(.*)/([^/]+)$")
                if f then
                    label = __fmStripFontExt(f)
                    isDef = false
                end
            end
            UpdatePreviewHeaderText(label, path, isDef)
        end

        if preview and editBox and preview.SetText and editBox.GetText then
            preview:SetText(editBox:GetText() or "")
        end
    end)
end

-- 13) MINIMAP ICON -----------------------------------------------------------
minimapButton = CreateFrame("Button", addonName .. "MinimapButton", Minimap)
minimapButton:SetSize(20, 20)
minimapButton:SetFrameStrata("MEDIUM")
minimapButton:SetFrameLevel(8)

-- helper to position the minimap button at a specific angle
local function PositionMinimapButton(btn, angle)
    local a = math.rad(angle or 0)
    local r = (Minimap and Minimap.GetWidth and (Minimap:GetWidth() / 2) or 70) + 10
    btn:ClearAllPoints()
    btn:SetPoint("CENTER", Minimap, "CENTER", math.cos(a) * r, math.sin(a) * r)
end

-- Cross-version atan2 (some clients expose math.atan2, others rely on math.atan(y,x))
local function SafeAtan2(y, x)
    if type(math.atan2) == "function" then
        return math.atan2(y, x)
    end
    if type(math.atan) == "function" then
        local ok, v = pcall(math.atan, y, x)
        if ok and v ~= nil then
            return v
        end
        if x == 0 then
            if y > 0 then return math.pi / 2 end
            if y < 0 then return -math.pi / 2 end
            return 0
        end
        return math.atan(y / x)
    end
    local gatan2 = _G and _G.atan2
    if type(gatan2) == "function" then
        return gatan2(y, x)
    end
    return 0
end

local function UpdateMinimapButtonDrag(self)
    local mx, my = GetCursorPosition()
    local scale = (Minimap and Minimap.GetEffectiveScale and Minimap:GetEffectiveScale()) or 1
    mx, my = mx / scale, my / scale
    local cx, cy = Minimap:GetCenter()
    local dx, dy = mx - cx, my - cy

    local ang = math.deg(SafeAtan2(dy, dx))
    FontMagicDB.minimapAngle = ang
    PositionMinimapButton(self, ang)
end

-- icon (masked to a circle)
local iconTex = minimapButton:CreateTexture(nil, "BACKGROUND")
iconTex:SetTexture(ADDON_PATH .. "FontMagic_MinimapIcon_Best_64.tga")
iconTex:SetAllPoints(minimapButton)
    if iconTex.SetMaskTexture then
        -- Use a double-backslash path to avoid Lua escape sequences removing the backslashes.
        -- Without escaping, a single \M would remove the backslash and break the path.
        iconTex:SetMaskTexture("Interface\\Minimap\\UI-Minimap-Mask")
    end

-- ring overlay
local ring = minimapButton:CreateTexture(nil, "OVERLAY")
ring:SetTexture(ADDON_PATH .. "FontMagic_MinimapRing_Best_64.tga")
local w, h = minimapButton:GetSize()
ring:SetSize(w * 1.5, h * 1.5)
ring:SetAlpha(0.85)
iconTex:SetAlpha(0.95)
ring:SetPoint("CENTER", minimapButton, "CENTER", 0, 0)

    -- Use a double-backslash path to avoid Lua escape sequences removing the backslashes.
    minimapButton:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")
minimapButton:EnableMouse(true)
minimapButton:SetHitRectInsets(-6, -6, -6, -6)
minimapButton:RegisterForClicks("LeftButtonUp")
minimapButton:RegisterForDrag("LeftButton")
minimapButton:SetScript("OnClick", function()
    if SlashCmdList and SlashCmdList["FCT"] then
        SlashCmdList["FCT"]()
    end
end)

minimapButton:SetScript("OnDragStart", function(self)
    self.isMoving = true
    self:SetScript("OnUpdate", UpdateMinimapButtonDrag)
end)
minimapButton:SetScript("OnDragStop", function(self)
    self.isMoving = false
    self:SetScript("OnUpdate", nil)
    PositionMinimapButton(self, FontMagicDB and FontMagicDB.minimapAngle)
end)

minimapButton:SetScript("OnEnter", function(self)
    if GameTooltip then
        GameTooltip:SetOwner(self, "ANCHOR_LEFT")
        GameTooltip:SetText("FontMagic", 1, 1, 1)
        GameTooltip:Show()
    end
    if ring then ring:SetAlpha(1.0) end
    if iconTex then iconTex:SetAlpha(1.0) end
end)
minimapButton:SetScript("OnLeave", function()
    if GameTooltip then GameTooltip:Hide() end
    if ring then ring:SetAlpha(0.85) end
    if iconTex then iconTex:SetAlpha(0.95) end
end)

-- place immediately
PositionMinimapButton(minimapButton, FontMagicDB and FontMagicDB.minimapAngle)

-- honour saved visibility preference
if FontMagicDB and FontMagicDB.minimapHide then
    minimapButton:Hide()
end

-- Apply minimap button preferences (angle + visibility). We call this again on
-- ADDON_LOADED/PLAYER_LOGIN because some clients only guarantee SavedVariables
-- are populated by then.
local function ApplyMinimapButtonPreferences()
    if not minimapButton then return end
    if type(FontMagicDB) ~= "table" then return end

    if type(FontMagicDB.minimapAngle) == "number" then
        PositionMinimapButton(minimapButton, FontMagicDB.minimapAngle)
    end

    if FontMagicDB.minimapHide then
        minimapButton:Hide()
    else
        minimapButton:Show()
    end
end

-- ---------------------------------------------------------------------------
-- Combat text override bootstrap
-- ---------------------------------------------------------------------------

local function ApplyAllSavedOverrides()
    ApplyCombatOverrides()
    ApplyIncomingOverrides()
    ApplyFloatingTextMotionSettings()

    -- If combat text was hidden via the master toggle, keep it hidden across relogs.
    if FontMagicDB and FontMagicDB.combatMasterOffByFontMagic and type(FontMagicDB.combatMasterSnapshot) == "table" then
        ApplyCombatTextMasterDisabled()
    end
end

local eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("PLAYER_LOGIN")
eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
eventFrame:RegisterEvent("ADDON_LOADED")
eventFrame:RegisterEvent("CVAR_UPDATE")

eventFrame:SetScript("OnEvent", function(self, event, arg1, arg2)
    local fix = _G and _G.FontMagicCombatTextFix
    if fix and type(fix.InstallExternalCombatFontHooks) == "function" then
        pcall(fix.InstallExternalCombatFontHooks, fix)
    end

    if event == "ADDON_LOADED" and fix and type(fix.QueueSavedCombatFontApply) == "function" then
        pcall(fix.QueueSavedCombatFontApply, fix, 0)
        pcall(fix.QueueSavedCombatFontApply, fix, 0.05)
    end

    if event == "PLAYER_LOGIN" then
        -- Build console index, register options, then apply only the saved overrides.
        RefreshConsoleCommandIndex()
        MigrateSavedFontKeys()
        RegisterOptionsCategory()
        CaptureBlizzardDefaultFonts()
        ApplySavedCombatFont()
        if fix and type(fix.QueueSavedCombatFontApplyBurst) == "function" then
            pcall(fix.QueueSavedCombatFontApplyBurst, fix)
        end
        if fix and type(fix.StartStartupCombatFontWatcher) == "function" then
            pcall(fix.StartStartupCombatFontWatcher, fix, 10)
        end
        if type(C_Timer) == "table" and type(C_Timer.After) == "function" then
            C_Timer.After(0.5, ApplySavedCombatFont)
            C_Timer.After(2.0, ApplySavedCombatFont)
        end
        if fix and type(fix.EnsureCombatTextPatches) == "function" then
            pcall(fix.EnsureCombatTextPatches, fix)
        end
        ApplyAllSavedOverrides()
        RefreshCombatTextCVars()
        -- Apply account-wide layering preference
        if FontMagicDB and FontMagicDB.combatTextAboveNameplates then
            ApplyCombatTextAboveNameplates(true)
        else
            ApplyCombatTextAboveNameplates(false)
        end

        UpdateMainCombatCheckboxes()
        if type(C_Timer) == "table" and type(C_Timer.After) == "function" then
            C_Timer.After(0.2, UpdateMainCombatCheckboxes)
            C_Timer.After(1.0, UpdateMainCombatCheckboxes)
        end

        -- Re-apply minimap icon state now that SavedVariables are definitely loaded.
        pcall(function()
            if type(FontMagicDB) ~= "table" then FontMagicDB = {} end
            if FontMagicDB.minimapAngle == nil then FontMagicDB.minimapAngle = 225 end
            if FontMagicDB.minimapHide == nil then FontMagicDB.minimapHide = false end
            if ApplyMinimapButtonPreferences then ApplyMinimapButtonPreferences() end
        end)

    elseif event == "PLAYER_ENTERING_WORLD" then
        CaptureBlizzardDefaultFonts()
        ApplySavedCombatFont()
        if fix and type(fix.QueueSavedCombatFontApplyBurst) == "function" then
            pcall(fix.QueueSavedCombatFontApplyBurst, fix)
        end
        if fix and type(fix.StartStartupCombatFontWatcher) == "function" then
            pcall(fix.StartStartupCombatFontWatcher, fix, 8)
        end
        if type(C_Timer) == "table" and type(C_Timer.After) == "function" then
            C_Timer.After(0.5, ApplySavedCombatFont)
            C_Timer.After(2.0, ApplySavedCombatFont)
        end

    elseif event == "ADDON_LOADED" then
        -- SavedVariables are guaranteed to be available on our own ADDON_LOADED.
        -- Apply the saved font as early as possible so Blizzard combat text picks it up
        -- during its initialization (some clients cache the font before PLAYER_LOGIN).
        if arg1 == addonName then
            MigrateSavedFontKeys()
            CaptureBlizzardDefaultFonts()
            ApplySavedCombatFont()
            if fix and type(fix.QueueSavedCombatFontApplyBurst) == "function" then
                pcall(fix.QueueSavedCombatFontApplyBurst, fix)
            end
            if fix and type(fix.StartStartupCombatFontWatcher) == "function" then
                pcall(fix.StartStartupCombatFontWatcher, fix, 8)
            end
            if fix and type(fix.EnsureCombatTextPatches) == "function" then
                pcall(fix.EnsureCombatTextPatches, fix)
            end
            ApplyFloatingTextMotionSettings()

            -- Ensure minimap icon respects saved hide/angle (SavedVariables are guaranteed here).
            pcall(function()
                if type(FontMagicDB) ~= "table" then FontMagicDB = {} end
                if FontMagicDB.minimapAngle == nil then FontMagicDB.minimapAngle = 225 end
                if FontMagicDB.minimapHide == nil then FontMagicDB.minimapHide = false end
                if ApplyMinimapButtonPreferences then ApplyMinimapButtonPreferences() end
            end)
        end

        -- If Blizzard_CombatText loads after us, re-apply incoming overrides (if any).
        if arg1 == "Blizzard_CombatText" or arg1 == "Blizzard_FloatingCombatText" then
            MigrateSavedFontKeys()
            CaptureBlizzardDefaultFonts()
            ApplySavedCombatFont()
            if type(C_Timer) == "table" and type(C_Timer.After) == "function" then
                C_Timer.After(0.5, ApplySavedCombatFont)
                C_Timer.After(2.0, ApplySavedCombatFont)
            end
            if fix and type(fix.QueueSavedCombatFontApplyBurst) == "function" then
                pcall(fix.QueueSavedCombatFontApplyBurst, fix)
            end
            if fix and type(fix.EnsureCombatTextPatches) == "function" then
                pcall(fix.EnsureCombatTextPatches, fix)
            end
            ApplyIncomingOverrides()
            ApplyFloatingTextMotionSettings()
            RefreshCombatTextCVars()
        end

    elseif event == "CVAR_UPDATE" then
        -- Keep UI in sync if the user changes settings via the default Options UI.
        RefreshCombatTextCVars()
    end
end)
