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

local AVOIDABLE_DAMAGE = Capture.AVOIDABLE_DAMAGE
local AppendRunNote = Capture.AppendRunNote
local BuildRecoveredCompletionInfo = Capture.BuildRecoveredCompletionInfo
local COMPLETION_RETRY_DELAY = Capture.COMPLETION_RETRY_DELAY
local ClearTransientRunNotes = Capture.ClearTransientRunNotes
local CopyCompletionInfo = Capture.CopyCompletionInfo
local CreateActiveRunSnapshot = Capture.CreateActiveRunSnapshot
local DAMAGE_DONE = Capture.DAMAGE_DONE
local DISPELS = Capture.DISPELS
local GetChallengeCompletionInfoCompat = Capture.GetChallengeCompletionInfoCompat
local GetDetailsActorActivityTime = Capture.GetDetailsActorActivityTime
local GetDetailsActorGUID = Capture.GetDetailsActorGUID
local GetDetailsActorName = Capture.GetDetailsActorName
local GetDetailsActorSpec = Capture.GetDetailsActorSpec
local GetDetailsActorSpells = Capture.GetDetailsActorSpells
local GetDetailsContainerActors = Capture.GetDetailsContainerActors
local GetMapInfo = Capture.GetMapInfo
local HEALING_DONE = Capture.HEALING_DONE
local HasAnyRunStats = Capture.HasAnyRunStats
local HasMissingCoreRunStats = Capture.HasMissingCoreRunStats
local HasMissingEntries = Capture.HasMissingEntries
local HasPositiveRunStatField = Capture.HasPositiveRunStatField
local HasRunStatField = Capture.HasRunStatField
local INTERRUPTS = Capture.INTERRUPTS
local IsDetailsPlayerActor = Capture.IsDetailsPlayerActor
local IsValidCompletionInfo = Capture.IsValidCompletionInfo
local MAX_COMPLETION_ATTEMPTS = Capture.MAX_COMPLETION_ATTEMPTS
local MAX_TOOLTIP_SPELL_ENTRIES = Capture.MAX_TOOLTIP_SPELL_ENTRIES
local NormalizeDetailsSpellBreakdown = Capture.NormalizeDetailsSpellBreakdown
local NormalizeInterruptCount = Capture.NormalizeInterruptCount
local SafeCall = Capture.SafeCall
local SafeDetailsActorMethod = Capture.SafeDetailsActorMethod
local SetCachedCompletionInfo = Capture.SetCachedCompletionInfo

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:QueueRebuildPlayerIndex("capture")
        end
        self:QueueRefreshAllViews("capture")
    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

    -- Damage meter / Details data can land well after the completion event.
    -- Early attempts poll fast; later ones back off so the total retry window
    -- is around 35s instead of ~8s before giving up.
    local delay = COMPLETION_RETRY_DELAY
    local attemptNumber = tonumber(attempt) or 1
    if attemptNumber > 8 then
        delay = math.min(2, COMPLETION_RETRY_DELAY * (attemptNumber - 7))
    end

    self:Schedule(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:QueueRefreshAllViews("capture")
        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:QueueRebuildPlayerIndex("capture")
    end

    self:QueueRefreshAllViews("capture")
    if self.completionPopup and self.completionPopup:IsShown() and self.completionPopup.runId == runId and self.RefreshCompletionPopup then
        self:RefreshCompletionPopup()
    end

    return true
end

-- Blizzard only updates C_PlayerInfo M+ ratings a few seconds after the key
-- ends, so scores read at completion still hold the pre-run value (delta 0).
-- Re-read them a couple of times while the group is likely still together.
function MythicTools:QueueCompletedRunScoreRefresh(runId, activeRunContext)
    runId = tonumber(runId)
    if not runId then
        return
    end

    for _, delay in ipairs({10, 30}) do
        self:Schedule(delay, function()
            if not (MythicTools and MythicTools.GetRunById) then
                return
            end

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

            local changed = MythicTools:RefreshCompletedRunScoresFromBlizzard(run, activeRunContext)
            if MythicTools.HydrateObservedScoresForTarget and MythicTools:HydrateObservedScoresForTarget(run) then
                changed = true
            end

            if changed then
                MythicTools:PushRuntimeDebug(("late score refresh run %s delay=%s"):format(tostring(runId), tostring(delay)))
                if MythicTools.RefreshRunDerivedStats then
                    MythicTools:RefreshRunDerivedStats(run)
                end
                MythicTools:QueueRefreshAllViews("score-refresh")
            end
        end)
    end
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
    run._dungeonKey = nil
    run._searchTextLower = nil

    if self.db then
        self:QueueRebuildPlayerIndex("capture")
    end
    self:QueueRefreshAllViews("capture")

    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
    if self.runtime then
        -- Data only reaches disk on logout/reload; used by the auto-save option.
        self.runtime.hasUnsavedRuns = true
    end
    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:QueueRefreshAllViews("capture")

    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
        self:QueueCompletedRunScoreRefresh(run.runId, statsContext)
    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")

        -- If some roster member still has no core data at all while others do,
        -- their meter data may simply not have streamed in yet. Keep retrying
        -- instead of freezing them at 0; found values are never overwritten.
        if allowRetry then
            for _, fullName in ipairs(run.roster or {}) do
                local stat = run.playerStats and run.playerStats[fullName]
                if hasDamageStats and (not stat or stat.damage == nil) then
                    run.statsNote = "Waiting for damage meter totals."
                    self:PushRuntimeDebug(("run %s waiting for stats of %s"):format(tostring(run.runId), tostring(fullName)))
                    return true
                end
            end
        end

        for _, fullName in ipairs(run.roster or {}) do
            local stat = self:EnsureCompletedRunPlayer(run, fullName)
            if hasDamageStats and stat.damage == nil then
                stat.damage = 0
                self:PushRuntimeDebug(("run %s zero-filled stats for %s (no meter data found)"):format(tostring(run.runId), tostring(fullName)))
            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










