-- ERT/Core/Collection.lua
-- Collection-gating: the ETB differentiator. A generic rare tracker tells you a
-- rare is up; ETB knows whether that rare drops a mount or pet YOU still need,
-- and can hide everything you've already finished. It reuses EMT (mounts) and
-- EPT (pets), which already map each collectible to its source npc and expose a
-- live "is it collected" check against the journals.
--
-- It also builds the sorted, optionally-filtered list the UI and alerts consume.

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

-- Status display order: act-now states first, then soonest, then noise.
local STATUS_RANK = {
    alive = 1, window_open = 2, too_early = 3, overdue = 4,
    summoned = 5, phased = 6, stale = 7, unknown = 8,
}
ERT.STATUS_RANK = STATUS_RANK

-- npcID -> { mounts = {spellID,...}, pets = {speciesID,...} }, built once from
-- EMT/EPT source data. A rare is the source of a collectible when its npcID
-- appears in the collectible's `n` field.
local index
local function BuildIndex()
    if index then return index end
    index = {}
    local function add(npcID, kind, id)
        if not npcID or not id then return end
        local e = index[npcID]
        if not e then e = { mounts = {}, pets = {} }; index[npcID] = e end
        local list = e[kind]
        for i = 1, #list do if list[i] == id then return end end  -- dedupe
        list[#list + 1] = id
    end
    if NS.EMT and NS.EMT.MountData then
        for spellID, m in pairs(NS.EMT.MountData) do
            if m.n then add(m.n, "mounts", spellID) end
        end
    end
    if NS.EPT and NS.EPT.PetData then
        for _, p in pairs(NS.EPT.PetData) do
            if p.n and p.sp then add(p.n, "pets", p.sp) end
        end
    end
    return index
end
ERT.BuildCollectionIndex = BuildIndex   -- exposed for tests / rebuilds

-- Force a rebuild (e.g. after EMT/EPT data loads lazily).
function ERT:RefreshCollectionIndex()
    index = nil
    return BuildIndex()
end

-- Everything a rare can drop that we know how to track.
function ERT:GetRareCollectibles(npcID)
    local e = BuildIndex()[npcID]
    if not e then return nil end
    return e.mounts, e.pets
end

-- Live collection checks against the game journals (mounts/pets/toys). Each
-- takes the collectible's ITEM id (the unified roster convention) and returns
-- true/false, or nil when it can't tell (caller errs toward "needed" so we never
-- hide something you might still want).
local function MountCollected(itemID)
    if not (C_MountJournal and C_MountJournal.GetMountInfoByID) then return nil end
    local ok, isCollected = pcall(function()
        local mid = C_MountJournal.GetMountFromItem and C_MountJournal.GetMountFromItem(itemID)
        if not mid then return nil end
        return select(11, C_MountJournal.GetMountInfoByID(mid))
    end)
    if not ok then return nil end
    return isCollected and true or false
end
local function PetCollected(itemID)
    local C = C_PetJournal
    if C and C.GetPetInfoByItemID and C.GetNumCollectedInfo then
        local ok, species = pcall(function() return select(13, C.GetPetInfoByItemID(itemID)) end)
        if ok and type(species) == "number" then
            local ok2, n = pcall(C.GetNumCollectedInfo, species)
            if ok2 then return (n or 0) > 0 end
        end
    end
    return nil
end
local function ToyCollected(itemID)
    if PlayerHasToy then
        local ok, has = pcall(PlayerHasToy, itemID)
        if ok then return has and true or false end
    end
    return nil
end
-- Caches: collection state changes rarely, but browse mode asks "do I need this?"
-- for all ~814 rares on every refresh. Without caching that's thousands of
-- journal/appearance lookups per second. We memoize definite results and only
-- recompute on real collection changes (and, throttled, as item data streams in).
local needsCache = {}
local itemTmogCache = {}   -- itemID -> 1 (need) / 0 (have/not-tmog); nil never cached (retry)
local function clearCaches() needsCache = {}; itemTmogCache = {} end
ERT.InvalidateCollectionCache = clearCaches

-- MoP transmog rule: only uncommon (green, quality >= 2) and above can be used
-- as a transmog source — even if a shared appearance exists on a poor/common
-- item. Returns true (eligible), false (poor/common), nil (not cached → retry).
local function TmogQualityOK(itemID)
    if not GetItemInfo then return true end
    local _, _, quality = GetItemInfo(itemID)
    if quality == nil then return nil end
    return quality >= 2
end

-- Is this item a transmog appearance the player hasn't collected? true (need),
-- false (not an appearance / already own), or nil (item not cached yet → retry).
-- Mirrors ETB's GetItemInfo->GetAppearanceSources path. Definite answers memoize.
local function TransmogNeeded(itemID)
    local c = itemTmogCache[itemID]
    if c ~= nil then return c == 1 end
    local C = C_TransmogCollection
    if not (C and C.GetItemInfo) then return nil end
    local ok, visualID = pcall(C.GetItemInfo, itemID)
    if not ok or not visualID then itemTmogCache[itemID] = 0; return false end
    -- Gate on quality: poor/common items aren't transmoggable in MoP.
    local q = TmogQualityOK(itemID)
    if q == nil then return nil end                  -- quality not cached → retry
    if not q then itemTmogCache[itemID] = 0; return false end
    if C.GetAppearanceSources then
        local ok2, sources = pcall(C.GetAppearanceSources, visualID)
        if ok2 and type(sources) == "table" then
            for _, src in ipairs(sources) do
                if src.isCollected then itemTmogCache[itemID] = 0; return false end
            end
            itemTmogCache[itemID] = 1; return true
        end
    end
    return nil   -- unknown: don't cache, retry once data is available
end

-- Everything a rare drops that we know how to surface (from the SD roster):
-- mounts (journal IDs), pets (species), toys (itemIDs), other items (itemIDs).
function ERT:GetRareDrops(npcID)
    local r = ERT.RareRoster and ERT.RareRoster[npcID]
    if not r then return nil end
    return r.mt, r.pt, r.ty, r.it
end

-- Collection-gating from the roster's tagged loot. Used for any rare SD knows a
-- mount/pet/toy for; the EMT/EPT-by-npc path below handles the rest.
local function RosterNeeds(r)
    local needM, needP, needT, unknown = {}, {}, {}, false
    local function scan(list, check, bucket)
        for _, id in ipairs(list or {}) do
            local c = check(id)
            if c == nil then unknown = true; bucket[#bucket + 1] = id
            elseif not c then bucket[#bucket + 1] = id end
        end
    end
    scan(r.mt, MountCollected, needM)
    scan(r.pt, PetCollected, needP)
    scan(r.ty, ToyCollected, needT)
    -- Transmog: only act on a definite "needed" (true); ignore not-an-appearance.
    -- Track `pending` so NeedsRare won't cache a result that may change once item
    -- data finishes loading.
    local needX, pending = {}, false
    for _, id in ipairs(r.it or {}) do
        local res = TransmogNeeded(id)
        if res == true then needX[#needX + 1] = id
        elseif res == nil then pending = true end
    end
    return {
        any = (#needM > 0 or #needP > 0 or #needT > 0 or #needX > 0),
        mounts = needM, pets = needP, toys = needT, transmog = needX, unknown = unknown,
        nMounts = #needM, nPets = #needP, nToys = #needT, nTransmog = #needX,
        pending = pending,
    }
end

-- Does this rare drop anything still uncollected? Returns a table:
--   { any=bool, mounts={...}, pets={...}, toys={...}, unknown=bool, nMounts, nPets, nToys }
-- Roster rares (with SD-tagged collectibles) use the journals directly; others
-- fall back to the EMT/EPT npc index. unknown=true means we couldn't verify, in
-- which case we err toward SHOWING rather than hiding something you may need.
function ERT:NeedsRare(npcID)
    local cached = needsCache[npcID]
    if cached then return cached end

    local r = ERT.RareRoster and ERT.RareRoster[npcID]
    if r and (r.mt or r.pt or r.ty or r.it) then
        local result = RosterNeeds(r)
        if not result.pending then needsCache[npcID] = result end  -- cache only stable results
        return result
    end

    local e = BuildIndex()[npcID]
    if not e then return { any = false } end

    local mAPI = NS.EMT and NS.EMT.MountAPI
    local pAPI = NS.EPT and NS.EPT.PetAPI
    local needM, needP, unknown = {}, {}, false

    for _, spellID in ipairs(e.mounts) do
        if mAPI and mAPI.IsCollected then
            local ok, collected = pcall(mAPI.IsCollected, mAPI, spellID)
            if not ok then unknown = true; needM[#needM + 1] = spellID
            elseif not collected then needM[#needM + 1] = spellID end
        else
            unknown = true; needM[#needM + 1] = spellID
        end
    end
    for _, sp in ipairs(e.pets) do
        if pAPI and pAPI.IsCollected then
            local ok, collected = pcall(pAPI.IsCollected, pAPI, sp)
            if not ok then unknown = true; needP[#needP + 1] = sp
            elseif not collected then needP[#needP + 1] = sp end
        else
            unknown = true; needP[#needP + 1] = sp
        end
    end

    local result = {
        any = (#needM > 0 or #needP > 0),
        mounts = needM, pets = needP, unknown = unknown,
        nMounts = #needM, nPets = #needP, nToys = 0, nTransmog = 0,
    }
    needsCache[npcID] = result
    return result
end

-- Invalidate the needs caches when the player's collections change, so newly
-- collected things drop off the "needed" list and transmog resolves as item
-- data streams in (GET_ITEM_INFO_RECEIVED is throttled to avoid thrash).
do
    local ev = CreateFrame("Frame")
    for _, e in ipairs({
        "NEW_MOUNT_ADDED", "NEW_PET_ADDED", "COMPANION_LEARNED",
        "NEW_TOY_ADDED", "TOYS_UPDATED", "TRANSMOG_COLLECTION_UPDATED",
        "PLAYER_ENTERING_WORLD",
    }) do pcall(function() ev:RegisterEvent(e) end) end
    pcall(function() ev:RegisterEvent("GET_ITEM_INFO_RECEIVED") end)

    -- Item-info-driven cache clears are throttled to once every few seconds, but
    -- the throttle ticker runs ONLY while a clear is pending. GET_ITEM_INFO_RECEIVED
    -- shows the (otherwise hidden) throttle frame; once the clear fires the frame
    -- hides itself again. A hidden frame receives no OnUpdate, so no per-frame work
    -- happens while item info isn't actively streaming in.
    local throttle = CreateFrame("Frame")
    throttle:Hide()
    local acc = 0
    throttle:SetScript("OnUpdate", function(self, elapsed)
        acc = acc + (elapsed or 0)
        if acc < 4 then return end
        acc = 0
        self:Hide()
        clearCaches()
    end)

    ev:SetScript("OnEvent", function(_, event)
        if event == "GET_ITEM_INFO_RECEIVED" then
            if not throttle:IsShown() then acc = 0; throttle:Show() end
        else
            clearCaches()
        end
    end)
end

-- The list the UI and alerts run on. Each item:
--   { npcID, name, st (full status table), needs (NeedsRare result) }
-- Sorted by status priority, then soonest (opensIn) / longest waiting (since),
-- then name. `neededOnly` drops rares with nothing uncollected left.
function ERT:GetTrackedList(neededOnly)
    local out = {}
    for _, npcID in ipairs(self:GetTrackedNPCs()) do
        local needs = self:NeedsRare(npcID)
        if (not neededOnly) or needs.any then
            local st = self:GetStatus(npcID)
            out[#out + 1] = { npcID = npcID, name = self:GetName(npcID), st = st, needs = needs }
        end
    end
    table.sort(out, function(a, b)
        local ra = STATUS_RANK[a.st.state] or 9
        local rb = STATUS_RANK[b.st.state] or 9
        if ra ~= rb then return ra < rb end
        local ka = a.st.opensIn or a.st.since or 0
        local kb = b.st.opensIn or b.st.since or 0
        if ka ~= kb then return ka < kb end
        return (a.name or "") < (b.name or "")
    end)
    return out
end

-- Browse mode: every rare in the SD roster (optionally only those still dropping
-- something uncollected), merged with live tracking status where we have it.
-- This turns the window into "every MoP rare with a collectible I still need,
-- where it is, what it drops" — even ones I've never personally seen.
function ERT:GetRosterList(neededOnly)
    local roster = ERT.RareRoster
    if not roster then return {} end
    local out = {}
    for npcID, r in pairs(roster) do
        local needs = self:NeedsRare(npcID)
        if (not neededOnly) or needs.any then
            out[#out + 1] = {
                npcID = npcID, name = self:GetName(npcID),
                st = self:GetStatus(npcID), needs = needs, roster = true,
            }
        end
    end
    table.sort(out, function(a, b)
        local ra = STATUS_RANK[a.st.state] or 9
        local rb = STATUS_RANK[b.st.state] or 9
        if ra ~= rb then return ra < rb end
        return (a.name or "") < (b.name or "")
    end)
    return out
end

-- ===========================================================================
-- Curation: a personal want / ignore overlay so the window can show "the rares
-- I actually care about" instead of all 814. Account-wide (collection is
-- account-wide), stored at the top level of ETB_RareDB. Want and ignore are
-- mutually exclusive — marking one clears the other.
-- ===========================================================================
local function curateDB()
    if type(ETB_RareDB) ~= "table" then ETB_RareDB = {} end
    ETB_RareDB.want   = ETB_RareDB.want   or {}
    ETB_RareDB.ignore = ETB_RareDB.ignore or {}
    return ETB_RareDB
end
function ERT:IsWanted(npcID)  return curateDB().want[npcID]   and true or false end
function ERT:IsIgnored(npcID) return curateDB().ignore[npcID] and true or false end
function ERT:SetWanted(npcID, v)
    local db = curateDB()
    db.want[npcID] = v and true or nil
    if v then db.ignore[npcID] = nil end
end
function ERT:SetIgnored(npcID, v)
    local db = curateDB()
    db.ignore[npcID] = v and true or nil
    if v then db.want[npcID] = nil end
end
function ERT:ToggleWanted(npcID)  self:SetWanted(npcID,  not self:IsWanted(npcID))  end
function ERT:ToggleIgnored(npcID) self:SetIgnored(npcID, not self:IsIgnored(npcID)) end
function ERT:CountWanted()
    local n = 0; for _ in pairs(curateDB().want) do n = n + 1 end; return n
end

-- Per-drop collection state for the detail pane. true = collected, false =
-- still needed, nil = can't tell yet (API/item not ready).
function ERT:IsDropCollected(kind, id)
    if kind == "mount" then return MountCollected(id)
    elseif kind == "pet" then return PetCollected(id)
    elseif kind == "toy" then return ToyCollected(id)
    elseif kind == "transmog" then
        local need = TransmogNeeded(id)
        if need == nil then return nil end
        return not need
    end
    return nil
end

-- True nature of an 'it'-bucket itemID, decided at runtime (the static bucket
-- mixes real transmog with quest items, mats and plain world drops). Returns:
--   "transmog" — has a collectable appearance
--   "quest"    — a quest item (may start a collectible quest)
--   "item"     — anything else (world/junk drop, no appearance)
--   nil        — item not cached yet; caller should retry on GET_ITEM_INFO_RECEIVED
local itemKindCache = {}
local function ClassifyItem(itemID)
    local c = itemKindCache[itemID]
    if c ~= nil then return c end
    -- Authoritative transmog test: does it grant an appearance the game lets
    -- you actually transmog? Appearance present AND uncommon+ quality (MoP rule).
    if C_TransmogCollection and C_TransmogCollection.GetItemInfo then
        local ok, appID = pcall(C_TransmogCollection.GetItemInfo, itemID)
        if ok and type(appID) == "number" and appID > 0 then
            local q = TmogQualityOK(itemID)
            if q == nil then return nil end           -- quality not cached → retry
            if q then
                itemKindCache[itemID] = "transmog"; return "transmog"
            else
                -- Has an appearance but poor/common: not a valid tmog source.
                itemKindCache[itemID] = "item"; return "item"
            end
        end
    end
    -- classID 12 == Quest (available instantly, no cache needed)
    if GetItemInfoInstant then
        local _, _, _, _, _, classID = GetItemInfoInstant(itemID)
        if classID == 12 then itemKindCache[itemID] = "quest"; return "quest" end
        if classID == nil then return nil end  -- truly unknown id; retry later
    end
    -- Need the full record to be sure it's not an as-yet-uncached appearance.
    local name, _, _, _, _, itemType = GetItemInfo and GetItemInfo(itemID)
    if not name then return nil end            -- not cached yet → retry
    if itemType == "Quest" then itemKindCache[itemID] = "quest"; return "quest" end
    itemKindCache[itemID] = "item"; return "item"
end
ERT._invalidateItemKind = function() itemKindCache = {} end

-- Structured drop list: every collectible a rare drops, tagged by its true kind,
-- in display order (collectibles first, then quest, then plain items). The 'it'
-- bucket is reclassified per-item so quest/world drops are never shown as
-- transmog. Each entry: { itemID, kind } where kind ∈ mount|pet|toy|transmog|quest|item.
function ERT:GetRareDropList(npcID)
    local mt, pt, ty, it = self:GetRareDrops(npcID)
    local out = {}
    local function add(ids, kind)
        if ids then for _, id in ipairs(ids) do out[#out + 1] = { itemID = id, kind = kind } end end
    end
    add(mt, "mount"); add(pt, "pet"); add(ty, "toy")
    if it then
        for _, id in ipairs(it) do
            -- The roster's `it` field is the rare's FULL loot table — food,
            -- potions, reagents, vendor trash and all. Surface ONLY items that
            -- grant a transmog appearance (ClassifyItem's authoritative test via
            -- C_TransmogCollection). Junk classifies as "item" and is dropped;
            -- quest items are dropped too. Uncached items (k == nil) are skipped
            -- now and reappear once GET_ITEM_INFO_RECEIVED re-renders with their
            -- true kind known — so nothing collectible is lost, only delayed.
            local k = ClassifyItem(id)
            if k == "transmog" then
                out[#out + 1] = { itemID = id, kind = k }
            end
        end
    end
    return out
end

-- Does a rare drop anything a collector would actually want (a real collectible
-- or a quest item), as opposed to only world/junk drops? Returns true/false.
function ERT:HasNotableDrop(npcID)
    local mt, pt, ty, it = self:GetRareDrops(npcID)
    if (mt and #mt > 0) or (pt and #pt > 0) or (ty and #ty > 0) then return true end
    if it then
        for _, id in ipairs(it) do
            local k = ClassifyItem(id)
            if k == "transmog" then return true end
            if k == nil then return true end  -- unknown yet; assume notable, refine later
        end
    end
    return false
end

-- ===========================================================================
-- Unified, filtered list builder — the single source the new UI draws from.
-- opts = {
--   scope     = "all" | "tracked" | "wanted",   -- which population to consider
--   neededOnly,                                  -- only rares with an uncollected drop
--   hideIgnored,                                 -- drop ignored rares
--   types     = { mount=, pet=, toy=, transmog= },-- if any set, require that kind
--   status    = "any" | "up" | "timed",          -- live-status gate
--   expansion = "All" | "Classic" | ...,         -- expansion gate
--   search    = "text",                          -- match name or zone
-- }
-- ===========================================================================
local STATUS_GROUP = {
    up    = { alive = true, window_open = true },
    timed = { window_open = true, too_early = true, overdue = true },
}
function ERT:GetFilteredList(opts)
    opts = opts or {}
    local types = opts.types
    local anyType = types and (types.mount or types.pet or types.toy or types.transmog)
    local out = {}

    local function consider(npcID, isRoster)
        if opts.hideIgnored and self:IsIgnored(npcID) then return end
        if opts.scope == "wanted" and not self:IsWanted(npcID) then return end

        local needs = self:NeedsRare(npcID)
        if opts.neededOnly and not needs.any then return end

        if anyType then
            local match
            if opts.neededOnly then
                match = (types.mount and (needs.nMounts or 0) > 0)
                     or (types.pet and (needs.nPets or 0) > 0)
                     or (types.toy and (needs.nToys or 0) > 0)
                     or (types.transmog and (needs.nTransmog or 0) > 0)
            else
                local mt, pt, ty, it = self:GetRareDrops(npcID)
                match = (types.mount and mt and #mt > 0)
                     or (types.pet and pt and #pt > 0)
                     or (types.toy and ty and #ty > 0)
                     or (types.transmog and it and #it > 0)
            end
            if not match then return end
        end

        local st = self:GetStatus(npcID)
        if opts.status and opts.status ~= "any" then
            local grp = STATUS_GROUP[opts.status]
            if not (grp and grp[st.state]) then return end
        end

        if opts.expansion and opts.expansion ~= "All" then
            if self:GetExpansion(npcID) ~= opts.expansion then return end
        end

        if type(opts.search) == "string" and opts.search ~= "" then
            local q = opts.search:lower()
            local name = (self:GetName(npcID) or ""):lower()
            local zone = (self:GetZoneName(npcID) or ""):lower()
            if not (name:find(q, 1, true) or zone:find(q, 1, true)) then return end
        end

        out[#out + 1] = {
            npcID = npcID, name = self:GetName(npcID), st = st, needs = needs,
            roster = isRoster, wanted = self:IsWanted(npcID), ignored = self:IsIgnored(npcID),
            zone = self:GetZoneName(npcID), expansion = self:GetExpansion(npcID),
        }
    end

    if opts.scope == "tracked" then
        for _, npcID in ipairs(self:GetTrackedNPCs()) do consider(npcID, false) end
    else
        local roster = ERT.RareRoster or {}
        for npcID in pairs(roster) do consider(npcID, true) end
    end

    table.sort(out, function(a, b)
        -- wanted rares float to the top, then by live-status urgency, then name
        if a.wanted ~= b.wanted then return a.wanted end
        local ra = STATUS_RANK[a.st.state] or 9
        local rb = STATUS_RANK[b.st.state] or 9
        if ra ~= rb then return ra < rb end
        return (a.name or "") < (b.name or "")
    end)
    return out
end
