local _, NSI = ...
local DF = _G["DetailsFramework"]

-- ============================================================
--  NSRT UI Components
--  Thin wrappers over raw WoW frames that enforce the Northern
--  Sky visual style without relying on DF templates.
--  Edit STYLE below to retheme the entire addon UI in one place.
-- ============================================================

local STYLE = {
    -- Normal state
    bg_color       = { 0.3, 0.3, 0.3, 0.8 },
    -- Input control background (text entries, sliders, color pickers, checkboxes)
    input_bg_color = { 0.3, 0.3, 0.3, 0.9 },

    -- Hover overlay (fades in/out on mouse enter/leave)
    hover_color     = {0,    1,    1,    0.13},

    -- Selected state (solid background)
    selected_color  = {0,    1,    1,    0.20},

    -- Text defaults
    text_color      = {1, 1, 1, 1},
    text_disabled   = {0.45, 0.45, 0.45, 0.70},
    text_size       = 14,
    text_left_pad   = 10,

    -- Animation durations in seconds
    hover_in        = 0.12,
    hover_out       = 0.20,
    select_in       = 0.10,
    deselect_out    = 0.15,
}

-- ============================================================
--  CreateButton
--
--  Returns a button object. Every method delegates to the
--  underlying WoW frame or FontString so nothing is hidden.
--
--  Params
--    parent  – WoW frame
--    text    – display string (nil or "" for icon-only buttons)
--    onClick – function(buttonObj) called on click; may be nil
--    width   – number (nil: text pixel width + 20px padding, icon included in content)
--    height  – number (default 26)
--    name    – optional global frame name string
--    icon    – optional lib icon name or full texture path
--    textSize– optional font size override
--    tooltip – optional tooltip: string (title only) or table {title=s, desc=s}
--
--  Returned object fields & methods
--    .frame           WoW Button frame (anchor, reparent, etc.)
--    .label           FontString (SetText, SetFont, SetTextColor…)
--    :SetText(s)
--    :GetText() → string
--    :SetFont(path, size, flags)
--    :SetTextColor(r, g, b [,a])
--    :SetPoint(…)     delegates to .frame
--    :SetSize(w, h)   delegates to .frame
--    :GetWidth()      delegates to .frame
--    :GetHeight()     delegates to .frame
--    :Select()        activates selected background (fades in)
--    :Deselect()      clears selected background (fades out)
--    :IsSelected() → bool
--    :Enable()
--    :Disable()       also dims label
-- ============================================================
-- Registry of every label created by this module so RefreshFonts() can
-- update them all in one call when the player changes the Global Font setting.
-- Stored as {label = FontString, size = number} plain entries — buttons are
-- never destroyed mid-session so no weak table needed.
local labelRegistry = {}

local componentRegistry = {
    Slider    = {},
    Dropdown  = {},
    Color     = {},
    Checkbox  = {},
    TextEntry = {},
    Label     = {},
    Breakline = {},
}
local FALLBACK_FONT = "Fonts\\FRIZQT__.TTF"

local function ValidateFont(path)
    if not path or path == "" then return FALLBACK_FONT end
    NSI.TestString = NSI.TestString or UIParent:CreateFontString(nil, "ARTWORK")
    local ok = NSI.TestString:SetFont(path, 12, "")
    NSI.TestString:Hide()
    return ok and path or FALLBACK_FONT
end

local function RefreshFonts()
    local rawPath = NSI.LSM:Fetch("font", NSRT.Settings.GlobalFont)
    local fontPath = ValidateFont(rawPath)
    for _, entry in ipairs(labelRegistry) do
        entry.label:SetFont(fontPath, entry.size, NSRT.Settings.GlobalFontFlags)
    end
end

local function CreateButton(parent, text, onClick, width, height, name, icon, textSize, tooltip)
    -- ---- base frame -------------------------------------------
    -- Width is resolved after measuring the label; start with a stub size.
    local btn = CreateFrame("Button", name, parent, "BackdropTemplate")
    local btnHeight = height or 26
    btn:SetSize(1, btnHeight)
    btn:EnableMouse(true)
    btn:SetFrameLevel(parent:GetFrameLevel() + 1)

    btn:SetBackdrop({
        bgFile   = [[Interface\Tooltips\UI-Tooltip-Background]],
        tile     = true,
        tileSize = 64,
    })
    btn:SetBackdropColor(unpack(STYLE.bg_color))

    -- ---- hover background (fades in/out) ----------------------
    -- Wrapping the texture in its own frame lets UIFrameFade work on it.
    local hoverBg = CreateFrame("Frame", nil, btn)
    hoverBg:SetAllPoints(btn)
    hoverBg:SetFrameLevel(btn:GetFrameLevel() + 1)
    hoverBg:EnableMouse(false)
    local hoverTex = hoverBg:CreateTexture(nil, "background")
    hoverTex:SetAllPoints()
    hoverTex:SetColorTexture(unpack(STYLE.hover_color))
    hoverBg:SetAlpha(0)

    -- ---- selected background (fades in when tab is active) ----
    local selectedBg = CreateFrame("Frame", nil, btn)
    selectedBg:SetAllPoints(btn)
    selectedBg:SetFrameLevel(btn:GetFrameLevel() + 1)
    selectedBg:EnableMouse(false)
    local selectedTex = selectedBg:CreateTexture(nil, "background")
    selectedTex:SetAllPoints()
    selectedTex:SetColorTexture(unpack(STYLE.selected_color))
    selectedBg:SetAlpha(0)

    -- ---- label ------------------------------------------------
    -- Lives on its own frame above hoverBg/selectedBg (N+2) so the cyan
    -- overlay never dims the text — it stays pure white at all times.
    local labelFrame = CreateFrame("Frame", nil, btn)
    labelFrame:SetFrameLevel(btn:GetFrameLevel() + 2)
    labelFrame:EnableMouse(false)

    local label = labelFrame:CreateFontString(nil, "OVERLAY")
    label:SetFont(ValidateFont(NSI.LSM:Fetch("font", NSRT.Settings.GlobalFont)), STYLE.text_size, NSRT.Settings.GlobalFontFlags)
    label:SetTextColor(unpack(STYLE.text_color))
    label:SetText(text or "")
    label:SetJustifyV("MIDDLE")
    label:SetJustifyH("CENTER")
    label:SetAllPoints(labelFrame)
    labelRegistry[#labelRegistry + 1] = {label = label, size = fontSize}

    -- ---- compute final button width & lay out content ---------
    local iconSize  = 14
    local iconGap   = 6   -- gap between icon and text
    local padH      = 10  -- padding on each side

    local textWidth = math.max(label:GetStringWidth(), 1)
    local contentW  = icon and (iconSize + iconGap + textWidth) or textWidth
    local btnWidth  = width or (contentW + padH * 2)
    btn:SetSize(btnWidth, btnHeight)

    if icon then
        -- Icon lives on a frame at N+2 so it renders above the hover/selected
        -- overlays (which sit at N+1) and is never obscured by them.
        local iconFrame = CreateFrame("Frame", nil, btn)
        iconFrame:SetSize(iconSize, iconSize)
        iconFrame:SetFrameLevel(btn:GetFrameLevel() + 2)
        iconFrame:EnableMouse(false)

        local iconTex = iconFrame:CreateTexture(nil, "ARTWORK")
        iconTex:SetAllPoints()
        -- Support both direct texture paths and LibSharedMedia names
        if icon:find("\\") or icon:find("/") then
            iconTex:SetTexture(icon)
        else
            local texture_info = NSI.LSM:Fetch("statusbar", icon)
            iconTex:SetTexture(texture_info .. ".png")
        end
        iconTex:SetTexCoord(0.1, 0.9, 0.09, 0.91)
        iconTex:SetVertexColor(1, 1, 1)
        btn.icon = iconTex

        -- Centre [icon  gap  text] as a group inside the button.
        local groupLeft = (btnWidth - contentW) / 2
        iconFrame:SetPoint("LEFT", btn, "LEFT", groupLeft, 0)

        -- For icon-only buttons (no text), just center the icon
        if textWidth <= 1 then
            iconFrame:ClearAllPoints()
            iconFrame:SetPoint("CENTER", btn, "CENTER", 0, 0)
            labelFrame:Hide()
        else
            labelFrame:SetSize(textWidth, btnHeight)
            labelFrame:SetPoint("LEFT", iconFrame, "RIGHT", iconGap, 0)
        end
    else
        labelFrame:SetSize(textWidth, btnHeight)
        labelFrame:SetPoint("CENTER", btn, "CENTER", 0, 0)
    end
    -- ---- mouse scripts ----------------------------------------
    btn:SetScript("OnEnter", function(self)
        UIFrameFadeIn(hoverBg, STYLE.hover_in, hoverBg:GetAlpha(), 1)
        if tooltip then
            GameTooltip:SetOwner(self, "ANCHOR_TOP")
            if type(tooltip) == "table" then
                GameTooltip:SetText(tooltip.title or "")
                if tooltip.desc then
                    GameTooltip:AddLine(tooltip.desc, 1, 1, 1, true)
                end
            else
                GameTooltip:SetText(tooltip)
            end
            GameTooltip:Show()
        end
    end)
    btn:SetScript("OnLeave", function()
        UIFrameFadeOut(hoverBg, STYLE.hover_out, hoverBg:GetAlpha(), 0)
        if tooltip then
            GameTooltip:Hide()
        end
    end)

    -- ---- public object ----------------------------------------
    local buttonObj = {
        frame      = btn,
        label      = label,
        labelFrame = labelFrame,
        hoverBg    = hoverBg,
        selectedBg = selectedBg,
        _selected  = false,
    }

    -- Wire click after buttonObj exists so the callback receives it
    btn:SetScript("OnClick", function()
        if onClick then onClick(buttonObj) end
    end)

    function buttonObj:SetText(s)
        self.label:SetText(s)
    end

    function buttonObj:GetText()
        return self.label:GetText()
    end

    function buttonObj:SetFont(path, size, flags)
        self.label:SetFont(path, size or STYLE.text_size, flags or "")
    end

    function buttonObj:SetTextColor(r, g, b, a)
        self.label:SetTextColor(r, g, b, a or 1)
    end

    function buttonObj:SetPoint(...)
        self.frame:SetPoint(...)
    end

    function buttonObj:SetSize(w, h)
        self.frame:SetSize(w, h)
    end

    function buttonObj:GetWidth()
        return self.frame:GetWidth()
    end

    function buttonObj:GetHeight()
        return self.frame:GetHeight()
    end

    function buttonObj:Select()
        self._selected = true
        UIFrameFadeIn(self.selectedBg, STYLE.select_in, self.selectedBg:GetAlpha(), 1)
    end

    function buttonObj:Deselect()
        self._selected = false
        UIFrameFadeOut(self.selectedBg, STYLE.deselect_out, self.selectedBg:GetAlpha(), 0)
    end

    function buttonObj:IsSelected()
        return self._selected
    end

    function buttonObj:Enable()
        self.frame:Enable()
        self.label:SetTextColor(unpack(STYLE.text_color))
    end

    function buttonObj:Disable()
        self.frame:Disable()
        self.label:SetTextColor(unpack(STYLE.text_disabled))
    end

    return buttonObj
end

-- ============================================================
--  CreateSubButton
--
--  Lighter-weight variant of CreateButton for secondary UI
--  controls (inner tab bars, type selectors, etc.).
--  Defaults: height=18, font size=12, no icon support.
-- ============================================================
local function CreateSubButton(parent, text, onClick, width, name)
    return CreateButton(parent, text, onClick, width, 18, name, nil, 12)
end

-- ============================================================
--  Local helpers shared by the three form controls below
-- ============================================================
local function MakeHoverBg(parent, level)
    local f = CreateFrame("Frame", nil, parent)
    f:SetAllPoints(parent)
    f:SetFrameLevel(level)
    f:EnableMouse(false)
    local t = f:CreateTexture(nil, "BACKGROUND")
    t:SetAllPoints()
    t:SetColorTexture(unpack(STYLE.hover_color))
    f:SetAlpha(0)
    return f
end

local function MakeControlBackdrop(frame)
    frame:SetBackdrop({
        bgFile   = [[Interface\Tooltips\UI-Tooltip-Background]],
        tile     = true,
        tileSize = 64,
    })
    frame:SetBackdropColor(unpack(STYLE.input_bg_color))
end

local function MakeFontString(parent, size)
    local fs = parent:CreateFontString(nil, "OVERLAY")
    fs:SetFont(ValidateFont(NSI.LSM:Fetch("font", NSRT.Settings.GlobalFont)), size, NSRT.Settings.GlobalFontFlags)
    fs:SetTextColor(unpack(STYLE.text_color))
    labelRegistry[#labelRegistry + 1] = {label = fs, size = size}
    return fs
end

-- ============================================================
--  CreateCheckButton
--
--  A styled checkbox row.  Clicking anywhere on the widget
--  toggles it and fires setValue(bool).
--
--  Params
--    parent   – WoW frame
--    label    – display string to the right of the box
--    getValue – function() → bool
--    setValue – function(bool)
--    width    – number (default 180)
--    height   – number (default 22)
--
--  Returned object
--    .frame          container Frame
--    .label          FontString
--    :SetValue(bool)
--    :GetValue() → bool
--    :SetPoint(…)
--    :SetSize(w, h)
-- ============================================================
local function CreateCheckButton(parent, label, getValue, setValue, width, height, name)
    local totalW = width  or 180
    local totalH = height or 22
    local BOX    = 14
    local GAP    = 8
    local baseLevel = parent:GetFrameLevel() + 1

    -- Container acts as the hit target
    local btn       = CreateFrame("Button", name, parent)
    btn:SetSize(totalW, totalH)
    btn:SetFrameLevel(baseLevel)

    -- Checkbox square background
    local box = CreateFrame("Frame", nil, btn, "BackdropTemplate")
    box:SetSize(BOX, BOX)
    box:SetPoint("LEFT", btn, "LEFT", 0, 0)
    box:SetFrameLevel(baseLevel + 1)
    box:SetBackdrop({
        bgFile   = [[Interface\Buttons\WHITE8x8]],
        edgeFile = [[Interface\Buttons\WHITE8x8]],
        edgeSize = 1,
    })
    box:SetBackdropColor(unpack(STYLE.bg_color))
    box:SetBackdropBorderColor(0.25, 0.25, 0.25, 1)

    -- Cyan fill shown when checked
    local checkFill = box:CreateTexture(nil, "ARTWORK")
    checkFill:SetPoint("TOPLEFT",     box, "TOPLEFT",     2, -2)
    checkFill:SetPoint("BOTTOMRIGHT", box, "BOTTOMRIGHT", -2,  2)
    checkFill:SetColorTexture(0, 1, 1, 0.85)
    checkFill:Hide()

    -- Hover overlay on the whole row (same fade pattern as CreateButton)
    local hoverBg = MakeHoverBg(btn, baseLevel + 1)

    -- Label
    local lbl = MakeFontString(btn, 13)
    lbl:SetText(label or "")
    lbl:SetJustifyH("LEFT")
    lbl:SetJustifyV("MIDDLE")
    lbl:SetPoint("LEFT",  box, "RIGHT",   GAP, 0)
    lbl:SetPoint("RIGHT", btn, "RIGHT",   0,   0)
    lbl:SetHeight(totalH)

    local isChecked = getValue and getValue(NSI) or false

    local function Refresh()
        if isChecked then
            checkFill:Show()
            box:SetBackdropBorderColor(0, 1, 1, 0.9)
        else
            checkFill:Hide()
            box:SetBackdropBorderColor(0.25, 0.25, 0.25, 1)
        end
    end
    Refresh()

    local cb = { fn = setValue }
    btn:SetScript("OnClick", function()
        isChecked = not isChecked
        Refresh()
        if cb.fn then cb.fn(NSI, isChecked) end
    end)
    btn:SetScript("OnEnter", function()
        UIFrameFadeIn(hoverBg, STYLE.hover_in, hoverBg:GetAlpha(), 1)
    end)
    btn:SetScript("OnLeave", function()
        UIFrameFadeOut(hoverBg, STYLE.hover_out, hoverBg:GetAlpha(), 0)
    end)

    local obj = {frame = btn, label = lbl}

    function obj:SetValue(v)
        isChecked = not not v; Refresh()
    end

    function obj:GetValue() return isChecked end

    function obj:SetOnChange(fn) cb.fn = fn end

    function obj:SetPoint(...) self.frame:SetPoint(...) end

    function obj:SetSize(w, h) self.frame:SetSize(w, h) end

    function obj:GetWidth() return self.frame:GetWidth() end

    componentRegistry.Checkbox[#componentRegistry.Checkbox + 1] = obj
    return obj
end

-- ============================================================
--  CreateTextEntry
--
--  A label + styled EditBox for single-line input.
--  Committing on Enter or focus-lost calls setValue.
--
--  Params
--    parent   – WoW frame
--    label    – display string on the left
--    getValue – function() → string|number
--    setValue – function(value)
--    width    – number (default 220)
--    height   – number (default 22)
--    numeric  – bool (default false); clamps to [minVal, maxVal] if set
--    minVal   – number | nil
--    maxVal   – number | nil
--
--  Returned object
--    .frame       container Frame
--    .label       FontString
--    .editBox     WoW EditBox
--    :SetValue(v)
--    :GetValue() → string|number
--    :SetPoint(…)
--    :SetSize(w, h)
-- ============================================================
local function CreateTextEntry(parent, label, getValue, setValue,
                               width, height, numeric, minVal, maxVal, name)
    local totalW    = width or 220
    local totalH    = height or 22
    local BOX_W     = 60
    local GAP       = 8
    local hasLabel  = label and label ~= ""
    local baseLevel = parent:GetFrameLevel() + 1

    local container = CreateFrame("Frame", name, parent)
    container:SetSize(totalW, totalH)
    container:SetFrameLevel(baseLevel)

    -- Label (only when provided; omitting it makes the input span the full width)
    local lbl
    if hasLabel then
        lbl = MakeFontString(container, 13)
        lbl:SetText(label)
        lbl:SetJustifyH("LEFT")
        lbl:SetJustifyV("MIDDLE")
        lbl:SetPoint("LEFT", container, "LEFT", 0, 0)
        lbl:SetPoint("RIGHT", container, "RIGHT", -(BOX_W + GAP), 0)
        lbl:SetHeight(totalH)
    end

    local inputW = hasLabel and BOX_W or totalW

    -- Input frame (backdrop + EditBox stacked inside)
    local inputFrame = CreateFrame("Frame", nil, container, "BackdropTemplate")
    inputFrame:SetSize(inputW, totalH)
    if hasLabel then
        inputFrame:SetPoint("RIGHT", container, "RIGHT", 0, 0)
    else
        inputFrame:SetPoint("LEFT", container, "LEFT", 0, 0)
    end
    inputFrame:SetFrameLevel(baseLevel + 1)
    MakeControlBackdrop(inputFrame)

    -- Cyan-border overlay when focused (same layer trick as hoverBg)
    local focusBorder = CreateFrame("Frame", nil, inputFrame, "BackdropTemplate")
    focusBorder:SetAllPoints(inputFrame)
    focusBorder:SetFrameLevel(baseLevel + 2)
    focusBorder:EnableMouse(false)
    focusBorder:SetBackdrop({
        edgeFile = [[Interface\Buttons\WHITE8x8]],
        edgeSize = 1,
    })
    focusBorder:SetBackdropBorderColor(0, 1, 1, 0)   -- hidden until focused

    local edit = CreateFrame("EditBox", nil, inputFrame)
    edit:SetPoint("TOPLEFT",     inputFrame, "TOPLEFT",     4,  -2)
    edit:SetPoint("BOTTOMRIGHT", inputFrame, "BOTTOMRIGHT", -4,  2)
    edit:SetFont(ValidateFont(NSI.LSM:Fetch("font", NSRT.Settings.GlobalFont)), 12, NSRT.Settings.GlobalFontFlags)
    edit:SetTextColor(1, 1, 1, 1)
    edit:SetAutoFocus(false)
    edit:SetMultiLine(false)
    edit:SetMaxLetters(numeric and 8 or 200)
    edit:SetText(tostring(getValue and getValue(NSI) or ""))

    local function Commit()
        local raw = edit:GetText()
        if numeric then
            local n = tonumber(raw)
            if not n then
                edit:SetText(tostring(getValue and getValue(NSI) or 0))
                return
            end
            if minVal then n = math.max(n, minVal) end
            if maxVal then n = math.min(n, maxVal) end
            edit:SetText(tostring(n))
            if setValue then setValue(NSI, n) end
        else
            if setValue then setValue(NSI, raw) end
        end
    end

    edit:SetScript("OnEnterPressed", function() Commit() ; edit:ClearFocus() end)
    edit:SetScript("OnEscapePressed", function()
        edit:SetText(tostring(getValue and getValue(NSI) or ""))
        edit:ClearFocus()
    end)
    edit:SetScript("OnEditFocusGained", function()
        UIFrameFadeIn(focusBorder, STYLE.select_in, focusBorder:GetAlpha(), 1)
    end)
    edit:SetScript("OnEditFocusLost", function()
        UIFrameFadeOut(focusBorder, STYLE.deselect_out, focusBorder:GetAlpha(), 0)
        Commit()
    end)

    local obj = {frame = container, editBox = edit, label = lbl}

    function obj:SetValue(v)   edit:SetText(tostring(v))                                    end
    function obj:GetValue()    return numeric and tonumber(edit:GetText()) or edit:GetText() end
    function obj:SetPoint(...) self.frame:SetPoint(...)                                      end
    function obj:SetSize(w,h)  self.frame:SetSize(w, h)                                     end
    function obj:GetWidth()    return self.frame:GetWidth()                                  end

    componentRegistry.TextEntry[#componentRegistry.TextEntry + 1] = obj
    return obj
end

-- ============================================================
--  CreateDropdown
--
--  A label + dropdown button that opens a scrollable popup.
--  The button matches CreateButton's exact visual style.
--
--  Params
--    parent      – WoW frame
--    label       – string shown on the left (nil/"" = full-width button)
--    getItems    – function() → { {label, value, onclick}, … }
--    getSelected – function() → string  (current display text)
--    width       – number (default 220)
--    height      – number (default 22)
--
--  Returned object
--    .frame    container Frame
--    .label    FontString (may be nil if no label)
--    :Refresh()          re-reads getSelected and updates display
--    :Close()
--    :SetPoint(…)
--    :SetSize(w, h)
-- ============================================================
local function CreateDropdown(parent, label, getItems, getSelected, width, height, name)
    local totalW   = width  or 220
    local totalH   = height or 22
    local ROW_H    = 20
    local MAX_ROWS = 11
    local baseLevel = parent:GetFrameLevel() + 1

    local container = CreateFrame("Frame", name, parent)
    container:SetSize(totalW, totalH)
    container:SetFrameLevel(baseLevel)

    local hasLabel = label and label ~= ""
    local labelW   = hasLabel and math.floor(totalW * 0.5) or 0
    local dropW    = totalW - labelW - (hasLabel and 8 or 0)

    -- Optional label
    local lbl
    if hasLabel then
        lbl = MakeFontString(container, 13)
        lbl:SetText(label)
        lbl:SetJustifyH("LEFT")
        lbl:SetJustifyV("MIDDLE")
        lbl:SetPoint("LEFT", container, "LEFT", 0, 0)
        lbl:SetWidth(labelW)
        lbl:SetHeight(totalH)
    end

    -- Dropdown button — same backdrop & hover pattern as CreateButton
    local dropBtn = CreateFrame("Button", nil, container, "BackdropTemplate")
    dropBtn:SetSize(dropW, totalH)
    dropBtn:SetPoint("RIGHT", container, "RIGHT", 0, 0)
    dropBtn:SetFrameLevel(baseLevel + 1)
    MakeControlBackdrop(dropBtn)

    local dropHover = MakeHoverBg(dropBtn, baseLevel + 2)

    -- Current-value text
    local valText = MakeFontString(dropBtn, 12)
    valText:SetJustifyH("LEFT")
    valText:SetJustifyV("MIDDLE")
    valText:SetPoint("LEFT",  dropBtn, "LEFT",  5,   0)
    valText:SetPoint("RIGHT", dropBtn, "RIGHT", -14, 0)
    valText:SetHeight(totalH)

    -- Arrow glyph
    local arrowTexture = dropBtn:CreateTexture(nil, "OVERLAY")
    arrowTexture:SetTexture([[Interface\AddOns\NorthernSkyRaidTools\Media\Icons\chevron-down.png]])
    arrowTexture:SetSize(10, 10)
    arrowTexture:SetPoint("RIGHT", dropBtn, "RIGHT", -4, 0)

    dropBtn:SetScript("OnEnter", function()
        UIFrameFadeIn(dropHover, STYLE.hover_in, dropHover:GetAlpha(), 1)
    end)
    dropBtn:SetScript("OnLeave", function()
        UIFrameFadeOut(dropHover, STYLE.hover_out, dropHover:GetAlpha(), 0)
    end)

    -- ---- Popup (parented to UIParent so it floats above everything) ----
    local SB_W = 6   -- scrollbar width
    local popup = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
    popup:SetFrameStrata("TOOLTIP")
    popup:Hide()
    popup:SetBackdrop({
        bgFile   = [[Interface\Buttons\WHITE8x8]],
        edgeFile = [[Interface\Buttons\WHITE8x8]],
        edgeSize = 1,
    })
    popup:SetBackdropColor(0.05, 0.05, 0.08, 0.97)
    popup:SetBackdropBorderColor(0, 1, 1, 0.7)

    -- Scrollbar track (right strip inside popup)
    local sbTrack = popup:CreateTexture(nil, "BACKGROUND")
    sbTrack:SetColorTexture(0.10, 0.10, 0.10, 1)
    sbTrack:SetPoint("TOPRIGHT",    popup, "TOPRIGHT",    -2, -2)
    sbTrack:SetPoint("BOTTOMRIGHT", popup, "BOTTOMRIGHT", -2,  2)
    sbTrack:SetWidth(SB_W)
    sbTrack:Hide()

    -- Scrollbar thumb
    local sbThumb = CreateFrame("Frame", nil, popup)
    sbThumb:SetWidth(SB_W)
    sbThumb:SetFrameLevel(popup:GetFrameLevel() + 5)
    local sbTex = sbThumb:CreateTexture(nil, "OVERLAY")
    sbTex:SetAllPoints(sbThumb)
    sbTex:SetColorTexture(0, 1, 1, 0.45)
    sbThumb:Hide()

    local scrollFrame = CreateFrame("ScrollFrame", nil, popup)
    scrollFrame:SetPoint("TOPLEFT",     popup, "TOPLEFT",     2,  -2)
    scrollFrame:SetPoint("BOTTOMRIGHT", popup, "BOTTOMRIGHT", -(2 + SB_W + 1), 2)
    scrollFrame:EnableMouseWheel(true)

    local content = CreateFrame("Frame", nil, scrollFrame)
    scrollFrame:SetScrollChild(content)
    content._rows = {}

    -- Shared scroll helper: clamps and syncs thumb
    local function ScrollTo(offset)
        local maxScroll = math.max(0, content:GetHeight() - scrollFrame:GetHeight())
        local clamped   = math.max(0, math.min(maxScroll, offset))
        scrollFrame:SetVerticalScroll(clamped)
        -- Update thumb position
        if maxScroll > 0 then
            local trackH  = sbTrack:GetHeight()
            local thumbH  = math.max(8, trackH * (scrollFrame:GetHeight() / content:GetHeight()))
            sbThumb:SetHeight(thumbH)
            local thumbY  = (clamped / maxScroll) * (trackH - thumbH)
            sbThumb:ClearAllPoints()
            sbThumb:SetPoint("TOPRIGHT", popup, "TOPRIGHT", -2, -(2 + thumbY))
        end
    end

    scrollFrame:SetScript("OnMouseWheel", function(_, delta)
        ScrollTo(scrollFrame:GetVerticalScroll() - delta * ROW_H)
    end)

    -- Thumb dragging
    local sbDragging = false
    local sbDragStartY, sbDragStartScroll
    sbThumb:EnableMouse(true)
    sbThumb:SetScript("OnMouseDown", function(self, btn)
        if btn ~= "LeftButton" then return end
        sbDragging = true
        sbDragStartY      = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale()
        sbDragStartScroll = scrollFrame:GetVerticalScroll()
        self:SetScript("OnUpdate", function()
            if not sbDragging then return end
            local curY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale()
            local dy   = sbDragStartY - curY
            local maxScroll = math.max(0, content:GetHeight() - scrollFrame:GetHeight())
            local trackH    = sbTrack:GetHeight()
            local thumbH    = sbThumb:GetHeight()
            local ratio     = maxScroll / math.max(1, trackH - thumbH)
            ScrollTo(sbDragStartScroll + dy * ratio)
        end)
    end)
    sbThumb:SetScript("OnMouseUp", function(self)
        sbDragging = false
        self:SetScript("OnUpdate", nil)
    end)

    -- Click-away: FULLSCREEN strata sits above DIALOG (settings win) but below TOOLTIP (popup)
    local clickaway = CreateFrame("Frame", nil, UIParent)
    clickaway:SetAllPoints(UIParent)
    clickaway:SetFrameStrata("FULLSCREEN")
    clickaway:EnableMouse(true)
    clickaway:Hide()

    local function RefreshDisplay()
        if getSelected then
            local v = getSelected()
            valText:SetText(v ~= nil and tostring(v) or "")
        end
    end

    local function Close()
        popup:Hide()
        clickaway:Hide()
    end

    local function Open()
        local items    = getItems and getItems() or {}
        local rowCount = #items
        if rowCount == 0 then return end

        local needsScroll = rowCount > MAX_ROWS
        local popupH = math.min(rowCount * ROW_H, MAX_ROWS * ROW_H)
        popup:SetSize(dropW, popupH)
        local contentW = dropW - 4 - (needsScroll and SB_W + 1 or 0)
        content:SetSize(contentW, rowCount * ROW_H)
        if needsScroll then sbTrack:Show() sbThumb:Show() else sbTrack:Hide() sbThumb:Hide() end

        -- Grow the row pool as needed (frames are never destroyed)
        for i = #content._rows + 1, rowCount do
            local row = CreateFrame("Button", nil, content)
            row:SetSize(dropW - 4, ROW_H)

            local rowHover = MakeHoverBg(row, 2)

            local iconTex = row:CreateTexture(nil, "ARTWORK")
            iconTex:SetPoint("LEFT", row, "LEFT", 4, 0)
            iconTex:Hide()
            local rlbl = MakeFontString(row, 12)
            rlbl:SetJustifyH("LEFT")
            rlbl:SetJustifyV("MIDDLE")
            rlbl:SetPoint("RIGHT", row, "RIGHT", 0, 0)
            rlbl:SetHeight(ROW_H)

            row.iconTex      = iconTex
            row.rlbl      = rlbl
            row.rowHover  = rowHover
            content._rows[i] = row
        end

        -- Update visible rows with current item data
        for i, item in ipairs(items) do
            local row = content._rows[i]
            row.rlbl:SetText(item.label or "")
            -- Icon
            if item.icon then
                local sz = item.iconsize or { ROW_H - 6, ROW_H - 6 }
                row.iconTex:SetSize(sz[1], sz[2])
                row.iconTex:SetTexture(item.icon)
                if item.texcoord then
                    row.iconTex:SetTexCoord(unpack(item.texcoord))
                else
                    row.iconTex:SetTexCoord(0, 1, 0, 1)
                end
                row.iconTex:Show()
                row.rlbl:ClearAllPoints()
                row.rlbl:SetPoint("LEFT", row.iconTex, "RIGHT", 4, 0)
                row.rlbl:SetPoint("RIGHT", row, "RIGHT", 0, 0)
                row.rlbl:SetHeight(ROW_H)
            else
                row.iconTex:Hide()
                row.rlbl:ClearAllPoints()
                row.rlbl:SetPoint("LEFT", row, "LEFT", 6, 0)
                row.rlbl:SetPoint("RIGHT", row, "RIGHT", 0, 0)
                row.rlbl:SetHeight(ROW_H)
            end
            row:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -(i - 1) * ROW_H)
            row:SetScript("OnEnter", function()
                UIFrameFadeIn(row.rowHover, STYLE.hover_in, row.rowHover:GetAlpha(), 1)
            end)
            row:SetScript("OnLeave", function()
                UIFrameFadeOut(row.rowHover, STYLE.hover_out, row.rowHover:GetAlpha(), 0)
            end)
            row:SetScript("OnClick", function()
                if item.onclick then item.onclick(nil, nil, item.value) end
                RefreshDisplay()
                Close()
            end)
            row:Show()
        end
        for i = rowCount + 1, #content._rows do
            content._rows[i]:Hide()
        end

        ScrollTo(0)

        -- Position below the button; flip above if too close to screen bottom
        popup:ClearAllPoints()
        local btnBottom = dropBtn:GetBottom()
        if btnBottom and btnBottom > popupH + 4 then
            popup:SetPoint("TOPRIGHT", dropBtn, "BOTTOMRIGHT", 0, -1)
        else
            popup:SetPoint("BOTTOMRIGHT", dropBtn, "TOPRIGHT", 0, 1)
        end

        popup:Show()
        clickaway:Show()
    end

    clickaway:SetScript("OnMouseDown", Close)
    dropBtn:SetScript("OnClick", function()
        if popup:IsShown() then Close() else Open() end
    end)

    RefreshDisplay()

    local obj = {frame = container, label = lbl, dropBtn = dropBtn}

    function obj:Refresh()     RefreshDisplay()              end
    function obj:Close()       Close()                       end
    function obj:SetPoint(...) self.frame:SetPoint(...)      end
    function obj:SetSize(w,h)  self.frame:SetSize(w, h)     end
    function obj:GetWidth()    return self.frame:GetWidth()  end

    componentRegistry.Dropdown[#componentRegistry.Dropdown + 1] = obj
    return obj
end

-- ============================================================
--  CreateSlider
--
--  A label + thin horizontal track with a draggable cyan thumb.
--  The current value is displayed on the right. setValue fires
--  only on mouse release (not continuously during drag).
--
--  Params
--    parent   – WoW frame
--    label    – display string on the left
--    getValue – function() → number
--    setValue – function(number)   called on mouse release only
--    width    – number (default 220)
--    height   – number (default 22)
--    minVal   – number (default 0)
--    maxVal   – number (default 100)
--    step     – number | nil  (nil = smooth, floats shown to 2dp)
--
--  Returned object
--    .frame    container Frame
--    .label    FontString
--    .slider   WoW Slider widget
--    :SetValue(n)
--    :GetValue() → number
--    :SetPoint(…)
--    :SetSize(w, h)
-- ============================================================
local function CreateSlider(parent, label, getValue, setValue,
                            width, height, minVal, maxVal, step, name)
    local totalW  = width  or 220
    local totalH  = height or 22
    local LABEL_W = math.floor(totalW * 0.38)
    local VAL_W   = 38
    local TRACK_W = totalW - LABEL_W - VAL_W - 8
    local baseLevel = parent:GetFrameLevel() + 1

    local container = CreateFrame("Frame", name, parent)
    container:SetSize(totalW, totalH)
    container:SetFrameLevel(baseLevel)

    -- Label
    local lbl = MakeFontString(container, 13)
    lbl:SetText(label or "")
    lbl:SetJustifyH("LEFT")
    lbl:SetJustifyV("MIDDLE")
    lbl:SetPoint("LEFT", container, "LEFT", 0, 0)
    lbl:SetWidth(LABEL_W)
    lbl:SetHeight(totalH)

    -- Track (visual only, behind the slider widget)
    local trackTex = container:CreateTexture(nil, "BACKGROUND")
    trackTex:SetColorTexture(0.10, 0.10, 0.10, 1)
    trackTex:SetHeight(3)
    trackTex:SetPoint("LEFT",  container, "LEFT", LABEL_W + 4, 0)
    trackTex:SetWidth(TRACK_W)

    -- Cyan fill that grows left → thumb position
    local fillTex = container:CreateTexture(nil, "ARTWORK")
    fillTex:SetColorTexture(0, 1, 1, 0.45)
    fillTex:SetHeight(3)
    fillTex:SetPoint("LEFT", container, "LEFT", LABEL_W + 4, 0)
    fillTex:SetWidth(1)

    -- Native Slider frame (handles all mouse + keyboard input)
    local slider = CreateFrame("Slider", nil, container)
    slider:SetOrientation("HORIZONTAL")
    slider:SetPoint("LEFT", container, "LEFT", LABEL_W + 4, 0)
    slider:SetSize(TRACK_W, totalH)
    slider:SetFrameLevel(baseLevel + 2)
    slider:SetMinMaxValues(minVal or 0, maxVal or 100)
    if step then
        slider:SetValueStep(step)
        if slider.SetObeyStepOnDrag then slider:SetObeyStepOnDrag(true) end
    end
    slider:SetThumbTexture([[Interface\Buttons\WHITE8x8]])
    local thumb = slider:GetThumbTexture()
    thumb:SetSize(8, 14)
    thumb:SetVertexColor(0, 1, 1, 1)

    -- Value text (right side)
    local valText = MakeFontString(container, 11)
    valText:SetTextColor(0.65, 0.65, 0.65, 1)
    valText:SetJustifyH("RIGHT")
    valText:SetJustifyV("MIDDLE")
    valText:SetPoint("RIGHT", container, "RIGHT", 0, 0)
    valText:SetWidth(VAL_W)
    valText:SetHeight(totalH)

    local useFloat = (step and step < 1)
        or ((minVal or 0) ~= math.floor(minVal or 0))
        or ((maxVal or 0) ~= math.floor(maxVal or 0))

    local function Fmt(v)
        return useFloat and string.format("%.2f", v) or tostring(math.floor(v + 0.5))
    end

    local function UpdateVisual(value)
        valText:SetText(Fmt(value))
        local mn, mx = minVal or 0, maxVal or 100
        local pct = mx > mn and (value - mn) / (mx - mn) or 0
        fillTex:SetWidth(math.max(1, math.floor(pct * TRACK_W)))
    end

    -- Fire setValue only on mouse release, not during drag.
    -- Keyboard changes (arrow keys) are not dragging, so they fire immediately.
    local dragging    = false
    local initialized = false

    slider:SetScript("OnMouseDown", function() dragging = true end)
    slider:SetScript("OnMouseUp", function(self)
        dragging = false
        if setValue then setValue(NSI, self:GetValue()) end
    end)
    slider:SetScript("OnValueChanged", function(_, value)
        UpdateVisual(value)
        if initialized and not dragging and setValue then setValue(NSI, value) end
    end)
    slider:SetScript("OnEnter", function() thumb:SetVertexColor(0.5, 1, 1, 1) end)
    slider:SetScript("OnLeave", function() thumb:SetVertexColor(0,   1, 1, 1) end)

    local initVal = (getValue and getValue(NSI)) or (minVal or 0)
    local mn, mx = minVal or 0, maxVal or 100
    if type(initVal) ~= "number" or initVal ~= initVal or initVal == math.huge or initVal == -math.huge then
        initVal = mn
    end
    initVal = math.max(mn, math.min(mx, initVal))
    slider:SetValue(initVal)
    UpdateVisual(initVal)
    initialized = true

    -- Right-click on value label → inline EditBox to type a number
    local valBtn = CreateFrame("Button", nil, container)
    valBtn:SetPoint("RIGHT", container, "RIGHT", 0, 0)
    valBtn:SetSize(VAL_W, totalH)
    valBtn:SetFrameLevel(baseLevel + 4)
    valBtn:RegisterForClicks("RightButtonUp")

    local typeBox = CreateFrame("EditBox", nil, container, "BackdropTemplate")
    typeBox:SetSize(VAL_W, totalH - 4)
    typeBox:SetPoint("RIGHT", container, "RIGHT", 0, 0)
    typeBox:SetFrameLevel(baseLevel + 5)
    typeBox:SetAutoFocus(false)
    typeBox:SetNumeric(false)
    typeBox:SetMaxLetters(10)
    typeBox:SetFont(FALLBACK_FONT, 11, "")
    typeBox:SetTextColor(0, 1, 1, 1)
    typeBox:SetJustifyH("RIGHT")
    typeBox:SetBackdrop({bgFile=[[Interface\Buttons\WHITE8x8]], edgeFile=[[Interface\Buttons\WHITE8x8]], edgeSize=1})
    typeBox:SetBackdropColor(0.04, 0.04, 0.06, 1)
    typeBox:SetBackdropBorderColor(0, 1, 1, 0.8)
    typeBox:Hide()

    local function CommitTypeBox()
        local raw = typeBox:GetText()
        local n   = tonumber(raw)
        if n then
            local mn, mx = minVal or 0, maxVal or 100
            n = math.max(mn, math.min(mx, n))
            initialized = false
            slider:SetValue(n)
            UpdateVisual(n)
            initialized = true
            if setValue then setValue(NSI, n) end
        end
        typeBox:ClearFocus()
        typeBox:Hide()
        valText:Show()
    end

    valBtn:SetScript("OnClick", function()
        valText:Hide()
        typeBox:SetText(Fmt(slider:GetValue()))
        typeBox:Show()
        typeBox:SetFocus()
        typeBox:HighlightText()
    end)
    typeBox:SetScript("OnEnterPressed", CommitTypeBox)
    typeBox:SetScript("OnEscapePressed", function()
        typeBox:ClearFocus()
        typeBox:Hide()
        valText:Show()
    end)
    typeBox:SetScript("OnEditFocusLost", function()
        if typeBox:IsShown() then CommitTypeBox() end
    end)

    local obj = {frame = container, slider = slider, label = lbl}

    function obj:SetValue(v)
        initialized = false
        slider:SetValue(v)
        UpdateVisual(v)
        initialized = true
    end
    function obj:GetValue()    return slider:GetValue()       end
    function obj:SetPoint(...) self.frame:SetPoint(...)       end
    function obj:SetSize(w,h)  self.frame:SetSize(w, h)      end
    function obj:GetWidth()    return self.frame:GetWidth()   end

    componentRegistry.Slider[#componentRegistry.Slider + 1] = obj
    return obj
end

-- ============================================================
--  CreateColorPicker
--
--  A label + color swatch button. Clicking opens WoW's built-in
--  ColorPickerFrame with full alpha/opacity support. The swatch
--  previews live as the picker is moved and restores on cancel.
--
--  Params
--    parent   – WoW frame
--    label    – display string on the left
--    getValue – function() → r, g, b, a   (all 0–1)
--    setValue – function(r, g, b, a)
--    width    – number (default 220)
--    height   – number (default 22)
--
--  Returned object
--    .frame      container Frame
--    .label      FontString
--    :Refresh()  re-reads getValue and repaints the swatch
--    :SetPoint(…)
--    :SetSize(w, h)
-- ============================================================
local function CreateColorPicker(parent, label, getValue, setValue, width, height, name)
    local totalW   = width  or 220
    local totalH   = height or 22
    local SWATCH_W = 40
    local GAP      = 8
    local baseLevel = parent:GetFrameLevel() + 1

    local container = CreateFrame("Frame", name, parent)
    container:SetSize(totalW, totalH)
    container:SetFrameLevel(baseLevel)

    -- Label
    local lbl = MakeFontString(container, 13)
    lbl:SetText(label or "")
    lbl:SetJustifyH("LEFT")
    lbl:SetJustifyV("MIDDLE")
    lbl:SetPoint("LEFT",  container, "LEFT",  0,                 0)
    lbl:SetPoint("RIGHT", container, "RIGHT", -(SWATCH_W + GAP), 0)
    lbl:SetHeight(totalH)

    -- Swatch button (same backdrop pattern as CreateButton)
    local swatchBtn = CreateFrame("Button", nil, container, "BackdropTemplate")
    swatchBtn:SetSize(SWATCH_W, totalH)
    swatchBtn:SetPoint("RIGHT", container, "RIGHT", 0, 0)
    swatchBtn:SetFrameLevel(baseLevel + 1)
    MakeControlBackdrop(swatchBtn)

    local swatchHover = MakeHoverBg(swatchBtn, baseLevel + 2)

    -- Inset colour rectangle; alpha blends over the dark button background
    local colorTex = swatchBtn:CreateTexture(nil, "ARTWORK")
    colorTex:SetPoint("TOPLEFT",     swatchBtn, "TOPLEFT",     3, -3)
    colorTex:SetPoint("BOTTOMRIGHT", swatchBtn, "BOTTOMRIGHT", -3,  3)

    local function UpdateSwatch()
        local r, g, b, a = 1, 1, 1, 1
        if getValue then r, g, b, a = getValue(NSI) end
        colorTex:SetColorTexture(r or 1, g or 1, b or 1, a or 1)
    end
    UpdateSwatch()

    swatchBtn:SetScript("OnEnter", function()
        UIFrameFadeIn(swatchHover, STYLE.hover_in, swatchHover:GetAlpha(), 1)
    end)
    swatchBtn:SetScript("OnLeave", function()
        UIFrameFadeOut(swatchHover, STYLE.hover_out, swatchHover:GetAlpha(), 0)
    end)

    swatchBtn:SetScript("OnClick", function()
        local r, g, b, a = 1, 1, 1, 1
        if getValue then r, g, b, a = getValue(NSI) end
        r = r or 1; g = g or 1; b = b or 1; a = a or 1
        local prevR, prevG, prevB, prevA = r, g, b, a

        -- Works with both the modern (10.x) and legacy ColorPickerFrame APIs.
        local function ReadCurrent()
            local nr, ng, nb = ColorPickerFrame:GetColorRGB()
            local na
            if ColorPickerFrame.GetColorAlpha then
                na = ColorPickerFrame:GetColorAlpha()
            elseif OpacitySliderFrame then
                na = 1 - OpacitySliderFrame:GetValue()
            else
                na = 1
            end
            return nr, ng, nb, na
        end

        local function OnChange()
            local nr, ng, nb, na = ReadCurrent()
            colorTex:SetColorTexture(nr, ng, nb, na)
            if setValue then setValue(NSI, nr, ng, nb, na) end
        end

        local function OnCancel(prev)
            local cr, cg, cb, ca
            if prev and prev.r then
                cr = prev.r; cg = prev.g; cb = prev.b
                ca = prev.opacity ~= nil and prev.opacity or prevA
            else
                cr, cg, cb, ca = prevR, prevG, prevB, prevA
            end
            colorTex:SetColorTexture(cr, cg, cb, ca)
            if setValue then setValue(NSI, cr, cg, cb, ca) end
        end

        if ColorPickerFrame.SetupColorPickerAndShow then
            ColorPickerFrame:SetupColorPickerAndShow({
                swatchFunc  = OnChange,
                opacityFunc = OnChange,
                cancelFunc  = OnCancel,
                hasOpacity  = true,
                r = r, g = g, b = b,
                opacity = a,
            })
        else
            ColorPickerFrame.func        = OnChange
            ColorPickerFrame.opacityFunc = OnChange
            ColorPickerFrame.cancelFunc  = OnCancel
            ColorPickerFrame.hasOpacity  = true
            ColorPickerFrame:SetColorRGB(r, g, b)
            if OpacitySliderFrame then OpacitySliderFrame:SetValue(1 - a) end
            ColorPickerFrame:Show()
        end
    end)

    local obj = {frame = container, label = lbl, colorTex = colorTex}

    function obj:Refresh()     UpdateSwatch()               end
    function obj:SetPoint(...) self.frame:SetPoint(...)     end
    function obj:SetSize(w,h)  self.frame:SetSize(w, h)    end
    function obj:GetWidth()    return self.frame:GetWidth() end

    componentRegistry.Color[#componentRegistry.Color + 1] = obj
    return obj
end

-- ============================================================
--  CreateLabel
--
--  A read-only text row, useful as a section header above a
--  group of controls. Text is dimmed to distinguish it from
--  interactive control labels.
--
--  Params
--    parent  – WoW frame
--    text    – display string
--    width   – number (default 220)
--    height  – number (default 16)
--
--  Returned object
--    .frame   container Frame
--    .label   FontString
--    :SetText(s)
--    :SetPoint(…)
--    :SetSize(w, h)
-- ============================================================
local function CreateLabel(parent, text, width, height, name)
    local totalW = width  or 220
    local totalH = height or 16

    local container = CreateFrame("Frame", name, parent)
    container:SetSize(totalW, totalH)

    local lbl = MakeFontString(container, 12)
    lbl:SetTextColor(0.55, 0.55, 0.55, 1)
    lbl:SetText(text or "")
    lbl:SetJustifyH("LEFT")
    lbl:SetJustifyV("MIDDLE")
    lbl:SetAllPoints(container)

    local obj = {frame = container, label = lbl}

    function obj:SetText(s)    self.label:SetText(s)         end
    function obj:SetPoint(...) self.frame:SetPoint(...)      end
    function obj:SetSize(w,h)  self.frame:SetSize(w, h)     end
    function obj:GetWidth()    return self.frame:GetWidth()  end

    componentRegistry.Label[#componentRegistry.Label + 1] = obj
    return obj
end

-- ============================================================
--  CreateBreakline
--
--  A thin horizontal rule for separating groups of controls.
--
--  Params
--    parent  – WoW frame
--    width   – number (default 220)
--    height  – number (default 10)  line is centred vertically
--
--  Returned object
--    .frame
--    :SetPoint(…)
--    :SetSize(w, h)
-- ============================================================
local function CreateBreakline(parent, width, height, name)
    local totalW = width  or 220
    local totalH = height or 10

    local container = CreateFrame("Frame", name, parent)
    container:SetSize(totalW, totalH)

    local line = container:CreateTexture(nil, "ARTWORK")
    line:SetColorTexture(0, 1, 1, 0.12)
    line:SetHeight(1)
    line:SetPoint("LEFT",  container, "LEFT",  0, 0)
    line:SetPoint("RIGHT", container, "RIGHT", 0, 0)

    local obj = {frame = container}

    function obj:SetPoint(...) self.frame:SetPoint(...)     end
    function obj:SetSize(w,h)  self.frame:SetSize(w, h)    end
    function obj:GetWidth()    return self.frame:GetWidth() end

    componentRegistry.Breakline[#componentRegistry.Breakline + 1] = obj
    return obj
end

-- ============================================================
--  CreateScrollBox
--
--  Creates a scrollable container.  Use .scrollChild as the
--  parent argument to BuildWidgets; call :UpdateScrollBar()
--  after setting the scroll child's height.
--
--  Params
--    parent – WoW frame
--    width  – outer scroll frame width
--    height – outer scroll frame height (visible area)
--
--  Returned object
--    .frame        outer ScrollFrame
--    .scrollChild  inner content Frame (pass to BuildWidgets)
--    :SetPoint(…)
--    :UpdateScrollBar()  syncs native scrollbar to child height
-- ============================================================
-- ============================================================
--  ReskinScrollbar
--
--  Applies the Northern Sky style to a native
--  UIPanelScrollFrameTemplate scrollbar: dark track,
--  thin cyan thumb, and custom chevron arrow buttons.
--
--  Params
--    scrollFrame – a named ScrollFrame created with
--                  UIPanelScrollFrameTemplate
-- ============================================================
local ICON_PATH = [[Interface\AddOns\NorthernSkyRaidTools\Media\Icons\]]

local function StyleArrowButton(btn, iconFile)
    if not btn then return end

    -- Clear all default Blizzard textures
    if btn.SetNormalTexture then btn:SetNormalTexture("") end
    if btn.SetPushedTexture then btn:SetPushedTexture("") end
    if btn.SetHighlightTexture then btn:SetHighlightTexture("") end
    if btn.SetDisabledTexture then btn:SetDisabledTexture("") end

    -- Dark box background
    local bg = btn:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints(btn)
    bg:SetColorTexture(0.07, 0.07, 0.07, 1)

    -- Cyan hover overlay (fades in/out)
    local hoverBg = CreateFrame("Frame", nil, btn)
    hoverBg:SetAllPoints(btn)
    hoverBg:SetFrameLevel(btn:GetFrameLevel() + 1)
    hoverBg:EnableMouse(false)
    local hoverTex = hoverBg:CreateTexture(nil, "BACKGROUND")
    hoverTex:SetAllPoints()
    hoverTex:SetColorTexture(unpack(STYLE.hover_color))
    hoverBg:SetAlpha(0)

    -- Chevron icon centred inside the box
    local iconFrame = CreateFrame("Frame", nil, btn)
    iconFrame:SetSize(8, 8)
    iconFrame:SetAllPoints(btn)
    iconFrame:SetFrameLevel(btn:GetFrameLevel() + 2)
    iconFrame:EnableMouse(false)
    local icon = iconFrame:CreateTexture(nil, "ARTWORK")
    icon:SetAllPoints()
    icon:SetTexture(ICON_PATH .. iconFile)
    icon:SetVertexColor(0.7, 0.7, 0.7, 1)

    btn:SetScript("OnEnter", function()
        UIFrameFadeIn(hoverBg, STYLE.hover_in, hoverBg:GetAlpha(), 1)
        icon:SetVertexColor(0, 1, 1, 1)
    end)
    btn:SetScript("OnLeave", function()
        UIFrameFadeOut(hoverBg, STYLE.hover_out, hoverBg:GetAlpha(), 0)
        icon:SetVertexColor(0.7, 0.7, 0.7, 1)
    end)
end

local function ReskinScrollbar(scrollFrame)
    local name = scrollFrame:GetName()
    if not name then return end
    local bar = _G[name .. "ScrollBar"]
    if not bar then return end

    -- Dark track behind the thumb
    local track = bar:CreateTexture(nil, "BACKGROUND")
    track:SetAllPoints(bar)
    track:SetColorTexture(0.07, 0.07, 0.07, 1)

    -- Thin cyan thumb
    if bar.GetThumbTexture then
        local thumb = bar:GetThumbTexture()
        if thumb then
            thumb:SetColorTexture(0, 1, 1, 0.45)
            thumb:SetWidth(6)
        end
    end

    -- Chevron arrow buttons
    StyleArrowButton(_G[name .. "ScrollBarScrollUpButton"], "chevron-up.png")
    StyleArrowButton(_G[name .. "ScrollBarScrollDownButton"], "chevron-down.png")
end
local scrollBoxCounter = 0
local SB_W = 16 -- native UIPanelScrollFrameTemplate scrollbar width

local function CreateScrollBox(parent, width, height)
    scrollBoxCounter = scrollBoxCounter + 1
    local boxName = "NSRTScrollBox" .. scrollBoxCounter

    local scrollFrame = CreateFrame("ScrollFrame", boxName, parent,
        "UIPanelScrollFrameTemplate")
    scrollFrame:SetSize(width, height)
    scrollFrame:EnableMouseWheel(true)
    scrollFrame:SetScript("OnMouseWheel", function(_, delta)
        local bar = _G[boxName .. "ScrollBar"]
        if bar then
            local cur    = bar:GetValue()
            local mn, mx = bar:GetMinMaxValues()
            bar:SetValue(math.max(mn, math.min(mx, cur - delta * 20)))
        end
    end)

    local child = CreateFrame("Frame", boxName .. "Child", scrollFrame)
    child:SetWidth(width - SB_W - 2)
    child:SetHeight(1)
    scrollFrame:SetScrollChild(child)
    ReskinScrollbar(scrollFrame)

    local obj = { frame = scrollFrame, scrollChild = child }

    function obj:SetPoint(...) self.frame:SetPoint(...) end

    function obj:SetSize(w, h) self.frame:SetSize(w, h) end

    function obj:GetWidth() return self.frame:GetWidth() end

    function obj:UpdateScrollBar()
        local bar = _G[boxName .. "ScrollBar"]
        if not bar then return end
        local maxScroll = math.max(0, child:GetHeight() - scrollFrame:GetHeight())
        bar:SetMinMaxValues(0, maxScroll)
        if bar:GetValue() > maxScroll then bar:SetValue(0) end

    end
    return obj
end

-- ============================================================
--  BuildWidgets
--
--  Lays out an ordered list of component descriptors inside
--  a parent frame, stacking them top-to-bottom with a uniform
--  gap. Returns the total height consumed so the caller can
--  resize the container to fit.
--
--  definitions – array of descriptor tables, in display order:
--
--    {Type="Slider",    label=s, get=fn, set=fn, min=n, max=n, step=n}
--    {Type="Dropdown",  label=s, get=fn, set=fn, values=tbl|fn}
--       values entries: {label=s, value=any}
--       get() returns the currently selected value (looked up to find label)
--    {Type="Color",     label=s, get=fn, set=fn}
--       get() → r, g, b, a (0–1 each);  set(r, g, b, a)
--    {Type="Checkbox",  label=s, get=fn, set=fn}
--    {Type="TextEntry", label=s, get=fn, set=fn, numeric=bool, min=n, max=n}
--    {Type="Label",     text=s}
--    {Type="Breakline"}
--
--    "Scale" is accepted as an alias for "Slider".
--    Any descriptor may include height=n to override the default row height.
--    Pass namePrefix to give each created frame a unique global WoW name of
--    the form namePrefix .. "_" .. gen .. Type .. index (e.g. "NSRTOpt_1Slider1").
-- ============================================================
local WIDGET_H = {
    Slider    = 22, Scale     = 22,
    Dropdown  = 22, Color     = 22,
    Checkbox  = 22, TextEntry = 22,
    Label     = 16, Breakline = 10,
    Link      = 22,
}
local WIDGET_GAP = 4
local buildGen = 0

-- Accepts a function or a loadstring-compatible string ("return function(...) end").
-- Returns a callable, or nil if the input is nil.
local function ResolveCallback(v)
    if type(v) == "string" then
        local fn, err = loadstring(v)
        if not fn then
            print("|cFFFF0000NSRT BuildWidgets:|r failed to compile callback:", err)
            return nil
        end
        return fn()   -- the string must evaluate to a function
    end
    return v          -- already a function (or nil)
end

local function BuildWidgets(parent, definitions, width, namePrefix)
    local C = NSI.UI.Components
    local y = 0
    local gen
    if namePrefix then
        buildGen = buildGen + 1
        gen = buildGen
    end
    local typeCounts = {}

    for _, def in ipairs(definitions) do
        local t    = def.Type
        local h    = def.height or WIDGET_H[t] or 22
        local ctrl

        local wName
        if namePrefix then
            typeCounts[t] = (typeCounts[t] or 0) + 1
            wName = namePrefix .. "_" .. gen .. t .. typeCounts[t]
        end
        if t == "Slider" or t == "Scale" then
            ctrl = C.CreateSlider(parent, def.label,
                ResolveCallback(def.get), ResolveCallback(def.set),
                width, h, def.min, def.max, def.step, wName)

        elseif t == "Dropdown" then
            local resolvedGet    = ResolveCallback(def.get)
            local resolvedSet    = ResolveCallback(def.set)
            local resolvedValues = ResolveCallback(def.values)
            local function getItems()
                local vals = type(resolvedValues) == "function"
                    and resolvedValues(NSI) or (resolvedValues or {})
                local out = {}
                for _, v in ipairs(vals) do
                    out[#out + 1] = {
                        label   = v.label,
                        value   = v.value,
                        onclick = function(_, _, val)
                            if resolvedSet then resolvedSet(NSI, val) end
                        end,
                    }
                end
                return out
            end
            local function getSelected()
                local cur  = resolvedGet and resolvedGet(NSI)
                local vals = type(resolvedValues) == "function"
                    and resolvedValues(NSI) or (resolvedValues or {})
                for _, v in ipairs(vals) do
                    if v.value == cur then return v.label end
                end
                return cur ~= nil and tostring(cur) or ""
            end
            ctrl = C.CreateDropdown(parent, def.label,
                getItems, getSelected, width, h, wName)

        elseif t == "Color" then
            ctrl = C.CreateColorPicker(parent, def.label,
                ResolveCallback(def.get), ResolveCallback(def.set), width, h, wName)

        elseif t == "Checkbox" then
            ctrl = C.CreateCheckButton(parent, def.label,
                ResolveCallback(def.get), ResolveCallback(def.set), width, h, wName)

        elseif t == "TextEntry" then
            ctrl = C.CreateTextEntry(parent, def.label,
                ResolveCallback(def.get), ResolveCallback(def.set),
                width, h, def.numeric, def.min, def.max, wName)

        elseif t == "Label" then
            ctrl = C.CreateLabel(parent, def.text, width, h, wName)

        elseif t == "Breakline" then
            ctrl = C.CreateBreakline(parent, width, h, wName)

        elseif t == "Link" then
            ctrl = C.CreateLink(parent, def.label, def.url, def.width or width, h, wName)

        elseif t == "Button" then
            local resolvedFunc = ResolveCallback(def.func)
            ctrl = C.CreateButton(parent, def.label, function()
                if resolvedFunc then resolvedFunc(NSI) end
            end, def.width or width, h, wName)
        end

        if ctrl then
            ctrl:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, -y)
            y = y + h + WIDGET_GAP
        end
    end

    return y > 0 and (y - WIDGET_GAP) or 0
end

-- ============================================================
--  CreateDialog
--
--  Creates (or re-shows) a named dialog.  Each unique name maps
--  to one frame cached in NSI.UI.Components.Dialogs; subsequent
--  calls with the same name just re-show the cached frame.
--
--  Params
--    name     – unique key; also forms the global WoW frame name
--    title    – title bar text
--    body     – body / content text (word-wrapped)
--    btn1Text – primary button label
--    btn1Fn   – function() called then dialog hides; may be nil
--    btn2Text – secondary button label (nil → single centered button)
--    btn2Fn   – function() called then dialog hides; may be nil
--
--  Returns the WoW frame.
-- ============================================================
local DIALOG_W       = 380
local DIALOG_H       = 190
local DIALOG_TITLE_H = 30
local DIALOG_BTN_H   = 26
local DIALOG_BTN_W   = 110
local DIALOG_PAD     = 12

local Dialogs = {}

local function CreateDialog(name, title, body, btn1Text, btn1Fn, btn2Text, btn2Fn)
    if Dialogs[name] then
        Dialogs[name]:Show()
        return Dialogs[name]
    end

    local frameName = "NSRTDialog_" .. name
    local f = CreateFrame("Frame", frameName, UIParent, "BackdropTemplate")
    f:SetSize(DIALOG_W, DIALOG_H)
    f:SetPoint("CENTER", UIParent, "CENTER")
    f:SetFrameStrata("DIALOG")
    f:SetMovable(true)
    f:SetClampedToScreen(true)
    f:EnableMouse(true)
    f:SetBackdrop({
        bgFile   = [[Interface\Buttons\WHITE8x8]],
        edgeFile = [[Interface\Buttons\WHITE8x8]],
        edgeSize = 1,
        tile     = true,
        tileSize = 64,
    })
    f:SetBackdropColor(0.05, 0.05, 0.08, 0.97)
    f:SetBackdropBorderColor(0, 1, 1, 0.7)

    -- ── Title bar (drag handle) ──────────────────────────────────
    local titleBar = CreateFrame("Frame", nil, f)
    titleBar:SetPoint("TOPLEFT",  f, "TOPLEFT",  1, -1)
    titleBar:SetPoint("TOPRIGHT", f, "TOPRIGHT", -1, -1)
    titleBar:SetHeight(DIALOG_TITLE_H)
    titleBar:EnableMouse(true)
    titleBar:SetScript("OnMouseDown", function(_, btn)
        if btn == "LeftButton" then f:StartMoving() end
    end)
    titleBar:SetScript("OnMouseUp", function() f:StopMovingOrSizing() end)

    local titleFS = MakeFontString(f, 14)
    titleFS:SetText(title or "")
    titleFS:SetJustifyH("LEFT")
    titleFS:SetJustifyV("MIDDLE")
    titleFS:SetPoint("LEFT",  titleBar, "LEFT",  DIALOG_PAD, 0)
    titleFS:SetPoint("RIGHT", titleBar, "RIGHT", -28, 0)
    titleFS:SetHeight(DIALOG_TITLE_H)

    local titleSep = f:CreateTexture(nil, "ARTWORK")
    titleSep:SetColorTexture(0, 1, 1, 0.20)
    titleSep:SetHeight(1)
    titleSep:SetPoint("TOPLEFT",  f, "TOPLEFT",  1, -(DIALOG_TITLE_H + 1))
    titleSep:SetPoint("TOPRIGHT", f, "TOPRIGHT", -1, -(DIALOG_TITLE_H + 1))

    -- ── Close (×) button ────────────────────────────────────────
    local xBtn = CreateFrame("Button", nil, f)
    xBtn:SetSize(14, 14)
    xBtn:SetPoint("TOPRIGHT", f, "TOPRIGHT", -8, -8)
    xBtn:SetFrameLevel(f:GetFrameLevel() + 3)
    xBtn:SetNormalTexture(ICON_PATH .. "x.png")
    xBtn:GetNormalTexture():SetVertexColor(0.55, 0.55, 0.55, 1)
    xBtn:SetScript("OnEnter", function() xBtn:GetNormalTexture():SetVertexColor(0, 1, 1, 1) end)
    xBtn:SetScript("OnLeave", function() xBtn:GetNormalTexture():SetVertexColor(0.55, 0.55, 0.55, 1) end)
    xBtn:SetScript("OnClick", function() f:Hide() end)

    -- ── Body text ────────────────────────────────────────────────
    local bodyFS = MakeFontString(f, 13)
    bodyFS:SetText(body or "")
    bodyFS:SetJustifyH("LEFT")
    bodyFS:SetJustifyV("TOP")
    bodyFS:SetWordWrap(true)
    bodyFS:SetPoint("TOPLEFT",     f, "TOPLEFT",     DIALOG_PAD, -(DIALOG_TITLE_H + DIALOG_PAD))
    bodyFS:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -DIALOG_PAD, DIALOG_BTN_H + DIALOG_PAD * 2)

    -- ── Buttons ──────────────────────────────────────────────────
    local btn1 = CreateButton(f, btn1Text or "OK", function()
        if btn1Fn then btn1Fn() end
        f:Hide()
    end, DIALOG_BTN_W, DIALOG_BTN_H)

    if btn2Text then
        btn1:SetPoint("BOTTOMRIGHT", f, "BOTTOM", -6, DIALOG_PAD)
        local btn2 = CreateButton(f, btn2Text, function()
            if btn2Fn then btn2Fn() end
            f:Hide()
        end, DIALOG_BTN_W, DIALOG_BTN_H)
        btn2:SetPoint("BOTTOMLEFT", f, "BOTTOM", 6, DIALOG_PAD)
    else
        btn1:SetPoint("BOTTOM", f, "BOTTOM", 0, DIALOG_PAD)
    end

    -- Escape key support via UISpecialFrames
    tinsert(UISpecialFrames, frameName)

    Dialogs[name] = f
    f:Show()
    return f
end

-- ============================================================
--  CreateStyledFrame
--
--  A general-purpose container frame styled with the Northern
--  Sky visual style: dark background, cyan border, and an ×
--  close button top-right. Draggable and clamped to screen.
--
--  Params
--    parent – WoW frame (nil defaults to UIParent)
--    width  – number
--    height – number
--    name   – optional global frame name (also registers Escape key support)
--
--  Returns the WoW Frame directly; use it as a parent for child
--  widgets. Call :Hide() / :Show() to toggle visibility.
-- ============================================================
local function CreateStyledFrame(parent, width, height, name)
    local f = CreateFrame("Frame", name, parent or UIParent, "BackdropTemplate")
    f:SetSize(width or 400, height or 300)
    f:SetMovable(true)
    f:SetClampedToScreen(true)
    f:EnableMouse(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", function(self) self:StartMoving() end)
    f:SetScript("OnDragStop",  function(self) self:StopMovingOrSizing() end)

    f:SetBackdrop({
        bgFile   = [[Interface\Buttons\WHITE8x8]],
        edgeFile = [[Interface\Buttons\WHITE8x8]],
        edgeSize = 1,
        tile     = true,
        tileSize = 64,
    })
    f:SetBackdropColor(0.05, 0.05, 0.08, 0.97)
    f:SetBackdropBorderColor(0, 1, 1, 0.7)

    local xBtn = CreateFrame("Button", nil, f)
    xBtn:SetSize(14, 14)
    xBtn:SetPoint("TOPRIGHT", f, "TOPRIGHT", -8, -8)
    xBtn:SetFrameLevel(f:GetFrameLevel() + 3)
    xBtn:SetNormalTexture(ICON_PATH .. "x.png")
    xBtn:GetNormalTexture():SetVertexColor(0.55, 0.55, 0.55, 1)
    xBtn:SetScript("OnEnter", function() xBtn:GetNormalTexture():SetVertexColor(0, 1, 1, 1) end)
    xBtn:SetScript("OnLeave", function() xBtn:GetNormalTexture():SetVertexColor(0.55, 0.55, 0.55, 1) end)
    xBtn:SetScript("OnClick", function() f:Hide() end)

    if name then tinsert(UISpecialFrames, name) end

    return f
end

-- ============================================================
--  ShowContextMenu
--
--  Opens a styled context menu at the cursor position.
--  Nested submenus open on hover; all menus share the NSRT
--  visual style (dark bg, cyan border, hover fades).
--
--  items – array of descriptor tables:
--    { type="button",  label="…", fnc=fn,    icon=texOrID, spellIcon=spellID }
--    { type="submenu", label="…", items={…}, icon=texOrID, spellIcon=spellID }
--    { type="label",   text="…" }
--    { type="separator" }
--
--  width – optional fixed px width; nil = auto-sized to widest label
-- ============================================================
local CTX_ROW_H    = 20
local CTX_SEP_H    = 8
local CTX_LABEL_H  = 18
local CTX_PAD_H    = 8
local CTX_ICON_SZ  = 14
local CTX_ICON_GAP = 4
local CTX_ARROW_W  = 14

local MAX_CTX_LEVELS = 5
local ctxFrames      = {}
local ctxClickaway   = nil
local _ctxMeasureFS

local function CtxMeasureText(text)
    if not _ctxMeasureFS then
        _ctxMeasureFS = UIParent:CreateFontString(nil, "ARTWORK")
        _ctxMeasureFS:Hide()
    end
    _ctxMeasureFS:SetFont(
        ValidateFont(NSI.LSM:Fetch("font", NSRT.Settings.GlobalFont)),
        13,
        NSRT.Settings.GlobalFontFlags or "")
    _ctxMeasureFS:SetText(text or "")
    return _ctxMeasureFS:GetStringWidth()
end

local function ResolveCtxIcon(item)
    if item.spellIcon then
        if C_Spell and C_Spell.GetSpellTexture then
            return C_Spell.GetSpellTexture(item.spellIcon)
        elseif GetSpellTexture then
            return GetSpellTexture(item.spellIcon)
        end
    end
    return item.icon or nil
end

local function HideCtxFromLevel(level)
    for i = level, MAX_CTX_LEVELS do
        if ctxFrames[i] then ctxFrames[i]:Hide() end
    end
    if level <= 1 and ctxClickaway then ctxClickaway:Hide() end
end

local function EnsureCtxClickaway()
    if ctxClickaway then return end
    ctxClickaway = CreateFrame("Frame", nil, UIParent)
    ctxClickaway:SetAllPoints(UIParent)
    ctxClickaway:SetFrameStrata("FULLSCREEN")
    ctxClickaway:EnableMouse(true)
    ctxClickaway:Hide()
    ctxClickaway:SetScript("OnMouseDown", function() HideCtxFromLevel(1) end)
end

local function GetOrCreateCtxFrame(level)
    if ctxFrames[level] then return ctxFrames[level] end
    local f = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
    f:SetFrameStrata("TOOLTIP")
    f:SetFrameLevel(200 + level * 5)
    f:Hide()
    f:SetBackdrop({
        bgFile   = [[Interface\Buttons\WHITE8x8]],
        edgeFile = [[Interface\Buttons\WHITE8x8]],
        edgeSize = 1,
        tile     = true,
        tileSize = 64,
    })
    f:SetBackdropColor(0.05, 0.05, 0.08, 0.97)
    f:SetBackdropBorderColor(0, 1, 1, 0.7)
    f._rows = {}
    ctxFrames[level] = f
    return f
end

local ShowContextAtLevel

ShowContextAtLevel = function(items, level, xNormal, xFlip, yTop, width)
    HideCtxFromLevel(level + 1)
    EnsureCtxClickaway()

    local f         = GetOrCreateCtxFrame(level)
    local baseLevel = f:GetFrameLevel()

    -- ── Width ────────────────────────────────────────────────────
    if not width then
        local hasIcon, hasSubmenu = false, false
        local maxW = 0
        for _, item in ipairs(items) do
            local t = item.type
            if t == "button" or t == "submenu" then
                local w = CtxMeasureText(item.label or "")
                if w > maxW then maxW = w end
                if ResolveCtxIcon(item) then hasIcon = true end
                if t == "submenu" then hasSubmenu = true end
            elseif t == "label" then
                local w = CtxMeasureText(item.text or "")
                if w > maxW then maxW = w end
            end
        end
        local iconW  = hasIcon    and (CTX_ICON_SZ + CTX_ICON_GAP) or 0
        local arrowW = hasSubmenu and CTX_ARROW_W                   or 0
        width = math.max(math.ceil(maxW) + CTX_PAD_H * 2 + iconW + arrowW, 100)
    end

    -- ── Height ───────────────────────────────────────────────────
    local totalH = 4   -- 2px inner padding top + bottom
    for _, item in ipairs(items) do
        if     item.type == "separator" then totalH = totalH + CTX_SEP_H
        elseif item.type == "label"     then totalH = totalH + CTX_LABEL_H
        else                                 totalH = totalH + CTX_ROW_H
        end
    end

    f:SetSize(width, totalH)

    -- ── Position with four-way edge-flip ─────────────────────────
    local screenW = GetScreenWidth()
    local flipX   = xNormal + width  > screenW
    local flipY   = yTop    - totalH < 0
    local anchorX = flipX and xFlip or xNormal
    local framePt = (flipY and "BOTTOM" or "TOP") .. (flipX and "RIGHT" or "LEFT")
    f:ClearAllPoints()
    f:SetPoint(framePt, UIParent, "BOTTOMLEFT", anchorX, yTop)

    -- ── Row pool helper ──────────────────────────────────────────
    local rowPool = f._rows
    local function GetRow(i)
        if rowPool[i] then return rowPool[i] end

        local row = CreateFrame("Button", nil, f)
        row:SetFrameLevel(baseLevel + 2)

        local hoverBg = CreateFrame("Frame", nil, row)
        hoverBg:SetAllPoints(row)
        hoverBg:SetFrameLevel(baseLevel + 3)
        hoverBg:EnableMouse(false)
        local ht = hoverBg:CreateTexture(nil, "BACKGROUND")
        ht:SetAllPoints()
        ht:SetColorTexture(unpack(STYLE.hover_color))
        hoverBg:SetAlpha(0)
        row.hoverBg = hoverBg

        local sepTex = row:CreateTexture(nil, "ARTWORK")
        sepTex:SetColorTexture(0, 1, 1, 0.15)
        sepTex:SetHeight(1)
        row.sepTex = sepTex

        local iconFrame = CreateFrame("Frame", nil, row)
        iconFrame:SetSize(CTX_ICON_SZ, CTX_ICON_SZ)
        iconFrame:SetFrameLevel(baseLevel + 4)
        iconFrame:EnableMouse(false)
        local iconTex = iconFrame:CreateTexture(nil, "ARTWORK")
        iconTex:SetAllPoints()
        row.iconFrame = iconFrame
        row.iconTex   = iconTex

        local labelFS = MakeFontString(row, 13)
        labelFS:SetJustifyH("LEFT")
        labelFS:SetJustifyV("MIDDLE")
        row.labelFS = labelFS

        local arrowTex = row:CreateTexture(nil, "ARTWORK")
        arrowTex:SetSize(8, 8)
        arrowTex:SetPoint("RIGHT", row, "RIGHT", -(CTX_PAD_H - 2), 0)
        arrowTex:SetTexture(ICON_PATH .. "chevron-right.png")
        arrowTex:SetVertexColor(0.6, 0.6, 0.6, 1)
        row.arrowTex = arrowTex

        rowPool[i] = row
        return row
    end

    -- ── Build rows ───────────────────────────────────────────────
    local curY     = -2
    local rowCount = 0

    for _, item in ipairs(items) do
        rowCount = rowCount + 1
        local row   = GetRow(rowCount)
        local itype = item.type

        row.hoverBg:SetAlpha(0)
        row.sepTex:Hide()
        row.iconFrame:Hide()
        row.arrowTex:Hide()
        row.labelFS:SetText("")
        row:SetScript("OnEnter", nil)
        row:SetScript("OnLeave", nil)
        row:SetScript("OnClick", nil)
        row:EnableMouse(false)

        if itype == "separator" then
            row:SetSize(width, CTX_SEP_H)
            row:SetPoint("TOPLEFT", f, "TOPLEFT", 0, curY)
            row.sepTex:ClearAllPoints()
            row.sepTex:SetPoint("LEFT",  row, "LEFT",  CTX_PAD_H, 0)
            row.sepTex:SetPoint("RIGHT", row, "RIGHT", -CTX_PAD_H, 0)
            row.sepTex:Show()
            curY = curY - CTX_SEP_H

        elseif itype == "label" then
            row:SetSize(width, CTX_LABEL_H)
            row:SetPoint("TOPLEFT", f, "TOPLEFT", 0, curY)
            row.labelFS:SetTextColor(0.55, 0.55, 0.55, 1)
            row.labelFS:SetText(item.text or "")
            row.labelFS:ClearAllPoints()
            row.labelFS:SetPoint("LEFT",  row, "LEFT",  CTX_PAD_H,  0)
            row.labelFS:SetPoint("RIGHT", row, "RIGHT", -CTX_PAD_H, 0)
            row.labelFS:SetHeight(CTX_LABEL_H)
            curY = curY - CTX_LABEL_H

        elseif itype == "button" or itype == "submenu" then
            row:SetSize(width, CTX_ROW_H)
            row:SetPoint("TOPLEFT", f, "TOPLEFT", 0, curY)
            row:EnableMouse(true)

            local tex   = ResolveCtxIcon(item)
            local leftX = CTX_PAD_H
            if tex then
                row.iconTex:SetTexture(tex)
                row.iconFrame:ClearAllPoints()
                row.iconFrame:SetPoint("LEFT", row, "LEFT", CTX_PAD_H, 0)
                row.iconFrame:Show()
                leftX = CTX_PAD_H + CTX_ICON_SZ + CTX_ICON_GAP
            end

            local rightX = -CTX_PAD_H
            if itype == "submenu" then
                row.arrowTex:Show()
                rightX = -(CTX_PAD_H + CTX_ARROW_W - 4)
            end

            row.labelFS:SetTextColor(1, 1, 1, 1)
            row.labelFS:SetText(item.label or "")
            row.labelFS:ClearAllPoints()
            row.labelFS:SetPoint("LEFT",  row, "LEFT",  leftX,  0)
            row.labelFS:SetPoint("RIGHT", row, "RIGHT", rightX, 0)
            row.labelFS:SetHeight(CTX_ROW_H)

            if itype == "button" then
                row:SetScript("OnEnter", function()
                    HideCtxFromLevel(level + 1)
                    UIFrameFadeIn(row.hoverBg, STYLE.hover_in, row.hoverBg:GetAlpha(), 1)
                end)
                row:SetScript("OnLeave", function()
                    UIFrameFadeOut(row.hoverBg, STYLE.hover_out, row.hoverBg:GetAlpha(), 0)
                end)
                if item.fnc then
                    row:SetScript("OnClick", function()
                        HideCtxFromLevel(1)
                        item.fnc()
                    end)
                end
            else -- submenu
                local subItems = item.items or {}
                row:SetScript("OnEnter", function()
                    UIFrameFadeIn(row.hoverBg, STYLE.hover_in, row.hoverBg:GetAlpha(), 1)
                    row.arrowTex:SetVertexColor(0, 1, 1, 1)
                    local rRight = row:GetRight()
                    local rLeft  = row:GetLeft()
                    local rTop   = row:GetTop()
                    if rRight and rTop then
                        ShowContextAtLevel(subItems, level + 1, rRight + 2, rLeft - 2, rTop, nil)
                    end
                end)
                row:SetScript("OnLeave", function()
                    UIFrameFadeOut(row.hoverBg, STYLE.hover_out, row.hoverBg:GetAlpha(), 0)
                    row.arrowTex:SetVertexColor(0.6, 0.6, 0.6, 1)
                end)
            end

            curY = curY - CTX_ROW_H
        end

        row:Show()
    end

    for i = rowCount + 1, #rowPool do rowPool[i]:Hide() end

    f:Show()
    if level == 1 then ctxClickaway:Show() end
end

local function ShowContextMenu(items, width)
    EnsureCtxClickaway()
    local scale  = UIParent:GetEffectiveScale()
    local cx, cy = GetCursorPosition()
    local uiX    = cx / scale
    local uiY    = cy / scale
    ShowContextAtLevel(items, 1, uiX, uiX, uiY, width)
end

-- ============================================================
--  CreateLink
--
--  A button that opens a small copy-popup when clicked.
--  The popup contains the url pre-selected in a focused
--  EditBox; pressing Ctrl+C copies and closes the popup.
--  Escape or clicking outside also closes it.
--
--  Params
--    parent – WoW frame
--    label  – button display text
--    url    – text placed in the copy box
--    width  – number (nil = auto from label)
--    height – number (default 22)
--    name   – optional global frame name
--
--  Returned object – same interface as CreateButton
-- ============================================================
local LINK_POPUP_W = 300
local LINK_POPUP_H = 64
local LINK_PAD     = 8

local linkPopup

local function EnsureLinkPopup()
    if linkPopup then return end

    local f = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
    f:SetSize(LINK_POPUP_W, LINK_POPUP_H)
    f:SetFrameStrata("TOOLTIP")
    f:SetMovable(true)
    f:SetClampedToScreen(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", function(self) self:StartMoving() end)
    f:SetScript("OnDragStop",  function(self) self:StopMovingOrSizing() end)
    f:Hide()
    f:SetBackdrop({
        bgFile   = [[Interface\Buttons\WHITE8x8]],
        edgeFile = [[Interface\Buttons\WHITE8x8]],
        edgeSize = 1,
        tile     = true,
        tileSize = 64,
    })
    f:SetBackdropColor(0.05, 0.05, 0.08, 0.97)
    f:SetBackdropBorderColor(0, 1, 1, 0.7)

    -- Hint label
    local hint = MakeFontString(f, 11)
    hint:SetTextColor(0.45, 0.45, 0.45, 1)
    hint:SetText("Press Ctrl+C to copy")
    hint:SetJustifyH("LEFT")
    hint:SetPoint("TOPLEFT", f, "TOPLEFT", LINK_PAD, -LINK_PAD)

    -- Input frame (same dark-box + focus-border pattern as CreateTextEntry)
    local inputFrame = CreateFrame("Frame", nil, f, "BackdropTemplate")
    inputFrame:SetPoint("TOPLEFT",     f, "TOPLEFT",     LINK_PAD,  -(LINK_PAD + 18 + 4))
    inputFrame:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -LINK_PAD, LINK_PAD)
    inputFrame:SetFrameLevel(f:GetFrameLevel() + 1)
    MakeControlBackdrop(inputFrame)

    local focusBorder = CreateFrame("Frame", nil, inputFrame, "BackdropTemplate")
    focusBorder:SetAllPoints(inputFrame)
    focusBorder:SetFrameLevel(inputFrame:GetFrameLevel() + 1)
    focusBorder:EnableMouse(false)
    focusBorder:SetBackdrop({ edgeFile = [[Interface\Buttons\WHITE8x8]], edgeSize = 1 })
    focusBorder:SetBackdropBorderColor(0, 1, 1, 0)

    local edit = CreateFrame("EditBox", nil, inputFrame)
    edit:SetPoint("TOPLEFT",     inputFrame, "TOPLEFT",     4, -2)
    edit:SetPoint("BOTTOMRIGHT", inputFrame, "BOTTOMRIGHT", -4,  2)
    edit:SetFont(ValidateFont(NSI.LSM:Fetch("font", NSRT.Settings.GlobalFont)), 12,
        NSRT.Settings.GlobalFontFlags or "")
    edit:SetTextColor(1, 1, 1, 1)
    edit:SetAutoFocus(false)
    edit:SetMultiLine(false)
    edit:SetJustifyH("CENTER")
    edit:SetJustifyV("MIDDLE")

    edit:SetScript("OnEditFocusGained", function()
        edit:HighlightText()
        UIFrameFadeIn(focusBorder, STYLE.select_in, focusBorder:GetAlpha(), 1)
    end)
    edit:SetScript("OnEditFocusLost", function()
        UIFrameFadeOut(focusBorder, STYLE.deselect_out, focusBorder:GetAlpha(), 0)
    end)
    edit:SetScript("OnEscapePressed", function() f:Hide() end)
    edit:SetScript("OnKeyDown", function(_, key)
        if key == "C" and IsControlKeyDown() then
            C_Timer.After(0, function() f:Hide() end)
        end
    end)

    linkPopup = { frame = f, edit = edit }
end

local function ShowLinkPopup(url, anchorFrame)
    EnsureLinkPopup()

    linkPopup.edit:SetText(url or "")

    local f      = linkPopup.frame
    local anchor = (NSI.UI.Core and NSI.UI.Core.NSUI) or UIParent
    f:ClearAllPoints()
    f:SetPoint("CENTER", anchor, "CENTER")

    f:Show()
    linkPopup.edit:SetFocus()
    linkPopup.edit:HighlightText()
end

local function CreateLink(parent, label, url, width, height, name)
    local btn
    btn = CreateButton(parent, label, function()
        ShowLinkPopup(url, btn.frame)
    end, width, height, name)
    return btn
end

-- ============================================================
--  Export
-- ============================================================
NSI.UI = NSI.UI or {}
NSI.UI.Components = {
    CreateButton        = CreateButton,
    CreateSubButton     = CreateSubButton,
    CreateCheckButton   = CreateCheckButton,
    CreateTextEntry     = CreateTextEntry,
    CreateDropdown      = CreateDropdown,
    CreateSlider        = CreateSlider,
    CreateColorPicker   = CreateColorPicker,
    CreateLabel         = CreateLabel,
    CreateBreakline     = CreateBreakline,
    CreateScrollBox     = CreateScrollBox,
    ReskinScrollbar     = ReskinScrollbar,
    BuildWidgets        = BuildWidgets,
    RefreshFonts        = RefreshFonts,
    CreateDialog        = CreateDialog,
    CreateFrame         = CreateStyledFrame,
    ShowContextMenu     = ShowContextMenu,
    CreateLink          = CreateLink,
    STYLE               = STYLE,
    registry            = componentRegistry,
    Dialogs             = Dialogs,
}
