--- Auras3/MSUF_Auras3_EditMode.lua
---
--- This file intentionally does not own live aura objects and does not add
--- per-aura render callbacks. It owns only edit-mode fake previews, drag
--- movement, and config refresh bridges.
---
--- Edit-mode state is visual and coldpath. Dragging writes layout offsets to
--- the Auras3 DB through the same shape that Menu_Model uses, then asks runtime
--- frames to refresh. Keep live aura payload handling inside the native runtime.
local _, MSUF = ...
MSUF = MSUF or (_G.MSUF_NS) or {}
local ExportPublic = MSUF.ExportPublic or function(name, value)
    _G[name] = value
    return value
end

local type, tonumber, tostring, pairs = type, tonumber, tostring, pairs
local math_floor, math_min, math_max, math_ceil, math_abs = math.floor, math.min, math.max, math.ceil, math.abs
local CreateFrame = _G.CreateFrame
local UIParent = _G.UIParent
local GetCursorPosition = _G.GetCursorPosition
local IsMouseButtonDown = _G.IsMouseButtonDown
local InCombatLockdown = _G.InCombatLockdown
local C_Timer = _G.C_Timer
local issecretvalue = _G.issecretvalue

local A3 = MSUF.MSUF_Auras3
if type(A3) ~= "table" then
    A3 = {}
    MSUF.MSUF_Auras3 = A3
end
ExportPublic("MSUF_Auras3", A3)

if A3.__editModeLoaded then return end
A3.__editModeLoaded = true

A3.EditMode = (type(A3.EditMode) == "table") and A3.EditMode or {}
local EM = A3.EditMode

local AURA_DRAG_RUNTIME_INTERVAL = 0.05
local AURA_PENDING_DRAG_THRESHOLD = 3
local AURA_UNITS = { "player", "target", "focus", "boss1", "boss2", "boss3", "boss4", "boss5" }
local BOSS_UNITS = { boss1=true, boss2=true, boss3=true, boss4=true, boss5=true }
local function IsBossScope(unit)
    return tostring(unit or ""):lower() == "boss"
end

local function ForEachBossUnit(fn)
    if type(fn) ~= "function" then return end
    for i = 1, 5 do fn("boss" .. i) end
end

local GROUPS = {
    buff = {
        label = "Buffs",
        xKey = "buffGroupOffsetX",
        yKey = "buffGroupOffsetY",
        sizeKey = "buffGroupIconSize",
        anchorKey = "buffAnchor",
        layerKey = "buffLayer",
        maxKey = "maxBuffs",
        showKey = "showBuffs",
        perRowKey = "buffPerRow",
        growthKey = "buffGrowthX",
        wrapKey = "buffGrowthY",
        texture = "Interface\\Icons\\Spell_Holy_WordFortitude",
        color = { 0.16, 0.82, 0.35, 0.28 },
        defaultAnchor = "BOTTOMRIGHT",
        defaultLayer = 5,
    },
    debuff = {
        label = "Debuffs",
        xKey = "debuffGroupOffsetX",
        yKey = "debuffGroupOffsetY",
        sizeKey = "debuffGroupIconSize",
        anchorKey = "debuffAnchor",
        layerKey = "debuffLayer",
        maxKey = "maxDebuffs",
        showKey = "showDebuffs",
        perRowKey = "debuffPerRow",
        growthKey = "debuffGrowthX",
        wrapKey = "debuffGrowthY",
        texture = "Interface\\Icons\\Spell_Shadow_ShadowWordPain",
        color = { 0.92, 0.20, 0.20, 0.28 },
        defaultAnchor = "TOPLEFT",
        defaultLayer = 6,
    },
    custom1 = { customIndex = 1, label = "Custom 1", texture = "Interface\\Icons\\INV_Misc_QuestionMark", color = { 0.45, 0.72, 1.00, 0.28 }, defaultAnchor = "TOPRIGHT", defaultLayer = 9 },
    custom2 = { customIndex = 2, label = "Custom 2", texture = "Interface\\Icons\\INV_Misc_QuestionMark", color = { 0.70, 0.48, 1.00, 0.28 }, defaultAnchor = "TOPRIGHT", defaultLayer = 9 },
    custom3 = { customIndex = 3, label = "Custom 3", texture = "Interface\\Icons\\INV_Misc_QuestionMark", color = { 1.00, 0.58, 0.28, 0.28 }, defaultAnchor = "TOPRIGHT", defaultLayer = 9 },
}

local LANE_STYLE_KEYS = {
    buff = {
        stackCountAnchor = "buffStackCountAnchor",
        cooldownTextAnchor = "buffCooldownTextAnchor",
        stackTextSize = "buffStackTextSize",
        stackTextOffsetX = "buffStackTextOffsetX",
        stackTextOffsetY = "buffStackTextOffsetY",
        cooldownTextSize = "buffCooldownTextSize",
        cooldownTextOffsetX = "buffCooldownTextOffsetX",
        cooldownTextOffsetY = "buffCooldownTextOffsetY",
        showDurationBar = "buffShowDurationBar",
        durationBarHeight = "buffDurationBarHeight",
        durationBarDisplay = "buffDurationBarDisplay",
        durationBarPosition = "buffDurationBarPosition",
        durationBarDirection = "buffDurationBarDirection",
    },
    debuff = {
        stackCountAnchor = "debuffStackCountAnchor",
        cooldownTextAnchor = "debuffCooldownTextAnchor",
        stackTextSize = "debuffStackTextSize",
        stackTextOffsetX = "debuffStackTextOffsetX",
        stackTextOffsetY = "debuffStackTextOffsetY",
        cooldownTextSize = "debuffCooldownTextSize",
        cooldownTextOffsetX = "debuffCooldownTextOffsetX",
        cooldownTextOffsetY = "debuffCooldownTextOffsetY",
        showDurationBar = "debuffShowDurationBar",
        durationBarHeight = "debuffDurationBarHeight",
        durationBarDisplay = "debuffDurationBarDisplay",
        durationBarPosition = "debuffDurationBarPosition",
        durationBarDirection = "debuffDurationBarDirection",
    },
}

local W8 = "Interface\\Buttons\\WHITE8X8"
local HEADER_H = 18
local PREVIEW_ICONS = 4
local TEXT_STYLE_LAYOUT_KEYS = {
    stackTextSize = true,
    stackTextOffsetX = true,
    stackTextOffsetY = true,
    cooldownTextSize = true,
    cooldownTextOffsetX = true,
    cooldownTextOffsetY = true,
    durationBarHeight = true,
    buffStackTextSize = true,
    buffStackTextOffsetX = true,
    buffStackTextOffsetY = true,
    buffCooldownTextSize = true,
    buffCooldownTextOffsetX = true,
    buffCooldownTextOffsetY = true,
    buffDurationBarHeight = true,
    debuffStackTextSize = true,
    debuffStackTextOffsetX = true,
    debuffStackTextOffsetY = true,
    debuffCooldownTextSize = true,
    debuffCooldownTextOffsetX = true,
    debuffCooldownTextOffsetY = true,
    debuffDurationBarHeight = true,
}
local TEXT_STYLE_SHARED_KEYS = {
    stackCountAnchor = true,
    cooldownTextAnchor = true,
    showDurationBar = true,
    durationBarDisplay = true,
    durationBarPosition = true,
    durationBarDirection = true,
    buffStackCountAnchor = true,
    buffCooldownTextAnchor = true,
    buffShowDurationBar = true,
    buffDurationBarDisplay = true,
    buffDurationBarPosition = true,
    buffDurationBarDirection = true,
    debuffStackCountAnchor = true,
    debuffCooldownTextAnchor = true,
    debuffShowDurationBar = true,
    debuffDurationBarDisplay = true,
    debuffDurationBarPosition = true,
    debuffDurationBarDirection = true,
}
local AURA_TEXT_ANCHOR_OK = {
    TOPLEFT = true, TOP = true, TOPRIGHT = true,
    LEFT = true, CENTER = true, RIGHT = true,
    BOTTOMLEFT = true, BOTTOM = true, BOTTOMRIGHT = true,
}

local function Clamp(v, defaultValue, minValue, maxValue)
    v = tonumber(v)
    if not v then v = defaultValue end
    if minValue and v < minValue then v = minValue end
    if maxValue and v > maxValue then v = maxValue end
    return v
end

local function Round(v)
    v = tonumber(v) or 0
    if v < -4096 then v = -4096 elseif v > 4096 then v = 4096 end
    if v < 0 then return -math_floor((-v) + 0.5) end
    return math_floor(v + 0.5)
end

local function IsEditModeActive()
    local st = rawget(_G, "MSUF_EditState")
    return (st and st.active == true) or rawget(_G, "MSUF_UnitEditModeActive") == true
end

local function UnitPreviewActive(unit)
    return IsEditModeActive()
end

local function IsConfigBlocked()
    if InCombatLockdown and InCombatLockdown() then return true end
    if _G.UnitAffectingCombat and _G.UnitAffectingCombat("player") then return true end
    if _G.MSUF_InCombat == true then return true end
    return false
end

local function RequestUnitFrameMenuPreview(reason)
    local fn = _G.MSUF_UFPreview_RequestRefresh
    if type(fn) == "function" then fn(reason or "AURAS3_EDITMODE") end
end

local function ProfileStart()
    local m = MSUF and MSUF.MSUF2
    if m and m.PerfProfile and m.PerfProfile.enabled == true and m.ProfileStart then return m.ProfileStart() end
end

local function ProfileStop(bucket, key, started, count)
    local m = MSUF and MSUF.MSUF2
    if started and m and m.PerfProfile and m.PerfProfile.enabled == true and m.ProfileStop then
        m.ProfileStop(bucket, key, started, count)
    end
end

local function NormalizeKind(kind)
    kind = (type(kind) == "string") and kind:lower() or "buff"
    if kind == "buffs" then return "buff" end
    if kind == "debuffs" then return "debuff" end
    if GROUPS[kind] then return kind end
    return "buff"
end

local function CustomItem(unit, index, create)
    local model = A3 and A3.MenuModel
    if not (model and type(model.CustomContainer) == "function") then return nil end
    return model.CustomContainer(unit, index, create == true)
end

local function UnitLabel(unit)
    if unit == "player" then return "Player" end
    if unit == "target" then return "Target" end
    if unit == "focus" then return "Focus" end
    if BOSS_UNITS[unit] then return "Boss " .. tostring(unit):match("%d+") end
    return tostring(unit or "")
end

local function EnsureDB()
    local auras, shared
    if A3.EnsureDB then
        auras, shared = A3.EnsureDB()
    else
        local db = _G.MSUF_DB
        auras = db and (db.auras3 or db.auras3)
        shared = auras and auras.shared
    end
    if type(auras) ~= "table" then return nil, nil end
    if type(shared) ~= "table" then
        shared = {}
        auras.shared = shared
    end
    return auras, shared
end

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

local function UnitHasCustomPreview(unit)
    for index = 1, 3 do
        local item = CustomItem and CustomItem(unit, index, false)
        local placed = item and item.placed
        if item and item.enabled == true and (tonumber(placed and placed.max) or 8) > 0 then return true end
    end
    return false
end

local function GetFrame(unit)
    if not unit then return nil end
    local uf = MSUF and MSUF.UF
    return (A3._runtimeFrames and A3._runtimeFrames[unit])
        or (A3._unitFrameOwners and A3._unitFrameOwners[unit])
        or (uf and type(uf.GetFrame) == "function" and uf.GetFrame(unit))
        or (uf and uf.frames and uf.frames[unit])
        or _G["MSUF_" .. unit]
end

local function FrameScaleRelativeToUIParent(frame)
    local frameScale = frame and frame.GetEffectiveScale and frame:GetEffectiveScale()
    local uiScale = UIParent and UIParent.GetEffectiveScale and UIParent:GetEffectiveScale()
    frameScale = tonumber(frameScale) or 1
    uiScale = tonumber(uiScale) or 1
    if frameScale <= 0 then frameScale = 1 end
    if uiScale <= 0 then uiScale = 1 end
    local scale = frameScale / uiScale
    if scale <= 0 then return 1 end
    return scale
end

local function ApplyGroupScaleForFrame(group, frame)
    local scale = FrameScaleRelativeToUIParent(frame)
    if group and group.SetScale and group._msufA3FrameScale ~= scale then
        group:SetScale(scale)
        group._msufA3FrameScale = scale
    end
    return scale
end

local function GetLayout(auras, unit, create)
    if type(auras) ~= "table" or not unit then return nil end
    if create then
        auras.perUnit = (type(auras.perUnit) == "table") and auras.perUnit or {}
        local pu = auras.perUnit[unit]
        if type(pu) ~= "table" then
            pu = {}
            auras.perUnit[unit] = pu
        end
        pu.overrideLayout = true
        pu.layout = (type(pu.layout) == "table") and pu.layout or {}
        return pu.layout, pu
    end

    local pu = auras.perUnit and auras.perUnit[unit]
    if pu and pu.overrideLayout == true and type(pu.layout) == "table" then
        return pu.layout, pu
    end
    return nil, pu
end

local function GetSharedLayout(auras, unit)
    local pu = auras and auras.perUnit and auras.perUnit[unit]
    if pu and pu.overrideSharedLayout == true and type(pu.layoutShared) == "table" then
        return pu.layoutShared
    end
    return nil
end

local function TableHasAnyKey(tbl, keys)
    if type(tbl) ~= "table" or type(keys) ~= "table" then return false end
    for key in pairs(keys) do
        if tbl[key] ~= nil then return true end
    end
    return false
end

local function UnitStyleOverrideActive(pu)
    if type(pu) ~= "table" then return false end
    if pu.overrideStyle ~= nil then return pu.overrideStyle == true end
    return TableHasAnyKey(pu.layout, TEXT_STYLE_LAYOUT_KEYS) or TableHasAnyKey(pu.layoutShared, TEXT_STYLE_SHARED_KEYS)
end

local function ReadNumber(shared, layout, key, defaultValue, minValue, maxValue)
    local v = shared and shared[key]
    if layout and layout[key] ~= nil then v = layout[key] end
    return Clamp(v, defaultValue, minValue, maxValue)
end

local function ReadRawNumber(shared, layout, key)
    local v = layout and layout[key]
    if v == nil then v = shared and shared[key] end
    return tonumber(v)
end

local function ReadRawValue(shared, layout, key)
    if layout and layout[key] ~= nil then return layout[key] end
    return shared and shared[key]
end

local function ReadLaneTextNumber(shared, layout, kind, key, defaultValue, minValue, maxValue)
    kind = NormalizeKind(kind)
    local laneKey = kind and LANE_STYLE_KEYS[kind] and LANE_STYLE_KEYS[kind][key]
    local v = laneKey and ReadRawNumber(shared, layout, laneKey) or nil
    if v == nil then v = ReadRawNumber(shared, layout, key) end
    return Clamp(v, defaultValue, minValue, maxValue)
end

local function ReadLaneTextBool(shared, layout, kind, key, defaultValue)
    kind = NormalizeKind(kind)
    local laneKey = kind and LANE_STYLE_KEYS[kind] and LANE_STYLE_KEYS[kind][key]
    local v = laneKey and ReadRawValue(shared, layout, laneKey) or nil
    if v == nil then v = ReadRawValue(shared, layout, key) end
    if v == nil then return defaultValue == true end
    return v == true
end

local function ReadLaneTextString(shared, layout, kind, key, fallback)
    kind = NormalizeKind(kind)
    local laneKey = kind and LANE_STYLE_KEYS[kind] and LANE_STYLE_KEYS[kind][key]
    local v = laneKey and ReadRawValue(shared, layout, laneKey) or nil
    if v == nil then v = ReadRawValue(shared, layout, key) end
    return tostring(v or fallback or "")
end

local function ReadLaneTextAnchor(shared, layoutShared, kind)
    kind = NormalizeKind(kind)
    local laneKey = kind and LANE_STYLE_KEYS[kind] and LANE_STYLE_KEYS[kind].stackCountAnchor
    local anchor = laneKey and ((layoutShared and layoutShared[laneKey]) or (shared and shared[laneKey])) or nil
    anchor = anchor or (layoutShared and layoutShared.stackCountAnchor) or (shared and shared.stackCountAnchor) or "TOPRIGHT"
    if anchor ~= "TOPLEFT" and anchor ~= "BOTTOMLEFT" and anchor ~= "BOTTOMRIGHT" then
        anchor = "TOPRIGHT"
    end
    return anchor
end

local function ReadLaneCooldownTextAnchor(shared, layoutShared, kind)
    kind = NormalizeKind(kind)
    local laneKey = kind and LANE_STYLE_KEYS[kind] and LANE_STYLE_KEYS[kind].cooldownTextAnchor
    local anchor = laneKey and ((layoutShared and layoutShared[laneKey]) or (shared and shared[laneKey])) or nil
    anchor = anchor or (layoutShared and layoutShared.cooldownTextAnchor) or (shared and shared.cooldownTextAnchor) or "CENTER"
    return AURA_TEXT_ANCHOR_OK[anchor] and anchor or "CENTER"
end

local function ReadTextConfig(unit, kind)
    kind = NormalizeKind(kind)
    local customSpec = GROUPS[kind]
    if customSpec and customSpec.customIndex then
        local item = CustomItem(unit, customSpec.customIndex, false)
        local placed = item and item.placed or {}
        return {
            stackSize = Clamp(placed.stackSize, 14, 6, 40),
            stackX = Clamp(placed.stackX, 0, -2000, 2000),
            stackY = Clamp(placed.stackY, 0, -2000, 2000),
            cooldownSize = Clamp(placed.cooldownSize, 14, 6, 40),
            cooldownX = Clamp(placed.cooldownX, 0, -2000, 2000),
            cooldownY = Clamp(placed.cooldownY, 0, -2000, 2000),
            showDurationBar = placed.showDurationBar == true,
            durationBarHeight = Clamp(placed.durationBarHeight, 2, 1, 16),
            durationBarDisplay = placed.durationBarDisplay == "OVERLAY" and "OVERLAY" or "BAR_ONLY",
            durationBarPosition = placed.durationBarPosition == "TOP" and "TOP" or "BOTTOM",
            durationBarDirection = placed.durationBarDirection == "ELAPSED" and "ELAPSED" or "REMAINING",
            stackAnchor = placed.stackAnchor or "BOTTOMRIGHT",
            cooldownAnchor = placed.cooldownAnchor or "CENTER",
        }
    end
    local auras, shared = EnsureDB()
    local layout, pu = GetLayout(auras, unit, false)
    local ls = GetSharedLayout(auras, unit)
    if not UnitStyleOverrideActive(pu) then
        layout = nil
        ls = nil
    end
    return {
        stackSize = ReadLaneTextNumber(shared, layout, kind, "stackTextSize", 14, 6, 40),
        stackX = ReadLaneTextNumber(shared, layout, kind, "stackTextOffsetX", -1, -2000, 2000),
        stackY = ReadLaneTextNumber(shared, layout, kind, "stackTextOffsetY", 1, -2000, 2000),
        cooldownSize = ReadLaneTextNumber(shared, layout, kind, "cooldownTextSize", 14, 6, 40),
        cooldownX = ReadLaneTextNumber(shared, layout, kind, "cooldownTextOffsetX", 0, -2000, 2000),
        cooldownY = ReadLaneTextNumber(shared, layout, kind, "cooldownTextOffsetY", 0, -2000, 2000),
        showDurationBar = ReadLaneTextBool(shared, ls, kind, "showDurationBar", false),
        durationBarHeight = ReadLaneTextNumber(shared, layout, kind, "durationBarHeight", 2, 1, 16),
        durationBarDisplay = ReadLaneTextString(shared, ls, kind, "durationBarDisplay", "BAR_ONLY") == "OVERLAY" and "OVERLAY" or "BAR_ONLY",
        durationBarPosition = ReadLaneTextString(shared, ls, kind, "durationBarPosition", "BOTTOM") == "TOP" and "TOP" or "BOTTOM",
        durationBarDirection = ReadLaneTextString(shared, ls, kind, "durationBarDirection", "REMAINING") == "ELAPSED" and "ELAPSED" or "REMAINING",
        stackAnchor = ReadLaneTextAnchor(shared, ls, kind),
        cooldownAnchor = ReadLaneCooldownTextAnchor(shared, ls, kind),
    }
end

local function ReadGroupConfig(unit, kind)
    kind = NormalizeKind(kind)
    local spec = GROUPS[kind]
    if spec and spec.customIndex then
        local item = CustomItem(unit, spec.customIndex, false)
        local placed = item and item.placed or {}
        local auraType = item and item.auraType == "DEBUFF" and "DEBUFF" or "BUFF"
        return {
            x = Round(placed.x or 0),
            y = Round(placed.y or 0),
            anchor = placed.anchor or spec.defaultAnchor,
            layer = Clamp(item and item.layer, spec.defaultLayer, 0, 30),
            size = Clamp(placed.size, 24, 1, 128),
            spacing = Clamp(placed.spacing, 2, 0, 64),
            perRow = Clamp(placed.perRow, 4, 1, 40),
            max = Clamp(placed.max, 8, 0, 40),
            growth = placed.growth or "LEFTDOWN",
            rowWrap = "DOWN",
            show = item and item.enabled == true or false,
            texture = auraType == "DEBUFF" and "Interface\\Icons\\Spell_Shadow_ShadowWordPain" or "Interface\\Icons\\Spell_Holy_WordFortitude",
        }
    end
    local auras, shared = EnsureDB()
    local layout = GetLayout(auras, unit, false)
    local ls = GetSharedLayout(auras, unit)

    local baseX = ReadNumber(shared, layout, "offsetX", 0, -4096, 4096)
    local baseY = ReadNumber(shared, layout, "offsetY", 6, -4096, 4096)
    local hasX = (layout and type(layout[spec.xKey]) == "number") or (shared and type(shared[spec.xKey]) == "number")
    local hasY = (layout and type(layout[spec.yKey]) == "number") or (shared and type(shared[spec.yKey]) == "number")
    local x = (layout and type(layout[spec.xKey]) == "number" and layout[spec.xKey])
        or (shared and type(shared[spec.xKey]) == "number" and shared[spec.xKey])
        or baseX
    local y = (layout and type(layout[spec.yKey]) == "number" and layout[spec.yKey])
        or (shared and type(shared[spec.yKey]) == "number" and shared[spec.yKey])
        or baseY
    if kind == "buff" and not hasY then
        y = baseY + Clamp(shared and shared.buffOffsetY, 30, -4096, 4096)
    end
    if not hasX and kind == "buff" then
        x = baseX + Clamp(shared and shared.buffOffsetX, 0, -4096, 4096)
    end

    local size = (layout and type(layout[spec.sizeKey]) == "number" and layout[spec.sizeKey])
        or (shared and type(shared[spec.sizeKey]) == "number" and shared[spec.sizeKey])
        or (layout and type(layout.iconSize) == "number" and layout.iconSize)
        or (shared and type(shared.iconSize) == "number" and shared.iconSize)
        or 26

    local spacing = ReadNumber(shared, layout, "spacing", 2, 0, 64)
    local perRow = (ls and type(ls[spec.perRowKey]) == "number" and ls[spec.perRowKey])
        or (shared and type(shared[spec.perRowKey]) == "number" and shared[spec.perRowKey])
        or (ls and type(ls.perRow) == "number" and ls.perRow)
        or (shared and shared.perRow)
        or 12
    local maxN = (ls and type(ls[spec.maxKey]) == "number" and ls[spec.maxKey])
        or (shared and shared[spec.maxKey])
        or (shared and shared.maxIcons)
        or 12
    local growth = (ls and ls[spec.growthKey])
        or (shared and shared[spec.growthKey])
        or (ls and ls.growth)
        or (shared and shared.growth)
        or "RIGHT"
    if growth ~= "RIGHT" and growth ~= "LEFT" and growth ~= "UP" and growth ~= "DOWN" then growth = "RIGHT" end
    local rowWrap = (ls and ls[spec.wrapKey])
        or (shared and shared[spec.wrapKey])
        or (ls and ls.rowWrap)
        or (shared and shared.rowWrap)
        or "DOWN"
    if rowWrap ~= "UP" and rowWrap ~= "DOWN" then rowWrap = "DOWN" end
    local anchor = (layout and layout[spec.anchorKey])
        or (shared and shared[spec.anchorKey])
        or spec.defaultAnchor
        or "TOPLEFT"
    if anchor ~= "TOPLEFT" and anchor ~= "TOPRIGHT" and anchor ~= "BOTTOMLEFT" and anchor ~= "BOTTOMRIGHT" and anchor ~= "CENTER" then
        anchor = spec.defaultAnchor or "TOPLEFT"
    end
    local layer = (layout and layout[spec.layerKey] ~= nil and layout[spec.layerKey])
        or (shared and shared[spec.layerKey])
        or spec.defaultLayer
        or 5

    return {
        x = Round(x),
        y = Round(y),
        anchor = anchor,
        layer = Clamp(layer, spec.defaultLayer or 5, 0, 30),
        size = Clamp(size, 26, 1, 128),
        spacing = spacing,
        perRow = Clamp(perRow, 12, 1, 40),
        max = Clamp(maxN, 12, 0, 80),
        growth = growth,
        rowWrap = rowWrap,
        show = shared and shared[spec.showKey] ~= false,
    }
end

local AnchorOffset

local function AnchorBase(anchor, frame)
    local w = frame and frame.GetWidth and frame:GetWidth() or 0
    local h = frame and frame.GetHeight and frame:GetHeight() or 0
    return AnchorOffset(anchor, w, h)
end

AnchorOffset = function(anchor, w, h)
    w = tonumber(w) or 0
    h = tonumber(h) or 0
    anchor = tostring(anchor or "TOPLEFT")
    if anchor == "TOPRIGHT" then return w, h end
    if anchor == "BOTTOMLEFT" then return 0, 0 end
    if anchor == "BOTTOMRIGHT" then return w, 0 end
    if anchor == "CENTER" then return w * 0.5, h * 0.5 end
    return 0, h
end

local function ButtonAnchor(xSign, ySign)
    if ySign > 0 then
        return xSign < 0 and "BOTTOMRIGHT" or "BOTTOMLEFT"
    end
    return xSign < 0 and "TOPRIGHT" or "TOPLEFT"
end

local function GrowthParts(growth, rowWrap)
    if growth == "LEFTUP" then return -1, 1, false, "BOTTOMRIGHT" end
    if growth == "LEFTDOWN" then return -1, -1, false, "TOPRIGHT" end
    if growth == "RIGHTUP" then return 1, 1, false, "BOTTOMLEFT" end
    if growth == "RIGHTDOWN" then return 1, -1, false, "TOPLEFT" end
    if growth ~= "LEFT" and growth ~= "UP" and growth ~= "DOWN" then growth = "RIGHT" end
    if rowWrap ~= "UP" then rowWrap = "DOWN" end
    local xSign = growth == "LEFT" and -1 or 1
    local ySign = rowWrap == "UP" and 1 or -1
    local vertical = false
    if growth == "UP" or growth == "DOWN" then
        vertical = true
        xSign = 1
        ySign = growth == "UP" and 1 or -1
    end
    return xSign, ySign, vertical, ButtonAnchor(xSign, ySign)
end

local function GridDimensions(maxN, perRow, size, spacing, vertical)
    local count = math_max(Round(maxN), 1)
    local per = math_max(Round(perRow), 1)
    local cols, rows
    if vertical == true then
        rows = math_min(count, per)
        cols = math_ceil(count / per)
    else
        cols = math_min(count, per)
        rows = math_ceil(count / per)
    end
    size = Clamp(size, 26, 1, 128)
    spacing = Clamp(spacing, 2, 0, 64)
    return math_max(1, cols * size + math_max(cols - 1, 0) * spacing),
        math_max(1, rows * size + math_max(rows - 1, 0) * spacing),
        cols,
        rows
end

local function IconGridCoord(index, perRow, vertical)
    local per = math_max(Round(perRow), 1)
    local idx = index - 1
    if vertical == true then
        local row = idx % per
        return (idx - row) / per, row
    end
    local col = idx % per
    return col, (idx - col) / per
end

local function PositionPreviewGroup(group, frame, anchor, x, y, laneW, laneH)
    if not (group and frame) then return end
    local baseX, baseY = AnchorBase(anchor, frame)
    local localX, localY = AnchorOffset(anchor, laneW, laneH)
    group:ClearAllPoints()
    group:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", baseX + (tonumber(x) or 0) - localX, baseY + (tonumber(y) or 0) - localY)
end

local function FallbackMetrics(cfg)
    local xSign, ySign, vertical, initialAnchor = GrowthParts(cfg and cfg.growth, cfg and cfg.rowWrap)
    local laneW, laneH = GridDimensions(cfg and cfg.max, cfg and cfg.perRow, cfg and cfg.size, cfg and cfg.spacing, vertical)
    return {
        enabled = cfg and cfg.show == true,
        num = cfg and cfg.max or 0,
        size = cfg and cfg.size or 26,
        spacing = cfg and cfg.spacing or 2,
        step = ((cfg and cfg.size) or 26) + ((cfg and cfg.spacing) or 2),
        perRow = cfg and cfg.perRow or 12,
        width = laneW,
        height = laneH,
        growthX = xSign,
        growthY = ySign,
        verticalGrowth = vertical == true,
        initialAnchor = initialAnchor,
        x = cfg and cfg.x or 0,
        y = cfg and cfg.y or 0,
        anchor = cfg and cfg.anchor or "TOPLEFT",
    }
end

local function WriteOffset(auras, unit, kind, x, y)
    kind = NormalizeKind(kind)
    local spec = GROUPS[kind]
    if spec and spec.customIndex then
        local item = CustomItem(unit, spec.customIndex, true)
        if not item then return end
        item.placed = type(item.placed) == "table" and item.placed or {}
        item.placed.x = Round(x)
        item.placed.y = Round(y)
        return
    end
    local layout = GetLayout(auras, unit, true)
    if not layout then return end
    layout[spec.xKey] = Round(x)
    layout[spec.yKey] = Round(y)
end

--- Older profiles may store only group-lane offsets/sizes. When a user drags a
--- lane in edit mode, promote the effective runtime position back into the
--- per-unit layout so future edits are stable and visible in the menu model.
local function PromoteRuntimeLayout(unit, kind)
    kind = NormalizeKind(kind or rawget(_G, "MSUF_EM2_ActiveAuraGroup"))
    local spec = GROUPS[kind]
    if spec and spec.customIndex then return end
    local auras, shared = EnsureDB()
    local layout = GetLayout(auras, unit, true)
    if not layout or not shared then return end

    local hasGroupOffset = type(layout[spec.xKey]) == "number" or type(shared[spec.xKey]) == "number"
    if hasGroupOffset then
        local cfg = ReadGroupConfig(unit, kind)
        layout.offsetX = cfg.x
        layout.offsetY = cfg.y
    end

    local hasGroupSize = type(layout[spec.sizeKey]) == "number" or type(shared[spec.sizeKey]) == "number"
    if hasGroupSize then
        layout.iconSize = ReadGroupConfig(unit, kind).size
    end
end

local function ApplyDragUnit(auras, unit, moverKind, x, y)
    WriteOffset(auras, unit, moverKind, x, y)
    local other = EM.groups and EM.groups[unit] and EM.groups[unit][moverKind]
    local frame = other and GetFrame(unit)
    if other and frame then
        local cfg = ReadGroupConfig(unit, moverKind)
        local metrics = type(A3.BuildAuraLaneMetrics) == "function" and A3.BuildAuraLaneMetrics(unit, moverKind) or nil
        metrics = metrics or FallbackMetrics(cfg)
        local laneW = metrics.width or cfg.size
        local laneH = metrics.height or cfg.size
        ApplyGroupScaleForFrame(other, frame)
        PositionPreviewGroup(other, frame, metrics.anchor or cfg.anchor, x, y, laneW, laneH)
    end
end

local function RefreshAffectedRuntimeUnits(unit, shared)
    local started = ProfileStart()
    local count = 0
    if BOSS_UNITS[unit] and shared and shared.bossEditTogether ~= false then
        for i = 1, 5 do
            count = count + 1
            A3.RefreshUnit("boss" .. i)
        end
    elseif unit then
        count = 1
        A3.RefreshUnit(unit)
    end
    ProfileStop("auraEditDrag", "RuntimeRefresh", started, count)
end

local function ShouldFlushDragRuntime(self, elapsed)
    self._dragRuntimeElapsed = (tonumber(self._dragRuntimeElapsed) or AURA_DRAG_RUNTIME_INTERVAL) + (tonumber(elapsed) or 0)
    if self._dragRuntimeElapsed < AURA_DRAG_RUNTIME_INTERVAL then
        self._dragRuntimePending = true
        return false
    end
    self._dragRuntimeElapsed = 0
    self._dragRuntimePending = nil
    return true
end

local function FlushDragRuntime(self, baseUnit, shared, reason, force)
    if not force and not ShouldFlushDragRuntime(self, self and self._lastDragElapsed) then return end
    RefreshAffectedRuntimeUnits(baseUnit, shared)
    RequestUnitFrameMenuPreview(reason or "AURAS3_EDITMODE_DRAG")
    local sync = _G.MSUF_SyncAuras3PositionPopup
    if type(sync) == "function" then sync(baseUnit) end
end

--- Drag writes are throttled by value equality and blocked in combat. Boss aura
--- lanes can be edited together, but the persisted value is still written to
--- each boss unit so the runtime path stays simple.
local function ApplyDragDelta(self, dx, dy, elapsed)
    if IsConfigBlocked() then return end
    local auras = self._dragAuras
    local shared = self._dragShared
    if not (auras and shared) then
        auras, shared = EnsureDB()
        self._dragAuras = auras
        self._dragShared = shared
    end
    if not auras or not shared then return end
    local startX = self._dragStartOffsetX or 0
    local startY = self._dragStartOffsetY or 0
    local x = Round(startX + dx)
    local y = Round(startY + dy)
    if self._lastDragX == x and self._lastDragY == y then return end
    self._lastDragX = x
    self._lastDragY = y
    self._lastDragElapsed = tonumber(elapsed) or 0
    local baseUnit = self._msufA3Unit
    local moverKind = self._msufA3MoverKind

    local previewStarted = ProfileStart()
    if BOSS_UNITS[baseUnit] and shared.bossEditTogether ~= false then
        for i = 1, 5 do
            ApplyDragUnit(auras, "boss" .. i, moverKind, x, y)
        end
    elseif baseUnit then
        ApplyDragUnit(auras, baseUnit, moverKind, x, y)
    end
    ProfileStop("auraEditDrag", "PreviewPosition", previewStarted)

    FlushDragRuntime(self, baseUnit, shared, "AURAS3_EDITMODE_DRAG", false)
end

local function AuraGroupDragOnUpdate(me, elapsed)
    if not me._dragging then
        me:SetScript("OnUpdate", nil)
        if me.SetOnUpdateMode then me:SetOnUpdateMode("Disabled") end
        return
    end
    if IsMouseButtonDown and not IsMouseButtonDown("LeftButton") then
        local handler = me:GetScript("OnMouseUp")
        if handler then handler(me, "LeftButton") end
        return
    end
    local uiScale = UIParent and UIParent.GetEffectiveScale and UIParent:GetEffectiveScale() or 1
    local mx, my = GetCursorPosition()
    if not (mx and my) then return end
    mx, my = mx / uiScale, my / uiScale
    local dx = mx - (me._dragStartCursorX or mx)
    local dy = my - (me._dragStartCursorY or my)
    if not me._dragMoved then
        if (dx * dx + dy * dy) < 9 then return end
        me._dragMoved = true
    end

    local Snap = _G.MSUF_EM2 and _G.MSUF_EM2.Snap
    if Snap and Snap.IsEnabled and Snap.IsEnabled() and Snap.Apply then
        if Snap.HideGuides then Snap.HideGuides() end
        local sx, sy = Snap.Apply((me._snapStartCX or 0) + dx, (me._snapStartCY or 0) + dy, me._snapHW or 0, me._snapHH or 0, me._msufA3SnapName)
        dx = sx - (me._snapStartCX or 0)
        dy = sy - (me._snapStartCY or 0)
    end
    local frameScale = tonumber(me._dragFrameScale) or tonumber(me._msufA3FrameScale) or 1
    if frameScale <= 0 then frameScale = 1 end
    ApplyDragDelta(me, dx / frameScale, dy / frameScale, elapsed)
end

local activeAuraDragGroup
local pendingAuraDragFrame
local pendingAuraDragGroup
local pendingAuraDragStartX
local pendingAuraDragStartY
local auraDragCaptureFrame
local BeginAuraGroupDrag

local function OpenAuraGroupPopup(group)
    if not (group and IsEditModeActive() and not IsConfigBlocked()) then return false end
    local kind = group._msufA3MoverKind
    if type(kind) == "string" and kind:match("^custom[1-3]$") then
        local menu = MSUF and MSUF.MSUF2
        local unit = group._msufA3Unit
        local scope = BOSS_UNITS[unit] and "boss" or unit
        if menu then
            menu.unitAuraTabSelection = menu.unitAuraTabSelection or {}
            menu.unitAuraTabSelection[scope] = kind
            if type(menu.SelectPage) == "function" then
                ExportPublic("MSUF_EM2_MenuFocusRequest", {
                    pageKey = "uf_" .. tostring(scope),
                    sectionId = "auras",
                    component = kind,
                    source = "edit-mode-custom-aura",
                    explicit = true,
                })
                menu.SelectPage("uf_" .. tostring(scope))
                return true
            end
        end
    end
    if type(_G.MSUF_OpenAuras3PositionPopup) ~= "function" then return false end
    ExportPublic("MSUF_EM2_ActiveAuraGroup", group._msufA3MoverKind)
    ExportPublic("MSUF_EM2_ActiveAuraUnit", group._msufA3Unit)
    _G.MSUF_OpenAuras3PositionPopup(group._msufA3Unit, group)
    return true
end

local function StopAuraPendingDrag(group)
    if group and pendingAuraDragGroup and pendingAuraDragGroup ~= group then return end
    pendingAuraDragGroup = nil
    pendingAuraDragStartX = nil
    pendingAuraDragStartY = nil
    if pendingAuraDragFrame then
        pendingAuraDragFrame:SetScript("OnUpdate", nil)
        pendingAuraDragFrame:Hide()
    end
end

local function EnsureAuraPendingDragFrame()
    if pendingAuraDragFrame then return pendingAuraDragFrame end
    pendingAuraDragFrame = CreateFrame("Frame", nil, UIParent)
    pendingAuraDragFrame:Hide()
    return pendingAuraDragFrame
end

local function StopAuraDragCapture(group)
    if group and activeAuraDragGroup and activeAuraDragGroup ~= group then return end
    activeAuraDragGroup = nil
    if auraDragCaptureFrame then
        auraDragCaptureFrame:SetScript("OnUpdate", nil)
        auraDragCaptureFrame:Hide()
    end
end

local function EnsureAuraDragCaptureFrame()
    if auraDragCaptureFrame then return auraDragCaptureFrame end
    auraDragCaptureFrame = CreateFrame("Button", nil, UIParent)
    auraDragCaptureFrame:SetAllPoints(UIParent)
    auraDragCaptureFrame:SetFrameStrata("TOOLTIP")
    auraDragCaptureFrame:SetFrameLevel(1500)
    auraDragCaptureFrame:EnableMouse(true)
    if auraDragCaptureFrame.RegisterForClicks then
        auraDragCaptureFrame:RegisterForClicks("LeftButtonUp")
    end
    auraDragCaptureFrame:SetScript("OnMouseUp", function(_, button)
        local target = activeAuraDragGroup
        local handler = target and target:GetScript("OnMouseUp")
        if handler then handler(target, button) end
    end)
    auraDragCaptureFrame:SetScript("OnHide", function(self)
        self:SetScript("OnUpdate", nil)
    end)
    auraDragCaptureFrame:Hide()
    return auraDragCaptureFrame
end

local function StartAuraDragCapture(group)
    if not group then return end
    activeAuraDragGroup = group
    local capture = EnsureAuraDragCaptureFrame()
    if capture.SetFrameLevel then
        capture:SetFrameLevel(math_max(1500, (group.GetFrameLevel and (group:GetFrameLevel() or 0) or 0) + 200))
    end
    capture:SetScript("OnUpdate", function(_, elapsed)
        local target = activeAuraDragGroup
        if target and target._dragging then
            AuraGroupDragOnUpdate(target, elapsed)
        else
            StopAuraDragCapture(target)
        end
    end)
    capture:Show()
end

local function QueueAuraPendingDrag(group, button)
    if button ~= "LeftButton" or not group or group._dragging then return false end
    if not IsEditModeActive() or IsConfigBlocked() then return false end
    local cx, cy = GetCursorPosition()
    if not (cx and cy) then return false end
    ExportPublic("MSUF_EM2_ActiveAuraGroup", group._msufA3MoverKind)
    ExportPublic("MSUF_EM2_ActiveAuraUnit", group._msufA3Unit)
    group._suppressNextAuraClick = nil
    pendingAuraDragGroup = group
    pendingAuraDragStartX = cx
    pendingAuraDragStartY = cy

    local frame = EnsureAuraPendingDragFrame()
    frame:SetScript("OnUpdate", function()
        local target = pendingAuraDragGroup
        if not target then
            StopAuraPendingDrag()
            return
        end
        if IsMouseButtonDown and not IsMouseButtonDown("LeftButton") then
            StopAuraPendingDrag(target)
            return
        end
        if not IsEditModeActive() or IsConfigBlocked() or (target.IsShown and not target:IsShown()) then
            StopAuraPendingDrag(target)
            return
        end
        local mx, my = GetCursorPosition()
        if not (mx and my) then return end
        if math_max(math_abs(mx - (pendingAuraDragStartX or mx)), math_abs(my - (pendingAuraDragStartY or my))) < AURA_PENDING_DRAG_THRESHOLD then
            return
        end
        StopAuraPendingDrag(target)
        if type(BeginAuraGroupDrag) == "function" then BeginAuraGroupDrag(target, true) end
    end)
    frame:Show()
    return true
end

BeginAuraGroupDrag = function(self, fromMotion)
    if not self or self._dragging then return true end
    StopAuraPendingDrag(self)
    if not IsEditModeActive() or IsConfigBlocked() then return false end
    if self.Raise then self:Raise() end

    ExportPublic("MSUF_EM2_ActiveAuraGroup", self._msufA3MoverKind)
    ExportPublic("MSUF_EM2_ActiveAuraUnit", self._msufA3Unit)

    local before = _G.MSUF_EM_UndoBeforeChange
    if type(before) == "function" and not _G.MSUF__UndoRestoring then
        before("aura", self._msufA3Unit)
    end

    local cfg = ReadGroupConfig(self._msufA3Unit, self._msufA3MoverKind)
    self._dragStartOffsetX = cfg.x
    self._dragStartOffsetY = cfg.y
    self._dragFrameScale = ApplyGroupScaleForFrame(self, GetFrame(self._msufA3Unit))

    local scale = UIParent and UIParent.GetEffectiveScale and UIParent:GetEffectiveScale() or 1
    if not scale or scale == 0 then scale = 1 end
    local cx, cy = GetCursorPosition()
    if not (cx and cy) then return false end
    self._dragStartCursorX = cx / scale
    self._dragStartCursorY = cy / scale
    self._dragMoved = false
    self._dragStartedByMotion = fromMotion == true
    self._dragging = true
    self._lastDragX = nil
    self._lastDragY = nil
    self._dragRuntimeElapsed = AURA_DRAG_RUNTIME_INTERVAL
    self._dragRuntimePending = nil
    self._lastDragElapsed = 0

    local l, r, t, b = self:GetLeft(), self:GetRight(), self:GetTop(), self:GetBottom()
    if not (l and r and t and b) then
        local centerX, centerY = self:GetCenter()
        local w = self:GetWidth() or 0
        local h = self:GetHeight() or 0
        centerX, centerY = centerX or 0, centerY or 0
        l, r = centerX - w * 0.5, centerX + w * 0.5
        b, t = centerY - h * 0.5, centerY + h * 0.5
    end
    self._snapStartCX = (l + r) * 0.5
    self._snapStartCY = (t + b) * 0.5
    self._snapHW = (r - l) * 0.5
    self._snapHH = (t - b) * 0.5

    if self.SetOnUpdateMode then self:SetOnUpdateMode("RunWhenVisible") end
    self:SetScript("OnUpdate", AuraGroupDragOnUpdate)
    StartAuraDragCapture(self)
    return true
end

local function EndAuraGroupDrag(self, button, suppressClick)
    if button and button ~= "LeftButton" then return false end
    StopAuraPendingDrag(self)
    if not self then return false end

    if not self._dragging then
        if self._suppressNextAuraClick then
            self._suppressNextAuraClick = nil
            return false
        end
        if not suppressClick and button == "LeftButton" then OpenAuraGroupPopup(self) end
        return false
    end

    local moved = self._dragMoved == true or self._dragStartedByMotion == true
    self._dragging = false
    self:SetScript("OnUpdate", nil)
    StopAuraDragCapture(self)
    if self.SetOnUpdateMode then self:SetOnUpdateMode("Disabled") end
    self._dragAuras = nil
    self._dragShared = nil
    self._dragRuntimeElapsed = nil
    self._dragRuntimePending = nil
    self._lastDragElapsed = nil
    self._dragStartedByMotion = nil
    local Snap = _G.MSUF_EM2 and _G.MSUF_EM2.Snap
    if Snap and Snap.HideGuides then Snap.HideGuides() end

    if moved then
        local _, shared = EnsureDB()
        RefreshAffectedRuntimeUnits(self._msufA3Unit, shared)
        RequestUnitFrameMenuPreview("AURAS3_EDITMODE_DRAG_END")
        local sync = _G.MSUF_SyncAuras3PositionPopup
        if type(sync) == "function" then sync(self._msufA3Unit) end
        self._lastDragX = nil
        self._lastDragY = nil
        self._dragMoved = false
        self._suppressNextAuraClick = true
        if C_Timer and C_Timer.After then
            C_Timer.After(0, function()
                if self then self._suppressNextAuraClick = nil end
            end)
        end
        return true
    end

    if not suppressClick then OpenAuraGroupPopup(self) end
    self._lastDragX = nil
    self._lastDragY = nil
    return false
end

local function ApplyGlobalFont(fs, size)
    if not fs then return end
    local fontPath, fontFlags, r, g, b, _, useShadow
    local gfs = _G.MSUF_GetGlobalFontSettings
    if type(gfs) == "function" then
        fontPath, fontFlags, r, g, b, _, useShadow = gfs()
    end
    fontPath = fontPath or _G.STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF"
    fontFlags = fontFlags or "OUTLINE"
    if fs.SetFont then
        size = tonumber(size) or 14
        if size <= 0 then size = 14 end
        if size < 6 then size = 6 elseif size > 40 then size = 40 end
        pcall(fs.SetFont, fs, fontPath, size, fontFlags)
    end
    if fs.SetTextColor then fs:SetTextColor(r or 1, g or 1, b or 1, 1) end
    if fs.SetShadowOffset then
        if useShadow then fs:SetShadowOffset(1, -1) else fs:SetShadowOffset(0, 0) end
    end
end

local function PlaceStackText(fs, owner, cfg)
    if not fs or not owner or not cfg then return end
    fs:ClearAllPoints()
    if cfg.stackAnchor == "TOPLEFT" then
        fs:SetPoint("TOPLEFT", owner, "TOPLEFT", cfg.stackX, cfg.stackY)
        fs:SetJustifyH("LEFT")
        fs:SetJustifyV("TOP")
    elseif cfg.stackAnchor == "BOTTOMLEFT" then
        fs:SetPoint("BOTTOMLEFT", owner, "BOTTOMLEFT", cfg.stackX, cfg.stackY)
        fs:SetJustifyH("LEFT")
        fs:SetJustifyV("BOTTOM")
    elseif cfg.stackAnchor == "BOTTOMRIGHT" then
        fs:SetPoint("BOTTOMRIGHT", owner, "BOTTOMRIGHT", cfg.stackX, cfg.stackY)
        fs:SetJustifyH("RIGHT")
        fs:SetJustifyV("BOTTOM")
    else
        fs:SetPoint("TOPRIGHT", owner, "TOPRIGHT", cfg.stackX, cfg.stackY)
        fs:SetJustifyH("RIGHT")
        fs:SetJustifyV("TOP")
    end
end

local function PlaceCooldownText(fs, owner, cfg)
    if not fs or not owner or not cfg then return end
    fs:ClearAllPoints()
    local anchor = AURA_TEXT_ANCHOR_OK[cfg.cooldownAnchor] and cfg.cooldownAnchor or "CENTER"
    fs:SetPoint(anchor, owner, anchor, cfg.cooldownX or 0, cfg.cooldownY or 0)
    if anchor == "TOPLEFT" or anchor == "LEFT" or anchor == "BOTTOMLEFT" then
        fs:SetJustifyH("LEFT")
    elseif anchor == "TOPRIGHT" or anchor == "RIGHT" or anchor == "BOTTOMRIGHT" then
        fs:SetJustifyH("RIGHT")
    else
        fs:SetJustifyH("CENTER")
    end
    if anchor == "TOPLEFT" or anchor == "TOP" or anchor == "TOPRIGHT" then
        fs:SetJustifyV("TOP")
    elseif anchor == "BOTTOMLEFT" or anchor == "BOTTOM" or anchor == "BOTTOMRIGHT" then
        fs:SetJustifyV("BOTTOM")
    else
        fs:SetJustifyV("MIDDLE")
    end
end

local function SafeFrameCall(frame, methodName, ...)
    local method = frame and frame[methodName]
    if type(method) ~= "function" then return nil end
    local ok, value = pcall(method, frame, ...)
    if ok then return value end
    return nil
end

local function IsSecretValue(value)
    if type(issecretvalue) ~= "function" then return true end
    local ok, secret = pcall(issecretvalue, value)
    if not ok then return true end
    return secret == true
end

local function SafeFrameBool(frame, methodName)
    local value = SafeFrameCall(frame, methodName)
    if value == nil then return nil end
    if IsSecretValue(value) then return nil end
    return value == true
end

local function StoreAuraMouseState(frame)
    if not frame or frame._msufA3EditMouseStored == true then return end
    frame._msufA3EditMouseStored = true
    frame._msufA3EditMouseEnabled = SafeFrameBool(frame, "IsMouseEnabled")
    frame._msufA3EditClickEnabled = SafeFrameBool(frame, "IsMouseClickEnabled")
    frame._msufA3EditMotionEnabled = SafeFrameBool(frame, "IsMouseMotionEnabled")
    frame._msufA3EditPropagateClicks = SafeFrameBool(frame, "GetPropagateMouseClicks")
end

local function SuppressAuraMouse(frame, forwardClicks)
    if not frame then return end
    StoreAuraMouseState(frame)
    local hasClick = type(frame.SetMouseClickEnabled) == "function"
    local hasMotion = type(frame.SetMouseMotionEnabled) == "function"
    if type(frame.EnableMouse) == "function" then
        SafeFrameCall(frame, "EnableMouse", forwardClicks ~= false)
    end
    if hasClick then SafeFrameCall(frame, "SetMouseClickEnabled", forwardClicks ~= false) end
    if hasMotion then SafeFrameCall(frame, "SetMouseMotionEnabled", false) end
    if forwardClicks ~= false then
        SafeFrameCall(frame, "SetPropagateMouseClicks", true)
        frame._msufA3EditPropagateChanged = true
    end
end

local function RestoreAuraMouse(frame, motionEnabled, fallbackClickEnabled)
    if not frame then return end
    local stored = frame._msufA3EditMouseStored == true
    local mouseEnabled = frame._msufA3EditMouseEnabled
    local clickEnabled = frame._msufA3EditClickEnabled
    local storedMotionEnabled = frame._msufA3EditMotionEnabled
    local propagateClicks = frame._msufA3EditPropagateClicks
    local propagateChanged = frame._msufA3EditPropagateChanged

    frame._msufA3EditMouseStored = nil
    frame._msufA3EditMouseEnabled = nil
    frame._msufA3EditClickEnabled = nil
    frame._msufA3EditMotionEnabled = nil
    frame._msufA3EditPropagateClicks = nil
    frame._msufA3EditPropagateChanged = nil

    local hasClick = type(frame.SetMouseClickEnabled) == "function"
    local hasMotion = type(frame.SetMouseMotionEnabled) == "function"
    if type(frame.EnableMouse) == "function" then
        if mouseEnabled ~= nil then
            SafeFrameCall(frame, "EnableMouse", mouseEnabled)
        elseif not hasClick and not hasMotion then
            if stored ~= true or fallbackClickEnabled == true then
                mouseEnabled = true
            elseif motionEnabled ~= nil then
                mouseEnabled = motionEnabled
            else
                mouseEnabled = false
            end
            SafeFrameCall(frame, "EnableMouse", mouseEnabled)
        end
    end
    if hasClick then
        if clickEnabled == nil then clickEnabled = fallbackClickEnabled == true end
        SafeFrameCall(frame, "SetMouseClickEnabled", clickEnabled)
    end
    if hasMotion then
        if motionEnabled == nil then motionEnabled = storedMotionEnabled end
        if motionEnabled == nil then motionEnabled = false end
        SafeFrameCall(frame, "SetMouseMotionEnabled", motionEnabled)
    end
    if propagateClicks ~= nil then
        SafeFrameCall(frame, "SetPropagateMouseClicks", propagateClicks)
    elseif propagateChanged == true then
        SafeFrameCall(frame, "SetPropagateMouseClicks", false)
    end
end

local function NativeAuraEditGroup(frame)
    if not frame or not IsEditModeActive() then return nil end
    local container = frame._msufA3EditForwardContainer or frame
    local laneKind = container._msufA3NativeLane
    if laneKind == "buffs" then laneKind = "buff" end
    if laneKind == "debuffs" then laneKind = "debuff" end
    if laneKind ~= "buff" and laneKind ~= "debuff" then return nil end
    local lane = container._msufA3NativeLaneConfig
    local unit = (lane and lane.unit) or container.unit
    return unit and EM.groups and EM.groups[unit] and EM.groups[unit][laneKind] or nil
end

local function ForwardNativeAuraMouse(frame, scriptName, button)
    if button ~= "LeftButton" and button ~= "RightButton" then return end
    local group = NativeAuraEditGroup(frame)
    local handler = group and group:GetScript(scriptName)
    if handler then handler(group, button) end
end

local function WireNativeAuraEditForward(frame, container)
    if not (frame and frame.HookScript) then return false end
    if frame._msufA3EditDragForwardHooked == true then return true end
    frame._msufA3EditForwardContainer = container or frame
    local okDown = pcall(frame.HookScript, frame, "OnMouseDown", function(self, button)
        ForwardNativeAuraMouse(self, "OnMouseDown", button)
    end)
    local okUp = pcall(frame.HookScript, frame, "OnMouseUp", function(self, button)
        ForwardNativeAuraMouse(self, "OnMouseUp", button)
    end)
    frame._msufA3EditDragForwardHooked = (okDown or okUp) and true or nil
    return frame._msufA3EditDragForwardHooked == true
end

local function SetLaneMouseSuppressed(element, container, suppressed)
    if not container then return end
    local laneKind = container._msufA3NativeLane
    local forwardKind = laneKind
    if forwardKind == "buffs" then forwardKind = "buff" end
    if forwardKind == "debuffs" then forwardKind = "debuff" end
    local canForward = forwardKind == "buff" or forwardKind == "debuff"
    local laneCfg = element and element._msufA3Config and element._msufA3Config.lanes and element._msufA3Config.lanes[laneKind]
    local motionEnabled = not laneCfg or laneCfg.showTooltip ~= false
    if suppressed then
        if canForward then WireNativeAuraEditForward(container, container) end
        SuppressAuraMouse(container, canForward)
    else
        RestoreAuraMouse(container, nil, false)
    end

    local count = type(container.GetAuraFrameCount) == "function" and container:GetAuraFrameCount() or tonumber(container.createdButtons) or 0
    for i = 1, count do
        local ok, button
        if type(container.GetAuraFrame) == "function" then
            ok, button = pcall(container.GetAuraFrame, container, i)
        end
        if not button then button = container[i] end
        if ok ~= false and button then
            if suppressed then
                if canForward then WireNativeAuraEditForward(button, container) end
                SuppressAuraMouse(button, canForward)
            else
                RestoreAuraMouse(button, motionEnabled, true)
            end
        end
    end
end

local function SetRuntimeAuraHidden(unit, hidden)
    local frame = GetFrame(unit)
    local element = frame and frame.Auras
    if not element or not element.SetAlpha then return end
    if hidden then
        if element._msufA3EditModeAlpha == nil and element.GetAlpha then
            element._msufA3EditModeAlpha = element:GetAlpha()
        end
        element:SetAlpha(0)
        SetLaneMouseSuppressed(element, element.Buffs, true)
        SetLaneMouseSuppressed(element, element.Debuffs, true)
        SetLaneMouseSuppressed(element, element.Externals, true)
    elseif element._msufA3EditModeAlpha ~= nil then
        element:SetAlpha(element._msufA3EditModeAlpha)
        element._msufA3EditModeAlpha = nil
        SetLaneMouseSuppressed(element, element.Buffs, false)
        SetLaneMouseSuppressed(element, element.Debuffs, false)
        SetLaneMouseSuppressed(element, element.Externals, false)
    end
end

local function StyleLabel(fs)
    ApplyGlobalFont(fs, 11)
    if fs and fs.SetTextColor then fs:SetTextColor(0.92, 0.96, 1, 0.95) end
end

local function EnsureIcon(group, index)
    local icons = group._icons
    if not icons then
        icons = {}
        group._icons = icons
    end
    local icon = icons[index]
    if icon then return icon end

    icon = CreateFrame("Frame", nil, group.Body or group, "BackdropTemplate")
    icon:SetBackdrop({ bgFile = W8, edgeFile = W8, edgeSize = 1 })
    icon:SetBackdropColor(0, 0, 0, 0)
    icon:SetBackdropBorderColor(0, 0, 0, 0)

    local tex = icon:CreateTexture(nil, "BORDER")
    tex:SetAllPoints(icon)
    tex:SetTexCoord(0, 1, 0, 1)
    icon.Icon = tex

    local shade = icon:CreateTexture(nil, "ARTWORK")
    shade:SetAllPoints(icon)
    shade:SetColorTexture(0, 0, 0, 0)
    icon.Shade = shade

    local durationBar = icon:CreateTexture(nil, "OVERLAY")
    durationBar:SetTexture(W8)
    durationBar:SetVertexColor(0.08, 0.78, 1, 0.92)
    durationBar:Hide()
    icon.DurationBar = durationBar

    local cd = icon:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    cd:SetPoint("CENTER", icon, "CENTER", 0, 0)
    ApplyGlobalFont(cd, 14)
    cd:SetText("1m")
    icon.CooldownText = cd

    local count = icon:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    ApplyGlobalFont(count, 14)
    count:SetText(index == 1 and "3" or "")
    icon.Count = count

    icons[index] = icon
    return icon
end

local function ApplyPreviewIconText(icon, unit, cfg)
    cfg = cfg or ReadTextConfig(unit)
    local barOnly = cfg.showDurationBar == true and cfg.durationBarDisplay == "BAR_ONLY"
    if icon.Icon then icon.Icon:SetShown(not barOnly) end
    if icon.Shade then icon.Shade:SetShown(not barOnly) end
    if icon.Count then
        ApplyGlobalFont(icon.Count, cfg.stackSize)
        PlaceStackText(icon.Count, icon, cfg)
    end
    if icon.CooldownText then
        ApplyGlobalFont(icon.CooldownText, cfg.cooldownSize)
        PlaceCooldownText(icon.CooldownText, icon, cfg)
    end
    if icon.DurationBar then
        if cfg.showDurationBar == true then
            local height = Clamp(cfg.durationBarHeight, 2, 1, 16)
            local inset = 1
            icon.DurationBar:ClearAllPoints()
            icon.DurationBar:SetHeight(height)
            if cfg.durationBarPosition == "TOP" then
                icon.DurationBar:SetPoint("TOPLEFT", icon, "TOPLEFT", inset, -inset)
                icon.DurationBar:SetPoint("TOPRIGHT", icon, "TOPRIGHT", -inset, -inset)
            else
                icon.DurationBar:SetPoint("BOTTOMLEFT", icon, "BOTTOMLEFT", inset, inset)
                icon.DurationBar:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -inset, inset)
            end
            if cfg.durationBarDirection == "ELAPSED" then
                icon.DurationBar:SetVertexColor(0.22, 0.88, 0.50, 0.92)
            else
                icon.DurationBar:SetVertexColor(0.08, 0.78, 1, 0.92)
            end
            icon.DurationBar:Show()
        else
            icon.DurationBar:Hide()
        end
    end
end

local function PreviewAuraState(kind, index, icon)
    local fn = _G.MSUF_GetPreviewAnimationAuraState
    if type(fn) ~= "function" then return nil end
    icon._msufA3PreviewAuraScratch = icon._msufA3PreviewAuraScratch or {}
    return fn(kind, index, icon._msufA3PreviewAuraScratch)
end

local function ApplyPreviewDurationBarProgress(icon, cfg, auraState)
    local bar = icon and icon.DurationBar
    if not (bar and cfg and cfg.showDurationBar == true) then
        if bar then bar:Hide() end
        return
    end
    local size = math_max(1, (icon.GetWidth and icon:GetWidth()) or 1)
    local height = Clamp(cfg.durationBarHeight, 2, 1, math_max(1, size))
    local inset = math_max(1, math_floor(size / 32 + 0.5))
    local frac
    if cfg.durationBarDirection == "ELAPSED" then
        frac = auraState and auraState.elapsedFrac or 1
        bar:SetVertexColor(0.22, 0.88, 0.50, 0.92)
    else
        frac = auraState and auraState.remainingFrac or 1
        bar:SetVertexColor(0.08, 0.78, 1, 0.92)
    end
    frac = math_max(0.02, math_min(1, tonumber(frac) or 1))
    bar:ClearAllPoints()
    bar:SetHeight(height)
    if auraState then
        bar:SetWidth(math_max(1, math_floor(math_max(1, size - inset * 2) * frac + 0.5)))
        if cfg.durationBarPosition == "TOP" then
            bar:SetPoint("TOPLEFT", icon, "TOPLEFT", inset, -inset)
        else
            bar:SetPoint("BOTTOMLEFT", icon, "BOTTOMLEFT", inset, inset)
        end
    elseif cfg.durationBarPosition == "TOP" then
        bar:SetPoint("TOPLEFT", icon, "TOPLEFT", inset, -inset)
        bar:SetPoint("TOPRIGHT", icon, "TOPRIGHT", -inset, -inset)
    else
        bar:SetPoint("BOTTOMLEFT", icon, "BOTTOMLEFT", inset, inset)
        bar:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -inset, inset)
    end
    bar:Show()
end

local function ApplyPreviewAuraAnimation(group, kind, shownIcons, textCfg)
    local icons = group and group._icons
    if not icons then return end
    for i = 1, shownIcons do
        local icon = icons[i]
        if icon then
            local auraState = PreviewAuraState(kind, i, icon)
            if icon.Count then icon.Count:SetText(auraState and auraState.stacks or (i == 1 and "3" or "")) end
            if icon.CooldownText then icon.CooldownText:SetText(auraState and auraState.text or (i == 1 and "1m" or "32")) end
            ApplyPreviewDurationBarProgress(icon, textCfg, auraState)
        end
    end
end

local function CreateGroup(unit, kind)
    kind = NormalizeKind(kind)
    EM.groups = EM.groups or {}
    local byUnit = EM.groups[unit]
    if not byUnit then
        byUnit = {}
        EM.groups[unit] = byUnit
    end
    if byUnit[kind] then return byUnit[kind] end

    local spec = GROUPS[kind]
    local snapName = "AuraPreview:" .. tostring(unit) .. ":" .. tostring(kind)
    local group = CreateFrame("Button", nil, UIParent, "BackdropTemplate")
    group:SetFrameStrata("TOOLTIP")
    group:SetFrameLevel(900)
    group:SetClampedToScreen(false)
    group:SetMovable(true)
    group:EnableMouse(true)
    if group.SetMouseClickEnabled then group:SetMouseClickEnabled(true) end
    if group.SetMouseMotionEnabled then group:SetMouseMotionEnabled(true) end
    if group.RegisterForClicks then group:RegisterForClicks("LeftButtonUp", "RightButtonUp") end
    if group.RegisterForDrag then group:RegisterForDrag("LeftButton") end
    group:SetBackdrop({ bgFile = W8, edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 12, insets = { left = 2, right = 2, top = 2, bottom = 2 } })
    group:SetBackdropColor(0.02, 0.03, 0.08, 0.28)
    group:SetBackdropBorderColor(spec.color[1], spec.color[2], spec.color[3], 0.72)

    local header = CreateFrame("Frame", nil, group, "BackdropTemplate")
    header:SetPoint("TOPLEFT", group, "TOPLEFT", 2, -2)
    header:SetPoint("TOPRIGHT", group, "TOPRIGHT", -2, -2)
    header:SetHeight(HEADER_H)
    header:SetBackdrop({ bgFile = W8 })
    header:SetBackdropColor(spec.color[1], spec.color[2], spec.color[3], spec.color[4])
    group.Header = header

    local label = header:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    label:SetPoint("LEFT", header, "LEFT", 6, 0)
    label:SetPoint("RIGHT", header, "RIGHT", -6, 0)
    label:SetJustifyH("LEFT")
    StyleLabel(label)
    label:SetText(UnitLabel(unit) .. " " .. spec.label)
    group.Label = label

    local body = CreateFrame("Frame", nil, group)
    body:SetPoint("BOTTOMLEFT", group, "BOTTOMLEFT", 0, 0)
    body:SetSize(1, 1)
    group.Body = body

    group._msufA3Unit = unit
    group._msufA3MoverKind = kind
    group._msufA3SnapName = snapName
    group:Hide()

    group:SetScript("OnMouseDown", function(self, button)
        if button == "RightButton" then
            StopAuraPendingDrag(self)
            OpenAuraGroupPopup(self)
            return
        end
        QueueAuraPendingDrag(self, button)
    end)

    group:SetScript("OnMouseUp", function(self, button)
        EndAuraGroupDrag(self, button)
    end)
    group:SetScript("OnDragStart", function(self)
        BeginAuraGroupDrag(self, true)
    end)
    group:SetScript("OnDragStop", function(self)
        EndAuraGroupDrag(self, "LeftButton")
    end)
    group:SetScript("OnHide", function(self)
        EndAuraGroupDrag(self, "LeftButton", true)
    end)

    local hitbox = CreateFrame("Button", nil, group)
    hitbox:SetAllPoints(group)
    hitbox:EnableMouse(true)
    if hitbox.SetMouseClickEnabled then hitbox:SetMouseClickEnabled(true) end
    if hitbox.SetMouseMotionEnabled then hitbox:SetMouseMotionEnabled(true) end
    if hitbox.RegisterForClicks then hitbox:RegisterForClicks("LeftButtonUp", "RightButtonUp") end
    if hitbox.RegisterForDrag then hitbox:RegisterForDrag("LeftButton") end
    hitbox._msufA3HitboxOwner = group
    hitbox:SetScript("OnMouseDown", function(self, button)
        local owner = self._msufA3HitboxOwner or self:GetParent()
        if button == "RightButton" then
            StopAuraPendingDrag(owner)
            OpenAuraGroupPopup(owner)
        else
            QueueAuraPendingDrag(owner, button)
        end
    end)
    hitbox:SetScript("OnMouseUp", function(self, button)
        local owner = self._msufA3HitboxOwner or self:GetParent()
        EndAuraGroupDrag(owner, button)
    end)
    hitbox:SetScript("OnDragStart", function(self)
        local owner = self._msufA3HitboxOwner or self:GetParent()
        BeginAuraGroupDrag(owner, true)
    end)
    hitbox:SetScript("OnDragStop", function(self)
        local owner = self._msufA3HitboxOwner or self:GetParent()
        EndAuraGroupDrag(owner, "LeftButton")
    end)
    group.Hitbox = hitbox

    byUnit[kind] = group
    return group
end

local function ApplyEditModeCustomEffect(group, frame, item)
    local effect = item and item.frame
    local kind = type(effect) == "table" and tostring(effect.type or "none"):lower() or "none"
    local root = group and group._msufA3CustomEffectPreview
    if kind == "none" or kind == "namecolor" then
        if root then root:Hide() end
        return
    end
    if not root then
        root = CreateFrame("Frame", nil, group)
        root:EnableMouse(false)
        root.tint = root:CreateTexture(nil, "OVERLAY")
        root.tint:SetTexture(W8)
        root.edges = {}
        for i = 1, 4 do
            root.edges[i] = root:CreateTexture(nil, "OVERLAY")
            root.edges[i]:SetTexture(W8)
        end
        group._msufA3CustomEffectPreview = root
    end
    root:ClearAllPoints()
    root:SetAllPoints(frame)
    if root.SetFrameLevel then root:SetFrameLevel((group:GetFrameLevel() or 900) + 24 + (11 - Clamp(effect.priority, 5, 1, 10))) end
    root.tint:Hide()
    for i = 1, 4 do root.edges[i]:Hide() end
    local color = effect.color or {}
    local r, g, b, a = color[1] or 1, color[2] or 1, color[3] or 1, color[4] or 0.8
    if kind == "healthtint" then
        root.tint:ClearAllPoints()
        root.tint:SetAllPoints(frame.hpBar or frame.Health or frame)
        root.tint:SetVertexColor(r, g, b, tonumber(effect.tintAlpha) or a)
        root.tint:Show()
    elseif kind == "border" or kind == "glow" or kind == "pulse" then
        local thickness = Clamp(effect.thickness, kind == "glow" and 3 or 2, 1, 16)
        local top, bottom, left, right = root.edges[1], root.edges[2], root.edges[3], root.edges[4]
        top:ClearAllPoints(); top:SetPoint("TOPLEFT", frame, "TOPLEFT", -thickness, thickness); top:SetPoint("TOPRIGHT", frame, "TOPRIGHT", thickness, thickness); top:SetHeight(thickness)
        bottom:ClearAllPoints(); bottom:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", -thickness, -thickness); bottom:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", thickness, -thickness); bottom:SetHeight(thickness)
        left:ClearAllPoints(); left:SetPoint("TOPLEFT", top, "BOTTOMLEFT"); left:SetPoint("BOTTOMLEFT", bottom, "TOPLEFT"); left:SetWidth(thickness)
        right:ClearAllPoints(); right:SetPoint("TOPRIGHT", top, "BOTTOMRIGHT"); right:SetPoint("BOTTOMRIGHT", bottom, "TOPRIGHT"); right:SetWidth(thickness)
        for i = 1, 4 do root.edges[i]:SetVertexColor(r, g, b, kind == "glow" and math_min(1, a * 0.85) or a); root.edges[i]:Show() end
    end
    root:Show()
end

local function FrameRefreshSignature(frame)
    if not frame then return "noframe" end
    local l = frame.GetLeft and frame:GetLeft() or 0
    local t = frame.GetTop and frame:GetTop() or 0
    local w = frame.GetWidth and frame:GetWidth() or 0
    local h = frame.GetHeight and frame:GetHeight() or 0
    return tostring(Round(l or 0)) .. "\031" .. tostring(Round(t or 0))
        .. "\031" .. tostring(Round(w or 0)) .. "\031" .. tostring(Round(h or 0))
        .. "\031" .. tostring(Round(FrameScaleRelativeToUIParent(frame) * 1000))
end

local function EditModeThemeRevision()
    local menu = MSUF and MSUF.MSUF2
    return tostring((menu and (menu._msuf2MenuDataRevision or menu._msuf2LayoutVersion)) or 0)
end

local function RefreshSignature(unit, kind, cfg, metrics, textCfg, shownIcons, size, step, perRow, laneW, laneH, growthX, growthY, vertical, initialAnchor, x, y, anchor, frameSig)
    return tostring(unit) .. "\030" .. tostring(kind) .. "\030" .. tostring(frameSig)
        .. "\030" .. EditModeThemeRevision()
        .. "\030" .. tostring(cfg and cfg.show) .. "\030" .. tostring(cfg and cfg.max)
        .. "\030" .. tostring(cfg and cfg.layer) .. "\030" .. tostring(cfg and cfg.spacing)
        .. "\030" .. tostring(shownIcons) .. "\030" .. tostring(size)
        .. "\030" .. tostring(step) .. "\030" .. tostring(perRow)
        .. "\030" .. tostring(laneW) .. "\030" .. tostring(laneH)
        .. "\030" .. tostring(growthX) .. "\030" .. tostring(growthY)
        .. "\030" .. tostring(vertical) .. "\030" .. tostring(initialAnchor)
        .. "\030" .. tostring(x) .. "\030" .. tostring(y) .. "\030" .. tostring(anchor)
        .. "\030" .. tostring(metrics and metrics.enabled)
        .. "\030" .. tostring(textCfg and textCfg.stackSize) .. "\030" .. tostring(textCfg and textCfg.stackX)
        .. "\030" .. tostring(textCfg and textCfg.stackY) .. "\030" .. tostring(textCfg and textCfg.cooldownSize)
        .. "\030" .. tostring(textCfg and textCfg.cooldownX) .. "\030" .. tostring(textCfg and textCfg.cooldownY)
        .. "\030" .. tostring(textCfg and textCfg.stackAnchor) .. "\030" .. tostring(textCfg and textCfg.cooldownAnchor)
        .. "\030" .. tostring(textCfg and textCfg.showDurationBar) .. "\030" .. tostring(textCfg and textCfg.durationBarHeight)
        .. "\030" .. tostring(textCfg and textCfg.durationBarDisplay) .. "\030" .. tostring(textCfg and textCfg.durationBarPosition)
        .. "\030" .. tostring(textCfg and textCfg.durationBarDirection)
end

function EM.HideUnit(unit)
    if IsBossScope(unit) then
        ForEachBossUnit(EM.HideUnit)
        return
    end
    local byUnit = EM.groups and EM.groups[unit]
    SetRuntimeAuraHidden(unit, false)
    if not byUnit then return end
    for _, group in pairs(byUnit) do
        if group then
            group._msufA3RefreshSignature = nil
            group:Hide()
        end
    end
end

function EM.RefreshUnit(unit)
    if not unit then return end
    if IsBossScope(unit) then
        ForEachBossUnit(EM.RefreshUnit)
        return
    end
    if not UnitPreviewActive(unit) then
        EM.HideUnit(unit)
        return
    end

    local auras, shared = EnsureDB()
    if not shared or shared.showInEditMode == false or (not UnitEnabled(auras, unit) and not UnitHasCustomPreview(unit)) then
        EM.HideUnit(unit)
        return
    end

    local frame = GetFrame(unit)
    if not frame then
        EM.HideUnit(unit)
        return
    end

    SetRuntimeAuraHidden(unit, true)
    local frameSig = FrameRefreshSignature(frame)

    for kind, spec in pairs(GROUPS) do
        local cfg = ReadGroupConfig(unit, kind)
        local metrics = type(A3.BuildAuraLaneMetrics) == "function" and A3.BuildAuraLaneMetrics(unit, kind) or nil
        local group = CreateGroup(unit, kind)
        if not (cfg.show and cfg.max > 0 and (not metrics or metrics.enabled ~= false)) then
            group._msufA3RefreshSignature = nil
            group:Hide()
        else
            local textCfg = ReadTextConfig(unit, kind)
            local shownIcons = math_min(PREVIEW_ICONS, (metrics and metrics.num) or cfg.max)
            if shownIcons < 1 then shownIcons = 1 end
            local size = (metrics and metrics.size) or cfg.size
            local step = (metrics and metrics.step) or (cfg.size + cfg.spacing)
            local perRow = (metrics and metrics.perRow) or cfg.perRow
            local fallback
            if not metrics then fallback = FallbackMetrics(cfg) end
            local laneW = (metrics and metrics.width) or (fallback and fallback.width) or cfg.size
            local laneH = (metrics and metrics.height) or (fallback and fallback.height) or cfg.size
            local growthX = (metrics and metrics.growthX) or (fallback and fallback.growthX) or 1
            local growthY = (metrics and metrics.growthY) or (fallback and fallback.growthY) or -1
            local vertical = metrics and metrics.verticalGrowth == true or (fallback and fallback.verticalGrowth == true)
            local initialAnchor = (metrics and metrics.initialAnchor) or (fallback and fallback.initialAnchor) or "TOPLEFT"
            local x = (metrics and metrics.x) or cfg.x
            local y = (metrics and metrics.y) or cfg.y
            local anchor = (metrics and metrics.anchor) or cfg.anchor
            local signature = RefreshSignature(unit, kind, cfg, metrics, textCfg, shownIcons, size, step, perRow, laneW, laneH, growthX, growthY, vertical, initialAnchor, x, y, anchor, frameSig)

            if group._msufA3RefreshSignature ~= signature or not (group.IsShown and group:IsShown()) then
                group._msufA3RefreshSignature = signature
                if group.SetClampedToScreen then group:SetClampedToScreen(false) end
                ApplyGroupScaleForFrame(group, frame)
                PositionPreviewGroup(group, frame, anchor, x, y, laneW, laneH)
                group:SetSize(laneW, laneH + HEADER_H)
                if group.Body then group.Body:SetSize(laneW, laneH) end
                group:SetFrameLevel(900 + (tonumber(cfg.layer) or 5))
                if group.Hitbox and group.Hitbox.SetFrameLevel then
                    group.Hitbox:SetFrameLevel((group:GetFrameLevel() or 0) + 20)
                end
                if group.Label then
                    group.Label:SetText(UnitLabel(unit) .. " " .. spec.label)
                    StyleLabel(group.Label)
                end

                for i = 1, shownIcons do
                    local icon = EnsureIcon(group, i)
                    icon:SetSize(size, size)
                    icon:ClearAllPoints()
                    local col, row = IconGridCoord(i, perRow, vertical)
                    local body = group.Body or group
                    icon:SetPoint(initialAnchor, body, initialAnchor, col * step * growthX, row * step * growthY)
                    if icon.Icon then icon.Icon:SetTexture(cfg.texture or spec.texture) end
                    if icon.Count then icon.Count:SetText(i == 1 and "3" or "") end
                    if icon.CooldownText then icon.CooldownText:SetText(i == 1 and "1m" or "32") end
                    ApplyPreviewIconText(icon, unit, textCfg)
                    icon:Show()
                end

                local icons = group._icons
                if icons then
                    for i = shownIcons + 1, #icons do
                        if icons[i] then icons[i]:Hide() end
                    end
                end
            end

            ApplyPreviewAuraAnimation(group, kind, shownIcons, textCfg)
            if spec.customIndex then ApplyEditModeCustomEffect(group, frame, CustomItem(unit, spec.customIndex, false)) end
            group:Show()
            if group.Raise then group:Raise() end
        end
    end
end

function EM.RefreshAll()
    for i = 1, #AURA_UNITS do
        EM.RefreshUnit(AURA_UNITS[i])
    end
end

function EM.HideAll()
    if not EM.groups then return end
    for unit in pairs(EM.groups) do
        EM.HideUnit(unit)
    end
end

local editRefreshSerial = 0
local function CancelQueuedEditRefresh()
    editRefreshSerial = editRefreshSerial + 1
end

local function RequestEditModeAurasRefresh(delay)
    CancelQueuedEditRefresh()
    if IsConfigBlocked() then return end
    if not IsEditModeActive() then
        EM.HideAll()
        return
    end
    local serial = editRefreshSerial
    if C_Timer and C_Timer.After then
        C_Timer.After(delay or 0, function()
            if serial ~= editRefreshSerial or not IsEditModeActive() then return end
            EM.RefreshAll()
        end)
    else
        EM.RefreshAll()
    end
end

local CoreRefreshAll = A3.RefreshAll
function A3.RefreshAll(...)
    local ret
    if type(CoreRefreshAll) == "function" then ret = CoreRefreshAll(...) end
    if IsConfigBlocked() then return ret end
    RequestEditModeAurasRefresh(0)
    return ret
end

local CoreEnableFrame = A3.EnableFrame
if type(CoreEnableFrame) == "function" then
    function A3.EnableFrame(frame, ...)
        local ret = CoreEnableFrame(frame, ...)
        if frame and frame.unit then
            if UnitPreviewActive(frame.unit) then
                EM.RefreshUnit(frame.unit)
            else
                EM.HideUnit(frame.unit)
            end
        end
        return ret
    end
end

local CoreDisableFrame = A3.DisableFrame
if type(CoreDisableFrame) == "function" then
    function A3.DisableFrame(frame, ...)
        if frame and frame.unit then EM.HideUnit(frame.unit) end
        if type(CoreDisableFrame) == "function" then return CoreDisableFrame(frame, ...) end
    end
end

local CoreRefreshUnit = A3.RefreshUnit
function A3.RefreshUnit(unit)
    if IsConfigBlocked() then
        if type(CoreRefreshUnit) == "function" then return CoreRefreshUnit(unit) end
        return false
    end
    if not unit then return end
    if IsEditModeActive() then
        if IsBossScope(unit) then
            ForEachBossUnit(function(bossUnit)
                PromoteRuntimeLayout(bossUnit, rawget(_G, "MSUF_EM2_ActiveAuraGroup"))
            end)
        else
            PromoteRuntimeLayout(unit, rawget(_G, "MSUF_EM2_ActiveAuraGroup"))
        end
    end
    local frame = GetFrame(unit)
    if frame and frame.Auras then frame.Auras.needFullUpdate = true end
    local ret = CoreRefreshUnit(unit)
    EM.RefreshUnit(unit)
    return ret
end

function A3.UpdateUnitAnchor(unit)
    if not unit then return end
    if IsConfigBlocked() then
        if type(A3._QueueDeferredAuraRuntime) == "function" then
            return A3._QueueDeferredAuraRuntime(unit, "AURAS3_UPDATE_ANCHOR")
        end
        return false
    end
    if IsBossScope(unit) then
        if IsEditModeActive() then
            ForEachBossUnit(function(bossUnit)
                PromoteRuntimeLayout(bossUnit, rawget(_G, "MSUF_EM2_ActiveAuraGroup"))
            end)
        end
        EM.RefreshUnit("boss")
        return
    end
    if IsEditModeActive() then
        PromoteRuntimeLayout(unit, rawget(_G, "MSUF_EM2_ActiveAuraGroup"))
    end
    EM.RefreshUnit(unit)
end

function A3.RefreshEditPreview(unit)
    if IsConfigBlocked() then return false end
    if not IsEditModeActive() then
        if unit then return EM.HideUnit(unit) end
        return EM.HideAll()
    end
    if unit then return EM.RefreshUnit(unit) end
    return EM.RefreshAll()
end

local function OpenAuras3PositionPopup(unit, parent)
    if parent and parent._msufA3MoverKind then
        ExportPublic("MSUF_EM2_ActiveAuraGroup", parent._msufA3MoverKind)
        ExportPublic("MSUF_EM2_ActiveAuraUnit", unit)
    end
    local EM2 = _G.MSUF_EM2
    if EM2 and EM2.Popups then
        return EM2.Popups.Open("aura_" .. tostring(unit or ""), parent)
    elseif EM2 and EM2.AuraPopup then
        return EM2.AuraPopup.Open(unit, parent)
    end
end
ExportPublic("MSUF_OpenAuras3PositionPopup", OpenAuras3PositionPopup)

local function OnEditModeChanged(active)
    if active then
        RequestEditModeAurasRefresh(0.12)
    else
        CancelQueuedEditRefresh()
        EM.HideAll()
        ExportPublic("MSUF_EM2_ActiveAuraGroup", nil)
        ExportPublic("MSUF_EM2_ActiveAuraUnit", nil)
    end
end

local function RegisterEditListener()
    if EM._registered then return end
    local reg = _G.MSUF_RegisterAnyEditModeListener
    if type(reg) == "function" then
        reg(OnEditModeChanged)
        EM._registered = true
        if IsEditModeActive() then OnEditModeChanged(true) end
    end
end

RegisterEditListener()
if not EM._registered then
    C_Timer.After(0, RegisterEditListener)
end
