EbonholdHub.BuildOverview = {}

local UI = EbonholdHub.UI
local C  = UI.Colors
local ST = EbonholdHub.SiteTheme
local SW = EbonholdHub.SiteWidgets
local SC = ST and ST.C
local SL = ST and ST.Layout
local SIDEBAR_W = SL and SL.SIDEBAR_W or 280
local AD = EbonholdHub.AffixData
local AL = EbonholdHub.AffixLoadout
local AO = EbonholdHub.AffixOwnership
local EL = EbonholdHub.EquipmentLoadout
local IC = EbonholdHub.ItemCatalog
local T  = EbonholdHub.Talents
local TD = EbonholdHub.TierData
local SCORING = EbonholdHub.Scoring
local EO = EbonholdHub.EchoOwnership

local HEADER_PAD = 8
local GAP = 8
local LEFT_PANEL_WIDTH = 340
local SLOT_SIZE = 40
local AFFIX_LIST_GAP = 12
local ACTION_ROW_H = 28
local FOOTER_HEIGHT = 36
local SITE_HEADER_H = 1
local SITE_HEADER_STATS_H = 88
local SITE_HEADER_ROW_STEP = 44
local SITE_HEADER_GAP_SM = 6
local SITE_HEADER_GAP = 8
local SITE_HEADER_COPY_H = 28
local SITE_HEADER_BOTTOM_PAD = 8
local SITE_LOCKED_H = 80
local SITE_BUILDS_H = 180
local SITE_DESC_H   = 120
local SITE_TAB_H    = 36
local RIGHT_COL_WIDTH = 340
local LOCKED_PANEL_HEIGHT = 72
local LOCKED_SLOT_SIZE = 32
local LOCKED_SLOT_STEP = 36
local ECHOES_PANEL_HEIGHT = 160
local ECHO_STATS_H = 44
local ECHO_STATS_GAP = 10
local TALENT_ICON_SIZE = 32
local TALENT_CELL_STEP = 40
local TALENT_TREE_PAD = 8
local TALENT_SECTION_GAP = 8

local ECHO_TAB_TIER_META = {
    { id = "S", label = "S", color = { 1.0,  0.82, 0.0  } },
    { id = "A", label = "A", color = { 0.63, 0.28, 0.94 } },
    { id = "B", label = "B", color = { 0.2,  0.5,  1.0  } },
    { id = "F", label = "F", color = { 0.5,  0.2,  0.2  } },
}

local CLASS_COLORS = {
    WARRIOR     = { 0.78, 0.61, 0.43 },
    PALADIN     = { 0.96, 0.55, 0.73 },
    HUNTER      = { 0.67, 0.83, 0.45 },
    ROGUE       = { 1.0,  0.96, 0.41 },
    PRIEST      = { 1.0,  1.0,  1.0  },
    DEATHKNIGHT = { 0.77, 0.12, 0.23 },
    SHAMAN      = { 0.0,  0.44, 0.87 },
    MAGE        = { 0.41, 0.8,  0.94 },
    WARLOCK     = { 0.58, 0.51, 0.79 },
    DRUID       = { 1.0,  0.49, 0.04 },
}

local QUALITY_BORDER_COLORS = {
    [0] = { 1.0, 1.0, 1.0 },
    [1] = { 30/255, 1.0, 0.0 },
    [2] = { 0.0, 112/255, 221/255 },
    [3] = { 163/255, 53/255, 238/255 },
    [4] = { 1.0, 128/255, 0.0 },
}

local QUALITY_LABELS = {
    [0] = "Common", [1] = "Uncommon", [2] = "Rare", [3] = "Epic", [4] = "Legendary",
}

local viewFrame
local tabBar
local siteTabState
local CollectBuildTierEchoes
local state = { build = nil }
local overviewOuter
local overviewBuildHeader
local overviewDescLabel, overviewDescMeasure, overviewDescScroll, overviewDescChild, overviewDescScrollHolder

local gearSetStrip
local affixCountLabel
local previewTalentPanels = {}
local previewTalentButtons = {}
local talentsTabPanel, gearTabPanel
local switchTalents, switchGear

local RefreshAffixSlots
local RefreshAffixCount
local RefreshGearSlots
local RefreshGearList
local RefreshTalentsPreview
local echoBestByName

local function GetEchoBestByName()
    if not echoBestByName and EbonholdHub.EchoTableRows and EbonholdHub.EchoTableRows.BuildBestByName then
        echoBestByName = EbonholdHub.EchoTableRows.BuildBestByName()
    end
    return echoBestByName
end


StaticPopupDialogs["EBONHOLDHUB_DELETE_BUILD"] = {
    text = "",
    button1 = "Delete",
    button2 = "Cancel",
    OnAccept = function()
        local build = state.build
        if not build or not build.id then return end
        local id = build.id
        EbonholdHub.Build.Delete(id)
        if EbonholdHub.BuildList and EbonholdHub.BuildList.Refresh then
            EbonholdHub.BuildList.Refresh()
        end
        local builds = EbonholdHub.Build.List()
        if #builds > 0 then
            EbonholdHub.Build.SetActive(builds[1].id)
            EbonholdHub.ViewRouter.Show("buildOverview", { build = builds[1] })
        else
            EbonholdHub.ViewRouter.Show("welcome")
        end
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
}


local function 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

local function CreateIconButton(parent, size)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetWidth(size)
    btn:SetHeight(size)
    local icon = btn:CreateTexture(nil, "ARTWORK")
    icon:SetAllPoints(btn)
    icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    btn._icon = icon
    return btn
end

local AUTOMATION_ICON   = "Interface\\Icons\\Trade_Engineering"
local AUTO_NEED_ICON    = "Interface\\Icons\\INV_Misc_Dice_02"
local AUTOMATION_ON_BG  = { 0.10, 0.24, 0.18, 1.00 }
local AUTOMATION_ON_TXT = { 0.35, 0.95, 0.55, 1.00 }

local function SetButtonBorderColor(btn, color)
    if not btn or not btn._border then return end
    if btn._border.top then
        btn._border.top:SetVertexColor(unpack(color))
        btn._border.bottom:SetVertexColor(unpack(color))
        btn._border.left:SetVertexColor(unpack(color))
        btn._border.right:SetVertexColor(unpack(color))
    else
        SW.SetBorderColor(btn._border, color)
    end
end

local function BtnLabel(btn)
    return btn._label or btn.label
end

local function StyleAutomationToggle(btn, enabled)
    if not btn then return end
    btn._automationEnabled = enabled and true or false
    local label = BtnLabel(btn)

    if enabled then
        btn._bg:SetVertexColor(unpack(AUTOMATION_ON_BG))
        SetButtonBorderColor(btn, SC and SC.accent or C.accent)
        if label then
            label:SetText("Auto Pick: ON")
            label:SetTextColor(unpack(AUTOMATION_ON_TXT))
        end
        if btn._icon then btn._icon:SetVertexColor(0.45, 1, 0.65, 1) end
    else
        btn._bg:SetVertexColor(unpack(SC and SC.elementBg or C.elementBg))
        SetButtonBorderColor(btn, SC and SC.border or C.border)
        if label then
            label:SetText("Auto Pick: OFF")
            label:SetTextColor(unpack(SC and SC.textDim or C.textDim))
        end
        if btn._icon then btn._icon:SetVertexColor(0.45, 0.45, 0.50, 1) end
    end
end

local function StyleAutoNeedToggle(btn, enabled)
    if not btn then return end
    btn._autoNeedEnabled = enabled and true or false
    local label = BtnLabel(btn)

    if enabled then
        btn._bg:SetVertexColor(unpack(AUTOMATION_ON_BG))
        SetButtonBorderColor(btn, SC and SC.accent or C.accent)
        if label then
            label:SetText("Auto-Need: ON")
            label:SetTextColor(unpack(AUTOMATION_ON_TXT))
        end
        if btn._icon then btn._icon:SetVertexColor(0.45, 1, 0.65, 1) end
    else
        btn._bg:SetVertexColor(unpack(SC and SC.elementBg or C.elementBg))
        SetButtonBorderColor(btn, SC and SC.border or C.border)
        if label then
            label:SetText("Auto-Need: OFF")
            label:SetTextColor(unpack(SC and SC.textDim or C.textDim))
        end
        if btn._icon then btn._icon:SetVertexColor(0.45, 0.45, 0.50, 1) end
    end
end

local function WireAutomationToggle(btn)
    btn:SetScript("OnEnter", function(self)
        self._bg:SetVertexColor(unpack(C.elementHov))
        SetButtonBorderColor(self, C.accent)
        GameTooltip:SetOwner(self, "ANCHOR_TOP")
        GameTooltip:ClearLines()
        GameTooltip:AddLine("Auto Pick — Echo Automation", 1, 0.82, 0)
        GameTooltip:AddLine(" ", 1, 1, 1)
        GameTooltip:AddLine("When the game offers three echo choices,", 1, 1, 1, true)
        GameTooltip:AddLine("the addon automatically banishes, rerolls,", 1, 1, 1, true)
        GameTooltip:AddLine("freezes and picks based on your tier list.", 1, 1, 1, true)
        GameTooltip:AddLine(" ", 1, 1, 1)
        GameTooltip:AddLine("Configure tiers and auto pick style in the Echoes", 0.7, 0.7, 0.7, true)
        GameTooltip:AddLine("and Auto Pick tabs when editing the build.", 0.7, 0.7, 0.7, true)
        GameTooltip:Show()
    end)
    btn:SetScript("OnLeave", function(self)
        GameTooltip:Hide()
        StyleAutomationToggle(self, self._automationEnabled)
    end)
end

local function WireAutoNeedToggle(btn)
    btn:SetScript("OnEnter", function(self)
        self._bg:SetVertexColor(unpack(C.elementHov))
        SetButtonBorderColor(self, C.accent)
        GameTooltip:SetOwner(self, "ANCHOR_TOP")
        GameTooltip:ClearLines()
        GameTooltip:AddLine("Auto-Need — BiS Loot Roll", 1, 0.82, 0)
        GameTooltip:AddLine(" ", 1, 1, 1)
        GameTooltip:AddLine("When a group loot roll starts, checks if the", 1, 1, 1, true)
        GameTooltip:AddLine("item is in your Best Gear list. If yes —", 1, 1, 1, true)
        GameTooltip:AddLine("automatically rolls Need.", 1, 1, 1, true)
        GameTooltip:AddLine(" ", 1, 1, 1)
        GameTooltip:AddLine("Only rolls if your class can equip the item.", 0.7, 0.7, 0.7, true)
        GameTooltip:AddLine("A chat message confirms every auto-roll.", 0.7, 0.7, 0.7, true)
        GameTooltip:Show()
    end)
    btn:SetScript("OnLeave", function(self)
        GameTooltip:Hide()
        StyleAutoNeedToggle(self, self._autoNeedEnabled)
    end)
end


local function PreviewClassMatches(build)
    if not build or not build.class then return false end
    local _, playerClass = UnitClass("player")
    return playerClass and build.class == playerClass
end

local echoesTabPanel
local logbookTabPanel
local RefreshEchoesTab

local function SafeDesaturate(tex, on)
    if tex and tex.SetDesaturated then
        tex:SetDesaturated(on)
    end
end

local function GetAccountEchoCount(name)
    if not name or name == "" then return 0 end
    if not ProjectEbonholdDB or type(ProjectEbonholdDB.cachedPerkCounts) ~= "table" then return 0 end
    local count = ProjectEbonholdDB.cachedPerkCounts[name]
    if type(count) == "number" then return count end
    if EO and EO.NormalizeName then
        local norm = EO.NormalizeName(name)
        for key, val in pairs(ProjectEbonholdDB.cachedPerkCounts) do
            if type(key) == "string" and EO.NormalizeName(key) == norm and type(val) == "number" then
                return val
            end
        end
    end
    return 0
end

local function FormatEchoPickBadge(runCount, accountCount, maxPicks, owned)
    runCount = runCount or 0
    accountCount = accountCount or 0
    maxPicks = maxPicks or 1
    if runCount > 0 then
        if maxPicks == 0 then
            return "×" .. runCount, owned and SC.success or SC.accent
        end
        return runCount .. "/" .. maxPicks,
            (runCount >= maxPicks) and SC.accent or (owned and SC.success or SC.text)
    end
    if owned and accountCount > 0 then
        return (accountCount > 1) and ("×" .. accountCount) or "×1", SC.success
    end
    if maxPicks == 0 then
        return "×0", SC.textMuted
    end
    return "0/" .. maxPicks, SC.textMuted
end

local function BuildEchoOwnershipContext()
    if SCORING and SCORING.SyncRunEchoesFromGranted then
        SCORING.SyncRunEchoesFromGranted()
    end
    local ownedLower, ownedGroups, ownedSpellIds = {}, {}, {}
    if EO and EO.GetOverviewOwnedSets then
        ownedLower, ownedGroups, ownedSpellIds = EO.GetOverviewOwnedSets()
    end
    return {
        byName = GetEchoBestByName(),
        ownedLower = ownedLower,
        ownedGroups = ownedGroups,
        ownedSpellIds = ownedSpellIds,
    }
end

local function IsEchoOwnedInContext(ctx, echo)
    if not echo or not echo.name then return false end
    if echo.spellId and ctx.ownedSpellIds[echo.spellId] then return true end
    local _, keyQuality = TD and TD.ParseTierKey and TD.ParseTierKey(echo.key or "")
    if keyQuality ~= nil then return false end
    local entry = ctx.byName and ctx.byName[echo.name]
    if EO and EO.IsOwnedFromSets then
        return EO.IsOwnedFromSets(echo.name, entry, ctx.ownedLower, ctx.ownedGroups, ctx.ownedSpellIds)
    end
    return false
end

local function GetEchoRunCount(echo)
    if SCORING and SCORING.GetGrantedCount then
        return SCORING.GetGrantedCount(echo.name, echo.spellId, echo.quality)
    end
    return 0
end

local function CountGuideEchoTotal(assigned)
    local guideTotal = 0
    if not assigned then return guideTotal end
    for _, list in pairs(assigned) do
        guideTotal = guideTotal + #list
    end
    return guideTotal
end

local function LayoutEchoStatsBar(bar)
    if not bar or not bar._cells then return end
    local w = bar:GetWidth()
    if not w or w <= 12 then return end
    local cell = bar._cells[1]
    if not cell then return end
    cell:SetWidth(w)
    cell:ClearAllPoints()
    cell:SetPoint("TOPLEFT", bar, "TOPLEFT", 0, 0)
end

local function RefreshEchoStatsBar(bar, assigned)
    if not bar or not bar._statLabels then return end
    local labels = bar._statLabels
    local guideTotal = CountGuideEchoTotal(assigned)

    if labels.guide then
        labels.guide:SetText(tostring(guideTotal))
        labels.guide:SetTextColor(unpack(SC.text))
    end
end

local function ApplyTierRowOwnership(row, echoes, ctx)
    if not row or not echoes or not ctx then return end
    local btns = row._iconBtns
    if not btns or #btns == 0 then return end

    for idx, echo in ipairs(echoes) do
        local btn = btns[idx]
        if btn and btn.icon and echo then
            local owned = IsEchoOwnedInContext(ctx, echo)
            local runCount = GetEchoRunCount(echo)
            local accountCount = GetAccountEchoCount(echo.name)
            local maxPicks = TD and TD.GetMaxPicks and TD.GetMaxPicks(echo.name) or 1
            if btn._countLabel then
                local badgeText, badgeColor = FormatEchoPickBadge(runCount, accountCount, maxPicks, owned)
                btn._countLabel:SetText(badgeText)
                btn._countLabel:SetTextColor(unpack(badgeColor))
                if btn._countBadge then
                    local w = math.max(16, btn._countLabel:GetStringWidth() + 8)
                    btn._countBadge:SetWidth(w)
                end
            end
            btn._tipRunCount = runCount
            btn._tipAccountCount = accountCount
            btn._tipMaxPicks = maxPicks
            btn._tipOwned = owned
            btn._tipEcho = echo
            if owned then
                SafeDesaturate(btn.icon, false)
                btn.icon:SetVertexColor(1, 1, 1, 1)
                btn:SetAlpha(1)
                if btn._glow and not btn._glow:IsShown() then btn._glow:Show() end
                if btn._glow then btn._glow:SetAlpha(0.5) end
                if btn._border then SW.SetBorderColor(btn._border, SC.success) end
                if btn._needOverlay then btn._needOverlay:Hide() end
                if btn._ownedMark then
                    btn._ownedMark:Show()
                else
                    local mark = btn:CreateTexture(nil, "OVERLAY")
                    mark:SetSize(10, 10)
                    mark:SetPoint("TOPRIGHT", -1, -1)
                    mark:SetTexture(ST.FLAT)
                    mark:SetVertexColor(SC.success[1], SC.success[2], SC.success[3], 0.95)
                    btn._ownedMark = mark
                end
            else
                SafeDesaturate(btn.icon, true)
                btn.icon:SetVertexColor(0.45, 0.45, 0.45, 1)
                btn:SetAlpha(0.72)
                if btn._glow then btn._glow:SetAlpha(0.12) end
                if btn._border then SW.SetBorderColor(btn._border, SC.borderSoft) end
                if btn._ownedMark then btn._ownedMark:Hide() end
                if not btn._needOverlay then
                    local overlay = btn:CreateTexture(nil, "OVERLAY")
                    overlay:SetPoint("TOPLEFT", btn.icon, "TOPLEFT", 0, 0)
                    overlay:SetPoint("BOTTOMRIGHT", btn.icon, "BOTTOMRIGHT", 0, 0)
                    overlay:SetTexture(ST.FLAT)
                    btn._needOverlay = overlay
                end
                btn._needOverlay:SetVertexColor(0, 0, 0, 0.42)
                btn._needOverlay:Show()
            end
            btn:SetScript("OnEnter", function(self)
                GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                GameTooltip:ClearLines()
                local tipEcho = self._tipEcho
                local spellName = tipEcho and (tipEcho.name or (tipEcho.spellId and GetSpellInfo(tipEcho.spellId)))
                if spellName then GameTooltip:AddLine(spellName, 1, 0.82, 0) end
                if utils and utils.GetSpellDescription and tipEcho and tipEcho.spellId then
                    local desc = utils.GetSpellDescription(tipEcho.spellId, 500, 1)
                    if desc and desc ~= "" then GameTooltip:AddLine(desc, 1, 1, 1, true) end
                end
                GameTooltip:AddLine(" ")
                GameTooltip:AddLine(string.format("This run: %d", self._tipRunCount or 0), 0.8, 0.8, 0.8)
                if (self._tipAccountCount or 0) > 0 then
                    GameTooltip:AddLine(string.format("Account book: %d", self._tipAccountCount), 0.8, 0.8, 0.8)
                end
                local tipMax = self._tipMaxPicks or 1
                if tipMax == 0 then
                    GameTooltip:AddLine("Max picks: unlimited", 0.6, 0.6, 0.6)
                elseif tipMax ~= 1 then
                    GameTooltip:AddLine(string.format("Max picks: %d", tipMax), 0.6, 0.6, 0.6)
                end
                GameTooltip:AddLine(" ")
                if self._tipOwned then
                    GameTooltip:AddLine("In your collection", 0.2, 1, 0.2)
                else
                    GameTooltip:AddLine("Not in your collection", 1, 0.45, 0.45)
                end
                GameTooltip:Show()
            end)
            btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
        end
    end
end

local function WireAffixTooltip(btn, slotDef)
    btn:SetScript("OnEnter", function()
        GameTooltip:SetOwner(btn, "ANCHOR_RIGHT")
        GameTooltip:ClearLines()
        GameTooltip:AddLine(slotDef.label, 1, 0.82, 0)
        local loadout = state.build and state.build.affixLoadout
        local entry = loadout and loadout[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 CreateAffixSlotButton(dollHolder, slotDef)
    local btn = UI.CreateIconButton(dollHolder.dollFrame, SLOT_SIZE)
    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
    WireAffixTooltip(btn, slotDef)
    btn:EnableMouse(true)
    EbonholdHub.AffixDoll.RegisterSlot(dollHolder, slotDef.key, btn)
    return btn
end

local function WireGearTooltip(btn, slotDef)
    btn:SetScript("OnEnter", function()
        GameTooltip:SetOwner(btn, "ANCHOR_RIGHT")
        GameTooltip:ClearLines()
        GameTooltip:AddLine(slotDef.label, 1, 0.82, 0)
        local loadout = state.build and EL.GetLoadoutForBuild(state.build)
        local entry = loadout and loadout[slotDef.key]
        if entry and entry.itemId then
            if IC then IC.EnrichEntry(entry) end
            local hyperlink = IC and IC.GetItemHyperlink(entry) or ("item:" .. entry.itemId)
            GameTooltip:SetHyperlink(hyperlink)
        else
            GameTooltip:AddLine("No item assigned", 0.5, 0.5, 0.5)
        end
        GameTooltip:Show()
    end)
    btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
end

local function CreateGearSlotButton(dollHolder, slotDef)
    local btn = UI.CreateIconButton(dollHolder.dollFrame, SLOT_SIZE)
    local empty = btn:CreateTexture(nil, "OVERLAY")
    empty:SetAllPoints(btn.icon)
    empty:SetTexture(0, 0, 0, 0.25)
    btn._empty = empty
    WireGearTooltip(btn, slotDef)
    btn:EnableMouse(true)
    EbonholdHub.AffixDoll.RegisterSlot(dollHolder, slotDef.key, btn)
    return btn
end

RefreshAffixCount = function()
    if not affixCountLabel or not state.build then return end
    local loadout = state.build.affixLoadout or {}
    local filled, have, need
    if AO then
        filled, have, need = AO.CountLoadout(loadout)
    else
        filled = AL.CountFilled(loadout)
    end
    if AO then
        affixCountLabel:SetText(string.format("%d need, %d have — %d / %d slots", need, have, filled, #AD.SLOTS))
    else
        affixCountLabel:SetText(string.format("%d / %d slots", filled, #AD.SLOTS))
    end
end

local RefreshAffixList

RefreshAffixSlots = function()
    if not viewFrame or not viewFrame._affixSlots or not state.build then return end
    local loadout = state.build.affixLoadout or {}
    local snapshot = AO and AO.CollectSnapshot() or nil
    for _, slotDef in ipairs(AD.SLOTS) do
        local btn = viewFrame._affixSlots[slotDef.key]
        if btn then
            local entry = loadout[slotDef.key]
            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
    end
    if overviewOuter then RefreshAffixList() end
    if viewFrame._dollHolder then
        EbonholdHub.AffixDoll.Refresh(viewFrame._dollHolder)
    end
    RefreshAffixCount()
end

RefreshAffixList = function()
    if not overviewOuter or not overviewOuter._affixListChild or not state.build then return end
    local affixLoadout = state.build.affixLoadout or {}
    local snapshot = AO and AO.CollectSnapshot() or nil
    local listChild = overviewOuter._affixListChild
    local listW = overviewOuter._affixListWidth or 200
    if overviewOuter._affixListScroll then
        local scrollW = overviewOuter._affixListScroll:GetWidth()
        if scrollW and scrollW > 0 then listW = scrollW end
    end

    listChild:SetWidth(listW)

    if listChild._rows then
        for _, row in ipairs(listChild._rows) do
            row:Hide()
            row:SetParent(nil)
        end
    end
    listChild._rows = {}

    local rowY = 0
    for _, slot in ipairs(AD.SLOTS) do
        local affix = affixLoadout[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(listChild, {
            showIcon       = false,
            slotLabel      = slot.label,
            name           = affixFilled and affix.name or "—",
            rankRoman      = rankRoman,
            height         = 28,
            slotLabelWidth = 60,
            nameWidth      = listW,
        })
        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)
        listChild._rows[#listChild._rows + 1] = row
        rowY = rowY + row:GetHeight()
    end
    listChild:SetHeight(math.max(1, rowY))
    if overviewOuter._affixListScroll and overviewOuter._affixListScroll.UpdateThumb then
        overviewOuter._affixListScroll.UpdateThumb()
    end
end

RefreshGearList = function()
    if not gearTabPanel or not gearTabPanel._gearListChild or not state.build then return end
    local loadout = EL.GetLoadoutForBuild(state.build) or {}
    local listChild = gearTabPanel._gearListChild
    local listW = gearTabPanel._gearListWidth or 200
    if gearTabPanel._gearListScroll then
        local scrollW = gearTabPanel._gearListScroll:GetWidth()
        if scrollW and scrollW > 0 then listW = scrollW end
    end

    listChild:SetWidth(listW)

    if listChild._rows then
        for _, row in ipairs(listChild._rows) do
            row:Hide()
            row:SetParent(nil)
        end
    end
    listChild._rows = {}

    local rowY = 0
    for _, slot in ipairs(AD.SLOTS) do
        local entry = loadout[slot.key]
        if entry and IC then IC.EnrichEntry(entry) end
        local filled = entry and (entry.name or entry.itemId)
        local row = SW.CreateListRow(listChild, {
            slotLabel = slot.label,
            invSlot = slot.invSlot,
            name = filled and (entry.name or ("Item " .. tostring(entry.itemId))) or "—",
            quality = entry and entry.quality,
            texture = entry and entry.texture,
            itemId = entry and entry.itemId,
            filled = filled,
            iconSize = 24,
            height = 32,
            slotLabelWidth = 52,
            nameWidth = listW,
        })
        row:SetPoint("TOPLEFT", 0, -rowY)
        row:SetPoint("RIGHT", 0, 0)
        row:SetAlpha(filled 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 entry and entry.itemId then
                local hyperlink = IC and IC.GetItemHyperlink(entry) or ("item:" .. entry.itemId)
                GameTooltip:SetHyperlink(hyperlink)
            else
                GameTooltip:AddLine("No item assigned", 0.5, 0.5, 0.5)
            end
            GameTooltip:Show()
        end)
        row:SetScript("OnLeave", function() GameTooltip:Hide() end)
        listChild._rows[#listChild._rows + 1] = row
        rowY = rowY + row:GetHeight()
    end
    listChild:SetHeight(math.max(1, rowY))
end

RefreshGearSlots = function()
    if not viewFrame or not viewFrame._gearSlots or not state.build then return end
    local loadout = EL.GetLoadoutForBuild(state.build)
    local toEnrich = {}
    for _, slotDef in ipairs(AD.SLOTS) do
        local btn = viewFrame._gearSlots[slotDef.key]
        if btn then
            local entry = loadout[slotDef.key]
            if entry and entry.name then
                if IC then IC.EnrichEntry(entry) end
                toEnrich[#toEnrich + 1] = entry
                local tex = EL.GetItemIcon(entry)
                if tex then
                    btn.icon:SetTexture(tex)
                    btn.icon:SetVertexColor(1, 1, 1)
                else
                    btn.icon:SetTexture(AD.GetSlotTexture(slotDef.invSlot))
                    btn.icon:SetVertexColor(0.85, 0.85, 0.85)
                end
                btn._empty:Hide()
            else
                btn.icon:SetTexture(AD.GetSlotTexture(slotDef.invSlot))
                btn.icon:SetVertexColor(0.55, 0.55, 0.55)
                btn._empty:Show()
            end
        end
    end
    if IC and #toEnrich > 0 then
        IC.EnrichList(toEnrich, function()
            if viewFrame and viewFrame:IsShown() then RefreshGearSlots() end
        end)
    end
    RefreshGearList()
end

local function LayoutPreviewTalentColumns(trees)
    if not viewFrame or not viewFrame._talentsScrollChild or not trees then return end
    local child = viewFrame._talentsScrollChild
    local scroll = viewFrame._talentsScroll
    if not scroll then return end
    local numTabs = T.GetTabCount(trees)
    if numTabs < 1 then return end
    local availW = scroll:GetWidth()
    if not availW or availW < 1 then return end
    local gaps = (numTabs - 1) * TALENT_SECTION_GAP
    local colW = math.floor((availW - gaps) / numTabs)
    local maxH = 0
    for tab = 1, numTabs do
        local tree = trees[tab]
        local panel = previewTalentPanels[tab]
        if tree and panel then
            local innerW = colW - TALENT_TREE_PAD * 2
            local colStep = math.max(TALENT_CELL_STEP, math.floor(innerW / math.max(tree.maxCol, 1)))
            local gridW = (tree.maxCol - 1) * colStep + TALENT_ICON_SIZE
            local offsetX = TALENT_TREE_PAD + math.max(0, math.floor((innerW - gridW) / 2))
            local panelH = 28 + tree.maxTier * colStep + TALENT_TREE_PAD
            if panelH > maxH then maxH = panelH end
            local x = (tab - 1) * (colW + TALENT_SECTION_GAP)
            panel:ClearAllPoints()
            panel:SetPoint("TOPLEFT", child, "TOPLEFT", x, 0)
            panel:SetSize(colW, panelH)
            panel:Show()
            for ti, btn in pairs(panel._buttons) do
                local info = tree.talents[ti]
                if info and btn then
                    btn:ClearAllPoints()
                    btn:SetPoint("TOPLEFT", panel, "TOPLEFT",
                        offsetX + (info.column - 1) * colStep,
                        -24 - (info.tier - 1) * colStep)
                end
            end
        end
    end
    child:SetWidth(availW)
    child:SetHeight(math.max(maxH, 1))
end

local function CreatePreviewTalentButton(parent, tab, ti, info, plan)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetSize(TALENT_ICON_SIZE, TALENT_ICON_SIZE)
    SW.Fill(btn, SC.elementBg)
    btn._border = SW.ThinBorder(btn, SC.borderSoft, 1)
    local icon = btn:CreateTexture(nil, "ARTWORK")
    icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 2, -2)
    icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -2, 2)
    icon:SetTexture(info.icon or "Interface\\Icons\\INV_Misc_QuestionMark")
    icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    local rankBadge = CreateFrame("Frame", nil, btn)
    rankBadge:SetPoint("BOTTOMRIGHT", -1, 1)
    rankBadge:SetFrameLevel(btn:GetFrameLevel() + 3)
    rankBadge:Hide()
    SW.Fill(rankBadge, SC.rankBadgeBg)
    SW.ThinBorder(rankBadge, SC.rankBadgeBorder, 1)
    btn._rankBadge = rankBadge
    local rankText = SW.Label(rankBadge, "", 10, SC.rankBadge, false, "semibold")
    rankText:SetPoint("CENTER")
    rankText:SetShadowOffset(1, -1)
    rankText:SetShadowColor(0, 0, 0, 0.9)
    btn._rankText = rankText
    local planRank = T.GetRank(plan, tab, ti)
    if planRank > 0 then
        rankText:SetText(planRank .. "/" .. info.maxRank)
        rankBadge:SetWidth(math.max(22, rankText:GetStringWidth() + 8))
        rankBadge:SetHeight(14)
        rankBadge:Show()
        SW.SetBorderColor(btn._border, SC.accentBorder55)
        icon:SetDesaturated(false)
        icon:SetVertexColor(1, 1, 1, 1)
    else
        icon:SetDesaturated(true)
        icon:SetVertexColor(0.55, 0.55, 0.55, 1)
    end
    btn:SetScript("OnEnter", function()
        GameTooltip:SetOwner(btn, "ANCHOR_RIGHT")
        GameTooltip:ClearLines()
        GameTooltip:SetText(info.name or "?", 1, 1, 1)
        GameTooltip:AddLine(string.format("Plan: %d / %d", planRank, info.maxRank), 0.8, 0.8, 0.8)
        GameTooltip:Show()
    end)
    btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
    return btn
end

local function BuildPreviewTalentTree(parent, tree, plan)
    local panel = CreateFrame("Frame", nil, parent)
    panel._isTalentPanel = true
    SW.Fill(panel, SC.statCellBg)
    SW.ThinBorder(panel, SC.border, 1)
    panel._buttons = {}
    local header = SW.Label(panel, tree.name, 11, SC.text, false, "semibold")
    header:SetPoint("TOPLEFT", panel, "TOPLEFT", TALENT_TREE_PAD, -6)
    local pointsText = SW.Label(panel, T.PointsInTab(plan, tree.index) .. " pts", 10, SC.textMuted)
    pointsText:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -TALENT_TREE_PAD, -6)
    for ti, info in pairs(tree.talents) do
        panel._buttons[ti] = CreatePreviewTalentButton(panel, tree.index, ti, info, plan)
    end
    previewTalentPanels[tree.index] = panel
    return panel
end

local function BuildTalentSummaryLines(build)
    local lines = {}
    local plan = build.talents or {}
    local order = build.talentOrder or {}
    if #order > 0 then
        for i, step in ipairs(order) do
            lines[#lines + 1] = string.format("%d. %s (%d)", i, step.name or "?", step.rank or 0)
        end
        return lines
    end
    local keys = {}
    for key, rank in pairs(plan) do
        if type(rank) == "number" and rank > 0 then
            keys[#keys + 1] = { key = key, rank = rank }
        end
    end
    table.sort(keys, function(a, b) return a.key < b.key end)
    for _, row in ipairs(keys) do
        lines[#lines + 1] = row.key .. ": " .. row.rank
    end
    return lines
end

RefreshTalentsPreview = function()
    if not viewFrame or not viewFrame._talentsScrollChild or not state.build then return end
    local child = viewFrame._talentsScrollChild
    local build = state.build
    local plan = build.talents or {}
    for _, panel in pairs(previewTalentPanels) do panel:Hide() end
    previewTalentPanels = {}
    for _, row in ipairs({ child:GetChildren() }) do
        if not row._isTalentPanel then row:Hide() end
    end
    if child._summaryLabel then child._summaryLabel:Hide() end
    if child._emptyLabel then child._emptyLabel:Hide() end
    local mismatch = viewFrame._talentsMismatchLabel
    local treesHolder = viewFrame._talentsTreesHolder
    local body = viewFrame._talentsBody
    if mismatch and treesHolder and body then
        if PreviewClassMatches(build) then
            mismatch:Hide()
            treesHolder:ClearAllPoints()
            treesHolder:SetPoint("TOPLEFT", body, "TOPLEFT", 0, 0)
            treesHolder:SetPoint("BOTTOMRIGHT", body, "BOTTOMRIGHT", 0, 0)
        else
            mismatch:SetText(string.format(
                "|cffff6666Build class (%s) differs from your character. Tree view requires a matching class.|r",
                build.class or "?"))
            mismatch:Show()
            mismatch:ClearAllPoints()
            mismatch:SetPoint("TOPLEFT", body, "TOPLEFT", 0, 0)
            mismatch:SetPoint("TOPRIGHT", body, "TOPRIGHT", 0, 0)
            treesHolder:ClearAllPoints()
            treesHolder:SetPoint("TOPLEFT", mismatch, "BOTTOMLEFT", 0, -6)
            treesHolder:SetPoint("BOTTOMRIGHT", body, "BOTTOMRIGHT", 0, 0)
        end
    end
    local used = T.TotalPoints(plan)
    if used == 0 then
        local empty = child._emptyLabel
        if not empty then
            empty = SW.Label(child, "", 11, SC.textMuted)
            child._emptyLabel = empty
        end
        empty:ClearAllPoints()
        empty:SetPoint("TOPLEFT", child, "TOPLEFT", 0, 0)
        empty:SetText("No talent plan configured.")
        empty:Show()
        child:SetHeight(20)
        return
    end
    if PreviewClassMatches(build) and T.ScanTrees then
        local trees = T.ScanTrees()
        viewFrame._previewTrees = trees
        if trees then
            for tab = 1, T.GetTabCount(trees) do
                local tree = trees[tab]
                if tree then
                    local panel = BuildPreviewTalentTree(child, tree, plan)
                    panel._isTalentPanel = true
                end
            end
            LayoutPreviewTalentColumns(trees)
            return
        end
    end
    local lines = BuildTalentSummaryLines(build)
    local textW = viewFrame._talentsScroll and viewFrame._talentsScroll:GetWidth() or 300
    textW = math.max(1, (textW or 300) - 8)
    local y = 0
    if not PreviewClassMatches(build) then
        local note = child._summaryLabel
        if not note then
            note = SW.Label(child, "", 11, SC.textMuted)
            child._summaryLabel = note
        end
        note:ClearAllPoints()
        note:SetPoint("TOPLEFT", child, "TOPLEFT", 0, 0)
        note:SetWidth(textW)
        note:SetJustifyH("LEFT")
        note:SetText(string.format("|cffff6666Class %s — tree view requires matching character class.|r", build.class or "?"))
        note:Show()
        y = (note:GetStringHeight() or 16) + 4
    end
    for i, line in ipairs(lines) do
        local row = child["talentLine" .. i]
        if not row then
            row = SW.Label(child, "", 11, SC.text)
            child["talentLine" .. i] = row
        end
        row:ClearAllPoints()
        row:SetPoint("TOPLEFT", child, "TOPLEFT", 0, -y)
        row:SetWidth(textW)
        row:SetJustifyH("LEFT")
        row:SetText(line)
        row:Show()
        y = y + (row:GetStringHeight() or 14) + 2
    end
    for i = #lines + 1, 50 do
        local row = child["talentLine" .. i]
        if row then row:Hide() end
    end
    child:SetHeight(math.max(y + 4, 1))
end


local function ResizeOverviewHeader()
    if not overviewBuildHeader or not overviewBuildHeader._copyBtn then return end
    local copyBtn = overviewBuildHeader._copyBtn
    local bottom = copyBtn:GetBottom()
    local top = overviewBuildHeader:GetTop()
    if bottom and top then
        overviewBuildHeader:SetHeight((top - bottom) + SITE_HEADER_BOTTOM_PAD)
        overviewBuildHeader:SetScript("OnUpdate", nil)
    end
end

local function ScheduleResizeOverviewHeader()
    if not overviewBuildHeader then return end
    -- Try immediately first; if geometry isn't ready, retry every frame via OnUpdate
    ResizeOverviewHeader()
    if overviewBuildHeader:IsShown() and overviewBuildHeader._copyBtn
       and not overviewBuildHeader._copyBtn:GetBottom() then
        overviewBuildHeader:SetScript("OnUpdate", function(self)
            ResizeOverviewHeader()
        end)
    end
end

local function UpdateHeaderMetaAnchor(build)
    if not overviewBuildHeader or not overviewBuildHeader._metaLabel or not overviewBuildHeader._subtitle then return end
    local meta = overviewBuildHeader._metaLabel
    meta:ClearAllPoints()
    local hasPublic = build.isPublic == true
    local hasValidated = build.validated == true
    if hasValidated and overviewBuildHeader._validatedBadge and overviewBuildHeader._validatedBadge:IsShown() then
        meta:SetPoint("TOPLEFT", overviewBuildHeader._validatedBadge, "BOTTOMLEFT", 0, -6)
    elseif hasPublic and overviewBuildHeader._publicBadge and overviewBuildHeader._publicBadge:IsShown() then
        meta:SetPoint("TOPLEFT", overviewBuildHeader._publicBadge, "BOTTOMLEFT", 0, -6)
    else
        meta:SetPoint("TOPLEFT", overviewBuildHeader._subtitle, "BOTTOMLEFT", 0, -SITE_HEADER_GAP_SM)
    end
end

local function RefreshBuildHeader(build)
    if not overviewBuildHeader or not build or not ST then return end
    local classToken = build.class or "PRIEST"
    local classColor = ST.CLASS_COLORS[classToken] or SC.text
    overviewBuildHeader._title:SetText(build.title or "Untitled")
    overviewBuildHeader._title:SetTextColor(unpack(classColor))
    if overviewBuildHeader._subtitle then
        local specName = ST.SpecDisplay(classToken, build.spec)
        overviewBuildHeader._subtitle:SetText((ST.CLASS_DISPLAY[classToken] or classToken) .. " · " .. specName)
    end
    if overviewBuildHeader._grad then
        ST.ApplyClassBackgroundGradient(overviewBuildHeader._grad, classColor, {
            SC.sidebarBg[1], SC.sidebarBg[2], SC.sidebarBg[3],
        })
    end
    if overviewBuildHeader._classStripe then
        ST.ApplyClassStripe(overviewBuildHeader._classStripe, classColor)
    end
    local holder = overviewBuildHeader._iconHolder
    if holder and holder._classIcon then
        SW.SetClassIcon(holder._classIcon, classToken)
    end
    if holder and holder._specTex then
        local _, specIconPath = ST.SpecDisplay(classToken, build.spec)
        if specIconPath then holder._specTex:SetTexture(specIconPath) end
    end

    if overviewBuildHeader._publicBadge then
        if build.isPublic == true then overviewBuildHeader._publicBadge:Show()
        else overviewBuildHeader._publicBadge:Hide() end
    end
    if overviewBuildHeader._validatedBadge then
        if build.validated == true then overviewBuildHeader._validatedBadge:Show()
        else overviewBuildHeader._validatedBadge:Hide() end
    end
    if overviewBuildHeader._metaLabel then
        local author = build.author or UnitName("player") or "Unknown"
        local dateStr = build.lastModified or ""
        local metaText = "by " .. author
        if dateStr ~= "" then metaText = metaText .. " · " .. dateStr end
        overviewBuildHeader._metaLabel:SetText(metaText)
    end
    UpdateHeaderMetaAnchor(build)
    ScheduleResizeOverviewHeader()
end

local function RefreshBuildStats(build)
    if not viewFrame or not viewFrame._statLabels or not build then return end
    local labels = viewFrame._statLabels
    local affixLoadout = build.affixLoadout or {}
    local affixFilled = AL.CountFilled(affixLoadout)
    local affixTotal = #AD.SLOTS
    if labels.affixes then labels.affixes:SetText(affixFilled .. "/" .. affixTotal) end

    local gearLoadout = EL.GetLoadoutForBuild(build)
    local gearFilled = EL.CountFilled(gearLoadout)
    if labels.gear then labels.gear:SetText(tostring(gearFilled)) end

    local talentPts = T.TotalPoints(build.talents or {})
    if labels.talents then labels.talents:SetText(tostring(talentPts)) end

    local tierEchoes = CollectBuildTierEchoes(build)
    local tierCount = 0
    for _, meta in ipairs(ECHO_TAB_TIER_META) do
        tierCount = tierCount + #(tierEchoes[meta.id] or {})
    end
    if labels.tiers then labels.tiers:SetText(tostring(tierCount)) end
end

local function BuildViewFooter(parent)
    local footerBar = CreateFrame("Frame", nil, parent)
    footerBar:SetPoint("BOTTOMLEFT", parent, "BOTTOMLEFT", 0, 0)
    footerBar:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", 0, 0)
    footerBar:SetHeight(FOOTER_HEIGHT)
    SW.Fill(footerBar, SC.tabBarBg)

    local footerSep = footerBar:CreateTexture(nil, "ARTWORK")
    footerSep:SetTexture(ST.FLAT)
    footerSep:SetVertexColor(unpack(SC.border))
    footerSep:SetPoint("TOPLEFT", footerBar, "TOPLEFT", 0, 0)
    footerSep:SetPoint("TOPRIGHT", footerBar, "TOPRIGHT", 0, 0)
    footerSep:SetHeight(1)

    local deleteBtn = CreateFrame("Button", nil, footerBar)
    deleteBtn:SetSize(64, 28)
    deleteBtn:SetPoint("LEFT", footerBar, "LEFT", SL.PAD, 0)
    deleteBtn._bg = SW.Fill(deleteBtn, SC.elementBg)
    deleteBtn._border = SW.ThinBorder(deleteBtn, ST.BlendRgb({ 0.9, 0.25, 0.25 }, ST.Base.border, 0.35), 1)
    deleteBtn._label = SW.Label(deleteBtn, "Delete", 12, { 0.95, 0.45, 0.45, 1 }, false, "medium")
    deleteBtn._label:SetPoint("CENTER")
    deleteBtn:SetScript("OnClick", function()
        local build = state.build
        if not build then return end
        local name = build.title or "Untitled"
        StaticPopupDialogs["EBONHOLDHUB_DELETE_BUILD"].text = "Delete build \"" .. name .. "\"?\n\nThis action cannot be undone."
        StaticPopup_Show("EBONHOLDHUB_DELETE_BUILD")
    end)

    local editBtn = SW.CreateAccentButton(footerBar, "Edit Build", function()
        if state.build then
            EbonholdHub.ViewRouter.Show("buildTabs", { mode = "edit", build = state.build })
        end
    end)
    editBtn:SetSize(120, 28)
    editBtn:SetPoint("LEFT", deleteBtn, "RIGHT", 6, 0)

    local autoToggle = CreateFrame("Button", nil, footerBar)
    autoToggle:SetSize(158, 28)
    autoToggle:SetPoint("RIGHT", footerBar, "RIGHT", -SL.PAD, 0)
    autoToggle._bg = SW.Fill(autoToggle, SC.elementBg)
    autoToggle._border = SW.ThinBorder(autoToggle, SC.border, 1)
    autoToggle._label = SW.Label(autoToggle, "Auto Pick: ON", 12, SC.text, false, "medium")
    autoToggle._label:SetPoint("LEFT", autoToggle, "LEFT", 30, 0)
    autoToggle._label:SetPoint("RIGHT", autoToggle, "RIGHT", -6, 0)
    autoToggle._label:SetJustifyH("LEFT")
    local autoIcon = autoToggle:CreateTexture(nil, "ARTWORK")
    autoIcon:SetSize(18, 18)
    autoIcon:SetPoint("LEFT", autoToggle, "LEFT", 8, 0)
    autoIcon:SetTexture(AUTOMATION_ICON)
    autoIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    autoToggle._icon = autoIcon
    autoToggle.label = autoToggle._label
    WireAutomationToggle(autoToggle)
    autoToggle:SetScript("OnClick", function()
        local build = state.build
        if not build then return end
        build.automationEnabled = not build.automationEnabled
        StyleAutomationToggle(autoToggle, build.automationEnabled)
    end)

    local autoNeedBtn = CreateFrame("Button", nil, footerBar)
    autoNeedBtn:SetSize(158, 28)
    autoNeedBtn:SetPoint("RIGHT", autoToggle, "LEFT", -6, 0)
    autoNeedBtn._bg = SW.Fill(autoNeedBtn, SC.elementBg)
    autoNeedBtn._border = SW.ThinBorder(autoNeedBtn, SC.border, 1)
    autoNeedBtn._label = SW.Label(autoNeedBtn, "Auto-Need: OFF", 12, SC.text, false, "medium")
    autoNeedBtn._label:SetPoint("LEFT", autoNeedBtn, "LEFT", 30, 0)
    autoNeedBtn._label:SetPoint("RIGHT", autoNeedBtn, "RIGHT", -6, 0)
    autoNeedBtn._label:SetJustifyH("LEFT")
    local autoNeedIcon = autoNeedBtn:CreateTexture(nil, "ARTWORK")
    autoNeedIcon:SetSize(18, 18)
    autoNeedIcon:SetPoint("LEFT", autoNeedBtn, "LEFT", 8, 0)
    autoNeedIcon:SetTexture(AUTO_NEED_ICON)
    autoNeedIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    autoNeedBtn._icon = autoNeedIcon
    autoNeedBtn.label = autoNeedBtn._label
    WireAutoNeedToggle(autoNeedBtn)
    autoNeedBtn:SetScript("OnClick", function()
        EbonholdHubDB.globalSettings = EbonholdHubDB.globalSettings or {}
        local newVal = not (EbonholdHubDB.globalSettings.lootAutoNeed == true)
        EbonholdHubDB.globalSettings.lootAutoNeed = newVal
        StyleAutoNeedToggle(autoNeedBtn, newVal)
    end)

    return footerBar, autoToggle, autoNeedBtn, deleteBtn
end

local function BuildSiteSidebar(parent, host)
    local sidebar = CreateFrame("Frame", nil, parent)
    sidebar:SetWidth(SIDEBAR_W)
    sidebar:SetPoint("TOPLEFT", 0, 0)
    sidebar:SetPoint("BOTTOMLEFT", 0, 0)
    SW.Fill(sidebar, SC.sidebarBg)

    overviewBuildHeader = SW.CreateBuildHeader(sidebar, {
        class = "PRIEST",
        spec  = 1,
        title = "Build",
        subtitle = "Priest · Shadow",
        height = SITE_HEADER_H,
    })

    local subtitle = overviewBuildHeader._subtitle or overviewBuildHeader._title
    local cellW = (SIDEBAR_W - SL.PAD * 2 - 6) / 2

    overviewBuildHeader._publicBadge = SW.CreateBadge(overviewBuildHeader, "Public", "accent")
    overviewBuildHeader._publicBadge:SetPoint("TOPLEFT", subtitle, "BOTTOMLEFT", 0, -6)
    overviewBuildHeader._publicBadge:Hide()

    overviewBuildHeader._validatedBadge = SW.CreateBadge(overviewBuildHeader, "Validated", "success")
    overviewBuildHeader._validatedBadge:SetPoint("LEFT", overviewBuildHeader._publicBadge, "RIGHT", 6, 0)
    overviewBuildHeader._validatedBadge:Hide()

    overviewBuildHeader._metaLabel = SW.Label(overviewBuildHeader, "by Unknown", 11, SC.textMuted)
    overviewBuildHeader._metaLabel:SetPoint("TOPLEFT", subtitle, "BOTTOMLEFT", 0, -SITE_HEADER_GAP_SM)
    overviewBuildHeader._metaLabel:SetWidth(SIDEBAR_W - SL.PAD * 2 - 60)
    overviewBuildHeader._metaLabel:SetJustifyH("LEFT")

    local statsGrid = CreateFrame("Frame", nil, overviewBuildHeader)
    statsGrid:SetHeight(SITE_HEADER_STATS_H)
    statsGrid:SetPoint("TOP", overviewBuildHeader._metaLabel, "BOTTOM", 0, -SITE_HEADER_GAP_SM)
    statsGrid:SetPoint("LEFT", overviewBuildHeader, "LEFT", SL.PAD, 0)
    statsGrid:SetPoint("RIGHT", overviewBuildHeader, "RIGHT", -SL.PAD, 0)
    overviewBuildHeader._statsGrid = statsGrid
    local statDefs = {
        { key = "affixes", label = "Affixes", value = "0/0" },
        { key = "gear",    label = "Gear",    value = "0" },
        { key = "talents", label = "Talents", value = "0" },
        { key = "tiers",   label = "Tiers",   value = "0" },
    }
    local statLabels = {}
    for i, def in ipairs(statDefs) do
        local col = (i - 1) % 2
        local row = math.floor((i - 1) / 2)
        local cell = SW.CreateStatCell(statsGrid, def.label, def.value)
        cell:SetWidth(cellW)
        cell:SetPoint("TOPLEFT", col * (cellW + 6), -row * SITE_HEADER_ROW_STEP)
        statLabels[def.key] = cell._valueLabel
    end
    host._statLabels = statLabels

    local copyBtn = SW.CreateAccentButton(overviewBuildHeader, "Copy export for site", function(btn)
        if state.build and EbonholdHub.ExportImport and EbonholdHub.ExportImport.ShowExportDialog then
            EbonholdHub.ExportImport.ShowExportDialog(state.build)
            btn:SetCopied(true)
            if C_Timer and C_Timer.After then
                C_Timer.After(2, function()
                    if btn and btn.SetCopied then btn:SetCopied(false) end
                end)
            end
        end
    end)
    local copyIcon = SW.CreateIcon(copyBtn, "copy", 12, SC.accent)
    copyIcon:SetPoint("LEFT", 10, 0)
    copyBtn._label:ClearAllPoints()
    copyBtn._label:SetPoint("CENTER", 8, 0)
    copyBtn:SetPoint("TOPLEFT", statsGrid, "BOTTOMLEFT", 0, -SITE_HEADER_GAP)
    copyBtn:SetPoint("TOPRIGHT", statsGrid, "BOTTOMRIGHT", 0, -SITE_HEADER_GAP)
    copyBtn:SetHeight(SITE_HEADER_COPY_H)
    overviewBuildHeader._copyBtn = copyBtn

    ScheduleResizeOverviewHeader()

    local sideHolder = CreateFrame("Frame", nil, sidebar)
    sideHolder:SetPoint("TOPLEFT", overviewBuildHeader, "BOTTOMLEFT", 0, 0)
    sideHolder:SetPoint("BOTTOMRIGHT", 0, 0)
    local sideScroll, sideChild = SW.CreatePlainScroll(sideHolder, SIDEBAR_W)
    sideChild:SetWidth(SIDEBAR_W)

    local blockY = 0
    local lockedN = EbonholdHub.Build.GetLockedSlotCount()
    local lockedBlock = SW.CreateSidebarBlock(sideChild, "Locked echoes")
    lockedBlock:SetPoint("TOPLEFT", 0, blockY)
    lockedBlock:SetHeight(SITE_LOCKED_H)
    local lockedCountLabel = SW.Label(lockedBlock, "0/" .. lockedN, 10, SC.textMuted)
    lockedCountLabel:SetPoint("TOPRIGHT", -SL.PAD, -SL.PAD)
    host._lockedCountLabel = lockedCountLabel

    local lockedRow = CreateFrame("Frame", nil, lockedBlock)
    lockedRow:SetPoint("TOPLEFT", SL.PAD, lockedBlock._contentTop)
    lockedRow:SetSize(SIDEBAR_W - SL.PAD * 2, SL.ICON_LOCKED)
    local lockedButtons = {}
    for i = 1, lockedN do
        local slot = CreateFrame("Frame", nil, lockedRow)
        slot:SetSize(SL.ICON_LOCKED, SL.ICON_LOCKED)
        slot:SetPoint("LEFT", (i - 1) * (SL.ICON_LOCKED + SL.ICON_GAP), 0)
        local btn = SW.CreateEmptyEchoSlot(slot, SL.ICON_LOCKED)
        btn:SetPoint("CENTER")
        btn:Hide()
        slot._echoBtn = btn
        lockedButtons[i] = slot
    end
    host._lockedButtons = lockedButtons
    blockY = blockY - SITE_LOCKED_H

    local buildsBlock = SW.CreateSidebarBlock(sideChild, "Your builds")
    buildsBlock:SetPoint("TOPLEFT", 0, blockY)
    buildsBlock:SetHeight(SITE_BUILDS_H)
    local buildsArea = CreateFrame("Frame", nil, buildsBlock)
    buildsArea:SetPoint("TOPLEFT", SL.PAD, buildsBlock._contentTop)
    buildsArea:SetPoint("BOTTOMRIGHT", -SL.PAD, SL.PAD)
    if EbonholdHub.BuildList and EbonholdHub.BuildList.MountInOverview then
        EbonholdHub.BuildList.MountInOverview(buildsArea, { titleMaxW = SIDEBAR_W - SL.PAD * 2 - 52 })
    end
    blockY = blockY - SITE_BUILDS_H

    sideChild:SetHeight(math.abs(blockY) + SL.PAD)
    return sidebar
end

local function BuildDescriptionTabPanel(parent)
    local panel = CreateFrame("Frame", nil, parent)
    panel:SetAllPoints(parent)
    SW.Fill(panel, SC.mainBg)

    local scrollHolder = UI.CreateScrollFrame(panel)
    scrollHolder:SetPoint("TOPLEFT", SL.PAD, -SL.PAD)
    scrollHolder:SetPoint("BOTTOMRIGHT", -SL.PAD, SL.PAD)
    local descScroll = scrollHolder.scrollFrame
    local descChild = scrollHolder.scrollChild

    local descLabel = descChild:CreateFontString(nil, "OVERLAY")
    SW.SetFont(descLabel, 13, "regular")
    descLabel:SetTextColor(unpack(SC.textDim))
    descLabel:SetPoint("TOPLEFT", descChild, "TOPLEFT", 0, 0)
    descLabel:SetJustifyH("LEFT")
    descLabel:SetJustifyV("TOP")

    local descMeasure = descChild:CreateFontString(nil, "OVERLAY")
    SW.SetFont(descMeasure, 13, "regular")
    descMeasure:Hide()

    local function UpdateDescLayout()
        local w = descScroll:GetWidth()
        if not w or w <= 0 then return end
        local textW = math.max(1, w - 4)
        descLabel:SetWidth(textW)
        descMeasure:SetWidth(textW)
        local text = descLabel:GetText()
        if text and text ~= "" then descMeasure:SetText(text) end
        local textHeight = descMeasure:GetStringHeight() or 0
        descChild:SetWidth(textW)
        descChild:SetHeight(math.max(textHeight + 8, descScroll:GetHeight()))
        if scrollHolder.UpdateThumb then scrollHolder.UpdateThumb() end
    end
    descScroll:SetScript("OnSizeChanged", UpdateDescLayout)

    overviewDescLabel  = descLabel
    overviewDescMeasure = descMeasure
    overviewDescScroll  = descScroll
    overviewDescChild   = descChild
    overviewDescScrollHolder = scrollHolder

    panel._updateDescLayout = UpdateDescLayout
    return panel
end

local function BuildOverviewMainPanel(parent)
    local outer = CreateFrame("Frame", nil, parent)
    outer:SetAllPoints(parent)

    local body = CreateFrame("Frame", nil, outer)
    body:SetPoint("TOPLEFT", SL.PAD, -SL.PAD)
    body:SetPoint("BOTTOMRIGHT", -SL.PAD, SL.PAD)
    outer._affixBody = body

    local dollShellPad = 8

    local dollColumn = CreateFrame("Frame", nil, body)
    dollColumn:SetPoint("TOPLEFT", body, "TOPLEFT", 0, 0)
    dollColumn:SetPoint("BOTTOMLEFT", body, "BOTTOMLEFT", 0, 0)

    local dollShell = CreateFrame("Frame", nil, dollColumn)
    SW.Fill(dollShell, SC.statCellBg)
    SW.ThinBorder(dollShell, SC.border, 1)
    outer._dollShell = dollShell

    local dollInset = CreateFrame("Frame", nil, dollShell)
    dollInset:SetPoint("TOPLEFT", dollShellPad, -dollShellPad)
    dollInset:SetPoint("BOTTOMRIGHT", -dollShellPad, dollShellPad)

    local dollHolder = EbonholdHub.AffixDoll.Create(dollInset, { slotSize = SLOT_SIZE, showModel = true })
    dollHolder:SetAllPoints(dollInset)
    outer._dollHolder = dollHolder

    outer._affixSlots = {}
    for _, slotDef in ipairs(AD.SLOTS) do
        outer._affixSlots[slotDef.key] = CreateAffixSlotButton(dollHolder, slotDef)
    end
    dollHolder:Layout()

    local listScroll, listChild = SW.CreatePlainScroll(body)
    listScroll:ClearAllPoints()
    listScroll:SetPoint("TOPLEFT", dollColumn, "TOPRIGHT", AFFIX_LIST_GAP, 0)
    listScroll:SetPoint("BOTTOMRIGHT", body, "BOTTOMRIGHT", 0, 0)
    listScroll:SetFrameLevel(dollColumn:GetFrameLevel() + 4)
    outer._affixListScroll = listScroll
    outer._affixListChild = listChild

    local function SyncAffixColumnLayout()
        local bodyW = body:GetWidth()
        if not bodyW or bodyW <= AFFIX_LIST_GAP then return end
        local halfW = math.floor((bodyW - AFFIX_LIST_GAP) / 2)
        dollColumn:SetWidth(halfW)
        outer._affixListWidth = halfW

        dollShell:ClearAllPoints()
        dollShell:SetPoint("TOPLEFT", dollColumn, "TOPLEFT", 0, 0)
        dollShell:SetPoint("BOTTOMRIGHT", dollColumn, "BOTTOMRIGHT", 0, 0)

        if dollHolder and dollHolder.Layout then
            dollHolder:Layout()
        end
        if state.build then RefreshAffixList() end
    end

    body:SetScript("OnSizeChanged", SyncAffixColumnLayout)
    SyncAffixColumnLayout()

    return outer
end

local function BuildTalentsTabPanel(parent)
    local outer = CreateFrame("Frame", nil, parent)
    outer:SetAllPoints(parent)
    outer:Hide()

    local body = CreateFrame("Frame", nil, outer)
    body:SetPoint("TOPLEFT", SL.PAD, -SL.PAD)
    body:SetPoint("BOTTOMRIGHT", -SL.PAD, SL.PAD)
    outer._talentsBody = body

    local mismatchLabel = SW.Label(body, "", 11, SC.textMuted)
    mismatchLabel:SetPoint("TOPLEFT", 0, 0)
    mismatchLabel:SetPoint("TOPRIGHT", 0, 0)
    mismatchLabel:SetJustifyH("LEFT")
    mismatchLabel:SetWordWrap(true)
    mismatchLabel:Hide()
    outer._mismatchLabel = mismatchLabel

    local treesHolder = CreateFrame("Frame", nil, body)
    treesHolder:SetPoint("TOPLEFT", 0, 0)
    treesHolder:SetPoint("BOTTOMRIGHT", 0, 0)

    local scroll, scrollChild = SW.CreatePlainScroll(treesHolder)
    scroll:SetScript("OnSizeChanged", function()
        if viewFrame and viewFrame._previewTrees then
            LayoutPreviewTalentColumns(viewFrame._previewTrees)
        end
    end)

    return outer, scroll, scrollChild, mismatchLabel, treesHolder
end

local function BuildGearTabPanel(parent)
    local outer = CreateFrame("Frame", nil, parent)
    outer:SetAllPoints(parent)
    outer:Hide()

    local body = CreateFrame("Frame", nil, outer)
    body:SetPoint("TOPLEFT", SL.PAD, -SL.PAD)
    body:SetPoint("BOTTOMRIGHT", -SL.PAD, SL.PAD)

    local setBarH = (EbonholdHub.GearSetStrip and EbonholdHub.GearSetStrip.RowHeight() or 26) + 4
    local setBar = CreateFrame("Frame", nil, body)
    setBar:SetPoint("TOPLEFT", 0, 0)
    setBar:SetPoint("TOPRIGHT", 0, 0)
    setBar:SetHeight(setBarH)

    gearSetStrip = EbonholdHub.GearSetStrip.Create(setBar, {
        editable = false,
        getBuild = function() return state.build end,
        onChange = function()
            RefreshGearSlots()
        end,
    })
    gearSetStrip:SetPoint("TOPLEFT", setBar, "TOPLEFT", 0, 0)
    gearSetStrip:SetPoint("TOPRIGHT", setBar, "TOPRIGHT", 0, 0)

    local content = CreateFrame("Frame", nil, body)
    content:SetPoint("TOPLEFT", setBar, "BOTTOMLEFT", 0, -8)
    content:SetPoint("BOTTOMRIGHT", 0, 0)

    local dollShellPad = 8
    local dollColumn = CreateFrame("Frame", nil, content)
    dollColumn:SetPoint("TOPLEFT", content, "TOPLEFT", 0, 0)
    dollColumn:SetPoint("BOTTOMLEFT", content, "BOTTOMLEFT", 0, 0)

    local dollShell = CreateFrame("Frame", nil, dollColumn)
    SW.Fill(dollShell, SC.statCellBg)
    SW.ThinBorder(dollShell, SC.border, 1)

    local dollInset = CreateFrame("Frame", nil, dollShell)
    dollInset:SetPoint("TOPLEFT", dollShellPad, -dollShellPad)
    dollInset:SetPoint("BOTTOMRIGHT", -dollShellPad, dollShellPad)

    local dollHolder = EbonholdHub.AffixDoll.Create(dollInset, { slotSize = SLOT_SIZE, showModel = true })
    dollHolder:SetAllPoints(dollInset)

    local gearSlots = {}
    for _, slotDef in ipairs(AD.SLOTS) do
        gearSlots[slotDef.key] = CreateGearSlotButton(dollHolder, slotDef)
    end
    dollHolder:Layout()

    local listScroll, listChild = SW.CreatePlainScroll(content)
    listScroll:ClearAllPoints()
    listScroll:SetPoint("TOPLEFT", dollColumn, "TOPRIGHT", AFFIX_LIST_GAP, 0)
    listScroll:SetPoint("BOTTOMRIGHT", content, "BOTTOMRIGHT", 0, 0)
    listScroll:SetFrameLevel(dollColumn:GetFrameLevel() + 4)
    outer._gearListScroll = listScroll
    outer._gearListChild = listChild

    local function SyncGearColumnLayout()
        local contentW = content:GetWidth()
        if not contentW or contentW <= AFFIX_LIST_GAP then return end
        local halfW = math.floor((contentW - AFFIX_LIST_GAP) / 2)
        dollColumn:SetWidth(halfW)
        outer._gearListWidth = halfW

        dollShell:ClearAllPoints()
        dollShell:SetPoint("TOPLEFT", dollColumn, "TOPLEFT", 0, 0)
        dollShell:SetPoint("BOTTOMRIGHT", dollColumn, "BOTTOMRIGHT", 0, 0)

        if dollHolder and dollHolder.Layout then
            dollHolder:Layout()
        end
        if state.build then RefreshGearList() end
    end

    content:SetScript("OnSizeChanged", SyncGearColumnLayout)
    SyncGearColumnLayout()

    return outer, dollHolder, gearSlots
end


local TAB_HEADER_HEIGHT = HEADER_PAD + 22 + 4 + ACTION_ROW_H + HEADER_PAD
local TAB_HEADER_INLINE_HEIGHT = HEADER_PAD + 18 + HEADER_PAD

local function BuildTabHeaderPanel(parent, title, subtitle, opts)
    opts = opts or {}
    local inline = opts.inlineSubtitle

    local headerPanel = UI.CreatePanel(parent)
    headerPanel:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0)
    headerPanel:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, 0)
    headerPanel:SetHeight(inline and TAB_HEADER_INLINE_HEIGHT or TAB_HEADER_HEIGHT)

    local titleLabel = UI.CreateLabel(headerPanel, title, UI.FONT_SIZE_TITLE, C.text)
    titleLabel:SetPoint("TOPLEFT", headerPanel, "TOPLEFT", HEADER_PAD, -HEADER_PAD)

    if inline then
        local subtitleLabel = UI.CreateLabel(headerPanel, subtitle or "", UI.FONT_SIZE_SMALL, C.textDim)
        subtitleLabel:SetPoint("LEFT", titleLabel, "RIGHT", 10, 0)
        subtitleLabel:SetPoint("RIGHT", headerPanel, "RIGHT", -HEADER_PAD, 0)
        subtitleLabel:SetJustifyH("LEFT")
        return headerPanel, subtitleLabel
    end

    local headerActions = CreateFrame("Frame", nil, headerPanel)
    headerActions:SetHeight(ACTION_ROW_H)
    headerActions:SetPoint("TOPLEFT", titleLabel, "BOTTOMLEFT", 0, -4)
    headerActions:SetPoint("TOPRIGHT", headerPanel, "TOPRIGHT", -HEADER_PAD, -4)

    local subtitleLabel = UI.CreateLabel(headerActions, subtitle or "", UI.FONT_SIZE_SMALL, C.textDim)
    subtitleLabel:SetPoint("LEFT", headerActions, "LEFT", 0, 0)

    return headerPanel, subtitleLabel
end


local function ResolveBuildEchoEntry(build, key)
    local name, quality = TD.ParseTierKey(key)
    local byName = GetEchoBestByName()
    local entry = byName and byName[name]

    local spellId, qual
    if entry then
        if quality ~= nil then
            spellId = entry.spellIds and entry.spellIds[quality]
            qual = quality
        else
            spellId = entry.spellId
            qual = entry.quality
        end
    end

    if not spellId and build and build.loadoutEchoSpellIds then
        spellId = build.loadoutEchoSpellIds[key]
        if spellId then
            local data = ProjectEbonhold and ProjectEbonhold.PerkDatabase and ProjectEbonhold.PerkDatabase[spellId]
            qual = qual or (data and data.quality) or quality or 0
        end
    end

    if not spellId then return nil end
    return { name = name, spellId = spellId, quality = qual, key = key }
end

CollectBuildTierEchoes = function(build)
    local tiers = build.echoTiers or {}
    local orders = build.echoTierOrder or {}
    local assigned = {}

    for _, meta in ipairs(ECHO_TAB_TIER_META) do
        local list = {}
        local seen = {}
        for _, key in ipairs(orders[meta.id] or {}) do
            if tiers[key] == meta.id and not seen[key] then
                seen[key] = true
                local echo = ResolveBuildEchoEntry(build, key)
                if echo then list[#list + 1] = echo end
            end
        end
        for key, tierId in pairs(tiers) do
            if tierId == meta.id and not seen[key] then
                seen[key] = true
                local echo = ResolveBuildEchoEntry(build, key)
                if echo then list[#list + 1] = echo end
            end
        end
        assigned[meta.id] = list
    end
    return assigned
end

local echoRefreshDebounceFrame
local echoPendingOwnershipOnly
local echoBuildFrame
local echoBuildQueue

local function StopEchoTabWork()
    echoBuildQueue = nil
    if echoBuildFrame then
        echoBuildFrame:Hide()
    end
    if echoRefreshDebounceFrame then
        echoRefreshDebounceFrame:Hide()
    end
end

local function IsEchoTabActive()
    return viewFrame and viewFrame:IsShown()
        and echoesTabPanel and echoesTabPanel:IsShown()
end

local function IsLogbookTabActive()
    return viewFrame and viewFrame:IsShown()
        and logbookTabPanel and logbookTabPanel:IsShown()
end

local function StopLogbookTabWork()
    if EbonholdHub.SessionHistory and EbonholdHub.SessionHistory.StopWork then
        EbonholdHub.SessionHistory.StopWork()
    end
end

local function BuildEchoesTabPanel(parent)
    local outer = CreateFrame("Frame", nil, parent)
    outer:SetAllPoints(parent)
    outer:Hide()

    local body = CreateFrame("Frame", nil, outer)
    body:SetPoint("TOPLEFT", SL.PAD, -SL.PAD)
    body:SetPoint("BOTTOMRIGHT", -SL.PAD, SL.PAD)

    local statsBar = CreateFrame("Frame", nil, body)
    statsBar:SetHeight(ECHO_STATS_H)
    statsBar:SetPoint("TOPLEFT", 0, 0)
    statsBar:SetPoint("TOPRIGHT", 0, 0)
    statsBar._cells = {}
    statsBar._statLabels = {}

    local statDefs = {
        { key = "guide", label = "In guide", value = "0" },
    }
    for i, def in ipairs(statDefs) do
        local cell = SW.CreateStatCell(statsBar, def.label, def.value)
        cell:SetHeight(ECHO_STATS_H)
        statsBar._cells[i] = cell
        statsBar._statLabels[def.key] = cell._valueLabel
    end
    statsBar:SetScript("OnSizeChanged", function() LayoutEchoStatsBar(statsBar) end)
    outer._echoStatsBar = statsBar

    local scroll, scrollChild = SW.CreatePlainScroll(body)
    scroll:ClearAllPoints()
    scroll:SetPoint("TOPLEFT", statsBar, "BOTTOMLEFT", 0, -ECHO_STATS_GAP)
    scroll:SetPoint("BOTTOMRIGHT", body, "BOTTOMRIGHT", 0, 0)
    outer._echoesScroll = scroll
    outer._echoesScrollChild = scrollChild

    scroll:SetScript("OnSizeChanged", function()
        if not IsEchoTabActive() then return end
        local w = scroll:GetWidth()
        if w and w > 0 and scrollChild then
            scrollChild:SetWidth(w)
        end
    end)

    return outer
end


local function BuildLogbookTabPanel(parent)
    local outer = CreateFrame("Frame", nil, parent)
    outer:SetAllPoints(parent)
    outer:Hide()

    local body = CreateFrame("Frame", nil, outer)
    body:SetPoint("TOPLEFT", SL.PAD, -SL.PAD)
    body:SetPoint("BOTTOMRIGHT", -SL.PAD, SL.PAD)
    outer._logbookBody = body

    return outer
end

local function GetEchoBuildKey(build, assigned)
    local parts = { tostring(build and (build.id or build.title) or "") }
    if assigned then
        for _, meta in ipairs(ST.TIER_META) do
            for _, echo in ipairs(assigned[meta.id] or {}) do
                parts[#parts + 1] = echo.name or ""
            end
        end
    end
    if build and build.echoBundles then
        for _, b in ipairs(build.echoBundles) do
            parts[#parts + 1] = "B" .. tostring(b.id) .. b.tier
            for _, n in ipairs(b.echoes or {}) do parts[#parts + 1] = n end
        end
    end
    return table.concat(parts, "|")
end

local function FinishEchoBuildQueue()
    if not IsEchoTabActive() then
        StopEchoTabWork()
        return
    end
    local q = echoBuildQueue
    if not q then return end
    local child = echoesTabPanel and echoesTabPanel._echoesScrollChild
    if not child then
        echoBuildQueue = nil
        if echoBuildFrame then echoBuildFrame:Hide() end
        return
    end

    while q.metaIdx <= #ST.TIER_META do
        local meta = ST.TIER_META[q.metaIdx]
        q.metaIdx = q.metaIdx + 1
        local echoes = q.assigned[meta.id] or {}
        if #echoes > 0 then
            local names = {}
            for _, echo in ipairs(echoes) do
                names[#names + 1] = echo.name
            end
            local row = SW.CreateTierRow(child, meta, names, function(name)
                for _, echo in ipairs(echoes) do
                    if echo.name == name then
                        return echo.spellId, echo.quality
                    end
                end
                local entry = q.byName and q.byName[name]
                if entry then return entry.spellId, entry.quality end
                return GetSpellInfo(name), 2
            end)
            row:SetPoint("TOPLEFT", 0, -q.y)
            row:SetPoint("RIGHT", 0, 0)
            ApplyTierRowOwnership(row, echoes, q.ctx)
            child._tierRows[#child._tierRows + 1] = row
            q.y = q.y + row:GetHeight() + 12
            child:SetHeight(math.max(1, q.y))
            return
        end
    end

    -- Render bundles once after all tier rows are done
    if not q.bundlesRendered then
        q.bundlesRendered = true
        local bundles = q.bundles or {}
        local BUNDLE_TIER_COLORS = {
            S = { 1.0, 0.78, 0.26 },
            A = { 0.78, 0.48, 1.0 },
        }
        if #bundles > 0 then
            -- Section separator
            local sep = child:CreateFontString(nil, "OVERLAY")
            SW.SetFont(sep, 10)
            sep:SetTextColor(unpack(SC.textMuted))
            sep:SetText("BUNDLES")
            sep:SetJustifyH("LEFT")
            sep:SetPoint("TOPLEFT", 0, -q.y)
            q.y = q.y + 18

            for i, b in ipairs(bundles) do
                local col = BUNDLE_TIER_COLORS[b.tier] or BUNDLE_TIER_COLORS.A
                local ICON = SL.ICON_TIER
                local GAP  = SL.ICON_GAP
                local ROW_PAD = 4
                local ROW_H = ICON + ROW_PAD * 2

                local bundleRow = CreateFrame("Frame", nil, child)
                bundleRow:SetPoint("TOPLEFT", 0, -q.y)
                bundleRow:SetPoint("RIGHT", 0, 0)
                bundleRow:SetHeight(ROW_H)
                SW.ThinBorder(bundleRow, col)

                -- Priority number badge
                local numBg = bundleRow:CreateTexture(nil, "ARTWORK")
                numBg:SetTexture(ST.FLAT)
                numBg:SetVertexColor(col[1], col[2], col[3], 0.18)
                numBg:SetPoint("TOPLEFT", 0, 0)
                numBg:SetSize(24, ROW_H)
                local numLabel = bundleRow:CreateFontString(nil, "OVERLAY")
                SW.SetFont(numLabel, 11)
                numLabel:SetTextColor(col[1], col[2], col[3])
                numLabel:SetText(tostring(i))
                numLabel:SetPoint("CENTER", numBg, "CENTER", 0, 0)

                -- Build icon list, tracking echoes for ownership pass
                -- Bundle keys may be "name" or "name|quality" — parse accordingly.
                local iconBtns = {}
                local echoData = {}
                local xOffset = 28
                for j, echoKey in ipairs(b.echoes or {}) do
                    local echoName, slotQuality = TD.ParseTierKey(echoKey)
                    if j == 1 then
                        -- Main echo: M label
                        local mLabel = bundleRow:CreateFontString(nil, "OVERLAY")
                        SW.SetFont(mLabel, 9)
                        mLabel:SetTextColor(0.95, 0.8, 0.2)
                        mLabel:SetText("M")
                        mLabel:SetPoint("TOPLEFT", xOffset, -ROW_PAD)
                        xOffset = xOffset + 10
                    elseif j == 2 then
                        -- Arrow separator
                        local arrow = bundleRow:CreateFontString(nil, "OVERLAY")
                        SW.SetFont(arrow, 10)
                        arrow:SetTextColor(unpack(SC.textMuted))
                        arrow:SetText(">")
                        arrow:SetPoint("TOPLEFT", xOffset + 2, -ROW_PAD - 2)
                        xOffset = xOffset + 14
                    end
                    local entry = q.byName and q.byName[echoName]
                    if entry then
                        -- Use quality-specific spellId when the slot has a required quality
                        local spellId = entry.spellId
                        if slotQuality ~= nil and entry.spellIds and entry.spellIds[slotQuality] then
                            spellId = entry.spellIds[slotQuality]
                        end
                        local displayQuality = slotQuality ~= nil and slotQuality or entry.quality
                        local btn = SW.CreateEchoIcon(bundleRow, {
                            name = echoName, spellId = spellId,
                            quality = displayQuality, size = ICON,
                        })
                        btn:SetPoint("TOPLEFT", xOffset, -ROW_PAD)
                        xOffset = xOffset + ICON + GAP
                        iconBtns[#iconBtns + 1] = btn
                        echoData[#echoData + 1] = { name = echoName, spellId = spellId, quality = displayQuality }
                    end
                end
                bundleRow._iconBtns = iconBtns
                bundleRow._echoData = echoData
                ApplyTierRowOwnership(bundleRow, echoData, q.ctx)

                -- Tier badge at right
                local badgeLabel = bundleRow:CreateFontString(nil, "OVERLAY")
                SW.SetFont(badgeLabel, 10)
                badgeLabel:SetTextColor(col[1], col[2], col[3])
                badgeLabel:SetText(b.tier .. "-Bundle")
                badgeLabel:SetJustifyH("RIGHT")
                badgeLabel:SetPoint("RIGHT", -6, 0)

                if child._bundleRows then
                    child._bundleRows[#child._bundleRows + 1] = bundleRow
                end
                q.y = q.y + ROW_H + 6
                child:SetHeight(math.max(1, q.y))
            end
        end
    end

    child._echoBuildKey = q.buildKey
    echoBuildQueue = nil
    if echoBuildFrame then echoBuildFrame:Hide() end
end

local function StartDeferredEchoBuild(build, assigned, listW)
    if not IsEchoTabActive() then return end
    local child = echoesTabPanel and echoesTabPanel._echoesScrollChild
    if not child then return end

    if child._tierRows then
        for _, row in ipairs(child._tierRows) do
            row:Hide()
            row:SetParent(nil)
        end
    end
    child._tierRows = {}
    if child._bundleRows then
        for _, row in ipairs(child._bundleRows) do
            row:Hide()
            row:SetParent(nil)
        end
    end
    child._bundleRows = {}
    child._echoBuildKey = nil
    if listW and listW > 0 then child:SetWidth(listW) end
    if child._emptyLabel then child._emptyLabel:Hide() end

    if not echoBuildFrame then
        echoBuildFrame = CreateFrame("Frame")
        echoBuildFrame:Hide()
        echoBuildFrame:SetScript("OnUpdate", function(self)
            if not IsEchoTabActive() then
                StopEchoTabWork()
                return
            end
            FinishEchoBuildQueue()
            if not echoBuildQueue then self:Hide() end
        end)
    end

    echoBuildQueue = {
        buildKey = GetEchoBuildKey(build, assigned),
        assigned = assigned,
        byName = GetEchoBestByName(),
        ctx = BuildEchoOwnershipContext(),
        bundles = build.echoBundles or {},
        metaIdx = 1,
        y = 0,
    }
    echoBuildFrame:Show()
    FinishEchoBuildQueue()
end

local function RefreshEchoOwnershipOnly(assigned)
    if not IsEchoTabActive() then return end
    local child = echoesTabPanel and echoesTabPanel._echoesScrollChild
    if not child or not child._tierRows or #child._tierRows == 0 then return end

    local ctx = BuildEchoOwnershipContext()
    local rowIndex = 0
    for _, meta in ipairs(ST.TIER_META) do
        local echoes = assigned[meta.id] or {}
        if #echoes > 0 then
            rowIndex = rowIndex + 1
            local row = child._tierRows[rowIndex]
            if row then
                ApplyTierRowOwnership(row, echoes, ctx)
            end
        end
    end

    -- Also refresh bundle rows so owned icons update live
    if child._bundleRows then
        for _, row in ipairs(child._bundleRows) do
            if row._echoData then
                ApplyTierRowOwnership(row, row._echoData, ctx)
            end
        end
    end
end

local function ScheduleEchoesRefresh(ownershipOnly)
    if not IsEchoTabActive() then return end
    echoPendingOwnershipOnly = ownershipOnly and true or false
    if not echoRefreshDebounceFrame then
        echoRefreshDebounceFrame = CreateFrame("Frame")
        echoRefreshDebounceFrame:Hide()
        echoRefreshDebounceFrame:SetScript("OnUpdate", function(self, elapsed)
            if not IsEchoTabActive() then
                self:Hide()
                return
            end
            self.wait = (self.wait or 0) + elapsed
            if self.wait < 0.35 then return end
            self.wait = 0
            self:Hide()
            local assigned = CollectBuildTierEchoes(state.build)
            if echoPendingOwnershipOnly then
                if echoesTabPanel and echoesTabPanel._echoStatsBar then
                    RefreshEchoStatsBar(echoesTabPanel._echoStatsBar, assigned)
                end
                local child = echoesTabPanel._echoesScrollChild
                if child and child._tierRows and #child._tierRows > 0 then
                    RefreshEchoOwnershipOnly(assigned)
                else
                    RefreshEchoesTab()
                end
            else
                RefreshEchoesTab()
            end
        end)
    end
    echoRefreshDebounceFrame.wait = 0
    echoRefreshDebounceFrame:Show()
end

RefreshEchoesTab = function()
    if not IsEchoTabActive() then return end
    local build = state.build
    local child = echoesTabPanel and echoesTabPanel._echoesScrollChild
    if not build or not child then return end

    local assigned = CollectBuildTierEchoes(build)
    local buildKey = GetEchoBuildKey(build, assigned)

    if echoesTabPanel and echoesTabPanel._echoStatsBar then
        LayoutEchoStatsBar(echoesTabPanel._echoStatsBar)
        RefreshEchoStatsBar(echoesTabPanel._echoStatsBar, assigned)
    end

    if child._echoBuildKey == buildKey and child._tierRows and #child._tierRows > 0 then
        local scroll = echoesTabPanel._echoesScroll
        local listW = scroll and scroll:GetWidth() or (SL.MAIN_W - SL.PAD * 2)
        if listW and listW > 0 then child:SetWidth(listW) end
        RefreshEchoOwnershipOnly(assigned)
        return
    end

    if echoBuildQueue then
        echoBuildQueue = nil
        if echoBuildFrame then echoBuildFrame:Hide() end
    end

    local scroll = echoesTabPanel._echoesScroll
    local listW = scroll and scroll:GetWidth() or (SL.MAIN_W - SL.PAD * 2)

    local total = 0
    for _, meta in ipairs(ST.TIER_META) do
        total = total + #(assigned[meta.id] or {})
    end

    if total == 0 then
        if child._tierRows then
            for _, row in ipairs(child._tierRows) do
                row:Hide()
                row:SetParent(nil)
            end
        end
        child._tierRows = {}
        child._echoBuildKey = nil
        local empty = child._emptyLabel
        if not empty then
            empty = SW.Label(child, "", 11, SC.textMuted)
            child._emptyLabel = empty
        end
        empty:ClearAllPoints()
        empty:SetPoint("TOPLEFT", 0, 0)
        empty:SetWidth(listW)
        empty:SetJustifyH("LEFT")
        empty:SetText("No echoes assigned to tiers S, A, B or F.\nEdit the build to set echo tiers.")
        empty:Show()
        child:SetHeight(48)
        return
    end
    if child._emptyLabel then child._emptyLabel:Hide() end

    StartDeferredEchoBuild(build, assigned, listW)
end

local function RefreshOverview()
    local build = state.build
    if not build then return end

    build.affixLoadout = build.affixLoadout or {}
    EL.MigrateBuild(build)
    build.talents = build.talents or {}
    build.talentOrder = build.talentOrder or {}

    RefreshBuildHeader(build)
    RefreshBuildStats(build)
    if EbonholdHub.BuildList and EbonholdHub.BuildList.Refresh then
        EbonholdHub.BuildList.Refresh()
    end

    StyleAutomationToggle(viewFrame._autoToggle, build.automationEnabled)

    if viewFrame._autoNeedBtn then
        local gs = EbonholdHubDB and EbonholdHubDB.globalSettings
        StyleAutoNeedToggle(viewFrame._autoNeedBtn, gs and gs.lootAutoNeed == true)
    end

    local desc = build.comments or ""
    local descText = desc ~= "" and desc or "No description."
    if overviewDescLabel then overviewDescLabel:SetText(descText) end
    if overviewDescMeasure then overviewDescMeasure:SetText(descText) end

    local lockedN = EbonholdHub.Build.GetLockedSlotCount()
    local filledLocked = 0
    for i = 1, lockedN do
        local slot = viewFrame._lockedButtons and viewFrame._lockedButtons[i]
        local spellId = build.lockedEchoes and build.lockedEchoes[i]
        if slot then
            if slot._echoBtn then
                slot._echoBtn:Hide()
                slot._echoBtn:SetParent(nil)
            end
            if spellId then
                filledLocked = filledLocked + 1
                local data = ProjectEbonhold.PerkDatabase[spellId]
                local quality = data and data.quality or 2
                local echoName = GetSpellInfo(spellId)
                local btn = SW.CreateEchoIcon(slot, {
                    spellId = spellId,
                    quality = quality,
                    name = echoName,
                    size = SL.ICON_LOCKED - 4,
                    showBadge = false,
                })
                btn:SetPoint("CENTER")
                slot._echoBtn = btn
            else
                local btn = SW.CreateEmptyEchoSlot(slot, SL.ICON_LOCKED)
                btn:SetPoint("CENTER")
                slot._echoBtn = btn
            end
        end
    end
    if viewFrame._lockedCountLabel then
        viewFrame._lockedCountLabel:SetText(filledLocked .. "/" .. lockedN)
    end

    RefreshAffixSlots()
end

local switchAffixes, switchTalents, switchGear, switchEchoes, switchLogbook

local function BuildViewFrame()
    local f = CreateFrame("Frame", "EbonholdHubBuildOverview", UIParent)

    local footerBar, autoToggle, autoNeedBtn = BuildViewFooter(f)
    f._autoToggle = autoToggle
    f._autoNeedBtn = autoNeedBtn
    f._footerBar = footerBar

    local siteBody = CreateFrame("Frame", nil, f)
    siteBody:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0)
    siteBody:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, 0)
    siteBody:SetPoint("BOTTOMLEFT", footerBar, "TOPLEFT", 0, 0)
    siteBody:SetPoint("BOTTOMRIGHT", footerBar, "TOPRIGHT", 0, 0)

    local sidebar = BuildSiteSidebar(siteBody, f)

    local bodyDivider = siteBody:CreateTexture(nil, "ARTWORK")
    bodyDivider:SetTexture(ST.FLAT)
    bodyDivider:SetVertexColor(unpack(SC.border))
    bodyDivider:SetWidth(1)
    bodyDivider:SetPoint("TOPLEFT", sidebar, "TOPRIGHT", 0, 0)
    bodyDivider:SetPoint("BOTTOMLEFT", sidebar, "BOTTOMRIGHT", 0, 0)

    local main = CreateFrame("Frame", nil, siteBody)
    main:SetPoint("TOPLEFT", sidebar, "TOPRIGHT", 1, 0)
    main:SetPoint("BOTTOMRIGHT", 0, 0)
    SW.Fill(main, SC.mainBg)

    local siteTabBar = CreateFrame("Frame", nil, main)
    siteTabBar:SetPoint("TOPLEFT", 0, 0)
    siteTabBar:SetPoint("TOPRIGHT", 0, 0)
    siteTabBar:SetHeight(SITE_TAB_H)
    SW.Fill(siteTabBar, SC.tabBarBg)
    local tabSep = siteTabBar:CreateTexture(nil, "ARTWORK")
    tabSep:SetTexture(ST.FLAT)
    tabSep:SetVertexColor(unpack(SC.border))
    tabSep:SetHeight(1)
    tabSep:SetPoint("BOTTOMLEFT", 0, 0)
    tabSep:SetPoint("BOTTOMRIGHT", 0, 0)

    siteTabState = { selectedIndex = 1 }
    local siteTabBtns = {}
    local tabDefs = {
        { name = "Affixes",      w = 80 },
        { name = "Talents",      w = 80 },
        { name = "Best Gear",    w = 95 },
        { name = "Echoes",       w = 80 },
        { name = "Logbook",      w = 90 },
        { name = "Description",  w = 100 },
    }
    local prevTabBtn
    for i, def in ipairs(tabDefs) do
        local btn = SW.CreateTabButton(siteTabBar, def.name, i, siteTabState)
        btn:SetWidth(def.w)
        if prevTabBtn then
            btn:SetPoint("LEFT", prevTabBtn, "RIGHT", 4, 0)
        else
            btn:SetPoint("LEFT", SL.PAD, 0)
        end
        prevTabBtn = btn
        siteTabBtns[i] = btn
    end

    local tabContent = CreateFrame("Frame", nil, main)
    tabContent:SetPoint("TOPLEFT", siteTabBar, "BOTTOMLEFT", 0, 0)
    tabContent:SetPoint("BOTTOMRIGHT", 0, 0)

    overviewOuter = BuildOverviewMainPanel(tabContent)
    f._affixSlots = overviewOuter._affixSlots
    f._dollHolder = overviewOuter._dollHolder

    local talentsScroll, talentsScrollChild, talentsMismatchLabel, talentsTreesHolder
    talentsTabPanel, talentsScroll, talentsScrollChild, talentsMismatchLabel, talentsTreesHolder =
        BuildTalentsTabPanel(tabContent)

    local gearDollHolder, gearSlots
    gearTabPanel, gearDollHolder, gearSlots = BuildGearTabPanel(tabContent)

    f._gearDollHolder = gearDollHolder
    f._gearSlots = gearSlots
    f._talentsMismatchLabel = talentsMismatchLabel
    f._talentsBody = talentsTabPanel._talentsBody
    f._talentsTreesHolder = talentsTreesHolder
    f._talentsScroll = talentsScroll
    f._talentsScrollChild = talentsScrollChild

    echoesTabPanel = BuildEchoesTabPanel(tabContent)
    logbookTabPanel = BuildLogbookTabPanel(tabContent)
    local descriptionTabPanel = BuildDescriptionTabPanel(tabContent)
    f._updateDescLayout = descriptionTabPanel._updateDescLayout

    local function HideAllContent()
        StopEchoTabWork()
        StopLogbookTabWork()
        overviewOuter:Hide()
        talentsTabPanel:Hide()
        gearTabPanel:Hide()
        echoesTabPanel:Hide()
        logbookTabPanel:Hide()
        descriptionTabPanel:Hide()
        EbonholdHub.SessionHistory.Hide()
    end

    function siteTabState.SelectTab(index)
        siteTabState.selectedIndex = index
        for i, btn in ipairs(siteTabBtns) do
            if i == index then
                btn._underline:Show()
                btn._label:SetTextColor(unpack(SC.accent))
            else
                btn._underline:Hide()
                btn._label:SetTextColor(unpack(SC.textMuted))
            end
        end
        HideAllContent()
        if index == 1 then
            overviewOuter:Show()
            RefreshOverview()
        elseif index == 2 then
            talentsTabPanel:Show()
            RefreshTalentsPreview()
            if f._previewTrees then LayoutPreviewTalentColumns(f._previewTrees) end
        elseif index == 3 then
            gearTabPanel:Show()
            if gearSetStrip then gearSetStrip:Refresh() end
            RefreshGearSlots()
            if f._gearDollHolder then EbonholdHub.AffixDoll.Refresh(f._gearDollHolder) end
        elseif index == 4 then
            echoesTabPanel:Show()
            RefreshEchoesTab()
        elseif index == 5 then
            logbookTabPanel:Show()
            if logbookTabPanel._logbookBody then
                EbonholdHub.SessionHistory.Show(logbookTabPanel._logbookBody)
            end
        elseif index == 6 then
            descriptionTabPanel:Show()
            if f._updateDescLayout then f._updateDescLayout() end
            if overviewDescScroll then overviewDescScroll:SetVerticalScroll(0) end
        end
    end

    switchAffixes = function() siteTabState.SelectTab(1) end
    switchTalents  = function() siteTabState.SelectTab(2) end
    switchGear     = function() siteTabState.SelectTab(3) end
    switchEchoes   = function() siteTabState.SelectTab(4) end
    switchLogbook  = function() siteTabState.SelectTab(5) end

    tabBar = nil

    return f
end


local view = {}

function view.Show(container, context)
    viewFrame:SetParent(container)
    viewFrame:ClearAllPoints()
    viewFrame:SetAllPoints(container)

    context = context or {}
    state.build = context.build or EbonholdHub.Build.GetActive()
    if switchAffixes then switchAffixes() end
    viewFrame:Show()
    ScheduleResizeOverviewHeader()
end



function view.Hide()
    StopEchoTabWork()
    StopLogbookTabWork()
    EbonholdHub.SessionHistory.Hide()
    if viewFrame then viewFrame:Hide() end
end


function EbonholdHub.BuildOverview.Init()
    if viewFrame and not viewFrame._overviewLayoutV44 then
        viewFrame = nil
    end
    if not viewFrame then
        viewFrame = BuildViewFrame()
        viewFrame._overviewLayoutV44 = true
        viewFrame:Hide()
    end
    if AO and not EbonholdHub.BuildOverview._affixRefreshRegistered then
        EbonholdHub.BuildOverview._affixRefreshRegistered = true
        AO.RegisterRefresh(function()
            if viewFrame and viewFrame:IsShown() then
                RefreshAffixSlots()
            end
        end)
    end
    if EO and not EbonholdHub.BuildOverview._echoRefreshRegistered then
        EbonholdHub.BuildOverview._echoRefreshRegistered = true
        EO.RegisterRefresh(function()
            if IsEchoTabActive() then
                ScheduleEchoesRefresh(true)
            end
        end)
    end
    EbonholdHub.ViewRouter.Register("buildOverview", view)
end
