--[[
============================================================================
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 or _G.ETB
end

-- 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 = "",
    hoverPreviewFrame = nil,
    selectedOutfitID = nil,
    itemCache = {},
    blizzardTransmogFrame = nil,
    transmogSlotIDs = {},
    itemIDToSourceID = {},
    categoryBarIcons = {},
}

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

local FRAME_WIDTH = 1150
local FRAME_HEIGHT = 820  -- Taller to fit category bar
local CATEGORY_BAR_HEIGHT = 88

local ROW_HEIGHT = 40
local ROW_SPACING = 2

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 (from prototype)
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 from MogIt v5.5.3 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 (MogIt colors)
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 (WeakAuras-style 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
local CATEGORY_TO_INVSLOT = {
    [1]=1, [2]=3, [3]=15, [4]=5, [5]=4, [6]=19, [7]=9, [8]=10, [9]=6, [10]=7, [11]=8,
    [12]=16, [13]=16, [14]=16, [15]=16, [16]=16, [17]=16,
    [18]=17, [19]=17,
    [20]=16, [21]=16, [22]=16, [23]=16, [24]=16,
    [25]=16, [26]=16, [27]=16,
}

local CATEGORY_ICONS = {
    [1]="Interface\\Icons\\INV_Helmet_44", [2]="Interface\\Icons\\INV_Shoulder_22", [3]="Interface\\Icons\\INV_Misc_Cape_18",
    [4]="Interface\\Icons\\INV_Chest_Chain_05", [5]="Interface\\Icons\\INV_Shirt_01", [6]="Interface\\Icons\\INV_Shirt_GuildTabard_01",
    [7]="Interface\\Icons\\INV_Bracer_07", [8]="Interface\\Icons\\INV_Gauntlets_24", [9]="Interface\\Icons\\INV_Belt_13",
    [10]="Interface\\Icons\\INV_Pants_06", [11]="Interface\\Icons\\INV_Boots_05",
    [12]="Interface\\Icons\\INV_Wand_01", [13]="Interface\\Icons\\INV_Axe_01", [14]="Interface\\Icons\\INV_Sword_04",
    [15]="Interface\\Icons\\INV_Mace_01", [16]="Interface\\Icons\\INV_Weapon_ShortBlade_05", [17]="Interface\\Icons\\INV_Gauntlets_04",
    [18]="Interface\\Icons\\INV_Shield_04", [19]="Interface\\Icons\\INV_Misc_Orb_01",
    [20]="Interface\\Icons\\INV_Axe_09", [21]="Interface\\Icons\\INV_Sword_27", [22]="Interface\\Icons\\INV_Mace_15",
    [23]="Interface\\Icons\\INV_Staff_08", [24]="Interface\\Icons\\INV_Spear_04",
    [25]="Interface\\Icons\\INV_Weapon_Bow_05", [26]="Interface\\Icons\\INV_Weapon_Rifle_01", [27]="Interface\\Icons\\INV_Weapon_Crossbow_01",
}

local CATEGORY_NAMES = {
    [1]="Head", [2]="Shoulder", [3]="Back", [4]="Chest", [5]="Shirt", [6]="Tabard", [7]="Wrist", [8]="Hands", [9]="Waist", [10]="Legs", [11]="Feet",
    [12]="Wands", [13]="1H Axes", [14]="1H Swords", [15]="1H Maces", [16]="Daggers", [17]="Fist Weapons",
    [18]="Shields", [19]="Off-Hands", [20]="2H Axes", [21]="2H Swords", [22]="2H Maces", [23]="Staves", [24]="Polearms",
    [25]="Bows", [26]="Guns", [27]="Crossbows",
}

-- 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", "ETBFloatingModeButton", 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
                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)
                    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)
    
    -- Collect all top-level Blizzard transmog frames to minimize
    local framesToHide = { frame }
    for _, frameName in ipairs(BLIZZARD_TRANSMOG_FRAMES) do
        local f = _G[frameName]
        if f and f.SetScale then
            -- Avoid duplicates
            local found = false
            for _, existing in ipairs(framesToHide) do
                if existing == f then found = true; break end
            end
            if not found then
                framesToHide[#framesToHide + 1] = f
            end
        end
    end
    
    -- Also get TransmogrifyFrame if not already in the list
    local txFrame = _G.TransmogrifyFrame
    if txFrame and txFrame.SetScale then
        local found = false
        for _, existing in ipairs(framesToHide) do
            if existing == txFrame then found = true; break end
        end
        if not found then
            framesToHide[#framesToHide + 1] = txFrame
        end
    end
    
    -- Save state and minimize each frame
    for _, f in ipairs(framesToHide) do
        local point, relativeTo, relativePoint, xOfs, yOfs = f:GetPoint(1)
        self._hiddenBlizzState[#self._hiddenBlizzState + 1] = {
            frame = f,
            scale = f:GetScale(),
            alpha = f:GetAlpha(),
            point = point,
            relativeTo = relativeTo,
            relativePoint = relativePoint,
            xOfs = xOfs,
            yOfs = yOfs,
        }
        
        -- SetScale cascades to ALL children — this is the key
        f:SetScale(0.001)
        f:SetAlpha(0)
        f:ClearAllPoints()
        f:SetPoint("TOPLEFT", UIParent, "TOPLEFT", -5000, 5000)
        if f.EnableMouse then f:EnableMouse(false) end
    end
    
    -- 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
            f:SetScale(entry.scale or 1)
            f:SetAlpha(entry.alpha or 1)
            f:ClearAllPoints()
            if entry.point then
                f:SetPoint(entry.point, entry.relativeTo or UIParent, entry.relativePoint or entry.point, entry.xOfs or 0, entry.yOfs or 0)
            else
                f:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
            end
            if f.EnableMouse then f:EnableMouse(true) end
        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.
function TUI:CallSetPendingHidden(slotID)
    if not C_Transmog or not C_Transmog.SetPending then return false, "no API" end
    
    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
                    return true, "ToggleOff"
                end
            end
        end
    end
    
    return false, "HIDDEN_FAILED"
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
    
    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
    
    -- Get icon even if name isn't available yet (GetItemIcon is sync)
    local itemIcon = GetItemIcon(itemID)
    
    -- Try ATT for instant name when Blizzard API hasn't cached yet
    local attName = nil
    if AllTheThings and AllTheThings.SearchForFieldContainer then
        local container = AllTheThings.SearchForFieldContainer("itemID")
        if container then
            local entries = container[itemID]
            if entries then
                attName = (entries.name or entries.text)
                    or (entries[1] and (entries[1].name or entries[1].text))
            end
        end
    end
    
    -- Store placeholder with icon - will be updated by GET_ITEM_INFO_RECEIVED
    self.itemCache[itemID] = { 
        itemID = itemID, 
        name = attName or "Loading...", 
        quality = 1, 
        icon = itemIcon or "Interface\\Icons\\INV_Misc_QuestionMark" 
    }
    
    -- Trigger async load
    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", "GameFontNormalLarge")
    parent.title:SetPoint("TOP", 0, -18)
    parent.title:SetText("|cffffd100Enhanced Transmogrification|r")
    
    parent.subtitle = parent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    parent.subtitle:SetPoint("TOP", parent.title, "BOTTOM", 0, -2)
    parent.subtitle:SetText("|cff00ff00v0.16.2|r |cff888888- Verified Source Resolution|r")
    
    parent.closeBtn = CreateFrame("Button", nil, parent, "UIPanelCloseButton")
    parent.closeBtn:SetPoint("TOPRIGHT", -8, -8)
    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(90, 22)
    parent.defaultUIBtn:SetPoint("TOPRIGHT", -35, -12)
    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")
    panel:SetSize(260, 520)
    panel:SetPoint("TOP", 0, -55)
    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(240, 440)
    model:SetPoint("TOP", 0, -30)
    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
    
    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 Preview button
    local resetBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    resetBtn:SetSize(100, 20)
    resetBtn:SetPoint("BOTTOM", 0, 26)
    resetBtn:SetText("Reset Preview")
    resetBtn:SetScript("OnClick", function()
        if TUI.frame.model then
            TUI.frame.model:Dress()
            TUI.frame.model.zoomLevel = 0
            TUI.frame.model:SetCamDistanceScale(1)
            -- Re-apply any pending transmogs
            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 hint = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    hint:SetPoint("BOTTOM", 0, 8)
    hint:SetText("|cff888888Drag to rotate • Scroll to zoom|r")
    
    parent.modelPanel = panel
    parent.model = model
end

function TUI:CreateSlotButtons(parent)
    parent.slotButtons = {}
    
    local buttonHeight = 36
    local buttonWidth = 36
    local spacing = 6
    local startY = -80  -- Start below the category bar
    
    for _, slotInfo in ipairs(SLOTS) do
        local btn = CreateFrame("Button", nil, parent, "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.modelPanel, "TOPLEFT", -8, yOffset)
        elseif slotInfo.side == "right" then
            btn:SetPoint("TOPLEFT", parent.modelPanel, "TOPRIGHT", 8, yOffset)
        elseif slotInfo.side == "center" then
            -- Center slots positioned below the model panel, side by side
            local centerX = (slotInfo.row == 1) and -(buttonWidth/2 + 3) or (buttonWidth/2 + 3)
            btn:SetPoint("TOP", parent.modelPanel, "BOTTOM", centerX, -6)
        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", -3, 3)
        btn.pendingBorder:SetPoint("BOTTOMRIGHT", 3, -3)
        btn.pendingBorder:SetColorTexture(1, 0.82, 0, 0.8)
        btn.pendingBorder:Hide()
        
        btn.pendingInner = btn:CreateTexture(nil, "OVERLAY", nil, 3)
        btn.pendingInner:SetAllPoints()
        btn.pendingInner:SetColorTexture(0.15, 0.12, 0.05, 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", -3, 3)
        btn.hiddenBorder:SetPoint("BOTTOMRIGHT", 3, -3)
        btn.hiddenBorder:SetColorTexture(0.8, 0.2, 0.2, 0.8)
        btn.hiddenBorder:Hide()
        
        -- Icon (shows pending transmog or default slot icon)
        btn.icon = btn:CreateTexture(nil, "ARTWORK")
        btn.icon:SetSize(28, 28)
        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("|cffff6666HIDDEN|r", 1, 1, 1)
                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(" ")
            GameTooltip:AddLine("|cffaaaaaClick to hide/show|r", 0.7, 0.7, 0.7)
            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:SetText("|cff00ff00Appearances|r")
    self.frame.browserPanel.setsTab:SetText("Sets")
    if self.frame.browserPanel.incompleteTab then
        self.frame.browserPanel.incompleteTab:SetText("Incomplete")
    end
    
    self:RefreshBrowser()
end

-- =============================================================================
-- CATEGORY BAR
-- =============================================================================

function TUI:CreateCategoryBar(parent)
    local bar = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    bar:SetSize(FRAME_WIDTH - 30, CATEGORY_BAR_HEIGHT)
    bar:SetPoint("BOTTOM", parent, "BOTTOM", 0, 70)
    bar:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 10,
        insets = { left = 4, right = 4, top = 4, bottom = 4 }
    })
    parent.categoryBar = bar
    
    bar.currentViewingLabel = bar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    bar.currentViewingLabel:SetPoint("TOPLEFT", 10, -6)
    bar.currentViewingLabel:SetText("|cff00ff00Currently Viewing:|r |cffffd100Select a slot|r")
    
    local function getIcon(catID)
        local ETB = GetETB()
        if ETB and ETB.CATEGORY_INFO and ETB.CATEGORY_INFO[catID] then return ETB.CATEGORY_INFO[catID].icon end
        return CATEGORY_ICONS[catID] or "Interface\\Icons\\INV_Misc_QuestionMark"
    end
    
    local function getName(catID)
        local ETB = GetETB()
        if ETB and ETB.CATEGORY_INFO and ETB.CATEGORY_INFO[catID] then return ETB.CATEGORY_INFO[catID].name end
        return CATEGORY_NAMES[catID] or ("Category " .. catID)
    end
    
    local CB_SIZE, CB_SPACING = 24, 26
    
    local function makeCategoryButton(catID, x, y)
        local btn = CreateFrame("Button", nil, bar)
        btn:SetSize(CB_SIZE, CB_SIZE)
        btn:SetPoint("TOPLEFT", bar, "TOPLEFT", x, y)
        
        btn.icon = btn:CreateTexture(nil, "ARTWORK")
        btn.icon:SetAllPoints()
        btn.icon:SetTexture(getIcon(catID))
        btn.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        
        btn.selected = btn:CreateTexture(nil, "OVERLAY")
        btn.selected:SetAllPoints()
        btn.selected:SetColorTexture(0, 1, 0, 0.45)
        btn.selected:Hide()
        
        btn:SetHighlightTexture("Interface\\Buttons\\ButtonHilight-Square", "ADD")
        
        btn:SetScript("OnEnter", function(b)
            GameTooltip:SetOwner(b, "ANCHOR_TOP")
            GameTooltip:SetText(getName(catID))
            GameTooltip:Show()
        end)
        btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
        
        btn:SetScript("OnClick", function()
            TUI.selectedCategoryID = catID
            local invSlot = CATEGORY_TO_INVSLOT[catID]
            if invSlot then
                TUI.selectedSlot = invSlot
                TUI.selectedTab = "appearances"
            end
            TUI:UpdateCategoryBarHighlight()
            TUI:UpdateCurrentViewingLabel()
            TUI.frame.browserPanel.title:SetText("|cffffd100" .. getName(catID) .. " Appearances|r")
            TUI.frame.browserPanel.appearancesTab:SetText("|cff00ff00Appearances|r")
            TUI.frame.browserPanel.setsTab:SetText("Sets")
            if TUI.frame.browserPanel.incompleteTab then
                TUI.frame.browserPanel.incompleteTab:SetText("Incomplete")
            end
            TUI:RefreshBrowser()
        end)
        
        btn.categoryID = catID
        TUI.categoryBarIcons[catID] = btn
        return btn
    end
    
    -- Armor row
    local armorLabel = bar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    armorLabel:SetPoint("TOPLEFT", 10, -26)
    armorLabel:SetText("|cffffd100Armor:|r")
    for i, catID in ipairs({ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }) do
        makeCategoryButton(catID, 55 + (i-1) * CB_SPACING, -24)
    end
    
    -- Weapons row
    local row3Y = -52
    local label1H = bar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    label1H:SetPoint("TOPLEFT", 10, row3Y - 2)
    label1H:SetText("|cffffd1001H:|r")
    for i, catID in ipairs({ 14, 13, 15, 16, 17, 12 }) do
        makeCategoryButton(catID, 35 + (i-1) * CB_SPACING, row3Y)
    end
    
    local label2H = bar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    label2H:SetPoint("TOPLEFT", 195, row3Y - 2)
    label2H:SetText("|cffffd1002H:|r")
    for i, catID in ipairs({ 21, 20, 22, 23, 24 }) do
        makeCategoryButton(catID, 220 + (i-1) * CB_SPACING, row3Y)
    end
    
    local labelRanged = bar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    labelRanged:SetPoint("TOPLEFT", 355, row3Y - 2)
    labelRanged:SetText("|cffffd100Ranged/OH:|r")
    for i, catID in ipairs({ 25, 26, 27, 18, 19 }) do
        makeCategoryButton(catID, 435 + (i-1) * CB_SPACING, row3Y)
    end
end

function TUI:UpdateCategoryBarHighlight()
    for catID, btn in pairs(self.categoryBarIcons or {}) do
        if btn.selected then btn.selected:SetShown(self.selectedCategoryID == catID) end
    end
end

function TUI:UpdateCurrentViewingLabel()
    if not self.frame or not self.frame.categoryBar then return end
    local catID = self.selectedCategoryID
    local name, collected, total, percent = "Select a category", 0, 0, 0
    
    local ETB = GetETB()
    if catID then
        name = (ETB and ETB.CATEGORY_INFO and ETB.CATEGORY_INFO[catID] and ETB.CATEGORY_INFO[catID].name) or CATEGORY_NAMES[catID] or ("Category " .. catID)
        
        if ETB and ETB.BlizzardData and ETB.BlizzardData.GetCollectionStats then
            local stats = ETB.BlizzardData:GetCollectionStats(catID)
            if stats and stats.total then
                collected, total = stats.collected or 0, stats.total
                percent = total > 0 and (100 * collected / total) or 0
            end
        end
        
        if (not total or total == 0) and C_TransmogCollection and C_TransmogCollection.GetCategoryAppearances then
            local ok, appearances = pcall(C_TransmogCollection.GetCategoryAppearances, catID)
            if ok and appearances then
                total = #appearances
                for _, app in ipairs(appearances) do
                    if app and app.isCollected then collected = collected + 1 end
                end
                percent = total > 0 and (100 * collected / total) or 0
            end
        end
    end
    
    local statStr = (total and total > 0) and string.format(" (%d/%d - %.1f%%)", collected, total, percent) or ""
    self.frame.categoryBar.currentViewingLabel:SetText("|cff00ff00Currently Viewing:|r |cffffd100" .. name .. "|r|cff888888" .. statStr .. "|r")
end

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

function TUI:CreateBrowserPanel(parent)
    local panel = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    panel:SetSize(350, 520)
    panel:SetPoint("TOPLEFT", 15, -55)
    panel:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
        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("TOP", 0, -10)
    panel.title:SetText("|cffffd100Select a Slot|r")
    
    panel.appearancesTab = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    panel.appearancesTab:SetSize(110, 22)
    panel.appearancesTab:SetPoint("TOPLEFT", 10, -30)
    panel.appearancesTab:SetText("|cff00ff00Appearances|r")
    panel.appearancesTab:SetScript("OnClick", function()
        TUI.selectedTab = "appearances"
        panel.appearancesTab:SetText("|cff00ff00Appearances|r")
        panel.setsTab:SetText("Sets")
        panel.incompleteTab:SetText("Incomplete")
        TUI:RefreshBrowser()
    end)
    
    panel.setsTab = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    panel.setsTab:SetSize(110, 22)
    panel.setsTab:SetPoint("LEFT", panel.appearancesTab, "RIGHT", 4, 0)
    panel.setsTab:SetText("Sets")
    panel.setsTab:SetScript("OnClick", function()
        TUI.selectedTab = "sets"
        panel.appearancesTab:SetText("Appearances")
        panel.setsTab:SetText("|cff00ff00Sets|r")
        panel.incompleteTab:SetText("Incomplete")
        TUI:RefreshBrowser()
    end)
    
    panel.incompleteTab = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    panel.incompleteTab:SetSize(110, 22)
    panel.incompleteTab:SetPoint("LEFT", panel.setsTab, "RIGHT", 4, 0)
    panel.incompleteTab:SetText("Incomplete")
    panel.incompleteTab:SetScript("OnClick", function()
        TUI.selectedTab = "incomplete"
        panel.appearancesTab:SetText("Appearances")
        panel.setsTab:SetText("Sets")
        panel.incompleteTab:SetText("|cffff8800Incomplete|r")
        TUI:RefreshBrowser()
    end)
    
    panel.search = CreateFrame("EditBox", nil, panel, "InputBoxTemplate")
    panel.search:SetSize(200, 20)
    panel.search:SetPoint("TOPLEFT", 15, -58)
    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 removed (will be re-added in a future version)
    
    local scrollContainer = CreateFrame("Frame", nil, panel)
    scrollContainer:SetPoint("TOPLEFT", 5, -85)
    scrollContainer:SetPoint("BOTTOMRIGHT", -5, 40)
    
    local scrollFrame = CreateFrame("ScrollFrame", "ETBTUIBrowserScroll", 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, 15)
    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
-- =============================================================================

function TUI:AcquireRow(parent, index)
    local row
    if index <= #self.rowPool then
        row = self.rowPool[index]
    else
        row = self:CreateItemRow(self.rowPoolParent or parent, index)
        table.insert(self.rowPool, row)
    end
    local rowWidth = math.max(100, (parent:GetWidth() or 330) - 5)
    row:SetParent(parent)
    row:SetSize(rowWidth, ROW_HEIGHT)
    row:SetPoint("TOPLEFT", 2, -2 - (index - 1) * (ROW_HEIGHT + ROW_SPACING))
    row:Show()
    return row
end

function TUI:ReleaseRowsToPool(fromIndex)
    for i = fromIndex, #self.rowPool do
        local r = self.rowPool[i]
        r:Hide()
        r:SetParent(self.rowPoolParent)
        r.itemData = nil
    end
end

function TUI:CreateItemRow(parent, index)
    local rowWidth = 330
    if parent and parent.GetWidth then rowWidth = parent:GetWidth() - 5 end
    
    local row = CreateFrame("Button", nil, parent)
    row:SetSize(rowWidth, ROW_HEIGHT)
    row:SetPoint("TOPLEFT", 2, -2 - (index - 1) * (ROW_HEIGHT + ROW_SPACING))
    
    row.bg = row:CreateTexture(nil, "BACKGROUND")
    row.bg:SetAllPoints()
    row.bg:SetColorTexture(0.1, 0.1, 0.1, 0.8)
    
    row.highlight = row:CreateTexture(nil, "BACKGROUND", nil, 1)
    row.highlight:SetAllPoints()
    row.highlight:SetColorTexture(0.2, 0.3, 0.2, 0.8)
    row.highlight:Hide()
    
    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(34, 34)
    row.icon:SetPoint("LEFT", 3, 0)
    row.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    
    row.iconBorder = row:CreateTexture(nil, "OVERLAY")
    row.iconBorder:SetSize(38, 38)
    row.iconBorder:SetPoint("CENTER", row.icon, "CENTER")
    row.iconBorder:SetTexture("Interface\\Common\\WhiteIconFrame")
    
    row.name = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    row.name:SetPoint("TOPLEFT", row.icon, "TOPRIGHT", 8, -2)
    row.name:SetPoint("RIGHT", row, "RIGHT", -5, 0)
    row.name:SetJustifyH("LEFT")
    row.name:SetMaxLines(1)
    
    row.source = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    row.source:SetPoint("BOTTOMLEFT", row.icon, "BOTTOMRIGHT", 8, 2)
    row.source:SetPoint("RIGHT", row, "RIGHT", -5, 0)
    row.source:SetJustifyH("LEFT")
    row.source:SetTextColor(0.6, 0.6, 0.6)
    row.source:SetMaxLines(1)
    
    row:SetScript("OnEnter", function(self)
        self.highlight:Show()
        if self.itemData then
            TUI:ShowHoverPreview(self.itemData)
            -- Show tooltip with item info and right-click hint
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            if self.itemData.itemID then
                local ok = ETB_SafeSetTooltipItem and ETB_SafeSetTooltipItem(GameTooltip, self.itemData.itemID)
                if not ok then
                    GameTooltip:SetText(self.itemData.name or "Unknown")
                end
            else
                GameTooltip:SetText(self.itemData.name or "Unknown")
            end
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine("|cff00ff00Left-click|r to apply this appearance", 0.8, 0.8, 0.8)
            GameTooltip:AddLine("|cffffff00Right-click|r to view items sharing this look", 0.8, 0.8, 0.8)
            GameTooltip:Show()
        end
    end)
    row:SetScript("OnLeave", function(self)
        self.highlight:Hide()
        TUI:HideHoverPreview()
        GameTooltip:Hide()
    end)
    row:SetScript("OnClick", function(self, button)
        if self.itemData then
            if self.itemData.isLoadingHint then return end  -- Don't click loading hints
            if button == "RightButton" then
                -- Right-click: Show "Items That Share This Appearance" panel
                local ETB = GetETB()
                if ETB and ETB.AppearanceDetail then
                    ETB.AppearanceDetail:Toggle(self.itemData)
                end
            elseif self.itemData.isApplyCollectedButton then
                -- Apply only the collected pieces from an incomplete set
                TUI:ApplyCollectedPieces(self.itemData.setData, self.itemData.pieces)
            elseif self.itemData.isIncompleteSet then
                -- Incomplete set: show farming detail view
                TUI:ShowIncompleteSetDetail(self.itemData)
            elseif self.itemData.isBackButton then
                -- Back button in incomplete detail view: return to list
                TUI:RefreshIncompleteSets()
            elseif self.itemData.isSet then
                TUI:ApplySet(self.itemData)
            else
                TUI:SelectAppearance(self.itemData)
            end
        end
    end)
    row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    
    return row
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
    -- Color filter UI removed for now; always matches
    local function matchesColorFilter(itemName, iconPath, itemID)
        return true
    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"
        
        -- Try to find the actual hidden appearance from the category
        local hiddenSourceID, hiddenVisualID, hiddenIcon
        if C_TransmogCollection and C_TransmogCollection.GetCategoryAppearances then
            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
        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 (MogIt approach)
                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.name, item.icon, item.itemID)
                    
                    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 or ("Item " .. (item.itemID or "?")),
                            icon = icon or "Interface\\Icons\\INV_Misc_QuestionMark",
                            quality = item.quality 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))
                            
                            if nameMatch 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 (a.name or "") < (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 app.source or (app.isCollected and "|cff00ff00Collected|r" or "|cffff0000Not Collected|r"))
        
        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 (BetterWardrobe-sourced, 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 (a.name or "") < (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)
        
        -- Color and label by set type
        if setData.isCommunitySet then
            row.iconBorder:SetVertexColor(0, 0.8, 1, 1)  -- Cyan
            row.name:SetText("|cff00ccff◆|r " .. (setData.name or "Unknown"))
            row.name:SetTextColor(0, 0.8, 1)
        elseif setData.isCustomSet then
            row.iconBorder:SetVertexColor(1, 0.82, 0, 1)  -- Gold
            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)  -- Purple (Blizzard)
            row.name:SetText(setData.name or "Unknown")
            row.name:SetTextColor(0.64, 0.21, 0.93)
        end
        
        -- Build info string with completion
        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 "|cff00ff00" or (pct >= 50 and "|cffffff00" or "|cffff6600")
            info = string.format("%s%d/%d (%d%%)|r", color, setData.completion.collected, setData.completion.total, pct)
        end
        if setData.armorType then info = info .. " " .. setData.armorType end
        if setData.source ~= "" then info = info .. " • " .. setData.source end
        if setData.isBlizzardSet then info = info .. " |cff00ffffBlizzard|r" end
        if setData.isUserDefined then info = info .. " |cff00ff00User|r" end
        if setData.isCommunitySet then info = info .. " |cff00ccff◆ Community|r" end
        row.source:SetText(info ~= "" and info or "Set")
        
        -- Dim if not collected at all
        if setData.completion and setData.completion.collected == 0 then
            row.icon:SetDesaturated(true); row.icon:SetAlpha(0.6)
        else
            row.icon:SetDesaturated(false); row.icon:SetAlpha(1)
        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 (Farming / Wishlist tab)
-- =============================================================================

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 (a.name or "") < (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 "|cffffff00" or (pct >= 50 and "|cffff8800" or "|cffff4444")
        local missing = comp.total - comp.collected
        local info = string.format("%s%d/%d (%d%%)|r  |cffff6666%d missing|r", color, comp.collected, comp.total, pct, missing)
        if setData.source and setData.source ~= "" then info = info .. " • " .. setData.source end
        if setData.isSetBuilderSet then info = info .. " |cffff8800✦ Wishlist|r" end
        if setData.isUserDefined then info = info .. " |cff00ff00User|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 (farming checklist for a specific set)
-- =============================================================================

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 from ATT
        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
    
    -- Reset model fully - SetUnit reloads the character model from scratch,
    -- then Undress strips all gear. This ensures no TryOn remnants persist.
    self.frame.model:SetUnit("player")
    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 (like prototype)
                    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()
    
    -- 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
    self.frame.model:SetUnit("player")
    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
    
    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 (like prototype - direct TryOn)
    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 (like prototype)
    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
-- =============================================================================

function TUI:CreateHoverPreview()
    local f = CreateFrame("Frame", "ETBTUIHoverPreview", UIParent, "BackdropTemplate")
    f:SetSize(300, 450)  -- Bigger preview window
    f:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 16,
        insets = { left = 4, right = 4, top = 4, bottom = 4 }
    })
    f:SetFrameStrata("TOOLTIP")  -- TOOLTIP is highest normal strata - above everything
    f:SetFrameLevel(200)
    f:Hide()
    
    -- Title at top
    f.title = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    f.title:SetPoint("TOP", 0, -12)
    f.title:SetWidth(280)
    f.title:SetJustifyH("CENTER")
    
    -- Bigger model frame
    local model = CreateFrame("DressUpModel", nil, f)
    model:SetSize(280, 380)
    model:SetPoint("TOP", 0, -35)
    model.zoomLevel = 0
    
    -- Enable mouse for rotation and zoom
    model:EnableMouse(true)
    model:EnableMouseWheel(true)
    
    -- Set up rotation
    model:SetScript("OnMouseDown", function(self, button)
        if button == "LeftButton" then
            self.rotating = true
            self.startX = GetCursorPosition()
            self.startRotation = self:GetFacing()
        end
    end)
    model:SetScript("OnMouseUp", function(self, button)
        if button == "LeftButton" then
            self.rotating = false
        end
    end)
    model:SetScript("OnUpdate", function(self)
        if self.rotating then
            local x = GetCursorPosition()
            local diff = (x - self.startX) * 0.01
            self:SetFacing(self.startRotation + diff)
        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)
    
    -- Better camera position to show gear clearly
    model:SetPortraitZoom(0)
    model:SetCamDistanceScale(1.0)
    model:SetPosition(0, 0, -0.15)
    
    f.model = model
    
    -- Item name at bottom
    f.label = f:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    f.label:SetPoint("BOTTOM", 0, 10)
    f.label:SetWidth(280)
    f.label:SetJustifyH("CENTER")
    
    self.hoverPreviewFrame = f
end

function TUI:ShowHoverPreview(itemData)
    if not self.hoverPreviewFrame or not itemData then return end
    if not itemData.itemID and not (itemData.isSet and itemData.items) then return end
    
    local f = self.hoverPreviewFrame
    self.hoverPreviewItem = itemData  -- Track for race check
    
    -- Position near cursor but ensure it stays on screen
    local x, y = GetCursorPosition()
    local scale = UIParent:GetEffectiveScale()
    local screenWidth = GetScreenWidth()
    local screenHeight = GetScreenHeight()
    
    local frameX = x/scale + 25
    local frameY = y/scale - 150
    
    -- Keep on screen (frame is now 300x450)
    if frameX + 300 > screenWidth then
        frameX = x/scale - 320
    end
    if frameY < 50 then
        frameY = 50
    end
    if frameY + 450 > screenHeight then
        frameY = screenHeight - 460
    end
    
    f:ClearAllPoints()
    f:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", frameX, frameY)
    
    -- Reset model SYNCHRONOUSLY like prototype (SetUnit + Undress + SetPosition)
    f.model:SetUnit("player")
    f.model:Undress()
    f.model:SetRotation(0)
    f.model:SetPosition(0, 0, 0)
    
    -- Set title
    if itemData.itemID then 
        f.title:SetText("|cffffffff" .. (itemData.name or "Item Preview") .. "|r")
    elseif itemData.isSet then
        f.title:SetText("|cffffd100" .. (itemData.name or "Set Preview") .. "|r")
    else
        f.title:SetText(itemData.name or "Preview")
    end
    
    -- Set label
    local labelText = ""
    if itemData.source then
        labelText = "|cff888888" .. itemData.source .. "|r"
    end
    f.label:SetText(labelText)
    
    f:Show()
    
    -- ONLY TryOn is delayed (like prototype) - gives model time to be ready
    C_Timer = C_Timer or {}
    if C_Timer and C_Timer.After then
        local capturedItemData = itemData  -- Capture for closure
        C_Timer.After(0.1, function()
            -- Race check: make sure we're still previewing the same item
            if self.hoverPreviewItem ~= capturedItemData then return end
            
            if capturedItemData.itemID then
                pcall(function() f.model:TryOn("item:" .. capturedItemData.itemID) end)
            elseif capturedItemData.isSet and capturedItemData.items then
                for _, id in pairs(capturedItemData.items) do
                    if type(id) == "number" then
                        pcall(function() f.model:TryOn("item:" .. id) end)
                    end
                end
            end
        end)
    else
        -- Fallback without timers
        if itemData.itemID then 
            pcall(function() f.model:TryOn("item:" .. itemData.itemID) end)
        elseif itemData.isSet and itemData.items then
            for _, id in pairs(itemData.items) do 
                if type(id) == "number" then 
                    pcall(function() f.model:TryOn("item:" .. id) end)
                end 
            end
        end
    end
end

function TUI:HideHoverPreview()
    self.hoverPreviewItem = nil  -- Clear for race check
    if self.hoverPreviewFrame then 
        self.hoverPreviewFrame:Hide() 
    end
end

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

function TUI:CreateOutfitsPanel(parent)
    local panel = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    panel:SetSize(300, 520)
    panel:SetPoint("TOPRIGHT", -15, -55)
    panel:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
        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("TOP", 0, -10)
    panel.title:SetText("|cffffd100Saved Outfits|r")
    
    -- Row 1: Save and View toggle only
    panel.saveBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    panel.saveBtn:SetSize(100, 22)
    panel.saveBtn:SetPoint("TOPLEFT", 10, -28)
    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(100, 22)
    panel.accountToggle:SetPoint("LEFT", panel.saveBtn, "RIGHT", 5, 0)
    panel.accountToggle:SetText("|cff88ff88View: Char|r")  -- Shows WHAT you're viewing
    panel.accountToggle.showAccount = false
    panel.accountToggle:SetScript("OnClick", function(self) 
        self.showAccount = not self.showAccount
        if self.showAccount then
            self:SetText("|cff88aaffView: All|r")  -- Viewing account-wide outfits
            panel.title:SetText("|cff88aaffAccount-Wide Outfits|r")
        else
            self:SetText("|cff88ff88View: Char|r")  -- Viewing this character's outfits
            panel.title:SetText("|cffffd100This Character's 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, -55)
    scrollContainer:SetPoint("BOTTOMRIGHT", -5, 70)
    
    local scrollFrame = CreateFrame("ScrollFrame", "ETBTUIOutfitsScroll", scrollContainer, "UIPanelScrollFrameTemplate")
    scrollFrame:SetAllPoints()
    
    local scrollChild = CreateFrame("Frame", nil, scrollFrame)
    scrollChild:SetWidth(scrollFrame:GetWidth() - 20)
    scrollChild:SetHeight(1)
    scrollFrame:SetScrollChild(scrollChild)
    
    panel.scrollChild = scrollChild
    
    panel.loadBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    panel.loadBtn:SetSize(130, 26)
    panel.loadBtn:SetPoint("BOTTOMLEFT", 10, 35)
    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(130, 26)
    panel.deleteBtn:SetPoint("BOTTOMRIGHT", -10, 35)
    panel.deleteBtn:SetText("|cffff6666Delete|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, 15)
    
    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
    
    -- Show dialog to name the outfit
    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 ~= "" then
                local id = "captured_" .. time()
                local _, classID = UnitClass("player")
                ETBTUI_SavedOutfits[id] = {
                    id = id,
                    name = name,
                    icon = items.Chest and items.Chest.icon or "Interface\\Icons\\INV_Chest_Cloth_17",
                    items = items,
                    created = time(),
                    pieceCount = pieceCount,
                    capturedFromGear = true,
                    classID = classID,
                }
                print(string.format("|cff00ff00[ETB]|r Captured: %s (%d pieces)", name, pieceCount))
                TUI:RefreshOutfits()
            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,
    }
    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
    
    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,
    }
    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 - 30, 55)
    bar:SetPoint("BOTTOM", 0, 15)
    bar:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 12,
        insets = { left = 4, right = 4, top = 4, bottom = 4 }
    })
    
    bar.costLabel = bar:CreateFontString(nil, "OVERLAY", "GameFontNormal"); bar.costLabel:SetPoint("LEFT", 20, 0); bar.costLabel:SetText("|cffffd100Cost:|r")
    bar.costValue = bar:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge"); bar.costValue:SetPoint("LEFT", bar.costLabel, "RIGHT", 5, 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", 20, 0); bar.pendingLabel:SetText("|cff8888880 changes pending|r")
    
    bar.resetBtn = CreateFrame("Button", nil, bar, "UIPanelButtonTemplate"); bar.resetBtn:SetSize(80, 30); bar.resetBtn:SetPoint("CENTER", -80, 0); bar.resetBtn:SetText("Reset"); bar.resetBtn:SetScript("OnClick", function() TUI:Reset() end)
    bar.applyBtn = CreateFrame("Button", nil, bar, "UIPanelButtonTemplate"); bar.applyBtn:SetSize(100, 30); bar.applyBtn:SetPoint("CENTER", 30, 0); bar.applyBtn:SetText("|cff00ff00Apply|r"); bar.applyBtn:SetScript("OnClick", function() TUI:ApplyTransmogs() end)
    bar.closeBtn = CreateFrame("Button", nil, bar, "UIPanelButtonTemplate"); bar.closeBtn:SetSize(80, 30); bar.closeBtn:SetPoint("RIGHT", -20, 0); bar.closeBtn:SetText("Close"); bar.closeBtn:SetScript("OnClick", function() if TUI.blizzardTransmogFrame then TUI:SwitchToBlizzardUI() else TUI:Hide() end 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
    self.frame.bottomBar.costValue:SetText(string.format("%d |TInterface\\MoneyFrame\\UI-GoldIcon:14:14:0:0|t", count * 10))
    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 
        -- Call SetUnit before Dress (like prototype) to ensure model is ready
        self.frame.model:SetUnit("player")
        self.frame.model:Dress()
        self.frame.model.zoomLevel = 0
        self.frame.model:SetCamDistanceScale(1)
    end
    
    -- Reset slot buttons to default icons (like prototype)
    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: use ToggleOff pending type
            local ok, result = self:CallSetPendingHidden(slotID)
            if ok then
                verified = verified + 1
                table.insert(verifiedSlots, slotName .. " (hidden)")
                print(string.format("|cff00ff00[ETB]|r %s: HIDDEN", slotName))
            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
    
    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()
    -- Always hide the floating ETB Mode button when TransmogUI closes
    if self.floatingButton then self.floatingButton:Hide() end
end

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

-- Export to both global and ETB namespace
ETB_TransmogUI = TUI
local ETB = GetETB()
if ETB then
    ETB.TransmogUI = TUI
end
-- Also add to namespace
NS.TransmogUI = TUI
