-- =============================================================
-- NemFramework.lua — shared drop-in options-panel framework for the Nem: Alerts suite.
-- Addon-agnostic: no addon-specific strings. Identity/theme/font come in via opts.
-- Contract: ship this file in the addon's .toc; the core captures the same `local addonName,
-- ns = ...` and reads `ns.UI`. Optional callbacks: opts.theme() -> r,g,b (default white) and
-- opts.uiFont (path or fn). Full reference: NemWiki Wiki\Concepts\nem-framework.md.
-- =============================================================

local addonName, ns = ...
ns.UI = ns.UI or {}
local UI = ns.UI

local CreateFrame    = CreateFrame
local math_floor     = math.floor
local math_max       = math.max
local math_min       = math.min
local GetCursorPosition = GetCursorPosition

UI.V_SPACING = 16
UI.H_SPACING = 20

-- ============================================================
-- Settings Registration
-- ============================================================
function UI.RegisterCanvas(frame)
    if Settings and Settings.RegisterCanvasLayoutCategory then
        local category = Settings.RegisterCanvasLayoutCategory(frame, frame.name)
        Settings.RegisterAddOnCategory(category)
        return category:GetID()
    elseif InterfaceOptions_AddCategory then
        InterfaceOptions_AddCategory(frame)
    end
end

function UI.OpenCategory(categoryID)
    if categoryID and Settings and Settings.OpenToCategory then
        Settings.OpenToCategory(categoryID)
    end
end

-- ============================================================
-- Serialization (LibSerialize + LibDeflate, optional)
-- ============================================================
-- Import/Export is an opt-in capability: it lights up only when both libs are vendored. Without
-- them UI.HasSerializer() is false and the Profiles tab simply omits its import/export controls;
-- the rest of the framework is unaffected (preserves the standalone drop-in promise).
local function GetSerializers()
    if not LibStub then return nil end
    local ser = LibStub:GetLibrary("LibSerialize", true)
    local def = LibStub:GetLibrary("LibDeflate", true)
    if ser and def then return ser, def end
    return nil
end

function UI.HasSerializer()
    return GetSerializers() ~= nil
end

-- table -> printable string (serialize -> deflate -> EncodeForPrint). Returns nil on any failure.
function UI.Encode(tbl)
    local ser, def = GetSerializers()
    if not ser then return nil end
    local ok, serialized = pcall(ser.Serialize, ser, tbl)
    if not ok or not serialized then return nil end
    local compressed = def:CompressDeflate(serialized, { level = 9 })
    if not compressed then return nil end
    return def:EncodeForPrint(compressed)
end

-- printable string -> table. Returns (nil, reason) on failure; deserialize is safe for hostile input.
function UI.Decode(str)
    local ser, def = GetSerializers()
    if not ser then return nil, "nolibs" end
    if type(str) ~= "string" then return nil, "empty" end
    str = str:gsub("%s", "")
    if str == "" then return nil, "empty" end
    local compressed = def:DecodeForPrint(str)
    if not compressed then return nil, "decode" end
    local serialized = def:DecompressDeflate(compressed)
    if not serialized then return nil, "decompress" end
    local ok, result = ser:Deserialize(serialized)
    if not ok or type(result) ~= "table" then return nil, "deserialize" end
    return result
end

-- ============================================================
-- Profile database (generic, addon-agnostic)
-- ============================================================
-- A thin manager over an SV container shaped { profiles = {[name]=tbl}, profileKeys = {[charKey]=name} }.
-- The addon owns its own migration into this shape and its per-profile schema; this only does the
-- generic CRUD + active-profile routing the whole suite needs.
local ProfileMixin = {}
local Profile_meta = { __index = ProfileMixin }

function UI.MakeProfileDB(sv, charKey, defaultName)
    defaultName = defaultName or "Default"
    sv.profiles    = sv.profiles or {}
    sv.profileKeys = sv.profileKeys or {}
    local active = sv.profileKeys[charKey]
    if not active or not sv.profiles[active] then active = defaultName end
    sv.profiles[active]      = sv.profiles[active] or {}
    sv.profileKeys[charKey]  = active
    return setmetatable({
        sv = sv, charKey = charKey, defaultName = defaultName, active = active,
    }, Profile_meta)
end

function ProfileMixin:ActiveName()  return self.active end
function ProfileMixin:ActiveTable() return self.sv.profiles[self.active] end

function ProfileMixin:List()
    local out = {}
    for name in pairs(self.sv.profiles) do out[#out + 1] = name end
    table.sort(out, function(a, b) return a:lower() < b:lower() end)
    return out
end

-- Every profile except the active one (the set offered for Copy From / Delete).
function ProfileMixin:Others()
    local out = {}
    for _, name in ipairs(self:List()) do
        if name ~= self.active then out[#out + 1] = name end
    end
    return out
end

-- Create an empty profile (its schema fills from defaults on next load). Returns true on success.
function ProfileMixin:Create(name)
    name = type(name) == "string" and name:match("^%s*(.-)%s*$") or ""
    if name == "" or self.sv.profiles[name] then return false end
    self.sv.profiles[name] = {}
    return true
end

-- Point this character at `name`. Caller reloads to re-init from the new profile.
function ProfileMixin:SetActive(name)
    if not self.sv.profiles[name] then return false end
    self.sv.profileKeys[self.charKey] = name
    self.active = name
    return true
end

-- Replace the active profile's contents with a deep copy of `name`. Caller reloads.
function ProfileMixin:CopyFrom(name)
    if name == self.active or not self.sv.profiles[name] then return false end
    self.sv.profiles[self.active] = CopyTable(self.sv.profiles[name])
    return true
end

-- Replace the active profile with an arbitrary table (an import payload). Caller reloads.
function ProfileMixin:Overwrite(tbl)
    if type(tbl) ~= "table" then return false end
    self.sv.profiles[self.active] = CopyTable(tbl)
    return true
end

-- Delete a non-active profile; any character pointed at it falls back to the default profile.
function ProfileMixin:Delete(name)
    if name == self.active or not self.sv.profiles[name] then return false end
    for ck, pn in pairs(self.sv.profileKeys) do
        if pn == name then self.sv.profileKeys[ck] = self.defaultName end
    end
    self.sv.profiles[name] = nil
    return true
end

-- ============================================================
-- Panel Controller
-- ============================================================
local PanelMixin = {}
local Panel_meta = { __index = PanelMixin }

function UI:CreatePanel(opts)
    local P = setmetatable({}, Panel_meta)
    P.theme    = opts.theme or function() return 1, 1, 1 end
    P._fs      = {}
    P._boxes   = {}
    P.tabs     = {}
    P.buttons  = {}
    P.uiFontPath = opts.uiFont and (type(opts.uiFont) == "function" and opts.uiFont() or opts.uiFont) or nil

    local root = CreateFrame("Frame", opts.frameName, UIParent)
    root.name = opts.name
    P.root = root

    local title = root:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    title:SetPoint("TOP", root, "TOP", 0, -16)
    title:SetFont(P.uiFontPath or (title:GetFont()), 20, "OUTLINE")
    title:SetText(opts.title or opts.name)
    P._fs[#P._fs + 1] = { fs = title, alpha = 1 }

    if opts.version then
        local ver = root:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        ver:SetPoint("BOTTOMRIGHT", root, "BOTTOMRIGHT", -20, 12)
        ver:SetText(opts.version)
        ver:SetTextColor(0.5, 0.5, 0.5)
        P:Reface(ver)
    end

    local headerBottom = title
    if opts.description then
        local desc = root:CreateFontString(nil, "OVERLAY", "GameFontWhite")
        desc:SetPoint("TOP", title, "BOTTOM", 0, -6)
        desc:SetWidth(560)
        desc:SetJustifyH("CENTER")
        desc:SetText(opts.description)
        desc:SetTextColor(0.75, 0.75, 0.75)
        P:Reface(desc)
        headerBottom = desc
    end

    local tabBar = CreateFrame("Frame", nil, root)
    tabBar:SetPoint("TOP",   headerBottom, "BOTTOM", 0, -12)
    tabBar:SetPoint("LEFT",  root, "LEFT",  16, 0)
    tabBar:SetPoint("RIGHT", root, "RIGHT", -16, 0)
    tabBar:SetHeight(24)
    P.tabBar = tabBar

    local tabDivider = tabBar:CreateTexture(nil, "ARTWORK")
    tabDivider:SetHeight(1)
    tabDivider:SetPoint("BOTTOMLEFT",  tabBar, "BOTTOMLEFT",  4, 0)
    tabDivider:SetPoint("BOTTOMRIGHT", tabBar, "BOTTOMRIGHT", -4, 0)
    tabDivider:SetColorTexture(1, 1, 1, 0.15)

    root:SetScript("OnShow", function()
        P:RefreshTheme()
        if P.onShow then P.onShow() end
    end)

    return P
end

-- ============================================================
-- Theme
-- ============================================================
function PanelMixin:Track(obj, kind, alpha)
    if kind == "border" then
        self._boxes[#self._boxes + 1] = obj
    else
        self._fs[#self._fs + 1] = { fs = obj, alpha = alpha or 1 }
    end
end

function PanelMixin:TrackColor(fn)
    self._fs[#self._fs + 1] = { setColor = fn }
end

-- Swap a FontString's typeface to the panel UI font, keeping its size, flags, and color.
function PanelMixin:Reface(fs)
    local path = self.uiFontPath
    if not path or not fs then return fs end
    local _, sz, fl = fs:GetFont()
    if sz then fs:SetFont(path, sz, fl or "") end
    return fs
end

function PanelMixin:RefreshTheme()
    local r, g, b = self.theme()
    for _, e in ipairs(self._fs) do
        if e.setColor then
            e.setColor(r, g, b)
        else
            e.fs:SetTextColor(r, g, b, e.alpha or 1)
        end
    end
    for _, box in ipairs(self._boxes) do
        if box.SetBackdropBorderColor then
            box:SetBackdropBorderColor(r, g, b, 0.3)
        end
    end
end

-- ============================================================
-- Scroll
-- ============================================================
local function MakeScroll(P, parent)
    local SB_W = 4

    local viewport = CreateFrame("Frame", nil, parent)
    viewport:SetClipsChildren(true)

    local content = CreateFrame("Frame", nil, viewport)
    content:SetWidth(1)
    content:SetHeight(1)
    content:SetPoint("TOPLEFT", viewport, "TOPLEFT", 0, 0)
    local UpdateBar
    viewport:SetScript("OnSizeChanged", function(_, w)
        if w and w > 0 then content:SetWidth(w) end
        if UpdateBar then UpdateBar() end
    end)

    local offset = 0
    local function ApplyScroll(o)
        local maxScroll = math_max(0, content:GetHeight() - viewport:GetHeight())
        o = math_max(0, math_min(o, maxScroll))
        offset = o
        content:SetPoint("TOPLEFT", viewport, "TOPLEFT", 0, offset)
        return offset, maxScroll
    end

    local sbBar = CreateFrame("Frame", nil, parent)
    sbBar:SetWidth(SB_W)
    sbBar:SetPoint("TOPRIGHT",    viewport, "TOPRIGHT",     SB_W + 4, 8)
    sbBar:SetPoint("BOTTOMRIGHT", viewport, "BOTTOMRIGHT",  SB_W + 4, 0)
    sbBar:SetFrameLevel(parent:GetFrameLevel() + 10)

    local rail = sbBar:CreateTexture(nil, "BACKGROUND")
    rail:SetAllPoints(true)
    rail:SetColorTexture(0.22, 0.22, 0.22, 0.85)

    local thumb = CreateFrame("Button", nil, sbBar)
    thumb:SetWidth(SB_W)
    thumb:SetHeight(40)
    thumb:SetPoint("TOP", sbBar, "TOP", 0, 0)

    local thumbTex = thumb:CreateTexture(nil, "ARTWORK")
    thumbTex:SetAllPoints(true)
    P:TrackColor(function(r, g, b) thumbTex:SetColorTexture(r, g, b, 0.85) end)

    function UpdateBar()
        local viewH = viewport:GetHeight()
        local childH = content:GetHeight()
        local maxScroll = math_max(0, childH - viewH)
        if viewH <= 0 then return end
        if maxScroll <= 0 then sbBar:Hide(); return end
        sbBar:Show()
        local trackH = sbBar:GetHeight()
        local thumbH = math_floor(math_max(20, trackH * (viewH / childH)) + 0.5)
        thumb:SetHeight(thumbH)
        local travel = trackH - thumbH
        local pos = travel > 0 and (offset / maxScroll) * travel or 0
        thumb:ClearAllPoints()
        thumb:SetPoint("TOP", sbBar, "TOP", 0, -math_floor(pos + 0.5))
    end

    local dragStartY, dragStartOffset
    thumb:RegisterForDrag("LeftButton")
    thumb:SetScript("OnDragStart", function()
        dragStartY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale()
        dragStartOffset = offset
        thumb:SetScript("OnUpdate", function()
            local curY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale()
            local delta = dragStartY - curY
            local trackH = sbBar:GetHeight()
            local thumbH = thumb:GetHeight()
            local maxScroll = math_max(0, content:GetHeight() - viewport:GetHeight())
            local travel = trackH - thumbH
            if travel > 0 then
                ApplyScroll(dragStartOffset + delta * (maxScroll / travel))
                UpdateBar()
            end
        end)
    end)
    thumb:SetScript("OnDragStop", function() thumb:SetScript("OnUpdate", nil) end)

    viewport:EnableMouseWheel(true)
    viewport:SetScript("OnMouseWheel", function(_, d)
        ApplyScroll(offset - d * 40)
        UpdateBar()
    end)

    return {
        viewport = viewport,
        content  = content,
        SetContentHeight = function(_, h)
            content:SetHeight(h)
            ApplyScroll(0)
            C_Timer.After(0.05, UpdateBar)
        end,
    }
end

-- ============================================================
-- Tabs
-- ============================================================
function PanelMixin:SelectTab(index)
    for i, tab in ipairs(self.tabs) do
        local sel = (i == index)
        tab.container:SetShown(sel)
        tab.box:SetShown(sel)
        tab.label:SetFontObject(sel and "GameFontHighlight" or "GameFontNormal")
        self:Reface(tab.label)
    end
    self.activeTab = index
end

function PanelMixin:AddTab(label)
    local index = #self.tabs + 1

    local container = CreateFrame("Frame", nil, self.root)
    container:SetPoint("TOPLEFT",     self.tabBar, "BOTTOMLEFT",  0, -8)
    container:SetPoint("BOTTOMRIGHT", self.root,   "BOTTOMRIGHT", -8, 44)
    container:Hide()

    local scroll = MakeScroll(self, container)
    scroll.viewport:SetPoint("TOPLEFT",     container, "TOPLEFT",      0, 0)
    scroll.viewport:SetPoint("BOTTOMRIGHT", container, "BOTTOMRIGHT", -8, 0)

    local btn = CreateFrame("Button", nil, self.tabBar)
    btn:SetHeight(24)
    local fs = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    fs:SetPoint("CENTER", 0, 1)
    fs:SetText(label)
    self:Reface(fs)
    btn:SetWidth(fs:GetStringWidth() + 28)
    if index == 1 then
        btn:SetPoint("LEFT", self.tabBar, "LEFT", 4, 0)
    else
        btn:SetPoint("LEFT", self.tabs[index - 1].button, "RIGHT", 6, 0)
    end

    local box = CreateFrame("Frame", nil, btn)
    box:SetAllPoints(btn)
    local edges = {}
    for _, p in ipairs({
        { "TOPLEFT", "TOPRIGHT", 1 }, { "BOTTOMLEFT", "BOTTOMRIGHT", 1 },
        { "TOPLEFT", "BOTTOMLEFT", 0 }, { "TOPRIGHT", "BOTTOMRIGHT", 0 },
    }) do
        local t = box:CreateTexture(nil, "ARTWORK")
        if p[3] == 1 then t:SetHeight(1) else t:SetWidth(1) end
        t:SetPoint(p[1], box, p[1], 0, 0)
        t:SetPoint(p[2], box, p[2], 0, 0)
        edges[#edges + 1] = t
    end
    box:Hide()
    self:TrackColor(function(r, g, b)
        for _, t in ipairs(edges) do t:SetColorTexture(r, g, b, 1) end
    end)

    local tab = {
        button = btn, label = fs, box = box,
        container = container, scroll = scroll, content = scroll.content, index = index,
    }
    tab.content = scroll.content
    self.tabs[index] = tab

    btn:SetScript("OnClick", function() self:SelectTab(index) end)
    if index == 1 then self:SelectTab(1) end

    return scroll.content, tab
end

-- ============================================================
-- Widget Factories
-- ============================================================
function PanelMixin:Section(parent, text)
    local c = CreateFrame("Frame", nil, parent)
    c:SetHeight(24)

    local label = c:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    label:SetText(text)
    label:SetPoint("CENTER", c, "CENTER", 0, 0)
    self:Reface(label)
    self:Track(label, "fs", 1)

    local left = c:CreateTexture(nil, "ARTWORK")
    left:SetHeight(1)
    left:SetPoint("LEFT", c, "LEFT", 0, 0)
    left:SetPoint("RIGHT", label, "LEFT", -8, 0)

    local right = c:CreateTexture(nil, "ARTWORK")
    right:SetHeight(1)
    right:SetPoint("LEFT", label, "RIGHT", 8, 0)
    right:SetPoint("RIGHT", c, "RIGHT", 0, 0)

    self:TrackColor(function(r, g, b)
        left:SetColorTexture(r, g, b, 0.35)
        right:SetColorTexture(r, g, b, 0.35)
    end)
    return c
end

function PanelMixin:Checkbox(parent, opts)
    local cb = CreateFrame("CheckButton", nil, parent, "UICheckButtonTemplate")
    cb.Text:SetText(opts.label)
    cb.Text:SetFontObject("GameFontHighlight")
    self:Reface(cb.Text)
    cb:SetChecked(opts.get())
    cb:SetScript("OnClick", function(self)
        opts.set(self:GetChecked() and true or false)
    end)
    if opts.tooltip then self:Tooltip(cb, opts.tooltip) end
    cb.Refresh = function() cb:SetChecked(opts.get()) end
    return cb
end

function PanelMixin:Button(parent, opts)
    local btn = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
    btn:SetSize(opts.width or 125, opts.height or 26)
    btn:SetText(opts.text)
    self:Reface(btn:GetFontString())
    btn:SetScript("OnClick", opts.onClick)
    return btn
end

function PanelMixin:EditBox(parent, opts)
    local box = CreateFrame("EditBox", nil, parent, "InputBoxTemplate")
    box:SetSize(opts.width or 120, opts.height or 20)
    box:SetAutoFocus(false)
    box:SetFontObject("GameFontHighlight")
    self:Reface(box)
    if opts.maxLetters then box:SetMaxLetters(opts.maxLetters) end
    if opts.numeric then box:SetNumeric(true) end
    if opts.justify then box:SetJustifyH(opts.justify) end
    box:SetText(opts.get() or "")
    box:SetScript("OnEnterPressed", function(self) self:ClearFocus() end)
    box:SetScript("OnEscapePressed", function(self) self:SetText(opts.get() or ""); self:ClearFocus() end)
    box:SetScript("OnEditFocusLost", function(self) opts.set(self:GetText()) end)
    self:Track(box, "fs", 1)
    box.Refresh = function() box:SetText(opts.get() or "") end
    return box
end

function PanelMixin:Slider(parent, opts)
    local frame = CreateFrame("Frame", nil, parent)

    local slider = CreateFrame("Slider", nil, frame, "OptionsSliderTemplate")
    slider:SetWidth((opts.width or 260) - 56)
    if opts.inline then
        frame:SetSize(opts.width or 260, 24)
        slider:SetPoint("LEFT", frame, "LEFT", 0, 0)
    else
        frame:SetSize(opts.width or 260, 44)
        local label = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        label:SetPoint("TOPLEFT", 0, 0)
        label:SetText(opts.label)
        self:Reface(label)
        slider:SetPoint("TOPLEFT", label, "BOTTOMLEFT", 0, -4)
    end
    slider:SetMinMaxValues(opts.min, opts.max)
    slider:SetValueStep(opts.step or 1)
    slider:SetObeyStepOnDrag(true)
    if slider.Low  then slider.Low:SetText("")  end
    if slider.High then slider.High:SetText("") end
    if slider.Text then slider.Text:SetText("") end

    local box = CreateFrame("EditBox", nil, frame, "InputBoxTemplate")
    box:SetSize(50, 20)
    box:SetPoint("LEFT", slider, "RIGHT", 8, 0)
    box:SetAutoFocus(false)
    box:SetNumeric(not opts.allowFloat)
    box:SetJustifyH("CENTER")
    box:SetFontObject("GameFontHighlight")
    self:Reface(box)
    if not opts.boxColor then self:Track(box, "fs", 1) end

    local function setText(v)
        box:SetText(opts.format and opts.format(v) or tostring(math_floor(v + 0.5)))
    end

    slider:SetValue(opts.get())
    setText(opts.get())

    slider:SetScript("OnValueChanged", function(self, v)
        if opts.step and opts.step >= 1 then v = math_floor(v + 0.5) end
        setText(v)
        opts.set(v)
    end)
    box:SetScript("OnEnterPressed", function(self)
        local v = tonumber(self:GetText())
        if v then
            v = math_max(opts.min, math_min(opts.max, v))
            slider:SetValue(v)
        end
        self:ClearFocus()
    end)
    box:SetScript("OnEscapePressed", function(self) setText(slider:GetValue()); self:ClearFocus() end)

    frame.slider = slider
    frame.Refresh = function()
        slider:SetValue(opts.get())
        setText(opts.get())
    end
    local function Heal()
        box:SetFontObject("GameFontHighlight")
        self:Reface(box)
        if opts.boxColor then box:SetTextColor(opts.boxColor[1], opts.boxColor[2], opts.boxColor[3])
        elseif self.theme then box:SetTextColor(self.theme()) end
        -- A fresh box never painted its text; re-setting the same string won't dirty it, so clear
        -- first to force a repaint.
        box:SetText("")
        setText(opts.get())
    end
    box:HookScript("OnShow", function()
        setText(opts.get())
        C_Timer.After(0, Heal)
    end)
    -- A born-visible box fires OnShow during creation, before the hook above exists, and a box on
    -- the default tab never gets a later show-transition. The first OnUpdate only fires once the
    -- box is genuinely on-screen (valid rect), so heal there and clear the script.
    box:SetScript("OnUpdate", function(self)
        self:SetScript("OnUpdate", nil)
        Heal()
    end)
    return frame
end

function PanelMixin:SliderWithBar(parent, opts)
    local frame = self:Slider(parent, opts)

    local barBG = CreateFrame("Frame", nil, frame, BackdropTemplateMixin and "BackdropTemplate" or nil)
    barBG:SetPoint("TOPLEFT", frame.slider, "BOTTOMLEFT", 0, -2)
    barBG:SetSize(frame.slider:GetWidth(), 6)
    if barBG.SetBackdrop then
        barBG:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8" })
        barBG:SetBackdropColor(0, 0, 0, 0.5)
    end

    local fill = barBG:CreateTexture(nil, "ARTWORK")
    fill:SetPoint("TOPLEFT", barBG, "TOPLEFT", 0, 0)
    fill:SetPoint("BOTTOMLEFT", barBG, "BOTTOMLEFT", 0, 0)
    self:TrackColor(function(r, g, b) fill:SetColorTexture(r, g, b, 0.8) end)

    local function updateFill(v)
        local pct = (v - opts.min) / (opts.max - opts.min)
        fill:SetWidth(math_max(1, barBG:GetWidth() * math_max(0, math_min(1, pct))))
    end

    frame.slider:HookScript("OnValueChanged", function(_, v) updateFill(v) end)
    C_Timer.After(0, function() updateFill(opts.get()) end)
    return frame
end

function PanelMixin:Dropdown(parent, opts)
    local frame = CreateFrame("Frame", nil, parent)

    local label = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    label:SetText(opts.label or "")
    self:Reface(label)

    local dd = CreateFrame("DropdownButton", nil, frame, "WowStyle1DropdownTemplate")
    dd:SetWidth(opts.width or 220)

    if opts.inline then
        frame:SetSize((opts.width or 220) + label:GetStringWidth() + 12, 32)
        label:SetPoint("LEFT", 0, 0)
        dd:SetPoint("LEFT", label, "RIGHT", 12, 0)
    else
        frame:SetSize(opts.width or 220, 44)
        label:SetPoint("TOPLEFT", 0, 0)
        dd:SetPoint("TOPLEFT", label, "BOTTOMLEFT", 0, -4)
    end

    local function CurrentText()
        local v = opts.get()
        for _, item in ipairs(opts.items) do
            if item.value == v then return item.text end
        end
        return ""
    end

    dd:SetupMenu(function(_, root)
        for _, item in ipairs(opts.items) do
            root:CreateRadio(item.text,
                function() return opts.get() == item.value end,
                function() opts.set(item.value); dd:SetDefaultText(CurrentText()); dd:GenerateMenu() end)
        end
    end)
    dd:SetDefaultText(CurrentText())

    frame.dropdown = dd
    frame.Refresh = function() dd:SetDefaultText(CurrentText()); dd:GenerateMenu() end
    return frame
end

function PanelMixin:ColorSwatch(parent, opts)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetSize(14, 14)

    local border = btn:CreateTexture(nil, "BORDER")
    border:SetAllPoints(true)
    border:SetColorTexture(0, 0, 0, 1)

    local tex = btn:CreateTexture(nil, "ARTWORK")
    tex:SetPoint("TOPLEFT", 1, -1)
    tex:SetPoint("BOTTOMRIGHT", -1, 1)
    local c = opts.get()
    tex:SetColorTexture(c.r, c.g, c.b, 1)

    btn:SetScript("OnClick", function()
        local cur = opts.get()
        local function apply()
            local nr, ng, nb = ColorPickerFrame:GetColorRGB()
            tex:SetColorTexture(nr, ng, nb, 1)
            opts.set(nr, ng, nb)
        end
        ColorPickerFrame:SetupColorPickerAndShow({
            r = cur.r, g = cur.g, b = cur.b,
            swatchFunc = apply,
            cancelFunc = function(prev)
                if prev then
                    tex:SetColorTexture(prev.r, prev.g, prev.b, 1)
                    opts.set(prev.r, prev.g, prev.b)
                end
            end,
        })
    end)

    btn.Refresh = function()
        local cc = opts.get()
        tex:SetColorTexture(cc.r, cc.g, cc.b, 1)
    end
    return btn
end

function PanelMixin:Carousel(parent, opts)
    local frame = CreateFrame("Frame", nil, parent)
    frame:SetSize(opts.width or 200, 24)

    local left = CreateFrame("Button", nil, frame)
    left:SetSize(24, 24)
    left:SetPoint("LEFT", 0, 0)
    left:SetNormalAtlas("common-icon-backarrow")
    left:SetHighlightAtlas("common-icon-backarrow")

    local right = CreateFrame("Button", nil, frame)
    right:SetSize(24, 24)
    right:SetPoint("RIGHT", 0, 0)
    right:SetNormalAtlas("common-icon-forwardarrow")
    right:SetHighlightAtlas("common-icon-forwardarrow")

    local center = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    center:SetPoint("LEFT", left, "RIGHT", 4, 0)
    center:SetPoint("RIGHT", right, "LEFT", -4, 0)
    center:SetJustifyH("CENTER")
    self:Reface(center)

    local function indexOf(v)
        for i, item in ipairs(opts.items) do
            if item.value == v then return i end
        end
        return 1
    end
    local function refresh()
        local i = indexOf(opts.get())
        center:SetText(opts.items[i].text)
    end
    local function step(dir)
        local i = indexOf(opts.get()) + dir
        if i < 1 then i = #opts.items elseif i > #opts.items then i = 1 end
        opts.set(opts.items[i].value)
        refresh()
    end

    left:SetScript("OnClick", function() step(-1) end)
    right:SetScript("OnClick", function() step(1) end)
    refresh()

    frame.Refresh = refresh
    return frame
end

function PanelMixin:Note(parent, text)
    local fs = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    fs:SetTextColor(0.7, 0.7, 0.7)
    fs:SetJustifyH("LEFT")
    fs:SetText(text)
    self:Reface(fs)
    return fs
end

function PanelMixin:Tooltip(widget, text)
    widget:HookScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(text, 1, 1, 1, 1, true)
        GameTooltip:Show()
    end)
    widget:HookScript("OnLeave", function() GameTooltip:Hide() end)
end

-- ============================================================
-- Layout — shared column grid + vertical cursor + row builders
-- ============================================================
-- Every suite addon inherits identical alignment from these constants. Tune ONCE here, not per-addon.
UI.GRID = {
    CHECK_X     = 8,     -- leading checkbox / swatch gutter
    CHECK_INSET = 4,     -- checkbox texture's transparent left margin (flush-left labels nudge past it)
    LABEL_X     = 40,    -- row labels begin here
    CONTROL_X   = 225,   -- dropdowns / sliders / edit boxes begin here
    CONTROL_W   = 218,   -- standard control width: right edge lands at CONTROL_X + CONTROL_W
    SWATCH_X    = 38,    -- color swatch position (color rows)
    COLOR_LBL_X = 62,    -- color-row label (clears checkbox + swatch)
    ROW_LABELED = 38,    -- advance for a labeled-control row (dropdown / slider)
    ROW_CHECK   = 32,    -- advance for a plain checkbox row
    ROW_SOUND   = 36,    -- advance for a sound row
    ROW_COLOR   = 25,    -- advance for a color row
    SEC_BEFORE  = 6,     -- gap above a section header
    SEC_AFTER   = 30,    -- gap below a section header
    TOP_PAD     = 10,    -- first-row gap from the top
    BOTTOM_PAD  = 20,    -- trailing gap fed to the scroll height
}

local LayoutMixin = {}
local Layout_meta = { __index = LayoutMixin }

function PanelMixin:Layout(tab)
    local lay = setmetatable({}, Layout_meta)
    lay.panel   = self
    lay.tab     = tab
    lay.content = tab.content
    lay.y       = -UI.GRID.TOP_PAD
    lay.widgets = {}
    return lay
end

function LayoutMixin:_add(w)
    self.widgets[#self.widgets + 1] = w
    return w
end

-- Hide and forget every widget this layout created, and rewind the cursor. Theme tracking is
-- rewound by the caller (panel:Rewind) so one mark can cover several layouts rebuilt together.
function LayoutMixin:Reset()
    for _, w in ipairs(self.widgets) do if w.Hide then w:Hide() end end
    wipe(self.widgets)
    self.y = -UI.GRID.TOP_PAD
end

function LayoutMixin:Finish()
    self.tab.scroll:SetContentHeight(-self.y + UI.GRID.BOTTOM_PAD)
end

-- A control's label, vertically centered on it and flush-left at the CHECK_X column. White
-- (untracked) so it stays a constant break against the class-tinted section headers.
function LayoutMixin:_label(ctrl, text)
    local G = UI.GRID
    local l = self.content:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    l:SetPoint("LEFT", ctrl, "LEFT", G.CHECK_X + G.CHECK_INSET - G.CONTROL_X, 0)
    l:SetText(text)
    self.panel:Reface(l)
    l:SetTextColor(1, 1, 1)
    return self:_add(l)
end

function LayoutMixin:Section(title)
    local G = UI.GRID
    self.y = self.y - G.SEC_BEFORE
    local sec = self.panel:Section(self.content, title)
    sec:ClearAllPoints()
    sec:SetPoint("TOPLEFT",  self.content, "TOPLEFT",   4, self.y)
    sec:SetPoint("TOPRIGHT", self.content, "TOPRIGHT", -4, self.y)
    self:_add(sec)
    self.y = self.y - G.SEC_AFTER
    return sec
end

function LayoutMixin:Checkbox(opts)
    local G = UI.GRID
    local cb = self.panel:Checkbox(self.content, opts)
    cb:SetPoint("TOPLEFT", self.content, "TOPLEFT", G.CHECK_X, self.y)
    -- Carry the label as its own white FontString; the template's .Text renders unreliably
    -- inside the scroll content (this is what dropped the "Enable Addon" label).
    cb.Text:SetText("")
    local lbl = self.content:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    lbl:SetPoint("LEFT", cb, "LEFT", G.LABEL_X - G.CHECK_X, 0)
    lbl:SetText(opts.label)
    self.panel:Reface(lbl); lbl:SetTextColor(1, 1, 1)
    self:_add(cb); self:_add(lbl)
    self.y = self.y - G.ROW_CHECK
    return cb
end

-- A labeled dropdown. Returns the bare DropdownButton so the caller owns the menu (its data).
function LayoutMixin:Dropdown(opts)
    local G = UI.GRID
    local dd = CreateFrame("DropdownButton", opts.name, self.content, "WowStyle1DropdownTemplate")
    dd:SetPoint("TOPLEFT", self.content, "TOPLEFT", G.CONTROL_X, self.y)
    dd:SetWidth(opts.width or G.CONTROL_W)
    if opts.label then self:_label(dd, opts.label) end
    self:_add(dd)
    self.y = self.y - G.ROW_LABELED
    return dd
end

-- A labeled slider with a value box. Track spans CONTROL_X..CONTROL_X+CONTROL_W; box rides beyond.
function LayoutMixin:Slider(opts)
    local G = UI.GRID
    opts.inline = true
    opts.width  = (opts.width or G.CONTROL_W) + 56
    local label = opts.label
    opts.label  = nil
    local fr = self.panel:Slider(self.content, opts)
    fr:SetPoint("TOPLEFT", self.content, "TOPLEFT", G.CONTROL_X, self.y)
    if label then self:_label(fr, label) end
    self:_add(fr)
    self.y = self.y - G.ROW_LABELED
    return fr
end

-- Checkbox + label + dropdown + preview button. Caller fills the dropdown menu and preview action.
function LayoutMixin:SoundRow(opts)
    local G = UI.GRID
    local cb = CreateFrame("CheckButton", nil, self.content, "UICheckButtonTemplate")
    cb:SetPoint("TOPLEFT", self.content, "TOPLEFT", G.CHECK_X, self.y)
    cb:SetChecked(opts.getEnabled())
    cb:SetScript("OnClick", function(s) opts.setEnabled(s:GetChecked() and true or false) end)
    cb.Text:SetText("")
    self:_add(cb)

    local lbl = self.content:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    lbl:SetPoint("LEFT", cb, "LEFT", G.LABEL_X - G.CHECK_X, 0)
    lbl:SetText(opts.label)
    self.panel:Reface(lbl); lbl:SetTextColor(1, 1, 1); self:_add(lbl)

    local dd = CreateFrame("DropdownButton", nil, self.content, "WowStyle1DropdownTemplate")
    dd:SetPoint("LEFT", cb, "LEFT", G.CONTROL_X - G.CHECK_X, 0)
    dd:SetWidth(opts.width or G.CONTROL_W)
    self:_add(dd)

    local preview = CreateFrame("Button", nil, self.content, "UIPanelButtonTemplate")
    preview:SetSize(64, 20)
    preview:SetPoint("LEFT", dd, "RIGHT", 6, 0)
    preview:SetText(opts.previewText or "")
    self.panel:Reface(preview:GetFontString())
    if opts.onPreview then preview:SetScript("OnClick", opts.onPreview) end
    self:_add(preview)

    self.y = self.y - G.ROW_SOUND
    return cb, dd, preview
end

-- Checkbox + color swatch + label + text-override edit box. Returns the checkbox and edit box for
-- the caller to wire the override behavior; the swatch is self-contained (opens the color picker).
function LayoutMixin:ColorRow(opts)
    local G = UI.GRID
    local cb = CreateFrame("CheckButton", nil, self.content, "UICheckButtonTemplate")
    cb:SetPoint("TOPLEFT", self.content, "TOPLEFT", G.CHECK_X, self.y)
    cb:SetChecked(opts.getEnabled())
    cb:SetScript("OnClick", function(s) opts.setEnabled(s:GetChecked() and true or false) end)
    cb.Text:SetText("")
    self:_add(cb)

    if opts.color then
        local sw = self.panel:ColorSwatch(self.content, opts.color)
        sw:SetPoint("LEFT", cb, "LEFT", G.SWATCH_X - G.CHECK_X, 0)
        self:_add(sw)
    end

    local lbl = self.content:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    lbl:SetPoint("LEFT", cb, "LEFT", G.COLOR_LBL_X - G.CHECK_X, 0)
    lbl:SetWidth(G.CONTROL_X - G.COLOR_LBL_X - 8); lbl:SetJustifyH("LEFT")
    lbl:SetText(opts.label)
    self.panel:Reface(lbl); lbl:SetTextColor(1, 1, 1); self:_add(lbl)

    -- Field inset to line up with the slider groove above (InputBox cap overhangs left); right edge fixed.
    local eb = opts.makeEdit(self.content, G.CONTROL_W - 5, 20, opts.maxLetters or 80)
    eb:SetPoint("TOPLEFT", self.content, "TOPLEFT", G.CONTROL_X + 5, self.y - 3)
    self:_add(eb)

    self.y = self.y - G.ROW_COLOR
    return cb, eb
end

-- ============================================================
-- Profiles tab (drop-in; needs a ProfileDB manager + optional serializer)
-- ============================================================
local PROFILE_POPUP = "NEMFRAMEWORK_PROFILE_CONFIRM"
if not StaticPopupDialogs[PROFILE_POPUP] then
    StaticPopupDialogs[PROFILE_POPUP] = {
        text = "%s", button1 = YES, button2 = NO,
        OnAccept = function(self) local d = self.data; if d and d.onAccept then d.onAccept() end end,
        timeout = 0, whileDead = true, hideOnEscape = true, showAlert = true,
    }
end

function PanelMixin:Confirm(msg, onAccept)
    StaticPopup_Show(PROFILE_POPUP, msg, nil, { onAccept = onAccept })
end

-- opts = {
--   mgr    = ProfileDB manager (UI.MakeProfileDB),
--   text   = { current, new, add, existing, copyFrom, delete, export, import,
--              exported, imported, invalid, notMine, confirmImport, confirmCopy, confirmDelete },
--   reload = fn (default ReloadUI),
--   wrap   = fn(activeTbl) -> payload      (addon stamps its own id/version header),
--   unwrap = fn(payload)   -> innerTbl|nil (addon validates the header, strips it),
-- }
function PanelMixin:BuildProfilesTab(tab, opts)
    local content = tab.content
    local mgr     = opts.mgr
    local T       = opts.text or {}
    local reload  = opts.reload or ReloadUI
    local LCOL, RCOL = 16, 170

    local function Confirm(msg, onAccept)
        StaticPopup_Show(PROFILE_POPUP, msg, nil, { onAccept = onAccept })
    end
    local function WhiteLabel(text, x, y)
        local l = content:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        l:SetPoint("TOPLEFT", content, "TOPLEFT", x, y)
        l:SetText(text); l:SetTextColor(1, 1, 1); self:Reface(l)
        return l
    end

    local y = -16

    -- Current profile status line; no section divider (the tab name already says "Profiles").
    WhiteLabel(T.current or "Current Profile:", LCOL, y)
    local curVal = content:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    curVal:SetPoint("TOPLEFT", content, "TOPLEFT", RCOL, y)
    curVal:SetText(mgr:ActiveName()); self:Reface(curVal); self:Track(curVal, "fs", 1)
    y = y - 40

    -- New profile + Add
    WhiteLabel(T.new or "New Profile", LCOL, y)
    local newBox = CreateFrame("EditBox", nil, content, "InputBoxTemplate")
    newBox:SetSize(214, 20); newBox:SetAutoFocus(false); newBox:SetMaxLetters(40)
    newBox:SetFontObject("GameFontHighlight"); self:Reface(newBox); self:Track(newBox, "fs", 1)
    newBox:SetPoint("TOPLEFT", content, "TOPLEFT", RCOL + 6, y + 2)
    local function doAdd()
        local name = newBox:GetText()
        if mgr:Create(name) then mgr:SetActive(name); reload() end
    end
    newBox:SetScript("OnEnterPressed", function(s) s:ClearFocus(); doAdd() end)
    newBox:SetScript("OnEscapePressed", function(s) s:SetText(""); s:ClearFocus() end)
    local addBtn = self:Button(content, { text = T.add or "Add", width = 80, onClick = doAdd })
    addBtn:SetPoint("LEFT", newBox, "RIGHT", 8, 0)
    y = y - 40

    -- Labeled dropdown row; itemsFn returns the live name list, onPick(name, dd) acts.
    local function DropdownRow(labelText, placeholder, itemsFn, onPick)
        WhiteLabel(labelText, LCOL, y)
        local dd = CreateFrame("DropdownButton", nil, content, "WowStyle1DropdownTemplate")
        dd:SetPoint("TOPLEFT", content, "TOPLEFT", RCOL, y + 4)
        dd:SetWidth(220)
        dd:SetDefaultText(placeholder)
        dd:SetupMenu(function(_, root)
            for _, name in ipairs(itemsFn()) do
                local n = name
                root:CreateButton(n, function() onPick(n, dd) end)
            end
        end)
        y = y - 36
        return dd
    end

    DropdownRow(T.existing or "Existing Profiles", mgr:ActiveName(),
        function() return mgr:List() end,
        function(name) if name ~= mgr:ActiveName() then mgr:SetActive(name); reload() end end)

    DropdownRow(T.copyFrom or "Copy From", T.copyFrom or "Copy From",
        function() return mgr:Others() end,
        function(name)
            Confirm(string.format(T.confirmCopy or "Overwrite the active profile with \"%s\"?", name),
                function() if mgr:CopyFrom(name) then reload() end end)
        end)

    DropdownRow(T.delete or "Delete a Profile", T.delete or "Delete a Profile",
        function() return mgr:Others() end,
        function(name, dd)
            Confirm(string.format(T.confirmDelete or "Delete the profile \"%s\"?", name),
                function() mgr:Delete(name); dd:GenerateMenu() end)
        end)
    y = y - 8

    -- Import / Export (only when a serializer is vendored and the addon wired the hooks)
    if UI.HasSerializer() and opts.wrap and opts.unwrap then
        local statusFS = content:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")

        local sf = CreateFrame("ScrollFrame", nil, content, "InputScrollFrameTemplate")
        sf:SetPoint("TOPLEFT", content, "TOPLEFT", LCOL + 5, y - 36)
        sf:SetSize(420, 90)
        if sf.CharCount then sf.CharCount:Hide() end
        local box = sf.EditBox
        box:SetMaxLetters(0); box:SetWidth(410); box:SetFontObject("ChatFontSmall")

        local exportBtn = self:Button(content, { text = T.export or "Export", width = 125,
            onClick = function()
                local str = UI.Encode(opts.wrap(mgr:ActiveTable()))
                box:SetText(str or ""); box:HighlightText(); box:SetFocus()
                statusFS:SetText(T.exported or "")
            end })
        exportBtn:SetPoint("TOPLEFT", content, "TOPLEFT", LCOL, y)

        local importBtn = self:Button(content, { text = T.import or "Import", width = 125,
            onClick = function()
                local decoded = UI.Decode(box:GetText())
                if not decoded then statusFS:SetText(T.invalid or ""); return end
                local inner = opts.unwrap(decoded)
                if not inner then statusFS:SetText(T.notMine or ""); return end
                Confirm(T.confirmImport or "Overwrite the active profile with the imported settings?",
                    function()
                        if mgr:Overwrite(inner) then statusFS:SetText(T.imported or ""); reload() end
                    end)
            end })
        importBtn:SetPoint("LEFT", exportBtn, "RIGHT", 10, 0)

        statusFS:SetPoint("TOPLEFT", sf, "BOTTOMLEFT", 4, -8)
        statusFS:SetText("")
        y = y - 36 - 90 - 26
    end

    tab.scroll:SetContentHeight(-y + 20)
    self:RefreshTheme()
end

-- ============================================================
-- Bottom Buttons
-- ============================================================
function PanelMixin:SetButtons(list)
    local spacing, w = 10, 125
    local total = (#list * w) + ((#list - 1) * spacing)
    for i, def in ipairs(list) do
        local btn = CreateFrame("Button", nil, self.root, "UIPanelButtonTemplate")
        btn:SetSize(w, 26)
        btn:SetText(def.text)
        btn:SetScript("OnClick", def.onClick)
        local cx = -total / 2 + (i - 1) * (w + spacing) + w / 2
        btn:SetPoint("BOTTOM", self.root, "BOTTOM", cx, 12)
        btn.def = def
        self.buttons[i] = btn
    end
end

function PanelMixin:RefreshButtons()
    for _, btn in ipairs(self.buttons) do
        if btn.def.getText then btn:SetText(btn.def.getText()) end
    end
end

function PanelMixin:Mark()
    return #self._fs, #self._boxes
end

function PanelMixin:Rewind(nfs, nboxes)
    for i = #self._fs, nfs + 1, -1 do self._fs[i] = nil end
    for i = #self._boxes, nboxes + 1, -1 do self._boxes[i] = nil end
end

function PanelMixin:Register()
    self.categoryID = UI.RegisterCanvas(self.root)
    return self.categoryID
end

function PanelMixin:Open()
    UI.OpenCategory(self.categoryID)
end
