-- ============================================================================
-- MSUF_A2_Render.lua  Auras 3.0 Orchestrator
-- Replaces the 3357-line monolith.
--
-- Responsibilities:
--   - Dirty queue + coalesced flush (OnUpdate driver)
--   - RenderUnit: collect  commit  layout (single pass)
--   - Config resolution + caching (cold path, invalidated on DB change)
--   - Private aura anchor management
--   - Edit Mode mover integration
--   - Public API surface (RefreshAll, RefreshUnit, MarkDirty, etc.)
--
-- NOT in this file: icon creation/visuals (Icons.lua), event wiring (Events.lua)
-- ============================================================================

local addonName, ns = ...
ns = (rawget(_G, "MSUF_NS") or ns) or {}
-- =========================================================================
-- PERF LOCALS (Auras2 runtime)
--  - Reduce global table lookups in high-frequency aura pipelines.
--  - Secret-safe: localizing function references only (no value comparisons).
-- =========================================================================
local type, tostring, tonumber, select = type, tostring, tonumber, select
local pairs, ipairs, next = pairs, ipairs, next
local math_min, math_max, math_floor = math.min, math.max, math.floor
local string_format, string_match, string_sub = string.format, string.match, string.sub
local CreateFrame, GetTime = CreateFrame, GetTime
local UnitExists = UnitExists
local InCombatLockdown = InCombatLockdown
local C_Timer = C_Timer
local C_UnitAuras = C_UnitAuras
local C_Secrets = C_Secrets
local C_CurveUtil = C_CurveUtil

-- FastCall: no pcall in hot paths
local function MSUF_A2_FastCall(fn, ...)
    if fn == nil then return false end
    return true, fn(...)
end
_G.MSUF_A2_FastCall = MSUF_A2_FastCall

if ns.__MSUF_A2_CORE_LOADED then return end
ns.__MSUF_A2_CORE_LOADED = true

local API = ns.MSUF_Auras2
if type(API) ~= "table" then
    API = {}
    ns.MSUF_Auras2 = API
end
API.state = (type(API.state) == "table") and API.state or {}
API.perf  = (type(API.perf)  == "table") and API.perf  or {}

local A2_STATE = API.state


-- Hot locals

local type = type
local pairs = pairs
local CreateFrame = CreateFrame
local GetTime = GetTime
local UnitExists = UnitExists
local floor = math.floor
local max = math.max

-- Module references (late-bound)
local Collect  -- API.Collect
local Icons    -- API.Icons / API.Apply
local Store    -- API.Store (epoch only)
local _storeEpochs  -- Store._epochs (direct table, Phase 8)
local Filters  -- API.Filters


-- Combat / Edit Mode state (cheap cached checks)

local _inCombat = false
do
    local f = CreateFrame("Frame")
    f:RegisterEvent("PLAYER_REGEN_DISABLED")
    f:RegisterEvent("PLAYER_REGEN_ENABLED")
    f:SetScript("OnEvent", function(_, event)
        _inCombat = (event == "PLAYER_REGEN_DISABLED")
    end)
    if InCombatLockdown and InCombatLockdown() then _inCombat = true end
end

local _editModeActive = false
local _editModeCheckAt = 0

local function IsEditModeActive()
    if _inCombat then return false end
    local now = GetTime()
    if now < _editModeCheckAt then return _editModeActive end
    _editModeCheckAt = now + 0.10

    local active = false
    local st = rawget(_G, "MSUF_EditState")
    if type(st) == "table" and st.active == true then active = true end
    if not active and rawget(_G, "MSUF_UnitEditModeActive") == true then active = true end
    if not active then
        local fn = rawget(_G, "MSUF_IsInEditMode")
        if type(fn) == "function" then
            -- Direct call (no FastCall overhead). fn() returns boolean.
            local v = fn()
            if v == true then active = true end
        end
    end

    _editModeActive = active
    return active
end

API.IsEditModeActive = IsEditModeActive

-- Force-set the cached edit-mode state (called by Events.OnAnyEditModeChanged
-- to avoid the 100ms stale-cache race that re-enables previews after exit).
local function ForceSetEditModeActive(active)
    _editModeActive = (active == true)
    _editModeCheckAt = GetTime() + 0.10
end
API.ForceSetEditModeActive = ForceSetEditModeActive


-- DB access + config cache

local MSUF_DB

-- DB defaults (copied from original Render  must stay identical for migration compat)
local A2_AURAS2_DEFAULTS = { enabled=true, showTarget=true, showFocus=true, showBoss=true, showPlayer=false }
local A2_SHARED_DEFAULTS = {
    showBuffs=true, showDebuffs=true, showTooltip=true,
    showCooldownSwipe=true, showCooldownText=true, cooldownSwipeDarkenOnLoss=false,
    showInEditMode=true, showStackCount=true,
    stackCountAnchor="TOPRIGHT", masqueEnabled=false, masqueHideBorder=false,
    layoutMode="SEPARATE", buffDebuffAnchor="STACKED", splitSpacing=0,
    highlightPrivatePlayerAuras=false, highlightOwnBuffs=false, highlightOwnDebuffs=false,
    iconSize=26, spacing=2, perRow=12, maxIcons=12,
    growth="RIGHT", rowWrap="DOWN",
    offsetX=0, offsetY=6, buffOffsetY=30,
    stackTextSize=14, cooldownTextSize=14, bossEditTogether=true,
    showPrivateAurasPlayer=true, showPrivateAurasFocus=true, showPrivateAurasBoss=true,
    privateAuraMaxPlayer=4, privateAuraMaxOther=4,
}

local A2_GROWTH_OK = {RIGHT=true,LEFT=true,UP=true,DOWN=true}
local A2_ROWWRAP_OK = {DOWN=true,UP=true}
-- LayoutMode (SINGLE vs SEPARATE) permanently deprecated — runtime always uses separate groups.
local A2_STACKANCHOR_OK = {TOPRIGHT=true,TOPLEFT=true,BOTTOMRIGHT=true,BOTTOMLEFT=true}

local function DefaultKV(t, d) for k, v in pairs(d) do if t[k] == nil then t[k] = v end end end
local function Clamp(v, def, lo, hi) v = tonumber(v); if not v then v = def end; if lo and v < lo then v = lo end; if hi and v > hi then v = hi end; return v end

local function EnsureDB()
    local gdb = _G.MSUF_DB
    if type(gdb) ~= "table" then _G.MSUF_DB = {}; gdb = _G.MSUF_DB end
    if type(_G.EnsureDB) == "function" then MSUF_A2_FastCall(_G.EnsureDB) end
    MSUF_DB = _G.MSUF_DB
    if type(MSUF_DB) ~= "table" then return nil end

    if type(MSUF_DB.auras2) ~= "table" then MSUF_DB.auras2 = {} end
    local a2 = MSUF_DB.auras2
    DefaultKV(a2, A2_AURAS2_DEFAULTS)

    if type(a2.shared) ~= "table" then a2.shared = {} end
    local s = a2.shared
    DefaultKV(s, A2_SHARED_DEFAULTS)

    -- Hard-disable legacy layoutMode behavior (SINGLE/SEPARATE logic).
    -- We keep the stored value for profile compatibility, but the runtime
    -- always uses separate Buff/Debuff containers positioned by movers.
    -- (Fixes login/reload drift caused by growth-direction anchorpoints.)
    s.layoutMode = "SEPARATE"

    if s.maxBuffs == nil then s.maxBuffs = s.maxIcons or 12 end
    if s.maxDebuffs == nil then s.maxDebuffs = s.maxIcons or 12 end
    s.showPrivateAurasTarget = false
    s.privateAuraMaxPlayer = Clamp(s.privateAuraMaxPlayer, 4, 0, 12)
    s.privateAuraMaxOther  = Clamp(s.privateAuraMaxOther,  4, 0, 12)
    -- Per-type growth: sanitize invalid values (nil = fall back to s.growth)
    if s.buffGrowth ~= nil and not A2_GROWTH_OK[s.buffGrowth] then s.buffGrowth = nil end
    if s.debuffGrowth ~= nil and not A2_GROWTH_OK[s.debuffGrowth] then s.debuffGrowth = nil end
    if s.privateGrowth ~= nil and not A2_GROWTH_OK[s.privateGrowth] then s.privateGrowth = nil end

    -- Filters
    Filters = API.Filters
    if Filters and Filters.EnsureSharedFilters then
        Filters.EnsureSharedFilters(a2, s)
    end

    -- Per-unit config
    if type(a2.perUnit) ~= "table" then a2.perUnit = {} end

    -- Rebuild DB cache
    local DB = API.DB
    if DB and DB.RebuildCache then DB.RebuildCache(a2, s) end

    return a2, s
end

local _ensureReady = false
local _lastDB = nil

local function GetAuras2DB()
    local gdb = _G.MSUF_DB
    if _ensureReady and gdb == _lastDB and type(gdb) == "table" then
        local a2 = gdb.auras2
        local s = a2 and a2.shared
        if a2 and s then
            MSUF_DB = gdb
            return a2, s
        end
    end
    local a2, s = EnsureDB()
    _ensureReady = true
    _lastDB = MSUF_DB
    return a2, s
end

-- Config invalidation
local _configGen = 0

local function InvalidateDB()
    _ensureReady = false
    _lastDB = nil
    _configGen = _configGen + 1
    _modulesBound = false  -- re-bind modules on next RenderUnit (picks up late-loaded modules)
    if API.DB and API.DB.InvalidateCache then API.DB.InvalidateCache() end
    if API.Colors and API.Colors.InvalidateCache then API.Colors.InvalidateCache() end
    Icons = API.Icons or API.Apply
    if Icons and Icons.BumpConfigGen then Icons.BumpConfigGen() end
    -- Schedule refresh
    if API.MarkAllDirty then API.MarkAllDirty(0) end
end

API.InvalidateDB = InvalidateDB
API.EnsureDB = EnsureDB
API.GetDB = GetAuras2DB

if type(_G.MSUF_A2_InvalidateDB) ~= "function" then
    _G.MSUF_A2_InvalidateDB = function() return InvalidateDB() end
end

-- Bind EnsureDB into DB module
if API.DB and API.DB.BindEnsure then
    API.DB.BindEnsure(EnsureDB)
end


-- Per-unit state

A2_STATE.aurasByUnit = (type(A2_STATE.aurasByUnit) == "table") and A2_STATE.aurasByUnit or {}
local AurasByUnit = A2_STATE.aurasByUnit

local _IS_BOSS = { boss1=true, boss2=true, boss3=true, boss4=true, boss5=true }

-- Phase 8: pre-computed fallback names (eliminates string concat in hot path)
local _FRAME_FALLBACK = {
    player = "MSUF_player", target = "MSUF_target", focus = "MSUF_focus",
    boss1 = "MSUF_boss1", boss2 = "MSUF_boss2", boss3 = "MSUF_boss3",
    boss4 = "MSUF_boss4", boss5 = "MSUF_boss5",
}

local function FindUnitFrame(unit)
    local uf = _G.MSUF_UnitFrames
    if type(uf) == "table" and uf[unit] then return uf[unit] end
    local key = _FRAME_FALLBACK[unit]
    return key and _G[key] or nil
end

API.FindUnitFrame = FindUnitFrame

local function UnitEnabled(a2, unit)
    if not a2 or a2.enabled ~= true then return false end
    if unit == "player" then return a2.showPlayer == true end
    if unit == "target" then return a2.showTarget == true end
    if unit == "focus" then return a2.showFocus == true end
    if _IS_BOSS[unit] then return a2.showBoss == true end
    return false
end


-- Dirty queue + flush driver

local DirtyA, DirtyB = {}, {}
local DirtyList = DirtyA
local DirtyCount = 0
local DirtyMark = {}
local DirtyGen = 1
local FlushScheduled = false
local _isFlushing = false
local _dirtyWhileFlushing = false

local function DirtyAdd(unit)
    if not unit then return end
    if DirtyMark[unit] == DirtyGen then return end
    DirtyMark[unit] = DirtyGen
    DirtyCount = DirtyCount + 1
    DirtyList[DirtyCount] = unit
    if _isFlushing then _dirtyWhileFlushing = true end
end

local function DirtySwap()
    local list, count = DirtyList, DirtyCount
    DirtyList = (DirtyList == DirtyA) and DirtyB or DirtyA
    DirtyCount = 0
    DirtyGen = DirtyGen + 1
    return list, count
end

local Flush -- forward decl

local _flushDriver = CreateFrame("Frame")
_flushDriver:Hide()
local _flushNextAt = nil
local _flushDriverActive = false   -- PERF: Boolean gate replaces GetScript() C API call

local function StopFlushDriver()
    _flushNextAt = nil
    _flushDriverActive = false
    _flushDriver:SetScript("OnUpdate", nil)
    _flushDriver:Hide()
end

local function FlushDriverOnUpdate()
    local at = _flushNextAt
    if not at then StopFlushDriver(); return end
    local now = GetTime()
    if now >= at then
        _flushNextAt = nil
        if Flush then Flush() end
    end
end

local function ScheduleFlush(delay)
    if not delay or delay < 0 then delay = 0 end

    if _flushDriverActive then
        -- Driver already running. Only update target time if new delay is sooner.
        if delay == 0 then
            _flushNextAt = 0  -- immediate (next OnUpdate)
        else
            local at = GetTime() + delay
            if not _flushNextAt or at < _flushNextAt then
                _flushNextAt = at
            end
        end
        return
    end

    -- Start driver
    _flushNextAt = (delay == 0) and 0 or (GetTime() + delay)
    _flushDriverActive = true
    _flushDriver:Show()
    _flushDriver:SetScript("OnUpdate", FlushDriverOnUpdate)
end


-- MarkDirty (public entry point for scheduling unit updates)


local function MarkDirty(unit, delay)
    if not unit then return end

    -- Early dedupe
    if DirtyMark[unit] == DirtyGen then
        -- Unit already queued. Only bring flush forward if caller wants immediate
        -- and driver is actually running (safe guard against stopped-driver edge case).
        if delay and delay == 0 and _flushDriverActive and _flushNextAt and _flushNextAt > 0 then
            _flushNextAt = 0
        end
        return
    end

    -- PERF: Combat fast-path — skip DB validation and edit mode check.
    -- In combat, edit mode is impossible and DB doesn't change.
    if _inCombat then
        if UnitExists and not UnitExists(unit) then return end
        DirtyAdd(unit)
        if not delay or delay < 0 then delay = 0 end
        FlushScheduled = true
        ScheduleFlush(delay)
        return
    end

    -- Out-of-combat: full validation (DB + edit mode preview)
    local a2, shared = GetAuras2DB()
    local allowPreview = (shared and shared.showInEditMode == true and IsEditModeActive())

    if not allowPreview then
        if UnitExists and not UnitExists(unit) then return end
    end

    DirtyAdd(unit)
    if not delay or delay < 0 then delay = 0 end
    FlushScheduled = true
    ScheduleFlush(delay)
end


-- EnsureAttached: create per-unit anchor + container frames


local function EnsureAttached(unit)
    local entry = AurasByUnit[unit]
    local frame = FindUnitFrame(unit)
    if not frame then return nil end

    if entry and entry.frame == frame and entry.anchor and entry.anchor:GetParent() then
        return entry
    end

    -- Clean up old entry
    if entry then
        if entry.anchor then entry.anchor:Hide() end
    end

    local anchor = CreateFrame("Frame", nil, UIParent)
    anchor:SetSize(1, 1)
    anchor:SetFrameStrata("MEDIUM")
    anchor:SetFrameLevel(50)

    local debuffs = CreateFrame("Frame", nil, anchor)
    debuffs:SetSize(1, 1)
    debuffs:SetPoint("BOTTOMLEFT", anchor, "BOTTOMLEFT", 0, 0)

    local buffs = CreateFrame("Frame", nil, anchor)
    buffs:SetSize(1, 1)
    buffs:SetPoint("BOTTOMLEFT", anchor, "BOTTOMLEFT", 0, 30)

    local mixed = CreateFrame("Frame", nil, anchor)
    mixed:SetSize(1, 1)
    mixed:SetPoint("BOTTOMLEFT", anchor, "BOTTOMLEFT", 0, 0)

    local private = CreateFrame("Frame", nil, anchor)
    private:SetSize(1, 1)
    private:SetPoint("BOTTOMLEFT", buffs, "TOPLEFT", 0, 0)
    private:Hide()

    -- Sync visibility with parent unitframe (direct calls - no FastCall overhead)
    frame:HookScript("OnShow", function()
        if anchor then anchor:Show() end
        if API.MarkAllDirty then API.MarkAllDirty(0) end
    end)
    frame:HookScript("OnHide", function()
        if anchor then anchor:Hide() end
    end)

    entry = {
        unit = unit,
        frame = frame,
        anchor = anchor,
        debuffs = debuffs,
        buffs = buffs,
        mixed = mixed,
        private = private,
        -- Reusable list buffers (zero alloc on steady state)
        _buffList = {},
        _debuffList = {},
        -- Last rendered state for diff
        _lastBuffAids = {},
        _lastDebuffAids = {},
        _lastBuffCount = 0,
        _lastDebuffCount = 0,
        _lastEpoch = -1,
        _lastConfigGen = -1,
        _lastAnchorGen = -1,
        _lastPrivateGen = -1,
    }
    AurasByUnit[unit] = entry
    return entry
end


-- Config resolution (pre-computed per InvalidateDB, not per render)


local function ResolveUnitConfig(unit, a2, shared)
    local iconSize = shared.iconSize or 26
    local spacing = shared.spacing or 2
    local perRow = shared.perRow or 12
    local maxBuffs = shared.maxBuffs or shared.maxIcons or 12
    local maxDebuffs = shared.maxDebuffs or shared.maxIcons or 12
    local growth = shared.growth or "RIGHT"
    local rowWrap = shared.rowWrap or "DOWN"
    local stackCountAnchor = shared.stackCountAnchor or "TOPRIGHT"
    -- Sort order (caps level — per-unit overridable via layoutShared)
    -- Falls back to shared.filters.sortOrder for backward compatibility.
    local sf = shared.filters
    local sortOrder = shared.sortOrder
    if type(sortOrder) ~= "number" then
        sortOrder = (sf and type(sf.sortOrder) == "number") and sf.sortOrder or 0
    end
    local sortReverse = shared.sortReverse
    if sortReverse == nil then
        sortReverse = (sf and sf.sortReverse == true) and true or false
    end
    -- Per-type growth (nil = fall back to growth)
    local buffGrowth = shared.buffGrowth
    local debuffGrowth = shared.debuffGrowth
    local privateGrowth = shared.privateGrowth
    -- Per-type row wrap (nil = fall back to rowWrap)
    local buffRowWrap = shared.buffRowWrap
    local debuffRowWrap = shared.debuffRowWrap

    -- Per-unit overrides
    local pu = a2.perUnit and a2.perUnit[unit]
    if pu and pu.overrideSharedLayout == true and type(pu.layoutShared) == "table" then
        local ls = pu.layoutShared
        if type(ls.perRow) == "number" and ls.perRow >= 1 then perRow = ls.perRow end
        if type(ls.maxBuffs) == "number" then maxBuffs = ls.maxBuffs end
        if type(ls.maxDebuffs) == "number" then maxDebuffs = ls.maxDebuffs end
        if ls.growth and A2_GROWTH_OK[ls.growth] then growth = ls.growth end
        if ls.buffGrowth and A2_GROWTH_OK[ls.buffGrowth] then buffGrowth = ls.buffGrowth end
        if ls.debuffGrowth and A2_GROWTH_OK[ls.debuffGrowth] then debuffGrowth = ls.debuffGrowth end
        if ls.privateGrowth and A2_GROWTH_OK[ls.privateGrowth] then privateGrowth = ls.privateGrowth end
        if ls.rowWrap and A2_ROWWRAP_OK[ls.rowWrap] then rowWrap = ls.rowWrap end
        if ls.buffRowWrap and A2_ROWWRAP_OK[ls.buffRowWrap] then buffRowWrap = ls.buffRowWrap end
        if ls.debuffRowWrap and A2_ROWWRAP_OK[ls.debuffRowWrap] then debuffRowWrap = ls.debuffRowWrap end
        if ls.stackCountAnchor and A2_STACKANCHOR_OK[ls.stackCountAnchor] then stackCountAnchor = ls.stackCountAnchor end
        if type(ls.sortOrder) == "number" then sortOrder = ls.sortOrder end
        if ls.sortReverse ~= nil then sortReverse = (ls.sortReverse == true) end
    end
    -- Resolve per-type fallback: nil → growth
    if not buffGrowth or not A2_GROWTH_OK[buffGrowth] then buffGrowth = growth end
    if not debuffGrowth or not A2_GROWTH_OK[debuffGrowth] then debuffGrowth = growth end
    if not privateGrowth or not A2_GROWTH_OK[privateGrowth] then privateGrowth = growth end
    -- Resolve per-type rowWrap fallback: nil → rowWrap
    if not buffRowWrap or not A2_ROWWRAP_OK[buffRowWrap] then buffRowWrap = rowWrap end
    if not debuffRowWrap or not A2_ROWWRAP_OK[debuffRowWrap] then debuffRowWrap = rowWrap end

    if pu and pu.overrideLayout == true and type(pu.layout) == "table" then
        local lay = pu.layout
        if type(lay.iconSize) == "number" and lay.iconSize > 1 then iconSize = lay.iconSize end
        if type(lay.spacing) == "number" and lay.spacing >= 0 then spacing = lay.spacing end
    end


-- Group-specific sizing (Edit Mode controls)
local buffIconSize = iconSize
local debuffIconSize = iconSize
local privateIconSize = iconSize

-- Shared defaults (if present)
local bsz = shared.buffGroupIconSize
if type(bsz) == "number" and bsz > 1 then buffIconSize = bsz end
local dsz = shared.debuffGroupIconSize
if type(dsz) == "number" and dsz > 1 then debuffIconSize = dsz end
local psz = shared.privateSize
if type(psz) == "number" and psz > 1 then privateIconSize = psz end

-- Per-unit overrides (only when overrideLayout is enabled)
if pu and pu.overrideLayout == true and type(pu.layout) == "table" then
    local lay = pu.layout
    bsz = lay.buffGroupIconSize
    if type(bsz) == "number" and bsz > 1 then buffIconSize = bsz end
    dsz = lay.debuffGroupIconSize
    if type(dsz) == "number" and dsz > 1 then debuffIconSize = dsz end
    psz = lay.privateSize
    if type(psz) == "number" and psz > 1 then privateIconSize = psz end
end

    return iconSize, spacing, perRow, maxBuffs, maxDebuffs, growth, buffGrowth, debuffGrowth, privateGrowth, rowWrap, buffRowWrap, debuffRowWrap, stackCountAnchor, buffIconSize, debuffIconSize, privateIconSize, sortOrder, sortReverse
end


-- Private Auras (Blizzard-rendered)


local function PrivateAurasSupported()
    return C_UnitAuras
        and type(C_UnitAuras.AddPrivateAuraAnchor) == "function"
        and type(C_UnitAuras.RemovePrivateAuraAnchor) == "function"
end

local function PrivateClear(entry)
    if not entry then return end
    local ids = entry._privateAnchorIDs
    if type(ids) == "table" and C_UnitAuras then
        local removeFn = C_UnitAuras.RemovePrivateAuraAnchor
        if removeFn then
            for i = 1, #ids do
                if ids[i] then removeFn(ids[i]) end
            end
        end
    end
    entry._privateAnchorIDs = nil
    entry._privUnit = nil
    entry._privToken = nil
    entry._privSize = nil
    entry._privSpacing = nil
    entry._privMax = nil
    entry._privGrowth = nil
    local slots = entry._privateSlots
    if type(slots) == "table" then
        for i = 1, #slots do if slots[i] then slots[i]:Hide() end end
    end
    if entry.private then entry.private:Hide() end
end

local function PrivateRebuild(entry, shared, privateIconSize, spacing, privateGrowth)
    if not entry or not shared then return end
    local unit = entry.unit

    local enabled = false
    if unit == "player" then enabled = (shared.showPrivateAurasPlayer == true)
    elseif unit == "focus" then enabled = (shared.showPrivateAurasFocus == true)
    elseif _IS_BOSS[unit] then enabled = (shared.showPrivateAurasBoss == true)
    end

    if not enabled or not PrivateAurasSupported() then
        PrivateClear(entry)
        return
    end

    local maxN = (unit == "player") and (shared.privateAuraMaxPlayer or 4) or (shared.privateAuraMaxOther or 4)
    maxN = Clamp(maxN, 4, 0, 12)
    if maxN == 0 then PrivateClear(entry); return end

    -- Effective unit token (focusplayer if focus IS player)
    local effectiveToken = unit
    if unit ~= "player" and UnitIsUnit and UnitIsUnit(unit, "player") then
        effectiveToken = "player"
    end

    -- Resolve growth direction
    privateGrowth = (privateGrowth and A2_GROWTH_OK[privateGrowth]) and privateGrowth or "RIGHT"
    local vertical = (privateGrowth == "UP" or privateGrowth == "DOWN")

    -- PERF: Zero-alloc diff check (replaces 6× string concat + comparison)
    if entry._privUnit == unit
       and entry._privToken == effectiveToken
       and entry._privSize == privateIconSize
       and entry._privSpacing == spacing
       and entry._privMax == maxN
       and entry._privGrowth == privateGrowth
       and type(entry._privateAnchorIDs) == "table"
    then
        if entry.private then entry.private:Show() end
        return
    end

    PrivateClear(entry)
    if not entry.private then return end

    -- Store fields for next diff check
    entry._privUnit = unit
    entry._privToken = effectiveToken
    entry._privSize = privateIconSize
    entry._privSpacing = spacing
    entry._privMax = maxN
    entry._privGrowth = privateGrowth

    local slots = entry._privateSlots or {}
    entry._privateSlots = slots
    local step = privateIconSize + spacing
    if step <= 0 then step = 28 end

    entry.private:Show()
    entry._privateAnchorIDs = {}

    -- Container sizing: horizontal = wide row, vertical = tall column
    if vertical then
        entry.private:SetSize(privateIconSize, (maxN * step) - spacing)
    else
        entry.private:SetSize((maxN * step) - spacing, privateIconSize)
    end

    -- Direction + anchor (same logic as Icons.LayoutIcons)
    local anchorX, anchorY = "LEFT", "BOTTOM"
    local dx, dy = 1, 0
    if vertical then
        dx, dy = 0, 1
        if privateGrowth == "DOWN" then
            anchorY = "TOP"
            dy = -1
        end
    else
        if privateGrowth == "LEFT" then
            anchorX = "RIGHT"
            dx = -1
        end
    end
    local anchor = anchorY .. anchorX

    -- Reuse args table (avoid allocation per slot)
    local args = entry._privateArgs
    if not args then
        args = {
            unitToken = effectiveToken,
            auraIndex = 1,
            parent = nil,
            showCountdownFrame = false,
            showCountdownNumbers = false,
            iconInfo = {
                iconWidth = privateIconSize,
                iconHeight = privateIconSize,
                iconAnchor = {
                    point = "CENTER", relativeTo = nil, relativePoint = "CENTER",
                    offsetX = 0, offsetY = 0,
                },
            },
        }
        entry._privateArgs = args
    end

    for i = 1, maxN do
        local slot = slots[i]
        if not slot then
            slot = CreateFrame("Frame", nil, entry.private)
            slot:SetFrameStrata("MEDIUM")
            slot:SetFrameLevel(60)
            slots[i] = slot
        end
        slot:ClearAllPoints()
        local off = (i - 1) * step
        slot:SetPoint(anchor, entry.private, anchor, off * dx, off * dy)
        slot:SetSize(privateIconSize, privateIconSize)
        slot:Show()

        -- Update reused args
        args.unitToken = effectiveToken
        args.auraIndex = i
        args.parent = slot
        args.showCountdownFrame = (shared.showCooldownSwipe == true)
        args.showCountdownNumbers = (shared.showCooldownText == true)
        args.iconInfo.iconWidth = privateIconSize
        args.iconInfo.iconHeight = privateIconSize
        args.iconInfo.iconAnchor.relativeTo = slot

        local ok, anchorID = true, C_UnitAuras.AddPrivateAuraAnchor(args)
        if ok and anchorID then
            entry._privateAnchorIDs[#entry._privateAnchorIDs + 1] = anchorID
        end
    end
end


-- UpdateAnchor (position the aura container relative to unitframe)


-- File-scope helpers for UpdateAnchor (zero closure alloc) 

local _mathFloor = math.floor

-- Read numeric offset from shared/perUnit.layout, clamped to [-2000,2000]
local function ReadOffset(shared, lay, key, def)
    local v = shared[key]
    if lay and lay[key] ~= nil then v = lay[key] end
    v = tonumber(v)
    if v == nil then return def end
    if v < -2000 then return -2000 end
    if v >  2000 then return  2000 end
    return _mathFloor(v + 0.5)
end

-- Position a mover to match its container's anchor point
local function MirrorMover(mover, container, fallbackAnchor, w, h)
    if not mover then return end
    mover:ClearAllPoints()
    if container and container:GetNumPoints() > 0 then
        local p, rel, rp, ox, oy = container:GetPoint(1)
        if p and rel and rp then
            mover:SetPoint(p, rel, rp, ox or 0, oy or 0)
        else
            mover:SetPoint("BOTTOMLEFT", fallbackAnchor, "BOTTOMLEFT", 0, 0)
        end
    else
        mover:SetPoint("BOTTOMLEFT", fallbackAnchor, "BOTTOMLEFT", 0, 0)
    end
    if w and h then mover:SetSize(w, h) end
end

local function UpdateAnchor(entry, shared, isEditActive)
    if not entry or not entry.anchor or not entry.frame or not shared then return end

    local unit = entry.unit
    local offX = shared.offsetX or 0
    local offY = shared.offsetY or 0

    -- Per-unit layout overrides
    local a2 = MSUF_DB and MSUF_DB.auras2
    local pu = a2 and a2.perUnit and a2.perUnit[unit]
    local lay = (pu and pu.overrideLayout == true and type(pu.layout) == "table") and pu.layout or nil

    if lay then
        if type(lay.offsetX) == "number" then offX = lay.offsetX end
        if type(lay.offsetY) == "number" then offY = lay.offsetY end
    end

    -- Sizing
    local iconSize = shared.iconSize or 26
    local spacing = shared.spacing or 2
    local perRow = shared.perRow or 12
    if lay then
        if type(lay.iconSize) == "number" and lay.iconSize > 1 then iconSize = lay.iconSize end
        if type(lay.spacing) == "number" and lay.spacing >= 0 then spacing = lay.spacing end
    end

-- Group-specific sizing (Edit Mode controls)
local buffIconSize = iconSize
local debuffIconSize = iconSize
local privateIconSize = iconSize

local bsz = shared.buffGroupIconSize
if type(bsz) == "number" and bsz > 1 then buffIconSize = bsz end
local dsz = shared.debuffGroupIconSize
if type(dsz) == "number" and dsz > 1 then debuffIconSize = dsz end
local psz = shared.privateSize
if type(psz) == "number" and psz > 1 then privateIconSize = psz end

if lay then
    bsz = lay.buffGroupIconSize
    if type(bsz) == "number" and bsz > 1 then buffIconSize = bsz end
    dsz = lay.debuffGroupIconSize
    if type(dsz) == "number" and dsz > 1 then debuffIconSize = dsz end
    psz = lay.privateSize
    if type(psz) == "number" and psz > 1 then privateIconSize = psz end
end


    -- Per-group offsets (drag movers write to these) 
    local buffDX   = ReadOffset(shared, lay, "buffGroupOffsetX",   0)
    local buffDY   = ReadOffset(shared, lay, "buffGroupOffsetY",   0)
    local debuffDX = ReadOffset(shared, lay, "debuffGroupOffsetX", 0)
    local debuffDY = ReadOffset(shared, lay, "debuffGroupOffsetY", 0)
    local privOffX = ReadOffset(shared, lay, "privateOffsetX",     0)
    local privOffY = ReadOffset(shared, lay, "privateOffsetY",     0)

    -- Position anchor 
    local anchor = entry.anchor
    anchor:ClearAllPoints()
    anchor:SetPoint("BOTTOMLEFT", entry.frame, "TOPLEFT", offX, offY)

    -- Position containers (always separate groups, always driven by per-group offsets)
    entry.debuffs:ClearAllPoints()
    entry.debuffs:SetPoint("BOTTOMLEFT", anchor, "BOTTOMLEFT", debuffDX, debuffDY)

    entry.buffs:ClearAllPoints()
    entry.buffs:SetPoint("BOTTOMLEFT", anchor, "BOTTOMLEFT", buffDX, buffDY)

    -- Mixed container is legacy; keep hidden so it can't influence layout/anchors.
    if entry.mixed then
        entry.mixed:ClearAllPoints()
        entry.mixed:SetPoint("BOTTOMLEFT", anchor, "BOTTOMLEFT", 0, 0)
        entry.mixed:Hide()
    end

    -- Private
    if entry.private then
        entry.private:ClearAllPoints()
        entry.private:SetPoint("BOTTOMLEFT", anchor, "BOTTOMLEFT", privOffX, privOffY)
    end

    -- Position edit movers (mirror containers) 
    if isEditActive then
        local stepB = buffIconSize + spacing
        local stepD = debuffIconSize + spacing
        local stepP = privateIconSize + spacing
        local maxBuff = (shared.maxBuffs or shared.maxIcons or 12)
        local maxDebuff = (shared.maxDebuffs or shared.maxIcons or 12)
        local cols = (maxBuff < perRow) and maxBuff or perRow
        local dcols = (maxDebuff < perRow) and maxDebuff or perRow
        local headerH = 20

        MirrorMover(entry.editMoverBuff,    entry.buffs,   anchor, cols * stepB,  buffIconSize + headerH)
        MirrorMover(entry.editMoverDebuff,  entry.debuffs, anchor, dcols * stepD, debuffIconSize + headerH)
        -- Private auras are player-only; skip mover for target.
        if unit ~= "target" then
            local privVertical = (privateGrowth == "UP" or privateGrowth == "DOWN")
            local privW, privH
            if privVertical then
                privW = privateIconSize
                privH = (4 * stepP) + headerH
            else
                privW = 4 * stepP
                privH = privateIconSize + headerH
            end
            MirrorMover(entry.editMoverPrivate, entry.private, anchor, privW, privH)
        end
    end
end

-- PERF: File-scope helper (avoids closure allocation inside RenderUnit)
local function HasImportantToggle(f)
    if type(f) ~= "table" then return false end
    if f.onlyImportantAuras == true then return true end -- legacy
    local b = f.buffs
    if type(b) == "table" and b.onlyImportant == true then return true end
    local d = f.debuffs
    if type(d) == "table" and d.onlyImportant == true then return true end
    return false
end

-- Pre-cached boss unit strings (avoid "boss"..i concatenation in loops)
local _BOSS_UNITS = { "boss1", "boss2", "boss3", "boss4", "boss5" }

-- Module binding flag (set once, reset only on hard reload)
local _modulesBound = false


-- RenderUnit  the core render loop (single pass, clean)


local function RenderUnit(entry)
    if not entry then return end

    -- Bind modules once
    if not _modulesBound then
        Collect = API.Collect
        Icons   = API.Icons or API.Apply
        Store   = API.Store
        _storeEpochs = Store and Store._epochs
        Filters = API.Filters
        if Collect and Icons then _modulesBound = true end
    end

    if not Collect or not Icons then return end

    -- Single gen read for entire function (value cannot change mid-call)
    local gen = _configGen

    -- PERF: Update scan limits once per configGen (reduces C API calls)
    -- GetAuras2DB result is reused by the main render path below.
    local a2, shared
    if Collect._scanLimitsGen ~= gen then
        Collect._scanLimitsGen = gen
        a2, shared = GetAuras2DB()
        if shared and Collect.SetScanLimits then
            -- Start with shared values
            local maxB = shared.maxBuffs or 12
            local maxD = shared.maxDebuffs or 12
            -- Check perUnit overrides for higher values
            if a2 and a2.perUnit then
                for _, pu in pairs(a2.perUnit) do
                    if pu.overrideSharedLayout and pu.layoutShared then
                        local ls = pu.layoutShared
                        if type(ls.maxBuffs) == "number" and ls.maxBuffs > maxB then
                            maxB = ls.maxBuffs
                        end
                        if type(ls.maxDebuffs) == "number" and ls.maxDebuffs > maxD then
                            maxD = ls.maxDebuffs
                        end
                    end
                end
            end
            Collect.SetScanLimits(maxB, maxD)
        end

        -- Scan flags (only compute expensive per-aura tags when any frame needs them)
        if shared and Collect.SetScanFlags then
            local needImportant = false
            local sf = shared.filters
            if HasImportantToggle(sf) then
                needImportant = true
            elseif a2 and a2.perUnit then
                for _, pu in pairs(a2.perUnit) do
                    if pu and pu.overrideFilters == true and HasImportantToggle(pu.filters) then
                        needImportant = true
                        break
                    end
                end
            end
            Collect.SetScanFlags(needImportant)
        end

        -- Aura sort order: read from shared.filters, pass to Collect.
        -- Uses the Blizzard Enum.AuraSortOrder values (0-6) on the
        -- C_UnitAuras.GetAuraSlots 4th parameter.  Secret-safe: plain number.
        if shared and Collect.SetSortOrder then
            local sf = shared.filters
            Collect.SetSortOrder(sf and sf.sortOrder or 0)
            if Collect.SetSortReverse then
                Collect.SetSortReverse(sf and sf.sortReverse or false)
            end
        end
    end

    local unit = entry.unit
    
    -- PERF: Fast-path epoch check BEFORE expensive DB/config resolution
    -- If epoch unchanged AND configGen unchanged AND not in edit mode, skip entirely
    local epoch = _storeEpochs and _storeEpochs[unit] or 0
    local isEditActive = (not _inCombat) and IsEditModeActive() or false
    
    if epoch == entry._lastEpoch 
       and gen == entry._lastConfigGen 
       and not isEditActive 
       and entry._unitExisted  -- Only fast-path if unit existed last time
    then
        return  -- Nothing changed, skip all work
    end
    
    -- Reuse DB from scan-limits path if available, otherwise fetch now
    if not a2 or not shared then
        a2, shared = GetAuras2DB()
    end
    if not a2 or not shared then return end

    -- Unit disabled via options toggle: hide all icons + anchor and bail out.
    -- Edit mode preview bypasses this so movers remain visible for positioning.
    if not UnitEnabled(a2, unit) and not isEditActive then
        Icons.HideUnused(entry.buffs, 1)
        Icons.HideUnused(entry.debuffs, 1)
        if entry.mixed then Icons.HideUnused(entry.mixed, 1) end
        PrivateClear(entry)
        if entry.anchor then entry.anchor:Hide() end
        return
    end

    -- Cache resolved config per configGen (eliminates ~40 table reads per aura event) 
    local cfg = entry._cfg
    if not cfg then
        cfg = { _gen = -1 }
        entry._cfg = cfg
    end

    if cfg._gen ~= gen then
        cfg._gen = gen

        -- Layout config
        cfg.iconSize, cfg.spacing, cfg.perRow, cfg.maxBuffs, cfg.maxDebuffs,
        cfg.growth, cfg.buffGrowth, cfg.debuffGrowth, cfg.privateGrowth, cfg.rowWrap, cfg.buffRowWrap, cfg.debuffRowWrap, cfg.stackCountAnchor,
        cfg.buffIconSize, cfg.debuffIconSize, cfg.privateIconSize, cfg.capsSortOrder, cfg.capsSortReverse =
            ResolveUnitConfig(unit, a2, shared)        -- Filter flags
        if Filters and Filters.ResolveRuntimeFlags then
            cfg.tf, cfg.masterOn,
            cfg.onlyBossAuras,
            cfg.onlyImportantBuffs, cfg.onlyImportantDebuffs,
            cfg.buffsOnlyMine, cfg.debuffsOnlyMine,
            cfg.buffsIncludeBoss, cfg.debuffsIncludeBoss,
            cfg.hidePermanentBuffs,
            cfg.sortOrder, cfg.sortReverse =
                Filters.ResolveRuntimeFlags(a2, shared, unit)
        else
            cfg.tf = nil
            cfg.masterOn = false
            cfg.onlyBossAuras = false
            cfg.onlyImportantBuffs = false
            cfg.onlyImportantDebuffs = false
            cfg.buffsOnlyMine = false
            cfg.debuffsOnlyMine = false
            cfg.buffsIncludeBoss = false
            cfg.debuffsIncludeBoss = false
            cfg.hidePermanentBuffs = false
            cfg.sortOrder = 0
            cfg.sortReverse = false
        end
        -- Display flags
        cfg.showBuffs = (shared.showBuffs == true)
        cfg.showDebuffs = (shared.showDebuffs == true)
        cfg.needPlayerAura = (shared.highlightOwnBuffs == true)
            or (shared.highlightOwnDebuffs == true)
            or cfg.buffsOnlyMine
            or cfg.debuffsOnlyMine
    end

    -- Local aliases for hot-path values
    local iconSize          = cfg.iconSize
    local buffIconSize      = cfg.buffIconSize or iconSize
    local debuffIconSize    = cfg.debuffIconSize or iconSize
    local privateIconSize   = cfg.privateIconSize or iconSize
    local spacing           = cfg.spacing
    local perRow            = cfg.perRow
    local maxBuffs          = cfg.maxBuffs
    local maxDebuffs        = cfg.maxDebuffs
    local growth            = cfg.growth
    local buffGrowth        = cfg.buffGrowth or growth
    local debuffGrowth      = cfg.debuffGrowth or growth
    local privateGrowth     = cfg.privateGrowth or growth
    local rowWrap           = cfg.rowWrap
    local buffRowWrap       = cfg.buffRowWrap or rowWrap
    local debuffRowWrap     = cfg.debuffRowWrap or rowWrap
    local stackCountAnchor  = cfg.stackCountAnchor
    local showBuffs         = cfg.showBuffs
    local showDebuffs       = cfg.showDebuffs
    local needPlayerAura    = cfg.needPlayerAura
    local masterOn          = cfg.masterOn

    -- Early bail: no unit, no edit mode  nothing to render 
    local unitExists = UnitExists and UnitExists(unit)
    -- PERF: isEditActive already computed at top of function

    if not unitExists and not isEditActive then
        Icons.HideUnused(entry.debuffs, 1)
        Icons.HideUnused(entry.buffs, 1)
        if entry.mixed then Icons.HideUnused(entry.mixed, 1) end
        entry.anchor:Hide()
        return
    end

    -- Edit Mode: create movers before anchoring so UpdateAnchor can position them 
    local EditMode = isEditActive and API.EditMode or nil
    if EditMode and EditMode.EnsureMovers then
        EditMode.EnsureMovers(entry, unit, shared, iconSize, spacing)
    end

    -- Anchor: only reposition when config changes (configGen bumped) or edit mode active
    if gen ~= entry._lastAnchorGen or isEditActive then
        UpdateAnchor(entry, shared, isEditActive)
        entry._lastAnchorGen = gen
    end
    entry.anchor:Show()

    -- Private auras: only rebuild when config changes
    if gen ~= entry._lastPrivateGen then
        PrivateRebuild(entry, shared, privateIconSize, spacing, privateGrowth)
        entry._lastPrivateGen = gen
    end

    -- Edit Mode: show/hide movers (skip entirely in combat) 
    if not _inCombat then
        if EditMode then
            if EditMode.ShowMovers then EditMode.ShowMovers(entry) end
        else
            local EM = API.EditMode
            if EM and EM.HideMovers then EM.HideMovers(entry) end
        end
    end

    local showTest = (shared.showInEditMode == true and isEditActive)

    if showTest then
        if entry.buffs then entry.buffs:Show() end
        if entry.debuffs then entry.debuffs:Show() end
        if entry.mixed then entry.mixed:Hide() end
        -- Private auras are player-only; skip for target.
        if entry.private and unit ~= "target" then entry.private:Show() end
        entry._msufA2_previewActive = true
    else
        -- Leaving edit mode or previews disabled: ensure preview state is cleared.
        if entry._msufA2_previewActive then
            local Preview = API.Preview
            if Preview and Preview.ClearPreviewsForEntry then
                Preview.ClearPreviewsForEntry(entry)
            else
                entry._msufA2_previewActive = nil
            end
            -- Invalidate epoch + config caches so the real aura path runs a
            -- full rebuild instead of hitting the epoch-diff early-out.
            entry._lastEpoch = -1
            entry._lastConfigGen = -1
            entry._msufA2_playerPreviewInit = nil
        end
    end

    -- Edit Mode preview: always show fake auras while active
    -- Player always has a live frame, so skip buff previews and let real buffs render.
    if showTest then
        local isPlayer = (unit == "player")

        if Icons.RenderPreviewIcons and not isPlayer then
            -- Non-player units: full preview (buffs + debuffs)
            local bc, dc = Icons.RenderPreviewIcons(entry, unit, shared, false, maxBuffs, maxDebuffs, stackCountAnchor)
            local bSize = buffIconSize
            local dSize = debuffIconSize
            Icons.LayoutIcons(entry.buffs, bc or 0, bSize, spacing, perRow, buffGrowth, buffRowWrap)
            Icons.LayoutIcons(entry.debuffs, dc or 0, dSize, spacing, perRow, debuffGrowth, debuffRowWrap)
        elseif Icons.RenderPreviewIcons and isPlayer then
            -- Player: debuff preview only (buffCap = 0), real buffs render below
            local _, dc = Icons.RenderPreviewIcons(entry, unit, shared, false, 0, maxDebuffs, stackCountAnchor)
            local dSize = debuffIconSize
            Icons.LayoutIcons(entry.debuffs, dc or 0, dSize, spacing, perRow, debuffGrowth, debuffRowWrap)
        end

        if Icons.RenderPreviewPrivateIcons and unit ~= "target" then
            Icons.RenderPreviewPrivateIcons(entry, unit, shared, privateIconSize, spacing, stackCountAnchor, privateGrowth)
        end

        -- Non-player: done. Player: fall through to real aura path for buffs.
        if not isPlayer then
            return
        end
        -- Player: invalidate epoch once on preview entry so real buff path runs.
        if not entry._msufA2_playerPreviewInit then
            entry._msufA2_playerPreviewInit = true
            entry._lastEpoch = -1
            entry._lastConfigGen = -1
        end
    end

    if not unitExists then
        Icons.HideUnused(entry.debuffs, 1)
        Icons.HideUnused(entry.buffs, 1)
        entry.anchor:Hide()
        entry._unitExisted = false  -- PERF: Track for fast-path
        return
    end

    -- PERF: Epoch already checked at top of function for fast-path
    -- Here we just update the tracking vars
    entry._lastEpoch = epoch
    entry._lastConfigGen = gen
    entry._unitExisted = true  -- PERF: Track for fast-path

    -- Collect auras (single pass) 
    -- Per-unit sort order: set before collect so PreScanUnit uses the right order.
    -- Secret-safe: plain numeric config, never compared with secret data.
    if Collect.SetUnitSortOrder then
        Collect.SetUnitSortOrder(unit, cfg.capsSortOrder or 0, cfg.capsSortReverse)
    end
    local buffCount = 0
    local debuffCount = 0
    local buffsOnlyMine    = cfg.buffsOnlyMine
    local debuffsOnlyMine  = cfg.debuffsOnlyMine
    local buffsIncludeBoss = cfg.buffsIncludeBoss
    local debuffsIncludeBoss = cfg.debuffsIncludeBoss
    local onlyBossAuras    = cfg.onlyBossAuras
    local onlyImportantBuffs = cfg.onlyImportantBuffs
    local onlyImportantDebuffs = cfg.onlyImportantDebuffs
    local hidePermanentBuffs = cfg.hidePermanentBuffs

    -- PERF: Local function references eliminate table lookups in hot loops
    local _AcquireIcon = Icons.AcquireIcon
    local _CommitIcon = Icons.CommitIcon
    local _GetAuras = Collect.GetAuras
    local _GetMergedAuras = Collect.GetMergedAuras

    -- Player in edit mode: debuffs already rendered as preview above, skip real debuff path.
    local skipDebuffs = (showTest and unit == "player")

    if showDebuffs and not skipDebuffs then
        local list
        if debuffsOnlyMine and debuffsIncludeBoss then
            list, debuffCount = _GetMergedAuras(unit, "HARMFUL", maxDebuffs, false, onlyImportantDebuffs, entry._debuffList, nil, needPlayerAura)
        else
            list, debuffCount = _GetAuras(unit, "HARMFUL", maxDebuffs, debuffsOnlyMine, false, onlyBossAuras, onlyImportantDebuffs, entry._debuffList, needPlayerAura)
        end

        local container = entry.debuffs
        for i = 1, debuffCount do
            local aura = list[i]
            if aura then
                local icon = _AcquireIcon(container, i)
                _CommitIcon(icon, unit, aura, shared, false, false, masterOn, aura._msufIsPlayerAura, stackCountAnchor, gen)
            end
        end
    end

    if showBuffs then
        local list
        if buffsOnlyMine and buffsIncludeBoss then
            list, buffCount = _GetMergedAuras(unit, "HELPFUL", maxBuffs, hidePermanentBuffs, onlyImportantBuffs, entry._buffList, nil, needPlayerAura)
        else
            list, buffCount = _GetAuras(unit, "HELPFUL", maxBuffs, buffsOnlyMine, hidePermanentBuffs, onlyBossAuras, onlyImportantBuffs, entry._buffList, needPlayerAura)
        end

        local container = entry.buffs
        local offset = 0
        for i = 1, buffCount do
            local aura = list[i]
            if aura then
                local icon = _AcquireIcon(container, offset + i)
                _CommitIcon(icon, unit, aura, shared, true, hidePermanentBuffs, masterOn, aura._msufIsPlayerAura, stackCountAnchor, gen)
            end
        end
    end

    -- Layout 
    -- PERF: Local function references
    local _LayoutIcons = Icons.LayoutIcons
    local _HideUnused = Icons.HideUnused
    
    -- PERF: Skip redundant HideUnused when counts unchanged
    local lastBuffCount = entry._lastBuffCount or 0
    local lastDebuffCount = entry._lastDebuffCount or 0
    local countsChanged = (buffCount ~= lastBuffCount) or (debuffCount ~= lastDebuffCount)
    
    -- SEPARATE (only) layout path
    if skipDebuffs then
        -- Player edit mode: debuff layout already handled by preview path.
    elseif showDebuffs then
        _LayoutIcons(entry.debuffs, debuffCount, debuffIconSize, spacing, perRow, debuffGrowth, debuffRowWrap)
        if debuffCount ~= lastDebuffCount then
            _HideUnused(entry.debuffs, debuffCount + 1)
        end
    else
        if lastDebuffCount > 0 then
            _HideUnused(entry.debuffs, 1)
        end
    end

    if showBuffs then
        _LayoutIcons(entry.buffs, buffCount, buffIconSize, spacing, perRow, buffGrowth, buffRowWrap)
        if buffCount ~= lastBuffCount then
            _HideUnused(entry.buffs, buffCount + 1)
        end
    else
        if lastBuffCount > 0 then
            _HideUnused(entry.buffs, 1)
        end
    end

    if entry.mixed and countsChanged then _HideUnused(entry.mixed, 1) end

    entry._lastBuffCount = buffCount
    entry._lastDebuffCount = debuffCount
end


-- Flush


-- PERF: Budget cap for aura flush. Shared with UFCore for combined frame budget.
-- Own cap: 350μs. Shared total: 700μs (UFCore 350 + A2 350 max).
-- In practice UFCore uses 50-200μs → A2 gets 500-650μs on most frames.
-- Spiky frames (UFCore near cap): A2 gets min 150μs = enough for 1 unit.
local _A2_FLUSH_BUDGET_US = 350  -- own cap (microseconds)
local _A2_SHARED_BUDGET_US = 700 -- combined MSUF budget per frame
local _A2_MIN_BUDGET_US = 150    -- minimum budget (always process ≥1 unit)
local _debugprofilestop = debugprofilestop  -- microsecond timer (nil if unavailable)

Flush = function()
    _isFlushing = true
    _dirtyWhileFlushing = false
    FlushScheduled = true

    local list, count = DirtySwap()
    local startUs = _debugprofilestop and _debugprofilestop() or nil

    -- PERF: Shared frame budget with UFCore. Deduct UFCore's usage from our cap.
    local effectiveBudget = _A2_FLUSH_BUDGET_US
    if startUs then
        local ufcoreUsed = _G._MSUF_FrameBudgetUsed
        local ufcoreStart = _G._MSUF_FrameBudgetStart
        -- Only trust if UFCore ran recently (same frame = within ~20ms of us starting)
        if ufcoreUsed and ufcoreStart and (startUs - ufcoreStart) < 20000 then
            local remaining = _A2_SHARED_BUDGET_US - ufcoreUsed
            if remaining < _A2_MIN_BUDGET_US then remaining = _A2_MIN_BUDGET_US end
            if remaining < effectiveBudget then
                effectiveBudget = remaining
            end
        end
    end

    local budgetHit = false

    for i = 1, count do
        -- PERF: Budget check after each unit (not each aura icon).
        if startUs and i > 1 then
            local elapsed = _debugprofilestop() - startUs
            if elapsed >= effectiveBudget then
                -- Re-queue remaining units for next frame.
                for j = i, count do
                    local u = list[j]
                    if u then MarkDirty(u, 0) end
                end
                budgetHit = true
                break
            end
        end

        local unit = list[i]
        local entry = AurasByUnit[unit]

        -- Fast path: entry already attached with valid frame â†’ skip FindUnitFrame
        if entry and entry.frame then
            RenderUnit(entry)
        else
            local frame = FindUnitFrame(unit)
            if not frame then
                -- No parent frame at all: just hide anchor if leftover
                if entry and entry.anchor then entry.anchor:Hide() end
            else
                -- Always let RenderUnit handle both rendering AND cleanup.
                -- UnitEnabled gating lives inside RenderUnit so disabled units
                -- get their icons hidden properly.
                local e = EnsureAttached(unit)
                if e then RenderUnit(e) end
            end
        end
    end

    _isFlushing = false

    if _dirtyWhileFlushing or budgetHit then
        ScheduleFlush(0)
    end

    if DirtyCount == 0 and not budgetHit then
        FlushScheduled = false
        StopFlushDriver()
    end
end


-- Public API


local function MarkAllDirty(delay)
    -- Always mark ALL units, not just enabled ones.
    -- Disabled units need a render pass to hide their lingering icons.
    MarkDirty("player", delay)
    MarkDirty("target", delay)
    MarkDirty("focus", delay)
    for i = 1, 5 do MarkDirty(_BOSS_UNITS[i], delay) end
end

local function RefreshAll()
    _configGen = _configGen + 1
    if Icons and Icons.BumpConfigGen then Icons.BumpConfigGen() end

    local Store = API.Store
    if Store and Store.InvalidateUnit then
        Store.InvalidateUnit("player")
        Store.InvalidateUnit("target")
        Store.InvalidateUnit("focus")
        for i = 1, 5 do Store.InvalidateUnit(_BOSS_UNITS[i]) end
    end
    MarkAllDirty(0)
end

local function RefreshUnit(unit)
    if not unit then return end
    -- Bump configGen: RefreshUnit is called when per-unit settings change
    -- (caps, layout, filters). Config-dependent gates (anchor, layout, private)
    -- must invalidate so the new values take effect.
    _configGen = _configGen + 1
    Icons = API.Icons or API.Apply
    if Icons and Icons.BumpConfigGen then Icons.BumpConfigGen() end
    local Store = API.Store
    if Store and Store.InvalidateUnit then Store.InvalidateUnit(unit) end
    MarkDirty(unit, 0)
end

local function HardDisableAll()
    StopFlushDriver()
    FlushScheduled = false
    DirtyCount = 0
    DirtyGen = DirtyGen + 1

    for _, entry in pairs(AurasByUnit) do
        if entry then
            if entry.anchor then entry.anchor:Hide() end
            PrivateClear(entry)
            Icons = API.Icons or API.Apply
            if Icons then
                Icons.HideUnused(entry.buffs, 1)
                Icons.HideUnused(entry.debuffs, 1)
                Icons.HideUnused(entry.mixed, 1)
            end
        end
    end
end


-- ClearAllPreviews (called by Events when leaving Edit Mode)


-- Helper: clear preview state from a single container (file-scope, no closure alloc)
local function _ClearPreviewContainer(container)
    if not container then return end
    local pool = container._msufIcons
    if not pool then return end
    for i = 1, #pool do
        local icon = pool[i]
        if icon and icon._msufA2_isPreview then
            icon._msufA2_isPreview = nil
            icon._msufA2_previewKind = nil
            icon._msufA2_previewCDCounter = nil
            icon._msufA2_lastCommit = nil
            icon._msufA2_lastTexAid = nil
            -- Hide preview CD text FontString so it doesn't bleed into real auras.
            local pvFS = icon._msufA2_previewCDText
            if pvFS then
                pvFS:Hide()
                pvFS:SetText("")
            end
            icon:Hide()
        end
    end
end

local function ClearAllPreviews()
    Icons = API.Icons or API.Apply
    if not Icons then return end

    -- Prefer Preview module's thorough cleanup (unreg from CD manager,
    -- clear duration objects, visual ID caches, preview CD FontStrings).
    local Preview = API.Preview
    local deepClean = Preview and Preview.ClearPreviewsForEntry

    for _, entry in pairs(AurasByUnit) do
        if entry then
            if deepClean then
                deepClean(entry)
            else
                _ClearPreviewContainer(entry.buffs)
                _ClearPreviewContainer(entry.debuffs)
                _ClearPreviewContainer(entry.mixed)
                _ClearPreviewContainer(entry.private)
                entry._msufA2_previewActive = nil
            end
            -- Force full rebuild so real auras render after preview exit.
            entry._lastEpoch = -1
            entry._lastConfigGen = -1
            entry._msufA2_playerPreviewInit = nil
        end
    end

    -- Bug 2 fix: Force Masque reskin after clearing previews.
    -- Preview icons modified cooldown/texture state that Masque skins
    -- (e.g. Shadow) track internally. Without a reskin, stale overlay
    -- state causes icons to appear very dark.
    local MasqueMod = API.Masque
    if MasqueMod and MasqueMod.ForceReskin then
        MasqueMod.ForceReskin()
    end
end

API.ClearAllPreviews = ClearAllPreviews

-- Register on API
API.MarkDirty = MarkDirty
API.MarkAllDirty = MarkAllDirty
API.RefreshAll = RefreshAll
API.RefreshUnit = RefreshUnit
API.RequestUnit = MarkDirty
API.HardDisableAll = HardDisableAll
API.Flush = Flush

-- RequestApply: called by Options after any settings change (checkboxes, sliders, etc.)
-- Must bump configGen so CommitIcon diff-gate triggers a full re-apply (highlights, timers, etc.)
API.RequestApply = function()
    InvalidateDB()
end

-- Global wrappers for backward compat
_G.MSUF_Auras2_RefreshAll = function() return API.RefreshAll() end
_G.MSUF_Auras2_RefreshUnit = function(unit) return API.RefreshUnit(unit) end
_G.MSUF_A2_RequestUnit = function(unit, delay) return API.RequestUnit(unit, delay) end
_G.MSUF_A2_HardDisableAll = function() return API.HardDisableAll() end
_G.MSUF_UpdateTargetAuras = function() MarkDirty("target") end


-- Init: prime DB + kick off events



-- API bridge for Options / EditMode / Fonts

API._Render = API._Render or {}

-- UpdateUnitAnchor: immediate re-anchor for a single unit (used by EditMode drag for instant feedback)
API.UpdateUnitAnchor = function(unit)
    if not unit then return end
    local entry = AurasByUnit[unit]
    if not entry then return end
    local a2, shared = GetAuras2DB()
    if shared then UpdateAnchor(entry, shared, (not _inCombat) and IsEditModeActive() or false) end
end

API.ApplyEventRegistration = API.ApplyEventRegistration or function()
    local Ev = API.Events
    if Ev and Ev.ApplyEventRegistration then return Ev.ApplyEventRegistration() end
end

function API.Init()
    if API._renderInited then return end
    API._renderInited = true

    -- Prime DB so all caches are warm
    EnsureDB()

    -- If Events module is already loaded, initialize it
    local Ev = API.Events
    if Ev and Ev.Init then
        Ev.Init()
    end


-- IMPORTANT: On /reload, Auras2 can initialize before MSUF core has fully
-- hydrated SavedVariables (or before some modules finish their first Apply).
-- That can cause the initial anchor placement to use default offsets until
-- Edit Mode forces a re-anchor.
--
-- Fix: one-shot post-init invalidate on the next tick so we re-read the
-- final DB state and force UpdateAnchor to run with the correct offsets.
if not API._didPostInitInvalidate then
    API._didPostInitInvalidate = true
    if C_Timer and C_Timer.After then
        C_Timer.After(0, function()
            InvalidateDB()
        end)
    else
        InvalidateDB()
    end
end
end

-- Deferred auto-init: if Events already loaded, Init now; otherwise Events.lua calls API.Init() at its tail
if API.Events and API.Events.Init then
    API.Init()
end
