EbonholdHub.TierView = {}

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

-- C_Timer.After does not exist in WotLK 3.3.0. Use a one-shot OnUpdate frame instead.
-- Returns the frame so callers can cancel by calling frame:SetScript("OnUpdate", nil).
local function AfterDelay(delay, func)
    local elapsed = 0
    local f = CreateFrame("Frame")
    f:SetScript("OnUpdate", function(self, dt)
        elapsed = elapsed + dt
        if elapsed >= delay then
            self:SetScript("OnUpdate", nil)
            func()
        end
    end)
    return f
end

local CLASS_BITS = {
    WARRIOR = 1, PALADIN = 2, HUNTER = 4, ROGUE = 8, PRIEST = 16,
    DEATHKNIGHT = 32, SHAMAN = 64, MAGE = 128, WARLOCK = 256, DRUID = 1024,
}

-- ── Constants ─────────────────────────────────────────────────────────────────

local ICON_SIZE    = 32
local ICON_PAD     = 3
local ICON_STEP    = ICON_SIZE + ICON_PAD
local TIER_LABEL_W = 24
local TIER_PAD     = 4
local POOL_ICON_SIZE = 28
local POOL_ICON_STEP = POOL_ICON_SIZE + ICON_PAD

local 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 = "C", label = "C", color = { 0.45, 0.75, 0.45 } },
    { id = "F", label = "F", color = { 0.5,  0.2,  0.2  } },
}

local MAX_PICKS_CYCLE = { 1, 2, 3, 0 }  -- 0 = unlimited

local QUALITY_LABEL = {
    [0] = "Common", [1] = "Uncommon", [2] = "Rare", [3] = "Epic", [4] = "Legendary",
}
local QUALITY_COLOR = {
    [0] = { 1.0,  1.0,  1.0  },
    [1] = { 0.12, 1.0,  0.0  },
    [2] = { 0.0,  0.44, 0.87 },
    [3] = { 0.64, 0.21, 0.93 },
    [4] = { 1.0,  0.82, 0.0  },
}

-- ── Quality-key helpers (mirrors TierData) ────────────────────────────────────

local function TierKey(name, quality)
    if quality ~= nil then
        return name .. "|" .. tostring(math.floor(tonumber(quality) or 0))
    end
    return name
end

local function ParseTierKey(key)
    local name, q = key:match("^(.+)|(%d+)$")
    if name then return name, tonumber(q) end
    return key, nil
end

-- ── State ─────────────────────────────────────────────────────────────────────

local viewFrame
local leftPanel
local tierRows = {}
local poolFrame, poolScrollChild, poolScrollFrame
local poolIconFrames = {}  -- recycling list: frames are reused, never orphaned
local searchBox          -- pool window search
local tierSearchBox      -- main tier list search
local tierSearchSummary  -- "Contagion — Tier A" when filtering
local toggleBtn  -- header button that opens/closes pool window

-- Stored pixel widths (set in BuildLayout, used by rebuild functions
-- instead of GetWidth() which returns 0 before first layout pass)
local storedLeftW  = 300
local storedRightW = 140

-- Pool filter state
local poolFilterQuality   = -1    -- -1 = All, 0-4 = specific quality
local poolFilterMyClass   = false -- show only echoes for current build class
local poolFilterOwnedOnly = false -- show only echoes the character has unlocked

-- Drag state
local dragGhost      = nil
local dragName       = nil
local dragKey        = nil   -- original tier-storage key (may be "name|quality" or bare "name")
local dragSpellId    = nil
local dragQuality    = nil
local dragSourceTier = nil
local dragFrame      = nil

-- Bundle UI state
local bundleRowsList      = {}  -- { {bundleId, tier, frame}, ... } rebuilt each RebuildView
local bundleDynamicFrames = {}  -- all dynamically created bundle frames (for cleanup)

-- Bundle drag-to-reorder state
local dragBundleId    = nil
local dragBundleTier  = nil
local dragBundleGhost = nil

-- Context menu (forward declarations — defined later, referenced in MakeTierIcon/MakePoolIcon)
local HideContextMenu
local ShowContextMenu

-- Bundle (forward declarations — defined later, referenced in MakeTierIcon/MakePoolIcon)
local HitTestBundle
local FinishBundleDrop
local StartBundleDrag
local FinishBundleReorder
local DestroyBundleGhost

-- ── Helpers ───────────────────────────────────────────────────────────────────

local function GetLockedIds()
    local locked = {}
    local build = EbonholdHub.Build.GetActive()
    local list = (build and build.lockedEchoes) or {}
    for _, id in ipairs(list) do
        if id then locked[id] = true end
    end
    return locked
end

local function IsLocked(spellId)
    return EbonholdHub.Scoring.IsLocked(spellId)
end

local function GetTierSearchText()
    if not tierSearchBox then return "" end
    return strlower(strtrim(tierSearchBox:GetText() or ""))
end

local function EntryMatchesSearch(entry, search)
    if search == "" then return true end
    return entry.name and entry.name:lower():find(search, 1, true) ~= nil
end

local function FilterEntriesBySearch(entries, search)
    if search == "" then return entries end
    local out = {}
    for _, entry in ipairs(entries) do
        if EntryMatchesSearch(entry, search) then
            out[#out + 1] = entry
        end
    end
    return out
end

local function BuildUnassignedSearchEntries(search)
    if search == "" then return {} end
    local list
    if EbonholdHub.EchoTableRows and EbonholdHub.EchoTableRows.BuildAllQualitiesList then
        list = EbonholdHub.EchoTableRows.BuildAllQualitiesList()
    else
        list = EbonholdHub.EchoTableRows and EbonholdHub.EchoTableRows.BuildSortedList() or {}
    end
    local out = {}
    for _, e in ipairs(list) do
        if e.name and e.name:lower():find(search, 1, true)
           and EbonholdHub.TierData.GetTier(e.name, e.quality) == "C" then
            out[#out + 1] = {
                name    = e.name,
                key     = TierKey(e.name, e.quality),
                spellId = e.spellId,
                quality = e.quality,
            }
        end
    end
    return out
end

local function UpdateTierSearchSummary(search, assigned)
    if not tierSearchSummary then return end
    if search == "" then
        tierSearchSummary:SetText("")
        tierSearchSummary:Hide()
        return
    end

    local parts = {}
    for _, meta in ipairs(TIER_META) do
        if meta.id ~= "C" then
            for _, entry in ipairs(assigned[meta.id] or {}) do
                if EntryMatchesSearch(entry, search) then
                    parts[#parts + 1] = entry.name .. " — Tier " .. meta.id
                end
            end
        end
    end
    for _, entry in ipairs(assigned.C or {}) do
        parts[#parts + 1] = entry.name .. " — Unassigned"
    end

    if #parts == 0 then
        tierSearchSummary:SetText("|cffff6666No echoes match \"" .. search .. "\"|r")
    elseif #parts == 1 then
        tierSearchSummary:SetText("|cff30bff5" .. parts[1] .. "|r")
    else
        tierSearchSummary:SetText("|cff30bff5" .. #parts .. " matches:|r " .. table.concat(parts, ", "))
    end
    tierSearchSummary:Show()
end

local function BuildPoolEntries(searchText)
    -- Use per-quality list so each quality variant of an echo is a separate entry.
    -- Fall back to BuildSortedList if BuildAllQualitiesList is unavailable.
    local list
    if EbonholdHub.EchoTableRows and EbonholdHub.EchoTableRows.BuildAllQualitiesList then
        list = EbonholdHub.EchoTableRows.BuildAllQualitiesList()
    else
        list = EbonholdHub.EchoTableRows and EbonholdHub.EchoTableRows.BuildSortedList() or {}
    end
    local lockedIds = GetLockedIds()

    local build = EbonholdHub.Build.GetActive()
    local buildClass = build and build.class

    local playerClassBit = nil
    if poolFilterMyClass and buildClass then
        playerClassBit = CLASS_BITS[buildClass]
    end

    -- Collect tome-owned sets once before the entry loop.
    -- Uses only cachedPerkCounts (Echo Journal unlocks), not per-run granted perks.
    local ownedLower, ownedGroups, ownedSpellIds
    if poolFilterOwnedOnly and EbonholdHub.EchoOwnership then
        ownedLower, ownedGroups, ownedSpellIds =
            EbonholdHub.EchoOwnership.CollectTomeOwnedSets()
    end

    local search = searchText and searchText:lower() or ""

    local entries = {}
    for _, e in ipairs(list) do
        local skip = false

        -- Quality filter
        if poolFilterQuality >= 0 and (e.quality or 0) ~= poolFilterQuality then
            skip = true
        end

        -- Class filter
        if not skip and playerClassBit and e.classMask and e.classMask ~= 0 then
            if bit.band(e.classMask, playerClassBit) == 0 then
                skip = true
            end
        end

        -- Owned filter
        if not skip and poolFilterOwnedOnly and EbonholdHub.EchoOwnership then
            if not EbonholdHub.EchoOwnership.IsOwnedFromSets(
                    e.name, e, ownedLower, ownedGroups, ownedSpellIds) then
                skip = true
            end
        end

        -- Search filter
        if not skip and search ~= "" and not e.name:lower():find(search, 1, true) then
            skip = true
        end

        if not skip then
            entries[#entries + 1] = {
                name     = e.name,
                spellId  = e.spellId,
                quality  = e.quality,
                locked   = lockedIds[e.spellId] and true or false,
            }
        end
    end
    return entries
end

-- ── Drag-and-drop ─────────────────────────────────────────────────────────────

local function DestroyGhost()
    if dragGhost then
        dragGhost:Hide()
        dragGhost:SetParent(nil)
        dragGhost = nil
    end
    if dragFrame then
        dragFrame:SetScript("OnUpdate", nil)
        dragFrame:Hide()
    end
end

local function CreateGhost(spellId, quality)
    DestroyGhost()
    dragGhost = CreateFrame("Frame", nil, UIParent)
    dragGhost:SetSize(ICON_SIZE, ICON_SIZE)
    dragGhost:SetFrameStrata("TOOLTIP")
    SW.Fill(dragGhost, SC.elementBg)
    local icon = dragGhost:CreateTexture(nil, "ARTWORK")
    icon:SetAllPoints(dragGhost)
    icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    if spellId then
        local _, _, tex = GetSpellInfo(spellId)
        if tex then icon:SetTexture(tex) end
    end
    dragGhost:SetAlpha(0.75)
    dragGhost:Show()

    if not dragFrame then
        dragFrame = CreateFrame("Frame", nil, UIParent)
    end
    dragFrame:SetScript("OnUpdate", function()
        if not dragGhost then return end
        local x, y = GetCursorPosition()
        local scale = UIParent:GetEffectiveScale()
        x, y = x / scale, y / scale
        dragGhost:ClearAllPoints()
        dragGhost:SetPoint("CENTER", UIParent, "BOTTOMLEFT", x, y)
    end)
    dragFrame:Show()
end

local function HitTestTier(x, y)
    for _, row in ipairs(tierRows) do
        local f = row.frame
        if f and f:IsVisible() then
            local left   = f:GetLeft()
            local right  = f:GetRight()
            local top    = f:GetTop()
            local bottom = f:GetBottom()
            if left and right and top and bottom then
                if x >= left and x <= right and y >= bottom and y <= top then
                    return row.id
                end
            end
        end
    end
    return nil
end

local function HitTestPool(x, y)
    if not poolFrame or not poolFrame:IsVisible() then return false end
    local left   = poolFrame:GetLeft()
    local right  = poolFrame:GetRight()
    local top    = poolFrame:GetTop()
    local bottom = poolFrame:GetBottom()
    if left and right and top and bottom then
        return x >= left and x <= right and y >= bottom and y <= top
    end
    return false
end

local function GetDropPositionInTier(tierId, cursorX, cursorY)
    for _, row in ipairs(tierRows) do
        if row.id == tierId and row.iconHolder then
            local holder = row.iconHolder
            local icons  = row._icons or {}
            if #icons == 0 then return 1 end

            -- Figure out how many columns we use in this tier row
            local availW = storedLeftW - TIER_LABEL_W - TIER_PAD * 2
            local cols   = math.max(1, math.floor(availW / ICON_STEP))

            -- Determine which visual row the cursor is on
            local holderTop = holder:GetTop()
            local targetRow = 0
            if holderTop and cursorY then
                local dy = holderTop - cursorY   -- pixels below top of holder
                targetRow = math.max(0, math.floor(dy / ICON_STEP))
            end

            -- Find the icon at that row, iterate by logical index
            for i, btn in ipairs(icons) do
                local iconRow = math.floor((i - 1) / cols)
                if iconRow == targetRow then
                    local cx = btn:GetLeft()
                    if cx and cursorX < (cx + ICON_STEP / 2) then
                        return i
                    end
                elseif iconRow > targetRow then
                    -- Cursor is past all icons in targetRow — insert at end of that row
                    return i
                end
            end

            return #icons + 1
        end
    end
    return 1
end

local RebuildView  -- forward declaration

local function FinishDrop(tierId, pos)
    if not dragName then return end

    -- Determine which quality key to use:
    -- • Dragging FROM pool (dragSourceTier == nil): always quality-specific.
    -- • Dragging FROM a tier row: preserve the original key type so legacy
    --   name-only entries stay as name-only when reordered, and quality-specific
    --   entries keep their quality when moved between tiers.
    local effectiveQuality
    if dragSourceTier == nil then
        effectiveQuality = dragQuality  -- from pool: quality-specific
    else
        _, effectiveQuality = ParseTierKey(dragKey or dragName)  -- from tier: use original key's quality
    end

    if tierId then
        EbonholdHub.TierData.InsertAt(dragName, tierId, pos or 1, effectiveQuality)
    else
        EbonholdHub.TierData.SetTier(dragName, "C", effectiveQuality)
    end
    dragName = nil
    dragKey  = nil
    dragSpellId = nil
    dragQuality = nil
    dragSourceTier = nil
    DestroyGhost()
    RebuildView()
end

local function StartDrag(name, key, spellId, quality, sourceTier)
    dragName = name
    dragKey  = key
    dragSpellId = spellId
    dragQuality = quality
    dragSourceTier = sourceTier
    CreateGhost(spellId, quality)
end

-- ── Icon button factory ───────────────────────────────────────────────────────

local function MakeTierIcon(parent, entry, tierId)
    local btn = UI.CreateIconButton(parent, ICON_SIZE)
    btn._tierEntry = entry

    local _, _, tex = GetSpellInfo(entry.spellId)
    if tex then btn.icon:SetTexture(tex) end
    UI.SetIconButtonQuality(btn, entry.quality)

    -- ×N badge (not shown for F tier) — click to cycle max picks per run
    if tierId ~= "F" then
        local badgeBg = CreateFrame("Button", nil, btn)
        badgeBg:SetSize(18, 14)
        badgeBg:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", 0, 0)
        badgeBg:SetFrameLevel(btn:GetFrameLevel() + 2)
        badgeBg:EnableMouse(true)
        badgeBg:RegisterForClicks("LeftButtonUp")
        SW.Fill(badgeBg, SC.bgHeader)
        SW.ThinBorder(badgeBg, SC.border, 1)

        local badge = SW.Label(badgeBg, "x1", 9, SC.textDim)
        badge:SetPoint("CENTER", badgeBg, "CENTER", 0, 0)
        btn._badge = badge

        local function UpdateBadge()
            local mp = EbonholdHub.TierData.GetMaxPicks(entry.name)
            if mp == 0 then
                badge:SetText("x∞")
                badge:SetTextColor(0.9, 0.75, 0.1)
            elseif mp > 1 then
                badge:SetText("x" .. mp)
                badge:SetTextColor(0.4, 0.9, 1.0)
            else
                badge:SetText("x1")
                badge:SetTextColor(unpack(C.textDim))
            end
        end
        UpdateBadge()

        badgeBg:SetScript("OnClick", function(self, button)
            if button ~= "LeftButton" then return end
            local cur = EbonholdHub.TierData.GetMaxPicks(entry.name)
            local next = 1
            for i, v in ipairs(MAX_PICKS_CYCLE) do
                if v == cur then
                    next = MAX_PICKS_CYCLE[i % #MAX_PICKS_CYCLE + 1]
                    break
                end
            end
            EbonholdHub.TierData.SetMaxPicks(entry.name, next)
            UpdateBadge()
        end)

        badgeBg:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, "ANCHOR_CURSOR")
            GameTooltip:ClearLines()
            GameTooltip:SetText("Max picks per run", 1, 1, 1)
            GameTooltip:AddLine("Click to cycle:  x1  >>  x2  >>  x3  >>  x(inf)", 0.8, 0.8, 0.8, true)
            local mp = EbonholdHub.TierData.GetMaxPicks(entry.name)
            if mp == 0 then
                GameTooltip:AddLine("Current: unlimited", 0.9, 0.75, 0.1)
            else
                GameTooltip:AddLine("Current: up to " .. mp .. " per run", 0.4, 0.9, 1.0)
            end
            GameTooltip:Show()
        end)
        badgeBg:SetScript("OnLeave", function() GameTooltip:Hide() end)
    end

    -- Drag start — pass entry.key so FinishDrop knows the original storage key
    btn:SetScript("OnMouseDown", function(self, button)
        if button == "LeftButton" then
            StartDrag(entry.name, entry.key, entry.spellId, entry.quality, tierId)
        elseif button == "RightButton" then
            HideContextMenu()
            local items = {
                {
                    label  = "Trash all unassigned",
                    action = function()
                        EbonholdHub.TierData.TrashUnassigned(entry.name)
                        RebuildView()
                    end,
                },
                {
                    label  = "Remove from tier",
                    action = function()
                        EbonholdHub.TierData.SetTier(entry.name, "C", entry.quality)
                        RebuildView()
                    end,
                },
            }
            ShowContextMenu(entry.name, entry.quality, tierId, items)
        end
    end)
    btn:SetScript("OnMouseUp", function(self, button)
        if button == "LeftButton" and dragName then
            local scale = UIParent:GetEffectiveScale()
            local x, y = GetCursorPosition()
            x, y = x / scale, y / scale
            local dropBundle = HitTestBundle(x, y)
            if dropBundle then
                FinishBundleDrop(dropBundle)
            else
                local dropTier = HitTestTier(x, y)
                if dropTier then
                    local pos = GetDropPositionInTier(dropTier, x, y)
                    FinishDrop(dropTier, pos)
                elseif HitTestPool(x, y) then
                    FinishDrop(nil)
                else
                    FinishDrop(nil)
                end
            end
        end
    end)

    -- Tooltip
    btn:HookScript("OnEnter", function(self)
        EbonholdHub.EchoTableRows.ShowSpellTooltip(self, entry.spellId, "ANCHOR_CURSOR")
    end)
    btn:HookScript("OnLeave", function(self)
        GameTooltip:Hide()
    end)

    return btn
end

-- Creates a blank pool icon frame with entry-independent scripts.
-- Reused across rebuilds via poolIconFrames; entry data is set by BindPoolIconEntry.
local function CreatePoolIconFrame(parent)
    local btn = UI.CreateIconButton(parent, POOL_ICON_SIZE)

    -- Lock label: pre-created, shown/hidden per entry in BindPoolIconEntry.
    local lockLabel = SW.Label(btn, "L", 8, SC.textDim)
    lockLabel:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", 1, 1)
    lockLabel:Hide()
    btn._lockLabel = lockLabel

    -- Scripts reference btn._poolEntry so the same frame can serve different entries.
    btn:SetScript("OnMouseDown", function(self, button)
        local e = self._poolEntry
        if not e or e.locked then return end
        if button == "LeftButton" then
            StartDrag(e.name, e.name, e.spellId, e.quality, nil)
        elseif button == "RightButton" then
            HideContextMenu()
            local items = {
                {
                    label  = "Send to F tier",
                    action = function()
                        EbonholdHub.TierData.SetTier(e.name, "F", e.quality)
                        RebuildView()
                    end,
                },
                {
                    label  = "Trash all unassigned",
                    action = function()
                        EbonholdHub.TierData.TrashUnassigned(e.name)
                        RebuildView()
                    end,
                },
            }
            ShowContextMenu(e.name, e.quality, nil, items)
        end
    end)
    btn:SetScript("OnMouseUp", function(self, button)
        if button == "LeftButton" and dragName then
            local scale = UIParent:GetEffectiveScale()
            local x, y = GetCursorPosition()
            x, y = x / scale, y / scale
            local dropBundle = HitTestBundle(x, y)
            if dropBundle then
                FinishBundleDrop(dropBundle)
            else
                local dropTier = HitTestTier(x, y)
                if dropTier then
                    local pos = GetDropPositionInTier(dropTier, x, y)
                    FinishDrop(dropTier, pos)
                else
                    FinishDrop(nil)
                end
            end
        end
    end)
    btn:HookScript("OnEnter", function(self)
        local e = self._poolEntry
        if e then EbonholdHub.EchoTableRows.ShowSpellTooltip(self, e.spellId, "ANCHOR_CURSOR") end
    end)
    btn:HookScript("OnLeave", function()
        GameTooltip:Hide()
    end)

    return btn
end

-- Rebinds an existing pool icon frame to a new entry without creating new textures.
local function BindPoolIconEntry(btn, entry)
    btn._poolEntry = entry
    local _, _, tex = GetSpellInfo(entry.spellId)
    if tex then btn.icon:SetTexture(tex) end
    UI.SetIconButtonQuality(btn, entry.quality)
    if entry.locked then
        btn._bg:SetAlpha(0.4)
        btn.icon:SetAlpha(0.5)
        if btn._lockLabel then btn._lockLabel:Show() end
    else
        btn._bg:SetAlpha(1.0)
        btn.icon:SetAlpha(1.0)
        if btn._lockLabel then btn._lockLabel:Hide() end
    end
    btn:Show()
end

-- ── Bundle rows ───────────────────────────────────────────────────────────────
-- Bundles are displayed as fixed-width cards laid out horizontally.
-- Leftmost card = highest priority (first match in GetBundleBoost).
-- Drag the priority number handle left/right to swap priority.

-- Slot / card layout constants
local BUNDLE_MAX_SUPPORT = 3    -- support echo slots per bundle
local ARROW_W            = 20   -- px for the "→" separator
local SLOT_PAD           = 3    -- gap between support slots
local BUNDLE_CARD_H      = ICON_SIZE + TIER_PAD * 2
local BUNDLE_CARD_GAP    = 6    -- horizontal gap between cards
-- Card width: label + pad + main + arrow + 3*(icon+gap) + pad + del + pad
local BUNDLE_CARD_W      = TIER_LABEL_W + TIER_PAD
                         + ICON_SIZE + ARROW_W
                         + BUNDLE_MAX_SUPPORT * (ICON_SIZE + SLOT_PAD)
                         + TIER_PAD + 20 + TIER_PAD   -- del btn

HitTestBundle = function(x, y)
    for _, brow in ipairs(bundleRowsList) do
        local f = brow.frame
        if f and f:IsShown() then
            local left, bottom, width, height = f:GetRect()
            if left and x >= left and x <= left + width and y >= bottom and y <= bottom + height then
                return brow.bundleId
            end
        end
    end
    return nil
end

FinishBundleDrop = function(bundleId)
    if not dragName then return end
    if dragBundleId then return end  -- bundle reorder takes priority
    HideContextMenu()
    EbonholdHub.TierData.AddEchoToBundle(bundleId, dragName, dragQuality)
    dragName = nil; dragKey = nil; dragSpellId = nil; dragQuality = nil; dragSourceTier = nil
    DestroyGhost()
    RebuildView()
end

DestroyBundleGhost = function()
    if dragBundleGhost then
        dragBundleGhost:SetScript("OnUpdate", nil)
        dragBundleGhost:SetParent(nil)
        dragBundleGhost:Hide()
        dragBundleGhost = nil
    end
end

FinishBundleReorder = function(x, y)
    local srcId = dragBundleId
    local tier  = dragBundleTier
    dragBundleId   = nil
    dragBundleTier = nil
    DestroyBundleGhost()
    if not srcId then return end
    -- Find which bundle card is under the cursor (same tier)
    for _, bc in ipairs(bundleRowsList) do
        if bc.tier == tier and bc.frame and bc.frame:IsShown() then
            local left, bottom, w, h = bc.frame:GetRect()
            if left and x >= left and x <= left + w and y >= bottom and y <= bottom + h then
                if bc.bundleId ~= srcId then
                    EbonholdHub.TierData.SwapBundles(srcId, bc.bundleId)
                end
                break
            end
        end
    end
    RebuildView()
end

StartBundleDrag = function(bundleId, tier)
    if dragName then return end  -- echo drag has priority
    dragBundleId   = bundleId
    dragBundleTier = tier

    dragBundleGhost = CreateFrame("Frame", nil, UIParent)
    dragBundleGhost:SetSize(BUNDLE_CARD_W, BUNDLE_CARD_H)
    dragBundleGhost:SetFrameStrata("TOOLTIP")
    dragBundleGhost:SetAlpha(0.65)
    SW.Fill(dragBundleGhost, SC.statCellBg)
    SW.ThinBorder(dragBundleGhost, SC.accent or { 0.3, 0.6, 1.0, 0.9 }, 1)
    local lbl = SW.Label(dragBundleGhost, "Bundle  " .. (tier or ""), 11, SC.textDim)
    lbl:SetPoint("CENTER", dragBundleGhost, "CENTER", 0, 0)

    dragBundleGhost:SetScript("OnUpdate", function(self)
        local scale = UIParent:GetEffectiveScale()
        local cx, cy = GetCursorPosition()
        cx, cy = cx / scale, cy / scale
        self:SetPoint("CENTER", UIParent, "BOTTOMLEFT", cx, cy)
        if not IsMouseButtonDown("LeftButton") then
            FinishBundleReorder(cx, cy)
        end
    end)
end

local function MakeBundleIcon(parent, echoName, bundleId, spellId, quality)
    local btn = UI.CreateIconButton(parent, ICON_SIZE)
    if spellId then
        local _, _, tex = GetSpellInfo(spellId)
        if tex then btn.icon:SetTexture(tex) end
    end
    UI.SetIconButtonQuality(btn, quality)

    -- MaxPicks badge (bottom-right, same size/style as tier icons)
    local badgeBg = CreateFrame("Frame", nil, btn)
    badgeBg:SetSize(18, 14)
    badgeBg:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", 0, 0)
    badgeBg:SetFrameLevel(btn:GetFrameLevel() + 3)
    SW.Fill(badgeBg, SC.bgHeader)
    SW.ThinBorder(badgeBg, SC.border, 1)

    local badge = SW.Label(badgeBg, "x1", 9, SC.textDim)
    badge:SetPoint("CENTER", badgeBg, "CENTER", 0, 0)

    local function UpdateBundleBadge()
        local mp = EbonholdHub.TierData.GetMaxPicks(echoName)
        if mp == 0 then
            badge:SetText("x*")
            badge:SetTextColor(0.9, 0.75, 0.1)
        elseif mp > 1 then
            badge:SetText("x" .. mp)
            badge:SetTextColor(0.4, 0.9, 1.0)
        else
            badge:SetText("x1")
            badge:SetTextColor(unpack(C.textDim))
        end
    end
    UpdateBundleBadge()

    badgeBg:EnableMouse(true)
    badgeBg:SetScript("OnMouseDown", function(self, button)
        if button == "LeftButton" then
            local cur = EbonholdHub.TierData.GetMaxPicks(echoName)
            local next = 1
            for i, v in ipairs(MAX_PICKS_CYCLE) do
                if v == cur then next = MAX_PICKS_CYCLE[i % #MAX_PICKS_CYCLE + 1]; break end
            end
            EbonholdHub.TierData.SetMaxPicks(echoName, next)
            UpdateBundleBadge()
        end
    end)
    badgeBg:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_CURSOR")
        GameTooltip:SetText("Max picks per run", 1, 1, 1)
        GameTooltip:AddLine("Left-click to cycle:  x1 >> x2 >> x3 >> x*(unlimited)", 0.8, 0.8, 0.8, true)
        local mp = EbonholdHub.TierData.GetMaxPicks(echoName)
        if mp == 0 then
            GameTooltip:AddLine("Current: unlimited", 0.9, 0.75, 0.1)
        else
            GameTooltip:AddLine("Current: up to " .. mp .. " per run", 0.4, 0.9, 1.0)
        end
        GameTooltip:Show()
    end)
    badgeBg:SetScript("OnLeave", function() GameTooltip:Hide() end)

    btn:SetScript("OnMouseDown", function(self, button)
        if button == "RightButton" then
            HideContextMenu()
            local items = {
                {
                    label  = "Remove from bundle",
                    action = function()
                        EbonholdHub.TierData.RemoveEchoFromBundle(bundleId, echoName)
                        RebuildView()
                    end,
                },
            }
            ShowContextMenu(echoName, quality, nil, items)
        end
    end)
    btn:HookScript("OnEnter", function()
        if spellId and EbonholdHub.EchoTableRows then
            EbonholdHub.EchoTableRows.ShowSpellTooltip(btn, spellId, "ANCHOR_CURSOR")
        end
    end)
    btn:HookScript("OnLeave", function() GameTooltip:Hide() end)
    return btn
end

-- slotQuality: numeric quality stored in the bundle key (nil = any quality).
-- When slotQuality is set, uses the quality-specific spellId and shows a
-- quality dot so the player can see which quality this slot requires.
local function MakeBundleSlot(parent, bundleId, echoName, isMain, entry, slotQuality)
    -- Resolve the base echo name (strip quality suffix from key)
    local baseName = echoName and (ParseTierKey(echoName)) or nil

    local slot = CreateFrame("Frame", nil, parent)
    slot:SetSize(ICON_SIZE, ICON_SIZE)
    slot:EnableMouse(true)

    if entry and baseName then
        -- Use quality-specific spellId when available, else fall back to default
        local spellId = entry.spellId
        if slotQuality ~= nil and entry.spellIds and entry.spellIds[slotQuality] then
            spellId = entry.spellIds[slotQuality]
        end
        local btn = MakeBundleIcon(slot, baseName, bundleId, spellId, slotQuality ~= nil and slotQuality or entry.quality)
        btn:SetAllPoints(slot)
        if isMain then
            SW.ThinBorder(slot, { 1.0, 0.82, 0.0, 0.7 }, 1)
            local starBg = CreateFrame("Frame", nil, btn)
            starBg:SetSize(13, 11)
            starBg:SetPoint("TOPLEFT", btn, "TOPLEFT", 0, 0)
            starBg:SetFrameLevel(btn:GetFrameLevel() + 2)
            SW.Fill(starBg, { 0.08, 0.06, 0.0, 0.9 })
            local starLbl = SW.Label(starBg, "M", 7, { 1.0, 0.82, 0.0 })
            starLbl:SetPoint("CENTER", starBg, "CENTER", 0, 0)
        else
            SW.ThinBorder(slot, { 0.4, 0.4, 0.55, 0.5 }, 1)
        end
    else
        local borderColor = isMain and { 1.0, 0.82, 0.0, 0.35 } or { 0.35, 0.35, 0.45, 0.35 }
        SW.Fill(slot, { 0.08, 0.08, 0.10, 0.4 })
        SW.ThinBorder(slot, borderColor, 1)
        local hint = SW.Label(slot, isMain and "M" or "+", 11,
            isMain and { 1.0, 0.82, 0.0, 0.5 } or { 0.4, 0.4, 0.5, 0.6 })
        hint:SetPoint("CENTER", slot, "CENTER", 0, 0)
        slot:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, "ANCHOR_CURSOR")
            if isMain then
                GameTooltip:SetText("Main echo slot", 1.0, 0.82, 0.0)
                GameTooltip:AddLine("Drag here first. When this echo drops in a run,\nsupport echoes become top priority.", 0.8, 0.8, 0.8, true)
            else
                GameTooltip:SetText("Support echo slot", 0.7, 0.9, 1.0)
                GameTooltip:AddLine("Boosted once the main echo [M] is owned.", 0.8, 0.8, 0.8, true)
            end
            GameTooltip:Show()
        end)
        slot:SetScript("OnLeave", function() GameTooltip:Hide() end)
        slot:SetScript("OnMouseUp", function(self, button)
            if button == "LeftButton" then
                if dragBundleId then
                    local scale = UIParent:GetEffectiveScale()
                    local x, y = GetCursorPosition()
                    FinishBundleReorder(x / scale, y / scale)
                elseif dragName then
                    FinishBundleDrop(bundleId)
                end
            end
        end)
    end
    return slot
end

local function BuildBundleCard(parent, bundle, meta, byName, priority)
    local tc     = meta.color
    local frameH = BUNDLE_CARD_H
    local slotY  = -(frameH / 2 - ICON_SIZE / 2)

    local frame = CreateFrame("Frame", nil, parent)
    frame:SetSize(BUNDLE_CARD_W, frameH)
    SW.Fill(frame, SC.statCellBg)
    SW.ThinBorder(frame, { tc[1] * 0.5, tc[2] * 0.5, tc[3] * 0.5, 0.5 }, 1)

    -- Drag handle — shows priority number, gold; left-click to drag-reorder
    local labelFrame = CreateFrame("Frame", nil, frame)
    labelFrame:SetPoint("TOPLEFT",    frame, "TOPLEFT",    0, 0)
    labelFrame:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0)
    labelFrame:SetWidth(TIER_LABEL_W)
    labelFrame:EnableMouse(true)
    SW.Fill(labelFrame, { tc[1], tc[2], tc[3], 0.12 })
    SW.ThinBorder(labelFrame, { tc[1], tc[2], tc[3], 0.25 }, 1)
    local labelText = SW.Label(labelFrame, tostring(priority or "?"), 10, { 1.0, 0.82, 0.0, 1.0 }, false, "semibold")
    labelText:SetPoint("CENTER", labelFrame, "CENTER", 0, 0)
    labelFrame:SetScript("OnMouseDown", function(self, button)
        if button == "LeftButton" then StartBundleDrag(bundle.id, bundle.tier) end
    end)
    labelFrame:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_CURSOR")
        GameTooltip:SetText("Drag to reorder", 0.7, 0.9, 1.0)
        GameTooltip:AddLine("Left bundle = higher priority.", 0.8, 0.8, 0.8, true)
        GameTooltip:Show()
    end)
    labelFrame:SetScript("OnLeave", function() GameTooltip:Hide() end)

    -- Delete button (top-right)
    local delBtn = SW.CreateOutlineButton(frame, "×", 20)
    delBtn:SetHeight(20)
    delBtn:SetPoint("RIGHT", frame, "RIGHT", -TIER_PAD, 0)
    delBtn:SetScript("OnClick", function()
        EbonholdHub.TierData.RemoveBundle(bundle.id)
        RebuildView()
    end)
    delBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_CURSOR")
        GameTooltip:SetText("Delete bundle", 1, 0.4, 0.4)
        GameTooltip:Show()
    end)
    delBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)

    -- Main slot — bundle.echoes[1] may be "name" or "name|quality"
    local slotX    = TIER_LABEL_W + TIER_PAD
    local mainKey   = bundle.echoes[1]
    local mainBase, mainQ = ParseTierKey(mainKey or "")
    local mainEntry = mainBase and byName[mainBase] or nil
    local mainSlot  = MakeBundleSlot(frame, bundle.id, mainKey, true, mainEntry, mainQ)
    mainSlot:SetPoint("TOPLEFT", frame, "TOPLEFT", slotX, slotY)

    -- Arrow separator
    local arrow = SW.Label(frame, ">", 12, { 0.4, 0.4, 0.5, 0.8 })
    arrow:SetPoint("LEFT", mainSlot, "RIGHT", 2, 0)

    -- Support slots
    for i = 1, BUNDLE_MAX_SUPPORT do
        local ekey  = bundle.echoes[i + 1]
        local ebase, eqty = ParseTierKey(ekey or "")
        local eentry = ebase and byName[ebase] or nil
        local sslot = MakeBundleSlot(frame, bundle.id, ekey, false, eentry, eqty)
        local ox    = slotX + ICON_SIZE + ARROW_W + (i - 1) * (ICON_SIZE + SLOT_PAD)
        sslot:SetPoint("TOPLEFT", frame, "TOPLEFT", ox, slotY)
    end

    -- Whole card: echo drop + bundle reorder catch-all
    frame:EnableMouse(true)
    frame:SetScript("OnMouseUp", function(self, button)
        if button == "LeftButton" then
            if dragBundleId then
                local scale = UIParent:GetEffectiveScale()
                local x, y = GetCursorPosition()
                FinishBundleReorder(x / scale, y / scale)
            elseif dragName then
                FinishBundleDrop(bundle.id)
            end
        end
    end)

    return frame
end

local function BuildNewBundleButton(parent, meta)
    local frame = CreateFrame("Frame", nil, parent)
    frame:SetSize(BUNDLE_CARD_W, BUNDLE_CARD_H)
    local tc = meta.color

    SW.Fill(frame, { SC.statCellBg[1], SC.statCellBg[2], SC.statCellBg[3], 0.4 })
    SW.ThinBorder(frame, { tc[1], tc[2], tc[3], 0.2 }, 1)

    local btn = SW.CreateOutlineButton(frame, "+ New Bundle", 110)
    btn:SetHeight(22)
    btn:SetPoint("CENTER", frame, "CENTER", 0, 0)
    if btn._label then btn._label:SetTextColor(tc[1], tc[2], tc[3]) end
    btn:SetScript("OnClick", function()
        EbonholdHub.TierData.AddBundle(meta.id)
        RebuildView()
    end)
    btn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_CURSOR")
        GameTooltip:SetText("New Echo Bundle", 1, 0.82, 0.0)
        GameTooltip:AddLine("Drop the main echo (★) first, then 1-3 support echoes.", 0.8, 0.8, 0.8, true)
        GameTooltip:AddLine("Leftmost bundle = highest priority when multiple are active.", 0.7, 0.9, 1.0, true)
        GameTooltip:Show()
    end)
    btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
    return frame
end

-- ── Tier row builder ──────────────────────────────────────────────────────────

local function BuildTierRow(parent, meta)
    local row = {}
    row.id = meta.id

    local frame = CreateFrame("Frame", nil, parent)
    frame:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0)
    frame:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, 0)
    SW.Fill(frame, SC.statCellBg)
    SW.ThinBorder(frame, SC.border, 1)

    -- Tier label column (matches SW.CreateTierRow style)
    local labelFrame = CreateFrame("Frame", nil, frame)
    labelFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0)
    labelFrame:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0)
    labelFrame:SetWidth(TIER_LABEL_W)
    local tc = meta.color
    SW.Fill(labelFrame, { tc[1], tc[2], tc[3], 0.12 })
    SW.ThinBorder(labelFrame, { tc[1], tc[2], tc[3], 0.30 }, 1)

    local labelText = SW.Label(labelFrame, meta.label, 14, meta.color, false, "semibold")
    labelText:SetPoint("CENTER", labelFrame, "CENTER", 0, 0)

    -- For C tier: add a note that unassigned echoes end up here
    if meta.id == "C" then
        local note = SW.Label(frame, "all unassigned", 10, SC.textMuted)
        note:SetPoint("LEFT", labelFrame, "RIGHT", TIER_PAD + 2, 0)
        note:SetJustifyH("LEFT")
    end

    -- Scrollable icon area
    local iconHolder = CreateFrame("Frame", nil, frame)
    iconHolder:SetPoint("TOPLEFT", labelFrame, "TOPRIGHT", TIER_PAD, -TIER_PAD)
    iconHolder:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -TIER_PAD, -TIER_PAD)

    row.frame       = frame
    row.iconHolder  = iconHolder
    row.labelFrame  = labelFrame
    row._icons      = {}
    tierRows[#tierRows + 1] = row
    return row
end

-- ── Rebuild ───────────────────────────────────────────────────────────────────

local function RebuildTierIcons(row, meta, entries)
    -- Clear old icons
    for _, btn in ipairs(row._icons) do
        btn:Hide()
        btn:SetParent(nil)
    end
    row._icons = {}

    -- Available width for icons = left panel width minus label and padding
    local availW = storedLeftW - TIER_LABEL_W - TIER_PAD * 2
    local cols   = math.max(1, math.floor(availW / ICON_STEP))

    local col, rowIdx = 0, 0
    for _, entry in ipairs(entries) do
        local btn = MakeTierIcon(row.iconHolder, entry, meta.id)
        btn:SetPoint("TOPLEFT", row.iconHolder, "TOPLEFT",
            col * ICON_STEP,
            -(rowIdx * ICON_STEP))
        row._icons[#row._icons + 1] = btn
        col = col + 1
        if col >= cols then
            col = 0
            rowIdx = rowIdx + 1
        end
    end

    local numRows  = (#entries > 0) and (math.ceil(#entries / cols)) or 0
    local holderH  = math.max(numRows * ICON_STEP, ICON_SIZE)
    row.iconHolder:SetHeight(holderH)
    local rowH = holderH + TIER_PAD * 2
    row.frame:SetHeight(rowH)
    row.labelFrame:SetHeight(rowH)
end

local function RebuildPoolIcons(searchText)
    if not poolScrollChild then return end

    local entries = BuildPoolEntries(searchText)

    -- Only show quality variants that are effectively unassigned (tier C).
    -- GetTier checks the quality-specific key first, then the name-only fallback.
    local filtered = {}
    for _, e in ipairs(entries) do
        if EbonholdHub.TierData.GetTier(e.name, e.quality) == "C" then
            filtered[#filtered + 1] = e
        end
    end

    -- Hide all recycled frames first.
    for _, btn in ipairs(poolIconFrames) do
        btn:Hide()
        btn:ClearAllPoints()
    end

    -- Grow recycling pool only when needed (frames are never destroyed).
    while #poolIconFrames < #filtered do
        poolIconFrames[#poolIconFrames + 1] = CreatePoolIconFrame(poolScrollChild)
    end

    -- Use stored width (reliable before first layout), fall back to live
    local frameW = storedRightW - 12
    local liveW  = poolScrollFrame and poolScrollFrame:GetWidth() or 0
    if liveW > 10 then frameW = liveW end
    frameW = math.max(frameW, POOL_ICON_STEP + ICON_PAD * 2)

    local cols = math.max(1, math.floor((frameW - ICON_PAD) / POOL_ICON_STEP))
    poolScrollChild:SetWidth(math.max(1, math.floor(frameW)))

    local col, row = 0, 0
    for i, entry in ipairs(filtered) do
        local btn = poolIconFrames[i]
        BindPoolIconEntry(btn, entry)
        btn:SetPoint("TOPLEFT", poolScrollChild, "TOPLEFT",
            col * POOL_ICON_STEP + ICON_PAD,
            -(row * POOL_ICON_STEP + ICON_PAD))
        col = col + 1
        if col >= cols then col = 0; row = row + 1 end
    end

    local numRows = (#filtered > 0) and math.ceil(#filtered / cols) or 0
    local childH  = math.max(numRows * POOL_ICON_STEP + ICON_PAD * 2, POOL_ICON_SIZE + ICON_PAD * 2)
    poolScrollChild:SetHeight(childH)
end

RebuildView = function()
    if not viewFrame then return end
    local tierSearch = GetTierSearchText()
    local poolSearch = searchBox and (searchBox:GetText() or "") or ""

    -- Collect entries assigned to each tier, preserving tier order.
    -- Keys in echoTierOrder may be bare names (legacy) or "name|quality".
    local assigned = {}  -- { [tierId] = { {name, key, spellId, quality}, ... } }

    -- Build a name→entry lookup from BuildSortedList (same source as old code, proven stable).
    -- Entries include .spellIds[quality] for quality-specific spellId lookup.
    local byName = {}
    local sortedList = EbonholdHub.EchoTableRows and EbonholdHub.EchoTableRows.BuildSortedList()
    if sortedList then
        for _, e in ipairs(sortedList) do
            if e.name then byName[e.name] = e end
        end
    end

    for _, meta in ipairs(TIER_META) do
        assigned[meta.id] = {}
        if meta.id ~= "C" then
            local order = EbonholdHub.TierData.GetOrder(meta.id) or {}
            for _, key in ipairs(order) do
                local name, quality = ParseTierKey(key)
                local entry = byName[name]
                if entry then
                    local spellId, qual
                    if quality ~= nil then
                        -- Quality-specific key: use the exact spellId for that quality
                        spellId = entry.spellIds and entry.spellIds[quality]
                        qual    = quality
                    else
                        -- Legacy name-only key: display best available quality
                        spellId = entry.spellId
                        qual    = entry.quality
                    end
                    if spellId then
                        assigned[meta.id][#assigned[meta.id] + 1] = {
                            name    = name,
                            key     = key,    -- original storage key for drag tracking
                            spellId = spellId,
                            quality = qual,
                        }
                    end
                end
            end
        end
    end

    if tierSearch ~= "" then
        assigned.C = BuildUnassignedSearchEntries(tierSearch)
    else
        assigned.C = {}
    end

    UpdateTierSearchSummary(tierSearch, assigned)

    -- Clean up bundle dynamic frames from the previous rebuild
    for _, f in ipairs(bundleDynamicFrames) do
        f:SetParent(nil)
        f:Hide()
    end
    bundleDynamicFrames = {}
    bundleRowsList      = {}

    -- Update each tier row; insert bundle rows after S and A tiers
    local bundles   = EbonholdHub.TierData.GetBundles and EbonholdHub.TierData.GetBundles() or {}
    local yOffset   = 0
    local BUNDLE_META = {
        S = TIER_META[1],  -- index 1 = S
        A = TIER_META[2],  -- index 2 = A
    }

    for i, row in ipairs(tierRows) do
        local meta = TIER_META[i]
        local entries = assigned[meta.id] or {}
        local showRow = true

        if tierSearch ~= "" then
            entries = FilterEntriesBySearch(entries, tierSearch)
            if meta.id == "C" then
                showRow = #entries > 0
            elseif #entries == 0 then
                showRow = false
            end
        elseif meta.id == "C" then
            entries = {}
        end

        if showRow then
            row.frame:Show()
            RebuildTierIcons(row, meta, entries)
            row.frame:ClearAllPoints()
            row.frame:SetPoint("TOPLEFT",  leftPanel, "TOPLEFT",  0, -yOffset)
            row.frame:SetPoint("TOPRIGHT", leftPanel, "TOPRIGHT", 0, -yOffset)
            yOffset = yOffset + row.frame:GetHeight() + 2
        else
            row.frame:Hide()
        end

        -- Bundle cards: shown after S and A tier rows, only when not in search mode
        if (meta.id == "S" or meta.id == "A") and tierSearch == "" then
            local bmeta    = BUNDLE_META[meta.id]
            local cardStep = BUNDLE_CARD_W + BUNDLE_CARD_GAP
            local maxCols  = math.max(1, math.floor((storedLeftW + BUNDLE_CARD_GAP) / cardStep))
            local col, curRow = 0, 0

            -- Place existing bundle cards; priority counted per-tier
            local tierPriority = 0
            for _, bundle in ipairs(bundles) do
                if bundle.tier == meta.id then
                    tierPriority = tierPriority + 1
                    if col >= maxCols then col = 0; curRow = curRow + 1 end
                    local card = BuildBundleCard(leftPanel, bundle, bmeta, byName, tierPriority)
                    card:ClearAllPoints()
                    card:SetPoint("TOPLEFT", leftPanel, "TOPLEFT",
                        col * cardStep,
                        -(yOffset + curRow * (BUNDLE_CARD_H + 2)))
                    bundleRowsList[#bundleRowsList + 1]       = { bundleId = bundle.id, tier = bundle.tier, frame = card }
                    bundleDynamicFrames[#bundleDynamicFrames + 1] = card
                    col = col + 1
                end
            end

            -- "+ New Bundle" card at end of last row
            if col >= maxCols then col = 0; curRow = curRow + 1 end
            local nbCard = BuildNewBundleButton(leftPanel, bmeta)
            nbCard:ClearAllPoints()
            nbCard:SetPoint("TOPLEFT", leftPanel, "TOPLEFT",
                col * cardStep,
                -(yOffset + curRow * (BUNDLE_CARD_H + 2)))
            bundleDynamicFrames[#bundleDynamicFrames + 1] = nbCard

            local totalRows = curRow + 1
            yOffset = yOffset + totalRows * (BUNDLE_CARD_H + 2) + 2
        end
    end

    if leftPanel then
        leftPanel:SetHeight(math.max(yOffset + 4, 32))
    end

    -- Update pool (separate search field in the floating window)
    -- Only rebuild when the pool window is actually visible to avoid
    -- creating hundreds of icon frames on every RebuildView call.
    if poolWindowVisible then
        RebuildPoolIcons(poolSearch)
    end
end

-- ── Context menu ──────────────────────────────────────────────────────────────

local ctxFrame    = nil   -- the popup menu frame
local ctxBlind    = nil   -- full-screen click-catcher to dismiss the menu
local ctxItemBtns = {}    -- reusable item button pool

HideContextMenu = function()
    if ctxFrame then ctxFrame:Hide() end
    if ctxBlind  then ctxBlind:Hide()  end
end

local CTX_W      = 192
local CTX_ITEM_H = 24
local CTX_HDR_H  = 40   -- header: name + quality label
local CTX_PAD    = 8

local function EnsureContextMenu()
    if ctxFrame then return end

    -- Blind: transparent full-screen frame that catches a click outside the menu.
    -- Level 102 puts it above the pool window (level 100) so any click closes the menu.
    ctxBlind = CreateFrame("Frame", nil, UIParent)
    ctxBlind:SetAllPoints(UIParent)
    ctxBlind:SetFrameStrata("DIALOG")
    ctxBlind:SetFrameLevel(102)
    ctxBlind:EnableMouse(true)
    ctxBlind:SetScript("OnMouseDown", HideContextMenu)
    ctxBlind:Hide()

    -- Menu card — TOOLTIP strata so it is always drawn on top of DIALOG frames.
    ctxFrame = CreateFrame("Frame", nil, UIParent)
    ctxFrame:SetWidth(CTX_W)
    ctxFrame:SetFrameStrata("TOOLTIP")
    ctxFrame:SetFrameLevel(1)
    SW.Fill(ctxFrame, SC.bg)
    SW.ThinBorder(ctxFrame, SC.border, 1)

    -- Echo name (semibold, truncates if long)
    local hdrName = SW.Label(ctxFrame, "", 11, SC.text, false, "semibold")
    hdrName:SetPoint("TOPLEFT",  ctxFrame, "TOPLEFT",  CTX_PAD, -CTX_PAD)
    hdrName:SetPoint("TOPRIGHT", ctxFrame, "TOPRIGHT", -CTX_PAD, -CTX_PAD)
    hdrName:SetJustifyH("LEFT")
    ctxFrame._hdrName = hdrName

    -- Quality label (coloured) below the name
    local hdrQual = SW.Label(ctxFrame, "", 10, SC.textDim)
    hdrQual:SetPoint("TOPLEFT", hdrName, "BOTTOMLEFT", 0, -2)
    ctxFrame._hdrQual = hdrQual

    -- Separator line between header and items
    local sep = ctxFrame:CreateTexture(nil, "ARTWORK")
    sep:SetTexture(ST and ST.FLAT or "Interface\\ChatFrame\\ChatFrameBackground")
    sep:SetVertexColor(unpack(SC.border))
    sep:SetHeight(1)
    sep:SetPoint("TOPLEFT",  ctxFrame, "TOPLEFT",  0, -CTX_HDR_H)
    sep:SetPoint("TOPRIGHT", ctxFrame, "TOPRIGHT", 0, -CTX_HDR_H)

    ctxFrame:Hide()
end

ShowContextMenu = function(name, quality, tierId, itemDefs)
    EnsureContextMenu()

    -- Header
    ctxFrame._hdrName:SetText(name or "")
    local ql = (quality ~= nil and QUALITY_LABEL[quality]) or ""
    local qc = (quality ~= nil and QUALITY_COLOR[quality]) or SC.textDim
    if tierId and tierId ~= "C" then ql = ql .. "  [" .. tierId .. "]" end
    ctxFrame._hdrQual:SetText(ql)
    ctxFrame._hdrQual:SetTextColor(unpack(qc))

    -- Reset reusable item buttons
    for _, btn in ipairs(ctxItemBtns) do btn:Hide() end

    for i, def in ipairs(itemDefs) do
        local btn = ctxItemBtns[i]
        if not btn then
            btn = CreateFrame("Button", nil, ctxFrame)
            btn:SetHeight(CTX_ITEM_H)
            btn:SetPoint("LEFT",  ctxFrame, "LEFT",  0, 0)
            btn:SetPoint("RIGHT", ctxFrame, "RIGHT", 0, 0)
            btn._bg  = SW.Fill(btn, { 0, 0, 0, 0 })
            btn._lbl = SW.Label(btn, "", 11, SC.text)
            btn._lbl:SetPoint("LEFT", btn, "LEFT", CTX_PAD + 6, 0)
            btn._lbl:SetJustifyH("LEFT")
            btn:SetScript("OnEnter", function(self)
                self._bg:SetVertexColor(SC.accent[1], SC.accent[2], SC.accent[3], 0.14)
                self._lbl:SetTextColor(unpack(SC.accent))
            end)
            btn:SetScript("OnLeave", function(self)
                self._bg:SetVertexColor(0, 0, 0, 0)
                self._lbl:SetTextColor(unpack(SC.text))
            end)
            ctxItemBtns[i] = btn
        end

        btn._lbl:SetText(def.label)
        btn._lbl:SetTextColor(unpack(SC.text))
        btn._bg:SetVertexColor(0, 0, 0, 0)
        btn:SetScript("OnClick", function()
            HideContextMenu()
            def.action()
        end)
        btn:SetPoint("TOPLEFT", ctxFrame, "TOPLEFT", 0,
            -(CTX_HDR_H + 1 + (i - 1) * CTX_ITEM_H))
        btn:Show()
    end

    ctxFrame:SetHeight(CTX_HDR_H + 1 + #itemDefs * CTX_ITEM_H + 4)

    -- Position just to the right of the cursor
    local scale = UIParent:GetEffectiveScale()
    local cx, cy = GetCursorPosition()
    cx, cy = cx / scale, cy / scale
    ctxFrame:ClearAllPoints()
    ctxFrame:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", cx + 6, cy + 6)

    ctxBlind:Show()
    ctxFrame:Show()
end

-- ── Pool window (standalone, movable) ────────────────────────────────────────

local poolWindow = nil   -- the floating frame
local poolWindowVisible = false

local QUALITY_FILTERS = {
    { label = "All",      quality = -1, color = { 0.75, 0.75, 0.75 } },
    { label = "Common",   quality =  0, color = { 1.0,  1.0,  1.0  } },
    { label = "Uncommon", quality =  1, color = { 0.12, 1.0,  0.0  } },
    { label = "Rare",     quality =  2, color = { 0.0,  0.44, 0.87 } },
    { label = "Epic",     quality =  3, color = { 0.64, 0.21, 0.93 } },
}

local poolQualityBtns = {}

local function RefreshPoolQualityBtns()
    for _, qb in ipairs(poolQualityBtns) do
        if qb._quality == poolFilterQuality then
            qb._bg:SetVertexColor(unpack(SC.accent))
            qb.label:SetTextColor(SC.bg[1], SC.bg[2], SC.bg[3])
        else
            qb._bg:SetVertexColor(unpack(SC.elementBg))
            qb.label:SetTextColor(unpack(qb._color))
        end
    end
end

local function SavePoolWindowPos()
    if not poolWindow then return end
    EbonholdHubDB = EbonholdHubDB or {}
    EbonholdHubDB.poolWindowPos = {
        x = poolWindow:GetLeft(),
        y = poolWindow:GetTop(),
    }
end

local function BuildPoolWindow()
    local pw = CreateFrame("Frame", "EbonholdHubPoolWindow", UIParent)
    pw:SetSize(340, 480)
    pw:SetFrameStrata("DIALOG")
    pw:SetFrameLevel(100)
    pw:SetMovable(true)
    pw:EnableMouse(true)
    pw:RegisterForDrag("LeftButton")
    pw:SetScript("OnDragStart", function(self) self:StartMoving() end)
    pw:SetScript("OnDragStop",  function(self)
        self:StopMovingOrSizing()
        SavePoolWindowPos()
    end)

    SW.Fill(pw, SC.bg)
    SW.ThinBorder(pw, SC.border, 1)

    -- Title bar
    local titleBar = CreateFrame("Frame", nil, pw)
    titleBar:SetHeight(28)
    titleBar:SetPoint("TOPLEFT",  pw, "TOPLEFT",  0, 0)
    titleBar:SetPoint("TOPRIGHT", pw, "TOPRIGHT", 0, 0)
    SW.Fill(titleBar, SC.bgHeader)
    local titleBarSep = titleBar:CreateTexture(nil, "ARTWORK")
    titleBarSep:SetTexture(ST.FLAT)
    titleBarSep:SetVertexColor(unpack(SC.border))
    titleBarSep:SetHeight(1)
    titleBarSep:SetPoint("BOTTOMLEFT",  titleBar, "BOTTOMLEFT",  0, 0)
    titleBarSep:SetPoint("BOTTOMRIGHT", titleBar, "BOTTOMRIGHT", 0, 0)

    local titleLabel = SW.Label(titleBar, "Unassigned Echoes", 12, SC.text, false, "semibold")
    titleLabel:SetPoint("LEFT", titleBar, "LEFT", 10, 0)

    -- Close button
    local closeBtn = CreateFrame("Button", nil, titleBar)
    closeBtn:SetSize(22, 22)
    closeBtn:SetPoint("RIGHT", titleBar, "RIGHT", -4, 0)
    SW.Fill(closeBtn, SC.elementBg)
    SW.ThinBorder(closeBtn, SC.border, 1)
    local closeLabel = SW.Label(closeBtn, "×", 13, SC.textDim)
    closeLabel:SetPoint("CENTER", closeBtn, "CENTER", 0, 0)
    closeBtn:SetScript("OnClick", function()
        pw:Hide()
        poolWindowVisible = false
        if toggleBtn then toggleBtn._label:SetText("Unassigned v") end
        SavePoolWindowPos()
    end)
    closeBtn:SetScript("OnEnter", function(self)
        closeLabel:SetTextColor(unpack(SC.accent))
    end)
    closeBtn:SetScript("OnLeave", function(self)
        closeLabel:SetTextColor(unpack(SC.textDim))
    end)

    -- "My Class" checkbox — height forced to 22 to match searchHolder
    local classCheck = UI.CreateCheckbox(pw, "My Class", false)
    classCheck:SetPoint("TOPLEFT",  pw, "TOPLEFT", 8, -32)
    classCheck:SetSize(90, 22)
    classCheck.OnValueChanged = function(checked)
        poolFilterMyClass = checked
        RebuildPoolIcons(searchBox and searchBox:GetText() or "")
    end

    -- "Owned" checkbox (same row, after My Class)
    local ownedCheck = UI.CreateCheckbox(pw, "Owned", false)
    ownedCheck:SetPoint("TOPLEFT", classCheck, "TOPRIGHT", 4, 0)
    ownedCheck:SetSize(76, 22)
    ownedCheck.OnValueChanged = function(checked)
        poolFilterOwnedOnly = checked
        RebuildPoolIcons(searchBox and searchBox:GetText() or "")
    end

    -- Search box (same row, takes remaining width)
    local searchHolder = UI.CreateEditBox(pw, 100, 20)
    searchHolder:SetHeight(22)
    searchHolder:SetPoint("TOPLEFT",  ownedCheck, "TOPRIGHT",  4,  0)
    searchHolder:SetPoint("TOPRIGHT", pw,         "TOPRIGHT", -8,  0)
    searchBox = searchHolder.editBox

    local searchHint = SW.Label(searchHolder, "Search...", 11, SC.textDim)
    searchHint:SetPoint("LEFT", searchHolder, "LEFT", 8, 0)
    searchBox:HookScript("OnEditFocusGained", function() searchHint:Hide() end)
    searchBox:HookScript("OnEditFocusLost", function()
        if (searchBox:GetText() or "") == "" then searchHint:Show() end
    end)
    searchBox:SetScript("OnTextChanged", function(self)
        RebuildPoolIcons(self:GetText())
    end)

    -- Quality filter row — anchored below classCheck (now 22px), leaving 6px gap
    poolQualityBtns = {}
    local filterRow = CreateFrame("Frame", nil, pw)
    filterRow:SetHeight(22)
    filterRow:SetPoint("TOPLEFT",  classCheck, "BOTTOMLEFT", 0,  -6)
    filterRow:SetPoint("TOPRIGHT", pw,         "TOPRIGHT",  -8,   0)

    local GAP      = 2
    local NUM_BTNS = #QUALITY_FILTERS

    local function LayoutQualityBtns()
        local rowW = filterRow:GetWidth()
        if not rowW or rowW < 10 then return end
        local btnW = math.floor((rowW - GAP * (NUM_BTNS - 1)) / NUM_BTNS)
        local x = 0
        for i, qb in ipairs(poolQualityBtns) do
            qb:ClearAllPoints()
            qb:SetWidth(btnW)
            qb:SetPoint("TOPLEFT", filterRow, "TOPLEFT", x, 0)
            x = x + btnW + GAP
        end
    end

    filterRow:SetScript("OnSizeChanged", LayoutQualityBtns)

    for i, qf in ipairs(QUALITY_FILTERS) do
        local qb = CreateFrame("Button", nil, filterRow)
        qb:SetHeight(20)
        qb._bg = SW.Fill(qb, SC.elementBg)
        SW.ThinBorder(qb, SC.border, 1)
        qb.label = SW.Label(qb, qf.label, 11, qf.color)
        qb.label:SetPoint("CENTER", qb, "CENTER", 0, 0)
        qb._quality = qf.quality
        qb._color   = qf.color
        qb:SetScript("OnClick", function()
            poolFilterQuality = qf.quality
            RefreshPoolQualityBtns()
            RebuildPoolIcons(searchBox and searchBox:GetText() or "")
        end)
        qb:SetScript("OnEnter", function(self)
            if self._quality ~= poolFilterQuality then
                self._bg:SetVertexColor(unpack(SC.bgElevated))
            end
        end)
        qb:SetScript("OnLeave", function(self)
            if self._quality ~= poolFilterQuality then
                self._bg:SetVertexColor(unpack(SC.elementBg))
            end
        end)
        poolQualityBtns[i] = qb
    end
    RefreshPoolQualityBtns()
    -- Initial layout pass (deferred so filterRow has its width)
    AfterDelay(0, LayoutQualityBtns)

    -- Pool scroll area
    poolFrame = CreateFrame("Frame", nil, pw)
    poolFrame:SetPoint("TOPLEFT",     filterRow, "BOTTOMLEFT",  0, -4)
    poolFrame:SetPoint("BOTTOMRIGHT", pw,        "BOTTOMRIGHT", -4, 4)

    poolScrollFrame = CreateFrame("ScrollFrame", nil, poolFrame)
    poolScrollFrame:SetAllPoints(poolFrame)

    poolScrollChild = CreateFrame("Frame", nil, poolScrollFrame)
    poolScrollChild:SetHeight(32)
    poolScrollFrame:SetScrollChild(poolScrollChild)

    poolScrollFrame:EnableMouseWheel(true)
    poolScrollFrame:SetScript("OnMouseWheel", function(self, delta)
        local childH = poolScrollChild:GetHeight() or 0
        local selfH  = self:GetHeight() or 0
        local max    = math.max(0, childH - selfH)
        if max <= 0 then return end
        local newPos = math.max(0, math.min(max,
            self:GetVerticalScroll() - delta * (POOL_ICON_SIZE + ICON_PAD) * 2))
        self:SetVerticalScroll(newPos)
    end)

    poolScrollFrame:SetScript("OnSizeChanged", function(self)
        local w = self:GetWidth()
        if w and w > 10 then
            storedRightW = math.floor(w) + 12
            RebuildPoolIcons(searchBox and searchBox:GetText() or "")
        end
    end)

    poolFrame:SetScript("OnMouseUp", function(self, button)
        if button == "LeftButton" and dragName then FinishDrop(nil) end
    end)

    -- Position: restore saved position or default to right of center
    local db = EbonholdHubDB
    if db and db.poolWindowPos then
        pw:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT",
            db.poolWindowPos.x, db.poolWindowPos.y)
    else
        pw:SetPoint("CENTER", UIParent, "CENTER", 300, 0)
    end

    pw:Hide()
    return pw
end

local function TogglePoolWindow()
    if not poolWindow then
        poolWindow = BuildPoolWindow()
    end
    if poolWindow:IsShown() then
        poolWindow:Hide()
        poolWindowVisible = false
    else
        poolWindow:Show()
        poolWindowVisible = true
        RebuildPoolIcons(searchBox and searchBox:GetText() or "")
    end
end

-- ── Layout ────────────────────────────────────────────────────────────────────

local HEADER_H   = 64
local HEADER_GAP = 4

local function BuildLayout(parent)
    local f = CreateFrame("Frame", nil, parent)

    local PAD = 6

    -- ── Toolbar (slim sidebarBg panel matching other tabs) ─────────────────
    local toolbar = CreateFrame("Frame", nil, f)
    toolbar:SetPoint("TOPLEFT",  f, "TOPLEFT",  0, 0)
    toolbar:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, 0)
    toolbar:SetHeight(HEADER_H)
    SW.Fill(toolbar, SC.sidebarBg)
    local toolbarSep = toolbar:CreateTexture(nil, "ARTWORK")
    toolbarSep:SetTexture(ST.FLAT)
    toolbarSep:SetVertexColor(unpack(SC.border))
    toolbarSep:SetHeight(1)
    toolbarSep:SetPoint("BOTTOMLEFT",  toolbar, "BOTTOMLEFT",  0, 0)
    toolbarSep:SetPoint("BOTTOMRIGHT", toolbar, "BOTTOMRIGHT", 0, 0)

    -- Search box: full width, top row
    local tierSearchHolder = UI.CreateEditBox(toolbar, 200, 20)
    tierSearchHolder:SetHeight(22)
    tierSearchHolder:SetPoint("TOPLEFT",  toolbar, "TOPLEFT",  PAD, -PAD)
    tierSearchHolder:SetPoint("TOPRIGHT", toolbar, "TOPRIGHT", -PAD, -PAD)
    tierSearchBox = tierSearchHolder.editBox
    tierSearchBox:SetMaxLetters(64)

    local tierSearchHint = SW.Label(tierSearchHolder, "Search echoes...", 11, SC.textDim)
    tierSearchHint:SetPoint("LEFT", tierSearchHolder, "LEFT", 8, 0)
    tierSearchBox:HookScript("OnEditFocusGained", function() tierSearchHint:Hide() end)
    tierSearchBox:HookScript("OnEditFocusLost", function()
        if GetTierSearchText() == "" then tierSearchHint:Show() end
    end)
    tierSearchBox:SetScript("OnTextChanged", function()
        RebuildView()
    end)
    tierSearchBox:SetScript("OnEscapePressed", function(self)
        self:ClearFocus()
        self:SetText("")
        tierSearchHint:Show()
        RebuildView()
    end)

    tierSearchSummary = SW.Label(toolbar, "", 10, SC.textDim)
    tierSearchSummary:SetPoint("BOTTOMLEFT",  toolbar, "BOTTOMLEFT",  PAD, PAD + 26)
    tierSearchSummary:SetPoint("BOTTOMRIGHT", toolbar, "BOTTOMRIGHT", -PAD, PAD + 26)
    tierSearchSummary:SetJustifyH("LEFT")
    tierSearchSummary:SetWordWrap(false)
    tierSearchSummary:Hide()

    -- Action buttons: bottom row — right side
    toggleBtn = SW.CreateOutlineButton(toolbar, "Unassigned v", 114)
    toggleBtn:SetHeight(24)
    toggleBtn:SetPoint("BOTTOMRIGHT", toolbar, "BOTTOMRIGHT", -PAD, PAD)
    toggleBtn:SetScript("OnClick", function()
        TogglePoolWindow()
        if poolWindowVisible then
            toggleBtn._label:SetText("Unassigned ^")
        else
            toggleBtn._label:SetText("Unassigned v")
        end
    end)

    local helpBtn = SW.CreateOutlineButton(toolbar, "?", 24)
    helpBtn:SetHeight(24)
    helpBtn:SetPoint("BOTTOMRIGHT", toggleBtn, "BOTTOMLEFT", -4, 0)

    local clearBtn = SW.CreateOutlineButton(toolbar, "Clear All", 70)
    clearBtn:SetHeight(24)
    clearBtn:SetPoint("BOTTOMRIGHT", helpBtn, "BOTTOMLEFT", -4, 0)

    -- "Copy from character" — left side of bottom row
    local copyBtn = SW.CreateOutlineButton(toolbar, "Copy from character", 152)
    copyBtn:SetHeight(24)
    copyBtn:SetPoint("BOTTOMLEFT", toolbar, "BOTTOMLEFT", PAD, PAD)
    copyBtn:SetScript("OnClick", function()
        local CE = EbonholdHub.CharacterEchoes
        if not CE or not CE.CopyToBuild then return end
        local ok, countOrErr, lockedCount = CE.CopyToBuild()
        if ok then
            local msg = "Copied " .. tostring(countOrErr) .. " echo tier(s)"
            if (lockedCount or 0) > 0 then
                msg = msg .. " + " .. lockedCount .. " locked"
            end
            copyBtn._label:SetText(msg)
            AfterDelay(2.5, function()
                if copyBtn and copyBtn._label then
                    copyBtn._label:SetText("Copy from character")
                end
            end)
        else
            copyBtn._label:SetText("No echo data")
            AfterDelay(2, function()
                if copyBtn and copyBtn._label then
                    copyBtn._label:SetText("Copy from character")
                end
            end)
        end
    end)

    -- Confirmation state: first click arms it, second click fires
    local clearArmed = false
    local clearTimer = nil
    clearBtn:SetScript("OnClick", function()
        if not clearArmed then
            clearArmed = true
            clearBtn._label:SetText("Sure?")
            clearBtn._bg:SetVertexColor(unpack(C.danger))
            clearBtn._label:SetTextColor(1, 1, 1)
            clearTimer = AfterDelay(3, function()
                clearArmed = false
                clearTimer = nil
                clearBtn._label:SetText("Clear All")
                clearBtn._bg:SetVertexColor(unpack(SC.elementBg))
                clearBtn._label:SetTextColor(unpack(SC.text))
            end)
        else
            clearArmed = false
            if clearTimer then clearTimer:SetScript("OnUpdate", nil) end
            clearTimer = nil
            EbonholdHub.TierData.ClearAll()
            RebuildView()
            clearBtn._label:SetText("Clear All")
            clearBtn._bg:SetVertexColor(unpack(SC.elementBg))
            clearBtn._label:SetTextColor(unpack(SC.text))
        end
    end)
    clearBtn:SetScript("OnEnter", function(self)
        if not clearArmed then self._bg:SetVertexColor(unpack(SC.bgElevated)) end
    end)
    clearBtn:SetScript("OnLeave", function(self)
        if not clearArmed then self._bg:SetVertexColor(unpack(SC.elementBg)) end
    end)

    -- ── Help panel (hidden by default, appears below toolbar) ─────────────
    local helpPanel = CreateFrame("Frame", nil, f)
    SW.Fill(helpPanel, SC.statCellBg)
    SW.ThinBorder(helpPanel, SC.border, 1)
    helpPanel:SetPoint("TOPLEFT",  toolbar, "BOTTOMLEFT",  4, -HEADER_GAP)
    helpPanel:SetPoint("TOPRIGHT", toolbar, "TOPRIGHT",   -4,  0)
    helpPanel:Hide()

    local HELP_LINES = {
        { "HOW IT WORKS", C.accent },
        { " ", nil },
        { "TIERS  —  drag echoes from the Unassigned window into a tier:", C.text },
        { "  S  —  Must-have.  Picked first whenever it appears.", { 0.95, 0.85, 0.1  } },
        { "  A  —  Great.  Pick if nothing from S is available.", { 0.12, 1.0,  0.0  } },
        { "  B  —  Good.  Pick when S and A are missing.", { 0.0,  0.55, 1.0  } },
        { "  C  —  All unassigned echoes.  Picked as last resort if S/A/B unavailable.", C.textDim },
        { "  F  —  Never pick.  Banished on sight (charges permitting).", { 0.9,  0.3,  0.3  } },
        { " ", nil },
        { "ORDER WITHIN A TIER  —  drag icons left/right to prioritise.", C.text },
        { "  If two echoes from the same tier appear, the leftmost one wins.", C.textDim },
        { " ", nil },
        { "MAX PICKS BADGE  —  small badge on each tier icon (x1 / x2 / x3 / x∞).", C.text },
        { "  Click it to set how many times the addon may pick that echo per run.", C.textDim },
        { "  Default x1.  Use x2 or x3 for echoes you want to stack.", C.textDim },
        { " ", nil },
        { "AGGRESSION SLIDER  (Auto Pick tab)  —  controls how hard the addon", C.text },
        { "  fights for good echoes via banishes, rerolls and freezes.", C.textDim },
        { "  1 = very passive  /  3 = balanced (recommended)  /  5 = aggressive.", C.textDim },
        { " ", nil },
        { "TIP:  put your key echoes in S, nice-to-haves in A/B, and anything", C.accentDim },
        { "  you never want in F.  Leave the rest unassigned.", C.accentDim },
    }

    local lineH = 12 + 3
    local yOff = -PAD
    local helpLines = {}
    for _, ln in ipairs(HELP_LINES) do
        local lbl = SW.Label(helpPanel, ln[1], 11, ln[2] or SC.textDim)
        lbl:SetPoint("TOPLEFT",  helpPanel, "TOPLEFT",  PAD, yOff)
        lbl:SetPoint("TOPRIGHT", helpPanel, "TOPRIGHT", -PAD, 0)
        lbl:SetJustifyH("LEFT")
        yOff = yOff - lineH
        helpLines[#helpLines + 1] = lbl
    end
    helpPanel:SetHeight(-yOff + PAD)

    local helpVisible = false
    local splitY = -(HEADER_H + HEADER_GAP)
    local leftScroll  -- forward ref so helpBtn OnClick can reattach it

    helpBtn:SetScript("OnClick", function()
        helpVisible = not helpVisible
        if helpVisible then
            helpPanel:Show()
            helpBtn._label:SetText("x")
            splitY = -(HEADER_H + HEADER_GAP + helpPanel:GetHeight() + HEADER_GAP)
        else
            helpPanel:Hide()
            helpBtn._label:SetText("?")
            splitY = -(HEADER_H + HEADER_GAP)
        end
        if leftScroll then
            leftScroll:ClearAllPoints()
            leftScroll:SetPoint("TOPLEFT",     f, "TOPLEFT",     0, splitY)
            leftScroll:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, 0)
        end
    end)

    -- ── Full-width scroll for tier rows ──────────────────────────────────────
    leftScroll = CreateFrame("ScrollFrame", nil, f)
    leftScroll:SetPoint("TOPLEFT",     f, "TOPLEFT",     0, splitY)
    leftScroll:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, 0)

    local parentW = parent:GetWidth() or 560
    if parentW < 100 then parentW = 560 end
    storedLeftW  = parentW
    storedRightW = 240   -- default pool window width

    leftPanel = CreateFrame("Frame", nil, leftScroll)
    leftPanel:SetWidth(parentW)
    leftPanel:SetHeight(32)
    leftScroll:SetScrollChild(leftPanel)

    leftScroll:EnableMouseWheel(true)
    leftScroll:SetScript("OnMouseWheel", function(self, delta)
        local childH = leftPanel:GetHeight() or 0
        local selfH  = self:GetHeight() or 0
        local maxScroll = math.max(0, childH - selfH)
        if maxScroll <= 0 then return end
        local newPos = math.max(0, math.min(maxScroll,
            self:GetVerticalScroll() - delta * (ICON_SIZE + ICON_PAD) * 2))
        self:SetVerticalScroll(newPos)
    end)

    -- Keep leftPanel width in sync with scroll frame
    leftScroll:SetScript("OnSizeChanged", function(self)
        local w = self:GetWidth()
        if w and w > 10 then
            storedLeftW = math.floor(w)
            leftPanel:SetWidth(storedLeftW)
            RebuildView()
        end
    end)

    tierRows = {}
    for _, meta in ipairs(TIER_META) do
        BuildTierRow(leftPanel, meta)
    end

    return f
end

-- ── Public ────────────────────────────────────────────────────────────────────

function EbonholdHub.TierView.Mount(container)
    if not viewFrame then
        viewFrame = BuildLayout(container)
    end
    viewFrame:SetParent(container)
    viewFrame:ClearAllPoints()
    viewFrame:SetAllPoints(container)
    viewFrame:Show()
    RebuildView()
end

function EbonholdHub.TierView.Unmount()
    if viewFrame then viewFrame:Hide() end
    if poolWindow then
        poolWindow:Hide()
        poolWindowVisible = false
    end
    DestroyGhost()
    -- Reset filters so they don't stick between open/close
    poolFilterQuality = -1
    poolFilterMyClass = false
    if tierSearchBox then
        tierSearchBox:SetText("")
    end
    if tierSearchSummary then
        tierSearchSummary:SetText("")
        tierSearchSummary:Hide()
    end
end

function EbonholdHub.TierView.Refresh()
    RebuildView()
end

function EbonholdHub.TierView.Init()
end
