local NS = rawget(_G, "MCDVoiceCooldownNS") or {}
_G.MCDVoiceCooldownNS = NS

NS.AceOptions = NS.AceOptions or {}

local LibStubGlobal = rawget(_G, "LibStub")
local GetNumClasses = rawget(_G, "GetNumClasses")
local GetClassInfo = rawget(_G, "GetClassInfo")
local GetNumSpecializationsForClassID = rawget(_G, "GetNumSpecializationsForClassID")
local GetSpecializationInfoForClassID = rawget(_G, "GetSpecializationInfoForClassID")

local L = NS.L or function(key, ...) if select("#", ...) > 0 then return string.format(tostring(key), ...) end return tostring(key) end
local OPTION_NAME = L("OPTION_NAME")
local SAVED_ENTRY_ROW_HEIGHT = 20
local SAVED_SECTION_HEADING_HEIGHT = 18
local SAVED_SECTION_HEADER_HEIGHT = 26
local SAVED_SCROLL_HEIGHT = 330
local MAIN_WINDOW_HEIGHT = 800
local DEFAULT_COLLECTION_ICON = "Interface\\Icons\\INV_Misc_Note_01"
local ALL_CLASSES_ID = 0
local ALL_SPECS_ID = 0
local OBJECT_TYPE_SPELL = "spell"
local OBJECT_TYPE_ITEM = "item"

local SOUND_ROOT = "Interface\\AddOns\\MCDVoiceCooldown\\Media\\Sounds\\"
local DEFAULT_BUILTIN_SOUND_FILE = "AirHorn.ogg"
local BUILTIN_SOUND_FILES = {
    "AcousticGuitar.ogg",
    "Adds.ogg",
    "aggro.ogg",
    "AirHorn.ogg",
    "Applause.ogg",
    "Arrow_Swoosh.ogg",
    "bam.ogg",
    "BananaPeelSlip.ogg",
    "BatmanPunch.ogg",
    "bear_polar.ogg",
    "bigkiss.ogg",
    "BikeHorn.ogg",
    "BITE.ogg",
    "Blast.ogg",
    "Bleat.ogg",
    "Boss.ogg",
    "BoxingArenaSound.ogg",
    "Brass.mp3",
    "burp4.ogg",
    "CartoonVoiceBaritone.ogg",
    "CartoonWalking.ogg",
    "cat2.ogg",
    "CatMeow2.ogg",
    "chant2.ogg",
    "chant4.ogg",
    "ChickenAlarm.ogg",
    "chimes.ogg",
    "Circle.ogg",
    "cookie.ogg",
    "CowMooing.ogg",
    "Cross.ogg",
    "Diamond.ogg",
    "DontRelease.ogg",
    "DoubleWhoosh.ogg",
    "Drums.ogg",
    "Empowered.ogg",
    "ErrorBeep.ogg",
    "ESPARK1.ogg",
    "Fireball.ogg",
    "Focus.ogg",
    "Gasp.ogg",
    "Glass.mp3",
    "GoatBleating.ogg",
    "heartbeat.ogg",
    "HeartbeatSingle.ogg",
    "hic3.ogg",
    "huh_1.ogg",
    "hurricane.ogg",
    "hyena.ogg",
    "Idiot.ogg",
    "kaching.ogg",
    "KittenMeow.ogg",
    "Left.ogg",
    "moan.ogg",
    "Moon.ogg",
    "Next.ogg",
    "OhNo.ogg",
    "panther1.ogg",
    "phone.ogg",
    "Portal.ogg",
    "Protected.ogg",
    "PUNCH.ogg",
    "rainroof.ogg",
    "Release.ogg",
    "Right.ogg",
    "RingingPhone.ogg",
    "RoaringLion.ogg",
    "RobotBlip.ogg",
    "rocket.ogg",
    "RoosterChickenCalls.ogg",
    "RunAway.ogg",
    "SharpPunch.ogg",
    "SheepBleat.ogg",
    "shipswhistle.ogg",
    "shot.ogg",
    "Shotgun.ogg",
    "Skull.ogg",
    "snakeatt.ogg",
    "sneeze.ogg",
    "sonar.ogg",
    "splash.ogg",
    "Spread.ogg",
    "Square.ogg",
    "Squeakypig.ogg",
    "SqueakyToyShort.ogg",
    "SquishFart.ogg",
    "Stack.ogg",
    "Star.ogg",
    "Switch.ogg",
    "swordecho.ogg",
    "SynthChord.ogg",
    "TadaFanfare.ogg",
    "Taunt.ogg",
    "TempleBellHuge.ogg",
    "throwknife.ogg",
    "thunder.ogg",
    "Torch.ogg",
    "Triangle.ogg",
    "WarningSiren.ogg",
    "WaterDrop.ogg",
    "wickedmalelaugh1.ogg",
    "wilhelm.ogg",
    "wlaugh.ogg",
    "wolf5.ogg",
    "Xylophone.ogg",
    "yeehaw.ogg",
}
local BUILTIN_SOUND_LIST = nil
local BUILTIN_SOUND_ALIASES = nil

local function GetApi()
    return NS.API
end

local function IsLoadedScope(api, classID, specID, currentClassID, currentSpecID)
    classID = tonumber(classID) or 0
    specID = tonumber(specID) or 0
    currentClassID = tonumber(currentClassID) or 0
    currentSpecID = tonumber(currentSpecID) or 0
    if api and type(api.IsActiveScopeLoaded) == "function" then
        return api.IsActiveScopeLoaded(classID, specID, currentClassID, currentSpecID)
    end
    if classID == ALL_CLASSES_ID then
        return true
    end
    return classID == currentClassID and (specID == currentSpecID or specID == ALL_SPECS_ID)
end

function NS.AceOptions:GetOptionName()
    return NS.ADDON_DISPLAY_NAME or OPTION_NAME
end

local function EnsureBloodlustConfigTable()
    local db = MCDVoiceCooldownDB
    if type(db) ~= "table" then
        db = {}
        MCDVoiceCooldownDB = db
    end

    if type(db.bloodlustConfig) ~= "table" then
        db.bloodlustConfig = {}
    end
    if type(db.bloodlustConfig.customSoundPaths) ~= "table" then
        db.bloodlustConfig.customSoundPaths = { "", "", "", "", "" }
    end

    return db.bloodlustConfig
end

local function TrimText(value)
    return tostring(value or ""):match("^%s*(.-)%s*$")
end

NS.AceOptions.TrimText = TrimText

local function NormalizeIconID(value)
    local iconID = tonumber(TrimText(value))
    if iconID and iconID > 0 then
        return math.floor(iconID)
    end
    return nil
end

function NS.AceOptions:NormalizeIconID(value)
    return NormalizeIconID(value)
end

function NS.AceOptions:GetDefaultCollectionIcon()
    return DEFAULT_COLLECTION_ICON
end

function NS.AceOptions:ResolveCollectionIcon(value)
    return NormalizeIconID(value) or DEFAULT_COLLECTION_ICON
end

local function StripAudioExtension(fileName)
    local value = tostring(fileName or "")
    return (value:gsub("%.[Oo][Gg][Gg]$", ""):gsub("%.[Mm][Pp]3$", ""))
end

local function CanonicalSoundPath(value)
    local path = TrimText(value)
    if path == "" then
        return ""
    end
    return (path:gsub("/", "\\"))
end

local function EnsureBuiltinSoundTables()
    if BUILTIN_SOUND_LIST and BUILTIN_SOUND_ALIASES then
        return
    end

    BUILTIN_SOUND_LIST = {}
    BUILTIN_SOUND_ALIASES = {}

    for _, fileName in ipairs(BUILTIN_SOUND_FILES) do
        local fullPath = SOUND_ROOT .. fileName
        local displayName = StripAudioExtension(fileName)
        BUILTIN_SOUND_LIST[fullPath] = displayName

        local noExtPath = SOUND_ROOT .. StripAudioExtension(fileName)
        local relativePath = "Media\\Sounds\\" .. fileName
        local relativeNoExt = "Media\\Sounds\\" .. StripAudioExtension(fileName)

        BUILTIN_SOUND_ALIASES[string.lower(fullPath)] = fullPath
        BUILTIN_SOUND_ALIASES[string.lower(noExtPath)] = fullPath
        BUILTIN_SOUND_ALIASES[string.lower(relativePath)] = fullPath
        BUILTIN_SOUND_ALIASES[string.lower(relativeNoExt)] = fullPath
        BUILTIN_SOUND_ALIASES[string.lower(fileName)] = fullPath
        BUILTIN_SOUND_ALIASES[string.lower(StripAudioExtension(fileName))] = fullPath
    end
end

function NS.AceOptions:GetBuiltinSoundList()
    EnsureBuiltinSoundTables()
    return BUILTIN_SOUND_LIST
end

function NS.AceOptions:GetDefaultBuiltinSoundPath()
    EnsureBuiltinSoundTables()
    local defaultPath = SOUND_ROOT .. DEFAULT_BUILTIN_SOUND_FILE
    if BUILTIN_SOUND_LIST[defaultPath] then
        return defaultPath
    end
    local first = BUILTIN_SOUND_FILES[1]
    return first and (SOUND_ROOT .. first) or ""
end

function NS.AceOptions:NormalizeSoundPath(value)
    local path = CanonicalSoundPath(value)
    if path == "" then
        return ""
    end

    EnsureBuiltinSoundTables()
    return BUILTIN_SOUND_ALIASES[string.lower(path)] or path
end

function NS.AceOptions:IsBuiltinSoundPath(value)
    local path = self:NormalizeSoundPath(value)
    if path == "" then
        return false
    end

    EnsureBuiltinSoundTables()
    return BUILTIN_SOUND_LIST[path] ~= nil
end

local function GetFileNameFromPath(value)
    local path = tostring(value or ""):gsub("/", "\\")
    return path:match("([^\\]+)$") or path
end

function NS.AceOptions:GetBuiltinSoundDisplayName(value)
    local path = self:NormalizeSoundPath(value)
    if path == "" then
        return ""
    end

    EnsureBuiltinSoundTables()
    return BUILTIN_SOUND_LIST[path] or StripAudioExtension(GetFileNameFromPath(path))
end

local function BuildSoundDetailForEntry(entry, modeTts, modeSound)
    if type(entry) ~= "table" then
        return ""
    end

    local notifyMode = tostring(entry.notifyMode or modeSound or "sound")
    if notifyMode == tostring(modeTts or "tts") then
        local text = TrimText(entry.ttsText or "")
        if text == "" then
            text = L("TTS_READY_DEFAULT")
        end
        return L("SAVED_VOICE_TTS", text)
    end

    local soundPath = tostring(entry.soundPath or "")
    local normalizedPath = NS.AceOptions:NormalizeSoundPath(soundPath)
    if normalizedPath ~= "" and NS.AceOptions:IsBuiltinSoundPath(normalizedPath) then
        return L("SAVED_VOICE_BUILTIN", NS.AceOptions:GetBuiltinSoundDisplayName(normalizedPath))
    end

    local fileName = GetFileNameFromPath(soundPath)
    if TrimText(fileName) == "" and normalizedPath ~= "" then
        fileName = GetFileNameFromPath(normalizedPath)
    end
    if TrimText(fileName) == "" then
        fileName = L("ENTRY_UNNAMED")
    end
    return L("SAVED_VOICE_CUSTOM", fileName)
end

local function BuildEntryKey(classID, specID, index)
    return string.format("%d:%d:%d", tonumber(classID) or 0, tonumber(specID) or 0, tonumber(index) or 0)
end

local function ParseEntryKey(key)
    local classID, specID, index = tostring(key or ""):match("^(%-?%d+):(%-?%d+):(%-?%d+)$")
    return tonumber(classID) or 0, tonumber(specID) or 0, tonumber(index) or 0
end

local function BuildGroupKey(classID, specID, groupID)
    return string.format("group:%d:%d:%s", tonumber(classID) or 0, tonumber(specID) or 0, tostring(groupID or ""))
end

local function ParseGroupKey(key)
    local classID, specID, groupID = tostring(key or ""):match("^group:(%-?%d+):(%-?%d+):(.+)$")
    return tonumber(classID) or 0, tonumber(specID) or 0, tostring(groupID or "")
end


local function GroupRefToKey(scopeClassID, scopeSpecID, ref)
    scopeClassID = tonumber(scopeClassID) or 0
    scopeSpecID = tonumber(scopeSpecID) or 0

    if type(ref) == "table" and tostring(ref.type or "") == "group" then
        local groupID = tostring(ref.id or ref.groupID or "")
        if groupID ~= "" then
            return BuildGroupKey(scopeClassID, scopeSpecID, groupID), scopeClassID, scopeSpecID, groupID
        end
        return nil
    end

    local value = TrimText(ref)
    if value == "" then
        return nil
    end

    local classID, specID, groupID = ParseGroupKey(value)
    if groupID ~= "" and classID >= 0 and specID >= 0 then
        return BuildGroupKey(classID, specID, groupID), classID, specID, groupID
    end

    return nil
end

local function IsSameScopeGroupRef(scopeClassID, scopeSpecID, ref)
    local key, classID, specID, groupID = GroupRefToKey(scopeClassID, scopeSpecID, ref)
    if key and classID == tonumber(scopeClassID) and specID == tonumber(scopeSpecID) and groupID ~= "" then
        return key, groupID
    end
    return nil
end

local function EnsureRootDB()
    if type(MCDVoiceCooldownDB) ~= "table" then
        MCDVoiceCooldownDB = {}
    end
    if type(MCDVoiceCooldownDB.collectionData) ~= "table" then
        MCDVoiceCooldownDB.collectionData = {}
    end
    if type(MCDVoiceCooldownDB.collectionSerial) ~= "number" then
        MCDVoiceCooldownDB.collectionSerial = 0
    end
    if type(MCDVoiceCooldownDB.deletedEntries) ~= "table" then
        MCDVoiceCooldownDB.deletedEntries = {}
    end
    return MCDVoiceCooldownDB
end

local function EnsureSavedListOrderDB()
    local db = EnsureRootDB()
    if type(db.savedListOrder) ~= "table" then
        db.savedListOrder = {}
    end
    if type(db.savedListOrder.loaded) ~= "table" then
        db.savedListOrder.loaded = {}
    end
    if type(db.savedListOrder.unloaded) ~= "table" then
        db.savedListOrder.unloaded = {}
    end
    return db.savedListOrder
end

local function StripDropSuffix(key)
    key = tostring(key or "")
    key = key:gsub(":after$", "")
    key = key:gsub(":inside$", "")
    key = key:gsub(":empty$", "")
    return key
end

local function RemoveKeyFromSavedListOrders(key)
    key = StripDropSuffix(key)
    if key == "" then
        return
    end
    local orderDB = EnsureSavedListOrderDB()
    for _, section in ipairs({ "loaded", "unloaded" }) do
        local list = orderDB[section]
        for i = #list, 1, -1 do
            if tostring(list[i] or "") == key then
                table.remove(list, i)
            end
        end
    end
end

local function RecordSavedListDisplayMove(sourceKey, targetKey, targetSection, insertAfter)
    sourceKey = StripDropSuffix(sourceKey)
    targetKey = StripDropSuffix(targetKey)
    targetSection = tostring(targetSection or "")
    if sourceKey == "" or targetKey == "" or sourceKey == targetKey then
        return
    end
    if targetSection ~= "unloaded" then
        targetSection = "loaded"
    end

    local orderDB = EnsureSavedListOrderDB()
    RemoveKeyFromSavedListOrders(sourceKey)
    local list = orderDB[targetSection]
    local targetPos = nil
    for i, key in ipairs(list) do
        if tostring(key or "") == targetKey then
            targetPos = i
            break
        end
    end
    if not targetPos then
        list[#list + 1] = sourceKey
        return
    end
    local insertPos = targetPos + (insertAfter and 1 or 0)
    table.insert(list, math.max(1, math.min(insertPos, #list + 1)), sourceKey)
end

local function EnsureCollectionScope(classID, specID)
    local db = EnsureRootDB()
    classID = tonumber(classID) or 0
    specID = tonumber(specID) or 0
    if classID == ALL_CLASSES_ID then
        specID = ALL_SPECS_ID
    end
    if classID < 0 or specID < 0 then
        return nil
    end
    if type(db.collectionData[classID]) ~= "table" then
        db.collectionData[classID] = {}
    end
    if type(db.collectionData[classID][specID]) ~= "table" then
        db.collectionData[classID][specID] = {}
    end
    local scope = db.collectionData[classID][specID]
    if type(scope.root) ~= "table" then
        scope.root = {}
    end
    if type(scope.groups) ~= "table" then
        scope.groups = {}
    end
    return scope
end

local function MakeValidEntrySet(entryMap)
    local api = GetApi()
    local valid = {}
    if not api or type(entryMap) ~= "table" then
        return valid
    end
    local indices = type(api.GetOrderedEntryIndices) == "function" and api.GetOrderedEntryIndices(entryMap) or {}
    for _, index in ipairs(indices) do
        valid[tonumber(index) or 0] = true
    end
    return valid
end

local function EntryRefToKey(scopeClassID, scopeSpecID, ref)
    scopeClassID = tonumber(scopeClassID) or 0
    scopeSpecID = tonumber(scopeSpecID) or 0

    if type(ref) == "number" then
        local index = tonumber(ref) or 0
        if scopeClassID >= 0 and scopeSpecID >= 0 and index > 0 then
            return BuildEntryKey(scopeClassID, scopeSpecID, index), scopeClassID, scopeSpecID, index
        end
        return nil
    end

    local value = TrimText(ref)
    if value == "" then
        return nil
    end

    local classID, specID, index = ParseEntryKey(value)
    if classID >= 0 and specID >= 0 and index > 0 then
        return BuildEntryKey(classID, specID, index), classID, specID, index
    end

    index = tonumber(value) or 0
    if scopeClassID >= 0 and scopeSpecID >= 0 and index > 0 then
        return BuildEntryKey(scopeClassID, scopeSpecID, index), scopeClassID, scopeSpecID, index
    end

    return nil
end

local function EntryKeyExists(key)
    local api = GetApi()
    if not api then
        return false
    end

    local classID, specID, index = ParseEntryKey(key)
    if classID < 0 or specID < 0 or index <= 0 then
        return false
    end

    local map = api.GetStoredEntryMap(classID, specID)
    if type(map) ~= "table" then
        return false
    end
    return api.GetEntry(map, index) ~= nil
end

local function NormalizeGroupBasics(group)
    if type(group) ~= "table" then
        return false
    end
    group.name = TrimText(group.name)
    if group.name == "" then
        group.name = L("COLLECTION_UNNAMED")
    end
    group.iconID = NormalizeIconID(group.iconID)
    if type(group.entries) ~= "table" then
        group.entries = {}
    end
    return true
end

local function NormalizeCollectionScope(scope, entryMap, scopeClassID, scopeSpecID)
    if type(scope) ~= "table" then
        return nil
    end
    if type(scope.root) ~= "table" then
        scope.root = {}
    end
    if type(scope.groups) ~= "table" then
        scope.groups = {}
    end

    scopeClassID = tonumber(scopeClassID) or 0
    scopeSpecID = tonumber(scopeSpecID) or 0

    local valid = MakeValidEntrySet(entryMap)
    local seenEntryKeys = {}
    local nestedGroupIDs = {}
    local cleanedGroups = {}

    local function cleanGroup(groupID, stack)
        groupID = tostring(groupID or "")
        local group = scope.groups[groupID]
        if groupID == "" or type(group) ~= "table" then
            return false
        end
        if cleanedGroups[groupID] then
            return true
        end

        NormalizeGroupBasics(group)
        stack = stack or {}
        if stack[groupID] then
            return false
        end
        stack[groupID] = true

        local cleaned = {}
        for _, value in ipairs(group.entries or {}) do
            local groupKey, childGroupID = IsSameScopeGroupRef(scopeClassID, scopeSpecID, value)
            if groupKey and childGroupID ~= groupID and type(scope.groups[childGroupID]) == "table" and not stack[childGroupID] and not nestedGroupIDs[childGroupID] then
                if cleanGroup(childGroupID, stack) then
                    cleaned[#cleaned + 1] = groupKey
                    nestedGroupIDs[childGroupID] = true
                end
            else
                local entryKey, entryClassID, entrySpecID, entryIndex = EntryRefToKey(scopeClassID, scopeSpecID, value)
                if entryKey and entryClassID >= 0 and entrySpecID >= 0 and entryIndex > 0 and EntryKeyExists(entryKey) and not seenEntryKeys[entryKey] then
                    cleaned[#cleaned + 1] = entryKey
                    seenEntryKeys[entryKey] = true
                end
            end
        end

        group.entries = cleaned
        stack[groupID] = nil
        cleanedGroups[groupID] = true
        return true
    end

    for groupID, group in pairs(scope.groups) do
        groupID = tostring(groupID or "")
        if groupID == "" or type(group) ~= "table" then
            scope.groups[groupID] = nil
        else
            NormalizeGroupBasics(group)
        end
    end

    for _, item in ipairs(scope.root or {}) do
        if type(item) == "table" and item.type == "group" then
            local groupID = tostring(item.id or "")
            if groupID ~= "" and type(scope.groups[groupID]) == "table" then
                cleanGroup(groupID, {})
            end
        end
    end

    for groupID in pairs(scope.groups) do
        cleanGroup(groupID, {})
    end

    local seenRootGroups = {}
    local newRoot = {}

    for _, item in ipairs(scope.root or {}) do
        if type(item) == "table" then
            if item.type == "entry" then
                local index = tonumber(item.index) or 0
                local key = BuildEntryKey(scopeClassID, scopeSpecID, index)
                if index > 0 and valid[index] and not seenEntryKeys[key] then
                    newRoot[#newRoot + 1] = { type = "entry", index = index }
                    seenEntryKeys[key] = true
                end
            elseif item.type == "group" then
                local groupID = tostring(item.id or "")
                if groupID ~= "" and type(scope.groups[groupID]) == "table" and not nestedGroupIDs[groupID] and not seenRootGroups[groupID] then
                    newRoot[#newRoot + 1] = { type = "group", id = groupID }
                    seenRootGroups[groupID] = true
                end
            end
        end
    end

    for groupID, group in pairs(scope.groups) do
        groupID = tostring(groupID or "")
        if groupID ~= "" and type(group) == "table" and not nestedGroupIDs[groupID] and not seenRootGroups[groupID] then
            newRoot[#newRoot + 1] = { type = "group", id = groupID }
            seenRootGroups[groupID] = true
        end
    end

    local api = GetApi()
    local ordered = api and type(api.GetOrderedEntryIndices) == "function" and api.GetOrderedEntryIndices(entryMap) or {}
    for _, index in ipairs(ordered) do
        index = tonumber(index) or 0
        local key = BuildEntryKey(scopeClassID, scopeSpecID, index)
        if index > 0 and valid[index] and not seenEntryKeys[key] then
            newRoot[#newRoot + 1] = { type = "entry", index = index }
            seenEntryKeys[key] = true
        end
    end

    scope.root = newRoot
    return scope
end

local function RemoveEntryKeyFromCollectionScope(scope, entryKey, scopeClassID, scopeSpecID)
    local key, classID, specID, index = EntryRefToKey(scopeClassID, scopeSpecID, entryKey)
    if type(scope) ~= "table" or not key or index <= 0 then
        return nil
    end

    if classID == tonumber(scopeClassID) and specID == tonumber(scopeSpecID) then
        for i = #scope.root, 1, -1 do
            local item = scope.root[i]
            if type(item) == "table" and item.type == "entry" and tonumber(item.index) == index then
                table.remove(scope.root, i)
                return { container = "root", position = i }
            end
        end
    end

    for groupID, group in pairs(scope.groups or {}) do
        if type(group) == "table" and type(group.entries) == "table" then
            for i = #group.entries, 1, -1 do
                local groupEntryKey = EntryRefToKey(scopeClassID, scopeSpecID, group.entries[i])
                if groupEntryKey == key then
                    table.remove(group.entries, i)
                    return { container = "group", groupID = tostring(groupID), position = i }
                end
            end
        end
    end
    return nil
end

local function RemoveEntryFromCollectionScope(scope, entryIndex, scopeClassID, scopeSpecID)
    return RemoveEntryKeyFromCollectionScope(scope, BuildEntryKey(scopeClassID, scopeSpecID, entryIndex), scopeClassID, scopeSpecID)
end

local function RemoveGroupKeyFromCollectionScope(scope, groupKey, scopeClassID, scopeSpecID)
    local key, classID, specID, groupID = GroupRefToKey(scopeClassID, scopeSpecID, groupKey)
    if type(scope) ~= "table" or not key or groupID == "" or classID ~= tonumber(scopeClassID) or specID ~= tonumber(scopeSpecID) then
        return nil
    end

    for i = #scope.root, 1, -1 do
        local item = scope.root[i]
        if type(item) == "table" and item.type == "group" and tostring(item.id or "") == groupID then
            table.remove(scope.root, i)
            return { container = "root", position = i }
        end
    end

    for parentGroupID, group in pairs(scope.groups or {}) do
        if type(group) == "table" and type(group.entries) == "table" then
            for i = #group.entries, 1, -1 do
                local childGroupKey, childGroupID = IsSameScopeGroupRef(scopeClassID, scopeSpecID, group.entries[i])
                if childGroupKey == key and childGroupID == groupID then
                    table.remove(group.entries, i)
                    return { container = "group", groupID = tostring(parentGroupID), position = i }
                end
            end
        end
    end
    return nil
end

local function FindGroupKeyLocation(scope, groupKey, scopeClassID, scopeSpecID)
    local key, classID, specID, groupID = GroupRefToKey(scopeClassID, scopeSpecID, groupKey)
    if type(scope) ~= "table" or not key or groupID == "" or classID ~= tonumber(scopeClassID) or specID ~= tonumber(scopeSpecID) then
        return nil
    end

    for i, item in ipairs(scope.root or {}) do
        if type(item) == "table" and item.type == "group" and tostring(item.id or "") == groupID then
            return { container = "root", position = i }
        end
    end

    for parentGroupID, group in pairs(scope.groups or {}) do
        if type(group) == "table" and type(group.entries) == "table" then
            for i, value in ipairs(group.entries) do
                local childGroupKey, childGroupID = IsSameScopeGroupRef(scopeClassID, scopeSpecID, value)
                if childGroupKey == key and childGroupID == groupID then
                    return { container = "group", groupID = tostring(parentGroupID), position = i }
                end
            end
        end
    end
    return nil
end

local function GroupContainsGroup(scope, parentGroupID, childGroupID, scopeClassID, scopeSpecID, seen)
    if type(scope) ~= "table" then
        return false
    end
    parentGroupID = tostring(parentGroupID or "")
    childGroupID = tostring(childGroupID or "")
    if parentGroupID == "" or childGroupID == "" then
        return false
    end
    if parentGroupID == childGroupID then
        return true
    end
    seen = seen or {}
    if seen[parentGroupID] then
        return false
    end
    seen[parentGroupID] = true

    local group = scope.groups and scope.groups[parentGroupID]
    if type(group) ~= "table" or type(group.entries) ~= "table" then
        return false
    end

    for _, value in ipairs(group.entries) do
        local _, nestedGroupID = IsSameScopeGroupRef(scopeClassID, scopeSpecID, value)
        if nestedGroupID and (nestedGroupID == childGroupID or GroupContainsGroup(scope, nestedGroupID, childGroupID, scopeClassID, scopeSpecID, seen)) then
            return true
        end
    end
    return false
end

local function InsertGroupIntoRoot(scope, groupID, position)
    if type(scope) ~= "table" or tostring(groupID or "") == "" then
        return false
    end
    if type(scope.root) ~= "table" then
        scope.root = {}
    end
    local item = { type = "group", id = tostring(groupID) }
    position = tonumber(position) or (#scope.root + 1)
    table.insert(scope.root, math.max(1, math.min(position, #scope.root + 1)), item)
    return true
end

local function InsertGroupIntoGroup(scope, parentGroupID, childGroupKey, position)
    if type(scope) ~= "table" then
        return false
    end
    local group = scope.groups and scope.groups[tostring(parentGroupID or "")]
    if type(group) ~= "table" then
        return false
    end
    if type(group.entries) ~= "table" then
        group.entries = {}
    end
    position = tonumber(position) or (#group.entries + 1)
    table.insert(group.entries, math.max(1, math.min(position, #group.entries + 1)), tostring(childGroupKey))
    return true
end


local function RemoveEntryKeyFromAllCollectionScopes(entryKey)
    entryKey = TrimText(entryKey)
    if entryKey == "" then
        return 0
    end
    local db = EnsureRootDB()
    local removed = 0
    for classIDKey, classMap in pairs(db.collectionData or {}) do
        local scopeClassID = tonumber(classIDKey)
        if scopeClassID and scopeClassID >= 0 and type(classMap) == "table" then
            for specIDKey, scope in pairs(classMap) do
                local scopeSpecID = tonumber(specIDKey)
                if scopeSpecID and scopeSpecID >= 0 and type(scope) == "table" then
                    while RemoveEntryKeyFromCollectionScope(scope, entryKey, scopeClassID, scopeSpecID) do
                        removed = removed + 1
                    end
                end
            end
        end
    end
    return removed
end

local function NormalizeEntryTypeValue(value)
    value = tostring(value or "cooldown")
    if value ~= "cast" then
        value = "cooldown"
    end
    return value
end

local function NormalizeObjectTypeValue(value)
    value = tostring(value or OBJECT_TYPE_SPELL):lower()
    if value ~= OBJECT_TYPE_ITEM then
        value = OBJECT_TYPE_SPELL
    end
    return value
end

local function SameEntryIdentity(left, right)
    if type(left) ~= "table" or type(right) ~= "table" then
        return false
    end
    local leftID = tonumber(left.spellId or left.itemID) or 0
    local rightID = tonumber(right.spellId or right.itemID) or 0
    if leftID <= 0 or rightID <= 0 or math.floor(leftID) ~= math.floor(rightID) then
        return false
    end
    if NormalizeEntryTypeValue(left.entryType) ~= NormalizeEntryTypeValue(right.entryType) then
        return false
    end
    if NormalizeObjectTypeValue(left.objectType) ~= NormalizeObjectTypeValue(right.objectType) then
        return false
    end
    return true
end

local function DeleteMatchingEntriesFromMap(map, entry, classID, specID)
    if type(map) ~= "table" or type(entry) ~= "table" then
        return 0
    end
    local api = GetApi()
    local removed = 0
    for index, candidate in pairs(map) do
        local entryIndex = tonumber(index) or 0
        if entryIndex > 0 and SameEntryIdentity(candidate, entry) then
            if api and type(api.MarkEntryDeleted) == "function" then
                api.MarkEntryDeleted(classID, specID, candidate, entryIndex)
            end
            map[index] = nil
            RemoveEntryKeyFromAllCollectionScopes(BuildEntryKey(classID, specID, entryIndex))
            removed = removed + 1
        end
    end
    return removed
end

local function DeleteMatchingLegacyCastEntries(classID, specID, entry)
    if type(entry) ~= "table" or NormalizeEntryTypeValue(entry.entryType) ~= "cast" then
        return 0
    end
    local db = EnsureRootDB()
    local classMap = db.castSuccessConfigs and db.castSuccessConfigs[classID]
    local legacyMap = type(classMap) == "table" and classMap[specID] or nil
    if type(legacyMap) ~= "table" then
        return 0
    end
    local legacyProbe = {}
    for k, v in pairs(entry) do
        legacyProbe[k] = v
    end
    legacyProbe.entryType = "cast"
    return DeleteMatchingEntriesFromMap(legacyMap, legacyProbe, classID, specID)
end

local function ClearDeletedEntryMarker(classID, specID, entry, index)
    local api = GetApi()
    if api and type(api.ClearEntryDeletedMarker) == "function" then
        api.ClearEntryDeletedMarker(classID, specID, entry, index)
    end
end

local function FindEntryKeyLocation(scope, entryKey, scopeClassID, scopeSpecID)
    local key, classID, specID, index = EntryRefToKey(scopeClassID, scopeSpecID, entryKey)
    if type(scope) ~= "table" or not key or index <= 0 then
        return nil
    end

    if classID == tonumber(scopeClassID) and specID == tonumber(scopeSpecID) then
        for i, item in ipairs(scope.root or {}) do
            if type(item) == "table" and item.type == "entry" and tonumber(item.index) == index then
                return { container = "root", position = i }
            end
        end
    end

    for groupID, group in pairs(scope.groups or {}) do
        if type(group) == "table" and type(group.entries) == "table" then
            for i, value in ipairs(group.entries) do
                local groupEntryKey = EntryRefToKey(scopeClassID, scopeSpecID, value)
                if groupEntryKey == key then
                    return { container = "group", groupID = tostring(groupID), position = i }
                end
            end
        end
    end
    return nil
end

local function FindEntryLocation(scope, entryIndex, scopeClassID, scopeSpecID)
    return FindEntryKeyLocation(scope, BuildEntryKey(scopeClassID, scopeSpecID, entryIndex), scopeClassID, scopeSpecID)
end

local function FindGroupRootPosition(scope, groupID)
    groupID = tostring(groupID or "")
    for i, item in ipairs((scope and scope.root) or {}) do
        if type(item) == "table" and item.type == "group" and tostring(item.id or "") == groupID then
            return i
        end
    end
    return nil
end

local function BuildInlineTexture(texture, size, left, right, top, bottom)
    if type(texture) ~= "string" and type(texture) ~= "number" then
        return ""
    end

    size = tonumber(size) or 14
    if left and right and top and bottom then
        return string.format("|T%s:%d:%d:0:0:256:256:%d:%d:%d:%d|t ", tostring(texture), size, size, left, right, top,
            bottom)
    end
    return string.format("|T%s:%d:%d|t ", tostring(texture), size, size)
end

local function GetClassColorCode(classFile)
    local color = RAID_CLASS_COLORS and classFile and RAID_CLASS_COLORS[classFile]
    if not color then
        return nil
    end

    local r = math.floor(((tonumber(color.r) or 1) * 255) + 0.5)
    local g = math.floor(((tonumber(color.g) or 1) * 255) + 0.5)
    local b = math.floor(((tonumber(color.b) or 1) * 255) + 0.5)
    return string.format("|cff%02x%02x%02x", r, g, b)
end

local function BuildClassDisplayText(className, classFile)
    local icon = ""
    local coords = CLASS_ICON_TCOORDS and classFile and CLASS_ICON_TCOORDS[classFile]
    if coords then
        icon = BuildInlineTexture(
            "Interface\\GLUES\\CHARACTERCREATE\\UI-CHARACTERCREATE-CLASSES",
            14,
            math.floor((coords[1] or 0) * 256),
            math.floor((coords[2] or 1) * 256),
            math.floor((coords[3] or 0) * 256),
            math.floor((coords[4] or 1) * 256)
        )
    end

    local text = tostring(className or "-")
    local colorCode = GetClassColorCode(classFile)
    if colorCode then
        return icon .. colorCode .. text .. "|r"
    end
    return icon .. text
end

local function BuildSpecDisplayText(specName, specIcon)
    return BuildInlineTexture(specIcon, 14) .. tostring(specName or "-")
end

local function BuildSavedEntryLine(entry, selected, includeScopeText)
    local metaParts = {}
    local scopeText = TrimText(entry and entry.scopeText)
    if includeScopeText and scopeText ~= "" then
        metaParts[#metaParts + 1] = scopeText
    end
    local objectType = tostring(entry and entry.objectType or OBJECT_TYPE_SPELL)
    local idLabel = (objectType == OBJECT_TYPE_ITEM) and L("ENTRY_OBJECT_ITEM_ID") or L("ENTRY_OBJECT_SPELL_ID")
    metaParts[#metaParts + 1] = string.format("#%d", tonumber(entry and entry.index) or 0)
    metaParts[#metaParts + 1] = string.format("%s:%d", idLabel, tonumber(entry and entry.spellId) or 0)
    metaParts[#metaParts + 1] = tostring(entry and entry.modeText or "")
    if tostring(entry and entry.entryType or "") == "cast" and entry.delayEnabled == true and (tonumber(entry.delaySeconds) or 0) > 0 then
        metaParts[#metaParts + 1] = L("SAVED_CAST_DELAY", tonumber(entry.delaySeconds) or 0)
    end

    local prefix = selected and "|cffffd200>>|r " or ""
    return string.format("%s%s |cff808080%s|r", prefix, tostring(entry and entry.spellName or ""),
        table.concat(metaParts, "  "))
end

local function ApplySavedEntrySelectionStyle(item, selected)
    if not item or not item.frame then
        return
    end

    local frame = item.frame
    local bg = frame.mcdSelectedBG
    if not bg then
        bg = frame:CreateTexture(nil, "BACKGROUND")
        bg:SetPoint("TOPLEFT", frame, "TOPLEFT", -2, 1)
        bg:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 2, -1)
        frame.mcdSelectedBG = bg
    end

    item:SetHighlight("Interface\\Buttons\\WHITE8X8")
    if item.highlight and item.highlight.SetVertexColor then
        item.highlight:SetVertexColor(1, 1, 1, 0.06)
    end

    if selected then
        bg:SetColorTexture(1, 0.82, 0, 0.18)
        bg:Show()
    else
        bg:Hide()
    end
end


local function ApplyEditorDropdownStyle(widget)
    if not widget then
        return
    end

    if widget.dropdown and widget.dropdown.ClearAllPoints then
        widget.dropdown:ClearAllPoints()
        widget.dropdown:SetPoint("TOPLEFT", widget.frame, "TOPLEFT", -9, -14)
        widget.dropdown:SetPoint("BOTTOMRIGHT", widget.frame, "BOTTOMRIGHT", 23, 0)
    end

    if widget.text and widget.text.SetJustifyH then
        widget.text:SetJustifyH("LEFT")
    end
    if widget.text and widget.text.ClearAllPoints and widget.dropdown then
        local right = _G[widget.dropdown:GetName() .. "Right"]
        local left = _G[widget.dropdown:GetName() .. "Left"]
        if right and left then
            widget.text:ClearAllPoints()
            widget.text:SetPoint("LEFT", left, "LEFT", 25, 2)
            widget.text:SetPoint("RIGHT", right, "RIGHT", -43, 2)
        end
    end

    if widget.dropdown and not widget.dropdown.mcdEditorBG then
        local bg = widget.dropdown:CreateTexture(nil, "BACKGROUND")
        bg:SetPoint("TOPLEFT", widget.dropdown, "TOPLEFT", 16, -2)
        bg:SetPoint("BOTTOMRIGHT", widget.dropdown, "BOTTOMRIGHT", -32, 8)
        bg:SetColorTexture(0.16, 0.16, 0.16, 0.38)
        widget.dropdown.mcdEditorBG = bg
    end
end

local function ApplyEditorEditBoxStyle(widget)
    if not widget or not widget.editbox then
        return
    end

    local editbox = widget.editbox
    if not editbox.mcdEditorBG then
        local bg = editbox:CreateTexture(nil, "BACKGROUND")
        bg:SetAllPoints(editbox)
        bg:SetColorTexture(0.16, 0.16, 0.16, 0.40)
        editbox.mcdEditorBG = bg
    end
end

local function ApplyEditorCheckBoxStyle(widget)
    if not widget or not widget.checkbg then
        return
    end

    if widget.SetHeight then
        widget:SetHeight(44)
    end
    if widget.checkbg and widget.checkbg.ClearAllPoints then
        widget.checkbg:ClearAllPoints()
        widget.checkbg:SetPoint("TOPLEFT", widget.frame, "TOPLEFT", 0, -18)
    end
    if widget.check and widget.check.SetAllPoints then
        widget.check:SetAllPoints(widget.checkbg)
    end
    if widget.highlight and widget.highlight.SetAllPoints then
        widget.highlight:SetAllPoints(widget.checkbg)
    end
    if widget.text and widget.text.ClearAllPoints then
        widget.text:ClearAllPoints()
        widget.text:SetPoint("LEFT", widget.checkbg, "RIGHT", 0, 0)
        widget.text:SetPoint("RIGHT", widget.frame, "RIGHT", 0, 0)
    end
    if widget.image and widget.image.ClearAllPoints then
        widget.image:ClearAllPoints()
        widget.image:SetPoint("LEFT", widget.checkbg, "RIGHT", 1, 0)
    end

    if not widget.checkbg.mcdEditorBG then
        local bg = widget.frame:CreateTexture(nil, "BACKGROUND")
        bg:SetPoint("TOPLEFT", widget.checkbg, "TOPLEFT", 1, -1)
        bg:SetPoint("BOTTOMRIGHT", widget.checkbg, "BOTTOMRIGHT", -1, 1)
        bg:SetColorTexture(0.16, 0.16, 0.16, 0.40)
        widget.checkbg.mcdEditorBG = bg
    end
end

function NS.AceOptions:GetState()
    self.state = self.state or {}
    local api = GetApi()
    if not api then
        return self.state
    end

    if self.state.classID == nil or self.state.specID == nil then
        self:SyncScopeToCurrentSpec()
    end
    local classID = tonumber(self.state.classID) or 0
    local specID = tonumber(self.state.specID) or 0
    if classID == ALL_CLASSES_ID then
        self.state.specID = ALL_SPECS_ID
    end
    local modeTts, modeSound = api.GetModes()
    self.state.notifyMode = self.state.notifyMode or modeSound
    self.state.fixedCD = true
    self.state.checkTalent = self.state.checkTalent == true
    self.state.talentId = tonumber(self.state.talentId) or 0
    self.state.talentName = tostring(self.state.talentName or "")
    self.state.talentCD = tonumber(self.state.talentCD) or 0
    self.state.delayEnabled = self.state.delayEnabled == true
    self.state.delaySeconds = math.max(0, tonumber(self.state.delaySeconds) or 0)

    local defaultBuiltin = self:GetDefaultBuiltinSoundPath()
    self.state.builtinSoundPath = self:NormalizeSoundPath(self.state.builtinSoundPath or defaultBuiltin)
    if self.state.builtinSoundPath == "" or not self:IsBuiltinSoundPath(self.state.builtinSoundPath) then
        self.state.builtinSoundPath = defaultBuiltin
    end

    if TrimText(self.state.soundPath or "") == "" and tostring(self.state.notifyMode or "") ~= tostring(modeTts) then
        self.state.soundPath = self.state.builtinSoundPath
    else
        self.state.soundPath = self:NormalizeSoundPath(self.state.soundPath or "")
    end

    return self.state
end

function NS.AceOptions:SyncScopeToCurrentSpec()
    local api = GetApi()
    self.state = self.state or {}
    if not api or type(api.GetCurrentClassSpec) ~= "function" then
        return
    end

    local classID, specID = api.GetCurrentClassSpec()
    self.state.classID = tonumber(classID) or 0
    self.state.specID = tonumber(specID) or 0
    self.state.selectedKey = nil
    self.state.selectedCollectionKey = nil
end

function NS.AceOptions:GetClassOptionList()
    local options = {
        { classID = ALL_CLASSES_ID, className = L("ALL_CLASSES"), classFile = nil },
    }
    if type(GetNumClasses) ~= "function" or type(GetClassInfo) ~= "function" then
        return options
    end

    local classCount = tonumber(GetNumClasses()) or 0
    for classIndex = 1, classCount do
        local ok, className, classFile, classID = pcall(GetClassInfo, classIndex)
        classID = tonumber(classID) or 0
        if ok and classID > 0 and type(className) == "string" and className ~= "" then
            options[#options + 1] = {
                classID = classID,
                className = className,
                classFile = classFile,
            }
        end
    end

    return options
end

function NS.AceOptions:GetClassValues()
    local values = {}
    local classList = self:GetClassOptionList()
    for _, option in ipairs(classList) do
        values[option.classID] = BuildClassDisplayText(option.className, option.classFile)
    end
    return values
end

function NS.AceOptions:GetSpecOptionList(classID)
    classID = tonumber(classID) or 0
    local options = {
        {
            specID = ALL_SPECS_ID,
            specName = L("ALL_SPECS"),
            specIcon = nil,
        }
    }
    if classID == ALL_CLASSES_ID then
        return options
    end
    if classID < 0 then
        return options
    end

    if type(GetNumSpecializationsForClassID) ~= "function" or type(GetSpecializationInfoForClassID) ~= "function" then
        return options
    end

    local specCount = tonumber(GetNumSpecializationsForClassID(classID)) or 0
    for specIndex = 1, specCount do
        local ok, specID, specName, _, specIcon = pcall(GetSpecializationInfoForClassID, classID, specIndex)
        specID = tonumber(specID) or 0
        if ok and specID > 0 then
            options[#options + 1] = {
                specID = specID,
                specName = (type(specName) == "string" and specName ~= "") and specName or L("FALLBACK_SPEC", " " .. tostring(specID)),
                specIcon = specIcon,
            }
        end
    end

    return options
end

function NS.AceOptions:GetSpecValues(classID)
    local values = {}
    for _, option in ipairs(self:GetSpecOptionList(classID)) do
        values[option.specID] = BuildSpecDisplayText(option.specName, option.specIcon)
    end
    return values
end

function NS.AceOptions:EnsureValidScope()
    local state = self:GetState()
    if state.classID == nil then
        self:SyncScopeToCurrentSpec()
    end
    state.classID = tonumber(state.classID) or 0
    state.specID = tonumber(state.specID) or 0

    if state.classID == ALL_CLASSES_ID then
        state.specID = ALL_SPECS_ID
        return
    end

    if state.classID < 0 then
        self:SyncScopeToCurrentSpec()
    end

    local specValues = self:GetSpecValues(state.classID)
    if not specValues[state.specID] then
        state.specID = ALL_SPECS_ID
    end
end

function NS.AceOptions:ClearEditorFields()
    local api = GetApi()
    local state = self:GetState()
    local _, modeSound = api.GetModes()

    state.selectedKey = nil
    state.spellId = 0
    state.objectType = nil
    state.spellName = ""
    state.baseCD = 0
    state.fixedCD = true
    state.checkTalent = false
    state.talentId = 0
    state.talentName = ""
    state.talentCD = 0
    state.delayEnabled = false
    state.delaySeconds = 0
    state.notifyMode = modeSound
    state.ttsText = ""
    state.ttsRate = 0
    state.builtinSoundPath = self:GetDefaultBuiltinSoundPath()
    state.customSoundPath = ""
    state.useCustomSound = false
    state.soundPath = state.builtinSoundPath
end

function NS.AceOptions:LoadBloodlustConfig()
    local state = self:GetState()
    local cfg = EnsureBloodlustConfigTable()
    state.entryType = "bloodlust"
    state.bloodlustPaths = state.bloodlustPaths or {}

    for i = 1, 5 do
        state.bloodlustPaths[i] = self:NormalizeSoundPath(cfg.customSoundPaths[i] or "")
    end
end

function NS.AceOptions:GetBloodlustSummary()
    local state = self:GetState()
    local paths = state.bloodlustPaths
    if type(paths) ~= "table" then
        self:LoadBloodlustConfig()
        paths = state.bloodlustPaths
    end

    local count = 0
    for i = 1, 5 do
        if TrimText(paths[i] or "") ~= "" then
            count = count + 1
        end
    end

    return L("MSG_BLOODLUST_SUMMARY", count)
end

function NS.AceOptions:SaveBloodlustConfig()
    local api = GetApi()
    local state = self:GetState()
    local cfg = EnsureBloodlustConfigTable()
    local paths = type(state.bloodlustPaths) == "table" and state.bloodlustPaths or {}

    cfg.customSoundPaths = cfg.customSoundPaths or {}
    for i = 1, 5 do
        cfg.customSoundPaths[i] = self:NormalizeSoundPath(paths[i] or "")
    end

    state.bloodlustPaths = {}
    for i = 1, 5 do
        state.bloodlustPaths[i] = cfg.customSoundPaths[i]
    end

    if api and type(api.RebuildBloodlustConfig) == "function" then
        api.RebuildBloodlustConfig()
    end
    if api and type(api.RefreshPanel) == "function" then
        api.RefreshPanel()
    end

    print("[MCD-CVA] " .. L("MSG_BLOODLUST_SAVED"))
    return true
end

function NS.AceOptions:TestBloodlustConfig()
    local api = GetApi()
    local state = self:GetState()
    if not api or type(api.PlayBloodlustNotification) ~= "function" then
        return
    end

    api.PlayBloodlustNotification({
        customSoundPaths = type(state.bloodlustPaths) == "table" and state.bloodlustPaths or {},
    })
end

function NS.AceOptions:GetEntryMap()
    local api = GetApi()
    local state = self:GetState()
    if not api then
        return {}
    end
    return api.GetStoredEntryMap(state.classID, state.specID)
end

function NS.AceOptions:GetCurrentScopeEntryList()
    local api = GetApi()
    local state = self:GetState()
    if not api then
        return {}
    end

    local entries = {}
    local map = self:GetEntryMap()
    local indices = type(api.GetOrderedEntryIndices) == "function" and api.GetOrderedEntryIndices(map) or {}
    local modeTts, modeSound = api.GetModes()

    for _, index in ipairs(indices) do
        local entry = api.GetEntry(map, index)
        if entry then
            local spellId = tonumber(entry.spellId) or 0
            local objectType = type(api.ResolveObjectType) == "function" and api.ResolveObjectType(spellId, entry.objectType) or tostring(entry.objectType or OBJECT_TYPE_SPELL)
            local spellName = TrimText(entry.spellName)
            if spellName == "" and spellId > 0 then
                spellName = type(api.ResolveObjectName) == "function" and api.ResolveObjectName(spellId, objectType) or api.ResolveSpellName(spellId)
            end

            entries[#entries + 1] = {
                key = BuildEntryKey(state.classID, state.specID, index),
                entryType = tostring(entry.entryType or "cooldown"),
                index = index,
                spellId = spellId,
                objectType = objectType,
                spellName = spellName ~= "" and spellName or tostring(spellId),
                modeText = tostring(entry.notifyMode or modeSound) == modeTts and "TTS" or L("MODE_SOUND"),
                soundDetail = BuildSoundDetailForEntry(entry, modeTts, modeSound),
                notifyMode = tostring(entry.notifyMode or modeSound),
                soundPath = tostring(entry.soundPath or ""),
                ttsText = tostring(entry.ttsText or ""),
                delayEnabled = entry.delayEnabled == true,
                delaySeconds = math.max(0, tonumber(entry.delaySeconds) or 0),
                checkTalent = entry.checkTalent == true and (tonumber(entry.talentId) or 0) > 0,
                talentId = tonumber(entry.talentId) or 0,
                talentName = TrimText(entry.talentName or ""),
                talentCD = tonumber(entry.talentCD) or 0,
                icon = type(api.ResolveObjectIcon) == "function" and api.ResolveObjectIcon(spellId, objectType) or (type(api.ResolveSpellIcon) == "function" and api.ResolveSpellIcon(spellId) or nil),
            }
        end
    end

    return entries
end

function NS.AceOptions:GetAllSavedEntryList()
    local api = GetApi()
    if not api then
        return {}
    end

    local entries = {}
    local db = MCDVoiceCooldownDB
    local root = type(db) == "table" and db.specConfigs or nil
    if type(root) ~= "table" then
        return entries
    end

    local currentClassID, currentSpecID = api.GetCurrentClassSpec()
    local modeTts, modeSound = api.GetModes()

    for classIDKey, classMap in pairs(root) do
        local classID = tonumber(classIDKey)
        if classID and classID >= 0 and type(classMap) == "table" then
            local className = api.ResolveClassName(classID)
            for specIDKey, specMap in pairs(classMap) do
                local specID = tonumber(specIDKey)
                if specID and specID >= 0 and type(specMap) == "table" then
                    local specName = api.ResolveSpecName(classID, specID)
                    local loadedTag = IsLoadedScope(api, classID, specID, currentClassID, currentSpecID) and L("LOADED_TAG") or ""
                    local indices = type(api.GetOrderedEntryIndices) == "function" and api.GetOrderedEntryIndices(specMap) or {}
                    for _, index in ipairs(indices) do
                        local entry = api.GetEntry(specMap, index)
                        if entry then
                            local spellId = tonumber(entry.spellId) or 0
                            local objectType = type(api.ResolveObjectType) == "function" and api.ResolveObjectType(spellId, entry.objectType) or tostring(entry.objectType or OBJECT_TYPE_SPELL)
                            local spellName = TrimText(entry.spellName)
                            if spellName == "" and spellId > 0 then
                                spellName = type(api.ResolveObjectName) == "function" and api.ResolveObjectName(spellId, objectType) or api.ResolveSpellName(spellId)
                            end

                            entries[#entries + 1] = {
                                key = BuildEntryKey(classID, specID, index),
                                entryType = tostring(entry.entryType or "cooldown"),
                                objectType = objectType,
                                classID = classID,
                                specID = specID,
                                index = index,
                                spellId = spellId,
                                spellName = spellName ~= "" and spellName or tostring(spellId),
                                modeText = tostring(entry.notifyMode or modeSound) == modeTts and "TTS" or L("MODE_SOUND"),
                                soundDetail = BuildSoundDetailForEntry(entry, modeTts, modeSound),
                                notifyMode = tostring(entry.notifyMode or modeSound),
                                soundPath = tostring(entry.soundPath or ""),
                                ttsText = tostring(entry.ttsText or ""),
                                delayEnabled = entry.delayEnabled == true,
                                delaySeconds = math.max(0, tonumber(entry.delaySeconds) or 0),
                                checkTalent = entry.checkTalent == true and (tonumber(entry.talentId) or 0) > 0,
                                talentId = tonumber(entry.talentId) or 0,
                                talentName = TrimText(entry.talentName or ""),
                                talentCD = tonumber(entry.talentCD) or 0,
                                icon = type(api.ResolveObjectIcon) == "function" and api.ResolveObjectIcon(spellId, objectType) or (type(api.ResolveSpellIcon) == "function" and api.ResolveSpellIcon(spellId) or nil),
                                scopeText = string.format("%s / %s%s", className, specName, loadedTag),
                                isLoaded = IsLoadedScope(api, classID, specID, currentClassID, currentSpecID),
                            }
                        end
                    end
                end
            end
        end
    end

    table.sort(entries, function(a, b)
        if a.classID ~= b.classID then
            return a.classID < b.classID
        end
        if a.specID ~= b.specID then
            return a.specID < b.specID
        end
        return a.index < b.index
    end)

    return entries
end

function NS.AceOptions:CreateCollection(name, iconID, explicitParentGroupKey)
    local api = GetApi()
    local state = self:GetState()
    if not api then
        return false
    end

    local classID = tonumber(state.classID) or 0
    local specID = tonumber(state.specID) or 0
    local parentGroupKey = tostring(explicitParentGroupKey or state.selectedCollectionKey or "")
    if parentGroupKey == "" and tostring(state.selectedKey or ""):match("^group:") then
        parentGroupKey = tostring(state.selectedKey or "")
    end
    local parentClassID, parentSpecID, parentGroupID = ParseGroupKey(parentGroupKey)
    if parentGroupID ~= "" and parentClassID >= 0 and parentSpecID >= 0 then
        classID = parentClassID
        specID = parentSpecID
    end
    if classID < 0 or specID < 0 then
        print("[MCD-CVA] " .. L("MSG_INVALID_SCOPE"))
        return false
    end

    name = TrimText(name)
    if name == "" then
        print("[MCD-CVA] " .. L("MSG_COLLECTION_NAME_EMPTY"))
        return false
    end

    iconID = NormalizeIconID(iconID)

    local scope = EnsureCollectionScope(classID, specID)
    if not scope then
        return false
    end
    NormalizeCollectionScope(scope, api.GetStoredEntryMap(classID, specID), classID, specID)

    local db = EnsureRootDB()
    db.collectionSerial = (tonumber(db.collectionSerial) or 0) + 1
    local groupID = "g" .. tostring(db.collectionSerial)
    scope.groups[groupID] = {
        name = name,
        iconID = iconID,
        entries = {},
    }

    if parentGroupID ~= "" and parentClassID == classID and parentSpecID == specID and type(scope.groups[parentGroupID]) == "table" then
        local childGroupKey = BuildGroupKey(classID, specID, groupID)
        scope.groups[parentGroupID].collapsed = false
        InsertGroupIntoGroup(scope, parentGroupID, childGroupKey)
        state.selectedCollectionKey = BuildGroupKey(classID, specID, parentGroupID)
        state.selectedKey = state.selectedCollectionKey
    else
        scope.root[#scope.root + 1] = {
            type = "group",
            id = groupID,
        }
        state.selectedCollectionKey = BuildGroupKey(classID, specID, groupID)
        state.selectedKey = state.selectedCollectionKey
    end

    NormalizeCollectionScope(scope, api.GetStoredEntryMap(classID, specID), classID, specID)

    if type(api.RefreshPanel) == "function" then
        api.RefreshPanel()
    end
    if NS.UI and NS.UI.MainFrame and type(NS.UI.MainFrame.Refresh) == "function" then
        NS.UI.MainFrame:Refresh()
    end
    print("[MCD-CVA] " .. L("MSG_COLLECTION_ADDED", name))
    return true
end

function NS.AceOptions:GetCollectionInfo(groupKey)
    local api = GetApi()
    if not api then
        return nil
    end

    local classID, specID, groupID = ParseGroupKey(groupKey)
    if classID < 0 or specID < 0 or groupID == "" then
        return nil
    end

    local scope = EnsureCollectionScope(classID, specID)
    if not scope then
        return nil
    end
    NormalizeCollectionScope(scope, api.GetStoredEntryMap(classID, specID), classID, specID)

    local group = scope.groups[groupID]
    if type(group) ~= "table" then
        return nil
    end

    return {
        key = BuildGroupKey(classID, specID, groupID),
        classID = classID,
        specID = specID,
        groupID = groupID,
        name = TrimText(group.name) ~= "" and TrimText(group.name) or L("COLLECTION_UNNAMED"),
        iconID = NormalizeIconID(group.iconID),
        icon = NormalizeIconID(group.iconID) or DEFAULT_COLLECTION_ICON,
        count = #(group.entries or {}),
    }
end

function NS.AceOptions:RenameCollection(groupKey, name, iconID)
    local api = GetApi()
    if not api then
        return false
    end

    local classID, specID, groupID = ParseGroupKey(groupKey)
    if classID < 0 or specID < 0 or groupID == "" then
        return false
    end

    name = TrimText(name)
    if name == "" then
        print("[MCD-CVA] " .. L("MSG_COLLECTION_NAME_EMPTY"))
        return false
    end

    local scope = EnsureCollectionScope(classID, specID)
    if not scope then
        return false
    end
    NormalizeCollectionScope(scope, api.GetStoredEntryMap(classID, specID), classID, specID)

    local group = scope.groups[groupID]
    if type(group) ~= "table" then
        return false
    end

    group.name = name
    group.iconID = NormalizeIconID(iconID)

    if type(api.RefreshPanel) == "function" then
        api.RefreshPanel()
    end
    print("[MCD-CVA] " .. L("MSG_COLLECTION_RENAMED", name))
    return true
end

function NS.AceOptions:DeleteCollection(groupKey)
    local api = GetApi()
    if not api then
        return false
    end
    local classID, specID, groupID = ParseGroupKey(groupKey)
    if classID < 0 or specID < 0 or groupID == "" then
        return false
    end
    local scope = EnsureCollectionScope(classID, specID)
    if not scope or type(scope.groups[groupID]) ~= "table" then
        return false
    end

    local map = api.EnsureEntryMap(classID, specID)
    if type(map) ~= "table" then
        return false
    end

    NormalizeCollectionScope(scope, map, classID, specID)

    local deleted = {}
    local deletedGroups = {}
    local deletedCount = 0

    local function collectGroup(gid, seen)
        gid = tostring(gid or "")
        local group = scope.groups[gid]
        if gid == "" or type(group) ~= "table" then
            return
        end
        seen = seen or {}
        if seen[gid] then
            return
        end
        seen[gid] = true
        deletedGroups[gid] = true

        local refs = {}
        for _, entryRef in ipairs(group.entries or {}) do
            refs[#refs + 1] = entryRef
        end
        for _, entryRef in ipairs(refs) do
            local childGroupKey, childGroupID = IsSameScopeGroupRef(classID, specID, entryRef)
            if childGroupKey and childGroupID then
                collectGroup(childGroupID, seen)
            else
                local entryKey, entryClassID, entrySpecID, entryIndex = EntryRefToKey(classID, specID, entryRef)
                if entryKey and entryIndex > 0 and not deleted[entryKey] then
                    local entryMap = api.EnsureEntryMap(entryClassID, entrySpecID)
                    if type(entryMap) == "table" then
                        local deletedEntry = api.GetEntry(entryMap, entryIndex)
                        if deletedEntry and type(api.MarkEntryDeleted) == "function" then
                            api.MarkEntryDeleted(entryClassID, entrySpecID, deletedEntry, entryIndex)
                        end
                        deleted[entryKey] = true
                        deletedCount = deletedCount + 1
                        entryMap[entryIndex] = nil
                        RemoveEntryKeyFromAllCollectionScopes(entryKey)
                        DeleteMatchingLegacyCastEntries(entryClassID, entrySpecID, deletedEntry)
                    end
                end
            end
        end
    end

    collectGroup(groupID, {})

    for gid in pairs(deletedGroups) do
        RemoveGroupKeyFromCollectionScope(scope, BuildGroupKey(classID, specID, gid), classID, specID)
    end
    for gid in pairs(deletedGroups) do
        scope.groups[gid] = nil
    end

    NormalizeCollectionScope(scope, map, classID, specID)

    local state = self:GetState()
    local _, _, selectedGroupID = ParseGroupKey(state.selectedKey)
    if deleted[tostring(state.selectedKey or "")] or deletedGroups[tostring(selectedGroupID or "")] then
        self:ClearEditorFields()
        state.selectedCollectionKey = nil
    end

    api.RebuildRuntimeConfig()
    if type(api.RebuildCastSuccessConfig) == "function" then
        api.RebuildCastSuccessConfig()
    end
    api.RefreshRuntimeCooldowns()
    if type(api.RefreshPanel) == "function" then
        api.RefreshPanel()
    end
    print("[MCD-CVA] " .. L("MSG_COLLECTION_DELETED", deletedCount))
    return true
end

function NS.AceOptions:GetSavedListLayoutForScope(classID, specID, includeScopeText)
    local api = GetApi()
    if not api then
        return {}
    end
    classID = tonumber(classID) or 0
    specID = tonumber(specID) or 0
    if classID < 0 or specID < 0 then
        return {}
    end

    local specMap = api.GetStoredEntryMap(classID, specID)
    local scope = EnsureCollectionScope(classID, specID)
    NormalizeCollectionScope(scope, specMap, classID, specID)

    local modeTts, modeSound = api.GetModes()
    local currentClassID, currentSpecID = api.GetCurrentClassSpec()

    local function buildEntryRow(rowClassID, rowSpecID, rowIndex, depth, parentGroupID, parentGroupKey)
        rowClassID = tonumber(rowClassID) or 0
        rowSpecID = tonumber(rowSpecID) or 0
        rowIndex = tonumber(rowIndex) or 0
        if rowClassID < 0 or rowSpecID < 0 or rowIndex <= 0 then
            return nil
        end

        local map = api.GetStoredEntryMap(rowClassID, rowSpecID)
        local entry = api.GetEntry(map, rowIndex)
        if not entry then
            return nil
        end

        local spellId = tonumber(entry.spellId) or 0
        local objectType = type(api.ResolveObjectType) == "function" and api.ResolveObjectType(spellId, entry.objectType) or tostring(entry.objectType or OBJECT_TYPE_SPELL)
        local spellName = TrimText(entry.spellName)
        if spellName == "" and spellId > 0 then
            spellName = type(api.ResolveObjectName) == "function" and api.ResolveObjectName(spellId, objectType) or api.ResolveSpellName(spellId)
        end

        local isLoaded = IsLoadedScope(api, rowClassID, rowSpecID, currentClassID, currentSpecID)
        local className = api.ResolveClassName(rowClassID)
        local specName = api.ResolveSpecName(rowClassID, rowSpecID)
        local loadedTag = isLoaded and L("LOADED_TAG") or L("UNLOADED_TAG")

        return {
            itemType = "entry",
            key = BuildEntryKey(rowClassID, rowSpecID, rowIndex),
            entryType = tostring(entry.entryType or "cooldown"),
            objectType = objectType,
            classID = rowClassID,
            specID = rowSpecID,
            index = rowIndex,
            spellId = spellId,
            spellName = spellName ~= "" and spellName or tostring(spellId),
            modeText = tostring(entry.notifyMode or modeSound) == modeTts and "TTS" or L("MODE_SOUND"),
            soundDetail = BuildSoundDetailForEntry(entry, modeTts, modeSound),
            notifyMode = tostring(entry.notifyMode or modeSound),
            soundPath = tostring(entry.soundPath or ""),
            ttsText = tostring(entry.ttsText or ""),
            delayEnabled = entry.delayEnabled == true,
            delaySeconds = math.max(0, tonumber(entry.delaySeconds) or 0),
            checkTalent = entry.checkTalent == true and (tonumber(entry.talentId) or 0) > 0,
            talentId = tonumber(entry.talentId) or 0,
            talentName = TrimText(entry.talentName or ""),
            talentCD = tonumber(entry.talentCD) or 0,
            icon = type(api.ResolveObjectIcon) == "function" and api.ResolveObjectIcon(spellId, objectType) or (type(api.ResolveSpellIcon) == "function" and api.ResolveSpellIcon(spellId) or nil),
            scopeText = includeScopeText and string.format("%s / %s%s", className, specName, loadedTag) or "",
            isLoaded = isLoaded,
            depth = tonumber(depth) or 0,
            groupID = parentGroupID,
            groupKey = parentGroupKey,
            parentGroupKey = parentGroupKey,
        }
    end

    local rows = {}
    local entriesByIndex = {}
    local indices = type(api.GetOrderedEntryIndices) == "function" and api.GetOrderedEntryIndices(specMap) or {}
    for _, index in ipairs(indices) do
        entriesByIndex[tonumber(index) or 0] = buildEntryRow(classID, specID, index, 0)
    end

    local function appendGroup(groupID, depth, parentGroupID, parentGroupKey, stack)
        groupID = tostring(groupID or "")
        local group = scope.groups[groupID]
        if groupID == "" or type(group) ~= "table" then
            return 0, 0, 0
        end

        stack = stack or {}
        if stack[groupID] then
            return 0, 0, 0
        end
        stack[groupID] = true

        local groupKey = BuildGroupKey(classID, specID, groupID)
        local groupRowIndex = #rows + 1
        rows[groupRowIndex] = {
            itemType = "group",
            key = groupKey,
            groupKey = groupKey,
            groupID = groupID,
            parentGroupID = parentGroupID,
            parentGroupKey = parentGroupKey,
            classID = classID,
            specID = specID,
            name = TrimText(group.name) ~= "" and TrimText(group.name) or L("COLLECTION_UNNAMED"),
            count = 0,
            icon = NormalizeIconID(group.iconID) or DEFAULT_COLLECTION_ICON,
            scopeText = "",
            isLoaded = true,
            depth = tonumber(depth) or 0,
            collapsed = group.collapsed == true,
            loadState = "green",
        }

        local totalCount, loadedCount, unloadedCount = 0, 0, 0

        if not (group.collapsed == true) then
            for _, ref in ipairs(group.entries or {}) do
                local childGroupKey, childGroupID = IsSameScopeGroupRef(classID, specID, ref)
                if childGroupKey and childGroupID and type(scope.groups[childGroupID]) == "table" then
                    local childTotal, childLoaded, childUnloaded = appendGroup(childGroupID, (tonumber(depth) or 0) + 1, groupID, groupKey, stack)
                    totalCount = totalCount + childTotal
                    loadedCount = loadedCount + childLoaded
                    unloadedCount = unloadedCount + childUnloaded
                else
                    local entryKey, rowClassID, rowSpecID, rowIndex = EntryRefToKey(classID, specID, ref)
                    local entryRow = entryKey and buildEntryRow(rowClassID, rowSpecID, rowIndex, (tonumber(depth) or 0) + 1, groupID, groupKey) or nil
                    if entryRow then
                        rows[#rows + 1] = entryRow
                        totalCount = totalCount + 1
                        if entryRow.isLoaded == false then
                            unloadedCount = unloadedCount + 1
                        else
                            loadedCount = loadedCount + 1
                        end
                    end
                end
            end
        else
            -- 折叠时仍然计算合集载入状态，但不输出子行。
            local function countCollapsed(gid, seen)
                gid = tostring(gid or "")
                local g = scope.groups[gid]
                if gid == "" or type(g) ~= "table" then
                    return 0, 0, 0
                end
                seen = seen or {}
                if seen[gid] then
                    return 0, 0, 0
                end
                seen[gid] = true
                local total, loaded, unloaded = 0, 0, 0
                for _, ref in ipairs(g.entries or {}) do
                    local _, childGroupID = IsSameScopeGroupRef(classID, specID, ref)
                    if childGroupID and type(scope.groups[childGroupID]) == "table" then
                        local ct, cl, cu = countCollapsed(childGroupID, seen)
                        total = total + ct
                        loaded = loaded + cl
                        unloaded = unloaded + cu
                    else
                        local entryKey, rowClassID, rowSpecID, rowIndex = EntryRefToKey(classID, specID, ref)
                        local entryRow = entryKey and buildEntryRow(rowClassID, rowSpecID, rowIndex, 0) or nil
                        if entryRow then
                            total = total + 1
                            if entryRow.isLoaded == false then
                                unloaded = unloaded + 1
                            else
                                loaded = loaded + 1
                            end
                        end
                    end
                end
                return total, loaded, unloaded
            end
            totalCount, loadedCount, unloadedCount = countCollapsed(groupID, {})
        end

        local groupRow = rows[groupRowIndex]
        groupRow.count = totalCount
        groupRow.loadedCount = loadedCount
        groupRow.unloadedCount = unloadedCount
        if totalCount <= 0 then
            groupRow.loadState = "green"
            groupRow.isLoaded = true
        elseif loadedCount > 0 and unloadedCount > 0 then
            groupRow.loadState = "yellow"
            groupRow.isLoaded = true
        elseif loadedCount > 0 then
            groupRow.loadState = "green"
            groupRow.isLoaded = true
        else
            groupRow.loadState = "red"
            groupRow.isLoaded = false
        end

        stack[groupID] = nil
        return totalCount, loadedCount, unloadedCount
    end

    for _, item in ipairs(scope.root or {}) do
        if type(item) == "table" and item.type == "group" then
            appendGroup(tostring(item.id or ""), 0, nil, nil, {})
        elseif type(item) == "table" and item.type == "entry" then
            local entryRow = entriesByIndex[tonumber(item.index) or 0]
            if entryRow then
                rows[#rows + 1] = entryRow
            end
        end
    end

    return rows
end

function NS.AceOptions:MoveSavedListItem(sourceKey, targetKey, targetSection)
    local api = GetApi()
    if not api then
        return false
    end
    sourceKey = tostring(sourceKey or "")
    targetKey = tostring(targetKey or "")
    local insertAfter = targetKey:match(":after$") ~= nil
    local insertInside = targetKey:match(":inside$") ~= nil
    local displayTargetSection = tostring(targetSection or "")
    targetKey = targetKey:gsub(":after$", "")
    targetKey = targetKey:gsub(":inside$", "")
    targetKey = targetKey:gsub(":empty$", "")
    if sourceKey == "" or targetKey == "" or sourceKey == targetKey then
        return false
    end

    local currentClassID, currentSpecID = api.GetCurrentClassSpec()
    if currentClassID <= 0 or currentSpecID <= 0 then
        return false
    end

    local currentScope = EnsureCollectionScope(currentClassID, currentSpecID)
    NormalizeCollectionScope(currentScope, api.GetStoredEntryMap(currentClassID, currentSpecID), currentClassID, currentSpecID)

    local sourceIsGroup = sourceKey:match("^group:") ~= nil
    local targetIsGroup = targetKey:match("^group:") ~= nil

    if sourceIsGroup then
        local sourceClassID, sourceSpecID, sourceGroupID = ParseGroupKey(sourceKey)
        if sourceClassID < 0 or sourceSpecID < 0 or sourceGroupID == "" then
            return false
        end

        local sourceScope = EnsureCollectionScope(sourceClassID, sourceSpecID)
        if not sourceScope or type(sourceScope.groups[sourceGroupID]) ~= "table" then
            return false
        end
        NormalizeCollectionScope(sourceScope, api.GetStoredEntryMap(sourceClassID, sourceSpecID), sourceClassID, sourceSpecID)

        local function insertSourceAtLocation(loc, fallbackRootPosition)
            -- 先验证目标，再移除原位置。否则把父合集拖到自己的子合集/非法目标时，
            -- 会先从原位置移除，导致合集“消失”。
            local originalLoc = FindGroupKeyLocation(sourceScope, sourceKey, sourceClassID, sourceSpecID)

            if loc and loc.container == "group" and loc.groupID and type(sourceScope.groups[loc.groupID]) == "table" then
                if loc.groupID == sourceGroupID or GroupContainsGroup(sourceScope, sourceGroupID, loc.groupID, sourceClassID, sourceSpecID) then
                    return false
                end
                RemoveGroupKeyFromCollectionScope(sourceScope, sourceKey, sourceClassID, sourceSpecID)
                local pos = math.max(1, (tonumber(loc.position) or 1) + (insertAfter and 1 or 0))
                if InsertGroupIntoGroup(sourceScope, loc.groupID, sourceKey, pos) then
                    return true
                end
            else
                RemoveGroupKeyFromCollectionScope(sourceScope, sourceKey, sourceClassID, sourceSpecID)
                local pos = fallbackRootPosition or (loc and loc.position) or (#(sourceScope.root or {}) + 1)
                if insertAfter then
                    pos = pos + 1
                end
                if InsertGroupIntoRoot(sourceScope, sourceGroupID, pos) then
                    return true
                end
            end

            -- 极端情况下插入失败，恢复到原位置，避免保存结构丢失。
            if originalLoc and originalLoc.container == "group" and originalLoc.groupID and type(sourceScope.groups[originalLoc.groupID]) == "table" then
                InsertGroupIntoGroup(sourceScope, originalLoc.groupID, sourceKey, originalLoc.position)
            elseif originalLoc and originalLoc.container == "root" then
                InsertGroupIntoRoot(sourceScope, sourceGroupID, originalLoc.position)
            else
                InsertGroupIntoRoot(sourceScope, sourceGroupID)
            end
            return false
        end

        local moved = false
        local function moveGroupTreeAcrossScope(targetClassID, targetSpecID, targetLoc, targetGroupIDForInside)
            targetClassID = tonumber(targetClassID) or 0
            targetSpecID = tonumber(targetSpecID) or 0
            if targetClassID < 0 or targetSpecID < 0 then
                return false
            end
            if targetClassID == sourceClassID and targetSpecID == sourceSpecID then
                return false
            end

            local targetScope = EnsureCollectionScope(targetClassID, targetSpecID)
            if not targetScope then
                return false
            end
            NormalizeCollectionScope(targetScope, api.GetStoredEntryMap(targetClassID, targetSpecID), targetClassID, targetSpecID)

            local db = EnsureRootDB()
            local idMap = {}
            local movedIDs = {}

            local function allocateGroupID()
                db.collectionSerial = (tonumber(db.collectionSerial) or 0) + 1
                return "g" .. tostring(db.collectionSerial)
            end

            local function cloneGroup(oldID, seen)
                oldID = tostring(oldID or "")
                if oldID == "" or type(sourceScope.groups[oldID]) ~= "table" then
                    return nil
                end
                if idMap[oldID] then
                    return idMap[oldID]
                end
                seen = seen or {}
                if seen[oldID] then
                    return nil
                end
                seen[oldID] = true

                local oldGroup = sourceScope.groups[oldID]
                local newID = allocateGroupID()
                idMap[oldID] = newID
                movedIDs[oldID] = true

                local newEntries = {}
                targetScope.groups[newID] = {
                    name = TrimText(oldGroup.name) ~= "" and TrimText(oldGroup.name) or L("COLLECTION_UNNAMED"),
                    iconID = NormalizeIconID(oldGroup.iconID),
                    collapsed = oldGroup.collapsed == true,
                    entries = newEntries,
                }

                for _, ref in ipairs(oldGroup.entries or {}) do
                    local _, childGroupID = IsSameScopeGroupRef(sourceClassID, sourceSpecID, ref)
                    if childGroupID and type(sourceScope.groups[childGroupID]) == "table" then
                        local childNewID = cloneGroup(childGroupID, seen)
                        if childNewID then
                            newEntries[#newEntries + 1] = BuildGroupKey(targetClassID, targetSpecID, childNewID)
                        end
                    else
                        local entryKey = EntryRefToKey(sourceClassID, sourceSpecID, ref)
                        if entryKey then
                            newEntries[#newEntries + 1] = entryKey
                        end
                    end
                end

                seen[oldID] = nil
                return newID
            end

            local newGroupID = cloneGroup(sourceGroupID, {})
            if not newGroupID then
                return false
            end
            local newGroupKey = BuildGroupKey(targetClassID, targetSpecID, newGroupID)

            for oldID in pairs(movedIDs) do
                RemoveGroupKeyFromCollectionScope(sourceScope, BuildGroupKey(sourceClassID, sourceSpecID, oldID), sourceClassID, sourceSpecID)
            end
            for oldID in pairs(movedIDs) do
                sourceScope.groups[oldID] = nil
            end

            local ok = false
            if targetGroupIDForInside and type(targetScope.groups[targetGroupIDForInside]) == "table" then
                ok = InsertGroupIntoGroup(targetScope, targetGroupIDForInside, newGroupKey)
            elseif targetLoc and targetLoc.container == "group" and targetLoc.groupID and type(targetScope.groups[targetLoc.groupID]) == "table" then
                local pos = math.max(1, (tonumber(targetLoc.position) or 1) + (insertAfter and 1 or 0))
                ok = InsertGroupIntoGroup(targetScope, targetLoc.groupID, newGroupKey, pos)
            else
                local pos = (targetLoc and targetLoc.position) or (#(targetScope.root or {}) + 1)
                if insertAfter then
                    pos = pos + 1
                end
                ok = InsertGroupIntoRoot(targetScope, newGroupID, pos)
            end

            NormalizeCollectionScope(sourceScope, api.GetStoredEntryMap(sourceClassID, sourceSpecID), sourceClassID, sourceSpecID)
            NormalizeCollectionScope(targetScope, api.GetStoredEntryMap(targetClassID, targetSpecID), targetClassID, targetSpecID)
            if ok then
                sourceKey = newGroupKey
            end
            return ok
        end

        if targetIsGroup then
            local targetClassID, targetSpecID, targetGroupID = ParseGroupKey(targetKey)
            if targetGroupID == "" then
                return false
            end
            if targetClassID == sourceClassID and targetSpecID == sourceSpecID then
                if type(sourceScope.groups[targetGroupID]) ~= "table" then
                    return false
                end
                if targetGroupID == sourceGroupID or GroupContainsGroup(sourceScope, sourceGroupID, targetGroupID, sourceClassID, sourceSpecID) then
                    return false
                end

                if insertInside then
                    RemoveGroupKeyFromCollectionScope(sourceScope, sourceKey, sourceClassID, sourceSpecID)
                    moved = InsertGroupIntoGroup(sourceScope, targetGroupID, sourceKey)
                else
                    local targetLoc = FindGroupKeyLocation(sourceScope, targetKey, sourceClassID, sourceSpecID)
                    moved = insertSourceAtLocation(targetLoc)
                end
            else
                local targetScope = EnsureCollectionScope(targetClassID, targetSpecID)
                if not targetScope then
                    return false
                end
                NormalizeCollectionScope(targetScope, api.GetStoredEntryMap(targetClassID, targetSpecID), targetClassID, targetSpecID)
                if type(targetScope.groups[targetGroupID]) ~= "table" then
                    return false
                end
                if insertInside then
                    moved = moveGroupTreeAcrossScope(targetClassID, targetSpecID, nil, targetGroupID)
                else
                    local targetLoc = FindGroupKeyLocation(targetScope, targetKey, targetClassID, targetSpecID)
                    if targetLoc and targetLoc.container == "group" then
                        moved = moveGroupTreeAcrossScope(targetClassID, targetSpecID, targetLoc, nil)
                    else
                        RecordSavedListDisplayMove(sourceKey, targetKey, displayTargetSection, insertAfter)
                        moved = true
                    end
                end
            end
        else
            local targetClassID, targetSpecID, targetIndex = ParseEntryKey(targetKey)
            if targetIndex <= 0 then
                return false
            end

            if targetClassID == sourceClassID and targetSpecID == sourceSpecID then
                local targetEntryKey = BuildEntryKey(targetClassID, targetSpecID, targetIndex)
                local targetLoc = FindEntryKeyLocation(sourceScope, targetEntryKey, targetClassID, targetSpecID)
                moved = insertSourceAtLocation(targetLoc)
            else
                local targetScope = EnsureCollectionScope(targetClassID, targetSpecID)
                if not targetScope then
                    return false
                end
                NormalizeCollectionScope(targetScope, api.GetStoredEntryMap(targetClassID, targetSpecID), targetClassID, targetSpecID)
                local targetEntryKey = BuildEntryKey(targetClassID, targetSpecID, targetIndex)
                local targetLoc = FindEntryKeyLocation(targetScope, targetEntryKey, targetClassID, targetSpecID)
                if targetLoc and targetLoc.container == "group" then
                    moved = moveGroupTreeAcrossScope(targetClassID, targetSpecID, targetLoc, nil)
                else
                    RecordSavedListDisplayMove(sourceKey, targetKey, displayTargetSection, insertAfter)
                    moved = true
                end
            end
        end

        if not moved then
            return false
        end

        NormalizeCollectionScope(sourceScope, api.GetStoredEntryMap(sourceClassID, sourceSpecID), sourceClassID, sourceSpecID)
        local state = self:GetState()
        state.selectedKey = sourceKey
        state.selectedCollectionKey = sourceKey
    else
        local sourceClassID, sourceSpecID, sourceIndex = ParseEntryKey(sourceKey)
        if sourceClassID < 0 or sourceSpecID < 0 or sourceIndex <= 0 then
            return false
        end

        local sourceEntryKey = BuildEntryKey(sourceClassID, sourceSpecID, sourceIndex)
        local sourceIsLoaded = IsLoadedScope(api, sourceClassID, sourceSpecID, currentClassID, currentSpecID)
        local sourceWasInCurrentScope = FindEntryKeyLocation(currentScope, sourceEntryKey, currentClassID, currentSpecID) ~= nil

        if targetIsGroup then
            local targetClassID, targetSpecID, targetGroupID = ParseGroupKey(targetKey)
            local targetScope = EnsureCollectionScope(targetClassID, targetSpecID)
            if not targetScope then
                return false
            end
            NormalizeCollectionScope(targetScope, api.GetStoredEntryMap(targetClassID, targetSpecID), targetClassID, targetSpecID)
            if type(targetScope.groups[tostring(targetGroupID or "")]) ~= "table" then
                print("[MCD-CVA] " .. L("MSG_UNLOADED_ONLY_TO_LOADED_GROUP"))
                return false
            end
        elseif not sourceIsLoaded then
            local targetClassID, targetSpecID, targetIndex = ParseEntryKey(targetKey)
            local targetScope = EnsureCollectionScope(targetClassID, targetSpecID)
            if not targetScope then
                return false
            end
            NormalizeCollectionScope(targetScope, api.GetStoredEntryMap(targetClassID, targetSpecID), targetClassID, targetSpecID)
            local targetEntryKey = BuildEntryKey(targetClassID, targetSpecID, targetIndex)
            local loc = FindEntryKeyLocation(targetScope, targetEntryKey, targetClassID, targetSpecID)
            if not (loc and loc.container == "group") and displayTargetSection ~= "unloaded" then
                print("[MCD-CVA] " .. L("MSG_DRAG_UNLOADED_TO_ROOT"))
                return false
            end
        end

        if targetIsGroup then
            local targetClassID, targetSpecID, targetGroupID = ParseGroupKey(targetKey)
            local targetScope = EnsureCollectionScope(targetClassID, targetSpecID)
            if not targetScope then
                return false
            end
            NormalizeCollectionScope(targetScope, api.GetStoredEntryMap(targetClassID, targetSpecID), targetClassID, targetSpecID)
            local group = targetScope.groups[tostring(targetGroupID or "")]
            if type(group) ~= "table" then
                return false
            end

            -- 先从所有合集位置移除同一个条目，避免拖入空合集后仍显示在原位置。
            RemoveEntryKeyFromAllCollectionScopes(sourceEntryKey)

            if type(group.entries) ~= "table" then
                group.entries = {}
            end
            group.entries[#group.entries + 1] = sourceEntryKey
            NormalizeCollectionScope(targetScope, api.GetStoredEntryMap(targetClassID, targetSpecID), targetClassID, targetSpecID)
        else
            local targetClassID, targetSpecID, targetIndex = ParseEntryKey(targetKey)
            if targetClassID < 0 or targetSpecID < 0 or targetIndex <= 0 then
                return false
            end

            local targetScope = EnsureCollectionScope(targetClassID, targetSpecID)
            if not targetScope then
                return false
            end
            NormalizeCollectionScope(targetScope, api.GetStoredEntryMap(targetClassID, targetSpecID), targetClassID, targetSpecID)

            local targetEntryKey = BuildEntryKey(targetClassID, targetSpecID, targetIndex)
            local loc = FindEntryKeyLocation(targetScope, targetEntryKey, targetClassID, targetSpecID)
            local sameScope = sourceClassID == targetClassID and sourceSpecID == targetSpecID

            if loc and loc.container == "group" then
                local group = targetScope.groups[loc.groupID]
                if type(group) ~= "table" then
                    return false
                end
                -- 先从所有合集位置移除同一个条目，避免拖入非空合集子项后仍显示在原位置。
                RemoveEntryKeyFromAllCollectionScopes(sourceEntryKey)
                if sameScope then
                    RemoveEntryKeyFromCollectionScope(targetScope, sourceEntryKey, targetClassID, targetSpecID)
                end
                if type(group.entries) ~= "table" then
                    group.entries = {}
                end
                local insertPos = math.max(1, loc.position + (insertAfter and 1 or 0))
                table.insert(group.entries, math.min(insertPos, #group.entries + 1), sourceEntryKey)
                NormalizeCollectionScope(targetScope, api.GetStoredEntryMap(targetClassID, targetSpecID), targetClassID, targetSpecID)
            elseif loc and loc.container == "root" then
                if not sameScope then
                    RecordSavedListDisplayMove(sourceEntryKey, targetEntryKey, displayTargetSection, insertAfter)
                else
                    RemoveEntryKeyFromAllCollectionScopes(sourceEntryKey)
                    RemoveEntryKeyFromCollectionScope(targetScope, sourceEntryKey, targetClassID, targetSpecID)
                    local insertPos = math.max(1, loc.position + (insertAfter and 1 or 0))
                    table.insert(targetScope.root, math.min(insertPos, #targetScope.root + 1), { type = "entry", index = sourceIndex })
                end
            else
                if not sameScope then
                    RecordSavedListDisplayMove(sourceEntryKey, targetEntryKey, displayTargetSection, insertAfter)
                else
                    RemoveEntryKeyFromAllCollectionScopes(sourceEntryKey)
                    RemoveEntryKeyFromCollectionScope(targetScope, sourceEntryKey, targetClassID, targetSpecID)
                    targetScope.root[#targetScope.root + 1] = { type = "entry", index = sourceIndex }
                end
            end
        end

        if not sourceIsLoaded and not sourceWasInCurrentScope and targetIsGroup then
            print("[MCD-CVA] " .. L("MSG_DRAG_UNLOADED_TO_GROUP"))
        end
    end

    NormalizeCollectionScope(currentScope, api.GetStoredEntryMap(currentClassID, currentSpecID), currentClassID, currentSpecID)
    if not insertInside then
        RecordSavedListDisplayMove(sourceKey, targetKey, displayTargetSection, insertAfter)
    end
    if type(api.RefreshPanel) == "function" then
        api.RefreshPanel()
    end
    return true
end

function NS.AceOptions:IsGroupKey(key)
    return tostring(key or ""):match("^group:") ~= nil
end



-------------------------------------------------
-- 导入 / 导出：AceSerializer + LibDeflate，格式参考 WA 的压缩字符串思路。
-------------------------------------------------
local EXPORT_PREFIX = "!MCDVCA:1!"

local function DeepCopyTable(value, seen)
    if type(value) ~= "table" then
        return value
    end
    seen = seen or {}
    if seen[value] then
        return seen[value]
    end
    local copy = {}
    seen[value] = copy
    for k, v in pairs(value) do
        copy[DeepCopyTable(k, seen)] = DeepCopyTable(v, seen)
    end
    return copy
end

local function SanitizeEntryForImport(entry)
    if type(entry) ~= "table" then
        return nil
    end
    local spellId = tonumber(entry.spellId) or 0
    if spellId <= 0 then
        return nil
    end
    local modeTts, modeSound = "tts", "sound"
    local api = GetApi()
    if api and type(api.GetModes) == "function" then
        modeTts, modeSound = api.GetModes()
    end
    local notifyMode = tostring(entry.notifyMode or modeSound)
    if notifyMode ~= modeTts then
        notifyMode = modeSound
    end
    local soundPath = tostring(entry.soundPath or "")
    if NS.AceOptions and type(NS.AceOptions.NormalizeSoundPath) == "function" then
        soundPath = NS.AceOptions:NormalizeSoundPath(soundPath)
    else
        soundPath = soundPath:gsub("/", "\\")
    end
    local entryType = tostring(entry.entryType or "cooldown")
    if entryType ~= "cast" then
        entryType = "cooldown"
    end

    local objectType = tostring(entry.objectType or "")
    if api and type(api.ResolveObjectType) == "function" then
        objectType = api.ResolveObjectType(spellId, objectType)
    end
    if objectType ~= OBJECT_TYPE_ITEM then
        objectType = OBJECT_TYPE_SPELL
    end

    local sanitized = {
        entryType = entryType,
        objectType = objectType,
        spellName = TrimText(entry.spellName or ""),
        spellId = math.floor(spellId),
        itemID = (objectType == OBJECT_TYPE_ITEM) and math.floor(tonumber(entry.itemID or spellId) or spellId) or nil,
        triggerSpellID = math.floor(tonumber(entry.triggerSpellID or entry.triggerSpellId) or 0),
        triggerSpellName = TrimText(entry.triggerSpellName or ""),
        baseCD = tonumber(string.format("%.2f", tonumber(entry.baseCD) or 0)) or 0,
        fixedCD = true,
        checkTalent = entry.checkTalent == true and (tonumber(entry.talentId) or 0) > 0,
        talentId = math.floor(tonumber(entry.talentId) or 0),
        talentName = TrimText(entry.talentName or ""),
        talentCD = tonumber(string.format("%.2f", tonumber(entry.talentCD) or 0)) or 0,
        chargeInput = 1,
        notifyMode = notifyMode,
        ttsText = tostring(entry.ttsText or ""),
        ttsRate = math.max(-10, math.min(10, tonumber(entry.ttsRate) or 0)),
        delayEnabled = entryType == "cast" and entry.delayEnabled == true,
        delaySeconds = entryType == "cast" and math.max(0, tonumber(string.format("%.2f", tonumber(entry.delaySeconds) or 0)) or 0) or 0,
        soundPath = soundPath,
    }

    if objectType == OBJECT_TYPE_ITEM and api and type(api.ResolveItemTriggerForEntry) == "function" then
        api.ResolveItemTriggerForEntry(sanitized, true)
    end

    return sanitized
end

local function GetAceSerializer()
    if type(LibStubGlobal) == "table" and LibStubGlobal.GetLibrary then
        return LibStubGlobal:GetLibrary("AceSerializer-3.0", true)
    end
    return nil
end

local function GetLibDeflate()
    if type(LibStubGlobal) == "table" and LibStubGlobal.GetLibrary then
        return LibStubGlobal:GetLibrary("LibDeflate", true)
    end
    return nil
end

local function EncodeExportPayload(payload)
    local serializer = GetAceSerializer()
    local deflate = GetLibDeflate()
    if not serializer or not deflate then
        print("[MCD-CVA] " .. L("MSG_EXPORT_MISSING_LIBS"))
        return ""
    end
    payload = payload or {}
    payload.addon = "MCDVoiceCooldown"
    payload.format = 1
    local serialized = serializer:Serialize(payload)
    local compressed = deflate:CompressDeflate(serialized, { level = 9 })
    if type(compressed) ~= "string" then
        print("[MCD-CVA] " .. L("MSG_EXPORT_DEFLATE_FAILED"))
        return ""
    end
    return EXPORT_PREFIX .. deflate:EncodeForPrint(compressed)
end

local function DecodeExportPayload(text)
    text = tostring(text or ""):gsub("%s+", "")
    if text == "" then
        return nil, L("ERR_IMPORT_EMPTY")
    end

    local serializer = GetAceSerializer()
    local deflate = GetLibDeflate()
    if not serializer or not deflate then
        return nil, L("ERR_IMPORT_MISSING_LIBS")
    end

    if text:sub(1, #EXPORT_PREFIX) ~= EXPORT_PREFIX then
        return nil, L("ERR_IMPORT_BAD_PREFIX")
    end

    local encoded = text:sub(#EXPORT_PREFIX + 1)
    local compressed = deflate:DecodeForPrint(encoded)
    if type(compressed) ~= "string" then
        return nil, L("ERR_IMPORT_DECODE_FAILED")
    end
    local serialized = deflate:DecompressDeflate(compressed)
    if type(serialized) ~= "string" then
        return nil, L("ERR_IMPORT_DEFLATE_FAILED")
    end

    local ok, payload = serializer:Deserialize(serialized)
    if not ok or type(payload) ~= "table" then
        return nil, L("ERR_IMPORT_DESERIALIZE_FAILED")
    end
    if payload.addon ~= "MCDVoiceCooldown" or tonumber(payload.format) ~= 1 then
        return nil, L("ERR_IMPORT_VERSION_MISMATCH")
    end
    return payload
end

local function FindMatchingEntryIndexBySpell(map, spellId, entryType, objectType)
    spellId = tonumber(spellId) or 0
    entryType = tostring(entryType or "cooldown")
    objectType = tostring(objectType or OBJECT_TYPE_SPELL)
    if spellId <= 0 or type(map) ~= "table" then
        return nil
    end
    local api = GetApi()
    if api and type(api.ResolveObjectType) == "function" then
        objectType = api.ResolveObjectType(spellId, objectType)
    end
    local indices = api and type(api.GetOrderedEntryIndices) == "function" and api.GetOrderedEntryIndices(map) or {}
    for _, index in ipairs(indices) do
        local entry = api.GetEntry(map, index)
        local existingID = tonumber(entry and entry.spellId) or 0
        local existingType = api and type(api.ResolveObjectType) == "function" and api.ResolveObjectType(existingID, entry and entry.objectType) or tostring(entry and entry.objectType or OBJECT_TYPE_SPELL)
        if entry
            and (tonumber(entry.spellId) or 0) == spellId
            and tostring(entry.entryType or "cooldown") == entryType
            and tostring(existingType) == tostring(objectType) then
            return tonumber(index) or 0
        end
    end
    return nil
end

local function FindFirstFreeIndexInMap(map)
    local api = GetApi()
    if api and type(api.FindFirstFreeIndex) == "function" then
        return api.FindFirstFreeIndex(map)
    end
    for i = 1, 999 do
        if type(map[i]) ~= "table" or (tonumber(map[i].spellId) or 0) <= 0 then
            return i
        end
    end
    return nil
end

local function ImportEntryRecord(record, preferImportedIndex)
    local api = GetApi()
    if not api or type(record) ~= "table" then
        return nil
    end
    local classID = tonumber(record.classID) or 0
    local specID = tonumber(record.specID) or 0
    if classID == ALL_CLASSES_ID then
        specID = ALL_SPECS_ID
    end
    local importIndex = tonumber(record.index) or 0
    local entry = SanitizeEntryForImport(record.entry)
    if classID < 0 or specID < 0 or not entry then
        return nil
    end

    local map = api.EnsureEntryMap(classID, specID)
    if type(map) ~= "table" then
        return nil
    end

    local targetIndex = FindMatchingEntryIndexBySpell(map, entry.spellId, entry.entryType, entry.objectType)
    if not targetIndex and preferImportedIndex and importIndex > 0 then
        local existing = api.GetEntry(map, importIndex)
        if not existing then
            targetIndex = importIndex
        end
    end
    if not targetIndex then
        targetIndex = FindFirstFreeIndexInMap(map)
    end
    if not targetIndex or targetIndex <= 0 then
        return nil
    end

    ClearDeletedEntryMarker(classID, specID, entry, targetIndex)
    map[targetIndex] = entry
    return BuildEntryKey(classID, specID, targetIndex), targetIndex
end

local function BuildEntryExportRecord(classID, specID, index)
    local api = GetApi()
    if not api then
        return nil
    end
    classID = tonumber(classID) or 0
    specID = tonumber(specID) or 0
    if classID == ALL_CLASSES_ID then
        specID = ALL_SPECS_ID
    end
    index = tonumber(index) or 0
    if classID < 0 or specID < 0 or index <= 0 then
        return nil
    end
    local map = api.GetStoredEntryMap(classID, specID)
    local entry = api.GetEntry(map, index)
    if not entry then
        return nil
    end
    return {
        classID = classID,
        specID = specID,
        index = index,
        entry = DeepCopyTable(entry),
    }
end

function NS.AceOptions:ExportEntryString(entryKey)
    local classID, specID, index = ParseEntryKey(entryKey)
    local record = BuildEntryExportRecord(classID, specID, index)
    if not record then
        print("[MCD-CVA] " .. L("MSG_EXPORT_ENTRY_NOT_FOUND"))
        return ""
    end
    return EncodeExportPayload({ type = "entry", entries = { record } })
end

function NS.AceOptions:ExportCollectionString(groupKey)
    local classID, specID, groupID = ParseGroupKey(groupKey)
    if classID < 0 or specID < 0 or tostring(groupID or "") == "" then
        print("[MCD-CVA] " .. L("MSG_EXPORT_COLLECTION_INVALID"))
        return ""
    end
    local scope = EnsureCollectionScope(classID, specID)
    local api = GetApi()
    if not scope or not api or type(scope.groups[groupID]) ~= "table" then
        print("[MCD-CVA] " .. L("MSG_EXPORT_COLLECTION_NOT_FOUND"))
        return ""
    end
    NormalizeCollectionScope(scope, api.GetStoredEntryMap(classID, specID), classID, specID)

    local entries = {}
    local exportedEntryKeys = {}
    local groups = {}

    local function collectGroup(gid, seen)
        gid = tostring(gid or "")
        local group = scope.groups[gid]
        if gid == "" or type(group) ~= "table" then
            return
        end
        seen = seen or {}
        if seen[gid] then
            return
        end
        seen[gid] = true

        local groupCopy = {
            name = TrimText(group.name) ~= "" and TrimText(group.name) or L("COLLECTION_UNNAMED"),
            iconID = NormalizeIconID(group.iconID),
            collapsed = group.collapsed == true,
            entries = DeepCopyTable(group.entries or {}),
        }
        groups[gid] = groupCopy

        for _, ref in ipairs(group.entries or {}) do
            local _, childGroupID = IsSameScopeGroupRef(classID, specID, ref)
            if childGroupID and type(scope.groups[childGroupID]) == "table" then
                collectGroup(childGroupID, seen)
            else
                local key, rowClassID, rowSpecID, rowIndex = EntryRefToKey(classID, specID, ref)
                if key and not exportedEntryKeys[key] then
                    local record = BuildEntryExportRecord(rowClassID, rowSpecID, rowIndex)
                    if record then
                        record.originalKey = key
                        entries[#entries + 1] = record
                        exportedEntryKeys[key] = true
                    end
                end
            end
        end
    end

    collectGroup(groupID, {})

    local rootGroup = groups[groupID] or {
        name = L("COLLECTION_UNNAMED"),
        entries = {},
    }

    return EncodeExportPayload({
        type = "collection",
        classID = classID,
        specID = specID,
        rootGroupID = groupID,
        group = rootGroup,
        groups = groups,
        entries = entries,
    })
end

function NS.AceOptions:ExportFullString()
    local db = EnsureRootDB()
    return EncodeExportPayload({
        type = "full",
        specConfigs = DeepCopyTable(db.specConfigs or {}),
        collectionData = DeepCopyTable(db.collectionData or {}),
        collectionSerial = tonumber(db.collectionSerial) or 0,
        savedListOrder = DeepCopyTable(db.savedListOrder or {}),
        bloodlustConfig = DeepCopyTable(db.bloodlustConfig or {}),
    })
end

local function ImportFullPayload(payload)
    local api = GetApi()
    if not api then
        return false, 0
    end
    local db = EnsureRootDB()
    if type(db.specConfigs) ~= "table" then
        db.specConfigs = {}
    end
    local importedRoot = type(payload.specConfigs) == "table" and payload.specConfigs or {}
    local importedCount = 0

    for classIDKey, classMap in pairs(importedRoot) do
        local classID = tonumber(classIDKey)
        if classID and classID >= 0 and type(classMap) == "table" then
            if type(db.specConfigs[classID]) ~= "table" then
                db.specConfigs[classID] = {}
            end
            for specIDKey, importedMap in pairs(classMap) do
                local specID = tonumber(specIDKey)
                if specID and specID >= 0 and type(importedMap) == "table" then
                    if classID == ALL_CLASSES_ID then
                        specID = ALL_SPECS_ID
                    end
                    local oldMap = db.specConfigs[classID][specID]
                    if type(oldMap) ~= "table" then
                        oldMap = {}
                    end
                    local usedOld = {}
                    local newMap = {}
                    local importedIndices = {}
                    for index, entry in pairs(importedMap) do
                        index = tonumber(index) or 0
                        if index > 0 and type(entry) == "table" and (tonumber(entry.spellId) or 0) > 0 then
                            importedIndices[#importedIndices + 1] = index
                        end
                    end
                    table.sort(importedIndices)
                    for _, index in ipairs(importedIndices) do
                        local sanitized = SanitizeEntryForImport(importedMap[index])
                        if sanitized then
                            local oldIndex = FindMatchingEntryIndexBySpell(oldMap, sanitized.spellId, sanitized.entryType, sanitized.objectType)
                            if oldIndex then
                                usedOld[oldIndex] = true
                            end
                            ClearDeletedEntryMarker(classID, specID, sanitized, index)
                            newMap[index] = sanitized
                            importedCount = importedCount + 1
                        end
                    end
                    local oldIndices = api.GetOrderedEntryIndices(oldMap)
                    for _, oldIndex in ipairs(oldIndices) do
                        if not usedOld[oldIndex] then
                            local oldEntry = api.GetEntry(oldMap, oldIndex)
                            local sanitizedOld = SanitizeEntryForImport(oldEntry)
                            if sanitizedOld then
                                local targetIndex = oldIndex
                                while type(newMap[targetIndex]) == "table" and (tonumber(newMap[targetIndex].spellId) or 0) > 0 do
                                    targetIndex = targetIndex + 1
                                end
                                ClearDeletedEntryMarker(classID, specID, sanitizedOld, targetIndex)
                                newMap[targetIndex] = sanitizedOld
                            end
                        end
                    end
                    db.specConfigs[classID][specID] = newMap
                end
            end
        end
    end

    if type(payload.collectionData) == "table" then
        if type(db.collectionData) ~= "table" then
            db.collectionData = {}
        end
        for classIDKey, classMap in pairs(payload.collectionData) do
            local classID = tonumber(classIDKey)
            if classID and classID >= 0 and type(classMap) == "table" then
                if type(db.collectionData[classID]) ~= "table" then
                    db.collectionData[classID] = {}
                end
                for specIDKey, scope in pairs(classMap) do
                    local specID = tonumber(specIDKey)
                    if specID and specID >= 0 and type(scope) == "table" then
                        if classID == ALL_CLASSES_ID then
                            specID = ALL_SPECS_ID
                        end
                        db.collectionData[classID][specID] = DeepCopyTable(scope)
                        NormalizeCollectionScope(db.collectionData[classID][specID], api.GetStoredEntryMap(classID, specID), classID, specID)
                    end
                end
            end
        end
    end

    if type(payload.savedListOrder) == "table" then
        db.savedListOrder = DeepCopyTable(payload.savedListOrder)
    end
    EnsureSavedListOrderDB()

    if type(payload.bloodlustConfig) == "table" then
        db.bloodlustConfig = DeepCopyTable(payload.bloodlustConfig)
    end
    db.collectionSerial = math.max(tonumber(db.collectionSerial) or 0, tonumber(payload.collectionSerial) or 0)
    return true, importedCount
end

local function ImportEntryPayload(payload)
    local imported = 0
    for _, record in ipairs(payload.entries or {}) do
        if ImportEntryRecord(record, true) then
            imported = imported + 1
        end
    end
    return imported > 0, imported
end

local function FindGroupByName(scope, name)
    name = TrimText(name)
    if name == "" or type(scope) ~= "table" then
        return nil
    end
    for groupID, group in pairs(scope.groups or {}) do
        if type(group) == "table" and TrimText(group.name) == name then
            return tostring(groupID)
        end
    end
    return nil
end

local function EnsureGroupInRoot(scope, groupID)
    if type(scope) ~= "table" or tostring(groupID or "") == "" then
        return
    end
    for _, item in ipairs(scope.root or {}) do
        if type(item) == "table" and item.type == "group" and tostring(item.id or "") == tostring(groupID) then
            return
        end
    end
    scope.root[#scope.root + 1] = { type = "group", id = tostring(groupID) }
end

local function ImportCollectionPayload(payload)
    local api = GetApi()
    if not api or type(payload.group) ~= "table" then
        return false, 0
    end
    local classID = tonumber(payload.classID) or 0
    local specID = tonumber(payload.specID) or 0
    if classID == ALL_CLASSES_ID then
        specID = ALL_SPECS_ID
    end
    if classID < 0 or specID < 0 then
        return false, 0
    end

    local keyMap = {}
    local imported = 0
    for _, record in ipairs(payload.entries or {}) do
        local newKey = ImportEntryRecord(record, true)
        if newKey then
            imported = imported + 1
            if record.originalKey then
                keyMap[tostring(record.originalKey)] = newKey
            end
            local oldKey = BuildEntryKey(record.classID, record.specID, record.index)
            keyMap[tostring(oldKey)] = newKey
        end
    end

    local scope = EnsureCollectionScope(classID, specID)
    NormalizeCollectionScope(scope, api.GetStoredEntryMap(classID, specID), classID, specID)
    local db = EnsureRootDB()

    local payloadGroups = type(payload.groups) == "table" and payload.groups or nil
    if payloadGroups then
        local rootOldID = tostring(payload.rootGroupID or "")
        if rootOldID == "" then
            rootOldID = "root"
            payloadGroups[rootOldID] = payload.group
        end

        local groupIDMap = {}
        for oldID, group in pairs(payloadGroups) do
            if type(group) == "table" then
                oldID = tostring(oldID)
                local newID
                if oldID == rootOldID then
                    local name = TrimText(group.name)
                    if name == "" then
                        name = L("IMPORT_COLLECTION_NAME")
                    end
                    newID = FindGroupByName(scope, name)
                end
                if not newID then
                    db.collectionSerial = (tonumber(db.collectionSerial) or 0) + 1
                    newID = "g" .. tostring(db.collectionSerial)
                end
                groupIDMap[oldID] = newID
            end
        end

        for oldID, group in pairs(payloadGroups) do
            if type(group) == "table" then
                oldID = tostring(oldID)
                local newID = groupIDMap[oldID]
                if newID then
                    local remappedEntries = {}
                    for _, oldRef in ipairs(group.entries or {}) do
                        local oldGroupKey, oldGroupClassID, oldGroupSpecID, oldGroupID = GroupRefToKey(classID, specID, oldRef)
                        if oldGroupKey and oldGroupID ~= "" then
                            local mappedGroupID = groupIDMap[tostring(oldGroupID)]
                            if mappedGroupID then
                                remappedEntries[#remappedEntries + 1] = BuildGroupKey(classID, specID, mappedGroupID)
                            end
                        else
                            local oldKey = EntryRefToKey(classID, specID, oldRef)
                            local newKey = oldKey and keyMap[tostring(oldKey)] or nil
                            if newKey then
                                remappedEntries[#remappedEntries + 1] = newKey
                            end
                        end
                    end
                    scope.groups[newID] = {
                        name = TrimText(group.name) ~= "" and TrimText(group.name) or L("IMPORT_COLLECTION_NAME"),
                        iconID = NormalizeIconID(group.iconID),
                        collapsed = group.collapsed == true,
                        entries = remappedEntries,
                    }
                end
            end
        end

        local rootNewID = groupIDMap[rootOldID]
        if rootNewID then
            EnsureGroupInRoot(scope, rootNewID)
        end
        NormalizeCollectionScope(scope, api.GetStoredEntryMap(classID, specID), classID, specID)
        return true, imported
    end

    local name = TrimText(payload.group.name)
    if name == "" then
        name = L("IMPORT_COLLECTION_NAME")
    end
    local groupID = FindGroupByName(scope, name)
    if not groupID then
        db.collectionSerial = (tonumber(db.collectionSerial) or 0) + 1
        groupID = "g" .. tostring(db.collectionSerial)
    end

    local groupEntries = {}
    for _, oldRef in ipairs(payload.group.entries or {}) do
        local oldKey = EntryRefToKey(classID, specID, oldRef)
        local newKey = oldKey and keyMap[tostring(oldKey)] or nil
        if newKey then
            groupEntries[#groupEntries + 1] = newKey
        end
    end

    scope.groups[groupID] = {
        name = name,
        iconID = NormalizeIconID(payload.group.iconID),
        collapsed = payload.group.collapsed == true,
        entries = groupEntries,
    }
    EnsureGroupInRoot(scope, groupID)
    NormalizeCollectionScope(scope, api.GetStoredEntryMap(classID, specID), classID, specID)
    return true, imported
end

function NS.AceOptions:ImportString(text)
    local payload, err = DecodeExportPayload(text)
    if not payload then
        print("[MCD-CVA] " .. L("MSG_IMPORT_FAILED", tostring(err or "unknown error")))
        return false
    end

    local ok, count = false, 0
    if payload.type == "entry" then
        ok, count = ImportEntryPayload(payload)
    elseif payload.type == "collection" then
        ok, count = ImportCollectionPayload(payload)
    elseif payload.type == "full" then
        ok, count = ImportFullPayload(payload)
    else
        print("[MCD-CVA] " .. L("MSG_IMPORT_UNKNOWN_TYPE"))
        return false
    end

    if ok then
        local api = GetApi()
        if api then
            if type(api.ResolveAllStoredItemTriggers) == "function" then
                api.ResolveAllStoredItemTriggers(true)
            end
            api.RebuildRuntimeConfig()
            if type(api.RebuildCastSuccessConfig) == "function" then
                api.RebuildCastSuccessConfig()
            end
            api.RefreshRuntimeCooldowns()
            if type(api.RebuildBloodlustConfig) == "function" then
                api.RebuildBloodlustConfig()
            end
            if type(api.RefreshPanel) == "function" then
                api.RefreshPanel()
            end
        end
        print("[MCD-CVA] " .. L("MSG_IMPORT_DONE", tostring(payload.type), tonumber(count) or 0))
        return true
    end

    print("[MCD-CVA] " .. L("MSG_IMPORT_EMPTY"))
    return false
end

function NS.AceOptions:GetCurrentScopeSummary()
    local api = GetApi()
    local state = self:GetState()
    if not api then
        return ""
    end

    local className = api.ResolveClassName(state.classID)
    local specName = api.ResolveSpecName(state.classID, state.specID)
    local count = #self:GetCurrentScopeEntryList()
    return L("MSG_CURRENT_EDIT_SCOPE", className, specName)
end

function NS.AceOptions:LoadSelectedEntry()
    local api = GetApi()
    local state = self:GetState()
    if not api then
        return
    end

    local classID, specID, selectedIndex = ParseEntryKey(state.selectedKey)
    if classID < 0 or specID < 0 or selectedIndex <= 0 then
        return
    end
    if classID == ALL_CLASSES_ID then
        specID = ALL_SPECS_ID
    end

    local map = api.GetStoredEntryMap(classID, specID)
    local entry = api.GetEntry(map, selectedIndex)
    if not entry then
        print("[MCD-CVA] " .. L("MSG_SELECTED_NOT_EXIST"))
        return
    end

    state.classID = classID
    state.specID = specID
    state.entryType = tostring(entry.entryType or "cooldown")
    state.spellId = tonumber(entry.spellId) or 0
    state.objectType = type(api.ResolveObjectType) == "function" and api.ResolveObjectType(state.spellId, entry.objectType) or tostring(entry.objectType or OBJECT_TYPE_SPELL)
    state.spellName = TrimText(entry.spellName)
    state.baseCD = tonumber(entry.baseCD) or 0
    state.fixedCD = true
    state.checkTalent = entry.checkTalent == true and (tonumber(entry.talentId) or 0) > 0
    state.talentId = tonumber(entry.talentId) or 0
    state.talentName = TrimText(entry.talentName or "")
    state.talentCD = tonumber(entry.talentCD) or 0
    state.delayEnabled = entry.delayEnabled == true
    state.delaySeconds = math.max(0, tonumber(entry.delaySeconds) or 0)
    local modeTts, modeSound = api.GetModes()
    state.notifyMode = tostring(entry.notifyMode or modeSound)
    state.ttsText = tostring(entry.ttsText or "")
    state.ttsRate = tonumber(entry.ttsRate) or 0
    state.soundPath = self:NormalizeSoundPath(entry.soundPath or "")

    if self:IsBuiltinSoundPath(state.soundPath) then
        state.builtinSoundPath = state.soundPath
        state.customSoundPath = ""
        state.useCustomSound = false
    else
        state.builtinSoundPath = self:GetDefaultBuiltinSoundPath()
        state.customSoundPath = state.soundPath
        state.useCustomSound = (state.notifyMode == modeSound and TrimText(state.customSoundPath) ~= "")
    end

    if state.notifyMode == modeTts then
        state.useCustomSound = false
    end

    if state.spellName == "" and state.spellId > 0 then
        state.spellName = type(api.ResolveObjectName) == "function" and api.ResolveObjectName(state.spellId, state.objectType) or api.ResolveSpellName(state.spellId)
    end
end

function NS.AceOptions:AutofillFromSpellId()
    local api = GetApi()
    local state = self:GetState()
    if not api then
        return
    end

    local spellId = tonumber(state.spellId) or 0
    if spellId <= 0 then
        return
    end

    local objectType = type(api.ResolveObjectType) == "function" and api.ResolveObjectType(spellId, state.objectType) or OBJECT_TYPE_SPELL
    state.objectType = objectType

    local spellName = type(api.ResolveObjectName) == "function" and api.ResolveObjectName(spellId, objectType) or api.ResolveSpellName(spellId)
    if spellName ~= "" then
        state.spellName = spellName
    end

    local baseCD = type(api.ResolveObjectBaseCooldownSeconds) == "function" and api.ResolveObjectBaseCooldownSeconds(spellId, objectType) or api.ResolveSpellBaseCooldownSeconds(spellId)
    if baseCD and baseCD > 0 then
        state.baseCD = baseCD
    end
end


function NS.AceOptions:AutofillFromTalentId()
    local api = GetApi()
    local state = self:GetState()
    if not api then
        return
    end

    local talentId = tonumber(state.talentId) or 0
    if talentId <= 0 then
        state.talentName = ""
        return
    end

    local talentName = ""
    if type(api.ResolveTalentName) == "function" then
        talentName = api.ResolveTalentName(talentId)
    elseif type(api.ResolveSpellName) == "function" then
        talentName = api.ResolveSpellName(talentId)
    end
    if talentName ~= "" then
        state.talentName = talentName
    end
end

function NS.AceOptions:SaveEntry()
    local api = GetApi()
    local state = self:GetState()
    if not api then
        return
    end

    local classID = tonumber(state.classID) or 0
    local specID = tonumber(state.specID) or 0
    local selectedCollectionKeyForSave = tostring(state.selectedCollectionKey or "")
    if selectedCollectionKeyForSave == "" and tostring(state.selectedKey or ""):match("^group:") then
        selectedCollectionKeyForSave = tostring(state.selectedKey or "")
    end
    local saveGroupClassID, saveGroupSpecID, saveGroupID = ParseGroupKey(selectedCollectionKeyForSave)
    if saveGroupID ~= "" and saveGroupClassID >= 0 and saveGroupSpecID >= 0 then
        classID = saveGroupClassID
        specID = saveGroupSpecID
    end
    if classID == ALL_CLASSES_ID then
        specID = ALL_SPECS_ID
        state.specID = ALL_SPECS_ID
    end
    local spellId = tonumber(state.spellId) or 0
    local objectType = type(api.ResolveObjectType) == "function" and api.ResolveObjectType(spellId, state.objectType) or OBJECT_TYPE_SPELL
    local baseCD = tonumber(state.baseCD) or 0
    local checkTalent = state.checkTalent == true
    local talentId = tonumber(state.talentId) or 0
    local talentName = TrimText(state.talentName or "")
    local talentCD = tonumber(state.talentCD) or 0
    local notifyMode = tostring(state.notifyMode or select(2, api.GetModes()))
    local ttsText = tostring(state.ttsText or "")
    local delayEnabled = state.delayEnabled == true
    local delaySeconds = math.max(0, tonumber(state.delaySeconds) or 0)
    local soundPath = self:NormalizeSoundPath(state.soundPath or "")
    state.soundPath = soundPath
    state.objectType = objectType
    local modeTts, modeSound = api.GetModes()
    local entryType = tostring(state.entryType or "cooldown")
    if entryType ~= "cast" then
        entryType = "cooldown"
    end
    local isCooldownEntry = entryType == "cooldown"

    if classID < 0 or specID < 0 then
        print("[MCD-CVA] " .. L("MSG_INVALID_CLASS_SPEC"))
        return
    end
    if spellId <= 0 then
        print("[MCD-CVA] " .. L("MSG_INVALID_SPELL_ID"))
        return
    end
    if isCooldownEntry and baseCD <= 0 then
        print("[MCD-CVA] " .. L("MSG_INVALID_FIXED_CD"))
        return
    end
    if (not isCooldownEntry) then
        baseCD = 0
        checkTalent = false
        talentId = 0
        talentName = ""
        talentCD = 0
    else
        delayEnabled = false
        delaySeconds = 0
    end
    if (not isCooldownEntry) and delayEnabled and delaySeconds <= 0 then
        print("[MCD-CVA] " .. L("MSG_INVALID_DELAY_SECONDS"))
        return
    end
    if checkTalent and talentId <= 0 then
        print("[MCD-CVA] " .. L("MSG_NEED_TALENT_ID"))
        return
    end
    if checkTalent and talentCD <= 0 then
        print("[MCD-CVA] " .. L("MSG_NEED_TALENT_CD"))
        return
    end
    if checkTalent and talentName == "" and type(api.ResolveTalentName) == "function" then
        talentName = api.ResolveTalentName(talentId)
        state.talentName = talentName
    end
    if notifyMode == modeTts and TrimText(ttsText) == "" then
        print("[MCD-CVA] " .. L("MSG_NEED_TTS_TEXT"))
        return
    end
    if notifyMode == modeSound and TrimText(soundPath) == "" then
        print("[MCD-CVA] " .. L("MSG_NEED_SOUND_PATH"))
        return
    end

    local map = api.EnsureEntryMap(classID, specID)
    local selectedClassID, selectedSpecID, selectedIndex = ParseEntryKey(state.selectedKey)
    local targetIndex = 0

    if selectedClassID == classID and selectedSpecID == specID and selectedIndex >= 1 and api.GetEntry(map, selectedIndex) then
        targetIndex = selectedIndex
    else
        targetIndex = api.FindFirstFreeIndex(map) or 0
    end

    local indices = type(api.GetOrderedEntryIndices) == "function" and api.GetOrderedEntryIndices(map) or {}
    for _, index in ipairs(indices) do
        local entry = api.GetEntry(map, index)
        local existingID = tonumber(entry and entry.spellId) or 0
        local existingType = api and type(api.ResolveObjectType) == "function" and api.ResolveObjectType(existingID, entry and entry.objectType) or tostring(entry and entry.objectType or OBJECT_TYPE_SPELL)
        if entry
            and index ~= targetIndex
            and (tonumber(entry.spellId) or 0) == spellId
            and tostring(entry.entryType or "cooldown") == entryType
            and tostring(existingType) == tostring(objectType) then
            print("[MCD-CVA] " .. L("MSG_DUP_SPELL"))
            return
        end
    end

    if targetIndex <= 0 then
        print("[MCD-CVA] " .. L("MSG_SAVE_LIMIT"))
        return
    end

    local oldEntry = api.GetEntry(map, targetIndex)
    local savedEntry = {
        entryType = entryType,
        objectType = objectType,
        spellName = tostring(state.spellName or ""),
        spellId = math.floor(spellId),
        itemID = (objectType == OBJECT_TYPE_ITEM) and math.floor(spellId) or nil,
        baseCD = isCooldownEntry and tonumber(string.format("%.2f", baseCD)) or 0,
        fixedCD = true,
        checkTalent = isCooldownEntry and checkTalent and talentId > 0,
        talentId = (isCooldownEntry and checkTalent) and math.floor(talentId) or 0,
        talentName = (isCooldownEntry and checkTalent) and talentName or "",
        talentCD = (isCooldownEntry and checkTalent) and tonumber(string.format("%.2f", talentCD)) or 0,
        chargeInput = 1,
        notifyMode = notifyMode,
        ttsText = ttsText,
        ttsRate = math.max(-10, math.min(10, tonumber(state.ttsRate) or 0)),
        delayEnabled = (not isCooldownEntry) and delayEnabled == true,
        delaySeconds = (not isCooldownEntry) and tonumber(string.format("%.2f", delaySeconds)) or 0,
        soundPath = soundPath,
    }

    -- 已经保存过 triggerSpellID 的同一物品，不再重复解析；只沿用旧值。
    if objectType == OBJECT_TYPE_ITEM
        and oldEntry
        and tostring(oldEntry.objectType or "") == OBJECT_TYPE_ITEM
        and (tonumber(oldEntry.spellId) or 0) == spellId
        and (tonumber(oldEntry.triggerSpellID or oldEntry.triggerSpellId) or 0) > 0 then
        savedEntry.triggerSpellID = math.floor(tonumber(oldEntry.triggerSpellID or oldEntry.triggerSpellId) or 0)
        savedEntry.triggerSpellName = tostring(oldEntry.triggerSpellName or "")
    end

    if objectType == OBJECT_TYPE_ITEM and type(api.ResolveItemTriggerForEntry) == "function" then
        local resolved = api.ResolveItemTriggerForEntry(savedEntry, true)
        if not resolved then
            print("[MCD-CVA] 物品ID " .. tostring(spellId) .. " 已保存，但暂未解析到触发法术ID；脱战或物品资料加载后会自动补全。")
        end
    end

    ClearDeletedEntryMarker(classID, specID, savedEntry, targetIndex)
    map[targetIndex] = savedEntry

    local selectedCollectionKey = tostring(state.selectedCollectionKey or "")
    if selectedCollectionKey == "" and tostring(state.selectedKey or ""):match("^group:") then
        selectedCollectionKey = tostring(state.selectedKey or "")
    end
    if selectedCollectionKey ~= "" then
        local groupClassID, groupSpecID, groupID = ParseGroupKey(selectedCollectionKey)
        if groupID ~= "" and groupClassID == classID and groupSpecID == specID then
            local scope = EnsureCollectionScope(classID, specID)
            if scope and type(scope.groups[groupID]) == "table" then
                local entryKey = BuildEntryKey(classID, specID, targetIndex)
                RemoveEntryKeyFromAllCollectionScopes(entryKey)
                local group = scope.groups[groupID]
                if type(group.entries) ~= "table" then
                    group.entries = {}
                end
                group.entries[#group.entries + 1] = entryKey
                NormalizeCollectionScope(scope, map, classID, specID)
            end
        end
    end

    api.RebuildRuntimeConfig()
    if type(api.RebuildCastSuccessConfig) == "function" then
        api.RebuildCastSuccessConfig()
    end
    api.RefreshRuntimeCooldowns()
    state.selectedKey = BuildEntryKey(classID, specID, targetIndex)
    self:LoadSelectedEntry()
    if type(api.RefreshPanel) == "function" then
        api.RefreshPanel()
    end
    if NS.UI and NS.UI.MainFrame and type(NS.UI.MainFrame.Refresh) == "function" then
        NS.UI.MainFrame:Refresh()
    end
    print("[MCD-CVA] " .. L("MSG_CONFIG_SAVED"))
    return true
end

function NS.AceOptions:DeleteSelectedEntry()
    local api = GetApi()
    local state = self:GetState()
    if not api then
        return
    end

    local classID, specID, index = ParseEntryKey(state.selectedKey)
    if classID < 0 or specID < 0 or index <= 0 then
        print("[MCD-CVA] " .. L("MSG_CHOOSE_CONFIG"))
        return
    end

    local map = api.EnsureEntryMap(classID, specID)
    if not map then
        print("[MCD-CVA] " .. L("MSG_INVALID_CLASS_SPEC"))
        return
    end

    local deletedEntry = api.GetEntry(map, index)
    if deletedEntry and type(api.MarkEntryDeleted) == "function" then
        api.MarkEntryDeleted(classID, specID, deletedEntry, index)
    end

    map[index] = nil
    RemoveEntryKeyFromAllCollectionScopes(BuildEntryKey(classID, specID, index))
    DeleteMatchingLegacyCastEntries(classID, specID, deletedEntry)
    self:ClearEditorFields()
    api.RebuildRuntimeConfig()
    if type(api.RebuildCastSuccessConfig) == "function" then
        api.RebuildCastSuccessConfig()
    end
    api.RefreshRuntimeCooldowns()
    if type(api.RefreshPanel) == "function" then
        api.RefreshPanel()
    end
    print("[MCD-CVA] " .. L("MSG_CONFIG_DELETED"))
end

function NS.AceOptions:TestCurrent()
    local api = GetApi()
    local state = self:GetState()
    if not api then
        return
    end

    local cfg = {
        spellId = tonumber(state.spellId) or 0,
        objectType = tostring(state.objectType or OBJECT_TYPE_SPELL),
        spellName = tostring(state.spellName or ""),
        notifyMode = tostring(state.notifyMode or select(2, api.GetModes())),
        ttsText = tostring(state.ttsText or ""),
        ttsRate = math.max(-10, math.min(10, tonumber(state.ttsRate) or 0)),
        soundPath = self:NormalizeSoundPath(state.soundPath or ""),
    }
    if tostring(state.entryType or "") == "cast" and type(api.PlayCastSuccessNotification) == "function" then
        api.PlayCastSuccessNotification(cfg)
    else
        api.PlayReadyNotification(cfg)
    end
end

function NS.AceOptions:SyncStateFromWidgets()
    local widgets = self.editorWidgets or self.widgets
    local state = self:GetState()
    local api = GetApi()
    if not widgets or not api then
        return
    end

    if widgets.classDrop then
        state.classID = tonumber(widgets.classDrop:GetValue()) or state.classID or 0
    end
    if widgets.specDrop then
        if (tonumber(state.classID) or 0) == ALL_CLASSES_ID then
            state.specID = ALL_SPECS_ID
        else
            state.specID = tonumber(widgets.specDrop:GetValue()) or state.specID or 0
        end
    end
    if widgets.spellId then
        state.spellId = tonumber(widgets.spellId:GetText() or "") or 0
    end
    if widgets.spellName then
        state.spellName = tostring(widgets.spellName:GetText() or "")
    end
    if widgets.baseCD then
        state.baseCD = tonumber(widgets.baseCD:GetText() or "") or 0
    end
    if widgets.checkTalent then
        if type(widgets.checkTalent.GetValue) == "function" then
            state.checkTalent = widgets.checkTalent:GetValue() == true
        elseif type(widgets.checkTalent.GetChecked) == "function" then
            state.checkTalent = widgets.checkTalent:GetChecked() == true
        end
    end
    if widgets.talentId then
        state.talentId = tonumber(widgets.talentId:GetText() or "") or 0
    end
    if widgets.talentName then
        state.talentName = tostring(widgets.talentName:GetText() or "")
    end
    if widgets.talentCD then
        state.talentCD = tonumber(widgets.talentCD:GetText() or "") or 0
    end
    if widgets.delayEnabled then
        if type(widgets.delayEnabled.GetValue) == "function" then
            state.delayEnabled = widgets.delayEnabled:GetValue() == true
        elseif type(widgets.delayEnabled.GetChecked) == "function" then
            state.delayEnabled = widgets.delayEnabled:GetChecked() == true
        end
    end
    if widgets.delaySeconds then
        state.delaySeconds = math.max(0, tonumber(widgets.delaySeconds:GetText() or "") or 0)
    end
    state.fixedCD = true
    if widgets.notifyMode then
        state.notifyMode = tostring(widgets.notifyMode:GetValue() or state.notifyMode or select(2, api.GetModes()))
    end
    if widgets.ttsText then
        state.ttsText = tostring(widgets.ttsText:GetText() or "")
    end
    if widgets.soundPath then
        state.soundPath = self:NormalizeSoundPath(widgets.soundPath:GetText() or "")
    end
end

function NS.AceOptions:ApplyStateToWidgets()
    local widgets = self.editorWidgets or self.widgets
    local state = self:GetState()
    local api = GetApi()
    if not widgets or not api then
        return
    end

    if widgets.classDrop then
        local classValues = self:GetClassValues()
        widgets.classDrop:SetList(classValues)
        widgets.classDrop:SetValue(state.classID)
        widgets.classDrop:SetPulloutWidth(220)
        if widgets.classDrop.SetDisabled then
            widgets.classDrop:SetDisabled(false)
        end
    end

    if widgets.specDrop then
        if (tonumber(state.classID) or 0) == ALL_CLASSES_ID then
            state.specID = ALL_SPECS_ID
        end
        widgets.specDrop:SetList(self:GetSpecValues(state.classID))
        widgets.specDrop:SetValue(state.specID)
        widgets.specDrop:SetPulloutWidth(260)
        if widgets.specDrop.SetDisabled then
            widgets.specDrop:SetDisabled((tonumber(state.classID) or 0) == ALL_CLASSES_ID)
        end
    end

    if widgets.spellId then
        widgets.spellId:SetText(state.spellId > 0 and tostring(state.spellId) or "")
    end
    if widgets.spellName then
        widgets.spellName:SetText(tostring(state.spellName or ""))
    end
    if widgets.baseCD then
        widgets.baseCD:SetText(state.baseCD > 0 and tostring(state.baseCD) or "")
    end
    if widgets.checkTalent then
        if type(widgets.checkTalent.SetValue) == "function" then
            widgets.checkTalent:SetValue(state.checkTalent == true)
        elseif type(widgets.checkTalent.SetChecked) == "function" then
            widgets.checkTalent:SetChecked(state.checkTalent == true)
        end
    end
    if widgets.talentId then
        widgets.talentId:SetText((tonumber(state.talentId) or 0) > 0 and tostring(state.talentId) or "")
        if widgets.talentId.SetDisabled then
            widgets.talentId:SetDisabled(not (state.checkTalent == true))
        end
    end
    if widgets.talentName then
        widgets.talentName:SetText(tostring(state.talentName or ""))
        if widgets.talentName.SetDisabled then
            widgets.talentName:SetDisabled(not (state.checkTalent == true))
        end
    end
    if widgets.talentCD then
        widgets.talentCD:SetText((tonumber(state.talentCD) or 0) > 0 and tostring(state.talentCD) or "")
        if widgets.talentCD.SetDisabled then
            widgets.talentCD:SetDisabled(not (state.checkTalent == true))
        end
    end
    if widgets.delayEnabled then
        if type(widgets.delayEnabled.SetValue) == "function" then
            widgets.delayEnabled:SetValue(state.delayEnabled == true)
        elseif type(widgets.delayEnabled.SetChecked) == "function" then
            widgets.delayEnabled:SetChecked(state.delayEnabled == true)
        end
    end
    if widgets.delaySeconds then
        widgets.delaySeconds:SetText((tonumber(state.delaySeconds) or 0) > 0 and tostring(state.delaySeconds) or "")
        if widgets.delaySeconds.SetDisabled then
            widgets.delaySeconds:SetDisabled(not (state.delayEnabled == true))
        end
    end
    state.fixedCD = true
    if widgets.notifyMode then
        widgets.notifyMode:SetList({
            [select(2, api.GetModes())] = L("MODE_SOUND_FILE"),
            [select(1, api.GetModes())] = L("MODE_TTS_TEXT"),
        })
        widgets.notifyMode:SetValue(tostring(state.notifyMode or select(2, api.GetModes())))
        widgets.notifyMode:SetPulloutWidth(220)
    end
    if widgets.ttsText then
        widgets.ttsText:SetText(tostring(state.ttsText or ""))
    end
    if widgets.soundPath then
        widgets.soundPath:SetText(tostring(state.soundPath or ""))
    end

    local useTts = tostring(state.notifyMode or "") == tostring(select(1, api.GetModes()))
    if widgets.ttsText then
        widgets.ttsText:SetDisabled(not useTts)
    end
    if widgets.soundPath then
        widgets.soundPath:SetDisabled(useTts)
    end
end

function NS.AceOptions:RefreshSavedList()
    local widgets = self.widgets
    local AceGUI = self.AceGUI
    if not widgets or not AceGUI then
        return
    end

    -- 清空两个列表
    if widgets.loadedList then
        widgets.loadedList:ReleaseChildren()
        if widgets.loadedList.SetScroll then
            widgets.loadedList:SetScroll(0)
        end
    end

    if widgets.unloadedList then
        widgets.unloadedList:ReleaseChildren()
        if widgets.unloadedList.SetScroll then
            widgets.unloadedList:SetScroll(0)
        end
    end

    local entries = self:GetAllSavedEntryList()
    local selectedKey = tostring(self:GetState().selectedKey or "")
    local selectedExists = false

    local api = GetApi()
    local currentClassID, currentSpecID = 0, 0
    if api and type(api.GetCurrentClassSpec) == "function" then
        currentClassID, currentSpecID = api.GetCurrentClassSpec()
    end

    local loadedEntries = {}
    local unloadedEntries = {}

    -- 将条目分为已载入和未载入两组
    for _, entry in ipairs(entries) do
        local isLoaded = IsLoadedScope(api, entry.classID, entry.specID, currentClassID, currentSpecID)
        if isLoaded then
            loadedEntries[#loadedEntries + 1] = entry
        else
            unloadedEntries[#unloadedEntries + 1] = entry
        end
    end

    -- 添加已载入条目到左侧列表
    if widgets.loadedList then
        if #loadedEntries == 0 then
            local empty = AceGUI:Create("Label")
            empty:SetFullWidth(true)
            empty:SetText("None")
            widgets.loadedList:AddChild(empty)
        else
            for _, entry in ipairs(loadedEntries) do
                local item = AceGUI:Create("InteractiveLabel")
                item:SetFullWidth(true)
                item:SetHeight(SAVED_ENTRY_ROW_HEIGHT)
                item:SetImage(entry.icon)
                item:SetImageSize(16, 16)

                local selected = selectedKey == entry.key
                if selected then
                    selectedExists = true
                end
                item:SetText(BuildSavedEntryLine(entry, selected, false))
                ApplySavedEntrySelectionStyle(item, selected)
                item:SetCallback("OnClick", function()
                    self:GetState().selectedKey = entry.key
                    self:LoadSelectedEntry()
                    self:RefreshUI()
                end)
                widgets.loadedList:AddChild(item)
            end
        end
    end

    -- 添加未载入条目到右侧列表
    if widgets.unloadedList then
        if #unloadedEntries == 0 then
            local empty = AceGUI:Create("Label")
            empty:SetFullWidth(true)
            empty:SetText("None")
            widgets.unloadedList:AddChild(empty)
        else
            for _, entry in ipairs(unloadedEntries) do
                local item = AceGUI:Create("InteractiveLabel")
                item:SetFullWidth(true)
                item:SetHeight(SAVED_ENTRY_ROW_HEIGHT)
                item:SetImage(entry.icon)
                item:SetImageSize(16, 16)

                local selected = selectedKey == entry.key
                if selected then
                    selectedExists = true
                end
                item:SetText(BuildSavedEntryLine(entry, selected, true))
                ApplySavedEntrySelectionStyle(item, selected)
                item:SetCallback("OnClick", function()
                    self:GetState().selectedKey = entry.key
                    self:LoadSelectedEntry()
                    self:RefreshUI()
                end)
                widgets.unloadedList:AddChild(item)
            end
        end
    end

    if selectedKey ~= "" and not selectedExists then
        self:GetState().selectedKey = nil
    end
end

function NS.AceOptions:RefreshUI()
    if not self.frameWidget then
        return
    end

    if self.widgets and self.widgets.scopeLabel then
        self.widgets.scopeLabel:SetText(self:GetCurrentScopeSummary())
    end

    local hasSelected = tostring(self:GetState().selectedKey or "") ~= ""
    if self.widgets and self.widgets.editBtn and self.widgets.editBtn.SetDisabled then
        self.widgets.editBtn:SetDisabled(not hasSelected)
    end
    if self.widgets and self.widgets.deleteBtn and self.widgets.deleteBtn.SetDisabled then
        self.widgets.deleteBtn:SetDisabled(not hasSelected)
    end

    if self.editorPopup and self.editorPopup.frame and self.editorPopup.frame:IsShown() then
        self:ApplyStateToWidgets()
    end

    self:RefreshSavedList()
    self.frameWidget:SetStatusText("")
end

function NS.AceOptions:CreateEditorPopup()
    if self.editorPopup and self.AceGUI and self.AceGUI.Release then
        self.AceGUI:Release(self.editorPopup)
        self.editorPopup = nil
        self.editorWidgets = nil
    end

    local AceGUI = self.AceGUI
    local popup = AceGUI:Create("Frame")
    popup:SetTitle(L("TITLE_EDIT_CONFIG"))
    popup:SetStatusText("")
    popup:SetLayout("List")
    popup:SetWidth(560)
    popup:SetHeight(520)
    popup:SetCallback("OnClose", function(widget)
        widget:Hide()
    end)
    popup:Hide()

    local widgets = {}
    self.editorWidgets = widgets
    self.editorPopup = popup

    local scopeRow = AceGUI:Create("SimpleGroup")
    scopeRow:SetFullWidth(true)
    scopeRow:SetLayout("Flow")
    popup:AddChild(scopeRow)

    local classDrop = AceGUI:Create("Dropdown")
    classDrop:SetLabel(L("LABEL_CLASS"))
    classDrop:SetRelativeWidth(0.48)
    classDrop:SetCallback("OnValueChanged", function(_, _, value)
        local state = self:GetState()
        state.classID = tonumber(value) or 0
        -- 不能设为 nil；否则 GetState() 会同步回当前职业/专精。
        -- 切换职业时默认选择“全专精”，包括“全职业 / 全专精”。
        state.specID = ALL_SPECS_ID
        self:EnsureValidScope()
        self:ApplyStateToWidgets()
    end)
    -- 在创建时就设置职业列表
    local classValues = self:GetClassValues()
    classDrop:SetList(classValues)
    classDrop:SetValue(self:GetState().classID)
    classDrop:SetPulloutWidth(220)
    if classDrop.SetDisabled then
        classDrop:SetDisabled(false)
    end
    ApplyEditorDropdownStyle(classDrop)
    scopeRow:AddChild(classDrop)
    widgets.classDrop = classDrop

    local specDrop = AceGUI:Create("Dropdown")
    specDrop:SetLabel(L("LABEL_SPEC"))
    specDrop:SetRelativeWidth(0.48)
    specDrop:SetCallback("OnValueChanged", function(_, _, value)
        self:GetState().specID = tonumber(value) or 0
        self:ApplyStateToWidgets()
    end)
    -- 在创建时就设置专精列表
    specDrop:SetList(self:GetSpecValues(self:GetState().classID))
    specDrop:SetValue(self:GetState().specID)
    specDrop:SetPulloutWidth(260)
    if specDrop.SetDisabled then
        specDrop:SetDisabled(false)
    end
    ApplyEditorDropdownStyle(specDrop)
    scopeRow:AddChild(specDrop)
    widgets.specDrop = specDrop

    local spellRow = AceGUI:Create("SimpleGroup")
    spellRow:SetFullWidth(true)
    spellRow:SetLayout("Flow")
    popup:AddChild(spellRow)

    local spellId = AceGUI:Create("EditBox")
    spellId:SetLabel(L("LABEL_SPELL_ID"))
    spellId:DisableButton(true)
    spellId:SetRelativeWidth(0.66)
    spellId:SetCallback("OnEnterPressed", function()
        self:SyncStateFromWidgets()
    end)
    spellRow:AddChild(spellId)
    widgets.spellId = spellId
    ApplyEditorEditBoxStyle(spellId)

    local autofill = AceGUI:Create("Button")
    autofill:SetText("Auto")
    autofill:SetRelativeWidth(0.32)
    autofill:SetCallback("OnClick", function()
        self:SyncStateFromWidgets()
        self:AutofillFromSpellId()
        self:ApplyStateToWidgets()
    end)
    spellRow:AddChild(autofill)

    local spellName = AceGUI:Create("EditBox")
    spellName:SetLabel(L("LABEL_SPELL_NAME"))
    spellName:DisableButton(true)
    spellName:SetFullWidth(true)
    popup:AddChild(spellName)
    widgets.spellName = spellName
    ApplyEditorEditBoxStyle(spellName)

    local baseRow = AceGUI:Create("SimpleGroup")
    baseRow:SetFullWidth(true)
    baseRow:SetLayout("Flow")
    popup:AddChild(baseRow)

    local baseCD = AceGUI:Create("EditBox")
    baseCD:SetLabel(L("LABEL_FIXED_CD_SEC"))
    baseCD:DisableButton(true)
    baseCD:SetFullWidth(true)
    baseRow:AddChild(baseCD)
    widgets.baseCD = baseCD
    ApplyEditorEditBoxStyle(baseCD)

    local talentCD = AceGUI:Create("EditBox")
    talentCD:SetLabel(L("LABEL_TALENT_CD_SEC"))
    talentCD:DisableButton(true)
    talentCD:SetFullWidth(true)
    baseRow:AddChild(talentCD)
    widgets.talentCD = talentCD
    ApplyEditorEditBoxStyle(talentCD)

    local notifyMode = AceGUI:Create("Dropdown")
    notifyMode:SetLabel(L("SECTION_NOTIFY"))
    notifyMode:SetFullWidth(true)
    notifyMode:SetCallback("OnValueChanged", function()
        self:SyncStateFromWidgets()
        self:ApplyStateToWidgets()
    end)
    popup:AddChild(notifyMode)
    widgets.notifyMode = notifyMode
    ApplyEditorDropdownStyle(notifyMode)

    local ttsText = AceGUI:Create("EditBox")
    ttsText:SetLabel(L("LABEL_USE_TTS"))
    ttsText:DisableButton(true)
    ttsText:SetFullWidth(true)
    popup:AddChild(ttsText)
    widgets.ttsText = ttsText
    ApplyEditorEditBoxStyle(ttsText)

    local soundPath = AceGUI:Create("EditBox")
    soundPath:SetLabel(L("LABEL_SOUND_PATH_N", 1))
    soundPath:DisableButton(true)
    soundPath:SetFullWidth(true)
    popup:AddChild(soundPath)
    widgets.soundPath = soundPath
    ApplyEditorEditBoxStyle(soundPath)

    local actionRow = AceGUI:Create("SimpleGroup")
    actionRow:SetFullWidth(true)
    actionRow:SetLayout("Flow")
    popup:AddChild(actionRow)

    local save = AceGUI:Create("Button")
    save:SetText(L("BTN_SAVE"))
    save:SetRelativeWidth(0.32)
    save:SetCallback("OnClick", function()
        self:SyncStateFromWidgets()
        self:SaveEntry()
        self:RefreshUI()
        popup:Hide()
    end)
    actionRow:AddChild(save)

    local test = AceGUI:Create("Button")
    test:SetText(L("BTN_TEST"))
    test:SetRelativeWidth(0.32)
    test:SetCallback("OnClick", function()
        self:SyncStateFromWidgets()
        self:TestCurrent()
    end)
    actionRow:AddChild(test)

    local close = AceGUI:Create("Button")
    close:SetText(L("BTN_CLOSE"))
    close:SetRelativeWidth(0.32)
    close:SetCallback("OnClick", function()
        popup:Hide()
    end)
    actionRow:AddChild(close)
end

function NS.AceOptions:OpenEditorPopup(title)
    if self.editorPopup and self.AceGUI and self.AceGUI.Release then
        self.AceGUI:Release(self.editorPopup)
        self.editorPopup = nil
        self.editorWidgets = nil
    end
    self:CreateEditorPopup()
    self:EnsureValidScope()
    if self.editorPopup and self.editorPopup.SetTitle then
        self.editorPopup:SetTitle(tostring(title or L("TITLE_EDIT_CONFIG")))
    end
    self:ApplyStateToWidgets()
    if self.editorWidgets and self.editorWidgets.classDrop then
        self.editorWidgets.classDrop:SetList(self:GetClassValues())
        self.editorWidgets.classDrop:SetValue(self:GetState().classID)
        if self.editorWidgets.classDrop.SetDisabled then
            self.editorWidgets.classDrop:SetDisabled(false)
        end
    end
    if self.editorWidgets and self.editorWidgets.specDrop then
        self.editorWidgets.specDrop:SetList(self:GetSpecValues(self:GetState().classID))
        self.editorWidgets.specDrop:SetValue(self:GetState().specID)
        if self.editorWidgets.specDrop.SetDisabled then
            self.editorWidgets.specDrop:SetDisabled((tonumber(self:GetState().classID) or 0) == ALL_CLASSES_ID)
        end
    end
    self.editorPopup:Show()
end

function NS.AceOptions:CreateWindow()
    if self.frameWidget then
        return
    end

    local AceGUI = self.AceGUI
    local frame = AceGUI:Create("Frame")
    frame:SetTitle(NS.ADDON_DISPLAY_NAME or L("ADDON_DISPLAY_NAME"))
    frame:SetStatusText("")
    frame:SetLayout("List")
    frame:SetWidth(900)
    frame:SetHeight(MAIN_WINDOW_HEIGHT)
    frame:SetStatusTable(self.windowStatus or {})
    frame:SetCallback("OnClose", function(widget)
        widget:Hide()
        if self.editorPopup then
            self.editorPopup:Hide()
        end
    end)
    self.windowStatus = self.windowStatus or {}
    self.frameWidget = frame

    local widgets = {}
    self.widgets = widgets

    local pluginName = AceGUI:Create("EditBox")
    pluginName:SetLabel("Addon")
    pluginName:DisableButton(true)
    pluginName:SetFullWidth(true)
    pluginName:SetText(NS.ADDON_DISPLAY_NAME or L("ADDON_DISPLAY_NAME"))
    frame:AddChild(pluginName)
    widgets.pluginName = pluginName

    local scopeLabel = AceGUI:Create("Label")
    scopeLabel:SetFullWidth(true)
    scopeLabel:SetText("")
    frame:AddChild(scopeLabel)
    widgets.scopeLabel = scopeLabel

    local actionRow = AceGUI:Create("SimpleGroup")
    actionRow:SetFullWidth(true)
    actionRow:SetLayout("Flow")
    frame:AddChild(actionRow)

    local addBtn = AceGUI:Create("Button")
    addBtn:SetText(L("BTN_ADD_VOICE"))
    addBtn:SetRelativeWidth(0.2)
    addBtn:SetCallback("OnClick", function()
        self:SyncScopeToCurrentSpec()
        self:EnsureValidScope()
        self:ClearEditorFields()
        self:OpenEditorPopup(L("TITLE_NEW_CONFIG"))
        self:RefreshUI()
    end)
    actionRow:AddChild(addBtn)
    widgets.addBtn = addBtn

    local editBtn = AceGUI:Create("Button")
    editBtn:SetText(L("BTN_EDIT"))
    editBtn:SetRelativeWidth(0.2)
    editBtn:SetCallback("OnClick", function()
        if tostring(self:GetState().selectedKey or "") == "" then
            print("[MCD-CVA] " .. L("MSG_CHOOSE_SAVED_ENTRY"))
            return
        end
        self:LoadSelectedEntry()
        self:OpenEditorPopup(L("TITLE_EDIT_CONFIG"))
    end)
    actionRow:AddChild(editBtn)
    widgets.editBtn = editBtn

    local deleteBtn = AceGUI:Create("Button")
    deleteBtn:SetText(L("BTN_DELETE"))
    deleteBtn:SetRelativeWidth(0.2)
    deleteBtn:SetCallback("OnClick", function()
        self:DeleteSelectedEntry()
        self:RefreshUI()
    end)
    actionRow:AddChild(deleteBtn)
    widgets.deleteBtn = deleteBtn

    local savedContainer = AceGUI:Create("SimpleGroup")
    savedContainer:SetFullWidth(true)
    savedContainer:SetLayout("List")
    savedContainer:SetAutoAdjustHeight(false)
    savedContainer:SetHeight(SAVED_CONTAINER_HEIGHT)
    frame:AddChild(savedContainer)

    -- 上方：已载入区域
    local loadedGroup = AceGUI:Create("SimpleGroup")
    loadedGroup:SetFullWidth(true)
    loadedGroup:SetLayout("List")
    loadedGroup:SetAutoAdjustHeight(false)
    loadedGroup:SetHeight(SAVED_GROUP_HEIGHT)
    savedContainer:AddChild(loadedGroup)
    widgets.loadedGroup = loadedGroup

    local loadedTitle = AceGUI:Create("Heading")
    loadedTitle:SetText(L("HEADER_LOADED"))
    loadedTitle:SetFullWidth(true)
    loadedGroup:AddChild(loadedTitle)

    local loadedList = AceGUI:Create("ScrollFrame")
    loadedList:SetFullWidth(true)
    loadedList:SetLayout("List")
    loadedList:SetHeight(SAVED_LIST_HEIGHT)
    loadedGroup:AddChild(loadedList)
    widgets.loadedList = loadedList

    -- 下方：未载入区域
    local unloadedGroup = AceGUI:Create("SimpleGroup")
    unloadedGroup:SetFullWidth(true)
    unloadedGroup:SetLayout("List")
    unloadedGroup:SetAutoAdjustHeight(false)
    unloadedGroup:SetHeight(SAVED_GROUP_HEIGHT)
    savedContainer:AddChild(unloadedGroup)
    widgets.unloadedGroup = unloadedGroup

    local unloadedTitle = AceGUI:Create("Heading")
    unloadedTitle:SetText(L("HEADER_UNLOADED"))
    unloadedTitle:SetFullWidth(true)
    unloadedGroup:AddChild(unloadedTitle)

    local unloadedList = AceGUI:Create("ScrollFrame")
    unloadedList:SetFullWidth(true)
    unloadedList:SetLayout("List")
    unloadedList:SetHeight(SAVED_LIST_HEIGHT)
    unloadedGroup:AddChild(unloadedList)
    widgets.unloadedList = unloadedList
end

function NS.AceOptions:Initialize()
    if self.initialized then
        return self.available == true
    end
    self.initialized = true

    if type(LibStubGlobal) ~= "table" then
        self.available = false
        return false
    end

    local AceGUI = LibStubGlobal:GetLibrary("AceGUI-3.0", true)
    if not AceGUI then
        self.available = false
        return false
    end

    self.AceGUI = AceGUI
    self.available = true
    return true
end

function NS.AceOptions:Open()
    if not self:Initialize() then
        return false
    end

    self:SyncScopeToCurrentSpec()
    self:EnsureValidScope()
    self:ClearEditorFields()
    self:CreateWindow()
    self:RefreshUI()
    self.frameWidget:Show()
    return true
end

function NS.AceOptions:RefreshSavedList()
    local widgets = self.widgets
    local AceGUI = self.AceGUI
    if not widgets or not AceGUI or not widgets.savedList then
        return
    end

    widgets.savedList:ReleaseChildren()
    if widgets.savedList.SetScroll then
        widgets.savedList:SetScroll(0)
    end

    local entries = self:GetAllSavedEntryList()
    local state = self:GetState()
    local selectedKey = tostring(state.selectedKey or "")
    local selectedExists = false

    local api = GetApi()
    local currentClassID, currentSpecID = 0, 0
    if api and type(api.GetCurrentClassSpec) == "function" then
        currentClassID, currentSpecID = api.GetCurrentClassSpec()
    end

    local loadedEntries = {}
    local unloadedEntries = {}
    for _, entry in ipairs(entries) do
        local isLoaded = IsLoadedScope(api, entry.classID, entry.specID, currentClassID, currentSpecID)
        if isLoaded then
            loadedEntries[#loadedEntries + 1] = entry
        else
            unloadedEntries[#unloadedEntries + 1] = entry
        end
    end

    local function AddSectionHeader(text, count, stateKey)
        local headerRow = AceGUI:Create("SimpleGroup")
        headerRow:SetFullWidth(true)
        headerRow:SetLayout("Flow")
        headerRow:SetHeight(SAVED_SECTION_HEADER_HEIGHT)
        widgets.savedList:AddChild(headerRow)

        local heading = AceGUI:Create("Heading")
        heading:SetText(text)
        heading:SetRelativeWidth(0.78)
        heading:SetHeight(SAVED_SECTION_HEADING_HEIGHT)
        headerRow:AddChild(heading)

        local toggleBtn = AceGUI:Create("Button")
        toggleBtn:SetText(string.format("%s (%d)", state[stateKey] and L("EXPAND_COLLECTION") or L("COLLAPSE_COLLECTION"), tonumber(count) or 0))
        toggleBtn:SetRelativeWidth(0.2)
        toggleBtn:SetCallback("OnClick", function()
            state[stateKey] = not state[stateKey]
            self:RefreshUI()
        end)
        headerRow:AddChild(toggleBtn)
    end

    local function AddEmptyText()
        local empty = AceGUI:Create("Label")
        empty:SetFullWidth(true)
        empty:SetText("None")
        widgets.savedList:AddChild(empty)
    end

    local function AddEntryItem(entry, includeScopeText)
        local item = AceGUI:Create("InteractiveLabel")
        item:SetFullWidth(true)
        item:SetHeight(SAVED_ENTRY_ROW_HEIGHT)
        item:SetImage(entry.icon)
        item:SetImageSize(16, 16)

        local selected = selectedKey == entry.key
        if selected then
            selectedExists = true
        end

        item:SetText(BuildSavedEntryLine(entry, selected, includeScopeText))
        ApplySavedEntrySelectionStyle(item, selected)
        item:SetCallback("OnClick", function()
            self:GetState().selectedKey = entry.key
            self:LoadSelectedEntry()
            self:RefreshUI()
        end)
        widgets.savedList:AddChild(item)
    end

    AddSectionHeader(L("HEADER_LOADED"), #loadedEntries, "loadedCollapsed")
    if not state.loadedCollapsed then
        if #loadedEntries == 0 then
            AddEmptyText()
        else
            for _, entry in ipairs(loadedEntries) do
                AddEntryItem(entry, false)
            end
        end
    end

    AddSectionHeader(L("HEADER_UNLOADED"), #unloadedEntries, "unloadedCollapsed")
    if not state.unloadedCollapsed then
        if #unloadedEntries == 0 then
            AddEmptyText()
        else
            for _, entry in ipairs(unloadedEntries) do
                AddEntryItem(entry, true)
            end
        end
    end

    if selectedKey ~= "" and not selectedExists then
        state.selectedKey = nil
    end
end

function NS.AceOptions:CreateWindow()
    if self.frameWidget then
        return
    end

    local AceGUI = self.AceGUI
    local frame = AceGUI:Create("Frame")
    frame:SetTitle(NS.ADDON_DISPLAY_NAME or L("ADDON_DISPLAY_NAME"))
    frame:SetStatusText("")
    frame:SetLayout("List")
    frame:SetWidth(900)
    frame:SetHeight(MAIN_WINDOW_HEIGHT)
    frame:SetStatusTable(self.windowStatus or {})
    frame:SetCallback("OnClose", function(widget)
        widget:Hide()
        if self.editorPopup then
            self.editorPopup:Hide()
        end
    end)
    self.windowStatus = self.windowStatus or {}
    self.frameWidget = frame

    local widgets = {}
    self.widgets = widgets

    local pluginName = AceGUI:Create("EditBox")
    pluginName:SetLabel("Addon")
    pluginName:DisableButton(true)
    pluginName:SetFullWidth(true)
    pluginName:SetText(NS.ADDON_DISPLAY_NAME or L("ADDON_DISPLAY_NAME"))
    frame:AddChild(pluginName)
    widgets.pluginName = pluginName

    local scopeLabel = AceGUI:Create("Label")
    scopeLabel:SetFullWidth(true)
    scopeLabel:SetText("")
    frame:AddChild(scopeLabel)
    widgets.scopeLabel = scopeLabel

    local actionRow = AceGUI:Create("SimpleGroup")
    actionRow:SetFullWidth(true)
    actionRow:SetLayout("Flow")
    frame:AddChild(actionRow)

    local addBtn = AceGUI:Create("Button")
    addBtn:SetText(L("BTN_ADD_VOICE"))
    addBtn:SetRelativeWidth(0.2)
    addBtn:SetCallback("OnClick", function()
        self:SyncScopeToCurrentSpec()
        self:EnsureValidScope()
        self:ClearEditorFields()
        self:OpenEditorPopup(L("TITLE_NEW_CONFIG"))
        self:RefreshUI()
    end)
    actionRow:AddChild(addBtn)
    widgets.addBtn = addBtn

    local editBtn = AceGUI:Create("Button")
    editBtn:SetText(L("BTN_EDIT"))
    editBtn:SetRelativeWidth(0.2)
    editBtn:SetCallback("OnClick", function()
        if tostring(self:GetState().selectedKey or "") == "" then
            print("[MCD-CVA] " .. L("MSG_CHOOSE_SAVED_ENTRY"))
            return
        end
        self:LoadSelectedEntry()
        self:OpenEditorPopup(L("TITLE_EDIT_CONFIG"))
    end)
    actionRow:AddChild(editBtn)
    widgets.editBtn = editBtn

    local deleteBtn = AceGUI:Create("Button")
    deleteBtn:SetText(L("BTN_DELETE"))
    deleteBtn:SetRelativeWidth(0.2)
    deleteBtn:SetCallback("OnClick", function()
        self:DeleteSelectedEntry()
        self:RefreshUI()
    end)
    actionRow:AddChild(deleteBtn)
    widgets.deleteBtn = deleteBtn

    local savedContainer = AceGUI:Create("SimpleGroup")
    savedContainer:SetFullWidth(true)
    savedContainer:SetLayout("Fill")
    savedContainer:SetAutoAdjustHeight(false)
    savedContainer:SetHeight(SAVED_SCROLL_HEIGHT)
    frame:AddChild(savedContainer)

    local savedList = AceGUI:Create("ScrollFrame")
    savedList:SetFullWidth(true)
    savedList:SetFullHeight(true)
    savedList:SetLayout("List")
    savedList:SetAutoAdjustHeight(false)
    savedContainer:AddChild(savedList)
    widgets.savedList = savedList
end

local function EnsureSavedNativeScrollArea(widgets)
    if not widgets or not widgets.savedHost then
        return
    end

    if widgets.savedScrollFrame and widgets.savedScrollContent and widgets.savedScrollBar then
        return
    end

    local host = widgets.savedHost.content or widgets.savedHost.frame
    if not host then
        return
    end

    local scrollFrame = CreateFrame("ScrollFrame", nil, host)
    scrollFrame:SetPoint("TOPLEFT", host, "TOPLEFT", 0, 0)
    scrollFrame:SetPoint("BOTTOMRIGHT", host, "BOTTOMRIGHT", -24, 0)
    scrollFrame:EnableMouseWheel(true)

    local scrollBar = CreateFrame("Slider", nil, host, "UIPanelScrollBarTemplate")
    scrollBar:SetPoint("TOPLEFT", scrollFrame, "TOPRIGHT", 4, -16)
    scrollBar:SetPoint("BOTTOMLEFT", scrollFrame, "BOTTOMRIGHT", 4, 16)
    scrollBar:SetWidth(16)
    scrollBar:SetMinMaxValues(0, 0)
    scrollBar:SetValueStep(1)
    scrollBar:SetValue(0)
    scrollBar:Hide()

    local content = CreateFrame("Frame", nil, scrollFrame)
    content:SetPoint("TOPLEFT", 0, 0)
    content:SetPoint("TOPRIGHT", 0, 0)
    content:SetHeight(1)
    scrollFrame:SetScrollChild(content)

    scrollBar:SetScript("OnValueChanged", function(_, value)
        scrollFrame:SetVerticalScroll(value or 0)
    end)

    scrollFrame:SetScript("OnMouseWheel", function(_, delta)
        local minValue, maxValue = scrollBar:GetMinMaxValues()
        local nextValue = (scrollBar:GetValue() or 0) - ((delta or 0) * 36)
        if nextValue < minValue then
            nextValue = minValue
        end
        if nextValue > maxValue then
            nextValue = maxValue
        end
        scrollBar:SetValue(nextValue)
    end)

    scrollFrame:SetScript("OnSizeChanged", function(_, width)
        if width and width > 0 then
            content:SetWidth(width)
        end
    end)

    widgets.savedScrollFrame = scrollFrame
    widgets.savedScrollBar = scrollBar
    widgets.savedScrollContent = content
    widgets.savedNativeRows = widgets.savedNativeRows or {}
end

local function ClearSavedNativeRows(widgets)
    if not widgets or not widgets.savedNativeRows then
        return
    end

    for index = 1, #widgets.savedNativeRows do
        local row = widgets.savedNativeRows[index]
        if row then
            row:Hide()
            row:SetParent(nil)
        end
        widgets.savedNativeRows[index] = nil
    end
end

local function UpdateSavedNativeScrollMetrics(widgets, totalHeight)
    if not widgets or not widgets.savedHost or not widgets.savedScrollContent or not widgets.savedScrollBar or not widgets.savedScrollFrame then
        return
    end

    local viewportHeight = (widgets.savedHost.frame and widgets.savedHost.frame:GetHeight()) or SAVED_SCROLL_HEIGHT
    local contentHeight = math.max(tonumber(totalHeight) or 0, viewportHeight)
    widgets.savedScrollContent:SetHeight(contentHeight)

    local maxScroll = math.max((tonumber(totalHeight) or 0) - viewportHeight, 0)
    if maxScroll > 0 then
        widgets.savedScrollBar:Show()
        widgets.savedScrollBar:SetMinMaxValues(0, maxScroll)
        if (widgets.savedScrollBar:GetValue() or 0) > maxScroll then
            widgets.savedScrollBar:SetValue(maxScroll)
        end
    else
        widgets.savedScrollBar:SetMinMaxValues(0, 0)
        widgets.savedScrollBar:SetValue(0)
        widgets.savedScrollBar:Hide()
        widgets.savedScrollFrame:SetVerticalScroll(0)
    end
end

local function CreateSavedSectionHeader(parent, widgets, yOffset, text, count, collapsed, onClick)
    local row = CreateFrame("Button", nil, parent)
    row:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, -yOffset)
    row:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, -yOffset)
    row:SetHeight(SAVED_SECTION_HEADER_HEIGHT)
    row:RegisterForClicks("LeftButtonUp")
    row:SetScript("OnClick", onClick)

    local hover = row:CreateTexture(nil, "HIGHLIGHT")
    hover:SetAllPoints()
    hover:SetColorTexture(1, 1, 1, 0.06)

    local label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    label:SetPoint("CENTER", row, "CENTER", 0, 0)
    label:SetJustifyH("CENTER")
    label:SetText(string.format("%s（%d）", text, tonumber(count) or 0))
    label:SetTextColor(1, 0.82, 0)

    local left = row:CreateTexture(nil, "BACKGROUND")
    left:SetHeight(8)
    left:SetPoint("LEFT", row, "LEFT", 3, 0)
    left:SetPoint("RIGHT", label, "LEFT", -5, 0)
    left:SetTexture(137057)
    left:SetTexCoord(0.81, 0.94, 0.5, 1)

    local right = row:CreateTexture(nil, "BACKGROUND")
    right:SetHeight(8)
    right:SetPoint("RIGHT", row, "RIGHT", -3, 0)
    right:SetPoint("LEFT", label, "RIGHT", 5, 0)
    right:SetTexture(137057)
    right:SetTexCoord(0.81, 0.94, 0.5, 1)

    local stateText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    stateText:SetPoint("RIGHT", row, "RIGHT", -8, 0)
    stateText:SetJustifyH("RIGHT")
    stateText:SetText(collapsed and L("STATUS_EXPAND") or L("STATUS_COLLAPSE"))
    stateText:SetTextColor(1, 0.82, 0)

    widgets.savedNativeRows[#widgets.savedNativeRows + 1] = row
    return SAVED_SECTION_HEADER_HEIGHT
end

local function CreateSavedEntryRow(parent, widgets, yOffset, text, icon, selected, onClick)
    local row = CreateFrame("Button", nil, parent)
    row:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, -yOffset)
    row:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, -yOffset)
    row:SetHeight(SAVED_ENTRY_ROW_HEIGHT)
    row:RegisterForClicks("LeftButtonUp")
    row:SetScript("OnClick", onClick)

    local bg = row:CreateTexture(nil, "BACKGROUND")
    bg:SetPoint("TOPLEFT", row, "TOPLEFT", -2, 1)
    bg:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 2, -1)
    if selected then
        bg:SetColorTexture(1, 0.82, 0, 0.18)
    else
        bg:SetColorTexture(0, 0, 0, 0)
    end

    local hover = row:CreateTexture(nil, "HIGHLIGHT")
    hover:SetAllPoints()
    hover:SetColorTexture(1, 1, 1, 0.06)

    local iconTexture = row:CreateTexture(nil, "ARTWORK")
    iconTexture:SetPoint("LEFT", row, "LEFT", 2, 0)
    iconTexture:SetSize(16, 16)
    iconTexture:SetTexture(icon)

    local label = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    label:SetPoint("LEFT", iconTexture, "RIGHT", 4, 0)
    label:SetPoint("RIGHT", row, "RIGHT", -4, 0)
    label:SetJustifyH("LEFT")
    label:SetJustifyV("MIDDLE")
    label:SetText(text)

    widgets.savedNativeRows[#widgets.savedNativeRows + 1] = row
    return SAVED_ENTRY_ROW_HEIGHT
end

function NS.AceOptions:RefreshSavedList()
    local widgets = self.widgets
    if not widgets or not widgets.savedHost then
        return
    end

    EnsureSavedNativeScrollArea(widgets)
    if not widgets.savedScrollContent then
        return
    end

    ClearSavedNativeRows(widgets)

    local entries = self:GetAllSavedEntryList()
    local state = self:GetState()
    local selectedKey = tostring(state.selectedKey or "")
    local selectedExists = false

    local api = GetApi()
    local currentClassID, currentSpecID = 0, 0
    if api and type(api.GetCurrentClassSpec) == "function" then
        currentClassID, currentSpecID = api.GetCurrentClassSpec()
    end

    local loadedEntries = {}
    local unloadedEntries = {}
    for _, entry in ipairs(entries) do
        local isLoaded = IsLoadedScope(api, entry.classID, entry.specID, currentClassID, currentSpecID)
        if isLoaded then
            loadedEntries[#loadedEntries + 1] = entry
        else
            unloadedEntries[#unloadedEntries + 1] = entry
        end
    end

    local yOffset = 0

    yOffset = yOffset + CreateSavedSectionHeader(
        widgets.savedScrollContent,
        widgets,
        yOffset,
        L("HEADER_LOADED"),
        #loadedEntries,
        state.loadedCollapsed == true,
        function()
            state.loadedCollapsed = not (state.loadedCollapsed == true)
            self:RefreshUI()
        end
    )

    if not state.loadedCollapsed then
        if #loadedEntries == 0 then
            yOffset = yOffset + CreateSavedEntryRow(
                widgets.savedScrollContent,
                widgets,
                yOffset,
                "None",
                nil,
                false,
                function() end
            )
        else
            for _, entry in ipairs(loadedEntries) do
                local isSelected = (selectedKey == entry.key)
                if isSelected then
                    selectedExists = true
                end
                yOffset = yOffset + CreateSavedEntryRow(
                    widgets.savedScrollContent,
                    widgets,
                    yOffset,
                    BuildSavedEntryLine(entry, isSelected, false),
                    entry.icon,
                    isSelected,
                    function()
                        state.selectedKey = entry.key
                        self:LoadSelectedEntry()
                        self:RefreshUI()
                    end
                )
            end
        end
    end

    yOffset = yOffset + CreateSavedSectionHeader(
        widgets.savedScrollContent,
        widgets,
        yOffset,
        L("HEADER_UNLOADED"),
        #unloadedEntries,
        state.unloadedCollapsed == true,
        function()
            state.unloadedCollapsed = not (state.unloadedCollapsed == true)
            self:RefreshUI()
        end
    )

    if not state.unloadedCollapsed then
        if #unloadedEntries == 0 then
            yOffset = yOffset + CreateSavedEntryRow(
                widgets.savedScrollContent,
                widgets,
                yOffset,
                "None",
                nil,
                false,
                function() end
            )
        else
            for _, entry in ipairs(unloadedEntries) do
                local isSelected = (selectedKey == entry.key)
                if isSelected then
                    selectedExists = true
                end
                yOffset = yOffset + CreateSavedEntryRow(
                    widgets.savedScrollContent,
                    widgets,
                    yOffset,
                    BuildSavedEntryLine(entry, isSelected, true),
                    entry.icon,
                    isSelected,
                    function()
                        state.selectedKey = entry.key
                        self:LoadSelectedEntry()
                        self:RefreshUI()
                    end
                )
            end
        end
    end

    if selectedKey ~= "" and not selectedExists then
        state.selectedKey = nil
    end

    UpdateSavedNativeScrollMetrics(widgets, yOffset)
end

function NS.AceOptions:CreateWindow()
    if self.frameWidget then
        return
    end

    local AceGUI = self.AceGUI
    local frame = AceGUI:Create("Frame")
    frame:SetTitle(NS.ADDON_DISPLAY_NAME or L("ADDON_DISPLAY_NAME"))
    frame:SetStatusText("")
    frame:SetLayout("List")
    frame:SetWidth(900)
    frame:SetHeight(MAIN_WINDOW_HEIGHT)
    frame:SetStatusTable(self.windowStatus or {})
    frame:SetCallback("OnClose", function(widget)
        widget:Hide()
        if self.editorPopup then
            self.editorPopup:Hide()
        end
    end)
    self.windowStatus = self.windowStatus or {}
    self.frameWidget = frame

    local widgets = {}
    self.widgets = widgets

    local pluginName = AceGUI:Create("EditBox")
    pluginName:SetLabel("Addon")
    pluginName:DisableButton(true)
    pluginName:SetFullWidth(true)
    pluginName:SetText(NS.ADDON_DISPLAY_NAME or L("ADDON_DISPLAY_NAME"))
    frame:AddChild(pluginName)
    widgets.pluginName = pluginName

    local scopeLabel = AceGUI:Create("Label")
    scopeLabel:SetFullWidth(true)
    scopeLabel:SetText("")
    frame:AddChild(scopeLabel)
    widgets.scopeLabel = scopeLabel

    local actionRow = AceGUI:Create("SimpleGroup")
    actionRow:SetFullWidth(true)
    actionRow:SetLayout("Flow")
    frame:AddChild(actionRow)

    local addBtn = AceGUI:Create("Button")
    addBtn:SetText(L("BTN_ADD_VOICE"))
    addBtn:SetRelativeWidth(0.2)
    addBtn:SetCallback("OnClick", function()
        self:SyncScopeToCurrentSpec()
        self:EnsureValidScope()
        self:ClearEditorFields()
        self:OpenEditorPopup(L("TITLE_NEW_CONFIG"))
        self:RefreshUI()
    end)
    actionRow:AddChild(addBtn)
    widgets.addBtn = addBtn

    local editBtn = AceGUI:Create("Button")
    editBtn:SetText(L("BTN_EDIT"))
    editBtn:SetRelativeWidth(0.2)
    editBtn:SetCallback("OnClick", function()
        if tostring(self:GetState().selectedKey or "") == "" then
            print("[MCD-CVA] " .. L("MSG_CHOOSE_SAVED_ENTRY"))
            return
        end
        self:LoadSelectedEntry()
        self:OpenEditorPopup(L("TITLE_EDIT_CONFIG"))
    end)
    actionRow:AddChild(editBtn)
    widgets.editBtn = editBtn

    local deleteBtn = AceGUI:Create("Button")
    deleteBtn:SetText(L("BTN_DELETE"))
    deleteBtn:SetRelativeWidth(0.2)
    deleteBtn:SetCallback("OnClick", function()
        self:DeleteSelectedEntry()
        self:RefreshUI()
    end)
    actionRow:AddChild(deleteBtn)
    widgets.deleteBtn = deleteBtn

    local savedHost = AceGUI:Create("SimpleGroup")
    savedHost:SetFullWidth(true)
    savedHost:SetLayout("Fill")
    savedHost:SetAutoAdjustHeight(false)
    savedHost:SetHeight(SAVED_SCROLL_HEIGHT)
    frame:AddChild(savedHost)
    widgets.savedHost = savedHost

    EnsureSavedNativeScrollArea(widgets)
end

-- Final override: keep only the scope summary at the top and no plugin-name field.
function NS.AceOptions:GetCurrentScopeSummary()
    local api = GetApi()
    local state = self:GetState()
    if not api then
        return ""
    end

    local _, className = MCD_GetClassFileByID(self, state.classID)
    className = className or api.ResolveClassName(state.classID) or "-"
    local specName = api.ResolveSpecName(state.classID, state.specID) or "-"
    return string.format("|cffffd200%s|r", L("MSG_CURRENT_EDIT_SCOPE", tostring(className), tostring(specName)))
end

function NS.AceOptions:CreateWindow()
    if self.frameWidget then
        return
    end

    local AceGUI = self.AceGUI
    local frame = AceGUI:Create("Frame")
    frame:SetTitle(NS.ADDON_DISPLAY_NAME or L("ADDON_DISPLAY_NAME"))
    frame:SetStatusText("")
    frame:SetLayout("List")
    frame:SetWidth(900)
    frame:SetHeight(MAIN_WINDOW_HEIGHT)
    frame:SetStatusTable(self.windowStatus or {})
    frame:SetCallback("OnClose", function(widget)
        widget:Hide()
        if self.editorPopup then
            self.editorPopup:Hide()
        end
    end)
    self.windowStatus = self.windowStatus or {}
    self.frameWidget = frame

    local widgets = {}
    self.widgets = widgets

    local scopeLabel = AceGUI:Create("Label")
    scopeLabel:SetFullWidth(true)
    scopeLabel:SetText("")
    frame:AddChild(scopeLabel)
    widgets.scopeLabel = scopeLabel

    local actionRow = AceGUI:Create("SimpleGroup")
    actionRow:SetFullWidth(true)
    actionRow:SetLayout("Flow")
    frame:AddChild(actionRow)

    local addBtn = AceGUI:Create("Button")
    addBtn:SetText(L("BTN_ADD_VOICE"))
    addBtn:SetRelativeWidth(0.2)
    addBtn:SetCallback("OnClick", function()
        self:SyncScopeToCurrentSpec()
        self:EnsureValidScope()
        self:ClearEditorFields()
        self:OpenEditorPopup(L("TITLE_NEW_CONFIG"))
        self:RefreshUI()
    end)
    actionRow:AddChild(addBtn)
    widgets.addBtn = addBtn

    local editBtn = AceGUI:Create("Button")
    editBtn:SetText(L("BTN_EDIT"))
    editBtn:SetRelativeWidth(0.2)
    editBtn:SetCallback("OnClick", function()
        if tostring(self:GetState().selectedKey or "") == "" then
            print("[MCD-CVA] " .. L("MSG_CHOOSE_SAVED_ENTRY"))
            return
        end
        self:LoadSelectedEntry()
        self:OpenEditorPopup(L("TITLE_EDIT_CONFIG"))
    end)
    actionRow:AddChild(editBtn)
    widgets.editBtn = editBtn

    local deleteBtn = AceGUI:Create("Button")
    deleteBtn:SetText(L("BTN_DELETE"))
    deleteBtn:SetRelativeWidth(0.2)
    deleteBtn:SetCallback("OnClick", function()
        self:DeleteSelectedEntry()
        self:RefreshUI()
    end)
    actionRow:AddChild(deleteBtn)
    widgets.deleteBtn = deleteBtn

    local savedHost = AceGUI:Create("SimpleGroup")
    savedHost:SetFullWidth(true)
    savedHost:SetLayout("Fill")
    savedHost:SetAutoAdjustHeight(false)
    savedHost:SetHeight(SAVED_SCROLL_HEIGHT)
    frame:AddChild(savedHost)
    widgets.savedHost = savedHost

    EnsureSavedNativeScrollArea(widgets)
end

function NS.AceOptions:GetCurrentScopeSummary()
    local api = GetApi()
    local state = self:GetState()
    if not api then
        return ""
    end

    local _, className = MCD_GetClassFileByID(self, state.classID)
    className = className or api.ResolveClassName(state.classID) or "-"
    local specName = api.ResolveSpecName(state.classID, state.specID) or "-"
    return string.format("|cffffd200%s|r", L("MSG_CURRENT_EDIT_SCOPE", tostring(className), tostring(specName)))
end

function NS.AceOptions:CreateWindow()
    if self.frameWidget then
        return
    end

    local AceGUI = self.AceGUI
    local frame = AceGUI:Create("Frame")
    frame:SetTitle(NS.ADDON_DISPLAY_NAME or L("ADDON_DISPLAY_NAME"))
    frame:SetStatusText("")
    frame:SetLayout("List")
    frame:SetWidth(900)
    frame:SetHeight(MAIN_WINDOW_HEIGHT)
    frame:SetStatusTable(self.windowStatus or {})
    frame:SetCallback("OnClose", function(widget)
        widget:Hide()
        if self.editorPopup then
            self.editorPopup:Hide()
        end
    end)
    self.windowStatus = self.windowStatus or {}
    self.frameWidget = frame

    local widgets = {}
    self.widgets = widgets

    local scopeLabel = AceGUI:Create("Label")
    scopeLabel:SetFullWidth(true)
    scopeLabel:SetText("")
    frame:AddChild(scopeLabel)
    widgets.scopeLabel = scopeLabel

    local actionRow = AceGUI:Create("SimpleGroup")
    actionRow:SetFullWidth(true)
    actionRow:SetLayout("Flow")
    frame:AddChild(actionRow)

    local addBtn = AceGUI:Create("Button")
    addBtn:SetText(L("BTN_ADD_VOICE"))
    addBtn:SetRelativeWidth(0.2)
    addBtn:SetCallback("OnClick", function()
        self:SyncScopeToCurrentSpec()
        self:EnsureValidScope()
        self:ClearEditorFields()
        self:OpenEditorPopup(L("TITLE_NEW_CONFIG"))
        self:RefreshUI()
    end)
    actionRow:AddChild(addBtn)
    widgets.addBtn = addBtn

    local editBtn = AceGUI:Create("Button")
    editBtn:SetText(L("BTN_EDIT"))
    editBtn:SetRelativeWidth(0.2)
    editBtn:SetCallback("OnClick", function()
        if tostring(self:GetState().selectedKey or "") == "" then
            print("[MCD-CVA] " .. L("MSG_CHOOSE_SAVED_ENTRY"))
            return
        end
        self:LoadSelectedEntry()
        self:OpenEditorPopup(L("TITLE_EDIT_CONFIG"))
    end)
    actionRow:AddChild(editBtn)
    widgets.editBtn = editBtn

    local deleteBtn = AceGUI:Create("Button")
    deleteBtn:SetText(L("BTN_DELETE"))
    deleteBtn:SetRelativeWidth(0.2)
    deleteBtn:SetCallback("OnClick", function()
        self:DeleteSelectedEntry()
        self:RefreshUI()
    end)
    actionRow:AddChild(deleteBtn)
    widgets.deleteBtn = deleteBtn

    local savedHost = AceGUI:Create("SimpleGroup")
    savedHost:SetFullWidth(true)
    savedHost:SetLayout("Fill")
    savedHost:SetAutoAdjustHeight(false)
    savedHost:SetHeight(SAVED_SCROLL_HEIGHT)
    frame:AddChild(savedHost)
    widgets.savedHost = savedHost

    EnsureSavedNativeScrollArea(widgets)
end

function NS.AceOptions:GetCurrentScopeSummary()
    local api = GetApi()
    local state = self:GetState()
    if not api then
        return ""
    end

    local classFile, className = MCD_GetClassFileByID(self, state.classID)
    className = className or api.ResolveClassName(state.classID) or "-"
    local specName = api.ResolveSpecName(state.classID, state.specID) or "-"
    local goldColor = "|cffffd200"
    return string.format("%s%s|r", goldColor, L("MSG_CURRENT_EDIT_SCOPE", tostring(className), tostring(specName)))
end

function NS.AceOptions:CreateWindow()
    if self.frameWidget then
        return
    end

    local AceGUI = self.AceGUI
    local frame = AceGUI:Create("Frame")
    frame:SetTitle(NS.ADDON_DISPLAY_NAME or L("ADDON_DISPLAY_NAME"))
    frame:SetStatusText("")
    frame:SetLayout("List")
    frame:SetWidth(900)
    frame:SetHeight(MAIN_WINDOW_HEIGHT)
    frame:SetStatusTable(self.windowStatus or {})
    frame:SetCallback("OnClose", function(widget)
        widget:Hide()
        if self.editorPopup then
            self.editorPopup:Hide()
        end
    end)
    self.windowStatus = self.windowStatus or {}
    self.frameWidget = frame

    local widgets = {}
    self.widgets = widgets

    local scopeLabel = AceGUI:Create("Label")
    scopeLabel:SetFullWidth(true)
    scopeLabel:SetText("")
    frame:AddChild(scopeLabel)
    widgets.scopeLabel = scopeLabel

    local actionRow = AceGUI:Create("SimpleGroup")
    actionRow:SetFullWidth(true)
    actionRow:SetLayout("Flow")
    frame:AddChild(actionRow)

    local addBtn = AceGUI:Create("Button")
    addBtn:SetText(L("BTN_ADD_VOICE"))
    addBtn:SetRelativeWidth(0.2)
    addBtn:SetCallback("OnClick", function()
        self:SyncScopeToCurrentSpec()
        self:EnsureValidScope()
        self:ClearEditorFields()
        self:OpenEditorPopup(L("TITLE_NEW_CONFIG"))
        self:RefreshUI()
    end)
    actionRow:AddChild(addBtn)
    widgets.addBtn = addBtn

    local editBtn = AceGUI:Create("Button")
    editBtn:SetText(L("BTN_EDIT"))
    editBtn:SetRelativeWidth(0.2)
    editBtn:SetCallback("OnClick", function()
        if tostring(self:GetState().selectedKey or "") == "" then
            print("[MCD-CVA] " .. L("MSG_CHOOSE_SAVED_ENTRY"))
            return
        end
        self:LoadSelectedEntry()
        self:OpenEditorPopup(L("TITLE_EDIT_CONFIG"))
    end)
    actionRow:AddChild(editBtn)
    widgets.editBtn = editBtn

    local deleteBtn = AceGUI:Create("Button")
    deleteBtn:SetText(L("BTN_DELETE"))
    deleteBtn:SetRelativeWidth(0.2)
    deleteBtn:SetCallback("OnClick", function()
        self:DeleteSelectedEntry()
        self:RefreshUI()
    end)
    actionRow:AddChild(deleteBtn)
    widgets.deleteBtn = deleteBtn

    local savedHost = AceGUI:Create("SimpleGroup")
    savedHost:SetFullWidth(true)
    savedHost:SetLayout("Fill")
    savedHost:SetAutoAdjustHeight(false)
    savedHost:SetHeight(SAVED_SCROLL_HEIGHT)
    frame:AddChild(savedHost)
    widgets.savedHost = savedHost

    EnsureSavedNativeScrollArea(widgets)
end

local function MCD_GetClassFileByID(self, classID)
    classID = tonumber(classID) or 0
    for _, option in ipairs(self:GetClassOptionList()) do
        if tonumber(option.classID) == classID then
            return option.classFile, option.className
        end
    end
    return nil, nil
end

function NS.AceOptions:GetCurrentScopeSummary()
    local api = GetApi()
    local state = self:GetState()
    if not api then
        return ""
    end

    local classFile, className = MCD_GetClassFileByID(self, state.classID)
    className = className or api.ResolveClassName(state.classID) or "-"
    local specName = api.ResolveSpecName(state.classID, state.specID) or "-"
    local colorCode = GetClassColorCode(classFile) or "|cffffffff"
    return string.format("%s%s|r", colorCode, L("MSG_CURRENT_EDIT_SCOPE", tostring(className), tostring(specName)))
end

function NS.AceOptions:CreateWindow()
    if self.frameWidget then
        return
    end

    local AceGUI = self.AceGUI
    local frame = AceGUI:Create("Frame")
    frame:SetTitle(NS.ADDON_DISPLAY_NAME or L("ADDON_DISPLAY_NAME"))
    frame:SetStatusText("")
    frame:SetLayout("List")
    frame:SetWidth(900)
    frame:SetHeight(MAIN_WINDOW_HEIGHT)
    frame:SetStatusTable(self.windowStatus or {})
    frame:SetCallback("OnClose", function(widget)
        widget:Hide()
        if self.editorPopup then
            self.editorPopup:Hide()
        end
    end)
    self.windowStatus = self.windowStatus or {}
    self.frameWidget = frame

    local widgets = {}
    self.widgets = widgets

    local scopeLabel = AceGUI:Create("Label")
    scopeLabel:SetFullWidth(true)
    scopeLabel:SetText("")
    frame:AddChild(scopeLabel)
    widgets.scopeLabel = scopeLabel

    local actionRow = AceGUI:Create("SimpleGroup")
    actionRow:SetFullWidth(true)
    actionRow:SetLayout("Flow")
    frame:AddChild(actionRow)

    local addBtn = AceGUI:Create("Button")
    addBtn:SetText(L("BTN_ADD_VOICE"))
    addBtn:SetRelativeWidth(0.2)
    addBtn:SetCallback("OnClick", function()
        self:SyncScopeToCurrentSpec()
        self:EnsureValidScope()
        self:ClearEditorFields()
        self:OpenEditorPopup(L("TITLE_NEW_CONFIG"))
        self:RefreshUI()
    end)
    actionRow:AddChild(addBtn)
    widgets.addBtn = addBtn

    local editBtn = AceGUI:Create("Button")
    editBtn:SetText(L("BTN_EDIT"))
    editBtn:SetRelativeWidth(0.2)
    editBtn:SetCallback("OnClick", function()
        if tostring(self:GetState().selectedKey or "") == "" then
            print("[MCD-CVA] " .. L("MSG_CHOOSE_SAVED_ENTRY"))
            return
        end
        self:LoadSelectedEntry()
        self:OpenEditorPopup(L("TITLE_EDIT_CONFIG"))
    end)
    actionRow:AddChild(editBtn)
    widgets.editBtn = editBtn

    local deleteBtn = AceGUI:Create("Button")
    deleteBtn:SetText(L("BTN_DELETE"))
    deleteBtn:SetRelativeWidth(0.2)
    deleteBtn:SetCallback("OnClick", function()
        self:DeleteSelectedEntry()
        self:RefreshUI()
    end)
    actionRow:AddChild(deleteBtn)
    widgets.deleteBtn = deleteBtn

    local savedHost = AceGUI:Create("SimpleGroup")
    savedHost:SetFullWidth(true)
    savedHost:SetLayout("Fill")
    savedHost:SetAutoAdjustHeight(false)
    savedHost:SetHeight(SAVED_SCROLL_HEIGHT)
    frame:AddChild(savedHost)
    widgets.savedHost = savedHost

    EnsureSavedNativeScrollArea(widgets)
end

local function MCD_CreateSavedSectionHeader(parent, widgets, yOffset, text, count, onClick)
    local row = CreateFrame("Button", nil, parent)
    row:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, -yOffset)
    row:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, -yOffset)
    row:SetHeight(SAVED_SECTION_HEADER_HEIGHT)
    row:RegisterForClicks("LeftButtonUp")
    row:SetScript("OnClick", onClick)

    local hover = row:CreateTexture(nil, "HIGHLIGHT")
    hover:SetAllPoints()
    hover:SetColorTexture(1, 1, 1, 0.06)

    local label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    label:SetPoint("CENTER", row, "CENTER", 0, 0)
    label:SetJustifyH("CENTER")
    label:SetText(string.format("%s（%d）", tostring(text or ""), tonumber(count) or 0))
    label:SetTextColor(1, 0.82, 0)

    local left = row:CreateTexture(nil, "BACKGROUND")
    left:SetHeight(8)
    left:SetPoint("LEFT", row, "LEFT", 3, 0)
    left:SetPoint("RIGHT", label, "LEFT", -5, 0)
    left:SetTexture(137057)
    left:SetTexCoord(0.81, 0.94, 0.5, 1)
    left:SetVertexColor(1, 0.82, 0, 1)

    local right = row:CreateTexture(nil, "BACKGROUND")
    right:SetHeight(8)
    right:SetPoint("RIGHT", row, "RIGHT", -3, 0)
    right:SetPoint("LEFT", label, "RIGHT", 5, 0)
    right:SetTexture(137057)
    right:SetTexCoord(0.81, 0.94, 0.5, 1)
    right:SetVertexColor(1, 0.82, 0, 1)

    widgets.savedNativeRows[#widgets.savedNativeRows + 1] = row
    return SAVED_SECTION_HEADER_HEIGHT
end

function NS.AceOptions:RefreshSavedList()
    local widgets = self.widgets
    if not widgets or not widgets.savedHost then
        return
    end

    EnsureSavedNativeScrollArea(widgets)
    if not widgets.savedScrollContent then
        return
    end

    ClearSavedNativeRows(widgets)

    local entries = self:GetAllSavedEntryList()
    local state = self:GetState()
    local selectedKey = tostring(state.selectedKey or "")
    local selectedExists = false

    local api = GetApi()
    local currentClassID, currentSpecID = 0, 0
    if api and type(api.GetCurrentClassSpec) == "function" then
        currentClassID, currentSpecID = api.GetCurrentClassSpec()
    end

    local loadedEntries = {}
    local unloadedEntries = {}
    for _, entry in ipairs(entries) do
        local isLoaded = IsLoadedScope(api, entry.classID, entry.specID, currentClassID, currentSpecID)
        if isLoaded then
            loadedEntries[#loadedEntries + 1] = entry
        else
            unloadedEntries[#unloadedEntries + 1] = entry
        end
    end

    local yOffset = 0

    yOffset = yOffset + MCD_CreateSavedSectionHeader(
        widgets.savedScrollContent,
        widgets,
        yOffset,
        L("HEADER_LOADED"),
        #loadedEntries,
        function()
            state.loadedCollapsed = not (state.loadedCollapsed == true)
            self:RefreshUI()
        end
    )

    if not state.loadedCollapsed then
        if #loadedEntries == 0 then
            yOffset = yOffset + CreateSavedEntryRow(
                widgets.savedScrollContent,
                widgets,
                yOffset,
                "None",
                nil,
                false,
                function() end
            )
        else
            for _, entry in ipairs(loadedEntries) do
                local isSelected = (selectedKey == entry.key)
                if isSelected then
                    selectedExists = true
                end
                yOffset = yOffset + CreateSavedEntryRow(
                    widgets.savedScrollContent,
                    widgets,
                    yOffset,
                    BuildSavedEntryLine(entry, isSelected, false),
                    entry.icon,
                    isSelected,
                    function()
                        state.selectedKey = entry.key
                        self:LoadSelectedEntry()
                        self:RefreshUI()
                    end
                )
            end
        end
    end

    yOffset = yOffset + MCD_CreateSavedSectionHeader(
        widgets.savedScrollContent,
        widgets,
        yOffset,
        L("HEADER_UNLOADED"),
        #unloadedEntries,
        function()
            state.unloadedCollapsed = not (state.unloadedCollapsed == true)
            self:RefreshUI()
        end
    )

    if not state.unloadedCollapsed then
        if #unloadedEntries == 0 then
            yOffset = yOffset + CreateSavedEntryRow(
                widgets.savedScrollContent,
                widgets,
                yOffset,
                "None",
                nil,
                false,
                function() end
            )
        else
            for _, entry in ipairs(unloadedEntries) do
                local isSelected = (selectedKey == entry.key)
                if isSelected then
                    selectedExists = true
                end
                yOffset = yOffset + CreateSavedEntryRow(
                    widgets.savedScrollContent,
                    widgets,
                    yOffset,
                    BuildSavedEntryLine(entry, isSelected, true),
                    entry.icon,
                    isSelected,
                    function()
                        state.selectedKey = entry.key
                        self:LoadSelectedEntry()
                        self:RefreshUI()
                    end
                )
            end
        end
    end

    if selectedKey ~= "" and not selectedExists then
        state.selectedKey = nil
    end

    UpdateSavedNativeScrollMetrics(widgets, yOffset)
end

function NS.AceOptions:CreateWindow()
    if self.frameWidget then
        return
    end

    local AceGUI = self.AceGUI
    local frame = AceGUI:Create("Frame")
    frame:SetTitle(NS.ADDON_DISPLAY_NAME or L("ADDON_DISPLAY_NAME"))
    frame:SetStatusText("")
    frame:SetLayout("List")
    frame:SetWidth(900)
    frame:SetHeight(MAIN_WINDOW_HEIGHT)
    frame:SetStatusTable(self.windowStatus or {})
    frame:SetCallback("OnClose", function(widget)
        widget:Hide()
        if self.editorPopup then
            self.editorPopup:Hide()
        end
    end)
    self.windowStatus = self.windowStatus or {}
    self.frameWidget = frame

    local widgets = {}
    self.widgets = widgets

    local scopeLabel = AceGUI:Create("Label")
    scopeLabel:SetFullWidth(true)
    scopeLabel:SetText("")
    frame:AddChild(scopeLabel)
    widgets.scopeLabel = scopeLabel

    local actionRow = AceGUI:Create("SimpleGroup")
    actionRow:SetFullWidth(true)
    actionRow:SetLayout("Flow")
    frame:AddChild(actionRow)

    local addBtn = AceGUI:Create("Button")
    addBtn:SetText(L("BTN_ADD_VOICE"))
    addBtn:SetRelativeWidth(0.2)
    addBtn:SetCallback("OnClick", function()
        self:SyncScopeToCurrentSpec()
        self:EnsureValidScope()
        self:ClearEditorFields()
        self:OpenEditorPopup(L("TITLE_NEW_CONFIG"))
        self:RefreshUI()
    end)
    actionRow:AddChild(addBtn)
    widgets.addBtn = addBtn

    local editBtn = AceGUI:Create("Button")
    editBtn:SetText(L("BTN_EDIT"))
    editBtn:SetRelativeWidth(0.2)
    editBtn:SetCallback("OnClick", function()
        if tostring(self:GetState().selectedKey or "") == "" then
            print("[MCD-CVA] " .. L("MSG_CHOOSE_SAVED_ENTRY"))
            return
        end
        self:LoadSelectedEntry()
        self:OpenEditorPopup(L("TITLE_EDIT_CONFIG"))
    end)
    actionRow:AddChild(editBtn)
    widgets.editBtn = editBtn

    local deleteBtn = AceGUI:Create("Button")
    deleteBtn:SetText(L("BTN_DELETE"))
    deleteBtn:SetRelativeWidth(0.2)
    deleteBtn:SetCallback("OnClick", function()
        self:DeleteSelectedEntry()
        self:RefreshUI()
    end)
    actionRow:AddChild(deleteBtn)
    widgets.deleteBtn = deleteBtn

    local savedHost = AceGUI:Create("SimpleGroup")
    savedHost:SetFullWidth(true)
    savedHost:SetLayout("Fill")
    savedHost:SetAutoAdjustHeight(false)
    savedHost:SetHeight(SAVED_SCROLL_HEIGHT)
    frame:AddChild(savedHost)
    widgets.savedHost = savedHost

    EnsureSavedNativeScrollArea(widgets)
end


-- Final helper used by the native saved-list UI.
function NS.AceOptions:ToggleCollectionCollapsed(groupKey)
    local classID, specID, groupID = ParseGroupKey(groupKey)
    if classID < 0 or specID < 0 or tostring(groupID or "") == "" then
        return false
    end

    local scope = EnsureCollectionScope(classID, specID)
    local group = scope and scope.groups and scope.groups[tostring(groupID)]
    if type(group) ~= "table" then
        return false
    end

    group.collapsed = not (group.collapsed == true)
    return true
end
