--[[
============================================================================
UI/TransmogUI/Sets.lua  (added in v14.9.0 — Audit Batch 3 Part 1)
============================================================================

Set-related rendering and apply logic for the TransmogUI orchestrator.
Owner-pattern: each method takes `owner` (the TUI orchestrator) as first
argument and accesses TUI state via owner.frame, owner.pendingTransmogs,
etc. The orchestrator keeps thin delegator methods so all existing call
sites (self:RefreshSets, self:ApplySet, etc.) keep working.

Methods owned here (6):
  CanPlayerUseSet         — class-mask check via C_TransmogSets API
  RefreshSets             — render the "Sets" tab in the browser panel
  RefreshIncompleteSets   — render the "Incomplete" tab
  ShowIncompleteSetDetail — open the detail panel for a partial-collection set
  ApplySet                — equip a fully-collected set as transmog
  ApplyCollectedPieces    — equip the collected pieces of a partial set

Methods kept on TUI itself (used cross-module):
  GetPlayerArmorType      — used by Sets, Capture, and others
  GetBlizzardSetItems     — generic set-data builder used by RefreshSets +
                            future Capture flows; doesn't naturally belong
                            in any one submodule
  CategoryToSlot          — small mapping helper

Closure / shadowing notes (v12.63 lessons):
  - `table.sort(t, function(a, b))` callbacks use (a, b) only — no `self`
    shadowing, safe across the move.
  - `C_Timer.After(delay, function() ... end)` callbacks reference outer
    `self` via closure. After the move, that closure-captured variable is
    `owner` (renamed during extraction). Verified: 6 ApplySet timer
    callbacks updated, no orphan TUI references remain.
  - The file-local `TUI._applyGeneration` upvalue from the original
    TransmogUI.lua is replaced with `owner._applyGeneration` — same
    storage, accessed through the orchestrator instead of by name.
============================================================================
]]--
local ADDON_NAME, NS = ...
local ETB = NS.ETB
NS.ETB.TransmogUI = NS.ETB.TransmogUI or {}
NS.ETB.TransmogUI.Sets = NS.ETB.TransmogUI.Sets or {}
local Sets = NS.ETB.TransmogUI.Sets

-- GetETB shim: a few of the moved methods reference `local ETB = GetETB()`
-- defensively. Provide the same shim so we don't have to rewrite those lines.
local function GetETB()
    return _G.ETB or NS.ETB
end

-- =============================================================================
-- METHODS (extracted from UI/TransmogUI.lua in v14.9.0)
-- =============================================================================

function Sets:CanPlayerUseSet(owner, setInfo)
    if not setInfo then return false end
    
    -- Check via C_TransmogSets.IsSetUsable if available
    if C_TransmogSets and C_TransmogSets.IsSetUsable and setInfo.setID then
        local ok, result = pcall(C_TransmogSets.IsSetUsable, setInfo.setID)
        if ok then return result end
    end
    
    -- Fallback: check classMask if present
    if setInfo.classMask then
        local _, class = UnitClass("player")
        local classBits = {
            WARRIOR = 1, PALADIN = 2, HUNTER = 4, ROGUE = 8,
            PRIEST = 16, DEATHKNIGHT = 32, SHAMAN = 64, MAGE = 128,
            WARLOCK = 256, MONK = 512, DRUID = 1024,
        }
        local myBit = classBits[class] or 0
        if bit.band(setInfo.classMask, myBit) == 0 then
            return false
        end
    end
    
    return true
end

-- Get item data for a Blizzard set


function Sets:RefreshSets(owner)
    local panel = owner.frame.browserPanel
    local scrollChild = panel.scrollChild
    
    scrollChild:SetWidth(math.max(1, panel.scrollFrame:GetWidth() - 20))
    wipe(owner.itemRows)
    panel.title:SetText("|cffffd100Transmog Sets|r")
    
    local sets = {}
    local playerArmor = owner:GetPlayerArmorType()
    local ETB = GetETB()
    
    -- 1. Load Blizzard sets from C_TransmogSets (via ETB.SetsData or directly)
    if ETB and ETB.SetsData then
        -- Make sure sets are discovered
        if ETB.SetsData.DiscoverSets and ETB.SetsData:CountTable(ETB.SetsData.allSets or {}) == 0 then
            ETB.SetsData:DiscoverSets()
        end
        
        for setID, setData in pairs(ETB.SetsData.allSets or {}) do
            -- Only show sets the player can use
            if owner:CanPlayerUseSet(setData) then
                local searchText = owner.searchText or ""
                if searchText == "" or (setData.name and setData.name:lower():find(searchText, 1, true)) then
                    -- Get completion status
                    local collected, total = 0, 0
                    if ETB.SetsData.GetSetCompletion then
                        collected, total = ETB.SetsData:GetSetCompletion(setID)
                    end
                    
                    -- Only show complete sets
                    if total > 0 and collected == total then
                        -- Get items for this set
                        local items = owner:GetBlizzardSetItems(setID)
                        
                        table.insert(sets, {
                            id = setID, name = setData.name, 
                            source = setData.label or setData.description or "",
                            isBlizzardSet = true, isSet = true,
                            collected = setData.collected,
                            completion = { collected = collected, total = total },
                            setInfo = setData,
                            items = items,
                        })
                    end
                end
            end
        end
    -- Fallback: Use C_TransmogSets directly if ETB not available
    elseif C_TransmogSets and C_TransmogSets.GetAllSets then
        local allSets = C_TransmogSets.GetAllSets() or {}
        for _, setInfo in ipairs(allSets) do
            if owner:CanPlayerUseSet(setInfo) then
                local searchText = owner.searchText or ""
                if searchText == "" or (setInfo.name and setInfo.name:lower():find(searchText, 1, true)) then
                    -- Calculate completion from set appearances
                    local collected, total = 0, 0
                    
                    if C_TransmogSets.GetSetPrimaryAppearances then
                        local appearances = C_TransmogSets.GetSetPrimaryAppearances(setInfo.setID)
                        if appearances then
                            for _, app in ipairs(appearances) do
                                total = total + 1
                                if app.collected then
                                    collected = collected + 1
                                end
                            end
                        end
                    end
                    
                    -- Only show complete sets
                    if total > 0 and collected == total then
                        local items = owner:GetBlizzardSetItems(setInfo.setID)
                        
                        table.insert(sets, {
                            id = setInfo.setID, name = setInfo.name,
                            source = setInfo.label or setInfo.description or "",
                            isBlizzardSet = true, isSet = true,
                            collected = setInfo.collected,
                            completion = { collected = collected, total = total },
                            setInfo = setInfo,
                            items = items,
                        })
                    end
                end
            end
        end
    end
    
    -- 2. Load ETB Custom Sets (predefined + user)
    if ETB and ETB.CustomSets then
        -- Initialize if needed
        if ETB.CustomSets.Initialize and not ETB.CustomSets.initialized then
            ETB.CustomSets:Initialize()
        end
        
        local allCustomSets = ETB.CustomSets.allCustomSets or {}
        for setID, setData in pairs(allCustomSets) do
            -- Only show sets matching player's armor type (or no armor type specified)
            local canUse = true
            if setData.armorType and setData.armorType ~= playerArmor then
                canUse = false
            end
            if canUse then
            local searchText = owner.searchText or ""
            if searchText == "" or (setData.name and setData.name:lower():find(searchText, 1, true)) then
                -- Get completion
                local collected, total = 0, 0
                if ETB.CustomSets.GetSetCompletion then
                    collected, total = ETB.CustomSets:GetSetCompletion(setID)
                end
                
                -- Only show complete sets
                if total > 0 and collected == total then
                    table.insert(sets, {
                        id = setID, name = setData.name,
                        source = setData.source or "Custom",
                        armorType = setData.armorType, 
                        items = setData.items, 
                        isSet = true,
                        isCustomSet = true,
                        isUserDefined = setData.isUserDefined,
                        completion = { collected = collected, total = total },
                    })
                end
            end
            end -- canUse
        end
    end
    
    -- 3. Load ETB Community Sets (MoP-validated)
    if ETB and ETB.CommunitySets then
        -- Trigger resolution if it hasn't started or completed
        if not ETB.CommunitySets.resolveComplete and ETB.CommunitySets.loaded then
            -- Resolution still in progress — refresh will be triggered when it completes
            -- Show a "loading" message hint for now
            if not ETB.CommunitySets._resolveTriggered then
                ETB.CommunitySets._resolveTriggered = true
                ETB.CommunitySets:ResolveAllSources()
            end
        end
        
        local allCommunitySets = ETB.CommunitySets:GetAllSets() or {}
        for setID, setData in pairs(allCommunitySets) do
            -- Only show sets matching player's armor type (TransmogUI = at NPC, show equippable)
            if setData.isUsable then
            local searchText = owner.searchText or ""
            if searchText == "" or (setData.name and setData.name:lower():find(searchText, 1, true)) then
                local collected = setData.collectedCount or 0
                local total = setData.pieceCount or 0
                
                -- Only show complete community sets with 3+ pieces
                if total >= 3 and collected == total then
                    table.insert(sets, {
                        id = setID, name = setData.name,
                        source = setData.source or "Community",
                        armorType = setData.armorDisplay,
                        isSet = true,
                        isCommunitySet = true,
                        items = setData.items,
                        completion = { collected = collected, total = total },
                    })
                end
            end
            end -- isUsable
        end
        
        -- If resolution is still in progress, add a loading hint
        if not ETB.CommunitySets.resolveComplete and ETB.CommunitySets.loaded then
            table.insert(sets, {
                id = "COMMUNITY_LOADING",
                name = "|cff888888Community sets loading...|r",
                source = "Resolving appearances",
                isSet = true,
                isCommunitySet = true,
                isLoadingHint = true,
                completion = { collected = 0, total = 0 },
            })
        end
    end
    
    -- Sort alphabetically (all are complete)
    table.sort(sets, function(a, b)
        return tostring(a.name or "") < tostring(b.name or "")
    end)
    
    for i, setData in ipairs(sets) do
        local row = owner:AcquireRow(scrollChild, i)
        row.itemData = setData
        
        -- Get icon from first item in set
        local icon = "Interface\\Icons\\INV_Chest_Cloth_17"
        if setData.items then
            for _, itemData in pairs(setData.items) do
                -- Community sets: itemData is a table {itemID=X, visualID=Y, icon=Z}
                -- Custom sets: itemData is a plain itemID number
                local itemID = type(itemData) == "number" and itemData or (type(itemData) == "table" and itemData.itemID)
                if itemID then
                    -- Try community set pre-resolved icon first
                    if type(itemData) == "table" and itemData.icon then
                        icon = itemData.icon
                        break
                    end
                    local cached = owner:GetCachedItem(itemID)
                    if cached.icon then icon = cached.icon; break end
                end
            end
        elseif setData.setInfo and C_TransmogSets and C_TransmogSets.GetSetPrimaryAppearances then
            local appearances = C_TransmogSets.GetSetPrimaryAppearances(setData.id)
            if appearances and #appearances > 0 and appearances[1].appearanceID then
                if C_TransmogCollection and C_TransmogCollection.GetAppearanceSources then
                    local sources = C_TransmogCollection.GetAppearanceSources(appearances[1].appearanceID)
                    if sources and #sources > 0 then
                        local srcInfo = C_TransmogCollection.GetSourceInfo(sources[1])
                        if srcInfo and srcInfo.itemID then
                            local cached = owner:GetCachedItem(srcInfo.itemID)
                            if cached.icon then icon = cached.icon end
                        end
                    end
                end
            end
        end
        
        row.icon:SetTexture(icon)
        
        -- Clean set name without symbols
        if setData.isCommunitySet then
            row.iconBorder:SetVertexColor(0, 0.7, 0.9, 1)
            row.name:SetText(setData.name or "Unknown")
            row.name:SetTextColor(0, 0.7, 0.9)
        elseif setData.isCustomSet then
            row.iconBorder:SetVertexColor(1, 0.82, 0, 1)
            row.name:SetText(setData.name or "Unknown")
            row.name:SetTextColor(1, 0.82, 0)
        else
            row.iconBorder:SetVertexColor(0.6, 0.2, 0.9, 1)
            row.name:SetText(setData.name or "Unknown")
            row.name:SetTextColor(0.6, 0.2, 0.9)
        end
        
        -- Clean info: just completion + source
        local info = ""
        if setData.completion and setData.completion.total > 0 then
            local pct = math.floor(setData.completion.collected / setData.completion.total * 100)
            local color = pct == 100 and "|cff00cc66" or (pct >= 50 and "|cffcccc00" or "|cffcc6600")
            info = string.format("%s%d/%d|r", color, setData.completion.collected, setData.completion.total)
        end
        if setData.source and setData.source ~= "" then
            info = info .. (info ~= "" and " |cff444444" or "|cff444444") .. setData.source .. "|r"
        end
        row.source:SetText(info)
        
        -- Dim if not collected at all
        if setData.completion and setData.completion.collected == 0 then
            row.icon:SetDesaturated(true); row.icon:SetAlpha(0.6)
            if row.collectedBar then row.collectedBar:SetColorTexture(1, 0.25, 0.25, 0.8) end
        else
            row.icon:SetDesaturated(false); row.icon:SetAlpha(1)
            if row.collectedBar then
                local pctVal = (setData.completion and setData.completion.total > 0) and (setData.completion.collected / setData.completion.total) or 0
                if pctVal >= 1 then
                    row.collectedBar:SetColorTexture(0, 1, 0.5, 0.9)
                elseif pctVal >= 0.5 then
                    row.collectedBar:SetColorTexture(1, 1, 0, 0.8)
                else
                    row.collectedBar:SetColorTexture(1, 0.4, 0, 0.8)
                end
            end
        end
        if row.status then row.status:SetText("") end
        
        owner.itemRows[i] = row
    end
    
    owner:ReleaseRowsToPool(#sets + 1)
    scrollChild:SetHeight(math.max(1, #sets * (owner.ROW_HEIGHT + owner.ROW_SPACING) + 10))
    
    -- Count by type for stats
    local blizCount, customCount, communityCount = 0, 0, 0
    for _, s in ipairs(sets) do
        if s.isCommunitySet then communityCount = communityCount + 1
        elseif s.isCustomSet then customCount = customCount + 1
        else blizCount = blizCount + 1 end
    end
    local parts = {}
    if blizCount > 0 then table.insert(parts, blizCount .. " Blizzard") end
    if customCount > 0 then table.insert(parts, customCount .. " Custom") end
    if communityCount > 0 then table.insert(parts, communityCount .. " Community") end
    panel.stats:SetText(string.format("|cff888888%d complete sets (%s)|r", #sets, table.concat(parts, ", ")))
end

-- =============================================================================
-- REFRESH INCOMPLETE SETS
-- =============================================================================



function Sets:RefreshIncompleteSets(owner)
    local panel = owner.frame.browserPanel
    local scrollChild = panel.scrollChild
    
    scrollChild:SetWidth(math.max(1, panel.scrollFrame:GetWidth() - 20))
    wipe(owner.itemRows)
    
    local ETB = GetETB()
    local playerArmor = (ETB and ETB.BlizzardData) and ETB.BlizzardData:GetPlayerPrimaryArmor() or "CLOTH"
    local sets = {}
    local searchText = owner.searchText or ""
    
    -- =========================================================================
    -- 1. Blizzard sets (incomplete only)
    -- =========================================================================
    if ETB and ETB.BlizzardData and ETB.BlizzardData.setsData then
        for setID, setData in pairs(ETB.BlizzardData.setsData) do
            if setData.name then
                local canUse = true
                if setData.armorType and setData.armorType ~= playerArmor then canUse = false end
                if canUse and (searchText == "" or setData.name:lower():find(searchText, 1, true)) then
                    local collected, total = 0, 0
                    if C_TransmogSets and C_TransmogSets.GetSetPrimaryAppearances then
                        local appearances = C_TransmogSets.GetSetPrimaryAppearances(setID)
                        if appearances then
                            for _, app in ipairs(appearances) do
                                total = total + 1
                                if app.collected then collected = collected + 1 end
                            end
                        end
                    end
                    if total > 0 and collected < total then
                        table.insert(sets, {
                            id = setID, name = setData.name,
                            source = setData.label or setData.description or "",
                            isBlizzardSet = true, isSet = true, isIncompleteSet = true,
                            completion = { collected = collected, total = total },
                            setInfo = setData,
                            items = owner:GetBlizzardSetItems(setID),
                        })
                    end
                end
            end
        end
    elseif C_TransmogSets and C_TransmogSets.GetAllSets then
        local allSets = C_TransmogSets.GetAllSets() or {}
        for _, setInfo in ipairs(allSets) do
            if owner:CanPlayerUseSet(setInfo) then
                if searchText == "" or (setInfo.name and setInfo.name:lower():find(searchText, 1, true)) then
                    local collected, total = 0, 0
                    if C_TransmogSets.GetSetPrimaryAppearances then
                        local appearances = C_TransmogSets.GetSetPrimaryAppearances(setInfo.setID)
                        if appearances then
                            for _, app in ipairs(appearances) do
                                total = total + 1
                                if app.collected then collected = collected + 1 end
                            end
                        end
                    end
                    if total > 0 and collected < total then
                        table.insert(sets, {
                            id = setInfo.setID, name = setInfo.name,
                            source = setInfo.label or setInfo.description or "",
                            isBlizzardSet = true, isSet = true, isIncompleteSet = true,
                            completion = { collected = collected, total = total },
                            setInfo = setInfo,
                            items = owner:GetBlizzardSetItems(setInfo.setID),
                        })
                    end
                end
            end
        end
    end
    
    -- =========================================================================
    -- 2. ETB Custom Sets (incomplete only)
    -- =========================================================================
    if ETB and ETB.CustomSets then
        if ETB.CustomSets.Initialize and not ETB.CustomSets.initialized then
            ETB.CustomSets:Initialize()
        end
        local allCustomSets = ETB.CustomSets.allCustomSets or {}
        for setID, setData in pairs(allCustomSets) do
            local canUse = true
            if setData.armorType and setData.armorType ~= playerArmor then canUse = false end
            if canUse and (searchText == "" or (setData.name and setData.name:lower():find(searchText, 1, true))) then
                local collected, total = 0, 0
                if ETB.CustomSets.GetSetCompletion then
                    collected, total = ETB.CustomSets:GetSetCompletion(setID)
                end
                if total > 0 and collected < total then
                    table.insert(sets, {
                        id = setID, name = setData.name,
                        source = setData.source or "Custom",
                        armorType = setData.armorType,
                        isSet = true, isCustomSet = true, isIncompleteSet = true,
                        isUserDefined = setData.isUserDefined,
                        items = setData.items,
                        completion = { collected = collected, total = total },
                    })
                end
            end
        end
    end
    
    -- =========================================================================
    -- 3. Community Sets (incomplete only, 3+ pieces)
    -- =========================================================================
    if ETB and ETB.CommunitySets then
        local allCommunitySets = ETB.CommunitySets:GetAllSets() or {}
        for setID, setData in pairs(allCommunitySets) do
            -- Only show sets matching player's armor type (TransmogUI = equippable only)
            if setData.isUsable then
            if searchText == "" or (setData.name and setData.name:lower():find(searchText, 1, true)) then
                local collected = setData.collectedCount or 0
                local total = setData.pieceCount or 0
                if total >= 3 and collected < total then
                    table.insert(sets, {
                        id = setID, name = setData.name,
                        source = setData.source or "Community",
                        armorType = setData.armorDisplay,
                        isSet = true, isCommunitySet = true, isIncompleteSet = true,
                        items = setData.items,
                        completion = { collected = collected, total = total },
                    })
                end
            end
            end -- isUsable
        end
    end
    
    -- =========================================================================
    -- 4. SetBuilder saved sets (farming wishlist sets)
    -- =========================================================================
    if ETB_SetBuilder_Data and ETB_SetBuilder_Data.savedSets then
        for setName, savedSet in pairs(ETB_SetBuilder_Data.savedSets) do
            if searchText == "" or setName:lower():find(searchText, 1, true) then
                if savedSet.items then
                    local collected, total = 0, 0
                    local itemsTable = {}
                    for slot, itemData in pairs(savedSet.items) do
                        local itemID = type(itemData) == "table" and itemData.itemID or (type(itemData) == "number" and itemData)
                        if itemID then
                            total = total + 1
                            local slotNum = tonumber(slot)
                            if slotNum then itemsTable[slotNum] = itemID end
                            -- Check collection via appearance sources (not PlayerHasTransmog which takes sourceID)
                            if C_TransmogCollection and C_TransmogCollection.GetItemInfo then
                                local ok, appID = pcall(C_TransmogCollection.GetItemInfo, itemID)
                                if ok and appID and C_TransmogCollection.GetAppearanceSources then
                                    local srcOK, sources = pcall(C_TransmogCollection.GetAppearanceSources, appID)
                                    if srcOK and sources then
                                        for _, src in ipairs(sources) do
                                            local sid = type(src) == "table" and src.sourceID or src
                                            if sid then
                                                local siOK, si = pcall(C_TransmogCollection.GetSourceInfo, sid)
                                                if siOK and si and si.isCollected then
                                                    collected = collected + 1
                                                    break
                                                end
                                            end
                                        end
                                    end
                                end
                            end
                        end
                    end
                    -- Only show if incomplete AND not already in CustomSets (avoid duplicates)
                    if total > 0 and collected < total then
                        -- Check if a CustomSet with this name already exists
                        local isDuplicate = false
                        for _, existing in ipairs(sets) do
                            if existing.name == setName then isDuplicate = true; break end
                        end
                        if not isDuplicate then
                            table.insert(sets, {
                                id = "SB_" .. setName,
                                name = setName,
                                source = savedSet.source or "Set Builder",
                                armorType = savedSet.armorType,
                                isSet = true, isSetBuilderSet = true, isIncompleteSet = true,
                                items = itemsTable,
                                completion = { collected = collected, total = total },
                            })
                        end
                    end
                end
            end
        end
    end
    
    -- Sort: closest to complete first, then alphabetical
    table.sort(sets, function(a, b)
        local aPct = (a.completion.total > 0) and (a.completion.collected / a.completion.total) or 0
        local bPct = (b.completion.total > 0) and (b.completion.collected / b.completion.total) or 0
        if aPct ~= bPct then return aPct > bPct end
        return tostring(a.name or "") < tostring(b.name or "")
    end)
    
    -- =========================================================================
    -- Render rows
    -- =========================================================================
    for i, setData in ipairs(sets) do
        local row = owner:AcquireRow(scrollChild, i)
        row.itemData = setData
        
        -- Icon from first item
        local icon = "Interface\\Icons\\INV_Chest_Cloth_17"
        if setData.items then
            for _, itemData in pairs(setData.items) do
                local itemID = type(itemData) == "number" and itemData or (type(itemData) == "table" and itemData.itemID)
                if itemID then
                    if type(itemData) == "table" and itemData.icon then
                        icon = itemData.icon; break
                    end
                    local cached = owner:GetCachedItem(itemID)
                    if cached.icon then icon = cached.icon; break end
                end
            end
        end
        row.icon:SetTexture(icon)
        
        -- Color by type
        if setData.isCommunitySet then
            row.iconBorder:SetVertexColor(0, 0.8, 1, 1)
            row.name:SetText("|cff00ccff◆|r " .. (setData.name or "Unknown"))
            row.name:SetTextColor(0, 0.8, 1)
        elseif setData.isSetBuilderSet then
            row.iconBorder:SetVertexColor(1, 0.5, 0, 1)  -- Orange for SetBuilder
            row.name:SetText("|cffff8800✦|r " .. (setData.name or "Unknown"))
            row.name:SetTextColor(1, 0.5, 0)
        elseif setData.isCustomSet then
            row.iconBorder:SetVertexColor(1, 0.82, 0, 1)
            row.name:SetText("|cffffd100★|r " .. (setData.name or "Unknown"))
            row.name:SetTextColor(1, 0.82, 0)
        else
            row.iconBorder:SetVertexColor(0.64, 0.21, 0.93, 1)
            row.name:SetText(setData.name or "Unknown")
            row.name:SetTextColor(0.64, 0.21, 0.93)
        end
        
        -- Completion info
        local comp = setData.completion
        local pct = comp.total > 0 and math.floor(comp.collected / comp.total * 100) or 0
        local color = pct >= 75 and "|cffcccc00" or (pct >= 50 and "|cffcc8800" or "|cffcc4444")
        local missing = comp.total - comp.collected
        local info = string.format("%s%d/%d|r |cff666666%d need|r", color, comp.collected, comp.total, missing)
        if setData.source and setData.source ~= "" then info = info .. " |cff444444" .. setData.source .. "|r" end
        row.source:SetText(info)
        
        -- Partially desaturate based on completion
        row.icon:SetDesaturated(pct < 50)
        row.icon:SetAlpha(pct >= 25 and 1 or 0.7)
        
        owner.itemRows[i] = row
    end
    
    owner:ReleaseRowsToPool(#sets + 1)
    scrollChild:SetHeight(math.max(1, #sets * (owner.ROW_HEIGHT + owner.ROW_SPACING) + 10))
    
    panel.stats:SetText(string.format("|cffff8800%d incomplete sets to farm|r", #sets))
end

-- =============================================================================
-- SHOW INCOMPLETE SET DETAIL
-- =============================================================================



function Sets:ShowIncompleteSetDetail(owner, setData)
    if not setData or not owner.frame or not owner.frame.browserPanel then return end
    
    local panel = owner.frame.browserPanel
    local scrollChild = panel.scrollChild
    
    scrollChild:SetWidth(math.max(1, panel.scrollFrame:GetWidth() - 20))
    wipe(owner.itemRows)
    
    local ETB = GetETB()
    local SLOT_NAMES = {
        [1] = "Head", [2] = "Shoulder", [3] = "Chest",
        [4] = "Waist", [5] = "Legs", [6] = "Feet",
        [7] = "Wrist", [8] = "Hands", [9] = "Back",
    }
    
    -- Build item list from set data
    local pieces = {}
    
    if setData.isBlizzardSet and setData.setInfo and C_TransmogSets and C_TransmogSets.GetSetPrimaryAppearances then
        -- Blizzard set: use API to get items
        local appearances = C_TransmogSets.GetSetPrimaryAppearances(setData.id)
        if appearances then
            for _, appInfo in ipairs(appearances) do
                local itemID = nil
                local isCollected = appInfo.collected or false
                local sourceID = appInfo.sourceID or appInfo.appearanceID
                
                -- Resolve to itemID
                if sourceID and C_TransmogCollection and C_TransmogCollection.GetSourceInfo then
                    local srcInfo = C_TransmogCollection.GetSourceInfo(sourceID)
                    if srcInfo then itemID = srcInfo.itemID end
                end
                if not itemID and appInfo.appearanceID and C_TransmogCollection.GetAppearanceSources then
                    local sources = C_TransmogCollection.GetAppearanceSources(appInfo.appearanceID)
                    if sources and #sources > 0 then
                        itemID = sources[1].itemID
                    end
                end
                
                if itemID then
                    local _, _, _, equipLoc = GetItemInfoInstant(itemID)
                    table.insert(pieces, {
                        itemID = itemID,
                        isCollected = isCollected,
                        equipLoc = equipLoc,
                    })
                end
            end
        end
    elseif setData.items then
        -- Custom/Community/SetBuilder set: items table
        for slot, itemData in pairs(setData.items) do
            local itemID = type(itemData) == "number" and itemData or (type(itemData) == "table" and itemData.itemID)
            if itemID then
                -- Determine collection status:
                -- Community set items (tables) already have isCollected resolved
                -- Custom set items (numbers) need a runtime check
                local isCollected = false
                if type(itemData) == "table" and itemData.isCollected ~= nil then
                    -- Use pre-resolved collection status from CommunitySetLoader
                    isCollected = itemData.isCollected
                else
                    -- Runtime check via sourceID chain (not PlayerHasTransmog with itemID!)
                    if C_TransmogCollection and C_TransmogCollection.GetItemInfo then
                        local ok, appearanceID, sourceID = pcall(C_TransmogCollection.GetItemInfo, itemID)
                        if ok and appearanceID then
                            -- Check if any source for this appearance is collected
                            if C_TransmogCollection.GetAppearanceSources then
                                local srcOK, sources = pcall(C_TransmogCollection.GetAppearanceSources, appearanceID)
                                if srcOK and sources then
                                    for _, src in ipairs(sources) do
                                        local sid = type(src) == "table" and src.sourceID or src
                                        if sid then
                                            local siOK, si = pcall(C_TransmogCollection.GetSourceInfo, sid)
                                            if siOK and si and si.isCollected then
                                                isCollected = true
                                                break
                                            end
                                        end
                                    end
                                end
                            end
                        end
                    end
                end
                
                local _, _, _, equipLoc = GetItemInfoInstant(itemID)
                table.insert(pieces, {
                    itemID = itemID,
                    isCollected = isCollected,
                    equipLoc = equipLoc,
                    slot = tonumber(slot),
                })
            end
        end
    end
    
    -- Sort: uncollected first, then by slot
    table.sort(pieces, function(a, b)
        if a.isCollected ~= b.isCollected then return not a.isCollected end
        return (a.slot or 0) < (b.slot or 0)
    end)
    
    -- =========================================================================
    -- Render: Back button (row 1)
    -- =========================================================================
    local rowIdx = 1
    local backRow = owner:AcquireRow(scrollChild, rowIdx)
    backRow.itemData = { isBackButton = true }
    backRow.icon:SetTexture("Interface\\Buttons\\UI-SpellbookIcon-PrevPage")
    backRow.name:SetText("|cff00ccff← Back to Incomplete Sets|r")
    backRow.name:SetTextColor(0, 0.8, 1)
    backRow.source:SetText("")
    backRow.icon:SetDesaturated(false)
    backRow.icon:SetAlpha(1)
    backRow.iconBorder:SetVertexColor(0, 0.8, 1, 1)
    owner.itemRows[rowIdx] = backRow
    rowIdx = rowIdx + 1
    
    -- =========================================================================
    -- Render: Set header (row 2)
    -- =========================================================================
    local headerRow = owner:AcquireRow(scrollChild, rowIdx)
    headerRow.itemData = {}  -- Non-clickable
    headerRow.icon:SetTexture("Interface\\Icons\\Achievement_Transmog_Collections")
    headerRow.name:SetText("|cffffd100" .. (setData.name or "Unknown Set") .. "|r")
    headerRow.name:SetTextColor(1, 0.82, 0)
    local comp = setData.completion or { collected = 0, total = 0 }
    local pct = comp.total > 0 and math.floor(comp.collected / comp.total * 100) or 0
    headerRow.source:SetText(string.format("|cffff8800%d/%d collected (%d%%) — %d pieces to farm|r",
        comp.collected, comp.total, pct, comp.total - comp.collected))
    headerRow.icon:SetDesaturated(false)
    headerRow.icon:SetAlpha(1)
    headerRow.iconBorder:SetVertexColor(1, 0.82, 0, 1)
    owner.itemRows[rowIdx] = headerRow
    rowIdx = rowIdx + 1
    
    -- =========================================================================
    -- Render: Apply Collected Pieces button (row 3) — only if some pieces collected
    -- =========================================================================
    if comp.collected > 0 then
        local applyRow = owner:AcquireRow(scrollChild, rowIdx)
        applyRow.itemData = { isApplyCollectedButton = true, setData = setData, pieces = pieces }
        applyRow.icon:SetTexture("Interface\\Icons\\INV_Misc_GroupLooking")
        applyRow.name:SetText("|cff00ff00▶ Apply " .. comp.collected .. " Collected Pieces|r")
        applyRow.name:SetTextColor(0, 1, 0)
        applyRow.source:SetText("|cff888888Preview collected items on your character model|r")
        applyRow.icon:SetDesaturated(false)
        applyRow.icon:SetAlpha(1)
        applyRow.iconBorder:SetVertexColor(0, 1, 0, 1)
        owner.itemRows[rowIdx] = applyRow
        rowIdx = rowIdx + 1
    end
    
    -- =========================================================================
    -- Render: Each piece with source info
    -- =========================================================================
    for _, piece in ipairs(pieces) do
        local row = owner:AcquireRow(scrollChild, rowIdx)
        
        -- Build item data for right-click detail panel support
        local itemName, _, itemQuality, _, _, _, _, _, _, itemIcon = GetItemInfo(piece.itemID)
        local cached = owner:GetCachedItem(piece.itemID)
        itemName = itemName or cached.name or ("Item " .. piece.itemID)
        itemIcon = itemIcon or cached.icon
        
        row.itemData = {
            itemID = piece.itemID,
            name = itemName,
            quality = itemQuality or cached.quality,
            icon = itemIcon,
            isCollected = piece.isCollected,
            invType = piece.equipLoc,
        }
        
        row.icon:SetTexture(itemIcon or "Interface\\Icons\\INV_Misc_QuestionMark")
        row.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        
        -- Slot name
        local slotName = ""
        local EQUIP_TO_SLOT = {
            INVTYPE_HEAD = "Head", INVTYPE_SHOULDER = "Shoulder",
            INVTYPE_CHEST = "Chest", INVTYPE_ROBE = "Chest",
            INVTYPE_WAIST = "Waist", INVTYPE_LEGS = "Legs",
            INVTYPE_FEET = "Feet", INVTYPE_WRIST = "Wrist",
            INVTYPE_HAND = "Hands", INVTYPE_CLOAK = "Back",
            INVTYPE_WEAPON = "Weapon", INVTYPE_WEAPONMAINHAND = "Main Hand",
            INVTYPE_WEAPONOFFHAND = "Off Hand", INVTYPE_2HWEAPON = "Two-Hand",
            INVTYPE_SHIELD = "Shield", INVTYPE_HOLDABLE = "Off-Hand",
            INVTYPE_RANGED = "Ranged", INVTYPE_RANGEDRIGHT = "Ranged",
        }
        if piece.equipLoc then slotName = EQUIP_TO_SLOT[piece.equipLoc] or "" end
        if slotName == "" and piece.slot and SLOT_NAMES[piece.slot] then
            slotName = SLOT_NAMES[piece.slot]
        end
        
        -- Name with collection status
        if piece.isCollected then
            row.name:SetText("|cff00ff00✓|r " .. (itemName or "Unknown"))
            row.name:SetTextColor(0.5, 0.8, 0.5)
            row.icon:SetDesaturated(false)
            row.icon:SetAlpha(1)
            row.iconBorder:SetVertexColor(0, 1, 0, 1)
        else
            row.name:SetText("|cffff4444✗|r " .. (itemName or "Unknown"))
            row.name:SetTextColor(1, 0.4, 0.4)
            row.icon:SetDesaturated(true)
            row.icon:SetAlpha(0.7)
            row.iconBorder:SetVertexColor(1, 0.3, 0.3, 1)
        end
        
        -- Source info line
        local sourceInfo = ""
        if slotName ~= "" then
            sourceInfo = "|cff888888[" .. slotName .. "]|r "
        end
        
        -- Get rich source data
        if ETB and piece.itemID then
            local sourceLines = nil
            if ETB.SourceMapper and ETB.SourceMapper.FormatSourceTooltip then
                sourceLines = ETB.SourceMapper:FormatSourceTooltip(piece.itemID)
            end
            if sourceLines and #sourceLines > 0 then
                -- First line = source type, second = "From: BossName"
                sourceInfo = sourceInfo .. sourceLines[1]
                for li = 2, math.min(#sourceLines, 3) do
                    sourceInfo = sourceInfo .. "  " .. sourceLines[li]
                end
            elseif ETB.SourceData_ATT and ETB.SourceData_ATT.GetPrimarySource then
                local src = ETB.SourceData_ATT:GetPrimarySource(piece.itemID)
                if src then
                    if src.name and src.name ~= "Unknown" then
                        sourceInfo = sourceInfo .. "|cffffd100" .. src.name .. "|r"
                    end
                    if src.location and src.location ~= "" then
                        sourceInfo = sourceInfo .. " |cff888888(" .. src.location .. ")|r"
                    end
                end
            end
        end
        
        -- Fallback if no source found
        if sourceInfo == "" or sourceInfo == ("|cff888888[" .. slotName .. "]|r ") then
            sourceInfo = sourceInfo .. "|cff666666Source unknown|r"
        end
        
        -- Add "NEEDED" tag for uncollected
        if not piece.isCollected then
            sourceInfo = sourceInfo .. "  |cffff4444← FARM|r"
        end
        
        row.source:SetText(sourceInfo)
        
        owner.itemRows[rowIdx] = row
        rowIdx = rowIdx + 1
    end
    
    owner:ReleaseRowsToPool(rowIdx)
    scrollChild:SetHeight(math.max(1, (rowIdx - 1) * (owner.ROW_HEIGHT + owner.ROW_SPACING) + 10))
    
    local missing = comp.total - comp.collected
    panel.stats:SetText(string.format("|cffff8800%s — %d/%d collected, %d to farm|r",
        setData.name or "Set", comp.collected, comp.total, missing))
end



function Sets:ApplySet(owner, setData)
    if not setData then return end
    
    owner._applyingSet = true
    -- Increment apply generation to invalidate stale C_Timer callbacks
    owner._applyGeneration = (owner._applyGeneration or 0) + 1
    local currentGeneration = owner._applyGeneration
    
    wipe(owner.pendingTransmogs)
    
    -- Clear Blizzard's pending for ALL slots before applying new set
    -- This prevents old set remnants from leaking through
    owner:ClearBlizzardPending()
    
    -- Reset all slot buttons to default first
    for invSlot, btn in pairs(owner.frame.slotButtons or {}) do
        btn.icon:SetTexture(owner:GetDefaultSlotIcon(invSlot))
        btn.pendingBorder:Hide()
        btn.pendingInner:Hide()
        btn.hiddenBorder:Hide()
        btn.isHidden = false
        btn:SetBackdropBorderColor(0.4, 0.4, 0.4, 1)
    end
    
    -- Reset model fully — restored from v12.34.0. The original comment was:
    -- "SetUnit reloads the character model from scratch, then Undress strips
    -- all gear. This ensures no TryOn remnants persist." Removing SetUnit
    -- during modulation broke this guarantee.
    owner.frame.model:SetUnit("player")
    owner.frame.model:Undress()
    
    local appliedCount = 0
    local totalCount = 0
    
    -- Map invType to invSlot for set items
    local invTypeToSlot = {
        ["INVTYPE_HEAD"] = 1, ["INVTYPE_SHOULDER"] = 3, ["INVTYPE_CLOAK"] = 15,
        ["INVTYPE_CHEST"] = 5, ["INVTYPE_ROBE"] = 5, ["INVTYPE_WRIST"] = 9,
        ["INVTYPE_HAND"] = 10, ["INVTYPE_WAIST"] = 6, ["INVTYPE_LEGS"] = 7,
        ["INVTYPE_FEET"] = 8, ["INVTYPE_MAINHAND"] = 16, ["INVTYPE_WEAPON"] = 16,
        ["INVTYPE_WEAPONMAINHAND"] = 16, ["INVTYPE_2HWEAPON"] = 16,
        ["INVTYPE_OFFHAND"] = 17, ["INVTYPE_HOLDABLE"] = 17, ["INVTYPE_SHIELD"] = 17,
        ["INVTYPE_WEAPONOFFHAND"] = 17, ["INVTYPE_RANGED"] = 16, ["INVTYPE_RANGEDRIGHT"] = 16,
    }
    
    -- Direct categoryID to invSlot mapping
    local categoryToInvSlot = {
        [1] = 1, [2] = 3, [3] = 15, [4] = 5, [5] = 4, [6] = 19,
        [7] = 9, [8] = 10, [9] = 6, [10] = 7, [11] = 8,
    }
    
    if setData.isBlizzardSet and C_TransmogSets then
        local sources = C_TransmogSets.GetSetPrimaryAppearances(setData.id)
        ETB:Debug("TransmogUI", string.format("Set '%s' returned %d items", setData.name or "?", sources and #sources or 0))
        
        if sources then
            -- Collect all items first
            local itemsToApply = {}
            
            for idx, appInfo in ipairs(sources) do
                totalCount = totalCount + 1
                
                local itemID = nil
                local sourceID = nil
                local invSlot = nil
                
                sourceID = appInfo.sourceID or appInfo.appearanceID
                
                if sourceID then
                    local srcInfo = C_TransmogCollection.GetSourceInfo(sourceID)
                    if srcInfo then
                        itemID = srcInfo.itemID
                        if srcInfo.categoryID then
                            invSlot = categoryToInvSlot[srcInfo.categoryID] or owner:CategoryToSlot(srcInfo.categoryID)
                        end
                        ETB:Debug("TransmogUI", string.format("#%d: itemID=%s, catID=%s -> slot=%s", 
                            idx, tostring(itemID), tostring(srcInfo.categoryID), tostring(invSlot)))
                    end
                end
                
                if itemID then
                    local cached = owner:GetCachedItem(itemID)
                    local icon = cached.icon or GetItemIcon(itemID) or "Interface\\Icons\\INV_Misc_QuestionMark"
                    
                    table.insert(itemsToApply, {
                        itemID = itemID,
                        sourceID = sourceID,
                        invSlot = invSlot,
                        name = cached.name,
                        icon = icon
                    })
                    appliedCount = appliedCount + 1
                    
                    -- Update slot button immediately
                    if invSlot then
                        owner.pendingTransmogs[invSlot] = { 
                            sourceID = sourceID, itemID = itemID, name = cached.name, icon = icon 
                        }
                        if sourceID then owner.itemIDToSourceID[itemID] = sourceID end
                        
                        -- Check if player has a collected source for this appearance
                        local collectedSrc, isCollected = owner:GetCollectedSourceForItem(itemID)
                        
                        local btn = owner.frame.slotButtons[invSlot]
                        if btn then
                            btn.icon:SetTexture(icon)
                            btn.pendingBorder:Show()
                            btn.pendingInner:Show()
                            if isCollected then
                                btn:SetBackdropBorderColor(1, 0.82, 0, 1)  -- Gold = collected
                            else
                                btn:SetBackdropBorderColor(0.6, 0.3, 0.3, 1)  -- Muted red = not collected
                            end
                        end
                    end
                end
            end
            
            -- Apply ALL items to model with staggered timing
            local model = owner.frame.model
            for i, item in ipairs(itemsToApply) do
                C_Timer.After((i - 1) * 0.05, function()
                    -- Guard: only apply if no newer set has been loaded
                    if owner._applyGeneration == currentGeneration and model then
                        model:TryOn("item:" .. item.itemID)
                    end
                end)
            end
            
            -- Also do a final re-apply of everything after all staggered calls
            C_Timer.After(#itemsToApply * 0.05 + 0.2, function()
                if owner._applyGeneration == currentGeneration and model then
                    for _, item in ipairs(itemsToApply) do
                        model:TryOn("item:" .. item.itemID)
                    end
                end
            end)
        end
    elseif setData.isCommunitySet and setData.items then
        -- Community set: items keyed by invSlot, values are tables {itemID, visualID, sourceID, isCollected}
        -- Collect all items first, then apply with staggered timing (like Blizzard sets)
        local itemsToApply = {}
        
        for invSlot, itemData in pairs(setData.items) do
            if type(itemData) == "table" and itemData.itemID then
                totalCount = totalCount + 1
                
                local itemID = itemData.itemID
                local sourceID = itemData.sourceID
                
                -- If no sourceID resolved yet, try to get one from visualID
                local resolvedVID = ETB:ResolveVisualID(itemData)
                if (not sourceID or sourceID == 0) and resolvedVID then
                    if C_TransmogCollection and C_TransmogCollection.GetAppearanceSources then
                        local ok, sources = pcall(C_TransmogCollection.GetAppearanceSources, resolvedVID)
                        if ok and sources then
                            for _, src in ipairs(sources) do
                                local sid = type(src) == "table" and src.sourceID or src
                                if sid then
                                    sourceID = sid
                                    -- Prefer collected source
                                    local srcOK, srcInfo = pcall(C_TransmogCollection.GetSourceInfo, sid)
                                    if srcOK and srcInfo and srcInfo.isCollected then
                                        break
                                    end
                                end
                            end
                        end
                    end
                end
                
                -- Cache sourceID mapping
                if sourceID and sourceID > 0 then
                    owner.itemIDToSourceID[itemID] = sourceID
                end
                
                -- Get icon
                local icon = itemData.icon
                if not icon then
                    local cached = owner:GetCachedItem(itemID)
                    icon = cached.icon
                end
                if not icon or icon == "" then
                    icon = GetItemIcon(itemID) or "Interface\\Icons\\INV_Misc_QuestionMark"
                end
                
                -- Get name
                local name = itemData.name
                if not name or type(name) == "number" then
                    local cached = owner:GetCachedItem(itemID)
                    name = cached.name or ("Item " .. itemID)
                end
                
                -- Map robe slot 20 to chest slot 5 for pending transmog
                local pendingSlot = invSlot
                if invSlot == 20 then pendingSlot = 5 end
                if invSlot == 4 then pendingSlot = 4 end  -- shirt
                
                -- Store pending transmog (update UI immediately)
                owner.pendingTransmogs[pendingSlot] = {
                    sourceID = sourceID,
                    itemID = itemID,
                    name = name,
                    icon = icon,
                }
                
                -- Update slot button immediately
                local btn = owner.frame.slotButtons[pendingSlot]
                if btn then
                    btn.icon:SetTexture(icon)
                    btn.pendingBorder:Show()
                    btn.pendingInner:Show()
                    
                    -- Gold border for collected, muted for not
                    if itemData.isCollected then
                        btn:SetBackdropBorderColor(1, 0.82, 0, 1)
                    else
                        btn:SetBackdropBorderColor(0.6, 0.3, 0.3, 1)
                    end
                end
                
                table.insert(itemsToApply, { itemID = itemID, sourceID = sourceID })
                appliedCount = appliedCount + 1
            end
        end
        
        -- Apply ALL items with staggered timing so Undress() has time to clear
        local model = owner.frame.model
        for i, item in ipairs(itemsToApply) do
            C_Timer.After((i - 1) * 0.05, function()
                if owner._applyGeneration == currentGeneration and model then
                    model:TryOn("item:" .. item.itemID)
                end
            end)
        end
        
        -- Final re-apply pass to catch any that were overwritten during stagger
        C_Timer.After(#itemsToApply * 0.05 + 0.2, function()
            if owner._applyGeneration == currentGeneration and model then
                for _, item in ipairs(itemsToApply) do
                    model:TryOn("item:" .. item.itemID)
                end
            end
        end)
    elseif setData.items then
        -- Custom set with item IDs - look up sourceIDs immediately
        local slotToInv = { [1] = 1, [2] = 3, [3] = 5, [4] = 6, [5] = 7, [6] = 8, [7] = 9, [8] = 10, [9] = 15 }
        
        for slot, itemID in pairs(setData.items) do
            if type(itemID) == "number" then
                totalCount = totalCount + 1
                
                local invSlot = slotToInv[slot]
                if invSlot then
                    -- Get sourceID from itemID via API
                    local sourceID = nil
                    if C_TransmogCollection and C_TransmogCollection.GetItemInfo then
                        local ok, visualID, directSourceID = pcall(C_TransmogCollection.GetItemInfo, itemID)
                        if ok and visualID then
                            if directSourceID and type(directSourceID) == "number" then
                                sourceID = directSourceID
                            elseif visualID and type(visualID) == "number" then
                                local sources = C_TransmogCollection.GetAppearanceSources(visualID)
                                if sources then
                                    for _, src in ipairs(sources) do
                                        local sid = type(src) == "number" and src or (src and src.sourceID)
                                        if sid then
                                            sourceID = sid
                                            break
                                        end
                                    end
                                end
                            end
                        end
                    end
                    
                    -- Cache the mapping
                    if sourceID then
                        owner.itemIDToSourceID[itemID] = sourceID
                    end
                    
                    local cached = owner:GetCachedItem(itemID)
                    local icon = cached.icon
                    if not icon or icon == "" or icon == "Interface\\Icons\\INV_Misc_QuestionMark" then
                        icon = GetItemIcon(itemID) or "Interface\\Icons\\INV_Misc_QuestionMark"
                    end
                    
                    owner.frame.model:TryOn("item:" .. itemID)
                    owner.pendingTransmogs[invSlot] = { 
                        sourceID = sourceID,  -- Now includes sourceID!
                        itemID = itemID, 
                        name = cached.name, 
                        icon = icon 
                    }
                    
                    -- Directly update slot button 
                    local btn = owner.frame.slotButtons[invSlot]
                    if btn then
                        btn.icon:SetTexture(icon)
                        btn.pendingBorder:Show()
                        btn.pendingInner:Show()
                        btn:SetBackdropBorderColor(1, 0.82, 0, 1)
                    end
                    
                    appliedCount = appliedCount + 1
                end
            end
        end
    end
    
    -- Auto-hide armor slots that the set doesn't cover.
    -- This ensures a clean look: only the set pieces show, everything else is hidden.
    -- Weapons, Shirt, and Tabard are excluded (sets may intentionally not include them).
    local AUTO_HIDE_SLOTS = { [1]=true, [3]=true, [5]=true, [6]=true, [7]=true, [8]=true, [9]=true, [10]=true, [15]=true }
    local hiddenCount = 0
    for invSlot, _ in pairs(AUTO_HIDE_SLOTS) do
        if not owner.pendingTransmogs[invSlot] then
            owner.pendingTransmogs[invSlot] = {
                isHiddenSlot = true,
                name = "Hidden",
                icon = "Interface\\Icons\\Spell_Shadow_SacrificialShield",
            }
            hiddenCount = hiddenCount + 1
        end
    end
    
    owner:UpdateSlotButtonIcons()
    owner:UpdateCost()
    
    -- Push all pending to Blizzard so both UIs stay in sync
    -- (this also enables Apply to work at the transmog NPC)
    owner:SyncToBlizzardPending()
    owner:UpdateCost()
    
    -- Delay clearing the guard flag — TRANSMOGRIFY_UPDATE events from 
    -- ClearBlizzardPending/SyncToBlizzardPending fire asynchronously
    C_Timer.After(1.0, function()
        -- Only clear if no newer set was applied in the meantime
        if owner._applyGeneration == currentGeneration then
            owner._applyingSet = nil
        end
    end)
    
    -- Show completion message
    local hiddenMsg = hiddenCount > 0 and string.format(", %d slots hidden", hiddenCount) or ""
    if appliedCount == totalCount and totalCount > 0 then
        print(string.format("|cff00ff00[ETB]|r Applied set: %s (%d/%d pieces%s)", 
            setData.name or "Unknown", appliedCount, totalCount, hiddenMsg))
    elseif appliedCount > 0 then
        print(string.format("|cffffff00[ETB]|r Applied partial set: %s (%d/%d pieces%s)", 
            setData.name or "Unknown", appliedCount, totalCount, hiddenMsg))
    else
        print(string.format("|cffff6600[ETB]|r Could not apply set: %s (0/%d)", 
            setData.name or "Unknown", totalCount))
    end
end

-- Apply only collected pieces from an incomplete set to the model


function Sets:ApplyCollectedPieces(owner, setData, pieces)
    if not setData or not pieces then return end
    
    owner._applyingSet = true
    owner._applyGeneration = (owner._applyGeneration or 0) + 1
    local currentGeneration = owner._applyGeneration
    
    wipe(owner.pendingTransmogs)
    owner:ClearBlizzardPending()
    
    -- Reset all slot buttons
    for invSlot, btn in pairs(owner.frame.slotButtons or {}) do
        btn.icon:SetTexture(owner:GetDefaultSlotIcon(invSlot))
        btn.pendingBorder:Hide()
        btn.pendingInner:Hide()
        btn.hiddenBorder:Hide()
        btn.isHidden = false
        btn:SetBackdropBorderColor(0.4, 0.4, 0.4, 1)
    end
    
    -- Reset model fully — restored from v12.34.0 (was simplified to just
    -- Undress() during modulation, which doesn't reliably clear TryOn'd items
    -- in MoP Classic 5.4.8). Same pattern as v12.48.4's hover preview fix.
    owner.frame.model:SetUnit("player")
    owner.frame.model:Undress()
    
    local invTypeToSlot = {
        ["INVTYPE_HEAD"] = 1, ["INVTYPE_SHOULDER"] = 3, ["INVTYPE_CLOAK"] = 15,
        ["INVTYPE_CHEST"] = 5, ["INVTYPE_ROBE"] = 5, ["INVTYPE_WRIST"] = 9,
        ["INVTYPE_HAND"] = 10, ["INVTYPE_WAIST"] = 6, ["INVTYPE_LEGS"] = 7,
        ["INVTYPE_FEET"] = 8, ["INVTYPE_MAINHAND"] = 16, ["INVTYPE_WEAPON"] = 16,
        ["INVTYPE_WEAPONMAINHAND"] = 16, ["INVTYPE_2HWEAPON"] = 16,
        ["INVTYPE_OFFHAND"] = 17, ["INVTYPE_HOLDABLE"] = 17, ["INVTYPE_SHIELD"] = 17,
        ["INVTYPE_WEAPONOFFHAND"] = 17, ["INVTYPE_RANGED"] = 16, ["INVTYPE_RANGEDRIGHT"] = 16,
    }
    
    local itemsToApply = {}
    local appliedCount = 0
    
    for _, piece in ipairs(pieces) do
        if piece.isCollected and piece.itemID then
            local cached = owner:GetCachedItem(piece.itemID)
            local icon = cached.icon or GetItemIcon(piece.itemID) or "Interface\\Icons\\INV_Misc_QuestionMark"
            local invSlot = piece.equipLoc and invTypeToSlot[piece.equipLoc]
            
            if invSlot then
                -- Find a collected sourceID for this item
                local sourceID = nil
                local collectedSrc = owner:GetCollectedSourceForItem(piece.itemID)
                if collectedSrc then sourceID = collectedSrc end
                
                owner.pendingTransmogs[invSlot] = {
                    sourceID = sourceID,
                    itemID = piece.itemID,
                    name = cached.name or ("Item " .. piece.itemID),
                    icon = icon,
                }
                if sourceID then owner.itemIDToSourceID[piece.itemID] = sourceID end
                
                -- Update slot button
                local btn = owner.frame.slotButtons[invSlot]
                if btn then
                    btn.icon:SetTexture(icon)
                    btn.pendingBorder:Show()
                    btn.pendingInner:Show()
                    btn:SetBackdropBorderColor(1, 0.82, 0, 1)  -- Gold = collected
                end
                
                table.insert(itemsToApply, { itemID = piece.itemID })
                appliedCount = appliedCount + 1
            end
        end
    end
    
    -- Apply collected items to model with staggered timing
    local model = owner.frame.model
    for i, item in ipairs(itemsToApply) do
        C_Timer.After((i - 1) * 0.05, function()
            if owner._applyGeneration == currentGeneration and model then
                model:TryOn("item:" .. item.itemID)
            end
        end)
    end
    
    -- Final re-apply for safety
    C_Timer.After(#itemsToApply * 0.05 + 0.2, function()
        if owner._applyGeneration == currentGeneration and model then
            for _, item in ipairs(itemsToApply) do
                model:TryOn("item:" .. item.itemID)
            end
        end
    end)
    
    local totalPieces = 0
    for _ in pairs(pieces) do totalPieces = totalPieces + 1 end
    
    -- Auto-hide armor slots not covered by this set (same as ApplySet)
    local AUTO_HIDE_SLOTS = { [1]=true, [3]=true, [5]=true, [6]=true, [7]=true, [8]=true, [9]=true, [10]=true, [15]=true }
    local hiddenCount = 0
    for invSlot, _ in pairs(AUTO_HIDE_SLOTS) do
        if not owner.pendingTransmogs[invSlot] then
            owner.pendingTransmogs[invSlot] = {
                isHiddenSlot = true,
                name = "Hidden",
                icon = "Interface\\Icons\\Spell_Shadow_SacrificialShield",
            }
            hiddenCount = hiddenCount + 1
        end
    end
    
    owner:UpdateSlotButtonIcons()
    owner:UpdateCost()
    owner:SyncToBlizzardPending()
    
    owner._applyingSet = false
    print(string.format("|cff00ff00[ETB]|r Applied %d collected pieces from '%s' (%d/%d total)",
        appliedCount, setData.name or "Unknown", appliedCount, totalPieces))
end

-- Helper to convert categoryID to invSlot

