-- EbonholdHub: modules/ui/SiteWidgets.lua
-- Website-parity widget factories. Use with SiteTheme for portable addon UI.

EbonholdHub.SiteWidgets = EbonholdHub.SiteWidgets or {}

local SW = EbonholdHub.SiteWidgets
local ST = EbonholdHub.SiteTheme
local C  = ST.C
local L  = ST.Layout
local FLAT = ST.FLAT

local FONT_BY_WEIGHT = {
    regular  = ST.FONT.regular,
    medium   = ST.FONT.medium,
    semibold = ST.FONT.semibold,
}

-- ── Primitives ────────────────────────────────────────────────────────────────

function SW.Fill(parent, color, layer)
    local tex = parent:CreateTexture(nil, layer or "BACKGROUND")
    tex:SetTexture(FLAT)
    tex:SetVertexColor(unpack(color))
    tex:SetAllPoints(parent)
    return tex
end

function SW.ThinBorder(frame, color, thickness)
    thickness = thickness or 1
    local borders = {}
    for _, spec in ipairs({
        { "TOPLEFT", "TOPRIGHT", "SetHeight", thickness },
        { "BOTTOMLEFT", "BOTTOMRIGHT", "SetHeight", thickness },
        { "TOPLEFT", "BOTTOMLEFT", "SetWidth", thickness },
        { "TOPRIGHT", "BOTTOMRIGHT", "SetWidth", thickness },
    }) do
        local tex = frame:CreateTexture(nil, "BORDER")
        tex:SetTexture(FLAT)
        tex:SetVertexColor(unpack(color))
        tex:SetPoint(spec[1], frame, spec[1], 0, 0)
        tex:SetPoint(spec[2], frame, spec[2], 0, 0)
        tex[spec[3]](tex, spec[4])
        borders[#borders + 1] = tex
    end
    return borders
end

function SW.SetBorderColor(borders, color)
    if not borders then return end
    for _, tex in ipairs(borders) do
        tex:SetVertexColor(unpack(color))
    end
end

function SW.DashedBorder(frame, color, dashLen, gapLen)
    dashLen = dashLen or 4
    gapLen = gapLen or 3
    color = color or C.borderSoft
    local borders = {}
    local function EdgeDashes(p1, p2, horizontal)
        local holder = CreateFrame("Frame", nil, frame)
        holder:SetPoint(p1, frame, p1, 0, 0)
        holder:SetPoint(p2, frame, p2, 0, 0)
        if horizontal then holder:SetHeight(1) else holder:SetWidth(1) end
        local len = horizontal and (frame:GetWidth() or 32) or (frame:GetHeight() or 32)
        local x = 0
        while x < len do
            local dash = holder:CreateTexture(nil, "OVERLAY")
            dash:SetTexture(FLAT)
            dash:SetVertexColor(unpack(color))
            if horizontal then
                dash:SetHeight(1)
                dash:SetWidth(math.min(dashLen, len - x))
                dash:SetPoint("LEFT", holder, "LEFT", x, 0)
            else
                dash:SetWidth(1)
                dash:SetHeight(math.min(dashLen, len - x))
                dash:SetPoint("TOP", holder, "TOP", 0, -x)
            end
            borders[#borders + 1] = dash
            x = x + dashLen + gapLen
        end
        holder._sync = function()
            local w, h = frame:GetWidth(), frame:GetHeight()
            len = horizontal and w or h
        end
    end
    frame:HookScript("OnSizeChanged", function()
        -- Dashes are approximate on first layout; good enough for empty slots.
    end)
    EdgeDashes("TOPLEFT", "TOPRIGHT", true)
    EdgeDashes("BOTTOMLEFT", "BOTTOMRIGHT", true)
    EdgeDashes("TOPLEFT", "BOTTOMLEFT", false)
    EdgeDashes("TOPRIGHT", "BOTTOMRIGHT", false)
    return borders
end

function SW.SetFont(fontString, size, weight, outline)
    local path = FONT_BY_WEIGHT[weight or "regular"] or ST.FONT.regular
    fontString:SetFont(path, size, outline and "OUTLINE" or "")
    local loaded = fontString:GetFont()
    if not loaded or not loaded:lower():find("geist", 1, true) then
        fontString:SetFont(ST.FONT.fallback, size, outline and "OUTLINE" or "")
    end
end

function SW.Label(parent, text, size, color, outline, weight)
    local fs = parent:CreateFontString(nil, "OVERLAY")
    SW.SetFont(fs, size, weight, outline)
    fs:SetTextColor(unpack(color or C.text))
    if text then fs:SetText(text) end
    return fs
end

function SW.AttachPanelShadow(parent, offset)
    offset = offset or 4
    local shadow = CreateFrame("Frame", nil, parent)
    shadow:SetPoint("TOPLEFT", offset, -offset)
    shadow:SetPoint("BOTTOMRIGHT", -offset, offset)
    SW.Fill(shadow, C.shadow)
    shadow:SetFrameLevel(math.max(0, parent:GetFrameLevel() - 1))
    return shadow
end

-- Site-styled main window shell (same as /ebh sitedemo panel — no UIKit title bar / padding).
function SW.CreateSiteWindow(opts)
    opts = opts or {}
    local w = opts.width or L.WIN_W
    local h = opts.height or L.WIN_H

    local outer = CreateFrame("Frame", opts.name, UIParent)
    outer:SetSize(w, h)
    outer:SetPoint("CENTER")
    outer:SetMovable(true)
    outer:EnableMouse(true)
    outer:SetClampedToScreen(true)
    outer:SetFrameStrata(opts.strata or "DIALOG")
    if opts.toplevel then outer:SetToplevel(true) end
    outer:Hide()

    if opts.shadow ~= false then
        SW.AttachPanelShadow(outer, opts.shadowOffset or 4)
    end

    local panel = CreateFrame("Frame", nil, outer)
    panel:SetAllPoints(outer)
    SW.Fill(panel, C.bg)
    SW.ThinBorder(panel, C.border, 1)

    if opts.draggable ~= false then
        panel:EnableMouse(true)
        panel:SetScript("OnMouseDown", function(_, button)
            if button == "LeftButton" then outer:StartMoving() end
        end)
        panel:SetScript("OnMouseUp", function()
            outer:StopMovingOrSizing()
        end)
    end

    if opts.closable ~= false then
        local closeBtn = CreateFrame("Button", nil, panel, "UIPanelCloseButton")
        closeBtn:SetPoint("TOPRIGHT", -2, -2)
        closeBtn:SetFrameLevel(panel:GetFrameLevel() + 4)
        closeBtn:SetScript("OnClick", function()
            if opts.onClose then opts.onClose() else outer:Hide() end
        end)
        outer._closeBtn = closeBtn
    end

    local body = CreateFrame("Frame", nil, panel)
    body:SetAllPoints(panel)

    if opts.name then
        tinsert(UISpecialFrames, opts.name)
    end

    outer.panel = panel
    outer.body = body
    return outer
end

function SW.AttachAccentGlow(parent, width)
    width = width or 8
    local glow = parent:CreateTexture(nil, "BACKGROUND", nil, -1)
    glow:SetTexture(FLAT)
    glow:SetVertexColor(unpack(C.accentGlow))
    glow:SetWidth(width)
    glow:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0)
    glow:SetPoint("BOTTOMLEFT", parent, "BOTTOMLEFT", 0, 0)
    return glow
end

function SW.AnimateFadeIn(frame, duration)
    duration = duration or 0.35
    frame:SetAlpha(0)
    local elapsed = 0
    frame:SetScript("OnUpdate", function(self, dt)
        elapsed = elapsed + dt
        local t = math.min(1, elapsed / duration)
        self:SetAlpha(t)
        if t >= 1 then self:SetScript("OnUpdate", nil) end
    end)
end

-- ── Icons ─────────────────────────────────────────────────────────────────────

function SW.SetClassIcon(tex, classToken)
    local coords = CLASS_ICON_TCOORDS[classToken]
    if coords then
        tex:SetTexture("Interface\\TargetingFrame\\UI-Classes-Circles")
        tex:SetTexCoord(coords[1], coords[2], coords[3], coords[4])
    end
end

function SW.CreateClassIconHolder(parent, classToken, specNameOrIndex, iconSize)
    iconSize = iconSize or L.CLASS_ICON
    local holder = CreateFrame("Frame", nil, parent)
    holder:SetSize(iconSize, iconSize)

    local classIcon = holder:CreateTexture(nil, "ARTWORK")
    classIcon:SetSize(iconSize, iconSize)
    classIcon:SetPoint("TOPLEFT", 0, 0)
    SW.SetClassIcon(classIcon, classToken)
    holder._classIcon = classIcon

    local _, specIconPath = ST.SpecDisplay(classToken, specNameOrIndex)
    if specIconPath then
        local specFrame = CreateFrame("Frame", nil, holder)
        specFrame:SetSize(L.SPEC_ICON, L.SPEC_ICON)
        specFrame:SetPoint("BOTTOMRIGHT", holder, "BOTTOMRIGHT", 2, -2)
        specFrame:SetFrameLevel(holder:GetFrameLevel() + 2)
        SW.Fill(specFrame, C.bg)
        SW.ThinBorder(specFrame, C.border, 1)
        local specTex = specFrame:CreateTexture(nil, "ARTWORK")
        specTex:SetPoint("TOPLEFT", 1, -1)
        specTex:SetPoint("BOTTOMRIGHT", -1, 1)
        specTex:SetTexture(specIconPath)
        specTex:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        holder._specTex = specTex
    end

    return holder
end

function SW.CreateEchoIcon(parent, opts)
    opts = opts or {}
    local size = opts.size or L.ICON_TIER
    local name = opts.name
    local spellId = opts.spellId
    local quality = opts.quality or 2
    local maxPicks = opts.maxPicks or 1
    local showBadge = opts.showBadge ~= false

    if name and not spellId and EbonholdHub.EchoTableRows then
        local best = EbonholdHub.EchoTableRows.BuildBestByName and EbonholdHub.EchoTableRows.BuildBestByName()
        local entry = best and best[name]
        if entry then
            spellId = spellId or entry.spellId
            quality = entry.quality or quality
        end
    end
    if not spellId and name then
        spellId = GetSpellInfo(name)
    end

    local qColor = ST.QUALITY_BORDER[quality] or ST.QUALITY_BORDER[2]
    if opts.softBorder then
        qColor = ST.BlendRgb(qColor, C.border, 0.55)
    end
    local btn = CreateFrame("Button", nil, parent)
    btn:SetSize(size, size)

    local glow = btn:CreateTexture(nil, "BACKGROUND")
    glow:SetTexture(FLAT)
    if opts.showGlow == false then
        glow:Hide()
    else
        glow:SetVertexColor(qColor[1], qColor[2], qColor[3], 0.35)
        glow:SetPoint("TOPLEFT", -2, 2)
        glow:SetPoint("BOTTOMRIGHT", 2, -2)
    end

    SW.Fill(btn, C.elementBg)
    btn._border = SW.ThinBorder(btn, qColor, opts.borderThickness or 2)

    local inset = opts.iconInset or 2
    local icon = btn:CreateTexture(nil, "ARTWORK")
    icon:SetPoint("TOPLEFT", inset, -inset)
    icon:SetPoint("BOTTOMRIGHT", -inset, inset)
    icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    if spellId then
        local _, _, tex = GetSpellInfo(spellId)
        if tex then icon:SetTexture(tex) end
    else
        icon:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
    end
    btn.icon = icon
    btn._glow = glow

    if showBadge then
        local badge = CreateFrame("Frame", nil, btn)
        badge:SetSize(16, 14)
        badge:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", 2, -2)
        badge:SetFrameLevel(btn:GetFrameLevel() + 2)
        SW.Fill(badge, C.bg)
        SW.ThinBorder(badge, C.border, 1)
        local badgeColor = (maxPicks == 0 or maxPicks > 1) and C.accent or C.textMuted
        local badgeText = opts.maxPicks == 0 and "×∞" or ("×" .. tostring(maxPicks))
        btn._countBadge = badge
        btn._countLabel = SW.Label(badge, badgeText, 9, badgeColor)
        btn._countLabel:SetPoint("CENTER")
    end

    if name then
        btn:SetScript("OnEnter", function()
            GameTooltip:SetOwner(btn, "ANCHOR_RIGHT")
            GameTooltip:SetText(name, 1, 1, 1)
            GameTooltip:Show()
        end)
        btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
    end

    return btn
end

function SW.CreateEmptyEchoSlot(parent, size)
    size = size or L.ICON_LOCKED
    local slot = CreateFrame("Frame", nil, parent)
    slot:SetSize(size, size)
    SW.Fill(slot, C.emptySlotBg)
    SW.ThinBorder(slot, C.borderSoft, 1)
    SW.Label(slot, "—", 10, C.textMuted):SetPoint("CENTER")
    return slot
end

-- ── Components ────────────────────────────────────────────────────────────────

function SW.CreateBadge(parent, text, variant)
    local badge = CreateFrame("Frame", nil, parent)
    badge:SetHeight(16)
    local bgColor, textColor, borderColor
    if variant == "accent" then
        bgColor, textColor, borderColor = C.accentBg10, C.accent, C.accentBorder30
    elseif variant == "success" then
        bgColor, textColor, borderColor = C.successBg10, C.success, C.successBorder30
    else
        bgColor, textColor, borderColor = C.elementBg, C.textDim, C.border
    end
    SW.Fill(badge, bgColor)
    SW.ThinBorder(badge, borderColor, 1)
    local label = SW.Label(badge, string.upper(text or ""), 10, textColor, false, "medium")
    label:SetPoint("LEFT", 8, 0)
    label:SetPoint("RIGHT", -8, 0)
    badge:SetWidth(label:GetStringWidth() + 16)
    return badge
end

function SW.CreateStatCell(parent, statLabel, value)
    local cell = CreateFrame("Frame", nil, parent)
    SW.Fill(cell, C.statCellBg)
    SW.ThinBorder(cell, C.border, 1)
    cell:SetHeight(44)
    local lbl = SW.Label(cell, string.upper(statLabel or ""), 9, C.textMuted)
    lbl:SetPoint("TOP", 0, -6)
    local val = SW.Label(cell, value, 13, C.text, false, "semibold")
    val:SetPoint("TOP", lbl, "BOTTOM", 0, -2)
    cell._valueLabel = val
    return cell
end

function SW.CreateAccentButton(parent, text, onClick)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetHeight(32)
    btn._bg = SW.Fill(btn, C.accentBg10)
    btn._border = SW.ThinBorder(btn, C.accentBorder35, 1)
    btn._label = SW.Label(btn, text, 11, C.accent, false, "medium")
    btn._label:SetPoint("CENTER")
    btn._defaultText = text

    btn.SetCopied = function(self, copied)
        self._label:SetText(copied and "Copied!" or self._defaultText)
    end

    local function SetHover(hover)
        btn._bg:SetVertexColor(unpack(hover and C.accentBg18 or C.accentBg10))
        SW.SetBorderColor(btn._border, hover and C.accentBorder55 or C.accentBorder35)
    end

    btn:SetScript("OnEnter", function() SetHover(true) end)
    btn:SetScript("OnLeave", function() SetHover(false) end)
    btn:SetScript("OnClick", function(self)
        if onClick then onClick(self) end
    end)
    return btn
end

function SW.CreateOutlineButton(parent, text, width)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetSize(width or 72, 28)
    btn._bg = SW.Fill(btn, C.elementBg)
    btn._border = SW.ThinBorder(btn, C.border, 1)
    btn._label = SW.Label(btn, text, 12, C.text, false, "medium")
    btn._label:SetPoint("CENTER")
    btn:SetScript("OnEnter", function(self)
        SW.SetBorderColor(self._border, C.borderHover)
    end)
    btn:SetScript("OnLeave", function(self)
        SW.SetBorderColor(self._border, C.border)
    end)
    return btn
end

function SW.CreateNavActionButton(parent, opts)
    opts = opts or {}
    local variant = opts.variant or "default"
    local btn = CreateFrame("Button", nil, parent)
    btn:SetHeight(28)

    local bgColor, textColor, borderColor
    if variant == "accent" then
        bgColor, textColor, borderColor = C.accentBg10, C.accent, C.accentBorder35
    elseif variant == "danger" then
        local d = { 0.9, 0.25, 0.25 }
        bgColor = ST.BlendRgb(d, ST.Base.bg, 0.12)
        textColor = { 0.95, 0.45, 0.45, 1 }
        borderColor = ST.BlendRgb(d, ST.Base.border, 0.35)
    else
        bgColor, textColor, borderColor = C.elementBg, C.textDim, C.border
    end

    btn._bg = SW.Fill(btn, bgColor)
    btn._border = SW.ThinBorder(btn, borderColor, 1)
    btn._bgColor = bgColor
    btn._textColor = textColor
    btn._borderColor = borderColor
    btn._variant = variant

    local padL = 10
    if opts.icon and EbonholdHub.SiteIcons and EbonholdHub.SiteIcons.Exists(opts.icon) then
        btn._icon = SW.CreateIcon(btn, opts.icon, 13, textColor)
        btn._icon:SetPoint("LEFT", padL, 0)
        btn._icon:SetPoint("TOP", btn, "TOP", 0, -8)
        padL = padL + 13 + 5
    end

    btn._label = SW.Label(btn, opts.label or "", 11, textColor, false, "medium")
    btn._label:SetPoint("LEFT", padL, 0)
    btn._label:SetPoint("TOP", btn, "TOP", 0, -9)
    btn:SetWidth(math.max(72, btn._label:GetStringWidth() + padL + 10))

    local function SetHover(hover)
        if variant == "accent" then
            btn._bg:SetVertexColor(unpack(hover and C.accentBg18 or C.accentBg10))
            SW.SetBorderColor(btn._border, hover and C.accentBorder55 or C.accentBorder35)
            local tc = hover and C.text or C.accent
            btn._label:SetTextColor(unpack(tc))
            if btn._icon then btn._icon:SetColor(tc) end
        elseif variant == "danger" then
            local d = { 0.9, 0.25, 0.25 }
            btn._bg:SetVertexColor(unpack(hover and ST.BlendRgb(d, ST.Base.bg, 0.18) or bgColor))
            SW.SetBorderColor(btn._border, hover and ST.BlendRgb(d, ST.Base.border, 0.55) or borderColor)
            local tc = hover and C.text or textColor
            btn._label:SetTextColor(unpack(tc))
            if btn._icon then btn._icon:SetColor(tc) end
        else
            btn._bg:SetVertexColor(unpack(hover and C.bgElevated or C.elementBg))
            SW.SetBorderColor(btn._border, hover and C.borderHover or C.border)
            btn._label:SetTextColor(unpack(hover and C.text or C.textDim))
            if btn._icon then btn._icon:SetColor(hover and C.text or C.textDim) end
        end
    end

    btn:SetScript("OnEnter", function() SetHover(true) end)
    btn:SetScript("OnLeave", function() SetHover(false) end)
    btn:SetScript("OnClick", function()
        if opts.onClick then opts.onClick() end
    end)
    return btn
end

function SW.CreateCloseButton(parent, onClick, size)
    size = size or 28
    local btn = CreateFrame("Button", nil, parent)
    btn:SetSize(size, size)
    btn._bg = SW.Fill(btn, C.elementBg)
    btn._border = SW.ThinBorder(btn, C.border, 1)
    btn._icon = SW.CreateIcon(btn, "close", 14, C.textDim)
    btn._icon:SetPoint("CENTER")
    btn:SetScript("OnEnter", function()
        btn._bg:SetVertexColor(unpack(C.bgElevated))
        SW.SetBorderColor(btn._border, C.borderHover)
        btn._icon:SetColor(C.text)
    end)
    btn:SetScript("OnLeave", function()
        btn._bg:SetVertexColor(unpack(C.elementBg))
        SW.SetBorderColor(btn._border, C.border)
        btn._icon:SetColor(C.textDim)
    end)
    btn:SetScript("OnClick", function()
        if onClick then onClick() end
    end)
    return btn
end

function SW.CreateIcon(parent, name, size, color)
    size = size or 16
    color = color or C.textDim
    local holder = CreateFrame("Frame", nil, parent)
    holder:SetSize(size, size)

    local tex = holder:CreateTexture(nil, "ARTWORK")
    tex:SetAllPoints(holder)
    local path = EbonholdHub.SiteIcons and EbonholdHub.SiteIcons.Path(name)
    if path then
        tex:SetTexture(path)
    else
        tex:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
    end
    tex:SetVertexColor(unpack(color))
    holder._tex = tex

    holder.SetColor = function(self, rgb)
        self._tex:SetVertexColor(unpack(rgb))
    end

    return holder
end

function SW.CreateNavLink(parent, item, navState)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetHeight(L.NAV_H)
    btn._id = item.id

    local bg = btn:CreateTexture(nil, "BACKGROUND")
    bg:SetTexture(FLAT)
    bg:SetAllPoints(btn)
    bg:Hide()
    btn._bg = bg

    local padL = 10
    if item.icon and EbonholdHub.SiteIcons and EbonholdHub.SiteIcons.Exists(item.icon) then
        btn._icon = SW.CreateIcon(btn, item.icon, 14, C.textDim)
        btn._icon:SetPoint("LEFT", padL, 0)
        padL = padL + 14 + 6
    end

    local label = SW.Label(btn, item.label, 13, C.textDim, false, "medium")
    label:SetPoint("LEFT", padL, 0)
    btn._label = label

    local function ApplyState()
        local active = (navState.activeId == item.id)
        local textColor = active and C.accent or C.textDim
        if active then
            bg:Show()
            bg:SetVertexColor(unpack(C.accentBg12))
        else
            bg:Hide()
        end
        label:SetTextColor(unpack(textColor))
        if btn._icon then btn._icon:SetColor(textColor) end
    end
    btn.ApplyState = ApplyState

    btn:SetScript("OnClick", function()
        navState.activeId = item.id
        if navState.onSelect then navState.onSelect(item.id, item.label) end
        if navState.registry then
            for _, link in pairs(navState.registry) do
                if link.ApplyState then link.ApplyState() end
            end
        end
    end)
    btn:SetScript("OnEnter", function()
        if navState.activeId ~= item.id then
            bg:Show()
            bg:SetVertexColor(unpack(C.elementBg))
            label:SetTextColor(unpack(C.text))
            if btn._icon then btn._icon:SetColor(C.text) end
        end
    end)
    btn:SetScript("OnLeave", ApplyState)

    local w = label:GetStringWidth() + padL + 12
    btn:SetWidth(w)
    ApplyState()
    return btn
end

function SW.CreateTabButton(parent, text, index, tabState)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetHeight(36)
    local label = SW.Label(btn, text, 13, C.textMuted, false, "medium")
    label:SetPoint("CENTER")
    local underline = btn:CreateTexture(nil, "OVERLAY")
    underline:SetTexture(FLAT)
    underline:SetVertexColor(unpack(C.accent))
    underline:SetHeight(2)
    underline:SetPoint("BOTTOMLEFT", 0, 0)
    underline:SetPoint("BOTTOMRIGHT", 0, 0)
    underline:Hide()
    btn._label = label
    btn._underline = underline
    btn._index = index

    btn:SetScript("OnClick", function()
        if tabState.SelectTab then tabState.SelectTab(index) end
    end)
    btn:SetScript("OnEnter", function()
        if tabState.selectedIndex ~= index then
            label:SetTextColor(unpack(C.text))
        end
    end)
    btn:SetScript("OnLeave", function()
        if tabState.selectedIndex ~= index then
            label:SetTextColor(unpack(C.textMuted))
        end
    end)
    return btn
end

function SW.CreateSidebarBlock(parent, title)
    local block = CreateFrame("Frame", nil, parent)
    block:SetPoint("LEFT", 0, 0)
    block:SetPoint("RIGHT", 0, 0)

    local sep = block:CreateTexture(nil, "ARTWORK")
    sep:SetTexture(FLAT)
    sep:SetVertexColor(unpack(C.borderSoft))
    sep:SetHeight(1)
    sep:SetPoint("BOTTOMLEFT", 0, 0)
    sep:SetPoint("BOTTOMRIGHT", 0, 0)

    local titleLabel = SW.Label(block, string.upper(title or ""), 11, C.textMuted, false, "semibold")
    titleLabel:SetPoint("TOPLEFT", L.PAD, -L.PAD)
    block._title = titleLabel
    block._contentTop = -L.PAD - 18
    return block
end

function SW.CreatePlainScroll(parent, childWidth)
    local scroll = CreateFrame("ScrollFrame", nil, parent)
    scroll:SetPoint("TOPLEFT", 0, 0)
    scroll:SetPoint("BOTTOMRIGHT", 0, 0)
    scroll:EnableMouseWheel(true)

    local child = CreateFrame("Frame", nil, scroll)
    child:SetWidth(childWidth or L.MAIN_W - L.PAD * 2)
    child:SetHeight(1)
    scroll:SetScrollChild(child)

    scroll:SetScript("OnMouseWheel", function(self, delta)
        local range = child:GetHeight() - self:GetHeight()
        if range <= 0 then return end
        local pos = self:GetVerticalScroll() - delta * 30
        self:SetVerticalScroll(math.max(0, math.min(range, pos)))
    end)

    return scroll, child
end

function SW.CreateBuildHeader(parent, opts)
    opts = opts or {}
    local classToken = opts.class or "PRIEST"
    local classColor = ST.CLASS_COLORS[classToken] or C.text
    local header = CreateFrame("Frame", nil, parent)
    header:SetPoint("TOPLEFT", 0, 0)
    header:SetPoint("TOPRIGHT", 0, 0)
    header:SetHeight(opts.height or 200)

    if opts.draggable then
        header:EnableMouse(true)
        header:SetScript("OnMouseDown", function() opts.draggable:StartMoving() end)
        header:SetScript("OnMouseUp", function() opts.draggable:StopMovingOrSizing() end)
    end

    local grad = header:CreateTexture(nil, "BACKGROUND")
    ST.ApplyClassBackgroundGradient(grad, classColor, {
        C.sidebarBg[1], C.sidebarBg[2], C.sidebarBg[3],
    })
    grad:SetAllPoints(header)
    header._grad = grad

    local headerSep = header:CreateTexture(nil, "ARTWORK")
    headerSep:SetTexture(FLAT)
    headerSep:SetVertexColor(unpack(C.border))
    headerSep:SetHeight(1)
    headerSep:SetPoint("BOTTOMLEFT", 0, 0)
    headerSep:SetPoint("BOTTOMRIGHT", 0, 0)

    local classStripe = header:CreateTexture(nil, "ARTWORK")
    classStripe:SetWidth(4)
    classStripe:SetPoint("TOPLEFT", 0, 0)
    classStripe:SetPoint("BOTTOMLEFT", 0, 0)
    ST.ApplyClassStripe(classStripe, classColor)
    header._classStripe = classStripe

    local iconHolder = SW.CreateClassIconHolder(header, classToken, opts.spec, L.CLASS_ICON)
    iconHolder:SetPoint("TOPLEFT", L.PAD + 6, -L.PAD)

    local title = SW.Label(header, opts.title or "", 16, classColor, false, "semibold")
    title:SetPoint("TOPLEFT", iconHolder, "TOPRIGHT", 10, -2)
    title:SetWidth(L.SIDEBAR_W - L.PAD * 2 - 60)
    title:SetJustifyH("LEFT")

    if opts.subtitle then
        local subtitle = SW.Label(header, opts.subtitle, 12, C.textDim)
        subtitle:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -4)
        header._subtitle = subtitle
    end

    header._title = title
    header._iconHolder = iconHolder
    return header
end

function SW.CreateTierRow(parent, meta, echoNames, resolveEcho)
    local row = CreateFrame("Frame", nil, parent)
    SW.Fill(row, C.tierRowBg)
    SW.ThinBorder(row, C.border, 1)

    local labelCol = CreateFrame("Frame", nil, row)
    labelCol:SetWidth(L.TIER_LABEL_W)
    labelCol:SetPoint("TOPLEFT", 0, 0)
    labelCol:SetPoint("BOTTOMLEFT", 0, 0)
    local tc = meta.color
    SW.Fill(labelCol, { tc[1], tc[2], tc[3], 0.09 })
    SW.ThinBorder(labelCol, { tc[1], tc[2], tc[3], 0.27 }, 1)
    SW.Label(labelCol, meta.label, 13, tc, false, "semibold"):SetPoint("CENTER")

    local content = CreateFrame("Frame", nil, row)
    content:SetPoint("TOPLEFT", labelCol, "TOPRIGHT", 0, 0)
    content:SetPoint("TOPRIGHT", row, "TOPRIGHT", 0, 0)
    content:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 0, 0)

    SW.Label(content, meta.desc, 10, C.textMuted, false, "medium"):SetPoint("TOPLEFT", 8, -6)

    local iconArea = CreateFrame("Frame", nil, content)
    iconArea:SetPoint("TOPLEFT", 8, -22)
    iconArea:SetPoint("RIGHT", -8, 0)
    row._iconArea = iconArea

    local x, y = 0, 0
    local maxPerRow = math.floor((L.MAIN_W - L.PAD * 2 - L.TIER_LABEL_W - 24) / (L.ICON_TIER + L.ICON_GAP))
    row._iconBtns = {}

    for _, name in ipairs(echoNames or {}) do
        local spellId, quality = nil, 2
        if resolveEcho then spellId, quality = resolveEcho(name) end
        local btn = SW.CreateEchoIcon(iconArea, { name = name, spellId = spellId, quality = quality, size = L.ICON_TIER })
        btn:SetPoint("TOPLEFT", x * (L.ICON_TIER + L.ICON_GAP), -y * (L.ICON_TIER + L.ICON_GAP))
        row._iconBtns[#row._iconBtns + 1] = btn
        x = x + 1
        if x >= maxPerRow then x = 0; y = y + 1 end
    end

    local rows = y + 1
    local iconH = rows * (L.ICON_TIER + L.ICON_GAP)
    local rowH = math.max(52, 22 + iconH + 8)
    row:SetHeight(rowH)
    content:SetHeight(rowH)
    iconArea:SetHeight(iconH)
    return row
end

function SW.CreateSiteNav(parent, opts)
    opts = opts or {}
    local outer = CreateFrame("Frame", opts.name or "EbonholdHubSiteNav", parent or UIParent)
    outer:SetSize(L.WIN_W, L.NAV_H)
    outer:SetMovable(true)
    outer:EnableMouse(true)
    outer:SetClampedToScreen(true)
    outer:SetFrameStrata("DIALOG")
    outer:SetFrameLevel(opts.frameLevel or 10)

    SW.Fill(outer, C.navBg)
    SW.ThinBorder(outer, C.border, 1)

    local accentStripe = outer:CreateTexture(nil, "ARTWORK")
    accentStripe:SetTexture(FLAT)
    accentStripe:SetVertexColor(unpack(C.accent))
    accentStripe:SetWidth(4)
    accentStripe:SetPoint("TOPLEFT", outer, "TOPLEFT", 0, 0)
    accentStripe:SetPoint("BOTTOMLEFT", outer, "BOTTOMLEFT", 0, 0)
    local glowParent = CreateFrame("Frame", nil, outer)
    glowParent:SetPoint("TOPLEFT", 0, 0)
    glowParent:SetPoint("BOTTOMLEFT", 0, 0)
    glowParent:SetWidth(10)
    SW.AttachAccentGlow(glowParent, 10)

    local logoArea = CreateFrame("Frame", nil, outer)
    logoArea:SetPoint("LEFT", accentStripe, "RIGHT", 10, 0)
    logoArea:SetSize(150, L.NAV_H)
    logoArea:EnableMouse(true)
    logoArea:SetScript("OnMouseDown", function() outer:StartMoving() end)
    logoArea:SetScript("OnMouseUp", function() outer:StopMovingOrSizing() end)

    local logoText = logoArea:CreateFontString(nil, "OVERLAY")
    SW.SetFont(logoText, 14, "semibold")
    logoText:SetPoint("LEFT", 0, 0)
    logoText:SetText("|cffebebf0Ebonhold|r|cff4dbff2Hub|r")

    local navRow = CreateFrame("Frame", nil, outer)
    navRow:SetPoint("LEFT", logoArea, "RIGHT", 12, 0)
    navRow:SetPoint("RIGHT", outer, "RIGHT", -280, 0)
    navRow:SetHeight(L.NAV_H)

    local navState = opts.navState or { activeId = "catalog", registry = {} }
    navState.registry = navState.registry or {}
    local prev
    for _, item in ipairs(opts.items or {}) do
        local link = SW.CreateNavLink(navRow, item, navState)
        if prev then link:SetPoint("LEFT", prev, "RIGHT", 2, 0) else link:SetPoint("LEFT", 0, 0) end
        prev = link
        navState.registry[item.id] = link
    end

    if opts.userArea then opts.userArea(outer) end

    if opts.showHideButton ~= false then
        local hideNavBtn = CreateFrame("Button", nil, outer)
        hideNavBtn:SetSize(72, 22)
        hideNavBtn:SetPoint("RIGHT", outer, "RIGHT", -36, 0)
        SW.Fill(hideNavBtn, C.elementBg)
        SW.ThinBorder(hideNavBtn, C.border, 1)
        local hideLabel = SW.Label(hideNavBtn, "Hide menu", 10, C.textMuted)
        hideLabel:SetPoint("CENTER")
        hideNavBtn:SetScript("OnClick", opts.onHide or function() outer:Hide() end)
    end

    if opts.closable ~= false then
        local closeBtn = CreateFrame("Button", nil, outer, "UIPanelCloseButton")
        closeBtn:SetPoint("TOPRIGHT", -2, -2)
        closeBtn:SetScript("OnClick", opts.onClose or function() outer:Hide() end)
    end

    return outer, navState
end

-- ── Preview panels (read-only gear / affix — portable to CatalogPreviewView) ──

local function ItemQualityColor(quality)
    if GetItemQualityColor then
        local r, g, b = GetItemQualityColor(quality or 0)
        if r then return { r, g, b, 1 } end
    end
    return C.text
end

function SW.CreateItemIcon(parent, opts)
    opts = opts or {}
    local size = opts.size or 32
    local AD = EbonholdHub.AffixData
    local filled = opts.filled ~= false and (opts.texture or opts.name or opts.itemId)
    local qColor = filled and ItemQualityColor(opts.quality or 2) or C.borderSoft

    local btn = CreateFrame("Button", nil, parent)
    btn:SetSize(size, size)
    SW.Fill(btn, C.elementBg)
    btn._border = SW.ThinBorder(btn, qColor, filled and 2 or 1)

    local icon = btn:CreateTexture(nil, "ARTWORK")
    icon:SetPoint("TOPLEFT", 2, -2)
    icon:SetPoint("BOTTOMRIGHT", -2, 2)
    icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    if opts.texture then
        icon:SetTexture(opts.texture)
    elseif opts.itemId then
        local _, _, tex = GetItemInfo(opts.itemId)
        if tex then icon:SetTexture(tex) end
    elseif AD and opts.invSlot then
        icon:SetTexture(AD.GetSlotTexture(opts.invSlot))
    else
        icon:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
    end
    if not filled then icon:SetVertexColor(0.55, 0.55, 0.55) end

    if opts.tooltip then
        btn:SetScript("OnEnter", function()
            GameTooltip:SetOwner(btn, "ANCHOR_RIGHT")
            GameTooltip:SetText(opts.tooltip, 1, 1, 1)
            GameTooltip:Show()
        end)
        btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
    end
    return btn
end

function SW.CreateSetChip(parent, text, active, onClick)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetHeight(24)
    btn._bg = SW.Fill(btn, active and C.accentBg12 or C.bgElevated)
    btn._border = SW.ThinBorder(btn, active and C.accentBorder55 or C.border, 1)
    btn._label = SW.Label(btn, text, 12, active and C.accent or C.textDim, false, "medium")
    btn._label:SetPoint("CENTER")
    btn:SetWidth(btn._label:GetStringWidth() + 20)

    btn._isActive = active

    btn.SetActive = function(self, isActive)
        self._isActive = isActive
        self._bg:SetVertexColor(unpack(isActive and C.accentBg12 or C.bgElevated))
        SW.SetBorderColor(self._border, isActive and C.accentBorder55 or C.border)
        self._label:SetTextColor(unpack(isActive and C.accent or C.textDim))
    end

    btn:SetScript("OnClick", function()
        if onClick then onClick(btn) end
    end)
    btn:SetScript("OnEnter", function(self)
        if not self._isActive then
            SW.SetBorderColor(self._border, C.borderHover)
            self._label:SetTextColor(unpack(C.text))
        end
    end)
    btn:SetScript("OnLeave", function(self)
        if not self._isActive then
            SW.SetBorderColor(self._border, C.border)
            self._label:SetTextColor(unpack(C.textDim))
        end
    end)
    return btn
end

function SW.CreateListRow(parent, opts)
    opts = opts or {}
    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(opts.height or 36)

    local sep = row:CreateTexture(nil, "ARTWORK")
    sep:SetTexture(FLAT)
    sep:SetVertexColor(unpack(C.borderSoft))
    sep:SetHeight(1)
    sep:SetPoint("BOTTOMLEFT", 0, 0)
    sep:SetPoint("BOTTOMRIGHT", 0, 0)

    local iconAnchor  -- used as LEFT anchor for slotLabel
    if opts.showIcon ~= false then
        local icon = SW.CreateItemIcon(row, {
            size = opts.iconSize or 32,
            invSlot = opts.invSlot,
            texture = opts.texture,
            itemId = opts.itemId,
            quality = opts.quality,
            name = opts.name,
            filled = opts.filled,
            tooltip = opts.tooltip,
        })
        icon:SetPoint("LEFT", 0, 0)
        row._iconBtn = icon
        iconAnchor = icon
    end

    local slotLabel = SW.Label(row, opts.slotLabel or "", 12, C.textMuted)
    if iconAnchor then
        slotLabel:SetPoint("LEFT", iconAnchor, "RIGHT", 10, 0)
    else
        slotLabel:SetPoint("LEFT", row, "LEFT", 0, 0)
    end
    slotLabel:SetWidth(opts.slotLabelWidth or 72)

    local nameColor = opts.filled and ItemQualityColor(opts.quality or 2) or C.textMuted
    local nameLabel = SW.Label(row, opts.name or "—", 13, nameColor)
    nameLabel:SetPoint("LEFT", slotLabel, "RIGHT", 8, 0)
    local iconOffset = (opts.showIcon ~= false) and 130 or 88
    nameLabel:SetWidth(math.max(80, (opts.nameWidth or 220) - iconOffset))
    nameLabel:SetJustifyH("LEFT")

    if opts.rankRoman then
        local rankBadge = CreateFrame("Frame", nil, row)
        rankBadge:SetSize(28, 18)
        rankBadge:SetPoint("RIGHT", 0, 0)
        SW.Fill(rankBadge, C.rankBadgeBg)
        SW.ThinBorder(rankBadge, C.rankBadgeBorder, 1)
        SW.Label(rankBadge, opts.rankRoman, 11, C.rankBadge, false, "semibold"):SetPoint("CENTER")
    end

    row._iconBtn = icon
    row._nameLabel = nameLabel
    return row
end

function SW.CreateGearDoll(parent, opts)
    opts = opts or {}
    local AD = EbonholdHub.AffixData
    local AO = EbonholdHub.AffixOwnership
    local SLOT_SIZE = opts.slotSize or 36
    local loadout = opts.loadout or {}
    local affixLoadout = opts.affixLoadout or {}
    local dollW, dollH = AD.GetDollSize(SLOT_SIZE)

    local shell = CreateFrame("Frame", nil, parent)
    shell:SetSize(dollW + 16, dollH + 16)
    SW.Fill(shell, C.bg)
    SW.ThinBorder(shell, C.border, 1)

    local inset = CreateFrame("Frame", nil, shell)
    inset:SetPoint("TOPLEFT", 8, -8)
    inset:SetPoint("BOTTOMRIGHT", -8, 8)
    SW.Fill(inset, C.dollInset)

    local holder = EbonholdHub.AffixDoll.Create(inset, { slotSize = SLOT_SIZE, showModel = opts.showModel ~= false })
    holder:SetAllPoints(inset)

    for _, slotDef in ipairs(AD.SLOTS) do
        local entry = loadout[slotDef.key]
        local affix = affixLoadout[slotDef.key]
        local gearFilled = entry and (entry.name or entry.texture or entry.itemId)
        local affixFilled = affix and affix.name
        local btn = CreateFrame("Button", nil, holder.dollFrame)
        btn:SetSize(SLOT_SIZE, SLOT_SIZE)
        SW.Fill(btn, C.elementBg)
        btn._border = SW.ThinBorder(btn, gearFilled and ItemQualityColor(entry.quality or 2) or C.borderSoft, gearFilled and 2 or 1)
        btn.icon = btn:CreateTexture(nil, "ARTWORK")
        btn.icon:SetPoint("TOPLEFT", 2, -2)
        btn.icon:SetPoint("BOTTOMRIGHT", -2, 2)
        btn.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        if entry and entry.texture then
            btn.icon:SetTexture(entry.texture)
        elseif entry and entry.itemId then
            local _, _, tex = GetItemInfo(entry.itemId)
            if tex then btn.icon:SetTexture(tex) end
        else
            btn.icon:SetTexture(AD.GetSlotTexture(slotDef.invSlot))
        end
        if not gearFilled then btn.icon:SetVertexColor(0.55, 0.55, 0.55) end
        if affixFilled then
            local ring = btn:CreateTexture(nil, "OVERLAY")
            ring:SetTexture(FLAT)
            ring:SetVertexColor(C.accent[1], C.accent[2], C.accent[3], 0.35)
            ring:SetPoint("TOPLEFT", 1, -1)
            ring:SetPoint("BOTTOMRIGHT", -1, 1)
            btn._affixRing = ring
            if affix.rank then
                local rankText = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                rankText:SetPoint("CENTER")
                rankText:SetText(AD.RANK_ROMAN[affix.rank] or AD.RankToRoman(affix.rank) or "")
                rankText:SetTextColor(0.94, 0.78, 0.38)
                rankText:SetShadowOffset(1, -1)
                btn._rankText = rankText
            end
        end
        btn:SetScript("OnEnter", function()
            GameTooltip:SetOwner(btn, "ANCHOR_RIGHT")
            GameTooltip:ClearLines()
            GameTooltip:AddLine(slotDef.label, 1, 0.82, 0)
            if affixFilled then
                local label = affix.rank and (affix.name .. " " .. (AD.RANK_ROMAN[affix.rank] or AD.RankToRoman(affix.rank))) or affix.name
                GameTooltip:AddLine(label, 0.7, 0.9, 1)
                if AO then AO.AddTooltipLines(affix) end
            elseif gearFilled and entry.name then
                GameTooltip:AddLine(entry.name, unpack(ItemQualityColor(entry.quality or 2)))
            else
                GameTooltip:AddLine("No affix assigned", 0.5, 0.5, 0.5)
            end
            GameTooltip:Show()
        end)
        btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
        EbonholdHub.AffixDoll.RegisterSlot(holder, slotDef.key, btn)
    end
    holder:Layout()
    return shell, holder
end

function SW.CreateGearPreview(parent, data)
    data = data or {}
    local AD = EbonholdHub.AffixData
    local sets = data.sets or { { name = "BiS", loadout = data.loadout or {} } }
    local activeIndex = data.activeSetIndex or 1

    local panel = CreateFrame("Frame", nil, parent)
    panel:SetAllPoints(parent)

    local scroll, child = SW.CreatePlainScroll(panel)
    child:SetWidth(L.MAIN_W - L.PAD * 2)

    local y = -L.PAD
    local chips = {}
    if #sets > 1 then
        local chipRow = CreateFrame("Frame", nil, child)
        chipRow:SetPoint("TOPLEFT", L.PAD, y)
        chipRow:SetHeight(28)
        local prev
        for i, set in ipairs(sets) do
            local filled = 0
            for _, slot in ipairs(AD.SLOTS) do
                local e = set.loadout and set.loadout[slot.key]
                if e and (e.name or e.itemId) then filled = filled + 1 end
            end
            local chip = SW.CreateSetChip(chipRow, (set.name or ("Set " .. i)) .. " (" .. filled .. ")", i == activeIndex, function()
                panel._activeSet = i
                if panel.Refresh then panel:Refresh() end
            end)
            if prev then chip:SetPoint("LEFT", prev, "RIGHT", 6, 0) else chip:SetPoint("LEFT", 0, 0) end
            prev = chip
            chips[i] = chip
        end
        chipRow:SetWidth(prev and (prev:GetLeft() and math.abs(prev:GetLeft()) + prev:GetWidth()) or 200)
        y = y - 36
    end

    local body = CreateFrame("Frame", nil, child)
    body:SetPoint("TOPLEFT", L.PAD, y)
    body:SetWidth(L.MAIN_W - L.PAD * 2)
    body:SetHeight(420)
    panel._body = body
    panel._child = child
    panel._chips = chips
    panel._sets = sets
    panel._activeSet = activeIndex

    function panel.Refresh()
        for i, chip in ipairs(panel._chips) do
            chip:SetActive(i == panel._activeSet)
        end
        if panel._doll then panel._doll:Hide(); panel._doll:SetParent(nil); panel._doll = nil end
        if panel._listScroll then panel._listScroll:Hide(); panel._listScroll:SetParent(nil); panel._listScroll = nil end

        local set = panel._sets[panel._activeSet] or panel._sets[1]
        local loadout = set and set.loadout or {}

        panel._doll = SW.CreateGearDoll(panel._body, { loadout = loadout, showModel = data.showModel })
        panel._doll:SetPoint("TOPLEFT", 0, 0)

        local listW = panel._body:GetWidth() - panel._doll:GetWidth() - 16
        panel._listScroll, panel._listChild = SW.CreatePlainScroll(panel._body)
        panel._listScroll:SetPoint("TOPLEFT", panel._doll, "TOPRIGHT", 16, 0)
        panel._listScroll:SetSize(listW, 400)
        panel._listChild:SetWidth(listW)

        local rowY = 0
        for _, slot in ipairs(AD.SLOTS) do
            local entry = loadout[slot.key]
            local filled = entry and (entry.name or entry.itemId)
            local row = SW.CreateListRow(panel._listChild, {
                slotLabel = slot.label,
                invSlot = slot.invSlot,
                name = filled and entry.name or "—",
                quality = entry and entry.quality,
                texture = entry and entry.texture,
                itemId = entry and entry.itemId,
                filled = filled,
                nameWidth = listW,
            })
            row:SetPoint("TOPLEFT", 0, -rowY)
            row:SetPoint("RIGHT", 0, 0)
            rowY = rowY + row:GetHeight()
        end
        panel._listChild:SetHeight(rowY)
        child:SetHeight(math.abs(y) + 420 + L.PAD)
    end

    panel:Refresh()
    return panel
end

function SW.CreateAffixPreview(parent, data)
    data = data or {}
    local UI = EbonholdHub.UI
    local AD = EbonholdHub.AffixData
    local AO = EbonholdHub.AffixOwnership
    local slotSize = data.slotSize or 40

    local panel = CreateFrame("Frame", nil, parent)
    panel:SetAllPoints(parent)

    local body = CreateFrame("Frame", nil, panel)
    body:SetPoint("TOPLEFT", L.PAD, -L.PAD)
    body:SetPoint("BOTTOMRIGHT", -L.PAD, L.PAD)
    panel._body = body
    panel._showModel = data.showModel ~= false
    panel._slotSize = slotSize
    panel._affixLoadout = data.affixLoadout or {}
    panel._gearLoadout = data.gearLoadout or {}
    panel._affixSlots = {}
    panel._dollBuilt = false

    local function RefreshAffixSlotButton(btn, slotDef, entry, snapshot)
        if entry and entry.name then
            btn.icon:SetTexture(AD.GetIcon(entry))
            btn.icon:SetVertexColor(1, 1, 1)
            if entry.rank then
                btn._rankText:SetText(AD.RankToRoman(entry.rank))
                btn._rankText:Show()
            else
                btn._rankText:Hide()
            end
            btn._empty:Hide()
            if AO then AO.ApplySlotBorder(btn, entry, snapshot) end
        else
            btn.icon:SetTexture(AD.GetSlotTexture(slotDef.invSlot))
            btn.icon:SetVertexColor(0.55, 0.55, 0.55)
            btn._rankText:Hide()
            btn._empty:Show()
            if AO then AO.ApplySlotBorder(btn, nil) end
        end
    end

    local function WireAffixSlotTooltip(btn, slotDef)
        btn:SetScript("OnEnter", function()
            GameTooltip:SetOwner(btn, "ANCHOR_RIGHT")
            GameTooltip:ClearLines()
            GameTooltip:AddLine(slotDef.label, 1, 0.82, 0)
            local entry = panel._affixLoadout and panel._affixLoadout[slotDef.key]
            if entry and entry.name then
                local label = entry.rank and (entry.name .. " " .. AD.RankToRoman(entry.rank)) or entry.name
                GameTooltip:AddLine(label, 0.7, 0.9, 1)
                if AO then AO.AddTooltipLines(entry) end
            else
                GameTooltip:AddLine("No affix assigned", 0.5, 0.5, 0.5)
            end
            GameTooltip:Show()
        end)
        btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
    end

    local function EnsureDoll()
        if panel._dollBuilt then return end
        local dollW, dollH = AD.GetDollSize(panel._slotSize)

        panel._doll = CreateFrame("Frame", nil, panel._body)
        panel._doll:SetSize(dollW + 16, dollH + 16)
        SW.Fill(panel._doll, C.bg)
        SW.ThinBorder(panel._doll, C.border, 1)
        panel._doll:SetPoint("TOPLEFT", 0, 0)

        local inset = CreateFrame("Frame", nil, panel._doll)
        inset:SetPoint("TOPLEFT", 8, -8)
        inset:SetPoint("BOTTOMRIGHT", -8, 8)
        SW.Fill(inset, C.dollInset)

        panel._dollHolder = EbonholdHub.AffixDoll.Create(inset, {
            slotSize = panel._slotSize,
            showModel = panel._showModel,
        })
        panel._dollHolder:SetAllPoints(inset)

        for _, slotDef in ipairs(AD.SLOTS) do
            local btn = UI.CreateIconButton(panel._dollHolder.dollFrame, panel._slotSize)
            local empty = btn:CreateTexture(nil, "OVERLAY")
            empty:SetAllPoints(btn.icon)
            empty:SetTexture(0, 0, 0, 0.25)
            btn._empty = empty
            local rankText = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            rankText:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -1, 1)
            rankText:SetTextColor(1, 0.9, 0.4)
            btn._rankText = rankText
            WireAffixSlotTooltip(btn, slotDef)
            panel._affixSlots[slotDef.key] = btn
            EbonholdHub.AffixDoll.RegisterSlot(panel._dollHolder, slotDef.key, btn)
        end
        panel._dollHolder:Layout()
        panel._dollBuilt = true
    end

    local function RebuildList(snapshot)
        if panel._listScroll then
            panel._listScroll:Hide()
            panel._listScroll:SetParent(nil)
            panel._listScroll = nil
            panel._listChild = nil
        end

        EnsureDoll()

        local bodyW = panel._body:GetWidth() or (L.MAIN_W - L.PAD * 2)
        local bodyH = panel._body:GetHeight() or 420
        local listW = math.max(200, bodyW - panel._doll:GetWidth() - 16)
        local listH = math.max(280, bodyH)

        panel._listScroll, panel._listChild = SW.CreatePlainScroll(panel._body)
        panel._listScroll:SetPoint("TOPLEFT", panel._doll, "TOPRIGHT", 16, 0)
        panel._listScroll:SetSize(listW, listH)
        panel._listChild:SetWidth(listW)

        local rowY = 0
        for _, slot in ipairs(AD.SLOTS) do
            local affix = panel._affixLoadout[slot.key]
            local gear = panel._gearLoadout[slot.key]
            local affixFilled = affix and affix.name
            local rankRoman = affix and affix.rank and (AD.RANK_ROMAN[affix.rank] or AD.RankToRoman(affix.rank))
            local row = SW.CreateListRow(panel._listChild, {
                slotLabel = slot.label,
                invSlot = slot.invSlot,
                name = affixFilled and affix.name or "—",
                quality = gear and gear.quality,
                texture = gear and gear.texture,
                itemId = gear and gear.itemId,
                filled = gear and (gear.name or gear.itemId),
                rankRoman = rankRoman,
                nameWidth = listW - 36,
            })
            row:SetPoint("TOPLEFT", 0, -rowY)
            row:SetPoint("RIGHT", 0, 0)
            row:SetAlpha(affixFilled and 1 or 0.45)
            row:SetScript("OnEnter", function()
                GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
                GameTooltip:ClearLines()
                GameTooltip:AddLine(slot.label, 1, 0.82, 0)
                if affixFilled then
                    local label = affix.rank and (affix.name .. " " .. (AD.RANK_ROMAN[affix.rank] or AD.RankToRoman(affix.rank))) or affix.name
                    GameTooltip:AddLine(label, 0.7, 0.9, 1)
                    if AO then AO.AddTooltipLines(affix, snapshot) end
                else
                    GameTooltip:AddLine("No affix assigned", 0.5, 0.5, 0.5)
                end
                GameTooltip:Show()
            end)
            row:SetScript("OnLeave", function() GameTooltip:Hide() end)
            rowY = rowY + row:GetHeight()
        end
        panel._listChild:SetHeight(math.max(1, rowY))
    end

    function panel.Refresh(affixLoadout, gearLoadout)
        if affixLoadout then panel._affixLoadout = affixLoadout end
        if gearLoadout then panel._gearLoadout = gearLoadout end

        local snapshot = AO and AO.CollectSnapshot() or nil
        EnsureDoll()

        for _, slotDef in ipairs(AD.SLOTS) do
            local entry = panel._affixLoadout[slotDef.key]
            local btn = panel._affixSlots[slotDef.key]
            if btn then RefreshAffixSlotButton(btn, slotDef, entry, snapshot) end
        end

        RebuildList(snapshot)

        if panel._dollHolder then
            EbonholdHub.AffixDoll.Refresh(panel._dollHolder)
        end
    end

    panel:Refresh()
    if C_Timer then
        C_Timer.After(0, function()
            if panel.Refresh then panel:Refresh() end
        end)
    end
    return panel
end
