local pluginName, pluginShared = ...
local Lockouts = {}
pluginShared.Lockouts = Lockouts

local guarded = pluginShared.guarded
local reported = pluginShared.reported or guarded
local handleError = pluginShared.handleError or function(err) return err end
-- Favorites.lua defines this first in the load order; the fallback keeps this file usable on its own.
local plain = pluginShared.plain or function(value)
    if type(issecretvalue) == "function" and issecretvalue(value) then
        return nil
    end
    return value
end

-- How long an answer the game could not give yet (an appearance's drop data, a source's named item
-- link) is held before the game is asked again.
local RETRY_SECONDS = 20

local BOSS_DROP_SOURCE = Enum and Enum.TransmogSource and Enum.TransmogSource.JournalEncounter or 1
local VENDOR_SOURCE = Enum and Enum.TransmogSource and Enum.TransmogSource.Vendor or 3

-- The panel never takes more than a small share of the frame budget. A draw that cost a while holds
-- the next one off for a multiple of what it cost, so a burst of events (a raid full of loot, a run of
-- lockout updates) cannot turn the panel into the most expensive thing running. A cheap draw waits the
-- old tenth of a second, so nothing feels slower than it did.
local REBUILD_DUTY = 19 -- rest this many times as long as the last draw took: at most a twentieth of the time
local REBUILD_MIN_WAIT, REBUILD_MAX_WAIT = 0.1, 3

function Lockouts:RebuildWait()
    local cost = self.lastRebuildCost
    if not cost then
        return REBUILD_MIN_WAIT
    end
    local wait = cost * REBUILD_DUTY
    if wait < REBUILD_MIN_WAIT then
        return REBUILD_MIN_WAIT
    end
    return wait > REBUILD_MAX_WAIT and REBUILD_MAX_WAIT or wait
end

-- Who asked for the draw, by file and line, so a profile can say what drove the count. Only the
-- asks that actually arm a timer or a follow-up are counted: an ask folded into one already pending
-- costs nothing and would only pad the numbers.
-- The file and line of the caller `level` frames up, past the profiler's own wrapper: while a
-- profile runs, ScheduleRebuild is wrapped, so the frame straight above it is the profiler's and the
-- walk goes up until it reaches a frame that is not.
function Lockouts:Asker(level)
    if type(debugstack) ~= "function" then
        return "?"
    end
    local stack = debugstack(level, 8, 0)
    if type(stack) == "string" then
        for entry in stack:gmatch("[^\n]+") do
            local file, line = entry:match("(%a[%w_]*%.lua)\"?%]?:(%d+)")
            if file and file ~= "Profiler.lua" then
                return file .. ":" .. line
            end
        end
    end
    return "?"
end

local function noteAsk(self)
    local key = self:Asker(4)
    self.rebuildAsks = self.rebuildAsks or {}
    self.rebuildAsks[key] = (self.rebuildAsks[key] or 0) + 1
end

function Lockouts:ScheduleRebuild()
    if self.rebuilding then
        -- asked for while the panel is drawing: one follow-up, however many rows ask during the draw
        if not self.rebuildAgain then
            noteAsk(self)
        end
        self.rebuildAgain = true
        return
    end
    if self.pendingRebuild then
        return
    end
    noteAsk(self)
    self.pendingRebuild = true
    if type(C_Timer) ~= "table" or type(C_Timer.After) ~= "function" then
        self.pendingRebuild = false
        return
    end
    C_Timer.After(self:RebuildWait(), function()
        Lockouts.rebuildAgain = nil
        local drew, err = true, nil
        if Lockouts.panel and Lockouts.panel:IsShown() and not Lockouts.minimized then
            -- the flag is held across the draw, not dropped before it: an ask raised from inside the
            -- draw would otherwise arm a fresh timer every time and the panel would feed itself
            Lockouts.rebuilding = true
            local began = type(debugprofilestop) == "function" and debugprofilestop() or nil
            -- xpcall, not pcall then error(): rethrowing here would hand over a traceback pointing at
            -- this line rather than at whatever actually broke inside the draw
            drew, err = xpcall(Lockouts.Rebuild, handleError, Lockouts)
            if began then
                Lockouts.lastRebuildCost = (debugprofilestop() - began) / 1000
            end
            Lockouts.rebuilding = nil
        end
        Lockouts.pendingRebuild = false
        if Lockouts.rebuildAgain then
            Lockouts.rebuildAgain = nil
            Lockouts:ScheduleRebuild()
        end
        -- the flags are clear either way; the draw reported itself above, with its own traceback
    end)
end

-- The shared guard, plus a note that the panel is showing less than it could so the rebuild can say so
-- in plain words rather than dropping rows silently or raising.
function Lockouts:Plain(value)
    if type(issecretvalue) == "function" and issecretvalue(value) then
        self.combatHidden = true
        self.hiddenCount = (self.hiddenCount or 0) + 1
        return nil
    end
    return value
end

-- Whether the game can name the item yet. A finished load that still has no name is not progress.
local function itemHasName(itemID)
    if not (itemID and C_Item and type(C_Item.GetItemNameByID) == "function") then
        return false
    end
    local ok, name = guarded(C_Item.GetItemNameByID, itemID)
    -- a withheld name is the game holding back, so the panel says so and asks again after the fight
    name = Lockouts:Plain(name)
    return ok and type(name) == "string" and name ~= ""
end

local function linkHasName(link)
    -- a secret link still calls itself a string, and comparing or matching on one raises
    link = plain(link)
    if type(link) ~= "string" or link == "" then
        return false
    end
    local name = link:match("%[(.-)%]")
    return name ~= nil and name ~= ""
end

-- Asks the game for an item's data once. ContinueOnItemLoad runs its callback straight away for an
-- item the client already has, so an item asked for on every rebuild answers inside that rebuild: an
-- unconditional redraw there makes the panel feed itself. Each item is asked once and a load only
-- redraws the panel when it actually brought a name.
function Lockouts:RequestItemLoad(itemID)
    -- guarded here as well as at the callers: a secret value used as a table key raises, and this
    -- function keeps two of them
    itemID = self:Plain(itemID)
    if not itemID then
        return
    end
    self.loadedItems = self.loadedItems or {}
    if self.loadedItems[itemID] then
        return
    end
    if type(Item) == "table" and type(Item.CreateFromItemID) == "function" then
        self.pendingItems = self.pendingItems or {}
        if self.pendingItems[itemID] then
            return
        end
        self.pendingItems[itemID] = true
        local registered = false
        local okCreate, item = pcall(Item.CreateFromItemID, Item, itemID)
        if okCreate and item and item.ContinueOnItemLoad then
            registered = pcall(item.ContinueOnItemLoad, item, function()
                if Lockouts.pendingItems then
                    Lockouts.pendingItems[itemID] = nil
                end
                Lockouts.loadedItems = Lockouts.loadedItems or {}
                Lockouts.loadedItems[itemID] = true
                -- a name the game withheld counts as hidden, so leaving combat opens the item up to be
                -- asked again (ForgetNamelessItems), the same as the journal arriving does
                if itemHasName(itemID) then
                    -- the draw this schedules must be able to use the name: the answers held for this
                    -- item while it had none are dropped first, or the hold outlives the draw and the
                    -- row stays at "retrieving" with nothing left to redraw it
                    Lockouts:ForgetHeldDisplay(itemID)
                    Lockouts:ScheduleRebuild()
                end
            end)
        end
        if not registered then
            self.pendingItems[itemID] = nil
        end
    elseif C_Item and C_Item.RequestLoadItemDataByID then
        self.loadedItems[itemID] = true
        guarded(C_Item.RequestLoadItemDataByID, itemID)
    end
end

-- The collection changed or the encounter journal arrived: an item the game could not name before may
-- be nameable now, so every asked-once item is open to being asked again, and no answer held while it
-- had no name outlives that.
function Lockouts:ForgetNamelessItems()
    self.loadedItems = nil
    self:ForgetHeldDisplay(nil)
end

-- Drops the display answers still on hold (no link yet) for one item, or for every item with nil.
-- Settled answers stay: the game's answer for a named source never changes.
function Lockouts:ForgetHeldDisplay(itemID)
    local cache = self.displayCache
    if not cache then
        return
    end
    for sourceID, held in pairs(cache) do
        if held.retryAt and (itemID == nil or held.itemID == itemID) then
            cache[sourceID] = nil
        end
    end
end

-- Asked for every source of every starred item on every rebuild, and the game's answer for a source
-- never changes once it has one. A source still without a name is held briefly rather than looked up
-- again on every rebuild; the item stays listed either way.
function Lockouts:ResolveItemDisplay(sourceID, fallbackItemID)
    self.displayCache = self.displayCache or {}
    local held = sourceID and self.displayCache[sourceID]
    if held then
        if not held.retryAt then
            return held.link, held.icon, held.itemID
        end
        local seen = type(GetTime) == "function" and GetTime() or nil
        if not seen or seen < held.retryAt then
            return held.link, held.icon, held.itemID
        end
    end
    local itemID = fallbackItemID
    if not itemID then
        local okItem, resolvedID = guarded(C_TransmogCollection.GetSourceItemID, sourceID)
        if okItem then
            itemID = self:Plain(resolvedID)
        end
    end
    local link, icon
    local okInfo, sinfo = guarded(C_TransmogCollection.GetAppearanceSourceInfo, sourceID)
    if okInfo and type(sinfo) == "table" then
        icon = self:Plain(sinfo.icon)
        local named = self:Plain(sinfo.itemLink)
        if linkHasName(named) then
            link = named
        end
    end
    if not link then
        self:RequestItemLoad(itemID)
    end
    if sourceID then
        local asked = type(GetTime) == "function" and GetTime() or nil
        if link or asked then
            self.displayCache[sourceID] = { link = link, icon = icon, itemID = itemID,
                retryAt = (not link) and asked and (asked + RETRY_SECONDS) or nil }
        end
    end
    return link, icon, itemID
end

function Lockouts:MountSourceText(mountID)
    if not (mountID and C_MountJournal and type(C_MountJournal.GetMountInfoExtraByID) == "function") then
        return nil
    end
    local info = { guarded(C_MountJournal.GetMountInfoExtraByID, mountID) }
    if info[1] and type(info[4]) == "string" and info[4] ~= "" then
        return info[4]
    end
    return nil
end

function Lockouts:MountLink(spellID, fallback)
    if spellID and C_MountJournal and type(C_MountJournal.GetMountLink) == "function" then
        local ok, link = guarded(C_MountJournal.GetMountLink, spellID)
        link = self:Plain(link)
        if ok and type(link) == "string" and link ~= "" then
            return link
        end
    end
    return fallback
end

function Lockouts:CollectFavoriteMounts(raids, raidOrder, vendors, otherMounts)
    local mounts = pluginShared.Favorites and pluginShared.Favorites:FavoritedUncollectedMounts() or {}
    local count = 0
    for _, m in ipairs(mounts) do
        count = count + 1
        local link = self:MountLink(m.spellID, m.name)
        local acq = pluginShared.Acquisition and pluginShared.Acquisition:GetMount(m.mountID)
        if acq and acq.kind == "drop" and acq.raidName and acq.bossName then
            local raid = raids[acq.raidName]
            if not raid then
                raid = { bosses = {}, bossOrder = {} }
                raids[acq.raidName] = raid
                raidOrder[#raidOrder + 1] = acq.raidName
            end
            local boss = raid.bosses[acq.bossName]
            if not boss then
                boss = { items = {}, seenVisuals = {}, difficulties = {} }
                raid.bosses[acq.bossName] = boss
                raid.bossOrder[#raid.bossOrder + 1] = acq.bossName
            end
            boss.items[#boss.items + 1] = { link = link, icon = m.icon, difficulties = {}, isMount = true, mountID = m.mountID, acq = acq }
        elseif acq and acq.kind == "vendor" then
            vendors[#vendors + 1] = {
                name = m.name, icon = m.icon, link = link, mountID = m.mountID, isMount = true,
                costText = acq.costText, vendorName = acq.vendorName, mapID = acq.mapID, x = acq.x, y = acq.y, note = acq.note, noteKey = acq.noteKey,
                acq = acq,
            }
        elseif acq and acq.kind == "coord" then
            otherMounts[#otherMounts + 1] = {
                name = m.name, icon = m.icon, link = link, source = self:MountSourceText(m.mountID), mountID = m.mountID,
                mapID = acq.mapID, x = acq.x, y = acq.y, locName = acq.locName, note = acq.note, noteKey = acq.noteKey,
                acq = acq,
            }
        else
            otherMounts[#otherMounts + 1] = { name = m.name, icon = m.icon, link = link, source = self:MountSourceText(m.mountID), mountID = m.mountID, acq = acq }
        end
    end
    return count
end

function Lockouts:SnapshotCharacter()
    if not (GetNumSavedInstances and GetSavedInstanceInfo and GetSavedInstanceEncounterInfo) then
        return
    end
    local charName = UnitName("player")
    local realmName = GetRealmName()
    if not (charName and realmName) then
        return
    end
    local _, classFile = UnitClass("player")
    local now = time()
    local raids = {}
    local function plain(v)
        if type(issecretvalue) == "function" and issecretvalue(v) then
            return nil
        end
        return v
    end
    for index = 1, plain(GetNumSavedInstances()) or 0 do
        local instanceName, _, reset, difficultyID, locked, extended, _, isRaid, _, difficultyName, numEncounters = GetSavedInstanceInfo(index)
        instanceName, reset, difficultyName, difficultyID = plain(instanceName), plain(reset), plain(difficultyName), plain(difficultyID)
        locked, extended, isRaid, numEncounters = plain(locked), plain(extended), plain(isRaid), plain(numEncounters)
        if isRaid and (locked or extended) and instanceName then
            local bosses = {}
            for encounterIndex = 1, numEncounters or 0 do
                local bossName, _, isKilled = GetSavedInstanceEncounterInfo(index, encounterIndex)
                bossName, isKilled = plain(bossName), plain(isKilled)
                if bossName then
                    bosses[bossName] = isKilled and true or false
                end
            end
            raids[#raids + 1] = {
                name = instanceName,
                difficulty = difficultyName,
                difficultyID = difficultyID,
                resetAt = now + (reset or 0),
                bosses = bosses,
            }
        end
    end
    AppearanceMaticDB.lockouts = AppearanceMaticDB.lockouts or {}
    AppearanceMaticDB.lockouts[charName .. "-" .. realmName] = {
        class = classFile,
        updatedAt = now,
        raids = raids,
    }
end

-- The collection changed: what drops where is worked out again. A source's name and icon are not:
-- the game's answer for a named source never changes, and dropping it here made the panel ask for
-- every name again two seconds after login, when the game answered without them (478 rows read
-- "retrieving" until the loads came back). Only the answers still on hold are dropped.
function Lockouts:InvalidateDropCache()
    self.dropCache = nil
    self:ForgetHeldDisplay(nil)
end

function Lockouts:VisualDrops(visualID)
    self.dropCache = self.dropCache or {}
    local cached = self.dropCache[visualID]
    if cached ~= nil then
        if not cached.retryAt then
            return cached
        end
        local held = type(GetTime) == "function" and GetTime() or nil
        if not held or held < cached.retryAt then
            return cached
        end
    end
    local result = { bossSources = {} }
    local hiddenBefore = self.hiddenCount or 0
    local okSources, sourceIDs = reported(C_TransmogCollection.GetAllAppearanceSources, visualID)
    if okSources and type(sourceIDs) == "table" then
        for _, sourceID in ipairs(sourceIDs) do
            local okInfo, info = reported(C_TransmogCollection.GetSourceInfo, sourceID)
            -- Every field is read through Plain. In combat and inside a raid the client hands these back
            -- as secret values, and comparing one, storing it or using it as a table key all raise.
            local collected = okInfo and type(info) == "table" and self:Plain(info.isCollected)
            local sourceType = okInfo and type(info) == "table" and self:Plain(info.sourceType) or nil
            local sourceItemID = okInfo and type(info) == "table" and self:Plain(info.itemID) or nil
            local sourceIcon = okInfo and type(info) == "table" and self:Plain(info.icon) or nil
            if okInfo and type(info) == "table" and not collected then
                if sourceType == BOSS_DROP_SOURCE then
                    local okDrops, drops = reported(C_TransmogCollection.GetAppearanceSourceDrops, sourceID)
                    local dropList = {}
                    if okDrops and type(drops) == "table" then
                        for _, drop in ipairs(drops) do
                            local instance, encounter = self:Plain(drop.instance), self:Plain(drop.encounter)
                            local difficulties
                            if type(drop.difficulties) == "table" then
                                difficulties = {}
                                for _, difficultyName in ipairs(drop.difficulties) do
                                    -- these become table keys and are sorted and joined later on
                                    local safe = self:Plain(difficultyName)
                                    if type(safe) == "string" then
                                        difficulties[#difficulties + 1] = safe
                                    end
                                end
                            end
                            if instance and encounter then
                                dropList[#dropList + 1] = {
                                    instance = instance,
                                    encounter = encounter,
                                    difficulties = difficulties,
                                }
                            end
                        end
                    end
                    if #dropList > 0 then
                        result.bossSources[#result.bossSources + 1] = {
                            sourceID = sourceID, itemID = sourceItemID, icon = sourceIcon, drops = dropList,
                        }
                    else
                        -- Boss drop whose encounter data is not available right now (journal not loaded,
                        -- secret values inside an instance): keep the item on the panel and retry next rebuild.
                        result.incomplete = true
                        if not result.otherSource then
                            result.otherSource = { sourceID = sourceID, itemID = sourceItemID, icon = sourceIcon, sourceType = sourceType }
                        end
                    end
                elseif sourceType == VENDOR_SOURCE and not result.vendorSource then
                    result.vendorSource = { sourceID = sourceID, itemID = sourceItemID, icon = sourceIcon }
                elseif not result.otherSource then
                    -- World drop, quest, achievement, profession, unknown: still uncollected, never drop it from the list
                    result.otherSource = { sourceID = sourceID, itemID = sourceItemID, icon = sourceIcon, sourceType = sourceType }
                end
            end
        end
    end
    if (self.hiddenCount or 0) > hiddenBefore then
        -- the game held something back this time round, so this is not the final answer for the look;
        -- that is the fight, not data still arriving, so the loading cover does not count it
        result.incomplete = true
        result.withheld = true
    end
    if result.incomplete then
        -- Still waiting on the game (journal not loaded, secret values inside an instance). The item
        -- stays listed either way; holding the unanswerable lookup for a moment stops it being re-run
        -- for every starred appearance on every rebuild.
        local asked = type(GetTime) == "function" and GetTime() or nil
        if asked then
            result.retryAt = asked + RETRY_SECONDS
            self.dropCache[visualID] = result
        end
    else
        self.dropCache[visualID] = result
    end
    return result
end

function Lockouts:CollectStarredDrops()
    -- set while any starred appearance's drop data is still loading: the route keeps every raid then;
    -- the counts beside it are for the loading cover, which wants to know how much is still to come
    self.dropsIncomplete = false
    self.dropsPending, self.dropsReady = 0, 0
    local favorites = pluginShared.Favorites and pluginShared.Favorites:All() or {}
    local raids = {}
    local raidOrder = {}
    local vendors = {}
    local others = {}
    local starredCount = 0
    if #favorites == 0 or not C_TransmogCollection then
        return raids, raidOrder, starredCount, vendors, others
    end
    for _, visualID in ipairs(favorites) do
        starredCount = starredCount + 1
        local sources = self:VisualDrops(visualID)
        if sources.incomplete then
            self.dropsIncomplete = true
            if not sources.withheld then
                self.dropsPending = self.dropsPending + 1
            end
        else
            self.dropsReady = self.dropsReady + 1
        end
        for _, src in ipairs(sources.bossSources) do
            for _, drop in ipairs(src.drops) do
                local raid = raids[drop.instance]
                if not raid then
                    raid = { bosses = {}, bossOrder = {} }
                    raids[drop.instance] = raid
                    raidOrder[#raidOrder + 1] = drop.instance
                end
                local boss = raid.bosses[drop.encounter]
                if not boss then
                    boss = { items = {}, seenVisuals = {}, difficulties = {} }
                    raid.bosses[drop.encounter] = boss
                    raid.bossOrder[#raid.bossOrder + 1] = drop.encounter
                end
                if not boss.seenVisuals[visualID] then
                    local link, icon = self:ResolveItemDisplay(src.sourceID, src.itemID)
                    local entry = { link = link, icon = icon or src.icon, difficulties = {}, sourceID = src.sourceID, visualID = visualID }
                    boss.seenVisuals[visualID] = entry
                    boss.items[#boss.items + 1] = entry
                end
                local entry = boss.seenVisuals[visualID]
                for _, difficultyName in ipairs(drop.difficulties or {}) do
                    entry.difficulties[difficultyName] = true
                    boss.difficulties[difficultyName] = true
                end
            end
        end
        local vendorSource = sources.vendorSource
        if vendorSource then
            local link, icon = self:ResolveItemDisplay(vendorSource.sourceID, vendorSource.itemID)
            vendors[#vendors + 1] = {
                visualID = visualID,
                itemID = vendorSource.itemID,
                sourceID = vendorSource.sourceID,
                link = link,
                icon = icon or vendorSource.icon,
            }
        end
        if #sources.bossSources == 0 and not vendorSource and sources.otherSource then
            local other = sources.otherSource
            local link, icon = self:ResolveItemDisplay(other.sourceID, other.itemID)
            others[#others + 1] = {
                visualID = visualID,
                itemID = other.itemID,
                sourceID = other.sourceID,
                link = link,
                icon = icon or other.icon,
                sourceText = other.sourceType and _G["TRANSMOG_SOURCE_" .. tostring(other.sourceType)] or nil,
            }
        end
    end
    table.sort(raidOrder)
    for _, raidName in ipairs(raidOrder) do
        table.sort(raids[raidName].bossOrder)
    end
    return raids, raidOrder, starredCount, vendors, others
end

function Lockouts:EnsureDropData()
    if self.dropDataLoaded then
        return
    end
    self.dropDataLoaded = true
    local loader = C_AddOns and C_AddOns.LoadAddOn or LoadAddOn
    if type(loader) == "function" then
        guarded(loader, "Blizzard_EncounterJournal")
    end
    if type(C_Timer) == "table" and type(C_Timer.After) == "function" then
        C_Timer.After(5, function()
            Lockouts:InvalidateIndexes()
            if Lockouts.panel and Lockouts.panel:IsShown() and not Lockouts.minimized then
                Lockouts:Rebuild()
            end
        end)
    end
end
