local addonName, ZL = ...

local function ObjectiveKey(mapID, categoryKey, instanceMapID, objective)
    return table.concat({ mapID, instanceMapID or 0, categoryKey or "unknown", objective.id or objective.name or "unknown" }, ":")
end

local VisitObjectives

local function HistoryCategoryKey(eventType, categoryName)
    if eventType == "zone" then return "zones" end
    if eventType == "journey" then return "journeys" end
    local normalized = tostring(categoryName or ""):lower()
    if normalized == "quests" then return "quests" end
    if normalized == "achievements" then return "achievements" end
    if normalized == "decor" then return "decor" end
    if normalized == "mounts" then return "mounts" end
    if normalized == "battle pets" or normalized == "pets" then return "pets" end
    if normalized == "rare drops" or normalized == "rares" then return "rares" end
    if normalized == "transmog" or normalized == "transmogs" then return "transmogs" end
    return normalized ~= "" and normalized or "items"
end

function ZL:IsHistoryTrackingEnabled(categoryKey)
    local settings = ZL_Saved.settings.history
    if not settings or settings.enabled == false then return false end
    return not settings.categories or settings.categories[categoryKey] ~= false
end

function ZL:GetHistoryEventGroup(event)
    if type(event) ~= "table" then return "items" end
    local key = HistoryCategoryKey(event and event.eventType, event and event.category)
    if key == "zones" or key == "journeys" then return "zones" end
    if key == "quests" then return "quests" end
    return "items"
end

local function RebuildHistoryEventKeys()
    ZL_Saved.historyEventKeys = {}
    for _, event in ipairs(ZL_Saved.history or {}) do
        if type(event) == "table" and event.dedupeKey then
            ZL_Saved.historyEventKeys[event.dedupeKey] = true
        end
    end
end

local function EncodeField(value)
    if value == nil or value == "" then return "!" end
    return (tostring(value or ""):gsub("([^%w%-%._~])", function(character)
        return string.format("%%%02X", string.byte(character))
    end))
end

local function DecodeField(value)
    if value == "!" then return "" end
    return (tostring(value or ""):gsub("%%(%x%x)", function(hex)
        return string.char(tonumber(hex, 16))
    end))
end

local function SplitFields(line)
    local fields = {}
    local startAt = 1
    while true do
        local separator = line:find("|", startAt, true)
        if not separator then
            fields[#fields + 1] = DecodeField(line:sub(startAt))
            break
        end
        fields[#fields + 1] = DecodeField(line:sub(startAt, separator - 1))
        startAt = separator + 1
    end
    return fields
end

function ZL:RepairHistoryData()
    local repaired, retained = 0, {}
    for _, event in ipairs(ZL_Saved.history or {}) do
        local malformedImport = type(event) == "table" and
            (event.zoneName == "objective" or event.zoneName == "zone" or event.zoneName == "journey") and
            (tostring(event.name or "") == tostring(event.mapID or "") or
                (tostring(event.name or ""):match("^%d+$") and
                    (event.category == nil or event.category == "")))
        if malformedImport then repaired = repaired + 1 else retained[#retained + 1] = event end
    end
    if repaired > 0 then
        ZL_Saved.history = retained
        RebuildHistoryEventKeys()
    end
    return repaired
end

local function BuildHistoryPayload()
    local lines = { "ZLH1" }
    for _, event in ipairs(ZL_Saved.history or {}) do
        if type(event) == "table" then
            local fields = { "E", EncodeField(event.timestamp), EncodeField(event.eventType),
                EncodeField(event.mapID), EncodeField(event.zoneName), EncodeField(event.category),
                EncodeField(event.name), EncodeField(event.instanceName), EncodeField(event.dedupeKey) }
            lines[#lines + 1] = table.concat(fields, "|")
        end
    end
    for _, journey in ipairs(ZL_Saved.journeys or {}) do
        if type(journey) == "table" then
            local zones = {}
            for _, mapID in ipairs(journey.zones or {}) do zones[#zones + 1] = tostring(mapID) end
            local fields = { "J", EncodeField(journey.startedAt), EncodeField(journey.completedAt),
                EncodeField(journey.expansionKey), EncodeField(journey.expansionName),
                EncodeField(journey.continentID), EncodeField(journey.continentName), EncodeField(journey.name),
                EncodeField(journey.archiveKey), EncodeField(table.concat(zones, ",")) }
            lines[#lines + 1] = table.concat(fields, "|")
        end
    end
    return table.concat(lines, "\n")
end

local function HexEncode(value)
    return (value:gsub(".", function(character) return string.format("%02X", string.byte(character)) end))
end

local function HexDecode(value)
    if #value % 2 ~= 0 or value:find("[^%x]") then return nil end
    return (value:gsub("(%x%x)", function(hex) return string.char(tonumber(hex, 16)) end))
end

function ZL:ExportHistory()
    -- ZLH2 is deliberately pipe-free. WoW EditBoxes can normalize pipe
    -- sequences because they are also used for hyperlinks and colour markup.
    local encoded, lines = HexEncode(BuildHistoryPayload()), { "ZLH2:" }
    for index = 1, #encoded, 96 do lines[#lines + 1] = encoded:sub(index, index + 95) end
    return table.concat(lines, "\n")
end

function ZL:ImportHistory(payload)
    self:RepairHistoryData()
    payload = tostring(payload or ""):gsub("^%s+", ""):gsub("%s+$", "")
    local zlh2Position = payload:find("ZLH2:", 1, true)
    if zlh2Position and zlh2Position > 1 then payload = payload:sub(zlh2Position) end
    if payload:sub(1, 5) == "ZLH2:" then
        -- Multiline EditBoxes may insert visual wrapping whitespace into very
        -- long copied values. It is not part of the hexadecimal transport.
        local encoded = payload:sub(6):gsub("%s+", "")
        payload = HexDecode(encoded)
        if not payload then return false, "The ZLH2 history payload is damaged or incomplete." end
        if payload:sub(1, 4) ~= "ZLH1" then
            return false, "The ZLH2 payload decoded, but its internal history header is invalid."
        end
    else
        local compact = payload:gsub("%s+", "")
        if compact ~= "" and not compact:find("[^%x]") then
            local decoded = HexDecode(compact)
            if decoded and decoded:sub(1, 4) == "ZLH1" then
                payload = decoded
            else
                return false, "Only part of the encoded export was pasted. Use Select All, then Ctrl+C."
            end
        end
    end
    payload = payload:gsub("\r", ""):gsub("^%s+", "")
    local lines = {}
    for line in payload:gmatch("[^\n]+") do lines[#lines + 1] = line end
    if lines[1] ~= "ZLH1" then
        return false, "This is not a supported Zone Locked history export. Copy from the ZLH2: header through the final line."
    end

    local eventKeys, journeyKeys = {}, {}
    for _, event in ipairs(ZL_Saved.history or {}) do
        if type(event) == "table" then
            local key = event.dedupeKey or table.concat({ event.timestamp or 0, event.eventType or "",
                event.mapID or 0, event.name or "" }, ":")
            eventKeys[key] = true
        end
    end
    for _, journey in ipairs(ZL_Saved.journeys or {}) do
        if type(journey) == "table" then
            journeyKeys[journey.archiveKey or table.concat({ journey.completedAt or 0,
                journey.continentID or 0, table.concat(journey.zones or {}, ",") }, ":")] = true
        end
    end

    local importedEvents, importedJourneys, duplicateEvents, duplicateJourneys, invalidRecords = 0, 0, 0, 0, 0
    for index = 2, #lines do
        local fields = SplitFields(lines[index])
        if fields[1] == "E" then
            local instanceName, dedupeKey = fields[8], fields[9]
            -- v0.27.0 leaked gsub's replacement count as a final field. Some
            -- copies also omitted the empty instance field; accept both.
            if not dedupeKey and instanceName and instanceName:find(":", 1, true) then
                instanceName, dedupeKey = "", instanceName
            elseif not fields[10] and instanceName and instanceName:find(":", 1, true) and
                dedupeKey and dedupeKey:match("^%d+$") then
                instanceName, dedupeKey = "", instanceName
            end
            local event = {
                timestamp = tonumber(fields[2]) or time(), eventType = fields[3] ~= "" and fields[3] or "objective",
                mapID = tonumber(fields[4]), zoneName = fields[5], category = fields[6], name = fields[7],
                instanceName = instanceName ~= "" and instanceName or nil,
                dedupeKey = dedupeKey ~= "" and dedupeKey or nil,
            }
            if not tonumber(fields[2]) or not event.mapID or event.name == "" or
                (event.eventType ~= "objective" and event.eventType ~= "zone" and event.eventType ~= "journey") then
                invalidRecords = invalidRecords + 1
            else
                local key = event.dedupeKey or table.concat({ event.timestamp, event.eventType,
                    event.mapID or 0, event.name or "" }, ":")
                if not eventKeys[key] then
                    eventKeys[key] = true
                    ZL_Saved.history[#ZL_Saved.history + 1] = event
                    importedEvents = importedEvents + 1
                else
                    duplicateEvents = duplicateEvents + 1
                end
            end
        elseif fields[1] == "J" then
            local zones = {}
            for mapID in tostring(fields[10] or ""):gmatch("%d+") do zones[#zones + 1] = tonumber(mapID) end
            local journey = {
                startedAt = tonumber(fields[2]), completedAt = tonumber(fields[3]), expansionKey = fields[4],
                expansionName = fields[5], continentID = tonumber(fields[6]), continentName = fields[7],
                name = fields[8] ~= "" and fields[8] or nil, archiveKey = fields[9] ~= "" and fields[9] or nil,
                zones = zones,
            }
            local key = journey.archiveKey or table.concat({ journey.completedAt or 0,
                journey.continentID or 0, table.concat(zones, ",") }, ":")
            if not journeyKeys[key] then
                journeyKeys[key] = true
                ZL_Saved.journeys[#ZL_Saved.journeys + 1] = journey
                if journey.archiveKey then ZL_Saved.journeyArchiveKeys[journey.archiveKey] = true end
                importedJourneys = importedJourneys + 1
            else
                duplicateJourneys = duplicateJourneys + 1
            end
        end
    end
    table.sort(ZL_Saved.history, function(left, right)
        return (type(left) == "table" and left.timestamp or 0) < (type(right) == "table" and right.timestamp or 0)
    end)
    while #ZL_Saved.history > 1000 do table.remove(ZL_Saved.history, 1) end
    RebuildHistoryEventKeys()
    self._uiSignature = nil
    self:RefreshUI()
    local importedTotal = importedEvents + importedJourneys
    return true, string.format("Imported %d history event%s and %d journey%s; skipped %d duplicate%s%s.", importedEvents,
        importedEvents == 1 and "" or "s", importedJourneys, importedJourneys == 1 and "" or "s",
        duplicateEvents + duplicateJourneys, duplicateEvents + duplicateJourneys == 1 and "" or "s",
        invalidRecords > 0 and (" Skipped " .. invalidRecords .. " invalid record" .. (invalidRecords == 1 and "" or "s")) or ""),
        importedTotal
end

function ZL:DeleteHistoryEvent(target)
    for index, event in ipairs(ZL_Saved.history or {}) do
        if event == target then
            table.remove(ZL_Saved.history, index)
            if type(event) == "table" and event.dedupeKey then
                ZL_Saved.historyKnown[event.dedupeKey] = nil
            end
            local rebuildZone = type(event) == "table" and event.eventType == "zone" and event.mapID
            if rebuildZone then
                ZL_Saved.historyKnown["zone:" .. event.mapID] = nil
                ZL_Saved.completedZones[event.mapID] = nil
                ZL_Saved.zoneSummaries[event.mapID] = nil
                self.ZoneData[event.mapID] = nil
            end
            RebuildHistoryEventKeys()
            self._uiSignature = nil
            self:RefreshUI()
            if rebuildZone and self.ScheduleRefresh then self:ScheduleRefresh(0.05) end
            return true
        end
    end
    return false
end

function ZL:ClearHistory(scope)
    scope = scope or "all"
    local function ShouldRemove(event)
        return scope == "all" or self:GetHistoryEventGroup(event) == scope
    end
    local retained = {}
    for _, event in ipairs(ZL_Saved.history or {}) do
        if not ShouldRemove(event) then retained[#retained + 1] = event end
    end
    ZL_Saved.history = retained
    RebuildHistoryEventKeys()

    for key in pairs(ZL_Saved.historyKnown or {}) do
        local remove = scope == "all"
        if not remove and type(key) == "string" then
            if scope == "zones" then
                remove = key:match("^zone:") ~= nil
            else
                local categoryKey = key:match("^%d+:%d+:([^:]+):")
                remove = scope == "quests" and categoryKey == "quests" or
                    scope == "items" and categoryKey ~= nil and categoryKey ~= "quests"
            end
        end
        if remove then ZL_Saved.historyKnown[key] = nil end
    end

    if scope == "all" or scope == "zones" then
        ZL_Saved.journeys = {}
        ZL_Saved.journeyArchiveKeys = {}
        -- Completion records are a cache for this route run, not proof that a
        -- future reroll is complete. Clear and rebuild them from live data.
        ZL_Saved.completedZones = {}
        ZL_Saved.zoneSummaries = {}
        for mapID in pairs(self.ZoneData or {}) do
            if self:GetRoutePosition(mapID) then self.ZoneData[mapID] = nil end
        end
    end
    self._uiSignature = nil
    self:RefreshUI()
    if (scope == "all" or scope == "zones") and self.ScheduleRefresh then self:ScheduleRefresh(0.05) end
end

local function HistoryContainsKey(key)
    if not key then return false end
    for _, event in ipairs(ZL_Saved.history or {}) do
        if type(event) == "table" and event.dedupeKey == key then return true end
    end
    return false
end

local function RecordObjective(ZL, data, category, objective, instance)
    local key = ObjectiveKey(data.mapID, category.key, instance and instance.mapID, objective)
    ZL_Saved.historyKnown[key] = true
    -- Older builds could save the index without ever saving the visible row.
    -- Only suppress an event when the row itself really exists.
    if ZL_Saved.historyEventKeys[key] and HistoryContainsKey(key) then return false end
    local sourceName = objective.source or (instance and instance.name) or objective.instanceName or data.name
    local historyText = objective.name
    if category.key == "quests" then
        historyText = "Completed " .. objective.name
    elseif category.key == "achievements" then
        historyText = "Earned " .. objective.name
    elseif category.key == "transmogs" or category.key == "rares" or category.key == "mounts" or
        category.key == "pets" or category.key == "decor" then
        historyText = string.format("Obtained %s from %s", objective.name, sourceName)
    end
    local event = ZL:AddHistoryEvent("objective", data.mapID, category.name, historyText,
        instance and instance.name or objective.instanceName, key)
    if event then ZL_Saved.historyEventKeys[key] = true end
    ZL:ShowCollectionAlert(objective.name, objective.itemID, objective.icon)
    if ZL.RequestATTRefresh then ZL:RequestATTRefresh("tracked completion", 0.05) end
    return true
end

function ZL:RecordObtainedItem(itemID, itemLink)
    if not itemID then return false end
    for _, mapID in ipairs(ZL_Saved.route or {}) do
        local data = self.ZoneData[mapID]
        if data then
            local found
            VisitObjectives(data, function(category, objective, instance)
                if not found and not objective.complete and objective.itemID == itemID then
                    found = true
                    if itemLink and (not objective.name or objective.name:match("^Appearance %d+$")) then
                        objective.name = C_Item.GetItemInfo(itemLink) or objective.name
                    end
                    RecordObjective(self, data, category, objective, instance)
                end
            end)
            if found then return true end
        end
    end
    return false
end

VisitObjectives = function(data, callback)
    for _, category in ipairs(data.categories or {}) do
        if category.key == "instances" then
            for _, instance in ipairs(category.instances or {}) do
                for _, childCategory in ipairs(instance.categories or {}) do
                    for _, objective in ipairs(childCategory.objectives or {}) do
                        callback(childCategory, objective, instance)
                    end
                end
            end
        else
            for _, objective in ipairs(category.objectives or {}) do callback(category, objective) end
        end
    end
end

function ZL:AddHistoryEvent(eventType, mapID, categoryName, name, instanceName, dedupeKey)
    local categoryKey = HistoryCategoryKey(eventType, categoryName)
    if not self:IsHistoryTrackingEnabled(categoryKey) then return nil end
    local history = ZL_Saved.history
    if dedupeKey and HistoryContainsKey(dedupeKey) then return end
    local definition = mapID and self:GetZoneDefinition(mapID)
    local event = {
        timestamp = time(),
        eventType = eventType,
        mapID = mapID,
        zoneName = definition and definition.name or "Unknown zone",
        category = categoryName,
        name = name,
        instanceName = instanceName,
        dedupeKey = dedupeKey,
    }
    history[#history + 1] = event
    while #history > 1000 do table.remove(history, 1) end
    self._uiSignature = nil
    if self.showHistory and self.UI and self.UI:IsShown() then
        C_Timer.After(0, function() if self.showHistory then self:RefreshUI() end end)
    end
    return event
end

function ZL:ArchiveJourneyLeg(finalMapID, context)
    local route = ZL_Saved.route or {}
    local firstIndex = math.max(1, ZL_Saved.currentJourneyStartIndex or 1)
    if firstIndex > #route then return end
    local zones = {}
    for index = firstIndex, #route do zones[#zones + 1] = route[index] end
    local continentID, expansionKey = self:GetMapContinentID(finalMapID)
    local continentName = self:GetContinentName(continentID, expansionKey)
    local expansion = self.ExpansionByKey and self.ExpansionByKey[expansionKey]
    local archiveKey = table.concat({ expansionKey or "unknown", continentID or 0, table.concat(zones, ",") }, ":")
    if not ZL_Saved.journeyArchiveKeys[archiveKey] then
        ZL_Saved.journeyArchiveKeys[archiveKey] = true
        ZL_Saved.journeys[#ZL_Saved.journeys + 1] = {
            archiveKey = archiveKey,
            startedAt = ZL_Saved.currentJourneyStartedAt or time(),
            completedAt = time(), expansionKey = expansionKey, expansionName = expansion and expansion.name,
            continentID = continentID, continentName = continentName, zones = zones,
        }
        self:AddHistoryEvent("journey", finalMapID, expansion and expansion.name or "Journey",
            continentName .. " Journey Completed (" .. #zones .. " zones)")
    end
    ZL_Saved.currentJourneyStartIndex = #route + 1
    ZL_Saved.currentJourneyStartedAt = time()
end

function ZL:RenameJourney(index, name)
    local journey = ZL_Saved.journeys and ZL_Saved.journeys[index]
    name = type(name) == "string" and name:match("^%s*(.-)%s*$") or nil
    if not journey or not name or name == "" then return false end
    journey.name = name:sub(1, 60)
    self._uiSignature = nil
    self:RefreshUI()
    return true
end

function ZL:DeleteJourney(index)
    local journeys = ZL_Saved.journeys or {}
    local journey = journeys[index]
    if not journey then return false end
    table.remove(journeys, index)
    ZL_Saved.journeyArchiveKeys = {}
    for _, remaining in ipairs(journeys) do
        if remaining.archiveKey then ZL_Saved.journeyArchiveKeys[remaining.archiveKey] = true end
    end
    self._uiSignature = nil
    self:RefreshUI()
    return true
end

function ZL:UpdateCompletionHistory(data)
    if not data or not data.mapID then return end
    local known = ZL_Saved.historyKnown

    local function TrackCategory(category, instance)
        for _, objective in ipairs(category.objectives or {}) do
            local key = ObjectiveKey(data.mapID, category.key, instance and instance.mapID, objective)
            if known[key] == nil then
                known[key] = objective.complete and true or false
            elseif objective.complete and not known[key] then
                if category.key == "decor" then
                    -- Housing data often becomes authoritative after login,
                    -- which is baseline reconciliation rather than a new gain.
                    -- Genuine gains are recorded by HOUSE_DECOR_ADDED_TO_CHEST.
                    known[key] = true
                else
                    RecordObjective(self, data, category, objective, instance)
                end
            elseif not objective.complete then
                known[key] = false
            end
        end
    end

    for _, category in ipairs(data.categories or {}) do
        if category.key == "instances" then
            for _, instance in ipairs(category.instances or {}) do
                for _, childCategory in ipairs(instance.categories or {}) do TrackCategory(childCategory, instance) end
            end
        else
            TrackCategory(category)
        end
    end

    local zoneKey = "zone:" .. data.mapID
    local complete = self:IsZoneComplete(data.mapID)
    if known[zoneKey] == nil then
        known[zoneKey] = complete
    elseif complete and not known[zoneKey] then
        known[zoneKey] = true
        self:AddHistoryEvent("zone", data.mapID, "Zone", data.name .. " Completed")
        self:ShowCollectionAlert(data.name .. " Completed", nil, 237381)
    elseif not complete then
        known[zoneKey] = false
    end
end


function ZL:RecordQuestTurnIn(questID)
    if not questID then return end
    for _, mapID in ipairs(ZL_Saved.route or {}) do
        local data = self.ZoneData[mapID]
        if data then
            local found
            VisitObjectives(data, function(category, objective, instance)
                if not found and category.key == "quests" and objective.id == questID then
                    found = true
                    RecordObjective(self, data, category, objective, instance)
                end
            end)
            if found then return end
        end
    end
    -- ATT may not have populated the objective before the turn-in event. A
    -- direct quest event is still authoritative and should not disappear from
    -- the challenge history merely because the provider was refreshing.
    local mapID = self:GetActiveZone()
    local eventKey = "quest-event:" .. questID
    if mapID and (not ZL_Saved.historyEventKeys[eventKey] or not HistoryContainsKey(eventKey)) then
        local name = C_QuestLog.GetTitleForQuestID(questID) or ("Quest " .. questID)
        local event = self:AddHistoryEvent("objective", mapID, "Quests", "Completed " .. name, nil, eventKey)
        if event then ZL_Saved.historyEventKeys[eventKey] = true end
        self:ShowCollectionAlert(name, nil, 463446)
        if self.RequestATTRefresh then self:RequestATTRefresh("tracked quest", 0.05) end
    end
end

function ZL:RecordTransmogSource(sourceID)
    if not sourceID then return end
    for _, mapID in ipairs(ZL_Saved.route or {}) do
        local data = self.ZoneData[mapID]
        if data then
            local found
            VisitObjectives(data, function(category, objective, instance)
                if not found and category.key == "transmogs" and objective.sourceID == sourceID then
                    found = true
                    RecordObjective(self, data, category, objective, instance)
                end
            end)
            if found then return end
        end
    end
    local mapID, eventKey = self:GetActiveZone(), "transmog-event:" .. sourceID
    if not mapID or (ZL_Saved.historyEventKeys[eventKey] and HistoryContainsKey(eventKey)) then return end
    local sourceInfo = C_TransmogCollection.GetSourceInfo and C_TransmogCollection.GetSourceInfo(sourceID)
    local itemID = sourceInfo and sourceInfo.itemID or
        (C_TransmogCollection.GetSourceItemID and C_TransmogCollection.GetSourceItemID(sourceID))
    local itemName, _, _, _, _, _, _, _, _, icon = itemID and C_Item.GetItemInfo(itemID)
    local name = itemName or ("Appearance " .. sourceID)
    local event = self:AddHistoryEvent("objective", mapID, "Transmog",
        string.format("Obtained %s from %s", name, self:GetZoneDefinition(mapID).name), nil, eventKey)
    if event then ZL_Saved.historyEventKeys[eventKey] = true end
    self:ShowCollectionAlert(name, itemID, icon or 135048)
    if self.RequestATTRefresh then self:RequestATTRefresh("tracked transmog", 0.05) end
end

function ZL:RecordDecorUnlock(...)
    local decorID
    for index = 1, select("#", ...) do
        local value = select(index, ...)
        if type(value) == "number" and value > 0 then decorID = value; break end
    end
    if not decorID then return end
    for _, mapID in ipairs(ZL_Saved.route or {}) do
        local data = self.ZoneData[mapID]
        if data then
            local found
            VisitObjectives(data, function(category, objective, instance)
                if not found and category.key == "decor" and
                    (objective.decorID == decorID or objective.id == decorID) then
                    found = true
                    RecordObjective(self, data, category, objective, instance)
                end
            end)
            if found then return end
        end
    end

    local mapID, eventKey = self:GetActiveZone(), "decor-event:" .. decorID
    if not mapID or (ZL_Saved.historyEventKeys[eventKey] and HistoryContainsKey(eventKey)) then return end
    local info
    if C_HousingCatalog and C_HousingCatalog.GetCatalogEntryInfoByRecordID and
        Enum and Enum.HousingCatalogEntryType then
        local ok, result = pcall(C_HousingCatalog.GetCatalogEntryInfoByRecordID,
            Enum.HousingCatalogEntryType.Decor, decorID, true)
        if ok then info = result end
    end
    local itemID = info and info.itemID
    local name = info and (info.name or info.entryName)
    if not name and itemID and C_Item.GetItemNameByID then name = C_Item.GetItemNameByID(itemID) end
    name = name or ("Decor " .. decorID)
    local event = self:AddHistoryEvent("objective", mapID, "Decor",
        string.format("Obtained %s from %s", name, self:GetZoneDefinition(mapID).name), nil, eventKey)
    if event then ZL_Saved.historyEventKeys[eventKey] = true end
    self:ShowCollectionAlert(name, itemID, (info and (info.icon or info.iconFileID)) or 134435)
    if self.RequestATTRefresh then self:RequestATTRefresh("tracked decor", 0.05) end
end

function ZL:DetectLiveCompletionChanges()
    for _, mapID in ipairs(ZL_Saved.route or {}) do
        local data = self.ZoneData[mapID]
        if data then
            VisitObjectives(data, function(category, objective, instance)
                if objective.complete then return end
                local complete
                if category.key == "quests" and objective.id and C_QuestLog.IsQuestFlaggedCompleted then
                    complete = C_QuestLog.IsQuestFlaggedCompleted(objective.id)
                elseif category.key == "transmogs" and objective.sourceID and
                    C_TransmogCollection.GetAppearanceSourceInfo then
                    local info = C_TransmogCollection.GetAppearanceSourceInfo(objective.sourceID)
                    complete = info and info.isCollected
                elseif category.key == "achievements" and objective.id then
                    local _, _, _, earned = GetAchievementInfo(objective.id)
                    complete = earned
                elseif category.key == "pets" and objective.id and C_PetJournal.GetNumCollectedInfo then
                    complete = (C_PetJournal.GetNumCollectedInfo(objective.id) or 0) > 0
                elseif category.key == "rares" and objective.itemID then
                    complete = C_Item.GetItemCount(objective.itemID, true) > 0
                elseif category.key == "mounts" and objective.id and C_MountJournal.GetMountFromSpell then
                    local mountID = C_MountJournal.GetMountFromSpell(objective.id)
                    if mountID then
                        local _, _, _, _, _, _, _, _, _, _, collected = C_MountJournal.GetMountInfoByID(mountID)
                        complete = collected
                    end
                end
                if complete then RecordObjective(self, data, category, objective, instance) end
            end)
        end
    end
end
