local addonName, ns = ...

ns.LOCALE_FONT = ({
    zhCN = "Fonts\\ARKai_T.ttf",
    zhTW = "Fonts\\bHEI00M.ttf",
    koKR = "Fonts\\2002.ttf",
    ruRU = "Fonts\\FRIZQT___CYR.ttf",
})[GetLocale()] or "Fonts\\FRIZQT__.TTF"

-- Face for status labels ("Stealth", "Dead"). Deliberately the
-- same font as everything else.
--
-- A display serif (MORPHEUS, the one Blizzard uses for quest titles) was tried
-- and rejected on two counts: it is not legible enough at bar text sizes, and
-- any face other than the locale font is a regional hazard — LOCALE_FONT above
-- exists precisely because glyph coverage differs by region, and a hardcoded
-- Latin face renders as boxes the moment a status string is localised.
-- Status labels are set apart by size and case instead, neither of which
-- depends on which glyphs a font happens to ship.
ns.STATUS_FONT = ns.LOCALE_FONT

ns.defaults = {
    -- -------------------------------------------------------------------------
    -- Modern-look prototype
    -- -------------------------------------------------------------------------
    barFillStyle                   = "smooth",  -- smooth | smoke | glass

    -- -------------------------------------------------------------------------
    -- Normal BG — Enemy Bars
    -- -------------------------------------------------------------------------
    normalEnemyEnable              = true,
    normalEnemyBarWidth            = 250,
    normalEnemyBarHeight           = 35,
    normalEnemyBarSpacing          = 3,
    normalEnemyTextSize            = 11,
    normalEnemyHeadingTextSize     = 18,
    normalEnemyPowerBarEnabled     = false,
    normalEnemyTextDisplayMode     = "name",
    -- Shown in the UI as "Objective Text Size": it sizes the carrier stack
    -- count AND the respawn countdown, both being numbers drawn on a marker in
    -- the right-hand slot. The KEY keeps its old name deliberately — renaming
    -- it would orphan every existing profile's saved value for a cosmetic
    -- change, and a migration isn't worth that risk.
    normalEnemyOrbStackTextSize    = 14,

    normalEnemyHideFactionInfo     = false,
    normalEnemyHideClassArt        = false,
    normalEnemyTeamTargeting       = false,
    normalEnemyStealthReveal       = true,
    normalEnemyRangeCheck          = false,
    normalEnemyRangeOpacity        = 0.15,
    normalEnemyFrameLocked         = false,
    normalEnemyPingEnabled         = false,
    normalEnemyPingLeftClick       = "Attack",
    normalEnemyPingRightClick      = "Warning",
    normalEnemyPingZoomAmount      = 1.8,
    normalEnemyFramePosition       = { x = 1492.893188476563, y = 757.5054931640625 },
    -- -------------------------------------------------------------------------
    -- Normal BG — Friendly Bars
    -- -------------------------------------------------------------------------
    normalFriendlyEnable           = false,
    normalFriendlyBarWidth         = 240,
    normalFriendlyBarHeight        = 35,
    normalFriendlyBarSpacing       = 3,
    normalFriendlyTextSize         = 12,
    normalFriendlyHeadingTextSize  = 12,
    normalFriendlyOrbStackTextSize = 16,
    normalFriendlyPowerBarEnabled  = true,
    normalFriendlyTextDisplayMode  = "name",

    normalFriendlyUseEnemySettings = true,
    normalFriendlyHideFactionInfo  = false,
    normalFriendlyHideClassArt     = false,
    normalFriendlyRangeCheck       = false,
    normalFriendlyRangeOpacity     = 0.15,
    normalFriendlyFrameLocked      = false,
    normalFriendlyPingEnabled      = false,
    normalFriendlyPingLeftClick    = "Assist",
    normalFriendlyPingRightClick   = "Assist",
    normalFriendlyPingZoomAmount   = 1.8,
    normalFriendlyFramePosition    = { x = 339.256591796875, y = 769.0255737304688 },
    -- -------------------------------------------------------------------------
    -- Epic BG — Enemy Bars
    -- -------------------------------------------------------------------------
    epicEnemyEnable                = true,
    epicEnemyBarWidth              = 220,
    epicEnemyBarHeight             = 20,
    epicEnemyBarSpacing            = 3,
    epicEnemyTextSize              = 12,
    epicEnemyHeadingTextSize       = 12,
    epicEnemyOrbStackTextSize      = 15,
    epicEnemyRangeCheck          = false,
    epicEnemyRangeOpacity        = 0.15,
    epicEnemyPowerBarEnabled       = false,
    epicEnemyTextDisplayMode       = "name",

    epicEnemyHideFactionInfo       = false,
    epicEnemyHideClassArt          = false,
    epicEnemyFrameLocked           = false,
    epicEnemyPingEnabled           = false,
    epicEnemyPingLeftClick         = "Attack",
    epicEnemyPingRightClick        = "Warning",
    epicEnemyPingZoomAmount        = 1.8,
    epicEnemyFramePosition         = { x = 1433, y = 830 },
    -- -------------------------------------------------------------------------
    -- Epic BG — Friendly Bars
    -- -------------------------------------------------------------------------
    epicFriendlyEnable             = false,
    epicFriendlyBarWidth           = 240,
    epicFriendlyBarHeight          = 18,
    epicFriendlyBarSpacing         = 3,
    epicFriendlyTextSize           = 12,
    epicFriendlyHeadingTextSize    = 12,
    epicFriendlyOrbStackTextSize   = 16,
    epicFriendlyRangeCheck          = false,
    epicFriendlyRangeOpacity        = 0.15,
    epicFriendlyPowerBarEnabled    = true,
    epicFriendlyTextDisplayMode    = "name",

    epicFriendlyUseEnemySettings   = true,
    epicFriendlyHideFactionInfo    = false,
    epicFriendlyHideClassArt       = false,
    epicFriendlyFrameLocked        = false,
    epicFriendlyPingEnabled        = false,
    epicFriendlyPingLeftClick      = "Assist",
    epicFriendlyPingRightClick     = "Assist",
    epicFriendlyPingZoomAmount     = 1.8,
    epicFriendlyFramePosition      = { x = 1433, y = 600 },
    -- -------------------------------------------------------------------------
    -- Global UI
    -- -------------------------------------------------------------------------
    minimapAngle                   = -117.0994646823852,
    optionsScale                   = 1.0,
    uiSoundVolume                  = 80,    -- 0 = off; steps of 20 (see ns.PlayUISound)

    -- -------------------------------------------------------------------------
    -- Additional Options (per-character; survives spec swaps)
    -- -------------------------------------------------------------------------
    drinkWarningEnabled            = false,
    -- Class art style: "legacy" (Blizzard's square icons), "sharp" (the
    -- previous commissioned set) and "polygon" (the current set) all hang
    -- OUTSIDE the card; "polygon2" is the polygon art in its original INSET
    -- composition. Shared rather than per-profile: one visual identity for
    -- the whole addon, the same control mirrored on every Appearance section.
    classArtStyle                  = "polygon",
    flaskWarningEnabled            = false,

    raidObjectiveEnabled           = false,
    raidObjectiveIconSize          = 28,
    raidObjectiveOffsetX           = 0,
    raidObjectiveOffsetY           = 0,
    raidObjectiveStackTextSize     = 12,
    raidObjectiveAnchor            = "RIGHT",
}

-- Session-only set of the enemy names on this match's scoreboard, keyed by
-- canonical Name-Realm with a value of `true`. Wiped on /reload — not in
-- SavedVariables. It was a record cache until 2026-08-17; see the write site in
-- UpdateEnemyFrameSet for why the fields went.
ns.playerKeys = {}

ns.settings = nil

function ns.DeepCopy(src)
    if type(src) ~= "table" then return src end
    local copy = {}
    for k, v in pairs(src) do copy[k] = ns.DeepCopy(v) end
    return copy
end

function ns.IsEpicBattleground()
    local _, _, _, _, maxPlayers = GetInstanceInfo()
    return maxPlayers and maxPlayers >= 35
end

-- Ping types the user can pick. TOKEN is what `/ping` takes; LABEL is what the
-- dropdown shows. They were the same string until 2026-08-15, which produced
-- two bugs: "Ping" is not a ping type at all (no such Enum.PingSubjectType
-- member), and "On My Way" has SPACES, so the macro fell through to the
-- contextual default instead of the requested type.
--
-- Enum.PingSubjectType is Attack=0 Warning=1 Assist=2 OnMyWay=3, then
-- AlertThreat=4 .. ActionNotReady=9. Only the first four appear on Blizzard's
-- radial wheel; 4-9 are pings the game fires itself and are deliberately absent
-- here.
--
-- The token is the ENUM MEMBER NAME, not a display string, so it does not
-- depend on the client's language.
-- `friendly` marks the ones that make sense aimed at your own team. You do not
-- "Attack" an ally, and the friendly dropdown offered exactly Assist and
-- On My Way before this table existed — that subset is preserved, just no
-- longer hardcoded in the tab file with a broken key.
ns.PING_TYPES = {
    { token = "Attack",  label = "Attack" },
    { token = "Warning", label = "Warning" },
    { token = "Assist",  label = "Assist",     friendly = true },
    { token = "OnMyWay", label = "On My Way",  friendly = true },
}

-- Builds the { [token] = label } map CreateDropdown expects.
function ns.PingTypeOptions(friendlyOnly)
    local t = {}
    for _, pt in ipairs(ns.PING_TYPES) do
        if not friendlyOnly or pt.friendly then t[pt.token] = pt.label end
    end
    return t
end

-- Settings saved before the split hold display strings. Mapped forward rather
-- than wiped, so nobody's ping button silently changes behaviour.
local PING_TYPE_LEGACY = {
    ["On My Way"] = "OnMyWay",
    ["Ping"]      = "Assist",   -- never a real type; Assist is the closest intent
}

function ns.NormalizePingType(v)
    v = PING_TYPE_LEGACY[v] or v
    for _, t in ipairs(ns.PING_TYPES) do
        if t.token == v then return v end
    end
    return "Assist"
end

-- Keys stored at ns.db root, not inside profiles
ns.SHARED_KEYS = {
    minimapAngle               = true,
    optionsScale               = true,
    uiSoundVolume              = true,
    drinkWarningEnabled        = true,
    classArtStyle              = true,
    flaskWarningEnabled        = true,
    raidObjectiveEnabled       = true,
    raidObjectiveIconSize      = true,
    raidObjectiveOffsetX       = true,
    raidObjectiveOffsetY       = true,
    raidObjectiveStackTextSize = true,
    raidObjectiveAnchor        = true,
}

ns.RAID_OBJECTIVE_ANCHORS = {
    "TOPLEFT", "TOP", "TOPRIGHT",
    "LEFT", "CENTER", "RIGHT",
    "BOTTOMLEFT", "BOTTOM", "BOTTOMRIGHT",
}

-- STATUS text is derived from BAR HEIGHT, not from the Centre Display Text
-- Size setting.
--
-- The centre label stays user-controlled — that is the text people actually
-- read and want sized to taste. The status word (DEAD / STEALTH, stamped over
-- the class art) is different: it is a fixed short word in a fixed slot, so
-- tying it to bar height keeps it proportionate automatically instead of
-- drifting every time someone retunes the centre text.
--
-- 0.40 of bar height is the user's calibration. Floored at 8: a 16px bar would
-- otherwise land at 6pt, past legible.
ns.STATUS_SIZE_RATIO = 0.40
ns.STATUS_SIZE_MIN   = 8

function ns.BarStatusSize(barHeight, scale)
    return math.max(ns.STATUS_SIZE_MIN,
                    math.floor((barHeight or 32) * ns.STATUS_SIZE_RATIO * (scale or 1) + 0.5))
end

local function Get(key)
    if ns.settings then return ns.settings[key] end
    return ns.defaults[key]
end

-------------------------------------------------------------------------------
-- Normal Enemy getters
-------------------------------------------------------------------------------
function ns.GetNormalEnemyEnable()            return Get("normalEnemyEnable") end
function ns.GetNormalEnemyBarWidth()          return Get("normalEnemyBarWidth") end
function ns.GetNormalEnemyBarHeight()         return Get("normalEnemyBarHeight") end
function ns.GetNormalEnemyBarSpacing()        return Get("normalEnemyBarSpacing") end
function ns.GetNormalEnemyTextSize()          return Get("normalEnemyTextSize") end
function ns.GetBarFillStyle()                 return Get("barFillStyle") end
function ns.GetNormalEnemyHeadingTextSize()   return Get("normalEnemyHeadingTextSize") end
function ns.GetNormalEnemyOrbStackTextSize()  return Get("normalEnemyOrbStackTextSize") end
function ns.GetNormalEnemyPowerBarEnabled()      return Get("normalEnemyPowerBarEnabled") end
function ns.GetNormalEnemyTextDisplayMode() return Get("normalEnemyTextDisplayMode") end
function ns.GetNormalEnemyHideFactionInfo()   return Get("normalEnemyHideFactionInfo") end
function ns.GetNormalEnemyHideClassArt()   return Get("normalEnemyHideClassArt") end
function ns.GetNormalEnemyTeamTargeting()   return Get("normalEnemyTeamTargeting") end
function ns.GetNormalEnemyStealthReveal()   return Get("normalEnemyStealthReveal") end
function ns.GetNormalEnemyRangeCheck()      return Get("normalEnemyRangeCheck") end
function ns.GetNormalEnemyRangeOpacity()    return Get("normalEnemyRangeOpacity") end
function ns.GetNormalEnemyFrameLocked()       return Get("normalEnemyFrameLocked") end
function ns.GetNormalEnemyPingEnabled()       return Get("normalEnemyPingEnabled") end
function ns.GetNormalEnemyPingLeftClick()     return ns.NormalizePingType(Get("normalEnemyPingLeftClick")) end
function ns.GetNormalEnemyPingRightClick()    return ns.NormalizePingType(Get("normalEnemyPingRightClick")) end
function ns.GetNormalEnemyPingZoomAmount()    return Get("normalEnemyPingZoomAmount") end
function ns.GetNormalEnemyFramePosition()     return Get("normalEnemyFramePosition") end

-------------------------------------------------------------------------------
-- Normal Friendly getters
-------------------------------------------------------------------------------
function ns.GetNormalFriendlyEnable()              return Get("normalFriendlyEnable") end
function ns.GetNormalFriendlyUseEnemySettings()    return Get("normalFriendlyUseEnemySettings") end
function ns.GetNormalFriendlyBarWidth()         return Get("normalFriendlyBarWidth") end
function ns.GetNormalFriendlyBarHeight()        return Get("normalFriendlyBarHeight") end
function ns.GetNormalFriendlyBarSpacing()       return Get("normalFriendlyBarSpacing") end
function ns.GetNormalFriendlyTextSize()         return Get("normalFriendlyTextSize") end
function ns.GetNormalFriendlyHeadingTextSize()  return Get("normalFriendlyHeadingTextSize") end
function ns.GetNormalFriendlyOrbStackTextSize() return Get("normalFriendlyOrbStackTextSize") end
function ns.GetNormalFriendlyPowerBarEnabled()     return Get("normalFriendlyPowerBarEnabled") end
function ns.GetNormalFriendlyTextDisplayMode() return Get("normalFriendlyTextDisplayMode") end
function ns.GetNormalFriendlyHideFactionInfo()  return Get("normalFriendlyHideFactionInfo") end
function ns.GetNormalFriendlyHideClassArt()  return Get("normalFriendlyHideClassArt") end
function ns.GetNormalFriendlyRangeCheck()     return Get("normalFriendlyRangeCheck") end
function ns.GetNormalFriendlyRangeOpacity()   return Get("normalFriendlyRangeOpacity") end
function ns.GetEpicEnemyRangeCheck()          return Get("epicEnemyRangeCheck") end
function ns.GetEpicEnemyRangeOpacity()        return Get("epicEnemyRangeOpacity") end
function ns.GetEpicFriendlyRangeCheck()       return Get("epicFriendlyRangeCheck") end
function ns.GetEpicFriendlyRangeOpacity()     return Get("epicFriendlyRangeOpacity") end
function ns.GetNormalFriendlyFrameLocked()      return Get("normalFriendlyFrameLocked") end
function ns.GetNormalFriendlyPingEnabled()      return Get("normalFriendlyPingEnabled") end
function ns.GetNormalFriendlyPingLeftClick()    return ns.NormalizePingType(Get("normalFriendlyPingLeftClick")) end
function ns.GetNormalFriendlyPingRightClick()   return ns.NormalizePingType(Get("normalFriendlyPingRightClick")) end
function ns.GetNormalFriendlyPingZoomAmount()   return Get("normalFriendlyPingZoomAmount") end
function ns.GetNormalFriendlyFramePosition()    return Get("normalFriendlyFramePosition") end

-------------------------------------------------------------------------------
-- Epic Enemy getters
-------------------------------------------------------------------------------
function ns.GetEpicEnemyEnable()            return Get("epicEnemyEnable") end
function ns.GetEpicEnemyBarWidth()          return Get("epicEnemyBarWidth") end
function ns.GetEpicEnemyBarHeight()         return Get("epicEnemyBarHeight") end
function ns.GetEpicEnemyBarSpacing()        return Get("epicEnemyBarSpacing") end
function ns.GetEpicEnemyTextSize()          return Get("epicEnemyTextSize") end
function ns.GetEpicEnemyHeadingTextSize()   return Get("epicEnemyHeadingTextSize") end
function ns.GetEpicEnemyOrbStackTextSize()  return Get("epicEnemyOrbStackTextSize") end
function ns.GetEpicEnemyPowerBarEnabled()      return Get("epicEnemyPowerBarEnabled") end
function ns.GetEpicEnemyTextDisplayMode() return Get("epicEnemyTextDisplayMode") end
function ns.GetEpicEnemyHideFactionInfo()   return Get("epicEnemyHideFactionInfo") end
function ns.GetEpicEnemyHideClassArt()   return Get("epicEnemyHideClassArt") end
function ns.GetEpicEnemyFrameLocked()       return Get("epicEnemyFrameLocked") end
function ns.GetEpicEnemyPingEnabled()       return Get("epicEnemyPingEnabled") end
function ns.GetEpicEnemyPingLeftClick()     return ns.NormalizePingType(Get("epicEnemyPingLeftClick")) end
function ns.GetEpicEnemyPingRightClick()    return ns.NormalizePingType(Get("epicEnemyPingRightClick")) end
function ns.GetEpicEnemyPingZoomAmount()    return Get("epicEnemyPingZoomAmount") end
function ns.GetEpicEnemyFramePosition()     return Get("epicEnemyFramePosition") end

-------------------------------------------------------------------------------
-- Epic Friendly getters
-------------------------------------------------------------------------------
function ns.GetEpicFriendlyEnable()              return Get("epicFriendlyEnable") end
function ns.GetEpicFriendlyUseEnemySettings()    return Get("epicFriendlyUseEnemySettings") end
function ns.GetEpicFriendlyBarWidth()         return Get("epicFriendlyBarWidth") end
function ns.GetEpicFriendlyBarHeight()        return Get("epicFriendlyBarHeight") end
function ns.GetEpicFriendlyBarSpacing()       return Get("epicFriendlyBarSpacing") end
function ns.GetEpicFriendlyTextSize()         return Get("epicFriendlyTextSize") end
function ns.GetEpicFriendlyHeadingTextSize()  return Get("epicFriendlyHeadingTextSize") end
function ns.GetEpicFriendlyOrbStackTextSize() return Get("epicFriendlyOrbStackTextSize") end
function ns.GetEpicFriendlyPowerBarEnabled()     return Get("epicFriendlyPowerBarEnabled") end
function ns.GetEpicFriendlyTextDisplayMode() return Get("epicFriendlyTextDisplayMode") end
function ns.GetEpicFriendlyHideFactionInfo()  return Get("epicFriendlyHideFactionInfo") end
function ns.GetEpicFriendlyHideClassArt()  return Get("epicFriendlyHideClassArt") end
function ns.GetEpicFriendlyFrameLocked()      return Get("epicFriendlyFrameLocked") end
function ns.GetEpicFriendlyPingEnabled()      return Get("epicFriendlyPingEnabled") end
function ns.GetEpicFriendlyPingLeftClick()    return ns.NormalizePingType(Get("epicFriendlyPingLeftClick")) end
function ns.GetEpicFriendlyPingRightClick()   return ns.NormalizePingType(Get("epicFriendlyPingRightClick")) end
function ns.GetEpicFriendlyPingZoomAmount()   return Get("epicFriendlyPingZoomAmount") end
function ns.GetEpicFriendlyFramePosition()    return Get("epicFriendlyFramePosition") end

-------------------------------------------------------------------------------
-- Setters
-------------------------------------------------------------------------------
-- Only the frame positions need one: every other setting is written straight
-- to ns.settings by the options widgets, and ns.settings *is* the active
-- profile table, so a passthrough setter added nothing. Positions are written
-- from the bar drag handlers via SETPOS, which has no widget behind it.
local function Set(key, value)
    if ns.settings then ns.settings[key] = value end
end

function ns.SetNormalEnemyFramePosition(v)    Set("normalEnemyFramePosition", v) end
function ns.SetNormalFriendlyFramePosition(v) Set("normalFriendlyFramePosition", v) end
function ns.SetEpicEnemyFramePosition(v)      Set("epicEnemyFramePosition", v) end
function ns.SetEpicFriendlyFramePosition(v)   Set("epicFriendlyFramePosition", v) end

-------------------------------------------------------------------------------
-- Global UI getters (shared — read from ns.db root, not profile)
-------------------------------------------------------------------------------

ns.CLASS_ART_STYLES = { "legacy", "sharp", "polygon", "polygon2" }  -- toggle order
-- Ordered segment tables for the 3-way toggles; shared so the four Appearance
-- sections cannot drift apart in wording or order.
ns.CLASS_ART_SEGMENTS = {
    { value = "legacy",  text = "Legacy"  },
    { value = "sharp",   text = "Sharp"   },
    { value = "polygon", text = "Polygon" },
    { value = "polygon2", text = "Polygon 2" },  -- polygon art, the original inset composition
}
ns.TEXT_DISPLAY_SEGMENTS = {
    { value = "name", text = "Name" },
    { value = "spec", text = "Spec" },
    { value = "both", text = "Name and Spec" },
}
function ns.GetClassArtStyle()
    if not ns.db then return ns.defaults.classArtStyle end
    return ns.db.classArtStyle or ns.defaults.classArtStyle
end
function ns.SetClassArtStyle(v) if ns.db then ns.db.classArtStyle = v end end

function ns.GetUISoundVolume()
    if not ns.db then return ns.defaults.uiSoundVolume end
    if ns.db.uiSoundVolume == nil then return ns.defaults.uiSoundVolume end
    return ns.db.uiSoundVolume
end
function ns.SetUISoundVolume(v) if ns.db then ns.db.uiSoundVolume = v end end

-- The UI sound, at the chosen volume.
--
-- PlaySoundFile takes NO volume argument -- there is no per-sound gain in the
-- API at all -- so the level cannot be applied at playback. Each step is a
-- separately baked file instead (Textures/remaster_sounds.sh), and this picks
-- one. That is also why the steps are coarse: every one costs a file.
--
-- Snapped to the nearest 20 so a slider dragged to 73 still finds a file rather
-- than silently playing nothing.
local lastUIPlay = {}   -- [name] = GetTime() it last started

-- Minimum gap between repeats of the SAME sound, in seconds.
--
-- These are set to just OVER each sample's own length on purpose. Now that
-- everything peaks at -1 dBFS, two instances of one sound landing together sum
-- to about +5 dBFS and clip outright -- there is no headroom left to absorb an
-- overlap, which is the price of the extra volume. A gap of one sample-length
-- makes self-overlap impossible, and at ~100-150ms it is far too short to feel
-- like a restriction.
--
-- TabSelect is the exception and cannot be fully protected: at 840ms a matching
-- gap would swallow legitimately fast tab changes, so it takes a compromise
-- 250ms. Clicking tabs faster than that can still stack. If that ever audibly
-- distorts, the fix is to SHORTEN the sample rather than to lower it -- its
-- level is limited by its length, not by the ceiling.
local UI_SOUND_GAP = {
    TabSelect   = 0.25,   -- 840ms sample; partial cover, see above
    PanelOpen   = 0.65,   -- 610ms
    PanelClose  = 0.45,   -- 400ms
    Toggle      = 0.16,   -- 145ms
    ButtonClick = 0.12,   -- 105ms
    SliderTick  = 0.10,   --  95ms
}
-- NOTE: this deliberately does NOT stop the previous instance.
--
-- It used to, to keep a sample from overlapping itself and summing past full
-- scale. But cutting a sound mid-body is a discontinuity even with a fade
-- argument, and that turned out to be audible as a click every time you bounced
-- between tabs -- trading a rare summing problem for a constant ticking one.
--
-- Overlap is handled by HEADROOM instead: the samples are levelled low enough
-- that two or three at once still sit under full scale, and a rate limit caps
-- how many can stack. Nothing is ever interrupted, so nothing can click.
function ns.PlayUISound(name)
    -- Nameless calls are the TAB CLICKS (main and sub tabs, volume slider
    -- audition). "TabSelect" must keep its literal quoted here: the release
    -- builder collects shipped ogg families by grepping PlayUISound literals,
    -- and this default is what deleted the family once already.
    name = name or "TabSelect"
    local v = ns.GetUISoundVolume() or 100
    v = math.floor(v / 20 + 0.5) * 20
    if v <= 0 then return end

    local gap = UI_SOUND_GAP[name]
    if gap then
        local now = GetTime()
        if lastUIPlay[name] and (now - lastUIPlay[name]) < gap then return end
        lastUIPlay[name] = now
    end

    PlaySoundFile(
        ("Interface\\AddOns\\BlitzFramesTargets\\Textures\\%s_%d.ogg"):format(name, v), "SFX")
end

function ns.GetDrinkWarningEnabled()
    if not ns.db then return ns.defaults.drinkWarningEnabled end
    if ns.db.drinkWarningEnabled == nil then return ns.defaults.drinkWarningEnabled end
    return ns.db.drinkWarningEnabled
end
function ns.GetFlaskWarningEnabled()
    if not ns.db then return ns.defaults.flaskWarningEnabled end
    if ns.db.flaskWarningEnabled == nil then return ns.defaults.flaskWarningEnabled end
    return ns.db.flaskWarningEnabled
end

local function GetShared(key)
    if not ns.db then return ns.defaults[key] end
    local v = ns.db[key]
    if v == nil then return ns.defaults[key] end
    return v
end
function ns.GetRaidObjectiveEnabled()       return GetShared("raidObjectiveEnabled") end
function ns.GetRaidObjectiveIconSize()      return GetShared("raidObjectiveIconSize") end
function ns.GetRaidObjectiveOffsetX()       return GetShared("raidObjectiveOffsetX") end
function ns.GetRaidObjectiveOffsetY()       return GetShared("raidObjectiveOffsetY") end
function ns.GetRaidObjectiveStackTextSize() return GetShared("raidObjectiveStackTextSize") end
function ns.GetRaidObjectiveAnchor()        return GetShared("raidObjectiveAnchor") end


-------------------------------------------------------------------------------
-- Global UI setters (shared — write to ns.db root, not profile)
-------------------------------------------------------------------------------

function ns.SetDrinkWarningEnabled(v) if ns.db then ns.db.drinkWarningEnabled = v end end
function ns.SetFlaskWarningEnabled(v) if ns.db then ns.db.flaskWarningEnabled = v end end

function ns.SetRaidObjectiveEnabled(v)       if ns.db then ns.db.raidObjectiveEnabled       = v end end
function ns.SetRaidObjectiveIconSize(v)      if ns.db then ns.db.raidObjectiveIconSize      = v end end
function ns.SetRaidObjectiveOffsetX(v)       if ns.db then ns.db.raidObjectiveOffsetX       = v end end
function ns.SetRaidObjectiveOffsetY(v)       if ns.db then ns.db.raidObjectiveOffsetY       = v end end
function ns.SetRaidObjectiveStackTextSize(v) if ns.db then ns.db.raidObjectiveStackTextSize = v end end
function ns.SetRaidObjectiveAnchor(v)        if ns.db then ns.db.raidObjectiveAnchor        = v end end

-------------------------------------------------------------------------------
-- Profile logic
-------------------------------------------------------------------------------
local PROFILE_EXPORT_VERSION = 1

local function StringifyNumericKeys(t)
    if type(t) ~= "table" then return t end
    local out = {}
    for k, v in pairs(t) do
        local key = (type(k) == "number") and ("n:" .. tostring(k)) or k
        out[key] = (type(v) == "table") and StringifyNumericKeys(v) or v
    end
    return out
end

local function RestoreNumericKeys(t)
    if type(t) ~= "table" then return t end
    local out = {}
    for k, v in pairs(t) do
        local key = k
        if type(k) == "string" and k:sub(1, 2) == "n:" then
            local n = tonumber(k:sub(3))
            if n then key = n end
        end
        out[key] = (type(v) == "table") and RestoreNumericKeys(v) or v
    end
    return out
end

-- Keys the addon has stopped using linger in every saved profile forever,
-- because nothing has ever deleted one. ActivateProfile fills in what is
-- MISSING against ns.defaults; this is the other half -- it drops what is no
-- longer THERE.
--
-- ns.defaults is the single source of truth for what a setting is, so anything
-- absent from it is by definition dead: no getter reads it, no UI writes it,
-- and it survives only because SavedVariables are written back verbatim.
-- Eight were cleared on 2026-08-17: all four *RespawnTextSize variants (folded
-- into OrbStackTextSize -- see the comment above that key), normalEnemyRangeYards,
-- normalFriendlyTeamTargeting (teamTargeting is wired for normalEnemy only; the
-- other three prefixes hardcode false in G), and the cardClickSound /
-- stealthRevealSound pair left behind when the sound work was removed.
--
-- Setting a field to nil during pairs() is explicitly allowed in Lua; adding
-- one is not, and this never does.
-- Keys that are NOT settings and must survive the prune. flagClock is the
-- shared flag-stack clock persisted across /reload (see RestoreFlagClock in
-- Core.lua): it has no default because it is state, not a setting, and pruning
-- it here -- which runs at load, before the match code restores it -- would
-- silently make the persistence do nothing.
local DB_RESERVED = { profiles = true, activeProfile = true, flagClock = true, orbPickups = true }

function ns.PruneStaleKeys()
    if not ns.db then return end
    for k in pairs(ns.db) do
        if not DB_RESERVED[k] and ns.defaults[k] == nil then
            ns.db[k] = nil
        end
    end
    if ns.db.profiles then
        for _, profile in pairs(ns.db.profiles) do
            if type(profile) == "table" then
                for k in pairs(profile) do
                    if ns.defaults[k] == nil then profile[k] = nil end
                end
            end
        end
    end
end

function ns.GetOrCreateProfile(name)
    if not name then return nil end
    if not ns.db.profiles then ns.db.profiles = {} end
    if not ns.db.profiles[name] then
        local p = {}
        for k, v in pairs(ns.defaults) do
            if not ns.SHARED_KEYS[k] then
                p[k] = (type(v) == "table") and ns.DeepCopy(v) or v
            end
        end
        ns.db.profiles[name] = p
    end
    return ns.db.profiles[name]
end

function ns.ActivateProfile(name)
    local profile = ns.GetOrCreateProfile(name)
    if not profile then return end
    for k, v in pairs(ns.defaults) do
        if not ns.SHARED_KEYS[k] and profile[k] == nil then
            profile[k] = (type(v) == "table") and ns.DeepCopy(v) or v
        end
    end
    ns.settings = profile
    ns.db.activeProfile = name
end

function ns.DeleteProfile(name)
    if not name or not ns.db.profiles then return end
    ns.db.profiles[name] = nil
    if ns.db.activeProfile == name then
        local nextName = next(ns.db.profiles)
        if nextName then
            ns.ActivateProfile(nextName)
        else
            ns.ActivateProfile("Default")
        end
    end
end

function ns.RenameProfile(oldName, newName)
    if not oldName or not newName or oldName == newName then return false end
    if not ns.db.profiles or not ns.db.profiles[oldName] then return false end
    if ns.db.profiles[newName] then return false, "exists" end
    ns.db.profiles[newName] = ns.db.profiles[oldName]
    ns.db.profiles[oldName] = nil
    if ns.db.activeProfile == oldName then
        ns.db.activeProfile = newName
        ns.settings = ns.db.profiles[newName]
    end
    return true
end

function ns.ExportProfile(name)
    local profile = ns.db and ns.db.profiles and ns.db.profiles[name]
    if not profile then return nil, "Profile not found" end
    if not C_EncodingUtil or not C_EncodingUtil.SerializeJSON then
        return nil, "C_EncodingUtil not available"
    end
    local exportData = {
        version    = PROFILE_EXPORT_VERSION,
        sourceName = name,
        timestamp  = time(),
        settings   = StringifyNumericKeys(ns.DeepCopy(profile)),
    }
    local ok, json = pcall(C_EncodingUtil.SerializeJSON, exportData)
    if not ok then return nil, "SerializeJSON: " .. tostring(json) end
    if not json then return nil, "SerializeJSON returned nil" end
    local compressed = C_EncodingUtil.CompressString(json)
    if not compressed then return nil, "Compress failed" end
    local encoded = C_EncodingUtil.EncodeBase64(compressed)
    if not encoded then return nil, "Encode failed" end
    return encoded
end

function ns.ImportProfileString(importString)
    if type(importString) ~= "string" or #importString == 0 then
        return false, "Empty string"
    end
    if not C_EncodingUtil or not C_EncodingUtil.DecodeBase64 then
        return false, "C_EncodingUtil not available"
    end
    importString = importString:gsub("^%s+", ""):gsub("%s+$", "")
    local decoded = C_EncodingUtil.DecodeBase64(importString)
    if not decoded then return false, "Invalid base64" end
    local decompressed = C_EncodingUtil.DecompressString(decoded)
    if not decompressed then return false, "Decompress failed" end
    local ok, data = pcall(C_EncodingUtil.DeserializeJSON, decompressed)
    if not ok or type(data) ~= "table" then return false, "Parse failed" end
    if type(data.version) ~= "number" or data.version > PROFILE_EXPORT_VERSION then
        return false, "Incompatible version"
    end
    if type(data.settings) ~= "table" then return false, "No settings in import" end
    data.settings = RestoreNumericKeys(data.settings)
    return true, data
end

function ns.ApplyImportToProfile(targetProfile, importedSettings)
    for k, v in pairs(importedSettings) do
        if ns.defaults[k] ~= nil and not ns.SHARED_KEYS[k] and type(v) == type(ns.defaults[k]) then
            targetProfile[k] = (type(v) == "table") and ns.DeepCopy(v) or v
        end
    end
end

-------------------------------------------------------------------------------
-- Stubs (implemented by the feature layer)
-------------------------------------------------------------------------------
ns.RebuildBars        = ns.RebuildBars        or function() end
ns.RefreshTestBars    = ns.RefreshTestBars    or function() end
ns.ApplyPositionToAll = ns.ApplyPositionToAll or function() end
