local _, ns = ...
local MythicTools = ns.MythicTools

local function GetDamageMeterTypeValue(fallback, ...)
    local damageMeterTypes = Enum and Enum.DamageMeterType or nil
    if type(damageMeterTypes) ~= "table" then
        return fallback
    end

    for index = 1, select("#", ...) do
        local key = select(index, ...)
        local value = damageMeterTypes[key]
        if type(value) == "number" then
            return value
        end
    end

    return fallback
end

local DAMAGE_DONE = GetDamageMeterTypeValue(0, "DamageDone", "Damage")
local HEALING_DONE = GetDamageMeterTypeValue(2, "HealingDone", "Healing")
local INTERRUPTS = GetDamageMeterTypeValue(5, "Interrupts", "Interrupt")
local DISPELS = GetDamageMeterTypeValue(nil, "Dispels", "Dispel")
local AVOIDABLE_DAMAGE = GetDamageMeterTypeValue(nil, "AvoidableDamage", "AvoidableDamageTaken", "DamageAvoidable", "DamageTakenAvoidable")
local DAMAGE_METER_OVERALL_SESSION = Enum and Enum.DamageMeterSessionType and Enum.DamageMeterSessionType.Overall or 0
local COMPLETION_RETRY_DELAY = 0.35
local MAX_COMPLETION_ATTEMPTS = 24
local INACTIVE_RESOLUTION_DELAY = 5
local LOOT_TRACKING_TIMEOUT = 5 * 60
local DEBUG_EVENT_BUFFER_SIZE = 30
local MAX_TOOLTIP_SPELL_ENTRIES = 7
local INSPECT_REQUEST_TIMEOUT = 1.5
local INSPECT_MAX_ATTEMPTS = 4

local function NormalizeCapturedItemLevel(value)
    if MythicTools and MythicTools.NormalizeItemLevel then
        return MythicTools:NormalizeItemLevel(value)
    end

    value = tonumber(value)
    if value and value > 0 then
        return value
    end

    return nil
end

local function GetCompletionMemberItemLevel(member)
    if type(member) ~= "table" then
        return nil
    end

    return NormalizeCapturedItemLevel(member.itemLevel)
        or NormalizeCapturedItemLevel(member.equippedItemLevel)
        or NormalizeCapturedItemLevel(member.avgItemLevel)
        or NormalizeCapturedItemLevel(member.averageItemLevel)
        or NormalizeCapturedItemLevel(member.ilvl)
end

local function NormalizeCompletionInfo(completionInfo)
    if type(completionInfo) ~= "table" then
        return nil
    end

    local mapChallengeModeID = tonumber(completionInfo.mapChallengeModeID)
        or tonumber(completionInfo.challengeModeMapID)
        or tonumber(completionInfo.mapID)
        or tonumber(completionInfo.mapId)
        or 0
    local level = tonumber(completionInfo.level)
        or tonumber(completionInfo.keystoneLevel)
        or tonumber(completionInfo.keystone_level)
        or 0
    local timeMS = tonumber(completionInfo.time)
        or tonumber(completionInfo.timeMS)
        or tonumber(completionInfo.durationMS)
        or tonumber(completionInfo.completionTimeMS)
        or tonumber(completionInfo.runTimeMS)
        or tonumber(completionInfo.durationMillis)
        or 0
    if timeMS <= 0 then
        local durationSeconds = tonumber(completionInfo.durationSec)
            or tonumber(completionInfo.durationSeconds)
            or tonumber(completionInfo.duration)
            or 0
        if durationSeconds > 0 then
            timeMS = math.floor((durationSeconds * 1000) + 0.5)
        end
    end

    return {
        mapChallengeModeID = mapChallengeModeID,
        level = level,
        time = timeMS,
        onTime = completionInfo.onTime,
        keystoneUpgradeLevels = completionInfo.keystoneUpgradeLevels,
        practiceRun = completionInfo.practiceRun,
        oldOverallDungeonScore = completionInfo.oldOverallDungeonScore,
        newOverallDungeonScore = completionInfo.newOverallDungeonScore,
        isMapRecord = completionInfo.isMapRecord,
        isAffixRecord = completionInfo.isAffixRecord,
        primaryAffix = completionInfo.primaryAffix,
        isEligibleForScore = completionInfo.isEligibleForScore,
        members = completionInfo.members,
    }
end

local function GetChallengeCompletionInfoCompat()
    if C_ChallengeMode.GetChallengeCompletionInfo then
        local info = NormalizeCompletionInfo(C_ChallengeMode.GetChallengeCompletionInfo())
        if type(info) == "table" and next(info) then
            return info
        end
    end

    if C_ChallengeMode.GetCompletionInfo then
        local mapChallengeModeID, level, timeMS, onTime, keystoneUpgradeLevels, practiceRun, oldOverallDungeonScore, newOverallDungeonScore, isMapRecord, isAffixRecord, primaryAffix, isEligibleForScore, members = C_ChallengeMode.GetCompletionInfo()
        if mapChallengeModeID then
            return NormalizeCompletionInfo({
                mapChallengeModeID = mapChallengeModeID,
                level = level,
                time = timeMS,
                onTime = onTime,
                keystoneUpgradeLevels = keystoneUpgradeLevels,
                practiceRun = practiceRun,
                oldOverallDungeonScore = oldOverallDungeonScore,
                newOverallDungeonScore = newOverallDungeonScore,
                isMapRecord = isMapRecord,
                isAffixRecord = isAffixRecord,
                primaryAffix = primaryAffix,
                isEligibleForScore = isEligibleForScore,
                members = members,
            })
        end
    end

    return nil
end

local function IsValidCompletionInfo(info)
    return type(info) == "table"
        and (tonumber(info.mapChallengeModeID) or 0) > 0
        and (tonumber(info.level) or 0) > 0
        and (tonumber(info.time) or 0) > 0
end

local function GetMapInfo(mapChallengeModeID)
    if not (C_ChallengeMode and C_ChallengeMode.GetMapUIInfo and mapChallengeModeID and mapChallengeModeID ~= 0) then
        return nil
    end

    local dungeonName, challengeMapID, timeLimit, textureFileID, backgroundTextureFileID, uiMapID = C_ChallengeMode.GetMapUIInfo(mapChallengeModeID)
    return {
        dungeonName = dungeonName,
        mapID = uiMapID or challengeMapID,
        timeLimit = timeLimit,
        textureFileID = textureFileID,
        backgroundTextureFileID = backgroundTextureFileID,
    }
end

local function BuildCompletionMembersFromActiveRun(activeRun)
    if type(activeRun) ~= "table" then
        return nil
    end

    local members = {}
    for _, fullName in ipairs(activeRun.participantOrder or {}) do
        local stat = activeRun.playerStats and activeRun.playerStats[fullName] or nil
        members[#members + 1] = {
            name = fullName,
            guid = stat and stat.guid or nil,
            classFilename = stat and stat.classFilename or nil,
            role = stat and stat.role or nil,
            specID = stat and stat.specID or nil,
            specName = stat and stat.specName or nil,
            specIconID = stat and stat.specIconID or nil,
            itemLevel = stat and stat.itemLevel or nil,
        }
    end

    return #members > 0 and members or nil
end

local function SetCachedCompletionInfo(activeRun, completionInfo, source)
    if type(activeRun) ~= "table" or not IsValidCompletionInfo(completionInfo) then
        return nil
    end

    activeRun.pendingCompletionInfo = completionInfo
    activeRun.pendingCompletionSource = source or activeRun.pendingCompletionSource or "unknown"
    return completionInfo
end

local function BuildRecoveredCompletionInfo(activeRun)
    if type(activeRun) ~= "table" then
        return nil
    end

    local mapChallengeModeID = tonumber(activeRun.mapChallengeModeID) or 0
    local level = tonumber(activeRun.keyLevel) or 0
    local completedAt = tonumber(activeRun.completedAt) or time()
    local startedAt = tonumber(activeRun.startedAt) or completedAt
    local elapsedMS = math.max(0, (completedAt - startedAt) * 1000)
    if mapChallengeModeID <= 0 or level <= 0 or elapsedMS <= 0 then
        return nil
    end

    local mapInfo = GetMapInfo(mapChallengeModeID)
    local timeLimitSeconds = tonumber(mapInfo and mapInfo.timeLimit) or tonumber(activeRun.timeLimitSeconds) or 0

    return {
        mapChallengeModeID = mapChallengeModeID,
        level = level,
        time = elapsedMS,
        onTime = timeLimitSeconds > 0 and elapsedMS <= (timeLimitSeconds * 1000) or nil,
        keystoneUpgradeLevels = 0,
        practiceRun = false,
        oldOverallDungeonScore = nil,
        newOverallDungeonScore = nil,
        isMapRecord = nil,
        isAffixRecord = nil,
        primaryAffix = nil,
        isEligibleForScore = nil,
        members = BuildCompletionMembersFromActiveRun(activeRun),
    }
end

local function CopySet(source)
    local copy = {}
    for key, value in pairs(source or {}) do
        copy[key] = value
    end
    return copy
end

local function MergeSessionSet(target, source, excluded)
    local changed = false
    if type(target) ~= "table" then
        return false
    end

    for sessionID in pairs(source or {}) do
        if not (excluded and excluded[sessionID]) and not target[sessionID] then
            target[sessionID] = true
            changed = true
        end
    end

    return changed
end

local function CopySpellBreakdownList(entries)
    if type(entries) ~= "table" then
        return {}
    end

    local copy = {}
    for _, entry in ipairs(entries) do
        if type(entry) == "table" then
            copy[#copy + 1] = {
                spellID = tonumber(entry.spellID) or tonumber(entry.spellId) or 0,
                totalAmount = tonumber(entry.totalAmount) or tonumber(entry.total) or tonumber(entry.amount) or 0,
                amountPerSecond = tonumber(entry.amountPerSecond) or tonumber(entry.perSecond) or nil,
            }
        end
    end

    return copy
end

local function CopyCompletionMembers(members)
    if type(members) ~= "table" then
        return nil
    end

    local copy = {}
    for index, member in ipairs(members) do
        if type(member) == "table" then
            copy[index] = {
                name = member.name,
                memberName = member.memberName,
                fullName = member.fullName,
                playerName = member.playerName,
                realm = member.realm,
                realmName = member.realmName,
                memberGUID = member.memberGUID,
                guid = member.guid,
                playerGUID = member.playerGUID,
                classFilename = member.classFilename or member.classFileName,
                classFileName = member.classFileName or member.classFilename,
                role = member.role,
                assignedRole = member.assignedRole,
                combatRole = member.combatRole,
                specID = member.specID or member.specId or member.specializationID,
                specId = member.specId or member.specID or member.specializationID,
                specializationID = member.specializationID or member.specID or member.specId,
                specName = member.specName or member.specializationName,
                specializationName = member.specializationName or member.specName,
                specIconID = member.specIconID or member.specIcon or member.specTexture,
                specIcon = member.specIcon or member.specIconID or member.specTexture,
                specTexture = member.specTexture or member.specIconID or member.specIcon,
                itemLevel = GetCompletionMemberItemLevel(member),
                equippedItemLevel = GetCompletionMemberItemLevel(member),
            }
        end
    end

    return next(copy) and copy or nil
end

local function CopyCompletionInfo(completionInfo)
    if type(completionInfo) ~= "table" then
        return nil
    end

    return {
        mapChallengeModeID = completionInfo.mapChallengeModeID,
        level = completionInfo.level,
        time = completionInfo.time,
        onTime = completionInfo.onTime,
        keystoneUpgradeLevels = completionInfo.keystoneUpgradeLevels,
        practiceRun = completionInfo.practiceRun,
        oldOverallDungeonScore = completionInfo.oldOverallDungeonScore,
        newOverallDungeonScore = completionInfo.newOverallDungeonScore,
        isMapRecord = completionInfo.isMapRecord,
        isAffixRecord = completionInfo.isAffixRecord,
        primaryAffix = completionInfo.primaryAffix,
        isEligibleForScore = completionInfo.isEligibleForScore,
        members = CopyCompletionMembers(completionInfo.members),
    }
end

local function CopyActiveRunPlayerStats(self, playerStats)
    local copy = {}

    for fullName, stat in pairs(playerStats or {}) do
        if type(stat) == "table" then
            copy[fullName] = {
                name = stat.name or fullName,
                shortName = stat.shortName or self:GetShortName(fullName),
                guid = stat.guid,
                classFilename = stat.classFilename,
                role = stat.role,
                specID = stat.specID,
                specName = stat.specName,
                specIconID = stat.specIconID,
                score = stat.score,
                previousScore = stat.previousScore,
                itemLevel = stat.itemLevel,
                damageActiveSeconds = stat.damageActiveSeconds,
                healingActiveSeconds = stat.healingActiveSeconds,
                dps = stat.dps,
                hps = stat.hps,
                damagePerSecond = stat.damagePerSecond,
                healingPerSecond = stat.healingPerSecond,
                damage = stat.damage,
                healing = stat.healing,
                interrupts = stat.interrupts,
                dispels = stat.dispels,
                avoidableDamage = stat.avoidableDamage,
                damageDoneBySpells = CopySpellBreakdownList(stat.damageDoneBySpells),
                healDoneBySpells = CopySpellBreakdownList(stat.healDoneBySpells),
                interruptsBySpells = CopySpellBreakdownList(stat.interruptsBySpells),
                dispelsBySpells = CopySpellBreakdownList(stat.dispelsBySpells),
                avoidableDamageBySpells = CopySpellBreakdownList(stat.avoidableDamageBySpells),
                deaths = tonumber(stat.deaths) or 0,
                loot = type(stat.loot) == "table" and self:CopyArray(stat.loot) or {},
            }
        end
    end

    return copy
end

local function CreateActiveRunSnapshot(self, activeRun)
    if type(activeRun) ~= "table" then
        return nil
    end

    return {
        runId = activeRun.runId,
        startedAt = activeRun.startedAt,
        completedAt = activeRun.completedAt,
        state = activeRun.state,
        mapChallengeModeID = activeRun.mapChallengeModeID,
        mapID = activeRun.mapID,
        dungeonName = activeRun.dungeonName,
        ownerCharacter = activeRun.ownerCharacter,
        keyLevel = activeRun.keyLevel,
        timeLimitSeconds = activeRun.timeLimitSeconds,
        affixIDs = type(activeRun.affixIDs) == "table" and self:CopyArray(activeRun.affixIDs) or {},
        participants = CopySet(activeRun.participants),
        participantOrder = type(activeRun.participantOrder) == "table" and self:CopyArray(activeRun.participantOrder) or {},
        initialPlayerScores = CopySet(activeRun.initialPlayerScores),
        playerStats = CopyActiveRunPlayerStats(self, activeRun.playerStats),
        petOwnerGUIDs = CopySet(activeRun.petOwnerGUIDs),
        deathState = CopySet(activeRun.deathState),
        totalDeaths = tonumber(activeRun.totalDeaths) or 0,
        deathPenaltySeconds = tonumber(activeRun.deathPenaltySeconds) or 0,
        sessionIDs = CopySet(activeRun.sessionIDs),
        excludedSessionIDs = CopySet(activeRun.excludedSessionIDs),
        completionPending = activeRun.completionPending and true or false,
        pendingCompletionSource = activeRun.pendingCompletionSource,
        pendingCompletionInfo = CopyCompletionInfo(activeRun.pendingCompletionInfo),
        finalizationInProgress = false,
        completionSignals = CopySet(activeRun.completionSignals),
        restoredFromReload = activeRun.restoredFromReload and true or false,
        allowCompletionRecovery = activeRun.allowCompletionRecovery and true or false,
        scenarioCompletedAt = activeRun.scenarioCompletedAt,
        inactiveResolutionQueued = false,
        lootFinalizeDeadline = activeRun.lootFinalizeDeadline,
        damageMeterResetSuspected = activeRun.damageMeterResetSuspected and true or false,
        damageMeterResetVerified = activeRun.damageMeterResetVerified and true or false,
        damageMeterResetRecovered = activeRun.damageMeterResetRecovered and true or false,
        damageMeterResetSnapshot = CopySet(activeRun.damageMeterResetSnapshot),
        damageMeterResetAt = activeRun.damageMeterResetAt,
    }
end

local function GetPlayerMythicScoreFromBlizzard(fullName, unitToken)
    if not (C_PlayerInfo and C_PlayerInfo.GetPlayerMythicPlusRatingSummary) then
        return nil
    end

    local candidates = {}
    if type(unitToken) == "string" and unitToken ~= "" then
        candidates[#candidates + 1] = unitToken
    end
    if type(fullName) == "string" and fullName ~= "" then
        candidates[#candidates + 1] = fullName
        candidates[#candidates + 1] = MythicTools:GetShortName(fullName)
        if MythicTools.playerName == fullName then
            candidates[#candidates + 1] = "player"
        end
    end

    local seen = {}
    for _, candidate in ipairs(candidates) do
        if not seen[candidate] then
            seen[candidate] = true
            local ok, ratingSummary = pcall(C_PlayerInfo.GetPlayerMythicPlusRatingSummary, candidate)
            local rating = ok and ratingSummary and tonumber(ratingSummary.currentSeasonScore) or nil
            if rating then
                return rating
            end
        end
    end

    return nil
end

local function GetObservedPlayerMythicScore(fullName, unitToken)
    if type(fullName) ~= "string" or fullName == "" then
        return nil
    end

    local blizzardRating = GetPlayerMythicScoreFromBlizzard(fullName, unitToken)
    if blizzardRating then
        return blizzardRating
    end

    local shortName = MythicTools:GetShortName(fullName)
    local openRaidLib = LibStub and LibStub:GetLibrary("LibOpenRaid-1.0", true) or nil
    if openRaidLib and type(openRaidLib.GetKeystoneInfo) == "function" then
        local keystoneInfo = openRaidLib.GetKeystoneInfo(fullName) or openRaidLib.GetKeystoneInfo(shortName)
        local rating = keystoneInfo and tonumber(keystoneInfo.rating or keystoneInfo.currentSeasonScore) or nil
        if rating then
            return rating
        end
    end

    if _G.PlayerInfo and type(_G.GetPlayerInfo) == "function" then
        local playerInfo = _G.GetPlayerInfo(fullName) or _G.GetPlayerInfo(shortName)
        local keystoneInfo = type(playerInfo) == "table" and playerInfo.keystoneInfo or nil
        local rating = keystoneInfo and tonumber(keystoneInfo.rating or keystoneInfo.currentSeasonScore) or nil
        if rating then
            return rating
        end
    end

    return nil
end

function MythicTools:RefreshObservedScoreForStat(stat, fullName, unitToken)
    if type(stat) ~= "table" or type(fullName) ~= "string" or fullName == "" then
        return false
    end

    local changed = false
    local observedScore = GetObservedPlayerMythicScore(fullName, unitToken)
    if observedScore and observedScore > 0 then
        if (tonumber(stat.previousScore) or 0) <= 0 then
            stat.previousScore = observedScore
            changed = true
        end
        if (tonumber(stat.score) or 0) <= 0 then
            stat.score = observedScore
            changed = true
        end
        return changed
    end

    local previousScore = tonumber(stat.previousScore) or 0
    if previousScore > 0 and (tonumber(stat.score) or 0) <= 0 then
        stat.score = previousScore
        return true
    end

    return changed
end

function MythicTools:HydrateObservedScoresForTarget(target)
    if type(target) ~= "table" or type(target.playerStats) ~= "table" then
        return false
    end

    local changed = false
    for _, fullName in ipairs(self:GetRosterNamesForTarget(target)) do
        local stat = target.playerStats[fullName]
        if type(stat) == "table" and self:RefreshObservedScoreForStat(stat, fullName, self:GetGroupUnitForPlayer(fullName)) then
            changed = true
        end
    end

    return changed
end

function MythicTools:RecordInitialPlayerMythicScore(activeRun, fullName, unitToken)
    if type(activeRun) ~= "table" or type(fullName) ~= "string" or fullName == "" then
        return nil
    end

    activeRun.initialPlayerScores = type(activeRun.initialPlayerScores) == "table" and activeRun.initialPlayerScores or {}
    if tonumber(activeRun.initialPlayerScores[fullName]) then
        return tonumber(activeRun.initialPlayerScores[fullName])
    end

    local initialScore = GetObservedPlayerMythicScore(fullName, unitToken)
    if initialScore and initialScore > 0 then
        activeRun.initialPlayerScores[fullName] = initialScore
        self:PushRuntimeDebug(("initial m+ score run %s player=%s score=%s"):format(
            tostring(activeRun.runId),
            tostring(fullName),
            tostring(initialScore)
        ))
        return initialScore
    end

    return nil
end

function MythicTools:RefreshCompletedRunScoresFromBlizzard(run, activeRun)
    if type(run) ~= "table" or type(run.playerStats) ~= "table" then
        return false
    end

    local initialScores = type(activeRun) == "table" and activeRun.initialPlayerScores or nil
    local changed = false
    for _, fullName in ipairs(self:GetRosterNamesForTarget(run)) do
        local stat = run.playerStats[fullName]
        if type(stat) == "table" then
            local previousScore = tonumber(initialScores and initialScores[fullName]) or tonumber(stat.previousScore) or 0
            local currentScore = GetPlayerMythicScoreFromBlizzard(fullName, self:GetGroupUnitForPlayer(fullName))

            if previousScore > 0 and tonumber(stat.previousScore) ~= previousScore then
                stat.previousScore = previousScore
                changed = true
            end

            if currentScore and currentScore > 0 and tonumber(stat.score) ~= currentScore then
                stat.score = currentScore
                changed = true
            elseif (tonumber(stat.score) or 0) <= 0 and previousScore > 0 then
                stat.score = previousScore
                changed = true
            end
        end
    end

    return changed
end

local function GetCombatSourceTotalAmount(source)
    if type(source) ~= "table" then
        return 0
    end

    if type(source.totalAmount) == "number" then
        return source.totalAmount
    end

    if type(source.amount) == "number" then
        return source.amount
    end

    if type(source.totalInterrupts) == "number" then
        return source.totalInterrupts
    end

    if type(source.value) == "number" then
        return source.value
    end

    return 0
end

local function GetCombatSourceAmountPerSecond(source)
    if type(source) ~= "table" then
        return nil
    end

    local value = tonumber(source.amountPerSecond)
        or tonumber(source.perSecond)
        or tonumber(source.last_dps)
        or tonumber(source.last_hps)
        or nil

    if value and value >= 0 then
        return value
    end

    return nil
end

local function GetSourceGUID(source)
    if type(source) ~= "table" then
        return nil
    end

    return source.sourceGUID
        or source.sourceGuid
        or source.guid
        or source.unitGUID
        or source.unitGuid
        or source.playerGUID
        or source.playerGuid
        or source.memberGUID
        or source.memberGuid
        or source.serial
end

local function GetSourceCreatureID(source)
    if type(source) ~= "table" then
        return nil
    end

    return tonumber(source.sourceCreatureID)
        or tonumber(source.sourceCreatureId)
        or tonumber(source.creatureID)
        or tonumber(source.creatureId)
        or nil
end

local function NormalizeSpellBreakdownEntries(entries, maxEntries)
    if type(entries) ~= "table" then
        return {}
    end

    local normalized = {}
    for _, spellData in ipairs(entries) do
        if type(spellData) == "table" then
            local spellID = tonumber(spellData.spellID) or tonumber(spellData.spellId) or tonumber(spellData.id) or nil
            local totalAmount = tonumber(spellData.totalAmount)
                or tonumber(spellData.total)
                or tonumber(spellData.amount)
                or tonumber(spellData.count)
                or tonumber(spellData.value)
                or nil
            if spellID and totalAmount and totalAmount > 0 then
                normalized[#normalized + 1] = {
                    spellID = spellID,
                    totalAmount = totalAmount,
                    amountPerSecond = tonumber(spellData.amountPerSecond) or nil,
                }
            end
        end
    end

    table.sort(normalized, function(a, b)
        return (a.totalAmount or 0) > (b.totalAmount or 0)
    end)

    if maxEntries and maxEntries > 0 and #normalized > maxEntries then
        for i = #normalized, maxEntries + 1, -1 do
            normalized[i] = nil
        end
    end

    return normalized
end

local function GetDamageMeterSourceSpellBreakdown(damageType, sourceGUID, sourceCreatureID, maxEntries)
    if not (C_DamageMeter and C_DamageMeter.GetCombatSessionSourceFromType and damageType and sourceGUID) then
        return {}
    end

    local seen = {}
    for _, sessionType in ipairs({DAMAGE_METER_OVERALL_SESSION, 0, 1}) do
        if type(sessionType) == "number" and not seen[sessionType] then
            seen[sessionType] = true
            local ok, sourceData = pcall(C_DamageMeter.GetCombatSessionSourceFromType, sessionType, damageType, sourceGUID, sourceCreatureID)
            if not ok or type(sourceData) ~= "table" then
                ok, sourceData = pcall(C_DamageMeter.GetCombatSessionSourceFromType, sessionType, damageType, sourceGUID)
            end
            if ok and type(sourceData) == "table" then
                local spells = NormalizeSpellBreakdownEntries(sourceData.combatSpells, maxEntries or MAX_TOOLTIP_SPELL_ENTRIES)
                if #spells > 0 then
                    return spells
                end
            end
        end
    end

    return {}
end

local function GetSourceOwnerGUID(source)
    if type(source) ~= "table" then
        return nil
    end

    local owner = source.owner or source.petOwner or source.master
    if type(owner) == "table" then
        return GetSourceGUID(owner)
    end
    if type(owner) == "string" and owner:match("^Player%-") then
        return owner
    end

    return source.ownerGUID
        or source.ownerGuid
        or source.ownerUnitGUID
        or source.ownerUnitGuid
        or source.petOwnerGUID
        or source.petOwnerGuid
        or source.masterGUID
        or source.masterGuid
end

local function GetSourceOwnerName(source)
    if type(source) ~= "table" then
        return nil
    end

    local owner = source.owner or source.petOwner or source.master
    if type(owner) == "table" then
        return owner.name or owner.fullName or owner.unitName or owner.playerName or owner.memberName
    end
    if type(owner) == "string" and not owner:match("^Player%-") then
        return owner
    end

    return source.ownerName
        or source.ownerFullName
        or source.ownerUnitName
        or source.petOwnerName
        or source.masterName
end

local function GetPetUnitForGroupUnit(unit)
    if type(unit) ~= "string" then
        return nil
    end

    if unit == "player" then
        return "pet"
    end

    local partyIndex = unit:match("^party(%d+)$")
    if partyIndex then
        return "partypet" .. partyIndex
    end

    local raidIndex = unit:match("^raid(%d+)$")
    if raidIndex then
        return "raidpet" .. raidIndex
    end

    return nil
end

local function NormalizeInterruptCount(value)
    local numberValue = tonumber(value)
    if numberValue == nil then
        return nil
    end

    return math.floor(numberValue + 0.5)
end

local function HasAnyRunStats(run)
    for _, stat in pairs(run.playerStats or {}) do
        if (tonumber(stat.damage) or 0) > 0
            or (tonumber(stat.healing) or 0) > 0
            or (tonumber(stat.interrupts) or 0) > 0
            or (tonumber(stat.dispels) or 0) > 0
            or (tonumber(stat.avoidableDamage) or 0) > 0 then
            return true
        end
    end

    return false
end

local function HasPositiveRunStatField(run, fieldName)
    for _, stat in pairs(run.playerStats or {}) do
        if (tonumber(stat and stat[fieldName]) or 0) > 0 then
            return true
        end
    end

    return false
end

local function HasRunStatField(run, fieldName)
    for _, stat in pairs(run.playerStats or {}) do
        if type(stat) == "table" and stat[fieldName] ~= nil then
            return true
        end
    end

    return false
end

function MythicTools:GetAvailableCombatSessionSet()
    local sessionSet = {}

    if not C_DamageMeter then
        return sessionSet
    end

    local sessions
    if C_DamageMeter.GetAvailableCombatSessions then
        sessions = C_DamageMeter.GetAvailableCombatSessions()
    elseif C_DamageMeter.GetSessions then
        sessions = C_DamageMeter.GetSessions()
    end

    if type(sessions) ~= "table" then
        return sessionSet
    end

    for _, sessionInfo in ipairs(sessions) do
        local sessionID = type(sessionInfo) == "number" and sessionInfo or (sessionInfo and (sessionInfo.sessionID or sessionInfo.sessionId or sessionInfo.id))
        if type(sessionID) == "number" then
            sessionSet[sessionID] = true
        end
    end

    return sessionSet
end

function MythicTools:GetSortedSessionIDs(sessionSet)
    local sorted = {}

    for sessionID in pairs(sessionSet or {}) do
        sorted[#sorted + 1] = sessionID
    end

    table.sort(sorted)
    return sorted
end

local function GetInspectQueueKey(runId, fullName, guid)
    return ("%s:%s"):format(tostring(runId or 0), tostring(guid or fullName or ""))
end

local function IsInspectDataMissing(stat)
    if type(stat) ~= "table" then
        return false
    end

    local hasSpec = stat.specID and stat.specName and stat.specIconID and stat.role
    local hasItemLevel = NormalizeCapturedItemLevel(stat.itemLevel) ~= nil
    return not hasSpec or not hasItemLevel
end

local function IsInspectablePlayerUnit(unit)
    return type(unit) == "string"
        and unit ~= ""
        and UnitExists ~= nil
        and UnitExists(unit)
        and UnitIsPlayer ~= nil
        and UnitIsPlayer(unit)
end

local PARTY_DATA_DAMAGE_FIELDS = {"damageDone", "totalDamage", "damage", "damageTotal", "totalDamageDone", "totalAmount", "amount", "value"}
local PARTY_DATA_HEALING_FIELDS = {"healingDone", "totalHealing", "totalHeal", "healing", "healAmount", "totalHealingDone", "totalAmount", "amount", "value"}
local PARTY_DATA_INTERRUPT_FIELDS = {"interrupts", "totalInterrupts", "interruptCount", "successfulInterrupts", "interruptAmount", "totalAmount", "amount", "value"}
local PARTY_DATA_DISPEL_FIELDS = {"dispels", "totalDispels", "dispelCount", "successfulDispels", "dispell", "dispel", "totalAmount", "amount", "value"}
local PARTY_DATA_AVOIDABLE_DAMAGE_FIELDS = {"avoidableDamage", "avoidableDamageTaken", "damageAvoidable", "damageTakenAvoidable", "avoidable_damage", "damage_avoidable", "totalAvoidableDamage", "totalAmount", "amount", "value"}

local function GetEntryAmount(entry, keys)
    if type(entry) ~= "table" then
        return nil
    end

    for _, key in ipairs(keys) do
        local value = entry[key]
        if type(value) == "number" then
            return value
        end
        if type(value) == "table" then
            for _, nestedKey in ipairs({"totalAmount", "amount", "damageDone", "healingDone", "totalDamage", "totalHealing", "totalHeal", "totalInterrupts", "interrupts", "totalDispels", "dispels", "avoidableDamage", "avoidableDamageTaken", "value", "count"}) do
                if type(value[nestedKey]) == "number" then
                    return value[nestedKey]
                end
            end
        end
    end

    if type(entry.totalAmount) == "number" then
        return entry.totalAmount
    end

    if type(entry.amount) == "number" then
        return entry.amount
    end

    return nil
end

local function NormalizeStoredItemLink(itemLink)
    if type(itemLink) ~= "string" or itemLink == "" then
        return nil
    end

    local hyperlink = itemLink:match("(|Hitem:.-|h%[.-%]|h)")
    if hyperlink and hyperlink ~= "" then
        return hyperlink
    end

    return itemLink
end

function MythicTools:GetPartyDataEntries(partyData)
    if type(partyData) ~= "table" then
        return {}
    end

    if #partyData > 0 then
        return partyData
    end

    if (partyData.name or partyData.guid or partyData.unitGUID or partyData.playerGUID)
        and (partyData.damageDone ~= nil or partyData.totalDamage ~= nil or partyData.healingDone ~= nil or partyData.totalHeal ~= nil or partyData.interrupts ~= nil or partyData.totalInterrupts ~= nil or partyData.dispels ~= nil or partyData.totalDispels ~= nil or partyData.avoidableDamage ~= nil or partyData.avoidableDamageTaken ~= nil) then
        return {partyData}
    end

    for _, key in ipairs({"players", "party", "partyData", "members", "partyMembers", "combatSources"}) do
        local entries = partyData[key]
        if type(entries) == "table" and #entries > 0 then
            return entries
        end
    end

    local entries = {}
    for _, value in pairs(partyData) do
        if type(value) == "table" then
            entries[#entries + 1] = value
        end
    end

    return entries
end

local function GetCombatSessionDurationFromData(sessionData)
    if type(sessionData) ~= "table" then
        return nil
    end

    local duration = tonumber(sessionData.durationSeconds)
        or tonumber(sessionData.duration)
        or tonumber(sessionData.combatTime)
        or tonumber(sessionData.elapsedCombatTime)
        or tonumber(sessionData.totalTimeSeconds)
        or nil

    if duration and duration > 0 then
        return duration
    end

    local startedAt = tonumber(sessionData.startTime or sessionData.startedAt or sessionData.data_inicio) or 0
    local endedAt = tonumber(sessionData.endTime or sessionData.endedAt or sessionData.data_fim) or 0
    if startedAt > 0 and endedAt > startedAt then
        return endedAt - startedAt
    end

    return nil
end

local function SafeGetCombatSessionDuration(sessionProvider, ...)
    if type(sessionProvider) ~= "function" then
        return nil
    end

    local okSession, sessionData = pcall(sessionProvider, ...)
    if not okSession then
        return nil
    end

    local okDuration, duration = pcall(GetCombatSessionDurationFromData, sessionData)
    if not okDuration then
        return nil
    end

    if type(duration) ~= "number" or duration <= 0 then
        return nil
    end

    return duration
end

local function GetCurrentOverallCombatDurationFromDamageMeter()
    if not (C_DamageMeter and C_DamageMeter.GetCombatSessionFromType) then
        return nil
    end

    local seen = {}
    for _, sessionType in ipairs({DAMAGE_METER_OVERALL_SESSION, 0, 1}) do
        if type(sessionType) == "number" and not seen[sessionType] then
            seen[sessionType] = true
            local duration = SafeGetCombatSessionDuration(C_DamageMeter.GetCombatSessionFromType, sessionType, DAMAGE_DONE)
                or SafeGetCombatSessionDuration(C_DamageMeter.GetCombatSessionFromType, sessionType, HEALING_DONE)
                or SafeGetCombatSessionDuration(C_DamageMeter.GetCombatSessionFromType, sessionType, INTERRUPTS)
            if duration and duration > 0 then
                return duration
            end
        end
    end

    return nil
end

function MythicTools:RefreshRunDerivedStats(run)
    if type(run) ~= "table" then
        return
    end

    local combatDurationSeconds = tonumber(run.combatTimeSeconds) or 0
    local runDurationSeconds = (tonumber(run.timeMS) or 0) / 1000
    for _, stat in pairs(run.playerStats or {}) do
        if type(stat) == "table" then
            stat.dps = nil
            stat.hps = nil

            local damageDuration = combatDurationSeconds > 0 and combatDurationSeconds or (tonumber(stat.damageActiveSeconds) or 0)
            local healingDuration = combatDurationSeconds > 0 and combatDurationSeconds or (tonumber(stat.healingActiveSeconds) or 0)
            if damageDuration <= 0 then
                damageDuration = runDurationSeconds
            end
            if healingDuration <= 0 then
                healingDuration = runDurationSeconds
            end

            if tonumber(stat.damagePerSecond) and tonumber(stat.damagePerSecond) >= 0 then
                stat.dps = tonumber(stat.damagePerSecond)
            elseif stat.damage ~= nil and damageDuration > 0 then
                stat.dps = (tonumber(stat.damage) or 0) / damageDuration
            end
            if tonumber(stat.healingPerSecond) and tonumber(stat.healingPerSecond) >= 0 then
                stat.hps = tonumber(stat.healingPerSecond)
            elseif stat.healing ~= nil and healingDuration > 0 then
                stat.hps = (tonumber(stat.healing) or 0) / healingDuration
            end
        end
    end
end

function MythicTools:HydrateRunCombatDuration(run)
    if type(run) ~= "table" then
        return false
    end

    local runtimeSeconds = ((tonumber(run.timeMS) or 0) / 1000)
    local currentCombatSeconds = tonumber(run.combatTimeSeconds) or 0
    local sessionIDs = self:GetDamageMeterSessionIDsForTotals(run)

    if currentCombatSeconds > 0 and runtimeSeconds > 0 and currentCombatSeconds <= (runtimeSeconds + 1) then
        currentCombatSeconds = math.min(currentCombatSeconds, runtimeSeconds)
        run.combatTimeSeconds = currentCombatSeconds
        run.outOfCombatSeconds = math.max(0, runtimeSeconds - currentCombatSeconds)
        return true
    end

    if type(sessionIDs) ~= "table" or #sessionIDs == 0 then
        local runEndedAt = tonumber(run.endTime) or 0
        if runEndedAt > 0 and run.result ~= "abandoned" and math.abs(time() - runEndedAt) <= 30 then
            local duration = GetCurrentOverallCombatDurationFromDamageMeter()
            if duration and duration > 0 then
                if runtimeSeconds > 0 then
                    duration = math.min(duration, runtimeSeconds)
                end

                run.combatTimeSeconds = duration
                run.outOfCombatSeconds = math.max(0, runtimeSeconds - duration)
                return true
            end
        end

        return false
    end

    if not C_DamageMeter then
        return false
    end

    local totalCombatSeconds = 0
    local foundDuration = false

    for _, sessionID in ipairs(sessionIDs) do
        local duration = SafeGetCombatSessionDuration(C_DamageMeter.GetSessionInfo, sessionID)

        if not duration and C_DamageMeter.GetCombatSessionFromID then
            duration = SafeGetCombatSessionDuration(C_DamageMeter.GetCombatSessionFromID, sessionID, DAMAGE_DONE)
                or SafeGetCombatSessionDuration(C_DamageMeter.GetCombatSessionFromID, sessionID, HEALING_DONE)
                or SafeGetCombatSessionDuration(C_DamageMeter.GetCombatSessionFromID, sessionID, INTERRUPTS)
        end

        if duration and duration > 0 then
            totalCombatSeconds = totalCombatSeconds + duration
            foundDuration = true
        end
    end

    if not foundDuration or totalCombatSeconds <= 0 then
        return false
    end

    if runtimeSeconds > 0 then
        totalCombatSeconds = math.min(totalCombatSeconds, runtimeSeconds)
    end

    run.combatTimeSeconds = totalCombatSeconds
    run.outOfCombatSeconds = math.max(0, runtimeSeconds - totalCombatSeconds)
    return true
end

function MythicTools:AccumulatePartyData(run, sessionIDs, requestedFields)
    if not (C_DamageMeter and C_DamageMeter.GetPartyData) then
        return false, false
    end

    requestedFields = requestedFields or {damage = {}, healing = {}, interrupts = {}, dispels = {}, avoidableDamage = {}}

    local guidMap = {}
    for fullName, stat in pairs(run.playerStats) do
        if stat.guid then
            guidMap[stat.guid] = fullName
        end
    end
    local petOwnerGuidMap = self:BuildRunPetOwnerGuidMap(run)

    local foundValues = false
    local blocked = false

    for _, sessionID in ipairs(sessionIDs) do
        local partyData = C_DamageMeter.GetPartyData(sessionID)
        if partyData ~= nil and not self:CanAccessTable(partyData) then
            blocked = true
        elseif self:CanAccessTable(partyData) then
            for _, entry in ipairs(self:GetPartyDataEntries(partyData)) do
                local fullName = self:ResolveCompletedRunSource(run, guidMap, entry)
                local petInterruptOnly = false
                if not fullName then
                    fullName = self:ResolveCompletedRunSource(run, guidMap, entry, petOwnerGuidMap)
                    petInterruptOnly = fullName ~= nil
                end
                if fullName then
                    local stat = self:EnsureCompletedRunPlayer(run, fullName)
                    local needsDamage = not petInterruptOnly and requestedFields.damage and requestedFields.damage[fullName]
                    local needsHealing = not petInterruptOnly and requestedFields.healing and requestedFields.healing[fullName]
                    local needsInterrupts = requestedFields.interrupts and requestedFields.interrupts[fullName]
                    local needsDispels = not petInterruptOnly and requestedFields.dispels and requestedFields.dispels[fullName]
                    local needsAvoidableDamage = not petInterruptOnly and requestedFields.avoidableDamage and requestedFields.avoidableDamage[fullName]
                    local damage = needsDamage and GetEntryAmount(entry, PARTY_DATA_DAMAGE_FIELDS) or nil
                    local healing = needsHealing and GetEntryAmount(entry, PARTY_DATA_HEALING_FIELDS) or nil
                    local interrupts = needsInterrupts and GetEntryAmount(entry, PARTY_DATA_INTERRUPT_FIELDS) or nil
                    local dispels = needsDispels and GetEntryAmount(entry, PARTY_DATA_DISPEL_FIELDS) or nil
                    local avoidableDamage = needsAvoidableDamage and GetEntryAmount(entry, PARTY_DATA_AVOIDABLE_DAMAGE_FIELDS) or nil

                    if damage ~= nil then
                        stat.damage = (stat.damage or 0) + damage
                        foundValues = true
                    end
                    if healing ~= nil then
                        stat.healing = (stat.healing or 0) + healing
                        foundValues = true
                    end
                    if interrupts ~= nil then
                        stat.interrupts = NormalizeInterruptCount((stat.interrupts or 0) + interrupts)
                        foundValues = true
                    end
                    if dispels ~= nil then
                        stat.dispels = NormalizeInterruptCount((stat.dispels or 0) + dispels)
                        foundValues = true
                    end
                    if avoidableDamage ~= nil then
                        stat.avoidableDamage = (stat.avoidableDamage or 0) + avoidableDamage
                        foundValues = true
                    end

                    local entryGUID = GetSourceGUID(entry)
                    if not stat.guid and entryGUID and self:CanAccessValue(entryGUID) and self:IsPlayerGUID(entryGUID) then
                        stat.guid = entryGUID
                        guidMap[entryGUID] = fullName
                    end

                    self:ApplySourceMetadataToStat(stat, entry)
                end
            end
        end
    end

    return foundValues, blocked
end

function MythicTools:GetMissingRunStatFields(run)
    local missing = {
        damage = {},
        healing = {},
        interrupts = {},
        dispels = {},
        avoidableDamage = {},
    }

    for fullName, stat in pairs(run.playerStats or {}) do
        if stat.damage == nil then
            missing.damage[fullName] = true
        end
        if stat.healing == nil then
            missing.healing[fullName] = true
        end
        if stat.interrupts == nil then
            missing.interrupts[fullName] = true
        end
        if stat.dispels == nil then
            missing.dispels[fullName] = true
        end
        if stat.avoidableDamage == nil then
            missing.avoidableDamage[fullName] = true
        end
    end

    return missing
end

local function HasMissingEntries(missingField)
    return type(missingField) == "table" and next(missingField) ~= nil
end

local function HasMissingCoreRunStats(missingFields)
    return HasMissingEntries(missingFields and missingFields.damage)
        or HasMissingEntries(missingFields and missingFields.healing)
        or HasMissingEntries(missingFields and missingFields.interrupts)
end

function MythicTools:AccumulatePlayerData(run, sessionIDs, requestedFields)
    if not (C_DamageMeter and C_DamageMeter.GetPlayerData) then
        return false, false
    end

    requestedFields = requestedFields or {damage = {}, healing = {}, interrupts = {}, dispels = {}, avoidableDamage = {}}

    local foundValues = false
    local blocked = false

    for _, sessionID in ipairs(sessionIDs) do
        for fullName, stat in pairs(run.playerStats) do
            local needsDamage = requestedFields.damage and requestedFields.damage[fullName]
            local needsHealing = requestedFields.healing and requestedFields.healing[fullName]
            local needsInterrupts = requestedFields.interrupts and requestedFields.interrupts[fullName]
            local needsDispels = requestedFields.dispels and requestedFields.dispels[fullName]
            local needsAvoidableDamage = requestedFields.avoidableDamage and requestedFields.avoidableDamage[fullName]

            if stat.guid and (needsDamage or needsHealing or needsInterrupts or needsDispels or needsAvoidableDamage) then
                local playerData = C_DamageMeter.GetPlayerData(sessionID, stat.guid)
                if playerData ~= nil and not self:CanAccessTable(playerData) then
                    blocked = true
                elseif self:CanAccessTable(playerData) then
                    local damage = needsDamage and GetEntryAmount(playerData, PARTY_DATA_DAMAGE_FIELDS) or nil
                    local healing = needsHealing and GetEntryAmount(playerData, PARTY_DATA_HEALING_FIELDS) or nil
                    local interrupts = needsInterrupts and GetEntryAmount(playerData, PARTY_DATA_INTERRUPT_FIELDS) or nil
                    local dispels = needsDispels and GetEntryAmount(playerData, PARTY_DATA_DISPEL_FIELDS) or nil
                    local avoidableDamage = needsAvoidableDamage and GetEntryAmount(playerData, PARTY_DATA_AVOIDABLE_DAMAGE_FIELDS) or nil

                    if damage ~= nil then
                        stat.damage = (stat.damage or 0) + damage
                        foundValues = true
                    end
                    if healing ~= nil then
                        stat.healing = (stat.healing or 0) + healing
                        foundValues = true
                    end
                    if interrupts ~= nil then
                        stat.interrupts = NormalizeInterruptCount((stat.interrupts or 0) + interrupts)
                        foundValues = true
                    end
                    if dispels ~= nil then
                        stat.dispels = NormalizeInterruptCount((stat.dispels or 0) + dispels)
                        foundValues = true
                    end
                    if avoidableDamage ~= nil then
                        stat.avoidableDamage = (stat.avoidableDamage or 0) + avoidableDamage
                        foundValues = true
                    end

                    self:ApplySourceMetadataToStat(stat, playerData)
                end
            end
        end
    end

    return foundValues, blocked
end

function MythicTools:AccumulateDamageMeterSession(run, session, fieldName, requestedPlayers, additive, damageType)
    local guidMap = {}
    for fullName, stat in pairs(run.playerStats) do
        if stat.guid then
            guidMap[stat.guid] = fullName
        end
    end
    local petOwnerGuidMap = fieldName == "interrupts" and self:BuildRunPetOwnerGuidMap(run) or nil

    if session ~= nil and not self:CanAccessTable(session) then
        return false, true
    end

    local foundValues = false
    local accumulatedTotals = {}
    if self:CanAccessTable(session) and type(session.combatSources) == "table" then
        for _, source in ipairs(session.combatSources) do
            local fullName = self:ResolveCompletedRunSource(run, guidMap, source, petOwnerGuidMap)
            if fullName and (not requestedPlayers or requestedPlayers[fullName]) then
                local stat = self:EnsureCompletedRunPlayer(run, fullName)
                local amount = GetCombatSourceTotalAmount(source)
                if fieldName == "interrupts" or fieldName == "dispels" then
                    local baseAmount = additive and (stat[fieldName] or 0) or (accumulatedTotals[fullName] or 0)
                    local totalAmount = NormalizeInterruptCount(baseAmount + amount)
                    stat[fieldName] = totalAmount
                    accumulatedTotals[fullName] = totalAmount
                elseif additive then
                    stat[fieldName] = (stat[fieldName] or 0) + amount
                else
                    stat[fieldName] = amount
                end

                if not additive then
                    local amountPerSecond = GetCombatSourceAmountPerSecond(source)
                    if amountPerSecond and fieldName == "damage" then
                        stat.damagePerSecond = amountPerSecond
                    elseif amountPerSecond and fieldName == "healing" then
                        stat.healingPerSecond = amountPerSecond
                    end
                end

                local sourceGUID = GetSourceGUID(source)
                local sourceCreatureID = GetSourceCreatureID(source)
                if not stat.guid and sourceGUID and self:CanAccessValue(sourceGUID) and self:IsPlayerGUID(sourceGUID) then
                    stat.guid = sourceGUID
                    guidMap[sourceGUID] = fullName
                end

                if not additive and sourceGUID and damageType then
                    local spellBreakdown = GetDamageMeterSourceSpellBreakdown(damageType, sourceGUID, sourceCreatureID, MAX_TOOLTIP_SPELL_ENTRIES)
                    if fieldName == "damage" and #spellBreakdown > 0 then
                        stat.damageDoneBySpells = spellBreakdown
                    elseif fieldName == "healing" and #spellBreakdown > 0 then
                        stat.healDoneBySpells = spellBreakdown
                    elseif fieldName == "interrupts" and #spellBreakdown > 0 then
                        stat.interruptsBySpells = spellBreakdown
                    elseif fieldName == "dispels" and #spellBreakdown > 0 then
                        stat.dispelsBySpells = spellBreakdown
                    elseif fieldName == "avoidableDamage" and #spellBreakdown > 0 then
                        stat.avoidableDamageBySpells = spellBreakdown
                    end
                end

                self:ApplySourceMetadataToStat(stat, source)
                foundValues = true
            end
        end
    end

    return foundValues, false
end

function MythicTools:GetRosterPlayersMissingStatField(run, fieldName, requestedPlayers)
    local missing = {}
    local missingCount = 0

    for _, fullName in ipairs(run and run.roster or {}) do
        if not requestedPlayers or requestedPlayers[fullName] then
            local stat = run.playerStats and run.playerStats[fullName]
            if not stat or stat[fieldName] == nil then
                missing[fullName] = true
                missingCount = missingCount + 1
            end
        end
    end

    return missing, missingCount
end

function MythicTools:AccumulateOverallDamageMeterType(run, damageType, fieldName, requestedPlayers)
    if not (C_DamageMeter and C_DamageMeter.GetCombatSessionFromType) then
        return false, false
    end

    local blocked = false
    local seen = {}
    local foundAny = false
    local remaining = requestedPlayers

    for _, sessionType in ipairs({DAMAGE_METER_OVERALL_SESSION, 0, 1}) do
        if type(sessionType) == "number" and not seen[sessionType] then
            seen[sessionType] = true
            local ok, session = pcall(C_DamageMeter.GetCombatSessionFromType, sessionType, damageType)
            if ok then
                local foundValues, sessionBlocked = self:AccumulateDamageMeterSession(run, session, fieldName, remaining, false, damageType)
                blocked = blocked or sessionBlocked
                foundAny = foundAny or foundValues
                if foundAny then
                    -- A single session can cover only part of the group (e.g.
                    -- after a disconnect or when someone leaves right at the
                    -- end). Keep trying the other session types, but only for
                    -- roster members still missing this field, so values
                    -- already found are never overwritten.
                    local missing, missingCount = self:GetRosterPlayersMissingStatField(run, fieldName, requestedPlayers)
                    if missingCount == 0 then
                        return true, blocked
                    end
                    remaining = missing
                end
            end
        end
    end

    return foundAny, blocked
end

function MythicTools:AccumulateOverallDamageMeterData(run)
    local foundValues = false
    local blocked = false

    local foundDamage, damageBlocked = self:AccumulateOverallDamageMeterType(run, DAMAGE_DONE, "damage")
    local foundHealing, healingBlocked = self:AccumulateOverallDamageMeterType(run, HEALING_DONE, "healing")
    local foundInterrupts, interruptsBlocked = self:AccumulateOverallDamageMeterType(run, INTERRUPTS, "interrupts")
    local foundDispels, dispelsBlocked = false, false
    if DISPELS then
        foundDispels, dispelsBlocked = self:AccumulateOverallDamageMeterType(run, DISPELS, "dispels")
    end
    local foundAvoidableDamage, avoidableDamageBlocked = false, false
    if AVOIDABLE_DAMAGE then
        foundAvoidableDamage, avoidableDamageBlocked = self:AccumulateOverallDamageMeterType(run, AVOIDABLE_DAMAGE, "avoidableDamage")
    end

    foundValues = foundDamage or foundHealing or foundInterrupts or foundDispels or foundAvoidableDamage
    blocked = damageBlocked or healingBlocked or interruptsBlocked

    return foundValues, blocked
end

function MythicTools:AccumulateCombatType(run, sessionIDs, damageType, fieldName, requestedPlayers)
    if not (C_DamageMeter and C_DamageMeter.GetCombatSessionFromID) then
        return false
    end

    local blocked = false
    for _, sessionID in ipairs(sessionIDs) do
        local ok, session = pcall(C_DamageMeter.GetCombatSessionFromID, sessionID, damageType)
        if ok then
            local _, sessionBlocked = self:AccumulateDamageMeterSession(run, session, fieldName, requestedPlayers, true, damageType)
            blocked = sessionBlocked or blocked
        end
    end

    return blocked
end

function MythicTools:GetRosterNamesForTarget(target)
    if type(target) ~= "table" then
        return {}
    end

    if type(target.roster) == "table" and #target.roster > 0 then
        return target.roster
    end

    if type(target.participantOrder) == "table" and #target.participantOrder > 0 then
        return target.participantOrder
    end

    local roster = {}
    for fullName in pairs(target.playerStats or {}) do
        roster[#roster + 1] = fullName
    end
    table.sort(roster)
    return roster
end

function MythicTools:ResolvePlayerNameForTarget(target, rawName)
    rawName = self:TrimText(rawName)
    if rawName == "" or type(target) ~= "table" then
        return nil
    end

    local linkedName = rawName:match("|Hplayer:([^:|]+)")
    if linkedName and linkedName ~= "" then
        rawName = linkedName
    else
        local bracketName = rawName:match("|h%[(.-)%]|h")
        if bracketName and bracketName ~= "" then
            rawName = bracketName
        end
    end

    if target.playerStats and target.playerStats[rawName] then
        return rawName
    end

    local normalized = self:NormalizePlayerName(rawName)
    if normalized and target.playerStats and target.playerStats[normalized] then
        return normalized
    end

    local shortName = self:GetShortName(normalized or rawName)
    for _, rosterEntry in ipairs(self:GetCurrentGroupRoster()) do
        if self:GetShortName(rosterEntry.name) == shortName and target.playerStats and target.playerStats[rosterEntry.name] then
            return rosterEntry.name
        end
    end

    local match = nil
    for _, fullName in ipairs(self:GetRosterNamesForTarget(target)) do
        if self:GetShortName(fullName) == shortName then
            match = fullName
            break
        end
    end

    return match
end

function MythicTools:AddLootToStat(stat, itemLink)
    if type(stat) ~= "table" or not itemLink or itemLink == "" then
        return false
    end

    itemLink = NormalizeStoredItemLink(itemLink)
    if not itemLink or itemLink == "" then
        return false
    end

    stat.loot = type(stat.loot) == "table" and stat.loot or {}
    for _, existingLink in ipairs(stat.loot) do
        if NormalizeStoredItemLink(existingLink) == itemLink then
            return false
        end
    end

    stat.loot[#stat.loot + 1] = itemLink
    return true
end

local function GetEncounterLootItemClassID(itemLink)
    if not itemLink or itemLink == "" then
        return nil
    end

    if C_Item and C_Item.GetItemInfoInstant then
        local _, _, _, _, _, classID = C_Item.GetItemInfoInstant(itemLink)
        return tonumber(classID)
    end

    if GetItemInfoInstant then
        local _, _, _, _, _, classID = GetItemInfoInstant(itemLink)
        return tonumber(classID)
    end

    return nil
end

local function GetEncounterLootDetailedItemLevel(itemLink)
    if C_Item and C_Item.GetDetailedItemLevelInfo then
        return C_Item.GetDetailedItemLevelInfo(itemLink)
    end

    if GetDetailedItemLevelInfo then
        return GetDetailedItemLevelInfo(itemLink)
    end

    return nil, nil, nil
end

local function IsBindToAccountUntilEquip(itemLink)
    if not (C_Item and C_Item.IsItemBindToAccountUntilEquip) then
        return false
    end

    local ok, result = pcall(C_Item.IsItemBindToAccountUntilEquip, itemLink)
    return ok and result and true or false
end

local function GetTargetAverageItemLevel(target)
    if type(target) ~= "table" then
        return nil
    end

    local total, count = 0, 0
    for _, fullName in ipairs(MythicTools:GetRosterNamesForTarget(target)) do
        local stat = target.playerStats and target.playerStats[fullName] or nil
        local itemLevel = tonumber(stat and stat.itemLevel)
        if itemLevel and itemLevel > 0 then
            total = total + itemLevel
            count = count + 1
        end
    end

    return count > 0 and (total / count) or nil
end

function MythicTools:IsTrackableEncounterLootItem(itemLink, runId)
    if not itemLink or itemLink == "" then
        return false
    end

    local classID = GetEncounterLootItemClassID(itemLink)
    if classID then
        local weaponClass = Enum and Enum.ItemClass and Enum.ItemClass.Weapon or 2
        local armorClass = Enum and Enum.ItemClass and Enum.ItemClass.Armor or 4
        if classID ~= weaponClass and classID ~= armorClass then
            return false
        end
    end

    if IsBindToAccountUntilEquip(itemLink) then
        return false
    end

    local effectiveItemLevel, _, baseItemLevel = GetEncounterLootDetailedItemLevel(itemLink)
    if tonumber(baseItemLevel) and tonumber(baseItemLevel) < 6 then
        return false
    end

    local target = self:GetLootTargetByRunId(runId)
    local averageItemLevel = GetTargetAverageItemLevel(target)
    if averageItemLevel and averageItemLevel > 0 and tonumber(effectiveItemLevel) and tonumber(effectiveItemLevel) < averageItemLevel * 0.69 then
        return false
    end

    return true
end

function MythicTools:RecordLootForTarget(target, rawName, itemLink)
    local fullName = self:ResolvePlayerNameForTarget(target, rawName)
    if not fullName then
        return false
    end

    local stat
    if target.participantOrder then
        stat = self:EnsureActiveRunPlayerStat(target, fullName)
    else
        stat = self:EnsureCompletedRunPlayer(target, fullName)
    end

    return self:AddLootToStat(stat, itemLink)
end

function MythicTools:RecordRunLoot(runId, rawName, itemLink)
    if not runId or not rawName or not itemLink then
        return false
    end

    local changed = false
    local savedRun = self:GetRunById(runId)
    if savedRun then
        changed = self:RecordLootForTarget(savedRun, rawName, itemLink) or changed
    elseif self.runtime and self.runtime.activeRun and self.runtime.activeRun.runId == runId then
        changed = self:RecordLootForTarget(self.runtime.activeRun, rawName, itemLink) or changed
        if changed then
            self:UpdateActiveRunSnapshot(self.runtime.activeRun)
        end
    end

    if changed then
        self:QueueRefreshAllViews("capture")
    end

    return changed
end

function MythicTools:StartLootTracking(runId)
    if not (self.runtime and runId) then
        return
    end

    local tracking = self.runtime.lootTracking
    if type(tracking) == "table" and tracking.runId ~= runId then
        self:PushRuntimeDebug(("replace loot tracking %s -> %s"):format(tostring(tracking.runId), tostring(runId)))
    end
    if type(tracking) == "table" and tracking.runId == runId then
        tracking.lootOpened = false
        tracking.lootClosed = false
        tracking.lootFinalizeDeadline = time() + LOOT_TRACKING_TIMEOUT
        local activeRun = self.runtime.activeRun
        if type(activeRun) == "table" and activeRun.runId == runId then
            activeRun.lootFinalizeDeadline = tracking.lootFinalizeDeadline
        end
        self:ScheduleLootTrackingTimeout(tracking)
        return
    end

    tracking = {
        runId = runId,
        startedAt = GetTime and GetTime() or 0,
        lootOpened = false,
        lootClosed = false,
        lootFinalizeDeadline = time() + LOOT_TRACKING_TIMEOUT,
    }
    self.runtime.lootTracking = tracking
    local activeRun = self.runtime.activeRun
    if type(activeRun) == "table" and activeRun.runId == runId then
        activeRun.lootFinalizeDeadline = tracking.lootFinalizeDeadline
    end
    self.EventFrame:RegisterEvent("ENCOUNTER_LOOT_RECEIVED")
    self.EventFrame:RegisterEvent("LOOT_OPENED")
    self.EventFrame:RegisterEvent("LOOT_CLOSED")
    self:PushRuntimeDebug(("start loot tracking run %s"):format(tostring(runId)))
    self:ScheduleLootTrackingTimeout(tracking)
end

function MythicTools:StopLootTracking()
    if not self.runtime then
        return
    end

    local tracking = self.runtime.lootTracking
    if type(tracking) == "table" then
        self:PushRuntimeDebug(("stop loot tracking run %s"):format(tostring(tracking.runId)))
    end
    local activeRun = self.runtime.activeRun
    if type(activeRun) == "table" and type(tracking) == "table" and activeRun.runId == tracking.runId then
        activeRun.lootFinalizeDeadline = nil
    end

    local pendingPopup = self.runtime.pendingCompletionPopup
    if type(pendingPopup) == "table" and type(tracking) == "table" and pendingPopup.runId == tracking.runId then
        self:PushRuntimeDebug(("discard pending popup on loot stop run %s"):format(tostring(tracking.runId)))
        self.runtime.pendingCompletionPopup = nil
    end

    self.runtime.lootTracking = nil
    self.EventFrame:UnregisterEvent("ENCOUNTER_LOOT_RECEIVED")
    self.EventFrame:UnregisterEvent("LOOT_OPENED")
    self.EventFrame:UnregisterEvent("LOOT_CLOSED")

    self:QueueRefreshAllViews("capture")
end

function MythicTools:HandleEncounterLootReceived(...)
    local tracking = self.runtime and self.runtime.lootTracking
    if not tracking then
        return
    end

    local itemLink = select(3, ...)
    local unitName = select(5, ...)
    if not itemLink or not unitName then
        self:PushRuntimeDebug(("encounter loot ignored run %s item=%s unit=%s"):format(
            tostring(tracking.runId),
            tostring(itemLink),
            tostring(unitName)
        ))
        return
    end

    if not self:IsTrackableEncounterLootItem(itemLink, tracking.runId) then
        self:PushRuntimeDebug(("encounter loot filtered run %s unit=%s item=%s"):format(
            tostring(tracking.runId),
            tostring(unitName),
            tostring(itemLink)
        ))
        return
    end

    self:PushRuntimeDebug(("encounter loot run %s unit=%s item=%s"):format(
        tostring(tracking.runId),
        tostring(unitName),
        tostring(itemLink)
    ))
    local recorded = self:RecordRunLoot(tracking.runId, unitName, itemLink)
    if not recorded then
        self:PushRuntimeDebug(("encounter loot not matched run %s unit=%s"):format(
            tostring(tracking.runId),
            tostring(unitName)
        ))
    end
end

local function ExtractLootLinkFromChatMessage(message)
    if type(message) ~= "string" or message == "" then
        return nil
    end

    return message:match("(|c%x+|Hitem:.-|h%[.-%]|h|r)")
        or message:match("(|Hitem:.-|h%[.-%]|h)")
end

local function EscapeLuaPattern(text)
    return (text:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1"))
end

local function BuildLootMessagePattern(template, captureStringArgumentIndex)
    if type(template) ~= "string" or template == "" then
        return nil
    end

    local parts = {}
    local index = 1
    local sequentialStringIndex = 0

    while index <= #template do
        local startPos, endPos, positionalIndex, valueType = template:find("%%(%d+)%$([sd])", index)
        local isPositional = startPos ~= nil
        if not isPositional then
            startPos, endPos, valueType = template:find("%%([sd])", index)
        end

        if not startPos then
            parts[#parts + 1] = EscapeLuaPattern(template:sub(index))
            break
        end

        if startPos > index then
            parts[#parts + 1] = EscapeLuaPattern(template:sub(index, startPos - 1))
        end

        local argumentIndex = tonumber(positionalIndex)
        if not argumentIndex and valueType == "s" then
            sequentialStringIndex = sequentialStringIndex + 1
            argumentIndex = sequentialStringIndex
        end

        if valueType == "s" and captureStringArgumentIndex and argumentIndex == captureStringArgumentIndex then
            parts[#parts + 1] = "(.+)"
        elseif valueType == "d" then
            parts[#parts + 1] = "%d+"
        else
            parts[#parts + 1] = ".+"
        end

        index = endPos + 1
    end

    return "^" .. table.concat(parts) .. "$"
end

local LOOT_SELF_MESSAGE_PATTERNS
local LOOT_RECIPIENT_MESSAGE_PATTERNS

local function GetLootSelfMessagePatterns()
    if LOOT_SELF_MESSAGE_PATTERNS then
        return LOOT_SELF_MESSAGE_PATTERNS
    end

    LOOT_SELF_MESSAGE_PATTERNS = {}
    for _, globalName in ipairs({
        "LOOT_ITEM_SELF",
        "LOOT_ITEM_PUSHED_SELF",
    }) do
        local pattern = BuildLootMessagePattern(_G[globalName], nil)
        if pattern then
            LOOT_SELF_MESSAGE_PATTERNS[#LOOT_SELF_MESSAGE_PATTERNS + 1] = pattern
        end
    end

    return LOOT_SELF_MESSAGE_PATTERNS
end

local function GetLootRecipientMessagePatterns()
    if LOOT_RECIPIENT_MESSAGE_PATTERNS then
        return LOOT_RECIPIENT_MESSAGE_PATTERNS
    end

    LOOT_RECIPIENT_MESSAGE_PATTERNS = {}
    for _, globalName in ipairs({
        "LOOT_ITEM",
        "LOOT_ITEM_PUSHED",
    }) do
        local pattern = BuildLootMessagePattern(_G[globalName], 1)
        if pattern then
            LOOT_RECIPIENT_MESSAGE_PATTERNS[#LOOT_RECIPIENT_MESSAGE_PATTERNS + 1] = pattern
        end
    end

    return LOOT_RECIPIENT_MESSAGE_PATTERNS
end

local function IsLootSelfMessage(message)
    if type(message) ~= "string" or message == "" then
        return false
    end

    for _, pattern in ipairs(GetLootSelfMessagePatterns()) do
        if message:match(pattern) then
            return true
        end
    end

    return false
end

local function ExtractLootRecipientFromMessage(message)
    if type(message) ~= "string" or message == "" then
        return nil
    end

    if IsLootSelfMessage(message) then
        return "__SELF__"
    end

    for _, pattern in ipairs(GetLootRecipientMessagePatterns()) do
        local recipient = message:match(pattern)
        if type(recipient) == "string" and recipient ~= "" then
            return recipient
        end
    end

    return nil
end

function MythicTools:GetLootTargetByRunId(runId)
    local target = self:GetRunById(runId)
    if not target and self.runtime and self.runtime.activeRun and self.runtime.activeRun.runId == runId then
        target = self.runtime.activeRun
    end

    if type(target) ~= "table" then
        return nil
    end

    return target
end

function MythicTools:ResolveLootRecipientFromGuid(runId, guid)
    if not self:CanAccessValue(guid) or not self:IsPlayerGUID(guid) then
        return nil
    end

    local target = self:GetLootTargetByRunId(runId)
    if type(target) ~= "table" then
        return nil
    end

    for _, fullName in ipairs(self:GetRosterNamesForTarget(target)) do
        local stat = target.playerStats and target.playerStats[fullName] or nil
        if type(stat) == "table" and stat.guid == guid then
            return fullName
        end
    end

    for _, rosterEntry in ipairs(self:GetCurrentGroupRoster()) do
        if rosterEntry.guid == guid then
            return self:ResolvePlayerNameForTarget(target, rosterEntry.name)
                or self:NormalizePlayerName(rosterEntry.name)
                or rosterEntry.name
        end
    end

    local playerGUID = UnitGUID and UnitGUID("player") or nil
    if playerGUID and playerGUID == guid then
        return self.playerName or self:GetUnitFullName("player")
    end

    return nil
end

function MythicTools:ResolveLootRecipientFromChat(runId, playerName, guid)
    local recipient = self:ResolveLootRecipientFromGuid(runId, guid)
    if recipient then
        return recipient
    end

    local target = self:GetLootTargetByRunId(runId)
    if type(target) ~= "table" then
        return nil
    end

    if self:CanAccessValue(playerName) and type(playerName) == "string" and playerName ~= "" then
        recipient = self:ResolvePlayerNameForTarget(target, playerName)
        if recipient then
            return recipient
        end
    end

    return nil
end

function MythicTools:HandleChatMsgLoot(message, playerName, _, _, _, _, _, _, _, _, _, guid)
    local tracking = self.runtime and self.runtime.lootTracking
    if not tracking then
        return
    end

    if not self:CanAccessValue(message) then
        self:PushRuntimeDebug(("chat loot inaccessible run %s"):format(tostring(tracking.runId)))
        return
    end

    local itemLink = ExtractLootLinkFromChatMessage(message)
    if not itemLink then
        return
    end

    local parsedRecipient = ExtractLootRecipientFromMessage(message)
    if parsedRecipient == "__SELF__" then
        playerName = self.playerName or self:GetUnitFullName("player")
    elseif type(parsedRecipient) == "string" and parsedRecipient ~= "" then
        playerName = parsedRecipient
    end

    local recipient = self:ResolveLootRecipientFromChat(tracking.runId, playerName, guid)
    if not recipient then
        self:PushRuntimeDebug(("chat loot recipient unresolved run %s guid=%s hasPlayerName=%s"):format(
            tostring(tracking.runId),
            tostring(guid),
            tostring(type(playerName) == "string" and playerName ~= "")
        ))
        return
    end

    self:PushRuntimeDebug(("chat loot run %s recipient=%s item=%s"):format(
        tostring(tracking.runId),
        tostring(recipient),
        tostring(itemLink)
    ))
    local recorded = self:RecordRunLoot(tracking.runId, recipient, itemLink)
    if not recorded then
        self:PushRuntimeDebug(("chat loot not matched run %s recipient=%s"):format(
            tostring(tracking.runId),
            tostring(recipient)
        ))
    end
end

function MythicTools:HandleLootOpened()
    local tracking = self.runtime and self.runtime.lootTracking
    if not tracking then
        return
    end

    if not tracking.lootOpened then
        tracking.lootOpened = true
        tracking.lootOpenedAt = time()
        self:PushRuntimeDebug(("loot opened run %s"):format(tostring(tracking.runId)))
        if self.ResolvePendingCompletionPopup then
            self:ResolvePendingCompletionPopup("LOOT_OPENED")
        end
    end

    self:QueueRefreshAllViews("capture")
end

function MythicTools:HandleLootClosed()
    local tracking = self.runtime and self.runtime.lootTracking
    if not tracking then
        return
    end

    if not tracking.lootOpened then
        tracking.lootOpened = true
        tracking.lootOpenedAt = time()
        self:PushRuntimeDebug(("loot closed before opened run %s"):format(tostring(tracking.runId)))
        if self.ResolvePendingCompletionPopup then
            self:ResolvePendingCompletionPopup("LOOT_OPENED")
        end
    end

    tracking.lootClosed = true
    tracking.lootClosedAt = time()
    self:QueueRefreshAllViews("capture")
end


function MythicTools:BuildCompletedRun(activeRun, completionInfo)
    local mapChallengeModeID = completionInfo.mapChallengeModeID or activeRun.mapChallengeModeID or 0
    local mapInfo = GetMapInfo(mapChallengeModeID)
    local completedAt = tonumber(activeRun.completedAt) or time()
    local elapsedTimeMS = math.max(0, (completedAt - (tonumber(activeRun.startedAt) or completedAt)) * 1000)

    self:UpdateActiveRunFromCompletionMembers(activeRun, completionInfo.members)
    self:HydrateObservedScoresForTarget(activeRun)

    local run = {
        runId = activeRun.runId,
        startTime = activeRun.startedAt,
        endTime = completedAt,
        mapChallengeModeID = mapChallengeModeID,
        mapID = mapInfo and mapInfo.mapID or activeRun.mapID or mapChallengeModeID,
        season = self.GetDefaultSeason and self:GetDefaultSeason() or "season1",
        dungeonName = mapInfo and mapInfo.dungeonName or activeRun.dungeonName or "Unknown",
        ownerCharacter = self.playerName or activeRun.ownerCharacter,
        textureFileID = mapInfo and mapInfo.textureFileID or nil,
        backgroundTextureFileID = mapInfo and mapInfo.backgroundTextureFileID or nil,
        level = tonumber(completionInfo.level) or tonumber(activeRun.keyLevel) or 0,
        timeMS = tonumber(completionInfo.time) or elapsedTimeMS,
        onTime = completionInfo.onTime and true or false,
        result = completionInfo.onTime and "timed" or "overtime",
        keystoneUpgradeLevels = tonumber(completionInfo.keystoneUpgradeLevels) or 0,
        practiceRun = completionInfo.practiceRun and true or false,
        isEligibleForScore = completionInfo.isEligibleForScore == nil and true or (completionInfo.isEligibleForScore and true or false),
        oldOverallDungeonScore = completionInfo.oldOverallDungeonScore,
        newOverallDungeonScore = completionInfo.newOverallDungeonScore,
        timeLimitSeconds = mapInfo and mapInfo.timeLimit or activeRun.timeLimitSeconds or 0,
        totalDeaths = tonumber(activeRun.totalDeaths) or 0,
        deathPenaltySeconds = tonumber(activeRun.deathPenaltySeconds) or 0,
        affixIDs = self:CopyArray(activeRun.affixIDs),
        roster = self:CopyArray(activeRun.participantOrder),
        playerStats = {},
        petOwnerGUIDs = CopySet(activeRun.petOwnerGUIDs),
        sessionIDs = self:GetTrackedSessionIDsForRun(activeRun),
        combatTimeSeconds = nil,
        outOfCombatSeconds = nil,
        completionSource = activeRun.pendingCompletionSource,
        statsSource = nil,
        statsUnavailable = false,
        statsNote = nil,
    }
    run.keystoneUpgradeLevels = self:GetRunKeystoneUpgradeLevels(run)

    for _, fullName in ipairs(run.roster) do
        local sourceStat = activeRun.playerStats[fullName] or {}
        run.playerStats[fullName] = {
            name = fullName,
            shortName = self:GetShortName(fullName),
            guid = sourceStat.guid,
            classFilename = sourceStat.classFilename,
            role = sourceStat.role,
            specID = sourceStat.specID,
            specName = sourceStat.specName,
            specIconID = sourceStat.specIconID,
            score = sourceStat.score,
            previousScore = sourceStat.previousScore,
            itemLevel = sourceStat.itemLevel,
            damageActiveSeconds = sourceStat.damageActiveSeconds,
            healingActiveSeconds = sourceStat.healingActiveSeconds,
            dps = sourceStat.dps,
            hps = sourceStat.hps,
            damagePerSecond = sourceStat.damagePerSecond,
            healingPerSecond = sourceStat.healingPerSecond,
            damage = nil,
            healing = nil,
            interrupts = nil,
            dispels = nil,
            avoidableDamage = nil,
            damageDoneBySpells = CopySpellBreakdownList(sourceStat.damageDoneBySpells),
            healDoneBySpells = CopySpellBreakdownList(sourceStat.healDoneBySpells),
            interruptsBySpells = CopySpellBreakdownList(sourceStat.interruptsBySpells),
            dispelsBySpells = CopySpellBreakdownList(sourceStat.dispelsBySpells),
            avoidableDamageBySpells = CopySpellBreakdownList(sourceStat.avoidableDamageBySpells),
            deaths = tonumber(sourceStat.deaths) or 0,
            loot = type(sourceStat.loot) == "table" and self:CopyArray(sourceStat.loot) or {},
        }
    end

    return run
end

function MythicTools:BuildAbandonedRun(activeRun)
    if type(activeRun) ~= "table" then
        return nil
    end

    local mapChallengeModeID = activeRun.mapChallengeModeID or 0
    local mapInfo = GetMapInfo(mapChallengeModeID)
    self:UpdateActiveRunParticipants()

    local startedAt = tonumber(activeRun.startedAt) or time()
    local run = {
        runId = activeRun.runId,
        startTime = startedAt,
        endTime = time(),
        mapChallengeModeID = mapChallengeModeID,
        mapID = mapInfo and mapInfo.mapID or activeRun.mapID or mapChallengeModeID,
        season = self.GetDefaultSeason and self:GetDefaultSeason() or "season1",
        dungeonName = mapInfo and mapInfo.dungeonName or activeRun.dungeonName or "Unknown",
        ownerCharacter = self.playerName or activeRun.ownerCharacter,
        textureFileID = mapInfo and mapInfo.textureFileID or nil,
        backgroundTextureFileID = mapInfo and mapInfo.backgroundTextureFileID or nil,
        level = tonumber(activeRun.keyLevel) or 0,
        timeMS = math.max(0, (time() - startedAt) * 1000),
        onTime = false,
        result = "abandoned",
        keystoneUpgradeLevels = 0,
        practiceRun = false,
        isEligibleForScore = false,
        oldOverallDungeonScore = nil,
        newOverallDungeonScore = nil,
        timeLimitSeconds = mapInfo and mapInfo.timeLimit or activeRun.timeLimitSeconds or 0,
        totalDeaths = tonumber(activeRun.totalDeaths) or 0,
        deathPenaltySeconds = tonumber(activeRun.deathPenaltySeconds) or 0,
        affixIDs = self:CopyArray(activeRun.affixIDs),
        roster = self:CopyArray(activeRun.participantOrder),
        playerStats = {},
        petOwnerGUIDs = CopySet(activeRun.petOwnerGUIDs),
        sessionIDs = self:GetTrackedSessionIDsForRun(activeRun),
        combatTimeSeconds = nil,
        outOfCombatSeconds = nil,
        completionSource = nil,
        statsSource = nil,
        statsUnavailable = false,
        statsNote = "Run was abandoned before completion.",
    }

    for _, fullName in ipairs(run.roster) do
        local sourceStat = activeRun.playerStats[fullName] or {}
        run.playerStats[fullName] = {
            name = fullName,
            shortName = self:GetShortName(fullName),
            guid = sourceStat.guid,
            classFilename = sourceStat.classFilename,
            role = sourceStat.role,
            specID = sourceStat.specID,
            specName = sourceStat.specName,
            specIconID = sourceStat.specIconID,
            score = sourceStat.score,
            previousScore = sourceStat.previousScore,
            itemLevel = sourceStat.itemLevel,
            damageActiveSeconds = sourceStat.damageActiveSeconds,
            healingActiveSeconds = sourceStat.healingActiveSeconds,
            dps = sourceStat.dps,
            hps = sourceStat.hps,
            damagePerSecond = sourceStat.damagePerSecond,
            healingPerSecond = sourceStat.healingPerSecond,
            damage = nil,
            healing = nil,
            interrupts = nil,
            dispels = nil,
            avoidableDamage = nil,
            damageDoneBySpells = CopySpellBreakdownList(sourceStat.damageDoneBySpells),
            healDoneBySpells = CopySpellBreakdownList(sourceStat.healDoneBySpells),
            interruptsBySpells = CopySpellBreakdownList(sourceStat.interruptsBySpells),
            dispelsBySpells = CopySpellBreakdownList(sourceStat.dispelsBySpells),
            avoidableDamageBySpells = CopySpellBreakdownList(sourceStat.avoidableDamageBySpells),
            deaths = tonumber(sourceStat.deaths) or 0,
            loot = type(sourceStat.loot) == "table" and self:CopyArray(sourceStat.loot) or {},
        }
    end

    self:FinalizeRunStats(activeRun, run, false)
    return run
end

function MythicTools:AddAbandonedRun(activeRun)
    local run = self:BuildAbandonedRun(activeRun)
    if not run then
        return
    end

    if self.RegisterOwnedCharacter then
        self:RegisterOwnedCharacter(self.playerName)
    end
    self:AddCompletedRun(run)
    self.db.ui.selectedRunId = run.runId
    self:QueueRefreshAllViews("capture")
end

local function AppendRunNote(run, noteText)
    if type(run) ~= "table" or type(noteText) ~= "string" or noteText == "" then
        return
    end

    if type(run.statsNote) == "string" and run.statsNote ~= "" then
        if not run.statsNote:find(noteText, 1, true) then
            run.statsNote = run.statsNote .. " " .. noteText
        end
    else
        run.statsNote = noteText
    end
end

local function ClearTransientRunNotes(run)
    if type(run) ~= "table" or type(run.statsNote) ~= "string" or run.statsNote == "" then
        return
    end

    local note = run.statsNote
    note = note:gsub("Waitingh? for damage meter sessions%.?", "")
    note = note:gsub("Waitingh? for damage meter totals%.?", "")
    note = note:gsub("Waiting for Details overall segment%.?", "")
    note = note:gsub("%s%s+", " ")
    note = note:gsub("^%s+", ""):gsub("%s+$", "")
    run.statsNote = note ~= "" and note or nil
end

local function SafeCall(method, object, ...)
    if type(method) ~= "function" then
        return nil
    end

    local ok, value = pcall(method, object, ...)
    if ok then
        return value
    end

    return nil
end

local function SafeDetailsActorMethod(actor, methodName)
    if type(actor) ~= "table" or type(actor[methodName]) ~= "function" then
        return nil
    end

    local ok, value = pcall(actor[methodName], actor)
    if ok then
        return value
    end

    return nil
end

local function IsDetailsPlayerActor(actor)
    if type(actor) ~= "table" then
        return false
    end

    local isPlayer = SafeDetailsActorMethod(actor, "IsPlayer")
    if isPlayer == false then
        return false
    end

    local classFilename = SafeDetailsActorMethod(actor, "Class") or actor.classe or actor.class
    return classFilename ~= "UNGROUPPLAYER"
end

local function GetDetailsActorName(actor)
    return SafeDetailsActorMethod(actor, "Name") or actor.nome or actor.name
end

local function GetDetailsActorGUID(actor)
    return SafeDetailsActorMethod(actor, "GetGUID") or actor.serial or actor.guid
end

local function GetDetailsActorSpec(actor)
    return SafeDetailsActorMethod(actor, "Spec") or actor.spec
end

local function GetDetailsActorActivityTime(actor)
    return tonumber(SafeDetailsActorMethod(actor, "Tempo") or actor.tempo or actor.activityTime) or nil
end

local function GetDetailsContainerActors(container)
    if type(container) ~= "table" or type(container.ListActors) ~= "function" then
        return {}
    end

    local ok, actors = pcall(function()
        local actorList = {}
        for _, actor in container:ListActors() do
            actorList[#actorList + 1] = actor
        end
        return actorList
    end)

    return ok and type(actors) == "table" and actors or {}
end

local function NormalizeDetailsSpellBreakdown(spells, maxEntries)
    if type(spells) ~= "table" then
        return {}
    end

    local rawSpells = spells
    if type(spells._ActorTable) == "table" then
        rawSpells = spells._ActorTable
    end

    local normalized = {}
    for _, spellTable in pairs(rawSpells) do
        if type(spellTable) == "table" then
            local spellID = tonumber(spellTable.id or spellTable.spellID or spellTable.spellId)
            local totalAmount = tonumber(spellTable.total or spellTable.totalAmount or spellTable.amount)
            if spellID and totalAmount and totalAmount > 0 then
                normalized[#normalized + 1] = {
                    spellID = spellID,
                    totalAmount = totalAmount,
                    amountPerSecond = tonumber(spellTable.amountPerSecond) or nil,
                }
            end
        end
    end

    table.sort(normalized, function(a, b)
        return (a.totalAmount or 0) > (b.totalAmount or 0)
    end)

    maxEntries = tonumber(maxEntries) or MAX_TOOLTIP_SPELL_ENTRIES
    if maxEntries > 0 and #normalized > maxEntries then
        for index = #normalized, maxEntries + 1, -1 do
            normalized[index] = nil
        end
    end

    return normalized
end

local function GetDetailsActorSpells(actor)
    local spells = SafeDetailsActorMethod(actor, "GetActorSpells")
    if type(spells) == "table" then
        return spells
    end

    return actor and actor.spells or nil
end


-- ---------------------------------------------------------------------------
-- Shared Capture foundation exposed for split modules (CaptureRun.lua, etc.).
-- References keep their upvalues; consumer modules import only what they use.
-- Split modules must appear AFTER Capture.lua in the .toc.
-- ---------------------------------------------------------------------------
ns.Capture = ns.Capture or {}
ns.Capture.AppendRunNote = AppendRunNote
ns.Capture.AVOIDABLE_DAMAGE = AVOIDABLE_DAMAGE
ns.Capture.BuildCompletionMembersFromActiveRun = BuildCompletionMembersFromActiveRun
ns.Capture.BuildLootMessagePattern = BuildLootMessagePattern
ns.Capture.BuildRecoveredCompletionInfo = BuildRecoveredCompletionInfo
ns.Capture.ClearTransientRunNotes = ClearTransientRunNotes
ns.Capture.COMPLETION_RETRY_DELAY = COMPLETION_RETRY_DELAY
ns.Capture.CopyActiveRunPlayerStats = CopyActiveRunPlayerStats
ns.Capture.CopyCompletionInfo = CopyCompletionInfo
ns.Capture.CopyCompletionMembers = CopyCompletionMembers
ns.Capture.CopySet = CopySet
ns.Capture.CopySpellBreakdownList = CopySpellBreakdownList
ns.Capture.CreateActiveRunSnapshot = CreateActiveRunSnapshot
ns.Capture.DAMAGE_DONE = DAMAGE_DONE
ns.Capture.DAMAGE_METER_OVERALL_SESSION = DAMAGE_METER_OVERALL_SESSION
ns.Capture.DEBUG_EVENT_BUFFER_SIZE = DEBUG_EVENT_BUFFER_SIZE
ns.Capture.DISPELS = DISPELS
ns.Capture.EscapeLuaPattern = EscapeLuaPattern
ns.Capture.ExtractLootLinkFromChatMessage = ExtractLootLinkFromChatMessage
ns.Capture.ExtractLootRecipientFromMessage = ExtractLootRecipientFromMessage
ns.Capture.GetChallengeCompletionInfoCompat = GetChallengeCompletionInfoCompat
ns.Capture.GetCombatSessionDurationFromData = GetCombatSessionDurationFromData
ns.Capture.GetCombatSourceAmountPerSecond = GetCombatSourceAmountPerSecond
ns.Capture.GetCombatSourceTotalAmount = GetCombatSourceTotalAmount
ns.Capture.GetCompletionMemberItemLevel = GetCompletionMemberItemLevel
ns.Capture.GetCurrentOverallCombatDurationFromDamageMeter = GetCurrentOverallCombatDurationFromDamageMeter
ns.Capture.GetDamageMeterSourceSpellBreakdown = GetDamageMeterSourceSpellBreakdown
ns.Capture.GetDamageMeterTypeValue = GetDamageMeterTypeValue
ns.Capture.GetDetailsActorActivityTime = GetDetailsActorActivityTime
ns.Capture.GetDetailsActorGUID = GetDetailsActorGUID
ns.Capture.GetDetailsActorName = GetDetailsActorName
ns.Capture.GetDetailsActorSpec = GetDetailsActorSpec
ns.Capture.GetDetailsActorSpells = GetDetailsActorSpells
ns.Capture.GetDetailsContainerActors = GetDetailsContainerActors
ns.Capture.GetEncounterLootDetailedItemLevel = GetEncounterLootDetailedItemLevel
ns.Capture.GetEncounterLootItemClassID = GetEncounterLootItemClassID
ns.Capture.GetEntryAmount = GetEntryAmount
ns.Capture.GetInspectQueueKey = GetInspectQueueKey
ns.Capture.GetLootRecipientMessagePatterns = GetLootRecipientMessagePatterns
ns.Capture.GetLootSelfMessagePatterns = GetLootSelfMessagePatterns
ns.Capture.GetMapInfo = GetMapInfo
ns.Capture.GetObservedPlayerMythicScore = GetObservedPlayerMythicScore
ns.Capture.GetPetUnitForGroupUnit = GetPetUnitForGroupUnit
ns.Capture.GetPlayerMythicScoreFromBlizzard = GetPlayerMythicScoreFromBlizzard
ns.Capture.GetSourceCreatureID = GetSourceCreatureID
ns.Capture.GetSourceGUID = GetSourceGUID
ns.Capture.GetSourceOwnerGUID = GetSourceOwnerGUID
ns.Capture.GetSourceOwnerName = GetSourceOwnerName
ns.Capture.GetTargetAverageItemLevel = GetTargetAverageItemLevel
ns.Capture.HasAnyRunStats = HasAnyRunStats
ns.Capture.HasMissingCoreRunStats = HasMissingCoreRunStats
ns.Capture.HasMissingEntries = HasMissingEntries
ns.Capture.HasPositiveRunStatField = HasPositiveRunStatField
ns.Capture.HasRunStatField = HasRunStatField
ns.Capture.HEALING_DONE = HEALING_DONE
ns.Capture.INACTIVE_RESOLUTION_DELAY = INACTIVE_RESOLUTION_DELAY
ns.Capture.INSPECT_MAX_ATTEMPTS = INSPECT_MAX_ATTEMPTS
ns.Capture.INSPECT_REQUEST_TIMEOUT = INSPECT_REQUEST_TIMEOUT
ns.Capture.INTERRUPTS = INTERRUPTS
ns.Capture.IsBindToAccountUntilEquip = IsBindToAccountUntilEquip
ns.Capture.IsDetailsPlayerActor = IsDetailsPlayerActor
ns.Capture.IsInspectablePlayerUnit = IsInspectablePlayerUnit
ns.Capture.IsInspectDataMissing = IsInspectDataMissing
ns.Capture.IsLootSelfMessage = IsLootSelfMessage
ns.Capture.IsValidCompletionInfo = IsValidCompletionInfo
ns.Capture.LOOT_RECIPIENT_MESSAGE_PATTERNS = LOOT_RECIPIENT_MESSAGE_PATTERNS
ns.Capture.LOOT_SELF_MESSAGE_PATTERNS = LOOT_SELF_MESSAGE_PATTERNS
ns.Capture.LOOT_TRACKING_TIMEOUT = LOOT_TRACKING_TIMEOUT
ns.Capture.MAX_COMPLETION_ATTEMPTS = MAX_COMPLETION_ATTEMPTS
ns.Capture.MAX_TOOLTIP_SPELL_ENTRIES = MAX_TOOLTIP_SPELL_ENTRIES
ns.Capture.MergeSessionSet = MergeSessionSet
ns.Capture.NormalizeCapturedItemLevel = NormalizeCapturedItemLevel
ns.Capture.NormalizeCompletionInfo = NormalizeCompletionInfo
ns.Capture.NormalizeDetailsSpellBreakdown = NormalizeDetailsSpellBreakdown
ns.Capture.NormalizeInterruptCount = NormalizeInterruptCount
ns.Capture.NormalizeSpellBreakdownEntries = NormalizeSpellBreakdownEntries
ns.Capture.NormalizeStoredItemLink = NormalizeStoredItemLink
ns.Capture.PARTY_DATA_AVOIDABLE_DAMAGE_FIELDS = PARTY_DATA_AVOIDABLE_DAMAGE_FIELDS
ns.Capture.PARTY_DATA_DAMAGE_FIELDS = PARTY_DATA_DAMAGE_FIELDS
ns.Capture.PARTY_DATA_DISPEL_FIELDS = PARTY_DATA_DISPEL_FIELDS
ns.Capture.PARTY_DATA_HEALING_FIELDS = PARTY_DATA_HEALING_FIELDS
ns.Capture.PARTY_DATA_INTERRUPT_FIELDS = PARTY_DATA_INTERRUPT_FIELDS
ns.Capture.SafeCall = SafeCall
ns.Capture.SafeDetailsActorMethod = SafeDetailsActorMethod
ns.Capture.SafeGetCombatSessionDuration = SafeGetCombatSessionDuration
ns.Capture.SetCachedCompletionInfo = SetCachedCompletionInfo
