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

local guarded = pluginShared.guarded
local reported = pluginShared.reported or guarded

local L = LibStub("AceLocale-3.0"):GetLocale("AppearanceMatic")

local KNOWN_CURRENCY = {
    [3252] = "Bronze",
}

local function copyCostRows(cost)
    if type(cost) == "number" and cost > 0 then
        -- a bare number is a gold price in copper
        return { { kind = "g", amount = cost } }
    end
    if type(cost) ~= "table" then
        return nil
    end
    local rows = {}
    for _, entry in ipairs(cost) do
        if type(entry) == "table" then
            rows[#rows + 1] = { kind = entry[1], id = entry[2], amount = entry[3] }
        end
    end
    if #rows == 0 then
        return nil
    end
    return rows
end

function Acquisition:EnsureDB()
    AppearanceMaticDB = AppearanceMaticDB or {}
    local db = AppearanceMaticDB.acquisition
    if type(db) ~= "table" or type(db.version) ~= "number" or db.version < 4 then
        db = { version = 5, mounts = {}, vendors = {} }
        AppearanceMaticDB.acquisition = db
    elseif db.version < 5 then
        -- v4 rows are valid (kind, cost, raid, boss); they only lack coordinates. Keep them: the shipped
        -- table supplies coordinates where it knows the row, and a cached row is never dropped.
        db.version = 5
    end
    db.mounts = db.mounts or {}
    db.vendors = db.vendors or {}
    return db
end

-- Shipped facts (Data/Acquisition.lua). Always consulted first; the SavedVariables cache only adds rows
-- the shipped table lacks, or upgrades a shipped row that has no coordinates.
function Acquisition:Static(kind, id)
    local data = pluginShared.AcquisitionData
    local t = data and data[kind]
    local rec = t and t[id]
    if type(rec) ~= "table" then rec = nil end
    if kind == "mounts" and data and type(data.mountsBySpell) == "table" then
        -- offline dumps only know the mount's spell; the client maps mountID to spellID.
        -- The spell row wins when it knows more (a raid, a vendor, a price) than a bare coordinate.
        local spellID = self:MountSpellID(id)
        local bySpell = spellID and data.mountsBySpell[spellID]
        if type(bySpell) == "table" then
            -- the offline row is the maintained one; a harvested raid-drop row only survives when the
            -- offline row is not itself a raid drop
            if not rec or bySpell.kind == "drop" or rec.kind ~= "drop" then
                return bySpell
            end
        end
    end
    if rec then
        return rec
    end
    return nil
end

function Acquisition:Best(kind, id, cached)
    local static = self:Static(kind, id)
    if static and (static.mapID or not (type(cached) == "table" and not cached.none and cached.mapID)) then
        return static
    end
    if type(cached) == "table" and not cached.none then
        return cached
    end
    return static
end


-- Offline only: tools/dump-source.lua sets this to look groups up by field ("itemID", "mountID") while it
-- builds Data/Acquisition.lua. In game it stays nil; the client reads shipped rows and its own cache.
Acquisition.SearchGroups = nil

local COST_ICON = 13 -- currency and item cost icons on a widget row

local function iconMarkup(fileID)
    if type(fileID) == "number" and fileID > 0 then
        return "|T" .. fileID .. ":" .. COST_ICON .. ":" .. COST_ICON .. ":0:0:64:64:4:60:4:60|t "
    end
    return ""
end

-- "<icon> Timewarped Badge": the currency's own icon in front of its name
-- The second return says whether the game gave a real name; a placeholder is never worth keeping.
function Acquisition:CurrencyName(currencyID)
    if C_CurrencyInfo then
        for _, fn in ipairs({ "GetCurrencyInfo", "GetBasicCurrencyInfo" }) do
            if type(C_CurrencyInfo[fn]) == "function" then
                local ok, info = guarded(C_CurrencyInfo[fn], currencyID)
                if ok and type(info) == "table" and type(info.name) == "string" and info.name ~= "" then
                    return iconMarkup(info.iconFileID or info.icon) .. info.name, true
                end
            end
        end
    end
    local known = KNOWN_CURRENCY[currencyID]
    if known then
        return known, true
    end
    return string.format(L["acq_currency_fallback"], currencyID), false
end

-- The second return says whether the game gave a real name; item names stream in after a request, so
-- the first ask often lands on the placeholder.
function Acquisition:ItemName(itemID)
    local icon = ""
    if C_Item and type(C_Item.GetItemIconByID) == "function" then
        local ok, fileID = guarded(C_Item.GetItemIconByID, itemID)
        if ok then icon = iconMarkup(fileID) end
    end
    if C_Item and type(C_Item.GetItemInfo) == "function" then
        local ok, name = guarded(C_Item.GetItemInfo, itemID)
        if ok and type(name) == "string" and name ~= "" then
            return icon .. name, true
        end
    end
    return icon .. string.format(L["acq_item_fallback"], itemID), false
end

-- The second return is true only when every name in the line came from the game, so a caller knows
-- whether the line is worth keeping.
function Acquisition:FormatCost(rows)
    if type(rows) ~= "table" then
        return nil
    end
    local parts, complete = {}, true
    for _, row in ipairs(rows) do
        if type(row) == "table" then
            -- shipped rows are positional {kind, id, amount}; harvested rows are keyed
            if row.kind == nil and type(row[1]) == "string" then
                row = { kind = row[1], id = row[2], amount = row[3] }
            end
            local kind = row.kind
            if kind == "g" and (row.amount or row.id) then
                local copper = row.amount or row.id
                parts[#parts + 1] = type(GetCoinTextureString) == "function"
                    and GetCoinTextureString(copper) or tostring(copper)
            elseif kind == "c" and row.id and row.amount then
                local name, named = self:CurrencyName(row.id)
                parts[#parts + 1] = row.amount .. " " .. name
                complete = complete and named
            elseif kind == "i" and row.id and row.amount then
                local name, named = self:ItemName(row.id)
                parts[#parts + 1] = row.amount .. " " .. name
                complete = complete and named
            end
        end
    end
    if #parts == 0 then
        return nil
    end
    return table.concat(parts, ", "), complete
end

function Acquisition:FindCoord(group)
    local node = group
    local depth = 0
    while type(node) == "table" and depth < 8 do
        if type(node.coord) == "table" then
            -- {x, y, mapID}, or a list of such rows
            local row = type(node.coord[1]) == "table" and node.coord[1] or node.coord
            if type(row[1]) == "number" and row[3] then
                return node, row
            end
        end
        if type(node.coords) == "table" then
            -- Legacy shape: { {x, y, mapID}, ... } (a mapID-keyed table can also carry key 1, Durotar)
            local first = node.coords[1]
            if type(first) == "table" and type(first[1]) == "number" then
                return node, first
            end
            -- Runtime shape: { [mapID] = { {x, y}, ... } }
            for mapID, list in pairs(node.coords) do
                if type(mapID) == "number" and type(list) == "table" and type(list[1]) == "table" then
                    local pair = list[1]
                    if pair[1] and pair[2] then
                        return node, { pair[1], pair[2], mapID }
                    end
                end
            end
        end
        node = node.parent
        depth = depth + 1
    end
    return nil, nil
end

function Acquisition:NormalizeCoord(coord)
    if type(coord) ~= "table" then
        return nil, nil, nil
    end
    local x, y, mapID = coord[1], coord[2], coord[3]
    if x and x > 1 then
        x = x / 100
    end
    if y and y > 1 then
        y = y / 100
    end
    return mapID, x, y
end

function Acquisition:VendorNameFromGroup(group)
    if type(group) ~= "table" then
        return nil
    end
    local node = group.parent
    local depth = 0
    while type(node) == "table" and depth < 8 do
        local name
        if type(node.name) == "string" and node.name ~= "" then
            name = node.name
        elseif type(node.text) == "string" and node.text ~= "" then
            name = node.text
        end
        -- only a creature can be a vendor; class filters and headers also carry names
        if name and (node.npcID or node.creatureID) then
            return name
        end
        node = node.parent
        depth = depth + 1
    end
    return nil
end

function Acquisition:FindCostNode(group)
    local node = group
    local depth = 0
    while type(node) == "table" and depth < 8 do
        if type(node.cost) == "table" or (type(node.cost) == "number" and node.cost > 0) then
            return node
        end
        node = node.parent
        depth = depth + 1
    end
    return nil
end

function Acquisition:FindNpcID(group)
    local node = group
    local depth = 0
    while type(node) == "table" and depth < 8 do
        local id = node.npcID or node.creatureID
        if type(id) == "number" and id > 0 then
            return id
        end
        node = node.parent
        depth = depth + 1
    end
    return nil
end

-- Creature name from the client's own cache, so shipped rows need no source database at runtime.
-- Returns nil until the client has the creature cached; callers retry on the next rebuild.
-- How long an NPC the game could not name is left alone before the tooltip lookup is tried again.
local NPC_RETRY_SECONDS = 30

function Acquisition:NpcName(npcID)
    if type(npcID) ~= "number" then
        return nil
    end
    local db = self:EnsureDB()
    db.npcNames = db.npcNames or {}
    local cached = db.npcNames[npcID]
    if type(cached) == "string" then
        return cached
    end
    if not (C_TooltipInfo and type(C_TooltipInfo.GetHyperlink) == "function") then
        return nil
    end
    -- An NPC the game cannot name yet is asked for again later, not on every row of every rebuild:
    -- the lookup builds a whole tooltip and there is no cheaper way to ask.
    self.npcMisses = self.npcMisses or {}
    local clock = type(GetTime) == "function" and GetTime() or nil
    local missedAt = self.npcMisses[npcID]
    if clock and missedAt and (clock - missedAt) < NPC_RETRY_SECONDS then
        return nil
    end
    local ok, data = guarded(C_TooltipInfo.GetHyperlink, "unit:Creature-0-0-0-0-" .. npcID .. "-0000000000")
    local line = ok and type(data) == "table" and type(data.lines) == "table" and data.lines[1]
    local name = type(line) == "table" and line.leftText
    -- the name is compared below, so a secret one (the client withholds it in combat and in some
    -- instances) is treated as a miss and asked for again later
    if type(issecretvalue) == "function" and issecretvalue(name) then
        name = nil
    end
    if type(name) == "string" and name ~= "" and name ~= (UNKNOWN or "Unknown") then
        db.npcNames[npcID] = name
        self.npcMisses[npcID] = nil
        return name
    end
    if clock then
        self.npcMisses[npcID] = clock
    end
    self.lastNpcMiss = string.format("npc %d: ok=%s data=%s lines=%s first=%s", npcID, tostring(ok),
        type(data) == "table" and "table" or tostring(data), tostring(type(data) == "table" and type(data.lines) == "table" and #data.lines),
        tostring(name))
    return nil
end

local function nodeName(node)
    if type(node) ~= "table" then return nil end
    if type(node.name) == "string" and node.name ~= "" then return node.name end
    if type(node.text) == "string" and node.text ~= "" then return node.text end
    return nil
end

-- One candidate row from one source group: cost from the nearest priced ancestor (ensemble pieces),
-- coordinates from the nearest located ancestor (the vendor NPC, or the rare/quest that drops it).
-- Event-only listings (Timewalking vendors, holiday vendors) carry an event id on the chain.
function Acquisition:EventNote(group)
    local node, depth = group, 0
    local eventID, timewalking
    while type(node) == "table" and depth < 10 do
        if node.e then eventID = node.e end
        if node.difficultyID == 24 then timewalking = true end
        node = node.parent
        depth = depth + 1
    end
    if timewalking then
        return "acq_note_timewalking"
    end
    if eventID then
        return "acq_note_limited_event"
    end
    return nil
end

-- The note a row shows: its own text, else the locale string behind its note key. Rows are built
-- offline where no locale is loaded, so the key travels in the data and resolves here.
function Acquisition:NoteText(rec)
    if type(rec) ~= "table" then
        return nil
    end
    if type(rec.note) == "string" and rec.note ~= "" then
        return rec.note
    end
    if type(rec.noteKey) == "string" and rec.noteKey ~= "" then
        return L[rec.noteKey]
    end
    return nil
end

-- Nearest zone above a group when it has no exact coordinates.
function Acquisition:FindZone(group)
    local node, depth = group, 0
    while type(node) == "table" and depth < 12 do
        if type(node.mapID) == "number" and not node.instanceID then
            return node.mapID
        end
        if type(node.maps) == "table" and type(node.maps[1]) == "number" then
            return node.maps[1]
        end
        node = node.parent
        depth = depth + 1
    end
    return nil
end

-- These fields apply down the parent chain: a header marked removed or event-only, a vendor behind
-- a quest chain or a reputation, a faction- or race-only source all apply to what sits under them.
local function relativeValue(node, field)
    local depth = 0
    while type(node) == "table" and depth < 12 do
        local value = node[field]
        if value ~= nil then
            return value
        end
        node = node.sourceParent or node.parent
        depth = depth + 1
    end
    return nil
end

-- What stands between the player and this source, as the source data records it: u (availability code), e
-- (event), rwp (removed with patch), sourceQuests, minReputation { factionID, standing value },
-- r (1 Horde, 2 Alliance) or races (race IDs). nil when nothing is recorded.
function Acquisition:GateFromGroup(group)
    if type(group) ~= "table" then
        return nil
    end
    local gate = {}
    local u = relativeValue(group, "u")
    if type(u) == "number" then gate.u = u end
    local e = relativeValue(group, "e")
    if type(e) == "number" then gate.e = e end
    local rwp = relativeValue(group, "rwp")
    if type(rwp) == "number" then gate.rwp = rwp end
    local quests = relativeValue(group, "sourceQuests")
    if type(quests) == "table" then
        local ids = {}
        for _, id in ipairs(quests) do
            if type(id) == "number" then ids[#ids + 1] = id end
        end
        if #ids > 0 then gate.quests = ids end
    end
    local rep = relativeValue(group, "minReputation")
    if type(rep) == "table" and type(rep[1]) == "number" and type(rep[2]) == "number" then
        gate.rep = { rep[1], rep[2] }
    end
    local r = relativeValue(group, "r")
    if r == 1 then
        gate.faction = "Horde"
    elseif r == 2 then
        gate.faction = "Alliance"
    else
        local races = relativeValue(group, "races")
        if type(races) == "table" then
            local ids = {}
            for _, id in ipairs(races) do
                if type(id) == "number" then ids[#ids + 1] = id end
            end
            if #ids > 0 then gate.races = ids end
        end
    end
    if next(gate) == nil then
        return nil
    end
    return gate
end

-- How the item is obtained when it is not sold: a creature drop, a quest reward, an object.
function Acquisition:SourceKind(group)
    local node, depth = group.parent, 0
    while type(node) == "table" and depth < 8 do
        if node.npcID or node.creatureID or node.crs then return "drop" end
        if node.questID then return "quest" end
        if node.objectID then return "object" end
        if node.achievementID or node.achID then return "achievement" end
        node = node.parent
        depth = depth + 1
    end
    return nil
end

function Acquisition:VendorRowFromGroup(group)
    local costNode = self:FindCostNode(group)
    local coordNode, coord = self:FindCoord(costNode or group)
    local costRows = costNode and copyCostRows(costNode.cost) or nil
    local zoneMapID = (not coord) and self:FindZone(group) or nil
    if not (costRows or coord or zoneMapID) then
        return nil
    end
    local mapID, x, y = self:NormalizeCoord(coord)
    return {
        costRows = costRows,
        vendorName = nodeName(coordNode) or self:VendorNameFromGroup(group),
        npcID = self:FindNpcID(coordNode or costNode or group),
        source = (not costRows) and self:SourceKind(group) or nil,
        zoneMapID = zoneMapID,
        note = (type(coordNode) == "table" and type(coordNode.description) == "string" and coordNode.description) or nil,
        noteKey = self:EventNote(group),
        mapID = mapID, x = x, y = y,
        gate = self:GateFromGroup(group),
    }
end

function Acquisition:WalkVendor(itemID)
    if not (itemID and type(self.SearchGroups) == "function") then
        return nil
    end
    local okSearch, groups = pcall(self.SearchGroups, self, "itemID", itemID)
    if not (okSearch and type(groups) == "table") then
        return nil
    end
    -- an item is listed in several places; prefer the entry that has both a price and a location
    local best, bestRank = nil, 0
    for _, group in ipairs(groups) do
        if type(group) == "table" then
            local row = self:VendorRowFromGroup(group)
            if row then
                local rank = (row.costRows and 4 or 0) + (row.mapID and 2 or 0) + (row.zoneMapID and 1 or 0)
                if rank > bestRank then
                    best, bestRank = row, rank
                end
            end
        end
    end
    return best
end

function Acquisition:MountRowFromGroup(group)
    local raidName, bossName, instanceID, encounterID
    local node, depth = group, 0
    while type(node) == "table" and depth < 10 do
        if node.encounterID and not encounterID then
            encounterID = node.encounterID
            if type(node.name) == "string" then bossName = node.name end
        end
        if node.instanceID and not instanceID then
            instanceID = node.instanceID
            if type(node.name) == "string" then raidName = node.name end
        end
        node = node.parent
        depth = depth + 1
    end
    local costNode = self:FindCostNode(group)
    local coordNode, coord = self:FindCoord(costNode or group)
    local mapID, x, y = self:NormalizeCoord(coord)
    local gate = self:GateFromGroup(group)
    if encounterID and (bossName or instanceID) then
        -- names are filled from the Encounter Journal at runtime when the source had none
        return { kind = "drop", raidName = raidName, bossName = bossName, instanceID = instanceID, encounterID = encounterID, mapID = mapID, x = x, y = y, gate = gate }, 3
    end
    if costNode then
        return {
            kind = "vendor", costRows = copyCostRows(costNode.cost),
            vendorName = nodeName(coordNode) or self:VendorNameFromGroup(group),
            npcID = self:FindNpcID(coordNode or costNode), mapID = mapID, x = x, y = y,
            note = (type(coordNode) == "table" and type(coordNode.description) == "string" and coordNode.description) or nil,
            noteKey = self:EventNote(group),
            gate = gate,
        }, mapID and 2 or 1
    end
    if mapID and x and y then
        return { kind = "coord", locName = nodeName(coordNode), npcID = self:FindNpcID(coordNode), mapID = mapID, x = x, y = y, gate = gate }, 1
    end
    return nil, 0
end

function Acquisition:WalkMount(spellID)
    if not (spellID and type(self.SearchGroups) == "function") then
        return nil
    end
    local okSearch, groups = pcall(self.SearchGroups, self, "mountID", spellID)
    if not (okSearch and type(groups) == "table") then
        return nil
    end
    -- prefer the raid drop entry over a vendor entry over a bare location
    local best, bestRank = nil, 0
    for _, group in ipairs(groups) do
        if type(group) == "table" then
            local row, rank = self:MountRowFromGroup(group)
            if row and rank > bestRank then
                best, bestRank = row, rank
            end
        end
    end
    return best
end

-- Boss and raid names for a shipped drop row, from the client's Encounter Journal.
function Acquisition:NameDrop(rec)
    if type(rec) ~= "table" or rec.kind ~= "drop" or (rec.raidName and rec.bossName) then
        return rec
    end
    if type(EJ_GetEncounterInfo) == "function" and rec.encounterID and not rec.bossName then
        local ok, name, _, _, _, _, journalInstanceID = guarded(EJ_GetEncounterInfo, rec.encounterID)
        if ok and type(name) == "string" and name ~= "" then
            rec.bossName = name
            rec.instanceID = rec.instanceID or journalInstanceID
        end
    end
    if type(EJ_GetInstanceInfo) == "function" and rec.instanceID and not rec.raidName then
        local ok, name = guarded(EJ_GetInstanceInfo, rec.instanceID)
        if ok and type(name) == "string" and name ~= "" then
            rec.raidName = name
        end
    end
    return rec
end

-- "The Mad Merchant, Dalaran": a place name the player can act on from anywhere.
function Acquisition:PlaceTitle(name, mapID)
    local zone
    if mapID and C_Map and type(C_Map.GetMapInfo) == "function" then
        local ok, info = reported(C_Map.GetMapInfo, mapID)
        zone = ok and type(info) == "table" and info.name or nil
        -- the zone name is compared and then joined below, so a secret one is dropped first
        if type(issecretvalue) == "function" and issecretvalue(zone) then
            zone = nil
        end
    end
    if zone and name and name ~= zone then
        return name .. ", " .. zone
    end
    return name or zone or L["acq_place_unknown"]
end

-- "Drops from Ickor Bileflesh", "Quest reward", "Zone: K'aresh": one line the player can act on.
function Acquisition:SourceText(rec)
    if type(rec) ~= "table" or rec.costRows then
        return nil
    end
    local who = rec.vendorName or (rec.npcID and self:NpcName(rec.npcID)) or nil
    if rec.source == "drop" then
        return who and string.format(L["acq_source_drops_from"], who) or L["acq_source_drops_generic"]
    elseif rec.source == "quest" then
        return L["acq_source_quest"]
    elseif rec.source == "object" then
        return L["acq_source_object"]
    elseif rec.source == "achievement" then
        return L["acq_source_achievement"]
    end
    return nil
end

-- Reputation standing index (1 Hated .. 8 Exalted) for a cumulative standing value, the scale
-- shipped minimum standing and FactionData.currentStanding share.
local STANDING_THRESHOLDS = { -42000, -6000, -3000, 0, 3000, 9000, 21000, 42000 }
local function standingIndex(value)
    local index = 1
    for i, threshold in ipairs(STANDING_THRESHOLDS) do
        if value >= threshold then index = i end
    end
    return index
end

local function patchString(patch)
    return math.floor(patch / 10000) .. "." .. (math.floor(patch / 100) % 100) .. "." .. (patch % 100)
end

-- A shipped name table { enUS = ..., deDE = ... } in the client's language.
function Acquisition:LocaleName(entry)
    if type(entry) ~= "table" then
        return nil
    end
    local locale = type(GetLocale) == "function" and GetLocale() or "enUS"
    return entry[locale] or entry.enUS
end

-- Region copies of one holiday: the shipped rows carry the US calendar ID, the client's calendar
-- reports the region's own. Timewalking 237 is the same event as 239.
local EVENT_ALIASES = {
    [237] = 239,
    [1583] = 1508, [1585] = 1508, [1584] = 1508,
    [622] = 559, [623] = 559, [624] = 559,
    [616] = 562, [618] = 562, [617] = 562,
    [628] = 587, [629] = 587, [630] = 587,
    [652] = 643, [656] = 643, [654] = 643,
    [1063] = 1056, [1068] = 1056, [1065] = 1056,
    [1265] = 1263, [1269] = 1263, [1267] = 1263,
    [1667] = 1669, [1668] = 1669, [1666] = 1669,
    [1704] = 1703, [1705] = 1703, [1706] = 1703, [1707] = 1703, [1708] = 1703, [1709] = 1703, [1710] = 1703,
}
local CALENDAR_MONTHS_BACK, CALENDAR_MONTHS_AHEAD = 18, 12

-- Holiday names from the client's own calendar, keyed by event ID: one scan per session (18 months
-- back, 12 ahead, the calendar left on the current month afterwards); an empty calendar is retried a
-- minute later.
function Acquisition:CalendarEventNames()
    if self.calendarNames then
        return self.calendarNames
    end
    local now = (type(GetTime) == "function") and GetTime() or 0
    if self.calendarRetryAt and now < self.calendarRetryAt then
        return {}
    end
    local names = {}
    local C = C_Calendar
    if not (type(C) == "table" and type(C.GetDayEvent) == "function" and type(C.SetAbsMonth) == "function"
        and type(C.SetMonth) == "function" and type(C.GetMonthInfo) == "function" and type(C.GetNumDayEvents) == "function"
        and type(C_DateAndTime) == "table" and type(C_DateAndTime.GetCurrentCalendarTime) == "function") then
        self.calendarNames = names
        return names
    end
    local okToday, today = guarded(C_DateAndTime.GetCurrentCalendarTime)
    if not (okToday and type(today) == "table" and today.month and today.year) then
        self.calendarRetryAt = now + 60
        return names
    end
    guarded(C.SetAbsMonth, today.month, today.year)
    guarded(C.SetMonth, -CALENDAR_MONTHS_BACK)
    for _ = -CALENDAR_MONTHS_BACK, CALENDAR_MONTHS_AHEAD do
        local okMonth, month = guarded(C.GetMonthInfo, 0)
        local days = (okMonth and type(month) == "table" and tonumber(month.numDays)) or 0
        for day = 1, days do
            local okCount, count = guarded(C.GetNumDayEvents, 0, day)
            for index = 1, (okCount and tonumber(count)) or 0 do
                local okEvent, event = guarded(C.GetDayEvent, 0, day, index)
                if okEvent and type(event) == "table" and event.calendarType == "HOLIDAY"
                    and type(event.eventID) == "number" and type(event.title) == "string" and event.title ~= "" then
                    names[event.eventID] = names[event.eventID] or event.title
                    local canonical = EVENT_ALIASES[event.eventID]
                    if canonical then
                        names[canonical] = names[canonical] or event.title
                    end
                end
            end
        end
        guarded(C.SetMonth, 1)
    end
    guarded(C.SetAbsMonth, today.month, today.year)
    if next(names) == nil then
        self.calendarRetryAt = now + 60
        return names
    end
    self.calendarNames = names
    return names
end

function Acquisition:EventName(eventID)
    local D = pluginShared.AcquisitionData
    local name = D and type(D.events) == "table" and self:LocaleName(D.events[eventID])
    if not name then
        name = self:CalendarEventNames()[eventID]
    end
    return name or string.format(L["acq_access_event_id_fmt"], eventID)
end

-- Asks the server for a quest's data once a session. Asking on every draw was a loop: the answer
-- comes back as QUEST_DATA_LOAD_RESULT, which redraws the panel, which asks again, for as long as
-- the panel is open, about once a second under the draw's own duty cap. A quest the server will not
-- name (removed, unknown) answers with success=false and is never asked about again; one it does
-- name is readable from then on, so there is nothing to ask again either.
local askedQuests = {}
function Acquisition:LoadQuestOnce(questID)
    if type(questID) ~= "number" or askedQuests[questID] then
        return false
    end
    if not (type(C_QuestLog) == "table" and type(C_QuestLog.RequestLoadQuestByID) == "function") then
        return false
    end
    askedQuests[questID] = true
    guarded(C_QuestLog.RequestLoadQuestByID, questID)
    return true
end

function Acquisition:QuestTitle(questID)
    if type(C_QuestLog) == "table" and type(C_QuestLog.GetTitleForQuestID) == "function" then
        local ok, title = guarded(C_QuestLog.GetTitleForQuestID, questID)
        if ok and type(title) == "string" and title ~= "" then
            return title
        end
        self:LoadQuestOnce(questID)
    end
    return nil
end

local function questDone(questID)
    if type(C_QuestLog) ~= "table" then
        return false
    end
    if type(C_QuestLog.IsQuestFlaggedCompleted) == "function" then
        local ok, done = guarded(C_QuestLog.IsQuestFlaggedCompleted, questID)
        if ok and done == true then return true end
    end
    if type(C_QuestLog.IsQuestFlaggedCompletedOnAccount) == "function" then
        local ok, done = guarded(C_QuestLog.IsQuestFlaggedCompletedOnAccount, questID)
        if ok and done == true then return true end
    end
    return false
end

local function currentBuild()
    if type(GetBuildInfo) == "function" then
        local ok, _, _, _, toc = guarded(GetBuildInfo)
        if ok and type(toc) == "number" then
            return toc
        end
    end
    return 0
end

-- Nobody can get this source any more: its availability code says removed from the game or never
-- implemented, or its removal patch is already live.
function Acquisition:IsUnattainable(rec)
    local gate = type(rec) == "table" and rec.gate
    if type(gate) ~= "table" then
        return false
    end
    local D = pluginShared.AcquisitionData or {}
    local phase = gate.u and type(D.phases) == "table" and D.phases[gate.u] or nil
    if phase then
        return (phase.state or 2) == 1
    end
    return gate.rwp ~= nil and gate.rwp <= currentBuild()
end

-- What stands between this character and the source, read live from the shipped gate: a list of
-- { text, blocked } (blocked = this character cannot get it as things stand), nil when nothing does.
function Acquisition:AccessLines(rec)
    local gate = type(rec) == "table" and rec.gate
    if type(gate) ~= "table" then
        return nil
    end
    local D = pluginShared.AcquisitionData or {}
    local lines = {}
    local function add(text, blocked)
        lines[#lines + 1] = { text = text, blocked = blocked }
    end
    local build = currentBuild()
    local phase = gate.u and type(D.phases) == "table" and D.phases[gate.u] or nil
    if phase then
        local suffix = gate.rwp and string.format(L["acq_access_patch_fmt"], patchString(gate.rwp)) or ""
        add(string.format(L["acq_access_unavailable_fmt"], self:LocaleName(phase.name) or tostring(gate.u), suffix), (phase.state or 2) == 1)
    elseif gate.rwp then
        if gate.rwp <= build then
            add(string.format(L["acq_access_removed_fmt"], patchString(gate.rwp)), true)
        else
            add(string.format(L["acq_access_leaving_fmt"], patchString(gate.rwp)), false)
        end
    end
    if gate.e then
        add(string.format(L["acq_access_event_fmt"], self:EventName(gate.e)), false)
    end
    if gate.faction then
        local mine
        if type(UnitFactionGroup) == "function" then
            local ok, tag = guarded(UnitFactionGroup, "player")
            if ok then mine = tag end
        end
        if mine ~= gate.faction then
            local label = gate.faction == "Horde" and (FACTION_HORDE or "Horde") or (FACTION_ALLIANCE or "Alliance")
            add(string.format(L["acq_access_faction_fmt"], label), true)
        end
    elseif gate.races then
        local myRace
        if type(UnitRace) == "function" then
            local ok, _, _, raceID = guarded(UnitRace, "player")
            if ok then myRace = raceID end
        end
        local allowed = false
        local names = {}
        for _, raceID in ipairs(gate.races) do
            if raceID == myRace then allowed = true end
            local name
            if type(C_CreatureInfo) == "table" and type(C_CreatureInfo.GetRaceInfo) == "function" then
                local ok, info = guarded(C_CreatureInfo.GetRaceInfo, raceID)
                if ok and type(info) == "table" and type(info.raceName) == "string" then name = info.raceName end
            end
            names[#names + 1] = name or tostring(raceID)
        end
        if not allowed then
            add(string.format(L["acq_access_race_fmt"], table.concat(names, ", ")), true)
        end
    end
    if gate.quests then
        for _, questID in ipairs(gate.quests) do
            if not questDone(questID) then
                -- the client names the quest when it can; a quest it has not loaded (or no longer
                -- knows) reads as plain "Quested", never as a number
                local title = self:QuestTitle(questID)
                add(title and string.format(L["acq_access_quest_fmt"], title) or L["acq_access_quested"], true)
                break
            end
        end
    end
    if gate.rep then
        local factionID, needed = gate.rep[1], gate.rep[2]
        local data
        if type(C_Reputation) == "table" and type(C_Reputation.GetFactionDataByID) == "function" then
            local ok, result = guarded(C_Reputation.GetFactionDataByID, factionID)
            if ok and type(result) == "table" then data = result end
        end
        if not (data and type(data.currentStanding) == "number" and data.currentStanding >= needed) then
            local standing = "FACTION_STANDING_LABEL" .. standingIndex(needed)
            if type(GetText) == "function" then
                local gender = type(UnitSex) == "function" and select(2, guarded(UnitSex, "player")) or nil
                local ok, text = guarded(GetText, standing, gender)
                if ok and type(text) == "string" and text ~= "" then standing = text end
            elseif type(_G[standing]) == "string" then
                standing = _G[standing]
            end
            add(string.format(L["acq_access_rep_fmt"], standing, data and data.name or tostring(factionID)), true)
        end
    end
    if #lines == 0 then
        return nil
    end
    return lines
end

function Acquisition:ZoneName(mapID)
    if mapID and C_Map and type(C_Map.GetMapInfo) == "function" then
        local ok, info = guarded(C_Map.GetMapInfo, mapID)
        if ok and type(info) == "table" and type(info.name) == "string" then
            return info.name
        end
    end
    return nil
end

function Acquisition:WithCost(rec)
    self:NameDrop(rec)
    -- Built once the game has every name in it. Item and currency names stream in after the first ask,
    -- so a line still carrying a placeholder is rebuilt next time rather than kept for the session.
    if type(rec) == "table" and rec.costRows and not rec.costTextFinal then
        local text, complete = self:FormatCost(rec.costRows)
        rec.costText = text
        rec.costTextFinal = complete or nil
    end
    if type(rec) == "table" and not rec.vendorName and rec.npcID then
        rec.vendorName = self:NpcName(rec.npcID)
    end
    if type(rec) == "table" and rec.kind == "coord" and not rec.locName and rec.npcID then
        rec.locName = self:NpcName(rec.npcID)
    end
    return rec
end

function Acquisition:MountSpellID(mountID)
    if not (mountID and C_MountJournal and type(C_MountJournal.GetMountInfoByID) == "function") then
        return nil
    end
    local ok, _, spellID = pcall(C_MountJournal.GetMountInfoByID, mountID)
    if ok then
        return spellID
    end
    return nil
end

function Acquisition:GetMount(mountID)
    if not mountID then
        return nil
    end
    local best = self:Best("mounts", mountID, self:EnsureDB().mounts[mountID])
    return best and self:WithCost(best) or nil
end

function Acquisition:GetVendor(itemID)
    if not itemID then
        return nil
    end
    local best = self:Best("vendors", itemID, self:EnsureDB().vendors[itemID])
    return best and self:WithCost(best) or nil
end
