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

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

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

    return itemLink
end

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

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

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

    return normalized
end

local function GetDefaultSeason()
    return MythicTools.GetDefaultSeason and MythicTools:GetDefaultSeason() or "midnight-season1"
end

local function CreateAggregateEntry(label)
    return {
        label = label,
        totalRuns = 0,
        timedRuns = 0,
        overtimeRuns = 0,
        abandonedRuns = 0,
        successRate = 0,
        bestLevel = 0,
        bestTimedLevel = 0,
        averageLevel = 0,
        maxDps = 0,
        averageDps = 0,
        maxHps = 0,
        averageHps = 0,
        ratingTotal = 0,
        ratingCount = 0,
        averageRating = 0,
        totalLevel = 0,
        countedLevels = 0,
        totalDps = 0,
        countedDps = 0,
        totalHps = 0,
        countedHps = 0,
    }
end

local function GetDerivedStatDuration(run, stat, activeSecondsField)
    local durationSeconds = tonumber(run and run.combatTimeSeconds)
    if durationSeconds and durationSeconds > 0 then
        return durationSeconds
    end

    durationSeconds = tonumber(stat and stat[activeSecondsField])
    if durationSeconds and durationSeconds > 0 then
        return durationSeconds
    end

    durationSeconds = (tonumber(run and run.timeMS) or 0) / 1000
    if durationSeconds > 0 then
        return durationSeconds
    end

    return 0
end

local function RunNeedsDerivedStatRefresh(run)
    if type(run) ~= "table" then
        return false
    end

    for _, stat in pairs(run.playerStats or {}) do
        if type(stat) == "table" then
            if stat.damage ~= nil and stat.dps == nil and GetDerivedStatDuration(run, stat, "damageActiveSeconds") > 0 then
                return true
            end
            if stat.healing ~= nil and stat.hps == nil and GetDerivedStatDuration(run, stat, "healingActiveSeconds") > 0 then
                return true
            end
        end
    end

    return false
end

local function AccumulateAggregate(target, run, dps, hps, stat)
    target.totalRuns = (target.totalRuns or 0) + 1

    if run.result == "timed" then
        target.timedRuns = (target.timedRuns or 0) + 1
    elseif run.result == "abandoned" then
        target.abandonedRuns = (target.abandonedRuns or 0) + 1
    else
        target.overtimeRuns = (target.overtimeRuns or 0) + 1
    end

    local isAbandoned = run.result == "abandoned"

    if run.level and run.level > 0 then
        target.bestLevel = math.max(target.bestLevel or 0, run.level)
        if run.result == "timed" then
            target.bestTimedLevel = math.max(target.bestTimedLevel or 0, run.level)
        end
        if not isAbandoned then
            target.totalLevel = (target.totalLevel or 0) + run.level
            target.countedLevels = (target.countedLevels or 0) + 1
        end
    end

    if dps ~= nil and not isAbandoned then
        target.maxDps = math.max(target.maxDps or 0, dps)
        target.totalDps = (target.totalDps or 0) + dps
        target.countedDps = (target.countedDps or 0) + 1
    end

    if hps ~= nil and not isAbandoned then
        target.maxHps = math.max(target.maxHps or 0, hps)
        target.totalHps = (target.totalHps or 0) + hps
        target.countedHps = (target.countedHps or 0) + 1
    end

    local rating = tonumber(stat and stat.rating)
    if rating and rating >= 1 and rating <= 5 then
        target.ratingTotal = (target.ratingTotal or 0) + rating
        target.ratingCount = (target.ratingCount or 0) + 1
    end
end

local function FinalizeAggregate(target)
    if (target.totalRuns or 0) > 0 then
        target.successRate = ((target.timedRuns or 0) / target.totalRuns) * 100
    else
        target.successRate = 0
    end

    if (target.countedLevels or 0) > 0 then
        target.averageLevel = (target.totalLevel or 0) / target.countedLevels
    else
        target.averageLevel = 0
    end

    if (target.countedDps or 0) > 0 then
        target.averageDps = (target.totalDps or 0) / target.countedDps
    else
        target.averageDps = 0
    end

    if (target.countedHps or 0) > 0 then
        target.averageHps = (target.totalHps or 0) / target.countedHps
    else
        target.averageHps = 0
    end

    target.averageRating = (target.ratingCount or 0) > 0 and ((target.ratingTotal or 0) / target.ratingCount) or 0
end

local defaults = {
    version = MythicTools.DB_VERSION,
    nextRunId = 1,
    runs = {},
    playersIndex = {},
    playerNotes = {},
    ownedCharacters = {},
    debug = {
        enabled = true,
        maxReports = 20,
        reports = {},
    },
    settings = {
        chatAlerts = true,
        historyAlertChat = true,
        historyAlertFloating = false,
        historyAlertSound = false,
        historyAlertSoundKey = "Interface\\AddOns\\SkyMythicHistory\\Media\\Sounds\\OhNo.ogg",
        historyAlertFloatingTemplate = "{{star}} You already ran [runs] keys with [player] ([timed] timed)",
        historyAlertChatTemplate = "{{star}} You already ran [runs] keys with [player] ([timed] timed) | Note : [note]",
        historyAlertFont = "default",
        historyAlertFontSize = 22,
        historyAlertOutline = "OUTLINE",
        historyAlertPosition = "TOP",
        historyAlertPoint = {"TOP", "TOP", 0, -180},
        historyAlertLocked = true,
        maxRuns = 500,
        autoSaveAfterRun = false,
        showMinimapButton = true,
        minimapAngle = -35,
        showCompletionPopup = true,
        completionPopupMode = "full",
        completionPopupTrigger = "LOOT_OPENED",
        completionPopupDelay = 0,
        completionPopupScale = 1,
        completionToastAutoHide = true,
        completionToastDuration = 10,
        showLootHistory = true,
        showPlayerHistoryTooltips = true,
        showLFGApplicantHistoryBadge = true,
        showUnitContextMenuEntry = true,
        tooltipShowHeader = true,
        tooltipShowRuns = true,
        tooltipShowRating = true,
        tooltipShowNotes = true,
    },
    ui = {
        point = {"CENTER", "CENTER", 0, 0},
        width = 1280,
        height = 760,
        activeTab = "Runs",
        filters = {
            search = "",
            player = "",
            dungeon = "all",
            season = GetDefaultSeason(),
            date = "",
            status = "all",
            ownedCharacters = "all",
        },
        playerFilters = {
            search = "",
            season = GetDefaultSeason(),
            dungeon = "all",
            augMode = "all",
        },
        statsFilters = {
            season = "all",
            character = "all",
        },
        characterFilters = {
            season = "all",
        },
        playerSort = {
            column = "runs",
            direction = "desc",
        },
        completionPopupSort = {
            column = "damage",
            direction = "desc",
        },
        playerSearch = "",
        playersView = "index",
        charactersView = "index",
        settingsTab = "General",
        selectedRunId = nil,
        selectedPlayer = nil,
        selectedCharacter = nil,
        runViewMode = "list",
        completionPopupPoint = {"CENTER", "CENTER", 0, 40},
        completionToastPoint = {"TOP", "TOP", 0, -210},
        exportFormat = "raw_csv",
    },
}

local function ApplyDefaults(target, source)
    for key, value in pairs(source) do
        if type(value) == "table" then
            if type(target[key]) ~= "table" then
                target[key] = {}
            end
            ApplyDefaults(target[key], value)
        elseif target[key] == nil then
            target[key] = value
        end
    end
end

local function NormalizeExportFormatValue(format)
    if format == "filtered_csv" then
        return "filtered_csv"
    end

    return "raw_csv"
end

local function NormalizeCompletionPopupModeValue(mode, legacyEnabled)
    if mode == "full" or mode == "compact" or mode == "off" then
        return mode
    end

    if legacyEnabled == false then
        return "off"
    end

    return "full"
end

local function NormalizeOwnedCharactersTable(owner, value)
    local normalized = {}

    if type(value) ~= "table" then
        return normalized
    end

    for fullName, enabled in pairs(value) do
        if enabled then
            local normalizedName = owner:NormalizePlayerName(fullName) or owner:TrimText(fullName)
            if normalizedName and normalizedName ~= "" then
                normalized[normalizedName] = true
            end
        end
    end

    return normalized
end

local function TableHasEntries(value)
    return type(value) == "table" and next(value) ~= nil
end

local function DatabaseHasHistoryData(value)
    if type(value) ~= "table" then
        return false
    end

    if type(value.runs) == "table" and #value.runs > 0 then
        return true
    end
    if TableHasEntries(value.playerNotes) or TableHasEntries(value.ownedCharacters) then
        return true
    end
    if type(value.debug) == "table" and type(value.debug.reports) == "table" and #value.debug.reports > 0 then
        return true
    end
    if type(value.nextRunId) == "number" and value.nextRunId > 1 then
        return true
    end

    return false
end

local function ShouldUseLegacyDatabase(current, legacy)
    if not DatabaseHasHistoryData(legacy) then
        return false
    end

    if type(current) ~= "table" or next(current) == nil then
        return true
    end

    local currentRunCount = type(current.runs) == "table" and #current.runs or 0
    local legacyRunCount = type(legacy.runs) == "table" and #legacy.runs or 0
    if currentRunCount == 0 and legacyRunCount > 0 then
        return true
    end

    return not DatabaseHasHistoryData(current)
end

local migrations = {
}

local function RunDatabaseMigrations(owner, db, previousVersion)
    if type(db) ~= "table" then
        return
    end

    previousVersion = tonumber(previousVersion) or 0
    for version = previousVersion + 1, owner.DB_VERSION do
        local migrate = migrations[version]
        if type(migrate) == "function" then
            migrate(owner, db)
        end
    end
end

function MythicTools:InitializeDB()
    local migratedLegacyDB = false
    if ShouldUseLegacyDatabase(SkyMythicHistoryDB, MythicToolsDB) then
        SkyMythicHistoryDB = MythicToolsDB
        migratedLegacyDB = true
    else
        SkyMythicHistoryDB = SkyMythicHistoryDB or {}
    end

    if migratedLegacyDB then
        MythicToolsDB = nil
    end

    self.db = SkyMythicHistoryDB
    local previousDBVersion = tonumber(self.db.version) or 0

    ApplyDefaults(self.db, defaults)
    RunDatabaseMigrations(self, self.db, previousDBVersion)

    self.db.version = self.DB_VERSION
    self.db.settings.maxRuns = self:Clamp(self.db.settings.maxRuns, 50, 5000)
    self.db.settings.completionPopupDelay = self:Clamp(self.db.settings.completionPopupDelay or 0, 0, 10)
    self.db.settings.completionPopupScale = self:Clamp(self.db.settings.completionPopupScale or 1, 0.7, 1.4)
    self.db.settings.completionToastDuration = self:Clamp(self.db.settings.completionToastDuration or 10, 3, 60)
    self.db.settings.completionToastAutoHide = self.db.settings.completionToastAutoHide ~= false
    self.db.settings.completionPopupMode = NormalizeCompletionPopupModeValue(
        self.db.settings.completionPopupMode,
        self.db.settings.showCompletionPopup
    )
    self.db.settings.showCompletionPopup = self.db.settings.completionPopupMode ~= "off"
    if self.db.settings.locale == nil then
        self.db.settings.locale = "auto"
    end
    self.db.settings.tooltipShowScore = nil
    if self.db.settings.historyAlertChat == nil then
        self.db.settings.historyAlertChat = self.db.settings.chatAlerts ~= false
    end
    self.db.settings.chatAlerts = self.db.settings.historyAlertChat ~= false
    if self.NormalizeAlertSettings then
        self:NormalizeAlertSettings()
    end
    if self.db.ui.settingsTab == "Alerts" then
        self.db.ui.settingsTab = "Notifications"
    elseif self.db.ui.settingsTab == "Data" then
        self.db.ui.settingsTab = "General"
    end
    self.db.ui.exportFormat = NormalizeExportFormatValue(self.db.ui.exportFormat)
    if self.db.settings.completionPopupTrigger == "LOOT_CLOSED"
        or (previousDBVersion > 0 and previousDBVersion < 9 and self.db.settings.completionPopupTrigger == "COMPLETED") then
        self.db.settings.completionPopupTrigger = "LOOT_OPENED"
    end
    if self.db.settings.completionPopupTrigger ~= "COMPLETED" and self.db.settings.completionPopupTrigger ~= "LOOT_OPENED" then
        self.db.settings.completionPopupTrigger = "LOOT_OPENED"
    end
    self.db.debug.maxReports = self:Clamp(self.db.debug.maxReports or 20, 5, 50)
    self.db.debug.reports = type(self.db.debug.reports) == "table" and self.db.debug.reports or {}
    self.db.ui.width = self:Clamp(self.db.ui.width, 1280, 1600)
    self.db.ui.height = self:Clamp(self.db.ui.height, 720, 960)
    self.db.ui.filters.season = self.NormalizeSeason and self:NormalizeSeason(self.db.ui.filters.season) or (self.db.ui.filters.season or GetDefaultSeason())
    self.db.ui.playerFilters.season = self.NormalizeSeason and self:NormalizeSeason(self.db.ui.playerFilters.season) or (self.db.ui.playerFilters.season or GetDefaultSeason())
    if self.db.ui.playerFilters.augMode ~= "no_aug" then
        self.db.ui.playerFilters.augMode = "all"
    end
    if self.db.ui.statsFilters.season ~= "all" then
        self.db.ui.statsFilters.season = self.NormalizeSeason and self:NormalizeSeason(self.db.ui.statsFilters.season) or (self.db.ui.statsFilters.season or "all")
    end
    if self.db.ui.statsFilters.character ~= "all" then
        self.db.ui.statsFilters.character = self:NormalizePlayerName(self.db.ui.statsFilters.character) or self:TrimText(self.db.ui.statsFilters.character) or "all"
        if self.db.ui.statsFilters.character == "" then
            self.db.ui.statsFilters.character = "all"
        end
    end
    if self.db.ui.characterFilters.season ~= "all" then
        self.db.ui.characterFilters.season = self.NormalizeSeason and self:NormalizeSeason(self.db.ui.characterFilters.season) or (self.db.ui.characterFilters.season or "all")
    end
    self.db.ownedCharacters = NormalizeOwnedCharactersTable(self, self.db.ownedCharacters)
    self.db.playerTags = nil
    if (self.db.ui.playerFilters.search or "") == "" and (self.db.ui.playerSearch or "") ~= "" then
        self.db.ui.playerFilters.search = self.db.ui.playerSearch
    end
    if self.db.ui.playersView ~= "detail" then
        self.db.ui.playersView = "index"
    end
    if self.db.ui.charactersView ~= "detail" then
        self.db.ui.charactersView = "index"
    end

    self:TrimRunsToLimit()
    self:RebuildPlayerIndex()
end

function MythicTools:TrimRunsToLimit()
    local runs = self.db and self.db.runs
    if type(runs) ~= "table" then
        self.db.runs = {}
        self.runLookup = {}
        return 0
    end

    local removedCount = 0
    local limit = self:Clamp(self.db.settings.maxRuns, 50, 5000)
    while #runs > limit do
        local removed = table.remove(runs, #runs)
        removedCount = removedCount + 1
        if self.runLookup and type(removed) == "table" and removed.runId then
            self.runLookup[removed.runId] = nil
        end
    end

    return removedCount
end

function MythicTools:RebuildRunLookup()
    local lookup = {}
    for _, run in ipairs((self.db and self.db.runs) or {}) do
        if type(run) == "table" and run.runId then
            lookup[run.runId] = run
        end
    end
    self.runLookup = lookup
    return lookup
end

function MythicTools:GetPlayerDPS(run, stat)
    local directDPS = tonumber(stat and stat.damagePerSecond)
    if directDPS ~= nil then
        return directDPS
    end

    local recordedDPS = tonumber(stat and stat.dps)
    if recordedDPS ~= nil then
        return recordedDPS
    end

    local durationSeconds = GetDerivedStatDuration(run, stat, "damageActiveSeconds")
    if durationSeconds <= 0 then
        return 0
    end

    return (tonumber(stat and stat.damage) or 0) / durationSeconds
end

function MythicTools:GetPlayerHPS(run, stat)
    local directHPS = tonumber(stat and stat.healingPerSecond)
    if directHPS ~= nil then
        return directHPS
    end

    local recordedHPS = tonumber(stat and stat.hps)
    if recordedHPS ~= nil then
        return recordedHPS
    end

    local durationSeconds = GetDerivedStatDuration(run, stat, "healingActiveSeconds")
    if durationSeconds <= 0 then
        return 0
    end

    return (tonumber(stat and stat.healing) or 0) / durationSeconds
end

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

    local owner = self:NormalizePlayerName(run.ownerCharacter) or self:TrimText(run.ownerCharacter)
    if owner and owner ~= "" then
        return owner
    end

    for _, fullName in ipairs(run.roster or {}) do
        if self:IsOwnedCharacter(fullName) then
            return fullName
        end
    end

    return run.roster and run.roster[1] or nil
end

function MythicTools:FinalizePlayerIndexEntry(playerEntry)
    if type(playerEntry) ~= "table" then
        return
    end

    FinalizeAggregate(playerEntry)
    for _, dungeonEntry in pairs(playerEntry.dungeons or {}) do
        FinalizeAggregate(dungeonEntry)
    end
    for _, roleEntry in pairs(playerEntry.roleBreakdown or {}) do
        FinalizeAggregate(roleEntry)
    end
    for _, specEntry in pairs(playerEntry.specBreakdown or {}) do
        FinalizeAggregate(specEntry)
    end
end

function MythicTools:AccumulateRunIntoPlayerIndex(index, run)
    if type(index) ~= "table" or type(run) ~= "table" or not run.runId then
        return false
    end

    run.roster = self:NormalizeRunRoster(run)
    run.ownerCharacter = self:InferRunOwnerCharacter(run)
    run.playerStats = type(run.playerStats) == "table" and run.playerStats or {}
    run.mapChallengeModeID = tonumber(run.mapChallengeModeID or run.challengeModeMapID) or 0
    run.mapID = tonumber(run.mapID) or 0
    run._dungeonKey = nil
    run._searchTextLower = nil
    run.level = tonumber(run.level) or 0
    run.timeMS = tonumber(run.timeMS) or 0
    run.keystoneUpgradeLevels = tonumber(run.keystoneUpgradeLevels) or 0
    run.combatTimeSeconds = tonumber(run.combatTimeSeconds) or nil
    run.outOfCombatSeconds = tonumber(run.outOfCombatSeconds) or nil
    run.totalDeaths = tonumber(run.totalDeaths) or 0
    run.deathPenaltySeconds = tonumber(run.deathPenaltySeconds) or 0
    run.endTime = tonumber(run.endTime) or 0
    run.season = self:GetRunSeason(run)
    run.result = self:GetRunResult(run)
    if run.result == "timed" then
        run.onTime = true
    elseif run.result == "overtime" then
        run.onTime = false
    end
    run.keystoneUpgradeLevels = self:GetRunKeystoneUpgradeLevels(run)

    local roster = run.roster
    local touchedPlayers = {}
    local dungeonKey = self:GetRunDungeonKey(run)

    for _, fullName in ipairs(roster) do
        if not self:IsPlaceholderName(fullName) then
        local playerEntry = index[fullName]
        if not playerEntry then
            playerEntry = CreateAggregateEntry(self:GetShortName(fullName))
            playerEntry.fullName = fullName
            playerEntry.shortName = self:GetShortName(fullName)
            playerEntry.lastSeenAt = 0
            playerEntry.lastRunId = nil
            playerEntry.lastResult = nil
            playerEntry.runIds = {}
            playerEntry.dungeons = {}
            playerEntry.dungeonBreakdown = playerEntry.dungeons
            playerEntry.roleBreakdown = {}
            playerEntry.specBreakdown = {}
            playerEntry.classFilename = nil
            playerEntry.specIconID = nil
            playerEntry.specID = nil
            playerEntry.specName = nil
            playerEntry.role = nil
            index[fullName] = playerEntry
        end

        local stat = run.playerStats[fullName]
        local dps = (stat and stat.damage ~= nil) and self:GetPlayerDPS(run, stat) or nil
        local hps = (stat and stat.healing ~= nil) and self:GetPlayerHPS(run, stat) or nil
        AccumulateAggregate(playerEntry, run, dps, hps, stat)
        if stat then
            if stat.classFilename then playerEntry.classFilename = stat.classFilename end
            if stat.specIconID then playerEntry.specIconID = stat.specIconID end
            if stat.specID then playerEntry.specID = stat.specID end
            if stat.specName then playerEntry.specName = stat.specName end
            if stat.role then playerEntry.role = stat.role end
        end

        if playerEntry.lastRunId == nil or (run.endTime or 0) >= (playerEntry.lastSeenAt or 0) then
            playerEntry.lastRunId = run.runId
            playerEntry.lastSeenAt = run.endTime
            playerEntry.lastDungeonName = run.dungeonName
            playerEntry.lastLevel = run.level
            playerEntry.lastOnTime = run.result == "timed"
            playerEntry.lastResult = run.result
        end

        playerEntry.runIds[#playerEntry.runIds + 1] = run.runId

        local dungeonEntry = playerEntry.dungeons[dungeonKey]
        if not dungeonEntry then
            dungeonEntry = CreateAggregateEntry(run.dungeonName or "Unknown")
            dungeonEntry.key = dungeonKey
            dungeonEntry.name = run.dungeonName or "Unknown"
            dungeonEntry.lastSeenAt = 0
            playerEntry.dungeons[dungeonKey] = dungeonEntry
        end
        AccumulateAggregate(dungeonEntry, run, dps, hps, stat)
        dungeonEntry.lastSeenAt = math.max(dungeonEntry.lastSeenAt, run.endTime or 0)

        if stat and stat.role then
            local roleKey = stat.role
            local roleEntry = playerEntry.roleBreakdown[roleKey]
            if not roleEntry then
                roleEntry = CreateAggregateEntry(self:GetRoleLabel(stat.role))
                roleEntry.role = stat.role
                playerEntry.roleBreakdown[roleKey] = roleEntry
            end
            AccumulateAggregate(roleEntry, run, dps, hps, stat)
        end

        if stat and (stat.specID or stat.specName) then
            local specKey = tostring(stat.specID or stat.specName)
            local specEntry = playerEntry.specBreakdown[specKey]
            if not specEntry then
                specEntry = CreateAggregateEntry(stat.specName or ("Spec " .. specKey))
                specEntry.specID = stat.specID
                specEntry.specName = stat.specName or specEntry.label
                specEntry.specIconID = stat.specIconID
                specEntry.role = stat.role
                playerEntry.specBreakdown[specKey] = specEntry
            end
            if stat.specIconID then specEntry.specIconID = stat.specIconID end
            if stat.role then specEntry.role = stat.role end
            AccumulateAggregate(specEntry, run, dps, hps, stat)
        end

        touchedPlayers[#touchedPlayers + 1] = playerEntry
        end
    end

    for _, playerEntry in ipairs(touchedPlayers) do
        self:FinalizePlayerIndexEntry(playerEntry)
    end

    return true
end

function MythicTools:AddRunToPlayerIndex(run)
    if self._rebuildInProgress then
        return false
    end

    self.db.playersIndex = type(self.db.playersIndex) == "table" and self.db.playersIndex or {}
    self.runLookup = self.runLookup or {}
    if type(run) == "table" and run.runId then
        self.runLookup[run.runId] = run
    end

    local ok = self:AccumulateRunIntoPlayerIndex(self.db.playersIndex, run)
    if ok and self.MarkHistoryDataDirty then
        self:MarkHistoryDataDirty("add_run_incremental")
    end
    return ok
end

function MythicTools:QueueRebuildPlayerIndex(reason, delay)
    if self.MarkHistoryDataDirty then
        self:MarkHistoryDataDirty(reason or "queue_rebuild_player_index")
    end

    if self._rebuildIndexQueued then
        return
    end

    self._rebuildIndexQueued = true
    self:Schedule(delay or 0.15, function()
        self._rebuildIndexQueued = false
        self:RebuildPlayerIndex()
    end)
end

function MythicTools:RebuildPlayerIndex()
    if self.MarkHistoryDataDirty then
        self:MarkHistoryDataDirty("rebuild_player_index")
    end

    if self._rebuildInProgress then
        self._rebuildPending = true
        return
    end

    self._rebuildGeneration = (self._rebuildGeneration or 0) + 1
    local rebuildGeneration = self._rebuildGeneration
    local runs = self.db.runs
    local totalRuns = #runs
    local index = {}
    local currentIdx = 1

    self:RebuildRunLookup()

    self._rebuildInProgress = true
    self._rebuildPending = false

    local function processBatch()
        if rebuildGeneration ~= self._rebuildGeneration then
            return
        end

        local BATCH_SIZE = 15
        local TIME_BUDGET_MS = 30
        local deadline = debugprofilestop and (debugprofilestop() + TIME_BUDGET_MS) or nil
        local processed = 0

        while currentIdx <= totalRuns and processed < BATCH_SIZE do
            if deadline and debugprofilestop() >= deadline then
                break
            end

            local run = runs[currentIdx]
            currentIdx = currentIdx + 1
            processed = processed + 1

            if type(run) ~= "table" or not run.runId then
                -- continue to next run
            else
                run.roster = self:NormalizeRunRoster(run)
                run.ownerCharacter = self:InferRunOwnerCharacter(run)
                run.playerStats = type(run.playerStats) == "table" and run.playerStats or {}
                run.mapChallengeModeID = tonumber(run.mapChallengeModeID or run.challengeModeMapID) or 0
                run.mapID = tonumber(run.mapID) or 0
                run._dungeonKey = nil
                run._searchTextLower = nil
                if run.mapChallengeModeID > 0 and C_ChallengeMode and C_ChallengeMode.GetMapUIInfo then
                    local localizedDungeonName = C_ChallengeMode.GetMapUIInfo(run.mapChallengeModeID)
                    if localizedDungeonName and localizedDungeonName ~= "" then
                        run.dungeonName = localizedDungeonName
                    end
                end
                if type(run.dungeonName) ~= "string" or run.dungeonName == "" then
                    run.dungeonName = "Unknown"
                end
                run.level = tonumber(run.level) or 0
                run.timeMS = tonumber(run.timeMS) or 0
                run.keystoneUpgradeLevels = tonumber(run.keystoneUpgradeLevels) or 0
                run.oldOverallDungeonScore = tonumber(run.oldOverallDungeonScore) or run.oldOverallDungeonScore
                run.newOverallDungeonScore = tonumber(run.newOverallDungeonScore) or run.newOverallDungeonScore
                run.combatTimeSeconds = tonumber(run.combatTimeSeconds) or nil
                run.outOfCombatSeconds = tonumber(run.outOfCombatSeconds) or nil
                run.totalDeaths = tonumber(run.totalDeaths) or 0
                run.deathPenaltySeconds = tonumber(run.deathPenaltySeconds) or 0
                run.endTime = tonumber(run.endTime) or 0
                run.season = self:GetRunSeason(run)
                run.result = self:GetRunResult(run)
                if run.result == "timed" then
                    run.onTime = true
                elseif run.result == "overtime" then
                    run.onTime = false
                end
                run.keystoneUpgradeLevels = self:GetRunKeystoneUpgradeLevels(run)

                local stalePlayers = nil
                for playerName, stat in pairs(run.playerStats) do
                    if not self:IsRunPlayerEntry(playerName, stat) then
                        stalePlayers = stalePlayers or {}
                        stalePlayers[#stalePlayers + 1] = playerName
                    elseif type(stat) == "table" then
                        stat.name = playerName
                        stat.shortName = stat.shortName or self:GetShortName(playerName)
                        if stat.damage ~= nil then stat.damage = tonumber(stat.damage) or 0 end
                        if stat.healing ~= nil then stat.healing = tonumber(stat.healing) or 0 end
                        if stat.interrupts ~= nil then stat.interrupts = math.floor((tonumber(stat.interrupts) or 0) + 0.5) end
                        if stat.dispels ~= nil then stat.dispels = math.floor((tonumber(stat.dispels) or 0) + 0.5) end
                        if stat.avoidableDamage ~= nil then stat.avoidableDamage = tonumber(stat.avoidableDamage) or 0 end
                        stat.score = tonumber(stat.score) or nil
                        stat.previousScore = tonumber(stat.previousScore) or nil
                        stat.itemLevel = tonumber(stat.itemLevel) or nil
                        stat.rating = tonumber(stat.rating) or nil
                        if stat.rating then
                            stat.rating = self:Clamp(math.floor(stat.rating + 0.5), 1, 5)
                        end
                        stat.damageActiveSeconds = tonumber(stat.damageActiveSeconds) or nil
                        stat.healingActiveSeconds = tonumber(stat.healingActiveSeconds) or nil
                        stat.dps = tonumber(stat.dps) or nil
                        stat.hps = tonumber(stat.hps) or nil
                        stat.damagePerSecond = tonumber(stat.damagePerSecond) or nil
                        stat.healingPerSecond = tonumber(stat.healingPerSecond) or nil
                        stat.damageDoneBySpells = NormalizeSpellBreakdownList(stat.damageDoneBySpells)
                        stat.healDoneBySpells = NormalizeSpellBreakdownList(stat.healDoneBySpells)
                        stat.interruptsBySpells = NormalizeSpellBreakdownList(stat.interruptsBySpells)
                        stat.dispelsBySpells = NormalizeSpellBreakdownList(stat.dispelsBySpells)
                        stat.avoidableDamageBySpells = NormalizeSpellBreakdownList(stat.avoidableDamageBySpells)
                        stat.role = self:NormalizeRole(stat.role)
                        stat.specID = tonumber(stat.specID) or stat.specID
                        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
                        stat.deaths = tonumber(stat.deaths) or 0
                        stat.loot = type(stat.loot) == "table" and stat.loot or {}
                        local normalizedLoot = {}
                        local seenLoot = {}
                        for _, itemLink in ipairs(stat.loot) do
                            local normalizedLink = NormalizeStoredItemLink(itemLink)
                            if normalizedLink and not seenLoot[normalizedLink] then
                                seenLoot[normalizedLink] = true
                                normalizedLoot[#normalizedLoot + 1] = normalizedLink
                            end
                        end
                        stat.loot = normalizedLoot
                    end
                end

                if self.RefreshRunDerivedStats and RunNeedsDerivedStatRefresh(run) then
                    self:RefreshRunDerivedStats(run)
                end

                if stalePlayers then
                    for _, playerName in ipairs(stalePlayers) do
                        run.playerStats[playerName] = nil
                    end
                end

                local roster = self:NormalizeRunRoster(run)
                local rosterSet = {}
                for _, fullName in ipairs(roster) do
                    rosterSet[fullName] = true
                end

                local removedPlayers = nil
                for playerName in pairs(run.playerStats) do
                    if not rosterSet[playerName] then
                        removedPlayers = removedPlayers or {}
                        removedPlayers[#removedPlayers + 1] = playerName
                    end
                end

                if removedPlayers then
                    for _, playerName in ipairs(removedPlayers) do
                        run.playerStats[playerName] = nil
                    end
                end

                for _, fullName in ipairs(roster) do
                    local playerEntry = index[fullName]
                    if not playerEntry then
                        playerEntry = CreateAggregateEntry(self:GetShortName(fullName))
                        playerEntry.fullName = fullName
                        playerEntry.shortName = self:GetShortName(fullName)
                        playerEntry.lastSeenAt = 0
                        playerEntry.lastRunId = nil
                        playerEntry.lastResult = nil
                        playerEntry.runIds = {}
                        playerEntry.dungeons = {}
                        playerEntry.dungeonBreakdown = playerEntry.dungeons
                        playerEntry.roleBreakdown = {}
                        playerEntry.specBreakdown = {}
                        playerEntry.classFilename = nil
                        playerEntry.specIconID = nil
                        playerEntry.specID = nil
                        playerEntry.specName = nil
                        playerEntry.role = nil
                        index[fullName] = playerEntry
                    end

                    local stat = run.playerStats[fullName]
                    local dps = (stat and stat.damage ~= nil) and self:GetPlayerDPS(run, stat) or nil
                    local hps = (stat and stat.healing ~= nil) and self:GetPlayerHPS(run, stat) or nil
                    AccumulateAggregate(playerEntry, run, dps, hps, stat)
                    if stat then
                        if stat.classFilename then
                            playerEntry.classFilename = stat.classFilename
                        end
                        if stat.specIconID then
                            playerEntry.specIconID = stat.specIconID
                        end
                        if stat.specID then
                            playerEntry.specID = stat.specID
                        end
                        if stat.specName then
                            playerEntry.specName = stat.specName
                        end
                        if stat.role then
                            playerEntry.role = stat.role
                        end
                    end

                    if playerEntry.lastRunId == nil or (run.endTime or 0) >= (playerEntry.lastSeenAt or 0) then
                        playerEntry.lastRunId = run.runId
                        playerEntry.lastSeenAt = run.endTime
                        playerEntry.lastDungeonName = run.dungeonName
                        playerEntry.lastLevel = run.level
                        playerEntry.lastOnTime = run.result == "timed"
                        playerEntry.lastResult = run.result
                    end

                    playerEntry.runIds[#playerEntry.runIds + 1] = run.runId

                    local dungeonKey = self:GetRunDungeonKey(run)
                    local dungeonEntry = playerEntry.dungeons[dungeonKey]
                    if not dungeonEntry then
                        dungeonEntry = CreateAggregateEntry(run.dungeonName or "Unknown")
                        dungeonEntry.key = dungeonKey
                        dungeonEntry.name = run.dungeonName or "Unknown"
                        dungeonEntry.lastSeenAt = 0
                        playerEntry.dungeons[dungeonKey] = dungeonEntry
                    end

                    AccumulateAggregate(dungeonEntry, run, dps, hps, stat)
                    dungeonEntry.lastSeenAt = math.max(dungeonEntry.lastSeenAt, run.endTime or 0)

                    if stat and stat.role then
                        local roleKey = stat.role
                        local roleEntry = playerEntry.roleBreakdown[roleKey]
                        if not roleEntry then
                            roleEntry = CreateAggregateEntry(self:GetRoleLabel(stat.role))
                            roleEntry.role = stat.role
                            playerEntry.roleBreakdown[roleKey] = roleEntry
                        end
                        AccumulateAggregate(roleEntry, run, dps, hps, stat)
                    end

                    if stat and (stat.specID or stat.specName) then
                        local specKey = tostring(stat.specID or stat.specName)
                        local specEntry = playerEntry.specBreakdown[specKey]
                        if not specEntry then
                            specEntry = CreateAggregateEntry(stat.specName or ("Spec " .. specKey))
                            specEntry.specID = stat.specID
                            specEntry.specName = stat.specName or specEntry.label
                            specEntry.specIconID = stat.specIconID
                            specEntry.role = stat.role
                            playerEntry.specBreakdown[specKey] = specEntry
                        end
                        if stat.specIconID then
                            specEntry.specIconID = stat.specIconID
                        end
                        if stat.role then
                            specEntry.role = stat.role
                        end
                        AccumulateAggregate(specEntry, run, dps, hps, stat)
                    end
                end
            end
        end

        if currentIdx <= totalRuns then
            self:Schedule(0.01, processBatch)
        else
            if rebuildGeneration ~= self._rebuildGeneration then
                return
            end
            for _, playerEntry in pairs(index) do
                FinalizeAggregate(playerEntry)
                for _, dungeonEntry in pairs(playerEntry.dungeons or {}) do
                    FinalizeAggregate(dungeonEntry)
                end
                for _, roleEntry in pairs(playerEntry.roleBreakdown or {}) do
                    FinalizeAggregate(roleEntry)
                end
                for _, specEntry in pairs(playerEntry.specBreakdown or {}) do
                    FinalizeAggregate(specEntry)
                end
            end

            self.db.playersIndex = index
            self._rebuildInProgress = false
            if self.MarkHistoryDataDirty then
                self:MarkHistoryDataDirty("player_index_ready")
            end

            if self._rebuildPending then
                self._rebuildPending = false
                self:RebuildPlayerIndex()
            elseif self.QueueRefreshAllViews then
                self:QueueRefreshAllViews("player_index_ready")
            end
        end
    end

    processBatch()
end

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

    run.runId = tonumber(run.runId) or self.db.nextRunId or 1
    self.db.nextRunId = math.max((self.db.nextRunId or 1), run.runId + 1)
    run.roster = self:NormalizeRunRoster(run)
    run.ownerCharacter = self:InferRunOwnerCharacter(run)
    run.mapChallengeModeID = tonumber(run.mapChallengeModeID or run.challengeModeMapID) or 0
    run.mapID = tonumber(run.mapID) or 0
    if (type(run.dungeonName) ~= "string" or run.dungeonName == "") and run.mapChallengeModeID > 0 and C_ChallengeMode and C_ChallengeMode.GetMapUIInfo then
        run.dungeonName = C_ChallengeMode.GetMapUIInfo(run.mapChallengeModeID)
    end
    if type(run.dungeonName) ~= "string" or run.dungeonName == "" then
        run.dungeonName = "Unknown"
    end
    run.season = self:GetRunSeason(run)
    run.result = self:GetRunResult(run)
    run.keystoneUpgradeLevels = tonumber(run.keystoneUpgradeLevels) or 0
    run.oldOverallDungeonScore = tonumber(run.oldOverallDungeonScore) or run.oldOverallDungeonScore
    run.newOverallDungeonScore = tonumber(run.newOverallDungeonScore) or run.newOverallDungeonScore
    run.combatTimeSeconds = tonumber(run.combatTimeSeconds) or nil
    run.outOfCombatSeconds = tonumber(run.outOfCombatSeconds) or nil
    if self.HydrateRunCombatDuration and run.combatTimeSeconds == nil then
        self:HydrateRunCombatDuration(run)
    end
    if self.RefreshRunDerivedStats and RunNeedsDerivedStatRefresh(run) then
        self:RefreshRunDerivedStats(run)
    end
    if run.result == "timed" then
        run.onTime = true
    elseif run.result == "overtime" then
        run.onTime = false
    end
    run.keystoneUpgradeLevels = self:GetRunKeystoneUpgradeLevels(run)

    run._dungeonKey = nil
    run._searchTextLower = nil

    table.insert(self.db.runs, 1, run)
    self.runLookup = self.runLookup or {}
    self.runLookup[run.runId] = run
    local trimmedRuns = self:TrimRunsToLimit()
    if trimmedRuns > 0 or not self:AddRunToPlayerIndex(run) then
        self:QueueRebuildPlayerIndex("add_completed_run")
    end

    if not self.db.ui.selectedRunId then
        self.db.ui.selectedRunId = run.runId
    end
end

function MythicTools:GetRunById(runId)
    if not runId then
        return nil
    end

    if self.runLookup and self.runLookup[runId] then
        return self.runLookup[runId]
    end

    for _, run in ipairs(self.db.runs) do
        if run.runId == runId then
            return run
        end
    end

    return nil
end

function MythicTools:RemoveRunById(runId)
    runId = tonumber(runId)
    if not runId then
        return false
    end

    for index, run in ipairs(self.db.runs or {}) do
        if run.runId == runId then
            table.remove(self.db.runs, index)
            if self.runLookup then
                self.runLookup[runId] = nil
            end
            self:RebuildPlayerIndex()

            if self.db.ui.selectedRunId == runId then
                self.db.ui.selectedRunId = self.db.runs[1] and self.db.runs[1].runId or nil
            end

            return true
        end
    end

    return false
end

function MythicTools:GetRunsForPlayer(fullName)
    local playerEntry = self.db.playersIndex[fullName]
    if not playerEntry then
        return {}
    end

    local runs = {}
    for _, runId in ipairs(playerEntry.runIds) do
        local run = self:GetRunById(runId)
        if run then
            runs[#runs + 1] = run
        end
    end

    return runs
end

function MythicTools:GetPlayerCount()
    local count = 0
    for _ in pairs(self.db.playersIndex) do
        count = count + 1
    end
    return count
end

function MythicTools:GetPlayerNote(fullName)
    if not (self.db and self.db.playerNotes) then
        return ""
    end

    fullName = self:NormalizePlayerName(fullName) or self:TrimText(fullName)
    if not fullName or fullName == "" then
        return ""
    end

    local note = self.db.playerNotes[fullName]
    if type(note) ~= "string" then
        return ""
    end

    return note
end

function MythicTools:RegisterOwnedCharacter(fullName)
    if not (self.db and self.db.ownedCharacters) then
        return nil
    end

    fullName = self:NormalizePlayerName(fullName) or self:TrimText(fullName)
    if not fullName or fullName == "" then
        return nil
    end

    self.db.ownedCharacters[fullName] = true
    return fullName
end

function MythicTools:IsOwnedCharacter(fullName)
    if not (self.db and self.db.ownedCharacters) then
        return false
    end

    fullName = self:NormalizePlayerName(fullName) or self:TrimText(fullName)
    return fullName ~= nil and self.db.ownedCharacters[fullName] == true
end

function MythicTools:RunHasOwnedCharacter(run)
    for _, fullName in ipairs(run and run.roster or {}) do
        if self:IsOwnedCharacter(fullName) then
            return true
        end
    end

    return false
end

function MythicTools:RunHasCharacter(run, fullName)
    fullName = self:NormalizePlayerName(fullName) or self:TrimText(fullName)
    if not fullName or fullName == "" then
        return false
    end

    for _, rosterName in ipairs(run and run.roster or {}) do
        if rosterName == fullName then
            return true
        end
    end

    return false
end

function MythicTools:GetOwnedCharacters()
    local characters = {}

    for fullName, enabled in pairs(self.db and self.db.ownedCharacters or {}) do
        if enabled then
            characters[#characters + 1] = fullName
        end
    end

    table.sort(characters, function(left, right)
        local leftShort = self:GetShortName(left)
        local rightShort = self:GetShortName(right)
        if leftShort ~= rightShort then
            return leftShort < rightShort
        end
        return left < right
    end)

    return characters
end

function MythicTools:SetPlayerNote(fullName, noteText)
    if not (self.db and self.db.playerNotes) then
        return
    end

    fullName = self:NormalizePlayerName(fullName) or self:TrimText(fullName)
    if not fullName or fullName == "" then
        return
    end

    noteText = self:TrimText(noteText)
    if noteText == "" then
        self.db.playerNotes[fullName] = nil
    else
        self.db.playerNotes[fullName] = noteText
    end
end

function MythicTools:SetRunPlayerRating(runOrRunId, fullName, rating)
    local run = type(runOrRunId) == "table" and runOrRunId or self:GetRunById(runOrRunId)
    if not (run and run.playerStats) then
        return false
    end

    fullName = self:NormalizePlayerName(fullName) or self:TrimText(fullName)
    if not fullName or fullName == "" then
        return false
    end

    local stat = run.playerStats[fullName]
    if type(stat) ~= "table" then
        return false
    end

    rating = math.floor((tonumber(rating) or 0) + 0.5)
    if rating == 0 then
        stat.rating = nil
        self:RebuildPlayerIndex()
        return true
    end

    if rating < 1 or rating > 5 then
        return false
    end

    stat.rating = rating
    self:RebuildPlayerIndex()
    return true
end

function MythicTools:HasPlayerHistory(fullName)
    if not (self.db and self.db.playersIndex) then
        return false
    end

    fullName = self:NormalizePlayerName(fullName) or self:TrimText(fullName)
    return fullName ~= nil and self.db.playersIndex[fullName] ~= nil
end

function MythicTools:SetChatAlertsEnabled(enabled)
    self.db.settings.historyAlertChat = enabled and true or false
    self.db.settings.chatAlerts = self.db.settings.historyAlertChat
end

function MythicTools:SetMinimapButtonEnabled(enabled)
    self.db.settings.showMinimapButton = enabled and true or false
    if self.UpdateMinimapButton then
        self:UpdateMinimapButton()
    end
end

function MythicTools:SetMaxRuns(limit)
    self.db.settings.maxRuns = self:Clamp(limit, 50, 5000)
    self:TrimRunsToLimit()
    self:RebuildPlayerIndex()
end

function MythicTools:ClearHistory()
    self._rebuildGeneration = (self._rebuildGeneration or 0) + 1
    self._rebuildInProgress = false
    self._rebuildPending = false
    self._rebuildIndexQueued = false
    if self.MarkHistoryDataDirty then
        self:MarkHistoryDataDirty("clear_history")
    end

    self.db.runs = {}
    self.db.playersIndex = {}
    self.db.nextRunId = 1
    self.db.ui.selectedRunId = nil
    self.db.ui.selectedPlayer = nil
    self.db.ui.playersView = "index"
    self.db.ui.playerSearch = ""
    self.db.ui.playerFilters = {
        search = "",
        season = GetDefaultSeason(),
        dungeon = "all",
    }
    self.db.ui.filters = {
        search = "",
        player = "",
        dungeon = "all",
        season = GetDefaultSeason(),
        date = "",
        status = "all",
        ownedCharacters = "all",
    }
    self.runLookup = {}
    if self.runtime then
        self.runtime.pendingCompletionPopup = nil
    end
    if self.HideCompletionPopup then
        self:HideCompletionPopup()
    end
    self:RefreshAllViews()
end
