--[[
============================================================================
ETB Transmog UI v0.16.2 - Integrated with EnhancedTransmogBrowser
============================================================================
Features:
- Floating "ETB Mode" button appears when you talk to Transmog NPC
- Click button to open ETB, click "Default UI" to switch back
- Button is draggable - position it where you like!
- Save your current transmog look as an outfit
- Browse and preview COMPLETE transmog sets only
- Profile system (share outfits across characters)

Changes in v0.16.2 - Shirt/Tabard + Verified Source Resolution:
- Added Shirt and Tabard slot buttons centered below the model preview
- TryApplyWithCollectedSources() tries ALL collected sources with verification
- Three-tier reporting: VERIFIED / COLLECTED_BUT_UNUSABLE / NOT_COLLECTED
- Handles account-wide vs character-usable distinction correctly
- GetAllCollectedSources() returns all collected sources, exact item matches first

Usage: Talk to NPC, click "ETB Mode" button, or /etbtui
============================================================================
]]--

local ADDON_NAME, NS = ...

-- Helper function to get ETB reference dynamically (may be set after this file loads)
local function GetETB()
    return NS.ETB
end

-- File-level ETB reference (EventBus.lua creates NS.ETB at TOC line 21, we're line 101)
local ETB = NS.ETB

-- SavedVariables (declared in TOC)
ETBTUI_Settings = ETBTUI_Settings or { autoOpen = false }
ETBTUI_SavedOutfits = ETBTUI_SavedOutfits or {}
ETBTUI_AccountData = ETBTUI_AccountData or { 
    profiles = {}, 
    accountOutfits = {},
    version = 1 
}

local TRANSMOG_TYPE_APPEARANCE = (LE_TRANSMOG_TYPE_APPEARANCE ~= nil) and LE_TRANSMOG_TYPE_APPEARANCE or 1
local SETPENDING_API_DETECTED = nil

local TUI = {
    frame = nil,
    selectedSlot = nil,
    selectedTab = "appearances",
    selectedCategoryID = nil,
    pendingTransmogs = {},
    itemRows = {},
    rowPool = {},
    rowPoolParent = nil,
    outfitButtons = {},
    searchText = "",
    colorFilter = nil,
    hoverPreviewFrame = nil,
    selectedOutfitID = nil,
    itemCache = {},
    blizzardTransmogFrame = nil,
    transmogSlotIDs = {},
    itemIDToSourceID = {},
    categoryBarIcons = {},
}

-- =============================================================================
-- CONSTANTS
-- =============================================================================

local FRAME_WIDTH = 1050
local FRAME_HEIGHT = 640
local CATEGORY_BAR_HEIGHT = 88

local ROW_HEIGHT = 30
local ROW_SPACING = 1

local QUALITY_COLORS = {
    [0] = { r = 0.6, g = 0.6, b = 0.6 },
    [1] = { r = 1, g = 1, b = 1 },
    [2] = { r = 0.12, g = 1, b = 0 },
    [3] = { r = 0, g = 0.44, b = 0.87 },
    [4] = { r = 0.64, g = 0.21, b = 0.93 },
    [5] = { r = 1, g = 0.5, b = 0 },
}

local SLOTS = {
    -- Left side slots (armor) - positioned to the left of model panel
    { id = 1,  name = "Head",       invSlot = 1,  side = "left",  row = 1, categoryID = 1 },
    { id = 3,  name = "Shoulder",   invSlot = 3,  side = "left",  row = 2, categoryID = 2 },
    { id = 15, name = "Back",       invSlot = 15, side = "left",  row = 3, categoryID = 3 },
    { id = 5,  name = "Chest",      invSlot = 5,  side = "left",  row = 4, categoryID = 4 },
    { id = 9,  name = "Wrist",      invSlot = 9,  side = "left",  row = 5, categoryID = 7 },
    -- Right side slots (armor + weapons) - positioned to the right of model panel
    { id = 10, name = "Hands",      invSlot = 10, side = "right", row = 1, categoryID = 8 },
    { id = 6,  name = "Waist",      invSlot = 6,  side = "right", row = 2, categoryID = 9 },
    { id = 7,  name = "Legs",       invSlot = 7,  side = "right", row = 3, categoryID = 10 },
    { id = 8,  name = "Feet",       invSlot = 8,  side = "right", row = 4, categoryID = 11 },
    { id = 16, name = "Main Hand",  invSlot = 16, side = "right", row = 5, categoryID = 14, isWeapon = true },
    { id = 17, name = "Off Hand",   invSlot = 17, side = "right", row = 6, categoryID = 18, isWeapon = true },
    -- Center slots (below model) - Shirt and Tabard
    { id = 4,  name = "Shirt",      invSlot = 4,  side = "center", row = 1, categoryID = 5 },
    { id = 19, name = "Tabard",     invSlot = 19, side = "center", row = 2, categoryID = 6 },
}

local ARMOR_SLOTS = { [1]=true, [3]=true, [4]=true, [5]=true, [6]=true, [7]=true, [8]=true, [9]=true, [10]=true, [15]=true, [19]=true }
local WEAPON_SLOTS = { [16]=true, [17]=true }

-- InvSlot -> CategoryIDs mapping for fallback sourceID lookup 
local INVSLOT_TO_CATEGORIES = {
    [1] = {1}, [3] = {2}, [15] = {3}, [5] = {4}, [4] = {5}, [19] = {6}, [9] = {7},
    [10] = {8}, [6] = {9}, [7] = {10}, [8] = {11},
    [16] = {12,13,14,15,16,17,20,21,22,23,24,25,26,27},
    [17] = {18,19},
}

local SLOT_NAME_TO_INV = {
    Head = 1, Shoulder = 3, Back = 15, Chest = 5, Shirt = 4, Tabard = 19,
    Wrist = 9, Hands = 10, Waist = 6, Legs = 7, Feet = 8, MainHand = 16, OffHand = 17,
}
local INV_TO_SLOT_NAME = {}
for name, id in pairs(SLOT_NAME_TO_INV) do INV_TO_SLOT_NAME[id] = name end

-- =============================================================================
-- TEST CUSTOM SETS (built-in for testing without ETB addon)
-- =============================================================================
-- Slot numbers: 1=Head, 2=Shoulder, 3=Chest, 4=Waist, 5=Legs, 6=Feet, 7=Wrist, 8=Hands, 9=Back

-- =============================================================================
-- COLOR SORTING SYSTEM
-- =============================================================================
-- Colors are determined by RGB data via ETB.ColorSearch module

local COLOR_CATEGORIES = {
    { id = "RED",    name = "Red",    color = {1, 0.2, 0.2} },
    { id = "ORANGE", name = "Orange", color = {1, 0.5, 0} },
    { id = "YELLOW", name = "Yellow", color = {1, 1, 0.2} },
    { id = "GREEN",  name = "Green",  color = {0.2, 1, 0.2} },
    { id = "TEAL",   name = "Teal",   color = {0, 0.8, 0.8} },
    { id = "BLUE",   name = "Blue",   color = {0.2, 0.4, 1} },
    { id = "PURPLE", name = "Purple", color = {0.6, 0.2, 1} },
    { id = "PINK",   name = "Pink",   color = {1, 0.4, 0.7} },
    { id = "WHITE",  name = "White",  color = {1, 1, 1} },
    { id = "BLACK",  name = "Black",  color = {0.2, 0.2, 0.2} },
    { id = "BROWN",  name = "Brown",  color = {0.55, 0.35, 0.18} },
    { id = "GRAY",   name = "Gray",   color = {0.5, 0.5, 0.5} },
    { id = "GOLD",   name = "Gold",   color = {0.78, 0.67, 0.2} },
}

-- Determine color category for an item using RGB data
local function GetItemColorCategory(itemName, iconPath, itemID)
    local ETB = GetETB()
    if ETB and ETB.ColorSearch and ETB.ColorSearch.HasData and ETB.ColorSearch:HasData() then
        if itemID then
            -- Use real RGB color matching
            local _, _, _, _, _, _, _, _, _, _, _, _, _, _, displayID = GetItemInfoInstant(itemID)
            if displayID and displayID > 0 then
                return ETB.ColorSearch:GetCategoryForDisplay(displayID)
            end
        end
    end
    return nil -- No color data available
end

-- =============================================================================
-- PROFILE SYSTEM (Account-Wide Outfits)
-- =============================================================================

local ProfileManager = {
    profiles = {},
    currentCharacterKey = nil,
    accountOutfits = {},
}

local CLASS_PRIMARY_ARMOR = {
    WARRIOR = "PLATE", PALADIN = "PLATE", DEATHKNIGHT = "PLATE",
    HUNTER = "MAIL", SHAMAN = "MAIL",
    ROGUE = "LEATHER", DRUID = "LEATHER", MONK = "LEATHER",
    MAGE = "CLOTH", WARLOCK = "CLOTH", PRIEST = "CLOTH",
}

local CLASS_WEAPONS = {
    WARRIOR     = { AXE=true, SWORD=true, MACE=true, POLEARM=true, STAFF=true, DAGGER=true, FIST=true, SHIELD=true },
    PALADIN     = { AXE=true, SWORD=true, MACE=true, POLEARM=true, SHIELD=true },
    DEATHKNIGHT = { AXE=true, SWORD=true, MACE=true, POLEARM=true },
    HUNTER      = { AXE=true, SWORD=true, POLEARM=true, STAFF=true, DAGGER=true, FIST=true, BOW=true, GUN=true, CROSSBOW=true },
    SHAMAN      = { AXE=true, MACE=true, STAFF=true, DAGGER=true, FIST=true, SHIELD=true },
    ROGUE       = { AXE=true, SWORD=true, MACE=true, DAGGER=true, FIST=true },
    DRUID       = { MACE=true, POLEARM=true, STAFF=true, DAGGER=true, FIST=true },
    MONK        = { AXE=true, SWORD=true, MACE=true, POLEARM=true, STAFF=true, FIST=true },
    MAGE        = { SWORD=true, STAFF=true, DAGGER=true, WAND=true },
    WARLOCK     = { SWORD=true, STAFF=true, DAGGER=true, WAND=true },
    PRIEST      = { MACE=true, STAFF=true, DAGGER=true, WAND=true },
}

function ProfileManager:Initialize()
    -- Load from SavedVariables
    if ETBTUI_AccountData then
        self.profiles = ETBTUI_AccountData.profiles or {}
        self.accountOutfits = ETBTUI_AccountData.accountOutfits or {}
    end
    
    -- Register current character
    self:RegisterCurrentCharacter()
end

function ProfileManager:GetProfileKey(name, realm)
    return string.format("%s-%s", name or "Unknown", realm or "Unknown")
end

function ProfileManager:RegisterCurrentCharacter()
    local name = UnitName("player")
    local realm = GetRealmName()
    local _, classID = UnitClass("player")
    local level = UnitLevel("player")
    local faction = UnitFactionGroup("player")
    
    self.currentCharacterKey = self:GetProfileKey(name, realm)
    
    self.profiles[self.currentCharacterKey] = {
        name = name,
        realm = realm,
        classID = classID,
        level = level,
        faction = faction,
        lastSeen = time(),
    }
    
    self:SaveData()
end

function ProfileManager:GetCurrentProfile()
    return self.profiles[self.currentCharacterKey]
end

function ProfileManager:GetPlayerArmorType()
    local profile = self:GetCurrentProfile()
    if profile and CLASS_PRIMARY_ARMOR[profile.classID] then
        return CLASS_PRIMARY_ARMOR[profile.classID]
    end
    local _, classID = UnitClass("player")
    return CLASS_PRIMARY_ARMOR[classID] or "CLOTH"
end

function ProfileManager:CanClassUseOutfit(classID, outfit)
    if not outfit or not outfit.items then return true end
    
    local primaryArmor = CLASS_PRIMARY_ARMOR[classID]
    local weapons = CLASS_WEAPONS[classID]
    
    for slot, item in pairs(outfit.items) do
        if item.armorType and item.armorType ~= primaryArmor then
            return false
        end
        if item.weaponType and not (weapons and weapons[item.weaponType]) then
            return false
        end
    end
    return true
end

function ProfileManager:CreateAccountOutfit(name, items, armorType)
    local outfit = {
        name = name,
        createdBy = self.currentCharacterKey,
        createdAt = time(),
        armorType = armorType,
        items = items,
        compatibleClasses = {},
    }
    
    -- Calculate compatible classes
    for classID, armor in pairs(CLASS_PRIMARY_ARMOR) do
        if self:CanClassUseOutfit(classID, outfit) then
            table.insert(outfit.compatibleClasses, classID)
        end
    end
    
    local outfitID = string.format("acct_%s_%d", name:gsub("%s+", "_"):gsub("[^%w_]", ""), time())
    self.accountOutfits[outfitID] = outfit
    self:SaveData()
    
    return outfitID
end

function ProfileManager:GetOutfitsForCurrentClass()
    local _, classID = UnitClass("player")
    local usable = {}
    
    for outfitID, outfit in pairs(self.accountOutfits) do
        if self:CanClassUseOutfit(classID, outfit) then
            usable[outfitID] = outfit
        end
    end
    
    return usable
end

function ProfileManager:DeleteAccountOutfit(outfitID)
    if self.accountOutfits[outfitID] then
        self.accountOutfits[outfitID] = nil
        self:SaveData()
        return true
    end
    return false
end

function ProfileManager:SaveData()
    ETBTUI_AccountData = {
        profiles = self.profiles,
        accountOutfits = self.accountOutfits,
        version = 1,
    }
end

function ProfileManager:GetStats()
    local profileCount, outfitCount = 0, 0
    for _ in pairs(self.profiles) do profileCount = profileCount + 1 end
    for _ in pairs(self.accountOutfits) do outfitCount = outfitCount + 1 end
    return profileCount, outfitCount
end

-- =============================================================================
-- EXPORT/IMPORT SYSTEM (serialized strings)
-- =============================================================================

local ExportSystem = {}

-- Simple Base64 encoding (pure Lua, no external libs)
local B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"

function ExportSystem:Base64Encode(data)
    local result = {}
    local padding = ""
    
    -- Pad to multiple of 3
    local mod = #data % 3
    if mod > 0 then
        padding = string.rep("=", 3 - mod)
        data = data .. string.rep("\0", 3 - mod)
    end
    
    for i = 1, #data, 3 do
        local b1, b2, b3 = data:byte(i, i + 2)
        local n = b1 * 65536 + b2 * 256 + b3
        
        local c1 = bit.rshift(n, 18) % 64
        local c2 = bit.rshift(n, 12) % 64
        local c3 = bit.rshift(n, 6) % 64
        local c4 = n % 64
        
        result[#result + 1] = B64_CHARS:sub(c1 + 1, c1 + 1)
        result[#result + 1] = B64_CHARS:sub(c2 + 1, c2 + 1)
        result[#result + 1] = B64_CHARS:sub(c3 + 1, c3 + 1)
        result[#result + 1] = B64_CHARS:sub(c4 + 1, c4 + 1)
    end
    
    local encoded = table.concat(result)
    if #padding > 0 then
        encoded = encoded:sub(1, -(#padding + 1)) .. padding
    end
    
    return encoded
end

function ExportSystem:Base64Decode(data)
    -- Remove padding
    data = data:gsub("=", "")
    
    local result = {}
    local charMap = {}
    for i = 1, #B64_CHARS do
        charMap[B64_CHARS:sub(i, i)] = i - 1
    end
    
    for i = 1, #data, 4 do
        local c1 = charMap[data:sub(i, i)] or 0
        local c2 = charMap[data:sub(i + 1, i + 1)] or 0
        local c3 = charMap[data:sub(i + 2, i + 2)] or 0
        local c4 = charMap[data:sub(i + 3, i + 3)] or 0
        
        local n = c1 * 262144 + c2 * 4096 + c3 * 64 + c4
        
        local b1 = bit.rshift(n, 16) % 256
        local b2 = bit.rshift(n, 8) % 256
        local b3 = n % 256
        
        result[#result + 1] = string.char(b1)
        if i + 2 <= #data then result[#result + 1] = string.char(b2) end
        if i + 3 <= #data then result[#result + 1] = string.char(b3) end
    end
    
    return table.concat(result)
end

-- Serialize outfit to string
function ExportSystem:SerializeOutfit(outfit)
    if not outfit then return nil end
    
    local parts = {
        "ETBOUT1", -- Version identifier
        outfit.name or "Unnamed",
        outfit.armorType or "UNKNOWN",
    }
    
    -- Serialize items
    local itemParts = {}
    for slot, item in pairs(outfit.items or {}) do
        if item.itemID then
            table.insert(itemParts, string.format("%s:%d", slot, item.itemID))
        end
    end
    table.insert(parts, table.concat(itemParts, ","))
    
    return table.concat(parts, "|")
end

-- Deserialize outfit from string
function ExportSystem:DeserializeOutfit(str)
    if not str or str == "" then return nil, "Empty string" end
    
    local parts = {}
    for part in str:gmatch("[^|]+") do
        table.insert(parts, part)
    end
    
    if #parts < 4 then return nil, "Invalid format" end
    if parts[1] ~= "ETBOUT1" then return nil, "Unknown version: " .. (parts[1] or "nil") end
    
    local outfit = {
        name = parts[2],
        armorType = parts[3],
        items = {},
        importedAt = time(),
    }
    
    -- Parse items
    for itemStr in parts[4]:gmatch("[^,]+") do
        local slot, itemID = itemStr:match("([^:]+):(%d+)")
        if slot and itemID then
            outfit.items[slot] = { itemID = tonumber(itemID) }
        end
    end
    
    return outfit
end

-- Export outfit to shareable string
function ExportSystem:ExportOutfit(outfit)
    local serialized = self:SerializeOutfit(outfit)
    if not serialized then return nil end
    
    local encoded = self:Base64Encode(serialized)
    return "!ETB!" .. encoded .. "!ETB!"
end

-- Import outfit from shareable string
function ExportSystem:ImportOutfit(str)
    if not str then return nil, "No string provided" end
    
    -- Extract the encoded part
    local encoded = str:match("!ETB!(.+)!ETB!")
    if not encoded then
        -- Try without markers (user might have copied without them)
        encoded = str:gsub("%s+", "")
    end
    
    local decoded = self:Base64Decode(encoded)
    if not decoded or decoded == "" then
        return nil, "Failed to decode"
    end
    
    return self:DeserializeOutfit(decoded)
end

local SLOT_NAME_TO_TOKEN = {
    Head = "HeadSlot", Shoulder = "ShoulderSlot", Back = "BackSlot", Chest = "ChestSlot",
    Shirt = "ShirtSlot", Tabard = "TabardSlot", Wrist = "WristSlot", Hands = "HandsSlot",
    Waist = "WaistSlot", Legs = "LegsSlot", Feet = "FeetSlot",
    ["Main Hand"] = "MainHandSlot", ["Off Hand"] = "SecondaryHandSlot",
}

-- Category bar mappings moved to UI/TransmogUI/CategoryBar.lua as
-- NS.ETB.TransmogUI.CategoryBar.{CATEGORY_TO_INVSLOT, CATEGORY_ICONS, CATEGORY_NAMES}

-- Test outfits
-- =============================================================================
-- INITIALIZATION
-- =============================================================================

local initFrame = CreateFrame("Frame")
initFrame:RegisterEvent("PLAYER_LOGIN")
initFrame:RegisterEvent("TRANSMOGRIFY_OPEN")
initFrame:RegisterEvent("TRANSMOGRIFY_CLOSE")
initFrame:RegisterEvent("TRANSMOGRIFY_UPDATE")
initFrame:RegisterEvent("GET_ITEM_INFO_RECEIVED")

-- Create a floating "ETB Mode" button that appears when at NPC
-- Parented to UIParent and positioned at fixed screen location (no taint!)
local etbFloatingBtn = CreateFrame("Button", nil, UIParent, "UIPanelButtonTemplate")
etbFloatingBtn:SetText("|cffffd100ETB Mode|r")
etbFloatingBtn:SetSize(120, 28)
etbFloatingBtn:SetFrameStrata("TOOLTIP")
etbFloatingBtn:SetFrameLevel(999)
etbFloatingBtn:SetPoint("TOP", UIParent, "TOP", 0, -100)  -- Fixed position at top-center of screen
etbFloatingBtn:Hide()

etbFloatingBtn:SetScript("OnClick", function()
    local blizzFrame = _G.TransmogrifyFrame or _G.WardrobeFrame
    if blizzFrame then
        TUI.blizzardTransmogFrame = blizzFrame
        TUI:HideBlizzardFrame(blizzFrame)
    end
    TUI:Show()
    etbFloatingBtn:Hide()
end)

etbFloatingBtn:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_BOTTOM")
    GameTooltip:SetText("Open Enhanced Transmog Browser")
    GameTooltip:AddLine("Click 'Default UI' in ETB to switch back", 0.8, 0.8, 0.8)
    GameTooltip:Show()
end)
etbFloatingBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)

-- Make it draggable so player can reposition
etbFloatingBtn:SetMovable(true)
etbFloatingBtn:EnableMouse(true)
etbFloatingBtn:RegisterForDrag("LeftButton")
etbFloatingBtn:SetScript("OnDragStart", etbFloatingBtn.StartMoving)
etbFloatingBtn:SetScript("OnDragStop", etbFloatingBtn.StopMovingOrSizing)

TUI.floatingButton = etbFloatingBtn

initFrame:SetScript("OnEvent", function(self, event, ...)
    if event == "PLAYER_LOGIN" then
        TUI:Initialize()
    elseif event == "TRANSMOGRIFY_OPEN" then
        -- Show our floating button so player can click to open ETB
        if TUI.floatingButton then
            TUI.floatingButton:Show()
        end
        
        -- Also auto-open if setting is enabled
        if ETBTUI_Settings.autoOpen then
            C_Timer = C_Timer or {}
            if C_Timer and C_Timer.After then
                C_Timer.After(0.1, function()
                    local blizzFrame = _G.TransmogrifyFrame or _G.WardrobeFrame
                    if blizzFrame then
                        TUI.blizzardTransmogFrame = blizzFrame
                        TUI:HideBlizzardFrame(blizzFrame)
                    end
                    TUI:Show()
                    if TUI.floatingButton then TUI.floatingButton:Hide() end
                end)
            else
                local blizzFrame = _G.TransmogrifyFrame or _G.WardrobeFrame
                if blizzFrame then
                    TUI.blizzardTransmogFrame = blizzFrame
                    TUI:HideBlizzardFrame(blizzFrame)
                end
                TUI:Show()
                if TUI.floatingButton then TUI.floatingButton:Hide() end
            end
        end
    elseif event == "TRANSMOGRIFY_CLOSE" then
        -- NPC interaction ended - close our UI and hide button
        TUI:Hide()
        if TUI.floatingButton then TUI.floatingButton:Hide() end
        if TUI.blizzardTransmogFrame then
            TUI:ShowBlizzardFrame(TUI.blizzardTransmogFrame)
            TUI.blizzardTransmogFrame = nil
        end
    elseif event == "TRANSMOGRIFY_UPDATE" then
        -- Blizzard UI changed pending transmogs — pull into ETB if our UI is open
        if TUI.frame and TUI.frame:IsShown() then
            -- Debounce: don't re-sync if we just pushed or are applying a set
            if not TUI._pushingToBlizzard and not TUI._applyingSet then
                TUI._pullingFromBlizzard = true
                if TUI:SyncFromBlizzardPending() then
                    -- Update slot button icons to reflect Blizzard changes
                    TUI:UpdateSlotButtonIcons()
                    -- Update model to match
                    if TUI.frame.model then
                        TUI.frame.model:Dress()
                        for _, pend in pairs(TUI.pendingTransmogs) do
                            if pend.itemID and not pend.isHiddenSlot then
                                TUI.frame.model:TryOn("item:" .. pend.itemID)
                            end
                        end
                    end
                    TUI:UpdateCost()
                end
                TUI._pullingFromBlizzard = nil
            end
        end
    elseif event == "GET_ITEM_INFO_RECEIVED" then
        -- Item data loaded - update cache
        local itemID = ...
        if itemID and TUI.itemCache[itemID] then
            local name, _, quality, _, _, _, _, _, _, icon = GetItemInfo(itemID)
            if name then
                TUI.itemCache[itemID] = { itemID = itemID, name = name, quality = quality or 1, icon = icon }
            end
        end
        -- Update matching rows in-place (no full refresh to avoid feedback loop)
        if itemID and TUI.frame and TUI.frame:IsShown() and TUI.rowPool then
            local name, _, quality = GetItemInfo(itemID)
            if name then
                local updated = false
                for _, row in ipairs(TUI.rowPool) do
                    if row:IsShown() and row.itemData and row.itemData.itemID == itemID then
                        row.itemData.name = name
                        if type(quality) == "number" then row.itemData.quality = quality end
                        local qColor = QUALITY_COLORS[row.itemData.quality] or QUALITY_COLORS[1]
                        row.name:SetText(name)
                        row.name:SetTextColor(qColor.r, qColor.g, qColor.b)
                        row.iconBorder:SetVertexColor(qColor.r, qColor.g, qColor.b, 1)
                        updated = true
                    end
                end
                -- Debounced full refresh if this item wasn't on a visible row
                -- Limited to 2 refreshes max to prevent constant refreshing
                if not updated and not TUI._itemRefreshTimer and (TUI._itemRefreshCount or 0) < 2 then
                    TUI._itemRefreshTimer = true
                    C_Timer.After(0.5, function()
                        TUI._itemRefreshTimer = nil
                        TUI._itemRefreshCount = (TUI._itemRefreshCount or 0) + 1
                        if TUI.frame and TUI.frame:IsShown() then
                            TUI:RefreshBrowser()
                        end
                    end)
                end
            end
        end
    end
end)

function TUI:Initialize()
    -- Initialize ProfileManager for account-wide outfits
    ProfileManager:Initialize()
    
    -- Initialize saved outfits table if not exists
    if not ETBTUI_SavedOutfits then
        ETBTUI_SavedOutfits = {}
    end
    
    self:BuildTransmogSlotIDs()
    self:HookBlizzardTransmogFrame()
    
    -- Create row pool parent (hidden frame)
    self.rowPoolParent = CreateFrame("Frame", nil, UIParent)
    self.rowPoolParent:Hide()
    
    -- Stats available via /etb command
    local profCount, outfitCount = ProfileManager:GetStats()
    ETB:Debug("TransmogUI", string.format("v0.16.2 loaded | %d characters, %d outfits", profCount, outfitCount))
end

-- =============================================================================
-- BLIZZARD FRAME HOOK (TSM-Style - Keep frame open but invisible)
-- =============================================================================

-- Fixed list of Blizzard transmog frames to hide
local BLIZZARD_TRANSMOG_FRAMES = {
    "WardrobeFrame",
    "WardrobeTransmogFrame", 
    "WardrobeCollectionFrame",
    "WardrobeOutfitFrame",
    "WardrobeOutfitDropDown",
    "WardrobeOutfitEditFrame",
    "WardrobeModelFrame",
    "WardrobeSetsCollectionFrame",
    "WardrobeSetsTransmogFrame",
    "TransmogrifyFrame",
    "TransmogrifyArtFrame",
    "TransmogrifyModelFrame",
    "TransmogrifyConfirmationPopup",
    "TransmogrifyNPCFrame",
}

-- Saved state for hidden Blizzard frames: {frame, scale, alpha, point, ...}
TUI._hiddenBlizzState = {}

-- Hide Blizzard transmog UI completely.
-- We CANNOT call :Hide() because that fires TRANSMOGRIFY_CLOSE and ends the NPC session.
-- SetScale(0.001) is the key: it cascades to ALL children automatically,
-- making everything sub-pixel and invisible. No child enumeration needed.
function TUI:HideBlizzardFrame(frame)
    if not frame then return end
    
    wipe(self._hiddenBlizzState)
    
    -- Only operate on the top-level frame passed to us
    -- SetScale cascades to ALL children automatically — no need to iterate 15 frames
    local ok, point, relativeTo, relativePoint, xOfs, yOfs = pcall(frame.GetPoint, frame, 1)
    if ok then
        self._hiddenBlizzState[1] = {
            frame = frame,
            scale = frame:GetScale(),
            alpha = frame:GetAlpha(),
            point = point,
            relativeTo = relativeTo,
            relativePoint = relativePoint,
            xOfs = xOfs,
            yOfs = yOfs,
        }
    end
    
    -- SetScale cascades to ALL children — makes frame sub-pixel invisible
    pcall(frame.SetScale, frame, 0.001)
    pcall(frame.SetAlpha, frame, 0)
    pcall(frame.ClearAllPoints, frame)
    pcall(frame.SetPoint, frame, "TOPLEFT", UIParent, "TOPLEFT", -5000, 5000)
    
    -- Raise our frame above everything
    if self.frame then
        self.frame:SetFrameStrata("FULLSCREEN")
        self.frame:SetFrameLevel(100)
    end
end

-- Restore ALL Blizzard transmog frames to their original state
function TUI:ShowBlizzardFrame(frame)
    for _, entry in ipairs(self._hiddenBlizzState) do
        local f = entry.frame
        if f and f.SetScale then
            pcall(f.SetScale, f, entry.scale or 1)
            pcall(f.SetAlpha, f, entry.alpha or 1)
            pcall(f.ClearAllPoints, f)
            if entry.point then
                pcall(f.SetPoint, f, entry.point, entry.relativeTo or UIParent, entry.relativePoint or entry.point, entry.xOfs or 0, entry.yOfs or 0)
            else
                pcall(f.SetPoint, f, "CENTER", UIParent, "CENTER", 0, 0)
            end
            -- NO EnableMouse — restoring scale/position is sufficient,
            -- and calling EnableMouse on Blizzard frames taints secure input
        end
    end
    wipe(self._hiddenBlizzState)
    
    -- Reset our own frame strata back to DIALOG
    if self.frame then
        self.frame:SetFrameStrata("DIALOG")
        self.frame:SetFrameLevel(100)
    end
end

function TUI:HookBlizzardTransmogFrame()
    -- No longer needed - we use autoOpen instead
    -- Kept as empty function in case other code calls it
end

function TUI:SwitchToBlizzardUI()
    -- Push all ETB pending to Blizzard BEFORE switching, so nothing is lost
    self:SyncToBlizzardPending()
    
    self:Hide()
    -- Only show floating button if we have a Blizzard frame we hid (meaning we're at NPC)
    local hasBlizzFrame = self.blizzardTransmogFrame ~= nil
    if self.blizzardTransmogFrame then
        self:ShowBlizzardFrame(self.blizzardTransmogFrame)
        self.blizzardTransmogFrame = nil
    elseif _G.TransmogrifyFrame and _G.TransmogrifyFrame:IsShown() then
        self:ShowBlizzardFrame(_G.TransmogrifyFrame)
        hasBlizzFrame = true
    end
    -- Re-show floating button only if we restored a Blizzard frame (at NPC)
    if hasBlizzFrame and self.floatingButton then
        self.floatingButton:Show()
    end
end

function TUI:SyncFromBlizzardPending()
    if not C_Transmog or not C_Transmog.GetPending then return false end
    local synced = 0
    
    -- Detect ToggleOff type for hidden slot detection
    local PENDING_APPLY = (Enum and Enum.TransmogPendingType and Enum.TransmogPendingType.Apply) or 0
    local PENDING_TOGGLEOFF = (Enum and Enum.TransmogPendingType and Enum.TransmogPendingType.ToggleOff) or 1
    
    for _, slot in ipairs(SLOTS) do
        local slotID = self:GetTransmogSlotID(slot.invSlot)
        local sourceID = nil
        local isHidden = false
        local hasPending = false
        
        -- Method 1: TransmogLocation-based API (MoP Classic native)
        if TransmogUtil and TransmogUtil.CreateTransmogLocation then
            local lok, transmogLocation = pcall(TransmogUtil.CreateTransmogLocation, slotID, 0, 0)
            if lok and transmogLocation then
                local ok, pending = pcall(C_Transmog.GetPending, transmogLocation)
                if ok and pending then
                    if type(pending) == "table" then
                        -- Check pending type: ToggleOff = hidden slot
                        local pendingType = pending.type
                        if pendingType == PENDING_TOGGLEOFF then
                            isHidden = true
                            hasPending = true
                        elseif pendingType == PENDING_APPLY or pendingType == nil then
                            sourceID = pending.transmogID or pending.sourceID
                            if sourceID and sourceID > 0 then
                                hasPending = true
                            end
                        end
                        -- If type is Revert or nil with no sourceID, slot has no pending
                    elseif type(pending) == "number" and pending > 0 then
                        sourceID = pending
                        hasPending = true
                    end
                end
            end
        end
        
        -- Method 2: Legacy 2-arg API fallback (only if Method 1 didn't find anything)
        if not hasPending then
            local ok, pending = pcall(C_Transmog.GetPending, slotID, TRANSMOG_TYPE_APPEARANCE)
            if ok and pending then
                if type(pending) == "table" then
                    local pendingType = pending.type
                    if pendingType == PENDING_TOGGLEOFF then
                        isHidden = true
                        hasPending = true
                    else
                        sourceID = pending.sourceID or pending.transmogID
                        if sourceID and sourceID > 0 then
                            hasPending = true
                        end
                    end
                elseif type(pending) == "number" and pending > 0 then
                    sourceID = pending
                    hasPending = true
                end
            end
        end
        
        if isHidden then
            -- Blizzard has this slot set to HIDDEN — create matching ETB entry
            self.pendingTransmogs[slot.invSlot] = {
                isHiddenSlot = true,
                name = "Hidden",
                icon = "Interface\\Icons\\Spell_Shadow_SacrificialShield",
            }
            synced = synced + 1
        elseif sourceID and sourceID > 0 and C_TransmogCollection then
            -- Blizzard has a normal appearance pending
            local si = C_TransmogCollection.GetSourceInfo(sourceID)
            if si and si.itemID then
                local name, _, _, _, _, _, _, _, _, icon = GetItemInfo(si.itemID)
                self.pendingTransmogs[slot.invSlot] = {
                    sourceID = sourceID, itemID = si.itemID,
                    name = name or ("Item " .. si.itemID), icon = icon,
                }
                self.itemIDToSourceID[si.itemID] = sourceID
                synced = synced + 1
            end
        else
            -- Blizzard has NO pending for this slot — clear from ETB too
            if self.pendingTransmogs[slot.invSlot] and not self._applyingSet then
                self.pendingTransmogs[slot.invSlot] = nil
            end
        end
    end
    return synced > 0
end

-- Clear ALL Blizzard pending transmog slots so old set pieces don't leak through
function TUI:ClearBlizzardPending()
    if not C_Transmog then return end
    
    -- Method 1: ClearAllPending if available (retail-style)
    if C_Transmog.ClearAllPending then
        pcall(C_Transmog.ClearAllPending)
        return
    end
    
    -- Method 2+3: Per-slot clearing using best available API
    for _, slot in ipairs(SLOTS) do
        local slotID = self:GetTransmogSlotID(slot.invSlot)
        if slotID then
            self:ClearSlotPending(slotID)
        end
    end
end

-- Clear Blizzard pending for a single slot — tries all available methods
function TUI:ClearSlotPending(slotID)
    if not C_Transmog then return end
    
    -- Try ClearPending with TransmogLocation first
    if C_Transmog.ClearPending then
        if TransmogUtil and TransmogUtil.CreateTransmogLocation then
            local ok, loc = pcall(TransmogUtil.CreateTransmogLocation, slotID, 0, 0)
            if ok and loc then
                pcall(C_Transmog.ClearPending, loc)
                return
            end
        end
        -- Fallback: plain slotID
        pcall(C_Transmog.ClearPending, slotID)
        return
    end
    
    -- No ClearPending — try SetPending with Revert type
    if C_Transmog.SetPending and TransmogUtil
       and TransmogUtil.CreateTransmogLocation and TransmogUtil.CreateTransmogPendingInfo then
        local noneType = (Enum and Enum.TransmogPendingType and Enum.TransmogPendingType.Revert) or 2
        local lok, loc = pcall(TransmogUtil.CreateTransmogLocation, slotID, 0, 0)
        if lok and loc then
            local pok, pendingInfo = pcall(TransmogUtil.CreateTransmogPendingInfo, noneType)
            if pok and pendingInfo then
                pcall(C_Transmog.SetPending, loc, pendingInfo)
            end
        end
    end
end

-- Push ALL ETB pending transmogs to Blizzard's C_Transmog.SetPending
-- so Blizzard UI stays in sync with what ETB has queued.
-- Also CLEARS Blizzard pending for slots ETB doesn't have pending —
-- this prevents old set remnants from persisting after switching sets.
function TUI:SyncToBlizzardPending()
    if not self:CanApplyTransmog() then return false end
    if not C_Transmog or not C_Transmog.SetPending then return false end
    
    self._pushingToBlizzard = true
    local synced = 0
    
    -- Track which slots have ETB pending
    local slotsWithPending = {}
    
    for invSlot, pending in pairs(self.pendingTransmogs) do
        local slotID = self:GetTransmogSlotID(invSlot)
        if slotID then
            slotsWithPending[invSlot] = true
            
            if pending.isHiddenSlot then
                -- Hidden slot: use ToggleOff pending type
                local ok, result = self:CallSetPendingHidden(slotID)
                if ok then
                    synced = synced + 1
                end
            else
                -- Normal appearance: use Apply with sourceID
                local sourceID = pending.sourceID
                if not sourceID and pending.itemID then
                    sourceID = self:GetCollectedSourceForItem(pending.itemID)
                end
                if sourceID then
                    local ok, result = self:CallSetPending(slotID, sourceID)
                    if ok then
                        synced = synced + 1
                    end
                end
            end
        end
    end
    
    -- Clear Blizzard pending for slots that ETB does NOT have pending
    -- This is critical: when switching from a full set to a partial set,
    -- slots not in the new set must be explicitly cleared from Blizzard
    for _, slot in ipairs(SLOTS) do
        if not slotsWithPending[slot.invSlot] then
            local slotID = self:GetTransmogSlotID(slot.invSlot)
            if slotID then
                self:ClearSlotPending(slotID)
            end
        end
    end
    
    self._pushingToBlizzard = nil
    ETB:Debug("TransmogUI", string.format("SyncToBlizzard: pushed %d pending to Blizzard API", synced))
    return synced > 0
end

-- Push a single slot to Blizzard (called right after SelectAppearance or ToggleSlotHide)
function TUI:PushSlotToBlizzard(invSlot)
    if not self:CanApplyTransmog() then return end
    if self._pullingFromBlizzard then return end  -- Don't push back what we just pulled
    
    local slotID = self:GetTransmogSlotID(invSlot)
    if not slotID then return end
    
    local pending = self.pendingTransmogs[invSlot]
    
    self._pushingToBlizzard = true
    
    if not pending then
        -- Slot was cleared in ETB — clear Blizzard pending too
        self:ClearSlotPending(slotID)
    elseif pending.isHiddenSlot then
        -- Hidden slot: use ToggleOff pending type
        self:CallSetPendingHidden(slotID)
    else
        -- Normal appearance: use Apply with sourceID
        local sourceID = pending.sourceID
        if not sourceID and pending.itemID then
            sourceID = self:GetCollectedSourceForItem(pending.itemID)
        end
        if sourceID then
            self:CallSetPending(slotID, sourceID)
        end
    end
    
    self._pushingToBlizzard = nil
end

-- =============================================================================
-- TRANSMOG SLOT ID HANDLING
-- =============================================================================

function TUI:BuildTransmogSlotIDs()
    if not GetInventorySlotInfo then return end
    self.transmogSlotIDs = {}
    for _, slot in ipairs(SLOTS) do
        local token = SLOT_NAME_TO_TOKEN[slot.name]
        if token then
            local id = GetInventorySlotInfo(token)
            if id then self.transmogSlotIDs[slot.invSlot] = id end
        end
    end
end

function TUI:GetTransmogSlotID(invSlot)
    -- Use runtime-built slot IDs if available
    if self.transmogSlotIDs and self.transmogSlotIDs[invSlot] then
        return self.transmogSlotIDs[invSlot]
    end
    -- Fallback: try to get via token
    local name = INV_TO_SLOT_NAME[invSlot]
    if not name then return invSlot end
    local token = SLOT_NAME_TO_TOKEN[name]
    if not token or not GetInventorySlotInfo then return invSlot end
    local id = GetInventorySlotInfo(token)
    return id or invSlot
end

function TUI:CanApplyTransmog()
    -- Check if we're at a transmog NPC via API
    if C_Transmog and C_Transmog.IsAtTransmogNPC and C_Transmog.IsAtTransmogNPC() then
        return true
    end
    -- Our UI open and we switched from Blizzard at NPC = still at NPC, Apply should work
    if self.frame and self.frame:IsShown() and self.blizzardTransmogFrame then
        return true
    end
    -- Fallback: check if Blizzard transmog frame is visible
    return (TransmogrifyFrame and TransmogrifyFrame:IsShown()) or (WardrobeFrame and WardrobeFrame:IsShown())
end

-- Call C_Transmog.SetPending with correct signature for MoP Classic.
-- MoP Classic (50400+) requires TransmogUtil.CreateTransmogLocation and CreateTransmogPendingInfo objects.
-- Falls back to plain tables for older clients.
local TRANSMOG_TYPE_APPEARANCE = 0  -- Always 0 for appearance transmogs
local SETPENDING_METHOD = nil  -- Auto-detect: "transmogutil", "tables", or "3arg"

function TUI:CallSetPending(slotID, sourceID)
    if not C_Transmog or not C_Transmog.SetPending then return false, "no API" end
    
    -- Method 1: Try TransmogUtil objects first (seems to be required for MoP Classic)
    if TransmogUtil and TransmogUtil.CreateTransmogLocation and TransmogUtil.CreateTransmogPendingInfo then
        local lok, transmogLocation = pcall(TransmogUtil.CreateTransmogLocation, slotID, 0, 0)
        if lok and transmogLocation then
            local pendingType = (Enum and Enum.TransmogPendingType and Enum.TransmogPendingType.Apply) or 0
            local pok, pendingInfo = pcall(TransmogUtil.CreateTransmogPendingInfo, pendingType)
            if pok and pendingInfo then
                if pendingInfo.Init then
                    pcall(pendingInfo.Init, pendingInfo, pendingType, sourceID)
                end
                pendingInfo.sourceID = sourceID
                pendingInfo.transmogID = sourceID
                
                local ok, err = pcall(C_Transmog.SetPending, transmogLocation, pendingInfo)
                if ok then
                    -- VERIFY it actually worked
                    if C_Transmog.GetPending then
                        local pendingOk, pendingResult = pcall(C_Transmog.GetPending, transmogLocation)
                        if pendingOk and pendingResult then
                            local pendingID = type(pendingResult) == "table" and (pendingResult.transmogID or pendingResult.sourceID) or pendingResult
                            if pendingID and pendingID > 0 then
                                return true, "VERIFIED"
                            else
                                return false, "SET_BUT_NOT_PENDING"
                            end
                        else
                            return false, "GETPENDING_FAILED"
                        end
                    end
                    return true, "NO_VERIFY_API"
                end
            end
        end
    end
    
    -- Method 2: Simple 2-arg as fallback
    local ok2, err2 = pcall(C_Transmog.SetPending, slotID, sourceID)
    if ok2 then
        return true, "2arg"
    end
    
    return false, "ALL_FAILED"
end

-- Call C_Transmog.SetPending with ToggleOff type to hide a slot.
-- MoP Classic uses TransmogPendingType.ToggleOff (1) to hide slots.
-- Cache of hidden-appearance sourceIDs per invSlot
-- Populated at runtime by querying C_TransmogCollection
TUI._hiddenSourceIDs = nil

-- Slot → categoryID mapping for finding hidden appearances
local SLOT_TO_CATEGORY = {
    [1] = 1,   -- Head
    [3] = 2,   -- Shoulder
    [5] = 4,   -- Chest
    [6] = 9,   -- Waist
    [7] = 10,  -- Legs
    [8] = 11,  -- Feet
    [9] = 7,   -- Wrist
    [10] = 8,  -- Hands
    [15] = 3,  -- Back
}

function TUI:FindHiddenSourceIDs()
    if self._hiddenSourceIDs then return self._hiddenSourceIDs end
    self._hiddenSourceIDs = {}
    
    if not C_TransmogCollection then return self._hiddenSourceIDs end
    
    for invSlot, catID in pairs(SLOT_TO_CATEGORY) do
        -- Method 1: Search category appearances for isHideVisual
        if C_TransmogCollection.GetCategoryAppearances then
            local ok, appearances = pcall(C_TransmogCollection.GetCategoryAppearances, catID)
            if ok and appearances then
                for _, app in ipairs(appearances) do
                    if app.isHideVisual then
                        -- Found the hidden appearance — get its sourceID
                        local srcOK, sources = pcall(C_TransmogCollection.GetAppearanceSources, app.visualID)
                        if srcOK and sources and #sources > 0 then
                            local src = sources[1]
                            local sid = type(src) == "table" and src.sourceID or src
                            if sid and sid > 0 then
                                self._hiddenSourceIDs[invSlot] = sid
                                ETB:Debug("TransmogUI", string.format("Hidden sourceID for slot %d: %d (visualID=%d)", invSlot, sid, app.visualID))
                            end
                        end
                        break
                    end
                end
            end
        end
    end
    
    local count = 0
    for _ in pairs(self._hiddenSourceIDs) do count = count + 1 end
    ETB:Debug("TransmogUI", string.format("Found %d hidden-appearance sourceIDs", count))
    
    return self._hiddenSourceIDs
end

function TUI:CallSetPendingHidden(slotID)
    if not C_Transmog or not C_Transmog.SetPending then return false, "no API" end
    
    -- Convert transmog slotID to invSlot for our lookup
    local invSlot = slotID  -- In MoP Classic these are typically the same
    
    -- Method 1: Apply actual hidden-appearance sourceID as a normal transmog
    -- This is the most reliable method — it applies a real "invisible" appearance
    local hiddenSources = self:FindHiddenSourceIDs()
    local hiddenSrcID = hiddenSources[invSlot]
    
    if hiddenSrcID then
        local ok, result = self:CallSetPending(slotID, hiddenSrcID)
        if ok and result == "VERIFIED" then
            return true, "HiddenSource"
        end
    end
    
    -- Method 2: TransmogUtil ToggleOff (Retail-style)
    if TransmogUtil and TransmogUtil.CreateTransmogLocation and TransmogUtil.CreateTransmogPendingInfo then
        local lok, transmogLocation = pcall(TransmogUtil.CreateTransmogLocation, slotID, 0, 0)
        if lok and transmogLocation then
            local toggleOffType = (Enum and Enum.TransmogPendingType and Enum.TransmogPendingType.ToggleOff) or 1
            local pok, pendingInfo = pcall(TransmogUtil.CreateTransmogPendingInfo, toggleOffType)
            if pok and pendingInfo then
                if pendingInfo.Init then
                    pcall(pendingInfo.Init, pendingInfo, toggleOffType, 0)
                end
                local ok, err = pcall(C_Transmog.SetPending, transmogLocation, pendingInfo)
                if ok then
                    -- VERIFY it actually set a pending
                    local vOk, vResult = pcall(C_Transmog.GetPending, transmogLocation)
                    if vOk and vResult then
                        return true, "ToggleOff"
                    end
                end
            end
        end
    end
    
    -- Method 3: ClearPending — at minimum removes any previous pending for this slot
    -- This won't "hide" but at least won't apply a wrong appearance
    if C_Transmog.ClearPending then
        pcall(C_Transmog.ClearPending, slotID)
    end
    
    return false, "HIDDEN_NOT_SUPPORTED"
end

-- Find ALL collected sourceIDs for a given itemID's appearance.
-- Returns a list of {sourceID, isExactItem} sorted with exact item matches first.
function TUI:GetAllCollectedSources(itemID)
    local results = {}
    if not itemID or not C_TransmogCollection then return results end

    if C_TransmogCollection.GetItemInfo then
        local ok, appearanceID = pcall(C_TransmogCollection.GetItemInfo, itemID)
        if ok and appearanceID then
            local sok, sources = pcall(C_TransmogCollection.GetAppearanceSources, appearanceID)
            if sok and sources then
                -- First pass: exact item matches
                for _, src in ipairs(sources) do
                    if type(src) == "table" and src.isCollected and src.sourceID then
                        local srcItemID = src.itemID
                        if not srcItemID and C_TransmogCollection.GetSourceInfo then
                            local si = C_TransmogCollection.GetSourceInfo(src.sourceID)
                            srcItemID = si and si.itemID
                        end
                        if srcItemID == itemID then
                            table.insert(results, {sourceID = src.sourceID, isExact = true})
                        end
                    end
                end
                -- Second pass: alternate sources (same appearance, different item)
                for _, src in ipairs(sources) do
                    if type(src) == "table" and src.isCollected and src.sourceID then
                        local srcItemID = src.itemID
                        if not srcItemID and C_TransmogCollection.GetSourceInfo then
                            local si = C_TransmogCollection.GetSourceInfo(src.sourceID)
                            srcItemID = si and si.itemID
                        end
                        if srcItemID ~= itemID then
                            table.insert(results, {sourceID = src.sourceID, isExact = false})
                        end
                    end
                end
            end
        end
    end

    return results
end

-- Try to apply a transmog for a slot by iterating through all collected sources.
-- Returns: ok, resultString, sourceID
-- This handles the account-wide vs character-usable distinction:
-- isCollected can be true for sources collected on other characters that
-- this character can't actually use (e.g. wrong armor type).
function TUI:TryApplyWithCollectedSources(slotID, itemID)
    local collectedSources = self:GetAllCollectedSources(itemID)
    
    if #collectedSources == 0 then
        return false, "NOT_COLLECTED", nil
    end
    
    -- Try each collected source - the first one that verifies wins
    for i, entry in ipairs(collectedSources) do
        local ok, result = self:CallSetPending(slotID, entry.sourceID)
        if result == "VERIFIED" then
            return true, "VERIFIED", entry.sourceID
        end
        -- If this source failed verification, try the next one
    end
    
    -- All collected sources failed verification - likely account-wide collection
    -- that this character can't use (wrong armor type, class restriction, etc.)
    return false, "COLLECTED_BUT_UNUSABLE", collectedSources[1].sourceID
end

-- Backward-compatible wrapper: returns sourceID, isCollected
-- Used by code paths that don't need the full TryApply flow
function TUI:GetCollectedSourceForItem(itemID)
    local sources = self:GetAllCollectedSources(itemID)
    if #sources > 0 then
        return sources[1].sourceID, true
    end
    return nil, false
end

-- Resolve sourceID from pending, ALWAYS preferring a collected source.
-- Returns sourceID, isCollected
function TUI:GetSourceIDForPending(invSlot, pending)
    if not pending then return nil, false end
    local itemID = pending.itemID
    
    -- PRIORITY 1: Find a collected source for this item's appearance
    if itemID and type(itemID) == "number" then
        local collectedSrc, isCollected = self:GetCollectedSourceForItem(itemID)
        if collectedSrc then
            self.itemIDToSourceID[itemID] = collectedSrc
            return collectedSrc, true
        end
    end
    
    -- PRIORITY 2: If no collected source found via appearance lookup, try category scan
    -- This handles cases where GetItemInfo might not work in MoP Classic
    if itemID and type(itemID) == "number" then
        local categories = INVSLOT_TO_CATEGORIES and INVSLOT_TO_CATEGORIES[invSlot]
        if categories and C_TransmogCollection and C_TransmogCollection.GetCategoryAppearances and C_TransmogCollection.GetAppearanceSources then
            for _, catID in ipairs(categories) do
                local ok, appearances = pcall(C_TransmogCollection.GetCategoryAppearances, catID)
                if ok and appearances then
                    for _, app in ipairs(appearances) do
                        if app.isCollected and app.visualID then
                            local ok2, sources = pcall(C_TransmogCollection.GetAppearanceSources, app.visualID)
                            if ok2 and sources then
                                for _, src in ipairs(sources) do
                                    local sid = type(src) == "number" and src or (src and src.sourceID)
                                    local srcItemID = type(src) == "table" and src.itemID or nil
                                    if not srcItemID and sid and C_TransmogCollection.GetSourceInfo then
                                        local si = C_TransmogCollection.GetSourceInfo(sid)
                                        srcItemID = si and si.itemID
                                    end
                                    if srcItemID == itemID and sid then
                                        local srcCollected = type(src) == "table" and src.isCollected
                                        if srcCollected then
                                            self.itemIDToSourceID[itemID] = sid
                                            return sid, true
                                        end
                                    end
                                end
                            end
                        end
                    end
                end
            end
        end
    end
    
    -- PRIORITY 3: Return uncollected sourceID as last resort
    if pending.sourceID and type(pending.sourceID) == "number" then 
        return pending.sourceID, false 
    end
    if itemID and type(itemID) == "number" and self.itemIDToSourceID[itemID] then 
        return self.itemIDToSourceID[itemID], false 
    end

    return nil, false
end

-- =============================================================================
-- SLASH COMMAND
-- =============================================================================

SLASH_ETBTUI1 = "/etbtui"
SlashCmdList["ETBTUI"] = function(msg)
    msg = msg and msg:lower():trim() or ""
    if msg == "auto" then
        ETBTUI_Settings.autoOpen = not ETBTUI_Settings.autoOpen
        print("|cff00ff00[ETB Transmog UI]|r Auto-open: " .. (ETBTUI_Settings.autoOpen and "|cff00ff00ON|r" or "|cffff6600OFF|r"))
    elseif msg == "help" then
        print("|cffffd100[ETB TransmogUI]|r Commands:")
        print("  |cff00ccff/etbtui|r — Open/close TransmogUI")
        print("  |cff00ccff/etbtui auto|r — Toggle auto-open at transmog NPC")
    else
        TUI:Toggle()
    end
end

-- =============================================================================
-- ITEM CACHE
-- =============================================================================

function TUI:GetCachedItem(itemID)
    if not itemID then return { itemID = 0, name = "Unknown", quality = 1, icon = "Interface\\Icons\\INV_Misc_QuestionMark" } end
    if self.itemCache[itemID] and self.itemCache[itemID].name ~= "Loading..." then 
        return self.itemCache[itemID] 
    end
    
    -- GetItemInfo is the ONLY source for item names (may return nil for uncached)
    local name, _, quality, _, _, _, _, _, _, icon = GetItemInfo(itemID)
    if name then
        self.itemCache[itemID] = { itemID = itemID, name = name, quality = quality or 1, icon = icon }
        return self.itemCache[itemID]
    end
    
    -- GetItemInfoInstant gives icon synchronously (but NOT name in MoP 5.4.8)
    -- Returns: itemID, itemType, itemSubType, equipLoc, icon, classID, subclassID
    local itemIcon = nil
    if GetItemInfoInstant then
        local _, _, _, _, iIcon = GetItemInfoInstant(itemID)
        itemIcon = iIcon
    end
    if not itemIcon then itemIcon = GetItemIcon and GetItemIcon(itemID) end
    
    self.itemCache[itemID] = { 
        itemID = itemID, 
        name = "Loading...", 
        quality = 1, 
        icon = itemIcon or "Interface\\Icons\\INV_Misc_QuestionMark" 
    }
    GetItemInfo(itemID)
    return self.itemCache[itemID]
end

-- =============================================================================
-- CREATE MAIN FRAME
-- =============================================================================

function TUI:Create()
    if self.frame then return self.frame end
    
    local f = CreateFrame("Frame", "ETBTransmogUIFrame", UIParent, "BackdropTemplate")
    f:SetSize(FRAME_WIDTH, FRAME_HEIGHT)
    f:SetPoint("CENTER")
    f:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
        edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Gold-Border",
        tile = true, tileSize = 32, edgeSize = 32,
        insets = { left = 8, right = 8, top = 8, bottom = 8 }
    })
    f:SetBackdropColor(0, 0, 0, 1)  -- Fully opaque black background
    f:SetMovable(true)
    f:EnableMouse(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop", f.StopMovingOrSizing)
    f:SetFrameStrata("DIALOG")  -- DIALOG is above Blizzard's transmog UI
    f:SetFrameLevel(100)
    f:Hide()
    
    -- Create a solid blocker texture to ensure complete coverage of main frame
    local blocker = f:CreateTexture(nil, "BACKGROUND", nil, -8)
    blocker:SetAllPoints(f)
    blocker:SetColorTexture(0.02, 0.02, 0.03, 1)  -- Very dark, fully opaque
    f.blocker = blocker
    
    -- ESC closes frame (taint-safe: avoids modifying UISpecialFrames)
    f:SetScript("OnKeyDown", function(self, key)
        if key == "ESCAPE" then
            pcall(self.SetPropagateKeyboardInput, self, false)
            if TUI.blizzardTransmogFrame then TUI:SwitchToBlizzardUI() else TUI:Hide() end
        else
            pcall(self.SetPropagateKeyboardInput, self, true)
        end
    end)
    
    self:CreateTitleBar(f)
    self:CreateModelPanel(f)
    self:CreateSlotButtons(f)
    self:CreateBrowserPanel(f)
    self:CreateOutfitsPanel(f)
    self:CreateCategoryBar(f)
    self:CreateBottomBar(f)
    self:CreateHoverPreview()
    
    self.frame = f
    return f
end

function TUI:CreateTitleBar(parent)
    parent.title = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    parent.title:SetPoint("TOPLEFT", 8, -5)
    parent.title:SetText("|cffffd100TransmogUI|r")
    
    parent.subtitle = parent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    parent.subtitle:SetPoint("LEFT", parent.title, "RIGHT", 8, 0)
    parent.subtitle:SetText("|cff888888Build and apply outfits|r")
    
    parent.closeBtn = CreateFrame("Button", nil, parent, "UIPanelCloseButton")
    parent.closeBtn:SetPoint("TOPRIGHT", -2, -2)
    parent.closeBtn:SetScript("OnClick", function()
        if TUI.blizzardTransmogFrame then TUI:SwitchToBlizzardUI() else TUI:Hide() end
    end)
    
    parent.defaultUIBtn = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
    parent.defaultUIBtn:SetSize(80, 20)
    parent.defaultUIBtn:SetPoint("TOPRIGHT", -28, -4)
    parent.defaultUIBtn:SetText("Default UI")
    parent.defaultUIBtn:SetScript("OnClick", function() TUI:SwitchToBlizzardUI() end)
    parent.defaultUIBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_BOTTOM")
        GameTooltip:SetText("Switch to Blizzard's transmog UI")
        GameTooltip:Show()
    end)
    parent.defaultUIBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)
end

function TUI:CreateModelPanel(parent)
    local panel = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    -- Fill gap between browser (340px + 6px margin) and outfits (220px + 6px margin)
    panel:SetPoint("TOPLEFT", parent, "TOPLEFT", 380, -24)
    panel:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -260, -24)
    panel:SetPoint("BOTTOM", parent, "BOTTOM", 0, 94)
    panel:SetFrameStrata("DIALOG")
    panel:SetFrameLevel(parent:GetFrameLevel() + 10)
    panel:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 12,
        insets = { left = 4, right = 4, top = 4, bottom = 4 }
    })
    panel:SetBackdropColor(0.02, 0.02, 0.03, 1)  -- Nearly black
    
    -- Create a solid background texture INSIDE the panel that covers the model area
    -- This needs to be at the ARTWORK layer to be behind the model but above the backdrop
    local modelBg = panel:CreateTexture(nil, "ARTWORK", nil, -8)
    modelBg:SetPoint("TOPLEFT", panel, "TOPLEFT", 6, -6)
    modelBg:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -6, 6)
    modelBg:SetColorTexture(0.01, 0.01, 0.02, 1)  -- Nearly black, fully opaque
    panel.modelBackground = modelBg
    
    -- The DressUpModel - parented to panel so it inherits DIALOG strata
    local model = CreateFrame("DressUpModel", nil, panel)
    model:SetFrameLevel(panel:GetFrameLevel() + 5)
    model:SetSize(220, 380)
    model:SetPoint("TOP", 0, -2)
    model:SetRotation(0)
    model.rotation = 0
    model.zoomLevel = 0  -- Track zoom level
    
    -- Initialize model ONCE here - never call SetUnit again after this
    model:SetUnit("player")
    model.initialized = true
    
    -- Zoom in slightly so character fills the frame
    model:SetCamDistanceScale(0.9)
    model:SetPosition(0, 0, -0.1)
    
    model:EnableMouse(true)
    model:EnableMouseWheel(true)
    
    model:SetScript("OnMouseDown", function(self, btn)
        if btn == "LeftButton" then self.rotating = true; self.startX = GetCursorPosition() end
    end)
    model:SetScript("OnMouseUp", function(self) self.rotating = false end)
    model:SetScript("OnUpdate", function(self)
        if self.rotating then
            local x = GetCursorPosition()
            self.rotation = (self.rotation or 0) + (x - self.startX) * 0.01
            self:SetRotation(self.rotation)
            self.startX = x
        end
    end)
    -- Proper zoom using camera distance
    model:SetScript("OnMouseWheel", function(self, delta)
        self.zoomLevel = (self.zoomLevel or 0) + delta * 0.1
        self.zoomLevel = math.max(-0.5, math.min(1.5, self.zoomLevel))
        self:SetCamDistanceScale(1 - self.zoomLevel)
    end)
    
    -- Reset and Undress buttons side by side
    local resetBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    resetBtn:SetSize(70, 18)
    resetBtn:SetPoint("BOTTOMLEFT", 10, 14)
    resetBtn:SetText("Reset")
    resetBtn:SetScript("OnClick", function()
        if TUI.frame.model then
            TUI.frame.model:Dress()
            TUI.frame.model.zoomLevel = 0
            TUI.frame.model:SetCamDistanceScale(0.9)
            for invSlot, pending in pairs(TUI.pendingTransmogs) do
                if pending.itemID and not pending.isHiddenSlot then
                    TUI.frame.model:TryOn("item:" .. pending.itemID)
                end
            end
        end
    end)
    
    local undressBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    undressBtn:SetSize(70, 18)
    undressBtn:SetPoint("BOTTOMRIGHT", -10, 14)
    undressBtn:SetText("Undress")
    undressBtn:SetScript("OnClick", function()
        if TUI.frame.model then
            TUI.frame.model:Undress()
        end
    end)
    
    local hint = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    hint:SetPoint("BOTTOM", 0, 2)
    hint:SetText("|cff888888Drag rotate / Scroll zoom|r")
    
    parent.modelPanel = panel
    parent.model = model
end

function TUI:CreateSlotButtons(parent)
    parent.slotButtons = {}
    
    local buttonHeight = 26
    local buttonWidth = 26
    local spacing = 2
    local startY = -15
    
    for _, slotInfo in ipairs(SLOTS) do
        -- Parent to modelPanel so buttons render above its backdrop
        local btn = CreateFrame("Button", nil, parent.modelPanel, "BackdropTemplate")
        btn:SetSize(buttonWidth, buttonHeight)
        
        -- Position: left side, right side, or center below model panel
        local yOffset = startY - ((slotInfo.row - 1) * (buttonHeight + spacing))
        if slotInfo.side == "left" then
            btn:SetPoint("TOPRIGHT", parent.model, "TOPLEFT", -4, yOffset)
        elseif slotInfo.side == "right" then
            btn:SetPoint("TOPLEFT", parent.model, "TOPRIGHT", 4, yOffset)
        elseif slotInfo.side == "center" then
            local centerX = (slotInfo.row == 1) and -(buttonWidth/2 + 3) or (buttonWidth/2 + 3)
            btn:SetPoint("TOP", parent.modelPanel, "BOTTOM", centerX, -4)
        end
        
        btn:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            tile = false, edgeSize = 8,
            insets = { left = 2, right = 2, top = 2, bottom = 2 }
        })
        btn:SetBackdropColor(0.1, 0.1, 0.1, 0.9)
        btn:SetBackdropBorderColor(0.4, 0.4, 0.4, 1)
        
        -- Pending border (gold glow when item is selected for this slot)
        btn.pendingBorder = btn:CreateTexture(nil, "OVERLAY", nil, 2)
        btn.pendingBorder:SetPoint("TOPLEFT", -1, 1)
        btn.pendingBorder:SetPoint("BOTTOMRIGHT", 1, -1)
        btn.pendingBorder:SetColorTexture(1, 0.82, 0, 0.9)
        btn.pendingBorder:Hide()
        
        btn.pendingInner = btn:CreateTexture(nil, "OVERLAY", nil, 3)
        btn.pendingInner:SetPoint("TOPLEFT", 1, -1)
        btn.pendingInner:SetPoint("BOTTOMRIGHT", -1, 1)
        btn.pendingInner:SetColorTexture(0.12, 0.1, 0.04, 0.9)
        btn.pendingInner:Hide()
        
        -- Hidden indicator (red border when slot is hidden)
        btn.hiddenBorder = btn:CreateTexture(nil, "OVERLAY", nil, 4)
        btn.hiddenBorder:SetPoint("TOPLEFT", -1, 1)
        btn.hiddenBorder:SetPoint("BOTTOMRIGHT", 1, -1)
        btn.hiddenBorder:SetColorTexture(0.8, 0.2, 0.2, 0.9)
        btn.hiddenBorder:Hide()
        
        -- Icon (shows pending transmog or default slot icon)
        btn.icon = btn:CreateTexture(nil, "OVERLAY", nil, 5)
        btn.icon:SetSize(20, 20)
        btn.icon:SetPoint("CENTER", 0, 0)
        btn.icon:SetTexture(self:GetDefaultSlotIcon(slotInfo.invSlot))
        btn.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        
        -- Store slot info
        btn.invSlot = slotInfo.invSlot
        btn.slotName = slotInfo.name
        btn.categoryID = slotInfo.categoryID
        btn.isWeapon = slotInfo.isWeapon
        btn.isHidden = false
        
        -- Click to toggle hide for this slot
        btn:SetScript("OnClick", function(self)
            TUI:ToggleSlotHide(self.invSlot)
        end)
        
        -- Tooltip on hover
        btn:SetScript("OnEnter", function(self)
            self:SetBackdropBorderColor(0.6, 0.6, 0.6, 1)
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(self.slotName)
            local pending = TUI.pendingTransmogs[self.invSlot]
            if pending then
                if pending.isHiddenSlot then
                    GameTooltip:AddLine("Hidden", 1, 0.4, 0.4)
                else
                    GameTooltip:AddLine(pending.name or "Unknown", 0.5, 1, 0.5)
                end
            else
                GameTooltip:AddLine("No change", 0.5, 0.5, 0.5)
            end
            GameTooltip:AddLine("Click to hide/show", 0.5, 0.5, 0.5)
            GameTooltip:Show()
        end)
        btn:SetScript("OnLeave", function(self)
            if not self.isHidden then
                self:SetBackdropBorderColor(0.4, 0.4, 0.4, 1)
            end
            GameTooltip:Hide()
        end)
        
        parent.slotButtons[slotInfo.invSlot] = btn
    end
end

-- Get default icon for a slot
function TUI:GetDefaultSlotIcon(invSlot)
    local slotIcons = {
        [1] = "Interface\\PaperDoll\\UI-PaperDoll-Slot-Head",
        [3] = "Interface\\PaperDoll\\UI-PaperDoll-Slot-Shoulder",
        [4] = "Interface\\PaperDoll\\UI-PaperDoll-Slot-Shirt",
        [5] = "Interface\\PaperDoll\\UI-PaperDoll-Slot-Chest",
        [6] = "Interface\\PaperDoll\\UI-PaperDoll-Slot-Waist",
        [7] = "Interface\\PaperDoll\\UI-PaperDoll-Slot-Legs",
        [8] = "Interface\\PaperDoll\\UI-PaperDoll-Slot-Feet",
        [9] = "Interface\\PaperDoll\\UI-PaperDoll-Slot-Wrists",
        [10] = "Interface\\PaperDoll\\UI-PaperDoll-Slot-Hands",
        [15] = "Interface\\PaperDoll\\UI-PaperDoll-Slot-Chest",  -- Back uses chest icon
        [16] = "Interface\\PaperDoll\\UI-PaperDoll-Slot-MainHand",
        [17] = "Interface\\PaperDoll\\UI-PaperDoll-Slot-SecondaryHand",
        [19] = "Interface\\PaperDoll\\UI-PaperDoll-Slot-Tabard",
    }
    return slotIcons[invSlot] or "Interface\\PaperDoll\\UI-Backpack-EmptySlot"
end

-- Toggle hide for a slot
function TUI:ToggleSlotHide(invSlot)
    local btn = self.frame.slotButtons[invSlot]
    if not btn then return end
    
    local pending = self.pendingTransmogs[invSlot]
    
    if pending and pending.isHiddenSlot then
        -- Currently hidden, remove the hide
        self.pendingTransmogs[invSlot] = nil
        btn.isHidden = false
        btn.hiddenBorder:Hide()
        btn.pendingBorder:Hide()
        btn.pendingInner:Hide()
        btn.icon:SetTexture(self:GetDefaultSlotIcon(invSlot))
        btn.icon:SetDesaturated(false)
        btn:SetBackdropBorderColor(0.4, 0.4, 0.4, 1)
        
        -- Restore on model (avoid SetUnit which causes graphics reset)
        if self.frame.model then
            self.frame.model:Dress()
            for slot, pend in pairs(self.pendingTransmogs) do
                if pend.itemID and not pend.isHiddenSlot then
                    self.frame.model:TryOn("item:" .. pend.itemID)
                end
            end
        end
        
        ETB:Debug("TransmogUI", string.format("%s visible", btn.slotName))
    else
        -- Set to hidden
        self.pendingTransmogs[invSlot] = {
            isHiddenSlot = true,
            name = "Hidden",
            icon = "Interface\\Icons\\INV_Misc_QuestionMark",
        }
        btn.isHidden = true
        btn.hiddenBorder:Show()
        btn.pendingBorder:Hide()
        btn.pendingInner:Hide()
        btn.icon:SetTexture("Interface\\Icons\\Spell_Shadow_SacrificialShield")
        btn.icon:SetDesaturated(true)
        btn:SetBackdropBorderColor(0.8, 0.2, 0.2, 1)
        
        ETB:Debug("TransmogUI", string.format("%s hidden", btn.slotName))
    end
    
    -- Push the change to Blizzard so both UIs stay in sync
    self:PushSlotToBlizzard(invSlot)
    self:UpdateCost()
end

-- Update slot button icons based on pending transmogs
function TUI:UpdateSlotButtonIcons()
    if not self.frame or not self.frame.slotButtons then return end
    
    for invSlot, btn in pairs(self.frame.slotButtons) do
        local pending = self.pendingTransmogs[invSlot]
        
        if pending then
            if pending.isHiddenSlot then
                -- Slot is set to hidden
                btn.isHidden = true
                btn.hiddenBorder:Show()
                btn.pendingBorder:Hide()
                btn.pendingInner:Hide()
                btn.icon:SetTexture("Interface\\Icons\\Spell_Shadow_SacrificialShield")
                btn.icon:SetDesaturated(true)
                btn:SetBackdropBorderColor(0.8, 0.2, 0.2, 1)
            else
                -- Slot has a transmog selected
                btn.isHidden = false
                btn.hiddenBorder:Hide()
                btn.pendingBorder:Show()
                btn.pendingInner:Show()
                btn.icon:SetTexture(pending.icon or self:GetDefaultSlotIcon(invSlot))
                btn.icon:SetDesaturated(false)
                btn:SetBackdropBorderColor(1, 0.82, 0, 1)
            end
        else
            -- No pending change
            btn.isHidden = false
            btn.hiddenBorder:Hide()
            btn.pendingBorder:Hide()
            btn.pendingInner:Hide()
            btn.icon:SetTexture(self:GetDefaultSlotIcon(invSlot))
            btn.icon:SetDesaturated(false)
            btn:SetBackdropBorderColor(0.4, 0.4, 0.4, 1)
        end
    end
end

function TUI:SelectSlot(invSlot)
    self.selectedSlot = invSlot
    self.selectedTab = "appearances"
    
    local slotName, categoryIDFromSlot = "Unknown", nil
    for _, slot in ipairs(SLOTS) do
        if slot.invSlot == invSlot then
            slotName = slot.name
            categoryIDFromSlot = slot.categoryID
            break
        end
    end
    
    self.selectedCategoryID = categoryIDFromSlot
    self:UpdateCategoryBarHighlight()
    self:UpdateCurrentViewingLabel()
    
    self.frame.browserPanel.title:SetText("|cffffd100" .. slotName .. " Appearances|r")
    self.frame.browserPanel.appearancesTab.text:SetText("|cff00ff00Appearances|r")
    self.frame.browserPanel.appearancesTab.underline:Show()
    self.frame.browserPanel.setsTab.text:SetText("|cff888888Sets|r")
    self.frame.browserPanel.setsTab.underline:Hide()
    if self.frame.browserPanel.incompleteTab then
        self.frame.browserPanel.incompleteTab.text:SetText("|cff888888Incomplete|r")
        self.frame.browserPanel.incompleteTab.underline:Hide()
    end
    
    self:RefreshBrowser()
end

-- =============================================================================
-- CATEGORY BAR (delegated to NS.ETB.TransmogUI.CategoryBar submodule in v12.40.0)
-- =============================================================================
-- Submodule owns CATEGORY_ICONS / CATEGORY_NAMES / CATEGORY_TO_INVSLOT constants
-- and all rendering/highlight/label logic.

function TUI:CreateCategoryBar(parent)
    local CB = NS.ETB.TransmogUI and NS.ETB.TransmogUI.CategoryBar
    if not CB then return end
    return CB:Create(parent, self)
end

function TUI:UpdateCategoryBarHighlight()
    local CB = NS.ETB.TransmogUI and NS.ETB.TransmogUI.CategoryBar
    if not CB then return end
    CB:UpdateHighlight(self)
end

function TUI:UpdateCurrentViewingLabel()
    local CB = NS.ETB.TransmogUI and NS.ETB.TransmogUI.CategoryBar
    if not CB then return end
    CB:UpdateCurrentViewingLabel(self)
end

-- =============================================================================
-- BROWSER PANEL
-- =============================================================================

function TUI:CreateBrowserPanel(parent)
    local panel = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    panel:SetWidth(370)
    panel:SetPoint("TOPLEFT", 6, -24)
    panel:SetPoint("BOTTOMLEFT", 6, 94)
    panel:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 12,
        insets = { left = 4, right = 4, top = 4, bottom = 4 }
    })
    
    panel.title = panel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    panel.title:SetPoint("TOPLEFT", 8, -6)
    panel.title:SetText("|cffffd100Select a slot|r")
    
    -- Underline-style tabs
    local tabFrame = CreateFrame("Frame", nil, panel)
    tabFrame:SetSize(320, 20)
    tabFrame:SetPoint("TOPLEFT", 8, -20)
    
    panel.appearancesTab = CreateFrame("Button", nil, tabFrame)
    panel.appearancesTab:SetSize(95, 20)
    panel.appearancesTab:SetPoint("LEFT", 0, 0)
    panel.appearancesTab.text = panel.appearancesTab:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    panel.appearancesTab.text:SetAllPoints()
    panel.appearancesTab.text:SetText("|cff00ff00Appearances|r")
    panel.appearancesTab.underline = panel.appearancesTab:CreateTexture(nil, "OVERLAY")
    panel.appearancesTab.underline:SetSize(95, 2)
    panel.appearancesTab.underline:SetPoint("BOTTOM")
    panel.appearancesTab.underline:SetColorTexture(0, 1, 0.5, 1)
    panel.appearancesTab:SetScript("OnClick", function()
        TUI.selectedTab = "appearances"
        panel.appearancesTab.text:SetText("|cff00ff00Appearances|r")
        panel.appearancesTab.underline:Show()
        panel.setsTab.text:SetText("|cff888888Sets|r")
        panel.setsTab.underline:Hide()
        panel.incompleteTab.text:SetText("|cff888888Incomplete|r")
        panel.incompleteTab.underline:Hide()
        TUI:RefreshBrowser()
    end)
    -- Compat: SetText method for code that calls panel.appearancesTab:SetText()
    panel.appearancesTab.SetText = function(self, text)
        self.text:SetText(text)
    end
    panel.appearancesTab.GetText = function(self)
        return self.text:GetText()
    end
    
    panel.setsTab = CreateFrame("Button", nil, tabFrame)
    panel.setsTab:SetSize(50, 20)
    panel.setsTab:SetPoint("LEFT", panel.appearancesTab, "RIGHT", 8, 0)
    panel.setsTab.text = panel.setsTab:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    panel.setsTab.text:SetAllPoints()
    panel.setsTab.text:SetText("|cff888888Sets|r")
    panel.setsTab.underline = panel.setsTab:CreateTexture(nil, "OVERLAY")
    panel.setsTab.underline:SetSize(50, 2)
    panel.setsTab.underline:SetPoint("BOTTOM")
    panel.setsTab.underline:SetColorTexture(0, 1, 0.5, 1)
    panel.setsTab.underline:Hide()
    panel.setsTab:SetScript("OnClick", function()
        TUI.selectedTab = "sets"
        panel.appearancesTab.text:SetText("|cff888888Appearances|r")
        panel.appearancesTab.underline:Hide()
        panel.setsTab.text:SetText("|cff00ff00Sets|r")
        panel.setsTab.underline:Show()
        panel.incompleteTab.text:SetText("|cff888888Incomplete|r")
        panel.incompleteTab.underline:Hide()
        panel.title:SetText("|cffffd100Transmog Sets|r")
        TUI:RefreshBrowser()
    end)
    panel.setsTab.SetText = function(self, text) self.text:SetText(text) end
    panel.setsTab.GetText = function(self) return self.text:GetText() end
    
    panel.incompleteTab = CreateFrame("Button", nil, tabFrame)
    panel.incompleteTab:SetSize(75, 20)
    panel.incompleteTab:SetPoint("LEFT", panel.setsTab, "RIGHT", 8, 0)
    panel.incompleteTab.text = panel.incompleteTab:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    panel.incompleteTab.text:SetAllPoints()
    panel.incompleteTab.text:SetText("|cff888888Incomplete|r")
    panel.incompleteTab.underline = panel.incompleteTab:CreateTexture(nil, "OVERLAY")
    panel.incompleteTab.underline:SetSize(75, 2)
    panel.incompleteTab.underline:SetPoint("BOTTOM")
    panel.incompleteTab.underline:SetColorTexture(1, 0.53, 0, 1)
    panel.incompleteTab.underline:Hide()
    panel.incompleteTab:SetScript("OnClick", function()
        TUI.selectedTab = "incomplete"
        panel.appearancesTab.text:SetText("|cff888888Appearances|r")
        panel.appearancesTab.underline:Hide()
        panel.setsTab.text:SetText("|cff888888Sets|r")
        panel.setsTab.underline:Hide()
        panel.incompleteTab.text:SetText("|cffff8800Incomplete|r")
        panel.incompleteTab.underline:Show()
        panel.title:SetText("|cffffd100Incomplete Sets|r")
        TUI:RefreshBrowser()
    end)
    panel.incompleteTab.SetText = function(self, text) self.text:SetText(text) end
    panel.incompleteTab.GetText = function(self) return self.text:GetText() end
    
    panel.search = CreateFrame("EditBox", nil, panel, "InputBoxTemplate")
    panel.search:SetSize(220, 18)
    panel.search:SetPoint("TOPLEFT", 8, -42)
    panel.search:SetAutoFocus(false)
    panel.search:SetScript("OnTextChanged", function(self)
        TUI.searchText = self:GetText():lower()
        TUI:RefreshBrowser()
    end)
    panel.search:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    
    -- Color filter dropdown
    local ETBDropdown = NS.ETBDropdown
    if ETBDropdown then
        panel.colorDropdown = ETBDropdown:Create("ETBTUIColorDD", panel)
        panel.colorDropdown:SetPoint("TOPLEFT", panel.search, "TOPRIGHT", 8, 3)
        ETBDropdown:SetWidth(panel.colorDropdown, 70)
        ETBDropdown:SetText(panel.colorDropdown, "Color")
        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, "Color")
                TUI.colorFilter = nil
                TUI:RefreshBrowser()
            end
            ETBDropdown:AddButton(_items_, info)
            
            if ETB and 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)
                        TUI.colorFilter = cat.id
                        TUI:RefreshBrowser()
                    end
                    ETBDropdown:AddButton(_items_, info)
                end
            end
        end)
    end
    
    local scrollContainer = CreateFrame("Frame", nil, panel)
    scrollContainer:SetPoint("TOPLEFT", 4, -62)
    scrollContainer:SetPoint("BOTTOMRIGHT", -5, 28)
    
    local scrollFrame = CreateFrame("ScrollFrame", nil, scrollContainer, "UIPanelScrollFrameTemplate")
    scrollFrame:SetAllPoints()
    
    local scrollChild = CreateFrame("Frame", nil, scrollFrame)
    scrollChild:SetWidth(scrollFrame:GetWidth() - 20)
    scrollChild:SetHeight(1)
    scrollFrame:SetScrollChild(scrollChild)
    
    panel.scrollFrame = scrollFrame
    panel.scrollChild = scrollChild
    
    panel.stats = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    panel.stats:SetPoint("BOTTOM", 0, 10)
    panel.stats:SetText("|cff888888Select a slot to browse|r")
    
    parent.browserPanel = panel
end

function TUI:RefreshBrowser()
    if not self.frame or not self.frame.browserPanel then return end
    if self.selectedTab == "sets" then
        self:RefreshSets()
    elseif self.selectedTab == "incomplete" then
        self:RefreshIncompleteSets()
    else
        self:RefreshAppearances()
    end
end

-- =============================================================================
-- ROW POOLING (delegated to TransmogUI/ItemRows.lua as of v12.45.0)
-- =============================================================================
--
-- The row construction and pooling logic lives in the ItemRows submodule.
-- These methods are preserved as thin delegators so call sites elsewhere in
-- this file (and any external callers) continue to work unchanged.
--
-- See UI/TransmogUI/ItemRows.lua for the implementation and the owner
-- contract (what fields and methods the submodule expects on `self`).

function TUI:AcquireRow(parent, index)
    return NS.ETB.TransmogUI.ItemRows:Acquire(self, parent, index)
end

function TUI:ReleaseRowsToPool(fromIndex)
    return NS.ETB.TransmogUI.ItemRows:ReleasePool(self, fromIndex)
end

function TUI:CreateItemRow(parent, index)
    return NS.ETB.TransmogUI.ItemRows:Create(self, parent, index)
end

-- =============================================================================
-- REFRESH APPEARANCES
-- =============================================================================

function TUI:RefreshAppearances()
    local panel = self.frame.browserPanel
    local scrollChild = panel.scrollChild
    
    scrollChild:SetWidth(math.max(1, panel.scrollFrame:GetWidth() - 20))
    wipe(self.itemRows)
    
    if not self.selectedSlot then
        self:ReleaseRowsToPool(1)
        panel.stats:SetText("|cff888888Select a slot to browse|r")
        scrollChild:SetHeight(1)
        return
    end
    
    local categoryID = self.selectedCategoryID
    local isWeapon = categoryID and categoryID >= 12
    
    if not categoryID then
        for _, slot in ipairs(SLOTS) do
            if slot.invSlot == self.selectedSlot then
                categoryID = slot.categoryID
                isWeapon = slot.isWeapon
                break
            end
        end
    end
    
    if not categoryID then
        self:ReleaseRowsToPool(1)
        panel.stats:SetText("|cffff6600Category not found|r")
        scrollChild:SetHeight(1)
        return
    end
    
    local appearances = {}
    ETB = GetETB()
    
    -- Helper function to check if item matches color filter (zero allocation)
    local function matchesColorFilter(item)
        if not TUI.colorFilter then return true end
        if not ETB or not ETB.ColorSearch or not ETB.ColorSearch:HasData() then return true end
        local vid = item.visualID
        local hasMapData = vid and ETB.ColorMap and ETB.ColorMap[vid]
        if hasMapData or (item.name and item.name ~= "Loading...") then
            return ETB.ColorSearch:ItemMatchesCategory(vid, item.name, TUI.colorFilter)
        end
        return true  -- No data = pass through
    end
    
    -- Add "Hide Appearance" option at the top for armor slots
    -- In MoP Classic, hidden appearances are actual items like "Hidden Helm", "Hidden Cloak"
    -- We'll try to find them from the API, or use a placeholder that gets resolved on apply
    if not isWeapon then
        local hiddenName = "Hidden"
        local hiddenSlotNames = {
            [1] = "Hidden Helm", [3] = "Hidden Shoulder", [5] = "Hidden Chest",
            [6] = "Hidden Belt", [7] = "Hidden Leg", [8] = "Hidden Boot",
            [9] = "Hidden Bracer", [10] = "Hidden Glove", [15] = "Hidden Cloak",
        }
        local slotHiddenName = hiddenSlotNames[self.selectedSlot] or "Hidden"
        
        -- Use cached hidden sourceID (looked up once per slot, stored permanently)
        if not TUI._hiddenSourceCache then TUI._hiddenSourceCache = {} end
        local hiddenSourceID, hiddenVisualID, hiddenIcon
        if TUI._hiddenSourceCache[categoryID] then
            local cached = TUI._hiddenSourceCache[categoryID]
            hiddenSourceID = cached.sourceID
            hiddenVisualID = cached.visualID
            hiddenIcon = cached.icon
        elseif C_TransmogCollection and C_TransmogCollection.GetCategoryAppearances then
            -- One-time search: find hidden item for this category
            local catApps = C_TransmogCollection.GetCategoryAppearances(categoryID)
            if catApps then
                for _, app in ipairs(catApps) do
                    local sources = C_TransmogCollection.GetAppearanceSources(app.visualID)
                    if sources then
                        for _, src in ipairs(sources) do
                            local sourceInfo = C_TransmogCollection.GetSourceInfo(src.sourceID)
                            if sourceInfo then
                                local itemName = GetItemInfo(sourceInfo.itemID)
                                if itemName and (itemName:lower():find("hidden") or itemName:lower():find("invisible")) then
                                    hiddenSourceID = src.sourceID
                                    hiddenVisualID = app.visualID
                                    hiddenIcon = sourceInfo.invType and GetInventoryItemTexture("player", sourceInfo.invType) or nil
                                    break
                                end
                            end
                        end
                    end
                    if hiddenSourceID then break end
                end
            end
            -- Cache result (even if nil) so we never search again
            TUI._hiddenSourceCache[categoryID] = {
                sourceID = hiddenSourceID,
                visualID = hiddenVisualID,
                icon = hiddenIcon,
            }
        end
        
        table.insert(appearances, {
            itemID = nil, 
            visualID = hiddenVisualID or 0, 
            sourceID = hiddenSourceID or 0,
            name = "|cffff8800[" .. slotHiddenName .. "]|r",
            icon = hiddenIcon or "Interface\\Icons\\Spell_Shadow_SacrificialShield",
            quality = 1, 
            isCollected = true, 
            source = hiddenSourceID and "|cff00ff00Click to hide|r" or "|cff888888Hide this slot|r",
            isHidden = true,
            actualHiddenSourceID = hiddenSourceID,  -- Store the real sourceID if found
        })
    end
    
    local ETB = GetETB()
    local seenVisualIDs = {}  -- Track which visualIDs we've already added
    
    -- Determine if this slot needs armor-type filtering
    -- Back (3), Shirt (5), Tabard (6) are cosmetic - no armor filter
    -- Weapons (>=12) have their own categories - no armor filter
    local ARMOR_FILTERED_CATEGORIES = { [1]=true, [2]=true, [4]=true, [7]=true, [8]=true, [9]=true, [10]=true, [11]=true }
    local playerArmorType = self:GetPlayerArmorType()  -- "CLOTH", "LEATHER", "MAIL", "PLATE"
    local filterByArmor = ARMOR_FILTERED_CATEGORIES[categoryID] or false
    
    if ETB and ETB.BlizzardData and ETB.BlizzardData.GetItemsForCategory then
        local items = ETB.BlizzardData:GetItemsForCategory(categoryID, { onlyUsable = true })
        if items then
            for _, item in ipairs(items) do
                -- ONLY show collected items in TransmogUI
                -- Also consolidate by visualID (only show one item per appearance)
                -- Resolve visualID at runtime if not pre-set
                local vid = ETB:ResolveVisualID(item)
                if item.isCollected and vid and not seenVisualIDs[vid] then
                    -- Filter by player's armor type for armor slots
                    if filterByArmor and item.armorType and item.armorType ~= playerArmorType then
                        -- Skip items that don't match player's primary armor type
                    else
                    seenVisualIDs[vid] = true
                    
                    local searchText = self.searchText or ""
                    local nameMatch = searchText == "" or (item.name and item.name:lower():find(searchText, 1, true))
                    local colorMatch = matchesColorFilter(item)
                    
                    if nameMatch and colorMatch then
                        local sourceText = ""
                        if ETB.SourceMapper and item.itemID then
                            local sources = ETB.SourceMapper:GetItemSources(item.itemID)
                            if sources and #sources > 0 then
                                sourceText = sources[1].bossName or sources[1].zoneName or ""
                            end
                        end
                        
                        -- Get icon if missing
                        local icon = item.icon
                        if (not icon or icon == "") and item.itemID then
                            icon = GetItemIcon(item.itemID) or "Interface\\Icons\\INV_Misc_QuestionMark"
                        end
                        
                        table.insert(appearances, {
                            itemID = item.itemID, visualID = vid, sourceID = item.sourceID,
                            name = (item.name and item.name ~= "Loading..." and item.name) or (item.itemID and GetItemInfo(item.itemID)) or ("Item " .. (item.itemID or "?")),
                            icon = icon or "Interface\\Icons\\INV_Misc_QuestionMark",
                            quality = item.quality or (item.itemID and select(3, GetItemInfo(item.itemID))) or 1,
                            isCollected = true, source = sourceText,
                        })
                        if item.sourceID and item.itemID then
                            self.itemIDToSourceID[item.itemID] = item.sourceID
                        end
                    end
                    end -- close armor filter else
                end
            end
        end
    else
        -- Fallback to direct API - already grouped by visualID
        if C_TransmogCollection and C_TransmogCollection.GetCategoryAppearances then
            local catAppearances = C_TransmogCollection.GetCategoryAppearances(categoryID)
            if catAppearances then
                for _, app in ipairs(catAppearances) do
                    if app.isCollected and not seenVisualIDs[app.visualID] then
                        local sources = C_TransmogCollection.GetAppearanceSources(app.visualID)
                        if sources and sources[1] then
                            local src = sources[1]
                            
                            -- Check armor type filter
                            local skipArmor = false
                            if filterByArmor and src.itemID then
                                local _, _, _, _, _, classID, subclassID = GetItemInfoInstant(src.itemID)
                                if classID == 4 then  -- Armor class
                                    local ARMOR_MAP = { [1]="CLOTH", [2]="LEATHER", [3]="MAIL", [4]="PLATE" }
                                    local srcArmor = ARMOR_MAP[subclassID]
                                    if srcArmor and srcArmor ~= playerArmorType then
                                        skipArmor = true
                                    end
                                end
                            end
                            
                            if not skipArmor then
                            seenVisualIDs[app.visualID] = true
                            local cached = self:GetCachedItem(src.itemID)
                            local searchText = self.searchText or ""
                            local nameMatch = searchText == "" or (cached.name and cached.name:lower():find(searchText, 1, true))
                            local colorMatch = matchesColorFilter({name = cached.name, visualID = app.visualID, itemID = src.itemID})
                            
                            if nameMatch and colorMatch then
                                table.insert(appearances, {
                                    itemID = src.itemID, visualID = app.visualID, sourceID = src.sourceID,
                                    name = cached.name, icon = cached.icon, quality = cached.quality,
                                    isCollected = true, source = "",
                                })
                                if src.sourceID then self.itemIDToSourceID[src.itemID] = src.sourceID end
                            end
                            end -- close skipArmor
                        end
                    end
                end
            end
        end
    end
    
    -- Sort by name, keep Hidden option at top
    table.sort(appearances, function(a, b)
        if a.isHidden then return true end
        if b.isHidden then return false end
        return tostring(a.name or "") < tostring(b.name or "")
    end)
    
    for i, app in ipairs(appearances) do
        local row = self:AcquireRow(scrollChild, i)
        row.itemData = app
        row.icon:SetTexture(app.icon)
        
        local qColor = QUALITY_COLORS[app.quality] or QUALITY_COLORS[1]
        row.iconBorder:SetVertexColor(qColor.r, qColor.g, qColor.b, 1)
        row.name:SetText(app.name)
        row.name:SetTextColor(qColor.r, qColor.g, qColor.b)
        
        row.source:SetText(app.source ~= "" and ("|cff666666" .. app.source .. "|r") or "")
        
        -- Collected bar color
        if row.collectedBar then
            if app.isCollected == false then
                row.collectedBar:SetColorTexture(1, 0.25, 0.25, 0.8)
            else
                row.collectedBar:SetColorTexture(0, 1, 0.5, 0.9)
            end
        end
        
        -- No status mark - collected bar is sufficient
        if row.status then row.status:SetText("") end
        
        if app.isCollected == false then
            row.icon:SetDesaturated(true); row.icon:SetAlpha(0.5)
        else
            row.icon:SetDesaturated(false); row.icon:SetAlpha(1)
        end
        
        self.itemRows[i] = row
    end
    
    self:ReleaseRowsToPool(#appearances + 1)
    scrollChild:SetHeight(math.max(1, #appearances * (ROW_HEIGHT + ROW_SPACING) + 10))
    panel.stats:SetText(string.format("|cff888888%d appearances|r", #appearances))
end

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

-- Get player's primary armor type
function TUI:GetPlayerArmorType()
    local _, class = UnitClass("player")
    local armorTypes = {
        WARRIOR = "PLATE", PALADIN = "PLATE", DEATHKNIGHT = "PLATE",
        HUNTER = "MAIL", SHAMAN = "MAIL",
        ROGUE = "LEATHER", DRUID = "LEATHER", MONK = "LEATHER",
        PRIEST = "CLOTH", MAGE = "CLOTH", WARLOCK = "CLOTH",
    }
    return armorTypes[class] or "CLOTH"
end

-- Check if player can use a set based on class mask
function TUI:CanPlayerUseSet(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 TUI:GetBlizzardSetItems(setID)
    local items = {}
    
    if not C_TransmogSets or not setID then return items end
    
    -- Try GetSetPrimaryAppearances first
    if C_TransmogSets.GetSetPrimaryAppearances then
        local appearances = C_TransmogSets.GetSetPrimaryAppearances(setID)
        if appearances then
            for _, app in ipairs(appearances) do
                local itemID = nil
                local slotNum = nil
                
                -- Try multiple methods to get itemID (same as ApplySet)
                -- Method 1: Direct itemID field
                if app.itemID then
                    itemID = app.itemID
                end
                
                -- Method 2: Direct sourceID field
                if not itemID and app.sourceID then
                    local srcInfo = C_TransmogCollection.GetSourceInfo(app.sourceID)
                    if srcInfo and srcInfo.itemID then
                        itemID = srcInfo.itemID
                        if srcInfo.categoryID then
                            slotNum = self:CategoryToSlotNum(srcInfo.categoryID)
                        end
                    end
                end
                
                -- Method 3: Treat appearanceID as sourceID
                if not itemID and app.appearanceID then
                    local srcInfo = C_TransmogCollection.GetSourceInfo(app.appearanceID)
                    if srcInfo and srcInfo.itemID then
                        itemID = srcInfo.itemID
                        if srcInfo.categoryID then
                            slotNum = self:CategoryToSlotNum(srcInfo.categoryID)
                        end
                    end
                end
                
                -- Method 4: GetAppearanceSources (fallback)
                if not itemID and app.appearanceID then
                    local sources = C_TransmogCollection.GetAppearanceSources(app.appearanceID)
                    if sources and #sources > 0 then
                        local firstSrc = sources[1]
                        local sourceID = type(firstSrc) == "table" and firstSrc.sourceID or firstSrc
                        if sourceID then
                            local srcInfo = C_TransmogCollection.GetSourceInfo(sourceID)
                            if srcInfo and srcInfo.itemID then
                                itemID = srcInfo.itemID
                                if srcInfo.categoryID then
                                    slotNum = self:CategoryToSlotNum(srcInfo.categoryID)
                                end
                            end
                        end
                    end
                end
                
                -- If we got an itemID and slot, store it
                if itemID and slotNum then
                    items[slotNum] = itemID
                end
            end
        end
    end
    
    return items
end

-- Helper to convert categoryID to slot number for sets
function TUI:CategoryToSlotNum(categoryID)
    local catToSlot = {
        [1] = 1,  -- Head
        [2] = 2,  -- Shoulder
        [3] = 9,  -- Back
        [4] = 3,  -- Chest
        [5] = 3,  -- Shirt -> Chest
        [6] = 3,  -- Tabard -> Chest
        [7] = 7,  -- Wrist
        [8] = 8,  -- Hands
        [9] = 4,  -- Waist
        [10] = 5, -- Legs
        [11] = 6, -- Feet
    }
    return catToSlot[categoryID]
end

function TUI:RefreshSets()
    local panel = self.frame.browserPanel
    local scrollChild = panel.scrollChild
    
    scrollChild:SetWidth(math.max(1, panel.scrollFrame:GetWidth() - 20))
    wipe(self.itemRows)
    panel.title:SetText("|cffffd100Transmog Sets|r")
    
    local sets = {}
    local playerArmor = self: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 self:CanPlayerUseSet(setData) then
                local searchText = self.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 = self: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 self:CanPlayerUseSet(setInfo) then
                local searchText = self.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 = self: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 = self.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 = self.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 = self: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 = self: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 = self: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
        
        self.itemRows[i] = row
    end
    
    self:ReleaseRowsToPool(#sets + 1)
    scrollChild:SetHeight(math.max(1, #sets * (ROW_HEIGHT + 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 TUI:RefreshIncompleteSets()
    local panel = self.frame.browserPanel
    local scrollChild = panel.scrollChild
    
    scrollChild:SetWidth(math.max(1, panel.scrollFrame:GetWidth() - 20))
    wipe(self.itemRows)
    
    local ETB = GetETB()
    local playerArmor = (ETB and ETB.BlizzardData) and ETB.BlizzardData:GetPlayerPrimaryArmor() or "CLOTH"
    local sets = {}
    local searchText = self.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 = self: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 self: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 = self: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 = self: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 = self: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)
        
        self.itemRows[i] = row
    end
    
    self:ReleaseRowsToPool(#sets + 1)
    scrollChild:SetHeight(math.max(1, #sets * (ROW_HEIGHT + ROW_SPACING) + 10))
    
    panel.stats:SetText(string.format("|cffff8800%d incomplete sets to farm|r", #sets))
end

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

function TUI:ShowIncompleteSetDetail(setData)
    if not setData or not self.frame or not self.frame.browserPanel then return end
    
    local panel = self.frame.browserPanel
    local scrollChild = panel.scrollChild
    
    scrollChild:SetWidth(math.max(1, panel.scrollFrame:GetWidth() - 20))
    wipe(self.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 = self: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)
    self.itemRows[rowIdx] = backRow
    rowIdx = rowIdx + 1
    
    -- =========================================================================
    -- Render: Set header (row 2)
    -- =========================================================================
    local headerRow = self: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)
    self.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 = self: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)
        self.itemRows[rowIdx] = applyRow
        rowIdx = rowIdx + 1
    end
    
    -- =========================================================================
    -- Render: Each piece with source info
    -- =========================================================================
    for _, piece in ipairs(pieces) do
        local row = self:AcquireRow(scrollChild, rowIdx)
        
        -- Build item data for right-click detail panel support
        local itemName, _, itemQuality, _, _, _, _, _, _, itemIcon = GetItemInfo(piece.itemID)
        local cached = self: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)
        
        self.itemRows[rowIdx] = row
        rowIdx = rowIdx + 1
    end
    
    self:ReleaseRowsToPool(rowIdx)
    scrollChild:SetHeight(math.max(1, (rowIdx - 1) * (ROW_HEIGHT + 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 TUI:ApplySet(setData)
    if not setData then return end
    
    self._applyingSet = true
    -- Increment apply generation to invalidate stale C_Timer callbacks
    self._applyGeneration = (self._applyGeneration or 0) + 1
    local currentGeneration = self._applyGeneration
    
    wipe(self.pendingTransmogs)
    
    -- Clear Blizzard's pending for ALL slots before applying new set
    -- This prevents old set remnants from leaking through
    self:ClearBlizzardPending()
    
    -- Reset all slot buttons to default first
    for invSlot, btn in pairs(self.frame.slotButtons or {}) do
        btn.icon:SetTexture(self:GetDefaultSlotIcon(invSlot))
        btn.pendingBorder:Hide()
        btn.pendingInner:Hide()
        btn.hiddenBorder:Hide()
        btn.isHidden = false
        btn:SetBackdropBorderColor(0.4, 0.4, 0.4, 1)
    end
    
    -- Undress strips all gear without reloading the 3D model
    self.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 self: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 = self: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
                        self.pendingTransmogs[invSlot] = { 
                            sourceID = sourceID, itemID = itemID, name = cached.name, icon = icon 
                        }
                        if sourceID then self.itemIDToSourceID[itemID] = sourceID end
                        
                        -- Check if player has a collected source for this appearance
                        local collectedSrc, isCollected = self:GetCollectedSourceForItem(itemID)
                        
                        local btn = self.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 = self.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 TUI._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 TUI._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
                    self.itemIDToSourceID[itemID] = sourceID
                end
                
                -- Get icon
                local icon = itemData.icon
                if not icon then
                    local cached = self: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 = self: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)
                self.pendingTransmogs[pendingSlot] = {
                    sourceID = sourceID,
                    itemID = itemID,
                    name = name,
                    icon = icon,
                }
                
                -- Update slot button immediately
                local btn = self.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 = self.frame.model
        for i, item in ipairs(itemsToApply) do
            C_Timer.After((i - 1) * 0.05, function()
                if TUI._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 TUI._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
                        self.itemIDToSourceID[itemID] = sourceID
                    end
                    
                    local cached = self: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
                    
                    self.frame.model:TryOn("item:" .. itemID)
                    self.pendingTransmogs[invSlot] = { 
                        sourceID = sourceID,  -- Now includes sourceID!
                        itemID = itemID, 
                        name = cached.name, 
                        icon = icon 
                    }
                    
                    -- Directly update slot button 
                    local btn = self.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 self.pendingTransmogs[invSlot] then
            self.pendingTransmogs[invSlot] = {
                isHiddenSlot = true,
                name = "Hidden",
                icon = "Interface\\Icons\\Spell_Shadow_SacrificialShield",
            }
            hiddenCount = hiddenCount + 1
        end
    end
    
    self:UpdateSlotButtonIcons()
    self:UpdateCost()
    
    -- Push all pending to Blizzard so both UIs stay in sync
    -- (this also enables Apply to work at the transmog NPC)
    self:SyncToBlizzardPending()
    self: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 TUI._applyGeneration == currentGeneration then
            TUI._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 TUI:ApplyCollectedPieces(setData, pieces)
    if not setData or not pieces then return end
    
    self._applyingSet = true
    self._applyGeneration = (self._applyGeneration or 0) + 1
    local currentGeneration = self._applyGeneration
    
    wipe(self.pendingTransmogs)
    self:ClearBlizzardPending()
    
    -- Reset all slot buttons
    for invSlot, btn in pairs(self.frame.slotButtons or {}) do
        btn.icon:SetTexture(self: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 - Undress without SetUnit to avoid GPU model reload
    self.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 = self: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 = self:GetCollectedSourceForItem(piece.itemID)
                if collectedSrc then sourceID = collectedSrc end
                
                self.pendingTransmogs[invSlot] = {
                    sourceID = sourceID,
                    itemID = piece.itemID,
                    name = cached.name or ("Item " .. piece.itemID),
                    icon = icon,
                }
                if sourceID then self.itemIDToSourceID[piece.itemID] = sourceID end
                
                -- Update slot button
                local btn = self.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 = self.frame.model
    for i, item in ipairs(itemsToApply) do
        C_Timer.After((i - 1) * 0.05, function()
            if TUI._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 TUI._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 self.pendingTransmogs[invSlot] then
            self.pendingTransmogs[invSlot] = {
                isHiddenSlot = true,
                name = "Hidden",
                icon = "Interface\\Icons\\Spell_Shadow_SacrificialShield",
            }
            hiddenCount = hiddenCount + 1
        end
    end
    
    self:UpdateSlotButtonIcons()
    self:UpdateCost()
    self:SyncToBlizzardPending()
    
    self._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
function TUI:CategoryToSlot(categoryID)
    for _, slot in ipairs(SLOTS) do
        if slot.categoryID == categoryID then 
            return slot.invSlot
        end
    end
    return nil
end

function TUI:SelectAppearance(appearance)
    if not appearance or not self.selectedSlot then return end
    
    -- Handle "Hide Appearance" option
    if appearance.isHidden then
        -- Always set as hidden slot — Blizzard sync uses ToggleOff pending type
        self.pendingTransmogs[self.selectedSlot] = {
            isHiddenSlot = true,
            name = appearance.name or "Hidden",
            icon = appearance.icon or "Interface\\Icons\\Spell_Shadow_SacrificialShield",
        }
        
        -- Update model: redress then re-apply remaining non-hidden pending items
        if self.frame.model then
            self.frame.model:Dress()
            for invSlot, pending in pairs(self.pendingTransmogs) do
                if pending.itemID and not pending.isHiddenSlot then
                    self.frame.model:TryOn("item:" .. pending.itemID)
                end
            end
        end
        
        self:UpdateSlotButtonIcons()
        self:PushSlotToBlizzard(self.selectedSlot)
        self:UpdateCost()
        return
    end
    
    -- Get icon - try multiple sources to ensure we have one
    local icon = appearance.icon
    if (not icon or icon == "" or icon == "Interface\\Icons\\INV_Misc_QuestionMark") and appearance.itemID then
        icon = GetItemIcon(appearance.itemID) or "Interface\\Icons\\INV_Misc_QuestionMark"
    end
    
    -- Apply to model 
    if self.frame and self.frame.model and appearance.itemID then
        self.frame.model:TryOn("item:" .. appearance.itemID)
    end
    
    -- Store pending transmog
    self.pendingTransmogs[self.selectedSlot] = {
        sourceID = appearance.sourceID,
        itemID = appearance.itemID,
        name = appearance.name,
        icon = icon,
    }
    
    -- Direct slot button update 
    local btn = self.frame.slotButtons[self.selectedSlot]
    if btn then
        btn.icon:SetTexture(icon)
        btn.pendingBorder:Show()
        btn.pendingInner:Show()
        btn:SetBackdropBorderColor(1, 0.82, 0, 1)  -- Gold border
    end
    
    if appearance.sourceID and appearance.itemID then 
        self.itemIDToSourceID[appearance.itemID] = appearance.sourceID 
    end
    
    -- Push this slot to Blizzard API so both UIs stay in sync
    self:PushSlotToBlizzard(self.selectedSlot)
    
    self:UpdateCost()
end

-- =============================================================================
-- HOVER PREVIEW (delegated to NS.ETB.TransmogUI.Preview submodule in v12.40.0)
-- =============================================================================
-- These thin wrappers exist so existing call sites (self:ShowHoverPreview(item)
-- etc.) keep working unchanged. Real implementation is in TransmogUI/Preview.lua.

function TUI:CreateHoverPreview()
    local P = NS.ETB.TransmogUI and NS.ETB.TransmogUI.Preview
    if not P then return end
    P:Create()
    -- Keep TUI-side alias so any code still reading self.hoverPreviewFrame works.
    self.hoverPreviewFrame = P:GetFrame()
end

function TUI:ShowHoverPreview(itemData)
    local P = NS.ETB.TransmogUI and NS.ETB.TransmogUI.Preview
    if not P then return end
    -- Pass the browser panel as the anchor (was implicit via self.frame.browserPanel).
    local anchor = self.frame and self.frame.browserPanel or nil
    P:Show(itemData, anchor)
    -- Mirror current item onto TUI for any legacy readers.
    self.hoverPreviewItem = itemData
end

function TUI:HideHoverPreview()
    self.hoverPreviewItem = nil
    local P = NS.ETB.TransmogUI and NS.ETB.TransmogUI.Preview
    if P then P:Hide() end
end

-- =============================================================================
-- OUTFITS PANEL
-- =============================================================================

function TUI:CreateOutfitsPanel(parent)
    local panel = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    panel:SetWidth(250)
    panel:SetPoint("TOPRIGHT", -6, -24)
    panel:SetPoint("BOTTOMRIGHT", -6, 94)
    panel:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 12,
        insets = { left = 4, right = 4, top = 4, bottom = 4 }
    })
    
    -- Compact header: title left, Save button right
    panel.title = panel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    panel.title:SetPoint("TOPLEFT", 8, -6)
    panel.title:SetText("|cffffd100Saved outfits|r")
    
    panel.saveBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    panel.saveBtn:SetSize(55, 20)
    panel.saveBtn:SetPoint("TOPRIGHT", -48, -3)
    panel.saveBtn:SetText("|cff00ff00Save|r")
    panel.saveBtn:SetScript("OnClick", function() TUI:CaptureCurrentTransmog() end)
    panel.saveBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_TOP")
        GameTooltip:SetText("|cff00ff00Save Current Look|r")
        GameTooltip:AddLine("Saves your current transmog appearance", 0.8, 0.8, 0.8)
        GameTooltip:AddLine("as a new outfit.", 0.8, 0.8, 0.8)
        GameTooltip:Show()
    end)
    panel.saveBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)
    
    panel.accountToggle = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    panel.accountToggle:SetSize(50, 20)
    panel.accountToggle:SetPoint("TOPRIGHT", -3, -3)
    panel.accountToggle:SetText("|cff88ff88Char|r")
    panel.accountToggle.showAccount = false
    panel.accountToggle:SetScript("OnClick", function(self)
        self.showAccount = not self.showAccount
        if self.showAccount then
            self:SetText("|cff88aaffAll|r")
            panel.title:SetText("|cff88aaffAccount outfits|r")
        else
            self:SetText("|cff88ff88Char|r")
            panel.title:SetText("|cffffd100Saved outfits|r")
        end
        TUI:RefreshOutfits()
    end)
    panel.accountToggle:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_TOP")
        if self.showAccount then
            GameTooltip:SetText("Viewing: Account-Wide Outfits")
            GameTooltip:AddLine("Shared across all your characters", 0.5, 0.8, 1)
            GameTooltip:AddLine("Click to show this character only", 0.8, 0.8, 0.8)
        else
            GameTooltip:SetText("Viewing: This Character's Outfits")
            GameTooltip:AddLine("Private to this character", 0.5, 1, 0.5)
            GameTooltip:AddLine("Click to show account-wide", 0.8, 0.8, 0.8)
        end
        GameTooltip:Show()
    end)
    panel.accountToggle:SetScript("OnLeave", function() GameTooltip:Hide() end)
    
    local scrollContainer = CreateFrame("Frame", nil, panel)
    scrollContainer:SetPoint("TOPLEFT", 5, -30)
    scrollContainer:SetPoint("BOTTOMRIGHT", -5, 34)
    
    local scrollFrame = CreateFrame("ScrollFrame", nil, scrollContainer, "UIPanelScrollFrameTemplate")
    scrollFrame:SetAllPoints()
    
    local scrollChild = CreateFrame("Frame", nil, scrollFrame)
    scrollChild:SetWidth(scrollFrame:GetWidth() - 20)
    scrollChild:SetHeight(1)
    scrollFrame:SetScrollChild(scrollChild)
    
    panel.scrollChild = scrollChild
    
    -- Bottom: Load and Delete side by side
    panel.loadBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    panel.loadBtn:SetSize(95, 22)
    panel.loadBtn:SetPoint("BOTTOMLEFT", 6, 10)
    panel.loadBtn:SetText("|cff00ff00Load|r")
    panel.loadBtn:SetScript("OnClick", function() if TUI.selectedOutfitID then TUI:LoadOutfitToPreview(TUI.selectedOutfitID) end end)
    
    panel.deleteBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    panel.deleteBtn:SetSize(95, 22)
    panel.deleteBtn:SetPoint("BOTTOMRIGHT", -6, 10)
    panel.deleteBtn:SetText("|cffff4444Delete|r")
    panel.deleteBtn:SetScript("OnClick", function() if TUI.selectedOutfitID then TUI:DeleteOutfit(TUI.selectedOutfitID) end end)
    
    panel.stats = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    panel.stats:SetPoint("BOTTOM", 0, 36)
    
    parent.outfitsPanel = panel
end

-- =============================================================================
-- CAPTURE CURRENT TRANSMOG (Reads your equipped gear and saves as outfit)
-- =============================================================================
-- Use this when Blizzard's outfit import returns 0/0 pieces.
-- It directly reads your equipped items and their appearances.

function TUI:CaptureCurrentTransmog()
    local items = {}
    local pieceCount = 0
    local slotNames = {
        [1] = "Head", [3] = "Shoulder", [5] = "Chest", [6] = "Waist",
        [7] = "Legs", [8] = "Feet", [9] = "Wrist", [10] = "Hands",
        [15] = "Back", [16] = "MainHand", [17] = "OffHand",
    }
    
    for invSlot, slotName in pairs(slotNames) do
        local sourceID = nil
        local itemID = nil
        local itemName = nil
        local itemIcon = nil
        
        -- Try to get the TRANSMOG appearance (what it looks like), not the equipped item
        if TransmogUtil and TransmogUtil.CreateTransmogLocation and C_Transmog and C_Transmog.GetSlotVisualInfo then
            local ok, transmogLocation = pcall(TransmogUtil.CreateTransmogLocation, invSlot, 0, 0)
            if ok and transmogLocation then
                -- GetSlotVisualInfo returns: baseSourceID, baseVisualID, appliedSourceID, appliedVisualID, ...
                local vizOk, baseSourceID, baseVisualID, appliedSourceID, appliedVisualID = pcall(C_Transmog.GetSlotVisualInfo, transmogLocation)
                if vizOk then
                    -- Use applied if transmogged, otherwise use base
                    if appliedSourceID and appliedSourceID > 0 then
                        sourceID = appliedSourceID
                    elseif baseSourceID and baseSourceID > 0 then
                        sourceID = baseSourceID
                    end
                    
                    -- Get item info from sourceID
                    if sourceID and sourceID > 0 then
                        local sourceInfo = C_TransmogCollection.GetSourceInfo(sourceID)
                        if sourceInfo and sourceInfo.itemID then
                            itemID = sourceInfo.itemID
                            itemName, _, _, _, _, _, _, _, _, itemIcon = GetItemInfo(itemID)
                        end
                    end
                end
            end
        end
        
        -- Fallback: if we couldn't get transmog, get equipped item appearance
        if not sourceID or sourceID == 0 then
            local itemLink = GetInventoryItemLink("player", invSlot)
            if itemLink then
                itemID = GetItemInfoInstant(itemLink)
                if itemID then
                    itemName, _, _, _, _, _, _, _, _, itemIcon = GetItemInfo(itemID)
                    -- Get the sourceID for this item
                    if C_TransmogCollection and C_TransmogCollection.GetItemInfo then
                        local ok, _, srcID = pcall(C_TransmogCollection.GetItemInfo, itemID)
                        if ok then sourceID = srcID end
                    end
                end
            end
        end
        
        if itemID and itemID > 0 then
            items[slotName] = {
                itemID = itemID,
                sourceID = sourceID,
                name = itemName or ("Item " .. itemID),
                icon = itemIcon,
            }
            pieceCount = pieceCount + 1
        end
    end
    
    if pieceCount == 0 then
        print("|cffff6600[ETB]|r No transmog appearances found to capture!")
        return
    end
    
    -- Store data for the popup's OnAccept handler
    TUI._pendingCapture = { items = items, pieceCount = pieceCount }
    StaticPopup_Show("ETBTUI_CAPTURE_OUTFIT")
end

-- =============================================================================
-- EXPORT/IMPORT DIALOGS
-- =============================================================================

function TUI:ShowExportDialog(outfitID)
    local outfit = ETBTUI_SavedOutfits[outfitID]
    if not outfit then return end
    
    local exportString = ExportSystem:ExportOutfit(outfit)
    if not exportString then
        print("|cffff6600[ETB]|r Failed to export outfit")
        return
    end
    
    -- Create export frame if needed
    if not self.exportFrame then
        local f = CreateFrame("Frame", "ETBTUIExportFrame", UIParent, "BackdropTemplate")
        f:SetSize(450, 200)
        f:SetPoint("CENTER")
        f:SetBackdrop({
            bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
            edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
            tile = true, tileSize = 32, edgeSize = 32,
            insets = { left = 8, right = 8, top = 8, bottom = 8 }
        })
        f:SetFrameStrata("DIALOG")
        f:EnableMouse(true)
        f:SetMovable(true)
        f:RegisterForDrag("LeftButton")
        f:SetScript("OnDragStart", f.StartMoving)
        f:SetScript("OnDragStop", f.StopMovingOrSizing)
        
        f.title = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
        f.title:SetPoint("TOP", 0, -15)
        f.title:SetText("|cff00ff00Export Outfit|r")
        
        f.scrollFrame = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
        f.scrollFrame:SetPoint("TOPLEFT", 15, -45)
        f.scrollFrame:SetPoint("BOTTOMRIGHT", -35, 50)
        
        f.editBox = CreateFrame("EditBox", nil, f.scrollFrame)
        f.editBox:SetMultiLine(true)
        f.editBox:SetFontObject("ChatFontNormal")
        f.editBox:SetWidth(380)
        f.editBox:SetAutoFocus(false)
        f.scrollFrame:SetScrollChild(f.editBox)
        
        f.closeBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
        f.closeBtn:SetSize(100, 24)
        f.closeBtn:SetPoint("BOTTOM", 0, 15)
        f.closeBtn:SetText("Close")
        f.closeBtn:SetScript("OnClick", function() f:Hide() end)
        
        f.copyHint = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        f.copyHint:SetPoint("BOTTOMLEFT", 15, 18)
        f.copyHint:SetText("|cff888888Ctrl+A to select all, Ctrl+C to copy|r")
        
        self.exportFrame = f
    end
    
    self.exportFrame.title:SetText(string.format("|cff00ff00Export:|r %s", outfit.name))
    self.exportFrame.editBox:SetText(exportString)
    self.exportFrame.editBox:HighlightText()
    self.exportFrame.editBox:SetFocus()
    self.exportFrame:Show()
end

function TUI:ShowImportDialog()
    -- Create import frame if needed
    if not self.importFrame then
        local f = CreateFrame("Frame", "ETBTUIImportFrame", UIParent, "BackdropTemplate")
        f:SetSize(450, 200)
        f:SetPoint("CENTER")
        f:SetBackdrop({
            bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
            edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
            tile = true, tileSize = 32, edgeSize = 32,
            insets = { left = 8, right = 8, top = 8, bottom = 8 }
        })
        f:SetFrameStrata("DIALOG")
        f:EnableMouse(true)
        f:SetMovable(true)
        f:RegisterForDrag("LeftButton")
        f:SetScript("OnDragStart", f.StartMoving)
        f:SetScript("OnDragStop", f.StopMovingOrSizing)
        
        f.title = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
        f.title:SetPoint("TOP", 0, -15)
        f.title:SetText("|cffaa88ffImport Outfit|r")
        
        f.scrollFrame = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
        f.scrollFrame:SetPoint("TOPLEFT", 15, -45)
        f.scrollFrame:SetPoint("BOTTOMRIGHT", -35, 50)
        
        f.editBox = CreateFrame("EditBox", nil, f.scrollFrame)
        f.editBox:SetMultiLine(true)
        f.editBox:SetFontObject("ChatFontNormal")
        f.editBox:SetWidth(380)
        f.editBox:SetAutoFocus(false)
        f.scrollFrame:SetScrollChild(f.editBox)
        
        f.importBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
        f.importBtn:SetSize(100, 24)
        f.importBtn:SetPoint("BOTTOMLEFT", 50, 15)
        f.importBtn:SetText("|cff00ff00Import|r")
        f.importBtn:SetScript("OnClick", function()
            local text = f.editBox:GetText()
            TUI:ImportOutfitFromString(text)
            f:Hide()
        end)
        
        f.closeBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
        f.closeBtn:SetSize(100, 24)
        f.closeBtn:SetPoint("BOTTOMRIGHT", -50, 15)
        f.closeBtn:SetText("Cancel")
        f.closeBtn:SetScript("OnClick", function() f:Hide() end)
        
        f.hint = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        f.hint:SetPoint("TOPLEFT", 15, -35)
        f.hint:SetText("|cff888888Paste an outfit string below:|r")
        
        self.importFrame = f
    end
    
    self.importFrame.editBox:SetText("")
    self.importFrame.editBox:SetFocus()
    self.importFrame:Show()
end

function TUI:ImportOutfitFromString(str)
    local outfit, err = ExportSystem:ImportOutfit(str)
    if not outfit then
        print(string.format("|cffff6600[ETB]|r Import failed: %s", err or "Unknown error"))
        return
    end
    
    -- Fetch item info for each item
    local pieceCount = 0
    for slot, item in pairs(outfit.items) do
        if item.itemID then
            local itemName, _, _, _, _, _, _, _, _, itemIcon = GetItemInfo(item.itemID)
            item.name = itemName
            item.icon = itemIcon
            pieceCount = pieceCount + 1
        end
    end
    
    -- Save the outfit
    local id = "imported_" .. time()
    ETBTUI_SavedOutfits[id] = {
        id = id,
        name = outfit.name or "Imported Outfit",
        icon = outfit.items.Chest and outfit.items.Chest.icon or "Interface\\Icons\\INV_Chest_Cloth_17",
        items = outfit.items,
        created = time(),
        pieceCount = pieceCount,
        importedFromString = true,
        armorType = outfit.armorType,
    }
    
    print(string.format("|cff00ff00[ETB]|r Imported: %s (%d pieces)", outfit.name, pieceCount))
    self:RefreshOutfits()
end

-- Import outfits from Blizzard's default UI
function TUI:ImportBlizzardOutfits(refreshMode)
    local imported = 0
    local updated = 0
    
    -- Check if C_TransmogCollection.GetOutfits exists (MoP Classic may not have this)
    if not C_TransmogCollection or not C_TransmogCollection.GetOutfits then
        print("|cffff6600[ETB]|r Blizzard outfit API not available in this version")
        return
    end
    
    local outfits = C_TransmogCollection.GetOutfits()
    if not outfits or #outfits == 0 then
        print("|cffff6600[ETB]|r No Blizzard outfits found to import")
        return
    end
    
    if refreshMode then
        print(string.format("|cff00ffff[ETB]|r Refreshing %d Blizzard outfits...", #outfits))
    else
        print(string.format("|cff00ffff[ETB]|r Scanning %d Blizzard outfits...", #outfits))
    end
    
    for _, outfitID in ipairs(outfits) do
        local name, icon = C_TransmogCollection.GetOutfitInfo(outfitID)
        if name then
            -- Find if already imported
            local existingID = nil
            for id, data in pairs(ETBTUI_SavedOutfits) do
                if data.blizzOutfitID == outfitID or (data.name == name and data.importedFromBlizz) then
                    existingID = id
                    break
                end
            end
            
            -- In normal import mode, skip existing outfits
            if not refreshMode and existingID then
                -- Skip, already imported
            else
                -- Get outfit items using helper function
                local items, pieceCount = self:GetBlizzardOutfitItems(outfitID)
                
                if refreshMode and existingID then
                    -- Update existing outfit
                    ETBTUI_SavedOutfits[existingID].items = items
                    ETBTUI_SavedOutfits[existingID].pieceCount = pieceCount
                    ETBTUI_SavedOutfits[existingID].icon = icon or ETBTUI_SavedOutfits[existingID].icon
                    ETBTUI_SavedOutfits[existingID].lastRefresh = time()
                    updated = updated + 1
                    
                    if pieceCount > 0 then
                        print(string.format("  |cff00ff00↻|r %s (%d pieces)", name, pieceCount))
                    else
                        print(string.format("  |cffffff00↻|r %s (updated, no gear data)", name))
                    end
                else
                    -- Create new outfit
                    local id = "blizz_" .. outfitID .. "_" .. time()
                    ETBTUI_SavedOutfits[id] = {
                        id = id,
                        name = name,
                        icon = icon or "Interface\\Icons\\INV_Chest_Cloth_17",
                        items = items,
                        created = time(),
                        pieceCount = pieceCount,
                        importedFromBlizz = true,
                        blizzOutfitID = outfitID,
                    }
                    imported = imported + 1
                    
                    if pieceCount > 0 then
                        print(string.format("  |cff00ff00✓|r %s (%d pieces)", name, pieceCount))
                    else
                        print(string.format("  |cffffff00!|r %s (name only - gear data unavailable)", name))
                    end
                end
            end
        end
    end
    
    if imported > 0 or updated > 0 then
        local msg = ""
        if imported > 0 then msg = msg .. string.format("Imported %d new", imported) end
        if updated > 0 then 
            if msg ~= "" then msg = msg .. ", " end
            msg = msg .. string.format("Updated %d existing", updated)
        end
        print(string.format("|cff00ff00[ETB]|r %s outfit(s)", msg))
        self:RefreshOutfits()
    else
        print("|cff888888[ETB]|r No changes (all outfits already up to date)")
    end
end

-- Helper to get outfit items from Blizzard API
function TUI:GetBlizzardOutfitItems(outfitID)
    local items = {}
    local pieceCount = 0
    local debugMode = false  -- Set true to debug
    
    -- MoP Classic has GetOutfitItemTransmogInfoList
    if C_TransmogCollection.GetOutfitItemTransmogInfoList then
        local ok, infoList = pcall(C_TransmogCollection.GetOutfitItemTransmogInfoList, outfitID)
        if ok and infoList then
            local debugCount = 0
            for slotID, info in pairs(infoList) do
                local sourceID, itemID
                
                -- The info table has appearanceID (visualID) - we need to convert to sourceID
                if type(info) == "table" and info.appearanceID and info.appearanceID > 0 then
                    -- Get sources for this appearance
                    local appSources = C_TransmogCollection.GetAppearanceSources(info.appearanceID)
                    
                    -- Debug: show what GetAppearanceSources returns
                    if debugMode and debugCount < 2 then
                        debugCount = debugCount + 1
                        if appSources then
                            print(string.format("  [DEBUG] Slot %d, appearanceID=%d:", slotID, info.appearanceID))
                            print(string.format("    GetAppearanceSources returned: %s with %d items", type(appSources), #appSources))
                            if #appSources > 0 then
                                local first = appSources[1]
                                if type(first) == "table" then
                                    local fields = {}
                                    for k, v in pairs(first) do
                                        table.insert(fields, tostring(k) .. "=" .. tostring(v))
                                    end
                                    print(string.format("    First source: {%s}", table.concat(fields, ", ")))
                                else
                                    print(string.format("    First source: %s (%s)", tostring(first), type(first)))
                                end
                            end
                        else
                            print(string.format("  [DEBUG] Slot %d: GetAppearanceSources returned nil", slotID))
                        end
                    end
                    
                    if appSources and #appSources > 0 then
                        -- Handle different possible structures
                        local firstSrc = appSources[1]
                        if type(firstSrc) == "table" then
                            -- Find a collected source, or use first one
                            for _, src in ipairs(appSources) do
                                if src.isCollected then
                                    sourceID = src.sourceID
                                    break
                                end
                            end
                            if not sourceID then
                                sourceID = firstSrc.sourceID
                            end
                        elseif type(firstSrc) == "number" then
                            -- Sources are just numbers
                            sourceID = firstSrc
                        end
                    end
                    
                    -- Get item info from sourceID
                    if sourceID and sourceID > 0 then
                        local sourceInfo = C_TransmogCollection.GetSourceInfo(sourceID)
                        if sourceInfo and sourceInfo.itemID then
                            itemID = sourceInfo.itemID
                        end
                    end
                    
                    -- Save if we got valid data
                    if itemID and itemID > 0 then
                        local slotName = INV_TO_SLOT_NAME[slotID]
                        if slotName then
                            local itemName, _, _, _, _, _, _, _, _, itemIcon = GetItemInfo(itemID)
                            items[slotName] = {
                                itemID = itemID,
                                sourceID = sourceID,
                                name = itemName or ("Item " .. itemID),
                                icon = itemIcon,
                                appearanceID = info.appearanceID,
                            }
                            pieceCount = pieceCount + 1
                        end
                    end
                end
            end
        end
    end
    
    -- Fallback: Try GetOutfitSourceList if available
    if pieceCount == 0 and C_TransmogCollection.GetOutfitSourceList then
        local ok, sourceList = pcall(C_TransmogCollection.GetOutfitSourceList, outfitID)
        if ok and sourceList then
            for slotID = 1, 19 do
                local sourceID = sourceList[slotID]
                if sourceID and sourceID > 0 then
                    local sourceInfo = C_TransmogCollection.GetSourceInfo(sourceID)
                    if sourceInfo and sourceInfo.itemID then
                        local slotName = INV_TO_SLOT_NAME[slotID]
                        if slotName then
                            local itemName, _, _, _, _, _, _, _, _, itemIcon = GetItemInfo(sourceInfo.itemID)
                            items[slotName] = {
                                itemID = sourceInfo.itemID,
                                sourceID = sourceID,
                                name = itemName or ("Item " .. sourceInfo.itemID),
                                icon = itemIcon,
                            }
                            pieceCount = pieceCount + 1
                        end
                    end
                end
            end
        end
    end
    
    return items, pieceCount
end

function TUI:ShowSaveOutfitDialog()
    local count = 0
    for _ in pairs(self.pendingTransmogs) do count = count + 1 end
    if count == 0 then print("|cffff6600[ETB]|r Nothing to save!"); return end
    StaticPopup_Show("ETBTUI_SAVE_OUTFIT")
end

function TUI:SaveOutfit(name)
    local items, pieceCount, firstIcon = {}, 0, nil
    for invSlot, data in pairs(self.pendingTransmogs) do
        local slotName = INV_TO_SLOT_NAME[invSlot]
        if slotName then
            items[slotName] = { itemID = data.itemID, name = data.name, icon = data.icon, sourceID = data.sourceID }
            pieceCount = pieceCount + 1
            if not firstIcon and data.icon then firstIcon = data.icon end
        end
    end
    
    local id = "outfit_" .. time() .. "_" .. math.random(1000, 9999)
    ETBTUI_SavedOutfits[id] = { id = id, name = name, icon = firstIcon or "Interface\\Icons\\INV_Chest_Cloth_17", items = items, created = time(), pieceCount = pieceCount }
    
    print(string.format("|cff00ff00[ETB]|r Saved outfit: %s (%d pieces)", name, pieceCount))
    self:RefreshOutfits()
end

function TUI:DeleteOutfit(outfitID)
    if ETBTUI_SavedOutfits[outfitID] then
        local name = ETBTUI_SavedOutfits[outfitID].name
        ETBTUI_SavedOutfits[outfitID] = nil
        self.selectedOutfitID = nil
        print(string.format("|cffff6600[ETB]|r Deleted: %s", name))
        self:RefreshOutfits()
    end
end

function TUI:RefreshOutfits()
    if not self.frame or not self.frame.outfitsPanel then return end
    local panel = self.frame.outfitsPanel
    local scrollChild = panel.scrollChild
    
    for _, btn in pairs(self.outfitButtons) do btn:Hide() end
    wipe(self.outfitButtons)
    
    local outfits = {}
    local showAccount = panel.accountToggle and panel.accountToggle.showAccount
    
    if showAccount then
        -- Show account-wide outfits (from ProfileManager)
        local usable = ProfileManager:GetOutfitsForCurrentClass()
        for id, data in pairs(usable) do 
            table.insert(outfits, { id = id, data = data, isAccount = true }) 
        end
    else
        -- Show character outfits
        for id, data in pairs(ETBTUI_SavedOutfits) do 
            table.insert(outfits, { id = id, data = data }) 
        end
    end
    
    table.sort(outfits, function(a, b) return (a.data.createdAt or a.data.created or 0) > (b.data.createdAt or b.data.created or 0) end)
    
    local rowH, rowS = 45, 3
    for i, entry in ipairs(outfits) do
        local btn = CreateFrame("Button", nil, scrollChild)
        btn:SetSize(260, rowH)
        btn:SetPoint("TOPLEFT", 5, -5 - (i - 1) * (rowH + rowS))
        
        btn.bg = btn:CreateTexture(nil, "BACKGROUND"); btn.bg:SetAllPoints()
        if entry.isAccount then
            btn.bg:SetColorTexture(0.1, 0.1, 0.2, 0.8)  -- Blue tint for account outfits
        else
            btn.bg:SetColorTexture(0.1, 0.1, 0.1, 0.8)
        end
        btn.selected = btn:CreateTexture(nil, "BACKGROUND", nil, 1); btn.selected:SetAllPoints(); btn.selected:SetColorTexture(0.15, 0.4, 0.15, 0.9); btn.selected:Hide()
        btn.highlight = btn:CreateTexture(nil, "HIGHLIGHT"); btn.highlight:SetAllPoints(); btn.highlight:SetColorTexture(0.2, 0.3, 0.2, 0.5)
        
        btn.icon = btn:CreateTexture(nil, "ARTWORK"); btn.icon:SetSize(38, 38); btn.icon:SetPoint("LEFT", 3, 0)
        local icon = entry.data.icon
        if not icon and entry.data.items then
            -- Try to get icon from first item
            for _, item in pairs(entry.data.items) do
                if item.icon then icon = item.icon; break end
            end
        end
        btn.icon:SetTexture(icon or "Interface\\Icons\\INV_Chest_Cloth_17"); btn.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        
        btn.name = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal"); btn.name:SetPoint("TOPLEFT", 45, -8)
        btn.name:SetText(entry.data.name); btn.name:SetWidth(200); btn.name:SetJustifyH("LEFT")
        
        -- Build info string
        local pieceCount = entry.data.pieceCount or 0
        if pieceCount == 0 and entry.data.items then
            for _ in pairs(entry.data.items) do pieceCount = pieceCount + 1 end
        end
        local infoText = string.format("|cff888888%d pieces|r", pieceCount)
        if entry.isAccount then
            infoText = infoText .. " |cff88aaff[Account]|r"
        elseif entry.data.importedFromBlizz then
            infoText = infoText .. " |cff00ffff[Blizz]|r"
        elseif entry.data.capturedFromGear then
            infoText = infoText .. " |cff00ff88[Captured]|r"
        elseif entry.data.importedFromString then
            infoText = infoText .. " |cffaa88ff[Imported]|r"
        end
        
        btn.info = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall"); btn.info:SetPoint("BOTTOMLEFT", 45, 8)
        btn.info:SetText(infoText)
        
        btn.outfitID = entry.id
        btn.isAccount = entry.isAccount
        btn:SetScript("OnClick", function(self) 
            TUI.selectedOutfitID = self.outfitID
            TUI.selectedIsAccount = self.isAccount
            TUI:RefreshOutfits() 
        end)
        btn:SetScript("OnDoubleClick", function(self) 
            TUI:LoadOutfitToPreview(self.outfitID, self.isAccount) 
        end)
        
        if self.selectedOutfitID == entry.id then btn.selected:Show(); btn.bg:Hide() end
        self.outfitButtons[i] = btn
    end
    
    scrollChild:SetHeight(math.max(1, #outfits * (rowH + rowS) + 10))
    local typeLabel = showAccount and "account" or "character"
    panel.stats:SetText(string.format("|cff888888%d %s outfits|r", #outfits, typeLabel))
end

function TUI:LoadOutfitToPreview(outfitID, isAccount)
    local outfit
    if isAccount then
        outfit = ProfileManager.accountOutfits[outfitID]
    else
        outfit = ETBTUI_SavedOutfits[outfitID]
    end
    if not outfit then return end
    
    wipe(self.pendingTransmogs)
    self.frame.model:Undress()
    
    for slotName, itemData in pairs(outfit.items) do
        local invSlot = SLOT_NAME_TO_INV[slotName]
        if invSlot and itemData and itemData.itemID then
            self.frame.model:TryOn("item:" .. itemData.itemID)
            self.pendingTransmogs[invSlot] = { itemID = itemData.itemID, name = itemData.name, icon = itemData.icon, sourceID = itemData.sourceID }
            if itemData.sourceID then self.itemIDToSourceID[itemData.itemID] = itemData.sourceID end
        end
    end
    
    -- Auto-hide armor slots not covered by the outfit
    local AUTO_HIDE_SLOTS = { [1]=true, [3]=true, [5]=true, [6]=true, [7]=true, [8]=true, [9]=true, [10]=true, [15]=true }
    for invSlot, _ in pairs(AUTO_HIDE_SLOTS) do
        if not self.pendingTransmogs[invSlot] then
            self.pendingTransmogs[invSlot] = {
                isHiddenSlot = true,
                name = "Hidden",
                icon = "Interface\\Icons\\Spell_Shadow_SacrificialShield",
            }
        end
    end
    
    self:UpdateSlotButtonIcons()
    self:UpdateCost()
    
    -- Push entire outfit to Blizzard API so both UIs match
    self:SyncToBlizzardPending()
    
    ETB:Debug("TransmogUI", string.format("Loaded outfit: %s", outfit.name))
end

-- =============================================================================
-- BOTTOM BAR
-- =============================================================================

function TUI:CreateBottomBar(parent)
    local bar = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    bar:SetSize(FRAME_WIDTH - 20, 28)
    bar:SetPoint("BOTTOM", 0, 2)
    bar:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 12,
        insets = { left = 4, right = 4, top = 4, bottom = 4 }
    })
    
    -- Left side: cost + pending
    bar.costLabel = bar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    bar.costLabel:SetPoint("LEFT", 14, 2)
    bar.costLabel:SetText("|cffffd100Cost:|r")
    
    bar.costValue = bar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    bar.costValue:SetPoint("LEFT", bar.costLabel, "RIGHT", 4, 0)
    bar.costValue:SetText("0 |TInterface\\MoneyFrame\\UI-GoldIcon:14:14:0:0|t")
    
    bar.pendingLabel = bar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    bar.pendingLabel:SetPoint("LEFT", bar.costValue, "RIGHT", 12, 0)
    bar.pendingLabel:SetText("|cff8888880 changes pending|r")
    
    -- Right side: Reset / Apply / Close
    bar.closeBtn = CreateFrame("Button", nil, bar, "UIPanelButtonTemplate")
    bar.closeBtn:SetSize(65, 20)
    bar.closeBtn:SetPoint("RIGHT", -12, 0)
    bar.closeBtn:SetText("Close")
    bar.closeBtn:SetScript("OnClick", function()
        if TUI.blizzardTransmogFrame then TUI:SwitchToBlizzardUI() else TUI:Hide() end
    end)
    
    bar.applyBtn = CreateFrame("Button", nil, bar, "UIPanelButtonTemplate")
    bar.applyBtn:SetSize(75, 20)
    bar.applyBtn:SetPoint("RIGHT", bar.closeBtn, "LEFT", -4, 0)
    bar.applyBtn:SetText("|cff00ff88Apply|r")
    bar.applyBtn:SetScript("OnClick", function() TUI:ApplyTransmogs() end)
    
    bar.resetBtn = CreateFrame("Button", nil, bar, "UIPanelButtonTemplate")
    bar.resetBtn:SetSize(65, 20)
    bar.resetBtn:SetPoint("RIGHT", bar.applyBtn, "LEFT", -4, 0)
    bar.resetBtn:SetText("Reset")
    bar.resetBtn:SetScript("OnClick", function() TUI:Reset() end)
    
    parent.bottomBar = bar
end

function TUI:UpdateCost()
    if not self.frame or not self.frame.bottomBar then return end
    local count = 0
    for _ in pairs(self.pendingTransmogs) do count = count + 1 end
    
    -- Use Blizzard API for accurate cost if available
    local totalCost = 0
    if C_Transmog and C_Transmog.GetCost then
        local ok, cost = pcall(C_Transmog.GetCost)
        if ok and cost then
            totalCost = math.floor(cost / 10000)  -- Convert copper to gold
        else
            totalCost = count * 10  -- Fallback: 10g per slot
        end
    else
        totalCost = count * 10
    end
    
    self.frame.bottomBar.costValue:SetText(string.format("%d |TInterface\\MoneyFrame\\UI-GoldIcon:14:14:0:0|t", totalCost))
    self.frame.bottomBar.pendingLabel:SetText(string.format("|cff888888%d change%s pending|r", count, count == 1 and "" or "s"))
end

function TUI:Reset()
    wipe(self.pendingTransmogs)
    self:ClearBlizzardPending()
    
    if self.frame.model then 
        -- Dress re-applies current gear WITHOUT reloading the 3D model
        -- SetUnit was reloading the entire character mesh causing graphics dump
        self.frame.model:Dress()
        self.frame.model.zoomLevel = 0
        self.frame.model:SetCamDistanceScale(0.9)
    end
    
    -- Reset slot buttons to default icons 
    for invSlot, btn in pairs(self.frame.slotButtons or {}) do
        btn.icon:SetTexture(self:GetDefaultSlotIcon(invSlot))
        btn.pendingBorder:Hide()
        btn.pendingInner:Hide()
        btn.hiddenBorder:Hide()
        btn.isHidden = false
        btn:SetBackdropBorderColor(0.4, 0.4, 0.4, 1)
    end
    
    self:UpdateCost()
end

-- =============================================================================
-- APPLY TRANSMOGS
-- =============================================================================

function TUI:ApplyTransmogs()
    local count = 0; for _ in pairs(self.pendingTransmogs) do count = count + 1 end
    if count == 0 then print("|cffff6600[ETB]|r No transmogs pending!"); return end
    
    local atNPC = self:CanApplyTransmog()
    if not atNPC then print("|cffff6600[ETB]|r Must be at Transmogrifier NPC!"); return end
    if not C_Transmog or not C_Transmog.SetPending or not C_Transmog.ApplyAllPending then 
        print("|cffff6600[ETB]|r Transmog API not available."); return 
    end
    
    local verified = 0
    local notCollected = 0
    local unusable = 0
    local failed = 0
    local verifiedSlots = {}
    local notCollectedSlots = {}
    local unusableSlots = {}
    local failedSlots = {}
    
    for invSlot, pending in pairs(self.pendingTransmogs) do
        local slotID = self:GetTransmogSlotID(invSlot)
        local slotName = INV_TO_SLOT_NAME[invSlot] or ("Slot " .. invSlot)
        local itemID = pending.itemID
        local itemName = pending.name or ("item " .. tostring(itemID))
        
        if pending.isHiddenSlot then
            -- Hidden slot: apply hidden-appearance sourceID
            local ok, result = self:CallSetPendingHidden(slotID)
            if ok then
                verified = verified + 1
                table.insert(verifiedSlots, slotName .. " (hidden:" .. result .. ")")
                print(string.format("|cff00ff00[ETB]|r %s: HIDDEN via %s", slotName, result))
            else
                failed = failed + 1
                table.insert(failedSlots, slotName)
                print(string.format("|cffff6600[ETB]|r %s: HIDE FAILED - %s", slotName, result or "unknown"))
            end
        elseif not itemID then
            -- No itemID at all - use legacy sourceID path
            local sourceID = pending.sourceID
            if sourceID then
                local ok, result = self:CallSetPending(slotID, sourceID)
                if result == "VERIFIED" then
                    verified = verified + 1
                    table.insert(verifiedSlots, slotName)
                    print(string.format("|cff00ff00[ETB]|r %s: VERIFIED (src=%d)", slotName, sourceID))
                else
                    failed = failed + 1
                    table.insert(failedSlots, slotName)
                    print(string.format("|cffff6600[ETB]|r %s: FAILED - %s", slotName, result))
                end
            else
                failed = failed + 1
                table.insert(failedSlots, slotName .. "(no src)")
            end
        else
            -- Use the full collected-source-with-verification flow
            local ok, result, sourceID = self:TryApplyWithCollectedSources(slotID, itemID)
            if result == "VERIFIED" then
                verified = verified + 1
                table.insert(verifiedSlots, slotName)
                print(string.format("|cff00ff00[ETB]|r %s: VERIFIED (src=%d)", slotName, sourceID))
            elseif result == "NOT_COLLECTED" then
                notCollected = notCollected + 1
                table.insert(notCollectedSlots, slotName)
                print(string.format("|cffff6600[ETB]|r %s: NOT COLLECTED - %s", slotName, itemName))
            elseif result == "COLLECTED_BUT_UNUSABLE" then
                unusable = unusable + 1
                table.insert(unusableSlots, slotName)
                print(string.format("|cffff9900[ETB]|r %s: COLLECTED but unusable on this character - %s", slotName, itemName))
            else
                failed = failed + 1
                table.insert(failedSlots, slotName)
                print(string.format("|cffff6600[ETB]|r %s: FAILED - %s", slotName, result))
            end
        end
    end
    
    -- Summary
    local summaryParts = {}
    if verified > 0 then table.insert(summaryParts, string.format("|cff00ff00%d applied|r", verified)) end
    if unusable > 0 then table.insert(summaryParts, string.format("|cffff9900%d unusable|r", unusable)) end
    if notCollected > 0 then table.insert(summaryParts, string.format("|cffff6600%d not collected|r", notCollected)) end
    if failed > 0 then table.insert(summaryParts, string.format("|cffff0000%d failed|r", failed)) end
    print(string.format("|cff00ffff[ETB Summary]|r %s (%d/%d)", table.concat(summaryParts, ", "), verified, count))
    
    -- Apply all pending
    if verified > 0 then
        local ok, err = pcall(C_Transmog.ApplyAllPending)
        if ok then
            print(string.format("|cff00ff00[ETB]|r Applied %d transmog(s): %s", 
                verified, table.concat(verifiedSlots, ", ")))
        else
            print(string.format("|cffff6600[ETB]|r ApplyAllPending failed: %s", tostring(err)))
        end
        self:ClearPendingAfterApply()
    else
        if unusable > 0 then
            print("|cffff9900[ETB]|r Appearances are collected account-wide but not usable on this character (wrong armor type or class restriction).")
        elseif notCollected > 0 then
            print(string.format("|cffff6600[ETB]|r None of these appearances are in your collection yet."))
        else
            print("|cffff6600[ETB]|r No transmogs could be applied.")
        end
    end
end

function TUI:ApplyTransmogsArmor()
    local count = 0; for invSlot in pairs(self.pendingTransmogs) do if ARMOR_SLOTS[invSlot] then count = count + 1 end end
    if count == 0 then print("|cffff6600[ETB]|r No armor pending!"); return end
    if not self:CanApplyTransmog() then print("|cffff6600[ETB]|r Must be at NPC!"); return end
    
    local applied = 0
    local skipped = 0
    for invSlot, pending in pairs(self.pendingTransmogs) do
        if ARMOR_SLOTS[invSlot] then
            local itemID = pending.itemID
            if itemID then
                local ok, result, sourceID = self:TryApplyWithCollectedSources(self:GetTransmogSlotID(invSlot), itemID)
                if result == "VERIFIED" then applied = applied + 1 else skipped = skipped + 1 end
            else
                local sourceID = pending.sourceID
                if sourceID then
                    local ok = self:CallSetPending(self:GetTransmogSlotID(invSlot), sourceID)
                    if ok then applied = applied + 1 else skipped = skipped + 1 end
                else skipped = skipped + 1 end
            end
        end
    end
    if applied > 0 then 
        pcall(C_Transmog.ApplyAllPending)
        local msg = string.format("|cff00ff00[ETB]|r Applied %d armor!", applied)
        if skipped > 0 then msg = msg .. string.format(" (%d skipped - not usable)", skipped) end
        print(msg)
        self:ClearPendingAfterApply() 
    elseif skipped > 0 then
        print(string.format("|cffff6600[ETB]|r No armor applied. %d piece(s) not usable on this character.", skipped))
    end
end

function TUI:ApplyTransmogsWeapons()
    local count = 0; for invSlot in pairs(self.pendingTransmogs) do if WEAPON_SLOTS[invSlot] then count = count + 1 end end
    if count == 0 then print("|cffff6600[ETB]|r No weapons pending!"); return end
    if not self:CanApplyTransmog() then print("|cffff6600[ETB]|r Must be at NPC!"); return end
    
    local applied = 0
    local skipped = 0
    for invSlot, pending in pairs(self.pendingTransmogs) do
        if WEAPON_SLOTS[invSlot] then
            local itemID = pending.itemID
            if itemID then
                local ok, result, sourceID = self:TryApplyWithCollectedSources(self:GetTransmogSlotID(invSlot), itemID)
                if result == "VERIFIED" then applied = applied + 1 else skipped = skipped + 1 end
            else
                local sourceID = pending.sourceID
                if sourceID then
                    local ok = self:CallSetPending(self:GetTransmogSlotID(invSlot), sourceID)
                    if ok then applied = applied + 1 else skipped = skipped + 1 end
                else skipped = skipped + 1 end
            end
        end
    end
    if applied > 0 then 
        pcall(C_Transmog.ApplyAllPending)
        local msg = string.format("|cff00ff00[ETB]|r Applied %d weapon(s)!", applied)
        if skipped > 0 then msg = msg .. string.format(" (%d skipped - not usable)", skipped) end
        print(msg)
        self:ClearPendingAfterApply() 
    elseif skipped > 0 then
        print(string.format("|cffff6600[ETB]|r No weapons applied. %d not usable on this character.", skipped))
    end
end

function TUI:ClearPendingAfterApply()
    wipe(self.pendingTransmogs)
    for _, btn in pairs(self.frame.slotButtons) do btn.pendingBorder:Hide(); btn.pendingInner:Hide() end
    
    -- DON'T reset model - keep showing what was just applied
    -- The TryOn() items will stay on the model
    
    self:UpdateCost()
end

-- =============================================================================
-- SHOW/HIDE/TOGGLE
-- =============================================================================

function TUI:Show()
    if not self.frame then 
        self:Create() 
    end
    
    -- Reset item refresh counter for this session
    self._itemRefreshCount = 0
    self._itemRefreshTimer = nil
    
    self.frame:Show()
    
    -- TSM-style: when flipping from Blizzard, sync their pending so we don't lose selection
    if self.blizzardTransmogFrame and self:SyncFromBlizzardPending() then
        self:UpdateCost()
        -- UpdateSlotButtonIcons handles both normal and hidden slots
        self:UpdateSlotButtonIcons()
        -- Mirror Blizzard model: re-dress player then apply all non-hidden pending transmogs
        if self.frame.model then
            self.frame.model:Dress()
            for _, pend in pairs(self.pendingTransmogs) do
                if pend.itemID and not pend.isHiddenSlot then
                    self.frame.model:TryOn("item:" .. pend.itemID)
                end
            end
        end
    else
        self:Reset()
        -- Ensure model shows current player appearance
        if self.frame.model then
            self.frame.model:Dress()
        end
    end
    
    self:RefreshOutfits()
    if not self.selectedSlot then self.selectedSlot = 1; self.selectedCategoryID = 1 end
    self:UpdateSlotButtonIcons()
    self:UpdateCategoryBarHighlight()
    self:UpdateCurrentViewingLabel()
    self:RefreshBrowser()
    self:UpdateCost()
end

function TUI:Hide()
    if self.frame then self.frame:Hide() end
    self:HideHoverPreview()
    if self.floatingButton then self.floatingButton:Hide() end
    
    -- Free memory: wipe item rows data and item cache
    if self.itemRows then wipe(self.itemRows) end
    if self.itemCache then wipe(self.itemCache) end
    
    -- Trim BlizzardData caches
    local ETB = GetETB()
    if ETB and ETB.BlizzardData then
        if ETB.BlizzardData.cache then
            wipe(ETB.BlizzardData.cache.categories)
            wipe(ETB.BlizzardData.cache.sources)
        end
    end
    
    collectgarbage("collect")
end

function TUI:Toggle()
    if self.frame and self.frame:IsShown() then self:Hide() else self:Show() end
end

-- =============================================================================
-- STATIC POPUP DEFINITIONS (at load time to prevent taint)
-- Never define StaticPopupDialogs inside callbacks - it taints secure paths
-- =============================================================================

StaticPopupDialogs["ETBTUI_CAPTURE_OUTFIT"] = {
    text = "Name your captured outfit:",
    button1 = "Save", button2 = "Cancel",
    hasEditBox = true, maxLetters = 32,
    OnAccept = function(self)
        local eb = self.editBox or self.EditBox
        local name = eb and eb:GetText() or "Captured"
        if name ~= "" and TUI._pendingCapture then
            local cap = TUI._pendingCapture
            local id = "captured_" .. time()
            local _, classID = UnitClass("player")
            ETBTUI_SavedOutfits = ETBTUI_SavedOutfits or {}
            ETBTUI_SavedOutfits[id] = {
                id = id, name = name,
                icon = cap.items.Chest and cap.items.Chest.icon or "Interface\\Icons\\INV_Chest_Cloth_17",
                items = cap.items, created = time(), pieceCount = cap.pieceCount,
                capturedFromGear = true, classID = classID,
            }
            print(string.format("|cff00ff00[ETB]|r Captured: %s (%d pieces)", name, cap.pieceCount))
            TUI:RefreshOutfits()
            TUI._pendingCapture = nil
        end
    end,
    OnShow = function(self)
        local eb = self.editBox or self.EditBox
        if eb then eb:SetText("My Look"); eb:HighlightText(); eb:SetFocus() end
    end,
    timeout = 0, whileDead = true, hideOnEscape = true, preferredIndex = 3,
}

StaticPopupDialogs["ETBTUI_SAVE_OUTFIT"] = {
    text = "Enter outfit name:",
    button1 = "Save", button2 = "Cancel",
    hasEditBox = true, maxLetters = 32,
    OnAccept = function(self)
        local eb = self.editBox or self.EditBox
        local name = eb and eb:GetText() or "New"
        if name ~= "" then TUI:SaveOutfit(name) end
    end,
    OnShow = function(self)
        local eb = self.editBox or self.EditBox
        if eb then eb:SetText("My Outfit"); eb:HighlightText(); eb:SetFocus() end
    end,
    EditBoxOnEnterPressed = function(self)
        local p = self:GetParent()
        local eb = p.editBox or p.EditBox
        local name = eb and eb:GetText() or "New"
        if name ~= "" then TUI:SaveOutfit(name) end
        p:Hide()
    end,
    timeout = 0, whileDead = true, hideOnEscape = true, preferredIndex = 3,
}

-- Export to ETB namespace only — no globals to avoid taint
local ETB = GetETB()
if ETB then
    -- v12.45.1: preserve submodule registrations.
    --
    -- Prior to v12.45.1, this just did `ETB.TransmogUI = TUI`, which silently
    -- CLOBBERED the namespace table that TransmogUI submodules (Preview,
    -- CategoryBar, ItemRows) register themselves into. Preview and CategoryBar
    -- delegators all use `NS.ETB.TransmogUI and NS.ETB.TransmogUI.Preview` —
    -- defensive `and` guards made them silently fail rather than crash, so
    -- hover previews and category bar probably weren't actually working at
    -- all in v12.40.0+ (just invisible, not reported).
    --
    -- ItemRows delegator didn't use the defensive guard, which is how this
    -- bug was finally discovered: Josh installed v12.45.0 and got a hard
    -- crash on first browser open.
    --
    -- Fix: merge any submodule fields that were registered on ETB.TransmogUI
    -- before this line runs into TUI, so TUI ends up serving both roles —
    -- as the singleton (ETB.TransmogUI:Toggle()) and as the submodule
    -- namespace (ETB.TransmogUI.ItemRows:Create(...)).
    local prior = ETB.TransmogUI
    if type(prior) == "table" then
        for k, v in pairs(prior) do
            if TUI[k] == nil then
                TUI[k] = v
            end
        end
    end
    ETB.TransmogUI = TUI
end
NS.TransmogUI = TUI
