local ADDON_NAME, ns = ...

local MythicTools = {}
ns.MythicTools = MythicTools
_G.MythicTools = MythicTools

MythicTools.name = ADDON_NAME
MythicTools.version = C_AddOns and C_AddOns.GetAddOnMetadata and (C_AddOns.GetAddOnMetadata(ADDON_NAME, "Version") or "1.0") or "1.0"
MythicTools.DB_VERSION = 13

local canaccessvalue = canaccessvalue or function(value)
    return value ~= nil
end

local canaccesstable = canaccesstable or function(value)
    return type(value) == "table"
end

local floor = math.floor
local min = math.min
local max = math.max

local function SafeToNumber(value)
    if type(value) == "number" then
        return value
    end

    if type(value) == "string" then
        return tonumber(value)
    end

    return nil
end

function MythicTools:CanAccessValue(value)
    if value == nil then
        return false
    end

    local ok, accessible = pcall(canaccessvalue, value)
    return ok and accessible and true or false
end

function MythicTools:CanAccessTable(value)
    if type(value) ~= "table" then
        return false
    end

    local ok, accessible = pcall(canaccesstable, value)
    return ok and accessible and true or false
end

function MythicTools:GetLocalRealmName()
    if self.localRealmName and self.localRealmName ~= "" then
        return self.localRealmName
    end

    local normalizedRealm = GetNormalizedRealmName and GetNormalizedRealmName() or nil
    if normalizedRealm and normalizedRealm ~= "" then
        self.localRealmName = normalizedRealm
        return self.localRealmName
    end

    local realmName = GetRealmName and GetRealmName() or ""
    self.localRealmName = realmName:gsub("[%s%-]", "")
    return self.localRealmName
end

function MythicTools:TrimText(text)
    if not self:CanAccessValue(text) or type(text) ~= "string" then
        return ""
    end

    return text:gsub("^%s+", ""):gsub("%s+$", "")
end

function MythicTools:NormalizePlayerName(name, realm)
    if not self:CanAccessValue(name) or type(name) ~= "string" then
        return nil
    end

    name = self:TrimText(name)
    if name == "" then
        return nil
    end

    if name:find("%-") then
        local shortName, realmPart = name:match("^([^%-]+)%-(.+)$")
        if shortName and realmPart and realmPart ~= "" then
            return shortName .. "-" .. realmPart:gsub("[%s%-]", "")
        end
    end

    realm = self:CanAccessValue(realm) and type(realm) == "string" and realm:gsub("[%s%-]", "") or ""
    if realm == "" then
        realm = self:GetLocalRealmName()
    end

    if realm == "" then
        return name
    end

    return name .. "-" .. realm
end

function MythicTools:GetUnitFullName(unit)
    if not unit then
        return nil
    end

    -- Blizzard can mark some unit tokens as protected in tainted callbacks.
    -- In those cases, resolve the full name defensively and degrade to nil.
    local ok, fullName = pcall(function()
        if not UnitExists(unit) then
            return nil
        end

        local name, realm = UnitFullName(unit)
        if not self:CanAccessValue(name) or self:IsPlaceholderName(name) then
            return nil
        end

        return self:NormalizePlayerName(name, realm)
    end)

    if ok then
        return fullName
    end

    return nil
end

function MythicTools:GetShortName(fullName)
    if type(fullName) ~= "string" then
        return ""
    end

    return fullName:match("^[^%-]+") or fullName
end

-- The game returns the localized UNKNOWN placeholder (e.g. "Unknown" / "Desconhecido")
-- from UnitName/UnitFullName when a unit's data has not loaded yet. Capturing it
-- pollutes history with junk entries and produces false "known player" alerts, so
-- callers should treat such names as unresolved.
function MythicTools:IsPlaceholderName(name)
    if type(name) ~= "string" then
        return true
    end

    local trimmed = self:TrimText(name)
    if trimmed == "" then
        return true
    end

    local candidate = self:Lower(self:GetShortName(trimmed))
    if candidate == "" or candidate == "unknown" then
        return true
    end

    if type(_G.UNKNOWN) == "string" and _G.UNKNOWN ~= "" and candidate == self:Lower(self:GetShortName(_G.UNKNOWN)) then
        return true
    end

    return false
end

function MythicTools:NormalizeRole(role)
    if type(role) ~= "string" then
        return nil
    end

    local normalized = role:upper()
    if normalized == "NONE" or normalized == "" then
        return nil
    end
    if normalized == "DPS" or normalized == "DAMAGE" or normalized == "DAMAGER" then
        return "DAMAGER"
    end
    if normalized == "HEAL" or normalized == "HEALING" or normalized == "HEALER" then
        return "HEALER"
    end
    if normalized == "TANK" then
        return "TANK"
    end
    if normalized == "DAMAGER" or normalized == "HEALER" then
        return normalized
    end

    return nil
end

function MythicTools:GetRoleLabel(role)
    local normalized = self:NormalizeRole(role)
    if normalized == "TANK" then
        return "Tank"
    end
    if normalized == "HEALER" then
        return "Healer"
    end
    if normalized == "DAMAGER" then
        return "DPS"
    end
    return "Unknown"
end

function MythicTools:GetSpecInfo(specID)
    if not specID or specID == 0 or not GetSpecializationInfoByID then
        return nil, nil, nil
    end

    local resolvedSpecID, specName, _, specIconID, role = GetSpecializationInfoByID(specID)
    return resolvedSpecID or specID, specName, specIconID, self:NormalizeRole(role)
end

function MythicTools:GetUnitRoleSpecInfo(unit)
    if not unit or not UnitExists(unit) then
        return nil, nil, nil, nil
    end

    local role = self:NormalizeRole(UnitGroupRolesAssigned and UnitGroupRolesAssigned(unit) or nil)
    local specID, specName, specIconID

    if UnitIsUnit and UnitIsUnit(unit, "player") and GetSpecialization and GetSpecializationInfo then
        local specIndex = GetSpecialization()
        if specIndex and specIndex > 0 then
            specID, specName, _, specIconID, role = GetSpecializationInfo(specIndex)
            role = self:NormalizeRole(role)
        end
    elseif GetInspectSpecialization then
        local inspectedSpecID = GetInspectSpecialization(unit)
        if inspectedSpecID and inspectedSpecID > 0 then
            specID, specName, specIconID, role = self:GetSpecInfo(inspectedSpecID)
        end
    end

    return role, specID, specName, specIconID
end

function MythicTools:NormalizeItemLevel(value)
    value = SafeToNumber(value)
    if value and value > 0 then
        return value
    end

    return nil
end

function MythicTools:GetPlayerEquippedItemLevel()
    if not GetAverageItemLevel then
        return nil
    end

    local overallItemLevel, equippedItemLevel = GetAverageItemLevel()
    return self:NormalizeItemLevel(equippedItemLevel) or self:NormalizeItemLevel(overallItemLevel)
end

function MythicTools:GetUnitItemLevel(unit)
    if type(unit) ~= "string" or unit == "" or not UnitExists or not UnitExists(unit) then
        return nil
    end

    if UnitIsUnit and UnitIsUnit(unit, "player") then
        return self:GetPlayerEquippedItemLevel()
    end

    if C_PaperDollInfo and C_PaperDollInfo.GetInspectItemLevel then
        local ok, inspectItemLevel = pcall(C_PaperDollInfo.GetInspectItemLevel, unit)
        if ok then
            local normalized = self:NormalizeItemLevel(inspectItemLevel)
            if normalized then
                return normalized
            end
        end
    end

    if C_PlayerInfo and C_PlayerInfo.GetInspectItemLevel then
        local ok, inspectItemLevel = pcall(C_PlayerInfo.GetInspectItemLevel, unit)
        if ok then
            local normalized = self:NormalizeItemLevel(inspectItemLevel)
            if normalized then
                return normalized
            end
        end
    end

    return nil
end

function MythicTools:Lower(text)
    return type(text) == "string" and text:lower() or ""
end

function MythicTools:ContainsText(haystack, needle)
    if not needle or needle == "" then
        return true
    end

    return self:Lower(haystack):find(self:Lower(needle), 1, true) ~= nil
end

function MythicTools:FormatDate(timestamp)
    if not timestamp or timestamp <= 0 then
        return "--"
    end

    return date("%d/%m/%Y", timestamp)
end

function MythicTools:FormatDateTime(timestamp)
    if not timestamp or timestamp <= 0 then
        return "--"
    end

    return date("%d/%m/%Y %H:%M", timestamp)
end

function MythicTools:FormatDurationMS(milliseconds)
    if not milliseconds or milliseconds <= 0 then
        return "--"
    end

    local totalSeconds = floor((milliseconds / 1000) + 0.5)
    local minutes = floor(totalSeconds / 60)
    local seconds = totalSeconds % 60
    local hours = floor(minutes / 60)

    if hours > 0 then
        return ("%d:%02d:%02d"):format(hours, minutes % 60, seconds)
    end

    return ("%d:%02d"):format(minutes, seconds)
end

function MythicTools:FormatDurationSeconds(seconds)
    if not seconds or seconds <= 0 then
        return "--"
    end

    return self:FormatDurationMS(seconds * 1000)
end

function MythicTools:FormatAmount(value)
    if value == nil then
        return "--"
    end

    value = SafeToNumber(value) or 0
    value = floor(value + 0.5)

    if AbbreviateNumbers then
        return AbbreviateNumbers(value)
    end

    if AbbreviateLargeNumbers then
        return AbbreviateLargeNumbers(value)
    end

    if BreakUpLargeNumbers then
        return BreakUpLargeNumbers(value)
    end

    return tostring(value)
end

function MythicTools:NormalizeRunResult(result, onTime)
    if result == "timed" or result == "overtime" or result == "abandoned" then
        return result
    end

    if onTime == true then
        return "timed"
    end

    if onTime == false then
        return "overtime"
    end

    return "overtime"
end

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

    return self:NormalizeRunResult(run.result, run.onTime)
end

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

    local timeLimitSeconds = tonumber(run.timeLimitSeconds) or tonumber(run.timeLimit) or 0
    if timeLimitSeconds > 0 then
        return timeLimitSeconds
    end

    local mapChallengeModeID = tonumber(run.mapChallengeModeID or run.challengeModeMapID) or 0
    if mapChallengeModeID > 0 and C_ChallengeMode and C_ChallengeMode.GetMapUIInfo then
        local _, _, timeLimit = C_ChallengeMode.GetMapUIInfo(mapChallengeModeID)
        return tonumber(timeLimit) or 0
    end

    return 0
end

function MythicTools:GetRunKeystoneUpgradeLevels(run)
    if type(run) ~= "table" or self:GetRunResult(run) ~= "timed" then
        return 0
    end

    local recordedUpgrades = tonumber(run.keystoneUpgradeLevels) or 0
    if recordedUpgrades > 0 then
        return self:Clamp(math.floor(recordedUpgrades + 0.5), 1, 3)
    end

    local timeMS = tonumber(run.timeMS) or 0
    local timeLimitSeconds = self:GetRunTimeLimitSeconds(run)
    if timeMS <= 0 or timeLimitSeconds <= 0 then
        return 1
    end

    local elapsedSeconds = timeMS / 1000
    if elapsedSeconds <= timeLimitSeconds * 0.6 then
        return 3
    end
    if elapsedSeconds <= timeLimitSeconds * 0.8 then
        return 2
    end

    return 1
end

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

    local mapChallengeModeID = tonumber(run.mapChallengeModeID or run.challengeModeMapID)
    if mapChallengeModeID and mapChallengeModeID > 0 then
        return mapChallengeModeID
    end

    return nil
end

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

    if run._dungeonKey ~= nil then
        return run._dungeonKey
    end

    local mapChallengeModeID = self:GetRunMapChallengeModeID(run)
    if mapChallengeModeID then
        run._dungeonKey = tostring(mapChallengeModeID)
        return run._dungeonKey
    end

    local mapID = tonumber(run.mapID)
    if mapID and mapID > 0 then
        run._dungeonKey = ("map:%d"):format(mapID)
        return run._dungeonKey
    end

    local dungeonName = self:TrimText(run.dungeonName)
    if dungeonName ~= "" then
        run._dungeonKey = "name:" .. self:Lower(dungeonName)
        return run._dungeonKey
    end

    run._dungeonKey = "unknown"
    return "unknown"
end

function MythicTools:GetRunSeason(run)
    local defaultSeason = self.GetDefaultSeason and self:GetDefaultSeason() or "midnight-season1"
    if type(run) ~= "table" then
        return defaultSeason
    end

    return (self.NormalizeSeason and self:NormalizeSeason(run.season)) or run.season or defaultSeason
end

function MythicTools:IsPlayerGUID(guid)
    return type(guid) == "string" and guid:match("^Player%-") ~= nil
end

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

    local guid = type(stat) == "table" and stat.guid or nil
    if guid ~= nil and guid ~= "" then
        return self:IsPlayerGUID(guid)
    end

    return true
end

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

    local unique = {}
    local seen = {}
    local source = type(run.roster) == "table" and run.roster or {}
    local stats = type(run.playerStats) == "table" and run.playerStats or {}

    for _, fullName in ipairs(source) do
        if type(fullName) == "string" and fullName ~= "" and not seen[fullName] and self:IsRunPlayerEntry(fullName, stats[fullName]) then
            seen[fullName] = true
            unique[#unique + 1] = fullName
        end
    end

    if #unique == 0 then
        for fullName, stat in pairs(stats) do
            if type(fullName) == "string" and fullName ~= "" and not seen[fullName] and self:IsRunPlayerEntry(fullName, stat) then
                seen[fullName] = true
                unique[#unique + 1] = fullName
            end
        end
        table.sort(unique)
    end

    if #unique > 5 then
        while #unique > 5 do
            table.remove(unique)
        end
    end

    run.roster = unique
    return unique
end

function MythicTools:GetRunPlayerCount(run)
    return #(self:NormalizeRunRoster(run))
end

function MythicTools:ColorText(text, color)
    if type(color) ~= "string" or color == "" then
        return tostring(text)
    end

    return color .. tostring(text) .. "|r"
end

function MythicTools:Print(message)
    print(("|cffcfa85dMythic Tools|r: %s"):format(tostring(message or "")))
end

function MythicTools:IsDebugLoggingEnabled()
    return self.db and self.db.debug and self.db.debug.enabled ~= false
end

function MythicTools:GetDebugReports()
    if not (self.db and self.db.debug) then
        return {}
    end

    self.db.debug.reports = type(self.db.debug.reports) == "table" and self.db.debug.reports or {}
    return self.db.debug.reports
end

function MythicTools:BuildDebugRuntimeSnapshot()
    local runtime = self.runtime or {}
    local activeRun = runtime.activeRun
    local lootTracking = runtime.lootTracking
    local rewards = runtime.lastCompletionRewards

    return {
        activeRunId = type(activeRun) == "table" and activeRun.runId or nil,
        activeRunState = type(activeRun) == "table" and activeRun.state or nil,
        activeRunLevel = type(activeRun) == "table" and activeRun.keyLevel or nil,
        activeRunDungeon = type(activeRun) == "table" and activeRun.dungeonName or nil,
        activeRunCompletionPending = type(activeRun) == "table" and (activeRun.completionPending and true or false) or false,
        lootTrackingRunId = type(lootTracking) == "table" and lootTracking.runId or nil,
        lootOpened = type(lootTracking) == "table" and (lootTracking.lootOpened and true or false) or false,
        lootClosed = type(lootTracking) == "table" and (lootTracking.lootClosed and true or false) or false,
        lastRewardsMapId = type(rewards) == "table" and rewards.mapChallengeModeID or nil,
        lastRewardsTimeMS = type(rewards) == "table" and rewards.timeMS or nil,
        pendingCompletionSnapshots = type(runtime.pendingCompletionSnapshots) == "table" and #runtime.pendingCompletionSnapshots or 0,
        pendingCompletionPopupRunId = type(runtime.pendingCompletionPopup) == "table" and runtime.pendingCompletionPopup.runId or nil,
        completionPopupTrigger = self.db and self.db.settings and self.db.settings.completionPopupTrigger or nil,
        savedRunCount = self.db and self.db.runs and #self.db.runs or 0,
    }
end

function MythicTools:StoreDebugReport(kind, context)
    if not self:IsDebugLoggingEnabled() then
        return nil
    end

    local reports = self:GetDebugReports()
    local report = {
        timestamp = time(),
        kind = kind or "event",
        context = type(context) == "table" and context or {},
        runtime = self:BuildDebugRuntimeSnapshot(),
        recentEvents = self:CopyArray(self.runtime and self.runtime.debugLog or {}),
    }

    table.insert(reports, 1, report)

    local maxReports = self:Clamp(self.db.debug.maxReports or 20, 5, 50)
    while #reports > maxReports do
        table.remove(reports)
    end

    return report
end

function MythicTools:GetLatestDebugReport()
    local reports = self:GetDebugReports()
    return reports[1]
end

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

    local lines = {}
    local context = type(report.context) == "table" and report.context or {}
    local runtime = type(report.runtime) == "table" and report.runtime or {}

    lines[#lines + 1] = ("Debug report: %s at %s"):format(tostring(report.kind or "event"), self:FormatDateTime(report.timestamp))
    lines[#lines + 1] = ("Context: trigger=%s result=%s runId=%s state=%s map=%s level=%s"):format(
        tostring(context.trigger or "-"),
        tostring(context.result or "-"),
        tostring(context.runId or runtime.activeRunId or "-"),
        tostring(context.state or runtime.activeRunState or "-"),
        tostring(context.mapChallengeModeID or context.lastRewardsMapId or runtime.lastRewardsMapId or "-"),
        tostring(context.level or runtime.activeRunLevel or "-")
    )
    lines[#lines + 1] = ("Runtime: lootRun=%s lootOpened=%s lootClosed=%s pendingSnapshots=%s pendingPopup=%s popupTrigger=%s rewardsTime=%s savedRuns=%s"):format(
        tostring(runtime.lootTrackingRunId or "-"),
        tostring(runtime.lootOpened or false),
        tostring(runtime.lootClosed or false),
        tostring(runtime.pendingCompletionSnapshots or 0),
        tostring(runtime.pendingCompletionPopupRunId or "-"),
        tostring(runtime.completionPopupTrigger or "-"),
        tostring(runtime.lastRewardsTimeMS or "-"),
        tostring(runtime.savedRunCount or 0)
    )

    local recentEvents = type(report.recentEvents) == "table" and report.recentEvents or {}
    if #recentEvents > 0 then
        lines[#lines + 1] = "Recent events:"
        local startIndex = math.max(1, #recentEvents - 14)
        for index = startIndex, #recentEvents do
            lines[#lines + 1] = ("[%02d] %s"):format(index - startIndex + 1, tostring(recentEvents[index]))
        end
    end

    return lines
end

function MythicTools:PrintLatestDebugReport()
    local report = self:GetLatestDebugReport()
    if type(report) ~= "table" then
        self:Print("No debug report saved yet.")
        return
    end

    for _, line in ipairs(self:FormatDebugReport(report)) do
        self:Print(line)
    end
    self:Print("Full report is also stored in SkyMythicHistoryDB.debug.reports[1].")
end

function MythicTools:Clamp(value, minValue, maxValue)
    value = SafeToNumber(value) or minValue
    value = max(minValue, value)
    value = min(maxValue, value)
    return value
end

function MythicTools:CopyArray(source)
    local copy = {}

    if type(source) ~= "table" then
        return copy
    end

    for index = 1, #source do
        copy[index] = source[index]
    end

    return copy
end

function MythicTools:GetGroupUnits()
    local units = {}

    if IsInRaid() then
        for index = 1, GetNumGroupMembers() do
            units[#units + 1] = "raid" .. index
        end
    elseif IsInGroup() then
        units[1] = "player"
        for index = 1, GetNumSubgroupMembers() do
            units[#units + 1] = "party" .. index
        end
    else
        units[1] = "player"
    end

    return units
end

function MythicTools:GetCurrentGroupRoster()
    local roster = {}
    local seen = {}

    for _, unit in ipairs(self:GetGroupUnits()) do
        if UnitExists(unit) then
            local fullName = self:GetUnitFullName(unit)
            if fullName and not seen[fullName] then
                seen[fullName] = true

                local guid = UnitGUID(unit)
                if not self:CanAccessValue(guid) then
                    guid = nil
                end

                local _, classFilename = UnitClass(unit)
                local role, specID, specName, specIconID = self:GetUnitRoleSpecInfo(unit)
                local itemLevel = self:GetUnitItemLevel(unit)
                roster[#roster + 1] = {
                    unit = unit,
                    name = fullName,
                    shortName = self:GetShortName(fullName),
                    guid = guid,
                    classFilename = classFilename,
                    role = role,
                    specID = specID,
                    specName = specName,
                    specIconID = specIconID,
                    itemLevel = itemLevel,
                    isDead = UnitIsDeadOrGhost(unit) and not UnitIsFeignDeath(unit) or false,
                }
            end
        end
    end

    return roster
end

function MythicTools:GetGroupUnitForPlayer(fullName)
    if not fullName then
        return nil
    end

    if self.playerName and fullName == self.playerName and UnitExists("player") then
        return "player"
    end

    local roster = self:GetCurrentGroupRoster()
    for _, rosterEntry in ipairs(roster) do
        if rosterEntry.name == fullName and rosterEntry.unit and UnitExists(rosterEntry.unit) then
            return rosterEntry.unit
        end
    end

    -- The client can momentarily report group units without their realm (or
    -- with the local realm appended), so an exact match can fail for a valid
    -- cross-realm member. Fall back to a unique short-name match.
    local shortName = self:GetShortName(fullName)
    local match
    for _, rosterEntry in ipairs(roster) do
        if self:GetShortName(rosterEntry.name) == shortName and rosterEntry.unit and UnitExists(rosterEntry.unit) then
            if match then
                return nil -- two same-short-name members: ambiguous
            end
            match = rosterEntry.unit
        end
    end

    return match
end

function MythicTools:GetRosterSignature(roster)
    if type(roster) ~= "table" then
        return ""
    end

    local names = {}
    for _, entry in ipairs(roster) do
        names[#names + 1] = entry.name
    end

    table.sort(names)
    return table.concat(names, "|")
end

function MythicTools:Schedule(delay, callback)
    if type(callback) ~= "function" then
        return
    end

    C_Timer.After(delay or 0, callback)
end

function MythicTools:RefreshAllViews()
    if self.RefreshUI then
        self:RefreshUI()
    end
    if self.RefreshCompletionPopup then
        self:RefreshCompletionPopup()
    end
end

function MythicTools:QueueRefreshAllViews(reason, delay)
    if self._refreshAllViewsQueued then
        self._refreshAllViewsReason = reason or self._refreshAllViewsReason
        return
    end

    self._refreshAllViewsQueued = true
    self._refreshAllViewsReason = reason
    self:Schedule(delay or 0.05, function()
        self._refreshAllViewsQueued = false
        self._refreshAllViewsReason = nil
        self:RefreshAllViews()
    end)
end

function MythicTools:MarkHistoryDataDirty(reason)
    self._historyDataRevision = (self._historyDataRevision or 0) + 1
    self._historyDataDirtyReason = reason
    self._filteredRunsCache = nil
    self._filteredPlayersCache = nil
    self._statsDataCache = nil
    self._dungeonOptionsCache = nil
end

function MythicTools:HandlePlayerLogin()
    self.playerName = self:GetUnitFullName("player")
    if self.RegisterOwnedCharacter then
        self:RegisterOwnedCharacter(self.playerName)
    end
    if self.RequestMythicPlusData then
        self:RequestMythicPlusData()
    end
    self:GetLocalRealmName()
    if self.InitializeUnitPopupMenu then
        self:InitializeUnitPopupMenu()
    end
    if self.InitializeBlizzardUnitTooltip then
        self:InitializeBlizzardUnitTooltip()
    end
    if self.UpdateMinimapButton then
        self:UpdateMinimapButton()
    end
    self:QueueGroupScan()
end

function MythicTools:HandlePlayerEnteringWorld(isLogin, isReload)
    self.playerName = self:GetUnitFullName("player")
    if self.RegisterOwnedCharacter then
        self:RegisterOwnedCharacter(self.playerName)
    end

    local challengeActive = C_ChallengeMode.IsChallengeModeActive and C_ChallengeMode.IsChallengeModeActive()
    if challengeActive then
        if self.TryRestoreActiveRun then
            self:TryRestoreActiveRun()
        end
    else
        local activeRun = self.runtime and self.runtime.activeRun or nil
        if self.QueueInactiveRunResolution and type(activeRun) == "table" then
            self:QueueInactiveRunResolution(activeRun, "PLAYER_ENTERING_WORLD")
        elseif self.QueuePendingCompletionProcessing then
            self:QueuePendingCompletionProcessing("PLAYER_ENTERING_WORLD")
        end
    end

    if not isLogin and not isReload and not challengeActive and self.PushRuntimeDebug then
        local activeRun = self.runtime and self.runtime.activeRun or nil
        if type(activeRun) == "table" then
            self:PushRuntimeDebug(("PLAYER_ENTERING_WORLD outside challenge for run %s"):format(tostring(activeRun.runId)))
        else
            self:PushRuntimeDebug("PLAYER_ENTERING_WORLD outside challenge with no active run")
        end
    end

    if not challengeActive then
        self:QueueAutoSaveHistoryToDisk("PLAYER_ENTERING_WORLD")
    end

    self:QueueGroupScan()
end

-- WoW only writes SavedVariables to disk on logout or UI reload; a crash or
-- power loss discards everything recorded since the last write. The only way
-- an addon can force a disk write is a UI reload, so (opt-in) we do a quick
-- reload at the first safe moment after leaving a dungeon with unsaved runs.
function MythicTools:SaveHistoryToDisk(silent)
    local reload = (C_UI and C_UI.Reload) or ReloadUI
    if not reload then
        return false
    end

    if self.runtime then
        self.runtime.hasUnsavedRuns = false
    end
    if not silent then
        self:Print(self:L("AUTO_SAVE_RELOADING"))
    end
    reload()
    return true
end

function MythicTools:QueueAutoSaveHistoryToDisk(reason)
    if not (self.db and self.db.settings and self.db.settings.autoSaveAfterRun) then
        return
    end
    if not (self.runtime and self.runtime.hasUnsavedRuns) then
        return
    end
    if self.runtime.autoSaveQueued then
        return
    end

    self.runtime.autoSaveQueued = true
    self:PushRuntimeDebug(("auto-save queued reason=%s"):format(tostring(reason)))

    -- Give the loading screen a moment to settle, then only reload when it is
    -- safe and still relevant: out of combat, no key in progress, and there
    -- are still unsaved runs.
    self:Schedule(8, function()
        if not (MythicTools and MythicTools.runtime) then
            return
        end
        MythicTools.runtime.autoSaveQueued = false

        if not (MythicTools.db and MythicTools.db.settings and MythicTools.db.settings.autoSaveAfterRun) then
            return
        end
        if not MythicTools.runtime.hasUnsavedRuns then
            return
        end
        if InCombatLockdown and InCombatLockdown() then
            MythicTools:QueueAutoSaveHistoryToDisk("combat-retry")
            return
        end
        if C_ChallengeMode.IsChallengeModeActive and C_ChallengeMode.IsChallengeModeActive() then
            return
        end
        if type(MythicTools.runtime.activeRun) == "table" then
            return
        end

        MythicTools:SaveHistoryToDisk()
    end)
end

function MythicTools:HandleGroupRosterChanged()
    if self.UpdateActiveRunParticipants then
        self:UpdateActiveRunParticipants()
    end

    self:QueueGroupScan()
end

function MythicTools:ADDON_LOADED(loadedAddon)
    if loadedAddon ~= ADDON_NAME then
        return
    end

    self.EventFrame:UnregisterEvent("ADDON_LOADED")

    self:InitializeDB()
    self:InitializeSlashCommands()

    self.runtime = {
        activeRun = nil,
        groupScanQueued = false,
        currentGroupPlayers = {},
        lootTracking = nil,
        pendingCompletionPopup = nil,
        lastActiveRunSnapshot = nil,
        pendingCompletionSnapshots = {},
        pendingCompletionProcessingQueued = false,
        lastCompletionRewards = nil,
        lastCompletedRunId = nil,
        lastCompletedAt = nil,
        lastCompletedMapChallengeModeID = nil,
        lastCompletedLevel = nil,
        debugLog = {},
    }

    self.EventFrame:RegisterEvent("PLAYER_LOGIN")
    self.EventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
    self.EventFrame:RegisterEvent("GROUP_ROSTER_UPDATE")
    self.EventFrame:RegisterEvent("UNIT_PET")
    self.EventFrame:RegisterEvent("CHALLENGE_MODE_START")
    self.EventFrame:RegisterEvent("CHALLENGE_MODE_COMPLETED")
    self.EventFrame:RegisterEvent("CHALLENGE_MODE_COMPLETED_REWARDS")
    self.EventFrame:RegisterEvent("CHALLENGE_MODE_RESET")
    self.EventFrame:RegisterEvent("SCENARIO_COMPLETED")
    self.EventFrame:RegisterEvent("CHALLENGE_MODE_DEATH_COUNT_UPDATED")
    self.EventFrame:RegisterEvent("DAMAGE_METER_COMBAT_SESSION_UPDATED")
    self.EventFrame:RegisterEvent("DAMAGE_METER_CURRENT_SESSION_UPDATED")
    self.EventFrame:RegisterEvent("DAMAGE_METER_RESET")
    self.EventFrame:RegisterEvent("GET_ITEM_INFO_RECEIVED")
    self.EventFrame:RegisterEvent("INSPECT_READY")

    if self.InitializeDetailsIntegration then
        self:InitializeDetailsIntegration()
    end
end

function MythicTools:PLAYER_LOGIN()
    self:HandlePlayerLogin()
end

function MythicTools:PLAYER_ENTERING_WORLD(...)
    self:HandlePlayerEnteringWorld(...)
end

function MythicTools:GROUP_ROSTER_UPDATE()
    self:HandleGroupRosterChanged()
end

function MythicTools:UNIT_PET(...)
    if self.HandleUnitPetChanged then
        self:HandleUnitPetChanged(...)
    end
end

function MythicTools:CHALLENGE_MODE_START(...)
    if self.HandleChallengeModeStart then
        self:HandleChallengeModeStart(...)
    end
end

function MythicTools:CHALLENGE_MODE_COMPLETED(...)
    if self.HandleChallengeModeCompleted then
        self:HandleChallengeModeCompleted(...)
    end
end

function MythicTools:CHALLENGE_MODE_COMPLETED_REWARDS(...)
    if self.HandleChallengeModeCompletedRewards then
        self:HandleChallengeModeCompletedRewards(...)
    end
end

function MythicTools:CHALLENGE_MODE_RESET(...)
    if self.HandleChallengeModeReset then
        self:HandleChallengeModeReset(...)
    end
end

function MythicTools:SCENARIO_COMPLETED(...)
    if self.HandleScenarioCompleted then
        self:HandleScenarioCompleted(...)
    end
end

function MythicTools:CHALLENGE_MODE_DEATH_COUNT_UPDATED(...)
    if self.HandleChallengeDeathCountUpdated then
        self:HandleChallengeDeathCountUpdated(...)
    end
end

function MythicTools:UNIT_HEALTH(...)
    if self.HandleUnitHealth then
        self:HandleUnitHealth(...)
    end
end

function MythicTools:DAMAGE_METER_COMBAT_SESSION_UPDATED(...)
    if self.HandleDamageMeterSessionUpdated then
        self:HandleDamageMeterSessionUpdated(...)
    end
end

function MythicTools:DAMAGE_METER_CURRENT_SESSION_UPDATED(...)
    if self.HandleDamageMeterCurrentSessionUpdated then
        self:HandleDamageMeterCurrentSessionUpdated(...)
    end
end

function MythicTools:DAMAGE_METER_RESET(...)
    if self.HandleDamageMeterReset then
        self:HandleDamageMeterReset(...)
    end
end

function MythicTools:ENCOUNTER_LOOT_RECEIVED(...)
    if self.HandleEncounterLootReceived then
        self:HandleEncounterLootReceived(...)
    end
end

function MythicTools:CHAT_MSG_LOOT(...)
    if self.HandleChatMsgLoot then
        self:HandleChatMsgLoot(...)
    end
end

function MythicTools:GET_ITEM_INFO_RECEIVED(...)
    if self.HandleItemInfoReceived then
        self:HandleItemInfoReceived(...)
    end
end

function MythicTools:INSPECT_READY(...)
    if self.HandleInspectReady then
        self:HandleInspectReady(...)
    end
end

function MythicTools:LOOT_CLOSED(...)
    if self.HandleLootClosed then
        self:HandleLootClosed(...)
    end
end

function MythicTools:LOOT_OPENED(...)
    if self.HandleLootOpened then
        self:HandleLootOpened(...)
    end
end

MythicTools.EventFrame = CreateFrame("Frame")
MythicTools.EventFrame:SetScript("OnEvent", function(_, event, ...)
    local handler = MythicTools[event]
    if handler then
        handler(MythicTools, ...)
    end
end)
MythicTools.EventFrame:RegisterEvent("ADDON_LOADED")
