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

local BuildRecoveredCompletionInfo = Capture.BuildRecoveredCompletionInfo
local CopyCompletionInfo = Capture.CopyCompletionInfo
local DAMAGE_DONE = Capture.DAMAGE_DONE
local GetCompletionMemberItemLevel = Capture.GetCompletionMemberItemLevel
local GetInspectQueueKey = Capture.GetInspectQueueKey
local GetMapInfo = Capture.GetMapInfo
local GetPetUnitForGroupUnit = Capture.GetPetUnitForGroupUnit
local GetSourceGUID = Capture.GetSourceGUID
local GetSourceOwnerGUID = Capture.GetSourceOwnerGUID
local GetSourceOwnerName = Capture.GetSourceOwnerName
local INSPECT_MAX_ATTEMPTS = Capture.INSPECT_MAX_ATTEMPTS
local INSPECT_REQUEST_TIMEOUT = Capture.INSPECT_REQUEST_TIMEOUT
local IsInspectDataMissing = Capture.IsInspectDataMissing
local IsInspectablePlayerUnit = Capture.IsInspectablePlayerUnit
local IsValidCompletionInfo = Capture.IsValidCompletionInfo
local LOOT_TRACKING_TIMEOUT = Capture.LOOT_TRACKING_TIMEOUT
local MAX_COMPLETION_ATTEMPTS = Capture.MAX_COMPLETION_ATTEMPTS
local MergeSessionSet = Capture.MergeSessionSet
local NormalizeCapturedItemLevel = Capture.NormalizeCapturedItemLevel
local SetCachedCompletionInfo = Capture.SetCachedCompletionInfo

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

-- UnitFullName can transiently return realm=nil for a cross-realm group member
-- (e.g. right at the end-of-run scoreboard). NormalizePlayerName then appends
-- the LOCAL realm, fabricating a ghost like "Mendie-Stormrage" next to the real
-- "Mendie-Azralon" — and the ghost steals the meter data via its shared GUID.
-- Resolve back to the existing participant by GUID first, then by unique short
-- name, before ever creating a new entry.
function MythicTools:ResolveActiveRunParticipantName(activeRun, playerInfo)
    local fullName = playerInfo and playerInfo.name
    if not fullName or type(activeRun) ~= "table" then
        return fullName
    end

    if activeRun.participants and activeRun.participants[fullName] then
        return fullName
    end

    if playerInfo.guid and type(activeRun.playerStats) == "table" then
        for existingName, stat in pairs(activeRun.playerStats) do
            if type(stat) == "table" and stat.guid == playerInfo.guid then
                return existingName
            end
        end
    end

    local shortName = self:GetShortName(fullName)
    local match
    for existingName in pairs(activeRun.participants or {}) do
        if self:GetShortName(existingName) == shortName then
            if match then
                -- Two distinct same-short-name participants: ambiguous,
                -- keep the name as given.
                return fullName
            end
            match = existingName
        end
    end

    return match or fullName
end

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

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

    local fullName = self:ResolveActiveRunParticipantName(activeRun, playerInfo)
    if fullName ~= rawName then
        self:PushRuntimeDebug(("participant %s resolved to existing %s (run %s)"):format(
            tostring(rawName),
            tostring(fullName),
            tostring(activeRun.runId)
        ))
    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)
    -- UNIT_HEALTH fires for every visible unit (target, nameplates, etc.);
    -- only group unit tokens matter for death tracking.
    if not unit or (unit ~= "player" and not unit:match("^party[1-4]$")) then
        return
    end

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

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

    -- Redirect to the existing participant if the client momentarily reported
    -- a wrong/missing realm for this unit (see ResolveActiveRunParticipantName).
    fullName = self:ResolveActiveRunParticipantName(activeRun, {name = fullName, guid = UnitGUID(unit)})

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

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

    if changed then
        self:QueueActiveRunSnapshot(activeRun)
    end
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:QueueActiveRunSnapshot(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

