-- =============================================================================
-- ETB v12.47.0 — TransmogUI/Outfits.lua
-- =============================================================================
-- Saved outfits panel: list, save, load, delete, import (Blizzard + string).
-- Extracted from the monolithic TransmogUI.lua as the 4th submodule, following
-- Preview.lua, CategoryBar.lua, and ItemRows.lua precedent.
--
-- Public API (all take `owner` = TUI singleton as first arg):
--   Outfits:CreatePanel(owner, parent)           — build the outfits panel
--   Outfits:Refresh(owner)                       — redraw the outfit list
--   Outfits:ShowSaveDialog(owner)                — prompt for outfit name
--   Outfits:Save(owner, name)                    — save current pending transmogs
--   Outfits:Delete(owner, outfitID)              — delete by ID
--   Outfits:Load(owner, outfitID, isAccount)     — load into preview
--   Outfits:ImportFromString(owner, str)         — import serialized outfit
--   Outfits:ImportFromBlizzard(owner, refreshMode) — import from Blizzard UI
--   Outfits:GetBlizzardItems(owner, outfitID)    — Blizzard API helper
--
-- Owner contract — fields used:
--   owner.frame, owner.frame.model, owner.frame.outfitsPanel
--   owner.outfitButtons                 (array; managed here)
--   owner.selectedOutfitID              (currently selected)
--   owner.selectedIsAccount             (account-wide flag)
--   owner.pendingTransmogs              (active transmog build)
--   owner.itemIDToSourceID              (sourceID lookup table)
--   owner.ProfileManager                (file-local PM instance on TUI)
--   owner.ExportSystem                  (file-local ES instance on TUI)
--   owner.INV_TO_SLOT_NAME              (slot id → name map)
--   owner.SLOT_NAME_TO_INV              (slot name → id map)
--
-- Owner methods called:
--   owner:CaptureCurrentTransmog()      (Save button handler)
--   owner:UpdateSlotButtonIcons()       (after outfit load)
--   owner:UpdateCost()                  (after outfit load)
--   owner:SyncToBlizzardPending()       (push to Blizzard transmog API)
--
-- SavedVariables referenced:
--   ETBTUI_SavedOutfits                 (character outfits dict)
--
-- Globals used (WoW API):
--   CreateFrame, GameTooltip, StaticPopup_Show, GetItemInfo, time, wipe,
--   C_TransmogCollection.{GetOutfits, GetOutfitInfo, GetOutfitItemTransmogInfoList,
--     GetOutfitSourceList, GetAppearanceSources, GetSourceInfo}
-- =============================================================================

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

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

-- AUTO_HIDE_SLOTS: when a loaded outfit doesn't cover these slots, force them
-- hidden in the preview. Copied verbatim from legacy TUI:LoadOutfitToPreview.
local AUTO_HIDE_SLOTS = {
    [1]=true, [3]=true, [5]=true, [6]=true, [7]=true,
    [8]=true, [9]=true, [10]=true, [15]=true,
}

-- =============================================================================
-- Panel construction
-- =============================================================================

function Outfits:CreatePanel(owner, parent)
    local panel = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    panel:SetWidth(250)
    panel:SetPoint("TOPRIGHT", -6, -24)
    panel:SetPoint("BOTTOMRIGHT", -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 }
    })

    -- Compact header: title left, Save button right
    panel.title = panel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    panel.title:SetPoint("TOPLEFT", 8, -6)
    panel.title:SetText("|cffffd100Saved outfits|r")

    panel.saveBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    panel.saveBtn:SetSize(55, 20)
    panel.saveBtn:SetPoint("TOPRIGHT", -48, -3)
    panel.saveBtn:SetText("|cff00ff00Save|r")
    -- v14.5.0: Save is now smart-dispatch.
    --   If the user has been clicking pieces in the browser (pendingTransmogs
    --   is non-empty), save THOSE — that's "the outfit you're building right now".
    --   Otherwise, fall back to capturing the player's current worn look via
    --   CaptureCurrentTransmog. Both paths work without a Transmog NPC.
    panel.saveBtn:SetScript("OnClick", function()
        local pendingCount = 0
        for _ in pairs(owner.pendingTransmogs or {}) do pendingCount = pendingCount + 1 end
        if pendingCount > 0 then
            Outfits:ShowSaveDialog(owner)
        else
            owner:CaptureCurrentTransmog()
        end
    end)
    panel.saveBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_TOP")
        GameTooltip:SetText("|cff00ff00Save Outfit|r")
        local pendingCount = 0
        for _ in pairs(owner.pendingTransmogs or {}) do pendingCount = pendingCount + 1 end
        if pendingCount > 0 then
            GameTooltip:AddLine("Save the " .. pendingCount .. " piece" ..
                (pendingCount == 1 and "" or "s") .. " you've selected.", 0.8, 0.8, 0.8, true)
            GameTooltip:AddLine("|cff88ff88Works without a Transmog NPC.|r", 0.5, 1, 0.5, true)
        else
            GameTooltip:AddLine("Capture your current worn appearance", 0.8, 0.8, 0.8, true)
            GameTooltip:AddLine("as a new outfit.", 0.8, 0.8, 0.8, true)
            GameTooltip:AddLine("|cffaaaaaaTip:|r click pieces in the browser first", 0.7, 0.7, 0.7, true)
            GameTooltip:AddLine("|cffaaaaaa     to build a new outfit instead.|r", 0.7, 0.7, 0.7, true)
        end
        GameTooltip:Show()
    end)
    panel.saveBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)

    panel.accountToggle = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    panel.accountToggle:SetSize(50, 20)
    panel.accountToggle:SetPoint("TOPRIGHT", -3, -3)
    panel.accountToggle:SetText("|cff88ff88Char|r")
    panel.accountToggle.showAccount = false
    panel.accountToggle:SetScript("OnClick", function(self)
        self.showAccount = not self.showAccount
        if self.showAccount then
            self:SetText("|cff88aaffAll|r")
            panel.title:SetText("|cff88aaffAccount outfits|r")
        else
            self:SetText("|cff88ff88Char|r")
            panel.title:SetText("|cffffd100Saved outfits|r")
        end
        Outfits:Refresh(owner)
    end)
    panel.accountToggle:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_TOP")
        if self.showAccount then
            GameTooltip:SetText("Viewing: Account-Wide Outfits")
            GameTooltip:AddLine("Shared across all your characters", 0.5, 0.8, 1)
            GameTooltip:AddLine("Click to show this character only", 0.8, 0.8, 0.8)
        else
            GameTooltip:SetText("Viewing: This Character's Outfits")
            GameTooltip:AddLine("Private to this character", 0.5, 1, 0.5)
            GameTooltip:AddLine("Click to show account-wide", 0.8, 0.8, 0.8)
        end
        GameTooltip:Show()
    end)
    panel.accountToggle:SetScript("OnLeave", function() GameTooltip:Hide() end)

    -- v14.3.0 Feature: Compatible-only toggle. Default ON — only show outfits
    -- this character can equip (armor matches; weapons may differ as warning).
    -- Click to flip and see all outfits with incompatibility warnings shown.
    panel.compatToggle = CreateFrame("CheckButton", nil, panel, "UICheckButtonTemplate")
    panel.compatToggle:SetSize(18, 18)
    panel.compatToggle:SetPoint("TOPLEFT", 6, -28)
    panel.compatToggle:SetChecked(true)  -- default: filter incompatible outfits
    owner.compatibleOnly = true

    panel.compatLabel = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    panel.compatLabel:SetPoint("LEFT", panel.compatToggle, "RIGHT", 1, 1)
    panel.compatLabel:SetText("|cffaaaaaaCompatible only|r")

    panel.compatToggle:SetScript("OnClick", function(self)
        owner.compatibleOnly = self:GetChecked() and true or false
        Outfits:Refresh(owner)
    end)
    panel.compatToggle:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_LEFT")
        GameTooltip:SetText("Compatible only")
        GameTooltip:AddLine("Hide outfits whose armor type doesn't match", 0.8, 0.8, 0.8, true)
        GameTooltip:AddLine("your class. Weapon mismatches still show as", 0.8, 0.8, 0.8, true)
        GameTooltip:AddLine("warnings, since you can swap weapons.", 0.8, 0.8, 0.8, true)
        GameTooltip:Show()
    end)
    panel.compatToggle:SetScript("OnLeave", function() GameTooltip:Hide() end)

    local scrollContainer = CreateFrame("Frame", nil, panel)
    scrollContainer:SetPoint("TOPLEFT", 5, -48)
    scrollContainer:SetPoint("BOTTOMRIGHT", -5, 34)

    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.scrollChild = scrollChild

    -- Bottom: Load, Random, Delete side by side
    panel.loadBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    panel.loadBtn:SetSize(78, 22)
    panel.loadBtn:SetPoint("BOTTOMLEFT", 6, 10)
    panel.loadBtn:SetText("|cff00ff00Load|r")
    panel.loadBtn:SetScript("OnClick", function()
        if owner.selectedOutfitID then
            -- v15.8.4: pass isAccount so account-scoped outfits (saved via the
            -- main browser's Save Outfit button in v15.8.3) actually load.
            -- Previously the third arg was omitted, causing :Load to fall
            -- through to ETBTUI_SavedOutfits (the character store) where the
            -- account outfit doesn't exist; silent no-op.
            Outfits:Load(owner, owner.selectedOutfitID, owner.selectedIsAccount)
        end
    end)

    -- v14.2.0 Feature 1.8: Random saved outfit button
    panel.randomBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    panel.randomBtn:SetSize(60, 22)
    panel.randomBtn:SetPoint("BOTTOM", 0, 10)
    panel.randomBtn:SetText("|cffffd100Random|r")
    panel.randomBtn:SetScript("OnClick", function() Outfits:LoadRandom(owner) end)
    panel.randomBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_TOP")
        GameTooltip:SetText("Random Saved Outfit")
        GameTooltip:AddLine("Pick a saved outfit at random and load it.", 0.8, 0.8, 0.8, true)
        GameTooltip:Show()
    end)
    panel.randomBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)

    panel.deleteBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    panel.deleteBtn:SetSize(78, 22)
    panel.deleteBtn:SetPoint("BOTTOMRIGHT", -6, 10)
    panel.deleteBtn:SetText("|cffff4444Delete|r")
    panel.deleteBtn:SetScript("OnClick", function()
        if owner.selectedOutfitID then
            -- v15.8.4: same fix as the Load button — pass isAccount
            Outfits:Delete(owner, owner.selectedOutfitID, owner.selectedIsAccount)
        end
    end)

    panel.stats = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    panel.stats:SetPoint("BOTTOM", 0, 36)

    panel.hint = panel:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    panel.hint:SetPoint("BOTTOM", 0, 50)
    panel.hint:SetText("|cff666666right-click an outfit for options|r")

    parent.outfitsPanel = panel
end

-- =============================================================================
-- Refresh (re-render outfit list)
-- =============================================================================

function Outfits:Refresh(owner)
    if not owner.frame or not owner.frame.outfitsPanel then return end
    local panel = owner.frame.outfitsPanel
    local scrollChild = panel.scrollChild

    for _, btn in pairs(owner.outfitButtons) do btn:Hide() end
    wipe(owner.outfitButtons)

    local outfits = {}
    local showAccount = panel.accountToggle and panel.accountToggle.showAccount

    if showAccount then
        local usable = owner.ProfileManager:GetOutfitsForCurrentClass()
        for id, data in pairs(usable) do
            table.insert(outfits, { id = id, data = data, isAccount = true })
        end
    else
        for id, data in pairs(ETBTUI_SavedOutfits) do
            table.insert(outfits, { id = id, data = data })
        end
    end

    table.sort(outfits, function(a, b)
        -- v14.4.0 Feature 4.1: respect manual ordering (drag-reordered),
        -- then fall back to creation date for outfits not yet ordered.
        local oa = a.data.manualOrder
        local ob = b.data.manualOrder
        if oa and ob then return oa < ob end
        if oa and not ob then return true end   -- ordered ahead of unordered
        if ob and not oa then return false end
        return (a.data.createdAt or a.data.created or 0) > (b.data.createdAt or b.data.created or 0)
    end)

    -- v14.3.0: get player class once for compatibility checks below
    local _, playerClass = UnitClass("player")
    local compatibleOnly = owner.compatibleOnly
    local OC = ETB.OutfitCompatibility

    -- v14.3.0: precompute compatibility for each candidate so we can both
    -- filter (when compatibleOnly is on) and decorate the row visually.
    -- We keep the entry struct flat: each gets .armorOK / .weaponOK / .reason.
    local filtered = {}
    for _, entry in ipairs(outfits) do
        if OC then
            entry.armorOK, entry.weaponOK, entry.reason =
                OC:CheckCompatibility(entry.data, playerClass)
        else
            entry.armorOK, entry.weaponOK = true, true
        end
        -- Hide armor-incompatible outfits when filter is on. Weapon mismatches
        -- always still show (soft warning only) per the design call.
        if compatibleOnly and entry.armorOK == false then
            -- skip
        else
            filtered[#filtered + 1] = entry
        end
    end
    outfits = filtered

    local rowH, rowS = 45, 3
    for i, entry in ipairs(outfits) do
        local btn = CreateFrame("Button", nil, scrollChild)
        btn:SetSize(260, rowH)
        btn:SetPoint("TOPLEFT", 5, -5 - (i - 1) * (rowH + rowS))

        btn.bg = btn:CreateTexture(nil, "BACKGROUND"); btn.bg:SetAllPoints()
        -- v14.3.0: dim incompatible entries so they read as warnings even when shown
        if entry.armorOK == false then
            btn.bg:SetColorTexture(0.18, 0.07, 0.07, 0.85)  -- red-tinted for armor mismatch
        elseif entry.weaponOK == false then
            btn.bg:SetColorTexture(0.18, 0.13, 0.05, 0.85)  -- amber-tinted for weapon warning
        elseif entry.isAccount then
            btn.bg:SetColorTexture(0.1, 0.1, 0.2, 0.8)  -- Blue tint for account
        else
            btn.bg:SetColorTexture(0.1, 0.1, 0.1, 0.8)
        end
        btn.selected = btn:CreateTexture(nil, "BACKGROUND", nil, 1); btn.selected:SetAllPoints(); btn.selected:SetColorTexture(0.15, 0.4, 0.15, 0.9); btn.selected:Hide()
        btn.highlight = btn:CreateTexture(nil, "HIGHLIGHT"); btn.highlight:SetAllPoints(); btn.highlight:SetColorTexture(0.2, 0.3, 0.2, 0.5)

        btn.icon = btn:CreateTexture(nil, "ARTWORK"); btn.icon:SetSize(38, 38); btn.icon:SetPoint("LEFT", 3, 0)
        local icon = entry.data.icon
        if not icon and entry.data.items then
            for _, item in pairs(entry.data.items) do
                if item.icon then icon = item.icon; break end
            end
        end
        btn.icon:SetTexture(icon or "Interface\\Icons\\INV_Chest_Cloth_17"); btn.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        -- Desaturate icon for armor-incompatible to make it read as "can't use"
        if entry.armorOK == false and btn.icon.SetDesaturated then
            btn.icon:SetDesaturated(true)
        end

        btn.name = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal"); btn.name:SetPoint("TOPLEFT", 45, -8)
        btn.name:SetText(entry.data.name); btn.name:SetWidth(200); btn.name:SetJustifyH("LEFT")

        local pieceCount = entry.data.pieceCount or 0
        if pieceCount == 0 and entry.data.items then
            for _ in pairs(entry.data.items) do pieceCount = pieceCount + 1 end
        end
        local infoText = string.format("|cff888888%d pieces|r", pieceCount)
        if entry.isAccount then
            infoText = infoText .. " |cff88aaff[Account]|r"
        elseif entry.data.importedFromBlizz then
            infoText = infoText .. " |cff00ffff[Blizz]|r"
        elseif entry.data.capturedFromGear then
            infoText = infoText .. " |cff00ff88[Captured]|r"
        elseif entry.data.importedFromString then
            infoText = infoText .. " |cffaa88ff[Imported]|r"
        end
        -- v14.3.0: append compatibility status label
        if OC then
            local label = OC:GetStatusLabel(entry.data, playerClass)
            if label then infoText = infoText .. " " .. label end
        end

        btn.info = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall"); btn.info:SetPoint("BOTTOMLEFT", 45, 8)
        btn.info:SetText(infoText)

        btn.outfitID = entry.id
        btn.isAccount = entry.isAccount
        btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
        btn:SetScript("OnClick", function(self, button)
            if button == "RightButton" then
                -- v14.2.0 Features 4.2 + 4.3: per-outfit options (ignored slots, icon)
                if ETB.OutfitOptions then
                    ETB.OutfitOptions:Show(self.outfitID, self.isAccount)
                end
            else
                owner.selectedOutfitID = self.outfitID
                owner.selectedIsAccount = self.isAccount
                Outfits:Refresh(owner)
            end
        end)
        btn:SetScript("OnDoubleClick", function(self)
            Outfits:Load(owner, self.outfitID, self.isAccount)
        end)

        -- v14.4.0 Feature 4.1: tiny up/down chevrons on the right edge of
        -- the row, only for character outfits (not account-wide). Click to
        -- swap with neighbor.
        if not entry.isAccount then
            btn.upBtn = CreateFrame("Button", nil, btn)
            btn.upBtn:SetSize(14, 18)
            btn.upBtn:SetPoint("TOPRIGHT", -2, -2)
            local upTex = btn.upBtn:CreateTexture(nil, "ARTWORK")
            upTex:SetAllPoints()
            -- Use the standard UI arrow texture (works on 5.4.8)
            upTex:SetTexture("Interface\\Buttons\\UI-MicroStream-Yellow")
            upTex:SetTexCoord(0, 1, 0, 1)
            upTex:SetRotation(math.pi)  -- flip to point up
            local upHi = btn.upBtn:CreateTexture(nil, "HIGHLIGHT")
            upHi:SetAllPoints()
            upHi:SetColorTexture(1, 1, 1, 0.25)
            btn.upBtn:SetScript("OnClick", function()
                Outfits:MoveOutfit(owner, entry.id, -1)
            end)
            btn.upBtn:SetScript("OnEnter", function(self)
                GameTooltip:SetOwner(self, "ANCHOR_LEFT")
                GameTooltip:SetText("Move up")
                GameTooltip:Show()
            end)
            btn.upBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)

            btn.downBtn = CreateFrame("Button", nil, btn)
            btn.downBtn:SetSize(14, 18)
            btn.downBtn:SetPoint("BOTTOMRIGHT", -2, 2)
            local downTex = btn.downBtn:CreateTexture(nil, "ARTWORK")
            downTex:SetAllPoints()
            downTex:SetTexture("Interface\\Buttons\\UI-MicroStream-Yellow")
            downTex:SetTexCoord(0, 1, 0, 1)
            local downHi = btn.downBtn:CreateTexture(nil, "HIGHLIGHT")
            downHi:SetAllPoints()
            downHi:SetColorTexture(1, 1, 1, 0.25)
            btn.downBtn:SetScript("OnClick", function()
                Outfits:MoveOutfit(owner, entry.id, 1)
            end)
            btn.downBtn:SetScript("OnEnter", function(self)
                GameTooltip:SetOwner(self, "ANCHOR_LEFT")
                GameTooltip:SetText("Move down")
                GameTooltip:Show()
            end)
            btn.downBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)
        end

        -- v14.3.0: hover tooltip explains incompatibility / weapon-warning
        if OC and (entry.armorOK == false or entry.weaponOK == false) then
            local ttText = OC:GetTooltipText(entry.data, playerClass)
            if ttText then
                btn:SetScript("OnEnter", function(self)
                    GameTooltip:SetOwner(self, "ANCHOR_LEFT")
                    GameTooltip:SetText(entry.data.name or "Outfit")
                    for line in string.gmatch(ttText, "[^\n]+") do
                        GameTooltip:AddLine(line, 1, 1, 1, true)
                    end
                    GameTooltip:Show()
                end)
                btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
            end
        end

        if owner.selectedOutfitID == entry.id then btn.selected:Show(); btn.bg:Hide() end
        owner.outfitButtons[i] = btn
    end

    scrollChild:SetHeight(math.max(1, #outfits * (rowH + rowS) + 10))
    local typeLabel = showAccount and "account" or "character"
    -- v14.3.0: count both shown + hidden if compatibleOnly is on, so users
    -- can see when a filter is hiding things
    local statsText = string.format("|cff888888%d %s outfits|r", #outfits, typeLabel)
    if compatibleOnly then
        local totalCount = 0
        if showAccount and owner.ProfileManager then
            for _ in pairs(owner.ProfileManager:GetOutfitsForCurrentClass()) do
                totalCount = totalCount + 1
            end
        else
            for _ in pairs(ETBTUI_SavedOutfits) do
                totalCount = totalCount + 1
            end
        end
        local hidden = totalCount - #outfits
        if hidden > 0 then
            statsText = statsText .. string.format(" |cff666666(%d hidden)|r", hidden)
        end
    end
    panel.stats:SetText(statsText)
end

-- =============================================================================
-- Move (v14.4.0 Feature 4.1)
-- =============================================================================
-- Swaps one outfit with the one directly above (delta=-1) or below (delta=+1)
-- in the currently-displayed sort order. We do this by writing manualOrder
-- values onto each outfit, normalizing them so we can compare cleanly.
--
-- Account outfits are NOT reorderable from this UI yet — only character
-- outfits. (Account outfits are stored on ProfileManager which has its own
-- ordering and would need its own write path. Out of scope for v14.4.0.)

function Outfits:MoveOutfit(owner, outfitID, delta)
    -- Currently visible character-outfit list, in display order
    local panel = owner.frame and owner.frame.outfitsPanel
    if not panel then return end
    if panel.accountToggle and panel.accountToggle.showAccount then
        -- Account outfits not reorderable in this UI
        return
    end

    local list = {}
    for id, data in pairs(ETBTUI_SavedOutfits) do
        list[#list + 1] = { id = id, data = data }
    end
    if #list < 2 then return end

    table.sort(list, function(a, b)
        local oa = a.data.manualOrder
        local ob = b.data.manualOrder
        if oa and ob then return oa < ob end
        if oa and not ob then return true end
        if ob and not oa then return false end
        return (a.data.createdAt or a.data.created or 0) > (b.data.createdAt or b.data.created or 0)
    end)

    -- Find the index of the requested outfit
    local idx = nil
    for i, entry in ipairs(list) do
        if entry.id == outfitID then idx = i; break end
    end
    if not idx then return end

    local newIdx = idx + delta
    if newIdx < 1 or newIdx > #list then return end

    -- Normalize: assign sequential manualOrder values from 1..n for ALL
    -- outfits. This ensures the sort behaves predictably regardless of how
    -- many outfits had a stored value vs. fell back to creation date.
    -- After normalization, swap the requested pair.
    for i, entry in ipairs(list) do
        entry.data.manualOrder = i
    end
    -- Swap idx <-> newIdx
    list[idx].data.manualOrder = newIdx
    list[newIdx].data.manualOrder = idx

    self:Refresh(owner)
end

-- =============================================================================
-- Save flow
-- =============================================================================

function Outfits:ShowSaveDialog(owner)
    local count = 0
    for _ in pairs(owner.pendingTransmogs) do count = count + 1 end
    if count == 0 then print("|cffff6600[ETB]|r Nothing to save!"); return end
    StaticPopup_Show("ETBTUI_SAVE_OUTFIT")
end

function Outfits:Save(owner, name)
    local items, pieceCount, firstIcon = {}, 0, nil
    for invSlot, data in pairs(owner.pendingTransmogs) do
        local slotName = owner.INV_TO_SLOT_NAME[invSlot]
        if slotName then
            items[slotName] = { itemID = data.itemID, name = data.name, icon = data.icon, sourceID = data.sourceID }
            pieceCount = pieceCount + 1
            if not firstIcon and data.icon then firstIcon = data.icon end
        end
    end

    local id = "outfit_" .. time() .. "_" .. math.random(1000, 9999)
    local outfit = {
        id = id, name = name,
        icon = firstIcon or "Interface\\Icons\\INV_Chest_Cloth_17",
        items = items, created = time(), pieceCount = pieceCount,
    }
    -- v14.3.0: enrich items with armor/weapon types so compatibility filtering
    -- can use them on first display without further GetItemInfoInstant calls.
    if ETB.OutfitCompatibility then
        ETB.OutfitCompatibility:EnrichOutfitItems(outfit)
    end
    ETBTUI_SavedOutfits[id] = outfit

    print(string.format("|cff00ff00[ETB]|r Saved outfit: %s (%d pieces)", name, pieceCount))
    Outfits:Refresh(owner)
end

-- =============================================================================
-- Delete
-- =============================================================================

function Outfits:Delete(owner, outfitID, isAccount)
    -- v15.8.4: previously only deleted from ETBTUI_SavedOutfits (character
    -- store). Account-wide outfits saved via Save Outfit in the main browser
    -- live in accountOutfits and were undeletable from the UI.
    local store, name
    if isAccount then
        store = owner.ProfileManager and owner.ProfileManager.accountOutfits
        if not store or not store[outfitID] then return end
        name = store[outfitID].name
        if owner.ProfileManager.DeleteAccountOutfit then
            owner.ProfileManager:DeleteAccountOutfit(outfitID)
        else
            store[outfitID] = nil
        end
    else
        if not ETBTUI_SavedOutfits[outfitID] then return end
        name = ETBTUI_SavedOutfits[outfitID].name
        ETBTUI_SavedOutfits[outfitID] = nil
    end
    owner.selectedOutfitID = nil
    owner.selectedIsAccount = nil
    print(string.format("|cffff6600[ETB]|r Deleted: %s", name))
    Outfits:Refresh(owner)
end

-- =============================================================================
-- Load (into preview)
-- =============================================================================

function Outfits:Load(owner, outfitID, isAccount)
    local outfit
    if isAccount then
        outfit = owner.ProfileManager and owner.ProfileManager.accountOutfits
                 and owner.ProfileManager.accountOutfits[outfitID]
    else
        outfit = ETBTUI_SavedOutfits and ETBTUI_SavedOutfits[outfitID]
    end
    if not outfit then
        print("|cffff6600[ETB]|r Outfit not found.")
        return
    end

    wipe(owner.pendingTransmogs)
    owner.frame.model:Undress()

    -- v15.8.5: Track what loaded, what was skipped, and why — so we can
    -- print a clear summary at the end. Previously the loader silently
    -- discarded any item with a slot-name we didn't recognize, an
    -- ignored slot, or a missing itemID. The user had no visibility
    -- into which pieces of their outfit actually made it onto the model.
    local loadedSlots = {}      -- {slotName: true}
    local skippedSlots = {}     -- {slotName: reason}
    local uncollectedAppearances = {}  -- {slotName: itemName} — present in outfit + slot mapping found, but appearance not collected

    local ignored = outfit.ignoredSlots or {}

    -- Count total outfit items for the summary line
    local totalItems = 0
    for _ in pairs(outfit.items or {}) do totalItems = totalItems + 1 end

    for slotName, itemData in pairs(outfit.items or {}) do
        local invSlot = owner.SLOT_NAME_TO_INV[slotName]
        if ignored[slotName] then
            -- v14.2.0 Feature 4.2: the user explicitly marked this slot
            -- "keep what I'm wearing" for this outfit. Currently-equipped
            -- piece stays in place. Not a problem; just track it for
            -- transparency.
            skippedSlots[slotName] = "marked ignored — keeping your current piece"
        elseif not invSlot then
            -- The save format included a slot name TransmogUI doesn't know
            -- about. Pre-v15.8.4 outfits could have raw category names like
            -- "1H Swords" or "Off-Hands" instead of MainHand/OffHand. The
            -- v15.8.4 normalizer fixes this for new saves, but old saves
            -- still have the raw names. Tell the user which slot is stuck.
            skippedSlots[slotName] = "unknown slot name (likely a pre-v15.8.4 save — re-save the outfit to fix)"
        elseif not itemData or not itemData.itemID then
            skippedSlots[slotName] = "no itemID stored"
        else
            -- The slot maps to an inventory slot; queue it for transmog
            owner.frame.model:TryOn("item:" .. itemData.itemID)
            owner.pendingTransmogs[invSlot] = {
                itemID = itemData.itemID, name = itemData.name,
                icon = itemData.icon, sourceID = itemData.sourceID,
            }
            if itemData.sourceID then
                owner.itemIDToSourceID[itemData.itemID] = itemData.sourceID
            end
            loadedSlots[slotName] = true

            -- v15.8.5: Check whether the player has actually collected this
            -- appearance. If not, the visual still shows in the preview (the
            -- model will TryOn anything), but at apply time it'll fail with
            -- NOT_COLLECTED. Warn now so the user can act on it.
            -- v15.8.12: also warn on KNOWN_OTHER_DIFFERENT_ARMOR — the player
            -- has the visual collected but only via the wrong armor type
            -- (e.g. plate user has the mail tier visual). Transmog in MoP
            -- 5.4.8 is per-armor-type, so apply will still fail.
            local collected = false
            if ETB.CollectionAPI and ETB.CollectionAPI.GetState then
                local state = ETB.CollectionAPI:GetState(itemData.itemID)
                -- COLLECTED or KNOWN_OTHER (a usable shared visual) → loadable
                collected = (state == "COLLECTED" or state == "KNOWN_OTHER")
                -- If state is nil (not yet loaded) or NO_APPEARANCE, can't
                -- determine; don't warn (avoid false positives).
                if state == "NOT_COLLECTED" then
                    uncollectedAppearances[slotName] = itemData.name or ("item " .. itemData.itemID)
                elseif state == "KNOWN_OTHER_DIFFERENT_ARMOR" then
                    -- Mark with a clearer message so the user knows it's
                    -- not "uncollected", just unusable for their armor type
                    uncollectedAppearances[slotName] = (itemData.name or ("item " .. itemData.itemID))
                        .. " |cffff9900[wrong armor type]|r"
                end
            end
        end
    end

    -- Auto-hide armor slots not covered by the outfit (existing behavior)
    for invSlot, _ in pairs(AUTO_HIDE_SLOTS) do
        if not owner.pendingTransmogs[invSlot] then
            owner.pendingTransmogs[invSlot] = {
                isHiddenSlot = true,
                name = "Hidden",
                icon = "Interface\\Icons\\Spell_Shadow_SacrificialShield",
            }
        end
    end

    owner:UpdateSlotButtonIcons()
    owner:UpdateCost()
    owner:SyncToBlizzardPending()

    -- v15.8.5: Print a transparent load summary so the user knows
    -- exactly what made it onto the model and what didn't.
    local loadedCount = 0
    for _ in pairs(loadedSlots) do loadedCount = loadedCount + 1 end
    local skippedCount = 0
    for _ in pairs(skippedSlots) do skippedCount = skippedCount + 1 end
    local uncollectedCount = 0
    for _ in pairs(uncollectedAppearances) do uncollectedCount = uncollectedCount + 1 end

    if loadedCount == 0 and skippedCount == 0 then
        print(string.format("|cffff6600[ETB]|r Outfit '%s' has no items.", outfit.name or "?"))
        return
    end

    -- Headline
    print(string.format("|cff00ffff[ETB]|r Loaded outfit '|cffffd100%s|r' — %d/%d slot(s) onto the model",
        outfit.name or "?", loadedCount, totalItems))

    -- Per-slot loaded list (only when there's something to celebrate)
    if loadedCount > 0 then
        local loadedNames = {}
        for s in pairs(loadedSlots) do table.insert(loadedNames, s) end
        table.sort(loadedNames)
        print(string.format("  |cff00ff00Loaded:|r %s", table.concat(loadedNames, ", ")))
    end

    -- Skipped items get explicit per-item reasons so the user understands
    if skippedCount > 0 then
        print(string.format("  |cffff9966Skipped %d:|r", skippedCount))
        for slot, reason in pairs(skippedSlots) do
            print(string.format("    %s — %s", slot, reason))
        end
    end

    -- Uncollected-appearance warnings (separate from skipped because the
    -- model still shows them; they just won't apply at the transmogrifier).
    if uncollectedCount > 0 then
        print(string.format("  |cffff9900Will fail at apply (%d uncollected):|r", uncollectedCount))
        for slot, name in pairs(uncollectedAppearances) do
            print(string.format("    %s — %s (appearance not in your collection)", slot, name))
        end
    end

    if ETB.Debug then
        ETB:Debug("TransmogUI", string.format("Loaded outfit: %s (%d loaded, %d skipped, %d uncollected)",
            outfit.name or "?", loadedCount, skippedCount, uncollectedCount))
    end
end

-- =============================================================================
-- Load Random (v14.2.0 Feature 1.8)
-- =============================================================================
-- Picks a saved outfit at random from the currently-visible source (character
-- outfits by default, or account-wide if the toggle is on) and loads it.
-- Falls back gracefully if there are no outfits.

function Outfits:LoadRandom(owner)
    -- Determine source: character or account-wide based on the toggle state
    local sourcePool = {}
    local isAccount = owner.outfitsPanel and owner.outfitsPanel.accountToggle
                      and owner.outfitsPanel.accountToggle.showAccount

    if isAccount and owner.ProfileManager and owner.ProfileManager.accountOutfits then
        for id in pairs(owner.ProfileManager.accountOutfits) do
            sourcePool[#sourcePool + 1] = id
        end
    else
        for id in pairs(ETBTUI_SavedOutfits or {}) do
            sourcePool[#sourcePool + 1] = id
        end
    end

    if #sourcePool == 0 then
        if ETB.Print then
            ETB:Print("|cffff6600No saved outfits to pick from.|r Save one first.")
        end
        return
    end

    -- Avoid loading the same outfit twice in a row when there's >1 to pick from.
    -- Track the last-loaded ID on the owner so the user can keep clicking until
    -- they find one they like, without ever getting the exact same one back.
    local pickedID
    if #sourcePool == 1 then
        pickedID = sourcePool[1]
    else
        local attempts = 0
        repeat
            pickedID = sourcePool[math.random(1, #sourcePool)]
            attempts = attempts + 1
        until pickedID ~= owner._lastRandomOutfitID or attempts > 5
    end
    owner._lastRandomOutfitID = pickedID

    self:Load(owner, pickedID, isAccount)
    -- Update selection so the user can see what was picked
    owner.selectedOutfitID = pickedID
    self:Refresh(owner)
end

-- =============================================================================
-- Import from serialized string
-- =============================================================================

function Outfits:ImportFromString(owner, str)
    local outfit, err = owner.ExportSystem:ImportOutfit(str)
    if not outfit then
        print(string.format("|cffff6600[ETB]|r Import failed: %s", err or "Unknown error"))
        return
    end

    -- Fetch item info for each item
    local pieceCount = 0
    for slot, item in pairs(outfit.items) do
        if item.itemID then
            local itemName, _, _, _, _, _, _, _, _, itemIcon = GetItemInfo(item.itemID)
            item.name = itemName
            item.icon = itemIcon
            pieceCount = pieceCount + 1
        end
    end

    local id = "imported_" .. time()
    ETBTUI_SavedOutfits[id] = {
        id = id,
        name = outfit.name or "Imported Outfit",
        icon = outfit.items.Chest and outfit.items.Chest.icon or "Interface\\Icons\\INV_Chest_Cloth_17",
        items = outfit.items,
        created = time(),
        pieceCount = pieceCount,
        importedFromString = true,
        armorType = outfit.armorType,
    }

    print(string.format("|cff00ff00[ETB]|r Imported: %s (%d pieces)", outfit.name, pieceCount))
    Outfits:Refresh(owner)
end

-- =============================================================================
-- Import from Blizzard UI
-- =============================================================================

function Outfits:ImportFromBlizzard(owner, refreshMode)
    local imported = 0
    local updated = 0

    if not C_TransmogCollection or not C_TransmogCollection.GetOutfits then
        print("|cffff6600[ETB]|r Blizzard outfit API not available in this version")
        return
    end

    local outfits = C_TransmogCollection.GetOutfits()
    if not outfits or #outfits == 0 then
        print("|cffff6600[ETB]|r No Blizzard outfits found to import")
        return
    end

    if refreshMode then
        print(string.format("|cff00ffff[ETB]|r Refreshing %d Blizzard outfits...", #outfits))
    else
        print(string.format("|cff00ffff[ETB]|r Scanning %d Blizzard outfits...", #outfits))
    end

    for _, outfitID in ipairs(outfits) do
        local name, icon = C_TransmogCollection.GetOutfitInfo(outfitID)
        if name then
            local existingID = nil
            for id, data in pairs(ETBTUI_SavedOutfits) do
                if data.blizzOutfitID == outfitID or (data.name == name and data.importedFromBlizz) then
                    existingID = id
                    break
                end
            end

            if not refreshMode and existingID then
                -- Skip, already imported
            else
                local items, pieceCount = Outfits:GetBlizzardItems(owner, outfitID)

                if refreshMode and existingID then
                    ETBTUI_SavedOutfits[existingID].items = items
                    ETBTUI_SavedOutfits[existingID].pieceCount = pieceCount
                    ETBTUI_SavedOutfits[existingID].icon = icon or ETBTUI_SavedOutfits[existingID].icon
                    ETBTUI_SavedOutfits[existingID].lastRefresh = time()
                    updated = updated + 1

                    if pieceCount > 0 then
                        print(string.format("  |cff00ff00↻|r %s (%d pieces)", name, pieceCount))
                    else
                        print(string.format("  |cffffff00↻|r %s (updated, no gear data)", name))
                    end
                else
                    local id = "blizz_" .. outfitID .. "_" .. time()
                    ETBTUI_SavedOutfits[id] = {
                        id = id,
                        name = name,
                        icon = icon or "Interface\\Icons\\INV_Chest_Cloth_17",
                        items = items,
                        created = time(),
                        pieceCount = pieceCount,
                        importedFromBlizz = true,
                        blizzOutfitID = outfitID,
                    }
                    imported = imported + 1

                    if pieceCount > 0 then
                        print(string.format("  |cff00ff00✓|r %s (%d pieces)", name, pieceCount))
                    else
                        print(string.format("  |cffffff00!|r %s (name only - gear data unavailable)", name))
                    end
                end
            end
        end
    end

    if imported > 0 or updated > 0 then
        local msg = ""
        if imported > 0 then msg = msg .. string.format("Imported %d new", imported) end
        if updated > 0 then
            if msg ~= "" then msg = msg .. ", " end
            msg = msg .. string.format("Updated %d existing", updated)
        end
        print(string.format("|cff00ff00[ETB]|r %s outfit(s)", msg))
        Outfits:Refresh(owner)
    else
        print("|cff888888[ETB]|r No changes (all outfits already up to date)")
    end
end

-- =============================================================================
-- Blizzard outfit items helper
-- =============================================================================

function Outfits:GetBlizzardItems(owner, outfitID)
    local items = {}
    local pieceCount = 0
    local debugMode = false

    -- MoP Classic has GetOutfitItemTransmogInfoList
    if C_TransmogCollection.GetOutfitItemTransmogInfoList then
        local ok, infoList = pcall(C_TransmogCollection.GetOutfitItemTransmogInfoList, outfitID)
        if ok and infoList then
            local debugCount = 0
            for slotID, info in pairs(infoList) do
                local sourceID, itemID

                if type(info) == "table" and info.appearanceID and info.appearanceID > 0 then
                    local appSources = C_TransmogCollection.GetAppearanceSources(info.appearanceID)

                    if debugMode and debugCount < 2 then
                        debugCount = debugCount + 1
                        if appSources then
                            print(string.format("  [DEBUG] Slot %d, appearanceID=%d:", slotID, info.appearanceID))
                            print(string.format("    GetAppearanceSources returned: %s with %d items", type(appSources), #appSources))
                            if #appSources > 0 then
                                local first = appSources[1]
                                if type(first) == "table" then
                                    local fields = {}
                                    for k, v in pairs(first) do
                                        table.insert(fields, tostring(k) .. "=" .. tostring(v))
                                    end
                                    print(string.format("    First source: {%s}", table.concat(fields, ", ")))
                                else
                                    print(string.format("    First source: %s (%s)", tostring(first), type(first)))
                                end
                            end
                        else
                            print(string.format("  [DEBUG] Slot %d: GetAppearanceSources returned nil", slotID))
                        end
                    end

                    if appSources and #appSources > 0 then
                        local firstSrc = appSources[1]
                        if type(firstSrc) == "table" then
                            for _, src in ipairs(appSources) do
                                if src.isCollected then
                                    sourceID = src.sourceID
                                    break
                                end
                            end
                            if not sourceID then
                                sourceID = firstSrc.sourceID
                            end
                        elseif type(firstSrc) == "number" then
                            sourceID = firstSrc
                        end
                    end

                    if sourceID and sourceID > 0 then
                        local sourceInfo = C_TransmogCollection.GetSourceInfo(sourceID)
                        if sourceInfo and sourceInfo.itemID then
                            itemID = sourceInfo.itemID
                        end
                    end

                    if itemID and itemID > 0 then
                        local slotName = owner.INV_TO_SLOT_NAME[slotID]
                        if slotName then
                            local itemName, _, _, _, _, _, _, _, _, itemIcon = GetItemInfo(itemID)
                            items[slotName] = {
                                itemID = itemID,
                                sourceID = sourceID,
                                name = itemName or ("Item " .. itemID),
                                icon = itemIcon,
                                appearanceID = info.appearanceID,
                            }
                            pieceCount = pieceCount + 1
                        end
                    end
                end
            end
        end
    end

    -- Fallback: GetOutfitSourceList
    if pieceCount == 0 and C_TransmogCollection.GetOutfitSourceList then
        local ok, sourceList = pcall(C_TransmogCollection.GetOutfitSourceList, outfitID)
        if ok and sourceList then
            for slotID = 1, 19 do
                local sourceID = sourceList[slotID]
                if sourceID and sourceID > 0 then
                    local sourceInfo = C_TransmogCollection.GetSourceInfo(sourceID)
                    if sourceInfo and sourceInfo.itemID then
                        local slotName = owner.INV_TO_SLOT_NAME[slotID]
                        if slotName then
                            local itemName, _, _, _, _, _, _, _, _, itemIcon = GetItemInfo(sourceInfo.itemID)
                            items[slotName] = {
                                itemID = sourceInfo.itemID,
                                sourceID = sourceID,
                                name = itemName or ("Item " .. sourceInfo.itemID),
                                icon = itemIcon,
                            }
                            pieceCount = pieceCount + 1
                        end
                    end
                end
            end
        end
    end

    return items, pieceCount
end
