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

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

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

local ADDON_NAME, NS = ...

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

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

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

local SETPENDING_API_DETECTED = nil

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

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

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

local ROW_HEIGHT = 30
local ROW_SPACING = 1

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

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

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

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

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

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

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

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

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

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

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

-- v14.8.0: tables consolidated to Data/Categories/ClassData.lua. Locals
-- still bound by name so the rest of TransmogUI.lua doesn't need changes;
-- they just reference the canonical tables when ClassData is loaded.

local _CD = ETB.ClassData
local CLASS_PRIMARY_ARMOR = _CD and _CD.PRIMARY_ARMOR or {
    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 = _CD and _CD.WEAPONS or {
    WARRIOR     = { AXE=true, SWORD=true, MACE=true, POLEARM=true, STAFF=true, DAGGER=true, FIST=true, SHIELD=true },
    PALADIN     = { AXE=true, SWORD=true, MACE=true, POLEARM=true, SHIELD=true },
    DEATHKNIGHT = { AXE=true, SWORD=true, MACE=true, POLEARM=true },
    HUNTER      = { AXE=true, SWORD=true, POLEARM=true, STAFF=true, DAGGER=true, FIST=true, BOW=true, GUN=true, CROSSBOW=true },
    SHAMAN      = { AXE=true, MACE=true, STAFF=true, DAGGER=true, FIST=true, SHIELD=true },
    ROGUE       = { AXE=true, SWORD=true, MACE=true, DAGGER=true, FIST=true },
    DRUID       = { MACE=true, POLEARM=true, STAFF=true, DAGGER=true, FIST=true },
    MONK        = { AXE=true, SWORD=true, MACE=true, POLEARM=true, STAFF=true, FIST=true },
    MAGE        = { SWORD=true, STAFF=true, DAGGER=true, WAND=true },
    WARLOCK     = { SWORD=true, STAFF=true, DAGGER=true, WAND=true },
    PRIEST      = { MACE=true, STAFF=true, DAGGER=true, WAND=true },
}

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

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

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

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

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

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

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

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

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

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

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

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

local ExportSystem = {}

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

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

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

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

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

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

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

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

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

-- Test outfits
-- =============================================================================
-- EXPOSE FILE-LOCAL DEPENDENCIES ON TUI (for submodule access)
-- =============================================================================
-- Outfits.lua, and other future submodules, reach these through `owner.X`
-- rather than the file-local. Kept as fields on TUI so the namespace-merge
-- in the closing block preserves them when TUI is published as the singleton.

TUI.ProfileManager = ProfileManager
TUI.ExportSystem = ExportSystem
TUI.INV_TO_SLOT_NAME = INV_TO_SLOT_NAME
TUI.SLOT_NAME_TO_INV = SLOT_NAME_TO_INV
TUI.FRAME_WIDTH = FRAME_WIDTH    -- v12.48.0: for Frame.lua submodule
TUI.FRAME_HEIGHT = FRAME_HEIGHT  -- v12.48.0: for Frame.lua submodule
TUI.SLOTS = SLOTS                -- v12.48.0: for Frame.lua submodule
-- v14.11.0: exposed for Apply.lua submodule (audit Batch 3 Part 3)
TUI.ARMOR_SLOTS = ARMOR_SLOTS
TUI.WEAPON_SLOTS = WEAPON_SLOTS
TUI.INVSLOT_TO_CATEGORIES = INVSLOT_TO_CATEGORIES
TUI.SLOT_NAME_TO_TOKEN = SLOT_NAME_TO_TOKEN
-- v15.0.1: hotfix for Sets.lua bare ROW_HEIGHT / ROW_SPACING references
-- (these were missed in v14.9.0 Sets extraction since the "expose constants
-- on TUI" pattern wasn't established until v14.11 / Apply extraction).
TUI.ROW_HEIGHT = ROW_HEIGHT
TUI.ROW_SPACING = ROW_SPACING

-- =============================================================================
-- INITIALIZATION
-- =============================================================================

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

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

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

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

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

TUI.floatingButton = etbFloatingBtn

initFrame:SetScript("OnEvent", function(self, event, ...)
    if event == "PLAYER_LOGIN" then
        local function initTUI()
            TUI:Initialize()
        end
        if ETB.RunWhenStartupIdle then
            ETB:RunWhenStartupIdle("transmogUI.init", initTUI, { delay = 2, interval = 2, maxWait = 180 })
        else
            initTUI()
        end
    elseif event == "TRANSMOGRIFY_OPEN" then
        -- Planner mode: ETB no longer hijacks Blizzard's transmogrifier. Browsing
        -- and applying happen in the native ETB tabs (ETB Sets / ETB Saved
        -- Outfits). The planner is a standalone build-and-save window opened from
        -- the minimap or /etbtui. If it's already open, just refresh its bottom
        -- bar so the Apply button reflects that an NPC is now in range.
        if TUI.frame and TUI.frame:IsShown() and TUI.frame.bottomBar
            and TUI.frame.bottomBar.RefreshApplyState
        then
            TUI.frame.bottomBar.RefreshApplyState()
        end
    elseif event == "TRANSMOGRIFY_CLOSE" then
        -- v14.5.0: NPC interaction ended, but the user may want to keep
        -- building outfits in save-only mode. Don't force the UI shut —
        -- the bottom bar's NPC-status indicator will tell them Apply is
        -- now disabled. Just hide the floating button (its job is done)
        -- and restore the Blizzard frame state.
        if TUI.floatingButton then TUI.floatingButton:Hide() end
        if TUI.blizzardTransmogFrame then
            TUI:ShowBlizzardFrame(TUI.blizzardTransmogFrame)
            TUI.blizzardTransmogFrame = nil
        end
        -- If our UI is open, refresh the bottom bar so it reflects no-NPC state
        if TUI.frame and TUI.frame:IsShown() and TUI.frame.bottomBar
            and TUI.frame.bottomBar.RefreshApplyState
        then
            TUI.frame.bottomBar.RefreshApplyState()
        end
    elseif event == "TRANSMOGRIFY_UPDATE" then
        -- Blizzard UI changed pending transmogs — pull into ETB if our UI is open
        if TUI.frame and TUI.frame:IsShown() then
            -- Debounce: don't re-sync if we just pushed or are applying a set
            if not TUI._pushingToBlizzard and not TUI._applyingSet then
                TUI._pullingFromBlizzard = true
                if TUI:SyncFromBlizzardPending() then
                    -- Update slot button icons to reflect Blizzard changes
                    TUI:UpdateSlotButtonIcons()
                    -- Update model to match
                    if TUI.frame.model then
                        TUI.frame.model:Dress()
                        for _, pend in pairs(TUI.pendingTransmogs) do
                            if pend.itemID and not pend.isHiddenSlot then
                                TUI.frame.model:TryOn("item:" .. pend.itemID)
                            end
                        end
                    end
                    TUI:UpdateCost()
                end
                TUI._pullingFromBlizzard = nil
            end
        end
    elseif event == "GET_ITEM_INFO_RECEIVED" then
        -- Item data loaded - update cache
        local itemID = ...
        if itemID and TUI.itemCache[itemID] then
            local name, _, quality, _, _, _, _, _, _, icon = GetItemInfo(itemID)
            if name then
                TUI.itemCache[itemID] = { itemID = itemID, name = name, quality = quality or 1, icon = icon }
            end
        end
        -- Update matching rows in-place (no full refresh to avoid feedback loop)
        if itemID and TUI.frame and TUI.frame:IsShown() and TUI.rowPool then
            local name, _, quality = GetItemInfo(itemID)
            if name then
                local updated = false
                for _, row in ipairs(TUI.rowPool) do
                    if row:IsShown() and row.itemData and row.itemData.itemID == itemID then
                        row.itemData.name = name
                        if type(quality) == "number" then row.itemData.quality = quality end
                        local qColor = QUALITY_COLORS[row.itemData.quality] or QUALITY_COLORS[1]
                        row.name:SetText(name)
                        row.name:SetTextColor(qColor.r, qColor.g, qColor.b)
                        row.iconBorder:SetVertexColor(qColor.r, qColor.g, qColor.b, 1)
                        updated = true
                    end
                end
                -- Debounced full refresh if this item wasn't on a visible row
                -- Limited to 2 refreshes max to prevent constant refreshing
                if not updated and not TUI._itemRefreshTimer and (TUI._itemRefreshCount or 0) < 2 then
                    TUI._itemRefreshTimer = true
                    C_Timer.After(0.5, function()
                        TUI._itemRefreshTimer = nil
                        TUI._itemRefreshCount = (TUI._itemRefreshCount or 0) + 1
                        if TUI.frame and TUI.frame:IsShown() then
                            TUI:RefreshBrowser()
                        end
                    end)
                end
            end
        end
    end
end)

function TUI:Initialize()
    return NS.ETB.TransmogUI.Frame:Initialize(self)
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)
    -- v15.0.0: implementation lives in NS.ETB.TransmogUI.Blizzard
    return NS.ETB.TransmogUI.Blizzard:HideBlizzardFrame(self, frame)
end

-- Restore ALL Blizzard transmog frames to their original state
function TUI:ShowBlizzardFrame(frame)
    -- v15.0.0: implementation lives in NS.ETB.TransmogUI.Blizzard
    return NS.ETB.TransmogUI.Blizzard:ShowBlizzardFrame(self, frame)
end

function TUI:HookBlizzardTransmogFrame()
    -- v15.0.0: implementation lives in NS.ETB.TransmogUI.Blizzard
    return NS.ETB.TransmogUI.Blizzard:HookBlizzardTransmogFrame(self)
end

function TUI:SwitchToBlizzardUI()
    -- v15.0.0: implementation lives in NS.ETB.TransmogUI.Blizzard
    return NS.ETB.TransmogUI.Blizzard:SwitchToBlizzardUI(self)
end

function TUI:SyncFromBlizzardPending()
    -- v15.0.0: implementation lives in NS.ETB.TransmogUI.Blizzard
    return NS.ETB.TransmogUI.Blizzard:SyncFromBlizzardPending(self)
end

-- Clear ALL Blizzard pending transmog slots so old set pieces don't leak through
function TUI:ClearBlizzardPending()
    -- v15.0.0: implementation lives in NS.ETB.TransmogUI.Blizzard
    return NS.ETB.TransmogUI.Blizzard:ClearBlizzardPending(self)
end

-- Clear Blizzard pending for a single slot — tries all available methods
function TUI:ClearSlotPending(slotID)
    -- v15.0.0: implementation lives in NS.ETB.TransmogUI.Blizzard
    return NS.ETB.TransmogUI.Blizzard:ClearSlotPending(self, slotID)
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()
    -- v15.0.0: implementation lives in NS.ETB.TransmogUI.Blizzard
    return NS.ETB.TransmogUI.Blizzard:SyncToBlizzardPending(self)
end

-- Push a single slot to Blizzard (called right after SelectAppearance or ToggleSlotHide)
function TUI:PushSlotToBlizzard(invSlot)
    -- v15.0.0: implementation lives in NS.ETB.TransmogUI.Blizzard
    return NS.ETB.TransmogUI.Blizzard:PushSlotToBlizzard(self, invSlot)
end

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

function TUI:BuildTransmogSlotIDs()
    -- v14.11.0: implementation lives in NS.ETB.TransmogUI.Apply
    return NS.ETB.TransmogUI.Apply:BuildTransmogSlotIDs(self)
end

function TUI:GetTransmogSlotID(invSlot)
    -- v14.11.0: implementation lives in NS.ETB.TransmogUI.Apply
    return NS.ETB.TransmogUI.Apply:GetTransmogSlotID(self, invSlot)
end

function TUI:CanApplyTransmog()
    -- v14.11.0: implementation lives in NS.ETB.TransmogUI.Apply
    return NS.ETB.TransmogUI.Apply:CanApplyTransmog(self)
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.
-- (v15.4.0 cleanup: removed orphan `local SETPENDING_METHOD = nil` left over
--  from the v14.11.0 Apply extraction. Never read anywhere.)

function TUI:CallSetPending(slotID, sourceID)
    -- v14.11.0: implementation lives in NS.ETB.TransmogUI.Apply
    return NS.ETB.TransmogUI.Apply:CallSetPending(self, slotID, sourceID)
end

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

-- Slot → categoryID mapping for finding hidden appearances

function TUI:FindHiddenSourceIDs()
    -- v14.11.0: implementation lives in NS.ETB.TransmogUI.Apply
    return NS.ETB.TransmogUI.Apply:FindHiddenSourceIDs(self)
end

function TUI:CallSetPendingHidden(slotID)
    -- v14.11.0: implementation lives in NS.ETB.TransmogUI.Apply
    return NS.ETB.TransmogUI.Apply:CallSetPendingHidden(self, slotID)
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)
    -- v14.11.0: implementation lives in NS.ETB.TransmogUI.Apply
    return NS.ETB.TransmogUI.Apply:GetAllCollectedSources(self, itemID)
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)
    -- v14.11.0: implementation lives in NS.ETB.TransmogUI.Apply
    return NS.ETB.TransmogUI.Apply:TryApplyWithCollectedSources(self, slotID, itemID)
end

-- Backward-compatible wrapper: returns sourceID, isCollected
-- Used by code paths that don't need the full TryApply flow
function TUI:GetCollectedSourceForItem(itemID)
    -- v14.11.0: implementation lives in NS.ETB.TransmogUI.Apply
    return NS.ETB.TransmogUI.Apply:GetCollectedSourceForItem(self, itemID)
end

-- Resolve sourceID from pending, ALWAYS preferring a collected source.
-- Returns sourceID, isCollected
function TUI:GetSourceIDForPending(invSlot, pending)
    -- v14.11.0: implementation lives in NS.ETB.TransmogUI.Apply
    return NS.ETB.TransmogUI.Apply:GetSourceIDForPending(self, invSlot, pending)
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 the Transmog Planner")
        print("  |cff00ccff/etbtui auto|r — Toggle auto-open at transmog NPC")
    else
        TUI:Toggle()
    end
end

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

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

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

function TUI:Create()
    return NS.ETB.TransmogUI.Frame:Create(self)
end

function TUI:CreateTitleBar(parent)
    return NS.ETB.TransmogUI.Frame:CreateTitleBar(self, parent)
end

function TUI:CreateModelPanel(parent)
    return NS.ETB.TransmogUI.Frame:CreateModelPanel(self, parent)
end

function TUI:CreateSlotButtons(parent)
    return NS.ETB.TransmogUI.Frame:CreateSlotButtons(self, parent)
end

-- Get default icon for a slot
function TUI:GetDefaultSlotIcon(invSlot)
    -- v14.9.0: implementation lives in NS.ETB.TransmogUI.Slots
    return NS.ETB.TransmogUI.Slots:GetDefaultSlotIcon(self, invSlot)
end

-- Toggle hide for a slot
function TUI:ToggleSlotHide(invSlot)
    -- v14.9.0: implementation lives in NS.ETB.TransmogUI.Slots
    return NS.ETB.TransmogUI.Slots:ToggleSlotHide(self, invSlot)
end

-- Update slot button icons based on pending transmogs
function TUI:UpdateSlotButtonIcons()
    -- v14.9.0: implementation lives in NS.ETB.TransmogUI.Slots
    return NS.ETB.TransmogUI.Slots:UpdateSlotButtonIcons(self)
end

function TUI:SelectSlot(invSlot)
    -- v14.9.0: implementation lives in NS.ETB.TransmogUI.Slots
    return NS.ETB.TransmogUI.Slots:SelectSlot(self, invSlot)
end

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

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

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

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

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

function TUI:CreateBrowserPanel(parent)
    return NS.ETB.TransmogUI.Frame:CreateBrowserPanel(self, parent)
end

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

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

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

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

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

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

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

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

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

-- Check if player can use a set based on class mask
function TUI:CanPlayerUseSet(setInfo)
    -- v14.9.0: implementation lives in NS.ETB.TransmogUI.Sets
    return NS.ETB.TransmogUI.Sets:CanPlayerUseSet(self, setInfo)
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()
    -- v14.9.0: implementation lives in NS.ETB.TransmogUI.Sets
    return NS.ETB.TransmogUI.Sets:RefreshSets(self)
end

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

function TUI:RefreshIncompleteSets()
    -- v14.9.0: implementation lives in NS.ETB.TransmogUI.Sets
    return NS.ETB.TransmogUI.Sets:RefreshIncompleteSets(self)
end

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

function TUI:ShowIncompleteSetDetail(setData)
    -- v14.9.0: implementation lives in NS.ETB.TransmogUI.Sets
    return NS.ETB.TransmogUI.Sets:ShowIncompleteSetDetail(self, setData)
end

function TUI:ApplySet(setData)
    -- v14.9.0: implementation lives in NS.ETB.TransmogUI.Sets
    return NS.ETB.TransmogUI.Sets:ApplySet(self, setData)
end

-- Apply only collected pieces from an incomplete set to the model
function TUI:ApplyCollectedPieces(setData, pieces)
    -- v14.9.0: implementation lives in NS.ETB.TransmogUI.Sets
    return NS.ETB.TransmogUI.Sets:ApplyCollectedPieces(self, setData, pieces)
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)
    -- v14.9.0: implementation lives in NS.ETB.TransmogUI.Slots
    return NS.ETB.TransmogUI.Slots:SelectAppearance(self, appearance)
end

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

function TUI:CreateHoverPreview()
    return NS.ETB.TransmogUI.Frame:CreateHoverPreview(self)
end

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

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

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

function TUI:CreateOutfitsPanel(parent)
    return NS.ETB.TransmogUI.Outfits:CreatePanel(self, parent)
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()
    -- v14.10.0: implementation lives in NS.ETB.TransmogUI.Capture
    return NS.ETB.TransmogUI.Capture:CaptureCurrentTransmog(self)
end

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

function TUI:ShowExportDialog(outfitID)
    -- v14.10.0: implementation lives in NS.ETB.TransmogUI.Capture
    return NS.ETB.TransmogUI.Capture:ShowExportDialog(self, outfitID)
end

function TUI:ShowImportDialog()
    -- v14.10.0: implementation lives in NS.ETB.TransmogUI.Capture
    return NS.ETB.TransmogUI.Capture:ShowImportDialog(self)
end

function TUI:ImportOutfitFromString(str)
    return NS.ETB.TransmogUI.Outfits:ImportFromString(self, str)
end

-- Import outfits from Blizzard's default UI
function TUI:ImportBlizzardOutfits(refreshMode)
    return NS.ETB.TransmogUI.Outfits:ImportFromBlizzard(self, refreshMode)
end

-- Helper to get outfit items from Blizzard API
function TUI:GetBlizzardOutfitItems(outfitID)
    return NS.ETB.TransmogUI.Outfits:GetBlizzardItems(self, outfitID)
end

function TUI:ShowSaveOutfitDialog()
    return NS.ETB.TransmogUI.Outfits:ShowSaveDialog(self)
end

function TUI:SaveOutfit(name)
    return NS.ETB.TransmogUI.Outfits:Save(self, name)
end

function TUI:DeleteOutfit(outfitID)
    return NS.ETB.TransmogUI.Outfits:Delete(self, outfitID)
end

function TUI:RefreshOutfits()
    return NS.ETB.TransmogUI.Outfits:Refresh(self)
end

function TUI:LoadOutfitToPreview(outfitID, isAccount)
    return NS.ETB.TransmogUI.Outfits:Load(self, outfitID, isAccount)
end

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

function TUI:CreateBottomBar(parent)
    return NS.ETB.TransmogUI.Frame:CreateBottomBar(self, parent)
end

function TUI:UpdateCost()
    -- v14.11.0: implementation lives in NS.ETB.TransmogUI.Apply
    return NS.ETB.TransmogUI.Apply:UpdateCost(self)
end

function TUI:Reset()
    return NS.ETB.TransmogUI.Frame:Reset(self)
end

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

function TUI:ApplyTransmogs()
    -- v14.11.0: implementation lives in NS.ETB.TransmogUI.Apply
    return NS.ETB.TransmogUI.Apply:ApplyTransmogs(self)
end

function TUI:ApplyTransmogsArmor()
    -- v14.11.0: implementation lives in NS.ETB.TransmogUI.Apply
    return NS.ETB.TransmogUI.Apply:ApplyTransmogsArmor(self)
end

function TUI:ApplyTransmogsWeapons()
    -- v14.11.0: implementation lives in NS.ETB.TransmogUI.Apply
    return NS.ETB.TransmogUI.Apply:ApplyTransmogsWeapons(self)
end

function TUI:ClearPendingAfterApply()
    -- v14.11.0: implementation lives in NS.ETB.TransmogUI.Apply
    return NS.ETB.TransmogUI.Apply:ClearPendingAfterApply(self)
end

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

function TUI:Show()
    return NS.ETB.TransmogUI.Frame:Show(self)
end

function TUI:Hide()
    return NS.ETB.TransmogUI.Frame:Hide(self)
end

function TUI:Toggle()
    return NS.ETB.TransmogUI.Frame:Toggle(self)
end

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

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

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

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