--[[
============================================================================
ETB - Modules/ZoneScan.lua
============================================================================
"What can I collect here?" panel for the current zone OR instance.

OPEN WORLD : reads ETB.ZoneIndex[uiMapID] = { m={[spellID]=xy|true},
             p={[speciesID]=xy|true}, a={[itemID]=xy|true} } built offline from
             AllTheThings 5.1.7 coordinate data.
INSTANCE   : when inside a dungeon/raid, derives the instance's drops from the
             boss-loot database (ETB.JInstances / JEncounters / JLoot) and
             classifies each item as mount / pet / transmog appearance.

Shows BOTH collected and uncollected so you can track progress: each section
header reads collected/total, uncollected entries are listed first in white,
collected entries follow dimmed with a green check.

mapID = uiMapID (C_Map.GetBestMapForUnit) — the same space ATT's coords use.
Clicking an entry with coordinates drops a TomTom waypoint.
============================================================================
]]--

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

local S = ETB.Style or {}
local GOLD  = S.HEADER      or "|cff00ccff"   -- cyan section header (canonical)
local GREEN = S.COLLECTED   or "|cff00ff00"
local GREY  = S.RATE        or "|cff808080"
local WHITE = S.WHITE       or "|cffffffff"
local SUBHEAD = S.SUBHEADER or "|cffaaaaff"
local CHECK = S.MARK_OWNED  or "|TInterface\\RaidFrame\\ReadyCheck-Ready:14:14:0:0|t"
local CROSS = S.MARK_MISSING or "|TInterface\\RaidFrame\\ReadyCheck-NotReady:14:14:0:0|t"
local STAR  = "|cffffd100\226\152\133|r"          -- gold filled star = wishlisted
local MAX_ROWS = 400
local CAP_PER_SECTION = 250                       -- after appearance-dedup the lists are far shorter

local After = (C_Timer and C_Timer.After) or function(d, fn)
    local f = CreateFrame("Frame"); f.t = 0
    f:SetScript("OnUpdate", function(self, e)
        self.t = self.t + e
        if self.t >= d then self:SetScript("OnUpdate", nil); fn() end
    end)
end

-- ---------------------------------------------------------------------------
-- Model preview framing (copied from PlannerUI — measured from Blizzard's
-- wardrobe). Per-slot camera so a hovered piece is framed the way the
-- transmog UI frames it. SetCamera is inert on a plain DressUpModel in this
-- client, so we use SetPosition + SetFacing + SetCameraDistance.
-- ---------------------------------------------------------------------------
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 },
    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 },
}
local SLOT_CAMERA_DEFAULT  = { pos = { 3.224, 0.020, -1.375 }, facing = -0.310 }
local SLOT_CAMERA_DISTANCE = 2.942

-- Lazy spellID -> mountID map (built once from C_MountJournal), shared by the
-- preview-model display lookup and the loot mount resolver below.
local _spellToMountID
local function EnsureMountMap()
    if _spellToMountID and next(_spellToMountID) then return end  -- retry if built empty
    _spellToMountID = {}
    if not (C_MountJournal and C_MountJournal.GetMountIDs) then return end
    local ok, ids = pcall(C_MountJournal.GetMountIDs)
    if ok and ids then
        for _, mountID in ipairs(ids) do
            local ok2, _, sID = pcall(C_MountJournal.GetMountInfoByID, mountID)
            if ok2 and sID then _spellToMountID[sID] = mountID end
        end
    end
end
-- Is this spell a mount the journal recognises? (covers mounts missing from
-- our own MountData so they still show.)
local function MountSpellKnown(spellID)
    EnsureMountMap()
    return _spellToMountID ~= nil and _spellToMountID[spellID] ~= nil
end
-- Creature display for a mount's spell, for the preview model. Everything is
-- pcall-guarded because the journal's extra-info signature isn't guaranteed.
local function MountDisplayID(spellID)
    EnsureMountMap()
    local mountID = _spellToMountID and _spellToMountID[spellID]
    if not (mountID and C_MountJournal and C_MountJournal.GetMountInfoExtraByID) then return nil end
    local ok, displayID = pcall(C_MountJournal.GetMountInfoExtraByID, mountID)
    if ok and type(displayID) == "number" and displayID > 0 then return displayID end
    return nil
end

-- ---------------------------------------------------------------------------
-- Map resolution (modern engine: uiMapIDs via C_Map)
-- ---------------------------------------------------------------------------
local function CurrentMapID()
    if C_Map and C_Map.GetBestMapForUnit then
        local id = C_Map.GetBestMapForUnit("player")
        if type(id) == "number" and id > 0 then return id end
    end
    if SetMapToCurrentZone then pcall(SetMapToCurrentZone) end
    local id = GetCurrentMapAreaID and GetCurrentMapAreaID() or nil
    if type(id) == "number" and id > 0 then return id end
    return nil
end

local function ResolveIndexedMap(mapID)
    local guard = 0
    while mapID and guard < 8 do
        if ETB.ZoneIndex and ETB.ZoneIndex[mapID] then return mapID end
        if C_Map and C_Map.GetMapInfo then
            local info = C_Map.GetMapInfo(mapID); mapID = info and info.parentMapID
        else return mapID end
        guard = guard + 1
    end
    return mapID
end

-- ---------------------------------------------------------------------------
-- Reverse maps: itemID -> mount spellID / pet speciesID (built once)
-- ---------------------------------------------------------------------------
local itemToMount, itemToPet
local function BuildReverse()
    if itemToMount then return end
    itemToMount, itemToPet = {}, {}
    if NS.EMT and NS.EMT.MountData then
        for spellID, rec in pairs(NS.EMT.MountData) do
            if rec.i then itemToMount[rec.i] = spellID end
        end
    end
    if NS.EPT and NS.EPT.PetData then
        for speciesID, rec in pairs(NS.EPT.PetData) do
            if rec.i then itemToPet[rec.i] = speciesID end
        end
    end
end

-- Resolve an arbitrary loot itemID to a tracked mount spellID. Direct itemID
-- map first; then via the item's TAUGHT spell (GetItemSpell), which catches
-- difficulty-variant mount items whose itemID differs from the one MountData
-- recorded — every variant teaches the same mount spell. Falls back to the
-- mount journal so mounts missing from our own MountData still surface, so
-- mounts show from every boss/source, not just the ones we pre-mapped.
local _notMount = {}
local function ResolveMountSpell(itemID)
    if itemToMount[itemID] then return itemToMount[itemID] end
    if _notMount[itemID] then return nil end
    if GetItemSpell then
        local _, spellID = GetItemSpell(itemID)
        if spellID then
            if (NS.EMT and NS.EMT.MountData and NS.EMT.MountData[spellID]) or MountSpellKnown(spellID) then
                itemToMount[itemID] = spellID
                return spellID
            end
        end
        -- Only negative-cache once the item is cached (name known) AND the
        -- mount journal has populated, so we don't wrongly rule out a mount
        -- that the journal simply hadn't loaded yet.
        if GetItemInfo(itemID) and _spellToMountID and next(_spellToMountID) then
            _notMount[itemID] = true
        end
    end
    return nil
end

-- ---------------------------------------------------------------------------
-- Collection-state + name/icon helpers per type
-- ---------------------------------------------------------------------------
local function MountState(spellID)
    local API = NS.EMT and NS.EMT.MountAPI
    if not API then return nil end
    local rec = NS.EMT.MountData[spellID] or {}
    return API:IsCollected(spellID), API:GetName(spellID, rec.i), API:GetIcon(spellID, rec.i)
end
local function PetState(speciesID)
    local API = NS.EPT and NS.EPT.PetAPI
    if not API then return nil end
    local rec = NS.EPT.PetData[speciesID] or {}
    return API:IsCollected(speciesID), API:GetName(speciesID, rec.i), API:GetIcon(speciesID, rec.i)
end
local function AppearanceCollected(itemID)
    -- 1) Addon's own appearance-wide state (preferred when available).
    if ETB.CollectionAPI and ETB.CollectionAPI.GetState then
        local ok, st = pcall(ETB.CollectionAPI.GetState, ETB.CollectionAPI, itemID)
        if ok and st ~= nil then
            if st == true or st == "collected" or st == "known" then return true end
            if st == false or st == "missing" then return false end
        end
    end
    -- 2) Appearance-WIDE Blizzard check: do you have this item's look from ANY
    --    source? More correct than the per-source check below, which only
    --    knows about one specific source of the appearance.
    if C_TransmogCollection and C_TransmogCollection.PlayerHasTransmogByItemInfo then
        local ok, has = pcall(C_TransmogCollection.PlayerHasTransmogByItemInfo, itemID)
        if ok and has ~= nil then return has end
    end
    -- 3) Per-source fallback.
    if C_TransmogCollection and C_TransmogCollection.GetItemInfo then
        local _, srcID = C_TransmogCollection.GetItemInfo(itemID)
        if srcID and C_TransmogCollection.PlayerHasTransmogItemModifiedAppearance then
            local ok, has = pcall(C_TransmogCollection.PlayerHasTransmogItemModifiedAppearance, srcID)
            if ok then return has end
        end
    end
    return nil
end
local pendingNames = false
local function AppearanceNameIcon(itemID)
    local name = GetItemInfo(itemID)          -- nil if not yet cached (queues a server fetch)
    local _, _, _, _, icon = GetItemInfoInstant(itemID)
    if not name then pendingNames = true; name = "Loading..." end
    return name, icon
end

-- Dedup key so the Normal/Heroic/Thunderforged versions of one look collapse
-- into a single row. Prefer the real appearanceID; fall back to name.
local function AppearanceKey(itemID, name)
    if ETB.CollectionAPI and ETB.CollectionAPI.GetAppearance then
        local ok, appID = pcall(ETB.CollectionAPI.GetAppearance, ETB.CollectionAPI, itemID)
        if ok and appID then return "a:" .. appID end
    end
    return "n:" .. tostring(name)
end

-- ---------------------------------------------------------------------------
-- Class / armor filter (reuses CollectionPlanner's single source of truth)
-- Default ON; toggle with /etbzone class
-- ---------------------------------------------------------------------------
local function ClassFilterOn()
    return not (ETB_Settings and ETB_Settings.zoneScanClassFilter == false)
end
local function AppearanceRelevant(itemID)
    if not ClassFilterOn() then return true end
    local CP = ETB.CollectionPlanner
    if CP and CP.IsRelevantToClass then
        local ok, rel = pcall(CP.IsRelevantToClass, CP, itemID)
        if ok then return rel end
    end
    return true
end

-- ---------------------------------------------------------------------------
-- Difficulty (reuses CollectionPlanner's content-guarded masks — the single
-- source of truth for the raid/dungeon difficulty rules). For an item at a
-- known boss/encounter, CP returns a mask that has already had bits which
-- can't belong to that instance type stripped. Labels likewise route through
-- CP so each expansion's schema (Classic 40/20, Wrath 10/25 N/H, MoP
-- Flex/10N/10H/25N/25H, dungeon Challenge tiers) is resolved in one place —
-- never hardcoded "10/25 N/H" here. (No LFR: Raid Finder doesn't exist in
-- the MoP Classic timeline.)
-- ---------------------------------------------------------------------------
local band = bit and bit.band
local bor  = bit and bit.bor
local function EncItemDiff(encID, itemID)
    local CP = ETB.CollectionPlanner
    if CP and CP.GetEncounterItemDifficulty then
        local ok, m = pcall(CP.GetEncounterItemDifficulty, CP, encID, itemID)
        if ok and type(m) == "number" then return m end
    end
    return 0
end
local function DiffString(mask)
    if not mask or mask == 0 then return nil end
    local CP = ETB.CollectionPlanner
    if CP and CP.GetDifficultyLabels then
        local ok, labels = pcall(CP.GetDifficultyLabels, CP, mask)
        if ok and labels and #labels > 0 then return table.concat(labels, "/") end
    end
    return nil
end
-- Active difficulty filter (a single bit value, or nil for "all"). Items with
-- no difficulty info default to Normal (bit 1), matching the planner's filter.
local function PassesDiffFilter(mask)
    local fb = ZS.diffFilter
    if not fb or not band then return true end
    local m = (mask and mask ~= 0) and mask or 1
    return band(m, fb) ~= 0
end

-- ---------------------------------------------------------------------------
-- Gatherers — each returns a section list of {id, kind, name, icon, owned, xy}
-- plus owned/total counts.
-- ---------------------------------------------------------------------------
local function NewSection() return { items = {}, owned = 0, total = 0 } end
local function PushItem(sec, e)
    sec.total = sec.total + 1
    if e.owned == true then sec.owned = sec.owned + 1 end
    sec.items[#sec.items + 1] = e
end
local function SortSection(sec)
    table.sort(sec.items, function(a, b)
        if (a.owned == true) ~= (b.owned == true) then return a.owned ~= true end  -- needed first
        return tostring(a.name) < tostring(b.name)
    end)
end

-- Returns a copy of a section keeping only entries that satisfy the active
-- difficulty filter, with owned/total recomputed for the filtered view. When
-- no filter is set the original section is returned unchanged. Transmog
-- collected-state is appearance-wide, so this is guidance ("what can I still
-- get on the difficulty I run"), not a change to what counts as collected.
local function FilterSectionByDiff(sec)
    if not ZS.diffFilter then return sec end
    local out = NewSection()
    for _, e in ipairs(sec.items) do
        if PassesDiffFilter(e.diffMask) then PushItem(out, e) end
    end
    return out
end

-- Walk the map's parents to find a zone name present in the expanded
-- transmog index (which is keyed by zone name, from the compact zoneIdx).
local function ResolveZoneName(mapID)
    local guard = 0
    while mapID and guard < 8 do
        if not (C_Map and C_Map.GetMapInfo) then return nil end
        local info = C_Map.GetMapInfo(mapID)
        local nm = info and info.name
        if nm and ETB.ZoneTransmogIndex and ETB.ZoneTransmogIndex[nm] then return nm end
        mapID = info and info.parentMapID
        guard = guard + 1
    end
    return nil
end

local function GatherOpenWorld(ziMap, rawMap)
    BuildReverse()
    local zone = ETB.ZoneIndex and ETB.ZoneIndex[ziMap]
    local mounts, pets, apps = NewSection(), NewSection(), NewSection()
    local seenM, seenP = {}, {}
    local function unpackXY(packed)
        if type(packed) == "number" and packed > 0 then
            return { math.floor(packed / 10000) / 100, (packed % 10000) / 100 }
        end
        return nil
    end
    local function addMount(spellID, xy)
        if not spellID or seenM[spellID] then return end
        local owned, name, icon = MountState(spellID)
        if not name then return end
        seenM[spellID] = true
        PushItem(mounts, { id = spellID, kind = "m", name = name, icon = icon,
            owned = owned, xy = type(xy) == "table" and xy or nil })
    end
    local function addPet(speciesID, xy)
        if not speciesID or seenP[speciesID] then return end
        local owned, name, icon = PetState(speciesID)
        if not name then return end
        seenP[speciesID] = true
        PushItem(pets, { id = speciesID, kind = "p", name = name, icon = icon,
            owned = owned, xy = type(xy) == "table" and xy or nil })
    end
    if zone and zone.m then
        for spellID, xy in pairs(zone.m) do addMount(spellID, type(xy) == "table" and xy or nil) end
    end
    if zone and zone.p then
        for speciesID, xy in pairs(zone.p) do addPet(speciesID, type(xy) == "table" and xy or nil) end
    end
    -- Wild + zone battle pets by uiMapID (ATT-sourced), unioned with ZoneIndex.p
    -- via the addPet dedup set so every capturable pet in the zone is listed.
    if ETB.ZonePets then
        local seenmk = {}
        for _, mk in ipairs({ rawMap, ziMap }) do
            if mk and not seenmk[mk] then
                seenmk[mk] = true
                local zp = ETB.ZonePets[mk]
                if zp then
                    for speciesID, packed in pairs(zp) do addPet(speciesID, unpackXY(packed)) end
                end
            end
        end
    end
    -- Transmog: ZoneIndex.a (vendor/quest, with coords) PLUS the expanded
    -- ZoneTransmogIndex (every zone-tagged appearance from the compact data),
    -- merged through one dedup set so the two sources collapse together.
    local seen = {}
    local function addApp(itemID, xy)
        if not AppearanceRelevant(itemID) then return end
        local owned = AppearanceCollected(itemID)
        if owned == nil then return end   -- not a real/collectible appearance
        local name, icon = AppearanceNameIcon(itemID)
        local key = AppearanceKey(itemID, name)
        if seen[key] then return end
        seen[key] = true
        PushItem(apps, { id = itemID, kind = "a", name = name, icon = icon,
            owned = owned, xy = type(xy) == "table" and xy or nil })
    end
    if zone and zone.a then
        for itemID, xy in pairs(zone.a) do addApp(itemID, xy) end
    end
    local zoneName = ResolveZoneName(rawMap or ziMap)
    local extra = zoneName and ETB.ZoneTransmogIndex and ETB.ZoneTransmogIndex[zoneName]
    if extra then
        for _, itemID in ipairs(extra) do addApp(itemID, nil) end
    end
    -- Rare-mob + world-boss drops by uiMapID. Each loot item is classified the
    -- same way as instance loot: mount (resolved spell) / pet (species) / else
    -- appearance. Coords (packed xxxxyyyy) drive TomTom waypoints; the mount/pet
    -- dedup sets collapse overlap with the ZoneIndex collectibles above.
    if ETB.RareZoneTransmog then
        local seenmk = {}
        for _, mk in ipairs({ rawMap, ziMap }) do
            if mk and not seenmk[mk] then
                seenmk[mk] = true
                local rz = ETB.RareZoneTransmog[mk]
                if rz then
                    for itemID, packed in pairs(rz) do
                        local xy = unpackXY(packed)
                        local spellID = ResolveMountSpell(itemID)
                        local speciesID = itemToPet[itemID]
                        if spellID then addMount(spellID, xy)
                        elseif speciesID then addPet(speciesID, xy)
                        else addApp(itemID, xy) end
                    end
                end
            end
        end
    end
    SortSection(mounts); SortSection(pets); SortSection(apps)
    return mounts, pets, apps
end

-- Instance loot grouped by boss: instanceLoot[instID] = { byEnc={[encID]={ids={},seen={}}}, order={encID...} }
local instanceLoot, nameToInst, instanceDataDeferred
local function BuildInstanceData(opts)
    if instanceLoot then return true end
    opts = opts or {}
    instanceDataDeferred = false
    -- The J-tables are lazy proxies (setmetatable __index). pairs() does NOT
    -- trigger __index, so iterating an unmaterialized proxy yields nothing and
    -- we would cache an empty result forever (e.g. fresh login -> straight into
    -- a raid -> no data). Force the loader to swap in the real tables first,
    -- THEN capture the references.
    if ETB._BossLootLoader then
        local inCombat = (InCombatLockdown and InCombatLockdown())
            or (UnitAffectingCombat and UnitAffectingCombat("player"))
        if opts.allowLoad ~= true or inCombat then
            instanceDataDeferred = true
            return false
        end
        if ETB.LootService then ETB.LootService:EnsureRawLoot()
        else ETB._BossLootLoader(); ETB._BossLootLoader = nil end
    end
    instanceLoot, nameToInst = {}, {}
    local JI, JE, JL = ETB.JInstances, ETB.JEncounters, ETB.JLoot
    if not (JI and JE and JL) then instanceDataDeferred = true; return false end
    -- Some names collide across expansions (e.g. "Onyxia's Lair" exists as the
    -- removed classic 40-man AND the live WotLK 10/25 re-release). The player
    -- can only be in the live one, which is always the higher expansion tier
    -- (rec[2]) since re-releases replace older versions. Prefer higher tier so
    -- GetInstanceInfo() resolves to the obtainable loot set, not the dead one.
    local nameTier = {}
    for instID, rec in pairs(JI) do
        if type(rec) == "table" and rec[1] then
            local nm, tier = rec[1], rec[2] or 0
            if not nameToInst[nm] or tier > (nameTier[nm] or -1) then
                nameToInst[nm] = instID; nameTier[nm] = tier
            end
        end
    end
    for itemID, encs in pairs(JL) do
        local list = encs
        if type(encs) == "table" and type(encs[1]) ~= "table" then list = { encs } end
        if type(list) == "table" then
            for _, pair in ipairs(list) do
                local encID = type(pair) == "table" and pair[1] or pair
                local enc = encID and JE[encID]
                local instID = enc and enc[2]
                if instID then
                    local D = instanceLoot[instID] or { byEnc = {}, order = {} }
                    instanceLoot[instID] = D
                    local E = D.byEnc[encID]
                    if not E then E = { ids = {}, seen = {} }; D.byEnc[encID] = E; D.order[#D.order + 1] = encID end
                    if not E.seen[itemID] then E.seen[itemID] = true; E.ids[#E.ids + 1] = itemID end
                end
            end
        end
    end
    for _, D in pairs(instanceLoot) do
        table.sort(D.order, function(a, b)
            return ((JE[a] and JE[a][3]) or 0) < ((JE[b] and JE[b][3]) or 0)
        end)
    end
    return true
end

local function GatherInstance(instID, opts)
    BuildReverse()
    if not BuildInstanceData(opts) then return NewSection(), NewSection(), NewSection() end
    local mounts, pets, apps = NewSection(), NewSection(), NewSection()
    local D = instanceLoot and instanceLoot[instID]
    if not D then return mounts, pets, apps end
    local JE = ETB.JEncounters
    local seenApp = {}
    for _, encID in ipairs(D.order) do
        local bossName = (JE and JE[encID] and JE[encID][1]) or "Unknown"
        local order = (JE and JE[encID] and JE[encID][3]) or 0
        for _, itemID in ipairs(D.byEnc[encID].ids) do
            local spellID = ResolveMountSpell(itemID)
            local speciesID = itemToPet[itemID]
            if spellID then
                local owned, name, icon = MountState(spellID)
                if name then PushItem(mounts, { id = spellID, kind = "m", name = name, icon = icon,
                    owned = owned, diffMask = EncItemDiff(encID, itemID) }) end
            elseif speciesID then
                local owned, name, icon = PetState(speciesID)
                if name then PushItem(pets, { id = speciesID, kind = "p", name = name, icon = icon,
                    owned = owned, diffMask = EncItemDiff(encID, itemID) }) end
            elseif AppearanceRelevant(itemID) then
                local dm = EncItemDiff(encID, itemID)
                -- A real tier TOKEN is not equippable (equipLoc == ""). Trinkets,
                -- rings, and the tier gear pieces themselves all have a real
                -- equipLoc, so this guard keeps them out of the token path even
                -- if the token data over-includes their itemIDs.
                local _, _, _, equipLoc = GetItemInfoInstant(itemID)
                local tokenPieces
                if (not equipLoc or equipLoc == "") and ETB.TierTokenMap then
                    tokenPieces = ETB.TierTokenMap[itemID]
                end
                if tokenPieces then
                    -- Tier token: no appearance of its own; it grants class tier
                    -- pieces. Track by the player-relevant granted pieces and
                    -- preview one of them on the model.
                    local relevantPiece, allOwned, anyRelevant = nil, true, false
                    for _, p in ipairs(tokenPieces) do
                        local pid = p[1]
                        if AppearanceRelevant(pid) then
                            anyRelevant = true
                            relevantPiece = relevantPiece or pid
                            if AppearanceCollected(pid) ~= true then allOwned = false end
                        end
                    end
                    if anyRelevant then
                        local key = "tok:" .. itemID
                        if not seenApp[key] then
                            local name, icon = AppearanceNameIcon(itemID)
                            local e = { id = itemID, kind = "a", name = name, icon = icon,
                                owned = allOwned, boss = bossName, ord = order, diffMask = dm,
                                isToken = true, previewItemID = relevantPiece }
                            seenApp[key] = e
                            PushItem(apps, e)
                        elseif bor and dm ~= 0 then
                            seenApp[key].diffMask = bor(seenApp[key].diffMask or 0, dm)
                        end
                    end
                else
                    local owned = AppearanceCollected(itemID)
                    if owned ~= nil then
                        local name, icon = AppearanceNameIcon(itemID)
                        local key = AppearanceKey(itemID, name)
                        local existing = seenApp[key]
                        if not existing then
                            -- First sighting of this look: push one row. Difficulty
                            -- variants (N/H/Thunderforged) of the same look
                            -- collapse here, OR-ing their masks so the row reflects
                            -- every difficulty that yields the appearance.
                            local e = { id = itemID, kind = "a", name = name, icon = icon,
                                owned = owned, boss = bossName, ord = order, diffMask = dm }
                            seenApp[key] = e
                            PushItem(apps, e)
                        elseif bor and dm ~= 0 then
                            existing.diffMask = bor(existing.diffMask or 0, dm)
                        end
                    end
                end
            end
        end
    end
    -- Aggregate difficulty across the whole view, for the filter cycle UI.
    local vm = 0
    if bor then
        for _, sec in ipairs({ mounts, pets, apps }) do
            for _, e in ipairs(sec.items) do
                if e.diffMask and e.diffMask ~= 0 then vm = bor(vm, e.diffMask) end
            end
        end
    end
    ZS._viewMask = vm
    -- Optional difficulty filter (guidance only; counts reflect the filter).
    mounts = FilterSectionByDiff(mounts)
    pets   = FilterSectionByDiff(pets)
    apps   = FilterSectionByDiff(apps)
    SortSection(mounts); SortSection(pets)
    -- transmog stays in boss order, then needed-first, then name
    table.sort(apps.items, function(a, b)
        if (a.ord or 0) ~= (b.ord or 0) then return (a.ord or 0) < (b.ord or 0) end
        if (a.owned == true) ~= (b.owned == true) then return a.owned ~= true end
        return tostring(a.name) < tostring(b.name)
    end)
    return mounts, pets, apps
end

-- ---------------------------------------------------------------------------
-- UI
-- ---------------------------------------------------------------------------
local frame
local function BuildFrame()
    if frame then return frame end
    frame = CreateFrame("Frame", "ETBZoneScanFrame", UIParent)
    frame:SetSize(300, 420)
    frame:SetPoint("CENTER", 300, 0)
    frame:SetMovable(true); frame:EnableMouse(true); frame:SetClampedToScreen(true)
    frame:RegisterForDrag("LeftButton")
    frame:SetScript("OnDragStart", frame.StartMoving)
    frame:SetScript("OnDragStop", frame.StopMovingOrSizing)
    frame:SetFrameStrata("MEDIUM")

    local bg = frame:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    if bg.SetColorTexture then bg:SetColorTexture(0, 0, 0, 0.85) else bg:SetTexture(0, 0, 0, 0.85) end
    local border = CreateFrame("Frame", nil, frame, BackdropTemplateMixin and "BackdropTemplate" or nil)
    border:SetPoint("TOPLEFT", -1, 1); border:SetPoint("BOTTOMRIGHT", 1, -1)
    if border.SetBackdrop then
        border:SetBackdrop({ edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 12 })
    end

    frame.title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.title:SetPoint("TOPLEFT", 10, -8); frame.title:SetPoint("RIGHT", -28, 0)
    frame.title:SetJustifyH("LEFT")

    local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
    close:SetPoint("TOPRIGHT", 2, 2)
    close:SetScript("OnClick", function() frame:Hide() end)

    frame.sub = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    frame.sub:SetPoint("TOPLEFT", 10, -26); frame.sub:SetPoint("RIGHT", -10, 0)
    frame.sub:SetJustifyH("LEFT")

    -- Search box: type any zone or instance name to view it from anywhere
    local search = CreateFrame("EditBox", nil, frame, "InputBoxTemplate")
    search:SetSize(202, 18); search:SetPoint("TOPLEFT", 14, -42)
    search:SetAutoFocus(false); search:SetText("")
    search.placeholder = search:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    search.placeholder:SetPoint("LEFT", 4, 0)
    search.placeholder:SetText("Search a zone or instance, Esc to clear")
    search:SetScript("OnEditFocusGained", function(self) self.placeholder:Hide() end)
    search:SetScript("OnEditFocusLost", function(self) if self:GetText() == "" then self.placeholder:Show() end end)
    search:SetScript("OnEnterPressed", function(self) ZS:SetSearch(self:GetText()); self:ClearFocus() end)
    search:SetScript("OnEscapePressed", function(self) self:SetText(""); self.placeholder:Show(); self:ClearFocus(); ZS:SetSearch(nil) end)
    frame.search = search

    -- Difficulty filter button (instance mode). Cycles through exactly the
    -- difficulties present in the current view, then back to "All", reusing
    -- ZS:SetDiffFilter()'s no-arg cycle path. Label updates in Render.
    local diffBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
    diffBtn:SetSize(66, 20)
    diffBtn:SetPoint("LEFT", search, "RIGHT", 6, 0)
    diffBtn:SetText("Diff: All")
    diffBtn:SetScript("OnClick", function() ZS:SetDiffFilter() end)
    diffBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_BOTTOMLEFT")
        GameTooltip:SetText("Difficulty filter")
        GameTooltip:AddLine("Click to cycle the difficulties this instance offers.", 0.8, 0.8, 0.8, true)
        GameTooltip:AddLine("Transmog is appearance-wide, so this is collection guidance only.", 0.6, 0.6, 0.6, true)
        GameTooltip:Show()
    end)
    diffBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)
    frame.diffBtn = diffBtn

    local scroll = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT", 8, -66); scroll:SetPoint("BOTTOMRIGHT", -28, 10)
    local content = CreateFrame("Frame", nil, scroll)
    content:SetSize(256, 10); scroll:SetScrollChild(content)
    frame.content = content; frame.rows = {}
    frame:Hide()   -- created shown-by-default; start hidden so first Toggle opens it
    return frame
end

local function AcquireRow(i)
    local f = BuildFrame()
    local row = f.rows[i]
    if row then row:Show(); return row end
    row = CreateFrame("Button", nil, f.content)
    row:SetSize(252, 16); row:SetPoint("TOPLEFT", 0, -((i - 1) * 16))
    row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(14, 14); row.icon:SetPoint("LEFT", 0, 0); row.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)
    row.text = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    row.text:SetPoint("LEFT", row.icon, "RIGHT", 4, 0); row.text:SetPoint("RIGHT", 0, 0)
    row.text:SetJustifyH("LEFT")
    row:SetScript("OnEnter", function(self)
        local e = self.entry
        if not e then return end
        ZS:PreviewEntry(e)                       -- show it on the model
        local ds = e.diffMask and DiffString(e.diffMask)
        local canWish = (e.kind == "a" and e.id and ETB.Wishlist)
        if not (e.xy or ds or canWish) then return end
        GameTooltip:SetOwner(self, "ANCHOR_LEFT")  -- left so it clears the model flyout
        GameTooltip:AddLine(e.name or "")
        if ds then GameTooltip:AddLine("|cffaaaaffDrops on: " .. ds .. "|r") end
        if e.xy then GameTooltip:AddLine(GREY .. "Left-click to set a TomTom waypoint|r") end
        if canWish then
            if ETB.Wishlist:HasItem(e.id) then
                GameTooltip:AddLine(GREY .. "Right-click to remove from wishlist|r")
            else
                GameTooltip:AddLine(GREY .. "Right-click to add to wishlist|r")
            end
        end
        GameTooltip:Show()
    end)
    row:SetScript("OnLeave", GameTooltip_Hide)
    f.rows[i] = row
    return row
end

local function SetWaypoint(mapID, entry)
    if not (mapID and entry.xy and TomTom and TomTom.AddWaypoint) then return end
    pcall(function()
        TomTom:AddWaypoint(mapID, entry.xy[1] / 100, entry.xy[2] / 100,
            { title = entry.name, persistent = false, minimap = true, world = true })
    end)
    if ETB.Print then ETB:Print("Waypoint set: " .. tostring(entry.name)) end
end

-- flatten sections into a display list; returns list, and updates header counts
local function AppendSection(list, label, sec)
    if sec.total == 0 then return end
    local countStr = (S.Count and S:Count(sec.owned, sec.total))
        or string.format("%s%d/%d|r", GREEN, sec.owned, sec.total)
    list[#list + 1] = { header = true,
        label = string.format("%s%s |r", GOLD, label) .. countStr }
    local shown, lastBoss = 0, nil
    for _, e in ipairs(sec.items) do
        if e.boss and e.boss ~= lastBoss then
            lastBoss = e.boss
            list[#list + 1] = { header = true, label = "  " .. SUBHEAD .. e.boss .. "|r" }  -- boss sub-header
        end
        list[#list + 1] = e; shown = shown + 1
        if shown >= CAP_PER_SECTION then
            local rest = sec.total - shown
            if rest > 0 then list[#list + 1] = { header = true, label = GREY .. "  ...and " .. rest .. " more|r" } end
            break
        end
    end
end

-- Zone-name index for search: lower(name) -> {id=mapID, name=displayName}
local zoneNameIndex
local function BuildZoneNames()
    if zoneNameIndex then return end
    zoneNameIndex = {}
    if not (ETB.ZoneIndex and C_Map and C_Map.GetMapInfo) then return end
    for mapID in pairs(ETB.ZoneIndex) do
        local info = C_Map.GetMapInfo(mapID)
        if info and info.name then zoneNameIndex[info.name:lower()] = { id = mapID, name = info.name } end
    end
end

-- Set (or clear) a searched zone/instance to view from anywhere.
function ZS:SetSearch(query)
    query = query and query:gsub("^%s+", ""):gsub("%s+$", "") or ""
    if query == "" then self.searchTarget = nil; self:Refresh(); return end
    BuildInstanceData({ allowLoad = false }); BuildZoneNames()
    local lq, target = query:lower(), nil
    if nameToInst then
        for name, instID in pairs(nameToInst) do
            if name:lower():find(lq, 1, true) then target = { instance = instID, name = name }; break end
        end
    end
    if not target and zoneNameIndex then
        for lname, rec in pairs(zoneNameIndex) do
            if lname:find(lq, 1, true) then target = { map = rec.id, name = rec.name }; break end
        end
    end
    if target then self.searchTarget = target
    else self.searchTarget = nil; if ETB.Print then ETB:Print("No zone or instance matching '" .. query .. "'") end end
    self:Refresh()
end

function ZS:Refresh(opts)
    opts = opts or {}
    local f = BuildFrame()
    -- NOTE: the scan resolves everything through WoW APIs — relevance via
    -- GetItemInfoInstant, collected-state via C_TransmogCollection, names via
    -- GetItemInfo — and the zone indexes (ZoneIndex / ZoneTransmogIndex /
    -- RareZoneTransmog) are self-contained. It does NOT read the compact
    -- appearance dataset, so we deliberately do NOT warm it here. The compact
    -- loads lazily only when a *source* is actually requested (item tooltip
    -- hover → TooltipEnhancer), which keeps a scan off the ~22MB load entirely.
    -- Reset per render: GatherInstance recomputes this from the view's items;
    -- open-world scans leave it 0 so the difficulty button disables itself.
    self._viewMask = 0
    local st = self.searchTarget
    local mounts, pets, apps, headerLoc, mapID

    if st and st.instance then
        mounts, pets, apps = GatherInstance(st.instance, opts); headerLoc = st.name .. GREY .. "  (search)|r"
    elseif st and st.map then
        mapID = st.map; mounts, pets, apps = GatherOpenWorld(mapID, mapID); headerLoc = st.name .. GREY .. "  (search)|r"
    else
        local inInstance, instType = IsInInstance and IsInInstance() or false, nil
        if IsInInstance then _, instType = IsInInstance() end
        if inInstance and (instType == "party" or instType == "raid") then
            BuildInstanceData(opts)
            local name = GetInstanceInfo and GetInstanceInfo() or nil
            local instID = name and nameToInst and nameToInst[name]
            headerLoc = name or "Instance"
            if instID then mounts, pets, apps = GatherInstance(instID, opts)
            else mounts, pets, apps = NewSection(), NewSection(), NewSection() end
        else
            local rawMap = CurrentMapID()
            mapID = rawMap and ResolveIndexedMap(rawMap) or rawMap
            headerLoc = (GetRealZoneText and GetRealZoneText()) or (GetZoneText and GetZoneText()) or "Unknown"
            if rawMap then mounts, pets, apps = GatherOpenWorld(mapID, rawMap)
            else mounts, pets, apps = NewSection(), NewSection(), NewSection() end
        end
    end

    f.title:SetText(GOLD .. "ETB Zone Scan|r")
    local filterNote = ""
    if self.diffFilter then
        local fl = DiffString(self.diffFilter)
        if fl then filterNote = GREY .. "  [diff: " .. fl .. "]|r" end
    end
    f.sub:SetText("|cffffffff" .. tostring(headerLoc) .. "|r" .. filterNote)

    -- Reflect the filter on the button. Disable it when the current view has no
    -- difficulty variants (open-world scans, or instances with a single tier).
    if f.diffBtn then
        local hasVariants = (self._viewMask or 0) ~= 0
        if self.diffFilter then
            f.diffBtn:SetText("Diff: " .. (DiffString(self.diffFilter) or "?"))
        else
            f.diffBtn:SetText("Diff: All")
        end
        if hasVariants then f.diffBtn:Enable() else f.diffBtn:Disable() end
    end

    local list = {}
    if instanceDataDeferred then
        list[#list + 1] = { header = true, label = GREY .. "Instance loot is deferred to avoid a zone-in hitch. Use /etbzone load after combat to load it.|r" }
    end
    AppendSection(list, "Mounts", mounts)
    AppendSection(list, "Battle Pets", pets)
    AppendSection(list, "Transmog", apps)
    if #list == 0 then
        list[1] = { header = true, label = GREY .. "No tracked collectibles found here.|r" }
    end

    for _, r in ipairs(f.rows) do r:Hide() end
    local n = math.min(#list, MAX_ROWS)
    for i = 1, n do
        local item = list[i]
        local row = AcquireRow(i)
        if item.header then
            row.icon:Hide(); row.text:SetPoint("LEFT", row, "LEFT", 0, 0)
            row.text:SetText(item.label); row.entry = nil; row:SetScript("OnClick", nil)
        else
            row.icon:Show(); row.icon:SetTexture(item.icon or "Interface\\Icons\\INV_Misc_QuestionMark")
            row.text:SetPoint("LEFT", row.icon, "RIGHT", 4, 0)
            local prefix, color
            if item.owned == true then prefix = CHECK .. " "; color = GREY
            elseif item.owned == false then prefix = CROSS .. " "; color = WHITE
            else prefix = GREY .. "? |r"; color = WHITE end
            local wp = item.xy and (GREEN .. " *|r") or ""
            local diffTag = ""
            if (item.kind == "m" or item.kind == "p") and item.diffMask and item.diffMask ~= 0 then
                local ds = DiffString(item.diffMask)
                if ds then diffTag = " |cffaaaaff(" .. ds .. ")|r" end
            end
            local tokenTag = item.isToken and (" " .. (S.TOKEN or "|cffff8000") .. "(Tier)|r") or ""
            local star = (item.kind == "a" and item.id and ETB.Wishlist and ETB.Wishlist:HasItem(item.id)) and (" " .. STAR) or ""
            row.text:SetText(prefix .. color .. tostring(item.name) .. "|r" .. tokenTag .. diffTag .. wp .. star)
            row.entry = item
            row:SetScript("OnClick", function(self, btn)
                if btn == "RightButton" then
                    if item.kind == "a" and item.id and ETB.Wishlist then
                        ETB.Wishlist:ToggleItem(item.id)
                        ZS:Refresh()              -- update the star immediately
                    end
                else
                    SetWaypoint(mapID, item)
                end
            end)
        end
    end
    f.content:SetHeight(math.max(10, n * 16))
end

-- ---------------------------------------------------------------------------
-- Hover model preview — a flyout to the right of the panel. Transmog pieces
-- are tried on the player model; mounts show their creature model; anything
-- that can't resolve a model (pets, unresolved mounts) falls back to a large
-- icon. Reuses the addon's proven DressUpModel sequence: SetUnit every call,
-- Undress, per-slot camera, then a DEFERRED TryOn with the item LINK.
-- ---------------------------------------------------------------------------
local function BuildModelFlyout()
    local f = BuildFrame()
    if f.modelFlyout then return f.modelFlyout end
    local fly = CreateFrame("Frame", nil, f, BackdropTemplateMixin and "BackdropTemplate" or nil)
    fly:SetSize(220, 320)
    fly:SetPoint("TOPLEFT", f, "TOPRIGHT", 6, 0)
    fly:SetFrameStrata("MEDIUM")
    local bg = fly:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    if bg.SetColorTexture then bg:SetColorTexture(0.03, 0.03, 0.04, 0.95) else bg:SetTexture(0.03, 0.03, 0.04, 0.95) end
    if fly.SetBackdrop then
        fly:SetBackdrop({ edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 12 })
    end

    fly.model = CreateFrame("DressUpModel", nil, fly)
    fly.model:SetPoint("TOPLEFT", 8, -8)
    fly.model:SetPoint("BOTTOMRIGHT", -8, 46)
    local m = fly.model
    m:EnableMouse(true)
    local function rotate(self)
        local x = GetCursorPosition()
        self._rot = (self._rot or 0) + (x - (self._sx or x)) * 0.01
        pcall(self.SetRotation, self, self._rot)
        self._sx = x
    end
    m:SetScript("OnMouseDown", function(self, b)
        if b == "LeftButton" then self._sx = GetCursorPosition(); self:SetScript("OnUpdate", rotate) end
    end)
    m:SetScript("OnMouseUp", function(self) self:SetScript("OnUpdate", nil) end)

    fly.icon = fly:CreateTexture(nil, "ARTWORK")
    fly.icon:SetSize(64, 64); fly.icon:SetPoint("CENTER", 0, 20)
    fly.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93); fly.icon:Hide()

    fly.label = fly:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    fly.label:SetPoint("BOTTOMLEFT", 8, 8); fly.label:SetPoint("BOTTOMRIGHT", -8, 8)
    fly.label:SetJustifyH("CENTER")
    fly.label:SetWordWrap(true)
    fly:Hide()
    -- Hide the flyout whenever the panel closes (close button / Toggle / Hide).
    f:HookScript("OnHide", function() fly:Hide() end)
    f.modelFlyout = fly
    return fly
end

-- Preview a hovered entry. kind: 'a' transmog, 'm' mount, 'p' pet.
-- entry.previewItemID overrides which item is tried on (tier tokens preview
-- a granted piece rather than the token itself, which has no appearance).
function ZS:PreviewEntry(entry)
    if not entry then return end
    local fly = BuildModelFlyout()
    fly:Show()
    local model, icon = fly.model, fly.icon
    self._previewKey = tostring(entry.kind) .. ":" .. tostring(entry.id)
    local key = self._previewKey

    local function iconFallback(tex, name)
        pcall(model.Hide, model)
        icon:SetTexture(tex or "Interface\\Icons\\INV_Misc_QuestionMark"); icon:Show()
        fly.label:SetText(name or "")
    end

    if entry.kind == "m" then
        local displayID = MountDisplayID(entry.id)
        if displayID then
            icon:Hide(); pcall(model.Show, model)
            pcall(model.SetCameraDistance, model, SLOT_CAMERA_DISTANCE * 2.2)
            pcall(model.SetDisplayInfo, model, displayID)
            fly.label:SetText(entry.name or "")
        else
            iconFallback(entry.icon, entry.name)
        end
        return
    elseif entry.kind == "p" then
        iconFallback(entry.icon, entry.name)   -- pet creature models unreliable in 5.4.8
        return
    end

    -- Transmog (or a tier token's preview piece): try it on the player.
    local itemID = entry.previewItemID or entry.id
    icon:Hide(); pcall(model.Show, model)
    pcall(model.SetUnit, model, "player")
    pcall(model.Undress, model)
    model._rot = 0; pcall(model.SetRotation, model, 0)
    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)
    After(0.1, function()
        if self._previewKey ~= key then return end   -- user moved to another row
        local _, link = GetItemInfo(itemID)
        pcall(model.TryOn, model, link or ("item:" .. itemID))
    end)
    fly.label:SetText(entry.name or GetItemInfo(itemID) or "")
end

function ZS:Toggle()
    local f = BuildFrame()
    if f:IsShown() then f:Hide() else f:Show(); self:Refresh() end
end
function ZS:Show() BuildFrame():Show(); self:Refresh() end
function ZS:Hide() if frame then frame:Hide() end end

function ZS:LoadInstanceLoot()
    local function load()
        instanceLoot, nameToInst = nil, nil
        BuildInstanceData({ allowLoad = true })
        if frame and frame:IsShown() then self:Refresh({ allowLoad = false }) end
    end
    if ETB.QueueWork then
        ETB:QueueWork("zoneScan.loadInstanceLoot", load, {
            pauseInCombat = true,
            startupIdleOptions = { ignoreQuietWindow = true },
        })
    elseif C_Timer and C_Timer.After then
        C_Timer.After(0, load)
    else
        load()
    end
end

-- Tokens accepted by /etbzone diff <token>. Maps to the JLootDiff bit values.
local DIFF_TOKEN = {
    ["10n"] = 16, ["10h"] = 32, ["25n"] = 64, ["25h"] = 128,
    normal = 1, heroic = 2, flex = 256, flexible = 256, celestial = 8192,
}

-- Difficulty filter for instance mode. With an explicit token, sets/clears it;
-- with no argument, cycles through exactly the difficulties present in the
-- current view (via CP's variant rules) and then back to off. Open-world
-- entries carry no difficulty, so the filter only affects instance views.
function ZS:SetDiffFilter(token)
    token = (token or ""):gsub("%s+", "")
    if token == "off" or token == "none" then
        self.diffFilter = nil
    elseif token ~= "" then
        local b = DIFF_TOKEN[token]
        if not b then
            if ETB.Print then ETB:Print("Unknown difficulty '" .. token ..
                "'. Try: 10n 10h 25n 25h normal heroic flex celestial off") end
            return
        end
        self.diffFilter = b
    else
        -- Cycle through the variants actually present in the current view.
        local bits = {}
        local CP = ETB.CollectionPlanner
        if CP and CP.GetDifficultyBits then
            local ok, b = pcall(CP.GetDifficultyBits, CP, self._viewMask or 0)
            if ok and b then bits = b end
        end
        if #bits == 0 then
            self.diffFilter = nil
        else
            local idx
            for i, b in ipairs(bits) do if b == self.diffFilter then idx = i; break end end
            if not idx then self.diffFilter = bits[1]
            elseif idx >= #bits then self.diffFilter = nil
            else self.diffFilter = bits[idx + 1] end
        end
    end
    if ETB.Print then
        local note = self.diffFilter and (DiffString(self.diffFilter) or "?") or nil
        ETB:Print("Zone Scan difficulty filter: " ..
            (note and (GREEN .. note .. "|r") or (GREY .. "off|r")))
    end
    if frame and frame:IsShown() then self:Refresh() end
end

-- Events
local ev = CreateFrame("Frame")
ev:RegisterEvent("PLAYER_ENTERING_WORLD")
ev:RegisterEvent("ZONE_CHANGED_NEW_AREA")
ev:RegisterEvent("GET_ITEM_INFO_RECEIVED")   -- appearance names cache asynchronously
local nameRefreshQueued = false
ev:SetScript("OnEvent", function(_, event)
    if event == "GET_ITEM_INFO_RECEIVED" then
        -- an item finished loading; if the panel is up and was waiting on names, repaint once (debounced)
        if frame and frame:IsShown() and pendingNames and not nameRefreshQueued then
            nameRefreshQueued = true
            After(0.3, function() nameRefreshQueued = false; pendingNames = false; ZS:Refresh() end)
        end
        return
    end
    After(0.5, function()
        if frame and frame:IsShown() then ZS:Refresh() end
        if ETB_Settings and ETB_Settings.zoneScanAuto and frame then frame:Show(); ZS:Refresh() end
    end)
end)

-- Keep wishlist stars in sync when the wishlist changes from anywhere
-- (e.g. the Wishlist panel), not just from a right-click here.
if ETB.EventBus and ETB.EventBus.Register then
    ETB.EventBus:Register("ETB_WISHLIST_CHANGED", function()
        if frame and frame:IsShown() then ZS:Refresh() end
    end)
end

SLASH_ETBZONE1 = "/etbzone"; SLASH_ETBZONE2 = "/zonescan"
SlashCmdList["ETBZONE"] = function(msg)
    msg = (msg or ""):lower():gsub("%s+", "")
    if msg == "auto" then
        ETB_Settings = ETB_Settings or {}
        ETB_Settings.zoneScanAuto = not ETB_Settings.zoneScanAuto
        if ETB.Print then ETB:Print("Zone Scan auto-open: " ..
            (ETB_Settings.zoneScanAuto and (GREEN .. "on|r") or (GREY .. "off|r"))) end
    elseif msg == "class" then
        ETB_Settings = ETB_Settings or {}
        ETB_Settings.zoneScanClassFilter = (ETB_Settings.zoneScanClassFilter == false) and true or false
        if ETB.Print then ETB:Print("Zone Scan class filter: " ..
            (ClassFilterOn() and (GREEN .. "on (your class only)|r") or (GREY .. "off (all)|r"))) end
        if frame and frame:IsShown() then ZS:Refresh() end
    elseif msg == "load" then
        ZS:LoadInstanceLoot()
    elseif msg:sub(1, 4) == "diff" then
        ZS:SetDiffFilter(msg:sub(5))
    else ZS:Toggle() end
end

if ETB.Debug then ETB:Debug("ZoneScan", "loaded (zones + instances + transmog)") end
