-- ERT/UI/Tracker.lua
-- The rare tracker window: a filterable, curatable browser over every rare in
-- the roster. You decide which rares you care about (want / ignore), filter by
-- scope, collectible type, live status, expansion, or name, and inspect exactly
-- what each rare drops — with item names, icons and your collection state — plus
-- where it spawns and when it's next up.
--
-- Presentation only: status/timing comes from Observer, gating + curation +
-- filtering from Collection. Opened with /etbrare ui; built lazily on first open
-- and self-refreshes on a slow tick while visible.

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

local UI = {}
ERT.Tracker = UI

local PANEL_W, PANEL_H = 820, 544
local INSET = 12
local LIST_TOP = 100        -- y where the list/divider begins
local ROW_H = 22
local DETAIL_W = 216        -- right-hand detail column
local MAX_DROP_ROWS = 6     -- drop lines shown in the detail pane

-- Per-status colour + short label for the status column.
local STATUS_STYLE = {
    alive       = { 0.20, 1.00, 0.20 },
    window_open = { 0.70, 1.00, 0.40 },
    too_early   = { 0.55, 0.70, 0.90 },
    overdue     = { 1.00, 0.65, 0.20 },
    summoned    = { 0.80, 0.60, 1.00 },
    phased      = { 0.70, 0.70, 0.70 },
    stale       = { 0.55, 0.55, 0.55 },
    unknown     = { 0.55, 0.55, 0.55 },
}

local KIND_COLOR = {
    mount    = "|cffff8000",   -- orange
    pet      = "|cff1eff00",   -- green
    toy      = "|cff00ccff",   -- blue
    transmog = "|cffa335ee",   -- purple
    quest    = "|cffffff00",   -- yellow
    item     = "|cff9d9d9d",   -- grey (world / junk drop)
}
local KIND_LABEL = {
    mount = "Mount", pet = "Pet", toy = "Toy", transmog = "Transmog",
    quest = "Quest", item = "World drop",
}
local KIND_ORDER = { mount = 1, pet = 2, toy = 3, transmog = 4, quest = 5, item = 6 }

-- Compact status phrase for a row.
local function StatusText(st)
    local clock = ERT.ServerClock
    local fmt = ERT.FmtDuration
    local s = st.state
    if s == "alive" then
        return st.confirmed and "UP NOW" or "up", (st.aliveFor and fmt and ("for " .. fmt(st.aliveFor)) or "")
    elseif s == "window_open" then
        if st.modalAt and GetServerTime and st.modalAt > GetServerTime() then
            return "window open", "likely ~" .. (clock and clock(st.modalAt) or "?")
        end
        return "window open", "by ~" .. (clock and clock(st.latestAt) or "?")
    elseif s == "too_early" then
        return "next", "in " .. (fmt and fmt(st.opensIn) or "?")
    elseif s == "overdue" then
        return "overdue", "since ~" .. (clock and clock(st.opensAt) or "?")
    elseif s == "summoned" then
        return "on demand", "summon/trigger"
    elseif s == "phased" then
        return "phased", "quest-gated"
    elseif s == "stale" then
        return "lost track", "kills missed"
    end
    return "unknown", ""
end

-- Set a map pin at the rare's last seen / primary spot.
local function SetWaypoint(npcID)
    local mapID, x, y = ERT:GetLocation(npcID)
    if not (mapID and x and y) then return end
    if C_Map and C_Map.SetUserWaypoint and UiMapPoint and UiMapPoint.CreateFromCoordinates then
        pcall(function()
            C_Map.SetUserWaypoint(UiMapPoint.CreateFromCoordinates(mapID, x, y))
            if C_SuperTrack and C_SuperTrack.SetSuperTrackedUserWaypoint then
                C_SuperTrack.SetSuperTrackedUserWaypoint(true)
            end
        end)
    elseif TomTom and TomTom.AddWaypoint then
        pcall(TomTom.AddWaypoint, TomTom, mapID, x, y, { title = ERT:GetName(npcID) })
    end
end

-- Resolve a drop's display name + icon. Every bucket now stores ITEM ids, so a
-- single item lookup serves all kinds — and the item name of a mount/pet/toy is
-- exactly what a collector recognises ("Reins of the Thundering Onyx Cloud
-- Serpent"). Names may be nil until cached (we refresh on GET_ITEM_INFO_RECEIVED).
local function ResolveDrop(kind, id)
    local name = GetItemInfo and GetItemInfo(id) or nil
    local icon
    if GetItemInfoInstant then
        local _, _, _, _, ic = GetItemInfoInstant(id)
        icon = ic
    end
    return name, icon
end

-- =====================================================================
-- Small widgets: a lightweight dropdown and a toggle button (taint-free,
-- no UIDropDownMenu / named frames).
-- =====================================================================
local function MakeDropdown(parent, width, options, onSelect)
    local dd = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
    dd:SetSize(width, 20)
    dd.options = options
    dd.value = options[1].value
    dd:SetText(options[1].label)

    local function apply(v)
        for _, o in ipairs(dd.options) do
            if o.value == v then dd.value = v; dd:SetText(o.label); break end
        end
    end
    dd.GetValue = function() return dd.value end
    dd.SetValue = function(_, v) apply(v) end

    dd:SetScript("OnClick", function()
        if dd.menu and dd.menu:IsShown() then dd.menu:Hide(); return end
        if not dd.menu then
            local m = CreateFrame("Frame", nil, dd,
                BackdropTemplateMixin and "BackdropTemplate" or nil)
            m:SetFrameStrata("FULLSCREEN_DIALOG")
            m:SetWidth(width)
            m:SetPoint("TOPLEFT", dd, "BOTTOMLEFT", 0, -2)
            if m.SetBackdrop then
                m:SetBackdrop({
                    bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
                    edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
                    tile = true, tileSize = 16, edgeSize = 12,
                    insets = { left = 3, right = 3, top = 3, bottom = 3 },
                })
            end
            local y = -6
            for _, o in ipairs(dd.options) do
                local b = CreateFrame("Button", nil, m)
                b:SetSize(width - 10, 18)
                b:SetPoint("TOPLEFT", 5, y)
                local fs = b:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
                fs:SetPoint("LEFT", 4, 0); fs:SetJustifyH("LEFT"); fs:SetText(o.label)
                local hl = b:CreateTexture(nil, "BACKGROUND")
                hl:SetAllPoints(); hl:SetColorTexture(1, 1, 1, 0.12); hl:Hide()
                b:SetScript("OnEnter", function() hl:Show() end)
                b:SetScript("OnLeave", function() hl:Hide() end)
                b:SetScript("OnClick", function()
                    apply(o.value); m:Hide()
                    if onSelect then onSelect(o.value) end
                end)
                y = y - 18
            end
            m:SetHeight(-y + 8)
            dd.menu = m
        end
        dd.menu:Show()
    end)
    return dd
end

local function MakeToggle(parent, label, width, onChange)
    local b = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
    b:SetSize(width, 20)
    b:SetText(label)
    b.active = false
    local function paint()
        b:SetButtonState(b.active and "PUSHED" or "NORMAL", b.active)
    end
    b.IsActive = function() return b.active end
    b.SetActive = function(_, v) b.active = v and true or false; paint() end
    b:SetScript("OnClick", function()
        b.active = not b.active; paint()
        if onChange then onChange(b.active) end
    end)
    return b
end

-- =====================================================================
-- Rows
-- =====================================================================
local function AcquireRow(self, i)
    self.rows = self.rows or {}
    local row = self.rows[i]
    if row then return row end

    row = CreateFrame("Button", nil, self.frame.scrollChild)
    row:SetHeight(ROW_H)
    row:SetPoint("TOPLEFT", 0, -((i - 1) * ROW_H))
    row:SetPoint("TOPRIGHT", 0, -((i - 1) * ROW_H))

    -- Want star (click toggles "rares I care about")
    row.star = CreateFrame("Button", nil, row)
    row.star:SetSize(16, 16)
    row.star:SetPoint("LEFT", 2, 0)
    row.star.tex = row.star:CreateTexture(nil, "ARTWORK")
    row.star.tex:SetAllPoints()

    row.dot = row:CreateTexture(nil, "ARTWORK")
    row.dot:SetSize(8, 8)
    row.dot:SetPoint("LEFT", row.star, "RIGHT", 4, 0)
    row.dot:SetColorTexture(0.5, 0.5, 0.5, 1)

    row.name = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    row.name:SetPoint("LEFT", row.dot, "RIGHT", 6, 0)
    row.name:SetJustifyH("LEFT")
    row.name:SetWidth(146)

    row.zone = row:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    row.zone:SetPoint("LEFT", row.name, "RIGHT", 4, 0)
    row.zone:SetJustifyH("LEFT")
    row.zone:SetWidth(104)

    row.state = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    row.state:SetPoint("LEFT", row.zone, "RIGHT", 4, 0)
    row.state:SetJustifyH("LEFT")
    row.state:SetWidth(96)

    row.pin = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
    row.pin:SetSize(34, 18)
    row.pin:SetPoint("RIGHT", -4, 0)
    row.pin:SetText("Pin")

    row.ignore = CreateFrame("Button", nil, row)
    row.ignore:SetSize(16, 16)
    row.ignore:SetPoint("RIGHT", row.pin, "LEFT", -4, 0)
    row.ignore.tex = row.ignore:CreateTexture(nil, "ARTWORK")
    row.ignore.tex:SetAllPoints()
    row.ignore.tex:SetTexture("Interface\\Buttons\\UI-StopButton")
    row.ignore.tex:SetAlpha(0.5)

    row.drops = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    row.drops:SetPoint("RIGHT", row.ignore, "LEFT", -6, 0)
    row.drops:SetJustifyH("RIGHT")
    row.drops:SetWidth(54)

    row.hl = row:CreateTexture(nil, "BACKGROUND")
    row.hl:SetAllPoints()
    row.hl:SetColorTexture(1, 1, 1, 0.05)
    row.hl:Hide()
    row:SetScript("OnEnter", function(r) r.hl:Show() end)
    row:SetScript("OnLeave", function(r) r.hl:Hide() end)

    self.rows[i] = row
    return row
end

local STAR_ON  = "Interface\\COMMON\\FavoritesIcon"
local function PaintStar(btn, on)
    btn.tex:SetTexture(STAR_ON)
    btn.tex:SetDesaturated(not on)
    btn.tex:SetAlpha(on and 1 or 0.35)
end

-- =====================================================================
-- Detail pane
-- =====================================================================
function UI:Select(npcID, name)
    local f = self.frame
    if not f then return end
    if self.selected ~= npcID and self._HideDropPopout then self:_HideDropPopout() end
    self.selected = npcID
    f.selName:SetText(name or ERT:GetName(npcID))

    -- Model: only (re)render when the selected rare actually changes. The detail
    -- pane refreshes repeatedly as item names stream in (GET_ITEM_INFO_RECEIVED),
    -- and re-seting the model each time made it flicker / reload constantly.
    self:_SeatCreatureModel(npcID)

    -- Zone + spawn count
    local zone = ERT:GetZoneName(npcID)
    local sp = ERT:GetSpawnPoints(npcID)
    local exp = ERT:GetExpansion(npcID)
    local zoneLine = zone or "Location unknown"
    if sp and #sp > 1 then zoneLine = zoneLine .. " |cff888888(" .. #sp .. " spawns)|r" end
    if exp and exp ~= "Other" then zoneLine = zoneLine .. " |cff666666· " .. exp .. "|r" end
    f.selZone:SetText(zoneLine)

    -- Status line
    local st = ERT:GetStatus(npcID)
    local lbl, det = StatusText(st)
    local style = STATUS_STYLE[st.state] or { 0.7, 0.7, 0.7 }
    f.selStatus:SetTextColor(style[1], style[2], style[3])
    f.selStatus:SetText(lbl .. (det ~= "" and (" — " .. det) or ""))

    self:_RenderDrops(npcID)

    -- Curation buttons reflect current state
    PaintStar(f.wantBtn, ERT:IsWanted(npcID))
    f.wantBtn.label:SetText(ERT:IsWanted(npcID) and "Wanted" or "Want")
    f.ignoreBtn:SetText(ERT:IsIgnored(npcID) and "Unignore" or "Ignore")
end

-- Seat the rare's creature portrait. Guarded so the repeated detail refreshes
-- (as item names stream in) don't reload the model and make it flicker.
function UI:_SeatCreatureModel(npcID)
    local f = self.frame
    if not f or not f.model or not npcID then return end
    if self._modelNpc == npcID then return end
    self._modelNpc = npcID
    self._previewDropID = nil
    pcall(function()
        if f.model.ClearModel then f.model:ClearModel() end
        local d = ERT:GetDisplayID(npcID)
        if d and f.model.SetDisplayInfo then f.model:SetDisplayInfo(d)
        elseif f.model.SetCreature then f.model:SetCreature(npcID) end
        if f.model.SetPortraitZoom then f.model:SetPortraitZoom(0) end
        if f.model.SetFacing then f.model:SetFacing(0.4) end
    end)
end

-- ---------------------------------------------------------------------------
-- Drop preview popout — a floating dress-up model to the RIGHT of the tracker
-- so the item tooltip never sits on top of it (the "popout" pattern the rest of
-- ETB uses). Shown while hovering a transmog drop; a short grace timer keeps it
-- open long enough to move onto it and drag to rotate. It's a child of the
-- tracker frame, so closing the tracker hides it automatically.
-- ---------------------------------------------------------------------------
function UI:_EnsurePopout()
    if self.popout then return self.popout end
    local f = self.frame
    local p = CreateFrame("Frame", "ETBRareDropPopout", f,
        BackdropTemplateMixin and "BackdropTemplate" or nil)
    p:SetSize(240, 360)
    p:SetPoint("TOPLEFT", f, "TOPRIGHT", 8, 0)
    p:SetFrameStrata("DIALOG")
    p:SetFrameLevel((f:GetFrameLevel() or 100) + 30)
    if p.SetBackdrop then
        p:SetBackdrop({
            bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
            edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Gold-Border",
            tile = true, tileSize = 32, edgeSize = 24,
            insets = { left = 6, right = 6, top = 6, bottom = 6 },
        })
    end

    p.title = p:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    p.title:SetPoint("TOP", 0, -12)
    p.title:SetWidth(216); p.title:SetWordWrap(true)

    p.status = p:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    p.status:SetPoint("TOP", p.title, "BOTTOM", 0, -2)

    p.model = CreateFrame("DressUpModel", nil, p)
    p.model:SetPoint("TOPLEFT", 12, -50)
    p.model:SetPoint("BOTTOMRIGHT", -12, 12)
    p.model:EnableMouse(true)
    local function onUpdateRotate(m)
        local x = GetCursorPosition()
        m._rot = (m._rot or 0) + (x - (m._rotCursor or x)) * 0.012
        m._rotCursor = x
        pcall(function() m:SetRotation(m._rot) end)
    end
    p.model:SetScript("OnMouseDown", function(m)
        m._rotCursor = GetCursorPosition(); m:SetScript("OnUpdate", onUpdateRotate)
    end)
    p.model:SetScript("OnMouseUp", function(m) m:SetScript("OnUpdate", nil) end)

    -- keep open while the cursor is over the popout (so you can rotate it)
    p:EnableMouse(true)
    p:SetScript("OnEnter", function() UI._popoutWantHide = false end)
    p:SetScript("OnLeave", function() UI:_ScheduleHidePopout() end)
    p.model:HookScript("OnEnter", function() UI._popoutWantHide = false end)
    p.model:HookScript("OnLeave", function() UI:_ScheduleHidePopout() end)

    p:Hide()
    self.popout = p
    return p
end

-- Dress a transmog drop onto your character in the popout, using the addon's
-- proven DressUpModel sequence: SetUnit every time, Undress, then a DEFERRED
-- TryOn of the item LINK (an immediate TryOn renders naked). A hover token
-- prevents a stale deferred TryOn from applying after the cursor has moved on.
function UI:_ShowDropPopout(itemID)
    if not itemID then return end
    local p = self:_EnsurePopout()
    self._popoutWantHide = false
    self._popoutItemID = itemID
    local model = p.model
    model._rot = 0

    local name, _, quality = GetItemInfo(itemID)
    p.title:SetText(name or "|cff888888Loading…|r")
    if quality and ITEM_QUALITY_COLORS and ITEM_QUALITY_COLORS[quality] then
        local c = ITEM_QUALITY_COLORS[quality]
        p.title:SetTextColor(c.r, c.g, c.b)
    else
        p.title:SetTextColor(1, 1, 1)
    end
    local col = ERT:IsDropCollected("transmog", itemID)
    if col == true then p.status:SetText("|cff40ff40Collected|r")
    elseif col == false then p.status:SetText("|cffffd100Not collected|r")
    else p.status:SetText("") end

    p:Show()
    pcall(function() model:SetUnit("player") end)
    pcall(function() if model.Undress then model:Undress() end end)
    pcall(function() if model.SetPortraitZoom then model:SetPortraitZoom(0) end end)
    pcall(function() if model.SetRotation then model:SetRotation(0) end end)
    pcall(function() if model.SetFacing then model:SetFacing(0.3) end end)
    local function apply()
        if UI._popoutItemID ~= itemID then return end
        local _, link = GetItemInfo(itemID)
        pcall(model.TryOn, model, link or ("item:" .. itemID))
        local nm, _, q = GetItemInfo(itemID)
        if nm then
            p.title:SetText(nm)
            if q and ITEM_QUALITY_COLORS and ITEM_QUALITY_COLORS[q] then
                local c = ITEM_QUALITY_COLORS[q]
                p.title:SetTextColor(c.r, c.g, c.b)
            end
        end
    end
    if C_Timer and C_Timer.After then C_Timer.After(0.1, apply) else apply() end
end

function UI:_ScheduleHidePopout()
    self._popoutWantHide = true
    local fn = function() if UI._popoutWantHide then UI:_HideDropPopout() end end
    if C_Timer and C_Timer.After then C_Timer.After(0.25, fn) else fn() end
end

function UI:_HideDropPopout()
    self._popoutItemID = nil
    self._popoutWantHide = false
    if self.popout then self.popout:Hide() end
end

-- Render just the drops list for the current selection — no model touch. Called
-- by Select and, debounced, as item names stream in. Sets _pendingDetail while
-- any name is still uncached so the streaming refresh keeps going until resolved.
function UI:_RenderDrops(npcID)
    local f = self.frame
    if not f then return end
    local drops = ERT:GetRareDropList(npcID)
    table.sort(drops, function(a, b)
        local ka, kb = KIND_ORDER[a.kind] or 9, KIND_ORDER[b.kind] or 9
        if ka ~= kb then return ka < kb end
        return (a.itemID or 0) < (b.itemID or 0)
    end)
    local notable = ERT:HasNotableDrop(npcID)
    self._pendingDetail = nil
    for i = 1, MAX_DROP_ROWS do
        local dr = f.dropRows[i]
        local d = drops[i]
        if d then
            local nm, icon = ResolveDrop(d.kind, d.itemID)
            if not nm then nm = "Loading…"; self._pendingDetail = npcID end
            dr.icon:SetTexture(icon or "Interface\\Icons\\INV_Misc_QuestionMark")
            local collected = ERT:IsDropCollected(d.kind, d.itemID)
            local mark = ETB.Style and ETB.Style:Mark(collected) or ""
            if mark ~= "" then mark = " " .. mark end
            dr.text:SetText((KIND_COLOR[d.kind] or "|cffffffff") .. nm .. "|r" ..
                " |cff666666(" .. (KIND_LABEL[d.kind] or "?") .. ")|r" .. mark)
            dr._itemID = d.itemID
            dr._kind = d.kind
            dr:Show()
        else
            dr._itemID = nil
            dr._kind = nil
            dr:Hide()
        end
    end
    if #drops == 0 then
        f.dropsEmpty:Show(); f.dropsHeader:Hide()
        f.dropsEmpty:SetText("No catalogued drops — world drops only")
    elseif not notable then
        f.dropsEmpty:Hide(); f.dropsHeader:Show()
        f.dropsHeader:SetText("|cff9d9d9dWorld drops only:|r")
    else
        f.dropsEmpty:Hide(); f.dropsHeader:Show()
        f.dropsHeader:SetText("Drops" .. (#drops > MAX_DROP_ROWS
            and (" |cff888888(" .. MAX_DROP_ROWS .. " of " .. #drops .. ")|r") or ":"))
    end
end

-- =====================================================================
-- List refresh
-- =====================================================================
function UI:GetOpts()
    local f = self.frame
    return {
        scope       = f.scopeDD:GetValue(),
        status      = f.statusDD:GetValue(),
        expansion   = f.expDD:GetValue(),
        neededOnly  = f.neededChk:GetChecked() and true or false,
        hideIgnored = f.hideIgnChk:GetChecked() and true or false,
        types = {
            mount    = f.tMount:IsActive(),
            pet      = f.tPet:IsActive(),
            toy      = f.tToy:IsActive(),
            transmog = f.tXmog:IsActive(),
        },
        search = f.search:GetText(),
    }
end

function UI:RefreshList()
    if not self.frame or not self.frame:IsShown() then return end
    local list = ERT:GetFilteredList(self:GetOpts())
    self._list = list

    if self.rows then for _, r in ipairs(self.rows) do r:Hide() end end

    local shown = 0
    for i, item in ipairs(list) do
        local row = AcquireRow(self, i)
        local st = item.st
        local style = STATUS_STYLE[st.state] or { 0.7, 0.7, 0.7 }
        local npc = item.npcID

        PaintStar(row.star, item.wanted)
        row.star:SetScript("OnClick", function()
            ERT:ToggleWanted(npc); UI:RefreshList()
        end)

        row.dot:SetColorTexture(style[1], style[2], style[3], 1)
        row.name:SetText(item.name or ("npc " .. npc))
        row.name:SetTextColor(style[1], style[2], style[3])
        row.zone:SetText(item.zone or "")

        local label, detail = StatusText(st)
        if item.roster and st.state == "unknown" then label = "not seen" end
        row.state:SetText(label)

        local n = item.needs or {}
        local tags = {}
        if (n.nMounts or 0) > 0 then tags[#tags + 1] = "M" end
        if (n.nPets or 0) > 0 then tags[#tags + 1] = "P" end
        if (n.nToys or 0) > 0 then tags[#tags + 1] = "T" end
        if (n.nTransmog or 0) > 0 then tags[#tags + 1] = "X" end
        if #tags > 0 then
            row.drops:SetText(table.concat(tags, " "))
            row.drops:SetTextColor(1, 0.82, 0)
        else
            row.drops:SetText("")
        end

        row.ignore:SetScript("OnClick", function()
            ERT:ToggleIgnored(npc); UI:RefreshList()
        end)
        row.ignore.tex:SetAlpha(item.ignored and 0.9 or 0.4)

        if ERT:GetLocation(npc) then
            row.pin:Show()
            row.pin:SetScript("OnClick", function() SetWaypoint(npc) end)
        else
            row.pin:Hide()
        end

        row:SetScript("OnClick", function() UI:Select(npc, item.name) end)
        row:Show()
        shown = shown + 1
    end

    self.frame.scrollChild:SetHeight(math.max(shown * ROW_H, 1))
    if self.frame.empty then
        if shown == 0 then self.frame.empty:Show() else self.frame.empty:Hide() end
    end
    if self.frame.count then
        self.frame.count:SetText(shown .. " shown |cff888888· " .. ERT:CountWanted() .. " wanted|r")
    end

    if shown > 0 then
        local stillShown = false
        if self.selected then
            for _, it in ipairs(list) do if it.npcID == self.selected then stillShown = true; break end end
        end
        if not stillShown then self:Select(list[1].npcID, list[1].name) end
    end
end

-- =====================================================================
-- Build
-- =====================================================================
local function Build(self)
    if self.frame then return self.frame end

    local f = CreateFrame("Frame", "ERTTrackerFrame", UIParent,
        BackdropTemplateMixin and "BackdropTemplate" or nil)
    f:SetSize(PANEL_W, PANEL_H)
    f:SetPoint("CENTER")
    f:SetFrameStrata("HIGH")
    if f.SetBackdrop then
        f:SetBackdrop({
            bgFile   = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
            edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
            tile = true, tileSize = 32, edgeSize = 24,
            insets = { left = 6, right = 6, top = 6, bottom = 6 },
        })
    end
    f:EnableMouse(true)
    f:SetMovable(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop", f.StopMovingOrSizing)
    f:Hide()
    if UISpecialFrames then tinsert(UISpecialFrames, "ERTTrackerFrame") end

    f.title = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    f.title:SetPoint("TOPLEFT", INSET, -14)
    f.title:SetText("Enhanced Rare Tracker")

    f.count = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    f.count:SetPoint("TOPRIGHT", -36, -18)

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

    -- ---- Filter bar, row A: scope / status / expansion dropdowns ----
    local function lbl(text, anchor, x)
        local s = f:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
        s:SetPoint("BOTTOMLEFT", anchor, "TOPLEFT", x or 0, 1)
        s:SetText(text)
        return s
    end

    f.scopeDD = MakeDropdown(f, 120, {
        { value = "wanted",  label = "My list" },
        { value = "all",     label = "All rares" },
        { value = "tracked", label = "Tracked only" },
    }, function() UI:RefreshList() end)
    f.scopeDD:SetPoint("TOPLEFT", INSET, -44)
    f.scopeDD:SetValue("all")
    lbl("Show", f.scopeDD)

    f.statusDD = MakeDropdown(f, 120, {
        { value = "any",   label = "Any status" },
        { value = "up",    label = "Up / window" },
        { value = "timed", label = "Has timer" },
    }, function() UI:RefreshList() end)
    f.statusDD:SetPoint("LEFT", f.scopeDD, "RIGHT", 10, 0)
    lbl("Status", f.statusDD)

    f.expDD = MakeDropdown(f, 110, {
        { value = "All",     label = "All expansions" },
        { value = "Classic", label = "Classic" },
        { value = "BC",      label = "Burning Crusade" },
        { value = "WotLK",   label = "Wrath" },
        { value = "Cata",    label = "Cataclysm" },
        { value = "MoP",     label = "Mists" },
    }, function() UI:RefreshList() end)
    f.expDD:SetPoint("LEFT", f.statusDD, "RIGHT", 10, 0)
    lbl("Expansion", f.expDD)

    f.timerBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.timerBtn:SetSize(100, 20)
    f.timerBtn:SetPoint("LEFT", f.expDD, "RIGHT", 12, 0)
    local function paintTimerBtn()
        f.timerBtn:SetText((ERT.Countdown and ERT.Countdown:IsPinned()) and "Hide timer" or "Pop out timer")
    end
    f.timerBtn:SetScript("OnClick", function()
        if ERT.PopOutTimer then ERT:PopOutTimer() end
        paintTimerBtn()
    end)
    paintTimerBtn()
    lbl("Countdown", f.timerBtn)

    -- ---- Filter bar, row B: type toggles + checks + search ----
    local function onType() UI:RefreshList() end
    f.tMount = MakeToggle(f, "Mount", 52, onType)
    f.tMount:SetPoint("TOPLEFT", INSET, -72)
    f.tPet  = MakeToggle(f, "Pet", 44, onType)
    f.tPet:SetPoint("LEFT", f.tMount, "RIGHT", 4, 0)
    f.tToy  = MakeToggle(f, "Toy", 44, onType)
    f.tToy:SetPoint("LEFT", f.tPet, "RIGHT", 4, 0)
    f.tXmog = MakeToggle(f, "Transmog", 68, onType)
    f.tXmog:SetPoint("LEFT", f.tToy, "RIGHT", 4, 0)

    f.neededChk = CreateFrame("CheckButton", nil, f, "UICheckButtonTemplate")
    f.neededChk:SetSize(22, 22)
    f.neededChk:SetPoint("LEFT", f.tXmog, "RIGHT", 10, 0)
    f.neededChk.text = f.neededChk:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    f.neededChk.text:SetPoint("LEFT", f.neededChk, "RIGHT", 2, 0)
    f.neededChk.text:SetText("Uncollected only")
    f.neededChk:SetChecked(false)
    f.neededChk:SetScript("OnClick", function() UI:RefreshList() end)

    f.hideIgnChk = CreateFrame("CheckButton", nil, f, "UICheckButtonTemplate")
    f.hideIgnChk:SetSize(22, 22)
    f.hideIgnChk:SetPoint("LEFT", f.neededChk.text, "RIGHT", 12, 0)
    f.hideIgnChk.text = f.hideIgnChk:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    f.hideIgnChk.text:SetPoint("LEFT", f.hideIgnChk, "RIGHT", 2, 0)
    f.hideIgnChk.text:SetText("Hide ignored")
    f.hideIgnChk:SetChecked(true)
    f.hideIgnChk:SetScript("OnClick", function() UI:RefreshList() end)

    -- Search box (right-aligned on row B)
    f.search = CreateFrame("EditBox", nil, f, "InputBoxTemplate")
    f.search:SetSize(150, 18)
    f.search:SetPoint("TOPRIGHT", -(INSET + 6), -73)
    f.search:SetAutoFocus(false)
    f.search:SetScript("OnTextChanged", function() UI:RefreshList() end)
    f.search:SetScript("OnEscapePressed", function(e) e:ClearFocus() end)
    f.searchLbl = f:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    f.searchLbl:SetPoint("RIGHT", f.search, "LEFT", -4, 0)
    f.searchLbl:SetText("Search")

    local div = f:CreateTexture(nil, "ARTWORK")
    div:SetColorTexture(0.4, 0.4, 0.4, 0.6)
    div:SetHeight(1)
    div:SetPoint("TOPLEFT", INSET, -(LIST_TOP - 4))
    div:SetPoint("TOPRIGHT", -INSET, -(LIST_TOP - 4))

    -- ---- List ----
    f.scrollFrame = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
    f.scrollFrame:SetPoint("TOPLEFT", INSET, -LIST_TOP)
    f.scrollFrame:SetPoint("BOTTOMRIGHT", -(INSET + 24 + DETAIL_W + 10), INSET + 16)
    f.scrollChild = CreateFrame("Frame", nil, f.scrollFrame)
    f.scrollChild:SetSize(PANEL_W - INSET * 2 - 24 - DETAIL_W - 10, 1)
    f.scrollFrame:SetScrollChild(f.scrollChild)

    -- ---- Detail pane ----
    local mp = CreateFrame("Frame", nil, f, BackdropTemplateMixin and "BackdropTemplate" or nil)
    mp:SetPoint("TOPRIGHT", -INSET, -LIST_TOP)
    mp:SetPoint("BOTTOM", f, "BOTTOM", 0, INSET + 16)
    mp:SetWidth(DETAIL_W)
    if mp.SetBackdrop then
        mp:SetBackdrop({
            bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            tile = true, tileSize = 16, edgeSize = 12,
            insets = { left = 3, right = 3, top = 3, bottom = 3 },
        })
    end
    f.modelPane = mp

    f.selName = mp:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    f.selName:SetPoint("TOP", 0, -8)
    f.selName:SetWidth(DETAIL_W - 14)
    f.selName:SetText("Select a rare")

    -- DressUpModel (not PlayerModel): it still does SetCreature/SetDisplayInfo
    -- for the rare's portrait, but also supports SetUnit + TryOn so hovering a
    -- transmog drop can dress the item onto your character.
    f.model = CreateFrame("DressUpModel", nil, mp)
    f.model:SetPoint("TOP", f.selName, "BOTTOM", 0, -6)
    f.model:SetSize(DETAIL_W - 24, 168)

    f.selZone = mp:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    f.selZone:SetPoint("TOPLEFT", f.model, "BOTTOMLEFT", 2, -6)
    f.selZone:SetPoint("TOPRIGHT", f.model, "BOTTOMRIGHT", -2, -6)
    f.selZone:SetJustifyH("LEFT")
    f.selZone:SetWordWrap(true)

    f.selStatus = mp:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    f.selStatus:SetPoint("TOPLEFT", f.selZone, "BOTTOMLEFT", 0, -3)
    f.selStatus:SetJustifyH("LEFT")

    f.dropsHeader = mp:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    f.dropsHeader:SetPoint("TOPLEFT", f.selStatus, "BOTTOMLEFT", 0, -8)
    f.dropsHeader:SetText("Drops:")
    f.dropsEmpty = mp:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    f.dropsEmpty:SetPoint("TOPLEFT", f.selStatus, "BOTTOMLEFT", 0, -8)
    f.dropsEmpty:SetText("No tracked collectibles")
    f.dropsEmpty:Hide()

    f.dropRows = {}
    local anchor = f.dropsHeader
    for i = 1, MAX_DROP_ROWS do
        local dr = CreateFrame("Frame", nil, mp)
        dr:SetSize(DETAIL_W - 16, 18)
        dr:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 0, -3)
        dr.icon = dr:CreateTexture(nil, "ARTWORK")
        dr.icon:SetSize(16, 16)
        dr.icon:SetPoint("LEFT", 0, 0)
        dr.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        dr.text = dr:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        dr.text:SetPoint("LEFT", dr.icon, "RIGHT", 4, 0)
        dr.text:SetPoint("RIGHT", dr, "RIGHT", 0, 0)
        dr.text:SetJustifyH("LEFT")

        dr.hl = dr:CreateTexture(nil, "BACKGROUND")
        dr.hl:SetAllPoints()
        dr.hl:SetColorTexture(1, 1, 1, 0.06)
        dr.hl:Hide()

        dr:EnableMouse(true)
        dr:SetScript("OnEnter", function(self2)
            self2.hl:Show()
            local k, id = self2._kind, self2._itemID
            if k == "transmog" and id then
                -- dressed model in a side popout (clear of the item tooltip)
                UI:_ShowDropPopout(id)
            elseif id and k == "toy" then
                GameTooltip:SetOwner(self2, "ANCHOR_RIGHT")
                pcall(GameTooltip.SetHyperlink, GameTooltip, "item:" .. id)
                GameTooltip:Show()
            end
        end)
        dr:SetScript("OnLeave", function(self2)
            self2.hl:Hide()
            GameTooltip:Hide()
            if self2._kind == "transmog" then UI:_ScheduleHidePopout() end
        end)

        dr:Hide()
        f.dropRows[i] = dr
        anchor = dr
    end

    -- Curation + pin buttons, pinned to the bottom of the detail pane
    f.wantBtn = CreateFrame("Button", nil, mp, "UIPanelButtonTemplate")
    f.wantBtn:SetSize(94, 22)
    f.wantBtn:SetPoint("BOTTOMLEFT", 6, 8)
    f.wantBtn:SetText("")
    f.wantBtn.tex = f.wantBtn:CreateTexture(nil, "ARTWORK")
    f.wantBtn.tex:SetSize(14, 14)
    f.wantBtn.tex:SetPoint("LEFT", 8, 0)
    f.wantBtn.label = f.wantBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    f.wantBtn.label:SetPoint("LEFT", f.wantBtn.tex, "RIGHT", 3, 0)
    f.wantBtn.label:SetText("Want")
    f.wantBtn:SetScript("OnClick", function()
        if UI.selected then ERT:ToggleWanted(UI.selected); UI:RefreshList(); UI:Select(UI.selected) end
    end)

    f.ignoreBtn = CreateFrame("Button", nil, mp, "UIPanelButtonTemplate")
    f.ignoreBtn:SetSize(64, 22)
    f.ignoreBtn:SetPoint("LEFT", f.wantBtn, "RIGHT", 4, 0)
    f.ignoreBtn:SetText("Ignore")
    f.ignoreBtn:SetScript("OnClick", function()
        if UI.selected then ERT:ToggleIgnored(UI.selected); UI:RefreshList(); UI:Select(UI.selected) end
    end)

    f.pinBtn = CreateFrame("Button", nil, mp, "UIPanelButtonTemplate")
    f.pinBtn:SetSize(40, 22)
    f.pinBtn:SetPoint("LEFT", f.ignoreBtn, "RIGHT", 4, 0)
    f.pinBtn:SetText("Pin")
    f.pinBtn:SetScript("OnClick", function() if UI.selected then SetWaypoint(UI.selected) end end)

    f.empty = f:CreateFontString(nil, "OVERLAY", "GameFontDisable")
    f.empty:SetPoint("CENTER", f.scrollFrame, "CENTER", 0, 0)
    f.empty:SetText("No rares match these filters.\nTry 'All rares' or clear the type toggles.")
    f.empty:Hide()

    f.footer = f:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    f.footer:SetPoint("BOTTOMLEFT", INSET, INSET)
    f.footer:SetText("★ want · ◼ ignore · |cffFFD200M/P/T/X|r = need mount/pet/toy/transmog · UP NOW = confirmed")

    -- Refresh detail names as item data streams in — drops only, never the model
    -- (debounced through the OnUpdate so a burst of events can't thrash the pane).
    f.itemEv = CreateFrame("Frame")
    f.itemEv:RegisterEvent("GET_ITEM_INFO_RECEIVED")
    f.itemEv:SetScript("OnEvent", function()
        if f:IsShown() and UI._pendingDetail and UI.selected == UI._pendingDetail then
            UI._detailDirty = true
        end
    end)

    -- Slow self-refresh while visible: drops names ~0.3s (debounced), list ~2s.
    f._acc = 0
    f._dacc = 0
    f:SetScript("OnUpdate", function(fr, elapsed)
        fr._dacc = (fr._dacc or 0) + (elapsed or 0)
        if fr._dacc >= 0.3 then
            fr._dacc = 0
            if UI._detailDirty and UI.selected then
                UI._detailDirty = false
                UI:_RenderDrops(UI.selected)
            end
        end
        fr._acc = (fr._acc or 0) + (elapsed or 0)
        if fr._acc < 2 then return end
        fr._acc = 0
        UI:RefreshList()
    end)
    f:SetScript("OnShow", function() UI:RefreshList() end)

    self.frame = f
    return f
end
UI.Build = Build

function ERT:ToggleTracker()
    local f = Build(UI)
    if f:IsShown() then f:Hide() else f:Show(); UI:RefreshList() end
end

function ERT:ShowTracker()
    local f = Build(UI)
    f:Show(); UI:RefreshList()
end
