-- =============================================================================
-- ETB v12.56.0 — UI/Windows/SetsUI/List.lua
-- =============================================================================
-- Second SetsUI submodule. Owns the LEFT panel: set list rendering, the
-- search/filter controls, and the data pipeline that produces sortedSets.
--
-- This is the largest cluster in SetsUI.lua: 9 methods plus 5 helper
-- functions and several constant tables.
--
-- Public API (all take owner = SetsUI singleton as first arg):
--   List:CreateSetListPanel(owner, parent)       -- builds the left panel UI
--   List:CreateSmallFilterButton(owner, parent, text, x, y, filter, width)
--   List:UpdateFilterButtons(owner)              -- color filter buttons by active filter
--   List:RefreshSetList(owner)                   -- rebuild sortedSets from filters
--   List:PopulateSetList(owner)                  -- create/seed button pool
--   List:UpdateVisibleButtons(owner)             -- virtual-scroll: position visible
--   List:UpdateSetButton(owner, btn, setData)    -- update one button's content
--   List:CreateSetListButton(owner, parent)      -- factory for button-pool entries
--   List:SetFilter(owner, filter)                -- change source-type filter + refresh
--
-- Owner contract — fields read/written:
--   owner.frame                        (visibility check)
--   owner.setListFrame                 (the panel)
--   owner.setButtons                   (button pool)
--   owner.sortedSets                   (filtered+sorted set list)
--   owner.selectedSetID                (current selection, for highlight)
--   owner.currentFilter, .searchText, .armorFilter, .expansionFilter,
--   owner.classFilter, .colorFilter
--   owner._filterData                  (reusable scratch table)
--   owner._completionCache             (rebuilt on TRANSMOG_COLLECTION_UPDATED)
--   owner._setColorCache               (rebuilt on TRANSMOG_COLLECTION_UPDATED)
--   owner._lastCollectionUpdate        (debounce timer)
--
-- Owner methods called (delegators in main SetsUI.lua):
--   owner:RefreshSetList()
--   owner:PopulateSetList()
--   owner:UpdateVisibleButtons()
--   owner:UpdateFilterButtons()
--   owner:UpdateSetButton(btn, setData)
--   owner:CreateSetListButton(parent)
--   owner:SelectSet(setID)             (Detail cluster — stays in main file)
--   owner:SetFilter(filter)
--   owner:UpdatePieceIcons(setID)             (Detail cluster)
--   owner:UpdateCommunitySetPieceIcons(...)   (Detail cluster)
--
-- Globals/Blizzard:
--   ETBDropdown (NS-stored; resolved at call time)
--   CreateFrame, GameTooltip, _G
--   GetTime, UnitFactionGroup, UnitClass
--   bit.band
--   C_TransmogSets.GetAllSourceIDs, C_TransmogCollection.GetSourceInfo
--   ETB.SetsData, ETB.CustomSets, ETB.CommunitySets, ETB.ColorSearch,
--   ETB.ColorMap, ETB.BlizzardData, ETB.SetBuilder
-- =============================================================================

local ADDON_NAME, NS = ...
local ETB = NS.ETB

NS.ETB.SetsUI = NS.ETB.SetsUI or ETB.SetsUI or {}
local List = {}
NS.ETB.SetsUI.List = List

-- =============================================================================
-- FILE-LOCAL CONSTANTS AND HELPERS
-- =============================================================================
-- v12.56.0: moved verbatim from SetsUI.lua. Used only inside the List cluster.

-- Class mask to class name mapping for tier sets
-- Blizzard uses a bitmask where each bit represents a class
local CLASS_MASK_MAP = {
    [1] = "Warrior",
    [2] = "Paladin",
    [4] = "Hunter",
    [8] = "Rogue",
    [16] = "Priest",
    [32] = "Death Knight",
    [64] = "Shaman",
    [128] = "Mage",
    [256] = "Warlock",
    [512] = "Monk",
    [1024] = "Druid",
}

-- Class colors for display
local CLASS_COLORS = {
    Warrior = "|cffC79C6E",
    Paladin = "|cffF58CBA",
    Hunter = "|cffABD473",
    Rogue = "|cffFFF569",
    Priest = "|cffFFFFFF",
    ["Death Knight"] = "|cffC41F3B",
    Shaman = "|cff0070DE",
    Mage = "|cff69CCF0",
    Warlock = "|cff9482C9",
    Monk = "|cff00FF96",
    Druid = "|cffFF7D0A",
}

-- Helper to get class name from classMask
local function GetClassFromMask(classMask)
    if not classMask or classMask == 0 then return nil end
    -- Check for single class mask
    for mask, className in pairs(CLASS_MASK_MAP) do
        if classMask == mask then
            return className
        end
    end
    -- If multiple classes (like all plate classes), return nil
    return nil
end

-- Faction icons/colors
local FACTION_INFO = {
    Alliance = { icon = "|TInterface\\PVPFrame\\PVP-Currency-Alliance:14:14|t", color = "|cff0078ff" },
    Horde = { icon = "|TInterface\\PVPFrame\\PVP-Currency-Horde:14:14|t", color = "|cffff2020" },
}

-- Helper to get faction display string
local function GetFactionDisplay(faction)
    if not faction then return nil end
    local info = FACTION_INFO[faction]
    if info then
        return info.icon .. " " .. info.color .. faction .. "|r"
    end
    return nil
end

-- Helper to check if player's class matches a classMask (currently unused but
-- preserved verbatim from v12.34.0 SetsUI.lua for future reference)
local function PlayerClassMatchesMask(classMask)
    if not classMask or classMask == 0 then return true end  -- No restriction

    -- Get player's class ID (1-indexed bit position)
    local _, playerClass = UnitClass("player")
    local classIDBits = {
        WARRIOR = 1,
        PALADIN = 2,
        HUNTER = 4,
        ROGUE = 8,
        PRIEST = 16,
        DEATHKNIGHT = 32,
        SHAMAN = 64,
        MAGE = 128,
        WARLOCK = 256,
        MONK = 512,
        DRUID = 1024,
    }

    local playerBit = classIDBits[playerClass] or 0
    return bit.band(classMask, playerBit) ~= 0
end

-- Helper: Map classMask to primary armor type
local function ClassMaskToArmor(classMask)
    if not classMask or classMask == 0 then return nil end
    -- Cloth: Mage(128) + Warlock(256) + Priest(16)
    if bit.band(classMask, 128 + 256 + 16) > 0 then return "CLOTH" end
    -- Leather: Rogue(8) + Druid(1024) + Monk(512)
    if bit.band(classMask, 8 + 1024 + 512) > 0 then return "LEATHER" end
    -- Mail: Hunter(4) + Shaman(64)
    if bit.band(classMask, 4 + 64) > 0 then return "MAIL" end
    -- Plate: Warrior(1) + Paladin(2) + DeathKnight(32)
    if bit.band(classMask, 1 + 2 + 32) > 0 then return "PLATE" end
    return nil
end

-- Helper: Map expansion name string to ID number
local EXP_NAME_TO_ID = {
    ["Classic"] = 0, ["TBC"] = 1, ["Wrath"] = 2, ["Cata"] = 3, ["MoP"] = 4,
}

-- Helper: Check if a set passes the dropdown filters
local function PassesDropdownFilters(owner, setData, armorFilter, expansionFilter, classFilter, colorFilter)
    -- Armor type filter
    if armorFilter ~= "all" then
        local setArmor = setData.armorType or ClassMaskToArmor(setData.classMask)
        if setArmor and setArmor ~= armorFilter then
            return false
        end
    end

    -- Expansion filter
    if expansionFilter ~= "all" then
        local setExp = nil
        -- Blizzard sets have numeric expansionID
        if setData.expansionID ~= nil then
            setExp = setData.expansionID
        -- Community/Custom sets have string expansion name
        elseif setData.expansion then
            setExp = EXP_NAME_TO_ID[setData.expansion]
        end
        if setExp ~= nil and setExp ~= expansionFilter then
            return false
        end
    end

    -- Class filter (classMask bitmask check)
    if classFilter ~= "all" then
        local cm = setData.classMask
        if cm and cm > 0 then
            if bit.band(cm, classFilter) == 0 then
                return false
            end
        end
        -- Community sets with specific armorType but no classMask:
        -- filter by armor type that matches the class
        if (not cm or cm == 0) and setData.armorType then
            local classArmor = ClassMaskToArmor(classFilter)
            if classArmor and setData.armorType ~= classArmor and setData.armorType ~= "COSMETIC" then
                return false
            end
        end
    end

    -- Color filter: set passes if ANY item has the selected color
    -- CRITICAL: NEVER call GetSetItems here — it creates 30+ tables per set.
    -- Use cached visualIDs from GetSetPrimaryAppearances instead (zero allocation).
    if colorFilter and ETB.ColorSearch and ETB.ColorSearch:HasData() then
        local hasMatch = false

        -- Community sets: check items directly (already have visualIDs)
        if setData.items then
            for _, item in pairs(setData.items) do
                local vid = nil
                if type(item) == "table" then
                    vid = item.visualID or item[2]
                end
                if vid and ETB.ColorSearch:ItemMatchesCategory(vid, nil, colorFilter) then
                    hasMatch = true
                    break
                end
            end
        end

        -- Blizzard sets: lookup in pre-built _setColorCache (built at start of RefreshSetList)
        if not hasMatch and setData.setID and owner._setColorCache then
            local cachedColors = owner._setColorCache[setData.setID]
            if cachedColors then
                local cat = ETB.ColorSearch.CategoryByID and ETB.ColorSearch.CategoryByID[colorFilter]
                if cat and cachedColors[cat.idx] then
                    hasMatch = true
                end
            end
        end

        -- No items data at all = pass through
        if not setData.items and not setData.setID then
            hasMatch = true
        end
        if not hasMatch then return false end
    end

    return true
end

-- Virtual scroll constants
local BUTTON_HEIGHT = 28
local BUTTON_POOL_SIZE = 25  -- Enough for visible area + buffer

-- =============================================================================
-- PANEL CONSTRUCTION
-- =============================================================================

function List:CreateSetListPanel(owner, parent)
    local ETBDropdown = NS.ETBDropdown  -- Resolved at call time

    local panel = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    panel:SetSize(280, parent:GetHeight() - 20)
    panel:SetPoint("TOPLEFT", 10, -10)
    panel:SetPoint("BOTTOMLEFT", 10, 10)
    panel:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 10,
        insets = { left = 2, right = 2, top = 2, bottom = 2 }
    })
    panel:SetClipsChildren(true)
    owner.setListFrame = panel

    -- Header
    local header = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    header:SetPoint("TOP", 0, -8)
    header:SetText("|cff00ffffTransmog Sets|r")

    -- Row 1: Source type filters
    local filterY1 = -30
    panel.filterBlizzard = List:CreateSmallFilterButton(owner, panel, "Blizzard", 8, filterY1, "blizzard", 48)
    panel.filterCustom = List:CreateSmallFilterButton(owner, panel, "Custom", 59, filterY1, "custom", 44)
    panel.filterCommunity = List:CreateSmallFilterButton(owner, panel, "Community", 106, filterY1, "community", 62)
    panel.filterAll = List:CreateSmallFilterButton(owner, panel, "All", 171, filterY1, "all", 30)

    -- Row 2: Completion + Builder
    local filterY2 = -50
    panel.filterIncomplete = List:CreateSmallFilterButton(owner, panel, "Missing", 8, filterY2, "incomplete", 50)
    panel.filterCollected = List:CreateSmallFilterButton(owner, panel, "Done", 61, filterY2, "collected", 40)

    panel.setBuilderBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    panel.setBuilderBtn:SetSize(65, 18)
    panel.setBuilderBtn:SetPoint("TOPLEFT", 105, filterY2)
    panel.setBuilderBtn:SetText("|cffffd100Builder|r")
    panel.setBuilderBtn:SetScript("OnClick", function()
        if ETB.SetBuilder then
            if _G.ETBMainFrame and _G.ETBMainFrame:IsShown() then
                ETB._builderHidMainFrame = true
                _G.ETBMainFrame:Hide()
            end
            ETB.SetBuilder:Show()
        end
    end)
    panel.setBuilderBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_LEFT")
        GameTooltip:SetText("Set Builder")
        GameTooltip:AddLine("Open Set Builder in a separate window", 0.8, 0.8, 0.8)
        GameTooltip:Show()
    end)
    panel.setBuilderBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)

    -- v14.1.0 Feature 3.6: "Hidden" toggle. Shows hidden sets so the user
    -- can right-click them to unhide. Default off — hidden sets are out of
    -- sight by default.
    panel.showHiddenBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    panel.showHiddenBtn:SetSize(50, 18)
    panel.showHiddenBtn:SetPoint("TOPLEFT", 175, filterY2)
    local function refreshHiddenBtnLabel()
        if owner.showHiddenSets then
            panel.showHiddenBtn:SetText("|cff00ff00Hidden|r")
        else
            panel.showHiddenBtn:SetText("|cff666666Hidden|r")
        end
    end
    refreshHiddenBtnLabel()
    panel.showHiddenBtn:SetScript("OnClick", function()
        owner.showHiddenSets = not owner.showHiddenSets
        refreshHiddenBtnLabel()
        owner:RefreshSetList()
    end)
    panel.showHiddenBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_LEFT")
        GameTooltip:SetText("Hidden Sets")
        GameTooltip:AddLine("Toggle visibility of hidden sets.", 0.8, 0.8, 0.8, true)
        GameTooltip:AddLine("Right-click any set to hide it.", 1, 0.82, 0)
        GameTooltip:Show()
    end)
    panel.showHiddenBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)

    -- Row 3: Armor Type dropdown
    local dropW = 128
    local ddY = -72

    local armorLabel = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    armorLabel:SetPoint("TOPLEFT", 8, ddY)
    armorLabel:SetText("|cffaaaaaaArmor:|r")

    panel.armorDropdown = ETBDropdown:Create("ETBSetsArmorDD", panel)
    panel.armorDropdown:SetPoint("TOPLEFT", 48, ddY + 3)
    ETBDropdown:SetWidth(panel.armorDropdown, dropW)
    ETBDropdown:SetText(panel.armorDropdown, "All Types")
    ETBDropdown:Initialize(panel.armorDropdown, function(dd, _items_)
        local info = ETBDropdown:CreateInfo()
        local types = {
            { text = "All Types", value = "all" },
            { text = "Cloth", value = "CLOTH" },
            { text = "Leather", value = "LEATHER" },
            { text = "Mail", value = "MAIL" },
            { text = "Plate", value = "PLATE" },
        }
        for _, t in ipairs(types) do
            info.text = t.text
            info.value = t.value
            info.func = function()
                ETBDropdown:SetText(panel.armorDropdown, t.text)
                owner.armorFilter = t.value
                owner:RefreshSetList()
            end
            ETBDropdown:AddButton(_items_, info)
        end
    end)

    -- Row 4: Expansion dropdown
    local expY = ddY - 22
    local expLabel = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    expLabel:SetPoint("TOPLEFT", 8, expY)
    expLabel:SetText("|cffaaaaaaExp:|r")

    panel.expansionDropdown = ETBDropdown:Create("ETBSetsExpDD", panel)
    panel.expansionDropdown:SetPoint("TOPLEFT", 48, expY + 3)
    ETBDropdown:SetWidth(panel.expansionDropdown, dropW)
    ETBDropdown:SetText(panel.expansionDropdown, "All Expansions")
    ETBDropdown:Initialize(panel.expansionDropdown, function(dd, _items_)
        local info = ETBDropdown:CreateInfo()
        local expansions = {
            { text = "All Expansions", value = "all" },
            { text = "Classic", value = 0 },
            { text = "Burning Crusade", value = 1 },
            { text = "Wrath", value = 2 },
            { text = "Cataclysm", value = 3 },
            { text = "Mists of Pandaria", value = 4 },
        }
        for _, e in ipairs(expansions) do
            info.text = e.text
            info.value = e.value
            info.func = function()
                ETBDropdown:SetText(panel.expansionDropdown, e.text)
                owner.expansionFilter = e.value
                owner:RefreshSetList()
            end
            ETBDropdown:AddButton(_items_, info)
        end
    end)

    -- Row 5: Class dropdown
    local classY = expY - 22
    local classLabel = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    classLabel:SetPoint("TOPLEFT", 8, classY)
    classLabel:SetText("|cffaaaaaaClass:|r")

    panel.classDropdown = ETBDropdown:Create("ETBSetsClassDD", panel)
    panel.classDropdown:SetPoint("TOPLEFT", 48, classY + 3)
    ETBDropdown:SetWidth(panel.classDropdown, dropW)
    ETBDropdown:SetText(panel.classDropdown, "All Classes")
    ETBDropdown:Initialize(panel.classDropdown, function(dd, _items_)
        local info = ETBDropdown:CreateInfo()
        local classes = {
            { text = "All Classes", value = "all" },
            { text = "Warrior", value = 1 },
            { text = "Paladin", value = 2 },
            { text = "Hunter", value = 4 },
            { text = "Rogue", value = 8 },
            { text = "Priest", value = 16 },
            { text = "Death Knight", value = 32 },
            { text = "Shaman", value = 64 },
            { text = "Mage", value = 128 },
            { text = "Warlock", value = 256 },
            { text = "Monk", value = 512 },
            { text = "Druid", value = 1024 },
        }
        for _, c in ipairs(classes) do
            info.text = c.text
            info.value = c.value
            info.func = function()
                ETBDropdown:SetText(panel.classDropdown, c.text)
                owner.classFilter = c.value
                owner:RefreshSetList()
            end
            ETBDropdown:AddButton(_items_, info)
        end
    end)

    -- Color filter dropdown
    local colorY = classY - 22
    local colorLabel = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    colorLabel:SetPoint("TOPLEFT", 8, colorY)
    colorLabel:SetText("|cffaaaaaaColor:|r")

    panel.colorDropdown = ETBDropdown:Create("ETBSetsColorDD", panel)
    panel.colorDropdown:SetPoint("TOPLEFT", 48, colorY + 3)
    ETBDropdown:SetWidth(panel.colorDropdown, dropW)
    ETBDropdown:SetText(panel.colorDropdown, "All Colors")
    ETBDropdown:Initialize(panel.colorDropdown, function(dd, _items_)
        local info = ETBDropdown:CreateInfo()
        info.text = "All Colors"
        info.value = nil
        info.func = function()
            ETBDropdown:SetText(panel.colorDropdown, "All Colors")
            owner.colorFilter = nil
            owner:RefreshSetList()
        end
        ETBDropdown:AddButton(_items_, info)

        if ETB.ColorSearch then
            for _, cat in ipairs(ETB.ColorSearch:GetCategories()) do
                info.text = cat.name
                info.value = cat.id
                info.func = function()
                    ETBDropdown:SetText(panel.colorDropdown, cat.name)
                    owner.colorFilter = cat.id
                    owner:RefreshSetList()
                end
                ETBDropdown:AddButton(_items_, info)
            end
        end
    end)

    -- Search box
    panel.searchBox = CreateFrame("EditBox", nil, panel, "InputBoxTemplate")
    panel.searchBox:SetSize(260, 18)
    panel.searchBox:SetPoint("TOP", 0, colorY - 20)
    panel.searchBox:SetAutoFocus(false)
    panel.searchBox:SetScript("OnTextChanged", function(eb)
        owner.searchText = eb:GetText():lower()
        owner:RefreshSetList()
    end)
    panel.searchBox:SetScript("OnEscapePressed", function(eb) eb:ClearFocus() end)

    -- Stats
    panel.stats = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    panel.stats:SetPoint("TOP", panel.searchBox, "BOTTOM", 0, -3)
    panel.stats:SetText("|cff888888Loading sets...|r")

    -- Scroll frame
    local scrollContainer = CreateFrame("Frame", nil, panel)
    scrollContainer:SetPoint("TOPLEFT", 5, colorY - 55)
    scrollContainer:SetPoint("BOTTOMRIGHT", -5, 5)

    local scrollFrame = CreateFrame("ScrollFrame", nil, scrollContainer, "UIPanelScrollFrameTemplate")
    scrollFrame:SetAllPoints()

    local scrollChild = CreateFrame("Frame", nil, scrollFrame)
    scrollChild:SetSize(scrollFrame:GetWidth() - 20, 1)
    scrollFrame:SetScrollChild(scrollChild)

    panel.scrollFrame = scrollFrame
    panel.scrollChild = scrollChild

    parent.setListPanel = panel

    -- =========================================================================
    -- COLLECTION UPDATE EVENT — refresh sets when appearances change
    -- =========================================================================
    local eventFrame = CreateFrame("Frame")
    eventFrame:RegisterEvent("TRANSMOG_COLLECTION_UPDATED")
    eventFrame:SetScript("OnEvent", function(ef, event)
        if owner._lastCollectionUpdate and (GetTime() - owner._lastCollectionUpdate) < 1.0 then
            return
        end
        owner._lastCollectionUpdate = GetTime()

        -- Invalidate caches that depend on collection state
        owner._completionCache = nil
        owner._setColorCache = nil

        if ETB.CommunitySets and ETB.CommunitySets.UpdateCollectionCounts then
            ETB.CommunitySets:UpdateCollectionCounts()
        end
        if ETB.SetsData then
            ETB.SetsData.setCompletion = {}
        end

        if owner.frame and owner.frame:IsShown() then
            owner:RefreshSetList()
            if owner.selectedSetID then
                local sid = owner.selectedSetID
                if type(sid) == "string" and sid:find("COMMUNITY_") then
                    local cs = ETB.CommunitySets and ETB.CommunitySets:GetSet(sid)
                    if cs then owner:UpdateCommunitySetPieceIcons(sid, cs) end
                elseif type(sid) == "number" then
                    owner:UpdatePieceIcons(sid)
                end
            end
        end
    end)
end

function List:CreateSmallFilterButton(owner, parent, text, x, y, filter, width)
    local btn = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
    btn:SetSize(width or 45, 20)
    btn:SetPoint("TOPLEFT", x, y)
    btn:SetText(text)
    btn:GetFontString():SetFont(btn:GetFontString():GetFont(), 10)
    btn.filter = filter
    btn:SetScript("OnClick", function() owner:SetFilter(filter) end)
    return btn
end

function List:UpdateFilterButtons(owner)
    local panel = owner.setListFrame
    if not panel then return end

    -- All filter buttons
    local buttons = {
        panel.filterBlizzard,
        panel.filterCustom,
        panel.filterCommunity,

        panel.filterIncomplete,
        panel.filterCollected,
        panel.filterAll
    }

    for _, btn in ipairs(buttons) do
        if btn and btn.filter then
            local fs = btn:GetFontString()
            if fs then
                if btn.filter == owner.currentFilter then
                    fs:SetTextColor(0.2, 1, 0.2)
                else
                    fs:SetTextColor(1, 0.82, 0)
                end
            end
        end
    end
end

-- =============================================================================
-- DATA REFRESH
-- =============================================================================

function List:RefreshSetList(owner)
    if not owner.setListFrame or not ETB.SetsData then return end

    -- Get sets from SetsData
    if ETB.SetsData:CountTable(ETB.SetsData.allSets) == 0 then
        ETB.SetsData:DiscoverSets()
    end

    -- Wipe and reuse existing sortedSets table (avoids orphaning old entries)
    if owner.sortedSets then
        wipe(owner.sortedSets)
    else
        owner.sortedSets = {}
    end

    -- Reusable filter data table (avoids creating 900 anonymous tables per refresh)
    local filterData = owner._filterData
    if not filterData then
        filterData = {}
        owner._filterData = filterData
    end

    -- Completion cache: only rebuild when TRANSMOG_COLLECTION_UPDATED fires
    if not owner._completionCache then
        owner._completionCache = {}
    end

    -- Pre-build Blizzard set color cache (ONCE, persists until collection update)
    -- Uses GetAllSourceIDs → GetSourceInfo → sourceInfo.visualID → ColorMap
    -- All lookups after first build are pure table reads (zero allocation)
    if owner.colorFilter and not owner._setColorCache and ETB.ColorSearch and ETB.ColorSearch:HasData() then
        owner._setColorCache = {}
        local colorMap = ETB.ColorMap
        if colorMap then
            for setID in pairs(ETB.SetsData.baseSets) do
                local colorSet = {}
                local ok, sourceIDs = pcall(C_TransmogSets.GetAllSourceIDs, setID)
                if ok and sourceIDs then
                    for _, sid in ipairs(sourceIDs) do
                        local siOK, si = pcall(C_TransmogCollection.GetSourceInfo, sid)
                        if siOK and si and si.visualID then
                            local packed = colorMap[si.visualID]
                            if packed then
                                -- Direct decode into set — no temporary tables
                                if packed <= 12 then
                                    colorSet[packed] = true
                                elseif packed <= 1299 then
                                    colorSet[math.floor(packed / 100)] = true
                                    local s = packed % 100
                                    if s > 0 and s <= 12 then colorSet[s] = true end
                                else
                                    colorSet[math.floor(packed / 10000)] = true
                                    local rem = packed % 10000
                                    local s = math.floor(rem / 100)
                                    local t = rem % 100
                                    if s > 0 and s <= 12 then colorSet[s] = true end
                                    if t > 0 and t <= 12 then colorSet[t] = true end
                                end
                            end
                        end
                    end
                end
                owner._setColorCache[setID] = colorSet
            end
        end
        ETB:Debug("SetsUI", "Built color cache for Blizzard sets")
    end

    -- Determine which sources to load based on filter
    local loadBlizzard = (owner.currentFilter ~= "custom" and owner.currentFilter ~= "community")
    local loadCustom = (owner.currentFilter ~= "blizzard" and owner.currentFilter ~= "community")
    local loadCommunity = (owner.currentFilter ~= "blizzard" and owner.currentFilter ~= "custom")

    local playerFaction = UnitFactionGroup("player")
    local primaryArmor = ETB.BlizzardData and ETB.BlizzardData:GetPlayerPrimaryArmor() or "CLOTH"

    -- ==========================================================================
    -- BLIZZARD SETS
    -- ==========================================================================
    if loadBlizzard then
        for setID, setInfo in pairs(ETB.SetsData.baseSets) do
            local include = true

            -- Use cached completion (rebuilt only on collection update events)
            local cachedComp = owner._completionCache[setID]
            local collected, total
            if cachedComp then
                collected, total = cachedComp[1], cachedComp[2]
            else
                collected, total = ETB.SetsData:GetSetCompletion(setID)
                owner._completionCache[setID] = { collected, total }
            end
            local isComplete = (collected == total and total > 0)
            local hasProgress = (collected > 0)

            -- Apply completion filters
            if owner.currentFilter == "collected" then
                include = isComplete
            elseif owner.currentFilter == "incomplete" then
                include = not isComplete
            end

            -- Apply search
            if include and owner.searchText ~= "" then
                local nameLower = (setInfo.name or ""):lower()
                local labelLower = (setInfo.label or ""):lower()
                include = nameLower:find(owner.searchText, 1, true) or labelLower:find(owner.searchText, 1, true)
            end

            -- Apply dropdown filters (reuse filterData table)
            if include then
                filterData.armorType = ClassMaskToArmor(setInfo.classMask)
                filterData.expansionID = setInfo.expansionID
                filterData.classMask = setInfo.classMask
                filterData.setID = setID
                filterData.expansion = nil
                filterData.items = nil
                include = PassesDropdownFilters(owner, filterData, owner.armorFilter, owner.expansionFilter, owner.classFilter, owner.colorFilter)
            end

            -- v14.1.0 Feature 3.6: respect per-set hide flag
            if include and not owner.showHiddenSets and ETB.SetsData:IsSetHidden(setID) then
                include = false
            end

            if include then
                table.insert(owner.sortedSets, {
                    setID = setID,
                    name = setInfo.name or "Unknown",
                    label = setInfo.label,
                    collected = isComplete,
                    collectedSlots = collected,
                    totalSlots = total,
                    hasProgress = hasProgress,
                    classMask = setInfo.classMask,
                    faction = setInfo.requiredFaction,
                    isCustom = false,
                })
            end
        end
    end

    -- ==========================================================================
    -- CUSTOM SETS (unofficial dungeon sets, etc.)
    -- ==========================================================================
    if loadCustom and ETB.CustomSets then
        for setID, setInfo in pairs(ETB.CustomSets:GetAllSets()) do
            local include = true

            -- Get completion for custom set
            local collected, total = ETB.CustomSets:GetSetCompletion(setID)
            local isComplete = (collected == total and total > 0)
            local hasProgress = (collected > 0)

            -- Apply completion filters
            if owner.currentFilter == "collected" then
                include = isComplete
            elseif owner.currentFilter == "incomplete" then
                include = not isComplete
            end
            -- "all", "custom" = no additional filtering beyond source

            -- Apply search
            if include and owner.searchText ~= "" then
                local nameLower = (setInfo.name or ""):lower()
                local sourceLower = (setInfo.source or ""):lower()
                include = nameLower:find(owner.searchText, 1, true) or sourceLower:find(owner.searchText, 1, true)
            end

            -- v14.1.0 Feature 3.6: respect per-set hide flag
            if include and not owner.showHiddenSets and ETB.SetsData:IsSetHidden(setID) then
                include = false
            end

            if include then
                table.insert(owner.sortedSets, {
                    setID = setID,
                    name = setInfo.name or "Unknown",
                    label = setInfo.source,
                    collected = isComplete,
                    collectedSlots = collected,
                    totalSlots = total,
                    hasProgress = hasProgress,
                    classMask = nil,
                    faction = setInfo.faction,
                    isCustom = true,
                    armorType = setInfo.armorType,
                })
            end
        end
    end

    -- ==========================================================================
    -- COMMUNITY SETS
    -- ==========================================================================
    if loadCommunity and ETB.CommunitySets then
        if not ETB.CommunitySets.loaded then
            ETB.CommunitySets:LoadData()
        end

        for setID, setInfo in pairs(ETB.CommunitySets:GetAllSets()) do
            local include = true

            local collected = setInfo.collectedCount or 0
            local total = setInfo.pieceCount or 0
            local isComplete = (collected == total and total > 0)
            local hasProgress = (collected > 0)

            if owner.currentFilter == "collected" then
                include = isComplete
            elseif owner.currentFilter == "incomplete" then
                include = not isComplete
            end

            if include and owner.searchText ~= "" then
                local nameLower = (setInfo.name or ""):lower()
                local sourceLower = (setInfo.source or ""):lower()
                local armorLower = (setInfo.armorDisplay or ""):lower()
                include = nameLower:find(owner.searchText, 1, true)
                    or sourceLower:find(owner.searchText, 1, true)
                    or armorLower:find(owner.searchText, 1, true)
            end

            -- Apply dropdown filters (reuse filterData table)
            if include then
                filterData.armorType = setInfo.armorType
                filterData.expansion = setInfo.expansionName
                filterData.classMask = nil
                filterData.items = setInfo.items
                filterData.expansionID = nil
                filterData.setID = nil
                include = PassesDropdownFilters(owner, filterData, owner.armorFilter, owner.expansionFilter, owner.classFilter, owner.colorFilter)
            end

            -- v14.1.0 Feature 3.6: respect per-set hide flag
            if include and not owner.showHiddenSets and ETB.SetsData:IsSetHidden(setID) then
                include = false
            end

            if include then
                table.insert(owner.sortedSets, {
                    setID = setID,
                    name = setInfo.name or "Unknown",
                    label = setInfo.source,
                    collected = isComplete,
                    collectedSlots = collected,
                    totalSlots = total,
                    hasProgress = hasProgress,
                    classMask = nil,
                    faction = nil,
                    isCustom = false,
                    isCommunity = true,
                    armorType = setInfo.armorType,
                    expansion = setInfo.expansionName,
                    filterType = setInfo.filterName,
                })
            end
        end
    end

    -- Sort by completion (incomplete first for "missing" to be useful), then by name
    table.sort(owner.sortedSets, function(a, b)
        -- If same completion status, sort by name
        if a.collected == b.collected then
            -- Within incomplete, show closest to completion first
            if not a.collected and a.totalSlots > 0 and b.totalSlots > 0 then
                local aProgress = a.collectedSlots / a.totalSlots
                local bProgress = b.collectedSlots / b.totalSlots
                if aProgress ~= bProgress then
                    return aProgress > bProgress  -- Higher progress first
                end
            end
            return a.name < b.name
        end
        -- Complete sets at the bottom when showing "All"
        return not a.collected
    end)

    -- Update stats with more useful info
    local completeSets = 0
    local inProgressSets = 0
    local blizzardCount = 0
    local customCount = 0

    for _, setData in ipairs(owner.sortedSets) do
        if setData.collected then
            completeSets = completeSets + 1
        elseif setData.hasProgress then
            inProgressSets = inProgressSets + 1
        end
        if setData.isCustom then
            customCount = customCount + 1
        else
            blizzardCount = blizzardCount + 1
        end
    end

    local statsText = string.format("|cff888888%d sets", #owner.sortedSets)
    if owner.currentFilter == "blizzard" then
        statsText = statsText .. string.format(" (|cff00ff00%d complete|r)|r", completeSets)
    elseif owner.currentFilter == "custom" then
        statsText = statsText .. string.format(" (|cffffd100★ custom|r)|r")
    elseif owner.currentFilter == "community" then
        statsText = statsText .. string.format(" (|cff00ff00%d done|r, |cff00ccff◆ community|r)|r", completeSets)
    else
        statsText = statsText .. string.format(" (|cff00ff00%d done|r, |cffffd100%d in progress|r)|r", completeSets, inProgressSets)
    end
    owner.setListFrame.stats:SetText(statsText)

    -- Populate scroll list
    owner:PopulateSetList()
    owner:UpdateFilterButtons()

    -- Step GC to reclaim temporary tables from this refresh cycle
    collectgarbage("step", 200)
end

-- =============================================================================
-- VIRTUAL-SCROLL BUTTON POOL
-- =============================================================================

function List:PopulateSetList(owner)
    local scrollChild = owner.setListFrame.scrollChild
    local scrollFrame = owner.setListFrame.scrollFrame
    if not scrollChild or not scrollFrame then return end

    -- Create button pool once (only first call creates them)
    if #owner.setButtons < BUTTON_POOL_SIZE then
        for i = #owner.setButtons + 1, BUTTON_POOL_SIZE do
            local btn = owner:CreateSetListButton(scrollChild)
            owner.setButtons[i] = btn
        end
    end

    -- Hide all buttons initially
    for _, btn in ipairs(owner.setButtons) do
        btn:Hide()
    end

    local totalSets = #owner.sortedSets

    -- Set scroll child height to full virtual height (makes scrollbar correct)
    scrollChild:SetHeight(math.max(totalSets * BUTTON_HEIGHT, 1))

    -- Hook scroll to update visible buttons on scroll change
    scrollFrame:SetScript("OnVerticalScroll", function(sf, offset)
        -- Standard UIPanelScrollFrame handler
        local scrollBar = sf.ScrollBar or _G[sf:GetName() .. "ScrollBar"]
        if scrollBar then
            scrollBar:SetValue(offset)
        end
        owner:UpdateVisibleButtons()
    end)

    -- Initial render
    owner:UpdateVisibleButtons()
end

function List:UpdateVisibleButtons(owner)
    local scrollChild = owner.setListFrame.scrollChild
    local scrollFrame = owner.setListFrame.scrollFrame
    if not scrollChild or not scrollFrame then return end

    local totalSets = #owner.sortedSets
    if totalSets == 0 then
        for _, btn in ipairs(owner.setButtons) do btn:Hide() end
        return
    end

    -- Get current scroll offset
    local scrollOffset = scrollFrame:GetVerticalScroll() or 0
    local visibleHeight = scrollFrame:GetHeight() or 400

    -- Calculate which data indices are visible
    local firstVisible = math.floor(scrollOffset / BUTTON_HEIGHT) + 1
    local lastVisible = math.min(firstVisible + BUTTON_POOL_SIZE - 1, totalSets)
    firstVisible = math.max(1, firstVisible)

    -- Hide all buttons first
    for _, btn in ipairs(owner.setButtons) do
        btn:Hide()
    end

    -- Assign data to pool buttons
    local poolIndex = 0
    for dataIndex = firstVisible, lastVisible do
        poolIndex = poolIndex + 1
        if poolIndex > BUTTON_POOL_SIZE then break end

        local btn = owner.setButtons[poolIndex]
        if not btn then break end

        local setData = owner.sortedSets[dataIndex]
        if not setData then break end

        -- Position at absolute Y based on data index
        local yPos = -((dataIndex - 1) * BUTTON_HEIGHT)
        btn:ClearAllPoints()
        btn:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 0, yPos)
        btn:SetPoint("RIGHT", scrollChild, "RIGHT", -5, 0)
        btn:SetHeight(BUTTON_HEIGHT)

        -- Update button content
        owner:UpdateSetButton(btn, setData)

        btn.setID = setData.setID
        btn:Show()
    end
end

function List:UpdateSetButton(owner, btn, setData)
    local collected = setData.collectedSlots or 0
    local total = setData.totalSlots or 0
    local isComplete = setData.collected

    -- Build display name
    local nameColor = isComplete and "|cff00ff00" or "|cffffffff"
    local displayName = nameColor .. setData.name .. "|r"

    if setData.classMask then
        local className = GetClassFromMask(setData.classMask)
        if className then
            local classColor = CLASS_COLORS[className] or "|cffaaaaaa"
            displayName = displayName .. " " .. classColor .. "(" .. className .. ")|r"
        end
    end

    if setData.faction then
        local factionDisplay = GetFactionDisplay(setData.faction)
        if factionDisplay then
            displayName = displayName .. " " .. factionDisplay
        end
    end

    if setData.isCustom then
        displayName = "|cffffd100★|r " .. displayName
    end

    if setData.isCommunity then
        displayName = "|cff00ccff◆|r " .. displayName
        if setData.armorType then
            local armorColors = {
                CLOTH = "|cffff7dff", LEATHER = "|cff00ff00",
                MAIL = "|cff0088ff", PLATE = "|cffffcc00",
                COSMETIC = "|cffff8800",
            }
            local armorShort = {
                CLOTH = "Cloth", LEATHER = "Leather",
                MAIL = "Mail", PLATE = "Plate",
                COSMETIC = "Cosmetic",
            }
            local color = armorColors[setData.armorType] or "|cffaaaaaa"
            local label = armorShort[setData.armorType] or setData.armorType
            displayName = displayName .. " " .. color .. "(" .. label .. ")|r"
        end
    end

    btn.name:SetText(displayName)

    -- Completion text with color
    if total > 0 then
        local compColor = isComplete and "|cff00ff00" or (collected > 0 and "|cffffd100" or "|cffaaaaaa")
        btn.completion:SetText(compColor .. collected .. "/" .. total .. "|r")

        local progress = collected / total
        local barWidth = btn:GetWidth() - 10
        if barWidth <= 0 then barWidth = 200 end
        btn.progressBar:SetWidth(math.max(1, barWidth * progress))

        if isComplete then
            btn.progressBar:SetColorTexture(0.2, 0.8, 0.2, 1)
        elseif progress > 0.5 then
            btn.progressBar:SetColorTexture(0.6, 0.6, 0.2, 1)
        elseif progress > 0 then
            btn.progressBar:SetColorTexture(0.4, 0.4, 0.4, 1)
        else
            btn.progressBar:SetColorTexture(0.2, 0.2, 0.2, 1)
        end
        btn.progressBar:Show()
        btn.progressBg:Show()
    else
        btn.completion:SetText("")
        btn.progressBar:Hide()
        btn.progressBg:Hide()
    end

    -- Highlight selected
    if setData.setID == owner.selectedSetID then
        btn.bg:SetColorTexture(0.2, 0.4, 0.6, 0.7)
    else
        btn.bg:SetColorTexture(0.1, 0.1, 0.1, 0.5)
    end
end

function List:CreateSetListButton(owner, parent)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetHeight(28)  -- Slightly taller
    btn:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight", "ADD")

    btn.bg = btn:CreateTexture(nil, "BACKGROUND")
    btn.bg:SetAllPoints()
    btn.bg:SetColorTexture(0.1, 0.1, 0.1, 0.5)

    btn.name = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    btn.name:SetPoint("TOPLEFT", 5, -2)
    btn.name:SetPoint("RIGHT", btn, "RIGHT", -45, 0)  -- Leave room for completion text
    btn.name:SetJustifyH("LEFT")
    btn.name:SetWordWrap(false)  -- Prevent overflow

    -- Completion text (e.g., "3/8")
    btn.completion = btn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    btn.completion:SetPoint("TOPRIGHT", -5, -2)
    btn.completion:SetWidth(40)  -- Fixed width for completion
    btn.completion:SetJustifyH("RIGHT")

    -- Progress bar background
    btn.progressBg = btn:CreateTexture(nil, "BORDER")
    btn.progressBg:SetPoint("BOTTOMLEFT", 5, 3)
    btn.progressBg:SetPoint("BOTTOMRIGHT", -5, 3)
    btn.progressBg:SetHeight(4)
    btn.progressBg:SetColorTexture(0.15, 0.15, 0.15, 1)

    -- Progress bar fill
    btn.progressBar = btn:CreateTexture(nil, "ARTWORK")
    btn.progressBar:SetPoint("BOTTOMLEFT", 5, 3)
    btn.progressBar:SetHeight(4)
    btn.progressBar:SetColorTexture(0.2, 0.6, 0.2, 1)

    btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    btn:SetScript("OnClick", function(self, button)
        if button == "RightButton" then
            -- v14.1.0 Feature 3.6: right-click toggles per-set hide flag
            local SD = ETB.SetsData
            if SD and self.setID then
                local nowHidden = SD:ToggleHidden(self.setID)
                if ETB.Print then
                    ETB:Print(string.format("Set #%d %s",
                        self.setID, nowHidden and "hidden" or "unhidden"))
                end
                owner:RefreshSetList()
            end
        else
            owner:SelectSet(self.setID)
        end
    end)

    return btn
end

function List:SetFilter(owner, filter)
    owner.currentFilter = filter
    owner:RefreshSetList()
end
