-- =============================================================================
-- ETB v12.48.0 — TransmogUI/Frame.lua
-- =============================================================================
-- Frame lifecycle and visual scaffolding for the TransmogUI window. Extracted
-- from the monolithic TransmogUI.lua as the 5th submodule, completing the
-- TransmogUI split (Preview, CategoryBar, ItemRows, Outfits, Frame).
--
-- Two clusters live here:
--
-- 1. LIFECYCLE — Initialize, Create, Show, Hide, Toggle, Reset
--    Owns when the frame exists and what state it's in.
--
-- 2. CONSTRUCTION — CreateTitleBar, CreateModelPanel, CreateSlotButtons,
--    CreateBrowserPanel, CreateBottomBar, CreateHoverPreview
--    Owns the visual scaffolding that gets built once on first Show.
--
-- Public API (all take owner = TUI singleton as first arg):
--   Frame:Initialize(owner)
--   Frame:Create(owner)            -> returns the main frame
--   Frame:Show(owner)
--   Frame:Hide(owner)
--   Frame:Toggle(owner)
--   Frame:Reset(owner)
--   Frame:CreateTitleBar(owner, parent)
--   Frame:CreateModelPanel(owner, parent)
--   Frame:CreateSlotButtons(owner, parent)
--   Frame:CreateBrowserPanel(owner, parent)
--   Frame:CreateBottomBar(owner, parent)
--   Frame:CreateHoverPreview(owner)
--
-- Owner contract — fields read/written:
--   owner.frame, .frame.model, .frame.modelPanel, .frame.browserPanel,
--   .frame.slotButtons, .frame.bottomBar, .frame.outfitsPanel
--   owner.pendingTransmogs, owner.itemRows, owner.itemCache, owner.rowPoolParent
--   owner.floatingButton, owner.blizzardTransmogFrame, owner.hoverPreviewFrame
--   owner.hoverPreviewItem
--   owner.selectedSlot, owner.selectedCategoryID, owner.selectedTab
--   owner.searchText, owner.colorFilter
--   owner._itemRefreshCount, owner._itemRefreshTimer
--   owner.ProfileManager, owner.SLOTS, owner.FRAME_WIDTH, owner.FRAME_HEIGHT
--
-- Owner methods called:
--   owner:BuildTransmogSlotIDs() owner:HookBlizzardTransmogFrame()
--   owner:SwitchToBlizzardUI() owner:ClearBlizzardPending()
--   owner:SyncFromBlizzardPending() owner:UpdateCost()
--   owner:UpdateSlotButtonIcons() owner:UpdateCategoryBarHighlight()
--   owner:UpdateCurrentViewingLabel() owner:RefreshOutfits() owner:RefreshBrowser()
--   owner:GetDefaultSlotIcon(invSlot) owner:ToggleSlotHide(invSlot)
--   owner:ApplyTransmogs()
--   owner:CreateCategoryBar(parent) owner:CreateOutfitsPanel(parent)
--   (the latter two call into other submodules via existing TUI delegators)
-- =============================================================================

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

NS.ETB.TransmogUI = NS.ETB.TransmogUI or {}
local Frame = {}
NS.ETB.TransmogUI.Frame = Frame

-- =============================================================================
-- LIFECYCLE: Initialize
-- =============================================================================

function Frame:Initialize(owner)
    -- Initialize ProfileManager for account-wide outfits
    owner.ProfileManager:Initialize()

    -- Initialize saved outfits table if not exists
    if not ETBTUI_SavedOutfits then
        ETBTUI_SavedOutfits = {}
    end

    owner:BuildTransmogSlotIDs()
    owner:HookBlizzardTransmogFrame()

    -- Create row pool parent (hidden frame)
    owner.rowPoolParent = CreateFrame("Frame", nil, UIParent)
    owner.rowPoolParent:Hide()

    -- Stats available via /etb command
    local profCount, outfitCount = owner.ProfileManager:GetStats()
    if ETB.Debug then
        ETB:Debug("TransmogUI", string.format("v0.16.2 loaded | %d characters, %d outfits", profCount, outfitCount))
    end
end

-- =============================================================================
-- LIFECYCLE: Create (one-time frame construction)
-- =============================================================================

function Frame:Create(owner)
    if owner.frame then return owner.frame end

    local f = CreateFrame("Frame", "ETBTransmogUIFrame", UIParent, "BackdropTemplate")
    f:SetSize(owner.FRAME_WIDTH, owner.FRAME_HEIGHT)
    f:SetPoint("CENTER")
    f:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
        edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Gold-Border",
        tile = true, tileSize = 32, edgeSize = 32,
        insets = { left = 8, right = 8, top = 8, bottom = 8 }
    })
    f:SetBackdropColor(0, 0, 0, 1)
    f:SetMovable(true)
    f:EnableMouse(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop", f.StopMovingOrSizing)
    f:SetFrameStrata("DIALOG")
    f:SetFrameLevel(100)
    f:Hide()

    -- Create a solid blocker texture to ensure complete coverage of main frame
    local blocker = f:CreateTexture(nil, "BACKGROUND", nil, -8)
    blocker:SetAllPoints(f)
    blocker:SetColorTexture(0.02, 0.02, 0.03, 1)
    f.blocker = blocker

    -- ESC closes frame (or switches to Blizzard transmog UI if opened from NPC).
    -- v14.10.1: removed pcall(SetPropagateKeyboardInput) calls — they fired
    -- ADDON_ACTION_BLOCKED on MoP Classic 5.4.8. Trade-off: while the
    -- transmog UI has keyboard focus, non-ESC keys won't propagate to chat
    -- (acceptable since this is a modal-style transmog browser, not a typing
    -- surface). The owner-aware ESC behavior is preserved.
    f:SetScript("OnKeyDown", function(self, key)
        if key == "ESCAPE" then
            if owner.blizzardTransmogFrame then owner:SwitchToBlizzardUI() else Frame:Hide(owner) end
        end
    end)

    -- Build the visual scaffolding (mix of submodule delegators + Frame methods).
    -- These match the order from the legacy implementation.
    Frame:CreateTitleBar(owner, f)
    Frame:CreateModelPanel(owner, f)
    Frame:CreateSlotButtons(owner, f)
    Frame:CreateBrowserPanel(owner, f)
    owner:CreateOutfitsPanel(f)   -- delegates to Outfits submodule
    owner:CreateCategoryBar(f)    -- delegates to CategoryBar submodule
    Frame:CreateBottomBar(owner, f)
    Frame:CreateHoverPreview(owner)

    owner.frame = f
    return f
end

-- =============================================================================
-- LIFECYCLE: Show
-- =============================================================================

function Frame:Show(owner)
    if not owner.frame then
        Frame:Create(owner)
    end

    -- Reset item refresh counter for this session
    owner._itemRefreshCount = 0
    owner._itemRefreshTimer = nil

    owner.frame:Show()

    -- TSM-style: when flipping from Blizzard, sync their pending so we don't lose selection
    if owner.blizzardTransmogFrame and owner:SyncFromBlizzardPending() then
        owner:UpdateCost()
        owner:UpdateSlotButtonIcons()
        -- Mirror Blizzard model: re-dress player then apply all non-hidden pending transmogs
        if owner.frame.model then
            owner.frame.model:Dress()
            for _, pend in pairs(owner.pendingTransmogs) do
                if pend.itemID and not pend.isHiddenSlot then
                    owner.frame.model:TryOn("item:" .. pend.itemID)
                end
            end
        end
    else
        Frame:Reset(owner)
        if owner.frame.model then
            owner.frame.model:Dress()
        end
    end

    owner:RefreshOutfits()
    if not owner.selectedSlot then owner.selectedSlot = 1; owner.selectedCategoryID = 1 end
    owner:UpdateSlotButtonIcons()
    owner:UpdateCategoryBarHighlight()
    owner:UpdateCurrentViewingLabel()
    owner:RefreshBrowser()
    owner:UpdateCost()
end

-- =============================================================================
-- LIFECYCLE: Hide
-- =============================================================================

function Frame:Hide(owner)
    if owner.frame then owner.frame:Hide() end
    if owner.HideHoverPreview then owner:HideHoverPreview() end
    if owner.floatingButton then owner.floatingButton:Hide() end

    -- Free memory: wipe item rows data and item cache
    if owner.itemRows then wipe(owner.itemRows) end
    if owner.itemCache then wipe(owner.itemCache) end

    -- Trim BlizzardData caches
    if ETB and ETB.BlizzardData and ETB.BlizzardData.cache then
        wipe(ETB.BlizzardData.cache.categories)
        wipe(ETB.BlizzardData.cache.sources)
    end

    collectgarbage("collect")
end

-- =============================================================================
-- LIFECYCLE: Toggle
-- =============================================================================

function Frame:Toggle(owner)
    if owner.frame and owner.frame:IsShown() then
        Frame:Hide(owner)
    else
        Frame:Show(owner)
    end
end

-- =============================================================================
-- LIFECYCLE: Reset
-- =============================================================================

function Frame:Reset(owner)
    wipe(owner.pendingTransmogs)
    owner:ClearBlizzardPending()

    if owner.frame.model then
        -- v15.8.13: The graphics flash near transmog NPCs traces to here.
        -- Original (v12.34.0) comment justified calling SetUnit unconditionally:
        -- "Dress alone may not reliably clear stale TryOn'd state." That's true,
        -- but SetUnit forces a full character mesh reload which visibly flashes
        -- whenever Reset runs — including the auto-Reset path in Frame:Show
        -- that fires every time the user opens TransmogUI (auto-triggered by
        -- TRANSMOGRIFY_OPEN if autoOpen is enabled — i.e., "when talking to
        -- the transmog NPC").
        --
        -- Fix: track whether the model has been TryOn'd since the last clean
        -- reset. If it hasn't, Dress alone is sufficient (and free). If it
        -- HAS been polluted, do the full SetUnit+Dress combo — but that's
        -- the rare case (right after a SetUnit-free Apply, or after switching
        -- to a saved outfit that TryOn'd previews).
        if owner.frame.model._tryOnCount and owner.frame.model._tryOnCount > 0 then
            owner.frame.model:SetUnit("player")
            owner.frame.model._tryOnCount = 0
        end
        owner.frame.model:Dress()
        owner.frame.model.zoomLevel = 0
        owner.frame.model:SetCamDistanceScale(0.9)
    end

    -- Reset slot buttons to default icons
    for invSlot, btn in pairs(owner.frame.slotButtons or {}) do
        btn.icon:SetTexture(owner:GetDefaultSlotIcon(invSlot))
        btn.pendingBorder:Hide()
        btn.pendingInner:Hide()
        btn.hiddenBorder:Hide()
        btn.isHidden = false
        btn:SetBackdropBorderColor(0.4, 0.4, 0.4, 1)
    end

    owner:UpdateCost()
end

-- =============================================================================
-- CONSTRUCTION: Title bar
-- =============================================================================

function Frame:CreateTitleBar(owner, parent)
    parent.title = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    parent.title:SetPoint("TOPLEFT", 8, -5)
    parent.title:SetText("|cffffd100TransmogUI|r")

    parent.subtitle = parent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    parent.subtitle:SetPoint("LEFT", parent.title, "RIGHT", 8, 0)
    parent.subtitle:SetText("|cff888888Build and apply outfits|r")

    parent.closeBtn = CreateFrame("Button", nil, parent, "UIPanelCloseButton")
    parent.closeBtn:SetPoint("TOPRIGHT", -2, -2)
    parent.closeBtn:SetScript("OnClick", function()
        if owner.blizzardTransmogFrame then owner:SwitchToBlizzardUI() else Frame:Hide(owner) end
    end)

    parent.defaultUIBtn = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
    parent.defaultUIBtn:SetSize(80, 20)
    parent.defaultUIBtn:SetPoint("TOPRIGHT", -28, -4)
    parent.defaultUIBtn:SetText("Default UI")
    parent.defaultUIBtn:SetScript("OnClick", function() owner:SwitchToBlizzardUI() end)
    parent.defaultUIBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_BOTTOM")
        GameTooltip:SetText("Switch to Blizzard's transmog UI")
        GameTooltip:Show()
    end)
    parent.defaultUIBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)
end

-- =============================================================================
-- CONSTRUCTION: Model panel
-- =============================================================================

function Frame:CreateModelPanel(owner, parent)
    local panel = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    panel:SetPoint("TOPLEFT", parent, "TOPLEFT", 380, -24)
    panel:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -260, -24)
    panel:SetPoint("BOTTOM", parent, "BOTTOM", 0, 94)
    panel:SetFrameStrata("DIALOG")
    panel:SetFrameLevel(parent:GetFrameLevel() + 10)
    panel:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 12,
        insets = { left = 4, right = 4, top = 4, bottom = 4 }
    })
    panel:SetBackdropColor(0.02, 0.02, 0.03, 1)

    local modelBg = panel:CreateTexture(nil, "ARTWORK", nil, -8)
    modelBg:SetPoint("TOPLEFT", panel, "TOPLEFT", 6, -6)
    modelBg:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -6, 6)
    modelBg:SetColorTexture(0.01, 0.01, 0.02, 1)
    panel.modelBackground = modelBg

    -- The DressUpModel - parented to panel so it inherits DIALOG strata
    local model = CreateFrame("DressUpModel", nil, panel)
    model:SetFrameLevel(panel:GetFrameLevel() + 5)
    model:SetSize(220, 380)
    model:SetPoint("TOP", 0, -2)
    model:SetRotation(0)
    model.rotation = 0
    model.zoomLevel = 0

    -- v15.8.13: hook TryOn to count pollution. Frame:Reset uses this counter
    -- to decide whether SetUnit("player") is needed (it's expensive — causes
    -- a visible character mesh reload). If the model hasn't been TryOn'd
    -- since the last clean reset, Dress alone is sufficient and free.
    model._tryOnCount = 0
    local _origTryOn = model.TryOn
    model.TryOn = function(self, ...)
        self._tryOnCount = (self._tryOnCount or 0) + 1
        return _origTryOn(self, ...)
    end

    -- Initialize model ONCE here - never call SetUnit again after this
    model:SetUnit("player")
    model.initialized = true

    model:SetCamDistanceScale(0.9)
    model:SetPosition(0, 0, -0.1)

    model:EnableMouse(true)
    model:EnableMouseWheel(true)

    -- v15.8.8: bind OnUpdate only WHILE rotating (was every-frame check)
    local function onUpdateRotate(self)
        local x = GetCursorPosition()
        self.rotation = (self.rotation or 0) + (x - self.startX) * 0.01
        self:SetRotation(self.rotation)
        self.startX = x
    end
    model:SetScript("OnMouseDown", function(self, btn)
        if btn == "LeftButton" then
            self.startX = GetCursorPosition()
            self:SetScript("OnUpdate", onUpdateRotate)
        end
    end)
    model:SetScript("OnMouseUp", function(self)
        self:SetScript("OnUpdate", nil)
    end)
    model:SetScript("OnMouseWheel", function(self, delta)
        self.zoomLevel = (self.zoomLevel or 0) + delta * 0.1
        self.zoomLevel = math.max(-0.5, math.min(1.5, self.zoomLevel))
        self:SetCamDistanceScale(1 - self.zoomLevel)
    end)

    -- Reset and Undress buttons side by side
    local resetBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    resetBtn:SetSize(70, 18)
    resetBtn:SetPoint("BOTTOMLEFT", 10, 14)
    resetBtn:SetText("Reset")
    resetBtn:SetScript("OnClick", function()
        if owner.frame.model then
            owner.frame.model:Dress()
            owner.frame.model.zoomLevel = 0
            owner.frame.model:SetCamDistanceScale(0.9)
            for invSlot, pending in pairs(owner.pendingTransmogs) do
                if pending.itemID and not pending.isHiddenSlot then
                    owner.frame.model:TryOn("item:" .. pending.itemID)
                end
            end
        end
    end)

    local undressBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    undressBtn:SetSize(70, 18)
    undressBtn:SetPoint("BOTTOMRIGHT", -10, 14)
    undressBtn:SetText("Undress")
    undressBtn:SetScript("OnClick", function()
        if owner.frame.model then
            owner.frame.model:Undress()
        end
    end)

    local hint = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    hint:SetPoint("BOTTOM", 0, 2)
    hint:SetText("|cff888888Drag rotate / Scroll zoom|r")

    parent.modelPanel = panel
    parent.model = model
end

-- =============================================================================
-- CONSTRUCTION: Slot buttons
-- =============================================================================

function Frame:CreateSlotButtons(owner, parent)
    parent.slotButtons = {}

    local buttonHeight = 26
    local buttonWidth = 26
    local spacing = 2
    local startY = -15

    for _, slotInfo in ipairs(owner.SLOTS) do
        local btn = CreateFrame("Button", nil, parent.modelPanel, "BackdropTemplate")
        btn:SetSize(buttonWidth, buttonHeight)

        local yOffset = startY - ((slotInfo.row - 1) * (buttonHeight + spacing))
        if slotInfo.side == "left" then
            btn:SetPoint("TOPRIGHT", parent.model, "TOPLEFT", -4, yOffset)
        elseif slotInfo.side == "right" then
            btn:SetPoint("TOPLEFT", parent.model, "TOPRIGHT", 4, yOffset)
        elseif slotInfo.side == "center" then
            local centerX = (slotInfo.row == 1) and -(buttonWidth/2 + 3) or (buttonWidth/2 + 3)
            btn:SetPoint("TOP", parent.modelPanel, "BOTTOM", centerX, -4)
        end

        btn:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            tile = false, edgeSize = 8,
            insets = { left = 2, right = 2, top = 2, bottom = 2 }
        })
        btn:SetBackdropColor(0.1, 0.1, 0.1, 0.9)
        btn:SetBackdropBorderColor(0.4, 0.4, 0.4, 1)

        btn.pendingBorder = btn:CreateTexture(nil, "OVERLAY", nil, 2)
        btn.pendingBorder:SetPoint("TOPLEFT", -1, 1)
        btn.pendingBorder:SetPoint("BOTTOMRIGHT", 1, -1)
        btn.pendingBorder:SetColorTexture(1, 0.82, 0, 0.9)
        btn.pendingBorder:Hide()

        btn.pendingInner = btn:CreateTexture(nil, "OVERLAY", nil, 3)
        btn.pendingInner:SetPoint("TOPLEFT", 1, -1)
        btn.pendingInner:SetPoint("BOTTOMRIGHT", -1, 1)
        btn.pendingInner:SetColorTexture(0.12, 0.1, 0.04, 0.9)
        btn.pendingInner:Hide()

        btn.hiddenBorder = btn:CreateTexture(nil, "OVERLAY", nil, 4)
        btn.hiddenBorder:SetPoint("TOPLEFT", -1, 1)
        btn.hiddenBorder:SetPoint("BOTTOMRIGHT", 1, -1)
        btn.hiddenBorder:SetColorTexture(0.8, 0.2, 0.2, 0.9)
        btn.hiddenBorder:Hide()

        btn.icon = btn:CreateTexture(nil, "OVERLAY", nil, 5)
        btn.icon:SetSize(20, 20)
        btn.icon:SetPoint("CENTER", 0, 0)
        btn.icon:SetTexture(owner:GetDefaultSlotIcon(slotInfo.invSlot))
        btn.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

        btn.invSlot = slotInfo.invSlot
        btn.slotName = slotInfo.name
        btn.categoryID = slotInfo.categoryID
        btn.isWeapon = slotInfo.isWeapon
        btn.isHidden = false

        btn:SetScript("OnClick", function(self)
            owner:ToggleSlotHide(self.invSlot)
        end)

        btn:SetScript("OnEnter", function(self)
            self:SetBackdropBorderColor(0.6, 0.6, 0.6, 1)
            GameTooltip:SetOwner(self, "ANCHOR_LEFT")
            GameTooltip:SetText(self.slotName)
            local pending = owner.pendingTransmogs[self.invSlot]
            if pending then
                if pending.isHiddenSlot then
                    GameTooltip:AddLine("Hidden", 1, 0.4, 0.4)
                else
                    GameTooltip:AddLine(pending.name or "Unknown", 0.5, 1, 0.5)
                end
            else
                GameTooltip:AddLine("No change", 0.5, 0.5, 0.5)
            end
            GameTooltip:AddLine("Click to hide/show", 0.5, 0.5, 0.5)
            GameTooltip:Show()
        end)
        btn:SetScript("OnLeave", function(self)
            if not self.isHidden then
                self:SetBackdropBorderColor(0.4, 0.4, 0.4, 1)
            end
            GameTooltip:Hide()
        end)

        parent.slotButtons[slotInfo.invSlot] = btn
    end
end

-- =============================================================================
-- CONSTRUCTION: Browser panel
-- =============================================================================

function Frame:CreateBrowserPanel(owner, parent)
    local panel = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    panel:SetWidth(370)
    panel:SetPoint("TOPLEFT", 6, -24)
    panel:SetPoint("BOTTOMLEFT", 6, 94)
    panel:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 12,
        insets = { left = 4, right = 4, top = 4, bottom = 4 }
    })

    panel.title = panel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    panel.title:SetPoint("TOPLEFT", 8, -6)
    panel.title:SetText("|cffffd100Select a slot|r")

    -- Underline-style tabs
    local tabFrame = CreateFrame("Frame", nil, panel)
    tabFrame:SetSize(320, 20)
    tabFrame:SetPoint("TOPLEFT", 8, -20)

    panel.appearancesTab = CreateFrame("Button", nil, tabFrame)
    panel.appearancesTab:SetSize(95, 20)
    panel.appearancesTab:SetPoint("LEFT", 0, 0)
    panel.appearancesTab.text = panel.appearancesTab:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    panel.appearancesTab.text:SetAllPoints()
    panel.appearancesTab.text:SetText("|cff00ff00Appearances|r")
    panel.appearancesTab.underline = panel.appearancesTab:CreateTexture(nil, "OVERLAY")
    panel.appearancesTab.underline:SetSize(95, 2)
    panel.appearancesTab.underline:SetPoint("BOTTOM")
    panel.appearancesTab.underline:SetColorTexture(0, 1, 0.5, 1)
    panel.appearancesTab:SetScript("OnClick", function()
        owner.selectedTab = "appearances"
        panel.appearancesTab.text:SetText("|cff00ff00Appearances|r")
        panel.appearancesTab.underline:Show()
        panel.setsTab.text:SetText("|cff888888Sets|r")
        panel.setsTab.underline:Hide()
        panel.incompleteTab.text:SetText("|cff888888Incomplete|r")
        panel.incompleteTab.underline:Hide()
        owner:RefreshBrowser()
    end)
    panel.appearancesTab.SetText = function(self, text) self.text:SetText(text) end
    panel.appearancesTab.GetText = function(self) return self.text:GetText() end

    panel.setsTab = CreateFrame("Button", nil, tabFrame)
    panel.setsTab:SetSize(50, 20)
    panel.setsTab:SetPoint("LEFT", panel.appearancesTab, "RIGHT", 8, 0)
    panel.setsTab.text = panel.setsTab:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    panel.setsTab.text:SetAllPoints()
    panel.setsTab.text:SetText("|cff888888Sets|r")
    panel.setsTab.underline = panel.setsTab:CreateTexture(nil, "OVERLAY")
    panel.setsTab.underline:SetSize(50, 2)
    panel.setsTab.underline:SetPoint("BOTTOM")
    panel.setsTab.underline:SetColorTexture(0, 1, 0.5, 1)
    panel.setsTab.underline:Hide()
    panel.setsTab:SetScript("OnClick", function()
        owner.selectedTab = "sets"
        panel.appearancesTab.text:SetText("|cff888888Appearances|r")
        panel.appearancesTab.underline:Hide()
        panel.setsTab.text:SetText("|cff00ff00Sets|r")
        panel.setsTab.underline:Show()
        panel.incompleteTab.text:SetText("|cff888888Incomplete|r")
        panel.incompleteTab.underline:Hide()
        panel.title:SetText("|cffffd100Transmog Sets|r")
        owner:RefreshBrowser()
    end)
    panel.setsTab.SetText = function(self, text) self.text:SetText(text) end
    panel.setsTab.GetText = function(self) return self.text:GetText() end

    panel.incompleteTab = CreateFrame("Button", nil, tabFrame)
    panel.incompleteTab:SetSize(75, 20)
    panel.incompleteTab:SetPoint("LEFT", panel.setsTab, "RIGHT", 8, 0)
    panel.incompleteTab.text = panel.incompleteTab:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    panel.incompleteTab.text:SetAllPoints()
    panel.incompleteTab.text:SetText("|cff888888Incomplete|r")
    panel.incompleteTab.underline = panel.incompleteTab:CreateTexture(nil, "OVERLAY")
    panel.incompleteTab.underline:SetSize(75, 2)
    panel.incompleteTab.underline:SetPoint("BOTTOM")
    panel.incompleteTab.underline:SetColorTexture(1, 0.53, 0, 1)
    panel.incompleteTab.underline:Hide()
    panel.incompleteTab:SetScript("OnClick", function()
        owner.selectedTab = "incomplete"
        panel.appearancesTab.text:SetText("|cff888888Appearances|r")
        panel.appearancesTab.underline:Hide()
        panel.setsTab.text:SetText("|cff888888Sets|r")
        panel.setsTab.underline:Hide()
        panel.incompleteTab.text:SetText("|cffff8800Incomplete|r")
        panel.incompleteTab.underline:Show()
        panel.title:SetText("|cffffd100Incomplete Sets|r")
        owner:RefreshBrowser()
    end)
    panel.incompleteTab.SetText = function(self, text) self.text:SetText(text) end
    panel.incompleteTab.GetText = function(self) return self.text:GetText() end

    panel.search = CreateFrame("EditBox", nil, panel, "InputBoxTemplate")
    panel.search:SetSize(220, 18)
    panel.search:SetPoint("TOPLEFT", 8, -42)
    panel.search:SetAutoFocus(false)
    panel.search:SetScript("OnTextChanged", function(self)
        owner.searchText = self:GetText():lower()
        owner:RefreshBrowser()
    end)
    panel.search:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)

    -- Color filter dropdown
    local ETBDropdown = NS.ETBDropdown
    if ETBDropdown then
        panel.colorDropdown = ETBDropdown:Create("ETBTUIColorDD", panel)
        panel.colorDropdown:SetPoint("TOPLEFT", panel.search, "TOPRIGHT", 8, 3)
        ETBDropdown:SetWidth(panel.colorDropdown, 70)
        ETBDropdown:SetText(panel.colorDropdown, "Color")
        ETBDropdown:Initialize(panel.colorDropdown, function(dd, _items_)
            local info = ETBDropdown:CreateInfo()
            info.text = "All Colors"
            info.value = nil
            info.func = function()
                ETBDropdown:SetText(panel.colorDropdown, "Color")
                owner.colorFilter = nil
                owner:RefreshBrowser()
            end
            ETBDropdown:AddButton(_items_, info)

            if ETB and ETB.ColorSearch then
                for _, cat in ipairs(ETB.ColorSearch:GetCategories()) do
                    info.text = cat.name
                    info.value = cat.id
                    info.func = function()
                        ETBDropdown:SetText(panel.colorDropdown, cat.name)
                        owner.colorFilter = cat.id
                        owner:RefreshBrowser()
                    end
                    ETBDropdown:AddButton(_items_, info)
                end
            end
        end)
    end

    local scrollContainer = CreateFrame("Frame", nil, panel)
    scrollContainer:SetPoint("TOPLEFT", 4, -62)
    scrollContainer:SetPoint("BOTTOMRIGHT", -5, 28)

    local scrollFrame = CreateFrame("ScrollFrame", nil, scrollContainer, "UIPanelScrollFrameTemplate")
    scrollFrame:SetAllPoints()

    local scrollChild = CreateFrame("Frame", nil, scrollFrame)
    scrollChild:SetWidth(scrollFrame:GetWidth() - 20)
    scrollChild:SetHeight(1)
    scrollFrame:SetScrollChild(scrollChild)

    panel.scrollFrame = scrollFrame
    panel.scrollChild = scrollChild

    panel.stats = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    panel.stats:SetPoint("BOTTOM", 0, 10)
    panel.stats:SetText("|cff888888Select a slot to browse|r")

    parent.browserPanel = panel
end

-- =============================================================================
-- CONSTRUCTION: Bottom bar
-- =============================================================================

function Frame:CreateBottomBar(owner, parent)
    local bar = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    bar:SetSize(owner.FRAME_WIDTH - 20, 28)
    bar:SetPoint("BOTTOM", 0, 2)
    bar:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 12,
        insets = { left = 4, right = 4, top = 4, bottom = 4 }
    })

    bar.costLabel = bar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    bar.costLabel:SetPoint("LEFT", 14, 2)
    bar.costLabel:SetText("|cffffd100Cost:|r")

    bar.costValue = bar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    bar.costValue:SetPoint("LEFT", bar.costLabel, "RIGHT", 4, 0)
    bar.costValue:SetText("0 |TInterface\\MoneyFrame\\UI-GoldIcon:14:14:0:0|t")

    bar.pendingLabel = bar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    bar.pendingLabel:SetPoint("LEFT", bar.costValue, "RIGHT", 12, 0)
    bar.pendingLabel:SetText("|cff8888880 changes pending|r")

    bar.closeBtn = CreateFrame("Button", nil, bar, "UIPanelButtonTemplate")
    bar.closeBtn:SetSize(65, 20)
    bar.closeBtn:SetPoint("RIGHT", -12, 0)
    bar.closeBtn:SetText("Close")
    bar.closeBtn:SetScript("OnClick", function()
        if owner.blizzardTransmogFrame then owner:SwitchToBlizzardUI() else Frame:Hide(owner) end
    end)

    bar.applyBtn = CreateFrame("Button", nil, bar, "UIPanelButtonTemplate")
    bar.applyBtn:SetSize(75, 20)
    bar.applyBtn:SetPoint("RIGHT", bar.closeBtn, "LEFT", -4, 0)
    bar.applyBtn:SetText("|cff00ff88Apply|r")
    bar.applyBtn:SetScript("OnClick", function() owner:ApplyTransmogs() end)
    -- v14.5.0: tooltip explicitly explains the NPC requirement so users
    -- understand why the button might fail. Outfit creation works anywhere;
    -- only Apply requires a Transmogrifier NPC.
    bar.applyBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_TOP")
        GameTooltip:SetText("Apply Transmogs")
        local atNPC = owner:CanApplyTransmog()
        if atNPC then
            GameTooltip:AddLine("|cff88ff88Ready to apply.|r Click to send the", 0.5, 1, 0.5, true)
            GameTooltip:AddLine("queued pieces to the Transmogrifier.", 0.8, 0.8, 0.8, true)
        else
            GameTooltip:AddLine("|cffff8888Requires Transmogrifier NPC.|r", 1, 0.5, 0.5, true)
            GameTooltip:AddLine("You can build and save outfits anywhere,", 0.8, 0.8, 0.8, true)
            GameTooltip:AddLine("but applying them needs the NPC.", 0.8, 0.8, 0.8, true)
        end
        GameTooltip:Show()
    end)
    bar.applyBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)

    bar.resetBtn = CreateFrame("Button", nil, bar, "UIPanelButtonTemplate")
    bar.resetBtn:SetSize(65, 20)
    bar.resetBtn:SetPoint("RIGHT", bar.applyBtn, "LEFT", -4, 0)
    bar.resetBtn:SetText("Reset")
    bar.resetBtn:SetScript("OnClick", function() Frame:Reset(owner) end)

    -- v14.5.0 NPC status indicator: shows whether the Apply button will work
    -- right now. Updates whenever the user opens the bottom-bar area or moves
    -- to/from a Transmogrifier (TRANSMOGRIFY_OPEN / CLOSE events also poke this).
    -- We position it in the middle gap between pending-label and reset button.
    bar.npcStatus = bar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    bar.npcStatus:SetPoint("RIGHT", bar.resetBtn, "LEFT", -8, 0)
    bar.npcStatus:SetWidth(220)
    bar.npcStatus:SetJustifyH("RIGHT")

    -- Helper: refresh both the status text and the button's visual state.
    -- Call this from event handlers that change NPC proximity.
    bar.RefreshApplyState = function()
        local atNPC = owner:CanApplyTransmog()
        if atNPC then
            bar.npcStatus:SetText("|cff88ff88At Transmogrifier|r")
            bar.applyBtn:Enable()
            bar.applyBtn:SetText("|cff00ff88Apply|r")
        else
            bar.npcStatus:SetText("|cffaaaaaaSave-only mode (no NPC)|r")
            bar.applyBtn:Disable()
            bar.applyBtn:SetText("|cff666666Apply|r")
        end
    end
    bar.RefreshApplyState()

    -- Poll every 2s while the bar is shown so the state catches up after
    -- the player walks to/from an NPC without the UI knowing. Light cost.
    -- (TRANSMOGRIFY_OPEN/CLOSE events would be better but they only fire
    -- when interacting with the Blizzard UI, not when entering proximity.)
    bar:SetScript("OnUpdate", function(self, elapsed)
        self._npcCheckTimer = (self._npcCheckTimer or 0) + elapsed
        if self._npcCheckTimer >= 2 then
            self._npcCheckTimer = 0
            if self.RefreshApplyState then self.RefreshApplyState() end
        end
    end)

    parent.bottomBar = bar
end

-- =============================================================================
-- CONSTRUCTION: Hover preview (delegates to Preview submodule)
-- =============================================================================

function Frame:CreateHoverPreview(owner)
    local P = NS.ETB.TransmogUI and NS.ETB.TransmogUI.Preview
    if not P then return end
    P:Create()
    -- Keep TUI-side alias so any code still reading owner.hoverPreviewFrame works.
    owner.hoverPreviewFrame = P:GetFrame()
end
