---@diagnostic disable:duplicate-set-field

local MitCrit = {}
MitCrit.color = "FFFFBBBB"
MitCrit.colorName = (">\124c%s%s\124r<"):format(MitCrit.color, "MitCrit")
MitCrit.spellSchoolsClassic = {
    [0] = "FFFFFFFF",  -- fallback
    [1] = "FFFFFF00",  -- Physical
    [2] = "FFFFE680",  -- Holy
    [4] = "FFFF8000",  -- Fire
    [8] = "FF4DFF4D",  -- Nature
    [16] = "FF80FFFF", -- Frost
    [32] = "FF8080FF", -- Shadow
    [64] = "FFFF80FF", -- Arcane
}
MitCrit.spellSchools = MitCrit.spellSchoolsClassic
MitCrit.mediaDir = "Interface\\AddOns\\MitCrit\\Media\\"
MitCrit.fontsDir = "Interface\\AddOns\\MitCrit\\Fonts\\"
MitCrit.allTimeCritBladeTexture = MitCrit.mediaDir .. "all-time-crit-cropped.tga"
MitCrit.allTimeCritBladeSourceWidth = 637
MitCrit.allTimeCritBladeSourceHeight = 535
MitCrit.allTimeCritBladeScale = 0.5
MitCrit.allTimeCritBladeOffset = -50
MitCrit.rarityColors = {
    common = "FFFFFFFF",
    uncommon = "FF1EFF00",
    rare = "FF0070DD",
    epic = "FFA335EE",
    legendary = "FFFF8000",
}
MitCrit.baseSoundOptions = {
    regular = {
        values = {
            [5888] = "Dire Troll",
            [1117] = "Succubus",
            [4316] = "Spider Tank",
            [8473] = "Level Up",
            [1147] = "Gun",
            [157] = "Axe",
            [2941] = "Human Male",
        },
        sorting = {
            5888, 1117, 4316, 8473, 1147, 157, 2941
        },
    },
    critical = {
        values = {
            [5890] = "Dire Troll",
            [7096] = "Succubus",
            [4317] = "Spider Tank",
            [1440] = "Level Up",
            [1148] = "Gun",
            [158] = "Axe",
            [186] = "Human Male",
        },
        sorting = {
            5890, 7096, 4317, 1440, 1148, 158, 186
        },
    },
}
MitCrit.baseFontOptions = {
    values = {
        ["Fonts\\FRIZQT__.TTF"] = "Friz Quadrata",
    },
    sorting = {
        "Fonts\\FRIZQT__.TTF",
    },
}
MitCrit.bundledFonts = {
    { name = "MitCrit Bangers",           file = "Bangers.ttf" },
    { name = "MitCrit Caslon Antique",    file = "Caslon Antique.ttf" },
    { name = "MitCrit Exocet Heavy",      file = "Exocet Heavy.ttf" },
    { name = "MitCrit Exocet Light",      file = "Exocet Light.ttf" },
    { name = "MitCrit Great Vibes",       file = "Great Vibes.ttf" },
    { name = "MitCrit If",                file = "If.ttf" },
    { name = "MitCrit League Gothic",     file = "League Gothic.otf" },
    { name = "MitCrit Marlboro",          file = "Marlboro.ttf" },
    { name = "MitCrit Marsh Stencil",     file = "Marsh Stencil.otf" },
    { name = "MitCrit Messy Handwritten", file = "Messy Handwritten.ttf" },
    { name = "MitCrit Metal Lord",        file = "Metal Lord.otf" },
    { name = "MitCrit Norse",             file = "Norse.otf" },
    { name = "MitCrit Open Sans",         file = "Open Sans.ttf" },
    { name = "MitCrit Oswald",            file = "Oswald.ttf" },
    { name = "MitCrit Varsity Team",      file = "Varsity Team.otf" },
}
MitCrit.nearRecordCooldown = 6
MitCrit.streakWindowSec = 5
MitCrit.streakPopupCooldown = 2
MitCrit.streakSwingMinIntervalRatio = 0.45
MitCrit.streakSwingMinIntervalFloor = 0.35
MitCrit.streakSwingMinIntervalCeil = 1.2
MitCrit.streakPopupCooldownSwing = 6
MitCrit.streakMilestonesAbility = { [2] = true, [4] = true, [6] = true, [9] = true, [12] = true }
MitCrit.streakMilestonesSwing = { [3] = true, [5] = true, [8] = true, [12] = true }
MitCrit.bossWarmupSamples = 2
MitCrit.bossMinDeltaPct = 0.03
MitCrit.bossMinDeltaFlat = 500
MitCrit.bossPopupCooldownSec = 6

function MitCrit:ResetRuntimeProgress()
    MitCrit.runtime = {
        nearRecord = {},
        bossBest = {},
        bossWarmup = {},
        bossPopupAt = {},
        activeEncounter = nil,
        streak = {
            lastPopupAt = 0,
            ability = { count = 0, total = 0, lastCritAt = 0, lastShownMilestone = 0, lastPopupAt = 0 },
            swing = { count = 0, total = 0, lastCritAt = 0, lastShownMilestone = 0, lastPopupAt = 0 },
        },
    }
end

function MitCrit:EnsureSpellData(spellName, spellSchool)
    MitCritData[spellName] = MitCritData[spellName] or {
        crit = 0,
        hit = 0,
        critTarget = "",
        hitTarget = "",
        spellSchool = spellSchool,
        casts = 0,
        crits = 0,
        hits = 0,
    }
    local data = MitCritData[spellName]
    if not data.spellSchool or data.spellSchool == 0 then
        data.spellSchool = spellSchool
    end
    return data
end

function MitCrit:ColorByRarity(rarity, text)
    local color = MitCrit.rarityColors[rarity] or MitCrit.rarityColors.common
    return ("\124c%s%s\124r"):format(color, text or "")
end

function MitCrit:BuildAllTimeBestText(spellName, schoolColor, amount, critical)
    local amountText = MitCrit:FormatNumber(amount)
    if critical then
        return string.format("%s \124cffff3636critical\124r \124c%s%s\124r: \124c%s%s\124r!",
            MitCrit:ColorByRarity("legendary", "ALL-TIME BEST"), schoolColor, spellName, schoolColor, amountText)
    end
    return string.format("%s \124c%s%s\124r: \124c%s%s\124r!",
        MitCrit:ColorByRarity("legendary", "ALL-TIME BEST"), schoolColor, spellName, schoolColor, amountText)
end

function MitCrit:BuildBossBestText(encounterName, spellName, schoolColor, amount, critical)
    local kind = critical and "critical" or "hit"
    local encounterTag = MitCrit:ColorByRarity("epic", ("%s BEST"):format(encounterName or "Encounter"))
    return ("%s \124c%s%s\124r %s: \124c%s%s\124r!"):format(
        encounterTag, schoolColor, spellName, kind, schoolColor, MitCrit:FormatNumber(amount))
end

function MitCrit:BuildNearRecordText(spellName, schoolColor, amount, critical, pct, band)
    local typeLabel = critical and "crit" or "hit"
    if band == "near95" then
        return ("%s \124c%s%s\124r %s reached \124c%s%.1f%%\124r of your all-time best (\124c%s%s\124r)."):format(
            MitCrit:ColorByRarity("rare", "ON THE EDGE"),
            schoolColor, spellName, typeLabel, schoolColor, pct, schoolColor, MitCrit:FormatNumber(amount))
    end
    return ("%s \124c%s%s\124r %s reached \124c%s%.1f%%\124r of your record (\124c%s%s\124r)."):format(
        MitCrit:ColorByRarity("common", "CLOSE CALL"),
        schoolColor, spellName, typeLabel, schoolColor, pct, schoolColor, MitCrit:FormatNumber(amount))
end

function MitCrit:BuildStreakText(spellName, schoolColor, amount, streak, total)
    return ("%s Big crit chain with \124c%s%s\124r. Total: \124c%s%s\124r (Last: \124c%s%s\124r)."):format(
        MitCrit:ColorByRarity("uncommon", ("STREAK x%d"):format(streak)),
        schoolColor, spellName,
        schoolColor, MitCrit:FormatNumber(total or amount),
        schoolColor, MitCrit:FormatNumber(amount))
end

function MitCrit:MaybeCelebrateNearRecord(spellName, amount, recordType, reference, critical, schoolColor)
    if not reference or reference <= 0 or not amount then return end
    if amount >= reference or amount < (reference * 0.90) then return end

    local pct = (amount / reference) * 100
    local band = pct >= 95 and "near95" or "near90"
    local now = GetTime and GetTime() or 0
    MitCrit.runtime = MitCrit.runtime or { nearRecord = {} }
    MitCrit.runtime.nearRecord = MitCrit.runtime.nearRecord or {}
    local key = ("%s:%s:%s"):format(spellName, recordType, band)
    local state = MitCrit.runtime.nearRecord[key]
    if state and now - state < MitCrit.nearRecordCooldown then
        return
    end
    MitCrit.runtime.nearRecord[key] = now

    local message = MitCrit:BuildNearRecordText(spellName, schoolColor, amount, critical, pct, band)
    MitCrit:PlayRecordImmediate(message, critical, band, false)
end

function MitCrit:GetSwingStreakMinIntervalSec()
    local mainSpeed = UnitAttackSpeed and select(1, UnitAttackSpeed("player")) or nil
    local speed = tonumber(mainSpeed) or 0
    if speed <= 0 then
        return 0.6
    end
    local raw = speed * MitCrit.streakSwingMinIntervalRatio
    if raw < MitCrit.streakSwingMinIntervalFloor then
        return MitCrit.streakSwingMinIntervalFloor
    end
    if raw > MitCrit.streakSwingMinIntervalCeil then
        return MitCrit.streakSwingMinIntervalCeil
    end
    return raw
end

function MitCrit:UpdateBigCritStreak(spellName, amount, critical, schoolColor, suppressMessage)
    if not critical then
        return
    end

    MitCrit.runtime = MitCrit.runtime or {}
    MitCrit.runtime.streak = MitCrit.runtime.streak or {
        lastPopupAt = 0,
        ability = { count = 0, total = 0, lastCritAt = 0, lastShownMilestone = 0, lastPopupAt = 0 },
        swing = { count = 0, total = 0, lastCritAt = 0, lastShownMilestone = 0, lastPopupAt = 0 },
    }

    local channelName = (spellName == "Swing") and "swing" or "ability"
    local channel = MitCrit.runtime.streak[channelName]
    local now = GetTime and GetTime() or 0
    local swingMinInterval = MitCrit:GetSwingStreakMinIntervalSec()
    if channelName == "swing" and channel.lastCritAt and channel.lastCritAt > 0 and
        (now - channel.lastCritAt) < swingMinInterval then
        return
    end
    local withinWindow = channel.lastCritAt and (now - channel.lastCritAt) <= MitCrit.streakWindowSec
    if channel.count > 0 and withinWindow then
        channel.count = channel.count + 1
        channel.total = (channel.total or 0) + amount
    else
        channel.count = 1
        channel.total = amount
        channel.lastShownMilestone = 0
    end
    channel.lastCritAt = now

    if suppressMessage then
        return
    end

    local milestones = (channelName == "swing") and MitCrit.streakMilestonesSwing or MitCrit.streakMilestonesAbility
    if not milestones[channel.count] then
        return
    end
    if channel.count <= (channel.lastShownMilestone or 0) then
        return
    end
    local lastPopupAt = MitCrit.runtime.streak.lastPopupAt or 0
    if now - lastPopupAt < MitCrit.streakPopupCooldown then
        return
    end
    local channelCooldown = (channelName == "swing") and MitCrit.streakPopupCooldownSwing or MitCrit.streakPopupCooldown
    if now - (channel.lastPopupAt or 0) < channelCooldown then
        return
    end

    channel.lastShownMilestone = channel.count
    MitCrit.runtime.streak.lastPopupAt = now
    channel.lastPopupAt = now
    local text = MitCrit:BuildStreakText(spellName, schoolColor, amount, channel.count, channel.total)
    MitCrit:PlayRecordImmediate(text, true, "streak", true)
end

function MitCrit:GetMediaManifest()
    if type(_G.MitCritMediaManifest) == "table" then
        return _G.MitCritMediaManifest
    end
end

function MitCrit:NormalizeMediaPath(entry)
    if not entry or entry == "" then return nil end
    local trimmed = entry:gsub("^%s+", ""):gsub("%s+$", "")
    if trimmed == "" then return nil end
    if trimmed:find("[\\/]") or trimmed:lower():find("^interface") then
        return trimmed
    end
    return MitCrit.mediaDir .. trimmed
end

function MitCrit:PrettySoundLabel(entry)
    if not entry then return "Custom sound" end
    local name = entry:gsub("^.*[\\/]", ""):gsub("%.ogg$", "")
    name = name:gsub("[_%-]+", " "):gsub("%s+", " ")
    return name
end

function MitCrit:GetSharedMedia()
    if MitCrit.sharedMedia ~= nil then
        return MitCrit.sharedMedia
    end
    local lsm = LibStub and LibStub("LibSharedMedia-3.0", true) or nil
    MitCrit.sharedMedia = lsm
    return lsm
end

function MitCrit:AddSharedMediaSounds(values, sorting)
    local lsm = MitCrit:GetSharedMedia()
    if not lsm then return end

    local list = lsm:List("sound")
    table.sort(list)
    for _, name in ipairs(list) do
        local path = lsm:Fetch("sound", name)
        if path and not values[path] then
            values[path] = ("LSM: %s"):format(name)
            table.insert(sorting, path)
        end
    end
end

function MitCrit:GetSoundOptions(kind)
    local base = MitCrit.baseSoundOptions[kind] or { values = {}, sorting = {} }
    local values = {}
    local sorting = {}
    for key, label in pairs(base.values) do
        values[key] = label
    end
    for i = 1, #base.sorting do
        sorting[i] = base.sorting[i]
    end

    local manifest = MitCrit:GetMediaManifest()
    if manifest then
        for _, entry in ipairs(manifest) do
            if type(entry) == "string" and entry:lower():match("%.ogg$") then
                local path = MitCrit:NormalizeMediaPath(entry)
                if path and not values[path] then
                    values[path] = MitCrit:PrettySoundLabel(entry)
                    table.insert(sorting, path)
                end
            end
        end
    end

    MitCrit:AddSharedMediaSounds(values, sorting)

    return values, sorting
end

function MitCrit:GetSoundValues(kind)
    local values = MitCrit:GetSoundOptions(kind)
    return values
end

function MitCrit:GetSoundSorting(kind)
    local _, sorting = MitCrit:GetSoundOptions(kind)
    return sorting
end

-- Create options via AceConfig
local console = LibStub("AceConsole-3.0")
local config = LibStub("AceConfig-3.0")
local dialog = LibStub("AceConfigDialog-3.0")
local aceGui = LibStub and LibStub("AceGUI-3.0", true) or nil

MitCrit.dropdownMaxHeight = 260

function MitCrit:HookDropdownMaxHeight()
    if not aceGui or self.dropdownHooked then return end
    self.dropdownHooked = true
    local originalCreate = aceGui.Create
    function aceGui:Create(widgetType)
        local widget = originalCreate(self, widgetType)
        if widgetType == "Dropdown" and MitCrit._optionsOpening and widget and widget.pullout then
            widget.pullout:SetMaxHeight(MitCrit.dropdownMaxHeight)
        end
        return widget
    end

    if dialog and dialog.Open then
        hooksecurefunc(dialog, "Open", function(_, appName)
            if appName == "MitCrit" then
                MitCrit._optionsOpening = true
                if C_Timer and C_Timer.After then
                    C_Timer.After(0, function() MitCrit._optionsOpening = nil end)
                else
                    MitCrit._optionsOpening = nil
                end
            end
        end)
    end
end

local options = {
    name = "MitCrit",
    handler = MitCrit,
    type = "group",
    args = {
        fontFace = {
            name = "Font",
            desc = "Pick the font for the record display.",
            type = "select",
            width = 1.5,
            values = function()
                local values = MitCrit:GetFontOptions()
                return values
            end,
            sorting = function()
                local _, sorting = MitCrit:GetFontOptions()
                return sorting
            end,
            get = function()
                return MitCritSettings.savedVars and MitCritSettings.savedVars.fontFace or "Fonts\\FRIZQT__.TTF"
            end,
            set = function(info, value)
                MitCritSettings.savedVars.fontFace = value
                MitCrit:ApplyFontStyle()
            end,
        },
        fontSize = {
            name = "Font Size",
            desc = "Set the font size for the record display.",
            type = "range",
            width = 1,
            min = 8,
            max = 32,
            step = 2,
            get = function()
                return MitCritSettings.savedVars and MitCritSettings.savedVars.fontSize or 16
            end,
            set = function(info, value)
                MitCrit:UpdateFontSize(value)
            end,
        },
        fontSample = {
            name = "Sample Text",
            type = "description",
            dialogControl = "MitCritFontSample",
            width = 1,
        },
        textStyle = {
            name = "Text Style",
            type = "group",
            inline = true,
            args = {
                fontOutline = {
                    name = "Outline",
                    desc = "Toggle a normal outline for the record text.",
                    type = "toggle",
                    get = function()
                        return MitCritSettings.savedVars and MitCritSettings.savedVars.fontOutline
                    end,
                    set = function(info, value)
                        MitCritSettings.savedVars.fontOutline = value
                        MitCrit:ApplyFontStyle()
                    end,
                },
                fontShadow = {
                    name = "Shadow",
                    desc = "Toggle a drop shadow for the record text.",
                    type = "toggle",
                    get = function()
                        return MitCritSettings.savedVars and MitCritSettings.savedVars.fontShadow
                    end,
                    set = function(info, value)
                        MitCritSettings.savedVars.fontShadow = value
                        MitCrit:ApplyFontStyle()
                    end,
                },
            },
        },
        soundEffect = {
            name = "Sound Effect",
            desc = "Set the sound effect for records.",
            type = "group",
            args = {
                regular = {
                    name = "Regular",
                    desc = "Set the sound effect for regular records.",
                    type = "select",
                    values = function()
                        return MitCrit:GetSoundValues("regular")
                    end,
                    sorting = function()
                        return MitCrit:GetSoundSorting("regular")
                    end,
                    get = function()
                        return MitCritSettings.savedVars.soundEffect and MitCritSettings.savedVars.soundEffect.regular or
                            5888
                    end,
                    set = function(info, value)
                        MitCritSettings.savedVars.soundEffect.regular = value
                        MitCrit:PlayRecordSound(false)
                    end,
                },
                critical = {
                    name = "Critical",
                    desc = "Set the sound effect for critical records.",
                    type = "select",
                    values = function()
                        return MitCrit:GetSoundValues("critical")
                    end,
                    sorting = function()
                        return MitCrit:GetSoundSorting("critical")
                    end,
                    get = function()
                        return MitCritSettings.savedVars.soundEffect.critical
                    end,
                    set = function(info, value)
                        MitCritSettings.savedVars.soundEffect.critical = value
                        MitCrit:PlayRecordSound(true)
                    end,
                },
            }
        },
        frameDuration = {
            name = "Pop-Up Duration",
            desc = "Set how long record pop-ups stay visible.",
            type = "range",
            width = 1.5,
            min = 1,
            max = 5,
            step = 1,
            get = function()
                return MitCritSettings.savedVars and MitCritSettings.savedVars.duration or 3
            end,
            set = function(info, value)
                MitCrit:UpdateDuration(value)
            end,
        },
    },
}
function MitCrit:RegisterFontSampleWidget()
    if self.fontSampleWidgetRegistered then return end
    if not aceGui then return end
    self.fontSampleWidgetRegistered = true

    local widgetType = "MitCritFontSample"
    if aceGui.GetWidgetVersion and aceGui:GetWidgetVersion(widgetType) then
        return
    end

    local function Constructor()
        local frame = CreateFrame("Frame", nil, UIParent)
        frame:SetHeight(25)

        local label = frame:CreateFontString(nil, "OVERLAY")
        label:SetPoint("LEFT", frame, "LEFT", 45, 0)
        label:SetJustifyH("LEFT")
        label:SetFont("Fonts\\FRIZQT__.TTF", 12)

        local widget = {
            frame = frame,
            label = label,
            type = widgetType,
        }

        function widget:SetText(text)
            if not self.label:GetFont() then
                self.label:SetFont("Fonts\\FRIZQT__.TTF", 12)
            end
            self.label:SetText(text or "")
        end

        function widget:SetFontObject(fontObject)
            if fontObject and self.label then
                self.label:SetFontObject(fontObject)
            end
        end

        function widget:SetLabel(text)
            self:SetText(text)
        end

        function widget:OnAcquire()
            self:SetText("Sample Text")
            MitCrit.fontSampleLabel = self.label
            MitCrit:ApplyFontStyle()
        end

        function widget:OnRelease()
            self.label:SetText("")
        end

        function widget:SetDisabled()
            -- No-op: description widget has no disabled state
        end

        return aceGui:RegisterAsWidget(widget)
    end

    aceGui:RegisterWidgetType(widgetType, Constructor, 1)
end

MitCrit:RegisterFontSampleWidget()
MitCrit:HookDropdownMaxHeight()
config:RegisterOptionsTable("MitCrit", options, "mcfont")
MitCrit.optionsFrame = dialog:AddToBlizOptions("MitCrit", "MitCrit")

local eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("ADDON_LOADED")
eventFrame:RegisterEvent("PLAYER_LOGIN")
eventFrame:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
eventFrame:RegisterEvent("ENCOUNTER_START")
eventFrame:RegisterEvent("ENCOUNTER_END")
eventFrame:SetScript("OnEvent", function(self, event, ...)
    if event == "ADDON_LOADED" and ... == "MitCrit" then
        MitCrit:Initialize()
    elseif event == "PLAYER_LOGIN" then
        -- Ensure our hook is last after other addons finish loading.
        MitCrit:SetupTabCompletion(true)
    elseif event == "ENCOUNTER_START" then
        MitCrit:OnEncounterStart(...)
    elseif event == "ENCOUNTER_END" then
        MitCrit:OnEncounterEnd(...)
    end
    if event == "COMBAT_LOG_EVENT_UNFILTERED" then
        MitCrit:OnCombatLogEvent()
    end
end)

function MitCrit:OnEncounterStart(encounterID, encounterName, difficultyID, groupSize)
    MitCrit.runtime = MitCrit.runtime or {}
    MitCrit.runtime.bossBest = {}
    MitCrit.runtime.bossWarmup = {}
    MitCrit.runtime.bossPopupAt = {}
    MitCrit.runtime.activeEncounter = {
        id = encounterID,
        name = encounterName or "Encounter",
        difficultyID = difficultyID,
        groupSize = groupSize,
    }
end

function MitCrit:OnEncounterEnd(encounterID, encounterName, difficultyID, groupSize, success)
    MitCrit.runtime = MitCrit.runtime or {}
    MitCrit.runtime.activeEncounter = nil
end

function MitCrit:Initialize(userEnforced)
    MitCritData = MitCritData or {}
    MitCritSettings = MitCritSettings or {}
    MitCritSettings.migrations = MitCritSettings.migrations or {}
    MitCritSettings.defaults = MitCritSettings.defaults or {
        fontFace = MitCrit.fontsDir .. "Oswald.ttf",
        fontSize = 20,
        duration = 3,
        fontOutline = false,
        fontShadow = true,
        soundEffect = {
            regular = "Interface\\AddOns\\MitCrit\\Media\\A.ogg",
            critical = "Interface\\AddOns\\MitCrit\\Media\\Roblox Death Sound.ogg",
        },
    }
    MitCritSettings.savedVars = MitCritSettings.savedVars or {
        fontFace = MitCritSettings.defaults.fontFace,
        fontSize = MitCritSettings.defaults.fontSize,
        duration = MitCritSettings.defaults.duration,
        fontOutline = MitCritSettings.defaults.fontOutline,
        fontShadow = MitCritSettings.defaults.fontShadow,
        soundEffect = {
            regular = MitCritSettings.defaults.soundEffect.regular,
            critical = MitCritSettings.defaults.soundEffect.critical,
        },
    }
    MitCritSettings.savedVars.fontShadow = MitCritSettings.savedVars.fontShadow ~= false
    if MitCritSettings.savedVars.fontFace and MitCritSettings.savedVars.fontFace:lower():match("^fonts\\") then
        local filename = MitCritSettings.savedVars.fontFace:match("[^\\]+$")
        if filename then
            MitCritSettings.savedVars.fontFace = MitCrit.fontsDir .. filename
        end
    end
    MitCrit:RegisterBundledFonts()
    MitCrit:ResetRuntimeProgress()
    MitCritData.GUID = MitCritData.GUID or UnitGUID("player")
    MitCrit:RunMigrations()
    if userEnforced then return end
    MitCrit:SetupTabCompletion()
    MitCrit:Print(string.format("loaded. Use \124c%s/mitcrit\124r or \124c%s/mc\124r for options.", MitCrit.color,
        MitCrit.color))
end

function MitCrit:MigrateSwingKeyToTitleCase()
    local fromKey = "swing"
    local toKey = "Swing"
    local source = MitCritData and MitCritData[fromKey] or nil
    if type(source) ~= "table" then
        return false
    end

    local target = MitCritData[toKey]
    if type(target) ~= "table" then
        MitCritData[toKey] = source
        MitCritData[fromKey] = nil
        return true
    end

    local sourceCrit = source.crit or 0
    local targetCrit = target.crit or 0
    if sourceCrit > targetCrit then
        target.crit = sourceCrit
        target.critTarget = source.critTarget or target.critTarget
    else
        target.crit = targetCrit
    end

    local sourceHit = source.hit or 0
    local targetHit = target.hit or 0
    if sourceHit > targetHit then
        target.hit = sourceHit
        target.hitTarget = source.hitTarget or target.hitTarget
    else
        target.hit = targetHit
    end

    target.casts = (target.casts or 0) + (source.casts or 0)
    target.crits = (target.crits or 0) + (source.crits or 0)
    target.hits = (target.hits or 0) + (source.hits or 0)
    target.spellSchool = target.spellSchool or source.spellSchool
    target.critTarget = target.critTarget or source.critTarget or ""
    target.hitTarget = target.hitTarget or source.hitTarget or ""

    MitCritData[fromKey] = nil
    return true
end

function MitCrit:MigrateLegacyNobodyTargetsToEmpty()
    local changed = false
    for spellName, data in pairs(MitCritData or {}) do
        if type(spellName) == "string" and type(data) == "table" then
            if data.critTarget == "Nobody" then
                data.critTarget = ""
                changed = true
            end
            if data.hitTarget == "Nobody" then
                data.hitTarget = ""
                changed = true
            end
        end
    end
    return changed
end

function MitCrit:RunMigrations()
    MitCritSettings.migrations = MitCritSettings.migrations or {}

    if not MitCritSettings.migrations.swingKeyTitleCaseV1 then
        local changed = MitCrit:MigrateSwingKeyToTitleCase()
        MitCritSettings.migrations.swingKeyTitleCaseV1 = true
        if changed then
            MitCrit:Print("Migrated legacy swing records to \"Swing\".")
        end
    end

    if not MitCritSettings.migrations.emptyTargetFallbackV1 then
        local changed = MitCrit:MigrateLegacyNobodyTargetsToEmpty()
        MitCritSettings.migrations.emptyTargetFallbackV1 = true
        if changed then
            MitCrit:Print("Migrated legacy target placeholders to empty values.")
        end
    end
end

SLASH_MITCRIT1, SLASH_MITCRIT2 = "/mc", "/mitcrit"
function SlashCmdList.MITCRIT(msg, editBox)
    local color = MitCrit.color
    MitCrit:Print(("\124c%s/mcstat <Spell Name>\124r to see the stats of an ability. Press \124c%s<Tab>/<Shift+Tab>\124r for autocomplete")
        :format(color, color))
    MitCrit:Print(("\124c%s/mcdump\124r to dump all records"):format(color))
    MitCrit:Print(("\124c%s/mctable\124r to open a scrollable records table"):format(color))
    MitCrit:Print(("\124c%s/mcwipe\124r to wipe all records"):format(color))
    MitCrit:Print(("\124c%s/mcsize\124r to resize the message font"):format(color))
    MitCrit:Print(("\124c%s/mcreset\124r to reset the settings"):format(color))
    MitCrit:Print(("\124c%s/mcsounds\124r to learn how to add custom sounds"):format(color))
end

SLASH_MITCRITWIPE1 = "/mcwipe"
function SlashCmdList.MITCRITWIPE(msg, editBox)
    MitCritData = {}
    MitCrit:Print(("All records were wiped"))
    MitCrit:Initialize(true)
end

SLASH_MITCRITRESET1 = "/mcreset"
function SlashCmdList.MITCRITRESET(msg, editBox)
    MitCritSettings = {}
    MitCrit:Print(("Settings were reset"))
    MitCrit:Initialize(true)
end

SLASH_MITCRITSOUNDS1 = "/mcsounds"
function SlashCmdList.MITCRITSOUNDS(msg, editBox)
    MitCrit:Print("To add custom sounds, drop .ogg files into Interface\\AddOns\\MitCrit\\Media\\")
    MitCrit:Print("Then list each filename in Media\\Media.lua")
    MitCrit:Print("Reload your UI and pick the new sound in the options")
end

SLASH_MITCRITSIZE1 = "/mcsize"
function SlashCmdList.MITCRITSIZE(msg, editBox)
    local size = tonumber(msg)
    MitCrit:UpdateFontSize(size)
end

SLASH_MITCRITDUMP1 = "/mcdump"
function SlashCmdList.MITCRITDUMP(msg, editBox)
    local keys = {}
    for spell in pairs(MitCritData) do
        table.insert(keys, spell)
    end
    table.sort(keys)
    for _, spellName in ipairs(keys) do
        local data = MitCritData[spellName]
        if type(data) == "table" then
            local spellColor = data.spellSchool and MitCrit.spellSchools[data.spellSchool] or MitCrit.spellSchools[0]
            MitCrit:Print(("\124c%s%s\124r: record crit \124c%s%s\124r, record hit \124c%s%s\124r."):format(spellColor,
                spellName, spellColor, MitCrit:FormatNumber(data.crit), spellColor, MitCrit:FormatNumber(data.hit)))
        end
    end
end

function MitCrit:GetTableRows()
    local rows = {}
    for spellName, data in pairs(MitCritData or {}) do
        if type(spellName) == "string" and type(data) == "table" then
            local casts = data.casts or 0
            local crits = data.crits or 0
            local critChance = casts > 0 and ((crits / casts) * 100) or 0
            rows[#rows + 1] = {
                spellName = spellName,
                crit = data.crit or 0,
                hit = data.hit or 0,
                critTarget = data.critTarget or "",
                hitTarget = data.hitTarget or "",
                spellSchool = data.spellSchool or 0,
                casts = casts,
                critChance = critChance,
            }
        end
    end
    return rows
end

function MitCrit:IsFuzzyMatch(haystack, needle)
    haystack = tostring(haystack or ""):lower()
    needle = tostring(needle or ""):lower()
    if needle == "" then return true end
    local pos = 1
    for i = 1, #needle do
        local ch = needle:sub(i, i)
        local found = haystack:find(ch, pos, true)
        if not found then
            return false
        end
        pos = found + 1
    end
    return true
end

function MitCrit:FilterTableRows(rows, query)
    if not query or query == "" then
        return rows
    end
    local filtered = {}
    for i = 1, #rows do
        local row = rows[i]
        if self:IsFuzzyMatch(row.spellName, query)
            or self:IsFuzzyMatch(row.critTarget, query)
            or self:IsFuzzyMatch(row.hitTarget, query) then
            filtered[#filtered + 1] = row
        end
    end
    return filtered
end

function MitCrit:SortTableRows(rows, frame)
    local sort = frame.tableSort or { key = "crit", asc = false }
    local key = sort.key or "crit"
    local asc = sort.asc == true

    table.sort(rows, function(a, b)
        local av, bv
        if key == "spell" then
            av = (a.spellName or ""):lower()
            bv = (b.spellName or ""):lower()
        elseif key == "crit" then
            av = a.crit or 0
            bv = b.crit or 0
        elseif key == "critTarget" then
            av = (a.critTarget or ""):lower()
            bv = (b.critTarget or ""):lower()
        elseif key == "hit" then
            av = a.hit or 0
            bv = b.hit or 0
        elseif key == "hitTarget" then
            av = (a.hitTarget or ""):lower()
            bv = (b.hitTarget or ""):lower()
        elseif key == "casts" then
            av = a.casts or 0
            bv = b.casts or 0
        elseif key == "critChance" then
            av = a.critChance or 0
            bv = b.critChance or 0
        else
            av = a.crit or 0
            bv = b.crit or 0
        end

        if av == bv then
            local as = (a.spellName or ""):lower()
            local bs = (b.spellName or ""):lower()
            if as == bs then
                return (a.crit or 0) > (b.crit or 0)
            end
            return as < bs
        end

        if asc then
            return av < bv
        end
        return av > bv
    end)
end

function MitCrit:UpdateTableHeaderLabels(frame)
    if not frame or not frame.headerButtons then return end
    local sort = frame.tableSort or { key = "crit", asc = false }
    local arrow = sort.asc and " ^" or " v"
    local sortLabel = "Crit"

    for i = 1, #frame.headerButtons do
        local button = frame.headerButtons[i]
        local label = button.baseLabel
        if button.sortKey == sort.key then
            label = label .. arrow
            sortLabel = button.baseLabel
        end
        button.text:SetText(label)
    end

    if frame.subtitle then
        frame.subtitle:SetText(("Sorted by %s (%s)"):format(sortLabel, sort.asc and "asc" or "desc"))
    end
end

function MitCrit:SetTableSort(sortKey)
    local frame = self:GetOrCreateTableFrame()
    local columns = frame.columns or {}
    local defaultAsc = false
    for i = 1, #columns do
        if columns[i].sortKey == sortKey then
            defaultAsc = columns[i].defaultAsc == true
            break
        end
    end

    frame.tableSort = frame.tableSort or { key = "crit", asc = false }
    if frame.tableSort.key == sortKey then
        frame.tableSort.asc = not frame.tableSort.asc
    else
        frame.tableSort.key = sortKey
        frame.tableSort.asc = defaultAsc
    end
    self:RefreshTableFrame()
end

function MitCrit:GetOrCreateTableFrame()
    if self.tableFrame then
        return self.tableFrame
    end

    local frame = CreateFrame("Frame", "MitCritTableFrame", UIParent, "BackdropTemplate")
    frame:SetSize(885, 405)
    frame:SetPoint("CENTER", UIParent, "CENTER", 0, 150)
    frame:SetMovable(true)
    frame:EnableMouse(true)
    frame:RegisterForDrag("LeftButton")
    frame:SetScript("OnDragStart", frame.StartMoving)
    frame:SetScript("OnDragStop", frame.StopMovingOrSizing)
    frame:SetClampedToScreen(true)
    frame:SetFrameStrata("DIALOG")
    frame:SetBackdrop({
        bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true,
        tileSize = 16,
        edgeSize = 16,
        insets = { left = 4, right = 4, top = 4, bottom = 4 },
    })
    frame:SetBackdropColor(0, 0, 0, 0.9)
    frame:SetBackdropBorderColor(0.8, 0.8, 0.8, 1)
    frame:Hide()
    if type(UISpecialFrames) == "table" then
        local found = false
        for i = 1, #UISpecialFrames do
            if UISpecialFrames[i] == "MitCritTableFrame" then
                found = true
                break
            end
        end
        if not found then
            table.insert(UISpecialFrames, "MitCritTableFrame")
        end
    end

    frame.title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    frame.title:SetPoint("TOPLEFT", frame, "TOPLEFT", 14, -12)
    frame.title:SetText("MitCrit Records")

    frame.subtitle = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    frame.subtitle:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -40, -16)
    frame.subtitle:SetText("Sorted by Crit (desc)")

    local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
    close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -6, -6)

    local headers = CreateFrame("Frame", nil, frame)
    headers:SetPoint("TOPLEFT", frame, "TOPLEFT", 12, -40)
    headers:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -36, -40)
    headers:SetHeight(18)
    frame.headers = headers

    local columns = {
        { key = "spell",      label = "Spell",  sortKey = "spell",      defaultAsc = true,  x = 6,   justify = "LEFT",  width = 190 },
        { key = "crit",       label = "Crit",   sortKey = "crit",       defaultAsc = false, x = 200, justify = "LEFT",  width = 85 },
        { key = "critTarget", label = "Target", sortKey = "critTarget", defaultAsc = true,  x = 290, justify = "RIGHT", width = 150 },
        { key = "hit",        label = "Hit",    sortKey = "hit",        defaultAsc = false, x = 445, justify = "LEFT",  width = 85 },
        { key = "hitTarget",  label = "Target", sortKey = "hitTarget",  defaultAsc = true,  x = 535, justify = "RIGHT", width = 150 },
        { key = "casts",      label = "Casts",  sortKey = "casts",      defaultAsc = false, x = 690, justify = "RIGHT", width = 70 },
        { key = "chance",     label = "Crit %", sortKey = "critChance", defaultAsc = false, x = 765, justify = "RIGHT", width = 70 },
    }
    frame.columns = columns
    frame.tableSort = { key = "crit", asc = false }
    frame.headerButtons = {}

    for i = 1, #columns do
        local column = columns[i]
        local button = CreateFrame("Button", nil, headers)
        button:SetPoint("TOPLEFT", headers, "TOPLEFT", column.x, -1)
        button:SetSize(column.width, 16)
        button.sortKey = column.sortKey
        button.baseLabel = column.label
        button:SetScript("OnClick", function(selfButton)
            MitCrit:SetTableSort(selfButton.sortKey)
        end)

        local fs = button:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        fs:SetAllPoints()
        fs:SetJustifyH(column.justify)
        fs:SetText(column.label)
        button.text = fs
        frame.headerButtons[i] = button
    end

    frame.divider = headers:CreateTexture(nil, "ARTWORK")
    frame.divider:SetColorTexture(1, 1, 1, 0.2)
    frame.divider:SetPoint("TOPLEFT", headers, "BOTTOMLEFT", 0, -2)
    frame.divider:SetPoint("TOPRIGHT", headers, "BOTTOMRIGHT", 0, -2)
    frame.divider:SetHeight(1)

    local scrollFrame = CreateFrame("ScrollFrame", "MitCritTableScrollFrame", frame, "UIPanelScrollFrameTemplate")
    scrollFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", 12, -62)
    scrollFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -32, 36)
    frame.scrollFrame = scrollFrame

    local content = CreateFrame("Frame", nil, scrollFrame)
    content:SetPoint("TOPLEFT", scrollFrame, "TOPLEFT", 0, 0)
    content:SetWidth(900)
    content:SetHeight(1)
    scrollFrame:SetScrollChild(content)
    frame.content = content

    frame.rows = {}
    frame.emptyText = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    frame.emptyText:SetPoint("CENTER", scrollFrame, "CENTER", 0, 0)
    frame.emptyText:SetText("No records yet. Deal damage/healing to populate this table.")
    frame.emptyText:Hide()

    frame.footer = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    frame.footer:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 14, 14)
    frame.footer:SetText("Use /mctable to refresh or reopen.")

    local searchLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    searchLabel:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -210, 14)
    searchLabel:SetText("Search")
    frame.searchLabel = searchLabel

    local searchBox = CreateFrame("EditBox", nil, frame, "InputBoxTemplate")
    searchBox:SetAutoFocus(false)
    searchBox:SetSize(180, 20)
    searchBox:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -14, 10)
    searchBox:SetMaxLetters(64)
    searchBox:SetScript("OnEnterPressed", function(selfBox)
        selfBox:ClearFocus()
    end)
    searchBox:SetScript("OnEscapePressed", function(selfBox)
        selfBox:ClearFocus()
    end)
    searchBox:SetScript("OnTextChanged", function()
        MitCrit:RefreshTableFrame()
    end)
    searchBox:SetText("")
    frame.searchBox = searchBox

    self:UpdateTableHeaderLabels(frame)

    self.tableFrame = frame
    return frame
end

function MitCrit:GetOrCreateTableRow(frame, index)
    local rows = frame.rows
    if rows[index] then
        return rows[index]
    end

    local row = CreateFrame("Frame", nil, frame.content)
    row:SetHeight(22)
    row:SetPoint("TOPLEFT", frame.content, "TOPLEFT", 0, -((index - 1) * 22))
    row:SetPoint("TOPRIGHT", frame.content, "TOPRIGHT", 0, -((index - 1) * 22))

    row.bg = row:CreateTexture(nil, "BACKGROUND")
    row.bg:SetAllPoints()
    row.bg:SetColorTexture(1, 1, 1, (index % 2 == 0) and 0.04 or 0.08)

    row.cells = {}
    for i = 1, #frame.columns do
        local column = frame.columns[i]
        local cell = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        cell:SetPoint("LEFT", row, "LEFT", column.x, 0)
        cell:SetWidth(column.width)
        cell:SetJustifyH(column.justify)
        row.cells[i] = cell
    end

    rows[index] = row
    return row
end

function MitCrit:RefreshTableFrame()
    local frame = self:GetOrCreateTableFrame()
    local allRows = self:GetTableRows()
    local rows = allRows
    local query = ""
    if frame.searchBox then
        query = frame.searchBox:GetText() or ""
        query = query:gsub("^%s+", ""):gsub("%s+$", "")
    end
    rows = self:FilterTableRows(rows, query)
    self:SortTableRows(rows, frame)
    local rowHeight = 22

    if #rows == 0 then
        if #allRows == 0 then
            frame.emptyText:SetText("No records yet. Deal damage/healing to populate this table.")
        else
            frame.emptyText:SetText("No filter results.")
        end
        frame.emptyText:Show()
    else
        frame.emptyText:Hide()
    end

    for i = 1, #rows do
        local data = rows[i]
        local row = self:GetOrCreateTableRow(frame, i)
        local schoolColor = self.spellSchools[data.spellSchool] or self.spellSchools[0]
        local colorText = function(text)
            return ("\124c%s%s\124r"):format(schoolColor, text)
        end
        row:Show()
        row:SetPoint("TOPLEFT", frame.content, "TOPLEFT", 0, -((i - 1) * rowHeight))
        row:SetPoint("TOPRIGHT", frame.content, "TOPRIGHT", 0, -((i - 1) * rowHeight))
        row.bg:SetColorTexture(1, 1, 1, (i % 2 == 0) and 0.04 or 0.08)
        row.cells[1]:SetText(colorText(data.spellName))
        row.cells[2]:SetText(colorText(self:FormatNumber(data.crit)))
        row.cells[3]:SetText(data.critTarget)
        row.cells[4]:SetText(colorText(self:FormatNumber(data.hit)))
        row.cells[5]:SetText(data.hitTarget)
        row.cells[6]:SetText(colorText(self:FormatNumber(data.casts)))
        row.cells[7]:SetText(colorText(("%.1f%%"):format(data.critChance)))
    end

    for i = #rows + 1, #frame.rows do
        frame.rows[i]:Hide()
    end

    local minHeight = frame.scrollFrame:GetHeight()
    local contentHeight = math.max(minHeight, #rows * rowHeight + 4)
    frame.content:SetHeight(contentHeight)
    frame.scrollFrame:SetVerticalScroll(0)
    self:UpdateTableHeaderLabels(frame)
end

SLASH_MITCRITTABLE1 = "/mctable"
function SlashCmdList.MITCRITTABLE(msg, editBox)
    MitCrit:RefreshTableFrame()
    local frame = MitCrit:GetOrCreateTableFrame()
    frame:Show()
    frame:Raise()
end

SLASH_MITCRITSTAT1 = "/mcstat"
function SlashCmdList.MITCRITSTAT(msg, editBox)
    local info = C_Spell.GetSpellInfo(msg)
    local name = info and info.name or nil
    if not name and msg and msg ~= "" then
        -- Fall back to stored entries (items, melee, etc.) with case-insensitive match.
        local query = msg:lower()
        for spellName in pairs(MitCritData or {}) do
            if type(spellName) == "string" and spellName:lower() == query then
                name = spellName
                break
            end
        end
    end
    if not name then
        MitCrit:Print("Please specify a spell name")
    elseif not MitCritData[name] then
        MitCrit:Print(("Missing spell %s. Possible typo? Never used this spell? Wrong character?"):format(name))
    else
        MitCrit:PrintSpellStat(name)
    end
end

SLASH_MITCRITUI1 = "/mcui"
function SlashCmdList.MITCRITUI(msg, editBox)
    local panel = MitCrit.optionsFrame
    if Settings and Settings.OpenToCategory then
        local category = (panel and panel.name) or "MitCrit"
        Settings.OpenToCategory(category)
        return
    end
    local openToCategory = rawget(_G, "InterfaceOptionsFrame_OpenToCategory")
    if openToCategory then
        openToCategory(panel or "MitCrit")
        openToCategory(panel or "MitCrit")
    end
end

function MitCrit:SetupTabCompletion(force)
    if MitCrit.tabCompletionInstalled and not force then return end
    local editBox = ChatFrame1EditBox or (DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.editBox)
    if not editBox then return end

    local original = editBox:GetScript("OnTabPressed")
    editBox:SetScript("OnTabPressed", function(box)
        local reverse = IsShiftKeyDown and IsShiftKeyDown() or false
        if MitCrit:HandleTabCompletion(box, reverse) then
            return
        end
        if original then
            original(box)
        end
    end)

    MitCrit.tabCompletionInstalled = true

    local currentCustom = ChatEdit_CustomTabPressed
    if currentCustom and currentCustom ~= MitCrit.ChatEditCustomTabWrapper then
        MitCrit.originalChatEditCustomTab = currentCustom
        MitCrit.ChatEditCustomTabWrapper = MitCrit.ChatEditCustomTabWrapper or function(box)
            local reverse = IsShiftKeyDown and IsShiftKeyDown() or false
            if MitCrit:HandleTabCompletion(box, reverse) then
                return true
            end
            local orig = MitCrit.originalChatEditCustomTab
            if orig and orig ~= MitCrit.ChatEditCustomTabWrapper then
                return orig(box)
            end
        end
        ChatEdit_CustomTabPressed = MitCrit.ChatEditCustomTabWrapper
    end
end

function MitCrit:HandleTabCompletion(editBox, reverse)
    local text = editBox:GetText() or ""
    if editBox:GetCursorPosition() ~= #text then
        return false
    end

    local lower = text:lower()
    local state = MitCrit.tabState or { active = false, index = 0 }

    local function applyCompletion(mode, baseText, prefix, matches)
        if not matches or #matches == 0 then
            state.active = false
            MitCrit.tabState = state
            return false
        end

        local reuse = state.active and state.mode == mode and state.baseText == baseText and state.prefix == prefix and
            state.matches and #state.matches > 0
        if not reuse then
            state.mode = mode
            state.baseText = baseText
            state.prefix = prefix
            state.matches = matches
            state.index = 0
        end

        if reverse then
            if state.index == 0 then
                state.index = #state.matches
            else
                state.index = state.index - 1
                if state.index < 1 then
                    state.index = #state.matches
                end
            end
        else
            state.index = (state.index % #state.matches) + 1
        end

        state.active = true
        local chosen = state.matches[state.index]
        local newText = baseText .. chosen
        editBox:SetText(newText)
        editBox:SetCursorPosition(#newText)
        editBox:HighlightText(0, 0)
        MitCrit.tabState = state
        return true
    end

    -- /mctest multi-arg completion
    if lower:sub(1, 7) == "/mctest" then
        local nextChar = lower:sub(8, 8)
        if nextChar ~= "" and nextChar ~= " " then
            return false
        end

        local rest = text:sub(8)
        local endsWithSpace = rest:match("%s$") ~= nil
        local args = {}
        for token in rest:gmatch("%S+") do
            args[#args + 1] = token
        end

        local tokenPos = endsWithSpace and (#args + 1) or #args
        local prefix = endsWithSpace and "" or ((args[#args] or ""):lower())

        local before = {}
        for i = 1, math.max(0, tokenPos - 1) do
            before[#before + 1] = args[i]
        end
        local baseText = "/mctest "
        if #before > 0 then
            baseText = baseText .. table.concat(before, " ") .. " "
        end

        local first = (args[1] or ""):lower()
        local styles = { "alltime", "boss", "streak", "near95", "near90" }
        local matches = {}

        local function collect(candidates)
            for i = 1, #candidates do
                local candidate = candidates[i]
                if prefix == "" or candidate:sub(1, #prefix) == prefix then
                    matches[#matches + 1] = candidate
                end
            end
        end

        if tokenPos == 1 then
            collect({ "crit", "critical", "reg", "regular", "burst", "multi", "styles", "logic" })
        elseif (first == "crit" or first == "critical" or first == "reg" or first == "regular") and tokenPos == 2 then
            collect(styles)
        elseif (first == "burst" or first == "multi") and tokenPos == 3 then
            collect({ "crit", "critical", "reg", "regular" })
        elseif (first == "burst" or first == "multi") and tokenPos == 5 then
            collect(styles)
        else
            return false
        end

        return applyCompletion("mctest", baseText, prefix, matches)
    end

    -- /mcstat completion
    if lower:sub(1, 7) ~= "/mcstat" then
        return false
    end
    local nextChar = lower:sub(8, 8)
    if nextChar ~= "" and nextChar ~= " " then
        return false
    end

    local rest = text:sub(8):gsub("^%s+", "")
    local prefix = rest:lower()
    if prefix == "" then
        state.active = false
        MitCrit.tabState = state
        return false
    end

    local matches = {}
    for spellName in pairs(MitCritData or {}) do
        if type(spellName) == "string" then
            local s = spellName:lower()
            if s:sub(1, #prefix) == prefix then
                table.insert(matches, spellName)
            end
        end
    end
    table.sort(matches, function(a, b) return a:lower() < b:lower() end)
    return applyCompletion("mcstat", "/mcstat ", prefix, matches)
end

function MitCrit:RunBehaviorSelfTest()
    local spell = "__MitCritSelfTestSpell__"
    local originalPlay = MitCrit.PlayRecordImmediate
    local originalSpellData = MitCritData[spell]
    local originalGetTime = GetTime
    local fakeNow = 1000
    local emitted = {}
    local results = {}

    local function emitResult(ok, label)
        results[#results + 1] = { ok = ok, label = label }
    end

    GetTime = function()
        return fakeNow
    end

    MitCrit:ResetRuntimeProgress()
    MitCrit.PlayRecordImmediate = function(_, _, _, style)
        emitted[#emitted + 1] = style or "allTime"
    end
    MitCritData[spell] = nil

    -- All-time and near-miss
    MitCrit:TrackRecord(nil, "Dummy", 1000, 4, false, spell)
    emitResult(emitted[#emitted] == "allTime", "all-time record emits allTime style")
    local afterAllTime = #emitted
    MitCrit:TrackRecord(nil, "Dummy", 960, 4, false, spell)
    emitResult(emitted[#emitted] == "near95", "95% near-miss emits near95 style")
    MitCrit:TrackRecord(nil, "Dummy", 955, 4, false, spell)
    emitResult(#emitted == afterAllTime + 1, "near-miss cooldown suppresses immediate repeats")

    -- Streak milestone
    MitCrit:ResetRuntimeProgress()
    MitCritData[spell] = nil
    emitted = {}
    MitCrit:TrackRecord(nil, "Dummy", 1000, 4, true, spell) -- streak=1, suppressed message due to all-time
    fakeNow = fakeNow + 3
    MitCrit:TrackRecord(nil, "Dummy", 920, 4, true, spell)  -- streak=2 milestone
    local streakFound = false
    for i = 1, #emitted do
        if emitted[i] == "streak" then
            streakFound = true
            break
        end
    end
    emitResult(streakFound, "big crit streak milestone emits streak style")

    -- Boss BEST (encounter scoped)
    MitCrit:ResetRuntimeProgress()
    MitCritData[spell] = nil
    emitted = {}
    fakeNow = fakeNow + 3
    MitCrit:TrackRecord(nil, "Dummy", 5000, 4, false, spell) -- all-time, out of encounter
    MitCrit:OnEncounterStart(1, "SelfTest Boss", 0, 1)
    fakeNow = fakeNow + 1
    MitCrit:TrackRecord(nil, "Boss", 900, 4, false, spell)  -- warmup 1
    fakeNow = fakeNow + 1
    MitCrit:TrackRecord(nil, "Boss", 1000, 4, false, spell) -- warmup 2
    fakeNow = fakeNow + 1
    MitCrit:TrackRecord(nil, "Boss", 1700, 4, false, spell) -- passes delta gate, should popup
    local bossFound = false
    for i = 1, #emitted do
        if emitted[i] == "boss" then
            bossFound = true
            break
        end
    end
    emitResult(bossFound, "encounter boss best emits boss style")

    MitCrit.PlayRecordImmediate = originalPlay
    MitCritData[spell] = originalSpellData
    GetTime = originalGetTime

    local pass, fail = 0, 0
    for i = 1, #results do
        local row = results[i]
        if row.ok then
            pass = pass + 1
            MitCrit:Print(("Self-test PASS: %s"):format(row.label))
        else
            fail = fail + 1
            MitCrit:Print(("Self-test FAIL: %s"):format(row.label))
        end
    end
    MitCrit:Print(("Self-test summary: %d passed, %d failed."):format(pass, fail))
end

SLASH_MITCRITTEST1 = "/mctest"
function SlashCmdList.MITCRITTEST(msg, editBox)
    local args = {}
    for token in (msg or ""):gmatch("%S+") do
        table.insert(args, token:lower())
    end
    local arg = args[1] or ""
    local styleFromArg = function(value)
        if value == "all" or value == "alltime" then return "allTime" end
        if value == "boss" then return "boss" end
        if value == "streak" then return "streak" end
        if value == "near90" or value == "near" then return "near90" end
        if value == "near95" then return "near95" end
    end
    if arg == "crit" or arg == "critical" then
        local style = styleFromArg(args[2])
        if style then
            MitCrit:PlayTestRecord(true, 1, 1, style)
        else
            MitCrit:PlayTestAnimation(true)
        end
        return
    end
    if arg == "reg" or arg == "regular" then
        local style = styleFromArg(args[2])
        if style then
            MitCrit:PlayTestRecord(false, 1, 1, style)
        else
            MitCrit:PlayTestAnimation(false)
        end
        return
    end
    if arg == "burst" or arg == "multi" then
        local count = tonumber(args[2]) or 3
        local kind = args[3] or "crit"
        local interval = tonumber(args[4]) or 0.15
        local critical = kind == "crit" or kind == "critical"
        MitCrit:PlayTestBurst(count, critical, interval, styleFromArg(args[5]))
        return
    end
    if arg == "styles" then
        MitCrit:PlayTestRecord(true, 1, 5, "allTime")
        if C_Timer and C_Timer.After then
            C_Timer.After(0.18, function() MitCrit:PlayTestRecord(true, 2, 5, "boss") end)
            C_Timer.After(0.36, function() MitCrit:PlayTestRecord(true, 3, 5, "streak") end)
            C_Timer.After(0.54, function() MitCrit:PlayTestRecord(true, 4, 5, "near95") end)
            C_Timer.After(0.72, function() MitCrit:PlayTestRecord(false, 5, 5, "near90") end)
        else
            MitCrit:PlayTestRecord(true, 2, 5, "boss")
            MitCrit:PlayTestRecord(true, 3, 5, "streak")
            MitCrit:PlayTestRecord(true, 4, 5, "near95")
            MitCrit:PlayTestRecord(false, 5, 5, "near90")
        end
        return
    end
    if arg == "logic" then
        MitCrit:RunBehaviorSelfTest()
        return
    end
    MitCrit:Print((
            "Usage: \124c%s/mctest crit [alltime\124\124boss\124\124streak\124\124near95\124\124near90]\124r, \124c%s/mctest reg [style]\124r, \124c%s/mctest burst <count> <crit\124\124reg> <interval> [style]\124r, \124c%s/mctest styles\124r, \124c%s/mctest logic\124r")
        :format(MitCrit.color, MitCrit.color, MitCrit.color, MitCrit.color, MitCrit.color))
end

function MitCrit:PrintSpellStat(spellName)
    local spellData = MitCritData[spellName]
    local spellSchool = spellData.spellSchool or 0
    local casts = spellData.casts or 0
    local schoolColor = MitCrit.spellSchools[spellSchool] or MitCrit.spellSchools[0]
    local critChance = 0
    local critTarget = spellData.critTarget or ""
    local hitTarget = spellData.hitTarget or ""
    if casts ~= 0 and spellData.crits then
        critChance = (spellData.crits / casts) * 100
    end
    MitCrit:Print(("\124c%s%s\124r highest recorded crit is: \124c%s%s\124r. Target: \124c%s%s\124r."):format(
        schoolColor, spellName, schoolColor,
        MitCrit:FormatNumber(spellData.crit), MitCrit.spellSchools[0], critTarget))
    MitCrit:Print(("\124c%s%s\124r highest recorded hit is: \124c%s%s\124r. Target: \124c%s%s\124r."):format(
        schoolColor, spellName, schoolColor,
        MitCrit:FormatNumber(spellData.hit), MitCrit.spellSchools[0], hitTarget))
    MitCrit:Print(("\124c%s%s\124r was cast \124c%s%s\124r times, with a \124c%s%d%%\124r crit chance."):format(
        schoolColor,
        spellName, MitCrit.spellSchools[0], MitCrit:FormatNumber(casts), MitCrit.spellSchools[0], critChance))
end

function MitCrit:UpdateFontSize(size)
    if size == MitCritSettings.savedVars.fontSize then return end -- No change
    local minSize = 8
    local maxSize = 32
    if size and size >= minSize and size <= maxSize then
        MitCritSettings.savedVars.fontSize = size
        MitCrit:ApplyFontStyle()
        MitCrit:Print(("Font size set to \124c%s%d\124r."):format(MitCrit.color, size))
    else
        MitCrit:Print(("Font size is \124c%s%d\124r."):format(MitCrit.color, MitCritSettings.savedVars.fontSize))
    end
end

function MitCrit:GetFontFlags()
    if not MitCritSettings or not MitCritSettings.savedVars then return nil end
    if MitCritSettings.savedVars.fontOutline then
        return "OUTLINE"
    end
end

function MitCrit:ApplyFontStyle()
    if not MitCritSettings or not MitCritSettings.savedVars then return end
    local size = MitCritSettings.savedVars.fontSize or MitCritSettings.defaults.fontSize
    local font = MitCritSettings.savedVars.fontFace or MitCritSettings.defaults.fontFace or "Fonts\\FRIZQT__.TTF"
    local flags = MitCrit:GetFontFlags()
    local shadow = MitCritSettings.savedVars.fontShadow
    local shadowX, shadowY = shadow and 2 or 0, shadow and -2 or 0
    local shadowA = shadow and 0.9 or 0

    local function ApplyToText(text)
        if not text then return end
        text:SetFont(font, size, flags)
        text:SetShadowOffset(shadowX, shadowY)
        text:SetShadowColor(0, 0, 0, shadowA)
    end

    if MitCrit.displayFrames then
        for _, frame in pairs(MitCrit.displayFrames) do
            ApplyToText(frame.text)
        end
    end
    if MitCrit.testFrame then
        ApplyToText(MitCrit.testFrame.text)
    end
    if MitCrit.fontSampleLabel then
        ApplyToText(MitCrit.fontSampleLabel)
    end
end

function MitCrit:OnCombatLogEvent()
    -- print(CombatLogGetCurrentEventInfo())
    local _, subEvent, _, sourceGUID = CombatLogGetCurrentEventInfo()
    if sourceGUID ~= MitCritData.GUID then
        return
    end
    local destGUID, destName, amount, spellSchool, critical, spellName, overheal, absorbed
    if subEvent == "SWING_DAMAGE" then
        destGUID, destName, _, _, amount, _, spellSchool, _, _, _, critical = select(8, CombatLogGetCurrentEventInfo())
        MitCrit:TrackRecord(destGUID, destName, amount, spellSchool, critical, "Swing")
    elseif subEvent == "SPELL_DAMAGE" or subEvent == "SPELL_PERIODIC_DAMAGE" or subEvent == "RANGE_DAMAGE" then
        destGUID, destName, _, _, _, spellName, spellSchool, amount, _, _, _, _, _, critical = select(8,
            CombatLogGetCurrentEventInfo())
        MitCrit:TrackRecord(destGUID, destName, amount, spellSchool, critical, spellName)
    elseif subEvent == "SPELL_HEAL" or subEvent == "SPELL_PERIODIC_HEAL" then
        destGUID, destName, _, _, _, spellName, spellSchool, amount, overheal, absorbed, critical = select(8,
            CombatLogGetCurrentEventInfo())
        MitCrit:TrackRecord(destGUID, destName, amount, spellSchool, critical, spellName)
    end
end

function MitCrit:UpdateDuration(duration)
    if duration == MitCritSettings.savedVars.duration then return end -- No change
    local minDuration = 1
    local maxDuration = 5
    if duration and duration >= minDuration and duration <= maxDuration then
        MitCritSettings.savedVars.duration = duration
        MitCrit:Print(("Frame duration set to \124c%s%.1f\124r seconds."):format(MitCrit.color, duration))
    else
        MitCrit:Print(("Frame duration is \124c%s%.1f\124r seconds."):format(
            MitCrit.color, MitCritSettings.savedVars.duration or MitCritSettings.defaults.duration or 3))
    end
end

function MitCrit:PlayRecordSound(critical)
    local sound = critical and MitCritSettings.savedVars.soundEffect.critical or
        MitCritSettings.savedVars.soundEffect.regular
    if type(sound) == "string" then
        PlaySoundFile(sound, "Master")
    else
        PlaySound(sound, "Master")
    end
end

function MitCrit:TrackRecord(destGUID, destName, amount, spellSchool, critical, spellName)
    -- print("destName:", destName, "amount:", amount, "critical:", critical, "spellName:", spellName, "heal:", _heal)
    if not spellName or type(amount) ~= "number" then return end
    local schoolColor = MitCrit.spellSchools[spellSchool] or MitCrit.spellSchools[0]
    local data = MitCrit:EnsureSpellData(spellName, spellSchool)
    data.casts = (data.casts or 0) + 1
    if critical then
        data.crits = (data.crits or 0) + 1
    else
        data.hits = (data.hits or 0) + 1
    end

    local recordType = critical and "crit" or "hit"
    local previousAllTime = data[recordType] or 0
    local isAllTimeRecord = false
    if previousAllTime < amount then
        isAllTimeRecord = true
        data[recordType] = amount
        if critical then
            data.critTarget = destName
        else
            data.hitTarget = destName
        end
        local recordText = MitCrit:BuildAllTimeBestText(spellName, schoolColor, amount, critical)
        MitCrit:PlayRecordImmediate(recordText, critical, "allTime", true)
    end

    MitCrit.runtime = MitCrit.runtime or {}
    local activeEncounter = MitCrit.runtime.activeEncounter
    local bossName = activeEncounter and activeEncounter.name
    if bossName then
        MitCrit.runtime.bossBest = MitCrit.runtime.bossBest or {}
        MitCrit.runtime.bossWarmup = MitCrit.runtime.bossWarmup or {}
        MitCrit.runtime.bossPopupAt = MitCrit.runtime.bossPopupAt or {}
        MitCrit.runtime.bossBest[spellName] = MitCrit.runtime.bossBest[spellName] or
            { crit = 0, hit = 0, critTarget = "", hitTarget = "", bossName = bossName }
        local bossRecord = MitCrit.runtime.bossBest[spellName]
        MitCrit.runtime.bossWarmup[spellName] = MitCrit.runtime.bossWarmup[spellName] or { crit = 0, hit = 0 }
        local warmup = MitCrit.runtime.bossWarmup[spellName]
        bossRecord.bossName = bossName
        local prevBoss = bossRecord[recordType] or 0
        if amount > prevBoss then
            bossRecord[recordType] = amount
            if critical then
                bossRecord.critTarget = destName or ""
            else
                bossRecord.hitTarget = destName or ""
            end
            if not isAllTimeRecord then
                local warmCount = warmup[recordType] or 0
                if warmCount < MitCrit.bossWarmupSamples then
                    warmup[recordType] = warmCount + 1
                else
                    local requiredDelta = math.max((prevBoss or 0) * MitCrit.bossMinDeltaPct, MitCrit.bossMinDeltaFlat)
                    local actualDelta = amount - (prevBoss or 0)
                    if actualDelta >= requiredDelta then
                        local popupKey = ("%s:%s"):format(spellName, recordType)
                        local now = GetTime and GetTime() or 0
                        local lastPopup = MitCrit.runtime.bossPopupAt[popupKey] or 0
                        if now - lastPopup >= MitCrit.bossPopupCooldownSec then
                            MitCrit.runtime.bossPopupAt[popupKey] = now
                            local text = MitCrit:BuildBossBestText(bossName, spellName, schoolColor, amount, critical)
                            MitCrit:PlayRecordImmediate(text, critical, "boss", true)
                        end
                    end
                end
            end
        end
    end

    if not isAllTimeRecord then
        MitCrit:MaybeCelebrateNearRecord(spellName, amount, recordType, previousAllTime, critical, schoolColor)
    end
    MitCrit:UpdateBigCritStreak(spellName, amount, critical, schoolColor, isAllTimeRecord)
end

function MitCrit:AddSharedMediaFonts(values, sorting)
    local lsm = MitCrit:GetSharedMedia()
    if not lsm then return end

    local list = lsm:List("font")
    table.sort(list)
    for _, name in ipairs(list) do
        local path = lsm:Fetch("font", name)
        if path and not values[path] then
            values[path] = ("LSM: %s"):format(name)
            table.insert(sorting, path)
        end
    end
end

function MitCrit:RegisterBundledFonts()
    local lsm = MitCrit:GetSharedMedia()
    if not lsm then return end
    for i = 1, #MitCrit.bundledFonts do
        local entry = MitCrit.bundledFonts[i]
        lsm:Register("font", entry.name, MitCrit.fontsDir .. entry.file)
    end
end

function MitCrit:GetFontOptions()
    local values = {}
    local sorting = {}
    for key, label in pairs(MitCrit.baseFontOptions.values) do
        values[key] = label
    end
    for i = 1, #MitCrit.baseFontOptions.sorting do
        sorting[i] = MitCrit.baseFontOptions.sorting[i]
    end

    MitCrit:AddSharedMediaFonts(values, sorting)

    return values, sorting
end

function MitCrit:EnsureAllTimeCritBlades(frame)
    if not frame or frame.leftBladeTexture then return end
    frame.leftBladeTexture = frame:CreateTexture(nil, "BACKGROUND")
    frame.rightBladeTexture = frame:CreateTexture(nil, "BACKGROUND")
    frame.leftBladeTexture:SetDrawLayer("BACKGROUND", 0)
    frame.rightBladeTexture:SetDrawLayer("BACKGROUND", 0)
    frame.leftBladeTexture:SetTexture(MitCrit.allTimeCritBladeTexture)
    frame.rightBladeTexture:SetTexture(MitCrit.allTimeCritBladeTexture)
    -- Mirror left blade horizontally.
    frame.leftBladeTexture:SetTexCoord(1, 0, 0, 1)
    frame.rightBladeTexture:SetTexCoord(0, 1, 0, 1)
    frame.leftBladeTexture:SetBlendMode("BLEND")
    frame.rightBladeTexture:SetBlendMode("BLEND")
    frame.leftBladeTexture:Hide()
    frame.rightBladeTexture:Hide()
end

function MitCrit:LayoutAllTimeCritBlades(frame)
    if not frame or not frame.leftBladeTexture or not frame.rightBladeTexture then return end
    local bladeW = (MitCrit.allTimeCritBladeSourceWidth or 637) * (MitCrit.allTimeCritBladeScale or 0.5)
    local bladeH = (MitCrit.allTimeCritBladeSourceHeight or 535) * (MitCrit.allTimeCritBladeScale or 0.5)
    frame.leftBladeTexture:SetSize(bladeW, bladeH)
    frame.rightBladeTexture:SetSize(bladeW, bladeH)
    frame.leftBladeTexture:ClearAllPoints()
    frame.rightBladeTexture:ClearAllPoints()
    local offset = MitCrit.allTimeCritBladeOffset or 8
    frame.leftBladeTexture:SetPoint("RIGHT", frame, "LEFT", -offset, 0)
    frame.rightBladeTexture:SetPoint("LEFT", frame, "RIGHT", offset, 0)
    MitCrit:UpdateAllTimeCritBlades(frame, 1)
end

function MitCrit:UpdateAllTimeCritBlades(frame, alpha, animScale)
    if not frame or not frame.leftBladeTexture or not frame.rightBladeTexture then return end
    local shouldShow = frame.recordStyle == "allTime" and frame.isCritical
    if not shouldShow then
        frame.leftBladeTexture:Hide()
        frame.rightBladeTexture:Hide()
        return
    end
    local a = math.max(0, math.min(1, alpha or 1))
    frame.leftBladeTexture:SetAlpha(a)
    frame.rightBladeTexture:SetAlpha(a)
    frame.leftBladeTexture:Show()
    frame.rightBladeTexture:Show()
end

function MitCrit:GetDisplayFrame(index)
    MitCrit.displayFrames = MitCrit.displayFrames or {}
    if MitCrit.displayFrames[index] then
        return MitCrit.displayFrames[index]
    end

    MitCrit.anchorFrame = MitCrit.anchorFrame or CreateFrame("Frame", nil, UIParent)
    MitCrit.anchorFrame:SetSize(1, 1)
    MitCrit.anchorFrame:ClearAllPoints()
    MitCrit.anchorFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 250)

    local f = CreateFrame("Frame", ("MitCritDisplayFrame%d"):format(index), MitCrit.anchorFrame)
    f:ClearAllPoints()
    f:SetPoint("CENTER", MitCrit.anchorFrame, "CENTER", 0, 0)
    f:SetSize(200, 100)

    f.content = CreateFrame("Frame", nil, f)
    f.content:SetPoint("CENTER")
    f.content:SetSize(200, 100)
    MitCrit:EnsureAllTimeCritBlades(f)

    f.text = f.content:CreateFontString(nil, "OVERLAY")
    f.text:SetPoint("CENTER")
    f.text:SetFont(MitCritSettings.savedVars.fontFace or MitCritSettings.defaults.fontFace or "Fonts\\FRIZQT__.TTF",
        MitCritSettings.savedVars.fontSize)
    f.text:SetJustifyH("CENTER")
    f.text:SetJustifyV("MIDDLE")
    MitCrit:ApplyFontStyle()

    f.elapsed = 0
    f.playing = false
    f:SetScript("OnUpdate", function(self, elapsed)
        if not self.playing then return end
        self.elapsed = self.elapsed + elapsed

        local popDur = 0.12
        local settleDur = 0.15
        local holdDur = MitCritSettings.savedVars.duration or 0.6
        local fadeDur = 0.35
        local total = popDur + settleDur + holdDur + fadeDur
        local maxScale = self.scaleMax or 1.6
        local settleScale = self.settleScale or 1
        local shakeAmp = self.shakeAmp or 0
        local shakeDur = self.shakeDur or total

        local t = self.elapsed
        local scale = 1
        local alpha = 1

        if t < popDur then
            local p = t / popDur
            scale = 1 + (maxScale - 1) * p
            alpha = p
        elseif t < popDur + settleDur then
            local p = (t - popDur) / settleDur
            scale = maxScale + (settleScale - maxScale) * p
            alpha = 1
        elseif t < popDur + settleDur + holdDur then
            scale = settleScale
            alpha = 1
        elseif t < total then
            local p = (t - popDur - settleDur - holdDur) / fadeDur
            scale = settleScale
            alpha = 1 - p
        else
            self.playing = false
            self.elapsed = 0
            self.content:SetAlpha(1)
            self.content:SetScale(1)
            self.shakeAmp = 0
            MitCrit:UpdateAllTimeCritBlades(self, 0, 1)
            self:Hide()
            MitCrit:RemoveActiveFrame(self)
            return
        end

        local jitterX, jitterY = 0, 0
        if shakeAmp > 0 and t < shakeDur then
            local decay = 1 - (t / shakeDur)
            local amp = shakeAmp * decay
            -- Use deterministic oscillation for visibility (different axes/frequencies)
            jitterX = math.sin(t * 60) * amp
            jitterY = math.cos(t * 45) * amp * 0.7
        end

        self.content:SetScale(scale)
        self.content:SetAlpha(alpha)
        self.content:ClearAllPoints()
        self.content:SetPoint("CENTER", self, "CENTER", jitterX, jitterY)
        MitCrit:UpdateAllTimeCritBlades(self, alpha, scale)
    end)

    MitCrit.displayFrames[index] = f
    return f
end

function MitCrit:GetNextDisplayFrame()
    MitCrit.displayFramePoolSize = MitCrit.displayFramePoolSize or 5
    MitCrit.displayFrameCursor = (MitCrit.displayFrameCursor or 0) % MitCrit.displayFramePoolSize + 1
    return MitCrit:GetDisplayFrame(MitCrit.displayFrameCursor)
end

function MitCrit:PlayRecordImmediate(recordText, critical, style, playSound)
    local frame = MitCrit:GetNextDisplayFrame()
    frame.text:SetText(recordText)
    MitCrit:ApplyAnimationSettings(frame, critical, style)
    MitCrit:UpdateActiveFrames(frame)
    if playSound ~= false then
        MitCrit:PlayRecordSound(critical)
    end
end

function MitCrit:UpdateActiveFrames(activeFrame)
    MitCrit.displayFramePoolSize = MitCrit.displayFramePoolSize or 5
    MitCrit.activeFrames = MitCrit.activeFrames or {}
    local active = MitCrit.activeFrames

    for i = #active, 1, -1 do
        if active[i] == activeFrame then
            table.remove(active, i)
        end
    end
    table.insert(active, 1, activeFrame)

    while #active > MitCrit.displayFramePoolSize do
        local old = table.remove(active)
        if old then
            old.playing = false
            old:Hide()
        end
    end

    for i, frame in ipairs(active) do
        MitCrit:SizeAndCenterFrame(frame, (i - 1) * 30)
    end
end

function MitCrit:RemoveActiveFrame(frame)
    if not MitCrit.activeFrames then return end
    for i = #MitCrit.activeFrames, 1, -1 do
        if MitCrit.activeFrames[i] == frame then
            table.remove(MitCrit.activeFrames, i)
            break
        end
    end
    for i, f in ipairs(MitCrit.activeFrames) do
        MitCrit:SizeAndCenterFrame(f, (i - 1) * 30)
    end
end

function MitCrit:PlayTestAnimation(critical, style)
    local testFrame = MitCrit:GetTestFrame()
    local recordText = critical and "MitCrit test critical animation" or "MitCrit test regular animation"

    testFrame.text:SetText(recordText)
    MitCrit:ApplyAnimationSettings(testFrame, critical, style or (critical and "allTime" or "boss"))
    MitCrit:SizeTestFrame(testFrame)
    MitCrit:PlayRecordSound(critical)
end

function MitCrit:PlayTestBurst(count, critical, interval, style)
    local total = tonumber(count) or 3
    if total < 1 then total = 1 end
    if total > 10 then total = 10 end
    local step = tonumber(interval) or 0.15
    if step < 0 then step = 0 end
    for i = 1, total do
        local delay = (i - 1) * step
        if delay > 0 and C_Timer and C_Timer.After then
            C_Timer.After(delay, function()
                MitCrit:PlayTestRecord(critical, i, total, style)
            end)
        else
            MitCrit:PlayTestRecord(critical, i, total, style)
        end
    end
end

function MitCrit:BuildTestRecordPopup(style, critical, index, total)
    local spellName = critical and "Eviscerate" or "Sinister Strike"
    local spellSchool = 1
    local schoolColor = MitCrit.spellSchools[spellSchool] or MitCrit.spellSchools[0]
    local amount = 1000 + (index - 1) * 111

    if style == "allTime" then
        amount = 124567 + (index - 1) * 777
        return MitCrit:BuildAllTimeBestText(spellName, schoolColor, amount, critical), "allTime"
    end
    if style == "boss" then
        amount = 98765 + (index - 1) * 555
        return MitCrit:BuildBossBestText("Boss", spellName, schoolColor, amount, critical), "boss"
    end
    if style == "near95" then
        amount = 97200 + (index - 1) * 100
        return MitCrit:BuildNearRecordText(spellName, schoolColor, amount, critical, 97.2, "near95"), "near95"
    end
    if style == "near90" then
        amount = 92300 + (index - 1) * 100
        return MitCrit:BuildNearRecordText(spellName, schoolColor, amount, critical, 92.3, "near90"), "near90"
    end
    if style == "streak" then
        amount = 88444 + (index - 1) * 444
        local streak = math.max(2, index + 1)
        local total = amount * streak
        return MitCrit:BuildStreakText(spellName, schoolColor, amount, streak, total), "streak"
    end

    local label = critical and "critical" or "regular"
    local recordText = ("Test %s record %d/%d: %s"):format(label, index, total, MitCrit:FormatNumber(amount))
    return recordText, style
end

function MitCrit:PlayTestRecord(critical, index, total, style)
    local recordText, resolvedStyle = MitCrit:BuildTestRecordPopup(style, critical, index, total)
    MitCrit:PlayRecordImmediate(recordText, critical, resolvedStyle)
end

function MitCrit:ApplyAnimationSettings(frame, critical, style)
    if not frame then return end
    if frame.text then
        MitCrit:ApplyFontStyle()
    end
    frame.recordStyle = style or "allTime"
    frame.isCritical = critical and true or false
    if frame.recordStyle == "allTime" then
        frame.text:SetScale(critical and 1.65 or 1.2)
        frame.scaleMax = critical and 2.25 or 1.55
        frame.settleScale = critical and 1.55 or 1.15
        frame.shakeAmp = critical and 2.5 or 1
        frame.shakeDur = nil
    elseif frame.recordStyle == "boss" then
        frame.text:SetScale(critical and 1.35 or 1.05)
        frame.scaleMax = critical and 1.75 or 1.25
        frame.settleScale = critical and 1.3 or 1.0
        frame.shakeAmp = critical and 1.9 or 0.8
        frame.shakeDur = critical and nil or 0.2
    elseif frame.recordStyle == "streak" then
        frame.text:SetScale(1.3)
        frame.scaleMax = 1.8
        frame.settleScale = 1.25
        frame.shakeAmp = 1.4
        frame.shakeDur = nil
    elseif frame.recordStyle == "near95" then
        frame.text:SetScale(1.15)
        frame.scaleMax = 1.45
        frame.settleScale = 1.1
        frame.shakeAmp = critical and 1.5 or 1.1
        frame.shakeDur = critical and 0.45 or 0.28
    else
        frame.text:SetScale(1.05)
        frame.scaleMax = 1.25
        frame.settleScale = 1.0
        frame.shakeAmp = 0
        frame.shakeDur = 0
    end
    frame.holdDur = nil
    if not critical then
        frame.shakeAmp = 0
        frame.shakeDur = 0
    end
    frame.elapsed = 0
    frame.playing = true
    frame.content:SetAlpha(0)
    frame.content:SetScale(1)
    MitCrit:UpdateAllTimeCritBlades(frame, 0, 1)
    frame:Show()
end

function MitCrit:GetTestFrame()
    if not MitCrit.testFrame then
        local f = CreateFrame("Frame", "MitCritTestFrame", UIParent)
        f:SetSize(200, 100)
        f:SetPoint("CENTER", UIParent, "CENTER", 0, 200)

        f.content = CreateFrame("Frame", nil, f)
        f.content:SetPoint("CENTER")
        f.content:SetSize(200, 100)
        MitCrit:EnsureAllTimeCritBlades(f)

        f.text = f.content:CreateFontString(nil, "OVERLAY")
        f.text:SetPoint("CENTER")
        f.text:SetFont(MitCritSettings.savedVars.fontFace or MitCritSettings.defaults.fontFace or "Fonts\\FRIZQT__.TTF",
            MitCritSettings.savedVars.fontSize)
        f.text:SetJustifyH("CENTER")
        f.text:SetJustifyV("MIDDLE")
        MitCrit:ApplyFontStyle()

        f.elapsed = 0
        f.playing = false
        f:SetScript("OnUpdate", function(self, elapsed)
            if not self.playing then return end
            self.elapsed = self.elapsed + elapsed

            local popDur = 0.12
            local settleDur = 0.15
            local holdDur = MitCritSettings.savedVars.duration or 0.6
            local fadeDur = 0.35
            local total = popDur + settleDur + holdDur + fadeDur
            local maxScale = self.scaleMax or 1.6
            local settleScale = self.settleScale or 1
            local shakeAmp = self.shakeAmp or 0
            local shakeDur = self.shakeDur or total

            local t = self.elapsed
            local scale = 1
            local alpha = 1

            if t < popDur then
                local p = t / popDur
                scale = 1 + (maxScale - 1) * p
                alpha = p
            elseif t < popDur + settleDur then
                local p = (t - popDur) / settleDur
                scale = maxScale + (settleScale - maxScale) * p
                alpha = 1
            elseif t < popDur + settleDur + holdDur then
                scale = settleScale
                alpha = 1
            elseif t < total then
                local p = (t - popDur - settleDur - holdDur) / fadeDur
                scale = settleScale
                alpha = 1 - p
            else
                self.playing = false
                self.elapsed = 0
                self.content:SetAlpha(1)
                self.content:SetScale(1)
                MitCrit:UpdateAllTimeCritBlades(self, 0, 1)
                self:Hide()
                return
            end

            local jitterX, jitterY = 0, 0
            if shakeAmp > 0 and t < shakeDur then
                local decay = 1 - (t / shakeDur)
                local amp = shakeAmp * decay
                jitterX = math.sin(t * 60) * amp
                jitterY = math.cos(t * 45) * amp * 0.7
            end

            self.content:SetScale(scale)
            self.content:SetAlpha(alpha)
            self.content:ClearAllPoints()
            self.content:SetPoint("CENTER", self, "CENTER", jitterX, jitterY)
            MitCrit:UpdateAllTimeCritBlades(self, alpha, scale)
        end)

        MitCrit.testFrame = f
    end

    return MitCrit.testFrame
end

function MitCrit:SizeTestFrame(frame)
    if not frame then return end
    local textScale = frame.text:GetScale() or 1
    local width = (frame.text:GetStringWidth() * textScale) + 40
    local height = (frame.text:GetStringHeight() * textScale) + 28
    frame.content:SetSize(width, height)
    frame:SetSize(width, height)
    frame:ClearAllPoints()
    frame:SetPoint("CENTER", UIParent, "CENTER", 0, 200)
    MitCrit:LayoutAllTimeCritBlades(frame)
end

function MitCrit:SizeAndCenterFrame(frame, yOffset)
    if not frame then return end
    local textScale = frame.text:GetScale() or 1
    local width = (frame.text:GetStringWidth() * textScale) + 40
    local height = (frame.text:GetStringHeight() * textScale) + 28
    frame.content:SetSize(width, height)
    frame:SetSize(width, height)
    frame:ClearAllPoints()
    frame:SetPoint("CENTER", MitCrit.anchorFrame or UIParent, "CENTER", 0, yOffset or 0)
    MitCrit:LayoutAllTimeCritBlades(frame)
end

function MitCrit:FormatNumber(value)
    if type(value) ~= "number" then return tostring(value) end
    if value < 1000 and value > -1000 then
        return tostring(value)
    end
    local sign = value < 0 and "-" or ""
    local integer = math.floor(math.abs(value) + 0.5)
    local str = tostring(integer)
    local formatted = str:gsub("^(-?%d+)(%d%d%d)", "%1,%2")
    while formatted ~= str do
        str = formatted
        formatted = str:gsub("^(-?%d+)(%d%d%d)", "%1,%2")
    end
    return sign .. formatted
end

function MitCrit:Print(msg)
    if not msg then return end
    local coloredMsg = COMMON_GRAY_COLOR:WrapTextInColorCode(msg)
    print(string.format("%s %s", MitCrit.colorName, coloredMsg))
end

return MitCrit
