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

NS.UI = NS.UI or {}
NS.UI.SavedList = NS.UI.SavedList or {}

local SavedList = NS.UI.SavedList
local Skin = NS.UI.Skin
local L = NS.L or function(key, ...) if select("#", ...) > 0 then return string.format(tostring(key), ...) end return tostring(key) end

local ROW_HEIGHT = 28
local GROUP_ROW_HEIGHT = 30
local EMPTY_ROW_HEIGHT = 24
local HEADER_HEIGHT = 32

local function TrimText(value)
    value = tostring(value or "")
    value = value:gsub("^%s+", ""):gsub("%s+$", "")
    return value
end

local function IsMouseOverFrame(frame)
    if not frame or not frame.IsShown or not frame:IsShown() then
        return false
    end
    if frame.IsMouseOver then
        return frame:IsMouseOver()
    end
    local mouseIsOver = rawget(_G, "MouseIsOver")
    if type(mouseIsOver) == "function" then
        return mouseIsOver(frame)
    end
    return false
end

local function GetEntryTypeText(entry)
    local typeText = TrimText(entry and entry.typeText)
    if typeText ~= "" then
        return typeText
    end

    local entryType = tostring((entry and entry.entryType) or "cooldown")
    if entryType == "cast" then
        return L("ENTRY_TYPE_CAST")
    elseif entryType == "bloodlust" then
        return L("ENTRY_TYPE_BLOODLUST")
    end
    return L("ENTRY_TYPE_COOLDOWN")
end

local function BuildEntryRowText(entry, includeScopeText)
    local parts = {}
    local spellId = tonumber(entry.spellId) or 0
    local index = tonumber(entry.index) or 0
    local spellName = TrimText(entry.spellName)

    parts[#parts + 1] = string.format("|cffffd24a%s|r", GetEntryTypeText(entry))
    if spellName ~= "" then
        parts[#parts + 1] = spellName
    elseif spellId > 0 then
        parts[#parts + 1] = tostring(spellId)
    else
        parts[#parts + 1] = L("ENTRY_UNNAMED")
    end

    if spellId > 0 then
        local objectType = tostring(entry and entry.objectType or "spell")
        local idLabel = (objectType == "item") and L("ENTRY_OBJECT_ITEM_ID") or L("ENTRY_OBJECT_SPELL_ID")
        parts[#parts + 1] = string.format("|cff808080%s:%d|r", idLabel, spellId)
    end
    if index > 0 then
        parts[#parts + 1] = string.format("|cff808080#%d|r", index)
    end
    local soundDetail = TrimText(entry.soundDetail)
    if soundDetail ~= "" then
        parts[#parts + 1] = string.format("|cff808080%s|r", soundDetail)
    elseif TrimText(entry.modeText) ~= "" then
        parts[#parts + 1] = string.format("|cff808080%s|r", TrimText(entry.modeText))
    end
    if entry.checkTalent == true and (tonumber(entry.talentId) or 0) > 0 then
        local talentText = TrimText(entry.talentName)
        if talentText == "" then
            talentText = tostring(entry.talentId)
        end
        local talentCD = tonumber(entry.talentCD) or 0
        if talentCD > 0 then
            parts[#parts + 1] = string.format("|cff808080%s|r", L("TALENT_ROW", talentText, talentCD))
        else
            parts[#parts + 1] = string.format("|cff808080%s|r", L("TALENT_ROW_NO_CD", talentText))
        end
    end
    if includeScopeText and TrimText(entry.scopeText) ~= "" then
        parts[#parts + 1] = string.format("|cff808080%s|r", TrimText(entry.scopeText))
    end

    return table.concat(parts, "  ")
end

local function BuildGroupRowText(entry)
    local name = TrimText(entry and entry.name)
    if name == "" then
        name = L("COLLECTION_UNNAMED")
    end
    local count = tonumber(entry and entry.count) or 0
    local countText
    if count <= 0 then
        countText = L("COLLECTION_EMPTY_COUNT")
    else
        countText = L("COLLECTION_COUNT", count)
    end
    return string.format("|cffffd24a%s|r  %s  |cff808080%s|r", L("COLLECTION_LABEL"), name, countText)
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 GetGroupDataByKey(groupKey)
    local classID, specID, groupID = ParseGroupKey(groupKey)
    local db = type(MCDVoiceCooldownDB) == "table" and MCDVoiceCooldownDB or nil
    local classMap = db and type(db.collectionData) == "table" and db.collectionData[classID] or nil
    local scope = type(classMap) == "table" and classMap[specID] or nil
    local group = type(scope) == "table" and type(scope.groups) == "table" and scope.groups[groupID] or nil
    if type(group) ~= "table" then
        return nil
    end
    return group, scope, classID, specID, groupID
end

local function IsGroupCollapsed(groupKey)
    local group = GetGroupDataByKey(groupKey)
    return type(group) == "table" and group.collapsed == true
end

local function ToggleGroupCollapsed(groupKey)
    if NS.AceOptions and type(NS.AceOptions.ToggleCollectionCollapsed) == "function" then
        NS.AceOptions:ToggleCollectionCollapsed(groupKey)
        return
    end
    local group = GetGroupDataByKey(groupKey)
    if type(group) == "table" then
        group.collapsed = not (group.collapsed == true)
    end
end

local function HideRowDropPreview(row)
    if not row then
        return
    end
    if row.dropBG then
        row.dropBG:Hide()
    end
    if row.dropLine then
        row.dropLine:Hide()
    end
    row.dropMode = nil
end

local function HideDropHighlights(list)
    for _, candidate in ipairs((list and list.rows) or {}) do
        HideRowDropPreview(candidate)
    end
end

local function GetScaledCursorY()
    local _, y = GetCursorPosition()
    local scale = UIParent:GetEffectiveScale() or 1
    return (y or 0) / scale
end

local function DetermineDropMode(sourceRow, targetRow)
    if not sourceRow or not targetRow then
        return nil
    end

    -- 合集可以拖入合集，也可以在根区域或父合集内排序。
    -- 语音条目可以拖入合集或在同一区域内排序。

    -- 未载入条目可以在“未载入”区域内排序，也可以拖入任意合集；
    -- 但不要单独拖到“已载入”根区域，避免改变实际职业/专精归属。
    if sourceRow.itemType == "entry" and sourceRow.isLoadedEntry == false then
        if targetRow.itemType ~= "group" and targetRow.itemType ~= "group-empty" and not targetRow.groupID then
            if tostring(sourceRow.displaySection or "") ~= tostring(targetRow.displaySection or "") then
                return nil
            end
        end
    end

    local top = targetRow:GetTop() or 0
    local bottom = targetRow:GetBottom() or 0
    local y = GetScaledCursorY()

    -- 拖到合集标题或空合集提示时，中间为放入，上下边缘为同级排序。
    if targetRow.itemType == "group-empty" then
        return "inside"
    end
    if targetRow.itemType == "group" then
        local height = math.max((top - bottom), 1)
        if sourceRow.itemType == "group" then
            if y >= top - height * 0.25 then
                return "before"
            elseif y <= bottom + height * 0.25 then
                return "after"
            end
            return "inside"
        end
        return "inside"
    end

    -- 非空合集内的子条目也可以作为“拖入合集”的目标：
    -- 上/下边缘用于排序，中间区域用于直接放入该合集。
    if targetRow.groupID and targetRow.parentGroupKey and sourceRow.itemType ~= "group" then
        local height = math.max((top - bottom), 1)
        if y >= top - height * 0.25 then
            return "before"
        elseif y <= bottom + height * 0.25 then
            return "after"
        end
        return "inside"
    end

    local mid = (top + bottom) / 2
    return y >= mid and "before" or "after"
end

local function ShowRowDropPreview(row, mode)
    HideRowDropPreview(row)
    if not row or not mode then
        return
    end

    row.dropMode = mode
    if mode == "inside" then
        if row.dropBG then
            row.dropBG:Show()
        end
        return
    end

    if row.dropLine then
        row.dropLine:ClearAllPoints()
        if mode == "before" then
            row.dropLine:SetPoint("TOPLEFT", row, "TOPLEFT", 8, 1)
            row.dropLine:SetPoint("TOPRIGHT", row, "TOPRIGHT", -8, 1)
        else
            row.dropLine:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 8, -1)
            row.dropLine:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", -8, -1)
        end
        row.dropLine:Show()
    end
end

local function UpdateDropPreview(list)
    if not list or not list.dragKey or not list.dragRow then
        return
    end

    local sourceRow = list.dragRow
    local hoverRow = nil
    local hoverMode = nil

    for _, candidate in ipairs(list.rows or {}) do
        if candidate ~= sourceRow and candidate:IsShown() and candidate.dropKey and not candidate.isPlaceholder and IsMouseOverFrame(candidate) then
            hoverRow = candidate
            hoverMode = DetermineDropMode(sourceRow, candidate)
            break
        end
    end

    HideDropHighlights(list)
    list.dragHoverRow = hoverRow
    list.dragHoverMode = hoverMode
    if hoverRow then
        ShowRowDropPreview(hoverRow, hoverMode)
    end
end

local function StopDragVisual(list)
    if not list then
        return
    end

    if list.dragGhost then
        list.dragGhost:Hide()
        list.dragGhost:SetScript("OnUpdate", nil)
    end
    if list.dragRow and list.dragRow.SetAlpha then
        list.dragRow:SetAlpha(1)
    end
    HideDropHighlights(list)
    list.dragHoverRow = nil
    list.dragHoverMode = nil
end

local function StartDragVisual(list, row)
    if not list or not row then
        return
    end

    if not list.dragGhost then
        local ghost = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
        ghost:SetFrameStrata("TOOLTIP")
        ghost:EnableMouse(false)
        if ghost.SetBackdrop then
            ghost:SetBackdrop({
                bgFile = "Interface\\Buttons\\WHITE8x8",
                edgeFile = "Interface\\Buttons\\WHITE8x8",
                edgeSize = 1,
            })
            ghost:SetBackdropColor(0.04, 0.04, 0.04, 0.90)
            ghost:SetBackdropBorderColor(1.00, 0.82, 0.18, 0.92)
        end

        local accent = ghost:CreateTexture(nil, "ARTWORK")
        accent:SetPoint("TOPLEFT", ghost, "TOPLEFT", 3, -3)
        accent:SetPoint("BOTTOMLEFT", ghost, "BOTTOMLEFT", 3, 3)
        accent:SetWidth(3)
        accent:SetColorTexture(1, 0.82, 0.18, 0.90)
        ghost.accent = accent

        local statusDot = ghost:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        statusDot:SetText("●")
        statusDot:SetJustifyH("CENTER")
        statusDot:SetJustifyV("MIDDLE")
        if Skin then
            Skin:StyleFont(statusDot, "body")
        end
        ghost.statusDot = statusDot

        local icon = ghost:CreateTexture(nil, "ARTWORK")
        icon:SetSize(20, 20)
        ghost.icon = icon

        local text = ghost:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        text:SetJustifyH("LEFT")
        text:SetJustifyV("MIDDLE")
        if text.SetWordWrap then
            text:SetWordWrap(false)
        end
        if text.SetMaxLines then
            text:SetMaxLines(1)
        end
        ghost.text = text
        list.dragGhost = ghost
    end

    local ghost = list.dragGhost
    local width = math.min(math.max((row:GetWidth() or 420), 420), 760)
    local height = math.max((row:GetHeight() or ROW_HEIGHT), ROW_HEIGHT)
    ghost:SetSize(width, height)

    ghost.statusDot:ClearAllPoints()
    ghost.icon:ClearAllPoints()
    ghost.text:ClearAllPoints()

    if row.statusDot and row.statusDot:IsShown() then
        local r, g, b, a = row.statusDot:GetTextColor()
        ghost.statusDot:SetTextColor(r or 1, g or 1, b or 1, a or 1)
        ghost.statusDot:SetPoint("LEFT", ghost, "LEFT", 14, 0)
        ghost.statusDot:Show()
        ghost.icon:SetPoint("LEFT", ghost.statusDot, "RIGHT", 8, 0)
    else
        ghost.statusDot:Hide()
        ghost.icon:SetPoint("LEFT", ghost, "LEFT", 14, 0)
    end

    local texture = row.icon and row.icon:GetTexture()
    ghost.icon:SetTexture(texture or 134400)
    ghost.text:SetPoint("LEFT", ghost.icon, "RIGHT", 10, 0)
    ghost.text:SetPoint("RIGHT", ghost, "RIGHT", -12, 0)
    ghost.text:SetText(tostring(row.dragText or (row.main and row.main:GetText()) or row.plainText or ""))

    ghost:ClearAllPoints()
    ghost:Show()
    ghost:SetScript("OnUpdate", function(self)
        local x, y = GetCursorPosition()
        local scale = UIParent:GetEffectiveScale() or 1
        self:ClearAllPoints()
        self:SetPoint("CENTER", UIParent, "BOTTOMLEFT", (x or 0) / scale + 22, (y or 0) / scale - 20)
        UpdateDropPreview(list)
    end)

    if row.SetAlpha then
        row:SetAlpha(0.46)
    end

    if PlaySound and SOUNDKIT and SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON then
        PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON)
    end
end

local function SelectKey(list, key, entryType)
    key = tostring(key or "")
    if key == "" then
        return
    end

    list:SetSelectedKey(key)
    if type(list.onSelectionChanged) == "function" then
        list.onSelectionChanged(key, entryType)
        return
    end

    local state = NS.AceOptions:GetState()
    state.selectedKey = key
    state.selectedCollectionKey = nil
    state.entryType = entryType or state.entryType or "cooldown"
    NS.AceOptions:LoadSelectedEntry()
end

local function OpenEntryMenu(list, key, entryType)
    SelectKey(list, key, entryType)

    if not list.menuFrame then
        list.menuFrame = CreateFrame("Frame", "MCDVoiceCooldownSavedListMenu", UIParent, "UIDropDownMenuTemplate")
    end

    local menu = {
        {
            text = L("EDIT_ENTRY"),
            notCheckable = true,
            func = function()
                SelectKey(list, key, entryType)
                if NS.UI and NS.UI.EditorFrame then
                    NS.UI.EditorFrame:OpenForEdit()
                end
            end,
        },
        {
            text = L("EXPORT_ENTRY"),
            notCheckable = true,
            func = function()
                if NS.UI and NS.UI.MainFrame and NS.AceOptions and type(NS.AceOptions.ExportEntryString) == "function" then
                    NS.UI.MainFrame:OpenExportDialog(L("EXPORT_ENTRY"), NS.AceOptions:ExportEntryString(key))
                end
            end,
        },
        {
            text = L("DELETE_ENTRY"),
            notCheckable = true,
            func = function()
                SelectKey(list, key, entryType)
                NS.AceOptions:DeleteSelectedEntry()
                if NS.UI and NS.UI.MainFrame then
                    NS.UI.MainFrame:Refresh()
                end
            end,
        },
    }

    local easyMenu = rawget(_G, "EasyMenu")
    if type(easyMenu) == "function" then
        easyMenu(menu, list.menuFrame, "cursor", 0, 0, "MENU")
    else
        print("[MCD-CVA] " .. L("MSG_MENU_MISSING"))
    end
end

local function OpenGroupMenu(list, key)
    if not list.menuFrame then
        list.menuFrame = CreateFrame("Frame", "MCDVoiceCooldownSavedListMenu", UIParent, "UIDropDownMenuTemplate")
    end

    local menu = {
        {
            text = IsGroupCollapsed(key) and L("EXPAND_COLLECTION") or L("COLLAPSE_COLLECTION"),
            notCheckable = true,
            func = function()
                ToggleGroupCollapsed(key)
                if NS.UI and NS.UI.MainFrame then
                    NS.UI.MainFrame:Refresh()
                else
                    list:Refresh()
                end
            end,
        },
        {
            text = L("CREATE_VOICE_IN_COLLECTION"),
            notCheckable = true,
            func = function()
                if NS.UI and NS.UI.MainFrame and type(NS.UI.MainFrame.OpenNewVoiceInCollection) == "function" then
                    NS.UI.MainFrame:OpenNewVoiceInCollection(key)
                end
            end,
        },
        {
            text = L("CREATE_CHILD_COLLECTION"),
            notCheckable = true,
            func = function()
                if NS.UI and NS.UI.MainFrame and type(NS.UI.MainFrame.OpenCollectionNameDialogForGroup) == "function" then
                    NS.UI.MainFrame:OpenCollectionNameDialogForGroup(key)
                end
            end,
        },
        {
            text = L("RENAME_COLLECTION"),
            notCheckable = true,
            func = function()
                if NS.UI and NS.UI.MainFrame and type(NS.UI.MainFrame.OpenRenameCollectionDialog) == "function" then
                    NS.UI.MainFrame:OpenRenameCollectionDialog(key)
                end
            end,
        },
        {
            text = L("EXPORT_COLLECTION"),
            notCheckable = true,
            func = function()
                if NS.UI and NS.UI.MainFrame and NS.AceOptions and type(NS.AceOptions.ExportCollectionString) == "function" then
                    NS.UI.MainFrame:OpenExportDialog(L("EXPORT_COLLECTION"), NS.AceOptions:ExportCollectionString(key))
                end
            end,
        },
        {
            text = L("DELETE_COLLECTION_WITH_ITEMS"),
            notCheckable = true,
            func = function()
                NS.AceOptions:DeleteCollection(key)
                if NS.UI and NS.UI.MainFrame then
                    NS.UI.MainFrame:Refresh()
                end
            end,
        },
    }

    local easyMenu = rawget(_G, "EasyMenu")
    if type(easyMenu) == "function" then
        easyMenu(menu, list.menuFrame, "cursor", 0, 0, "MENU")
    end
end

local function EnsureRow(list, index)
    if list.rows[index] then
        return list.rows[index]
    end

    local row = CreateFrame("Button", nil, list.content)
    row:SetHeight(ROW_HEIGHT)
    row:SetPoint("LEFT", list.content, "LEFT", 0, 0)
    row:SetPoint("RIGHT", list.content, "RIGHT", -4, 0)
    row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    row:RegisterForDrag("LeftButton")

    local bg = row:CreateTexture(nil, "BACKGROUND")
    bg:SetPoint("TOPLEFT", row, "TOPLEFT", 1, -1)
    bg:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", -1, 1)
    bg:SetColorTexture(0, 0, 0, 0)
    row.bg = bg

    local hover = row:CreateTexture(nil, "HIGHLIGHT")
    hover:SetPoint("TOPLEFT", row, "TOPLEFT", 1, -1)
    hover:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", -1, 1)
    hover:SetColorTexture(1, 0.82, 0.18, 0.08)
    row.hover = hover

    local dropBG = row:CreateTexture(nil, "OVERLAY")
    dropBG:SetPoint("TOPLEFT", row, "TOPLEFT", 1, -1)
    dropBG:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", -1, 1)
    dropBG:SetColorTexture(1.00, 0.82, 0.18, 0.18)
    dropBG:Hide()
    row.dropBG = dropBG

    local dropLine = row:CreateTexture(nil, "OVERLAY")
    dropLine:SetHeight(4)
    dropLine:SetColorTexture(1.00, 0.82, 0.18, 0.95)
    dropLine:Hide()
    row.dropLine = dropLine

    local accent = row:CreateTexture(nil, "ARTWORK")
    accent:SetPoint("TOPLEFT", row, "TOPLEFT", 2, -3)
    accent:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 2, 3)
    accent:SetWidth(3)
    accent:SetColorTexture(1, 0.82, 0.18, 0)
    row.accent = accent

    local divider = row:CreateTexture(nil, "ARTWORK")
    divider:SetHeight(1)
    divider:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 8, 0)
    divider:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", -8, 0)
    divider:SetColorTexture(1, 1, 1, 0.035)
    row.divider = divider

    local statusDot = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    statusDot:SetText("●")
    statusDot:SetJustifyH("CENTER")
    statusDot:SetJustifyV("MIDDLE")
    if Skin then
        Skin:StyleFont(statusDot, "body")
    end
    row.statusDot = statusDot

    local icon = row:CreateTexture(nil, "ARTWORK")
    icon:SetSize(20, 20)
    icon:SetPoint("LEFT", row, "LEFT", 12, 0)
    row.icon = icon

    local main = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    main:SetPoint("LEFT", icon, "RIGHT", 10, 0)
    main:SetPoint("RIGHT", row, "RIGHT", -12, 0)
    main:SetJustifyH("LEFT")
    main:SetJustifyV("MIDDLE")
    if main.SetWordWrap then
        main:SetWordWrap(false)
    end
    if main.SetMaxLines then
        main:SetMaxLines(1)
    end
    if Skin then
        Skin:StyleFont(main, "body")
    end
    row.main = main
    row.text = main

    row:SetScript("OnClick", function(_, button)
        if not row.key or row.isPlaceholder or row.itemType == "group-empty" then
            return
        end

        if row.itemType == "group" then
            list:SetSelectedKey(row.key)
            if NS.AceOptions and type(NS.AceOptions.GetState) == "function" then
                local state = NS.AceOptions:GetState()
                state.selectedKey = row.key
                state.selectedCollectionKey = row.key
            end
            if button == "RightButton" then
                OpenGroupMenu(list, row.key)
            else
                ToggleGroupCollapsed(row.key)
                list:Refresh()
            end
            return
        end

        if button == "RightButton" then
            OpenEntryMenu(list, row.key, row.entryType)
        else
            SelectKey(list, row.key, row.entryType)
        end
    end)

    row:SetScript("OnEnter", function()
        if list.dragKey then
            UpdateDropPreview(list)
        end
    end)

    row:SetScript("OnLeave", function()
        if list.dragHoverRow == row then
            list.dragHoverRow = nil
            list.dragHoverMode = nil
        end
        HideRowDropPreview(row)
    end)

    row:SetScript("OnDragStart", function()
        if not row.canDrag or not row.key or row.isPlaceholder then
            return
        end
        list.dragKey = row.key
        list.dragRow = row
        row.bg:SetColorTexture(1, 0.82, 0.18, 0.28)
        StartDragVisual(list, row)
    end)

    row:SetScript("OnDragStop", function()
        local sourceKey = list.dragKey
        if not sourceKey then
            StopDragVisual(list)
            return
        end

        local target = list:FindDropTarget(row)

        if row.bg then
            row.bg:SetColorTexture(0, 0, 0, 0)
        end
        StopDragVisual(list)
        list.dragKey = nil
        list.dragRow = nil

        if target and target.dropKey then
            NS.AceOptions:MoveSavedListItem(sourceKey, target.dropKey, target.row and target.row.displaySection)
            if NS.UI and NS.UI.MainFrame then
                NS.UI.MainFrame:Refresh()
            else
                list:Refresh()
            end
        else
            list:Refresh()
        end
    end)

    list.rows[index] = row
    return row
end

local function EnsureHeader(list, index)
    if list.headers[index] then
        return list.headers[index]
    end

    local header = Skin and Skin:CreateSectionHeader(list.content, "") or CreateFrame("Button", nil, list.content)
    header:SetHeight(HEADER_HEIGHT)
    header:SetPoint("LEFT", list.content, "LEFT", 0, 0)
    header:SetPoint("RIGHT", list.content, "RIGHT", -4, 0)

    local arrow = header:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    arrow:SetPoint("RIGHT", header, "RIGHT", -8, 0)
    if Skin then
        Skin:StyleFont(arrow, "accent")
    end
    arrow:SetText("-")
    header.arrow = arrow

    if header.rightLine and header.label then
        header.rightLine:ClearAllPoints()
        header.rightLine:SetPoint("LEFT", header.label, "RIGHT", 10, 0)
        header.rightLine:SetPoint("RIGHT", arrow, "LEFT", -8, 0)
    end

    header:SetScript("OnClick", function()
        if header.sectionKey == "loaded" then
            list.state.loadedCollapsed = not (list.state.loadedCollapsed == true)
        else
            list.state.unloadedCollapsed = not (list.state.unloadedCollapsed == true)
        end
        list:Refresh()
    end)

    list.headers[index] = header
    return header
end

function SavedList:Create(parent)
    local frame = CreateFrame("Frame", nil, parent)
    frame:SetPoint("TOPLEFT", parent, "TOPLEFT", 12, -12)
    frame:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", -12, 12)

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

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

    local slider = CreateFrame("Slider", "MCDVoiceCooldownSavedListScrollBar", frame, "BackdropTemplate")
    slider:SetPoint("TOPLEFT", scrollFrame, "TOPRIGHT", 6, -16)
    slider:SetPoint("BOTTOMLEFT", scrollFrame, "BOTTOMRIGHT", 6, 16)
    slider:SetWidth(16)
    slider:SetMinMaxValues(0, 0)
    slider:SetValueStep(1)
    slider:SetOrientation("VERTICAL")
    slider:SetThumbTexture("Interface\\Buttons\\WHITE8x8")

    local thumb = slider:GetThumbTexture()
    if thumb then
        thumb:SetSize(12, 34)
    end

    slider:SetValue(0)
    slider:Hide()

    if Skin and Skin.SkinScrollBar then
        Skin:SkinScrollBar(slider)
    end

    local list = {
        frame = frame,
        scrollFrame = scrollFrame,
        content = content,
        slider = slider,
        rows = {},
        headers = {},
        selectedKey = nil,
        state = NS.AceOptions:GetState(),
        onSelectionChanged = nil,
        menuFrame = nil,
        dragKey = nil,
        dragRow = nil,
    }

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

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

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

    setmetatable(list, { __index = self })
    return list
end

function SavedList:SetOnSelectionChanged(callback)
    self.onSelectionChanged = callback
end

function SavedList:SetSelectedKey(key)
    self.selectedKey = tostring(key or "")
end

function SavedList:GetSelectedKey()
    return self.selectedKey
end

function SavedList:FindDropTarget(sourceRow)
    local function buildTarget(row)
        if not row or row == sourceRow or not row:IsShown() or not row.dropKey then
            return nil
        end
        local mode = row.dropMode or self.dragHoverMode or DetermineDropMode(sourceRow, row)
        local key
        if mode == "inside" and row.parentGroupKey and row.itemType ~= "group" then
            key = tostring(row.parentGroupKey or "")
        else
            key = tostring(row.dropKey or "")
        end
        if key == "" then
            return nil
        end
        if not mode then
            return nil
        end
        if mode == "inside" then
            key = key .. ":inside"
        elseif mode == "after" then
            key = key .. ":after"
        end
        return { row = row, dropKey = key, mode = mode }
    end

    if self.dragHoverRow then
        local target = buildTarget(self.dragHoverRow)
        if target then
            return target
        end
    end

    for _, row in ipairs(self.rows or {}) do
        if row ~= sourceRow and row:IsShown() and row.dropKey and IsMouseOverFrame(row) then
            return buildTarget(row)
        end
    end
    return nil
end

function SavedList:Refresh()
    local entries = NS.AceOptions:GetAllSavedEntryList()
    local state = self.state or NS.AceOptions:GetState()
    local selectedKey = tostring(self.selectedKey or state.selectedKey or "")
    local api = NS.API
    local currentClassID, currentSpecID = api.GetCurrentClassSpec()

    local loadedEntries = {}
    local unloadedEntries = {}
    local entriesAlreadyShownWithLoaded = {}

    local function markEntryKeyShown(key)
        key = TrimText(key)
        if key ~= "" then
            entriesAlreadyShownWithLoaded[key] = true
        end
    end

    local function markShown(entry)
        markEntryKeyShown(entry and entry.key)
    end

    local function markGroupContainedEntries(groupKey, seenGroups)
        groupKey = TrimText(groupKey)
        if groupKey == "" then
            return
        end
        seenGroups = seenGroups or {}
        if seenGroups[groupKey] then
            return
        end
        seenGroups[groupKey] = true

        local group = GetGroupDataByKey(groupKey)
        if type(group) ~= "table" then
            return
        end
        for _, ref in ipairs(group.entries or {}) do
            local refText = TrimText(ref)
            if refText:match("^group:") then
                markGroupContainedEntries(refText, seenGroups)
            elseif refText:match("^%-?%d+:%-?%d+:%-?%d+$") then
                markEntryKeyShown(refText)
            end
        end
    end

    local function countHeaderItems(list)
        local count = 0
        for _, entry in ipairs(list or {}) do
            local itemType = tostring(entry.itemType or "entry")
            if itemType ~= "group-empty" then
                count = count + 1
            end
        end
        return count
    end

    local function appendGroup(sectionRows, groupRow, childRows)
        if type(groupRow) ~= "table" then
            return
        end

        local count = tonumber(groupRow.count) or 0
        for _, child in ipairs(childRows or {}) do
            if tostring(child.itemType or "entry") == "entry" then
                markShown(child)
            end
        end
        markGroupContainedEntries(groupRow.key)

        groupRow.isEmptyCollection = count <= 0
        groupRow.collapsed = (count <= 0) or IsGroupCollapsed(groupRow.key) or groupRow.collapsed == true
        sectionRows[#sectionRows + 1] = groupRow

        if count > 0 and not groupRow.collapsed then
            for _, child in ipairs(childRows or {}) do
                sectionRows[#sectionRows + 1] = child
            end
        end
    end

    local function flushCollectedGroup(groupRow, childRows)
        if type(groupRow) ~= "table" then
            return
        end

        local childCount = tonumber(groupRow.count) or 0
        local loadState = tostring(groupRow.loadState or "")
        local hasLoadedChild = loadState == "green" or loadState == "yellow"

        -- 合集显示规则：
        -- 1. 空合集固定显示在“已载入”；
        -- 2. 合集内只要有一个已载入条目，整个合集显示在“已载入”，未载入子项保留红点；
        -- 3. 只有合集内全部子项都是未载入时，合集才显示到“未载入”。
        if childCount <= 0 or hasLoadedChild then
            groupRow.isLoaded = true
            appendGroup(loadedEntries, groupRow, childRows)
        else
            groupRow.isLoaded = false
            appendGroup(unloadedEntries, groupRow, childRows)
        end
    end

    local function appendGroupsFromScope(scopeClassID, scopeSpecID)
        local scopeEntries = NS.AceOptions:GetSavedListLayoutForScope(scopeClassID, scopeSpecID, true)
        local currentGroup = nil
        local currentChildren = nil

        for _, entry in ipairs(scopeEntries) do
            local itemType = tostring(entry.itemType or "entry")
            local depth = tonumber(entry.depth) or 0
            if itemType == "group" and depth == 0 then
                flushCollectedGroup(currentGroup, currentChildren)
                currentGroup = entry
                currentChildren = {}
            elseif currentGroup and depth > 0 then
                -- 子合集和子条目都跟随顶层合集显示，避免未载入子项跑到外面的未载入区。
                currentChildren[#currentChildren + 1] = entry
            else
                flushCollectedGroup(currentGroup, currentChildren)
                currentGroup = nil
                currentChildren = nil
            end
        end

        flushCollectedGroup(currentGroup, currentChildren)
    end

    local function appendRootEntriesForScope(scopeClassID, scopeSpecID, includeScopeText)
        local scopeEntries = NS.AceOptions:GetSavedListLayoutForScope(scopeClassID, scopeSpecID, includeScopeText)
        for _, entry in ipairs(scopeEntries) do
            if tostring(entry.itemType or "entry") == "entry" and not entry.groupID then
                local key = TrimText(entry.key)
                if key ~= "" and not entriesAlreadyShownWithLoaded[key] then
                    loadedEntries[#loadedEntries + 1] = entry
                    markShown(entry)
                end
            end
        end
    end

    local function iterSortedNumberKeys(map, callback)
        local keys = {}
        for key, value in pairs(map or {}) do
            local num = tonumber(key)
            if num and type(value) == "table" then
                keys[#keys + 1] = { num = num, value = value }
            end
        end
        table.sort(keys, function(a, b) return a.num < b.num end)
        for _, item in ipairs(keys) do
            callback(item.num, item.value)
        end
    end

    local db = type(MCDVoiceCooldownDB) == "table" and MCDVoiceCooldownDB or nil
    if db and type(db.collectionData) == "table" then
        iterSortedNumberKeys(db.collectionData, function(scopeClassID, classMap)
            iterSortedNumberKeys(classMap, function(scopeSpecID)
                appendGroupsFromScope(scopeClassID, scopeSpecID)
            end)
        end)
    end

    -- 已载入的根条目 = 全职业 + 当前职业“全专精” + 当前实际专精。合集已在上面按规则单独归类。
    appendRootEntriesForScope(0, 0, true)
    appendRootEntriesForScope(currentClassID, 0, currentSpecID ~= 0)
    if currentSpecID ~= 0 then
        appendRootEntriesForScope(currentClassID, currentSpecID, false)
    end

    for _, entry in ipairs(entries) do
        local key = TrimText(entry.key)
        if key ~= "" and not entriesAlreadyShownWithLoaded[key] then
            local isLoadedScope = (NS.API.IsActiveScopeLoaded and NS.API.IsActiveScopeLoaded(entry.classID, entry.specID, currentClassID, currentSpecID))
                or (entry.classID == currentClassID and (entry.specID == currentSpecID or entry.specID == 0))
            if not isLoadedScope then
                entry.itemType = "entry"
                unloadedEntries[#unloadedEntries + 1] = entry
                entriesAlreadyShownWithLoaded[key] = true
            end
        end
    end

    local function applyDisplayOrder(sectionKey, rows)
        local db = type(MCDVoiceCooldownDB) == "table" and MCDVoiceCooldownDB or nil
        if not db then
            return rows
        end
        if type(db.savedListOrder) ~= "table" then
            db.savedListOrder = {}
        end
        if type(db.savedListOrder[sectionKey]) ~= "table" then
            db.savedListOrder[sectionKey] = {}
        end

        local order = db.savedListOrder[sectionKey]
        local blocks = {}
        local byKey = {}
        local i = 1
        while i <= #(rows or {}) do
            local row = rows[i]
            local block = { rows = { row }, key = TrimText(row and row.key) }
            local depth = tonumber(row and row.depth) or 0
            i = i + 1
            if tostring(row and row.itemType or "") == "group" then
                while i <= #rows do
                    local child = rows[i]
                    local childDepth = tonumber(child and child.depth) or 0
                    if childDepth <= depth then
                        break
                    end
                    block.rows[#block.rows + 1] = child
                    i = i + 1
                end
            end
            blocks[#blocks + 1] = block
            if block.key ~= "" and not byKey[block.key] then
                byKey[block.key] = block
            end
        end

        local used = {}
        local sorted = {}
        local cleanedOrder = {}
        for _, key in ipairs(order) do
            key = TrimText(key)
            local block = byKey[key]
            if block and not used[key] then
                sorted[#sorted + 1] = block
                used[key] = true
                cleanedOrder[#cleanedOrder + 1] = key
            end
        end
        for _, block in ipairs(blocks) do
            local key = block.key
            if key == "" or not used[key] then
                sorted[#sorted + 1] = block
                if key ~= "" then
                    cleanedOrder[#cleanedOrder + 1] = key
                    used[key] = true
                end
            end
        end

        local result = {}
        for _, block in ipairs(sorted) do
            for _, row in ipairs(block.rows or {}) do
                row.displaySection = sectionKey
                result[#result + 1] = row
            end
        end
        db.savedListOrder[sectionKey] = cleanedOrder
        return result
    end

    loadedEntries = applyDisplayOrder("loaded", loadedEntries)
    unloadedEntries = applyDisplayOrder("unloaded", unloadedEntries)

    local loadedDisplayCount = countHeaderItems(loadedEntries)
    local unloadedDisplayCount = countHeaderItems(unloadedEntries)

    local yOffset = 0
    local rowIndex = 1
    local headerIndex = 1
    local selectedExists = false

    local function placeFrame(frame, height)
        frame:ClearAllPoints()
        frame:SetPoint("TOPLEFT", self.content, "TOPLEFT", 0, -yOffset)
        frame:SetPoint("RIGHT", self.content, "RIGHT", -4, 0)
        frame:Show()
        yOffset = yOffset + height
    end

    local function updateHeader(sectionKey, title, count)
        local header = EnsureHeader(self, headerIndex)
        header.sectionKey = sectionKey
        local collapsed = sectionKey == "loaded" and state.loadedCollapsed or state.unloadedCollapsed
        if header.label then
            header.label:SetText(string.format("%s（%d）", title, count))
        end
        if header.arrow then
            header.arrow:SetText(collapsed and "+" or "-")
        end
        placeFrame(header, HEADER_HEIGHT)
        headerIndex = headerIndex + 1
    end

    local function updateRow(entry, includeScopeText, isPlaceholder, canDrag, canDrop)
        local itemType = tostring(entry.itemType or "entry")
        local row = EnsureRow(self, rowIndex)
        row.key = entry.key
        row.entryType = entry.entryType or "cooldown"
        row.itemType = itemType
        row.groupID = entry.groupID
        row.parentGroupID = entry.parentGroupID
        row.parentGroupKey = entry.parentGroupKey or entry.groupKey
        row.displaySection = entry.displaySection
        row.depth = tonumber(entry.depth) or 0
        row.isLoadedEntry = entry.isLoaded ~= false
        row.isPlaceholder = isPlaceholder == true
        row.canDrop = ((canDrop == true) or itemType == "group" or itemType == "group-empty") and (not row.isPlaceholder)
        if row.canDrop then
            if itemType == "group-empty" then
                row.dropKey = entry.groupKey or entry.key
            else
                row.dropKey = entry.key
            end
        else
            row.dropKey = nil
        end
        row.canDrag = (canDrag == true) and (not row.isPlaceholder) and (itemType == "entry" or itemType == "group")
        row:EnableMouse(not row.isPlaceholder)
        row.plainText = tostring(entry.spellName or entry.name or "")
        HideRowDropPreview(row)
        if row.SetAlpha then
            row:SetAlpha(1)
        end

        local selected = (not row.isPlaceholder) and (itemType == "entry" or itemType == "group") and selectedKey ~= "" and selectedKey == entry.key
        if selected then
            selectedExists = true
        end

        row.icon:ClearAllPoints()
        row.statusDot:ClearAllPoints()
        row.statusDot:Hide()
        if itemType == "entry" and not row.isPlaceholder then
            local indent = 12 + ((tonumber(entry.depth) or 0) * 24)
            row.statusDot:SetPoint("LEFT", row, "LEFT", indent, 0)
            row.statusDot:Show()
            if entry.isLoaded == false then
                row.statusDot:SetTextColor(1.00, 0.18, 0.18, 1)
            else
                row.statusDot:SetTextColor(0.18, 1.00, 0.25, 1)
            end
            row.icon:SetPoint("LEFT", row.statusDot, "RIGHT", 8, 0)
        elseif itemType == "group" and not row.isPlaceholder then
            local indent = 12 + ((tonumber(entry.depth) or 0) * 24)
            row.statusDot:SetPoint("LEFT", row, "LEFT", indent, 0)
            row.statusDot:Show()
            local loadState = tostring(entry.loadState or "")
            if loadState == "red" or entry.isLoaded == false then
                row.statusDot:SetTextColor(1.00, 0.18, 0.18, 1)
            elseif loadState == "yellow" then
                row.statusDot:SetTextColor(1.00, 0.82, 0.18, 1)
            else
                row.statusDot:SetTextColor(0.18, 1.00, 0.25, 1)
            end
            row.icon:SetPoint("LEFT", row.statusDot, "RIGHT", 8, 0)
        elseif itemType == "group-empty" then
            row.icon:SetPoint("LEFT", row, "LEFT", 36 + ((tonumber(entry.depth) or 0) * 24), 0)
        else
            row.icon:SetPoint("LEFT", row, "LEFT", 12, 0)
        end

        if row.isPlaceholder then
            row.bg:SetColorTexture(0, 0, 0, 0)
            row.accent:SetColorTexture(1, 0.82, 0.18, 0)
            row.icon:SetTexture(nil)
            row.main:SetText(L("EMPTY_CONFIG"))
            row.main:SetTextColor(0.62, 0.62, 0.62, 1)
        elseif itemType == "group" then
            row.bg:SetColorTexture(1, 0.82, 0.18, 0.06)
            row.accent:SetColorTexture(1, 0.82, 0.18, 0.55)
            row.icon:SetTexture(entry.icon or "Interface\\Icons\\INV_Misc_Note_01")
            local isEmptyCollection = entry.isEmptyCollection == true or (tonumber(entry.count) or 0) <= 0
            local arrow = (isEmptyCollection or entry.collapsed) and "|cffffd24a[+]|r " or "|cffffd24a[-]|r "
            row.main:SetText(arrow .. BuildGroupRowText(entry))
            row.plainText = tostring(entry.name or L("COLLECTION_LABEL"))
            row.main:SetTextColor(1, 0.88, 0.42, 1)
        elseif itemType == "group-empty" then
            row.bg:SetColorTexture(0, 0, 0, 0)
            row.accent:SetColorTexture(1, 0.82, 0.18, 0.12)
            row.icon:SetTexture(entry.icon or "Interface\\Icons\\INV_Misc_QuestionMark")
            row.main:SetText("|cff808080" .. L("DRAG_INTO_COLLECTION") .. "|r")
            row.main:SetTextColor(0.62, 0.62, 0.62, 1)
        else
            row.bg:SetColorTexture(1, 0.82, 0.18, selected and 0.18 or 0)
            row.accent:SetColorTexture(1, 0.82, 0.18, selected and 0.95 or (entry.groupID and 0.10 or 0.18))
            row.icon:SetTexture(entry.icon or 134400)
            row.main:SetText(BuildEntryRowText(entry, includeScopeText))
            if selected then
                row.main:SetTextColor(1, 0.95, 0.55, 1)
            else
                row.main:SetTextColor(0.92, 0.92, 0.92, 1)
            end
        end

        local height = ROW_HEIGHT
        if itemType == "group" then
            height = GROUP_ROW_HEIGHT
        elseif itemType == "group-empty" then
            height = EMPTY_ROW_HEIGHT
        end
        row.dragText = row.main and row.main:GetText() or row.plainText or ""
        row:SetHeight(height)
        placeFrame(row, height)
        rowIndex = rowIndex + 1
    end

    updateHeader("loaded", L("HEADER_LOADED"), loadedDisplayCount)
    if not state.loadedCollapsed then
        if #loadedEntries == 0 then
            updateRow({
                key = "__empty_loaded",
                itemType = "entry",
                spellName = L("EMPTY_CONFIG"),
                index = 0,
                spellId = 0,
                modeText = "",
                icon = 134400,
            }, false, true, false)
        else
            for _, entry in ipairs(loadedEntries) do
                updateRow(entry, false, false, true, true)
            end
        end
    end

    updateHeader("unloaded", L("HEADER_UNLOADED"), unloadedDisplayCount)
    if not state.unloadedCollapsed then
        if #unloadedEntries == 0 then
            updateRow({
                key = "__empty_unloaded",
                itemType = "entry",
                spellName = L("EMPTY_CONFIG"),
                index = 0,
                spellId = 0,
                modeText = "",
                icon = 134400,
            }, false, true, false)
        else
            for _, entry in ipairs(unloadedEntries) do
                updateRow(entry, true, false, true, true)
            end
        end
    end

    for index = rowIndex, #self.rows do
        self.rows[index]:Hide()
    end
    for index = headerIndex, #self.headers do
        self.headers[index]:Hide()
    end

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

    local viewportHeight = self.frame:GetHeight()
    local contentHeight = math.max(yOffset, viewportHeight)
    self.content:SetHeight(contentHeight)
    local maxScroll = math.max(yOffset - viewportHeight, 0)

    if maxScroll > 0 then
        self.slider:Show()
        self.slider:SetMinMaxValues(0, maxScroll)
    else
        self.slider:SetMinMaxValues(0, 0)
        self.slider:SetValue(0)
        self.slider:Hide()
        self.scrollFrame:SetVerticalScroll(0)
    end
end
