-- =============================================================================
-- NativeSetsTab.lua
-- Adds a native "ETB Sets" tab to Blizzard's transmogrifier (patch 5.5.4+).
--
-- The transmog window's tab strip is TransmogFrame.WardrobeCollection.TabHeaders,
-- a container whose children are the Items / Sets / Custom Sets / Situations tab
-- buttons (each 95x32). We add a matching button after Situations and overlay an
-- ETB content frame on the panel area when it's selected. We do NOT touch
-- Blizzard's own content frames -- our frame simply covers them while active and
-- hides again when another tab is clicked -- so this survives their tab churn.
--
-- Everything is guarded: if the frame structure is absent or a hotfix moves it,
-- the tab just doesn't appear instead of erroring.
-- =============================================================================

local ADDON_NAME, NS = ...
local ETB = NS.ETB

local NativeSetsTab = {}
ETB.NativeSetsTab = NativeSetsTab

local state = { mounted = false, tab = nil, content = nil, hookedTabs = false, rows = {}, page = 1 }

-- -----------------------------------------------------------------------------
-- Set enumeration (community + ETB custom sets; Blizzard sets stay in their tab)
-- -----------------------------------------------------------------------------
-- Armor-type filtering: only show sets the player's class can collect/wear.
local CLASS_ARMOR = {
    WARRIOR = "PLATE", PALADIN = "PLATE", DEATHKNIGHT = "PLATE",
    HUNTER = "MAIL", SHAMAN = "MAIL",
    ROGUE = "LEATHER", DRUID = "LEATHER", MONK = "LEATHER", DEMONHUNTER = "LEATHER",
    MAGE = "CLOTH", PRIEST = "CLOTH", WARLOCK = "CLOTH",
}
local VALID_ARMOR = { CLOTH = true, LEATHER = true, MAIL = true, PLATE = true }
local ARMOR_SUB = { [1] = "CLOTH", [2] = "LEATHER", [3] = "MAIL", [4] = "PLATE" }
-- Body slots that define a set's armor type (NOT cloak 15 — always cloth subclass).
local ARMOR_BODY_SLOTS = { 5, 7, 1, 3, 10, 8, 6, 9 }

local function getPlayerArmor()
    if ETB.BlizzardData and ETB.BlizzardData.GetPlayerPrimaryArmor then
        local ok, a = pcall(function() return ETB.BlizzardData:GetPlayerPrimaryArmor() end)
        if ok and a and VALID_ARMOR[a] then return a end
    end
    local _, class = UnitClass("player")
    return CLASS_ARMOR[class] or "CLOTH"
end

-- Derive a set's armor type: explicit metadata first, else read a body piece
-- via GetItemInfoInstant (synchronous, no server query).
local function setArmorType(entry)
    if entry.armorType and VALID_ARMOR[entry.armorType] then return entry.armorType end
    local items = entry.items
    if type(items) ~= "table" then return nil end
    for _, slot in ipairs(ARMOR_BODY_SLOTS) do
        local piece = items[slot]
        local itemID = (type(piece) == "table" and (piece.itemID or piece[1]))
            or (type(piece) == "number" and piece) or nil
        if itemID then
            local _, _, _, _, _, classID, subclassID = GetItemInfoInstant(itemID)
            if classID == 4 and ARMOR_SUB[subclassID] then return ARMOR_SUB[subclassID] end
        end
    end
    return nil
end

local function addSets(out, src, getter, completionFn)
    if not (src and getter and src[getter]) then return end
    local ok, list = pcall(src[getter], src)
    if not ok or type(list) ~= "table" then return end
    for setID, s in pairs(list) do
        if type(s) == "table" and (s.items or s.name) then
            local entry = { name = s.name or "Set", items = s.items, setID = setID, armorType = s.armorType }
            if completionFn and src[completionFn] then
                local ok2, c, t = pcall(src[completionFn], src, setID)
                if ok2 and tonumber(c) and tonumber(t) then
                    entry.collected, entry.total = c, t
                end
            end
            out[#out + 1] = entry
        end
    end
end

local function collectSets()
    if state._setsCache then return state._setsCache end
    -- Instrument the first-build cost (this runs the full collection re-walk once,
    -- ~150ms, then caches). Surfacing it in /etb perf so we can convert it next.
    local _t0 = (ETB.Perf and ETB.Perf.Record and debugprofilestop) and debugprofilestop() or nil
    local out = {}
    addSets(out, ETB.CustomSets, "GetAllSets")
    if ETB.CommunitySets then
        if not ETB.CommunitySets.loaded and ETB.CommunitySets.LoadData then
            pcall(function() ETB.CommunitySets:LoadData() end)
        end
        if ETB.CommunitySets.RequestRecheck and not ETB.CommunitySets._recheckDone then
            -- Kick the authoritative re-check only until it has run once. After that,
            -- counts are maintained by the SOURCE_ADDED / TRANSMOG_COLLECTION_UPDATED
            -- handlers (which invalidate this cache on completion), so we must NOT
            -- re-kick here or onDone→Invalidate→Refresh→collectSets would loop.
            pcall(function() ETB.CommunitySets:RequestRecheck() end)
        elseif not ETB.CommunitySets.RequestRecheck and ETB.CommunitySets.UpdateCollectionCounts then
            pcall(function() ETB.CommunitySets:UpdateCollectionCounts() end)
        end
        addSets(out, ETB.CommunitySets, "GetAllSets", "GetSetCompletion")
    end

    -- Keep only sets the player's class can wear (unknown armor types are kept
    -- so we never over-filter when item data isn't resolvable).
    local pa = getPlayerArmor()
    local filtered = {}
    for _, e in ipairs(out) do
        local at = setArmorType(e)
        if at == nil or at == pa then filtered[#filtered + 1] = e end
    end

    table.sort(filtered, function(a, b) return tostring(a.name) < tostring(b.name) end)
    state._setsCache = filtered
    if _t0 then ETB.Perf:Record("nativeSetsTab.collectSets", debugprofilestop() - _t0) end
    return filtered
end

-- Invalidated by CommunitySets when its authoritative chunked re-check finishes,
-- so the next build picks up corrected collection counts. If the tab is visible,
-- refresh it immediately so the corrected counts show without user interaction.
function NativeSetsTab:InvalidateSetsCache()
    state._setsCache = nil
    if state.content and state.content.IsShown and state.content:IsShown() and self.Refresh then
        pcall(function() self:Refresh() end)
    end
end

-- -----------------------------------------------------------------------------
-- Card grid (model cards, like Blizzard's Sets tab)
-- -----------------------------------------------------------------------------
local COLS, ROWS, GAP, PAD = 3, 3, 12, 12
local HEADER_H = 40
local FOOTER_H = 40
local CARD_RATIO = 1.28         -- card height = width * ratio (native set-card proportions)
local _gen = 0                  -- bumped each refresh to cancel stale dress jobs

local function pieceItemID(piece)
    if type(piece) == "table" then return piece.itemID or piece[1] end
    if type(piece) == "number" then return piece end
    return nil
end

local function tryLink(itemID)
    if not itemID then return nil end
    local _, link = GetItemInfo(itemID)
    return link or ("item:" .. itemID)
end

-- Frame the card model exactly like Blizzard's Sets tab: their cards use the
-- transmog camera from C_TransmogSets.GetCameraIDs applied via Model_ApplyUICamera
-- (head-to-hips, filling the cell). Falls back to full-body framing if absent.
local _cardCamID
local function cardCameraID()
    if _cardCamID ~= nil then return _cardCamID or nil end
    _cardCamID = false
    if C_TransmogSets and C_TransmogSets.GetCameraIDs then
        local ok, detailsID, transmogID = pcall(C_TransmogSets.GetCameraIDs)
        if ok then _cardCamID = transmogID or detailsID or false end
    end
    return _cardCamID or nil
end

local function frameCardModel(model)
    local camID = cardCameraID()
    if camID and Model_ApplyUICamera then
        pcall(function() model:RefreshCamera() end)
        pcall(function() Model_ApplyUICamera(model, camID) end)
        return true
    end
    if model.SetPortraitZoom then pcall(model.SetPortraitZoom, model, 0) end
    pcall(model.SetFacing, model, 0.35)
    return false
end

-- Blizzard's Sets-tab cards frame the player from the upper body via a
-- per-race/sex camera (base position + race offset). Recipe from the native
-- transmog set card / BetterWardrobe.
local CAM_BASE = { -0.108, -2.78, 1.55 }
local CAM_TRANSLATE = {
    BloodElf2 = { 0.058, 0.68, 0.05 }, BloodElf3 = { 0.058, 0.98, -0.05 },
    DarkIronDwarf2 = { -0.012, 0.28, -0.45 }, DarkIronDwarf3 = { 0.038, 0.78, -0.48 },
    Draenei2 = { -0.092, -0.72, 0.25 }, Draenei3 = { -0.042, 1.23, 0.35 },
    Dracthyr2 = { -0.862, -0.52, 0.65 }, Dracthyr3 = { -0.862, -0.52, 0.65 },
    Dwarf2 = { 0.038, 0.28, -0.45 }, Dwarf3 = { 0.008, 0.78, -0.48 },
    EarthenDwarf2 = { 0.038, 0.28, -0.45 }, EarthenDwarf3 = { 0.008, 0.78, -0.48 },
    Gnome2 = { 0.108, 1.28, -0.85 }, Gnome3 = { -0.112, 1.08, -0.90 },
    Goblin2 = { 0.108, 0.98, -0.75 }, Goblin3 = { -0.112, 0.78, -0.68 },
    Harronir2 = { -0.042, 0.88, 0.40 }, Harronir3 = { 0.058, 1.08, 0.25 },
    HighmountainTauren2 = { -0.012, -1.32, 0.35 }, HighmountainTauren3 = { -0.242, -0.52, 0.35 },
    Human2 = { 0.058, 0.48, -0.05 }, Human3 = { 0.058, 0.98, -0.05 },
    KulTiran2 = { 0.108, -0.02, 0.45 }, KulTiran3 = { 0.038, 0.68, 0.45 },
    LightforgedDraenei2 = { -0.092, -0.72, 0.25 }, LightforgedDraenei3 = { -0.042, 0.98, 0.35 },
    MagharOrc2 = { 0.158, -0.82, -0.05 }, MagharOrc3 = { -0.092, 0.58, 0.00 },
    Mechagnome2 = { 0.038, 1.18, -0.85 }, Mechagnome3 = { -0.062, 1.18, -0.90 },
    Nightborne2 = { 0.108, -0.02, 0.35 }, Nightborne3 = { 0.108, 0.73, 0.30 },
    NightElf2 = { -0.042, -0.02, 0.35 }, NightElf3 = { 0.058, 0.73, 0.30 },
    Orc2 = { 0.058, -0.82, -0.05 }, Orc3 = { -0.092, 0.58, 0.00 }, Orc4 = { -0.092, -0.82, -0.05 },
    Pandaren2 = { -0.242, -1.02, 0.05 }, Pandaren3 = { -0.192, -0.42, 0.05 },
    Scourge2 = { -0.042, 0.58, -0.15 }, Scourge3 = { 0.178, 0.98, -0.10 },
    Tauren2 = { -0.012, -1.32, 0.35 }, Tauren3 = { -0.242, -0.52, 0.35 },
    Troll2 = { 0.278, 0.08, 0.15 }, Troll3 = { -0.012, 0.68, 0.35 },
    VoidElf2 = { 0.058, 0.68, 0.05 }, VoidElf3 = { 0.058, 0.98, -0.05 },
    Vulpera2 = { -0.112, 0.68, -0.75 }, Vulpera3 = { -0.112, 0.68, -0.70 },
    Worgen2 = { 0.158, -0.72, 0.15 }, Worgen3 = { 0.008, 0.08, 0.25 },
    ZandalariTroll2 = { 0.058, -0.17, 0.60 }, ZandalariTroll3 = { -0.142, 0.38, 0.75 },
}

local function frameActorCamera(scene, actor)
    local _, race = UnitRace("player")
    local sex = UnitSex("player")
    local key = (race or "Human") .. (sex or 2)
    -- Some models share files across races; correct the key by model file id.
    local file = actor.GetModelFileID and actor:GetModelFileID()
    if file == 1968587 then key = "Orc4"
    elseif file == 4395382 then key = "BloodElf" .. (sex or 2)
    elseif file == 1000764 or file == 1011653 or file == 4220448 then key = "Human" .. (sex or 2) end
    local off = CAM_TRANSLATE[key] or { 0, 0, 0 }
    pcall(function() scene:SetCameraOrientationByAxisVectors(0, 1, 0, -1, 0, 0, 0, 0, 1) end)
    pcall(function() scene:SetCameraPosition(CAM_BASE[1] + off[1], CAM_BASE[2] + off[2], CAM_BASE[3] + off[3]) end)
end

-- Same upper-body framing, applied to a DressUpModel (which shares the scene
-- camera API in modern clients). Falls back to Blizzard's camera or full-body.
local function frameModelCamera(model)
    if model and model.SetCameraPosition and model.SetCameraOrientationByAxisVectors then
        local _, race = UnitRace("player")
        local sex = UnitSex("player")
        local key = (race or "Human") .. (sex or 2)
        local file = model.GetModelFileID and model:GetModelFileID()
        if file == 1968587 then key = "Orc4"
        elseif file == 4395382 then key = "BloodElf" .. (sex or 2)
        elseif file == 1000764 or file == 1011653 or file == 4220448 then key = "Human" .. (sex or 2) end
        local off = CAM_TRANSLATE[key] or { 0, 0, 0 }
        pcall(function() model:SetCameraOrientationByAxisVectors(0, 1, 0, -1, 0, 0, 0, 0, 1) end)
        pcall(function() model:SetCameraPosition(CAM_BASE[1] + off[1], CAM_BASE[2] + off[2], CAM_BASE[3] + off[3]) end)
        return
    end
    frameCardModel(model)
end

-- Dress a ModelScene-actor card (native Sets-tab look): player model, yaw, the
-- upper-body camera, then one SetItemTransmogInfo per piece.
local function dressActorCard(card, s, gen, idx)
    local scene, actor = card.scene, card.actor
    if not (scene and actor) then return end
    local function run()
        if _gen ~= gen then return end
        local alteredForm = false
        if C_PlayerInfo and C_PlayerInfo.GetAlternateFormInfo then
            local _, af = C_PlayerInfo.GetAlternateFormInfo()
            alteredForm = af
        end
        -- autoDress MUST be off: when the player model finishes loading it would
        -- otherwise re-dress with the player's collected gear and strip the
        -- uncollected pieces we TryOn'd.
        pcall(function() actor:SetModelByUnit("player", false, false, false, not alteredForm) end)
        if actor.SetAutoDress then pcall(function() actor:SetAutoDress(false) end) end
        pcall(function() actor:SetYaw(-1.5) end)
        pcall(function() scene:SetPaused(true) end)
        frameActorCamera(scene, actor)
        local function applyPieces()
            if _gen ~= gen then return end
            pcall(function() actor:Undress() end)
            local list = ETB.CustomSetExport and ETB.CustomSetExport:BuildList(s.items)
            if list then
                for _, info in pairs(list) do
                    local sourceID = info and info.appearanceID
                    if sourceID and sourceID > 0 then
                        -- TryOn previews the appearance whether collected or not,
                        -- so the card shows the FULL set.
                        pcall(function() actor:TryOn(sourceID) end)
                    end
                end
            end
        end
        applyPieces()
        -- Re-apply after the model has loaded so the full set sticks.
        if C_Timer and C_Timer.After then C_Timer.After(0.25, applyPieces) end
    end
    if C_Timer and C_Timer.After then C_Timer.After(0.03 * (idx or 1), run) else run() end
end

-- Dress one card's model with the set (proven sequence: SetUnit every time,
-- Undress, deferred TryOn with item link).
local function dressCard(card, s, gen, idx)
    if card.actor then return dressActorCard(card, s, gen, idx) end
    local model = card.model
    if not model then return end

    local function run()
        if _gen ~= gen then return end
        if model.SetAutoDress then pcall(model.SetAutoDress, model, false) end
        pcall(function() model:SetUnit("player") end)
        pcall(function() model:Undress() end)
        frameModelCamera(model)
        local function applyPieces()
            if _gen ~= gen then return end
            for _, piece in pairs(s.items or {}) do
                local link = tryLink(pieceItemID(piece))
                if link then pcall(model.TryOn, model, link) end
            end
            pcall(function() if model.SetSheathed then model:SetSheathed(true) end end)
            -- Re-apply the camera after geometry settles so it matches Blizzard.
            frameModelCamera(model)
        end
        if C_Timer and C_Timer.After then C_Timer.After(0.1, applyPieces) else applyPieces() end
    end

    -- Defer SetUnit so the card's model frame has laid out first; otherwise the
    -- base body fails to load and only the armor geometry renders ("floating
    -- armor"). Stagger across cards so they don't all initialise in one frame.
    if C_Timer and C_Timer.After then
        C_Timer.After(0.03 * (idx or 1), run)
    else
        run()
    end
end

local function showCardTooltip(card, s)
    GameTooltip:SetOwner(card, "ANCHOR_RIGHT")
    local head = s.name or "Set"
    if s.total then
        head = head .. string.format("  |cff40ff40(%d/%d)|r", s.collected or 0, s.total)
    end
    GameTooltip:AddLine(head, 1, 0.82, 0)
    for _, piece in pairs(s.items or {}) do
        local itemID = pieceItemID(piece)
        if itemID then
            local name = GetItemInfo(itemID)
            if name then GameTooltip:AddLine(name, 0.9, 0.9, 0.9) end
        end
    end
    GameTooltip:Show()
end

-- Map an ETB set slot key to the corresponding Enum.TransmogOutfitSlot.
local function buildSlotMap()
    local E = Enum and Enum.TransmogOutfitSlot
    if not E then return nil end
    return {
        [1]  = E.Head,
        [3]  = E.ShoulderRight,
        [4]  = E.Body,
        [5]  = E.Chest,
        [6]  = E.Waist,
        [7]  = E.Legs,
        [8]  = E.Feet,
        [9]  = E.Wrist,
        [10] = E.Hand,
        [15] = E.Back,
        [16] = E.WeaponMainHand,
        [17] = E.WeaponOffHand,
        [19] = E.Tabard,
    }
end

-- Weapon slots need an "option" derived from the appearance's category.
local function optionForCategory(outfitSlot, categoryID)
    local O = Enum and Enum.TransmogOutfitSlotOption
    local E = Enum and Enum.TransmogOutfitSlot
    if not (O and E) then return 0 end
    if outfitSlot == E.WeaponMainHand or outfitSlot == E.WeaponOffHand then
        if (categoryID >= 12 and categoryID <= 17) or categoryID == 28 then
            return O.OneHandedWeapon
        elseif categoryID == 18 then
            return O.Shield
        elseif categoryID == 19 then
            return O.OffHand
        elseif categoryID >= 20 and categoryID <= 24 then
            return O.TwoHandedWeapon
        elseif categoryID >= 25 and categoryID <= 27 then
            return O.RangedWeapon
        end
    end
    return 0
end

-- Stage a set onto the transmog model directly (no custom set, no cap).
-- Uses C_TransmogOutfitInfo, the working apply path on this build.
local function applySetToModel(items)
    if not (C_TransmogOutfitInfo and C_TransmogOutfitInfo.SetPendingTransmog
        and ETB.CustomSetExport and Enum and Enum.TransmogType) then
        return false
    end
    local SLOT = buildSlotMap()
    if not SLOT then return false end

    -- Reuse the export translator to resolve each slot's source id.
    local list = ETB.CustomSetExport:BuildList(items)
    pcall(function() C_TransmogOutfitInfo.ClearAllPendingTransmogs() end)

    local appType = Enum.TransmogType.Appearance
    local applied = 0
    for slot, info in pairs(list) do
        local outfitSlot = SLOT[tonumber(slot)]
        local sourceID = info and info.appearanceID
        if outfitSlot ~= nil and sourceID and sourceID > 0 then
            local sinfo = C_TransmogCollection.GetSourceInfo(sourceID)
            local categoryID = (sinfo and sinfo.categoryID) or 0
            local display = (sinfo and sinfo.isHideVisual and 3) or 1
            local option = optionForCategory(outfitSlot, categoryID)
            pcall(function()
                C_TransmogOutfitInfo.SetPendingTransmog(outfitSlot, appType, option, sourceID, display)
            end)
            applied = applied + 1
        end
    end
    return applied > 0
end

local function applyCard(s)
    local items, name = s.items, s.name
    if (type(items) ~= "table") and s.setID and ETB.CustomSetExport and ETB.CustomSetExport.ResolveSet then
        name, items = ETB.CustomSetExport:ResolveSet(s.setID)
    end
    if type(items) ~= "table" then
        print("|cff66ccffETB|r: Couldn't read that set's pieces.")
        return
    end
    if applySetToModel(items) then
        print(string.format("|cff66ccffETB|r: Loaded |cffffd100%s|r onto the model \226\128\148 hit the transmogrifier's Apply button to confirm.", name or "set"))
    elseif ETB.CustomSetExport then
        -- Fallback: if the direct path isn't available, send to Custom Sets.
        ETB.CustomSetExport:Export(name, items)
    end
end

local function acquireCard(parent, i)
    local card = state.rows[i]
    if card then return card end
    card = CreateFrame("Button", nil, parent)

    card.bg = card:CreateTexture(nil, "BACKGROUND")
    card.bg:SetAllPoints()
    card.bg:SetColorTexture(0, 0, 0, 1)            -- black like Blizzard set cards

    card.border = CreateFrame("Frame", nil, card, BackdropTemplateMixin and "BackdropTemplate" or nil)
    card.border:SetAllPoints()
    if card.border.SetBackdrop then
        card.border:SetBackdrop({ edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
        card.border:SetBackdropBorderColor(0.50, 0.42, 0.22, 1)   -- subtle gold default
    end

    -- Prefer a ModelScene actor (native Sets-tab card look); fall back to a
    -- DressUpModel if the template/scene isn't available on this build.
    -- Use a DressUpModel, NOT a player-bound ModelScene actor: TryOn on a
    -- DressUpModel previews ANY appearance, so the card shows the full set even
    -- for pieces you haven't collected. A player-bound actor gates to collected.
    card.model = CreateFrame("DressUpModel", nil, card)
    card.model:SetPoint("TOPLEFT", 4, -4)
    card.model:SetPoint("BOTTOMRIGHT", -4, 20)   -- leave room for the label strip
    card.model:EnableMouse(false)                -- let the card get hover/click
    if card.model.SetAutoDress then pcall(card.model.SetAutoDress, card.model, false) end

    -- Name strip along the bottom for readability.
    card.labelBg = card:CreateTexture(nil, "ARTWORK")
    card.labelBg:SetPoint("BOTTOMLEFT", 1, 1)
    card.labelBg:SetPoint("BOTTOMRIGHT", -1, 1)
    card.labelBg:SetHeight(18)
    card.labelBg:SetColorTexture(0, 0, 0, 0.6)

    card.label = card:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    card.label:SetPoint("BOTTOMLEFT", 3, 3)
    card.label:SetPoint("BOTTOMRIGHT", -3, 3)
    card.label:SetJustifyH("CENTER")
    card.label:SetWordWrap(false)

    card:SetScript("OnEnter", function(self)
        if self.border.SetBackdropBorderColor then self.border:SetBackdropBorderColor(1, 0.82, 0, 1) end
        if self.setData then showCardTooltip(self, self.setData) end
    end)
    card:SetScript("OnLeave", function(self)
        if self.border.SetBackdropBorderColor then self.border:SetBackdropBorderColor(0.50, 0.42, 0.22, 1) end
        GameTooltip:Hide()
    end)
    card:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    card:SetScript("OnClick", function(self, btn)
        if not self.setData then return end
        if btn == "RightButton" then
            local s = self.setData
            local items, name = s.items, s.name
            if (type(items) ~= "table") and s.setID and ETB.CustomSetExport and ETB.CustomSetExport.ResolveSet then
                name, items = ETB.CustomSetExport:ResolveSet(s.setID)
            end
            if type(items) == "table" and ETB.NativeSavedOutfits then
                local ok = ETB.NativeSavedOutfits:SaveSetAsOutfit(name or s.name, items)
                if ok then
                    print(string.format("|cff66ccffETB|r: Saved |cffffd100%s|r to ETB Saved Outfits.", name or s.name or "set"))
                else
                    print("|cff66ccffETB|r: Couldn't save that set (no resolvable appearances).")
                end
            end
        else
            applyCard(self.setData)
        end
    end)

    state.rows[i] = card
    return card
end

function NativeSetsTab:Refresh()
    local f = state.content
    if not f then return end
    -- Only populate when our tab is the active (shown) one. Without this, the
    -- first-show size-retry below can fire while another tab is active and the
    -- model actors render through onto it.
    if not f:IsShown() then return end
    -- On first show the panel can report zero size before layout settles, which
    -- leaves the model cards invisible until the tab is re-opened. Retry once sized.
    local w, h = f:GetWidth() or 0, f:GetHeight() or 0
    if w <= 1 or h <= 1 then
        if C_Timer and C_Timer.After then C_Timer.After(0.05, function() NativeSetsTab:Refresh() end) end
        return
    end
    local sets = collectSets()

    -- Fixed 3x3 grid like Blizzard's Sets tab; size cards to fill the panel.
    local usableW = w - 2 * PAD
    local usableH = h - HEADER_H - FOOTER_H
    local cardW = math.floor((usableW - (COLS - 1) * GAP) / COLS)
    local cardH = math.floor((usableH - (ROWS - 1) * GAP) / ROWS)
    local perPage = COLS * ROWS

    local totalPages = math.max(1, math.ceil(#sets / perPage))
    state.page = math.min(math.max(1, state.page or 1), totalPages)
    local page = state.page

    _gen = _gen + 1
    local gen = _gen
    local startIdx = (page - 1) * perPage

    for slot = 1, perPage do
        local s = sets[startIdx + slot]
        local card = state.rows[slot] or acquireCard(f, slot)
        if s then
            local col  = (slot - 1) % COLS
            local rowN = math.floor((slot - 1) / COLS)
            card:SetSize(cardW, cardH)
            card:ClearAllPoints()
            card:SetPoint("TOPLEFT", f, "TOPLEFT",
                PAD + col * (cardW + GAP),
                -(HEADER_H + rowN * (cardH + GAP)))
            local lbl = s.name or "Set"
            if s.total then lbl = lbl .. string.format(" (%d/%d)", s.collected or 0, s.total) end
            card.label:SetText(lbl)
            card.setData = s
            card:Show()
            dressCard(card, s, gen, slot)
        else
            card:Hide()
        end
    end
    for slot = perPage + 1, #state.rows do
        if state.rows[slot] then state.rows[slot]:Hide() end
    end

    if f.pageText then f.pageText:SetText(string.format("Page %d/%d", page, totalPages)) end
    if f.prevBtn then f.prevBtn:SetEnabled(page > 1) end
    if f.nextBtn then f.nextBtn:SetEnabled(page < totalPages) end
    f.empty:SetShown(#sets == 0)
end

function NativeSetsTab:SetPage(p)
    state.page = p
    self:Refresh()
end

-- -----------------------------------------------------------------------------
-- Build the content frame. Registered with Blizzard's tab system, which shows
-- it when our tab is active and hides it (and Blizzard's own content) when
-- another tab is selected -- so no manual overlay/strata is needed.
-- -----------------------------------------------------------------------------
local function buildContent(parent)
    local f = CreateFrame("Frame", nil, parent)
    f:SetAllPoints(parent)

    f.bg = f:CreateTexture(nil, "BACKGROUND")
    f.bg:SetAllPoints()
    f.bg:SetColorTexture(0, 0, 0, 0)               -- transparent: show Blizzard's panel

    f.title = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    f.title:SetPoint("TOPLEFT", 10, -8)
    f.title:SetText("ETB Sets")

    f.hint = f:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    f.hint:SetPoint("LEFT", f.title, "RIGHT", 10, 0)
    f.hint:SetText("Left-click to load onto the model; right-click to save to ETB Saved Outfits.")

    f.empty = f:CreateFontString(nil, "OVERLAY", "GameFontDisable")
    f.empty:SetPoint("CENTER")
    f.empty:SetText("No ETB sets found.")
    f.empty:Hide()

    -- Page navigation.
    f.prevBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.prevBtn:SetSize(28, 24)
    f.prevBtn:SetPoint("BOTTOM", -60, 6)
    f.prevBtn:SetText("<")
    f.prevBtn:SetScript("OnClick", function()
        NativeSetsTab:SetPage(math.max(1, (state.page or 1) - 1))
    end)

    f.nextBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.nextBtn:SetSize(28, 24)
    f.nextBtn:SetPoint("BOTTOM", 60, 6)
    f.nextBtn:SetText(">")
    f.nextBtn:SetScript("OnClick", function()
        NativeSetsTab:SetPage((state.page or 1) + 1)
    end)

    f.pageText = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    f.pageText:SetPoint("BOTTOM", 0, 10)
    f.pageText:SetText("Page 1/1")

    -- Refresh whenever the tab system shows us.
    f:SetScript("OnShow", function() NativeSetsTab:Refresh() end)

    return f
end

-- -----------------------------------------------------------------------------
-- Mount: register a native tab via Blizzard's TabSystem (AddNamedTab). It styles
-- the tab like the others and handles show/hide of all content -- showing ours
-- and hiding Blizzard's when active, and vice-versa -- so there's no bleed.
-- -----------------------------------------------------------------------------
function NativeSetsTab:EnsureMounted()
    if state.mounted then return true end
    -- AddNamedTab lives in the transmog UI addons; make sure they're loaded.
    if C_AddOns and C_AddOns.LoadAddOn then
        pcall(C_AddOns.LoadAddOn, "Blizzard_TransmogShared")
        pcall(C_AddOns.LoadAddOn, "Blizzard_Transmog")
    end
    local tf = _G.TransmogFrame
    local wc = tf and tf.WardrobeCollection
    if not (wc and wc.TabContent and type(wc.AddNamedTab) == "function") then
        return false
    end

    state.wc = wc
    state.content = buildContent(wc.TabContent)
    state.tabID = wc:AddNamedTab("ETB Sets", state.content)

    state.mounted = true
    return true
end

-- -----------------------------------------------------------------------------
-- Wiring: try to mount when the transmogrifier opens (deferred so the frame
-- has finished its own setup), and re-assert if needed.
-- -----------------------------------------------------------------------------
function NativeSetsTab:Initialize()
    local ev = CreateFrame("Frame")
    ev:RegisterEvent("TRANSMOGRIFY_OPEN")
    ev:SetScript("OnEvent", function()
        if C_Timer and C_Timer.After then
            C_Timer.After(0.15, function() NativeSetsTab:EnsureMounted() end)
            C_Timer.After(0.6,  function() NativeSetsTab:EnsureMounted() end)
        else
            NativeSetsTab:EnsureMounted()
        end
    end)
end

do
    local boot = CreateFrame("Frame")
    boot:RegisterEvent("PLAYER_LOGIN")
    boot:SetScript("OnEvent", function()
        local function initNativeSetsTab()
            if NativeSetsTab.Initialize then NativeSetsTab:Initialize() end
        end
        if ETB.RunWhenStartupIdle then
            ETB:RunWhenStartupIdle("nativeSetsTab.init", initNativeSetsTab, { delay = 2, interval = 2, maxWait = 180 })
        else
            initNativeSetsTab()
        end
    end)
end
