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

function MythicTools:PushRuntimeDebug(message)
    if not (self.runtime and message) then
        return
    end

    local buffer = self.runtime.debugLog
    if type(buffer) ~= "table" then
        buffer = {}
        self.runtime.debugLog = buffer
    end

    buffer[#buffer + 1] = ("%s %s"):format(date("%H:%M:%S"), tostring(message))
    while #buffer > DEBUG_EVENT_BUFFER_SIZE do
        table.remove(buffer, 1)
    end
end

function MythicTools:UpdateActiveRunSnapshot(activeRun)
    if not (self.runtime and type(activeRun) == "table") then
        return
    end

    self.runtime.lastActiveRunSnapshot = CreateActiveRunSnapshot(self, activeRun)
end

function MythicTools:RememberPendingCompletionSnapshot(activeRun, reason)
    if not (self.runtime and type(activeRun) == "table") then
        return nil
    end

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

    snapshot.completionPending = true
    snapshot.allowCompletionRecovery = true
    snapshot.pendingSnapshotReason = reason or "unknown"

    local pending = self.runtime.pendingCompletionSnapshots
    if type(pending) ~= "table" then
        pending = {}
        self.runtime.pendingCompletionSnapshots = pending
    end

    for index, existing in ipairs(pending) do
        if type(existing) == "table" and existing.runId == snapshot.runId then
            pending[index] = snapshot
            self:PushRuntimeDebug(("refresh pending snapshot run %s from %s"):format(tostring(snapshot.runId), tostring(reason or "unknown")))
            return snapshot
        end
    end

    pending[#pending + 1] = snapshot
    self:PushRuntimeDebug(("remember pending snapshot run %s from %s"):format(tostring(snapshot.runId), tostring(reason or "unknown")))
    return snapshot
end

function MythicTools:ForgetRecoveryState(runId)
    runId = tonumber(runId)
    if not (self.runtime and runId) then
        return
    end

    if type(self.runtime.lastActiveRunSnapshot) == "table" and tonumber(self.runtime.lastActiveRunSnapshot.runId) == runId then
        self.runtime.lastActiveRunSnapshot = nil
    end

    local pending = self.runtime.pendingCompletionSnapshots
    if type(pending) ~= "table" then
        return
    end

    for index = #pending, 1, -1 do
        local snapshot = pending[index]
        if type(snapshot) == "table" and tonumber(snapshot.runId) == runId then
            table.remove(pending, index)
        end
    end
end

function MythicTools:GetRecoverableCompletionSnapshot(mapChallengeModeID, level)
    if not self.runtime then
        return nil
    end

    local expectedMapID = tonumber(mapChallengeModeID) or 0
    local expectedLevel = tonumber(level) or 0

    local function SnapshotMatches(snapshot)
        if type(snapshot) ~= "table" then
            return false
        end

        local snapshotMapID = tonumber(snapshot.mapChallengeModeID) or 0
        local snapshotLevel = tonumber(snapshot.keyLevel) or 0
        local mapMatches = expectedMapID <= 0 or snapshotMapID <= 0 or snapshotMapID == expectedMapID
        local levelMatches = expectedLevel <= 0 or snapshotLevel <= 0 or snapshotLevel == expectedLevel
        return mapMatches and levelMatches
    end

    local pending = self.runtime.pendingCompletionSnapshots
    if type(pending) == "table" then
        for index = #pending, 1, -1 do
            if SnapshotMatches(pending[index]) then
                return pending[index]
            end
        end
    end

    local snapshot = self.runtime.lastActiveRunSnapshot
    if SnapshotMatches(snapshot) then
        return snapshot
    end

    return nil
end

function MythicTools:RestoreActiveRunFromSnapshot(snapshot, reason, completionInfo)
    if type(snapshot) ~= "table" then
        return nil
    end

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

    if IsValidCompletionInfo(completionInfo) then
        restoredRun.pendingCompletionInfo = CopyCompletionInfo(completionInfo)
        restoredRun.pendingCompletionSource = restoredRun.pendingCompletionSource or reason or "recovered"
    end

    restoredRun.restoredFromReload = true
    restoredRun.allowCompletionRecovery = true
    restoredRun.finalizationInProgress = false
    restoredRun.inactiveResolutionQueued = false
    restoredRun.state = restoredRun.state or "running"

    self.runtime.activeRun = restoredRun
    self.EventFrame:RegisterEvent("UNIT_HEALTH")
    self:UpdateActiveRunSnapshot(restoredRun)
    self:PushRuntimeDebug(("restore active run %s from %s"):format(tostring(restoredRun.runId), tostring(reason or "snapshot")))
    return restoredRun
end

function MythicTools:QueuePendingCompletionProcessing(trigger)
    if not (self.runtime and not self.runtime.pendingCompletionProcessingQueued) then
        return
    end

    local pending = self.runtime.pendingCompletionSnapshots
    if type(pending) ~= "table" or #pending == 0 then
        return
    end

    self.runtime.pendingCompletionProcessingQueued = true
    self:Schedule(INACTIVE_RESOLUTION_DELAY, function()
        if not self.runtime then
            return
        end

        self.runtime.pendingCompletionProcessingQueued = false
        if C_ChallengeMode.IsChallengeModeActive and C_ChallengeMode.IsChallengeModeActive() then
            return
        end

        if self.ProcessPendingCompletionSnapshots then
            self:ProcessPendingCompletionSnapshots(trigger or "PLAYER_ENTERING_WORLD", true)
        end
    end)
end

function MythicTools:SetActiveRunState(activeRun, newState)
    if type(activeRun) ~= "table" or activeRun.state == newState then
        return
    end

    activeRun.state = newState
    self:PushRuntimeDebug(("run %s state=%s"):format(tostring(activeRun.runId), tostring(newState)))
end

function MythicTools:MarkCompletionSignal(activeRun, signal)
    if type(activeRun) ~= "table" or type(signal) ~= "string" or signal == "" then
        return
    end

    local signals = type(activeRun.completionSignals) == "table" and activeRun.completionSignals or {}
    activeRun.completionSignals = signals
    signals[signal] = signals[signal] or time()
    self:PushRuntimeDebug(("run %s signal=%s"):format(tostring(activeRun.runId), signal))
end

function MythicTools:HasCompletionSignals(activeRun)
    if type(activeRun) ~= "table" then
        return false
    end

    for _ in pairs(activeRun.completionSignals or {}) do
        return true
    end

    return false
end

function MythicTools:HasCompletionSignal(activeRun, signal)
    return type(activeRun) == "table"
        and type(activeRun.completionSignals) == "table"
        and activeRun.completionSignals[signal] ~= nil
end

function MythicTools:ShouldUseEarlyCompletionRecovery(activeRun, attempt)
    if type(activeRun) ~= "table" then
        return false
    end

    attempt = tonumber(attempt) or 0
    if attempt < 3 then
        return false
    end

    if self.runtime and self.runtime.lastCompletionRewards ~= nil then
        return true
    end

    if self:HasCompletionSignal(activeRun, "SCENARIO_COMPLETED") and self:HasCompletionSignal(activeRun, "CHALLENGE_MODE_COMPLETED") then
        return true
    end

    if self:HasCompletionSignal(activeRun, "CHALLENGE_MODE_COMPLETED") and attempt >= 5 then
        return true
    end

    if not (C_ChallengeMode.IsChallengeModeActive and C_ChallengeMode.IsChallengeModeActive()) then
        return attempt >= 10 and self:HasCompletionSignals(activeRun)
    end

    return false
end

function MythicTools:QueueInactiveRunResolution(activeRun, trigger)
    if type(activeRun) ~= "table" or activeRun.inactiveResolutionQueued then
        return
    end

    activeRun.inactiveResolutionQueued = true
    self:PushRuntimeDebug(("run %s queue inactive resolution from %s"):format(tostring(activeRun.runId), tostring(trigger or "unknown")))
    self:Schedule(INACTIVE_RESOLUTION_DELAY, function()
        if not self.runtime or self.runtime.activeRun ~= activeRun then
            return
        end

        activeRun.inactiveResolutionQueued = false
        if C_ChallengeMode.IsChallengeModeActive and C_ChallengeMode.IsChallengeModeActive() then
            return
        end

        self:HandleChallengeModeInactive(trigger or "PLAYER_ENTERING_WORLD")
    end)
end

function MythicTools:ScheduleLootTrackingTimeout(tracking)
    if type(tracking) ~= "table" then
        return
    end

    self:Schedule(LOOT_TRACKING_TIMEOUT, function()
        if not (self.runtime and self.runtime.lootTracking == tracking) then
            return
        end

        local deadline = tonumber(tracking.lootFinalizeDeadline) or 0
        if deadline > 0 and time() < deadline then
            self:ScheduleLootTrackingTimeout(tracking)
            return
        end

        self:PushRuntimeDebug(("loot tracking timeout run %s"):format(tostring(tracking.runId)))
        self:StopLootTracking()
    end)
end

function MythicTools:RequestMythicPlusData()
    if not C_MythicPlus then
        return
    end

    if C_MythicPlus.RequestMapInfo then
        pcall(C_MythicPlus.RequestMapInfo)
    end

    if C_MythicPlus.RequestRewards then
        pcall(C_MythicPlus.RequestRewards)
    end
end

function MythicTools:GetMythicPlusRunHistory()
    if not (C_MythicPlus and C_MythicPlus.GetRunHistory) then
        return nil
    end

    if self.RequestMythicPlusData then
        self:RequestMythicPlusData()
    end

    local ok, runs = pcall(C_MythicPlus.GetRunHistory, true, true, true)
    if ok and type(runs) == "table" then
        return runs
    end

    ok, runs = pcall(C_MythicPlus.GetRunHistory, true, true)
    if ok and type(runs) == "table" then
        return runs
    end

    ok, runs = pcall(C_MythicPlus.GetRunHistory)
    if ok and type(runs) == "table" then
        return runs
    end

    return nil
end

function MythicTools:GetRunHistoryCompletionInfo(activeRun)
    local runs = self:GetMythicPlusRunHistory()
    if type(runs) ~= "table" or #runs == 0 then
        return nil
    end

    local expectedMapID = tonumber(activeRun and activeRun.mapChallengeModeID) or 0
    local expectedLevel = tonumber(activeRun and activeRun.keyLevel) or 0
    local expectedCompletedAt = tonumber(activeRun and activeRun.completedAt) or time()
    local bestRun, bestScore

    local function NormalizeHistoryTimestamp(value)
        value = tonumber(value) or 0
        if value <= 0 then
            return 0
        end
        if value > 1000000000000 then
            value = value / 1000
        end
        return value
    end

    for index, runInfo in ipairs(runs) do
        if type(runInfo) == "table" then
            local mapChallengeModeID = tonumber(runInfo.mapChallengeModeID or runInfo.challengeModeMapID or runInfo.mapID) or 0
            local level = tonumber(runInfo.level or runInfo.keystoneLevel) or 0
            local durationSeconds = tonumber(runInfo.durationSec or runInfo.time or runInfo.duration) or 0
            local completedAt = NormalizeHistoryTimestamp(runInfo.completedDate or runInfo.completedAt or runInfo.completedTime or runInfo.timestamp or runInfo.endTime)
            local completed = runInfo.completed
            local score = (#runs - index)
            local mapMatches = expectedMapID <= 0 or mapChallengeModeID == expectedMapID
            local levelMatches = expectedLevel <= 0 or level == expectedLevel
            local timeMatches = completedAt <= 0 or expectedCompletedAt <= 0 or math.abs(completedAt - expectedCompletedAt) <= 1800

            if completed ~= false and mapMatches and levelMatches and timeMatches and mapChallengeModeID > 0 and level > 0 and durationSeconds > 0 then
                if expectedMapID > 0 and mapChallengeModeID == expectedMapID then
                    score = score + 10
                end
                if expectedLevel > 0 and level == expectedLevel then
                    score = score + 6
                end
                if completedAt > 0 and expectedCompletedAt > 0 then
                    score = score + math.max(0, 4 - math.floor(math.abs(completedAt - expectedCompletedAt) / 300))
                end

                if not bestScore or score > bestScore then
                    bestRun = runInfo
                    bestScore = score
                end
            end
        end
    end

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

    local mapChallengeModeID = tonumber(bestRun.mapChallengeModeID or bestRun.challengeModeMapID or bestRun.mapID) or 0
    local level = tonumber(bestRun.level or bestRun.keystoneLevel) or 0
    local durationSeconds = tonumber(bestRun.durationSec or bestRun.time or bestRun.duration) or 0
    local mapInfo = GetMapInfo(mapChallengeModeID)
    local timeLimitSeconds = tonumber(mapInfo and mapInfo.timeLimit) or tonumber(activeRun and activeRun.timeLimitSeconds) or 0

    if mapChallengeModeID <= 0 or level <= 0 or durationSeconds <= 0 then
        return nil
    end

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

function MythicTools:GetResolvedChallengeCompletionInfo(activeRun, allowEstimated)
    local pendingInfo = type(activeRun) == "table" and activeRun.pendingCompletionInfo or nil
    if IsValidCompletionInfo(pendingInfo) then
        return pendingInfo
    end

    local completionInfo = GetChallengeCompletionInfoCompat()
    if IsValidCompletionInfo(completionInfo) then
        return SetCachedCompletionInfo(activeRun, completionInfo, "native")
    end

    local rewardsInfo = self.runtime and self.runtime.lastCompletionRewards or nil
    local mapChallengeModeID = tonumber(rewardsInfo and rewardsInfo.mapChallengeModeID) or tonumber(activeRun and activeRun.mapChallengeModeID) or 0
    local level = tonumber(activeRun and activeRun.keyLevel) or 0
    local mapInfo = GetMapInfo(mapChallengeModeID)
    local timeLimitSeconds = tonumber(mapInfo and mapInfo.timeLimit) or tonumber(activeRun and activeRun.timeLimitSeconds) or 0
    local endTimestamp = tonumber(activeRun and activeRun.completedAt) or tonumber(rewardsInfo and rewardsInfo.receivedAt) or time()
    local startedAt = tonumber(activeRun and activeRun.startedAt) or endTimestamp
    local rewardTimeMS = tonumber(rewardsInfo and rewardsInfo.timeMS) or 0

    if mapChallengeModeID > 0 and level > 0 and rewardTimeMS > 0 then
        local fallbackInfo = {
            mapChallengeModeID = mapChallengeModeID,
            level = level,
            time = rewardTimeMS,
            onTime = timeLimitSeconds > 0 and rewardTimeMS <= (timeLimitSeconds * 1000) or nil,
            keystoneUpgradeLevels = 0,
            practiceRun = false,
            oldOverallDungeonScore = nil,
            newOverallDungeonScore = nil,
            isMapRecord = nil,
            isAffixRecord = nil,
            primaryAffix = nil,
            isEligibleForScore = true,
            members = BuildCompletionMembersFromActiveRun(activeRun),
        }
        return SetCachedCompletionInfo(activeRun, fallbackInfo, "rewards")
    end

    local allowRecovery = allowEstimated and type(activeRun) == "table" and (activeRun.restoredFromReload or activeRun.allowCompletionRecovery)
    if allowRecovery then
        local historyInfo = self:GetRunHistoryCompletionInfo(activeRun)
        if IsValidCompletionInfo(historyInfo) then
            return SetCachedCompletionInfo(activeRun, historyInfo, "history")
        end
    end

    return nil
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

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

    self.runtime.inspect = type(self.runtime.inspect) == "table" and self.runtime.inspect or {}
    local inspectState = self.runtime.inspect
    inspectState.queue = type(inspectState.queue) == "table" and inspectState.queue or {}
    inspectState.queued = type(inspectState.queued) == "table" and inspectState.queued or {}
    inspectState.attempts = type(inspectState.attempts) == "table" and inspectState.attempts or {}
    inspectState.pending = type(inspectState.pending) == "table" and inspectState.pending or nil
    return inspectState
end

function MythicTools:QueueInspectForActiveRunPlayer(activeRun, fullName, unit, guid)
    if type(activeRun) ~= "table" or type(fullName) ~= "string" or fullName == "" or type(unit) ~= "string" or unit == "" then
        return false
    end

    if not IsInspectablePlayerUnit(unit) then
        return false
    end

    if UnitIsUnit and UnitIsUnit(unit, "player") then
        return false
    end

    if not (CanInspect and CanInspect(unit)) then
        return false
    end

    local stat = activeRun.playerStats and activeRun.playerStats[fullName] or nil
    if not IsInspectDataMissing(stat) then
        return false
    end

    local inspectState = self:GetInspectState()
    if not inspectState then
        return false
    end

    local key = GetInspectQueueKey(activeRun.runId, fullName, guid)
    if (inspectState.attempts[key] or 0) >= INSPECT_MAX_ATTEMPTS then
        return false
    end

    if inspectState.pending and inspectState.pending.key == key then
        return false
    end

    if inspectState.queued[key] then
        return false
    end

    inspectState.queued[key] = true
    inspectState.queue[#inspectState.queue + 1] = {
        key = key,
        runId = activeRun.runId,
        fullName = fullName,
        guid = guid,
        unit = unit,
    }

    self:ProcessPendingInspectForActiveRun(activeRun)
    return true
end

function MythicTools:TryHydrateActiveRunPlayerFromInspect(activeRun, fullName, unit)
    if type(activeRun) ~= "table" or type(fullName) ~= "string" or fullName == "" or type(unit) ~= "string" or unit == "" then
        return false
    end

    if not IsInspectablePlayerUnit(unit) then
        return false
    end

    local stat = self:EnsureActiveRunPlayerStat(activeRun, fullName)
    local changed = false

    local itemLevel = self.GetUnitItemLevel and self:GetUnitItemLevel(unit) or nil
    itemLevel = NormalizeCapturedItemLevel(itemLevel)
    if itemLevel and (NormalizeCapturedItemLevel(stat.itemLevel) or 0) < itemLevel then
        stat.itemLevel = itemLevel
        changed = true
    end

    local specID = GetInspectSpecialization and GetInspectSpecialization(unit) or nil
    specID = tonumber(specID) or specID
    if specID and specID > 0 and (not stat.specID or stat.specID ~= specID) then
        stat.specID = specID
        changed = true
    end

    if stat.specID and (not stat.specName or not stat.specIconID or not stat.role) then
        local resolvedSpecID, specName, specIconID, role = self:GetSpecInfo(stat.specID)
        if resolvedSpecID and resolvedSpecID ~= stat.specID then
            stat.specID = resolvedSpecID
            changed = true
        end
        if not stat.specName and specName then
            stat.specName = specName
            changed = true
        end
        if not stat.specIconID and specIconID then
            stat.specIconID = specIconID
            changed = true
        end
        if not stat.role and role then
            stat.role = role
            changed = true
        end
    end

    return changed
end

function MythicTools:TryHydrateCompletedRunPlayerFromInspect(run, fullName, unit)
    if type(run) ~= "table" or type(fullName) ~= "string" or fullName == "" or type(unit) ~= "string" or unit == "" then
        return false
    end

    if not IsInspectablePlayerUnit(unit) then
        return false
    end

    local resolvedName = self:ResolvePlayerNameForTarget(run, fullName) or self:GetUnitFullName(unit) or fullName
    local stat = self:EnsureCompletedRunPlayer(run, resolvedName)
    local changed = false

    local itemLevel = self.GetUnitItemLevel and self:GetUnitItemLevel(unit) or nil
    itemLevel = NormalizeCapturedItemLevel(itemLevel)
    if itemLevel and (NormalizeCapturedItemLevel(stat.itemLevel) or 0) < itemLevel then
        stat.itemLevel = itemLevel
        changed = true
    end

    local specID = GetInspectSpecialization and GetInspectSpecialization(unit) or nil
    specID = tonumber(specID) or specID
    if specID and specID > 0 and (not stat.specID or stat.specID ~= specID) then
        stat.specID = specID
        changed = true
    end

    if stat.specID and (not stat.specName or not stat.specIconID or not stat.role) then
        local resolvedSpecID, specName, specIconID, role = self:GetSpecInfo(stat.specID)
        if resolvedSpecID and resolvedSpecID ~= stat.specID then
            stat.specID = resolvedSpecID
            changed = true
        end
        if not stat.specName and specName then
            stat.specName = specName
            changed = true
        end
        if not stat.specIconID and specIconID then
            stat.specIconID = specIconID
            changed = true
        end
        if not stat.role and role then
            stat.role = role
            changed = true
        end
    end

    return changed
end

function MythicTools:ProcessPendingInspectForActiveRun(activeRun)
    local inspectState = self:GetInspectState()
    if not inspectState then
        return
    end

    local now = GetTime and GetTime() or time()
    if inspectState.pending and (now - (inspectState.pending.requestedAt or now)) < INSPECT_REQUEST_TIMEOUT then
        return
    end

    if inspectState.pending then
        inspectState.pending = nil
    end

    while #inspectState.queue > 0 do
        local entry = table.remove(inspectState.queue, 1)
        inspectState.queued[entry.key] = nil

        if type(entry) == "table" and IsInspectablePlayerUnit(entry.unit) then
            local targetRun = activeRun
            if not (type(targetRun) == "table" and targetRun.runId == entry.runId) then
                local runtimeRun = self.runtime and self.runtime.activeRun or nil
                if type(runtimeRun) == "table" and runtimeRun.runId == entry.runId then
                    targetRun = runtimeRun
                else
                    targetRun = nil
                end
            end

            if type(targetRun) == "table" then
                local stat = targetRun.playerStats and targetRun.playerStats[entry.fullName] or nil
                if IsInspectDataMissing(stat) and CanInspect and CanInspect(entry.unit) and NotifyInspect then
                    inspectState.attempts[entry.key] = (inspectState.attempts[entry.key] or 0) + 1
                    inspectState.pending = {
                        key = entry.key,
                        runId = entry.runId,
                        fullName = entry.fullName,
                        guid = entry.guid,
                        unit = entry.unit,
                        requestedAt = now,
                    }

                    local ok = pcall(NotifyInspect, entry.unit)
                    if ok then
                        self:Schedule(INSPECT_REQUEST_TIMEOUT, function()
                            if not MythicTools then
                                return
                            end

                            local inspect = MythicTools:GetInspectState()
                            if type(inspect) == "table" and type(inspect.pending) == "table" and inspect.pending.key == entry.key then
                                inspect.pending = nil
                                local currentRun = MythicTools.runtime and MythicTools.runtime.activeRun or nil
                                if type(currentRun) == "table" then
                                    MythicTools:ProcessPendingInspectForActiveRun(currentRun)
                                end
                            end
                        end)
                        return
                    end

                    inspectState.pending = nil
                end
            end
        end
    end
end

function MythicTools:GetDamageMeterSessionIDsForTotals(run)
    local sessionIDs = type(run) == "table" and run.sessionIDs or nil
    if type(sessionIDs) ~= "table" then
        return {}
    end

    local sessionSet = {}
    local hasTrackedCombatSession = false

    for _, sessionID in ipairs(sessionIDs) do
        if type(sessionID) == "number" then
            sessionSet[sessionID] = true
            if sessionID > 0 then
                hasTrackedCombatSession = true
            end
        end
    end

    -- The Blizzard damage meter exposes session 0 as an aggregate/current view
    -- on some builds. If we also have concrete combat sessions, summing both
    -- counts the same damage twice.
    if hasTrackedCombatSession and sessionSet[0] then
        sessionSet[0] = nil
    end

    return self:GetSortedSessionIDs(sessionSet)
end

function MythicTools:EnsureActiveRunPlayerStat(activeRun, fullName)
    activeRun.playerStats[fullName] = activeRun.playerStats[fullName] or {
        name = fullName,
        shortName = self:GetShortName(fullName),
        damage = nil,
        healing = nil,
        damagePerSecond = nil,
        healingPerSecond = nil,
        interrupts = nil,
        dispels = nil,
        avoidableDamage = nil,
        damageDoneBySpells = {},
        healDoneBySpells = {},
        avoidableDamageBySpells = {},
        deaths = 0,
        loot = {},
        role = nil,
        specID = nil,
        specName = nil,
        specIconID = nil,
        score = nil,
        previousScore = nil,
        itemLevel = nil,
    }

    local stat = activeRun.playerStats[fullName]
    stat.loot = type(stat.loot) == "table" and stat.loot or {}
    stat.damageDoneBySpells = type(stat.damageDoneBySpells) == "table" and stat.damageDoneBySpells or {}
    stat.healDoneBySpells = type(stat.healDoneBySpells) == "table" and stat.healDoneBySpells or {}
    stat.avoidableDamageBySpells = type(stat.avoidableDamageBySpells) == "table" and stat.avoidableDamageBySpells or {}
    return stat
end

function MythicTools:AddParticipantToActiveRun(activeRun, playerInfo)
    local fullName = playerInfo and playerInfo.name
    if not fullName then
        return
    end

    if playerInfo.guid and not self:IsPlayerGUID(playerInfo.guid) then
        return
    end

    if not activeRun.participants[fullName] then
        activeRun.participants[fullName] = true
        activeRun.participantOrder[#activeRun.participantOrder + 1] = fullName
    end

    local stat = self:EnsureActiveRunPlayerStat(activeRun, fullName)
    stat.name = fullName
    stat.shortName = self:GetShortName(fullName)

    if not stat.guid and playerInfo.guid then
        stat.guid = playerInfo.guid
    end

    if not stat.classFilename and playerInfo.classFilename then
        stat.classFilename = playerInfo.classFilename
    end

    local directItemLevel = NormalizeCapturedItemLevel(playerInfo.itemLevel)
    if directItemLevel and (NormalizeCapturedItemLevel(stat.itemLevel) or 0) < directItemLevel then
        stat.itemLevel = directItemLevel
    end

    if playerInfo.unit and self.GetUnitItemLevel then
        local observedItemLevel = NormalizeCapturedItemLevel(self:GetUnitItemLevel(playerInfo.unit))
        if observedItemLevel and (NormalizeCapturedItemLevel(stat.itemLevel) or 0) < observedItemLevel then
            stat.itemLevel = observedItemLevel
        end
    end

    local initialScore = self:RecordInitialPlayerMythicScore(activeRun, fullName, playerInfo.unit)
    if initialScore and (tonumber(stat.previousScore) or 0) <= 0 then
        stat.previousScore = initialScore
    end
    self:RefreshObservedScoreForStat(stat, fullName, playerInfo.unit)

    if not stat.role and playerInfo.role then
        stat.role = self:NormalizeRole(playerInfo.role)
    end

    if not stat.specID and playerInfo.specID then
        stat.specID = tonumber(playerInfo.specID) or playerInfo.specID
    end

    if not stat.specName and playerInfo.specName then
        stat.specName = playerInfo.specName
    end

    if not stat.specIconID and playerInfo.specIconID then
        stat.specIconID = playerInfo.specIconID
    end

    if stat.specID and (not stat.specName or not stat.specIconID or not stat.role) then
        local specID, specName, specIconID, role = self:GetSpecInfo(stat.specID)
        stat.specID = specID or stat.specID
        stat.specName = stat.specName or specName
        stat.specIconID = stat.specIconID or specIconID
        stat.role = stat.role or role
    end

    if playerInfo.unit and (not UnitIsUnit or not UnitIsUnit(playerInfo.unit, "player")) and IsInspectDataMissing(stat) then
        self:QueueInspectForActiveRunPlayer(activeRun, fullName, playerInfo.unit, playerInfo.guid)
    end

    activeRun.petOwnerGUIDs = type(activeRun.petOwnerGUIDs) == "table" and activeRun.petOwnerGUIDs or {}
    local petUnit = GetPetUnitForGroupUnit(playerInfo.unit)
    local petGUID = petUnit and UnitGUID and UnitGUID(petUnit) or nil
    if petGUID and self:CanAccessValue(petGUID) and not self:IsPlayerGUID(petGUID) then
        activeRun.petOwnerGUIDs[petGUID] = fullName
    end

    activeRun.deathState[fullName] = playerInfo.isDead and true or false
    self:UpdateActiveRunSnapshot(activeRun)
end

function MythicTools:UpdateActiveRunParticipants()
    local activeRun = self.runtime.activeRun
    if not activeRun then
        return
    end

    local roster = self:GetCurrentGroupRoster()
    for _, rosterEntry in ipairs(roster) do
        self:AddParticipantToActiveRun(activeRun, rosterEntry)
    end

    self:ProcessPendingInspectForActiveRun(activeRun)
    self:UpdateActiveRunSnapshot(activeRun)
end

function MythicTools:HandleUnitPetChanged()
    if self.runtime and self.runtime.activeRun then
        self:UpdateActiveRunParticipants()
    end
end

function MythicTools:UpdateActiveRunFromCompletionMembers(activeRun, members)
    if type(members) ~= "table" then
        return
    end

    for _, member in ipairs(members) do
        local fullName = self:NormalizePlayerName(member.name or member.memberName or member.fullName or member.playerName, member.realm or member.realmName)
        if fullName then
            local guid = member.memberGUID or member.guid or member.playerGUID
            if guid and not self:CanAccessValue(guid) then
                guid = nil
            end

            if guid and not self:IsPlayerGUID(guid) then
                guid = nil
                fullName = nil
            end

            if fullName and not guid and not activeRun.participants[fullName] then
                fullName = nil
            end

            if fullName then
                self:AddParticipantToActiveRun(activeRun, {
                    name = fullName,
                    guid = guid,
                    classFilename = member.classFilename or member.classFileName,
                    role = member.role or member.assignedRole or member.combatRole,
                    specID = member.specID or member.specId or member.specializationID,
                    specName = member.specName or member.specializationName,
                    specIconID = member.specIconID or member.specIcon or member.specTexture,
                    itemLevel = GetCompletionMemberItemLevel(member),
                    isDead = false,
                })
            end
        end
    end

    self:UpdateActiveRunSnapshot(activeRun)
end

function MythicTools:CreateNewActiveRun(restored)
    local mapChallengeModeID = C_ChallengeMode.GetActiveChallengeMapID and C_ChallengeMode.GetActiveChallengeMapID() or 0
    local keyLevel, affixIDs = 0, {}

    if C_ChallengeMode.GetActiveKeystoneInfo then
        keyLevel, affixIDs = C_ChallengeMode.GetActiveKeystoneInfo()
    end

    local mapInfo = GetMapInfo(mapChallengeModeID)
    local activeRun = {
        runId = self.db.nextRunId or 1,
        startedAt = time(),
        completedAt = nil,
        state = "running",
        mapChallengeModeID = mapChallengeModeID,
        mapID = mapInfo and mapInfo.mapID or mapChallengeModeID,
        dungeonName = mapInfo and mapInfo.dungeonName or "Unknown",
        ownerCharacter = self.playerName,
        keyLevel = keyLevel or 0,
        timeLimitSeconds = mapInfo and mapInfo.timeLimit or 0,
        affixIDs = type(affixIDs) == "table" and self:CopyArray(affixIDs) or {},
        participants = {},
        participantOrder = {},
        initialPlayerScores = {},
        playerStats = {},
        petOwnerGUIDs = {},
        deathState = {},
        totalDeaths = 0,
        deathPenaltySeconds = 0,
        sessionIDs = {},
        excludedSessionIDs = restored and {} or self:GetAvailableCombatSessionSet(),
        completionPending = false,
        pendingCompletionSource = nil,
        pendingCompletionInfo = nil,
        finalizationInProgress = false,
        completionSignals = {},
        restoredFromReload = restored and true or false,
        allowCompletionRecovery = restored and true or false,
        scenarioCompletedAt = nil,
        inactiveResolutionQueued = false,
        lootFinalizeDeadline = nil,
        damageMeterResetSuspected = false,
        damageMeterResetVerified = false,
        damageMeterResetRecovered = false,
        damageMeterResetSnapshot = nil,
        damageMeterResetAt = nil,
    }

    self.runtime.activeRun = activeRun
    if self.runtime then
        self.runtime.lastCompletionRewards = nil
        self.runtime.inspect = {
            queue = {},
            queued = {},
            attempts = {},
            pending = nil,
        }
    end
    if ClearInspectPlayer then
        pcall(ClearInspectPlayer)
    end
    self:PushRuntimeDebug(("create active run %s map=%s level=%s restored=%s"):format(
        tostring(activeRun.runId),
        tostring(activeRun.mapChallengeModeID),
        tostring(activeRun.keyLevel),
        tostring(restored and true or false)
    ))
    self.EventFrame:RegisterEvent("UNIT_HEALTH")

    self:UpdateActiveRunParticipants()
    self:HandleChallengeDeathCountUpdated()
    self:UpdateActiveRunSnapshot(activeRun)

    return activeRun
end

function MythicTools:EnsureCompletionActiveRun(completionInfo, trigger)
    if self.runtime and type(self.runtime.activeRun) == "table" then
        return self.runtime.activeRun
    end

    local mapChallengeModeID = tonumber(completionInfo and completionInfo.mapChallengeModeID) or 0
    local level = tonumber(completionInfo and completionInfo.level) or 0
    local snapshot = self:GetRecoverableCompletionSnapshot(mapChallengeModeID, level)
    if type(snapshot) == "table" then
        return self:RestoreActiveRunFromSnapshot(snapshot, trigger or "completion", completionInfo)
    end

    if C_ChallengeMode.IsChallengeModeActive and C_ChallengeMode.IsChallengeModeActive() then
        local activeRun = self:CreateNewActiveRun(true)
        if IsValidCompletionInfo(completionInfo) then
            activeRun.pendingCompletionInfo = CopyCompletionInfo(completionInfo)
            activeRun.pendingCompletionSource = trigger or "completion"
            self:UpdateActiveRunSnapshot(activeRun)
        end
        return activeRun
    end

    return nil
end

function MythicTools:BeginActiveRunCompletion(trigger, completionInfo, source)
    local activeRun = self:EnsureCompletionActiveRun(completionInfo, trigger)
    if type(activeRun) ~= "table" then
        return nil
    end

    if activeRun.state == "finalized_completed" or activeRun.state == "finalized_abandoned" then
        return activeRun
    end

    self:UpdateActiveRunParticipants()
    self:HandleChallengeDeathCountUpdated()
    self:ProcessPendingInspectForActiveRun(activeRun)
    self:MarkCompletionSignal(activeRun, trigger or "unknown")

    activeRun.completedAt = tonumber(activeRun.completedAt) or time()
    activeRun.completionPending = true
    activeRun.allowCompletionRecovery = true
    activeRun.pendingCompletionSource = source or activeRun.pendingCompletionSource or trigger or "unknown"
    activeRun.scenarioCompletedAt = trigger == "SCENARIO_COMPLETED" and (activeRun.scenarioCompletedAt or time()) or activeRun.scenarioCompletedAt
    self:SetActiveRunState(activeRun, "finishing_completed")

    if IsValidCompletionInfo(completionInfo) then
        activeRun.pendingCompletionInfo = CopyCompletionInfo(completionInfo)
    end

    local tracking = self.runtime and self.runtime.lootTracking or nil
    if self.StartLootTracking and (not tracking or tracking.runId ~= activeRun.runId) then
        self:StartLootTracking(activeRun.runId)
    elseif type(tracking) == "table" then
        tracking.lootFinalizeDeadline = time() + LOOT_TRACKING_TIMEOUT
        activeRun.lootFinalizeDeadline = tracking.lootFinalizeDeadline
    end

    if not activeRun.finalizationInProgress then
        self:FinalizeActiveRun(0)
    end

    if self.runtime and self.runtime.activeRun == activeRun then
        self:UpdateActiveRunSnapshot(activeRun)
    end

    return activeRun
end

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

    activeRun.damageMeterResetSuspected = false
    activeRun.damageMeterResetRecovered = true
end

function MythicTools:ClearActiveRun()
    if self.runtime and self.runtime.activeRun then
        self:PushRuntimeDebug(("clear active run %s"):format(tostring(self.runtime.activeRun.runId)))
    end
    self.runtime.activeRun = nil
    if self.runtime then
        self.runtime.inspect = {
            queue = {},
            queued = {},
            attempts = {},
            pending = nil,
        }
    end
    if ClearInspectPlayer then
        pcall(ClearInspectPlayer)
    end
    self.EventFrame:UnregisterEvent("UNIT_HEALTH")
end

function MythicTools:TryRestoreActiveRun()
    if self.runtime.activeRun then
        return
    end

    local snapshot = self:GetRecoverableCompletionSnapshot()
    if type(snapshot) == "table" and (snapshot.state == "running" or snapshot.state == "finishing_completed") then
        self:RestoreActiveRunFromSnapshot(snapshot, "TryRestoreActiveRun")
        return
    end

    if C_ChallengeMode.IsChallengeModeActive and C_ChallengeMode.IsChallengeModeActive() then
        self:CreateNewActiveRun(true)
    end
end

function MythicTools:TryFinalizeInactiveActiveRun(activeRun, trigger, preResolvedInfo)
    if type(activeRun) ~= "table" then
        return false
    end

    local completionInfo = preResolvedInfo
    if not IsValidCompletionInfo(completionInfo) then
        completionInfo = self:GetResolvedChallengeCompletionInfo(activeRun, true)
    end
    if not IsValidCompletionInfo(completionInfo) then
        return false
    end

    self:BeginActiveRunCompletion(trigger or "inactive", completionInfo, activeRun.pendingCompletionSource)
    return true
end

function MythicTools:ForceFinalizeCompletedRun(activeRun, trigger)
    if type(activeRun) ~= "table" then
        return false
    end

    activeRun.allowCompletionRecovery = true
    activeRun.completionPending = true
    activeRun.completedAt = tonumber(activeRun.completedAt) or time()
    self:SetActiveRunState(activeRun, "finishing_completed")

    if self:FinalizeActiveRun(MAX_COMPLETION_ATTEMPTS) then
        return true
    end

    if self.runtime and self.runtime.activeRun == activeRun then
        local recoveredInfo = activeRun.pendingCompletionInfo
        if not IsValidCompletionInfo(recoveredInfo) then
            recoveredInfo = BuildRecoveredCompletionInfo(activeRun)
        end
        if IsValidCompletionInfo(recoveredInfo) then
            SetCachedCompletionInfo(activeRun, recoveredInfo, activeRun.pendingCompletionSource or "runtime_recovery")
            return self:FinalizeActiveRun(MAX_COMPLETION_ATTEMPTS)
        end
    end

    self:PushRuntimeDebug(("run %s force finalize failed from %s"):format(tostring(activeRun.runId), tostring(trigger or "unknown")))
    return self.runtime and self.runtime.activeRun ~= activeRun or false
end

function MythicTools:HandleChallengeModeInactive(trigger)
    local activeRun = self.runtime and self.runtime.activeRun or nil
    if type(activeRun) ~= "table" then
        return
    end

    activeRun.inactiveResolutionQueued = false
    self:PushRuntimeDebug(("run %s resolve inactive from %s"):format(tostring(activeRun.runId), tostring(trigger or "unknown")))

    local recoveredCompletionInfo = self:GetResolvedChallengeCompletionInfo(activeRun, true)
    local hasCompletionSignal = activeRun.completionPending
        or self:HasCompletionSignals(activeRun)
        or IsValidCompletionInfo(recoveredCompletionInfo)
        or (self.runtime and self.runtime.lastCompletionRewards ~= nil)

    if hasCompletionSignal then
        activeRun.allowCompletionRecovery = true

        if self:TryFinalizeInactiveActiveRun(activeRun, trigger, recoveredCompletionInfo) then
            return
        end

        if self:ForceFinalizeCompletedRun(activeRun, trigger) then
            return
        end

        self:UpdateActiveRunSnapshot(activeRun)
        self:QueueInactiveRunResolution(activeRun, tostring(trigger or "inactive") .. "_retry")
        return
    end

    self:FinalizeAbandonedActiveRun(activeRun)
end

function MythicTools:HandleChallengeModeStart()
    local activeRun = self.runtime and self.runtime.activeRun or nil

    if activeRun then
        if activeRun.completionPending or self:HasCompletionSignals(activeRun) then
            if not self:ForceFinalizeCompletedRun(activeRun, "CHALLENGE_MODE_START") and self.runtime and self.runtime.activeRun == activeRun then
                self:RememberPendingCompletionSnapshot(activeRun, "CHALLENGE_MODE_START")
                self:PushRuntimeDebug(("run %s could not be persisted before new start; preserving snapshot and clearing runtime state"):format(tostring(activeRun.runId)))
                self:ClearActiveRun()
            end
        else
            self:FinalizeAbandonedActiveRun(activeRun)
        end
    end

    self:ProcessPendingCompletionSnapshots("CHALLENGE_MODE_START", false)
    self:CreateNewActiveRun(false)
end

function MythicTools:HandleChallengeModeReset()
    local activeRun = self.runtime.activeRun
    if not activeRun then
        return
    end

    if activeRun.completionPending or self:HasCompletionSignals(activeRun) then
        activeRun.allowCompletionRecovery = true
        self:HandleChallengeModeInactive("CHALLENGE_MODE_RESET")
        return
    end

    self:FinalizeAbandonedActiveRun(activeRun)
end

function MythicTools:HandleChallengeDeathCountUpdated()
    local activeRun = self.runtime.activeRun
    if not activeRun or not C_ChallengeMode.GetDeathCount then
        return
    end

    local totalDeaths, timeLost = C_ChallengeMode.GetDeathCount()
    activeRun.totalDeaths = totalDeaths or 0
    activeRun.deathPenaltySeconds = timeLost or 0
    self:UpdateActiveRunSnapshot(activeRun)
end

function MythicTools:HandleUnitHealth(unit)
    local activeRun = self.runtime.activeRun
    if not activeRun or not unit or not UnitExists(unit) or not UnitIsPlayer(unit) then
        return
    end

    local fullName = self:GetUnitFullName(unit)
    if not fullName then
        return
    end

    if not activeRun.participants[fullName] then
        local role, specID, specName, specIconID = self:GetUnitRoleSpecInfo(unit)
        self:AddParticipantToActiveRun(activeRun, {
            name = fullName,
            guid = UnitGUID(unit),
            classFilename = select(2, UnitClass(unit)),
            role = role,
            specID = specID,
            specName = specName,
            specIconID = specIconID,
            itemLevel = self.GetUnitItemLevel and self:GetUnitItemLevel(unit) or nil,
            isDead = UnitIsDeadOrGhost(unit) and not UnitIsFeignDeath(unit) or false,
        })
    end

    local isDead = UnitIsDeadOrGhost(unit) and not UnitIsFeignDeath(unit) or false
    local wasDead = activeRun.deathState[fullName] and true or false

    if isDead and not wasDead then
        local stat = self:EnsureActiveRunPlayerStat(activeRun, fullName)
        stat.deaths = (stat.deaths or 0) + 1
    end

    activeRun.deathState[fullName] = isDead
end

function MythicTools:HandleInspectReady(inspecteeGUID)
    local inspectState = self:GetInspectState()
    if type(inspectState) ~= "table" or type(inspectState.pending) ~= "table" then
        return
    end

    local pending = inspectState.pending
    if pending.guid and inspecteeGUID and pending.guid ~= inspecteeGUID then
        return
    end

    inspectState.pending = nil

    local changed = false
    local activeRun = self.runtime and self.runtime.activeRun or nil
    if type(activeRun) == "table" and activeRun.runId == pending.runId and IsInspectablePlayerUnit(pending.unit) then
        local fullName = pending.fullName
        if type(fullName) ~= "string" or fullName == "" or not (activeRun.playerStats and activeRun.playerStats[fullName]) then
            fullName = self:GetUnitFullName(pending.unit)
        end

        if type(fullName) == "string" and fullName ~= "" then
            changed = self:TryHydrateActiveRunPlayerFromInspect(activeRun, fullName, pending.unit) or changed
        end
    elseif IsInspectablePlayerUnit(pending.unit) then
        local run = self:GetRunById(pending.runId)
        if type(run) == "table" then
            local fullName = pending.fullName
            if type(fullName) ~= "string" or fullName == "" then
                fullName = self:GetUnitFullName(pending.unit)
            end
            if type(fullName) == "string" and fullName ~= "" then
                changed = self:TryHydrateCompletedRunPlayerFromInspect(run, fullName, pending.unit) or changed
            end
        end
    end

    if ClearInspectPlayer then
        pcall(ClearInspectPlayer)
    end

    if changed then
        if type(activeRun) == "table" and activeRun.runId == pending.runId then
            self:UpdateActiveRunSnapshot(activeRun)
        else
            local run = self:GetRunById(pending.runId)
            if type(run) == "table" then
                self:RefreshRunDerivedStats(run)
            end
            if self.RebuildPlayerIndex then
                self:RebuildPlayerIndex()
            end
            self:RefreshAllViews()
        end
    end

    local currentRun = self.runtime and self.runtime.activeRun or nil
    if type(currentRun) == "table" then
        self:ProcessPendingInspectForActiveRun(currentRun)
    end
end

function MythicTools:TrackDamageMeterSessionsFromAvailableList()
    local activeRun = self.runtime.activeRun
    if not activeRun then
        return
    end

    for sessionID in pairs(self:GetAvailableCombatSessionSet()) do
        if not activeRun.excludedSessionIDs[sessionID] then
            activeRun.sessionIDs[sessionID] = true
        end
    end

    self:UpdateActiveRunSnapshot(activeRun)
end

function MythicTools:HandleDamageMeterSessionUpdated(...)
    local activeRun = self.runtime.activeRun
    if not activeRun then
        return
    end

    local damageMeterType, sessionID = ...
    if type(damageMeterType) == "number" and type(sessionID) == "number" and damageMeterType == DAMAGE_DONE and sessionID > 0 and not activeRun.excludedSessionIDs[sessionID] then
        activeRun.sessionIDs[sessionID] = true
    end

    self:TrackDamageMeterSessionsFromAvailableList()

    if activeRun.damageMeterResetSuspected then
        self:ClearDamageMeterResetSuspicion(activeRun)
    end

    self:UpdateActiveRunSnapshot(activeRun)
end

function MythicTools:HandleDamageMeterCurrentSessionUpdated(...)
    self:HandleDamageMeterSessionUpdated(...)
end

function MythicTools:HandleDamageMeterReset()
    local activeRun = self.runtime.activeRun
    if not activeRun then
        return
    end

    if activeRun.completionPending then
        self:TrackDamageMeterSessionsFromAvailableList()
        return
    end

    activeRun.damageMeterResetSuspected = true
    activeRun.damageMeterResetVerified = false
    activeRun.damageMeterResetRecovered = false
    activeRun.damageMeterResetAt = time()
    activeRun.damageMeterResetSnapshot = self:GetAvailableCombatSessionSet()
    activeRun.excludedSessionIDs = activeRun.damageMeterResetSnapshot or {}
    self:UpdateActiveRunSnapshot(activeRun)
end

function MythicTools:GetTrackedSessionIDsForRun(activeRun)
    self:TrackDamageMeterSessionsFromAvailableList()
    return self:GetSortedSessionIDs(activeRun.sessionIDs)
end

function MythicTools:RefreshSavedRunStatsContext(activeRunContext)
    if type(activeRunContext) ~= "table" then
        return false
    end

    activeRunContext.sessionIDs = type(activeRunContext.sessionIDs) == "table" and activeRunContext.sessionIDs or {}
    activeRunContext.excludedSessionIDs = type(activeRunContext.excludedSessionIDs) == "table" and activeRunContext.excludedSessionIDs or {}

    local availableSessionSet = self:GetAvailableCombatSessionSet()
    local changed = MergeSessionSet(activeRunContext.sessionIDs, availableSessionSet, activeRunContext.excludedSessionIDs)
    return changed
end

function MythicTools:EnsureCompletedRunPlayer(run, fullName)
    if not run.playerStats[fullName] then
        run.playerStats[fullName] = {
            name = fullName,
            shortName = self:GetShortName(fullName),
            damage = nil,
            healing = nil,
            dps = nil,
            hps = nil,
            damagePerSecond = nil,
            healingPerSecond = nil,
            interrupts = nil,
            dispels = nil,
            avoidableDamage = nil,
            damageDoneBySpells = {},
            healDoneBySpells = {},
            avoidableDamageBySpells = {},
            deaths = 0,
            loot = {},
            role = nil,
            specID = nil,
            specName = nil,
            specIconID = nil,
            score = nil,
            previousScore = nil,
            itemLevel = nil,
            damageActiveSeconds = nil,
            healingActiveSeconds = nil,
        }
        run.roster[#run.roster + 1] = fullName
    end

    local stat = run.playerStats[fullName]
    stat.loot = type(stat.loot) == "table" and stat.loot or {}
    stat.damageDoneBySpells = type(stat.damageDoneBySpells) == "table" and stat.damageDoneBySpells or {}
    stat.healDoneBySpells = type(stat.healDoneBySpells) == "table" and stat.healDoneBySpells or {}
    stat.avoidableDamageBySpells = type(stat.avoidableDamageBySpells) == "table" and stat.avoidableDamageBySpells or {}
    return stat
end

function MythicTools:ResolveCompletedRunPlayerByGUID(run, guidMap, guid)
    if not self:CanAccessValue(guid) or not self:IsPlayerGUID(guid) then
        return nil
    end

    if guidMap and guidMap[guid] then
        return guidMap[guid]
    end

    for fullName, stat in pairs(run.playerStats or {}) do
        if type(stat) == "table" and stat.guid == guid then
            if guidMap then
                guidMap[guid] = fullName
            end
            return fullName
        end
    end

    for _, rosterEntry in ipairs(self:GetCurrentGroupRoster()) do
        if rosterEntry.guid == guid then
            local fullName = self:ResolvePlayerNameForTarget(run, rosterEntry.name)
                or self:NormalizePlayerName(rosterEntry.name)
                or rosterEntry.name
            if fullName then
                if guidMap then
                    guidMap[guid] = fullName
                end
                return fullName
            end
        end
    end

    return nil
end

function MythicTools:ResolveCompletedRunPlayerByName(run, rawName)
    if not self:CanAccessValue(rawName) or type(rawName) ~= "string" or rawName == "" then
        return nil
    end

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

    local shortName = self:GetShortName(normalized or rawName)
    for fullName in pairs(run.playerStats or {}) do
        if self:GetShortName(fullName) == shortName then
            return fullName
        end
    end

    return nil
end

function MythicTools:BuildRunPetOwnerGuidMap(run)
    local petOwnerGuidMap = {}

    local function addPetOwner(petGUID, fullName)
        if not self:CanAccessValue(petGUID) or self:IsPlayerGUID(petGUID) or type(fullName) ~= "string" or fullName == "" then
            return
        end
        if run.playerStats and not run.playerStats[fullName] then
            return
        end

        petOwnerGuidMap[petGUID] = fullName
    end

    for petGUID, fullName in pairs(run.petOwnerGUIDs or {}) do
        addPetOwner(petGUID, fullName)
    end

    for _, rosterEntry in ipairs(self:GetCurrentGroupRoster()) do
        local fullName = self:ResolvePlayerNameForTarget(run, rosterEntry.name)
            or self:NormalizePlayerName(rosterEntry.name)
            or rosterEntry.name
        if fullName and run.playerStats and run.playerStats[fullName] then
            local petUnit = GetPetUnitForGroupUnit(rosterEntry.unit)
            local petGUID = petUnit and UnitGUID and UnitGUID(petUnit) or nil
            addPetOwner(petGUID, fullName)
        end
    end

    return petOwnerGuidMap
end

function MythicTools:ResolveCompletedRunSource(run, guidMap, source, petOwnerGuidMap)
    if not source then
        return nil
    end

    if petOwnerGuidMap then
        local ownerFullName = self:ResolveCompletedRunPlayerByGUID(run, guidMap, GetSourceOwnerGUID(source))
            or self:ResolveCompletedRunPlayerByName(run, GetSourceOwnerName(source))
        if ownerFullName then
            return ownerFullName
        end
    end

    local sourceGUID = GetSourceGUID(source)
    if sourceGUID and self:CanAccessValue(sourceGUID) then
        if guidMap and guidMap[sourceGUID] then
            return guidMap[sourceGUID]
        end

        if not self:IsPlayerGUID(sourceGUID) then
            if petOwnerGuidMap and petOwnerGuidMap[sourceGUID] then
                return petOwnerGuidMap[sourceGUID]
            end
            return nil
        end

        local fullName = self:ResolveCompletedRunPlayerByGUID(run, guidMap, sourceGUID)
        if fullName then
            return fullName
        end
    end

    return self:ResolveCompletedRunPlayerByName(run, source.name or source.fullName or source.unitName or source.playerName or source.memberName)
end

function MythicTools:ApplySourceMetadataToStat(stat, source)
    if type(stat) ~= "table" or type(source) ~= "table" then
        return
    end

    if not stat.classFilename then
        local classFilename = source.classFilename or source.classFileName or source.classToken or source.class
        if type(classFilename) == "string" and classFilename ~= "" then
            stat.classFilename = classFilename
        end
    end

    if not stat.role then
        stat.role = self:NormalizeRole(source.role or source.assignedRole or source.combatRole)
    end

    if not stat.specID then
        local specID = source.specID or source.specId or source.specializationID
        if specID then
            stat.specID = tonumber(specID) or specID
        end
    end

    if not stat.specName and type(source.specName or source.specializationName) == "string" then
        stat.specName = source.specName or source.specializationName
    end

    if not stat.specIconID then
        local specIconID = source.specIconID or source.specIcon or source.specTexture
        if type(specIconID) == "number" and specIconID ~= 0 then
            stat.specIconID = specIconID
        end
    end

    if not NormalizeCapturedItemLevel(stat.itemLevel) then
        local itemLevel = NormalizeCapturedItemLevel(source.itemLevel)
            or NormalizeCapturedItemLevel(source.equippedItemLevel)
            or NormalizeCapturedItemLevel(source.avgItemLevel)
            or NormalizeCapturedItemLevel(source.averageItemLevel)
            or NormalizeCapturedItemLevel(source.ilvl)
        if itemLevel then
            stat.itemLevel = itemLevel
        end
    end

    if stat.specID and (not stat.specName or not stat.specIconID or not stat.role) then
        local resolvedSpecID, specName, specIconID, role = self:GetSpecInfo(stat.specID)
        stat.specID = resolvedSpecID or stat.specID
        stat.specName = stat.specName or specName
        stat.specIconID = stat.specIconID or specIconID
        stat.role = stat.role or role
    end
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:AccumulateOverallDamageMeterType(run, damageType, fieldName, requestedPlayers)
    if not (C_DamageMeter and C_DamageMeter.GetCombatSessionFromType) then
        return false, false
    end

    local blocked = false
    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, session = pcall(C_DamageMeter.GetCombatSessionFromType, sessionType, damageType)
            if ok then
                local foundValues, sessionBlocked = self:AccumulateDamageMeterSession(run, session, fieldName, requestedPlayers, false, damageType)
                blocked = blocked or sessionBlocked
                if foundValues then
                    return true, blocked
                end
            end
        end
    end

    return false, 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:RefreshAllViews()
    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:RefreshAllViews()
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:RefreshAllViews()
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:RefreshAllViews()
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:RefreshAllViews()
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

function MythicTools:GetDetailsMythicPlusOverallCombat(segment)
    if type(segment) == "table" and type(segment.GetContainer) == "function" then
        return segment
    end

    local Details = _G and _G.Details or nil
    if type(Details) == "table" and type(Details.GetCurrentCombat) == "function" then
        local combat = SafeCall(Details.GetCurrentCombat, Details)
        if type(combat) == "table" and type(combat.GetContainer) == "function" then
            return combat
        end
    end

    return nil
end

function MythicTools:ApplyDetailsActorMetadata(stat, actor)
    if type(stat) ~= "table" or type(actor) ~= "table" then
        return
    end

    local guid = GetDetailsActorGUID(actor)
    if not stat.guid and guid and self:CanAccessValue(guid) and self:IsPlayerGUID(guid) then
        stat.guid = guid
    end

    local classFilename = SafeDetailsActorMethod(actor, "Class") or actor.classe or actor.class
    if not stat.classFilename and type(classFilename) == "string" and classFilename ~= "" and classFilename ~= "UNGROUPPLAYER" then
        stat.classFilename = classFilename
    end

    local specID = GetDetailsActorSpec(actor)
    if not stat.specID and tonumber(specID) and tonumber(specID) > 0 then
        stat.specID = tonumber(specID)
    end

    if stat.specID and (not stat.specName or not stat.specIconID or not stat.role) then
        local resolvedSpecID, specName, specIconID, role = self:GetSpecInfo(stat.specID)
        stat.specID = resolvedSpecID or stat.specID
        stat.specName = stat.specName or specName
        stat.specIconID = stat.specIconID or specIconID
        stat.role = stat.role or role
    end
end

function MythicTools:AccumulateDetailsCombatData(run, segment)
    local combat = self:GetDetailsMythicPlusOverallCombat(segment)
    if type(run) ~= "table" or type(combat) ~= "table" or type(combat.GetContainer) ~= "function" then
        return false
    end

    local foundValues = false
    local combatTime = tonumber(SafeDetailsActorMethod(combat, "GetCombatTime") or combat.combatTime)
    if combatTime and combatTime > 0 then
        local runtimeSeconds = (tonumber(run.timeMS) or 0) / 1000
        run.combatTimeSeconds = runtimeSeconds > 0 and math.min(combatTime, runtimeSeconds) or combatTime
        run.outOfCombatSeconds = math.max(0, runtimeSeconds - run.combatTimeSeconds)
    end

    local damageContainer = SafeCall(combat.GetContainer, combat, DETAILS_ATTRIBUTE_DAMAGE or 1)
    local healingContainer = SafeCall(combat.GetContainer, combat, DETAILS_ATTRIBUTE_HEAL or 2)
    local utilityContainer = SafeCall(combat.GetContainer, combat, DETAILS_ATTRIBUTE_MISC or 4)

    for _, actor in ipairs(GetDetailsContainerActors(damageContainer)) do
        if IsDetailsPlayerActor(actor) then
            local fullName = self:ResolveCompletedRunPlayerByName(run, GetDetailsActorName(actor))
            if fullName then
                local stat = self:EnsureCompletedRunPlayer(run, fullName)
                local damage = tonumber(actor.total)
                if damage and damage > 0 then
                    stat.damage = damage
                    stat.damagePerSecond = tonumber(actor.last_dps) or stat.damagePerSecond
                    stat.damageActiveSeconds = GetDetailsActorActivityTime(actor) or stat.damageActiveSeconds
                    stat.damageDoneBySpells = NormalizeDetailsSpellBreakdown(GetDetailsActorSpells(actor), MAX_TOOLTIP_SPELL_ENTRIES)
                    foundValues = true
                end

                local avoidableDamage = tonumber(actor.damage_taken_avoidable)
                if avoidableDamage and avoidableDamage >= 0 then
                    stat.avoidableDamage = avoidableDamage
                    stat.avoidableDamageBySpells = NormalizeDetailsSpellBreakdown(actor.spells_damage_avoidable, MAX_TOOLTIP_SPELL_ENTRIES)
                end

                self:ApplyDetailsActorMetadata(stat, actor)
            end
        end
    end

    for _, actor in ipairs(GetDetailsContainerActors(healingContainer)) do
        if IsDetailsPlayerActor(actor) then
            local fullName = self:ResolveCompletedRunPlayerByName(run, GetDetailsActorName(actor))
            if fullName then
                local stat = self:EnsureCompletedRunPlayer(run, fullName)
                local healing = tonumber(actor.total)
                if healing and healing >= 0 then
                    stat.healing = healing
                    stat.healingPerSecond = tonumber(actor.last_hps) or stat.healingPerSecond
                    stat.healingActiveSeconds = GetDetailsActorActivityTime(actor) or stat.healingActiveSeconds
                    stat.healDoneBySpells = NormalizeDetailsSpellBreakdown(GetDetailsActorSpells(actor), MAX_TOOLTIP_SPELL_ENTRIES)
                    foundValues = foundValues or healing > 0
                end

                self:ApplyDetailsActorMetadata(stat, actor)
            end
        end
    end

    for _, actor in ipairs(GetDetailsContainerActors(utilityContainer)) do
        if IsDetailsPlayerActor(actor) then
            local fullName = self:ResolveCompletedRunPlayerByName(run, GetDetailsActorName(actor))
            if fullName then
                local stat = self:EnsureCompletedRunPlayer(run, fullName)
                local interrupts = tonumber(actor.interrupt)
                if interrupts and interrupts >= 0 then
                    stat.interrupts = NormalizeInterruptCount(interrupts)
                    stat.interruptsBySpells = NormalizeDetailsSpellBreakdown(actor.interrupt_spells, MAX_TOOLTIP_SPELL_ENTRIES)
                    foundValues = foundValues or interrupts > 0
                end

                local dispels = tonumber(actor.dispell or actor.dispel)
                if dispels and dispels >= 0 then
                    stat.dispels = NormalizeInterruptCount(dispels)
                    stat.dispelsBySpells = NormalizeDetailsSpellBreakdown(actor.dispell_spells or actor.dispel_spells, MAX_TOOLTIP_SPELL_ENTRIES)
                end

                self:ApplyDetailsActorMetadata(stat, actor)
            end
        end
    end

    return foundValues
end

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

    local foundValues = self:AccumulateDetailsCombatData(run, segment)
    if not foundValues or not HasAnyRunStats(run) then
        return false
    end

    for _, fullName in ipairs(run.roster or {}) do
        local stat = self:EnsureCompletedRunPlayer(run, fullName)
        if stat.damage == nil then
            stat.damage = 0
        end
        if stat.healing == nil then
            stat.healing = 0
        end
        if stat.interrupts == nil then
            stat.interrupts = 0
        end
        if stat.dispels == nil then
            stat.dispels = 0
        end
        if stat.avoidableDamage == nil then
            stat.avoidableDamage = 0
        end
    end

    run.statsSource = "details"
    run.statsUnavailable = false
    ClearTransientRunNotes(run)
    self:RefreshRunDerivedStats(run)
    return true
end

function MythicTools:RefreshLastCompletedRunFromDetails(segment)
    local runId = self.runtime and tonumber(self.runtime.lastCompletedRunId) or nil
    if not runId then
        return false
    end

    local run = self:GetRunById(runId)
    if type(run) ~= "table" then
        return false
    end

    if HasPositiveRunStatField(run, "damage") or HasPositiveRunStatField(run, "healing") then
        return false
    end

    local refreshed = self:FinalizeRunStatsFromDetails(run, segment)
    if refreshed then
        self:PushRuntimeDebug(("details stats refreshed run %s"):format(tostring(runId)))
        if self.RebuildPlayerIndex then
            self:RebuildPlayerIndex()
        end
        self:RefreshAllViews()
    end

    return refreshed
end

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

    self.runtime.lastDetailsMythicPlusOverallSegment = segment
    self.runtime.lastDetailsMythicPlusOverallAt = time()
    self:RefreshLastCompletedRunFromDetails(segment)
end

function MythicTools:InitializeDetailsIntegration()
    if self.detailsEventListener then
        return true
    end

    local Details = _G and _G.Details or nil
    if type(Details) ~= "table" or type(Details.CreateEventListener) ~= "function" then
        return false
    end

    local ok, listener = pcall(Details.CreateEventListener, Details)
    if not ok or type(listener) ~= "table" or type(listener.RegisterEvent) ~= "function" then
        return false
    end

    listener:RegisterEvent("COMBAT_MYTHICPLUS_OVERALL_READY")
    listener.OnDetailsEvent = function(_, event, ...)
        if event == "COMBAT_MYTHICPLUS_OVERALL_READY" and MythicTools and MythicTools.HandleDetailsMythicPlusOverallReady then
            MythicTools:HandleDetailsMythicPlusOverallReady(...)
        end
    end

    self.detailsEventListener = listener
    return true
end

function MythicTools:QueueSavedRunStatsRefresh(runId, activeRunContext, attempt)
    if not (runId and type(activeRunContext) == "table") then
        return
    end

    self:Schedule(COMPLETION_RETRY_DELAY, function()
        if MythicTools and MythicTools.FinalizeSavedRunStats then
            MythicTools:FinalizeSavedRunStats(runId, activeRunContext, attempt or 1)
        end
    end)
end

function MythicTools:FinalizeSavedRunStats(runId, activeRunContext, attempt)
    runId = tonumber(runId)
    attempt = tonumber(attempt) or 1
    if not (runId and type(activeRunContext) == "table") then
        return false
    end

    local run = self:GetRunById(runId)
    if type(run) ~= "table" then
        return false
    end

    if self.RefreshSavedRunStatsContext then
        self:RefreshSavedRunStatsContext(activeRunContext)
        run.sessionIDs = self:GetSortedSessionIDs(activeRunContext.sessionIDs)
    end

    ClearTransientRunNotes(run)
    local ok, needsRetry = pcall(self.FinalizeRunStats, self, activeRunContext, run, attempt < MAX_COMPLETION_ATTEMPTS)
    if not ok then
        run.statsUnavailable = true
        AppendRunNote(run, "An error happened while collecting post-run stats.")
        if self.StoreDebugReport then
            self:StoreDebugReport("stats_refresh_error", {
                trigger = "FinalizeSavedRunStats",
                runId = runId,
                state = activeRunContext.state,
                mapChallengeModeID = activeRunContext.mapChallengeModeID,
                level = activeRunContext.keyLevel,
                error = tostring(needsRetry),
            })
        end
        self:RefreshAllViews()
        return false
    end

    if needsRetry and attempt < MAX_COMPLETION_ATTEMPTS then
        self:PushRuntimeDebug(("stats refresh retry run %s attempt=%s sessionCount=%s"):format(
            tostring(runId),
            tostring(attempt),
            tostring(#(run.sessionIDs or {}))
        ))
        self:QueueSavedRunStatsRefresh(runId, activeRunContext, attempt + 1)
        return false
    end

    if needsRetry then
        run.statsUnavailable = true
        AppendRunNote(run, "Damage meter data was not available in time for this key.")
        self:PushRuntimeDebug(("stats refresh exhausted run %s sessionCount=%s"):format(
            tostring(runId),
            tostring(#(run.sessionIDs or {}))
        ))
    else
        self:PushRuntimeDebug(("stats refresh completed run %s sessionCount=%s source=%s"):format(
            tostring(runId),
            tostring(#(run.sessionIDs or {})),
            tostring(run.statsSource or "unknown")
        ))
    end

    if self.RebuildPlayerIndex then
        self:RebuildPlayerIndex()
    end

    self:RefreshAllViews()
    if self.completionPopup and self.completionPopup:IsShown() and self.completionPopup.runId == runId and self.RefreshCompletionPopup then
        self:RefreshCompletionPopup()
    end

    return true
end

function MythicTools:UpdateSavedRunCompletionInfo(runId, completionInfo, source)
    runId = tonumber(runId)
    if not (runId and IsValidCompletionInfo(completionInfo)) then
        return false
    end

    local run = self:GetRunById(runId)
    if type(run) ~= "table" then
        return false
    end

    local mapChallengeModeID = tonumber(completionInfo.mapChallengeModeID) or tonumber(run.mapChallengeModeID) or 0
    local mapInfo = GetMapInfo(mapChallengeModeID)

    run.mapChallengeModeID = mapChallengeModeID
    run.mapID = mapInfo and mapInfo.mapID or run.mapID or mapChallengeModeID
    run.dungeonName = mapInfo and mapInfo.dungeonName or run.dungeonName or "Unknown"
    run.textureFileID = mapInfo and mapInfo.textureFileID or run.textureFileID
    run.backgroundTextureFileID = mapInfo and mapInfo.backgroundTextureFileID or run.backgroundTextureFileID
    run.level = tonumber(completionInfo.level) or tonumber(run.level) or 0
    run.timeMS = tonumber(completionInfo.time) or tonumber(run.timeMS) or 0
    run.onTime = completionInfo.onTime and true or false
    run.result = completionInfo.onTime and "timed" or "overtime"
    run.keystoneUpgradeLevels = tonumber(completionInfo.keystoneUpgradeLevels) or tonumber(run.keystoneUpgradeLevels) or 0
    run.practiceRun = completionInfo.practiceRun and true or false
    if completionInfo.isEligibleForScore ~= nil then
        run.isEligibleForScore = completionInfo.isEligibleForScore and true or false
    end
    if completionInfo.oldOverallDungeonScore ~= nil then
        run.oldOverallDungeonScore = completionInfo.oldOverallDungeonScore
    end
    if completionInfo.newOverallDungeonScore ~= nil then
        run.newOverallDungeonScore = completionInfo.newOverallDungeonScore
    end
    if mapInfo and mapInfo.timeLimit then
        run.timeLimitSeconds = mapInfo.timeLimit
    end
    run.keystoneUpgradeLevels = self:GetRunKeystoneUpgradeLevels(run)
    run.completionSource = source or run.completionSource

    if self.db then
        self:RebuildPlayerIndex()
    end
    self:RefreshAllViews()

    if self.StoreDebugReport then
        self:StoreDebugReport("updated_completed_run", {
            trigger = source,
            result = run.result,
            runId = run.runId,
            mapChallengeModeID = run.mapChallengeModeID,
            level = run.level,
        })
    end

    return true
end

function MythicTools:TryUpdateLastCompletedRunCompletionInfo(completionInfo, source)
    if not (self.runtime and IsValidCompletionInfo(completionInfo)) then
        return false
    end

    local runId = tonumber(self.runtime.lastCompletedRunId)
    if not runId then
        return false
    end

    local run = self:GetRunById(runId)
    if type(run) ~= "table" then
        return false
    end

    local expectedMapID = tonumber(self.runtime.lastCompletedMapChallengeModeID) or tonumber(run.mapChallengeModeID) or 0
    local expectedLevel = tonumber(self.runtime.lastCompletedLevel) or tonumber(run.level) or 0
    local completionMapID = tonumber(completionInfo.mapChallengeModeID) or 0
    local completionLevel = tonumber(completionInfo.level) or 0

    if expectedMapID > 0 and completionMapID > 0 and expectedMapID ~= completionMapID then
        return false
    end

    if expectedLevel > 0 and completionLevel > 0 and expectedLevel ~= completionLevel then
        return false
    end

    return self:UpdateSavedRunCompletionInfo(runId, completionInfo, source)
end

function MythicTools:PersistCompletedRun(activeRun, completionInfo, trigger)
    if type(activeRun) ~= "table" or not IsValidCompletionInfo(completionInfo) then
        return nil
    end

    local existingRun = self:GetRunById(activeRun.runId)
    if type(existingRun) == "table" then
        self:PushRuntimeDebug(("skip duplicate completed run %s trigger=%s"):format(
            tostring(activeRun.runId),
            tostring(trigger or "unknown")
        ))
        self:ForgetRecoveryState(activeRun.runId)
        return existingRun
    end

    local run = self:BuildCompletedRun(activeRun, completionInfo)
    if type(run) ~= "table" then
        return nil
    end

    if activeRun.pendingCompletionSource == "runtime_recovery" then
        AppendRunNote(run, "Completion data was recovered from runtime state and may be approximate.")
    end

    run.statsUnavailable = true
    AppendRunNote(run, "Waiting for damage meter totals.")

    if self.RegisterOwnedCharacter then
        self:RegisterOwnedCharacter(self.playerName)
    end

    self:PushRuntimeDebug(("persist completed run %s source=%s trigger=%s"):format(
        tostring(run.runId),
        tostring(activeRun.pendingCompletionSource or "unknown"),
        tostring(trigger or "unknown")
    ))

    self:AddCompletedRun(run)
    self.db.ui.selectedRunId = run.runId
    self:PushRuntimeDebug(("saved run %s totalRuns=%s selectedRun=%s"):format(
        tostring(run.runId),
        tostring(self.db and self.db.runs and #self.db.runs or 0),
        tostring(self.db and self.db.ui and self.db.ui.selectedRunId or run.runId)
    ))

    if self.StartLootTracking then
        self:StartLootTracking(run.runId)
    end

    if self.runtime then
        self.runtime.lastCompletionRewards = nil
        self.runtime.lastCompletedRunId = run.runId
        self.runtime.lastCompletedAt = run.endTime
        self.runtime.lastCompletedMapChallengeModeID = run.mapChallengeModeID
        self.runtime.lastCompletedLevel = run.level
    end

    self:ForgetRecoveryState(run.runId)
    self:RefreshAllViews()

    if self.StoreDebugReport then
        self:StoreDebugReport("completed_run", {
            trigger = trigger,
            result = run.result,
            runId = run.runId,
            state = activeRun.state,
            mapChallengeModeID = run.mapChallengeModeID,
            level = run.level,
            completionSource = run.completionSource,
            statsUnavailable = run.statsUnavailable and true or false,
        })
    end

    local statsContext = CreateActiveRunSnapshot(self, activeRun)
    if type(statsContext) == "table" then
        local refreshOk = false
        local ok = pcall(function()
            refreshOk = self:FinalizeSavedRunStats(run.runId, statsContext, 1)
        end)
        if not ok and self.StoreDebugReport then
            self:StoreDebugReport("stats_refresh_crash", {
                trigger = trigger,
                runId = run.runId,
                state = activeRun.state,
                mapChallengeModeID = run.mapChallengeModeID,
                level = run.level,
            })
        end
    end

    if self.QueueCompletionPopup then
        self:QueueCompletionPopup(run.runId, "COMPLETED")
    elseif self.ShowCompletionPopup then
        self:ShowCompletionPopup(run.runId)
    end

    return run
end

function MythicTools:ProcessPendingCompletionSnapshots(trigger, forceRecovery)
    if not self.runtime then
        return false
    end

    local pending = self.runtime.pendingCompletionSnapshots
    if type(pending) ~= "table" or #pending == 0 then
        return false
    end

    for index = #pending, 1, -1 do
        local snapshot = pending[index]
        if type(snapshot) == "table" then
            local completionInfo = self:GetResolvedChallengeCompletionInfo(snapshot, true)
            if not IsValidCompletionInfo(completionInfo) and forceRecovery then
                completionInfo = BuildRecoveredCompletionInfo(snapshot)
                if IsValidCompletionInfo(completionInfo) then
                    completionInfo = SetCachedCompletionInfo(snapshot, completionInfo, snapshot.pendingCompletionSource or "runtime_recovery")
                end
            end

            if IsValidCompletionInfo(completionInfo) then
                snapshot.pendingCompletionInfo = CopyCompletionInfo(completionInfo)
                snapshot.pendingCompletionSource = snapshot.pendingCompletionSource or trigger or "pending"
                if self:PersistCompletedRun(snapshot, completionInfo, trigger or "pending") then
                    return true
                end
            end
        end
    end

    return false
end

function MythicTools:FinalizeRunStats(activeRun, run, allowRetry)
    if type(activeRun) ~= "table" or type(run) ~= "table" then
        return false
    end

    self:HydrateObservedScoresForTarget(run)

    if self:RefreshCompletedRunScoresFromBlizzard(run, activeRun) then
        self:PushRuntimeDebug(("refreshed m+ scores from blizzard run %s"):format(tostring(run.runId)))
    end

    self:HydrateRunCombatDuration(run)
    local damageMeterAvailable = C_DamageMeter and (not C_DamageMeter.IsDamageMeterAvailable or C_DamageMeter.IsDamageMeterAvailable())

    if not damageMeterAvailable then
        if C_DamageMeter and allowRetry and not activeRun.damageMeterResetSuspected then
            run.statsNote = "Waiting for damage meter totals."
            return true
        end

        run.statsUnavailable = true
        AppendRunNote(run, "Blizzard damage meter totals were not available for this run.")
        self:RefreshRunDerivedStats(run)
        return false
    end

    local _, overallBlocked = self:AccumulateOverallDamageMeterData(run)
    if overallBlocked and allowRetry then
        run.statsNote = "Waiting for damage meter totals."
        return true
    end

    local missingFields = self:GetMissingRunStatFields(run)
    local damageMeterSessionIDs = self:GetDamageMeterSessionIDsForTotals(run)

    if HasMissingCoreRunStats(missingFields)
        or HasMissingEntries(missingFields.dispels)
        or HasMissingEntries(missingFields.avoidableDamage) then
        if HasMissingCoreRunStats(missingFields) and #damageMeterSessionIDs == 0 and allowRetry and not activeRun.damageMeterResetSuspected then
            run.statsNote = "Waiting for damage meter sessions."
            return true
        end

        local _, partyDataBlocked = self:AccumulatePartyData(run, damageMeterSessionIDs, missingFields)
        if partyDataBlocked and allowRetry and HasMissingCoreRunStats(missingFields) then
            return true
        end
    end

    missingFields = self:GetMissingRunStatFields(run)
    if HasMissingCoreRunStats(missingFields)
        or HasMissingEntries(missingFields.dispels)
        or HasMissingEntries(missingFields.avoidableDamage) then
        local _, playerDataBlocked = self:AccumulatePlayerData(run, damageMeterSessionIDs, missingFields)
        if playerDataBlocked and allowRetry and HasMissingCoreRunStats(missingFields) then
            return true
        end
    end

    missingFields = self:GetMissingRunStatFields(run)
    if HasMissingCoreRunStats(missingFields)
        or HasMissingEntries(missingFields.dispels)
        or HasMissingEntries(missingFields.avoidableDamage) then
        local damageBlocked = HasMissingEntries(missingFields.damage) and self:AccumulateCombatType(run, damageMeterSessionIDs, DAMAGE_DONE, "damage", missingFields.damage) or false
        local healingBlocked = HasMissingEntries(missingFields.healing) and self:AccumulateCombatType(run, damageMeterSessionIDs, HEALING_DONE, "healing", missingFields.healing) or false
        local interruptsBlocked = HasMissingEntries(missingFields.interrupts) and self:AccumulateCombatType(run, damageMeterSessionIDs, INTERRUPTS, "interrupts", missingFields.interrupts) or false
        local dispelsBlocked = DISPELS and HasMissingEntries(missingFields.dispels) and self:AccumulateCombatType(run, damageMeterSessionIDs, DISPELS, "dispels", missingFields.dispels) or false
        local avoidableDamageBlocked = AVOIDABLE_DAMAGE and HasMissingEntries(missingFields.avoidableDamage) and self:AccumulateCombatType(run, damageMeterSessionIDs, AVOIDABLE_DAMAGE, "avoidableDamage", missingFields.avoidableDamage) or false

        if (damageBlocked or healingBlocked or interruptsBlocked) and allowRetry then
            return true
        end
    end

    if not HasPositiveRunStatField(run, "damage") and not HasPositiveRunStatField(run, "healing") then
        self:InitializeDetailsIntegration()
        local detailsSegment = self.runtime and self.runtime.lastDetailsMythicPlusOverallSegment or nil
        if (detailsSegment or not allowRetry) and self:FinalizeRunStatsFromDetails(run, detailsSegment) then
            activeRun.damageMeterResetVerified = false
            if activeRun.damageMeterResetSuspected then
                self:ClearDamageMeterResetSuspicion(activeRun)
            end
            return false
        end
        if allowRetry and self.detailsEventListener and not activeRun.damageMeterResetSuspected then
            run.statsNote = "Waiting for Details overall segment."
            return true
        end
    end

    local hasStats = HasAnyRunStats(run)
    if hasStats then
        local hasDamageStats = HasRunStatField(run, "damage")
        local hasHealingStats = HasRunStatField(run, "healing")
        local hasInterruptStats = HasRunStatField(run, "interrupts")
        local hasDispelsStats = HasRunStatField(run, "dispels")
        for _, fullName in ipairs(run.roster or {}) do
            local stat = self:EnsureCompletedRunPlayer(run, fullName)
            if hasDamageStats and stat.damage == nil then
                stat.damage = 0
            end
            if hasHealingStats and stat.healing == nil then
                stat.healing = 0
            end
            if hasInterruptStats and stat.interrupts == nil then
                stat.interrupts = 0
            end
            if hasDispelsStats and stat.dispels == nil then
                stat.dispels = 0
            end
            if stat.avoidableDamage == nil then
                stat.avoidableDamage = 0
            end
        end

        run.statsSource = "damage_meter"
        run.statsUnavailable = false
        activeRun.damageMeterResetVerified = false
        if activeRun.damageMeterResetSuspected then
            self:ClearDamageMeterResetSuspicion(activeRun)
        end
        self:RefreshRunDerivedStats(run)
        return false
    end

    if allowRetry and not activeRun.damageMeterResetSuspected then
        run.statsNote = "Waiting for damage meter totals."
        return true
    end

    run.statsUnavailable = true
    if activeRun.damageMeterResetSuspected and not activeRun.damageMeterResetRecovered then
        activeRun.damageMeterResetVerified = true
    end

    if activeRun.damageMeterResetVerified then
        AppendRunNote(run, "The damage meter was reset during the run, so totals are incomplete.")
    else
        AppendRunNote(run, "No damage, healing, interrupt, dispel, or avoidable-damage session data was available for this run.")
    end

    self:RefreshRunDerivedStats(run)
    return false
end

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

    self:SetActiveRunState(activeRun, "finalized_abandoned")
    self:PushRuntimeDebug(("finalize abandoned run %s"):format(tostring(activeRun.runId)))

    if self.StopLootTracking then
        self:StopLootTracking()
    end

    if self.StoreDebugReport then
        self:StoreDebugReport("abandoned_run", {
            trigger = "FinalizeAbandonedActiveRun",
            result = "abandoned",
            runId = activeRun.runId,
            state = activeRun.state,
            mapChallengeModeID = activeRun.mapChallengeModeID,
            level = activeRun.keyLevel,
        })
    end

    self:AddAbandonedRun(activeRun)
    self:ForgetRecoveryState(activeRun.runId)

    if self.runtime and self.runtime.activeRun == activeRun then
        self:ClearActiveRun()
    end
end

function MythicTools:FinalizeActiveRun(attempt)
    local activeRun = self.runtime and self.runtime.activeRun or nil
    if type(activeRun) ~= "table" or (not activeRun.completionPending and not self:HasCompletionSignals(activeRun)) then
        return false
    end

    if activeRun.state == "finalized_completed" or activeRun.state == "finalized_abandoned" then
        return false
    end

    if activeRun.finalizationInProgress then
        return false
    end

    activeRun.finalizationInProgress = true
    self:SetActiveRunState(activeRun, "finishing_completed")

    if self.runtime and self.runtime.activeRun ~= activeRun then
        activeRun.finalizationInProgress = false
        return false
    end

    self:HandleChallengeDeathCountUpdated()
    activeRun.completedAt = tonumber(activeRun.completedAt) or time()

    local completionInfo = self:GetResolvedChallengeCompletionInfo(activeRun, true)
    if IsValidCompletionInfo(completionInfo) then
        activeRun.pendingCompletionInfo = completionInfo
    else
        completionInfo = activeRun.pendingCompletionInfo
    end

    if not completionInfo or not completionInfo.mapChallengeModeID or completionInfo.mapChallengeModeID == 0 then
        if attempt < MAX_COMPLETION_ATTEMPTS and not self:ShouldUseEarlyCompletionRecovery(activeRun, attempt) then
            activeRun.finalizationInProgress = false
            self:PushRuntimeDebug(("run %s waiting for completion info attempt=%s"):format(tostring(activeRun.runId), tostring(attempt)))
            self:Schedule(COMPLETION_RETRY_DELAY, function()
                if self.runtime.activeRun == activeRun then
                    self:FinalizeActiveRun(attempt + 1)
                end
            end)
        else
            local recoveredInfo = BuildRecoveredCompletionInfo(activeRun)
            if IsValidCompletionInfo(recoveredInfo) then
                completionInfo = SetCachedCompletionInfo(activeRun, recoveredInfo, activeRun.pendingCompletionSource or "runtime_recovery")
                self:PushRuntimeDebug(("run %s using recovered completion info attempt=%s"):format(tostring(activeRun.runId), tostring(attempt)))
            else
                activeRun.finalizationInProgress = false
                self:PushRuntimeDebug(("run %s missing completion info after retries"):format(tostring(activeRun.runId)))
                if self.StoreDebugReport then
                    self:StoreDebugReport("missing_completion_info", {
                        trigger = activeRun.pendingCompletionSource or "FinalizeActiveRun",
                        runId = activeRun.runId,
                        state = activeRun.state,
                        mapChallengeModeID = activeRun.mapChallengeModeID,
                        level = activeRun.keyLevel,
                    })
                end
                return false
            end
        end
        if not completionInfo or not completionInfo.mapChallengeModeID or completionInfo.mapChallengeModeID == 0 then
            return false
        end
    end

    local persistedRun = self:PersistCompletedRun(activeRun, completionInfo, activeRun.pendingCompletionSource or "FinalizeActiveRun")
    activeRun.finalizationInProgress = false
    if not persistedRun then
        return false
    end

    self:SetActiveRunState(activeRun, "finalized_completed")

    if self.runtime and self.runtime.activeRun == activeRun then
        self:ClearActiveRun()
    end

    return true
end

function MythicTools:HandleChallengeModeCompleted()
    local completionInfo = GetChallengeCompletionInfoCompat()
    if not (self.runtime and type(self.runtime.activeRun) == "table") and IsValidCompletionInfo(completionInfo) and self.TryUpdateLastCompletedRunCompletionInfo then
        self:TryUpdateLastCompletedRunCompletionInfo(completionInfo, "native")
    end
    if self.StoreDebugReport then
        self:StoreDebugReport("event_completed", {
            trigger = "CHALLENGE_MODE_COMPLETED",
            runId = self.runtime and self.runtime.activeRun and self.runtime.activeRun.runId or nil,
            mapChallengeModeID = completionInfo and completionInfo.mapChallengeModeID or nil,
            level = completionInfo and completionInfo.level or nil,
        })
    end
    self:BeginActiveRunCompletion("CHALLENGE_MODE_COMPLETED", completionInfo, IsValidCompletionInfo(completionInfo) and "native" or "completed")
end

function MythicTools:HandleScenarioCompleted()
    local completionInfo = GetChallengeCompletionInfoCompat()
    if not (self.runtime and type(self.runtime.activeRun) == "table") and IsValidCompletionInfo(completionInfo) and self.TryUpdateLastCompletedRunCompletionInfo then
        self:TryUpdateLastCompletedRunCompletionInfo(completionInfo, "scenario")
    end
    if self.StoreDebugReport then
        self:StoreDebugReport("event_scenario_completed", {
            trigger = "SCENARIO_COMPLETED",
            runId = self.runtime and self.runtime.activeRun and self.runtime.activeRun.runId or nil,
            mapChallengeModeID = completionInfo and completionInfo.mapChallengeModeID or nil,
            level = completionInfo and completionInfo.level or nil,
        })
    end
    self:BeginActiveRunCompletion("SCENARIO_COMPLETED", completionInfo, IsValidCompletionInfo(completionInfo) and "native" or "scenario")
end

function MythicTools:HandleChallengeModeCompletedRewards(mapChallengeModeID, medal, timeMS, money, rewards)
    if self.runtime then
        self.runtime.lastCompletionRewards = {
            mapChallengeModeID = tonumber(mapChallengeModeID) or 0,
            medal = tonumber(medal) or 0,
            timeMS = tonumber(timeMS) or 0,
            money = tonumber(money) or 0,
            rewards = rewards,
            receivedAt = time(),
        }
    end

    if self.StoreDebugReport then
        self:StoreDebugReport("event_completed_rewards", {
            trigger = "CHALLENGE_MODE_COMPLETED_REWARDS",
            runId = self.runtime and self.runtime.activeRun and self.runtime.activeRun.runId or nil,
            mapChallengeModeID = tonumber(mapChallengeModeID) or 0,
            level = self.runtime and self.runtime.activeRun and self.runtime.activeRun.keyLevel or nil,
            rewardsTimeMS = tonumber(timeMS) or 0,
        })
    end

    local runtimeActiveRun = self.runtime and self.runtime.activeRun or nil
    local rewardCompletionInfo = nil
    local rewardMapID = tonumber(mapChallengeModeID) or 0
    local rewardLevel = (runtimeActiveRun and runtimeActiveRun.keyLevel)
        or (self.runtime and self.runtime.lastCompletedLevel)
        or nil
    local mapInfo = GetMapInfo(rewardMapID)
    local timeLimitSeconds = tonumber(mapInfo and mapInfo.timeLimit) or 0
    if rewardMapID > 0 and tonumber(timeMS) and tonumber(timeMS) > 0 and tonumber(rewardLevel) and tonumber(rewardLevel) > 0 then
        rewardCompletionInfo = {
            mapChallengeModeID = rewardMapID,
            level = tonumber(rewardLevel),
            time = tonumber(timeMS),
            onTime = timeLimitSeconds > 0 and tonumber(timeMS) <= (timeLimitSeconds * 1000) or nil,
            keystoneUpgradeLevels = 0,
            practiceRun = false,
            oldOverallDungeonScore = nil,
            newOverallDungeonScore = nil,
            isMapRecord = nil,
            isAffixRecord = nil,
            primaryAffix = nil,
            isEligibleForScore = nil,
            members = nil,
        }
    end

    if not (self.runtime and type(self.runtime.activeRun) == "table") and IsValidCompletionInfo(rewardCompletionInfo) and self.TryUpdateLastCompletedRunCompletionInfo then
        self:TryUpdateLastCompletedRunCompletionInfo(rewardCompletionInfo, "rewards")
    end

    local activeRun = self.runtime and self.runtime.activeRun or nil
    local queuedSnapshot = self:GetRecoverableCompletionSnapshot(mapChallengeModeID, 0)
    if type(queuedSnapshot) == "table" and (type(activeRun) ~= "table" or (not activeRun.completionPending and not self:HasCompletionSignals(activeRun))) then
        if self:ProcessPendingCompletionSnapshots("CHALLENGE_MODE_COMPLETED_REWARDS", true) then
            return
        end
    end

    activeRun = self:EnsureCompletionActiveRun(nil, "CHALLENGE_MODE_COMPLETED_REWARDS")
    if type(activeRun) ~= "table" then
        return
    end

    local completionInfo = self:GetResolvedChallengeCompletionInfo(activeRun, activeRun.allowCompletionRecovery and true or false)
    self:BeginActiveRunCompletion("CHALLENGE_MODE_COMPLETED_REWARDS", completionInfo, "rewards")
end










