-- 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()
    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 ONLY",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.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",-14,-10); self.search:SetAutoFocus(false); self.search:SetTextInsets(8,8,0,0)
    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.search:SetScript("OnEnterPressed",function(e) e:ClearFocus() end)
    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,-50) 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, and FAVORITES uses only your favorites. Your search box does not affect the outfit roll.")

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

    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,-105-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: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,-475); 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,-510); 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")
    -- 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,-68); self.sourceScroll:SetPoint("BOTTOMRIGHT",-32,42)
    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:SetText("SOURCE DETAILS: BLIZZARD API + LOCAL TRANSMOGGIN DB")

    self.state.pageSize=30
    self:SelectSlot("HEADSLOT")
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
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,285); 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 replay=button(p,"REPLAY BEGINNER TUTORIAL",210,34); replay:SetPoint("TOP",toggle,"BOTTOM",0,-12); 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: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 apps=self:GetSlotAppearanceCatalog(slot); local out={}
    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)))
        if pass then
            if q=="" then out[#out+1]=a else
                local rec=self:GetSourceRecord(a.visualID,categoryID,nil,slot)
                local sourceText=rec and self:EnsureSourceText(rec) or ""
                if rec and (((rec.itemName or rec.name or ""):lower():find(q,1,true)) or ((sourceText or ""):lower():find(q,1,true))) then out[#out+1]=a end
            end
        end
    end
    self.state.filteredAppearanceCache[fk]=out
    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)))
        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: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.state.page<1 then self.state.page=1 end
    local startIndex=(self.state.page-1)*self.state.pageSize+1; self.pageText:SetText(("%d / %d   /   %d wearable"):format(self.state.page,maxp,#apps))
    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.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 "")
            local p=self:GetWorkingPiece(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:ChooseAppearance(rec)
    if not rec then return end; rec.collected=self:IsPieceCollected(rec); self:SetWorkingPiece(self.state.selectedSlot,rec)
    self.state.selectedRecord=rec
    self.sourceIcon:SetTexture(rec.icon or 134400); self.sourceTitle:SetText(rec.name or "Appearance")
    self.sourceState:SetText(rec.collected and "COLLECTED - ready for the salon" or "MISSING - HOW TO OBTAIN")
    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)
    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 TryOnPiece(model,piece)
    if not model or not piece or not model.TryOn then return false end
    -- Transmog collection records are keyed by itemModifiedAppearance/source ID.
    -- Blizzard's wardrobe preview feeds that source directly to Actor:TryOn(),
    -- which also works for uncollected appearances. Item links are only a fallback.
    local sourceID=piece.previewSourceID or piece.sourceID or piece.itemModID
    if sourceID and sourceID~=0 then
        local ok=pcall(model.TryOn,model,sourceID)
        if ok then return true end
    end
    if piece.itemLink then
        local ok=pcall(model.TryOn,model,piece.itemLink)
        if ok then return true end
    end
    if piece.itemID and piece.itemID~=0 then
        local ok=pcall(model.TryOn,model,piece.itemID)
        if ok 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
    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

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
    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

function T:GetBlizzardSets(unfilteredExpansion)
    if not self.cap.sets then return {} end
    local cacheKey=tostring((self:GetPlayerContext() or {}).classID or 0)..":"..tostring(UnitFactionGroup and UnitFactionGroup("player") or "")
    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 and self:ClassMaskAllowsSet(setInfo,ctx) then
                    seen[id]=true; 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
        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 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)
        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.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
    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)
        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 then rec=self:GetSourceRecord(visualID,cat,sid) 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
            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 ("Set Piece "..tostring(sid)),itemName=itemName,categoryID=cat,visualID=visualID,
                allSources={{sourceID=sid}},sourceText="Set appearance"}
        end
        rec.sourceID=rec.sourceID or sid; rec.itemModID=rec.itemModID or sid; rec.previewSourceID=sid
        rec.collected=self:SourceIsCollected(sid) or (rec.visualID and self:IsVisualCollected(rec.visualID,rec.categoryID,sid)) or false
        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 rec=self:GetSourceRecord(visualID,cat,src.sourceID or src.itemModifiedAppearanceID)
                if rec then rec.collected=self:IsPieceCollected(rec); 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.name or "Armor Set",pieces={},dirty=true,blizzardSetID=setID}
    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 rec=self:GetSourceRecord(info.visualID,cat,sid)
                            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 ONLY")
            if self.setOwnedButton.SetBackdropBorderColor then self.setOwnedButton:SetBackdropBorderColor(rgba(self.state.setOwnedOnly 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.label or setInfo.description
            local sub=exp..((variant and variant~="") and ("  /  "..variant) or "")
            if setInfo.collected then sub=sub.."  /  COLLECTED" end
            rows[#rows+1]={kind="set",data=setInfo,name=setInfo.name,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
        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 "|cff55ff66SALON OPEN  /  PURCHASE ENABLED|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
