
-- ============================================================
-- FILE: QuestLog_Data.lua
-- PURPOSE: Core data-processing module for the custom quest
--   log panel. Collects raw C_QuestLog entries, classifies
--   them into NOW/NEXT/LATER buckets, extracts campaign/zone
--   groupings, and manages a reusable object-pool system for
--   UI widgets created by QuestLog_Panel.lua.
-- HOW IT WORKS:
--   1. Object pools (_pools table): holds recycled Frame
--      objects keyed by widget type (headers, quests,
--      objectives, bucket headers, zone banners, etc.).
--      QuestListAcquireFromPool / QuestListReleaseAllInPool
--      manage allocation and recycling.
--   2. CollectRawQuestEntries(searchFilter):
--      Iterates C_QuestLog.GetNumQuestLogEntries, reads each
--      entry's info + objectives. Applies search filter.
--   3. CollectQuestListData(searchFilter) → data table:
--      The main pipeline entry point. Calls
--      CollectRawQuestEntries, then assigns each quest to
--      NOW / NEXT / LATER based on proximity, turnin state,
--      expiry flags, and QLC.TIME_SENSITIVE_TAG_IDs.
--      Returns a structured data table consumed by
--      QuestLog_Panel.lua for rendering.
--   4. Utility helpers: ResolveCampaignCardFallback,
--      GetQuestProgressFraction, IsQuestNearlyComplete,
--      GetPlayerQuestContext, and others.
-- DEPENDS ON:
--   Addon.QuestLogConfig (QLC) — must load first
--   C_QuestLog, C_TaskQuest, C_CampaignInfo (Blizzard APIs)
-- EXPORTS: Addon.QuestData (table with all public functions)
-- OWNED SAVEDVARIABLES: none
-- EVENTS REGISTERED: none (pure data module, no event hooks)
-- FRAMES CREATED: none (pool reuses frames from Panel)
-- GOTCHAS:
--   _collapseState and _bucketCollapseState are session state
--   (not SavedVars) — collapse resets on reload.
--   The bucketing heuristics (now/next/later) depend on quest
--   tag IDs in QLC.TIME_SENSITIVE_TAG_IDs; missing tags mean
--   quests won't be classified as expiring.
-- ============================================================

local ADDON_NAME, Addon = ...
if type(ADDON_NAME) ~= "string" or ADDON_NAME == "" then
    ADDON_NAME = "MidnightUI"
end
if type(Addon) ~= "table" then
    Addon = {}
end

do
    -- The user's runtime settings live on MidnightUISettingsPerChar (Bootstrap.lua
    -- aliases MidnightUISettings to it on ADDON_LOADED). At file-load that swap
    -- hasn't happened yet, so check per-char first.
    local function IsBlizzardModeEnabled()
        local perChar = _G.MidnightUISettingsPerChar
        if type(perChar) == "table" and type(perChar.General) == "table"
           and perChar.General.useBlizzardQuestingInterface == true then
            return true
        end
        local s = _G.MidnightUISettings
        if type(s) == "table" and type(s.General) == "table"
           and s.General.useBlizzardQuestingInterface == true then
            return true
        end
        return false
    end
    if IsBlizzardModeEnabled() then
        return
    end
end

local QLC = Addon.QuestLogConfig
if type(QLC) ~= "table" then return end

local QuestData = {}
QuestData._metaCache = {}


QuestData._pools = {
    headers = {},
    quests = {},
    objectives = {},
    empty = {},
    bucketHeaders = {},
    focusHeaders = {},
    zoneBanners = {},
    zoneSubHeaders = {},
    comboBoxes = {},
    abandonButtons = {},
}

QuestData._collapseState = {}
QuestData._bucketCollapseState = { now = false, next = false, later = true, turnIn = false, expiring = false }
QuestData._expandedQuests = {}
QuestData._expandedGossip = {}

local function StopQuestListAnimationGroup(animGroup)
    if not animGroup or type(animGroup.IsPlaying) ~= "function" or type(animGroup.Stop) ~= "function" then
        return
    end
    local okPlaying, isPlaying = pcall(animGroup.IsPlaying, animGroup)
    if okPlaying and isPlaying then
        pcall(animGroup.Stop, animGroup)
    end
end

local function ResetQuestListPooledRow(row)
    if not row then
        return
    end

    StopQuestListAnimationGroup(row._pulseGroup)
    StopQuestListAnimationGroup(row._upgradePulse)

    if type(row.SetAlpha) == "function" then
        row:SetAlpha(1)
    end
    if type(row.ClearAllPoints) == "function" then
        row:ClearAllPoints()
    end
    if type(row.Hide) == "function" then
        row:Hide()
    end
end


-- ============================================================
-- SECTION: Object Pool Management
-- READS: QuestData._pools{} table
-- WRITES: pool entries (acquire creates, release returns)
-- FUNCTIONS:
--   QuestListAcquireFromPool(poolKey, createFunc, parent)
--     → widget: takes from pool or calls createFunc() to make
--     a new one; always hides widget on acquire.
--   QuestListReleaseAllInPool(poolKey) — hides and returns all
--     widgets for the given pool key.
--   Pool keys: headers, quests, objectives, empty,
--     bucketHeaders, focusHeaders, zoneBanners,
--     zoneSubHeaders, comboBoxes, abandonButtons.
-- ============================================================
function QuestData.QuestListAcquireFromPool(poolKey, createFunc, parent)
    local pool = QuestData._pools[poolKey]
    if type(pool) ~= "table" then
        pool = {}
        QuestData._pools[poolKey] = pool
    end

    for i = 1, #pool do
        local row = pool[i]
        if row and not row._muiQuestListInUse then
            row._muiQuestListInUse = true
            if parent and type(row.SetParent) == "function" then
                local currentParent = (type(row.GetParent) == "function") and row:GetParent() or nil
                if currentParent ~= parent then
                    row:SetParent(parent)
                end
            end
            if type(row.SetAlpha) == "function" then
                row:SetAlpha(1)
            end
            if type(row.Show) == "function" then
                row:Show()
            end
            return row
        end
    end

    if type(createFunc) ~= "function" then
        return nil
    end

    local okCreate, row = pcall(createFunc, parent)
    if not okCreate or not row then
        return nil
    end

    row._muiQuestListPoolKey = poolKey
    row._muiQuestListInUse = true
    pool[#pool + 1] = row

    if type(row.SetAlpha) == "function" then
        row:SetAlpha(1)
    end
    if type(row.Show) == "function" then
        row:Show()
    end

    return row
end

function QuestData.QuestListReleaseAllInPool(poolKey)
    local pool = QuestData._pools[poolKey]
    if type(pool) ~= "table" then
        return
    end

    for i = 1, #pool do
        local row = pool[i]
        if row then
            row._muiQuestListInUse = false
            ResetQuestListPooledRow(row)
        end
    end
end


function QuestData.ShouldShowQuestObjectivesInLog()
    if type(GetCVarBool) == "function" then
        local ok, value = pcall(GetCVarBool, "showQuestObjectivesInLog")
        if ok and type(value) == "boolean" then
            return value
        end
    end
    return true
end


function QuestData.ExtractFocusedQuestFromBuckets(listData, focusedQuestID)
    if type(focusedQuestID) ~= "number" or type(listData) ~= "table" or type(listData.buckets) ~= "table" then
        return nil, nil
    end

    for i = 1, #QLC.BUCKET_ORDER do
        local bucketKey = QLC.BUCKET_ORDER[i]
        local quests = listData.buckets[bucketKey]
        if type(quests) == "table" then
            for questIndex = 1, #quests do
                local questData = quests[questIndex]
                if type(questData) == "table" and questData.questID == focusedQuestID then
                    table.remove(quests, questIndex)
                    return questData, bucketKey
                end
            end
        end
    end

    if type(C_QuestLog) == "table" and type(C_QuestLog.GetLogIndexForQuestID) == "function" then
        local okIndex, questLogIndex = pcall(C_QuestLog.GetLogIndexForQuestID, focusedQuestID)
        if okIndex and type(questLogIndex) == "number" and questLogIndex > 0 then
            return nil, nil
        end
    end

    return nil, nil
end


function QuestData.GetQuestDifficultyColorMuted(questLevel, playerLevel)
    if type(questLevel) ~= "number" or questLevel <= 0 then
        return QLC.DIFFICULTY_COLORS.standard
    end
    if type(playerLevel) ~= "number" or playerLevel <= 0 then
        playerLevel = UnitLevel("player") or 1
    end
    local greenRange = type(GetQuestGreenRange) == "function" and GetQuestGreenRange() or 8
    local diff = questLevel - playerLevel
    if diff >= 5 then
        return QLC.DIFFICULTY_COLORS.impossible
    elseif diff >= 3 then
        return QLC.DIFFICULTY_COLORS.veryhard
    elseif diff >= -2 then
        return QLC.DIFFICULTY_COLORS.difficult
    elseif diff >= -greenRange - 1 then
        return QLC.DIFFICULTY_COLORS.standard
    else
        return QLC.DIFFICULTY_COLORS.trivial
    end
end


-- ============================================================
-- SECTION: Raw Quest Entry Collection + Quest Context Utils
-- READS: C_QuestLog.GetNumQuestLogEntries/GetInfo/GetObjectives
--   C_TaskQuest, C_CampaignInfo, QLC constants
-- WRITES: entries[] local — no persistent state
-- FUNCTIONS:
--   CollectRawQuestEntries(searchFilter) → entries[]
--     Iterates all quest log slots; reads title, objectives,
--     tags, campaign info. Applies case-insensitive search
--     filter if provided. Returns flat list of entry tables.
--   GetPlayerQuestContext(questID) — reads quest tag, area
--     level, suggested group size for bucketing decisions.
--   IsTimeSensitive(questID) — checks QLC.TIME_SENSITIVE_TAG_IDs.
--   Various difficulty/progress helpers used in bucketing.
-- ============================================================
function QuestData.CollectRawQuestEntries(searchFilter)
    local entries = {}
    if type(C_QuestLog) ~= "table" or type(C_QuestLog.GetNumQuestLogEntries) ~= "function"
        or type(C_QuestLog.GetInfo) ~= "function" then
        return entries
    end

    local okNum, numEntries = pcall(C_QuestLog.GetNumQuestLogEntries)
    if not okNum or type(numEntries) ~= "number" then
        return entries
    end

    local lowerFilter = nil
    if type(searchFilter) == "string" and searchFilter ~= "" then
        lowerFilter = searchFilter:lower()
    end

    local currentHeaderTitle = nil
    for i = 1, numEntries do
        local okInfo, info = pcall(C_QuestLog.GetInfo, i)
        if okInfo and type(info) == "table" and not info.isHidden and not info.isInternalOnly then
            if info.isHeader then
                currentHeaderTitle = info.title or ""
            elseif currentHeaderTitle and info.questID then
                local passesFilter = true
                if lowerFilter then
                    passesFilter = info.title and info.title:lower():find(lowerFilter, 1, true)
                end
                if passesFilter then
                    local objectives = {}
                    if type(C_QuestLog.GetQuestObjectives) == "function" then
                        local okObj, objs = pcall(C_QuestLog.GetQuestObjectives, info.questID)
                        if (not okObj or type(objs) ~= "table") and type(i) == "number" then
                            local okObjByIndex, objsByIndex = pcall(C_QuestLog.GetQuestObjectives, i)
                            if okObjByIndex and type(objsByIndex) == "table" then
                                okObj = true
                                objs = objsByIndex
                            end
                        end
                        if okObj and type(objs) == "table" then
                            objectives = objs
                        end
                    end

                    local questTagInfo = nil
                    if type(C_QuestLog.GetQuestTagInfo) == "function" then
                        local okTag, tag = pcall(C_QuestLog.GetQuestTagInfo, info.questID)
                        if okTag and type(tag) == "table" then
                            questTagInfo = tag
                        end
                    end

                    local questMapID = nil
                    if type(C_QuestLog.GetQuestAdditionalHighlights) == "function" then
                    end
                    if type(C_Map) == "table" and type(C_Map.GetBestMapForUnit) == "function" then
                    end

                    local isSharedInParty = false
                    if type(C_QuestLog.IsQuestSharedWithPlayerByPlayerName) == "function" then
                    end

                    local isComplete = (info.isComplete == true)
                    if not isComplete and info.questID then
                        if type(C_QuestLog) == "table" and type(C_QuestLog.IsComplete) == "function" then
                            local okC, val = pcall(C_QuestLog.IsComplete, info.questID)
                            if okC and val == true then isComplete = true end
                        end
                        if not isComplete and type(C_QuestLog) == "table" and type(C_QuestLog.ReadyForTurnIn) == "function" then
                            local okR, val = pcall(C_QuestLog.ReadyForTurnIn, info.questID)
                            if okR and val == true then isComplete = true end
                        end
                    end

                    entries[#entries + 1] = {
                        title = info.title or "",
                        questID = info.questID,
                        questLogIndex = i,
                        level = info.level,
                        isComplete = isComplete,
                        frequency = info.frequency,
                        objectives = objectives,
                        headerTitle = currentHeaderTitle,
                        suggestedGroup = info.suggestedGroup,
                        isAutoComplete = info.isAutoComplete,
                        questTagInfo = questTagInfo,
                    }
                end
            end
        end
    end
    return entries
end


function QuestData.GetPlayerQuestContext()
    local ctx = {
        playerLevel = UnitLevel("player") or 1,
        zoneName = GetZoneText() or "",
        subZone = GetSubZoneText() or "",
        mapID = nil,
        continentID = nil,
        isInInstance = false,
        instanceName = nil,
        instanceID = nil,
        isInGroup = false,
        groupSize = 0,
        partyQuestIDs = {},
        maxQuests = 35,
        playerX = nil,
        playerY = nil,
        equippedIlvl = QuestData.GetPlayerEquippedIlvl(),
    }

    local inInstance, instanceType = IsInInstance()
    ctx.isInInstance = (inInstance == true or inInstance == 1)
    if ctx.isInInstance then
        local name, _, _, _, _, _, _, instID = GetInstanceInfo()
        ctx.instanceName = name
        ctx.instanceID = instID
    end

    if type(C_Map) == "table" and type(C_Map.GetBestMapForUnit) == "function" then
        local okMap, mapID = pcall(C_Map.GetBestMapForUnit, "player")
        if okMap and type(mapID) == "number" then
            ctx.mapID = mapID
            if type(C_Map.GetMapInfo) == "function" then
                local okInfo, mapInfo = pcall(C_Map.GetMapInfo, mapID)
                if okInfo and type(mapInfo) == "table" then
                    ctx.continentID = mapInfo.parentMapID
                end
            end
            if type(C_Map.GetPlayerMapPosition) == "function" then
                local okPos, pos = pcall(C_Map.GetPlayerMapPosition, mapID, "player")
                if okPos and pos and type(pos.GetXY) == "function" then
                    ctx.playerX, ctx.playerY = pos:GetXY()
                end
            end
        end
    end

    local numGroup = GetNumGroupMembers()
    if type(numGroup) == "number" and numGroup > 0 then
        ctx.isInGroup = true
        ctx.groupSize = numGroup
        if type(C_QuestLog) == "table" and type(C_QuestLog.IsQuestFlaggedCompleted) == "function" then
            for i = 1, numGroup - 1 do
                local unit = (IsInRaid() and ("raid" .. i)) or ("party" .. i)
                if UnitExists(unit) then
                end
            end
        end
    end

    if type(C_QuestLog) == "table" and type(C_QuestLog.GetMaxNumQuestsCanAccept) == "function" then
        local okMax, maxQ = pcall(C_QuestLog.GetMaxNumQuestsCanAccept)
        if okMax and type(maxQ) == "number" and maxQ > 0 then
            ctx.maxQuests = maxQ
        end
    elseif type(MAX_QUESTS) == "number" then
        ctx.maxQuests = MAX_QUESTS
    end

    return ctx
end


function QuestData.IsQuestInCurrentZone(quest, ctx)
    if not quest or not ctx then return false end
    if type(quest.headerTitle) == "string" and type(ctx.zoneName) == "string" then
        if quest.headerTitle == ctx.zoneName then return true end
    end
    if ctx.mapID and type(C_QuestLog) == "table" and type(C_QuestLog.GetQuestAdditionalHighlights) ~= "function" then
        if type(C_QuestLog.IsOnMap) == "function" then
            local okOnMap, onMap = pcall(C_QuestLog.IsOnMap, quest.questID)
            if okOnMap and onMap then return true end
        end
    end
    return false
end

function QuestData.IsGroupOrEliteQuest(quest)
    if not quest then return false end
    local tag = quest.questTagInfo
    if type(tag) == "table" then
        if tag.isElite then return true end
        if type(tag.tagID) == "number" then
            if tag.tagID == 1 or tag.tagID == 62 or tag.tagID == 81 then
                return true
            end
        end
    end
    if type(quest.suggestedGroup) == "number" and quest.suggestedGroup > 1 then
        return true
    end
    return false
end

function QuestData.IsQuestOutleveled(quest, playerLevel)
    if type(quest.level) ~= "number" or quest.level <= 0 then return false end
    local diff = quest.level - playerLevel
    local greenRange = type(GetQuestGreenRange) == "function" and GetQuestGreenRange() or 8
    if diff < -(greenRange + 1) then return true, "trivial" end
    if diff >= 10 then return true, "impossible" end
    return false
end

function QuestData.GetQuestProximityScore(quest, ctx)
    if type(C_QuestLog) == "table" and type(C_QuestLog.GetNextWaypointForMap) == "function"
        and ctx.mapID and ctx.playerX and ctx.playerY then
        local okWP, wpX, wpY = pcall(C_QuestLog.GetNextWaypointForMap, quest.questID, ctx.mapID)
        if okWP and type(wpX) == "number" and type(wpY) == "number" then
            local dx = wpX - ctx.playerX
            local dy = wpY - ctx.playerY
            return math.sqrt(dx * dx + dy * dy)
        end
    end
    if QuestData.IsQuestInCurrentZone(quest, ctx) then
        return 0.5
    end
    return 100
end

function QuestData.FindSynergyGroups(quests)
    local keywordMap = {}
    for idx, quest in ipairs(quests) do
        if type(quest.objectives) == "table" then
            for _, obj in ipairs(quest.objectives) do
                if type(obj) == "table" and type(obj.text) == "string" then
                    local target = obj.text:match("[Kk]ill%s+%d*%s*(.+)")
                        or obj.text:match("[Ss]lay%s+%d*%s*(.+)")
                        or obj.text:match("[Dd]efeat%s+%d*%s*(.+)")
                    if target then
                        target = target:gsub("%s*%d+/%d+%s*$", ""):gsub("^%s+", ""):gsub("%s+$", "")
                        if target ~= "" then
                            local key = target:lower()
                            if not keywordMap[key] then
                                keywordMap[key] = { keyword = target, questIndices = {} }
                            end
                            local indices = keywordMap[key].questIndices
                            local found = false
                            for _, existing in ipairs(indices) do
                                if existing == idx then found = true; break end
                            end
                            if not found then
                                indices[#indices + 1] = idx
                            end
                        end
                    end
                end
            end
        end
    end

    local groups = {}
    for _, data in pairs(keywordMap) do
        if #data.questIndices >= 2 then
            groups[#groups + 1] = data
        end
    end
    return groups
end

function QuestData.IsQuestSharedWithParty(quest, ctx)
    if not ctx.isInGroup then return false end
    if type(C_QuestLog) == "table" and type(C_QuestLog.IsUnitOnQuest) == "function" then
        for i = 1, ctx.groupSize - 1 do
            local unit = (IsInRaid() and ("raid" .. i)) or ("party" .. i)
            if UnitExists(unit) then
                local okOnQ, onQuest = pcall(C_QuestLog.IsUnitOnQuest, unit, quest.questID)
                if okOnQ and onQuest then return true end
            end
        end
    end
    return false
end

function QuestData.IsCampaignQuest(questID)
    if type(questID) ~= "number" then return false end
    if type(C_CampaignInfo) == "table" and type(C_CampaignInfo.GetCampaignID) == "function" then
        local okCamp, campID = pcall(C_CampaignInfo.GetCampaignID, questID)
        if okCamp and type(campID) == "number" and campID > 0 then
            return true
        end
    end
    if type(C_QuestLog) == "table" and type(C_QuestLog.GetQuestTagInfo) == "function" then
        local okTag, tag = pcall(C_QuestLog.GetQuestTagInfo, questID)
        if okTag and type(tag) == "table" and type(tag.tagID) == "number" then
            if tag.tagID == 270 or tag.tagID == 271 then
                return true
            end
        end
    end
    return false
end

function QuestData.GetPlayerEquippedIlvl()
    if type(GetAverageItemLevel) == "function" then
        local okIlvl, avgEquipped = pcall(GetAverageItemLevel)
        if okIlvl and type(avgEquipped) == "number" and avgEquipped > 0 then
            return avgEquipped
        end
    end
    return 0
end

function QuestData.ScanQuestRewardUpgrade(questID, questLogIndex, equippedIlvl)
    if type(equippedIlvl) ~= "number" or equippedIlvl <= 0 then return false, 0 end

    if not QuestData._metaCache[questID] then QuestData._metaCache[questID] = {} end
    local cache = QuestData._metaCache[questID]
    if cache.upgradeScan and cache.equippedIlvl == equippedIlvl then
        return cache.isUpgrade, cache.maxRewardIlvl
    end

    local maxRewardIlvl = 0
    local isUpgrade = false

    local numRewards = 0
    if type(GetNumQuestLogRewards) == "function" then
        local okNum, n = pcall(GetNumQuestLogRewards, questID)
        if okNum and type(n) == "number" then numRewards = n end
    end

    for i = 1, numRewards do
        if type(GetQuestLogRewardInfo) == "function" then
            local okR, name, _, _, quality, _, itemID = pcall(GetQuestLogRewardInfo, i, questID)
            if okR and type(itemID) == "number" and itemID > 0 then
                if type(C_Item) == "table" and type(C_Item.GetDetailedItemLevelInfo) == "function" then
                    local okLvl, effectiveIlvl = pcall(C_Item.GetDetailedItemLevelInfo, itemID)
                    if okLvl and type(effectiveIlvl) == "number" and effectiveIlvl > 0 then
                        if effectiveIlvl > maxRewardIlvl then maxRewardIlvl = effectiveIlvl end
                        if effectiveIlvl > equippedIlvl then isUpgrade = true end
                    end
                end
            end
        end
    end

    local numChoices = 0
    if type(GetNumQuestLogChoices) == "function" then
        local okC, c = pcall(GetNumQuestLogChoices, questID)
        if okC and type(c) == "number" then numChoices = c end
    end

    for i = 1, numChoices do
        if type(GetQuestLogChoiceInfo) == "function" then
            local okC, name, _, _, quality, _, itemID = pcall(GetQuestLogChoiceInfo, i, questID)
            if okC and type(itemID) == "number" and itemID > 0 then
                if type(C_Item) == "table" and type(C_Item.GetDetailedItemLevelInfo) == "function" then
                    local okLvl, effectiveIlvl = pcall(C_Item.GetDetailedItemLevelInfo, itemID)
                    if okLvl and type(effectiveIlvl) == "number" and effectiveIlvl > 0 then
                        if effectiveIlvl > maxRewardIlvl then maxRewardIlvl = effectiveIlvl end
                        if effectiveIlvl > equippedIlvl then isUpgrade = true end
                    end
                end
            end
        end
    end

    cache.upgradeScan = true
    cache.equippedIlvl = equippedIlvl
    cache.isUpgrade = isUpgrade
    cache.maxRewardIlvl = maxRewardIlvl
    return isUpgrade, maxRewardIlvl
end


function QuestData.GetTimeSensitivePriority(quest)
    local multiplier = 1.0
    local tag = quest.questTagInfo

    if type(tag) == "table" and type(tag.tagID) == "number" then
        if QLC.TIME_SENSITIVE_TAG_IDS[tag.tagID] then
            multiplier = 2.0
        end
    end

    if type(quest.frequency) == "number" then
        local daily = _G.LE_QUEST_FREQUENCY_DAILY
        local weekly = _G.LE_QUEST_FREQUENCY_WEEKLY
        if quest.frequency == daily then
            multiplier = math.max(multiplier, 1.5)
        elseif quest.frequency == weekly then
            multiplier = math.max(multiplier, 1.3)
        end
    end

    if type(C_TaskQuest) == "table" and type(C_TaskQuest.GetQuestTimeLeftSeconds) == "function" then
        local okTime, timeLeft = pcall(C_TaskQuest.GetQuestTimeLeftSeconds, quest.questID)
        if okTime and type(timeLeft) == "number" and timeLeft > 0 then
            quest._expiresInSeconds = timeLeft
            if timeLeft <= 7200 then
                multiplier = math.max(multiplier, 3.0)
                quest._isExpiringSoon = true
            elseif timeLeft <= 28800 then
                multiplier = math.max(multiplier, 2.0)
            end
        end
    end

    if type(C_QuestLog) == "table" and type(C_QuestLog.GetTimeAllowed) == "function" then
        local okTA, totalTime, elapsedTime = pcall(C_QuestLog.GetTimeAllowed, quest.questID)
        if okTA and type(totalTime) == "number" and totalTime > 0
            and type(elapsedTime) == "number" then
            local remaining = totalTime - elapsedTime
            if remaining > 0 then
                quest._expiresInSeconds = quest._expiresInSeconds
                    and math.min(quest._expiresInSeconds, remaining) or remaining
                if remaining <= 600 then
                    multiplier = math.max(multiplier, 4.0)
                    quest._isExpiringSoon = true
                elseif remaining <= 3600 then
                    multiplier = math.max(multiplier, 2.5)
                    quest._isExpiringSoon = true
                end
            end
        end
    end

    return multiplier
end

function QuestData.GetNearestFlightNodeForQuest(quest, ctx)
    if not ctx.mapID or not ctx.continentID then return nil end
    if type(C_TaxiMap) ~= "table" or type(C_TaxiMap.GetAllTaxiNodes) ~= "function" then
        return nil
    end

    if not QuestData._metaCache[quest.questID] then QuestData._metaCache[quest.questID] = {} end
    if QuestData._metaCache[quest.questID].flightNodeScanned then
        return QuestData._metaCache[quest.questID].flightNode
    end

    local questX, questY = nil, nil
    if type(C_QuestLog) == "table" and type(C_QuestLog.GetNextWaypointForMap) == "function" then
        local okWP, wpX, wpY = pcall(C_QuestLog.GetNextWaypointForMap, quest.questID, ctx.continentID)
        if okWP and type(wpX) == "number" and type(wpY) == "number" then
            questX, questY = wpX, wpY
        end
        if not questX then
            okWP, wpX, wpY = pcall(C_QuestLog.GetNextWaypointForMap, quest.questID, ctx.mapID)
            if okWP and type(wpX) == "number" and type(wpY) == "number" then
                questX, questY = wpX, wpY
            end
        end
    end

    if not questX or not questY then return nil end

    local okNodes, nodes = pcall(C_TaxiMap.GetAllTaxiNodes, ctx.continentID)
    if not okNodes or type(nodes) ~= "table" then return nil end

    local bestNode = nil
    local bestDist = math.huge

    for _, node in ipairs(nodes) do
        if type(node) == "table" and node.position
            and (node.state == Enum.FlightPathState.Reachable or node.state == _G.TAXISTATE_CURRENT or node.state == 2 or node.state == 1) then
            local nx = node.position.x or 0
            local ny = node.position.y or 0
            local dx = nx - questX
            local dy = ny - questY
            local dist = dx * dx + dy * dy
            if dist < bestDist then
                bestDist = dist
                bestNode = {
                    nodeID = node.nodeID,
                    name = node.name or "Flight Point",
                    distance = math.sqrt(dist),
                }
            end
        end
    end

    QuestData._metaCache[quest.questID].flightNodeScanned = true
    QuestData._metaCache[quest.questID].flightNode = bestNode
    return bestNode
end


-- ============================================================
-- SECTION: Quest Bucketing + List Data Pipeline
-- READS: CollectRawQuestEntries output, QLC bucket config,
--   QLC.EXPIRING_EXTRACT_THRESHOLD, _collapseState
-- WRITES: data{} return table with campaign, buckets,
--   now/next/later/turnIn/expiring sub-lists
-- FUNCTIONS:
--   CollectQuestListData(searchFilter) → data{}
--     Master pipeline: collects raw entries, extracts campaign
--     quests, separates turnIn/expiring conditionals, assigns
--     remaining quests to now/next/later based on proximity
--     (task quests in area = NOW) and time-sensitivity flags.
--     Returns structured data table for QuestLog_Panel.
-- ============================================================
function QuestData.CollectQuestListData(searchFilter)
    local data = {
        campaign = nil,
        headers = {},
        zoneName = "",
        zoneQuestCount = 0,
        buckets = { now = {}, next = {}, later = {} },
        turnIn = {},
        expiring = {},
        synergyGroups = {},
        totalQuestCount = 0,
        maxQuests = 35,
    }

    if type(C_CampaignInfo) == "table" then
        local getCurrent = C_CampaignInfo.GetCurrentCampaignID

        -- Try GetCurrentCampaignID
        local okCid, campaignID = false, nil
        if type(getCurrent) == "function" then
            okCid, campaignID = pcall(getCurrent)
        end

        -- Fallback: scan quest log for campaign-tagged quests
        if not campaignID or campaignID == 0 then
            local foundCampaignIDs = {}
            if type(C_QuestLog) == "table" and type(C_QuestLog.GetNumQuestLogEntries) == "function" then
                local okNum, numEntries = pcall(C_QuestLog.GetNumQuestLogEntries)
                if okNum and numEntries then
                    for i = 1, numEntries do
                        local okQ, qInfo = pcall(C_QuestLog.GetInfo, i)
                        if okQ and qInfo and qInfo.questID then
                            local okCamp, qCampID = pcall(function()
                                return C_CampaignInfo.GetCampaignID(qInfo.questID)
                            end)
                            if okCamp and qCampID and qCampID > 0 then
                                foundCampaignIDs[qCampID] = true
                            end
                        end
                    end
                end
            end
            for id in pairs(foundCampaignIDs) do
                campaignID = id
                okCid = true
                break
            end
        end

        if okCid and type(campaignID) == "number" and campaignID > 0 then
            local okInfo, info = pcall(C_CampaignInfo.GetCampaignInfo, campaignID)
            if okInfo and type(info) == "table" and info.name then
                local completed = 0
                local total = 0
                local currentChapter = nil
                local chapterList = {}
                local chapters = nil

                -- Try GetCampaignChapterInfo with campaign ID
                if type(C_CampaignInfo.GetCampaignChapterInfo) == "function" then
                    local okCh, chResult = pcall(C_CampaignInfo.GetCampaignChapterInfo, campaignID)
                    if okCh and type(chResult) == "table" then
                        chapters = chResult
                    end
                end

                -- Fallback: GetChapterIDs + individual chapter lookups
                if not chapters or #chapters == 0 then
                    if type(C_CampaignInfo.GetChapterIDs) == "function" then
                        local okIds, chIDs = pcall(C_CampaignInfo.GetChapterIDs, campaignID)
                        if okIds and type(chIDs) == "table" and #chIDs > 0 then
                            chapters = {}
                            for _, chID in ipairs(chIDs) do
                                local okCh2, chInfo = pcall(C_CampaignInfo.GetCampaignChapterInfo, chID)
                                if okCh2 and type(chInfo) == "table" then
                                    chapters[#chapters + 1] = chInfo
                                else
                                    chapters[#chapters + 1] = { name = "Chapter " .. chID }
                                end
                            end
                        end
                    end
                end

                -- Get current chapter ID for ordering
                local currentChapterID = nil
                if type(C_CampaignInfo.GetCurrentChapterID) == "function" then
                    local okCur, curChID = pcall(C_CampaignInfo.GetCurrentChapterID, campaignID)
                    if okCur and curChID then currentChapterID = curChID end
                end

                -- Build chapter IDs list for ordering
                local chapterIDs = nil
                if type(C_CampaignInfo.GetChapterIDs) == "function" then
                    local okIds, ids = pcall(C_CampaignInfo.GetChapterIDs, campaignID)
                    if okIds and type(ids) == "table" then
                        chapterIDs = ids
                    end
                end

                if chapters and #chapters > 0 then
                    total = #chapters

                    -- Determine completed status from currentChapterID ordering
                    local currentIdx = nil
                    if currentChapterID and chapterIDs then
                        for i, chID in ipairs(chapterIDs) do
                            if chID == currentChapterID then
                                currentIdx = i
                                break
                            end
                        end
                    end

                    for i, ch in ipairs(chapters) do
                        local chName = ch.name or ("Chapter " .. i)
                        local isCompleted = false
                        local isCurrent = false

                        if currentIdx then
                            if i < currentIdx then
                                isCompleted = true
                            elseif i == currentIdx then
                                isCurrent = true
                            end
                        end

                        if isCompleted then
                            completed = completed + 1
                        elseif isCurrent and not currentChapter then
                            currentChapter = chName
                        elseif not currentChapter and not isCompleted then
                            currentChapter = chName
                        end

                        -- Try to get quests via C_QuestLine using the chapter ID
                        local questNames = {}
                        local chID = chapterIDs and chapterIDs[i]
                        if chID and type(C_QuestLine) == "table"
                            and type(C_QuestLine.GetQuestLineQuests) == "function" then
                            local okQL, qlQuests = pcall(C_QuestLine.GetQuestLineQuests, chID)
                            if okQL and type(qlQuests) == "table" and #qlQuests > 0 then
                                for _, qid in ipairs(qlQuests) do
                                    local qTitle = C_QuestLog.GetTitleForQuestID and C_QuestLog.GetTitleForQuestID(qid) or "?"
                                    local qDone = C_QuestLog.IsQuestFlaggedCompleted and C_QuestLog.IsQuestFlaggedCompleted(qid) or false
                                    questNames[#questNames + 1] = { name = qTitle, completed = qDone, questID = qid }
                                end
                            end
                        end

                        chapterList[i] = {
                            name = chName,
                            completed = isCompleted,
                            isCurrent = isCurrent,
                            quests = questNames,
                        }
                    end
                end

                local campaignTotal = total
                if campaignTotal < 1 then campaignTotal = 1 end
                local campaignProgress = completed
                if campaignProgress < 0 then campaignProgress = 0
                elseif campaignProgress > campaignTotal then campaignProgress = campaignTotal end

                data.campaign = {
                    name = info.name,
                    progress = campaignProgress,
                    total = campaignTotal,
                    chapterName = currentChapter,
                    chapters = chapterList,
                }
            end
        end
    end

    local rawEntries = QuestData.CollectRawQuestEntries(searchFilter)
    local ctx = QuestData.GetPlayerQuestContext()
    data.zoneName = ctx.zoneName
    data.maxQuests = ctx.maxQuests
    data.totalQuestCount = #rawEntries

    local headerMap = {}
    for _, entry in ipairs(rawEntries) do
        local ht = entry.headerTitle or "Quests"
        if not headerMap[ht] then
            headerMap[ht] = { title = ht, quests = {} }
            data.headers[#data.headers + 1] = headerMap[ht]
        end
        headerMap[ht].quests[#headerMap[ht].quests + 1] = entry
    end

    local nowQuests = {}
    local nextQuests = {}
    local laterQuests = {}
    local zoneQuestCount = 0

    for _, quest in ipairs(rawEntries) do
        local _, _, pct = QuestData.ResolveQuestObjectiveProgress(quest)
        quest._progressPct = pct or 0
        quest._isInCurrentZone = QuestData.IsQuestInCurrentZone(quest, ctx)
        quest._isGroupElite = QuestData.IsGroupOrEliteQuest(quest)
        quest._isSharedParty = QuestData.IsQuestSharedWithParty(quest, ctx)
        quest._proximityScore = QuestData.GetQuestProximityScore(quest, ctx)
        quest._difficultyColor = QuestData.GetQuestDifficultyColorMuted(quest.level, ctx.playerLevel)

        quest._isCampaignQuest = QuestData.IsCampaignQuest(quest.questID)

        quest._isUpgrade = false
        quest._rewardIlvl = 0
        if ctx.equippedIlvl > 0 then
            local isUp, maxIlvl = QuestData.ScanQuestRewardUpgrade(quest.questID, quest.questLogIndex, ctx.equippedIlvl)
            quest._isUpgrade = isUp
            quest._rewardIlvl = maxIlvl
        end

        quest._timePriority = QuestData.GetTimeSensitivePriority(quest)

        quest._nearestFlightNode = nil
        if not quest._isInCurrentZone then
            quest._nearestFlightNode = QuestData.GetNearestFlightNodeForQuest(quest, ctx)
        end

        if quest._isInCurrentZone then
            zoneQuestCount = zoneQuestCount + 1
        end

        local bucket = nil

        local outleveled, outlevelReason = QuestData.IsQuestOutleveled(quest, ctx.playerLevel)
        if outleveled then
            quest._laterReason = outlevelReason == "trivial" and "Outleveled (Trivial)" or "Outleveled (Too Hard)"
            bucket = "later"
        elseif quest._isGroupElite and not ctx.isInGroup then
            quest._laterReason = "Elite/Group (Solo)"
            bucket = "later"
        end

        if not bucket and ctx.isInInstance then
            if quest._isInCurrentZone then
                bucket = "now"
            else
                bucket = "later"
                quest._laterReason = "Outside Instance"
            end
        end

        if not bucket and quest.isComplete then
            bucket = "now"
            quest._nowReason = "Ready for Turn-in"
        end

        if not bucket and quest._progressPct >= QLC.NEARLY_COMPLETE_THRESHOLD and quest._isInCurrentZone then
            bucket = "now"
            quest._nowReason = "Almost Done"
            quest._isNearlyComplete = true
        end

        if not bucket and quest._isInCurrentZone then
            bucket = "now"
        end

        if not bucket then
            if quest.isComplete then
                bucket = "next"
                quest._nextReason = "Turn-in Elsewhere"
            else
                bucket = "next"
            end
        end

        if quest._isSharedParty then
            quest._coopBump = true
        end

        if bucket == "now" then
            nowQuests[#nowQuests + 1] = quest
        elseif bucket == "next" then
            nextQuests[#nextQuests + 1] = quest
        else
            laterQuests[#laterQuests + 1] = quest
        end
    end

    table.sort(nowQuests, function(a, b)
        local aTP = a._timePriority or 1
        local bTP = b._timePriority or 1
        if aTP ~= bTP then return aTP > bTP end
        if a._isUpgrade and not b._isUpgrade then return true end
        if not a._isUpgrade and b._isUpgrade then return false end
        if a._isNearlyComplete and not b._isNearlyComplete then return true end
        if not a._isNearlyComplete and b._isNearlyComplete then return false end
        if a.isComplete and not b.isComplete then return true end
        if not a.isComplete and b.isComplete then return false end
        if a._coopBump and not b._coopBump then return true end
        if not a._coopBump and b._coopBump then return false end
        if a._isCampaignQuest and not b._isCampaignQuest then return true end
        if not a._isCampaignQuest and b._isCampaignQuest then return false end
        return (a._progressPct or 0) > (b._progressPct or 0)
    end)

    table.sort(nextQuests, function(a, b)
        local aTP = a._timePriority or 1
        local bTP = b._timePriority or 1
        if aTP ~= bTP then return aTP > bTP end
        if a._isUpgrade and not b._isUpgrade then return true end
        if not a._isUpgrade and b._isUpgrade then return false end
        if a.isComplete and not b.isComplete then return true end
        if not a.isComplete and b.isComplete then return false end
        if a._coopBump and not b._coopBump then return true end
        if not a._coopBump and b._coopBump then return false end
        if a._isCampaignQuest and not b._isCampaignQuest then return true end
        if not a._isCampaignQuest and b._isCampaignQuest then return false end
        local aNode = a._nearestFlightNode and a._nearestFlightNode.nodeID or 0
        local bNode = b._nearestFlightNode and b._nearestFlightNode.nodeID or 0
        if aNode ~= bNode then
            local aDist = a._nearestFlightNode and a._nearestFlightNode.distance or 999
            local bDist = b._nearestFlightNode and b._nearestFlightNode.distance or 999
            if math.abs(aDist - bDist) > 0.01 then return aDist < bDist end
            return aNode < bNode
        end
        return (a._proximityScore or 100) < (b._proximityScore or 100)
    end)

    table.sort(laterQuests, function(a, b)
        local aT = (a._laterReason == "Outleveled (Trivial)") and 1 or 0
        local bT = (b._laterReason == "Outleveled (Trivial)") and 1 or 0
        if aT ~= bT then return aT < bT end
        return (a._progressPct or 0) > (b._progressPct or 0)
    end)

    data.synergyGroups = QuestData.FindSynergyGroups(nowQuests)

    for _, group in ipairs(data.synergyGroups) do
        for _, idx in ipairs(group.questIndices) do
            if nowQuests[idx] then
                nowQuests[idx]._synergyKeyword = group.keyword
            end
        end
    end

    local turnInQuests = {}
    local expiringQuests = {}
    local bucketArrays = { nowQuests, nextQuests, laterQuests }
    local bucketKeyNames = { "now", "next", "later" }

    for bi = 1, #bucketArrays do
        local arr = bucketArrays[bi]
        for i = #arr, 1, -1 do
            if arr[i].isComplete then
                local quest = table.remove(arr, i)
                quest._originalBucket = bucketKeyNames[bi]
                turnInQuests[#turnInQuests + 1] = quest
            end
        end
    end

    local expiringThreshold = QLC.EXPIRING_EXTRACT_THRESHOLD or 7200
    for bi = 1, #bucketArrays do
        local arr = bucketArrays[bi]
        for i = #arr, 1, -1 do
            local quest = arr[i]
            if quest._isExpiringSoon
                or (type(quest._expiresInSeconds) == "number"
                    and quest._expiresInSeconds > 0
                    and quest._expiresInSeconds <= expiringThreshold) then
                quest = table.remove(arr, i)
                quest._originalBucket = bucketKeyNames[bi]
                expiringQuests[#expiringQuests + 1] = quest
            end
        end
    end

    table.sort(turnInQuests, function(a, b)
        if a._isInCurrentZone and not b._isInCurrentZone then return true end
        if not a._isInCurrentZone and b._isInCurrentZone then return false end
        if a._isUpgrade and not b._isUpgrade then return true end
        if not a._isUpgrade and b._isUpgrade then return false end
        if a._isCampaignQuest and not b._isCampaignQuest then return true end
        if not a._isCampaignQuest and b._isCampaignQuest then return false end
        return (a._proximityScore or 100) < (b._proximityScore or 100)
    end)

    table.sort(expiringQuests, function(a, b)
        local aTime = a._expiresInSeconds or 999999
        local bTime = b._expiresInSeconds or 999999
        if aTime ~= bTime then return aTime < bTime end
        return (a._proximityScore or 100) < (b._proximityScore or 100)
    end)

    data.turnIn = turnInQuests
    data.expiring = expiringQuests
    data.buckets.now = nowQuests
    data.buckets.next = nextQuests
    data.buckets.later = laterQuests
    data.zoneQuestCount = zoneQuestCount

    return data
end

-- ============================================================
-- SECTION: Display Utilities + Progress Helpers + Export
-- READS: C_QuestLog quest info, QLC.NEARLY_COMPLETE_THRESHOLD
-- WRITES: nothing (pure query functions)
-- FUNCTIONS:
--   ResolveCampaignCardFallback(headers) — picks fallback
--     campaign card if no explicit campaign quest is tracked.
--   GetQuestProgressFraction(questID) → 0..1 — ratio of
--     completed objectives to total objectives.
--   IsQuestNearlyComplete(questID) — progress ≥ threshold.
--   GetQuestDisplayTitle(questID) — returns display title with
--     difficulty prefix if applicable.
--   Additional helpers for zone grouping and sort order.
-- Final line exports QuestData as Addon.QuestData.
-- ============================================================
function QuestData.ResolveCampaignCardFallback(headers)
    if type(headers) ~= "table" then
        return nil
    end

    local firstHeaderTitle = nil
    local totalQuests = 0
    local completedQuests = 0
    for _, header in ipairs(headers) do
        if type(header) == "table" and type(header.quests) == "table" then
            if not firstHeaderTitle and type(header.title) == "string" and header.title ~= "" then
                firstHeaderTitle = header.title
            end
            for _, quest in ipairs(header.quests) do
                totalQuests = totalQuests + 1
                if type(quest) == "table" and quest.isComplete then
                    completedQuests = completedQuests + 1
                end
            end
        end
    end

    if totalQuests < 1 then
        return nil
    end

    local name = firstHeaderTitle or "Campaign"
    local chapterName = string.format("%d Active Quests", totalQuests)
    local questList = {}
    for _, header in ipairs(headers) do
        if type(header) == "table" and type(header.quests) == "table" then
            for _, quest in ipairs(header.quests) do
                if type(quest) == "table" then
                    questList[#questList + 1] = {
                        name = quest.title or quest.name or "Unknown",
                        completed = quest.isComplete == true,
                    }
                end
            end
        end
    end
    return {
        name = name,
        chapterName = chapterName,
        progress = completedQuests,
        total = totalQuests,
        quests = questList,
    }
end


function QuestData.CoerceObjectiveFlag(value)
    local valueType = type(value)
    if valueType == "boolean" then
        return value
    end
    if valueType == "number" then
        return value ~= 0
    end
    if valueType == "string" then
        local lowered = value:lower()
        if lowered == "1" or lowered == "true" or lowered == "yes" then
            return true
        end
        if lowered == "0" or lowered == "false" or lowered == "no" then
            return false
        end
    end
    return nil
end

function QuestData.NormalizeQuestObjectiveText(text)
    if type(text) ~= "string" then return nil end
    local compact = text:gsub("\r", " "):gsub("\n", " ")
    compact = compact:gsub("%s+", " ")
    compact = compact:gsub("^%s+", ""):gsub("%s+$", "")
    if compact == "" then
        return nil
    end
    return compact
end

function QuestData.ParseObjectiveFractionFromText(text)
    if type(text) ~= "string" then
        return nil, nil
    end

    local lhs, rhs = text:match("([%d,]+)%s*/%s*([%d,]+)")
    if lhs and rhs then
        local fulfilled = tonumber((lhs:gsub(",", "")))
        local required = tonumber((rhs:gsub(",", "")))
        if type(fulfilled) == "number" and type(required) == "number" and required > 0 then
            return fulfilled, required
        end
    end

    lhs, rhs = text:match("([%d,]+)%s+[oO][fF]%s+([%d,]+)")
    if lhs and rhs then
        local fulfilled = tonumber((lhs:gsub(",", "")))
        local required = tonumber((rhs:gsub(",", "")))
        if type(fulfilled) == "number" and type(required) == "number" and required > 0 then
            return fulfilled, required
        end
    end

    return nil, nil
end

function QuestData.ResolveSingleObjectiveProgress(objective)
    if type(objective) ~= "table" then
        return nil
    end

    local done = QuestData.CoerceObjectiveFlag(objective.finished)
    if done == nil then
        done = QuestData.CoerceObjectiveFlag(objective.isCompleted)
    end
    if done == nil then
        done = QuestData.CoerceObjectiveFlag(objective.completed)
    end
    if done == nil then
        done = QuestData.CoerceObjectiveFlag(objective.isFinished)
    end

    local text = QuestData.NormalizeQuestObjectiveText(objective.text)
    local fulfilled = objective.numFulfilled
    local required = objective.numRequired
    if type(fulfilled) ~= "number" then
        fulfilled = objective.fulfilled
    end
    if type(required) ~= "number" then
        required = objective.required
    end
    if type(required) ~= "number" then
        required = objective.numNeeded
    end

    if type(fulfilled) ~= "number" or type(required) ~= "number" or required <= 0 then
        local parsedFulfilled, parsedRequired = QuestData.ParseObjectiveFractionFromText(text)
        if type(parsedFulfilled) == "number" and type(parsedRequired) == "number" and parsedRequired > 0 then
            fulfilled = parsedFulfilled
            required = parsedRequired
        else
            fulfilled = nil
            required = nil
        end
    end

    local fraction = nil
    if type(fulfilled) == "number" and type(required) == "number" and required > 0 then
        fraction = fulfilled / required
        if fraction < 0 then
            fraction = 0
        elseif fraction > 1 then
            fraction = 1
        end
        if done == nil then
            done = fulfilled >= required
        end
    elseif done ~= nil then
        fraction = done and 1 or 0
    end

    local normalizedText = text
    if not normalizedText and type(fulfilled) == "number" and type(required) == "number" and required > 0 then
        normalizedText = "Objective"
    end

    if done == true then
        fraction = 1
    end

    if done == nil and fraction == nil and not normalizedText then
        return nil
    end

    return {
        done = done,
        fraction = fraction,
        text = normalizedText,
        fulfilled = fulfilled,
        required = required,
    }
end


function QuestData.BuildQuestObjectiveDisplayRows(questData)
    local rows = {}
    local objectives = questData and questData.objectives
    if type(objectives) ~= "table" then
        return rows
    end

    for _, objective in ipairs(objectives) do
        local state = QuestData.ResolveSingleObjectiveProgress(objective)
        if state and state.text then
            local progressText = nil
            if type(state.fulfilled) == "number" and type(state.required) == "number" and state.required > 0 then
                local displayFulfilled = state.fulfilled
                if displayFulfilled < 0 then
                    displayFulfilled = 0
                elseif displayFulfilled > state.required then
                    displayFulfilled = state.required
                end
                local hasInlineProgress = QuestData.ParseObjectiveFractionFromText(state.text)
                if hasInlineProgress == nil then
                    progressText = string.format("%d/%d", displayFulfilled, state.required)
                end
            end
            rows[#rows + 1] = {
                text = state.text,
                finished = (state.done == true),
                progressText = progressText,
            }
        end
    end

    return rows
end

function QuestData.ResolveQuestObjectiveProgress(questData)
    if questData and questData.isComplete then
        return 1, 1, 1.0
    end

    local objectives = questData and questData.objectives
    if type(objectives) ~= "table" or #objectives == 0 then
        return 0, 1, 0.0
    end

    local completed = 0
    local total = 0
    local summedFraction = 0.0

    for _, objective in ipairs(objectives) do
        local state = QuestData.ResolveSingleObjectiveProgress(objective)
        if state then
            total = total + 1
            local f = 0.0
            if state.done == true then
                completed = completed + 1
                f = 1.0
            elseif type(state.fraction) == "number" then
                f = state.fraction
                if f < 0 then f = 0 end
                if f > 1 then f = 1 end
            end
            summedFraction = summedFraction + f
        end
    end

    if total < 1 then
        return 0, 1, 0.0
    end

    local progressPct = summedFraction / total
    if progressPct < 0 then progressPct = 0.0 end
    if progressPct > 1 then progressPct = 1.0 end

    return completed, total, progressPct
end


Addon.QuestData = QuestData
