--[[
============================================================================
PlannerUI.lua  (added in v15.9.0)
============================================================================

The Collection Planner panel. Shows raids and dungeons grouped by
expansion, with per-instance completion percentages, and lets the player
drill into a boss to see which appearances they're missing.

Renders data from ETB.CollectionPlanner (the query layer). This file is
UI only — no data logic lives here.

----------------------------------------------------------------------------
LAYOUT
----------------------------------------------------------------------------
  +--------------------------------------------------+
  | Collection Planner                          [X]  |
  +--------------------------------------------------+
  | [Raids] [Dungeons]      [ ] Hide completed       |   <- mode tabs + filter
  +--------------------------------------------------+
  | v Mists of Pandaria                              |   <- expansion header
  |     Siege of Orgrimmar          73/120  [####  ] |   <- instance row
  |     Throne of Thunder           41/96   [##    ] |
  |   > Cataclysm                                    |   <- collapsed expansion
  +--------------------------------------------------+

Clicking an instance row expands it to show per-boss rows. Clicking a
boss row opens the encounter detail (appearance grid with collected /
missing state).

----------------------------------------------------------------------------
RAID vs DUNGEON / EXPANSION
----------------------------------------------------------------------------
Both come straight from JInstances (no classification table needed):
  field 2 (level)       -> expansion index: 0=Classic .. 4=MoP
  field 3 (mountPolicy) -> 1 = raid, 0 = dungeon

When a future expansion is added, only EXPANSION_NAMES needs one new line.

----------------------------------------------------------------------------
PERFORMANCE
----------------------------------------------------------------------------
Rows are pooled (created once, reused). The tree is rebuilt only when the
mode/filter changes or an instance is expanded/collapsed — not every frame.
Collection state is queried through CollectionPlanner, which sits on top of
CollectionAPI's own cache.
============================================================================
]]

local ADDON_NAME, NS = ...
NS.ETB = NS.ETB or {}
local ETB = NS.ETB

ETB.PlannerUI = ETB.PlannerUI or {}
local PUI = ETB.PlannerUI

-- ============================================================================
-- CONSTANTS
-- ============================================================================

local PANEL_WIDTH    = 580
local PANEL_HEIGHT   = 660
local ROW_HEIGHT     = 28
local EXP_HEADER_H   = 30
local DETAIL_CELL    = 42   -- appearance grid cell size
local CONTENT_INSET  = 18   -- left/right margin inside the panel
local SCROLL_TOP     = 92   -- where the scroll list starts (below tabs)

-- Expansion index -> display name. ONLY maintenance point for new
-- expansions: add one line here. Classification itself is automatic.
local EXPANSION_NAMES = {
    [0] = "Classic",
    [1] = "The Burning Crusade",
    [2] = "Wrath of the Lich King",
    [3] = "Cataclysm",
    [4] = "Mists of Pandaria",
    -- [5] = "Warlords of Draenor",   -- future: just uncomment / add
}

local EXPANSION_COLORS = {
    [0] = "|cffffd100", [1] = "|cff1eff00", [2] = "|cff0070dd",
    [3] = "|cffff8000", [4] = "|cff00ff96",
}

-- ============================================================================
-- PER-SLOT MODEL FRAMING
-- ============================================================================
-- The hover preview frames the item the way Blizzard's wardrobe does — head
-- shots for helms, the back turned for cloaks, the waist for belts, etc.
--
-- These values were MEASURED from Blizzard's live WardrobeCollectionFrame
-- models (via ETBCameraProbe) — not guessed. Camera distance is a constant
-- 2.942 for every slot; only position and facing change. The wardrobe uses
-- SetPosition + SetFacing + SetCameraDistance (NOT SetCamera presets, which
-- are inert on a plain DressUpModel in this client).
--
-- Keyed by the equipLoc string GetItemInfoInstant returns. `pos` is the
-- model SetPosition {x,y,z}; `facing` is SetFacing radians.
local SLOT_CAMERA = {
    INVTYPE_HEAD          = { pos = { 3.452,  0.079, -1.763 }, facing = -0.545 },
    INVTYPE_SHOULDER      = { pos = { 3.349, -0.164, -1.572 }, facing = -0.335 },
    INVTYPE_CHEST         = { pos = { 3.224,  0.020, -1.375 }, facing = -0.310 },
    INVTYPE_ROBE          = { pos = { 3.224,  0.020, -1.375 }, facing = -0.310 },
    INVTYPE_CLOAK         = { pos = { 3.178,  0.007, -1.171 }, facing = -2.648 },
    INVTYPE_WRIST         = { pos = { 3.436,  0.158, -1.059 }, facing =  0.846 },
    INVTYPE_HAND          = { pos = { 3.275, -0.112, -0.974 }, facing = -0.817 },
    INVTYPE_WAIST         = { pos = { 3.240,  0.013, -1.125 }, facing =  0.008 },
    INVTYPE_LEGS          = { pos = { 2.908, -0.013, -0.717 }, facing = -0.045 },
    INVTYPE_FEET          = { pos = { 3.151,  0.013, -0.230 }, facing = -0.478 },
    -- Weapons are drawn separate from the body; the wardrobe uses a very
    -- different position for them.
    INVTYPE_WEAPON        = { pos = { -1.046, 0.059, -0.066 }, facing = -7.520 },
    INVTYPE_2HWEAPON      = { pos = { -1.046, 0.059, -0.066 }, facing = -7.520 },
    INVTYPE_WEAPONMAINHAND = { pos = { -1.046, 0.059, -0.066 }, facing = -7.520 },
    INVTYPE_WEAPONOFFHAND = { pos = { -1.046, 0.059, -0.066 }, facing = -7.520 },
    INVTYPE_RANGED        = { pos = { -1.046, 0.059, -0.066 }, facing = -7.520 },
    INVTYPE_RANGEDRIGHT   = { pos = { -1.046, 0.059, -0.066 }, facing = -7.520 },
    INVTYPE_HOLDABLE      = { pos = { -1.046, 0.059, -0.066 }, facing = -7.520 },
}
-- Fallback framing — a general upper-body shot for any slot not mapped.
local SLOT_CAMERA_DEFAULT = { pos = { 3.224, 0.020, -1.375 }, facing = -0.310 }
local SLOT_CAMERA_DISTANCE = 2.942


-- ============================================================================
-- STATE
-- ============================================================================

PUI.frame        = nil
PUI.mode         = "raid"      -- content type: "raid" | "dungeon"
PUI.lens         = "transmog"  -- data lens: "transmog" | "bis"
PUI.hideComplete = false
PUI._expanded    = {}          -- [instID] = true if expanded in the tree
PUI._rowPool     = {}          -- reusable tree rows
PUI._rowsInUse   = 0

-- ============================================================================
-- FRAME CONSTRUCTION
-- ============================================================================

function PUI:Create()
    if self.frame then return self.frame end

    -- Refresh the tree's farming star badges whenever the wishlist changes
    -- (registered exactly once — Create returns early on later calls).
    if ETB.EventBus and ETB.EventBus.Register then
        ETB.EventBus:Register("ETB_WISHLIST_CHANGED", function()
            if PUI.frame and PUI.frame:IsShown() then PUI:RefreshTree() end
        end)
    end

    local f = CreateFrame("Frame", "ETBPlannerPanel", UIParent, "BackdropTemplate")
    f:SetSize(PANEL_WIDTH, PANEL_HEIGHT)
    f:SetPoint("CENTER")
    f:SetFrameStrata("HIGH")
    -- Match the main addon frame styling (dark dialog + gold border).
    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:EnableMouse(true)
    f:SetMovable(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop", f.StopMovingOrSizing)
    f:Hide()

    -- Title bar strip (matches the main addon's topBar treatment)
    f.titleBar = CreateFrame("Frame", nil, f, "BackdropTemplate")
    f.titleBar:SetPoint("TOPLEFT", 12, -12)
    f.titleBar:SetPoint("TOPRIGHT", -12, -12)
    f.titleBar:SetHeight(30)
    f.titleBar:SetBackdrop({
        bgFile   = "Interface\\DialogFrame\\UI-DialogBox-Background",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 12,
        insets = { left = 4, right = 4, top = 4, bottom = 4 },
    })

    -- Title
    f.title = f.titleBar:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    f.title:SetPoint("LEFT", 10, 0)
    f.title:SetText("|cffffd100Collection Planner|r")

    -- Lens toggle (Transmog | BiS) — lives in the title bar, right side.
    -- Switches the data the planner shows: appearance collection vs the
    -- spec's best-in-slot list. Built as two small linked buttons.
    local function makeLensBtn(text, anchorRight)
        local b = CreateFrame("Button", nil, f.titleBar)
        b:SetSize(78, 20)
        if anchorRight then
            b:SetPoint("RIGHT", anchorRight, "LEFT", -2, 0)
        else
            b:SetPoint("RIGHT", -8, 0)
        end
        b.bg = b:CreateTexture(nil, "BACKGROUND")
        b.bg:SetAllPoints()
        b.label = b:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        b.label:SetPoint("CENTER")
        b._text = text
        b.label:SetText(text)
        return b
    end
    -- BiS button anchored near the right edge (clear of the close button);
    -- Transmog button left of it.
    f.bisLensBtn      = makeLensBtn("Raid (BiS)", nil)
    f.bisLensBtn:ClearAllPoints()
    f.bisLensBtn:SetPoint("RIGHT", -28, 0)
    f.transmogLensBtn = makeLensBtn("Transmog", f.bisLensBtn)
    f.bisLensBtn:SetScript("OnClick", function() PUI:SetLens("bis") end)
    f.transmogLensBtn:SetScript("OnClick", function() PUI:SetLens("transmog") end)

    -- Close button
    f.closeBtn = CreateFrame("Button", nil, f, "UIPanelCloseButton")
    f.closeBtn:SetPoint("TOPRIGHT", -10, -10)

    -- Mode tabs: Raids / Dungeons (below the title bar). Selection is shown
    -- by a bright gold label + a gold underline bar, NOT by disabling the
    -- button (a disabled button reads as "unavailable", which is confusing).
    local function makeTab(label, anchorTo, xoff)
        local tab = CreateFrame("Button", nil, f)
        tab:SetSize(96, 26)
        if anchorTo then
            tab:SetPoint("LEFT", anchorTo, "RIGHT", 8, 0)
        else
            tab:SetPoint("TOPLEFT", CONTENT_INSET, -52)
        end
        tab.bg = tab:CreateTexture(nil, "BACKGROUND")
        tab.bg:SetAllPoints()
        tab.bg:SetColorTexture(0.12, 0.12, 0.14, 0.9)
        tab.label = tab:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        tab.label:SetPoint("CENTER")
        tab._text = label   -- plain label, recolored on selection
        tab.label:SetText(label)
        tab.underline = tab:CreateTexture(nil, "OVERLAY")
        tab.underline:SetHeight(3)
        tab.underline:SetPoint("BOTTOMLEFT", 2, 0)
        tab.underline:SetPoint("BOTTOMRIGHT", -2, 0)
        tab.underline:SetColorTexture(1, 0.82, 0, 1)
        tab.underline:Hide()
        tab:SetScript("OnEnter", function(self) self.bg:SetColorTexture(0.2, 0.2, 0.22, 0.95) end)
        tab:SetScript("OnLeave", function(self)
            self.bg:SetColorTexture(self._selected and 0.18 or 0.12,
                self._selected and 0.16 or 0.12, self._selected and 0.06 or 0.14, 0.95)
        end)
        return tab
    end

    f.raidTab = makeTab("Raids", nil)
    f.raidTab:SetScript("OnClick", function() PUI:SetMode("raid") end)

    f.dungeonTab = makeTab("Dungeons", f.raidTab)
    f.dungeonTab:SetScript("OnClick", function() PUI:SetMode("dungeon") end)

    -- Hide-completed checkbox (right-aligned on the tab row)
    f.hideCheck = CreateFrame("CheckButton", nil, f, "UICheckButtonTemplate")
    f.hideCheck:SetSize(22, 22)
    f.hideCheck:SetPoint("TOPRIGHT", -CONTENT_INSET - 4, -52)
    f.hideCheck.text = f.hideCheck:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    f.hideCheck.text:SetPoint("RIGHT", f.hideCheck, "LEFT", -2, 0)
    f.hideCheck.text:SetText("Hide completed")
    f.hideCheck:SetScript("OnClick", function(self)
        PUI.hideComplete = self:GetChecked() and true or false
        PUI:RefreshTree()
    end)

    -- Recessed backing panel behind the scroll list
    f.scrollBacking = CreateFrame("Frame", nil, f, "BackdropTemplate")
    f.scrollBacking:SetPoint("TOPLEFT", CONTENT_INSET, -SCROLL_TOP)
    f.scrollBacking:SetPoint("BOTTOMRIGHT", -CONTENT_INSET, 16)
    f.scrollBacking:SetBackdrop({
        bgFile   = "Interface\\DialogFrame\\UI-DialogBox-Background",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 12,
        insets = { left = 4, right = 4, top = 4, bottom = 4 },
    })
    f.scrollBacking:SetBackdropColor(0.03, 0.03, 0.04, 0.95)

    -- Scroll area for the tree. The scrollbar lives inside the backing's
    -- right inset, so the list never overruns the gold border.
    f.scrollFrame = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
    f.scrollFrame:SetPoint("TOPLEFT", f.scrollBacking, "TOPLEFT", 8, -8)
    f.scrollFrame:SetPoint("BOTTOMRIGHT", f.scrollBacking, "BOTTOMRIGHT", -26, 8)
    f.scrollChild = CreateFrame("Frame", nil, f.scrollFrame)
    f.scrollFrame:SetScrollChild(f.scrollChild)
    -- Width of the content area rows render into
    f._contentWidth = PANEL_WIDTH - (CONTENT_INSET * 2) - 8 - 26 - 8
    f.scrollChild:SetSize(f._contentWidth, 10)

    -- Empty-state text (shown when a mode has no data)
    f.emptyText = f.scrollChild:CreateFontString(nil, "OVERLAY", "GameFontDisableLarge")
    f.emptyText:SetPoint("TOP", 0, -40)
    f.emptyText:Hide()

    self.frame = f
    return f
end

-- ============================================================================
-- MODE / TAB
-- ============================================================================

function PUI:SetMode(mode)
    self.mode = mode
    if self.frame then
        local rt, dt = self.frame.raidTab, self.frame.dungeonTab
        local function applyTabState(tab, selected)
            tab._selected = selected
            if selected then
                tab.label:SetText("|cffffd100" .. tab._text .. "|r")
                tab.bg:SetColorTexture(0.18, 0.16, 0.06, 0.95)
                tab.underline:Show()
            else
                tab.label:SetText("|cff999999" .. tab._text .. "|r")
                tab.bg:SetColorTexture(0.12, 0.12, 0.14, 0.95)
                tab.underline:Hide()
            end
        end
        applyTabState(rt, mode == "raid")
        applyTabState(dt, mode == "dungeon")
    end
    self:RefreshTree()
end

-- Switch the data lens: "transmog" (appearance collection) or "bis"
-- (best-in-slot for the player's spec). The instance/boss tree is the same;
-- only the per-row data source changes.
function PUI:SetLens(lens)
    self.lens = lens
    if self.frame then
        local function applyLens(btn, selected)
            if selected then
                btn.label:SetText("|cffffd100" .. btn._text .. "|r")
                btn.bg:SetColorTexture(0.20, 0.16, 0.05, 1)
            else
                btn.label:SetText("|cff888888" .. btn._text .. "|r")
                btn.bg:SetColorTexture(0.10, 0.10, 0.12, 1)
            end
        end
        applyLens(self.frame.transmogLensBtn, lens == "transmog")
        applyLens(self.frame.bisLensBtn,      lens == "bis")
        -- Title reflects the active lens
        self.frame.title:SetText(lens == "bis"
            and "|cffffd100Collection Planner|r |cff888888— Raid BiS|r"
            or  "|cffffd100Collection Planner|r |cff888888— Transmog|r")
    end
    self:RefreshTree()
end

-- ============================================================================
-- ROW POOL
-- ============================================================================
-- Tree rows are created once and reused. Each row can render either an
-- expansion header, an instance line, or a boss line, depending on `kind`.

function PUI:_acquireRow()
    self._rowsInUse = self._rowsInUse + 1
    local row = self._rowPool[self._rowsInUse]
    if row then
        row:Show()
        return row
    end

    row = CreateFrame("Button", nil, self.frame.scrollChild)
    row:SetSize(self.frame._contentWidth, ROW_HEIGHT)

    -- Row background plate — gives the tree visible structure.
    row.bg = row:CreateTexture(nil, "BACKGROUND")
    row.bg:SetAllPoints()
    row.bg:SetColorTexture(1, 1, 1, 1)  -- recolored per row below

    -- Hover highlight
    row:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight")
    local hl = row:GetHighlightTexture()
    if hl then hl:SetAlpha(0.25) end

    -- Progress bar — fixed position at the right edge.
    row.bar = CreateFrame("StatusBar", nil, row)
    row.bar:SetSize(90, 12)
    row.bar:SetPoint("RIGHT", -8, 0)
    row.bar:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar")
    row.bar:SetMinMaxValues(0, 1)
    row.bar.bg = row.bar:CreateTexture(nil, "BACKGROUND")
    row.bar.bg:SetAllPoints()
    row.bar.bg:SetColorTexture(0.12, 0.12, 0.13, 0.9)
    row.bar.border = row.bar:CreateTexture(nil, "BORDER")
    row.bar.border:SetPoint("TOPLEFT", -1, 1)
    row.bar.border:SetPoint("BOTTOMRIGHT", 1, -1)
    row.bar.border:SetColorTexture(0, 0, 0, 0.8)

    -- Progress text — sits just left of the bar. Normal size: this is the
    -- core data the planner exists to show, so it must be easily readable.
    row.progress = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    row.progress:SetPoint("RIGHT", row.bar, "LEFT", -8, 0)
    row.progress:SetJustifyH("RIGHT")

    -- Left label — name. Right edge stops before the progress text.
    row.label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    row.label:SetPoint("LEFT", 8, 0)
    row.label:SetPoint("RIGHT", row.progress, "LEFT", -8, 0)
    row.label:SetJustifyH("LEFT")
    row.label:SetWordWrap(false)

    self._rowPool[self._rowsInUse] = row
    return row
end

function PUI:_releaseAllRows()
    for i = 1, #self._rowPool do
        self._rowPool[i]:Hide()
    end
    self._rowsInUse = 0
end

-- ============================================================================
-- TREE RENDER
-- ============================================================================

function PUI:RefreshTree()
    local f = self:Create()
    self:_releaseAllRows()

    local CP = ETB.CollectionPlanner
    if not CP then
        f.emptyText:SetText("Planner data module not loaded.")
        f.emptyText:Show()
        return
    end

    -- Get all instances, filter by current mode (raid/dungeon)
    local JInstances = ETB.JInstances
    local instances = CP:GetInstanceList()

    -- Per-encounter uncollected wishlist counts — drives the farming star
    -- badges. Computed once here; cached inside CollectionPlanner.
    local wlCounts = (CP.GetWishlistEncCounts and CP:GetWishlistEncCounts()) or {}
    local STAR = "|TInterface\\TargetingFrame\\UI-RaidTargetingIcons:13:13:0:0:64:64:0:16:0:16|t"

    -- Partition by expansion, filtered to current mode. raid/dungeon and
    -- expansion are carried on each entry by GetInstanceList (entries may be
    -- virtual 10/25-Man variant rows whose instID is not a real JInstances
    -- key, so we must not look them up in JInstances here).
    local byExp = {}   -- expIndex -> { instEntry, ... }
    for _, inst in ipairs(instances) do
        local wantRaid = (self.mode == "raid")
        if (inst.isRaid and true or false) == wantRaid then
            local exp = inst.level or 0
            byExp[exp] = byExp[exp] or {}
            table.insert(byExp[exp], inst)
        end
    end

    -- Anything to show?
    local anyData = false
    for _ in pairs(byExp) do anyData = true break end
    if not anyData then
        f.emptyText:SetText(self.mode == "raid"
            and "No raid data available yet."
            or  "No dungeon data available yet.")
        f.emptyText:Show()
        f.scrollChild:SetHeight(10)
        return
    end
    f.emptyText:Hide()

    -- Render expansion by expansion, newest first (higher index = newer).
    -- Indentation: expansion header at x=0 full width; instance rows
    -- indented INDENT_1; boss rows indented INDENT_2. Each indented row is
    -- narrowed by exactly its indent so the right-edge bar stays aligned.
    local CW = f._contentWidth
    local INDENT_1, INDENT_2 = 10, 26
    local yOffset = 0
    local expansions = {}
    for exp in pairs(byExp) do table.insert(expansions, exp) end
    table.sort(expansions, function(a, b) return a > b end)

    for _, exp in ipairs(expansions) do
        local expName = EXPANSION_NAMES[exp] or ("Expansion " .. exp)
        local expColor = EXPANSION_COLORS[exp] or "|cffffffff"

        -- Expansion header row (full width, no bar)
        local hdr = self:_acquireRow()
        hdr:SetHeight(EXP_HEADER_H)
        hdr:SetWidth(CW)
        hdr:SetPoint("TOPLEFT", 0, -yOffset)
        hdr.bg:SetColorTexture(0.20, 0.16, 0.05, 1)
        hdr.label:SetText(expColor .. expName .. "|r")
        hdr.label:SetFontObject("GameFontNormalLarge")
        hdr.progress:SetText("")
        hdr.bar:Hide()
        hdr:SetScript("OnClick", nil)
        hdr:EnableMouse(false)
        yOffset = yOffset + EXP_HEADER_H + 2

        local insts = byExp[exp]
        table.sort(insts, function(a, b) return a.name < b.name end)

        for _, inst in ipairs(insts) do
            -- Lens dispatch: transmog uses appearance collection,
            -- bis uses the spec's best-in-slot list.
            local prog
            if self.lens == "bis" then
                prog = CP:GetInstanceBiSProgress(inst.instID)
            else
                prog = CP:GetInstanceProgress(inst.instID)
            end
            if prog then
                local pct = prog.total > 0 and (prog.collected / prog.total) or 0
                local isComplete = (prog.total > 0 and prog.collected >= prog.total)

                if not (self.hideComplete and isComplete) then
                    local row = self:_acquireRow()
                    row:SetHeight(ROW_HEIGHT)
                    row:SetWidth(CW - INDENT_1)
                    row:SetPoint("TOPLEFT", INDENT_1, -yOffset)
                    row.bg:SetColorTexture(0.11, 0.11, 0.13, 0.9)
                    row.label:SetFontObject("GameFontNormal")

                    local arrow = self._expanded[inst.instID]
                        and "|cffffd100v|r  " or "|cff888888>|r  "
                    row.label:SetText(arrow .. inst.name)

                    -- Farming badge: total uncollected wishlist items in raid.
                    local instWN = 0
                    if prog.encounters then
                        for _, e in ipairs(prog.encounters) do
                            instWN = instWN + (wlCounts[e.encID] or 0)
                        end
                    end
                    if instWN > 0 then
                        row.label:SetText(arrow .. inst.name .. "  "
                            .. STAR .. "|cffffd100" .. instWN .. "|r")
                    end

                    row.progress:SetText(string.format("|cffcccccc%d|r|cff666666/%d|r",
                        prog.collected, prog.total))
                    row.bar:Show()
                    row.bar:SetValue(pct)
                    if isComplete then
                        row.bar:SetStatusBarColor(0.15, 0.85, 0.2)
                    else
                        row.bar:SetStatusBarColor(0.85, 0.7, 0.15)
                    end

                    row:EnableMouse(true)
                    local thisInstID = inst.instID
                    row:SetScript("OnClick", function()
                        PUI._expanded[thisInstID] = not PUI._expanded[thisInstID]
                        PUI:RefreshTree()
                    end)
                    yOffset = yOffset + ROW_HEIGHT + 1

                    -- Boss rows when expanded
                    if self._expanded[inst.instID] then
                        for _, enc in ipairs(prog.encounters) do
                            local bossRow = self:_acquireRow()
                            bossRow:SetHeight(ROW_HEIGHT)
                            bossRow:SetWidth(CW - INDENT_2)
                            bossRow:SetPoint("TOPLEFT", INDENT_2, -yOffset)
                            bossRow.bg:SetColorTexture(0.055, 0.055, 0.07, 0.9)
                            bossRow.label:SetFontObject("GameFontHighlight")

                            local bossDone = (enc.total > 0 and enc.collected >= enc.total)
                            local nameColor = bossDone and "|cff66dd66" or "|cffdddddd"
                            bossRow.label:SetText(nameColor .. enc.name .. "|r")

                            -- Farming badge: uncollected wishlist items here.
                            local wn = wlCounts[enc.encID]
                            if wn then
                                bossRow.label:SetText(nameColor .. enc.name .. "|r  "
                                    .. STAR
                                    .. (wn > 1 and ("|cffffd100" .. wn .. "|r") or ""))
                            end

                            bossRow.progress:SetText(string.format("|cffcccccc%d|r|cff666666/%d|r",
                                enc.collected, enc.total))
                            local bpct = enc.total > 0 and (enc.collected / enc.total) or 0
                            bossRow.bar:Show()
                            bossRow.bar:SetValue(bpct)
                            if bossDone then
                                bossRow.bar:SetStatusBarColor(0.15, 0.85, 0.2)
                            else
                                bossRow.bar:SetStatusBarColor(0.35, 0.55, 0.9)
                            end

                            bossRow:EnableMouse(true)
                            local thisEncID = enc.encID
                            local thisEncName = enc.name
                            local thisDiffBit = inst.diffBit
                            local thisDiffName = inst.diffName
                            bossRow:SetScript("OnClick", function()
                                PUI:ShowEncounterDetail(thisEncID, thisEncName, thisDiffBit, thisDiffName)
                            end)
                            yOffset = yOffset + ROW_HEIGHT + 1
                        end
                    end
                end
            end
        end

        yOffset = yOffset + 8  -- gap between expansions
    end

    f.scrollChild:SetHeight(math.max(yOffset, 10))
end

-- ============================================================================
-- ENCOUNTER DETAIL
-- ============================================================================
-- Opens a secondary panel showing every appearance a boss drops, as a grid
-- of icons with collected (bright) / missing (dimmed red) state.

-- Show `itemID` in the detail frame's preview model, framed per-slot the
-- way Blizzard's wardrobe does. Called on cell hover.
--
-- Sequencing matches the addon's working AppearanceDetailPanel preview:
--   * SetUnit is called EVERY time (not cached) — a cached unit on a
--     DressUpModel goes stale and the model renders naked.
--   * TryOn is DEFERRED via C_Timer.After — applying it immediately after
--     SetUnit/Undress fires before the model finishes re-initialising and
--     the item is silently lost (the naked-model bug).
--   * TryOn takes the item LINK, not the bare itemID.
function PUI:PreviewItem(itemID)
    local d = self.detailFrame
    if not d or not d.previewModel or not itemID then return end
    local model = d.previewModel

    -- Track the most recent hover so a stale deferred TryOn doesn't apply
    -- an item the user already moved off of.
    self._previewItemID = itemID

    -- Re-seat the unit and clear every hover. SetUnit must run while the
    -- frame is visible — the detail frame is shown by now, so it's safe.
    pcall(function() model:SetUnit("player") end)
    pcall(function() model:Undress() end)
    -- Reset any drag-rotation so each new item starts at its slot framing.
    model._rotation = 0
    pcall(function() model:SetRotation(0) end)

    -- Frame the camera for this item's slot immediately (cheap, no model
    -- data needed).
    local _, _, _, equipLoc = GetItemInfoInstant(itemID)
    local cam = (equipLoc and SLOT_CAMERA[equipLoc]) or SLOT_CAMERA_DEFAULT
    pcall(function()
        if model.SetCameraDistance then
            model:SetCameraDistance(SLOT_CAMERA_DISTANCE)
        end
        model:SetPosition(cam.pos[1], cam.pos[2], cam.pos[3])
        model:SetFacing(cam.facing)
    end)

    -- Defer the TryOn so the model has a frame to re-initialise.
    if C_Timer and C_Timer.After then
        C_Timer.After(0.1, function()
            -- Only apply if the user is still hovering this same item.
            if PUI._previewItemID == itemID then
                local _, link = GetItemInfo(itemID)
                local tryOnArg = link or ("item:" .. itemID)
                pcall(model.TryOn, model, tryOnArg)
            end
        end)
    else
        local _, link = GetItemInfo(itemID)
        pcall(model.TryOn, model, link or ("item:" .. itemID))
    end

    -- Label with the item name.
    local name = GetItemInfo(itemID)
    d.previewLabel:SetText(name or "|cff888888Loading…|r")
end

function PUI:ShowEncounterDetail(encID, encName, diffBit, diffName)
    local CP = ETB.CollectionPlanner
    if not CP then return end

    -- Lens dispatch: transmog appearance grid vs BiS piece list. diffBit
    -- (a difficulty bitmask, or nil) restricts the grid to one difficulty
    -- for variant rows.
    local items, summary
    if self.lens == "bis" then
        items, summary = CP:GetEncounterBiS(encID, diffBit)
    else
        items, summary = CP:GetEncounterItems(encID, diffBit)
    end

    -- Build the detail frame lazily
    if not self.detailFrame then
        local d = CreateFrame("Frame", "ETBPlannerDetail", UIParent, "BackdropTemplate")
        -- Wider than before to fit the model preview pane on the right.
        d:SetSize(620, 480)
        -- Attached to the planner panel's right edge so the two read as one
        -- unit and the detail travels with the planner when it's dragged.
        d:ClearAllPoints()
        if self.frame then
            d:SetPoint("TOPLEFT", self.frame, "TOPRIGHT", 4, 0)
        else
            d:SetPoint("CENTER", 200, 0)
        end
        d:SetFrameStrata("HIGH")
        d: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 },
        })
        d:EnableMouse(true)
        d:SetMovable(true)
        d:RegisterForDrag("LeftButton")
        d:SetScript("OnDragStart", d.StartMoving)
        d:SetScript("OnDragStop", d.StopMovingOrSizing)

        d.title = d:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
        d.title:SetPoint("TOP", 0, -16)

        d.summary = d:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        d.summary:SetPoint("TOP", 0, -38)

        d.closeBtn = CreateFrame("Button", nil, d, "UIPanelCloseButton")
        d.closeBtn:SetPoint("TOPRIGHT", -8, -8)

        -- Item grid on the LEFT half.
        d.scrollFrame = CreateFrame("ScrollFrame", nil, d, "UIPanelScrollFrameTemplate")
        d.scrollFrame:SetPoint("TOPLEFT", 16, -60)
        d.scrollFrame:SetPoint("BOTTOMLEFT", 16, 16)
        d.scrollFrame:SetWidth(340)
        d.scrollChild = CreateFrame("Frame", nil, d.scrollFrame)
        d.scrollChild:SetSize(312, 10)
        d.scrollFrame:SetScrollChild(d.scrollChild)

        -- ------------------------------------------------------------------
        -- Model preview pane — RIGHT side. Shows the hovered item framed
        -- the way Blizzard's wardrobe frames it (per-slot camera).
        -- ------------------------------------------------------------------
        d.previewPane = CreateFrame("Frame", nil, d, "BackdropTemplate")
        d.previewPane:SetPoint("TOPRIGHT", -16, -60)
        d.previewPane:SetPoint("BOTTOMRIGHT", -16, 16)
        d.previewPane:SetWidth(228)
        d.previewPane:SetBackdrop({
            bgFile   = "Interface\\DialogFrame\\UI-DialogBox-Background",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            tile = true, tileSize = 16, edgeSize = 12,
            insets = { left = 4, right = 4, top = 4, bottom = 4 },
        })
        d.previewPane:SetBackdropColor(0.03, 0.03, 0.04, 0.95)

        d.previewModel = CreateFrame("DressUpModel", nil, d.previewPane)
        d.previewModel:SetPoint("TOPLEFT", 6, -6)
        d.previewModel:SetPoint("BOTTOMRIGHT", -6, 28)

        -- Drag to rotate / scroll to zoom — same interaction as the main
        -- appearance model so it feels consistent across the addon.
        local pm = d.previewModel
        pm:EnableMouse(true)
        local function onUpdateRotate(self)
            local x = GetCursorPosition()
            local diff = (x - (self._startX or x)) * 0.01
            self._rotation = (self._rotation or 0) + diff
            self:SetRotation(self._rotation)
            self._startX = x
        end
        pm:SetScript("OnMouseDown", function(self, button)
            if button == "LeftButton" then
                self._startX = GetCursorPosition()
                self:SetScript("OnUpdate", onUpdateRotate)
            end
        end)
        pm:SetScript("OnMouseUp", function(self)
            self:SetScript("OnUpdate", nil)
        end)
        pm:EnableMouseWheel(true)
        pm:SetScript("OnMouseWheel", function(self, delta)
            local x, y, z = self:GetPosition()
            z = (z or 0) + (delta > 0 and 0.15 or -0.15)
            self:SetPosition(x or 0, y or 0, math.max(-3, math.min(2, z)))
        end)

        d.previewLabel = d.previewPane:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        d.previewLabel:SetPoint("BOTTOM", 0, 8)
        d.previewLabel:SetWidth(216)
        d.previewLabel:SetText("|cff888888Hover an item to preview|r")

        d.previewHint = d.previewPane:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
        d.previewHint:SetPoint("TOP", 0, -4)
        d.previewHint:SetText("|cff666666drag to rotate · scroll to zoom|r")

        d._cellPool = {}
        self.detailFrame = d
    end

    local d = self.detailFrame
    local diffTag = diffName and ("  |cff888888(" .. diffName .. ")|r") or ""
    d.title:SetText((encName or "Encounter") .. diffTag)
    local summaryText = string.format(
        "|cff33ff33%d collected|r  |cffff6666%d missing|r  |cff888888%d total|r",
        summary.collected, summary.missing, summary.total)
    -- Thunderforged / Warforged note — Throne of Thunder & Siege of Orgrimmar.
    if ETB.Forge then
        local ei = ETB.JEncounters and ETB.JEncounters[encID]
        local fd = ei and ei[2] and ei[1] ~= "Trash Drops"
                   and ETB.Forge:GetInstanceForge(ei[2])
        if fd then
            summaryText = summaryText .. "   |cffff8000" .. fd.kind
                .. " +" .. ETB.Forge.ILVL_BONUS .. "|r"
        end
    end
    d.summary:SetText(summaryText)

    -- Hide all pooled cells
    for _, cell in ipairs(d._cellPool) do cell:Hide() end

    -- Lay out appearance cells in a grid
    local COLS = 6
    local x, y, col = 4, 4, 0
    for i, rec in ipairs(items) do
        local cell = d._cellPool[i]
        if not cell then
            cell = CreateFrame("Button", nil, d.scrollChild)
            cell:SetSize(DETAIL_CELL, DETAIL_CELL)

            -- Icon — never tinted. Collection status is shown by
            -- desaturation (uncollected) and a separate border/corner mark,
            -- matching the main addon's convention so the art stays legible.
            cell.icon = cell:CreateTexture(nil, "ARTWORK")
            cell.icon:SetPoint("TOPLEFT", 2, -2)
            cell.icon:SetPoint("BOTTOMRIGHT", -2, 2)
            cell.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)

            -- Border frame (thin, drawn behind the icon as a backing plate)
            cell.border = cell:CreateTexture(nil, "BACKGROUND")
            cell.border:SetAllPoints()

            -- Status corner mark (small dot, top-right). Green = collected.
            cell.mark = cell:CreateTexture(nil, "OVERLAY")
            cell.mark:SetSize(12, 12)
            cell.mark:SetPoint("TOPRIGHT", 1, 1)

            -- Wishlist star (top-left). Shown when the item is flagged to
            -- farm — the raid-target star marker, instantly recognizable.
            cell.wishMark = cell:CreateTexture(nil, "OVERLAY")
            cell.wishMark:SetSize(13, 13)
            cell.wishMark:SetPoint("TOPLEFT", -1, 1)
            cell.wishMark:SetTexture("Interface\\TargetingFrame\\UI-RaidTargetingIcons")
            cell.wishMark:SetTexCoord(0, 0.25, 0, 0.25)
            cell.wishMark:Hide()

            d._cellPool[i] = cell
        end
        cell:Show()
        cell:ClearAllPoints()
        cell:SetPoint("TOPLEFT", x, -y)

        cell.icon:SetTexture(rec.icon or "Interface\\Icons\\INV_Misc_QuestionMark")

        -- Collection state styling — icon legibility preserved.
        --   collected   : full color icon + green backing + green corner dot
        --   missing     : desaturated, slightly dimmed + red backing, no dot
        --   pending     : desaturated + neutral backing (data still loading)
        if rec.pending then
            cell.icon:SetDesaturated(true)
            cell.icon:SetAlpha(0.65)
            cell.border:SetColorTexture(0.3, 0.3, 0.3, 1)
            cell.mark:Hide()
        elseif rec.collected then
            cell.icon:SetDesaturated(false)
            cell.icon:SetAlpha(1)
            cell.border:SetColorTexture(0.1, 0.6, 0.1, 1)
            cell.mark:SetTexture("Interface\\Buttons\\UI-CheckBox-Check")
            cell.mark:Show()
        else
            cell.icon:SetDesaturated(true)
            cell.icon:SetAlpha(0.78)
            cell.border:SetColorTexture(0.55, 0.12, 0.12, 1)
            cell.mark:Hide()
        end

        -- Wishlist star — flags items the player has chosen to farm.
        if rec.itemID and ETB.Wishlist and ETB.Wishlist.HasItem
           and ETB.Wishlist:HasItem(rec.itemID) then
            cell.wishMark:Show()
        else
            cell.wishMark:Hide()
        end

        -- Tooltip + click-to-pin
        local thisItemID = rec.itemID
        local thisCollected = rec.collected
        local thisForge = rec.forge
        cell:SetScript("OnEnter", function(self)
            -- Anchor LEFT — the model preview pane is on the right side of
            -- the detail frame, so a right-anchored tooltip would cover it.
            GameTooltip:SetOwner(self, "ANCHOR_LEFT")
            if thisItemID then GameTooltip:SetItemByID(thisItemID) end
            GameTooltip:AddLine(thisCollected and "|cff33ff33Collected|r"
                or "|cffff6666Not collected|r")
            if thisForge and ETB.Forge then
                GameTooltip:AddLine("|cffff8000" .. thisForge .. " (+"
                    .. ETB.Forge.ILVL_BONUS .. " ilvl) possible|r")
            end
            local WL = ETB.Wishlist
            if thisItemID and WL and WL.HasItem and WL:HasItem(thisItemID) then
                GameTooltip:AddLine("|cffffd100On your wishlist|r |cff888888— click to remove|r")
            else
                GameTooltip:AddLine("|cffffd100Click to add to wishlist|r")
            end
            GameTooltip:Show()
            -- Frame this item in the preview model, wardrobe-style.
            if thisItemID then PUI:PreviewItem(thisItemID) end
        end)
        cell:SetScript("OnLeave", function() GameTooltip:Hide() end)
        cell:SetScript("OnClick", function(self)
            if not thisItemID or not ETB.Wishlist then return end
            local WL = ETB.Wishlist
            local name = GetItemInfo(thisItemID) or ("item " .. thisItemID)
            if WL.HasItem and WL:HasItem(thisItemID) then
                if WL.RemoveItem then WL:RemoveItem(thisItemID) end
                self.wishMark:Hide()
                ETB:Print("Removed from wishlist: " .. name)
            elseif WL.AddItem then
                WL:AddItem(thisItemID)
                self.wishMark:Show()
                ETB:Print("Added to wishlist: " .. name)
            end
        end)

        col = col + 1
        if col >= COLS then
            col = 0
            x = 4
            y = y + DETAIL_CELL + 4
        else
            x = x + DETAIL_CELL + 4
        end
    end
    if col > 0 then y = y + DETAIL_CELL + 4 end
    d.scrollChild:SetHeight(math.max(y, 10))

    d:Show()
end

-- ============================================================================
-- SHOW / TOGGLE
-- ============================================================================

function PUI:Show()
    local f = self:Create()
    self:SetLens(self.lens)   -- apply lens button visuals
    self:SetMode(self.mode)   -- also refreshes the tree
    f:Show()
end

function PUI:Toggle()
    local f = self:Create()
    if f:IsShown() then
        f:Hide()
    else
        self:Show()
    end
end

ETB:Debug("PlannerUI", "PlannerUI.lua loaded")
