local addonName, MSUF = ...
MSUF = MSUF or {}
local M = MSUF.MSUF2 or {}
MSUF.MSUF2 = M
local F = M.Fallbacks or {}
local H = M.PreviewHelpers or {}
M.PreviewHelpers = H
local CP = M.ClassPowerPreview or {}
M.ClassPowerPreview = CP

-- Shared Menu2 preview helpers.
-- Centralizes mock class-power colors, shape helpers, and small rendering utilities used by
-- preview modules. Keep preview-only fallbacks here instead of coupling pages to live runtime.
local floor = math.floor
local min = math.min
CP.WHITE8 = CP.WHITE8 or "Interface\\Buttons\\WHITE8X8"
CP.MEDIA = CP.MEDIA or ("Interface\\AddOns\\" .. tostring(addonName or "MidnightSimpleUnitFrames") .. "\\Media\\ClassPower\\")
function CP.ShapeTextures(prefix, axis)
    local tex = { fill = CP.MEDIA .. prefix .. "_fill.tga", bg = CP.MEDIA .. prefix .. "_bg.tga", edge = CP.MEDIA .. prefix .. "_edge.tga" }
    tex.axis = axis
    return tex
end
CP.CLASS_SHAPES = CP.CLASS_SHAPES or {
    CIRCLE = CP.ShapeTextures("pip_circle"),
    DIAMOND = CP.ShapeTextures("pip_diamond"),
    HEX = CP.ShapeTextures("pip_hex"),
}
CP.POWER_SHAPES = CP.POWER_SHAPES or {
    ROUND = CP.ShapeTextures("power_round"),
    CRYSTAL = CP.ShapeTextures("power_crystal"),
    ORB = CP.ShapeTextures("pip_circle", "VERTICAL"),
}
function CP.NormalizeClassShape(value)
    value = tostring(value or "BAR"):upper()
    return (value == "CIRCLE" or value == "DIAMOND" or value == "HEX") and value or "BAR"
end
function CP.ResolvePowerShape(value, classShape)
    value = tostring(value or "BAR"):upper()
    if value == "ROUND" or value == "CRYSTAL" or value == "ORB" or value == "BAR" then return value end
    if value == "FOLLOW_CLASS" then
        classShape = CP.NormalizeClassShape(classShape)
        if classShape == "CIRCLE" then return "ROUND" end
        if classShape == "DIAMOND" or classShape == "HEX" then return "CRYSTAL" end
    end
    return "BAR"
end
CP.FALLBACK_COLORS = CP.FALLBACK_COLORS or {
    ARCANE_CHARGES = { 0.45, 0.55, 1.00 },
    CHARGED = { 0.60, 0.20, 0.80 },
    CHI = { 0.70, 1.00, 0.86 },
    COMBO_POINTS = { 1.00, 0.82, 0.10 },
    EBON_MIGHT = { 0.40, 0.80, 0.60 },
    ESSENCE = { 0.32, 0.74, 1.00 },
    HOLY_POWER = { 0.95, 0.86, 0.20 },
    INSANITY = { 0.55, 0.32, 0.95 },
    MAELSTROM = { 0.00, 0.55, 1.00 },
    MAELSTROM_ABOVE_5 = { 1.00, 0.50, 0.00 },
    RUNES = { 0.55, 0.85, 1.00 },
    SOUL_FRAGMENTS = { 0.00, 0.80, 0.00 },
    SOUL_FRAGMENTS_META = { 0.60, 0.20, 0.93 },
    SOUL_FRAGMENTS_VENG = { 0.34, 0.06, 0.46 },
    SOUL_SHARDS = { 0.58, 0.28, 0.92 },
    STAGGER_GREEN = { 0.52, 1.00, 0.52 },
    STAGGER_YELLOW = { 1.00, 0.98, 0.72 },
    STAGGER_RED = { 1.00, 0.42, 0.42 },
    TIP_OF_THE_SPEAR = { 0.60, 0.80, 0.20 },
    WHIRLWIND = { 0.20, 0.80, 0.20 },
}
CP.COMBO_POINT_SLOT_TOKENS = CP.COMBO_POINT_SLOT_TOKENS or {
    "COMBO_POINTS_1", "COMBO_POINTS_2", "COMBO_POINTS_3", "COMBO_POINTS_4",
    "COMBO_POINTS_5", "COMBO_POINTS_6", "COMBO_POINTS_7",
}
local COMBO_POINT_RAMP_R = CP.COMBO_POINT_RAMP_R or { 0.00, 0.00, 1.00, 1.00, 1.00, 1.00, 1.00 }
local COMBO_POINT_RAMP_G = CP.COMBO_POINT_RAMP_G or { 0.95, 0.95, 1.00, 1.00, 1.00, 0.05, 0.05 }
local COMBO_POINT_RAMP_B = CP.COMBO_POINT_RAMP_B or { 1.00, 1.00, 0.00, 0.00, 0.00, 0.05, 0.05 }
CP.COMBO_POINT_RAMP_R, CP.COMBO_POINT_RAMP_G, CP.COMBO_POINT_RAMP_B = COMBO_POINT_RAMP_R, COMBO_POINT_RAMP_G, COMBO_POINT_RAMP_B
function CP.ColorOverride(tableName, token)
    local db = _G.MSUF_DB
    local general = db and db.general
    local overrides = general and general[tableName]
    local c = overrides and token and overrides[token]
    if type(c) ~= "table" then return nil end
    local r, g, b = c[1] or c.r, c[2] or c.g, c[3] or c.b
    if type(r) == "number" and type(g) == "number" and type(b) == "number" then return r, g, b end
    return nil
end
function CP.ResolveColor(token, fallbackR, fallbackG, fallbackB, powerColorFn)
    -- User overrides should show in preview, then fall back to runtime power-color helpers,
    -- and finally to fixed preview colors when the real runtime is unavailable.
    local r, g, b = CP.ColorOverride("classPowerColorOverrides", token)
    if r then return r, g, b end
    if type(_G.MSUF_GetPowerBarColor) == "function" and token then
        r, g, b = _G.MSUF_GetPowerBarColor(0, token)
        if type(r) == "number" then return r, g, b end
    end
    local pbc = _G.PowerBarColor
    local c = pbc and token and pbc[token]
    if c then
        r, g, b = c.r or c[1], c.g or c[2], c.b or c[3]
        if type(r) == "number" then return r, g, b end
    end
    c = token and CP.FALLBACK_COLORS[token]
    if c then return c[1], c[2], c[3] end
    if type(powerColorFn) == "function" and token then
        r, g, b = powerColorFn(token)
        if type(r) == "number" then return r, g, b end
    end
    return fallbackR or 1, fallbackG or 1, fallbackB or 1
end
function CP.ResolveBaseColor(spec, bars, fallbackR, fallbackG, fallbackB, powerColorFn)
    if bars and bars.classPowerColorByType == false then return 1, 1, 1 end
    return CP.ResolveColor(spec and spec.token, fallbackR, fallbackG, fallbackB, powerColorFn)
end
function CP.ResolveTextColor(fallbackR, fallbackG, fallbackB, powerColorFn)
    return CP.ResolveColor("RESOURCE_TEXT", fallbackR or 1, fallbackG or 1, fallbackB or 1, powerColorFn)
end
function CP.ResolveComboColor(bars, slot, baseR, baseG, baseB)
    local mode = bars and bars.classPowerComboPointColorMode
    if mode ~= "ramp" and mode ~= "custom" then return baseR, baseG, baseB end
    slot = tonumber(slot) or 1
    if slot < 1 then slot = 1 elseif slot > 7 then slot = 7 end
    if mode == "custom" then
        local r, g, b = CP.ColorOverride("classPowerColorOverrides", CP.COMBO_POINT_SLOT_TOKENS[slot])
        if r then return r, g, b end
    end
    return COMBO_POINT_RAMP_R[slot], COMBO_POINT_RAMP_G[slot], COMBO_POINT_RAMP_B[slot]
end
function CP.IsCharged(spec, bars, slot)
    return spec and spec.token == "COMBO_POINTS"
        and bars and bars.showChargedComboPoints ~= false
        and spec.chargedSlots and spec.chargedSlots[slot] == true
end
function CP.IsSingleBarMode(mode)
    return mode == "continuous" or mode == "timer_bar" or mode == "stagger" or mode == "aura_single"
end
function CP.IsEssence(spec)
    return spec and spec.token == "ESSENCE"
end
function CP.TokenForValue(spec, value)
    if spec and spec.mode == "stagger" then
        value = tonumber(value)
        if value == nil then value = tonumber(spec.value) or 0 end
        if value >= 0.60 then return "STAGGER_RED" end
        if value > 0.30 then return "STAGGER_YELLOW" end
        return "STAGGER_GREEN"
    end
    return spec and spec.token
end
function CP.FillForSegment(spec, index, valueOverride)
    if not spec then return index <= 3 and 1 or 0 end
    local mode = spec.mode or "segmented"
    local value = tonumber(valueOverride)
    if value == nil then value = tonumber(spec.value) or 0 end
    if CP.IsSingleBarMode(mode) then
        if index ~= 1 then return 0 end
        if value < 0 then value = 0 elseif value > 1 then value = 1 end
        return value
    end
    local full = floor(value)
    if mode == "fractional" or CP.IsEssence(spec) then
        local partial = value - full
        if index <= full then return 1 end
        if index == full + 1 and partial > 0.001 then return partial end
        return 0
    end
    return index <= full and 1 or 0
end
function CP.AnimatedValue(spec, elapsed)
    if not spec then return nil end
    local mode = spec.mode or "segmented"
    local maxValue = tonumber(spec.segments) or 1
    if CP.IsSingleBarMode(mode) then maxValue = 1 elseif maxValue < 1 then maxValue = 1 end
    elapsed = tonumber(elapsed) or 0
    if mode == "timer_bar" then return 1 - ((elapsed % 4.8) / 4.8) end
    local phase = (elapsed % 2.4) / 2.4
    local wave = phase < 0.5 and (phase * 2) or ((1 - phase) * 2)
    if mode == "continuous" or mode == "stagger" or mode == "aura_single" then return 0.08 + (wave * 0.88) end
    if CP.IsEssence(spec) then
        local cycle = (elapsed / 1.15) % (maxValue + 1)
        if cycle >= maxValue then return maxValue end
        local full = floor(cycle)
        return full + (cycle - full)
    end
    if mode == "fractional" then return wave * maxValue end
    local steps = maxValue * 2
    local step = floor((elapsed / 0.42) % steps)
    if step <= maxValue then return step end
    return steps - step
end
function CP.TextForValue(spec, value)
    if not spec then return "" end
    if value == nil then return spec.previewText or "" end
    local mode = spec.mode or "segmented"
    if mode == "continuous" then return tostring(floor((value * 100) + 0.5)) .. " / 100" end
    if mode == "timer_bar" then return string.format("%.1fs", floor((value * 20 * 10) + 0.5) / 10) end
    if mode == "stagger" then return tostring(floor((value * 34) + 0.5)) .. "K" end
    if mode == "aura_single" then return tostring(floor((value * 5) + 0.5)) end
    if mode == "fractional" then return string.format("%.1f", value) end
    local rounded = CP.IsEssence(spec) and floor(value) or floor(value + 0.5)
    if spec.token == "SOUL_FRAGMENTS_VENG" then return tostring(rounded) .. " / " .. tostring(tonumber(spec.segments) or 6) end
    return tostring(rounded)
end
local RUNE_PREVIEW_REMAINING = { nil, 7.2, nil, 4.1, nil, 1.4 }
local RUNE_PREVIEW_OFFSET = { nil, 0.0, nil, 3.1, nil, 6.2 }
local RUNE_PREVIEW_READY_HOLD = 1.2
function CP.FormatSeconds(remaining)
    remaining = tonumber(remaining) or 0
    if remaining <= 0.05 then return "" end
    return string.format("%.1f", floor((remaining * 10) + 0.5) / 10)
end
function CP.FillRuneState(out, runeID, totalDuration, elapsed, animated)
    out.id = runeID
    out.total = totalDuration
    local baseRemaining = RUNE_PREVIEW_REMAINING[runeID]
    if not baseRemaining then
        out.ready = true
        out.elapsed = totalDuration
        out.remaining = 0
        return out
    end
    out.ready = false
    if animated then
        local cycle = totalDuration + RUNE_PREVIEW_READY_HOLD
        local progress = ((tonumber(elapsed) or 0) + (RUNE_PREVIEW_OFFSET[runeID] or 0)) % cycle
        if progress >= totalDuration then
            out.ready = true
            out.elapsed = totalDuration
            out.remaining = 0
        else
            out.elapsed = progress
            out.remaining = totalDuration - progress
        end
    else
        out.remaining = baseRemaining
        out.elapsed = totalDuration - baseRemaining
    end
    if out.remaining < 0.05 then
        out.ready = true
        out.elapsed = totalDuration
        out.remaining = 0
    end
    return out
end
function CP.BuildRuneOrder(scratch, bars, spec, elapsed, animated)
    local states = scratch.runeStates
    if not states then
        states = {}
        scratch.runeStates = states
    end
    local totalDuration = tonumber(spec and spec.runeDuration) or 10
    if totalDuration < 1 then totalDuration = 10 end
    for i = 1, 6 do states[i] = CP.FillRuneState(states[i] or {}, i, totalDuration, elapsed, animated) end
    for i = 7, #states do states[i] = nil end
    local sortOrder = bars and bars.runeSortOrder
    if sortOrder == "asc" then
        table.sort(states, function(a, b)
            if a.ready ~= b.ready then return a.ready == true end
            return (a.id or 0) < (b.id or 0)
        end)
    elseif sortOrder == "desc" then
        table.sort(states, function(a, b)
            if a.ready ~= b.ready then return a.ready ~= true end
            return (a.id or 0) < (b.id or 0)
        end)
    else
        table.sort(states, function(a, b) return (a.id or 0) < (b.id or 0) end)
    end
    return states
end
function CP.ResolveTexture(key, fallback)
    if key and key ~= "" then
        local resolve = _G.MSUF_ResolveStatusbarTextureKey
        local path = type(resolve) == "function" and resolve(key) or nil
        if path and path ~= "" then return path end
    end
    if fallback and fallback ~= "" then return fallback end
    if type(_G.MSUF_GetBarTexture) == "function" then
        local path = _G.MSUF_GetBarTexture()
        if path and path ~= "" then return path end
    end
    return CP.WHITE8
end
function H.StylePreviewPillButton(btn, T, opts)
    if not btn then return btn end
    opts = opts or {}
    if btn.SetHitRectInsets then btn:SetHitRectInsets(-2, -2, -2, -2) end
    local fontField = opts.fontField or "fs"
    local useSuperellipse = T and T.CreateSuperellipseLayers
    if useSuperellipse and not btn._msuf2PreviewPillFill then
        local fill, edge = T.CreateSuperellipseLayers(btn, "_msuf2PreviewPill", 2, "BACKGROUND", "BORDER")
        btn._msuf2PreviewPillFill = fill
        btn._msuf2PreviewPillEdge = edge
        if btn.SetBackdropColor then btn:SetBackdropColor(0, 0, 0, 0) end
        if btn.SetBackdropBorderColor then btn:SetBackdropBorderColor(0, 0, 0, 0) end
    end
    local tc = T and T.colors
    local shadow = tc and tc.coreShadow or { 0.006, 0.016, 0.032 }
    local surface = tc and tc.coreSurface or { 0.014, 0.038, 0.072 }
    local raised = tc and tc.coreRaised or { 0.026, 0.070, 0.110 }
    local rim = tc and tc.coreRim or { 0.043, 0.096, 0.150 }
    local blue = tc and tc.coreBlue or { 0.095, 0.360, 0.560 }
    local bgIdle, bgHover, bgActive, bgDown = { shadow[1], shadow[2], shadow[3], 0.92 }, { surface[1], surface[2], surface[3], 0.98 }, { raised[1], raised[2], raised[3], 0.98 }, { raised[1], raised[2], raised[3], 1.00 }
    local brIdle, brHover, brActive = { rim[1], rim[2], rim[3], 0.72 }, { blue[1], blue[2], blue[3], 0.58 }, { blue[1], blue[2], blue[3], 0.70 }
    local bgScratch = { 0, 0, 0, 1 }
    function btn:MSUF2RefreshPreviewPill(active, hover, down)
        active = active == true
        if hover == nil then hover = self._msuf2PreviewPillHover == true end
        if down == nil then down = self._msuf2PreviewPillDown == true end
        self._msuf2PreviewPillActive = active
        local alpha = (self.IsEnabled and not self:IsEnabled()) and 0.42 or 1
        local bg = down and bgDown or (active and bgActive or (hover and bgHover or bgIdle))
        local br = active and brActive or (hover and brHover or brIdle)
        if useSuperellipse and self._msuf2PreviewPillFill then
            if T.SetFillGradient then
                bgScratch[1], bgScratch[2], bgScratch[3], bgScratch[4] = bg[1], bg[2], bg[3], (bg[4] or 1) * alpha
                T.SetFillGradient(self._msuf2PreviewPillFill, bgScratch, 0.12, -0.18)
            else
                self._msuf2PreviewPillFill:SetVertexColor(bg[1], bg[2], bg[3], (bg[4] or 1) * alpha)
            end
            if self._msuf2PreviewPillEdge then
                self._msuf2PreviewPillEdge:SetVertexColor(min(br[1] * (hover and 1.08 or 1), 1), min(br[2] * (hover and 1.08 or 1), 1), min(br[3] * (hover and 1.08 or 1), 1), (br[4] or 1) * alpha)
            end
        elseif self.SetBackdropColor then
            self:SetBackdropColor(bg[1], bg[2], bg[3], (bg[4] or 1) * alpha)
            self:SetBackdropBorderColor(br[1], br[2], br[3], (br[4] or 1) * alpha)
        end
        if self[fontField] and self[fontField].SetTextColor then
            self[fontField]:SetTextColor(active and 0.06 or (hover and 0.88 or 0.78), active and 0.95 or (hover and 0.94 or 0.84), active and 1.00 or 0.96, alpha)
        end
    end
    btn:SetScript("OnEnter", function(self)
        self._msuf2PreviewPillHover = true
        self:MSUF2RefreshPreviewPill(self._msuf2PreviewPillActive, true, self._msuf2PreviewPillDown)
    end)
    btn:SetScript("OnLeave", function(self)
        self._msuf2PreviewPillHover = nil
        self._msuf2PreviewPillDown = nil
        self:MSUF2RefreshPreviewPill(self._msuf2PreviewPillActive, false, false)
    end)
    btn:SetScript("OnMouseDown", function(self)
        self._msuf2PreviewPillDown = true
        self:MSUF2RefreshPreviewPill(self._msuf2PreviewPillActive, self._msuf2PreviewPillHover, true)
    end)
    btn:SetScript("OnMouseUp", function(self)
        self._msuf2PreviewPillDown = nil
        self:MSUF2RefreshPreviewPill(self._msuf2PreviewPillActive, self._msuf2PreviewPillHover, false)
    end)
    btn:SetScript("OnEnable", function(self) self:MSUF2RefreshPreviewPill(self._msuf2PreviewPillActive) end)
    btn:SetScript("OnDisable", function(self) self:MSUF2RefreshPreviewPill(false, false, false) end)
    btn:MSUF2RefreshPreviewPill(false, false, false)
    return btn
end
function H.HidePreviewHandleContext()
    local popup = H._previewHandleContextPopup
    if popup and popup.Hide then popup:Hide() end
end
function H.ShowPreviewHandleContext(handle, opts)
    opts = opts or {}
    if not handle then return end
    local M2 = opts.M or M
    local T = opts.T or (M2 and M2.Theme)
    local W = opts.W or (M2 and M2.Widgets)
    local tr = opts.Tr or opts.TR or (M2 and M2.Tr) or F.Identity
    local openSettings = opts.openSettings
    if type(openSettings) ~= "function" then return end
    local popup = H._previewHandleContextPopup
    if not popup then
        if M2 and type(M2.CreateMenuPopupPanel) == "function" then
            popup = M2.CreateMenuPopupPanel(UIParent, { name = "MSUF2PreviewHandleContextMenu", glass = "popup" })
        else
            popup = CreateFrame("Frame", "MSUF2PreviewHandleContextMenu", UIParent, "BackdropTemplate")
            popup:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
            popup:SetBackdropColor(0.014, 0.024, 0.050, 0.985)
            popup:SetBackdropBorderColor(0.10, 0.22, 0.44, 0.80)
        end
        popup:SetSize(176, 74)
        popup:SetFrameStrata("FULLSCREEN_DIALOG")
        popup:EnableMouse(true)
        local title = T and T.Font and T.Font(popup, "GameFontDisableSmall", "", (T.colors and T.colors.muted) or { 0.72, 0.78, 0.90, 1 }) or popup:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
        title:SetPoint("TOPLEFT", popup, "TOPLEFT", 10, -8)
        title:SetPoint("RIGHT", popup, "RIGHT", -10, 0)
        title:SetJustifyH("LEFT")
        popup._title = title
        local function MakeButton(label, y)
            local btn = W and W.TopButton and W.TopButton(popup, tr(label), 154, 22) or (T and T.Button and T.Button(popup, tr(label), 154, 22)) or CreateFrame("Button", nil, popup, "BackdropTemplate")
            btn:SetPoint("TOPLEFT", popup, "TOPLEFT", 10, y)
            if not btn.GetText then btn:SetText(tr(label)) end
            return btn
        end
        popup._open = MakeButton("Open Settings", -28)
        popup._keep = MakeButton("Keep Selected", -52)
        popup._keep:SetScript("OnClick", function(self)
            local p = self:GetParent()
            if p then p:Hide() end
        end)
        H._previewHandleContextPopup = popup
    end
    popup._handle = handle
    popup._openSettings = openSettings
    if popup._title and popup._title.SetText then popup._title:SetText(tr(opts.title or (handle._label or handle._previewText or handle._key or "Preview Element"))) end
    popup._open:SetScript("OnClick", function(self)
        local p = self:GetParent()
        local h = p and p._handle
        local fn = p and p._openSettings
        if p then p:Hide() end
        if type(fn) == "function" then fn(h, "context") end
    end)
    if M2 and type(M2.ApplyPopupFramePriority) == "function" then M2.ApplyPopupFramePriority(popup) end
    popup:ClearAllPoints()
    popup:SetPoint("TOPLEFT", handle, "BOTTOMRIGHT", 8, -2)
    popup:Show()
    return popup
end
local SETTINGS_ICON_RECTS = {
    { 10, 1, "CENTER", 0, 4, 0.70 }, { 10, 1, "CENTER", 0, 0, 0.70 }, { 10, 1, "CENTER", 0, -4, 0.70 },
    { 3, 3, "CENTER", -3, 4, 1.00 }, { 3, 3, "CENTER", 3, 0, 1.00 }, { 3, 3, "CENTER", -1, -4, 1.00 },
}
local function CreatePreviewSettingsIcon(button)
    if not (button and button.CreateTexture) or button._msuf2PreviewSettingsParts then return end
    local parts = {}
    for i = 1, #SETTINGS_ICON_RECTS do
        local r = SETTINGS_ICON_RECTS[i]
        local tex = button:CreateTexture(nil, "ARTWORK", nil, 6)
        tex:SetTexture("Interface\\Buttons\\WHITE8X8")
        tex:SetSize(r[1], r[2])
        tex:SetPoint(r[3], button, r[3], r[4], r[5])
        parts[i] = tex
    end
    button._msuf2PreviewSettingsParts = parts
end

local function PaintPreviewSettingsIcon(button, hover)
    local parts = button and button._msuf2PreviewSettingsParts
    local mul = hover and 1.16 or 1
    if parts then
        for i = 1, #parts do
            local alpha = SETTINGS_ICON_RECTS[i][6] or 1
            parts[i]:SetVertexColor(min(0.76 * mul, 1), min(0.94 * mul, 1), 1, hover and 1 or alpha)
        end
    end
end

function H.EnsurePreviewHandleGear(handle, opts)
    opts = opts or {}
    if not handle then return nil end
    local T = opts.T or (M and M.Theme)
    local tr = opts.Tr or opts.TR or (M and M.Tr) or F.Identity
    local gear = handle._msuf2SettingsGear
    if not gear then
        local template = T and T.Template and T.Template() or "BackdropTemplate"
        gear = CreateFrame("Button", nil, handle, template)
        gear:SetSize(18, 18)
        gear:SetPoint("BOTTOMLEFT", handle, "TOPRIGHT", -8, -8)
        if gear.SetHitRectInsets then gear:SetHitRectInsets(-2, -2, -2, -2) end
        if T and T.CreateSuperellipseLayers then
            local fill, edge = T.CreateSuperellipseLayers(gear, "_msuf2PreviewGear", 2, "BACKGROUND", "BORDER")
            gear._fill, gear._edge = fill, edge
        elseif gear.SetBackdrop then
            gear:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
        end
        CreatePreviewSettingsIcon(gear)
        local bg, br = { 0.010, 0.022, 0.040, 0.96 }, { 0.160, 0.560, 0.720, 0.92 }
        local function Paint(self, hover)
            local mul = hover and 1.10 or 1
            if self._fill then
                if T and T.SetFillGradient then T.SetFillGradient(self._fill, bg, 0.12, -0.18) else self._fill:SetVertexColor(bg[1], bg[2], bg[3], bg[4]) end
            elseif self.SetBackdropColor then
                self:SetBackdropColor(bg[1], bg[2], bg[3], bg[4])
            end
            if self._edge then
                self._edge:SetVertexColor(min(br[1] * mul, 1), min(br[2] * mul, 1), min(br[3] * mul, 1), br[4])
            elseif self.SetBackdropBorderColor then
                self:SetBackdropBorderColor(br[1], br[2], br[3], br[4])
            end
            PaintPreviewSettingsIcon(self, hover)
        end
        gear:SetScript("OnEnter", function(self)
            Paint(self, true)
            if GameTooltip then
                GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                GameTooltip:SetText(tr("Open Settings"), 1, 1, 1)
                GameTooltip:Show()
            end
        end)
        gear:SetScript("OnLeave", function(self)
            Paint(self, false)
            if GameTooltip then GameTooltip:Hide() end
        end)
        gear:SetScript("OnClick", function(self)
            local h = self._handle
            local fn = self._openSettings
            if type(fn) == "function" then fn(h, "gear") end
        end)
        Paint(gear, false)
        gear:Hide()
        handle._msuf2SettingsGear = gear
    end
    gear._handle = handle
    gear._openSettings = opts.openSettings
    gear:SetShown(opts.shown == true)
    return gear
end
local function PreviewControlsLines(tr)
    tr = tr or F.Identity
    return {
        tr("Drag handles to move."),
        tr("Double-click/settings: open options."),
        tr("Right-click: quick actions."),
        tr("Arrows nudge. Shift=5, Ctrl=10."),
        tr("Ctrl+wheel: zoom. Ctrl+drag: pan."),
        tr("Fit recenters."),
    }
end
function H.ShowPreviewControlsHelp(anchor, opts)
    opts = opts or {}
    local M2 = opts.M or M
    local T = opts.T or (M2 and M2.Theme)
    local W = opts.W or (M2 and M2.Widgets)
    local tr = opts.Tr or opts.TR or (M2 and M2.Tr) or F.Identity
    local popup = H._previewControlsHelpPopup
    if not popup then
        if M2 and type(M2.CreateMenuPopupPanel) == "function" then
            popup = M2.CreateMenuPopupPanel(UIParent, { name = "MSUF2PreviewControlsHelp", glass = "popup" })
        else
            popup = CreateFrame("Frame", "MSUF2PreviewControlsHelp", UIParent, "BackdropTemplate")
            popup:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
            popup:SetBackdropColor(0.014, 0.024, 0.050, 0.985)
            popup:SetBackdropBorderColor(0.10, 0.22, 0.44, 0.80)
        end
        popup:SetSize(328, 186)
        popup:SetFrameStrata("FULLSCREEN_DIALOG")
        popup:EnableMouse(true)
        popup._title = T and T.Font and T.Font(popup, "GameFontNormalSmall", "", (T.colors and T.colors.accent) or { 0.78, 0.92, 1, 1 }) or popup:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        popup._title:SetPoint("TOPLEFT", popup, "TOPLEFT", 12, -10)
        popup._title:SetPoint("RIGHT", popup, "RIGHT", -12, 0)
        popup._title:SetJustifyH("LEFT")
        popup._lines = {}
        for i = 1, 6 do
            local fs = T and T.Font and T.Font(popup, "GameFontDisableSmall", "", (T.colors and T.colors.muted) or { 0.72, 0.78, 0.90, 1 }) or popup:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
            fs:SetPoint("TOPLEFT", popup, "TOPLEFT", 14, -32 - ((i - 1) * 20))
            fs:SetPoint("RIGHT", popup, "RIGHT", -12, 0)
            fs:SetJustifyH("LEFT")
            if fs.SetWordWrap then fs:SetWordWrap(false) end
            if fs.SetNonSpaceWrap then fs:SetNonSpaceWrap(false) end
            if fs.SetMaxLines then fs:SetMaxLines(1) end
            popup._lines[i] = fs
        end
        local close = W and W.TopButton and W.TopButton(popup, tr("Got it"), 82, 22) or (T and T.Button and T.Button(popup, tr("Got it"), 82, 22)) or CreateFrame("Button", nil, popup, "BackdropTemplate")
        close:SetPoint("BOTTOMRIGHT", popup, "BOTTOMRIGHT", -10, 10)
        if not close.GetText then close:SetText(tr("Got it")) end
        close:SetScript("OnClick", function(self)
            local p = self:GetParent()
            if p then p:Hide() end
        end)
        popup._close = close
        H._previewControlsHelpPopup = popup
    end
    popup._title:SetText(tr("Preview Controls"))
    local lines = PreviewControlsLines(tr)
    for i = 1, #(popup._lines or {}) do
        local fs = popup._lines[i]
        if fs then fs:SetText(lines[i] and ("- " .. lines[i]) or "") end
    end
    if M2 and type(M2.ApplyPopupFramePriority) == "function" then M2.ApplyPopupFramePriority(popup) end
    popup:ClearAllPoints()
    if anchor and anchor.GetLeft then
        popup:SetPoint("TOPRIGHT", anchor, "BOTTOMRIGHT", 0, -6)
    else
        popup:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    end
    popup:Show()
    return popup
end
function H.EnsurePreviewControlsHint(box, anchor, opts)
    opts = opts or {}
    if not box then return nil end
    local M2 = opts.M or M
    local T = opts.T or (M2 and M2.Theme)
    local tr = opts.Tr or opts.TR or (M2 and M2.Tr) or F.Identity
    local state = M2 and M2.GetPersistentMenuStateTable and M2.GetPersistentMenuStateTable("previewControlsHintState") or nil
    if state and state.seen == true then return nil end
    local parent = anchor or box.canvas or box._stage or box
    if not parent then return nil end
    local hint = box._msuf2PreviewControlsHint
    if not hint then
        local template = T and T.Template and T.Template() or "BackdropTemplate"
        hint = CreateFrame("Frame", nil, parent, template)
        hint:SetSize(286, 48)
        hint:SetPoint("BOTTOMLEFT", parent, "BOTTOMLEFT", 8, 8)
        if hint.SetFrameLevel and parent.GetFrameLevel then hint:SetFrameLevel((parent:GetFrameLevel() or 0) + 90) end
        if hint.SetBackdrop then
            hint:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
            hint:SetBackdropColor(0.012, 0.020, 0.040, 0.94)
            hint:SetBackdropBorderColor(0.10, 0.32, 0.54, 0.92)
        end
        hint:EnableMouse(true)
        local text = T and T.Font and T.Font(hint, "GameFontDisableSmall", "", (T.colors and T.colors.text) or { 0.86, 0.90, 0.98, 1 }) or hint:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
        text:SetPoint("TOPLEFT", hint, "TOPLEFT", 10, -8)
        text:SetPoint("RIGHT", hint, "RIGHT", -58, 0)
        text:SetJustifyH("LEFT")
        text:SetWordWrap(true)
        hint._text = text
        local close = CreateFrame("Button", nil, hint, "BackdropTemplate")
        close:SetSize(44, 22)
        close:SetPoint("RIGHT", hint, "RIGHT", -8, 0)
        close.fs = close.fs or close:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
        close.fs:SetPoint("CENTER")
        close.fs:SetText(tr("OK"))
        if T and T.StyleFontString then T.StyleFontString(close.fs, (T.colors and T.colors.text) or { 1, 1, 1, 1 }, 0) end
        if T and H.StylePreviewPillButton then H.StylePreviewPillButton(close, T, { fontField = "fs" }) end
        close:SetScript("OnClick", function(self)
            local p = self:GetParent()
            local s = M2 and M2.GetPersistentMenuStateTable and M2.GetPersistentMenuStateTable("previewControlsHintState") or state
            if s then s.seen = true end
            if p then p:Hide() end
        end)
        hint._close = close
        box._msuf2PreviewControlsHint = hint
    end
    if hint._text then hint._text:SetText(tr("Preview tip: drag handles to move, double-click or use settings to open options, right-click for actions.")) end
    hint:Show()
    return hint
end
function H.InstallZoomPan(ZoomPan, opts)
    if type(ZoomPan) ~= "table" then return end
    opts = opts or {}
    local floor = math.floor
    local minZoom = tonumber(opts.minZoom) or 0.35
    local maxZoom = tonumber(opts.maxZoom) or 4.0
    local steps = opts.steps or { 0.35, 0.50, 0.75, 1.00, 1.25, 1.50, 2.00, 3.00, 4.00 }
    local deps = {}
    local white = "Interface\\Buttons\\WHITE8X8"
    local function PathValue(object, path)
        if not object or not path then return nil end
        if type(path) ~= "table" then return object[path] end
        local value = object
        for i = 1, #path do
            value = value and value[path[i]]
        end
        return value
    end
    local function TR(text)
        local fn = deps.TR
        return (type(fn) == "function" and fn(text)) or text
    end
    local function Round(value)
        return floor((tonumber(value) or 0) + 0.5)
    end
    local function PanKey(name)
        return tostring(opts.panPrefix or "_msufPreview") .. name
    end
    local PAN_PANNING, PAN_BOX = PanKey("Panning"), PanKey("PanBox")
    local PAN_CURSOR_X, PAN_CURSOR_Y = PanKey("PanCursorX"), PanKey("PanCursorY")
    local PAN_START_X, PAN_START_Y = PanKey("PanStartX"), PanKey("PanStartY")
    local PAN_BUTTON = PanKey("PanButton")
    ZoomPan.MIN = minZoom
    ZoomPan.MAX = maxZoom
    function ZoomPan.Configure(nextDeps)
        if opts.configureTableOnly then
            if type(nextDeps) == "table" then deps = nextDeps end
        else
            deps = nextDeps or deps or {}
        end
    end
    function ZoomPan.Clamp(value)
        value = tonumber(value) or 1
        if value < minZoom then return minZoom end
        if value > maxZoom then return maxZoom end
        return floor(value * 100 + 0.5) / 100
    end
    function ZoomPan.UpdateControls(box)
        if not box then return end
        local zoom = box._manualZoom
        local scale = tonumber(box._mockScale) or tonumber(zoom) or tonumber(box._mockAutoScale) or 1
        local readout = box[opts.readoutField or "zoomReadout"]
        if readout then
            local pct = floor(scale * 100 + 0.5)
            readout:SetText(zoom and string.format("%d%%", pct) or string.format(opts.translateFitText and TR("Fit %d%%") or "Fit %d%%", pct))
        end
        local fitText = PathValue(box, opts.fitButtonTextPath or { "zoomFitButton", "fs" })
        if fitText then fitText:SetTextColor(zoom and 0.72 or 0.25, zoom and 0.78 or 0.95, zoom and 0.90 or 1.00, 1) end
    end
    function ZoomPan.ApplyPan(box)
        if opts.panMode == "topLeft" then
            if not (box and box._stage and box._mock) then return end
            local x = (tonumber(box._mockBaseOffsetX) or 0) + (tonumber(box._zoomPanX) or 0)
            local y = (tonumber(box._mockBaseOffsetY) or 0) + (tonumber(box._zoomPanY) or 0)
            box._mock:ClearAllPoints()
            box._mock:SetPoint("TOPLEFT", box._stage, "TOPLEFT", x, y)
            return
        end
        if not (box and box.canvas and box.mock) then return end
        local panX, panY = tonumber(box._zoomPanX) or 0, tonumber(box._zoomPanY) or 0
        box.mock:ClearAllPoints()
        box.mock:SetPoint("CENTER", box.canvas, "CENTER", (tonumber(box._mockBaseOffsetX) or 0) + panX, (tonumber(box._mockBaseOffsetY) or 0) + panY)
        if box._detachedCastPreview and box.mock.cast and box.mock.cast:IsShown() then
            box.mock.cast:ClearAllPoints()
            box.mock.cast:SetPoint("CENTER", box.canvas, "CENTER", (tonumber(box._detachedCastBaseOffsetX) or 0) + panX, (tonumber(box._detachedCastBaseOffsetY) or 0) + panY)
        end
    end
    function ZoomPan.SetZoom(box, zoom, reason)
        if not box then return end
        if zoom == nil or zoom == "fit" then
            box._manualZoom = nil
            box._zoomPanX, box._zoomPanY = 0, 0
        else
            box._manualZoom = ZoomPan.Clamp(zoom)
        end
        ZoomPan.UpdateControls(box)
        reason = reason or opts.defaultReason or "UNIT_PREVIEW_ZOOM"
        if type(opts.refresh) == "function" then
            opts.refresh(box, reason, deps)
        elseif box.Refresh then
            box:Refresh(reason)
        end
    end
    function ZoomPan.Step(box, direction)
        if not box then return end
        local current = ZoomPan.Clamp(box._manualZoom or box._mockScale or box._mockAutoScale or 1)
        local nextZoom = current
        if (tonumber(direction) or 0) > 0 then
            for i = 1, #steps do
                if steps[i] > current + 0.001 then
                    nextZoom = steps[i]
                    break
                end
            end
        else
            for i = #steps, 1, -1 do
                if steps[i] < current - 0.001 then
                    nextZoom = steps[i]
                    break
                end
            end
        end
        ZoomPan.SetZoom(box, nextZoom, opts.stepReason or "UNIT_PREVIEW_ZOOM_STEP")
    end
    function ZoomPan.Stop(surface)
        if not surface then return end
        local box = surface[PAN_BOX]
        surface[PAN_PANNING], surface[PAN_BOX], surface[PAN_BUTTON] = nil, nil, nil
        surface[PAN_CURSOR_X], surface[PAN_CURSOR_Y] = nil, nil
        surface[PAN_START_X], surface[PAN_START_Y] = nil, nil
        surface:SetScript("OnUpdate", nil)
        local update = deps[opts.updateHintKey or "UpdateHandleHint"]
        if box and type(update) == "function" then update(box, box._selectedHandle) end
    end
    function ZoomPan.Start(surface, box, button)
        if not (surface and box) then return false end
        if surface[PAN_PANNING] then return true end
        local ctrlLeft = button == "LeftButton" and IsControlKeyDown and IsControlKeyDown()
        if not (ctrlLeft or button == "RightButton" or button == "MiddleButton") then return false end
        if not box._manualZoom then
            box._manualZoom = ZoomPan.Clamp(box._mockScale or box._mockAutoScale or 1)
            ZoomPan.UpdateControls(box)
        end
        local cx, cy = GetCursorPosition()
        local uiScale = (UIParent and UIParent.GetEffectiveScale and UIParent:GetEffectiveScale()) or 1
        if uiScale <= 0 then uiScale = 1 end
        surface[PAN_PANNING], surface[PAN_BOX] = true, box
        surface[PAN_BUTTON] = button
        surface[PAN_CURSOR_X], surface[PAN_CURSOR_Y] = (cx or 0) / uiScale, (cy or 0) / uiScale
        surface[PAN_START_X], surface[PAN_START_Y] = tonumber(box._zoomPanX) or 0, tonumber(box._zoomPanY) or 0
        local hint = box[opts.hintField or "hint"]
        if hint then hint:SetText(TR("moving preview canvas - release mouse to stop - Fit recenters")) end
        surface:SetScript("OnUpdate", function(self)
            if not self[PAN_PANNING] then return end
            if IsMouseButtonDown and self[PAN_BUTTON] and not IsMouseButtonDown(self[PAN_BUTTON]) then
                ZoomPan.Stop(self)
                return
            end
            local mx, my = GetCursorPosition()
            local scale = (UIParent and UIParent.GetEffectiveScale and UIParent:GetEffectiveScale()) or 1
            if scale <= 0 then scale = 1 end
            local nextX = Round((self[PAN_START_X] or 0) + ((mx or 0) / scale - (self[PAN_CURSOR_X] or 0)))
            local nextY = Round((self[PAN_START_Y] or 0) + ((my or 0) / scale - (self[PAN_CURSOR_Y] or 0)))
            if box._zoomPanX ~= nextX or box._zoomPanY ~= nextY then
                box._zoomPanX, box._zoomPanY = nextX, nextY
                ZoomPan.ApplyPan(box)
            end
        end)
        return true
    end
    function ZoomPan.CreateButton(parent, text, width, tooltip, onClick)
        local T = deps.T
        local template = (opts.themeButton and T and T.Template and T.Template()) or (opts.buttonTemplate or "BackdropTemplate")
        local btn = CreateFrame("Button", nil, parent, template)
        local tex = deps[opts.buttonTextureKey or "TEX_W8"] or white
        btn:SetSize(width or 24, opts.buttonHeight or 20)
        if btn.SetHitRectInsets then btn:SetHitRectInsets(-2, -2, -2, -2) end
        local useSuperellipse = opts.themeButton and T and T.CreateSuperellipseLayers
        if useSuperellipse then
            local fill, edge = T.CreateSuperellipseLayers(btn, "_msuf2PreviewZoom", 2, "BACKGROUND", "BORDER")
            btn._msuf2PreviewZoomFill = fill
            btn._msuf2PreviewZoomEdge = edge
        elseif btn.SetBackdrop then
            btn:SetBackdrop({ bgFile = tex, edgeFile = tex, edgeSize = 1 })
            btn:SetBackdropColor(0.025, 0.030, 0.045, 0.88)
            btn:SetBackdropBorderColor(0.12, 0.16, 0.24, 0.92)
        end
        local fontField = opts.buttonFontField or "fs"
        if opts.themeButton and T and T.Font then
            btn[fontField] = T.Font(btn, "GameFontDisableSmall", text, { 0.78, 0.84, 0.96, 1 })
        elseif not opts.themeButton then
            btn[fontField] = btn:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
            btn[fontField]:SetText(text)
            btn[fontField]:SetTextColor(0.78, 0.84, 0.96, 1)
            if T and T.StyleFontString then T.StyleFontString(btn[fontField], { 0.78, 0.84, 0.96, 1 }, 0) end
        end
        if btn[fontField] then btn[fontField]:SetPoint("CENTER") end
        btn:SetScript("OnClick", onClick)
        local tc = T and T.colors
        local shadow = tc and tc.coreShadow or { 0.006, 0.016, 0.032 }
        local surface = tc and tc.coreSurface or { 0.014, 0.038, 0.072 }
        local raised = tc and tc.coreRaised or { 0.026, 0.070, 0.110 }
        local rim = tc and tc.coreRim or { 0.043, 0.096, 0.150 }
        local blue = tc and tc.coreBlue or { 0.095, 0.360, 0.560 }
        local bgIdle, bgHover, bgDown = { shadow[1], shadow[2], shadow[3], 0.92 }, { surface[1], surface[2], surface[3], 0.98 }, { raised[1], raised[2], raised[3], 0.98 }
        local brIdle, brHover, brDown = { rim[1], rim[2], rim[3], 0.72 }, { blue[1], blue[2], blue[3], 0.58 }, { blue[1], blue[2], blue[3], 0.70 }
        local bgScratch = { 0, 0, 0, 1 }
        local function ApplyButtonVisual(self, hover, down)
            if useSuperellipse then
                local alpha = (self.IsEnabled and not self:IsEnabled()) and 0.42 or 1
                local bg = down and bgDown or (hover and bgHover or bgIdle)
                local br = down and brDown or (hover and brHover or brIdle)
                if self._msuf2PreviewZoomFill then
                    if T.SetFillGradient then
                        bgScratch[1], bgScratch[2], bgScratch[3], bgScratch[4] = bg[1], bg[2], bg[3], (bg[4] or 1) * alpha
                        T.SetFillGradient(self._msuf2PreviewZoomFill, bgScratch, 0.12, -0.18)
                    else
                        self._msuf2PreviewZoomFill:SetVertexColor(bg[1], bg[2], bg[3], (bg[4] or 1) * alpha)
                    end
                end
                if self._msuf2PreviewZoomEdge then
                    self._msuf2PreviewZoomEdge:SetVertexColor(min(br[1] * (hover and 1.08 or 1), 1), min(br[2] * (hover and 1.08 or 1), 1), min(br[3] * (hover and 1.08 or 1), 1), (br[4] or 1) * alpha)
                end
                if self[fontField] and self[fontField].SetTextColor then
                    self[fontField]:SetTextColor(hover and 0.88 or 0.78, hover and 0.94 or 0.84, 1.00, alpha)
                end
                return
            end
            local bg = hover and bgHover or bgIdle
            local br = hover and brHover or brIdle
            if self.SetBackdropColor then self:SetBackdropColor(bg[1], bg[2], bg[3], hover and 0.98 or 0.88) end
            if self.SetBackdropBorderColor then self:SetBackdropBorderColor(br[1], br[2], br[3], hover and 1 or 0.92) end
        end
        btn:SetScript("OnMouseDown", function(self)
            self._msuf2PreviewZoomDown = true
            ApplyButtonVisual(self, self._msuf2PreviewZoomHover, true)
        end)
        btn:SetScript("OnMouseUp", function(self)
            self._msuf2PreviewZoomDown = nil
            ApplyButtonVisual(self, self._msuf2PreviewZoomHover, false)
        end)
        btn:SetScript("OnEnter", function(self)
            self._msuf2PreviewZoomHover = true
            ApplyButtonVisual(self, true, self._msuf2PreviewZoomDown)
            if GameTooltip and tooltip then
                GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                GameTooltip:SetText(TR(tooltip), 1, 1, 1)
                GameTooltip:Show()
            end
        end)
        btn:SetScript("OnLeave", function(self)
            self._msuf2PreviewZoomHover = nil
            self._msuf2PreviewZoomDown = nil
            ApplyButtonVisual(self, false, false)
            if GameTooltip then GameTooltip:Hide() end
        end)
        btn:SetScript("OnEnable", function(self) ApplyButtonVisual(self, self._msuf2PreviewZoomHover, self._msuf2PreviewZoomDown) end)
        btn:SetScript("OnDisable", function(self) ApplyButtonVisual(self, false, false) end)
        ApplyButtonVisual(btn, false, false)
        return btn
    end
end
function H.BuildZoomBar(box, surface, opts)
    if not (box and surface) then return nil end
    opts = opts or {}
    local tr = opts.Tr or F.Identity
    local tex = opts.texture or "Interface\\Buttons\\WHITE8X8"
    local template = opts.template or "BackdropTemplate"
    local stepZoom = opts.StepZoom or F.Noop
    local setZoom = opts.SetZoom or F.Noop
    local startPan = opts.StartPan or F.False
    local stopPan = opts.StopPan or F.Noop
    local buttonH = tonumber(opts.buttonHeight) or 20
    local createButton = opts.CreateZoomButton or function(parent, text, width, tooltip, onClick)
        local btn = CreateFrame("Button", nil, parent, template)
        btn:SetSize(width or 24, buttonH)
        btn:SetBackdrop({ bgFile = tex, edgeFile = tex, edgeSize = 1 })
        btn:SetText(text)
        btn:SetScript("OnClick", onClick)
        return btn
    end
    local prefix = opts.fieldPrefix or ""
    local zoomBar = CreateFrame("Frame", nil, surface, template)
    zoomBar:SetSize(opts.width or 199, opts.height or 24)
    zoomBar:SetPoint("TOPRIGHT", surface, "TOPRIGHT", -8, -6)
    zoomBar:SetBackdrop({ bgFile = tex, edgeFile = tex, edgeSize = 1 })
    if opts.flatChrome ~= false and (opts.T or opts.flatChrome == true) then
        zoomBar:SetBackdropColor(0, 0, 0, 0)
        zoomBar:SetBackdropBorderColor(0, 0, 0, 0)
    else
        zoomBar:SetBackdropColor(0.015, 0.018, 0.030, 0.86)
        zoomBar:SetBackdropBorderColor(0.10, 0.14, 0.22, 0.92)
    end
    if zoomBar.SetFrameLevel then zoomBar:SetFrameLevel((surface.GetFrameLevel and surface:GetFrameLevel() or 0) + 80) end
    zoomBar:EnableMouse(true)
    zoomBar:EnableMouseWheel(true)
    if zoomBar.SetPropagateMouseWheel then zoomBar:SetPropagateMouseWheel(false) end
    box[prefix .. "zoomBar"] = zoomBar
    local function AddZoomButton(field, text, width, tooltip, onClick, relativeTo, offset)
        local btn = createButton(zoomBar, text, width, tooltip, onClick)
        if relativeTo then
            btn:SetPoint("LEFT", relativeTo, "RIGHT", offset or 3, 0)
        else
            btn:SetPoint("LEFT", zoomBar, "LEFT", offset or 3, 0)
        end
        box[prefix .. field] = btn
        return btn
    end
    zoomBar:SetScript("OnEnter", function(self)
        if GameTooltip then
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(tr("Preview zoom"), 1, 1, 1)
            GameTooltip:AddLine(tr("Use the buttons or Ctrl + mouse wheel to zoom."), 0.82, 0.82, 0.82, true)
            GameTooltip:AddLine(tr("Ctrl + left-drag moves the preview canvas. Fit recenters it."), 0.55, 0.68, 0.86, true)
            GameTooltip:AddLine(tr("Use ? for all preview controls."), 0.50, 0.78, 0.92, true)
            GameTooltip:Show()
        end
    end)
    zoomBar:SetScript("OnLeave", function()
        if GameTooltip then GameTooltip:Hide() end
    end)
    local zoomOut = AddZoomButton("zoomOutButton", "-", 20, "Zoom out", function() stepZoom(box, -1) end)
    local T = opts.T
    local readout
    if opts.themeReadout and T and T.Font then
        readout = T.Font(zoomBar, "GameFontDisableSmall", "", { 0.72, 0.78, 0.90, 1 })
    else
        readout = zoomBar:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
        readout:SetTextColor(0.72, 0.78, 0.90, 1)
        if T and T.StyleFontString then T.StyleFontString(readout, { 0.72, 0.78, 0.90, 1 }, 0) end
    end
    readout:SetPoint("LEFT", zoomOut, "RIGHT", 3, 0)
    readout:SetSize(54, buttonH)
    readout:SetJustifyH("CENTER")
    box[prefix .. "zoomReadout"] = readout
    local fitButton = AddZoomButton("zoomFitButton", "Fit", 30, "Fit preview", function() setZoom(box, nil, opts.fitReason) end, readout)
    local oneButton = AddZoomButton("zoomOneButton", "1:1", 32, "Pixel preview", function() setZoom(box, 1, opts.oneReason) end, fitButton)
    local zoomIn = AddZoomButton("zoomInButton", "+", 20, "Zoom in", function() stepZoom(box, 1) end, oneButton)
    AddZoomButton("zoomHelpButton", "?", 20, "Preview controls", function(self)
        H.ShowPreviewControlsHelp(self, { M = opts.M or M, T = opts.T, W = opts.W, Tr = tr })
    end, zoomIn)
    local function ZoomWheel(self, delta)
        local dir = (delta or 0) > 0 and 1 or -1
        if IsControlKeyDown and IsControlKeyDown() then
            if self.SetPropagateMouseWheel then self:SetPropagateMouseWheel(false) end
            stepZoom(box, dir)
        elseif self.SetPropagateMouseWheel then
            self:SetPropagateMouseWheel(true)
        end
    end
    if opts.wheelField then box[opts.wheelField] = ZoomWheel end
    surface:SetScript("OnMouseWheel", ZoomWheel)
    zoomBar:SetScript("OnMouseWheel", function(_, delta) stepZoom(box, (delta or 0) > 0 and 1 or -1) end)
    if surface.RegisterForDrag then surface:RegisterForDrag("LeftButton") end
    surface:SetScript("OnMouseDown", function(self, button) startPan(self, box, button) end)
    surface:SetScript("OnMouseUp", stopPan)
    surface:SetScript("OnDragStart", function(self, button) startPan(self, box, button) end)
    surface:SetScript("OnDragStop", stopPan)
    surface:SetScript("OnHide", stopPan)
    return zoomBar, ZoomWheel
end
function H.IsTextInputFocused()
    local focus = GetCurrentKeyBoardFocus and GetCurrentKeyBoardFocus()
    return focus and focus.IsObjectType and focus:IsObjectType("EditBox")
end
function H.KeyDelta(key)
    if key == "LEFT" then return -1, 0 end
    if key == "RIGHT" then return 1, 0 end
    if key == "UP" then return 0, 1 end
    if key == "DOWN" then return 0, -1 end
    return nil, nil
end
function H.NudgeStep(opts)
    opts = opts or {}
    if IsControlKeyDown and IsControlKeyDown() then return tonumber(opts.ctrlStep) or 10 end
    if IsShiftKeyDown and IsShiftKeyDown() then return tonumber(opts.shiftStep) or 5 end
    return tonumber(opts.step) or 1
end
function H.ShouldSkipDuplicateNudge(owner, dx, dy, opts)
    if not owner then return false end
    opts = opts or {}
    local now = GetTime and GetTime() or 0
    if now <= 0 then return false end
    local sigKey = opts.sigKey or "_msufLastNudgeSig"
    local atKey = opts.atKey or "_msufLastNudgeAt"
    local sig = tostring(dx or 0) .. ":" .. tostring(dy or 0)
    if owner[sigKey] == sig and (now - (owner[atKey] or 0)) < (tonumber(opts.window) or 0.02) then return true end
    owner[sigKey] = sig
    owner[atKey] = now
    return false
end
local function SetKeyboardPropagate(frame, propagate)
    if frame and frame.SetPropagateKeyboardInput then frame:SetPropagateKeyboardInput(propagate == true) end
end
local function InCombatLocked()
    return (_G.InCombatLockdown and _G.InCombatLockdown()) and true or false
end
local function ForEachPreviewKeyboardFrame(owner, callback)
    if not (owner and type(callback) == "function") then return end
    local seen = {}
    local function Visit(frame)
        if frame and not seen[frame] then
            seen[frame] = true
            callback(frame)
        end
    end
    Visit(owner)
    local handles = owner.handles
    if type(handles) == "table" then
        for i = 1, #handles do
            Visit(handles[i])
        end
    end
    handles = owner._handleList
    if type(handles) == "table" then
        for i = 1, #handles do
            Visit(handles[i])
        end
    end
end
function H.ReleaseKeyboardCapture(owner)
    ForEachPreviewKeyboardFrame(owner, function(frame)
        SetKeyboardPropagate(frame, true)
    end)
end
function H.FocusKeyboardTarget(owner, handle, defer, opts)
    if not owner then return end
    opts = opts or {}
    local selectedField = opts.selectedField or "_selectedHandle"
    handle = handle or (selectedField and owner[selectedField])
    H.ReleaseKeyboardCapture(owner)
    if InCombatLocked() then return end
    if owner.EnableKeyboard then owner:EnableKeyboard(true) end
    SetKeyboardPropagate(owner, handle and false or true)
    if handle and handle.EnableKeyboard then handle:EnableKeyboard(true) end
    SetKeyboardPropagate(handle, false)
    if handle and handle.SetFocus then
        handle:SetFocus()
    elseif owner.SetFocus then
        owner:SetFocus()
    end
    if defer then
        local selected = handle
        _G.C_Timer.After(0, function()
            if not (owner and owner.IsShown and owner:IsShown()) then return end
            if selected and selected.IsShown and not selected:IsShown() then return end
            if selected and selectedField and owner[selectedField] ~= selected then return end
            H.FocusKeyboardTarget(owner, selected, false, opts)
        end)
    end
end
function H.ArrowKeyDown(self, keyName, opts)
    opts = opts or {}
    local owner = (opts.owner and opts.owner(self)) or (self and self._preview) or self or (opts.active and opts.active())
    if InCombatLocked() then
        H.ReleaseKeyboardCapture(owner)
        SetKeyboardPropagate(self, true)
        return false
    end
    local dx, dy = H.KeyDelta(keyName)
    if not dx then
        SetKeyboardPropagate(self, true)
        SetKeyboardPropagate(owner, true)
        return false
    end
    if H.IsTextInputFocused() then
        H.ReleaseKeyboardCapture(owner)
        SetKeyboardPropagate(self, true)
        return false
    end
    SetKeyboardPropagate(self, false)
    SetKeyboardPropagate(owner, false)
    if opts.nudge and opts.nudge(owner, dx, dy) then
        local selectedField = opts.selectedField or "_selectedHandle"
        local selected = (opts.selected and opts.selected(owner)) or (owner and selectedField and owner[selectedField])
        H.FocusKeyboardTarget(owner, selected, true, opts)
        return true
    end
    SetKeyboardPropagate(self, true)
    SetKeyboardPropagate(owner, true)
    return false
end
function H.RegisterEditModeNudgeTarget(owner, opts)
    local fn = _G.MSUF_EM2_SetPreviewNudgeTarget
    if type(fn) ~= "function" or not owner then return end
    opts = opts or {}
    local targetField = opts.targetField or "_msufPreviewNudgeTarget"
    local selectedField = opts.selectedField or "_selectedHandle"
    local function Selected() return opts.selected and opts.selected(owner) or (selectedField and owner[selectedField]) end
    owner[targetField] = owner[targetField] or {
        frame = owner,
        IsActive = function()
            if InCombatLocked() then return false end
            if not (owner and owner.IsShown and owner:IsShown()) then return false end
            local selected = Selected()
            if opts.canNudge and not opts.canNudge(selected, owner) then return false end
            return selected ~= nil and not H.IsTextInputFocused()
        end,
        Nudge = function(_, dx, dy)
            local ok = opts.nudgeDelta and opts.nudgeDelta(owner, dx, dy)
            if ok then H.FocusKeyboardTarget(owner, Selected(), true, opts) end
            return ok
        end,
    }
    fn(owner[targetField])
end
local LAYER_BUTTON_FALLBACK_COLOR = { 1, 1, 1 }
local function LayerButtonAvailable(owner, key)
    return not (owner and owner.layerAvailable and owner.layerAvailable[key] == false)
end
local function LayerButtonOn(owner, key)
    return LayerButtonAvailable(owner, key) and not (owner and owner.layerVisibility and owner.layerVisibility[key] == false)
end
local function LayerButtonAvailableFor(owner, key, opts)
    if opts and opts.IsAvailable then return opts.IsAvailable(owner, key) end
    return LayerButtonAvailable(owner, key)
end
local function LayerButtonOnFor(owner, key, opts)
    if opts and opts.IsOn then return opts.IsOn(owner, key) end
    return LayerButtonOn(owner, key)
end
function H.RefreshLayerButton(btn, owner, opts)
    if not btn then return end
    opts = opts or {}
    local available = LayerButtonAvailableFor(owner, btn.key, opts)
    local on = LayerButtonOnFor(owner, btn.key, opts)
    local c = btn.color or LAYER_BUTTON_FALLBACK_COLOR
    local textOn = opts.textOn or { 0.82, 0.88, 1.00, 0.98 }
    local textOff = opts.textOff or { 0.54, 0.61, 0.72, 0.78 }
    local textDisabled = opts.textDisabled or { 0.42, 0.48, 0.58, 0.62 }
    if btn.off then
        btn.off:SetText(opts.offText or "OFF")
        btn.off:SetShown((opts.showOffText == true) and ((not available) or not on))
    end
    if not available then
        btn.bg:SetColorTexture(0.010, 0.018, 0.030, 0.32)
        btn.bar:SetColorTexture(c[1], c[2], c[3], 0.30)
        btn.fs:SetTextColor(textDisabled[1], textDisabled[2], textDisabled[3], textDisabled[4] or 0.62)
        if btn.off then btn.off:SetTextColor(textDisabled[1], textDisabled[2], textDisabled[3], 0.72) end
    elseif on then
        btn.bg:SetColorTexture(c[1] * 0.12, c[2] * 0.12, c[3] * 0.12, 0.54)
        btn.bar:SetColorTexture(c[1], c[2], c[3], 0.94)
        btn.fs:SetTextColor(textOn[1], textOn[2], textOn[3], textOn[4] or 0.98)
        if btn.off then btn.off:SetTextColor(textOff[1], textOff[2], textOff[3], 0.0) end
    else
        btn.bg:SetColorTexture(0.010, 0.018, 0.030, 0.26)
        btn.bar:SetColorTexture(c[1], c[2], c[3], 0.42)
        btn.fs:SetTextColor(textOff[1], textOff[2], textOff[3], textOff[4] or 0.78)
        if btn.off then btn.off:SetTextColor(textOff[1], textOff[2], textOff[3], 0.78) end
    end
end
function H.CreateLayerButton(parent, owner, def, index, sideW, opts)
    if not (parent and def) then return nil end
    opts = opts or {}
    local tr = opts.Tr or F.Identity
    local btn = CreateFrame("Button", nil, parent)
    local h = opts.height or 18
    btn:SetSize((sideW or 80) - 10, h)
    btn:SetPoint("TOP", parent, "TOP", 0, -((opts.topOffset or 20) + ((index or 1) - 1) * (opts.rowHeight or h)))
    btn:EnableMouse(true)
    btn.key, btn.color, btn.tooltip = def.key, def.color, def.tooltip
    btn.bg = btn:CreateTexture(nil, "BACKGROUND")
    btn.bg:SetAllPoints()
    btn.bar = btn:CreateTexture(nil, "ARTWORK")
    btn.bar:SetSize(3, h - 5)
    btn.bar:SetPoint("LEFT", btn, "LEFT", 3, 0)
    btn.fs = btn:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    btn.fs:SetPoint("LEFT", btn.bar, "RIGHT", 6, 0)
    btn.fs:SetPoint("RIGHT", btn, "RIGHT", opts.showOffText == true and -22 or -7, 0)
    btn.fs:SetJustifyH("LEFT")
    btn.fs:SetText(tr(def.label))
    if T and T.StyleFontString then T.StyleFontString(btn.fs, { 0.78, 0.84, 0.96, 1 }, 0) end
    btn.off = btn:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    btn.off:SetPoint("RIGHT", btn, "RIGHT", -2, 0)
    btn.off:SetText(opts.offText or "OFF")
    btn.off:SetJustifyH("RIGHT")
    if T and T.StyleFontString then T.StyleFontString(btn.off, { 0.55, 0.60, 0.70, 1 }, 0) end
    function btn:Refresh() H.RefreshLayerButton(self, owner, opts) end
    btn.refresh = btn.Refresh
    btn:SetScript("OnClick", function(self)
        if opts.OnClick then
            opts.OnClick(self, owner)
            return
        end
        if not LayerButtonAvailable(owner, self.key) then return end
        owner.layerVisibility[self.key] = owner.layerVisibility[self.key] == false
        self:Refresh()
    end)
    btn:SetScript("OnEnter", function(self)
        local available = LayerButtonAvailableFor(owner, self.key, opts)
        local on = LayerButtonOnFor(owner, self.key, opts)
        local c = self.color or LAYER_BUTTON_FALLBACK_COLOR
        self.bg:SetColorTexture((available and on) and c[1] * 0.18 or 0.026, (available and on) and c[2] * 0.18 or 0.070, (available and on) and c[3] * 0.18 or 0.110, (available and on) and 0.74 or 0.58)
        self.bar:SetColorTexture(c[1], c[2], c[3], available and 1.0 or 0.48)
        self.fs:SetTextColor(0.90, 0.92, 1, 1)
        if opts.OnEnter then
            opts.OnEnter(self, owner, available, on, tr)
        elseif GameTooltip then
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(tr(self.fs:GetText() or self.key), 1, 1, 1)
            if self.tooltip then GameTooltip:AddLine(tr(self.tooltip), 0.82, 0.82, 0.82, true) end
            if not available and opts.disabledLine then GameTooltip:AddLine(tr(opts.disabledLine), 0.55, 0.68, 0.86, true) end
            GameTooltip:Show()
        end
    end)
    btn:SetScript("OnLeave", function(self)
        if GameTooltip then GameTooltip:Hide() end
        self:Refresh()
        if opts.OnLeave then opts.OnLeave(self, owner) end
    end)
    btn:Refresh()
    return btn
end
local TEXT_FOCUS_SIDES = { "top", "bottom", "left", "right" }
local EDGE_ANCHORS = {
    top = { "TOPLEFT", "TOPRIGHT", "SetHeight" },
    bottom = { "BOTTOMLEFT", "BOTTOMRIGHT", "SetHeight" },
    left = { "TOPLEFT", "BOTTOMLEFT", "SetWidth" },
    right = { "TOPRIGHT", "BOTTOMRIGHT", "SetWidth" },
}
function H.NormalizeTextFocusKind(kind)
    if kind == "name" or kind == "hp" or kind == "power" then return kind end
    return nil
end
function H.NormalizeTextFocusSlot(slot)
    if slot == "left" or slot == "center" or slot == "right" then return slot end
    return nil
end
function H.TextFocusColor(kind, colors)
    colors = colors or {}
    if kind == "hp" then return colors.hp or { 0.28, 0.86, 0.45 } end
    if kind == "power" then return colors.power or { 0.95, 0.72, 0.18 } end
    return colors.name or { 0.30, 0.66, 1.00 }
end
function H.EnsureTextFocusFrame(box, parent)
    if not (box and parent) then return nil end
    local f = box._msufMenuTextFocusFrame
    if not f then
        f = CreateFrame("Frame", nil, parent)
        f:EnableMouse(false)
        f.fill = f:CreateTexture(nil, "BACKGROUND")
        f.fill:SetAllPoints()
        f.lines = {}
        for i = 1, #TEXT_FOCUS_SIDES do
            local side = TEXT_FOCUS_SIDES[i]
            local line = f:CreateTexture(nil, "OVERLAY")
            line:SetPoint(EDGE_ANCHORS[side][1])
            line:SetPoint(EDGE_ANCHORS[side][2])
            f.lines[side] = line
        end
        box._msufMenuTextFocusFrame = f
    elseif f.SetParent then
        f:SetParent(parent)
    end
    if f.SetFrameLevel and parent.GetFrameLevel then f:SetFrameLevel((parent:GetFrameLevel() or 0) + 85) end
    return f
end
function H.PaintTextFocusFrame(frame, color, active)
    if not (frame and color) then return end
    local lineAlpha = active and 0.92 or 0.74
    local fillAlpha = active and 0.10 or 0.065
    local thickness = active and 2 or 1
    if frame.fill then frame.fill:SetColorTexture(color[1], color[2], color[3], fillAlpha) end
    if frame.lines then
        frame.lines.top:SetHeight(thickness)
        frame.lines.bottom:SetHeight(thickness)
        frame.lines.left:SetWidth(thickness)
        frame.lines.right:SetWidth(thickness)
        for _, line in pairs(frame.lines) do
            if line then line:SetColorTexture(color[1], color[2], color[3], lineAlpha) end
        end
    end
end
function H.PlaceHandleAroundRegions(handle, parent, regions, pad, opts)
    if not (handle and parent and parent.GetLeft and regions) then return false end
    opts = opts or {}
    pad = tonumber(pad) or 3
    local min, max = math.min, math.max
    local left, right, top, bottom
    for i = 1, #regions do
        local region = regions[i]
        if region and region.IsShown and region:IsShown() and region.GetLeft then
            local l, r, t, b = region:GetLeft(), region:GetRight(), region:GetTop(), region:GetBottom()
            if l and r and t and b then
                if opts.fitText then
                    local regionW = r - l
                    if region.GetStringWidth and regionW > 0 then
                        local textW = tonumber(region:GetStringWidth()) or 0
                        if textW > 0 and textW < regionW then
                            local justify = (region.GetJustifyH and region:GetJustifyH()) or region._msufPreviewJustifyH or "LEFT"
                            if justify == "RIGHT" then
                                l = r - textW
                            elseif justify == "CENTER" then
                                local cx = (l + r) * 0.5
                                l, r = cx - (textW * 0.5), cx + (textW * 0.5)
                            else
                                r = l + textW
                            end
                        end
                    end
                    local regionH = t - b
                    if region.GetStringHeight and regionH > 0 then
                        local textH = tonumber(region:GetStringHeight()) or 0
                        if textH > 0 and textH < regionH then
                            local cy = (t + b) * 0.5
                            t, b = cy + (textH * 0.5), cy - (textH * 0.5)
                        end
                    end
                end
                left = left and min(left, l) or l
                right = right and max(right, r) or r
                top = top and max(top, t) or t
                bottom = bottom and min(bottom, b) or b
            end
        end
    end
    local pLeft, pBottom = parent:GetLeft(), parent:GetBottom()
    if not (left and right and top and bottom and pLeft and pBottom) then return false end
    handle:ClearAllPoints()
    handle:SetSize(max(18, right - left + pad * 2), max(18, top - bottom + pad * 2))
    handle:SetPoint("BOTTOMLEFT", parent, "BOTTOMLEFT", left - pLeft - pad, bottom - pBottom - pad)
    handle:Show()
    return true
end
function H.ApplyTextFocus(box, parent, mock, opts)
    opts = opts or {}
    local focus = box and box._msufMenuTextFocus
    local frame = box and box._msufMenuTextFocusFrame
    if not (focus and parent and mock) then
        if frame and frame.Hide then frame:Hide() end
        return
    end
    local regions = opts.Regions and opts.Regions(mock, focus.kind, focus.slot)
    if not regions then
        if frame and frame.Hide then frame:Hide() end
        return
    end
    frame = H.EnsureTextFocusFrame(box, parent)
    if not frame then return end
    local color = (opts.Color and opts.Color(focus.kind)) or H.TextFocusColor(focus.kind, opts.colors)
    H.PaintTextFocusFrame(frame, color, focus.active == true)
    if not (opts.Place and opts.Place(frame, parent, regions, focus.active and 5 or 4)) then frame:Hide() end
end
function H.SnapOff(region)
    if region and region.SetSnapToPixelGrid then
        region:SetSnapToPixelGrid(false)
        if region.SetTexelSnappingBias then region:SetTexelSnappingBias(0) end
    end
end
function H.MaskOwner(mock, tex, anchor)
    local owner = tex and tex.GetParent and tex:GetParent() or nil
    if owner and owner.CreateMaskTexture then return owner end
    if anchor and anchor.CreateMaskTexture then return anchor end
    return mock
end
function H.EnsureRoundedMask(mock, key, anchor, tex, maskStoreKey, maskTexture, snapOff)
    if not (mock and anchor) then return nil end
    local owner = H.MaskOwner(mock, tex, anchor)
    if not (owner and owner.CreateMaskTexture) then return nil end
    maskStoreKey = maskStoreKey or "_msufPreviewRoundedMasks"
    mock[maskStoreKey] = mock[maskStoreKey] or {}
    local store = mock[maskStoreKey]
    local bucket = store[key]
    if type(bucket) ~= "table" or bucket.SetTexture then
        bucket = {}
        store[key] = bucket
    end
    local ownerKey = tex or owner
    local mask = bucket[ownerKey]
    if not mask then
        mask = owner:CreateMaskTexture(nil, "ARTWORK")
        local snap = snapOff or H.SnapOff
        snap(mask)
        bucket[ownerKey] = mask
    end
    if mask._msufPreviewRoundedAnchor ~= anchor or mask._msufPreviewRoundedTexture ~= maskTexture then
        mask._msufPreviewRoundedAnchor = anchor
        mask._msufPreviewRoundedTexture = maskTexture
        mask:ClearAllPoints()
        mask:SetTexture(maskTexture, "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
        mask:SetAllPoints(anchor)
    end
    return mask
end
function H.SetMask(mock, tex, mask, maskedStoreKey)
    if not (mock and tex and tex.AddMaskTexture) then return end
    maskedStoreKey = maskedStoreKey or "_msufPreviewRoundedMasked"
    mock[maskedStoreKey] = mock[maskedStoreKey] or {}
    local store = mock[maskedStoreKey]
    local old = store[tex]
    if old == mask then return end
    if old and tex.RemoveMaskTexture then tex:RemoveMaskTexture(old) end
    store[tex] = nil
    if mask then
        tex:AddMaskTexture(mask)
        store[tex] = mask
    end
end
function H.ClearMasks(mock, maskedStoreKey)
    local store = mock and mock[maskedStoreKey or "_msufPreviewRoundedMasked"]
    if store then
        for tex, mask in pairs(store) do
            if tex and tex.RemoveMaskTexture and mask then tex:RemoveMaskTexture(mask) end
        end
    end
    if mock then mock[maskedStoreKey or "_msufPreviewRoundedMasked"] = nil end
end
local EDGE_LINE_KEYS = { "top", "bottom", "left", "right" }
function H.SetEdgeLinesShown(frame, shown, opts)
    local lines = frame and frame[(opts and opts.linesKey) or "_lines"]
    if type(lines) ~= "table" then return end
    local keys = (opts and opts.keys) or EDGE_LINE_KEYS
    for i = 1, #keys do
        local line = lines[keys[i]]
        if line then
            if shown then line:Show() else line:Hide() end
        end
    end
end
function H.LayoutEdgeLines(frame, edge, opts)
    if not (frame and frame.CreateTexture) then return false end
    opts = opts or {}
    edge = H.ClampEdgeSize(edge, 1, opts.maxEdgeSize or 30)
    if edge <= 0 then H.SetEdgeLinesShown(frame, false, opts); return false end
    local linesKey = opts.linesKey or "_lines"
    local keys = opts.keys or EDGE_LINE_KEYS
    frame[linesKey] = frame[linesKey] or {}
    local lines = frame[linesKey]
    local texture = opts.texture or "Interface\\Buttons\\WHITE8X8"
    local snap = opts.snapOff or H.SnapOff
    for i = 1, #keys do
        local key = keys[i]
        if not lines[key] then
            lines[key] = frame:CreateTexture(nil, opts.layer or "OVERLAY")
            lines[key]:SetTexture(texture)
            snap(lines[key])
        end
    end
    local r, g, b, a = 0, 0, 0, 1
    if type(opts.color) == "function" then r, g, b, a = opts.color(frame) end
    for i = 1, #keys do
        local key, line = keys[i], lines[keys[i]]
        local spec = EDGE_ANCHORS[key]
        line:SetVertexColor(r or 0, g or 0, b or 0, a or 1)
        line:ClearAllPoints()
        line:SetPoint(spec[1], frame, spec[1], 0, 0)
        line:SetPoint(spec[2], frame, spec[2], 0, 0)
        line[spec[3]](line, edge)
    end
    H.SetEdgeLinesShown(frame, true, opts)
    return true
end
function H.ClampEdgeSize(value, fallback, maxValue)
    local n = tonumber(value)
    if n == nil then n = tonumber(fallback) or 0 end
    n = math.floor(n + 0.5)
    if n < 0 then n = 0 end
    maxValue = tonumber(maxValue) or 8
    if n > maxValue then n = maxValue end
    return n
end
function H.EnsureRoundedVisuals(mock, opts)
    if not (mock and mock.CreateTexture) then return false end
    opts = opts or {}
    local bgKey = opts.bgKey or "roundedBg"
    local edgeKey = opts.edgeKey or "roundedEdge"
    local snap = opts.snapOff or H.SnapOff
    if not mock[bgKey] then
        mock[bgKey] = mock:CreateTexture(nil, opts.bgLayer or "BACKGROUND", nil, opts.bgSubLevel)
        mock[bgKey]:SetTexture(opts.whiteTexture or "Interface\\Buttons\\WHITE8X8")
        snap(mock[bgKey])
    end
    if not mock[edgeKey] then
        mock[edgeKey] = mock:CreateTexture(nil, opts.edgeLayer or "OVERLAY", nil, opts.edgeSubLevel)
        snap(mock[edgeKey])
    end
    if mock[edgeKey]._msufPreviewRoundedEdgeTexture ~= opts.edgeTexture then
        mock[edgeKey]._msufPreviewRoundedEdgeTexture = opts.edgeTexture
        mock[edgeKey]:SetTexture(opts.edgeTexture, "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
    end
    return true
end
function H.ForEachRoundedEdge(mock, opts, fn)
    if not (mock and type(fn) == "function") then return end
    opts = opts or {}
    local edge = mock[opts.edgeKey or "roundedEdge"]
    if edge then fn(edge, 1) end
    local stack = mock[opts.stackKey or "_msufPreviewRoundedEdgeStack"]
    if type(stack) ~= "table" then return end
    for i = 2, #stack do
        if stack[i] then fn(stack[i], i) end
    end
end
function H.SetRoundedEdgeStackShown(mock, shown, opts)
    opts = opts or {}
    local count = shown and H.ClampEdgeSize(mock and mock[opts.countKey or "_msufPreviewRoundedEdgeCount"], 1, opts.maxEdgeSize or 8) or 0
    H.ForEachRoundedEdge(mock, opts, function(edge, i)
        if edge.SetShown then
            edge:SetShown(i <= count)
        elseif i <= count then
            edge:Show()
        else
            edge:Hide()
        end
    end)
end
function H.SetRoundedEdgeStackAlpha(mock, alpha, opts)
    local clamp = opts and opts.clamp01
    alpha = type(clamp) == "function" and clamp(alpha, 1) or math.max(0, math.min(1, tonumber(alpha) or 1))
    H.ForEachRoundedEdge(mock, opts, function(edge)
        if edge and edge.SetAlpha then edge:SetAlpha(alpha) end
    end)
end
function H.ApplyRoundedEdgeStack(mock, edgeSize, opts)
    if not mock then return false end
    opts = opts or {}
    local count = H.ClampEdgeSize(edgeSize, 0, opts.maxEdgeSize or 8)
    local stackKey = opts.stackKey or "_msufPreviewRoundedEdgeStack"
    local countKey = opts.countKey or "_msufPreviewRoundedEdgeCount"
    local edgeKey = opts.edgeKey or "roundedEdge"
    mock[countKey] = count
    if count <= 0 then
        H.SetRoundedEdgeStackShown(mock, false, opts)
        return false
    end
    mock[stackKey] = mock[stackKey] or {}
    mock[stackKey][1] = mock[edgeKey]
    local snap = opts.snapOff or H.SnapOff
    local edgeTexture = opts.edgeTexture
    local r, g, b, a
    if type(opts.baseEdgeColor) == "function" then
        r, g, b, a = opts.baseEdgeColor(mock)
    else
        r, g, b, a = H.BaseEdgeColor()
    end
    for i = 1, count do
        local edge = (i == 1) and mock[edgeKey] or mock[stackKey][i]
        if not edge then
            edge = mock:CreateTexture(nil, opts.edgeLayer or "OVERLAY", nil, opts.edgeSubLevel)
            snap(edge)
            mock[stackKey][i] = edge
        end
        if edge._msufPreviewRoundedEdgeTexture ~= edgeTexture then
            edge._msufPreviewRoundedEdgeTexture = edgeTexture
            edge:SetTexture(edgeTexture, "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
        end
        if edge._msufPreviewRoundedAnchor ~= mock or edge._msufPreviewRoundedPad ~= i then
            edge._msufPreviewRoundedAnchor = mock
            edge._msufPreviewRoundedPad = i
            edge:ClearAllPoints()
            edge:SetPoint("TOPLEFT", mock, "TOPLEFT", -i, i)
            edge:SetPoint("BOTTOMRIGHT", mock, "BOTTOMRIGHT", i, -i)
        end
        if edge._msufPreviewRoundedR ~= r or edge._msufPreviewRoundedG ~= g
            or edge._msufPreviewRoundedB ~= b or edge._msufPreviewRoundedA ~= a then
            edge._msufPreviewRoundedR = r
            edge._msufPreviewRoundedG = g
            edge._msufPreviewRoundedB = b
            edge._msufPreviewRoundedA = a
            edge:SetVertexColor(r, g, b, a)
        end
        edge:Show()
    end
    H.SetRoundedEdgeStackShown(mock, true, opts)
    return true
end
function H.BaseEdgeColor()
    local fn = _G.MSUF_GetBarOutlineColor
    if type(fn) == "function" then
        local r, g, b = fn()
        if type(r) == "number" and type(g) == "number" and type(b) == "number" then return r, g, b, 1 end
    end
    local gen = _G.MSUF_DB and _G.MSUF_DB.general
    if gen then
        return tonumber(gen.barOutlineColorR) or 0,
               tonumber(gen.barOutlineColorG) or 0,
               tonumber(gen.barOutlineColorB) or 0,
               1
    end
    return 0, 0, 0, 1
end
