-- Transmoggin - Copyright (c) 2026 Rooster_Roost. All Rights Reserved.
-- Proprietary software. Personal non-commercial use only; redistribution/repackaging/forks/reuse require permission.
-- Full license: Core.lua and LICENSE.txt

local T=Transmoggin
local C=T.COLORS

local function rgba(t) return t[1],t[2],t[3],t[4] end
local function frame(tp,name,parent,template)
    if BackdropTemplateMixin and not template and tp~="DressUpModel" then template="BackdropTemplate" end
    return CreateFrame(tp,name,parent,template)
end
local function skin(f,bg,border,edge)
    if not f.SetBackdrop then return end
    f:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=edge or 1})
    f:SetBackdropColor(rgba(bg)); f:SetBackdropBorderColor(rgba(border))
end
local function fs(p,s,c,font)
    local x=p:CreateFontString(nil,"OVERLAY",font or "GameFontNormal")
    x:SetFont(STANDARD_TEXT_FONT,s,""); x:SetTextColor(rgba(c)); return x
end
local function button(p,label,w,h,kind)
    local b=frame("Button",nil,p); b:SetSize(w,h)
    local bg=kind=="red" and {0.28,0.035,0.02,0.98} or kind=="purple" and {0.10,0.045,0.16,0.98} or C.panel2
    skin(b,bg,C.gold2)
    b.txt=fs(b,11,C.gold); b.txt:SetPoint("CENTER"); b.txt:SetText(label)
    b:SetScript("OnEnter",function(self) if self.SetBackdropBorderColor then self:SetBackdropBorderColor(rgba(C.gold)) end end)
    b:SetScript("OnLeave",function(self) if self.SetBackdropBorderColor then self:SetBackdropBorderColor(rgba(C.gold2)) end end)
    return b
end
local function moneyString(copper)
    if GetMoneyString then return GetMoneyString(copper or 0,true) end
    return tostring(math.floor((copper or 0)/10000)).."g"
end
local function divider(p,x1,y1,x2,y2)
    local t=p:CreateTexture(nil,"ARTWORK"); t:SetTexture("Interface\\Buttons\\WHITE8X8"); t:SetVertexColor(.45,.24,.06,.8); t:SetPoint("TOPLEFT",x1,y1); t:SetPoint("BOTTOMRIGHT",x2,y2); return t
end

function T:TooltipsEnabled()
    return not (self.db and self.db.profile and self.db.profile.help and self.db.profile.help.tooltips==false)
end

function T:ShowHelpTooltip(owner,title,body,anchorPoint)
    if not self:TooltipsEnabled() or not GameTooltip then return end
    GameTooltip:SetOwner(owner,anchorPoint or "ANCHOR_RIGHT")
    GameTooltip:SetText(title or "Transmoggin",1,.72,.22)
    if body and body~="" then GameTooltip:AddLine(body,1,1,1,true) end
    GameTooltip:Show()
end

function T:AttachHelpTooltip(control,title,body,anchorPoint)
    if not control or not control.HookScript then return end
    control:HookScript("OnEnter",function(self) T:ShowHelpTooltip(self,title,body,anchorPoint) end)
    control:HookScript("OnLeave",function() if GameTooltip then GameTooltip:Hide() end end)
end

function T:CreateUI()
    if self.db and self.db.profile and self.db.profile.hideCompletedGoals==nil then self.db.profile.hideCompletedGoals=false end
    local f=frame("Frame","TransmogginSalon",UIParent); self.main=f
    f:SetSize(1580,880); f:SetPoint("CENTER"); f:SetFrameStrata("HIGH"); f:SetClampedToScreen(true); f:SetMovable(true); f:EnableMouse(true)
    -- Keep the full salon visible on every resolution/UI scale. The layout is
    -- authored at 1580x880 and scales down as one unit instead of clipping
    -- individual panels off-screen.
    function T:FitSalonToScreen()
        if not self.main or not UIParent then return end
        local sw, sh = UIParent:GetWidth(), UIParent:GetHeight()
        if not sw or not sh or sw <= 0 or sh <= 0 then return end
        local margin = 18
        local scale = math.min(1, (sw - margin * 2) / 1580, (sh - margin * 2) / 880)
        scale = math.max(0.62, scale)
        self.main:SetScale(scale)
        self.main:ClearAllPoints()
        self.main:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    end
    self:FitSalonToScreen()
    f:RegisterForDrag("LeftButton"); f:SetScript("OnDragStart",f.StartMoving); f:SetScript("OnDragStop",f.StopMovingOrSizing)
    -- The woodland salon is rendered on a dedicated child frame above the
    -- parent backdrop. This prevents Blizzard frame/backdrop changes from
    -- briefly showing the art and then covering it again.
    skin(f,{0.010,0.009,0.008,0.00},{0.38,0.20,0.05,1},2)

    local bgFrame=CreateFrame("Frame",nil,f)
    bgFrame:SetPoint("TOPLEFT",3,-3); bgFrame:SetPoint("BOTTOMRIGHT",-3,3)
    bgFrame:SetFrameLevel(f:GetFrameLevel()+1)
    bgFrame:EnableMouse(false)
    local salonBGLeft=bgFrame:CreateTexture(nil,"BACKGROUND")
    salonBGLeft:SetTexture("Interface\\AddOns\\Transmoggin\\Media\\woodland_left_exact.tga")
    salonBGLeft:SetPoint("TOPLEFT",0,0); salonBGLeft:SetPoint("BOTTOMLEFT",0,0)
    salonBGLeft:SetWidth(787)
    salonBGLeft:SetTexCoord(0,790/1024,0,880/1024); salonBGLeft:SetAlpha(1)
    local salonBGRight=bgFrame:CreateTexture(nil,"BACKGROUND")
    salonBGRight:SetTexture("Interface\\AddOns\\Transmoggin\\Media\\woodland_right_exact.tga")
    salonBGRight:SetPoint("TOPRIGHT",0,0); salonBGRight:SetPoint("BOTTOMRIGHT",0,0)
    salonBGRight:SetWidth(787)
    salonBGRight:SetTexCoord(0,790/1024,0,880/1024); salonBGRight:SetAlpha(1)
    self.salonBackgroundFrame=bgFrame
    self.salonBackgroundLeft=salonBGLeft; self.salonBackgroundRight=salonBGRight

    -- Gentle veil: enough contrast for text while keeping the forest visible.
    local salonShade=bgFrame:CreateTexture(nil,"BORDER")
    salonShade:SetAllPoints()
    salonShade:SetTexture("Interface\\Buttons\\WHITE8X8")
    salonShade:SetVertexColor(0,0,0,0.00)
    f:Hide(); table.insert(UISpecialFrames,"TransmogginSalon")

    -- top crown / branding
    local header=frame("Frame",nil,f); header:SetPoint("TOPLEFT",6,-6); header:SetPoint("TOPRIGHT",-6,-6); header:SetHeight(92); skin(header,{0.025,0.021,0.018,.70},{0.35,0.19,0.05,1})
    local flare=header:CreateTexture(nil,"BACKGROUND"); flare:SetTexture("Interface\\Buttons\\WHITE8X8"); flare:SetPoint("CENTER",0,0); flare:SetSize(480,2); flare:SetVertexColor(.75,.26,.04,.55)
    local logo=header:CreateTexture(nil,"ARTWORK"); logo:SetTexture(self.ROOSTER); logo:SetSize(66,66); logo:SetPoint("CENTER",-115,4)
    local title=fs(header,31,{0.98,0.68,0.22,1}); title:SetPoint("CENTER",55,15); title:SetText("TRANSMOGGIN")
    local tagline=fs(header,11,{0.78,0.62,0.38,1}); tagline:SetPoint("TOP",title,"BOTTOM",0,-3); tagline:SetText("YOUR STYLE. YOUR STORY.")
    self.playerBadge=fs(header,11,C.text); self.playerBadge:SetPoint("TOPRIGHT",-58,-18); self.playerBadge:SetJustifyH("RIGHT")
    self.vendorBadge=fs(header,10,C.muted); self.vendorBadge:SetPoint("TOPRIGHT",-58,-40); self.vendorBadge:SetJustifyH("RIGHT")
    local help=button(header,"HELP",58,25); help:SetPoint("TOPLEFT",12,-12); help:SetScript("OnClick",function() T:ToggleHelpPanel() end); self.helpButton=help
    self:AttachHelpTooltip(help,"Help & Options","Replay the beginner tutorial or turn button help tooltips on or off.","ANCHOR_BOTTOMLEFT")
    local guide=fs(header,9,{0.78,0.62,0.38,1}); guide:SetPoint("BOTTOM",0,9); guide:SetText("1 PICK A SLOT  >  2 CHOOSE A PIECE  >  3 SAVE OR TRACK  >  4 PURCHASE AT VENDOR")
    local close=CreateFrame("Button",nil,header,"UIPanelCloseButton"); close:SetPoint("TOPRIGHT",-4,-4); close:SetScript("OnClick",function() T:CloseSalon() end)
    self:AttachHelpTooltip(close,"Close Transmoggin","Closes the salon. If you are at a transmogrifier, this also ends the vendor interaction.","ANCHOR_BOTTOMRIGHT")

    -- left nav rail
    local rail=frame("Frame",nil,f); rail:SetPoint("TOPLEFT",6,-104); rail:SetPoint("BOTTOMLEFT",6,8); rail:SetWidth(74); skin(rail,{0.022,0.019,0.016,.72},{0.25,0.14,0.04,1})
    local navDefs={
        {"SALON","Interface\\AddOns\\Transmoggin\\Media\\salon"},
        {"WARDROBE","Interface\\AddOns\\Transmoggin\\Media\\wardrobe"},
        {"TRACKER","Interface\\AddOns\\Transmoggin\\Media\\tracker"},
        {"COLLECTIONS","Interface\\AddOns\\Transmoggin\\Media\\collections"},
        {"PHOTO","Interface\\AddOns\\Transmoggin\\Media\\photo"},
    }
    self.navButtons={}
    for i,n in ipairs(navDefs) do
        local b=button(rail,"",52,52); b:SetPoint("TOP",0,-14-(i-1)*72)
        b.icon=b:CreateTexture(nil,"ARTWORK"); b.icon:SetPoint("TOPLEFT",3,-3); b.icon:SetPoint("BOTTOMRIGHT",-3,3); b.icon:SetTexture(n[2]); b.icon:SetTexCoord(.03,.97,.03,.97)
        b.label=fs(rail,8,C.gold); b.label:SetPoint("TOP",b,"BOTTOM",0,-3); b.label:SetText(n[1])
        if i==1 then b:SetBackdropBorderColor(rgba(C.gold)) end
        self.navButtons[i]=b
    end
    self.navButtons[1]:SetScript("OnClick",function() T.state.leftMode="looks"; T:RefreshLeft() end)
    self.navButtons[2]:SetScript("OnClick",function() T.state.leftMode="sets"; T.state.setPage=1; T:RefreshLeft() end)
    self.navButtons[3]:SetScript("OnClick",function() T:ToggleTracker() end)
    self.navButtons[4]:SetScript("OnClick",function() T.state.leftMode="tracked"; T:RefreshLeft() end)
    self.navButtons[5]:SetScript("OnClick",function() T:TogglePhotoMode() end)
    self:AttachHelpTooltip(self.navButtons[1],"Salon","Your saved custom looks. Build, change, save, and purchase a look here.")
    self:AttachHelpTooltip(self.navButtons[2],"Wardrobe","Browse Blizzard armor sets available on this WoW client and preview them on your character.")
    self:AttachHelpTooltip(self.navButtons[3],"Tracker","Open the small missing-piece tracker while you play.")
    self:AttachHelpTooltip(self.navButtons[4],"Collections","Shows the looks you chose to track so you can finish collecting them.")
    self:AttachHelpTooltip(self.navButtons[5],"Photo Mode","Open the Photo Studio. The salon interface disappears so you can pose, animate, zoom, rotate, and photograph your current look.")

    -- three grand columns
    local left=frame("Frame",nil,f); left:SetPoint("TOPLEFT",84,-104); left:SetPoint("BOTTOMLEFT",84,8); left:SetWidth(330); skin(left,{0.030,0.026,0.021,.70},{0.26,0.15,0.05,1})
    local center=frame("Frame",nil,f); center:SetPoint("TOPLEFT",420,-104); center:SetPoint("BOTTOMLEFT",420,8); center:SetWidth(665); skin(center,{0.020,0.017,0.020,.52},{0.30,0.16,0.05,1})
    local right=frame("Frame",nil,f); right:SetPoint("TOPLEFT",1091,-104); right:SetPoint("BOTTOMRIGHT",-6,8); skin(right,{0.027,0.023,0.019,.70},{0.26,0.15,0.05,1})

    -- LEFT library cards
    self.leftTitle=fs(left,15,C.gold); self.leftTitle:SetPoint("TOPLEFT",16,-16); self.leftTitle:SetText("MY LOOKS")
    local newLook=button(left,"+ NEW LOOK",100,28); self.newLookButton=newLook; newLook:SetPoint("TOPRIGHT",-14,-10); newLook:SetScript("OnClick",function() T:ClearWorkingLook() end)
    self:AttachHelpTooltip(newLook,"New Look","Start a fresh outfit. Your saved looks are not deleted.")
    self.setExpansionButton=button(left,"ALL EXPANSIONS",150,28); self.setExpansionButton:SetPoint("TOPRIGHT",-14,-10); self.setExpansionButton:RegisterForClicks("LeftButtonUp","RightButtonUp"); self.setExpansionButton:SetScript("OnClick",function(_,btn) T:CycleSetExpansion(btn=="RightButton" and -1 or 1) end); self.setExpansionButton:Hide()
    self:AttachHelpTooltip(self.setExpansionButton,"Expansion Filter","Left-click moves forward through expansions. Right-click moves backward. Choose All Expansions to see every set this client exposes.")
    self.setOwnedButton=button(left,"OWNED: ON",92,24); self.setOwnedButton:SetPoint("TOPLEFT",14,-43); self.setOwnedButton:SetScript("OnClick",function()
        T.state.setOwnedOnly=not T.state.setOwnedOnly; T.state.setPage=1; T:RefreshLeft()
    end); self.setOwnedButton:Hide()
    self:AttachHelpTooltip(self.setOwnedButton,"Owned Sets Only","Show only Blizzard sets whose wearable pieces are fully collected. Click again to show every set.")

    self.setFactionButton=button(left,"FACTION: ALL",92,24); self.setFactionButton:SetPoint("LEFT",self.setOwnedButton,"RIGHT",6,0); self.setFactionButton:SetScript("OnClick",function()
        local cur=T.state.setFactionFilter or "all"
        if cur=="all" then cur="Alliance" elseif cur=="Alliance" then cur="Horde" else cur="all" end
        T.state.setFactionFilter=cur; T.state.setPage=1; T:RefreshLeft()
    end); self.setFactionButton:Hide()
    self:AttachHelpTooltip(self.setFactionButton,"Faction Filter","Cycle between all sets, Alliance-only sets, and Horde-only sets. Neutral sets appear only when the filter is set to All.")

    self.setWearabilityButton=button(left,"WEAR: ALL",92,24); self.setWearabilityButton:SetPoint("LEFT",self.setFactionButton,"RIGHT",6,0); self.setWearabilityButton:SetScript("OnClick",function()
        local cur=T.state.setWearabilityFilter or "all"
        if cur=="all" then cur="wearable" elseif cur=="wearable" then cur="notwearable" else cur="all" end
        T.state.setWearabilityFilter=cur; T.state.setPage=1; T:RefreshLeft()
    end); self.setWearabilityButton:Hide()
    self:AttachHelpTooltip(self.setWearabilityButton,"Wearability Filter","Cycle between all sets, sets wearable by this character, and sets not wearable by this character.")
    self.leftRows={}
    for i=1,13 do
        local r=frame("Button",nil,left); r:SetSize(300,46); r:SetPoint("TOPLEFT",14,-50-(i-1)*49); skin(r,{0.045,0.038,0.030,.98},{0.18,0.11,0.04,1})
        r.icon=r:CreateTexture(nil,"ARTWORK"); r.icon:SetSize(38,38); r.icon:SetPoint("LEFT",4,0); r.icon:SetTexture(self.ROOSTER); r.icon:SetTexCoord(.05,.95,.05,.95)
        r.name=fs(r,11,C.text); r.name:SetPoint("TOPLEFT",50,-8); r.name:SetPoint("RIGHT",-42,0); r.name:SetJustifyH("LEFT"); r.name:SetWordWrap(false); r.name:SetNonSpaceWrap(false)
        r.sub=fs(r,9,C.muted); r.sub:SetPoint("BOTTOMLEFT",50,7); r.sub:SetPoint("RIGHT",-42,0); r.sub:SetJustifyH("LEFT")
        r.star=fs(r,15,C.gold); r.star:SetPoint("RIGHT",-10,0)
        r:RegisterForClicks("LeftButtonUp","RightButtonUp"); r:SetScript("OnClick",function(self,btn) T:OnLeftRowClick(self,btn) end)
        r:HookScript("OnEnter",function(self)
            if self.kind=="set" then T:ShowHelpTooltip(self,"Armor Set","Left-click previews this complete set. Right-click adds the set directly to your Tracker so you can collect every missing piece.")
            elseif self.kind=="look" then T:ShowHelpTooltip(self,"Saved Look","Click to load this look. Tracked looks keep helping you find pieces you still need.") end
        end)
        r:HookScript("OnLeave",function() if GameTooltip then GameTooltip:Hide() end end)
        self.leftRows[i]=r
    end
    local save=button(left,"SAVE LOOK",126,32,"purple"); save:SetPoint("BOTTOMLEFT",14,12); save:SetScript("OnClick",function() T:PromptSaveLook() end)
    local track=button(left,"TRACK",76,32); track:SetPoint("LEFT",save,"RIGHT",6,0); track:SetScript("OnClick",function()
        local idx=T.state.working.loadedIndex; if idx then T:ToggleTrackLook(idx) else T:PromptSaveLook() end
    end)
    local del=button(left,"DELETE",76,32,"red"); del:SetPoint("LEFT",track,"RIGHT",6,0); del:SetScript("OnClick",function() if T.state.working.loadedIndex then T:DeleteLook(T.state.working.loadedIndex) end end)
    self:AttachHelpTooltip(save,"Save Look","Give the outfit a name and save it for later.")
    self:AttachHelpTooltip(track,"Track Missing Pieces","Track this saved look so Transmoggin can remind you which appearances are still missing.")
    self:AttachHelpTooltip(del,"Delete Look","Deletes the currently loaded saved look. This does not delete any WoW items or appearances.")

    -- Blizzard set catalog paging. GetAllSets can contain hundreds of sets, so
    -- never silently truncate the list to the first thirteen rows.
    self.setPrev=button(left,"< SETS",62,24); self.setPrev:SetPoint("BOTTOMLEFT",14,50); self.setPrev:SetScript("OnClick",function()
        if T.state.setPage>1 then T.state.setPage=T.state.setPage-1; T:RefreshLeft() end
    end)
    self.setPageText=fs(left,9,C.muted); self.setPageText:SetPoint("BOTTOM",0,57); self.setPageText:SetText("")
    self.setNext=button(left,"SETS >",62,24); self.setNext:SetPoint("BOTTOMRIGHT",-14,50); self.setNext:SetScript("OnClick",function()
        local sets=T:GetBlizzardSets(); local maxp=math.max(1,math.ceil(#sets/12))
        if T.state.setPage<maxp then T.state.setPage=T.state.setPage+1; T:RefreshLeft() end
    end)
    self:AttachHelpTooltip(self.setPrev,"Previous Sets","Show the previous page of Blizzard armor sets.")
    self:AttachHelpTooltip(self.setNext,"Next Sets","Show the next page of Blizzard armor sets.")
    self.setPrev:Hide(); self.setNext:Hide(); self.setPageText:Hide()

    -- CENTER salon stage
    self.lookTitle=fs(center,20,C.gold); self.lookTitle:SetPoint("TOPLEFT",18,-16); self.lookTitle:SetText("New Look")
    self.lookTitle:SetJustifyH("LEFT"); self.lookTitle:SetWordWrap(false); self.lookTitle:SetNonSpaceWrap(false)
    local reset=button(center,"RESET",58,26); reset:SetPoint("TOPRIGHT",-14,-12); reset:SetScript("OnClick",function() T:ClearWorkingLook() end)
    local redo=button(center,"REDO",48,26,"purple"); redo:SetPoint("RIGHT",reset,"LEFT",-7,0); redo:SetScript("OnClick",function() T:RedoWorking() end)
    local undo=button(center,"UNDO",48,26,"purple"); undo:SetPoint("RIGHT",redo,"LEFT",-4,0); undo:SetScript("OnClick",function() T:UndoWorking() end)
    self:AttachHelpTooltip(undo,"Undo","Go back one outfit change.")
    self:AttachHelpTooltip(redo,"Redo","Restore the last change you undid.")
    self:AttachHelpTooltip(reset,"Reset Look","Clear the current planned outfit and start over.")
    self.lookTitle:SetPoint("RIGHT",undo,"LEFT",-12,0)

    local stage=frame("Frame",nil,center); stage:SetPoint("TOPLEFT",14,-52); stage:SetPoint("TOPRIGHT",-14,-52); stage:SetHeight(565); skin(stage,{0.025,0.018,0.031,.22},{0.22,0.10,0.28,1})
    local glow=stage:CreateTexture(nil,"BACKGROUND"); glow:SetTexture("Interface\\Buttons\\WHITE8X8"); glow:SetPoint("CENTER",0,-20); glow:SetSize(300,500); glow:SetVertexColor(.13,.04,.18,.24)
    self.model=CreateFrame("DressUpModel",nil,stage); self.model:SetPoint("TOPLEFT",90,-12); self.model:SetPoint("BOTTOMRIGHT",-90,22); self.model:SetUnit("player"); self.model:EnableMouse(true)
    self.model:SetScript("OnMouseDown",function(m,btn) if btn=="LeftButton" then m.__drag=true; m.__x=GetCursorPosition() end end)
    self.model:SetScript("OnMouseUp",function(m) m.__drag=false end)
    self.model:SetScript("OnUpdate",function(m) if m.__drag and m.SetFacing then local x=GetCursorPosition(); local dx=(x-(m.__x or x))/180; m.__x=x; m:SetFacing((m:GetFacing() or 0)+dx) end end)

    self.slotButtons={}
    for i,s in ipairs(self.SLOTS) do
        local b=frame("Button",nil,stage); b:SetSize(145,38); skin(b,{0.035,0.028,0.040,.96},{0.28,0.11,0.36,1}); b.slotKey=s.key
        local leftSide=i<=7; local row=leftSide and i or i-7
        if leftSide then b:SetPoint("TOPLEFT",12,-62-(row-1)*58) else b:SetPoint("TOPRIGHT",-12,-62-(row-1)*58) end
        b.icon=b:CreateTexture(nil,"ARTWORK"); b.icon:SetSize(32,32); b.icon:SetPoint("LEFT",3,0); b.icon:SetTexture(134400); b.icon:SetTexCoord(.06,.94,.06,.94)
        b.txt=fs(b,9,C.text); b.txt:SetPoint("LEFT",40,0); b.txt:SetPoint("RIGHT",-4,0); b.txt:SetJustifyH("LEFT"); b.txt:SetText(s.label)
        b:RegisterForClicks("LeftButtonUp","RightButtonUp")
        b:SetScript("OnClick",function(self,btn)
            if btn=="RightButton" then
                T:SetWorkingPiece(self.slotKey,nil)
                T.state.selectedRecord=nil
                if T.SyncPendingToWorkingLook then T:SyncPendingToWorkingLook(0) end
                T:RefreshSlots(); T:RefreshModel(); T:RefreshAppearanceGrid(); T:RefreshVendorStatus()
                local have,total=T:CountLookProgress(T.state.working); T.progress:SetText(("LOOK COMPLETION   %d / %d COLLECTED"):format(have,total)); T.progressBar:SetMinMaxValues(0,math.max(1,total)); T.progressBar:SetValue(have)
            else
                T:SelectSlot(self.slotKey)
            end
        end)
        self:AttachHelpTooltip(b,s.label,"Left-click: browse this slot. Right-click: remove this slot from the planned transmog.",leftSide and "ANCHOR_RIGHT" or "ANCHOR_LEFT")
        self.slotButtons[s.key]=b
    end

    local rotate=fs(stage,9,C.muted); rotate:SetPoint("BOTTOM",0,10); rotate:SetText("DRAG TO ROTATE   /   SALON PREVIEW")

    local footer=frame("Frame",nil,center); footer:SetPoint("TOPLEFT",14,-626); footer:SetPoint("BOTTOMRIGHT",-14,14); skin(footer,{0.035,0.028,0.022,.68},{0.24,0.13,0.04,1})
    self.progress=fs(footer,12,C.gold); self.progress:SetPoint("TOPLEFT",14,-14); self.progress:SetText("Look progress: 0/0 collected")
    self.progressBar=CreateFrame("StatusBar",nil,footer); self.progressBar:SetSize(260,16); self.progressBar:SetPoint("TOPLEFT",14,-38); self.progressBar:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar"); self.progressBar:SetMinMaxValues(0,1); self.progressBar:SetValue(0)
    self.cost=fs(footer,12,C.text); self.cost:SetPoint("TOPRIGHT",-16,-18); self.cost:SetText("Cost shown at vendor")
    self.applyButton=button(footer,"PURCHASE XMOG",210,42); self.applyButton:SetPoint("BOTTOMRIGHT",-14,12)
    self.applyButton:SetScript("OnClick",function()
        local ok,msg=T:StageWorkingLook(); if not ok then T:Print(msg); return end
        local ok2,msg2=T:ApplyPending(); T:Print(ok2 and "Transmog applied." or msg2)
    end)
    self:AttachHelpTooltip(self.applyButton,"Purchase Xmog","Applies the collected pieces in this look. This button only works while you are at a transmogrifier vendor.","ANCHOR_TOP")
    local legend=fs(footer,9,C.muted); legend:SetPoint("BOTTOMLEFT",14,14); legend:SetText("GREEN = COLLECTED    RED = MISSING")
    local vendorHint=fs(footer,9,C.muted); vendorHint:SetPoint("BOTTOMRIGHT",-62,3); vendorHint:SetText("At Transmogrifier")

    -- RIGHT appearance browser
    self.slotTitle=fs(right,16,C.gold); self.slotTitle:SetPoint("TOPLEFT",14,-14); self.slotTitle:SetText("Head")
    self.search=CreateFrame("EditBox",nil,right,"InputBoxTemplate"); self.search:SetSize(250,28); self.search:SetPoint("TOPRIGHT",-76,-34)
    self.search:SetScript("OnTextChanged",function(e)
        T.state.search=(e:GetText() or ""):lower(); T.state.page=1; T.state.filteredAppearanceCache={}
        T.state.searchToken=(T.state.searchToken or 0)+1
        local token=T.state.searchToken
        local function refresh() if token==T.state.searchToken and T.main and T.main:IsShown() then T:RefreshAppearanceGrid() end end
        if C_Timer and C_Timer.After then C_Timer.After(0.12,refresh) else refresh() end
    end)
    self.search:SetScript("OnEscapePressed",function(e) e:SetText(""); e:ClearFocus() end)
    self:AttachHelpTooltip(self.search,"Search Appearances","Search the selected slot by appearance or verified source data. Boss, raid, dungeon, vendor, quest, profession, and location names are matched against Transmoggin HOW TO OBTAIN data. Your active filter still applies.","ANCHOR_BOTTOMLEFT")
    self.search:SetScript("OnEnterPressed",function(e) e:ClearFocus() end)
    self.clearSearchButton=button(right,"CLEAR",54,22)
    self.clearSearchButton:SetPoint("TOPRIGHT",-14,-35)
    self.clearSearchButton:SetScript("OnClick",function()
        if T.search then T.search:SetText(""); T.search:ClearFocus() end
        T.state.page=1
        T.state.filteredAppearanceCache={}
        T:RefreshAppearanceGrid()
    end)
    self:AttachHelpTooltip(self.clearSearchButton,"Clear Search","Clear the current appearance search and return to the full filtered list.")
    
    self.searchStatus=fs(right,9,C.muted)
    self.searchStatus:SetPoint("TOPLEFT",14,-58)
    self.searchStatus:SetText("")
    
    self.filterButtons={}
    local filterDefs={{"all","ALL",62},{"collected","COLLECTED",88},{"missing","MISSING",74},{"favorites","FAVORITES",88}}
    local prevFilter=nil
    for _,fd in ipairs(filterDefs) do
        local b=button(right,fd[2],fd[3],26); if not prevFilter then b:SetPoint("TOPLEFT",14,-76) else b:SetPoint("LEFT",prevFilter,"RIGHT",5,0) end
        b.filterKey=fd[1]; b:SetScript("OnClick",function(self) T.state.collectionFilter=self.filterKey; T.db.profile.filters.collection=self.filterKey; T.state.page=1; T.state.filteredAppearanceCache={}; T:RefreshAppearanceGrid() end)
        self.filterButtons[fd[1]]=b; prevFilter=b
    end
    self:AttachHelpTooltip(self.filterButtons.all,"Show All","Show every wearable appearance for the selected slot.")
    self:AttachHelpTooltip(self.filterButtons.collected,"Collected","Only show appearances you already own.")
    self:AttachHelpTooltip(self.filterButtons.missing,"Missing","Only show appearances you have not collected yet. You can still add these to a planned look and track them.")
    self:AttachHelpTooltip(self.filterButtons.favorites,"Favorites","Only show appearances you marked as favorites. Right-click an appearance tile to favorite or unfavorite it.")

    -- Random Outfit rolls the whole visible outfit in one pass, including weapons.
    -- The active collection filter controls the pool for every slot: COLLECTED,
    -- MISSING, ALL, or FAVORITES. Search text is intentionally ignored here so a
    -- slot-specific search cannot accidentally make most of the outfit empty.
    self.randomAppearanceButton=button(right,"RANDOM OUTFIT",112,26,"purple")
    self.randomAppearanceButton:SetPoint("LEFT",prevFilter,"RIGHT",5,0)
    self.randomAppearanceButton:SetScript("OnClick",function() T:ChooseRandomOutfit() end)
    self:AttachHelpTooltip(self.randomAppearanceButton,"Random Outfit","Builds a complete random look from Head through Main Hand and Off Hand. COLLECTED rolls only appearances you own, MISSING rolls only uncollected appearances, ALL mixes both, FAVORITES uses only your favorites, and GOALS uses only appearances on your collection goals list. Your search box does not affect the outfit roll.")

    self.wishlistFilterButton=button(right,"GOALS 0/0",154,22)
    self.wishlistFilterButton:SetPoint("TOPLEFT",14,-106)
    self.wishlistFilterButton.filterKey="wishlist"
    self.wishlistFilterButton:SetScript("OnClick",function(self)
        T.state.collectionFilter=self.filterKey; T.db.profile.filters.collection=self.filterKey; T.state.page=1; T.state.filteredAppearanceCache={}; T:RefreshAppearanceGrid()
    end)
    self:AttachHelpTooltip(self.wishlistFilterButton,"Collection Goals","Show your collection goals. The counter updates live as appearances are collected. Missing goals are listed first; when every goal is collected the button marks the list COMPLETE.")

    self.goalDoneButton=button(right,"HIDE DONE",86,22)
    self.goalDoneButton:SetPoint("LEFT",self.wishlistFilterButton,"RIGHT",6,0)
    self.goalDoneButton:SetScript("OnClick",function()
        T.db.profile.hideCompletedGoals=not T.db.profile.hideCompletedGoals
        T.state.page=1
        T.state.filteredAppearanceCache={}
        T:RefreshAppearanceGrid()
    end)
    self:AttachHelpTooltip(self.goalDoneButton,"Completed Goals","Hide completed goals while you work on the remaining appearances. Your GOALS counter still tracks the full list.")

    self.filterButton=self.filterButtons.all
    self.pageText=fs(right,10,C.muted); self.pageText:SetPoint("TOPRIGHT",-18,-104)

    self.appearanceButtons={}
    for i=1,30 do
        local b=frame("Button",nil,right); b:SetSize(68,68); skin(b,{0.018,0.016,0.015,1},{0.22,0.13,0.04,1})
        local col=(i-1)%6; local row=math.floor((i-1)/6); b:SetPoint("TOPLEFT",14+col*73,-134-row*73)
        b.icon=b:CreateTexture(nil,"ARTWORK"); b.icon:SetPoint("TOPLEFT",4,-4); b.icon:SetPoint("BOTTOMRIGHT",-4,4); b.icon:SetTexCoord(.06,.94,.06,.94)
        b.state=fs(b,13,C.text); b.state:SetPoint("BOTTOMRIGHT",-3,2); b.fav=fs(b,14,C.gold); b.fav:SetPoint("TOPLEFT",3,-2); b.wish=fs(b,12,{0.85,0.45,1,1}); b.wish:SetPoint("TOPRIGHT",-3,-2)
        b:RegisterForClicks("LeftButtonUp","RightButtonUp")
        b:SetScript("OnClick",function(self,btn) if not self.rec then return end; if btn=="RightButton" then T:ToggleFavorite(self.rec) else T:ChooseAppearance(self.rec) end end)
        b:SetScript("OnEnter",function(self) if self.rec then T:ShowAppearanceTooltip(self,self.rec); T:PreviewAppearance(self.rec) end end)
        b:SetScript("OnLeave",function() GameTooltip:Hide(); T.state.hoveredRecord=nil; T:RefreshModel() end)
        self.appearanceButtons[i]=b
    end
    local prev=button(right,"<",30,24); self.appearancePrev=prev; prev:SetPoint("TOPLEFT",14,-504); prev:SetScript("OnClick",function()
        if T.state.page>1 then T.state.page=T.state.page-1; T:RefreshAppearanceGrid() end
    end)
    self:AttachHelpTooltip(prev,"Previous Page","Show the previous page of appearances.")
    local next=button(right,">",30,24); self.appearanceNext=next; next:SetPoint("LEFT",prev,"RIGHT",356,0); next:SetScript("OnClick",function()
        local apps=T:GetFilteredAppearances(); local maxp=math.max(1,math.ceil(#apps/T.state.pageSize))
        if T.state.page<maxp then T.state.page=T.state.page+1; T:RefreshAppearanceGrid() end
    end)
    self:AttachHelpTooltip(next,"Next Page","Show the next page of appearances.")

    self.sourceBox=frame("Frame",nil,right); self.sourceBox:SetPoint("TOPLEFT",14,-539); self.sourceBox:SetPoint("BOTTOMRIGHT",-14,14); skin(self.sourceBox,{0.038,0.030,0.026,.68},{0.24,0.13,0.04,1})
    self.sourceIcon=self.sourceBox:CreateTexture(nil,"ARTWORK"); self.sourceIcon:SetSize(92,92); self.sourceIcon:SetPoint("TOPRIGHT",-14,-14); self.sourceIcon:SetTexture(self.ROOSTER); self.sourceIcon:SetTexCoord(.05,.95,.05,.95)
    self.sourceTitle=fs(self.sourceBox,15,{0.82,0.45,0.95,1}); self.sourceTitle:SetPoint("TOPLEFT",14,-16); self.sourceTitle:SetPoint("RIGHT",self.sourceIcon,"LEFT",-12,0); self.sourceTitle:SetJustifyH("LEFT"); self.sourceTitle:SetText("Choose an appearance")
    self.sourceState=fs(self.sourceBox,10,C.green); self.sourceState:SetPoint("TOPLEFT",14,-42); self.sourceState:SetPoint("RIGHT",self.sourceIcon,"LEFT",-12,0); self.sourceState:SetJustifyH("LEFT")
    self.sourceSummary=fs(self.sourceBox,9,C.muted); self.sourceSummary:SetPoint("TOPLEFT",14,-57); self.sourceSummary:SetPoint("RIGHT",self.sourceIcon,"LEFT",-12,0); self.sourceSummary:SetJustifyH("LEFT")
    -- Scrollable acquisition panel. Long source lists, crafting reagents and
    -- alternate appearance routes can now be read without overflowing the box.
    self.sourceScroll=CreateFrame("ScrollFrame",nil,self.sourceBox,"UIPanelScrollFrameTemplate")
    self.sourceScroll:SetPoint("TOPLEFT",14,-82); self.sourceScroll:SetPoint("BOTTOMRIGHT",-32,44)
    self.sourceScroll:EnableMouseWheel(true)
    self.sourceScrollChild=CreateFrame("Frame",nil,self.sourceScroll)
    self.sourceScrollChild:SetSize(1,1); self.sourceScroll:SetScrollChild(self.sourceScrollChild)
    self.sourceText=fs(self.sourceScrollChild,10,C.text); self.sourceText:SetPoint("TOPLEFT",0,0); self.sourceText:SetPoint("TOPRIGHT",0,0); self.sourceText:SetJustifyH("LEFT"); self.sourceText:SetJustifyV("TOP"); self.sourceText:SetWordWrap(true); self.sourceText:SetText("Select a piece to see where it comes from.")
    function T:RefreshSourceScroll()
        if not self.sourceScroll or not self.sourceScrollChild or not self.sourceText then return end
        local w=math.max(40,(self.sourceScroll:GetWidth() or 40)-4)
        self.sourceScrollChild:SetWidth(w); self.sourceText:SetWidth(w)
        local textH=math.max(20,self.sourceText:GetStringHeight() or 20)
        local viewH=math.max(20,self.sourceScroll:GetHeight() or 20)
        self.sourceScrollChild:SetHeight(math.max(viewH,textH+8))
        local maxScroll=math.max(0,self.sourceScrollChild:GetHeight()-viewH)
        if self.sourceScroll:GetVerticalScroll()>maxScroll then self.sourceScroll:SetVerticalScroll(maxScroll) end
    end
    self.sourceScroll:SetScript("OnMouseWheel",function(scroll,delta)
        T:RefreshSourceScroll()
        local childH=T.sourceScrollChild and T.sourceScrollChild:GetHeight() or 0
        local viewH=scroll:GetHeight() or 0
        local maxScroll=math.max(0,childH-viewH)
        local nextPos=math.max(0,math.min(maxScroll,(scroll:GetVerticalScroll() or 0)-(delta*34)))
        scroll:SetVerticalScroll(nextPos)
    end)
    self.sourceAttribution=fs(self.sourceBox,8,C.muted); self.sourceAttribution:SetPoint("BOTTOMLEFT",14,12); self.sourceAttribution:SetPoint("RIGHT",-92,0); self.sourceAttribution:SetText("SOURCE DETAILS: BLIZZARD API + LOCAL TRANSMOGGIN DB  |  GOAL-AWARE")
    self.wishlistButton=button(self.sourceBox,"-/+ GOAL",66,24,"purple")
    self.wishlistButton:SetPoint("BOTTOMRIGHT",self.sourceBox,"BOTTOMRIGHT",-14,8)
    self.wishlistButton:SetScript("OnClick",function()
        if T.state.selectedRecord then T:ToggleWishlist(T.state.selectedRecord) end
    end)
    self:AttachHelpTooltip(self.wishlistButton,"Collection Goal","Add or remove this appearance from your collection goals. The same button toggles the goal on or off.","ANCHOR_TOPRIGHT")
    function T:RefreshWishlistButton()
        if not self.wishlistButton then return end
        local rec=self.state and self.state.selectedRecord
        local wished=rec and self:IsVisualWishlisted(rec.visualID)
        if self.wishlistButton.txt then self.wishlistButton.txt:SetText("-/+ GOAL") end
        self.wishlistButton:SetAlpha(rec and 1 or .45)
        self.wishlistButton:EnableMouse(rec and true or false)
    end

    self.state.pageSize=30
    self:SelectSlot("HEADSLOT")
    if self.RefreshSourceSummary then self:RefreshSourceSummary(rec) end
end

function T:RefreshHelpPanel()
    if not self.helpPanel or not self.db then return end
    local on=self:TooltipsEnabled()
    self.helpPanel.tooltipToggle.txt:SetText(on and "BUTTON TOOLTIPS: ON" or "BUTTON TOOLTIPS: OFF")
    if self.helpPanel.tipState then
        self.helpPanel.tipState:SetText(on and "Helpful explanations appear when you hover over buttons." or "Button explanations are disabled. The tutorial can still be replayed anytime.")
        self.helpPanel.tipState:SetTextColor(on and .35 or .95,on and .95 or .55,on and .42 or .20)
    end
    if self.helpPanel.chatScanCheck then
        local scanOn=not (self.db.profile.help.chatScans==false)
        self.helpPanel.chatScanCheck:SetChecked(scanOn)
        if self.helpPanel.chatScanState then
            self.helpPanel.chatScanState:SetText(scanOn and "Vendor scan messages are shown in chat." or "Vendor scanning stays active, but chat messages are hidden.")
            self.helpPanel.chatScanState:SetTextColor(scanOn and .35 or .95,scanOn and .95 or .70,scanOn and .42 or .25)
        end
    end
end

function T:CreateHelpPanel()
    if self.helpPanel or not self.main then return end
    local p=frame("Frame",nil,self.main); self.helpPanel=p
    p:SetSize(430,365); p:SetPoint("CENTER",0,10); p:SetFrameStrata("DIALOG"); p:SetFrameLevel(self.main:GetFrameLevel()+80); p:EnableMouse(true)
    skin(p,{0.025,0.020,0.018,0.995},{0.70,0.39,0.08,1},2); p:Hide()
    local t=fs(p,23,C.gold); t:SetPoint("TOP",0,-20); t:SetText("HELP & ACCESSIBILITY")
    local body=fs(p,12,C.text); body:SetPoint("TOPLEFT",24,-60); body:SetPoint("RIGHT",-24,0); body:SetJustifyH("LEFT"); body:SetWordWrap(true)
    body:SetText("Transmoggin is designed so you can learn it by hovering over controls. Keep button tooltips on while learning, then turn them off whenever you feel comfortable.")
    p.tipState=fs(p,11,C.green); p.tipState:SetPoint("TOPLEFT",24,-122); p.tipState:SetPoint("RIGHT",-24,0); p.tipState:SetJustifyH("LEFT")
    local toggle=button(p,"BUTTON TOOLTIPS: ON",190,34,"purple"); p.tooltipToggle=toggle; toggle:SetPoint("TOP",0,-155); toggle:SetScript("OnClick",function()
        T.db.profile.help.tooltips=not T:TooltipsEnabled(); T:RefreshHelpPanel(); if GameTooltip then GameTooltip:Hide() end
    end)
    local chatCheck=CreateFrame("CheckButton",nil,p,"UICheckButtonTemplate"); p.chatScanCheck=chatCheck
    chatCheck:SetSize(28,28); chatCheck:SetPoint("TOPLEFT",112,-202)
    local chatLabel=fs(p,12,C.text); chatLabel:SetPoint("LEFT",chatCheck,"RIGHT",4,0); chatLabel:SetText("SHOW VENDOR SCAN MESSAGES IN CHAT")
    chatCheck:SetScript("OnClick",function(self)
        T.db.profile.help.chatScans=self:GetChecked() and true or false
        T:RefreshHelpPanel()
    end)
    p.chatScanState=fs(p,10,C.muted); p.chatScanState:SetPoint("TOP",0,-232)
    local replay=button(p,"REPLAY BEGINNER TUTORIAL",210,34); replay:SetPoint("TOP",0,-258); replay:SetScript("OnClick",function() p:Hide(); T:ShowTutorial(true) end)
    local close=button(p,"CLOSE",90,30); close:SetPoint("BOTTOM",0,16); close:SetScript("OnClick",function() p:Hide() end)
    self:RefreshHelpPanel()
end

function T:ToggleHelpPanel()
    if not self.helpPanel then self:CreateHelpPanel() end
    if not self.helpPanel then return end
    if self.helpPanel:IsShown() then self.helpPanel:Hide() else self:RefreshHelpPanel(); self.helpPanel:Show() end
end

function T:CreateTutorial()
    if self.tutorial or not self.main then return end
    local ov=frame("Frame",nil,self.main); self.tutorial=ov
    ov:SetAllPoints(self.main); ov:SetFrameStrata("DIALOG"); ov:SetFrameLevel(self.main:GetFrameLevel()+120); ov:EnableMouse(true); ov:Hide()
    local shade=ov:CreateTexture(nil,"BACKGROUND"); shade:SetAllPoints(); shade:SetTexture("Interface\\Buttons\\WHITE8X8"); shade:SetVertexColor(0,0,0,.72)
    local card=frame("Frame",nil,ov); card:SetSize(650,430); card:SetPoint("CENTER",0,5); skin(card,{0.030,0.024,0.020,1},{0.78,0.43,0.09,1},2)
    local logo=card:CreateTexture(nil,"ARTWORK"); logo:SetTexture(self.ROOSTER); logo:SetSize(76,76); logo:SetPoint("TOP",0,-18)
    ov.pageTitle=fs(card,24,C.gold); ov.pageTitle:SetPoint("TOP",0,-106)
    ov.pageBody=fs(card,15,C.text); ov.pageBody:SetPoint("TOPLEFT",52,-154); ov.pageBody:SetPoint("RIGHT",-52,0); ov.pageBody:SetHeight(170); ov.pageBody:SetJustifyH("LEFT"); ov.pageBody:SetJustifyV("TOP"); ov.pageBody:SetWordWrap(true)
    ov.pageCount=fs(card,10,C.muted); ov.pageCount:SetPoint("BOTTOM",0,72)
    ov.back=button(card,"BACK",90,34); ov.back:SetPoint("BOTTOMLEFT",42,24); ov.back:SetScript("OnClick",function() if ov.page>1 then ov.page=ov.page-1; T:RefreshTutorial() end end)
    ov.skip=button(card,"SKIP TUTORIAL",120,34); ov.skip:SetPoint("BOTTOM",0,24); ov.skip:SetScript("OnClick",function() T.db.profile.help.tutorialSeen=true; ov:Hide() end)
    ov.next=button(card,"NEXT",90,34,"purple"); ov.next:SetPoint("BOTTOMRIGHT",-42,24); ov.next:SetScript("OnClick",function()
        if ov.page<#T.tutorialPages then ov.page=ov.page+1; T:RefreshTutorial() else T.db.profile.help.tutorialSeen=true; ov:Hide(); T:Print("Tutorial complete. Click HELP anytime to replay it.") end
    end)
end

T.tutorialPages={
    {"Welcome to Your Transmog Salon","Transmoggin has two jobs: make changing your appearance easy, and help you collect the pieces you still need. You can plan a look anywhere. You can only purchase and apply the transmog while talking to a real transmogrifier vendor."},
    {"Step 1 - Pick a Gear Slot","Click Head, Shoulders, Chest, Hands, Weapon, or another gear slot around your character. The matching appearances immediately appear on the right side."},
    {"Step 2 - Choose the Look You Want","Click an appearance on the right to put it into your planned outfit. GREEN means you already collected that appearance. RED means it is still missing. You may plan with missing pieces anyway. Right-click a tile to favorite it."},
    {"Step 3 - Save It or Track It","SAVE LOOK keeps the outfit in your salon. TRACK adds the look to your missing-piece tracker so you can hunt down anything you do not own yet. Blizzard Sets in the Wardrobe tab can also be loaded and customized."},
    {"Step 4 - Visit the Transmogrifier","When you talk to a transmogrifier, Transmoggin enters Salon Mode and PURCHASE XMOG becomes available. Review the character in the middle, then purchase when you are happy. Hover over any button if you forget what it does. Click HELP to replay this tutorial or disable button tooltips."},
}

function T:RefreshTutorial()
    local ov=self.tutorial; if not ov then return end
    local p=self.tutorialPages[ov.page or 1]; if not p then return end
    ov.pageTitle:SetText(p[1]); ov.pageBody:SetText(p[2]); ov.pageCount:SetText(("PAGE %d OF %d"):format(ov.page,#self.tutorialPages))
    ov.back:SetAlpha(ov.page>1 and 1 or .35); ov.back:EnableMouse(ov.page>1)
    ov.next.txt:SetText(ov.page==#self.tutorialPages and "FINISH" or "NEXT")
end

function T:ShowTutorial(force)
    if not self.db or not self.main then return end
    if not force and self.db.profile.help.tutorialSeen then return end
    if not self.tutorial then self:CreateTutorial() end
    if not self.tutorial then return end
    self.tutorial.page=1; self:RefreshTutorial(); self.tutorial:Show()
end

function T:MaybeShowFirstUseTutorial()
    if self.db and self.db.profile and self.db.profile.help and not self.db.profile.help.tutorialSeen then
        if C_Timer and C_Timer.After then C_Timer.After(.15,function() if T.main and T.main:IsShown() then T:ShowTutorial(false) end end) else self:ShowTutorial(false) end
    end
end

function T:ToggleMain()
    if not self.main then self:EnsureUI() end
    if not self.main then return end
    if self.main:IsShown() then
        self.main:Hide()
    else
        if self.FitSalonToScreen then self:FitSalonToScreen() end
        self.main:Show()
        self:RefreshAll()
        self:MaybeShowFirstUseTutorial()
    end
end

function T:PromptSaveLook()
    if not StaticPopupDialogs["TRANSMOGGIN_SAVE_LOOK_V3"] then
        StaticPopupDialogs["TRANSMOGGIN_SAVE_LOOK_V3"]={text="Name your Transmoggin look:",button1="Save",button2="Cancel",hasEditBox=true,timeout=0,whileDead=true,hideOnEscape=true,preferredIndex=3,
            OnAccept=function(self) local e=self.EditBox or self.editBox; T:SaveLook(e and e:GetText() or "") end,
            EditBoxOnEnterPressed=function(self) T:SaveLook(self:GetText()); self:GetParent():Hide() end}
    end
    local p=StaticPopup_Show("TRANSMOGGIN_SAVE_LOOK_V3"); if p then local e=p.EditBox or p.editBox; if e then e:SetText(T:CurrentLookName()=="New Look" and "" or T:CurrentLookName()); e:HighlightText() end end
end

function T:SelectSlot(key)
    local s=self:GetSlotDef(key); if not s then return end
    self.state.selectedSlot=key; self.state.selectedCategory=self:GetEffectiveCategory(s); self.state.page=1; self.state.filteredAppearanceCache={}
    if self:IsWeaponSlot(s) then
        self.slotTitle:SetText(s.label.." - Compatible Weapons")
    else
        self.slotTitle:SetText(s.label)
    end
    self:RefreshSlots(); self:RefreshAppearanceGrid()
end


function T:SortGoalAppearances(apps)
    if type(apps)~="table" then return apps end
    table.sort(apps,function(a,b)
        local ac=false
        local bc=false
        if a and a.visualID then
            local categoryID=a.__categoryID or self.state.selectedCategory
            ac=self:IsVisualCollected(a.visualID,categoryID) and true or false
        end
        if b and b.visualID then
            local categoryID=b.__categoryID or self.state.selectedCategory
            bc=self:IsVisualCollected(b.visualID,categoryID) and true or false
        end
        if ac~=bc then return not ac end
        local an=tostring((a and (a.name or a.itemName)) or "")
        local bn=tostring((b and (b.name or b.itemName)) or "")
        return an<bn
    end)
    return apps
end


function T:ClearSourceSearchCache()
    if self.state then self.state.authoritativeSearchCache={} end
end

function T:GetAppearanceSearchText(a,categoryID,slot)
    if not a then return "" end

    if self.HydrateSpecialAppearanceRecord then
        a=self:HydrateSpecialAppearanceRecord(a) or a
    end

    self.state.authoritativeSearchCache=self.state.authoritativeSearchCache or {}
    local visualID=a.visualID
    local cacheKey=tostring(visualID or 0)..":"..tostring(categoryID or 0)
    local cached=self.state.authoritativeSearchCache[cacheKey]
    if cached then return cached end

    local parts={}
    local seenText={}
    local function add(v)
        if v==nil then return end
        v=tostring(v)
        if v=="" then return end
        local lower=v:lower()
        if not seenText[lower] then
            seenText[lower]=true
            parts[#parts+1]=v
        end
    end

    add(a.name)
    add(a.itemName)
    add(a.appearanceName)
    add(a.sourceText)
    add(a.description)
    add(a.specialAppearanceProvider)
    add(a.specialCatalogSource)

    local rec
    if visualID then
        rec=self:GetSourceRecord(visualID,categoryID,nil,slot or self:GetSlotDef(self.state.selectedSlot))
    end

    local itemIDs={}
    local seenItems={}
    local sourceIDs={}
    local seenSources={}

    local function addItemID(id)
        id=tonumber(id)
        if id and id>0 and not seenItems[id] then
            seenItems[id]=true
            itemIDs[#itemIDs+1]=id
        end
    end

    local function addSourceID(src)
        local sid
        if type(src)=="table" then
            sid=src.sourceID or src.itemModifiedAppearanceID or src.visualID
            add(src.name)
            add(src.itemName)
            add(src.sourceText)
            add(src.instance)
            add(src.encounter)
            add(src.zone)
        else
            sid=src
        end
        sid=tonumber(sid)
        if sid and sid>0 and not seenSources[sid] then
            seenSources[sid]=true
            sourceIDs[#sourceIDs+1]=sid
        end
    end

    if rec then
        add(rec.name)
        add(rec.itemName)
        add(rec.sourceText)
        addItemID(rec.itemID)
        addSourceID(rec.sourceID)
        addSourceID(rec.itemModID)
        for _,src in ipairs(rec.allSources or {}) do addSourceID(src) end
    end

    -- Resolve every source ID to its source item. This is the important bridge
    -- from an appearance to every item that can unlock it.
    for _,sid in ipairs(sourceIDs) do
        local itemID
        if C_TransmogCollection and C_TransmogCollection.GetSourceItemID then
            local ok,id=pcall(C_TransmogCollection.GetSourceItemID,sid)
            if ok then itemID=id end
        end
        if (not itemID or itemID==0) and self.GetGeneratedItemIDForModifiedAppearance then
            itemID=self:GetGeneratedItemIDForModifiedAppearance(sid)
        end
        addItemID(itemID)
    end

    -- Local generated/static source database. On MoP Classic this contains
    -- encounter -> instance -> difficulty records produced from the client DBs.
    if self.GetSourceDBRecord then
        for _,itemID in ipairs(itemIDs) do
            local d=self:GetSourceDBRecord(itemID,nil)
            if d then
                add(d.itemName)
                add(d.kind)
                add(d.encounter)
                add(d.instance)
                add(d.difficulty)
                add(d.zone)
                add(d.vendor)
                add(d.questName)
                add(d.quest)
                if type(d.quests)=="table" then
                    for _,q in ipairs(d.quests) do
                        add(q.quest); add(q.giver); add(q.zone); add(q.rewardType); if q.questID then add(tostring(q.questID)) end
                        for _,g in ipairs(type(q.starters)=="table" and q.starters or {}) do
                            add(g[2]); add(g[3]); if g[1] then add(tostring(g[1])) end
                        end
                        for _,e in ipairs(type(q.enders)=="table" and q.enders or {}) do
                            add(e[2]); add(e[3]); if e[1] then add(tostring(e[1])) end
                        end
                    end
                end
                add(d.profession)
                add(d.recipe)

                for _,enc in ipairs(d.encounters or {}) do
                    add(enc.encounter)
                    add(enc.instance)
                    add(enc.zone)
                    add(enc.difficulty)
                    for _,alias in ipairs(type(enc.aliases)=="table" and enc.aliases or {}) do add(alias) end
                end
                for _,setRow in ipairs(d.sets or {}) do
                    add(setRow.name)
                end
            end
        end
    end

    -- Retail quest search stays source-indexed and static. Do not resolve NPC
    -- tooltip names here: search/grid refresh can touch hundreds of appearances.
    -- Quest titles and map names are safe O(1) lookups from the shipped corpus.
    if self.clientMode=="retail" and self.GeneratedRetailQuestBySource then
        for _,sid in ipairs(sourceIDs) do
            local qrow=self.GeneratedRetailQuestBySource[sid]
            if type(qrow)=="table" then
                local qid=tonumber(qrow[1])
                add(qid and self.GeneratedRetailQuestNames and self.GeneratedRetailQuestNames[qid])
                local giverID=tonumber(qrow[2])
                add(giverID and self.GeneratedRetailQuestEntityNames and self.GeneratedRetailQuestEntityNames[giverID])
                local mapID=tonumber(qrow[3])
                if mapID and C_Map and C_Map.GetMapInfo then
                    local ok,mi=pcall(C_Map.GetMapInfo,mapID)
                    if ok and type(mi)=="table" then add(mi.name) end
                end
                local endRows=qid and self.GeneratedRetailQuestEnders and self.GeneratedRetailQuestEnders[qid]
                for _,erow in ipairs(type(endRows)=="table" and endRows or {}) do
                    local eid=tonumber(erow[1])
                    add(eid and self.GeneratedRetailQuestEntityNames and self.GeneratedRetailQuestEntityNames[eid])
                    local emap=tonumber(erow[2])
                    if emap and C_Map and C_Map.GetMapInfo then
                        local ok,mi=pcall(C_Map.GetMapInfo,emap)
                        if ok and type(mi)=="table" then add(mi.name) end
                    end
                end
            end
        end
    end

    -- Retail: ask Blizzard's appearance-source API directly for encounter data.
    -- This keeps Retail boss/raid searches tied to the same source graph Blizzard
    -- exposes rather than relying on locally generated MoP data.
    if C_TransmogCollection and C_TransmogCollection.GetAppearanceSourceDrops then
        for _,sid in ipairs(sourceIDs) do
            local ok,drops=pcall(C_TransmogCollection.GetAppearanceSourceDrops,sid)
            if ok and type(drops)=="table" then
                for _,d in ipairs(drops) do
                    add(d.encounter)
                    add(d.instance)
                    add(d.instanceType)
                    for _,difficulty in ipairs(type(d.difficulties)=="table" and d.difficulties or {}) do
                        add(difficulty)
                    end
                    for _,tier in ipairs(type(d.tiers)=="table" and d.tiers or {}) do
                        add(tier)
                    end
                end
            end
        end
    end

    local result=table.concat(parts," "):lower()
    self.state.authoritativeSearchCache[cacheKey]=result
    return result
end


function T:AugmentSpecialAppearanceCatalog(apps,categoryID,slotDef)
    if type(apps)~="table" then apps={} end

    local out={}
    local seen={}
    local function addAppearance(a)
        if type(a)~="table" then return end
        local visualID=tonumber(a.visualID or a.appearanceID)
        if not visualID or visualID==0 then return end
        if seen[visualID] then return end

        local rec=self.GetSourceRecord and self:GetSourceRecord(visualID,categoryID,a.sourceID or a.itemModifiedAppearanceID,slotDef) or nil
        if rec then
            -- Preserve client-provided flags where available.
            rec.isCollected=a.isCollected~=nil and a.isCollected or rec.collected
            rec.isUsable=a.isUsable
            rec.isFavorite=a.isFavorite
            rec.visualID=visualID
            seen[visualID]=true
            out[#out+1]=rec
        end
    end

    -- Start with Blizzard's normal appearance catalog.
    for _,a in ipairs(apps) do addAppearance(a) end

    -- Previously resolved Blizzard Set appearances are kept in a tiny
    -- runtime registry, avoiding any full DB scan.
    if self.state.specialAppearanceRegistry then
        for visualID,a in pairs(self.state.specialAppearanceRegistry) do
            visualID=tonumber(visualID)
            if visualID and visualID~=0 and not seen[visualID] then
                local rec=self.GetSourceRecord and self:GetSourceRecord(
                    visualID,
                    a.categoryID or categoryID,
                    a.sourceID or a.itemModifiedAppearanceID,
                    slotDef
                ) or nil
                if rec and (not rec.categoryID or not categoryID or tonumber(rec.categoryID)==tonumber(categoryID)) then
                    rec.specialCatalogSource="resolved-set"
                    seen[visualID]=true
                    out[#out+1]=rec
                end
            end
        end
    end

    -- Merchant discoveries are pre-indexed by category at vendor-scan time.
    -- This keeps appearance refresh O(current-category discoveries), never O(all vendors learned).
    if self.EnsureMerchantIndexes then self:EnsureMerchantIndexes() end
    local merchantBucket=self.db and self.db.merchantCatalogByCategory and self.db.merchantCatalogByCategory[tonumber(categoryID)]
    if type(merchantBucket)=="table" then
        for visualID,learned in pairs(merchantBucket) do
            visualID=tonumber(visualID)
            if visualID and visualID~=0 and not seen[visualID] then
                local learnedCategory=tonumber(learned.categoryID) or tonumber(categoryID)
                local sourceID=learned.sourceID or learned.itemModifiedAppearanceID
                local rec=self:GetSourceRecord(
                    visualID,
                    learnedCategory,
                    sourceID,
                    slotDef
                )

                -- Blizzard can omit legacy vendor appearances from its normal
                -- appearance catalog. The vendor scan already proved the exact
                -- item/source relationship, so do not discard the discovery just
                -- because the generic catalog resolver cannot reconstruct it.
                if not rec then
                    rec={
                        visualID=visualID,
                        appearanceID=visualID,
                        sourceID=sourceID,
                        itemModID=sourceID,
                        itemModifiedAppearanceID=sourceID,
                        itemID=learned.itemID,
                        itemName=learned.itemName,
                        name=learned.itemName or ("Vendor Appearance "..tostring(visualID)),
                        icon=learned.icon or 134400,
                        categoryID=learnedCategory,
                        allSources=sourceID and {{sourceID=sourceID}} or {},
                        sourceText="Verified vendor appearance",
                        specialCatalogSource=learned.specialCatalogSource or "merchant-retail",
                        merchantVerified=true,
                    }
                end

                if rec and ((not self.IsPieceWearable) or self:IsPieceWearable(rec,slotDef)) then
                    rec.itemID=rec.itemID or learned.itemID
                    rec.itemName=rec.itemName or learned.itemName
                    rec.name=rec.name or learned.itemName
                    rec.icon=rec.icon or learned.icon
                    rec.specialCatalogSource=learned.specialCatalogSource or "merchant"
                    seen[visualID]=true
                    out[#out+1]=rec
                end
            end
        end
    end

    -- Never enumerate the complete generated item database from the Salon hot path.
    -- Special appearances are supplied by direct set/source resolution and
    -- verified merchant memory.

    return out
end

function T:GetFilteredAppearances()
    if not self.cap.collection then return {} end
    self.state.filteredAppearanceCache=self.state.filteredAppearanceCache or {}
    local q=self.state.search or ""; local filter=self.state.collectionFilter
    local fk=tostring(self.state.selectedCategory)..":"..tostring(self.state.selectedSlot)..":"..filter..":"..q
    local cached=self.state.filteredAppearanceCache[fk]
    if cached then return cached end
    local slot=self:GetSlotDef(self.state.selectedSlot)
    local categoryID=self.state.selectedCategory or self:GetEffectiveCategory(slot)
    local apps=self:GetSlotAppearanceCatalog(slot); local out={}
    -- Pass the selected slot category explicitly.  The old nil category let the
    -- special-set registry leak Head/Legs/Hands/etc. into one another during
    -- source searches (most visible with Challenge Mode set-name searches).
    apps=self:AugmentSpecialAppearanceCatalog(apps,categoryID,slot)

    -- Fast-path ordinary item/appearance-name searches before touching acquisition
    -- resolution. Vendor-discovered appearances retain their itemName in the
    -- category cache, so searches for a specific newly learned vendor item stay
    -- O(catalog size) string checks instead of invoking source APIs for every tile.
    local cheapMatches={}
    local cheapMatchCount=0
    if q~="" then
        for _,a in ipairs(apps) do
            local text=table.concat({
                tostring(a.name or ""),
                tostring(a.itemName or ""),
                tostring(a.appearanceName or ""),
                tostring(a.specialAppearanceProvider or ""),
                tostring(a.specialCatalogSource or ""),
                tostring(a.sourceText or ""),
            }," "):lower()
            if text:find(q,1,true) then
                cheapMatches[a]=true
                cheapMatchCount=cheapMatchCount+1
            end
        end
    end

    for _,a in ipairs(apps) do
        local categoryID=a.__categoryID or self.state.selectedCategory
        local collected=self:IsVisualCollected(a.visualID,categoryID)
        local pass=(filter=="all") or (filter=="collected" and collected) or (filter=="missing" and not collected) or (filter=="favorites" and (a.isFavorite or self:IsVisualFavorite(a.visualID))) or (filter=="wishlist" and self:IsVisualWishlisted(a.visualID)
            and (not self.db.profile.hideCompletedGoals or not collected))
        if pass then
            if q=="" then
                out[#out+1]=a
            elseif cheapMatchCount>0 then
                if cheapMatches[a] then out[#out+1]=a end
            else
                local hay=self:GetAppearanceSearchText(a,categoryID,slot)
                if hay:find(q,1,true) then out[#out+1]=a end
            end
        end
    end
    self.state.filteredAppearanceCache[fk]=out
    if self.state.collectionFilter=="wishlist" then self:SortGoalAppearances(out) end
    return out
end

function T:GetRandomPoolForSlot(slotDef, filter)
    if not slotDef then return {} end
    local defaultCategoryID=self:GetEffectiveCategory(slotDef)
    local apps=self:GetSlotAppearanceCatalog(slotDef)
    local out={}
    for _,a in ipairs(apps or {}) do
        local categoryID=a.__categoryID or defaultCategoryID
        local collected=self:IsVisualCollected(a.visualID,categoryID)
        local pass=(filter=="all")
            or (filter=="collected" and collected)
            or (filter=="missing" and not collected)
            or (filter=="favorites" and (a.isFavorite or self:IsVisualFavorite(a.visualID))) or (filter=="wishlist" and self:IsVisualWishlisted(a.visualID))
        if pass then out[#out+1]=a end
    end
    return out,defaultCategoryID
end

function T:ChooseRandomOutfit()
    if not self.cap.collection then
        self:Print("Appearance collection is not available on this client.")
        return
    end

    if self.PrepareCollectionCatalog then self:PrepareCollectionCatalog(false) end
    local filter=self.state.collectionFilter or "all"
    local previous=self:CloneWorking()
    local rolled={}
    local skipped={}

    -- One undo step restores the entire previous outfit.
    self:PushUndo()

    for _,slotDef in ipairs(self.SLOTS) do
        local pool,categoryID=self:GetRandomPoolForSlot(slotDef,filter)
        if pool and #pool>0 then
            local chosen=pool[math.random(1,#pool)]
            local chosenCategory=chosen and (chosen.__categoryID or categoryID) or categoryID
            local rec=chosen and self:GetSourceRecord(chosen.visualID,chosenCategory,nil,slotDef) or nil
            if rec then
                rec.collected=self:IsPieceCollected(rec)
                self:SetWorkingPiece(slotDef.key,rec,true)
                rolled[#rolled+1]=slotDef.label
            else
                self.state.working.pieces[slotDef.key]=nil
                skipped[#skipped+1]=slotDef.label
            end
        else
            -- No valid result for this slot under the requested filter. Clear it
            -- instead of silently leaving an old piece from the previous outfit.
            self.state.working.pieces[slotDef.key]=nil
            skipped[#skipped+1]=slotDef.label
        end
    end

    self.state.working.name="Random Look"
    self.state.working.dirty=true

    if #rolled==0 then
        self.state.working=previous
        if self.state.undo and #self.state.undo>0 then table.remove(self.state.undo) end
        self:Print("No appearances matched the "..filter:upper().." filter for this character.")
        if self.RefreshAll then self:RefreshAll() end
        return
    end

    -- Keep the browser focused on the currently selected slot, but dress the model
    -- only once after all pieces are chosen to avoid a burst of expensive refreshes.
    self.state.filteredAppearanceCache={}
    if self.RefreshAll then self:RefreshAll() end
    if self:IsAtTransmogVendor() and self.SyncPendingToWorkingLook then
        self:SyncPendingToWorkingLook(0)
    end

    -- Intentionally silent: Random Outfit may be clicked repeatedly while browsing.
    -- Keep chat output reserved for actual errors or actions that need player attention.
end


function T:RefreshGoalCounter()
    if not self.wishlistFilterButton then return end
    local collected,total=self:GetRealtimeGoalProgress()
    if self.wishlistFilterButton.txt then local remaining=math.max(0,total-collected)
        if total>0 and remaining==0 then
            self.wishlistFilterButton.txt:SetText(string.format("GOALS %d/%d - COMPLETE",collected,total))
        else
            self.wishlistFilterButton.txt:SetText(string.format("GOALS %d/%d",collected,total))
        end end
end

function T:RefreshAppearanceGrid()
    if not self.main then return end
    if self.PrepareCollectionCatalog then self:PrepareCollectionCatalog(false) end
    if not self.cap.collection then self.pageText:SetText("Collection API unavailable"); for _,b in ipairs(self.appearanceButtons) do b:Hide() end; return end
    local apps=self:GetFilteredAppearances(); local maxp=math.max(1,math.ceil(#apps/self.state.pageSize)); if self.state.page>maxp then self.state.page=maxp end
    if self.searchStatus then
        local query=self.search and self.search:GetText() or ""
        query=query and query:gsub("^%s+",""):gsub("%s+$","") or ""
        if query~="" then
            if #apps==0 then
                self.searchStatus:SetText(("No verified source matches for \"%s\""):format(query))
            else
                self.searchStatus:SetText(("%d verified result%s for \"%s\""):format(#apps,#apps==1 and "" or "s",query))
            end
        else
            self.searchStatus:SetText("")
        end
    end
    if self.clearSearchButton then
        local hasSearch=self.search and self.search:GetText() and self.search:GetText()~=""
        self.clearSearchButton:SetAlpha(hasSearch and 1 or .35)
        self.clearSearchButton:EnableMouse(hasSearch and true or false)
    end
    if self.state.page<1 then self.state.page=1 end
    local startIndex=(self.state.page-1)*self.state.pageSize+1
    if self.state.collectionFilter=="wishlist" then
        local collected,total=self:GetRealtimeGoalProgress()
        local remaining=math.max(0,total-collected)
        if self.db.profile.hideCompletedGoals then
            if total>0 and remaining==0 then
                self.pageText:SetText("ALL GOALS COMPLETE")
            else
                self.pageText:SetText(("%d remaining / %d total"):format(remaining,total))
            end
        else
            self.pageText:SetText(("%d collected / %d remaining / %d total"):format(collected,remaining,total))
        end
    else
        self.pageText:SetText(("%d / %d   /   %d wearable"):format(self.state.page,maxp,#apps))
    end
    if self.filterButtons then
        for key,b in pairs(self.filterButtons) do
            local active=key==self.state.collectionFilter
            if b.SetBackdropBorderColor then local border=active and C.gold or C.gold2; b:SetBackdropBorderColor(rgba(border)) end
            if b.txt then b.txt:SetTextColor(active and C.gold[1] or C.text[1],active and C.gold[2] or C.text[2],active and C.gold[3] or C.text[3],1) end
        end
    end
    if self.wishlistFilterButton then
        local active=self.state.collectionFilter=="wishlist"
        local collected,total=self:GetRealtimeGoalProgress()
        if self.wishlistFilterButton.txt then self.wishlistFilterButton.txt:SetText(string.format("GOALS %d/%d",collected,total)) end
        if self.wishlistFilterButton.SetBackdropBorderColor then
            self.wishlistFilterButton:SetBackdropBorderColor(rgba(active and C.gold or C.gold2))
        end
        if self.wishlistFilterButton.txt then
            self.wishlistFilterButton.txt:SetTextColor(
                active and C.gold[1] or C.text[1],
                active and C.gold[2] or C.text[2],
                active and C.gold[3] or C.text[3],1
            )
        end
    end
    if self.goalDoneButton then
        local goalsActive=self.state.collectionFilter=="wishlist"
        self.goalDoneButton:SetAlpha(goalsActive and 1 or .35)
        self.goalDoneButton:EnableMouse(goalsActive)
        if self.goalDoneButton.txt then
            self.goalDoneButton.txt:SetText(self.db.profile.hideCompletedGoals and "SHOW DONE" or "HIDE DONE")
        end
    end
    if self.appearancePrev then self.appearancePrev:SetAlpha(self.state.page>1 and 1 or .35); self.appearancePrev:EnableMouse(self.state.page>1) end
    if self.appearanceNext then self.appearanceNext:SetAlpha(self.state.page<maxp and 1 or .35); self.appearanceNext:EnableMouse(self.state.page<maxp) end
    for i,b in ipairs(self.appearanceButtons) do
        local a=apps[startIndex+i-1]
        if a then
            local categoryID=a.__categoryID or self.state.selectedCategory
            local rec=self:GetSourceRecord(a.visualID,categoryID,nil,self:GetSlotDef(self.state.selectedSlot)); local collected=rec and self:IsPieceCollected(rec) or false
            if rec then rec.collected=collected end
            b.rec=rec; b.icon:SetTexture((rec and rec.icon) or 134400); b.state:SetText(collected and "|cff55ff66C|r" or "|cffff5555M|r"); b.fav:SetText((a.isFavorite or self:IsVisualFavorite(a.visualID)) and "F" or ""); b.wish:SetText((self:IsVisualWishlisted(a.visualID) and not collected) and "G" or "")
            local destination=rec and self:GetAppearanceDestinationSlot(rec) or self.state.selectedSlot
            local p=self:GetWorkingPiece(destination or self.state.selectedSlot)
            if b.SetBackdropBorderColor then b:SetBackdropBorderColor(rgba((p and p.visualID==a.visualID) and C.gold or C.gold2)) end
            b:Show()
        else b.rec=nil; b:Hide() end
    end
end


function T:GetSourceRouteCount(rec)
    if not rec then return 0 end

    local seen={}
    local count=0

    local function addSourceID(v)
        local sid
        if type(v)=="table" then
            sid=v.sourceID or v.itemModifiedAppearanceID or v.visualID
        else
            sid=v
        end
        if sid~=nil then
            local key=tostring(sid)
            if not seen[key] then
                seen[key]=true
                count=count+1
            end
        end
    end

    if rec.sourceID then addSourceID(rec.sourceID) end
    if rec.itemModID then addSourceID(rec.itemModID) end
    for _,src in ipairs(rec.allSources or {}) do addSourceID(src) end

    -- A resolved item is still a valid route even when Blizzard did not populate
    -- allSources for this client.
    if count==0 and rec.itemID then count=1 end

    return count
end

function T:RefreshSourceSummary(rec)
    if not self.sourceSummary then return end
    if not rec then
        self.sourceSummary:SetText("")
        return
    end

    local wished=self:IsVisualWishlisted(rec.visualID)
    local collected=rec.collected and true or false
    local routes=self:GetSourceRouteCount(rec)

    local parts={}
    if wished then parts[#parts+1]="GOAL TARGET" end
    parts[#parts+1]=collected and "COLLECTED" or "MISSING APPEARANCE"

    if routes>0 then
        parts[#parts+1]=routes==1 and "1 WAY TO OBTAIN" or (tostring(routes).." WAYS TO OBTAIN")
    end

    self.sourceSummary:SetText(table.concat(parts,"  |  "))
end

local SEARCH_EQUIPLOC_TO_SLOT={
    INVTYPE_HEAD="HEADSLOT",
    INVTYPE_SHOULDER="SHOULDERSLOT",
    INVTYPE_CLOAK="BACKSLOT",
    INVTYPE_CHEST="CHESTSLOT",
    INVTYPE_ROBE="CHESTSLOT",
    INVTYPE_BODY="SHIRTSLOT",
    INVTYPE_TABARD="TABARDSLOT",
    INVTYPE_WRIST="WRISTSLOT",
    INVTYPE_HAND="HANDSSLOT",
    INVTYPE_WAIST="WAISTSLOT",
    INVTYPE_LEGS="LEGSSLOT",
    INVTYPE_FEET="FEETSLOT",
    INVTYPE_2HWEAPON="MAINHANDSLOT",
    INVTYPE_WEAPONMAINHAND="MAINHANDSLOT",
    INVTYPE_WEAPONOFFHAND="SECONDARYHANDSLOT",
    INVTYPE_SHIELD="SECONDARYHANDSLOT",
    INVTYPE_HOLDABLE="SECONDARYHANDSLOT",
}

local SEARCH_CATEGORY_TO_SLOT={
    [1]="HEADSLOT", [2]="SHOULDERSLOT", [3]="BACKSLOT", [4]="CHESTSLOT",
    [5]="SHIRTSLOT", [6]="TABARDSLOT", [7]="WRISTSLOT", [8]="HANDSSLOT",
    [9]="WAISTSLOT", [10]="LEGSSLOT", [11]="FEETSLOT",
}

function T:GetAppearanceDestinationSlot(rec)
    if not rec then return nil end

    -- Exact item inventory type is authoritative for armor and explicit
    -- main/off-hand weapon records. Generic one-hand weapons intentionally
    -- remain ambiguous so the player's selected weapon hand is respected.
    local itemID=tonumber(rec.itemID)
    if itemID and GetItemInfoInstant then
        local _,_,_,equipLoc=GetItemInfoInstant(itemID)
        if equipLoc and equipLoc~="INVTYPE_WEAPON" then
            local key=SEARCH_EQUIPLOC_TO_SLOT[equipLoc]
            if key then return key end
        end
    end

    -- Armor transmog categories 1-11 have a stable one-to-one slot mapping.
    -- This also protects cached/special appearances before item data is loaded.
    local categoryID=tonumber(rec.categoryID or rec.__categoryID)
    return categoryID and SEARCH_CATEGORY_TO_SLOT[categoryID] or nil
end

function T:ChooseAppearance(rec)
    if not rec then return end
    rec.collected=self:IsPieceCollected(rec)

    local destination=self:GetAppearanceDestinationSlot(rec) or self.state.selectedSlot
    self:SetWorkingPiece(destination,rec)
    self.state.selectedRecord=rec
    if self.RefreshWishlistButton then self:RefreshWishlistButton() end
    if self.RefreshGoalCounter then self:RefreshGoalCounter() end
    self.sourceIcon:SetTexture(rec.icon or 134400); self.sourceTitle:SetText(rec.name or "Appearance")
    local wished=self:IsVisualWishlisted(rec.visualID)
    if wished then
        self.sourceState:SetText(rec.collected and "GOAL COMPLETE" or "GOAL TARGET - HOW TO OBTAIN")
    else
        self.sourceState:SetText(rec.collected and "COLLECTED - ready for the salon" or "MISSING - HOW TO OBTAIN")
    end
    self.sourceState:SetTextColor(rec.collected and C.green[1] or C.red[1],rec.collected and C.green[2] or C.red[2],rec.collected and C.green[3] or C.red[3],1)
    if self.RefreshSourceSummary then self:RefreshSourceSummary(rec) end
    local sourceText=self:EnsureSourceText(rec)
    self.sourceText:SetText((sourceText or "Source unavailable")..((rec.allSources and #rec.allSources>1) and ("\n\n"..#rec.allSources.." different items can unlock this appearance.") or ""))
    if self.sourceScroll then self.sourceScroll:SetVerticalScroll(0) end
    if C_Timer and C_Timer.After then C_Timer.After(0,function() if T and T.RefreshSourceScroll then T:RefreshSourceScroll() end end) else self:RefreshSourceScroll() end
    if self.state.vendorOpen and self.SyncPendingToWorkingLook then self:SyncPendingToWorkingLook(0) end
    -- A click only changes one slot/model and one selection border. Avoid rebuilding
    -- the full set list and rescanning the entire appearance catalog.
    self:RefreshSlots(); self:RefreshModel(); self:RefreshAppearanceGrid(); self:RefreshVendorStatus()
    local have,total=self:CountLookProgress(self.state.working); self.progress:SetText(("LOOK COMPLETION   %d / %d COLLECTED"):format(have,total)); self.progressBar:SetMinMaxValues(0,math.max(1,total)); self.progressBar:SetValue(have)
end
function T:PreviewAppearance(rec)
    if not rec then return end
    self.state.hoveredRecord=rec
    -- Mouse movement across a grid can fire dozens of previews per second. Coalesce
    -- them so only the final hovered tile rebuilds the DressUpModel.
    self.state.hoverPreviewToken=(self.state.hoverPreviewToken or 0)+1
    local token=self.state.hoverPreviewToken
    local function run() if token==T.state.hoverPreviewToken and T.state.hoveredRecord==rec then T:RefreshModel(true) end end
    if C_Timer and C_Timer.After then C_Timer.After(0.035,run) else run() end
end
local function TryOnSucceeded(ok,result)
    if not ok then return false end

    -- Some client branches return nil on success, others return an enum/reason.
    -- Treat nil/true/0 as success. Nonzero numeric or explicit false means the
    -- client rejected the dress operation (for example NotEquippable).
    if result==nil or result==true then return true end
    if result==false then return false end
    if type(result)=="number" then return result==0 end

    -- Unknown non-nil return values are not trusted as success.
    return false
end

local function TryOnPiece(model,piece)
    if not model or not piece then return false end

    local sourceID=piece.previewSourceID or piece.sourceID or piece.itemModID
    local visualID=piece.visualID
    local itemID=piece.itemID
    local itemLink=piece.itemLink

    -- 1) Exact item dressing-room path. This is the method Blizzard's normal
    -- dressing room uses for arbitrary item previews when available.
    if model.TryOn then
        if itemLink and itemLink~="" then
            local ok,result=pcall(model.TryOn,model,itemLink)
            if TryOnSucceeded(ok,result) then return true end
        end

        if itemID and itemID~=0 then
            local ok,result=pcall(model.TryOn,model,"item:"..tostring(itemID))
            if TryOnSucceeded(ok,result) then return true end
        end
    end

    -- 2) Modified appearance/source ID. This can preview uncollected wardrobe
    -- appearances on clients where the exact item is rejected.
    if sourceID and sourceID~=0 then
        if model.SetItemAppearance then
            local ok,result=pcall(model.SetItemAppearance,model,sourceID)
            if TryOnSucceeded(ok,result) then return true end
        end

        if model.TryOn then
            local ok,result=pcall(model.TryOn,model,sourceID)
            if TryOnSucceeded(ok,result) then return true end
        end
    end

    -- 3) Visual appearance ID fallback. Some Classic branches accept the broad
    -- visual ID through SetItemAppearance instead of modified appearance ID.
    if visualID and visualID~=0 and model.SetItemAppearance then
        local ok,result=pcall(model.SetItemAppearance,model,visualID)
        if TryOnSucceeded(ok,result) then return true end
    end

    -- 4) Last itemID numeric fallback for older DressUpModel implementations.
    if itemID and itemID~=0 and model.TryOn then
        local ok,result=pcall(model.TryOn,model,itemID)
        if TryOnSucceeded(ok,result) then return true end
    end

    return false
end

function T:RefreshModel(hover)
    if not self.model then return end
    self.model:SetUnit("player")
    if self.model.Undress then self.model:Undress() end
    for _,slot in ipairs(self.SLOTS) do
        local piece=self.state.working.pieces[slot.key]
        if piece then TryOnPiece(self.model,piece) end
    end
    local h=self.state.hoveredRecord
    if hover and h then TryOnPiece(self.model,h) end
end
function T:RefreshSlots()
    if not self.slotButtons then return end
    for _,s in ipairs(self.SLOTS) do local b=self.slotButtons[s.key]; local p=self:GetWorkingPiece(s.key)
        if p then b.icon:SetTexture(p.icon or 134400); b.txt:SetText(s.label.."\n"..(p.name or "Selected")); b.txt:SetTextColor(p.collected and C.green[1] or C.red[1],p.collected and C.green[2] or C.red[2],p.collected and C.green[3] or C.red[3],1)
        else b.icon:SetTexture(GetInventoryItemTexture and GetInventoryItemTexture("player",s.slotID) or 134400); b.txt:SetText(s.label); b.txt:SetTextColor(rgba(C.text)) end
        if b.SetBackdropBorderColor then
            local border = (s.key==self.state.selectedSlot) and C.gold or {0.28,0.11,0.36,1}
            b:SetBackdropBorderColor(rgba(border))
        end
    end
end
function T:OnLeftRowClick(r,btn)
    if not r.data then return end
    if r.kind=="look" then
        if btn=="RightButton" then self:ToggleTrackLook(r.index) else self:LoadLook(r.index) end
    elseif r.kind=="set" then
        if btn=="RightButton" then self:TrackBlizzardSet(r.data) else self:LoadBlizzardSet(r.data) end
    end
end

local EXPANSION_NAMES={
    [0]="Classic", [1]="The Burning Crusade", [2]="Wrath of the Lich King", [3]="Cataclysm",
    [4]="Mists of Pandaria", [5]="Warlords of Draenor", [6]="Legion", [7]="Battle for Azeroth",
    [8]="Shadowlands", [9]="Dragonflight", [10]="The War Within", [11]="Midnight",
}


function T:GetClientMaxExpansionID()
    -- MoP Classic 5.5.x must never surface metadata from Retail-only expansions.
    if self.interface and self.interface>=50500 and self.interface<60000 then return 4 end
    return nil
end

function T:GetExpansionName(id)
    id=tonumber(id)
    if id==nil then return "Unknown expansion" end
    if _G.GetExpansionDisplayInfo then
        local ok,info=pcall(_G.GetExpansionDisplayInfo,id)
        if ok and type(info)=="table" and info.name then return info.name end
    end
    return EXPANSION_NAMES[id] or ("Expansion "..tostring(id))
end

function T:ClassMaskAllowsSet(setInfo,ctx)
    if not setInfo then return false end
    if setInfo.validForCharacter~=nil then return setInfo.validForCharacter and true or false end

    -- Generated/special sets can carry an explicit class label even when the
    -- Blizzard set row has no classMask on this client.
    if setInfo.className and ctx and ctx.className and setInfo.className~=ctx.className then
        return false
    end
    local mask=tonumber(setInfo.classMask or 0) or 0
    if mask>0 and ctx and ctx.classID and ctx.classID>0 then
        local classBit=2^(ctx.classID-1)
        local band=(bit and bit.band) or (bit32 and bit32.band)
        if band then
            if band(mask,classBit)==0 then return false end
        else
            -- Fallback without a bit library: classBit is a power of two.
            if math.floor(mask/classBit)%2==0 then return false end
        end
    end
    local req=setInfo.requiredFaction
    if req and req~="" and UnitFactionGroup then
        local faction=UnitFactionGroup("player")
        if faction and faction~=req then return false end
    end
    return true
end



local MOP_CHALLENGE_MODE_SETS={
    [1436]={class="Warrior",name="Golden King Plate"},
    [1437]={class="Warlock",name="Betrayer Regalia"},
    [1438]={class="Shaman",name="Windfury Armor"},
    [1439]={class="Rogue",name="Silent Assassin Armor"},
    [1440]={class="Priest",name="Light Regalia"},
    [1441]={class="Paladin",name="Holy Warrior Plate"},
    [1442]={class="Monk",name="Regal Lord Armor"},
    [1443]={class="Mage",name="Elemental Triad Regalia"},
    [1444]={class="Hunter",name="Howling Beast Armor"},
    [1445]={class="Druid",name="Cycle Armor"},
    [1446]={class="Death Knight",name="Lich Lord Plate"},
}

function T:IsMoPChallengeModeSet(setID)
    return MOP_CHALLENGE_MODE_SETS[tonumber(setID)]~=nil
end

function T:DecorateMoPChallengeModeSet(setInfo)
    if not setInfo then return setInfo end
    local setID=tonumber(setInfo.setID or setInfo.ID or setInfo.id)
    local row=MOP_CHALLENGE_MODE_SETS[setID]
    if not row then return setInfo end

    setInfo.specialSet=true
    setInfo.challengeModeSet=true
    setInfo.specialFamily="Pandaria Challenge Mode"
    setInfo.className=row.class
    setInfo.name=row.name or setInfo.name
    setInfo.expansionID=4
    return setInfo
end

function T:IsRetailSpecialEventSet(setInfo)
    if not setInfo then return false end

    -- Prefer explicit metadata whenever Blizzard provides it.
    if setInfo.isSpecial or setInfo.isEvent or setInfo.isChallengeMode
        or setInfo.isMythic or setInfo.isMythicPlus or setInfo.isRemix
        or setInfo.isTimewalking then
        return true
    end

    -- Retail branches do not expose one universal "special set" flag.
    -- Use conservative metadata/name classification only for set discovery;
    -- actual pieces still come from Blizzard's set/source APIs.
    local text=table.concat({
        tostring(setInfo.name or ""),
        tostring(setInfo.description or ""),
        tostring(setInfo.label or ""),
        tostring(setInfo.source or ""),
        tostring(setInfo.sourceText or ""),
    }," "):lower()

    local keywords={
        "challenge mode",
        "challenge dungeon",
        "mythic+",
        "mythic plus",
        "mythic dungeon",
        "remix",
        "timewalking",
        "anniversary",
        "trading post",
        "event",
        "mage tower",
        "delve",
    }

    for _,keyword in ipairs(keywords) do
        if text:find(keyword,1,true) then return true end
    end
    return false
end

function T:DecorateRetailSpecialSet(setInfo)
    if not setInfo then return setInfo end
    if self.clientMode=="retail" and self:IsRetailSpecialEventSet(setInfo) then
        setInfo.specialSet=true
        setInfo.retailSpecialSet=true
        setInfo.specialFamily=setInfo.specialFamily or "Challenge / Mythic / Event"
    end
    return setInfo
end

local SPECIAL_SET_EQUIP_TO_SLOT={
    INVTYPE_HEAD="HEADSLOT",
    INVTYPE_SHOULDER="SHOULDERSLOT",
    INVTYPE_CLOAK="BACKSLOT",
    INVTYPE_CHEST="CHESTSLOT",
    INVTYPE_ROBE="CHESTSLOT",
    INVTYPE_WRIST="WRISTSLOT",
    INVTYPE_HAND="HANDSSLOT",
    INVTYPE_WAIST="WAISTSLOT",
    INVTYPE_LEGS="LEGSSLOT",
    INVTYPE_FEET="FEETSLOT",
    INVTYPE_WEAPON="MAINHANDSLOT",
    INVTYPE_2HWEAPON="MAINHANDSLOT",
    INVTYPE_WEAPONMAINHAND="MAINHANDSLOT",
    INVTYPE_WEAPONOFFHAND="SECONDARYHANDSLOT",
    INVTYPE_SHIELD="SECONDARYHANDSLOT",
    INVTYPE_HOLDABLE="SECONDARYHANDSLOT",
}

function T:GetGeneratedSpecialSetInfo(setID)
    setID=tonumber(setID)
    if not setID or not self.GeneratedSetSources or not self.GeneratedSpecialSetItems then return nil end
    local meta=self.GeneratedSetSources[setID]
    local items=self.GeneratedSpecialSetItems[setID]
    if not meta or not items or #items==0 then return nil end
    local info={
        setID=setID,
        id=setID,
        name=meta[1] or ("Special Set "..tostring(setID)),
        expansionID=tonumber(meta[2]),
        specialGenerated=true,
        specialSet=true,
        baseSetID=setID,
        uiOrder=100000+setID,
    }
    if self:IsMoPChallengeModeSet(setID) then
        info=self:DecorateMoPChallengeModeSet(info)
        info.uiOrder=1000+(setID-1436)
    end
    return info
end

function T:GetGeneratedSpecialSetPiece(setID,slotDef)
    if not setID or not slotDef or not self.GeneratedSpecialSetItems then return nil end
    local rows=self.GeneratedSpecialSetItems[tonumber(setID)]
    if type(rows)~="table" then return nil end

    local function equipSlotForItem(itemID)
        if not GetItemInfoInstant then return nil end
        local _,_,_,equipLoc=GetItemInfoInstant(itemID)
        return SPECIAL_SET_EQUIP_TO_SLOT[equipLoc]
    end

    local candidates={}
    for _,row in ipairs(rows) do
        local itemID=tonumber(row[1])
        local generatedVisualID=tonumber(row[2])
        local generatedSourceID=tonumber(row[3])

        if itemID and equipSlotForItem(itemID)==slotDef.key then
            local visualID,sourceID

            -- Live Blizzard IDs first when this client exposes them.
            if C_TransmogCollection and C_TransmogCollection.GetItemInfo then
                local ok,a,m=pcall(C_TransmogCollection.GetItemInfo,itemID)
                if ok then
                    visualID=tonumber(a)
                    sourceID=tonumber(m)
                end
            end

            -- Merchant-observed exact identity next.
            if (not visualID or visualID==0) and self.GetVerifiedMerchantRecord then
                local learned=self:GetVerifiedMerchantRecord(itemID)
                if learned then
                    visualID=tonumber(learned.appearanceID)
                    sourceID=tonumber(learned.modifiedAppearanceID)
                end
            end

            -- Generated IDs are authoritative fallback for special/legacy sets.
            if not visualID or visualID==0 then visualID=generatedVisualID end
            if not sourceID or sourceID==0 then sourceID=generatedSourceID end

            if visualID and visualID~=0 then
                candidates[#candidates+1]={
                    itemID=itemID,
                    visualID=visualID,
                    sourceID=sourceID,
                }
            end
        end
    end

    if #candidates==0 then return nil end

    -- Prefer a collected source only for display convenience. Class/spec
    -- usability is deliberately irrelevant in the Sets browser.
    local chosen=candidates[1]
    for _,c in ipairs(candidates) do
        if c.sourceID and self:SourceIsCollected(c.sourceID) then
            chosen=c
            break
        end
    end

    local categoryID=self:GetEffectiveCategory(slotDef)
    local rec
    if self.GetSetBrowserSourceRecord then
        rec=self:GetSetBrowserSourceRecord(
            chosen.visualID,
            categoryID,
            chosen.sourceID,
            chosen.itemID,
            slotDef
        )
    end

    if not rec then return nil end

    rec.specialSet=true
    rec.specialSetID=setID
    rec.liveSpecialSetSource=true
    rec.previewSourceID=chosen.sourceID or rec.previewSourceID

    -- Keep this source available to Salon search, but normal Salon filtering
    -- still decides whether the active character may actually wear it.
    if rec.visualID then
        self.state.specialAppearanceRegistry=self.state.specialAppearanceRegistry or {}
        self.state.specialAppearanceRegistry[rec.visualID]={
            visualID=rec.visualID,
            sourceID=rec.sourceID or rec.itemModID,
            itemModifiedAppearanceID=rec.itemModID or rec.sourceID,
            itemID=rec.itemID,
            itemName=rec.itemName or rec.name,
            categoryID=rec.categoryID,
            isCollected=rec.collected,
        }
    end

    return rec
end

function T:GetSetIcon(setInfo)
    if not setInfo then return self.ROOSTER end
    local id=setInfo.setID or setInfo.ID or setInfo.id
    if not id then return self.ROOSTER end

    if setInfo.specialGenerated and self.GeneratedSpecialSetItems then
        local rows=self.GeneratedSpecialSetItems[tonumber(id)]
        if type(rows)=="table" then
            for _,row in ipairs(rows) do
                local itemID=tonumber(row[1])
                if itemID and GetItemInfoInstant then
                    local icon=select(5,GetItemInfoInstant(itemID))
                    if icon then return icon end
                end
            end
        end
    end
    self.state.setIconCache=self.state.setIconCache or {}
    if self.state.setIconCache[id] then return self.state.setIconCache[id] end
    local icon=nil
    if C_TransmogSets and C_TransmogSets.GetSourcesForSlot then
        for _,slot in ipairs(self.SLOTS) do
            if slot.slotID~=4 and slot.slotID~=19 then
                local ok,sources=pcall(C_TransmogSets.GetSourcesForSlot,id,slot.slotID)
                if ok and type(sources)=="table" and #sources>0 then
                    local src=sources[1]
                    local itemID=type(src)=="table" and src.itemID or nil
                    if not itemID and type(src)=="table" and src.sourceID and C_TransmogCollection and C_TransmogCollection.GetSourceItemID then
                        local okItem,res=pcall(C_TransmogCollection.GetSourceItemID,src.sourceID); if okItem then itemID=res end
                    end
                    if itemID and GetItemInfoInstant then icon=select(5,GetItemInfoInstant(itemID)) end
                    if icon then break end
                end
            end
        end
    end
    if not icon and C_TransmogSets and C_TransmogSets.GetAllSourceIDs then
        local ok,ids=pcall(C_TransmogSets.GetAllSourceIDs,id)
        if ok and type(ids)=="table" then
            for _,sid in ipairs(ids) do
                local itemID=nil
                if C_TransmogCollection and C_TransmogCollection.GetSourceItemID then local oi,ri=pcall(C_TransmogCollection.GetSourceItemID,sid); if oi then itemID=ri end end
                if itemID and GetItemInfoInstant then icon=select(5,GetItemInfoInstant(itemID)) end
                if icon then break end
            end
        end
    end
    self.state.setIconCache[id]=icon or self.ROOSTER
    return self.state.setIconCache[id]
end

function T:GetSetExpansionIDs()
    local all=self:GetBlizzardSets(true); local seen={}; local ids={}
    for _,setInfo in ipairs(all) do
        local id=tonumber(setInfo.expansionID)
        if id~=nil and not seen[id] then seen[id]=true; ids[#ids+1]=id end
    end
    table.sort(ids,function(a,b) return a>b end)
    return ids
end

function T:CycleSetExpansion(direction)
    direction=direction or 1
    local ids=self:GetSetExpansionIDs(); if #ids==0 then return end
    local current=self.state.setExpansionFilter or "all"
    local choices={"all"}; for _,id in ipairs(ids) do choices[#choices+1]=id end
    local pos=1; for i,v in ipairs(choices) do if tostring(v)==tostring(current) then pos=i break end end
    pos=pos+direction; if pos>#choices then pos=1 elseif pos<1 then pos=#choices end
    self.state.setExpansionFilter=choices[pos]; self.state.setPage=1; self.state.page=1; self:RefreshLeft()
end

function T:IsBlizzardSetOwned(setInfo)
    if not setInfo then return false end
    if setInfo.collected~=nil then return setInfo.collected and true or false end
    local setID=setInfo.setID or setInfo.ID or setInfo.id
    if not setID then return false end
    self.state.setOwnedCache=self.state.setOwnedCache or {}
    if self.state.setOwnedCache[setID]~=nil then return self.state.setOwnedCache[setID] end
    local look,found=self:BuildBlizzardSetLook(setInfo)
    local owned=false
    if look and found and found>0 then
        local have,total=self:CountLookProgress(look)
        owned=(total>0 and have>=total)
    end
    self.state.setOwnedCache[setID]=owned
    return owned
end


-- Keep faction filtering metadata-only. Do not inspect set pieces/tooltips here;
-- this function runs during paged Sets browsing and must stay cheap.
function T:GetSetFactionLabel(setInfo)
    if not setInfo then return nil end
    local req=setInfo.requiredFaction or setInfo.faction or setInfo.factionName
    if type(req)=="string" and req~="" then
        local lower=req:lower()
        if lower:find("horde",1,true) then return "Horde" end
        if lower:find("alliance",1,true) then return "Alliance" end
    end
    local text=(tostring(setInfo.description or "").." "..tostring(setInfo.label or "").." "..tostring(setInfo.sourceText or "")):lower()
    if text:find("horde",1,true) then return "Horde" end
    if text:find("alliance",1,true) then return "Alliance" end
    return nil
end

function T:GetSetDisplayName(setInfo)
    if not setInfo then return "Armor Set" end
    if setInfo.displayName and setInfo.displayName~="" then return setInfo.displayName end
    local name=setInfo.name or "Armor Set"
    local faction=self:GetSetFactionLabel(setInfo)
    if faction then return name.." ["..faction.."]" end
    if setInfo.challengeModeSet and setInfo.className then
        return name.." ["..setInfo.className.."]"
    end
    return name
end

function T:GetBlizzardSets(unfilteredExpansion)
    if not self.cap.sets then return {} end
    local cacheKey="all-classes-all-factions"
    local all=self.state.setCache and self.state.setCache[cacheKey]
    if not all then
        local ctx=self:GetPlayerContext(); all={}; local seen={}; local bases={}
        local function ingest(list)
            if type(list)~="table" then return end
            for _,setInfo in ipairs(list) do
                local id=setInfo and (setInfo.setID or setInfo.ID or setInfo.id)
                local expID=tonumber(setInfo and setInfo.expansionID)
                local maxExp=self:GetClientMaxExpansionID()
                local expansionAllowed=(not maxExp) or expID==nil or expID<=maxExp
                if id and not seen[id] and setInfo.name and expansionAllowed then
                    -- Sets view is a complete catalog: do not hide opposite-faction,
                    -- other-class, or other-spec sets here. Wearability restrictions
                    -- remain enforced by Salon/equipping logic.
                    seen[id]=true
                    setInfo.specialSet=setInfo.specialSet or false
                    if self.clientMode=="retail" then
                        setInfo=self:DecorateRetailSpecialSet(setInfo)
                    elseif self.clientMode=="mop-classic" and self:IsMoPChallengeModeSet(id) then
                        setInfo=self:DecorateMoPChallengeModeSet(setInfo)
                    end
                    setInfo.wearableForCharacter=self:ClassMaskAllowsSet(setInfo,ctx)
                    all[#all+1]=setInfo
                    if not setInfo.baseSetID or setInfo.baseSetID==id then bases[#bases+1]=id end
                end
            end
        end
        local function call(fn,...)
            if type(fn)~="function" then return nil end
            local ok,res=pcall(fn,...); if ok then return res end
        end
        -- Different WoW branches expose slightly different subsets through these APIs.
        -- Merge every catalog we can query, then enumerate base-set variants so hidden
        -- raid/PvP recolors do not silently disappear from Transmoggin.
        ingest(call(C_TransmogSets.GetAllSets))
        ingest(call(C_TransmogSets.GetAvailableSets))
        ingest(call(C_TransmogSets.GetUsableSets))
        local baseList=call(C_TransmogSets.GetBaseSets)
        ingest(baseList)
        if type(baseList)=="table" and C_TransmogSets.GetVariantSets then
            for _,base in ipairs(baseList) do
                local bid=base and (base.setID or base.ID or base.id)
                if bid then ingest(call(C_TransmogSets.GetVariantSets,bid)) end
            end
        end

        -- Retail special-set coverage: some Challenge/Mythic/event variants are
        -- attached to non-base sets. Sweep variants for every currently known
        -- set ID, but only through Blizzard's direct API (no generated DB scan).
        if self.clientMode=="retail" and C_TransmogSets.GetVariantSets then
            local snapshot={}
            for _,known in ipairs(all) do snapshot[#snapshot+1]=known end
            for _,known in ipairs(snapshot) do
                local kid=known and (known.setID or known.ID or known.id)
                if kid then ingest(call(C_TransmogSets.GetVariantSets,kid)) end
            end
        end

        -- MoP Classic Challenge Mode: explicitly guarantee all 11 class sets.
        if self.clientMode=="mop-classic" and self.GeneratedSpecialSetItems then
            for setID=1436,1446 do
                if self:IsMoPChallengeModeSet(setID) and not seen[setID] then
                    local special=self:GetGeneratedSpecialSetInfo(setID)
                    if special then
                        special.wearableForCharacter=self:ClassMaskAllowsSet(special,ctx)
                        seen[setID]=true
                        all[#all+1]=special
                    end
                elseif seen[setID] then
                    for _,existing in ipairs(all) do
                        if tonumber(existing.setID or existing.ID or existing.id)==setID then
                            self:DecorateMoPChallengeModeSet(existing)
                            existing.wearableForCharacter=self:ClassMaskAllowsSet(existing,ctx)
                            break
                        end
                    end
                end
            end
        end

        -- MoP Classic: merge every generated set definition the client DB knows
        -- about if Blizzard's set API omitted it. The item membership index is
        -- generated offline, so this adds no runtime DB scan.
        if self.clientMode=="mop-classic" and self.GeneratedSetSources and self.GeneratedSpecialSetItems then
            for setID in pairs(self.GeneratedSpecialSetItems) do
                if not seen[setID] then
                    local special=self:GetGeneratedSpecialSetInfo(setID)
                    if special then
                        local expID=tonumber(special.expansionID)
                        local maxExp=self:GetClientMaxExpansionID()
                        if (not maxExp) or expID==nil or expID<=maxExp then
                            special.wearableForCharacter=self:ClassMaskAllowsSet(special,ctx)
                            seen[setID]=true
                            all[#all+1]=special
                        end
                    end
                end
            end
        end

        table.sort(all,function(a,b)
            local ea,eb=tonumber(a.expansionID or -1),tonumber(b.expansionID or -1)
            if ea~=eb then return ea>eb end

            local sa=(a.challengeModeSet and 0) or (a.retailSpecialSet and 1) or 2
            local sb=(b.challengeModeSet and 0) or (b.retailSpecialSet and 1) or 2
            if sa~=sb then return sa<sb end

            local ua,ub=tonumber(a.uiOrder or 0),tonumber(b.uiOrder or 0)
            if ua~=ub then return ua<ub end
            local na,nb=tostring(a.name or ""),tostring(b.name or "")
            if na~=nb then return na<nb end
            return tonumber(a.setID or 0)<tonumber(b.setID or 0)
        end)
        local duplicateGroups={}
        for _,setInfo in ipairs(all) do
            local key=(tostring(setInfo.name or "").."|"..tostring(setInfo.expansionID or "")):lower()
            duplicateGroups[key]=duplicateGroups[key] or {}
            duplicateGroups[key][#duplicateGroups[key]+1]=setInfo
        end

        for _,group in pairs(duplicateGroups) do
            if #group>1 then
                local usedLabels={}
                for index,setInfo in ipairs(group) do
                    local faction=self:GetSetFactionLabel(setInfo)
                    local label=faction
                    if not label or usedLabels[label] then
                        label="Variant "..tostring(index)
                    end
                    usedLabels[label]=true
                    setInfo.displayName=(setInfo.displayName or self:GetSetDisplayName(setInfo)).." ["..label.."]"
                end
            else
                local setInfo=group[1]
                if setInfo then setInfo.displayName=self:GetSetDisplayName(setInfo) end
            end
        end

        self.state.setCache=self.state.setCache or {}; self.state.setCache[cacheKey]=all
    end
    local filtered=all
    if not unfilteredExpansion and self.state.setExpansionFilter~="all" and self.state.setExpansionFilter~=nil then
        local wanted=tonumber(self.state.setExpansionFilter); filtered={}
        for _,setInfo in ipairs(all) do if tonumber(setInfo.expansionID)==wanted then filtered[#filtered+1]=setInfo end end
    end
    if not unfilteredExpansion and self.state.setFactionFilter and self.state.setFactionFilter~="all" then
        local wanted=self.state.setFactionFilter; local factionFiltered={}
        for _,setInfo in ipairs(filtered) do
            if self:GetSetFactionLabel(setInfo)==wanted then factionFiltered[#factionFiltered+1]=setInfo end
        end
        filtered=factionFiltered
    end
    if not unfilteredExpansion and self.state.setWearabilityFilter and self.state.setWearabilityFilter~="all" then
        local wantWearable=self.state.setWearabilityFilter=="wearable"; local wearableFiltered={}
        for _,setInfo in ipairs(filtered) do
            if setInfo.wearableForCharacter==wantWearable then wearableFiltered[#wearableFiltered+1]=setInfo end
        end
        filtered=wearableFiltered
    end
    if not unfilteredExpansion and self.state.setOwnedOnly then
        local owned={}
        for _,setInfo in ipairs(filtered) do if self:IsBlizzardSetOwned(setInfo) then owned[#owned+1]=setInfo end end
        filtered=owned
    end
    return filtered
end

function T:GetSetPieceRecord(setID,slotDef)
    if not setID or not slotDef then return nil end

    -- Synthetic/omitted special set source from the generated MoP set index.
    if self.GeneratedSpecialSetItems and self.GeneratedSpecialSetItems[tonumber(setID)] then
        local special=self:GetGeneratedSpecialSetPiece(setID,slotDef)
        if special then return special end
    end

    local sourceIDs={}
    if C_TransmogSets and C_TransmogSets.GetSourceIDsForSlot then
        local ok,ids=pcall(C_TransmogSets.GetSourceIDsForSlot,setID,slotDef.slotID)
        if ok and type(ids)=="table" then for _,sid in ipairs(ids) do if sid and sid~=0 then sourceIDs[#sourceIDs+1]=sid end end end
    end
    local sourceTables={}
    if C_TransmogSets and C_TransmogSets.GetSourcesForSlot then
        local ok,sources=pcall(C_TransmogSets.GetSourcesForSlot,setID,slotDef.slotID)
        if ok and type(sources)=="table" then
            sourceTables=sources
            for _,src in ipairs(sources) do
                local sid=type(src)=="table" and (src.sourceID or src.itemModifiedAppearanceID) or src
                if sid and sid~=0 then
                    local exists=false; for _,v in ipairs(sourceIDs) do if v==sid then exists=true break end end
                    if not exists then sourceIDs[#sourceIDs+1]=sid end
                end
            end
        end
    end
    local function recordFromSourceID(sid)
        if not sid then return nil end
        local info=nil
        if C_TransmogCollection and C_TransmogCollection.GetSourceInfo then
            local ok,res=pcall(C_TransmogCollection.GetSourceInfo,sid); if ok and type(res)=="table" then info=res end
        end
        local visualID=info and (info.visualID or info.appearanceID)
        if (not visualID or visualID==0) and self.GetGeneratedItemIDForModifiedAppearance then
            local generatedItemID=self:GetGeneratedItemIDForModifiedAppearance(sid)
            local row=generatedItemID and self.GeneratedItemSourceDB and self.GeneratedItemSourceDB[generatedItemID] or nil
            if row then visualID=row.a end
        end
        local cat=info and info.categoryID or nil
        if not cat or cat==0 then cat=self:GetEffectiveCategory(slotDef) end
        local rec=nil
        if visualID and visualID~=0 and self.GetSetBrowserSourceRecord then
            local exactItemID
            if C_TransmogCollection and C_TransmogCollection.GetSourceItemID then
                local oi,ri=pcall(C_TransmogCollection.GetSourceItemID,sid)
                if oi then exactItemID=ri end
            end
            if (not exactItemID or exactItemID==0) and self.GetGeneratedItemIDForModifiedAppearance then
                exactItemID=self:GetGeneratedItemIDForModifiedAppearance(sid)
            end
            rec=self:GetSetBrowserSourceRecord(visualID,cat,sid,exactItemID,slotDef)
        end
        if not rec then
            local itemID=nil
            if C_TransmogCollection and C_TransmogCollection.GetSourceItemID then
                local ok,res=pcall(C_TransmogCollection.GetSourceItemID,sid); if ok then itemID=res end
            end
            if (not itemID or itemID==0) and self.GetGeneratedItemIDForModifiedAppearance then
                itemID=self:GetGeneratedItemIDForModifiedAppearance(sid)
            end
            local itemName,itemLink,icon
            if itemID then
                itemName,itemLink=GetItemInfo(itemID)
                if GetItemInfoInstant then icon=select(5,GetItemInfoInstant(itemID)) end
            end
            rec={sourceID=sid,itemModID=sid,itemID=itemID,itemLink=itemLink,icon=icon or 134400,
                name=itemName or (visualID and ("Appearance "..tostring(visualID)) or ("Set Piece "..tostring(sid))),
                itemName=itemName,categoryID=cat,visualID=visualID,
                allSources={{sourceID=sid}},sourceText="Set appearance"}
            if self.HydrateSpecialAppearanceRecord then rec=self:HydrateSpecialAppearanceRecord(rec) end
        end
        rec.sourceID=rec.sourceID or sid; rec.itemModID=rec.itemModID or sid; rec.previewSourceID=sid
        if self.HydrateSpecialAppearanceRecord then rec=self:HydrateSpecialAppearanceRecord(rec) end
        rec.collected=self:SourceIsCollected(sid) or (rec.visualID and self:IsVisualCollected(rec.visualID,rec.categoryID,sid)) or false

        if rec.visualID then
            self.state.specialAppearanceRegistry=self.state.specialAppearanceRegistry or {}
            self.state.specialAppearanceRegistry[rec.visualID]={
                visualID=rec.visualID,
                sourceID=rec.sourceID or rec.itemModID,
                itemModifiedAppearanceID=rec.itemModID or rec.sourceID,
                itemID=rec.itemID,
                itemName=rec.itemName or rec.name,
                categoryID=rec.categoryID,
                isCollected=rec.collected,
            }
        end

        return rec
    end
    -- Prefer an owned source, but always keep an uncollected source available for preview.
    for _,sid in ipairs(sourceIDs) do if self:SourceIsCollected(sid) then local rec=recordFromSourceID(sid); if rec then return rec end end end
    if sourceIDs[1] then return recordFromSourceID(sourceIDs[1]) end
    -- Last-resort path for branches that only return source tables without IDs.
    for _,src in ipairs(sourceTables) do
        if type(src)=="table" then
            local visualID=src.visualID or src.appearanceID
            local cat=src.categoryID; if not cat or cat==0 then cat=self:GetEffectiveCategory(slotDef) end
            if visualID and visualID~=0 then
                local sid=src.sourceID or src.itemModifiedAppearanceID
                local itemID=src.itemID
                if (not itemID or itemID==0) and sid and C_TransmogCollection and C_TransmogCollection.GetSourceItemID then
                    local oi,ri=pcall(C_TransmogCollection.GetSourceItemID,sid)
                    if oi then itemID=ri end
                end
                local rec=self.GetSetBrowserSourceRecord
                    and self:GetSetBrowserSourceRecord(visualID,cat,sid,itemID,slotDef)
                    or nil
                if rec then return rec end
            end
        end
    end
    return nil
end

function T:BuildBlizzardSetLook(setInfo)
    if not setInfo then return nil,0 end
    local setID=setInfo.setID or setInfo.ID or setInfo.id
    if not setID then return nil,0 end
    local look={
        name=setInfo.displayName or self:GetSetDisplayName(setInfo),
        pieces={},
        dirty=true,
        blizzardSetID=setID,
        specialSet=setInfo.specialSet or setInfo.specialGenerated or false,
    }
    local found=0
    for _,slotDef in ipairs(self.SLOTS) do
        if slotDef.slotID~=4 and slotDef.slotID~=19 then
            local rec=self:GetSetPieceRecord(setID,slotDef)
            if rec then look.pieces[slotDef.key]=rec; found=found+1 end
        end
    end
    if found==0 and C_TransmogSets and C_TransmogSets.GetAllSourceIDs and C_TransmogCollection and C_TransmogCollection.GetSourceInfo then
        local ok,ids=pcall(C_TransmogSets.GetAllSourceIDs,setID)
        if ok and type(ids)=="table" then
            for _,sid in ipairs(ids) do
                local oi,info=pcall(C_TransmogCollection.GetSourceInfo,sid)
                if oi and type(info)=="table" and info.visualID then
                    local slotID=info.inventorySlot
                    if (not slotID or slotID==0) and info.invType and C_Transmog and C_Transmog.GetSlotForInventoryType then
                        local os,res=pcall(C_Transmog.GetSlotForInventoryType,info.invType); if os then slotID=res end
                    end
                    for _,slotDef in ipairs(self.SLOTS) do
                        if slotID==slotDef.slotID and not look.pieces[slotDef.key] then
                            local cat=info.categoryID; if not cat or cat==0 then cat=self:GetEffectiveCategory(slotDef) end
                            local exactItemID
                            if C_TransmogCollection.GetSourceItemID then
                                local oi,ri=pcall(C_TransmogCollection.GetSourceItemID,sid)
                                if oi then exactItemID=ri end
                            end
                            if (not exactItemID or exactItemID==0) and self.GetGeneratedItemIDForModifiedAppearance then
                                exactItemID=self:GetGeneratedItemIDForModifiedAppearance(sid)
                            end
                            local rec=self.GetSetBrowserSourceRecord
                                and self:GetSetBrowserSourceRecord(info.visualID,cat,sid,exactItemID,slotDef)
                                or nil
                            if rec then look.pieces[slotDef.key]=rec; found=found+1 end
                            break
                        end
                    end
                end
            end
        end
    end
    return look,found
end

function T:TrackBlizzardSet(setInfo)
    local look,found=self:BuildBlizzardSetLook(setInfo)
    if not look or found==0 then
        self:Print("This client returned the set, but did not expose wearable pieces to track.")
        return
    end
    local ctx=self:GetPlayerContext()
    look.classID=ctx.classID; look.classFile=ctx.classFile; look.specID=ctx.specID; look.created=time and time() or 0
    local existing=nil
    for i,l in ipairs(self.db.looks or {}) do
        if l.blizzardSetID==look.blizzardSetID or l.name==look.name then existing=i break end
    end
    if existing then
        look.loadedIndex=nil
        self.db.looks[existing]=look
    else
        table.insert(self.db.looks,look)
        existing=#self.db.looks
    end
    self.db.tracked[look.name]=true
    local have,total=self:CountLookProgress(look)
    self:Print(("Tracking set: %s (%d/%d collected)"):format(look.name,have,total))
    if self.RefreshLeft then self:RefreshLeft() end
    if self.RefreshTracker then self:RefreshTracker() end
end

function T:LoadBlizzardSet(setInfo)
    if not setInfo then return end
    self:PushUndo()
    local look,found=self:BuildBlizzardSetLook(setInfo)
    if not look then return end
    self.state.working=look
    if found==0 then self:Print("This client returned the set, but did not expose its wearable pieces.") end
    self:RefreshAll()
    if self:IsAtTransmogVendor() then
        self:StageWorkingLook()
        if self.ScheduleVendorCostRefresh then self:ScheduleVendorCostRefresh() end
    end
end

function T:RefreshLeft()
    if not self.leftRows then return end
    local mode=self.state.leftMode; local rows={}
    if mode=="sets" then
        self.leftTitle:ClearAllPoints(); self.leftTitle:SetPoint("TOPLEFT",16,-16); self.leftTitle:SetText("BLIZZARD SETS")
        if self.newLookButton then self.newLookButton:Hide() end
        if self.setExpansionButton then
            self.setExpansionButton:Show()
            local ef=self.state.setExpansionFilter or "all"
            local expLabel=ef=="all" and "ALL EXPANSIONS" or string.upper(self:GetExpansionName(ef))
            self.setExpansionButton.txt:SetText(expLabel)
            local textWidth=(self.setExpansionButton.txt.GetStringWidth and self.setExpansionButton.txt:GetStringWidth()) or 120
            self.setExpansionButton:SetWidth(math.min(205,math.max(138,textWidth+30)))
            self.leftTitle:SetPoint("RIGHT",self.setExpansionButton,"LEFT",-8,0)
        end
        if self.setOwnedButton then
            self.setOwnedButton:Show()
            self.setOwnedButton.txt:SetText(self.state.setOwnedOnly and "OWNED: ON" or "OWNED: OFF")
            if self.setOwnedButton.SetBackdropBorderColor then self.setOwnedButton:SetBackdropBorderColor(rgba(self.state.setOwnedOnly and C.gold or C.gold2)) end
        end
        if self.setFactionButton then
            self.setFactionButton:Show()
            local ff=self.state.setFactionFilter or "all"
            self.setFactionButton.txt:SetText(ff=="Alliance" and "ALLIANCE" or ff=="Horde" and "HORDE" or "FACTION: ALL")
            if self.setFactionButton.SetBackdropBorderColor then self.setFactionButton:SetBackdropBorderColor(rgba(ff~="all" and C.gold or C.gold2)) end
        end
        if self.setWearabilityButton then
            self.setWearabilityButton:Show()
            local wf=self.state.setWearabilityFilter or "all"
            self.setWearabilityButton.txt:SetText(wf=="wearable" and "WEARABLE" or wf=="notwearable" and "NOT WEAR" or "WEAR: ALL")
            if self.setWearabilityButton.SetBackdropBorderColor then self.setWearabilityButton:SetBackdropBorderColor(rgba(wf~="all" and C.gold or C.gold2)) end
        end
        for i,r in ipairs(self.leftRows) do r:ClearAllPoints(); r:SetPoint("TOPLEFT",14,-76-(i-1)*49) end
        local sets=self:GetBlizzardSets(); local size=12
        local maxp=math.max(1,math.ceil(#sets/size)); if self.state.setPage>maxp then self.state.setPage=maxp end
        local first=(self.state.setPage-1)*size+1
        for i=first,math.min(#sets,first+size-1) do
            local setInfo=sets[i]
            local exp=self:GetExpansionName(setInfo.expansionID)
            local variant=setInfo.specialFamily or setInfo.label or setInfo.description
            local sub=exp..((variant and variant~="") and ("  /  "..variant) or "")
            if setInfo.wearableForCharacter==true then
                sub=sub.."  /  WEARABLE"
            elseif setInfo.wearableForCharacter==false then
                sub=sub.."  /  NOT WEARABLE"
            end
            if setInfo.collected then sub=sub.."  /  COLLECTED" end
            rows[#rows+1]={kind="set",data=setInfo,name=setInfo.displayName or self:GetSetDisplayName(setInfo),sub=sub,icon=self:GetSetIcon(setInfo)}
        end
        if self.setPrev then self.setPrev:Show(); self.setNext:Show(); self.setPageText:Show(); self.setPageText:SetText(("SETS %d / %d  (%d total)"):format(self.state.setPage,maxp,#sets)); self.setPrev:SetAlpha(self.state.setPage>1 and 1 or .35); self.setNext:SetAlpha(self.state.setPage<maxp and 1 or .35) end
    else
        self.leftTitle:ClearAllPoints(); self.leftTitle:SetPoint("TOPLEFT",16,-16); self.leftTitle:SetText(mode=="tracked" and "TRACKED LOOKS" or "MY LOOKS")
        if self.newLookButton then self.newLookButton:Show() end
        if self.setExpansionButton then self.setExpansionButton:Hide() end
        if self.setOwnedButton then self.setOwnedButton:Hide() end
        if self.setFactionButton then self.setFactionButton:Hide() end
        if self.setWearabilityButton then self.setWearabilityButton:Hide() end
        for i,r in ipairs(self.leftRows) do r:ClearAllPoints(); r:SetPoint("TOPLEFT",14,-50-(i-1)*49) end
        if self.setPrev then self.setPrev:Hide(); self.setNext:Hide(); self.setPageText:Hide() end
        for i,l in ipairs(self.db.looks or {}) do
            if mode~="tracked" or self.db.tracked[l.name] then
                local have,total=self:CountLookProgress(l)
                rows[#rows+1]={kind="look",data=l,index=i,name=l.name,sub=("%d / %d collected%s"):format(have,total,self.db.tracked[l.name] and "  /  tracking" or ""),icon=self.ROOSTER}
            end
        end
    end
    for i,r in ipairs(self.leftRows) do
        local d=rows[i]
        if d then
            r.kind=d.kind; r.data=d.data; r.index=d.index; r.icon:SetTexture(d.icon or self.ROOSTER); r.name:SetText(d.name or "Look"); r.sub:SetText(d.sub or ""); r.star:SetText(d.kind=="look" and self.db.tracked[d.name] and "T" or "")
            r:Show()
        else r.data=nil; r:Hide() end
    end
end
function T:RefreshVendorStatus()
    if not self.vendorBadge then return end; local at=self:IsAtTransmogVendor(); self.state.vendorOpen=at or self.state.vendorOpen
    self.vendorBadge:SetText(at and "|cff55ff66C|r" or "|cffaaaaaaPLANNER MODE  /  VISIT A TRANSMOGRIFIER|r")
    local cost=self:GetApplyCost()
    self.cost:SetText(at and ("PENDING COST   "..moneyString(cost)) or "PENDING COST   shown at vendor")
    self.applyButton:SetAlpha(at and 1 or .42); self.applyButton:EnableMouse(at)
end
function T:RefreshAll()
    if not self.db or not self.main then return end; local ctx=self:GetPlayerContext(); self.playerBadge:SetText(((ctx.specName and ctx.specName~="") and (ctx.specName.."  /  ") or "")..(ctx.className or ""))
    self.lookTitle:SetText(self:CurrentLookName()); self:RefreshLeft(); self:RefreshSlots(); self:RefreshAppearanceGrid(); self:RefreshModel(); self:RefreshVendorStatus()
    local have,total=self:CountLookProgress(self.state.working); self.progress:SetText(("LOOK COMPLETION   %d / %d COLLECTED"):format(have,total)); self.progressBar:SetMinMaxValues(0,math.max(1,total)); self.progressBar:SetValue(have)
end
