local MAJOR, MINOR = "LibMatic-Settings-1.0", 16
assert(LibStub, MAJOR .. " requires LibStub")
local lib = LibStub:NewLibrary(MAJOR, MINOR)
if not lib then return end

local function fetchLib(name)
    return LibStub and LibStub(name, true)
end

function lib:ResolveShow(value)
    if value == nil then return true end
    if type(value) == "function" then
        return value() and true or false
    end
    return value and true or false
end

local function resolveFlag(value, default)
    if value == nil then return default end
    if type(value) == "function" then
        return value() and true or false
    end
    return value and true or false
end

local ROSTER = {
    {
        key = "CritMatic", displayName = "CritMatic", slug = "critmatic",
        url = "https://www.curseforge.com/wow/addons/critmatic",
        allianceIcon = "critmatic-icon.tga", hordeIcon = "critmatic-icon.tga",
        factionIcons = false, released = true,
    },
    {
        key = "GearMatic", displayName = "GearMatic", slug = "gearmatic",
        url = "https://www.curseforge.com/wow/addons/gearmatic",
        allianceIcon = "gearmatic-icon.tga", hordeIcon = "gearmatic-icon.tga",
        factionIcons = false, released = true,
    },
    {
        key = "RepMatic", displayName = "RepMatic", slug = "repmatic",
        url = "https://www.curseforge.com/wow/addons/repmatic",
        allianceIcon = "repmatic-icon.tga", hordeIcon = "repmatic-icon.tga",
        factionIcons = false, released = true,
    },
    {
        key = "MailMatic", displayName = "MailMatic", slug = "mailmatic",
        url = "https://www.curseforge.com/wow/addons/mailmatic",
        allianceIcon = "mailmatic-icon.tga", hordeIcon = "mailmatic-icon.tga",
        factionIcons = false, released = true,
    },
    {
        key = "RareMatic", displayName = "RareMatic", slug = "rarematic",
        url = "https://www.curseforge.com/wow/addons/rarematic",
        allianceIcon = "rarematic-icon.tga", hordeIcon = "rarematic-icon.tga",
        factionIcons = false, released = false,
    },
    {
        key = "QuestMatic", displayName = "QuestMatic", slug = "questmatic",
        url = "https://www.curseforge.com/wow/addons/questmatic",
        allianceIcon = "questmatic-icon.tga", hordeIcon = "questmatic-icon.tga",
        factionIcons = false, released = false,
    },
    {
        key = "MountMatic", displayName = "MountMatic", slug = "mountmatic",
        url = "https://www.curseforge.com/wow/addons/mountmatic",
        allianceIcon = "mountmatic-icon.tga", hordeIcon = "mountmatic-icon.tga",
        factionIcons = false, released = false,
    },
}

local BRAND = {
    label = "Infinite Loop Alchemist",
    icon = "profile.tga",
}

local DESCRIPTIONS = {
    CritMatic = {
        enUS = "Track your highest critical and normal hits. Pop an on-screen alert and play a sound on a new record. Adds a tooltip line to every spell in your spellbook and action bars, and more.",
        deDE = "Verfolgt deine höchsten kritischen und normalen Treffer. Zeigt eine Bildschirmmeldung an und spielt bei einem neuen Rekord einen Ton ab. Fügt jedem Zauber in deinem Zauberbuch und auf deinen Aktionsleisten eine Tooltipzeile hinzu, und mehr.",
        esES = "Registra tus golpes críticos y normales más altos. Muestra una alerta en pantalla y reproduce un sonido al batir un récord. Añade una línea de descripción a cada hechizo de tu libro de hechizos y barras de acción, y mucho más.",
        esMX = "Registra tus golpes críticos y normales más altos. Muestra una alerta en pantalla y reproduce un sonido cuando logras un nuevo récord. Agrega una línea de información a cada hechizo de tu libro de hechizos y barras de acción, y mucho más.",
        frFR = "Suivez vos coups critiques et vos coups normaux les plus élevés. Affichez une alerte à l'écran et jouez un son à chaque nouveau record. Ajoute une ligne d'infobulle à chaque sort de votre grimoire et de vos barres d'action, et plus encore.",
        itIT = "Tieni traccia dei tuoi colpi critici e normali più alti. Mostra un avviso a schermo e riproduce un suono quando stabilisci un nuovo record. Aggiunge una riga al suggerimento di ogni magia nel libro degli incantesimi e sulle barre delle azioni, e molto altro.",
        koKR = "최고 치명타 적중과 일반 적중 기록을 추적합니다. 새로운 기록이 나오면 화면에 알림을 띄우고 효과음을 재생합니다. 주문서와 행동 단축바의 모든 주문에 도구 설명 정보를 추가하며, 그 외 다양한 기능을 제공합니다.",
        ptBR = "Acompanhe seus maiores acertos críticos e normais. Exibe um alerta na tela e toca um som ao bater um novo recorde. Adiciona uma linha de dica de ferramenta a cada magia no seu grimório e nas suas barras de ação, e muito mais.",
        ruRU = "Отслеживает ваши самые сильные критические и обычные удары. При новом рекорде показывает оповещение на экране и проигрывает звук. Добавляет строку в подсказку для каждого заклинания в вашей книге заклинаний и на панелях команд, и не только.",
        zhCN = "追踪你的最高致命一击与普通命中纪录。刷新纪录时弹出屏幕提示并播放音效。在你法术书和动作条上的每个法术提示中加入一行信息，还有更多功能。",
        zhTW = "追蹤你最高的致命一擊與普通攻擊紀錄。締造新紀錄時會在畫面上彈出提示並播放音效。為法術書與動作列中的每個法術加上工具提示說明，還有更多功能。",
    },
    GearMatic = {
        enUS = "Smart upgrade detection driven by real stat weights. Tooltips, quest rewards, loot, and bags show exactly how much each item upgrades your spec, across every alt. Includes Auto Equip, Auto Repair, and Auto Junk Sell.",
        deDE = "Intelligente Verbesserungserkennung auf Basis echter Werte-Gewichtungen. Tooltips, Questbelohnungen, Beute und Taschen zeigen genau, wie sehr jeder Gegenstand deine Spezialisierung verbessert, über all deine Twinks hinweg. Beinhaltet automatisches Anlegen, automatisches Reparieren und automatischen Schrottverkauf.",
        esES = "Detección inteligente de mejoras basada en pesos de estadísticas reales. Las descripciones, recompensas de misión, el botín y las bolsas muestran exactamente cuánto mejora cada objeto tu especialización, en todos tus personajes. Incluye Equipamiento automático, Reparación automática y Venta automática de objetos basura.",
        esMX = "Detección inteligente de mejoras basada en pesos de estadísticas reales. La información de objetos, las recompensas de misión, el botín y las bolsas muestran exactamente cuánto mejora cada objeto a tu especialización, en todos tus personajes. Incluye Equipar Automático, Reparar Automático y Vender Chatarra Automático.",
        frFR = "Détection intelligente des améliorations basée sur de véritables poids de statistiques. Les infobulles, les récompenses de quête, le butin et les sacs indiquent précisément combien chaque objet améliore votre spécialisation, sur tous vos personnages. Inclut l'équipement automatique, la réparation automatique et la vente automatique de la camelote.",
        itIT = "Rilevamento intelligente dei miglioramenti basato sui pesi reali delle statistiche. I suggerimenti, le ricompense delle missioni, il bottino e le borse mostrano esattamente quanto ogni oggetto migliora la tua specializzazione, su tutti i personaggi alternativi. Include Equipaggiamento Automatico, Riparazione Automatica e Vendita Automatica della Cianfrusaglia.",
        koKR = "실제 능력치 가중치에 기반한 스마트 업그레이드 감지 기능입니다. 도구 설명, 퀘스트 보상, 전리품, 가방에서 각 아이템이 당신의 전문화를 얼마나 향상시키는지 정확히 보여주며, 모든 부캐릭터에 걸쳐 적용됩니다. 자동 착용, 자동 수리, 자동 잡템 판매 기능을 포함합니다.",
        ptBR = "Detecção inteligente de melhorias baseada em pesos reais de atributos. Dicas de ferramenta, recompensas de missão, saques e bolsas mostram exatamente o quanto cada item melhora sua especialização, em todos os seus personagens. Inclui Equipar Automático, Reparar Automático e Vender Bugigangas Automático.",
        ruRU = "Умное определение улучшений на основе реальных весов характеристик. В подсказках, наградах за задания, добыче и сумках точно показывается, насколько каждый предмет улучшает экипировку вашей специализации, и так для каждого персонажа. Включает автоматическую экипировку, автоматический ремонт и автоматическую продажу хлама.",
        zhCN = "基于真实属性权重的智能升级检测。鼠标提示、任务奖励、战利品和背包都会精确显示每件物品对你当前专精的提升幅度，并覆盖你的每个备用角色。内置自动装备、自动修理和自动出售垃圾功能。",
        zhTW = "以真實屬性權重驅動的智慧裝備升級偵測。工具提示、任務獎勵、戰利品與背包都會明確顯示每件物品為你的專精帶來多少提升，並涵蓋你所有的另一個角色。內建自動裝備、自動修理與自動販售垃圾物品功能。",
    },
    RepMatic = {
        enUS = "Cross-character reputation tracker with floating gain and loss alerts, a sortable browser, and a tooltip overlay showing every alt's standing.",
        deDE = "Charakterübergreifender Rufverfolger mit aufsteigenden Meldungen bei Ruf-Gewinn und -Verlust, einer sortierbaren Übersicht und einer Tooltip-Einblendung, die den Ruf-Stand jedes Twinks anzeigt.",
        esES = "Rastreador de reputación entre personajes con alertas flotantes de ganancia y pérdida, un explorador ordenable y una superposición en la descripción que muestra la reputación de cada personaje.",
        esMX = "Rastreador de reputación entre personajes con alertas flotantes de ganancia y pérdida, un navegador ordenable y una superposición en la información que muestra el estado de reputación de cada personaje.",
        frFR = "Suivi de réputation multi-personnages avec des alertes flottantes de gain et de perte, un navigateur triable, et une infobulle affichant la réputation de chacun de vos personnages.",
        itIT = "Tracciatore della reputazione per tutti i personaggi con avvisi fluttuanti di guadagno e perdita, un visualizzatore ordinabile e una sovrapposizione nei suggerimenti che mostra la reputazione di ogni personaggio alternativo.",
        koKR = "캐릭터 간 평판 추적기로, 평판 획득과 감소를 화면에 표시하는 알림, 정렬 가능한 목록 창, 그리고 모든 부캐릭터의 평판 상태를 보여주는 도구 설명 오버레이를 제공합니다.",
        ptBR = "Rastreador de reputação entre personagens com alertas flutuantes de ganho e perda, um navegador ordenável e uma sobreposição na dica de ferramenta mostrando a situação de reputação de cada personagem.",
        ruRU = "Отслеживание репутации по всем персонажам с всплывающими оповещениями о приросте и потере, сортируемым просмотрщиком и наложением на подсказку, которое показывает уровень отношения каждого персонажа.",
        zhCN = "跨角色声望追踪器，配有浮动的声望获得与损失提示、可排序的浏览界面，以及在鼠标提示中展示每个备用角色声望进度的浮层。",
        zhTW = "跨角色聲望追蹤工具，提供浮動的聲望增減提示、可排序的瀏覽介面，以及顯示每個另一個角色聲望等級的工具提示疊層。",
    },
    MailMatic = {
        enUS = "MailMatic greets you with an on-screen pop-up and a sound the moment new mail arrives, including at login. Per-race, per-gender voice lines, a wax-sealed envelope icon, and adjustable text, position, and timing. Fully localized. /mailmatic to set up.",
        deDE = "MailMatic begrüßt dich mit einem Bildschirm-Pop-up und einem Sound, sobald neue Post eintrifft, auch beim Anmelden. Sprachzeilen je Volk und Geschlecht, ein Symbol mit versiegeltem Umschlag sowie anpassbarer Text, Position und Timing. Vollständig lokalisiert. /mailmatic zum Einrichten.",
        esES = "MailMatic te avisa con una ventana emergente en pantalla y un sonido en cuanto llega correo nuevo, incluso al iniciar sesión. Voces por raza y género, un icono de sobre con sello de cera y texto, posición y tiempos ajustables. Totalmente traducido. /mailmatic para configurarlo.",
        esMX = "MailMatic te avisa con una ventana emergente en pantalla y un sonido en cuanto llega correo nuevo, incluso al iniciar sesión. Voces por raza y género, un icono de sobre con sello de cera y texto, posición y tiempos ajustables. Totalmente traducido. /mailmatic para configurarlo.",
        frFR = "MailMatic vous accueille avec une alerte à l'écran et un son dès l'arrivée d'un nouveau courrier, y compris à la connexion. Voix par race et par sexe, une icône d'enveloppe scellée à la cire, et texte, position et délais réglables. Entièrement traduit. /mailmatic pour le configurer.",
        itIT = "MailMatic ti accoglie con un pop-up sullo schermo e un suono nel momento in cui arriva nuova posta, anche all'accesso. Voci per razza e sesso, un'icona a busta sigillata con ceralacca e testo, posizione e tempi regolabili. Completamente tradotto. /mailmatic per configurarlo.",
        koKR = "MailMatic은 새 우편이 도착하는 순간, 로그인 시에도 화면 팝업과 소리로 알려줍니다. 종족별, 성별별 음성 대사와 밀랍 봉인 봉투 아이콘, 그리고 조절 가능한 문구, 위치, 시간을 제공합니다. 완전히 현지화되었습니다. 설정하려면 /mailmatic.",
        ptBR = "O MailMatic avisa você com um pop-up na tela e um som no momento em que chega correspondência nova, inclusive ao entrar. Vozes por raça e gênero, um ícone de envelope lacrado e texto, posição e tempos ajustáveis. Totalmente traduzido. /mailmatic para configurar.",
        ruRU = "MailMatic оповещает вас всплывающим окном на экране и звуком в момент получения новой почты, в том числе при входе в игру. Голосовые реплики для каждой расы и пола, значок конверта с сургучной печатью, а также настраиваемые текст, положение и тайминги. Полностью локализовано. /mailmatic для настройки.",
        zhCN = "每当有新邮件送达，包括登录时，MailMatic 都会用屏幕弹窗和声音提醒你。提供按种族和性别区分的语音台词、蜡封信封图标，以及可调整的文本、位置和时间。完全本地化。输入 /mailmatic 进行设置。",
        zhTW = "每當有新郵件送達，包括登入時，MailMatic 都會以畫面彈出提示與音效通知你。提供依種族與性別區分的語音台詞、蠟封信封圖示，以及可調整的文字、位置與時間。完全在地化。輸入 /mailmatic 進行設定。",
    },
}

local function resolveDescription(key)
    local entry = DESCRIPTIONS[key]
    if not entry then return nil end
    local locale = (GetLocale and GetLocale()) or "enUS"
    return entry[locale] or entry.enUS
end

function lib:GetRoster()
    return ROSTER
end

function lib:GetAddonDescription(key)
    return resolveDescription(key)
end

local STRINGS = {
    general = "General",
    alert = "Alert",
    sound = "Sound",
    social = "Social",
    changelog = "Change Log",
    profiles = "Profiles",
    alertGeneralTab = "General",
    alertMessageTab = "Messages",
    alertFontTab = "Font",
    positionAlert = "Position Alert",
    sharedPosition = "Use shared LibMatic alert position",
    sharedPositionDesc = "Anchor this addon's alerts to the position shared by all LibMatic addons and merge them into one stack. Turn off to use this addon's own alert position.",
    maxSharedAlerts = "Max Shared Alerts",
    maxSharedAlertsDesc = "Maximum number of alerts shown at once in the shared LibMatic stack, counting alerts from all addons together.",
    bounce = "Bounce Style",
    bounceDesc = "Animation style used for alert text.",
    bounceModern = "Bounce",
    bounceClassic = "Classic Bounce",
    preview = "Preview",
    support = "Support",
    discord = "Discord",
    coffee = "Buy Me a Coffee",
    moreAddons = "More Infinite Loop Alchemist Addons",
    reset = "Reset to Default",
    resetConfirm = "Reset these settings to default?",
    resetProfileConfirm = "Reset this profile to its default settings?",
    muteAll = "Mute All Sounds",
    scanCustomSounds = "Scan for Custom Sounds",
    party = "Party",
    raid = "Raid",
    guild = "Guild",
    battleground = "Battlegrounds",
    missingLSM = "LibSharedMedia-3.0 is not available.",
    missingChangeLog = "LibMatic-ChangeLog-1.0 is not available.",
    missingProfiles = "AceDBOptions-3.0 is not available.",
}

local function str(locale, key)
    if locale and locale[key] ~= nil then
        return locale[key]
    end
    return STRINGS[key]
end

local function makeGetSet(bind, entry)
    if entry.get or entry.set then
        return entry.get, entry.set
    end
    if not bind or type(bind.table) ~= "function" then
        return nil, nil
    end
    local key = entry.key
    local get = function()
        local t = bind.table()
        return t and t[key]
    end
    local set = function(_, value)
        local t = bind.table()
        if t then t[key] = value end
    end
    return get, set
end

local function applyShow(node, show)
    if show == nil then return node end
    node.hidden = function()
        return not lib:ResolveShow(show)
    end
    return node
end

local function resolveIcon(entry, iconRoot, factionIcons)
    local useFaction = factionIcons
    if entry.factionIcons ~= nil then
        useFaction = entry.factionIcons
    end
    local file = entry.allianceIcon
    if useFaction and entry.hordeIcon then
        local faction = UnitFactionGroup and UnitFactionGroup("player")
        if faction == "Horde" then
            file = entry.hordeIcon
        end
    end
    return (iconRoot or "") .. (file or "")
end

local function buildNavBlock(opts, locale)
    local nav = opts.nav or {}
    local iconRoot = nav.iconRoot or ""
    local roster = nav.roster or ROSTER
    local factionIcons = resolveFlag(nav.factionIcons, false)
    local current = opts.currentAddon
    local navArgs = {}
    local order = 1

    navArgs.brand = {
        type = "description",
        order = order,
        width = "full",
        name = "|cffffffff" .. BRAND.label .. "|r",
        image = iconRoot .. BRAND.icon,
        imageWidth = 30,
        imageHeight = 30,
        fontSize = "large",
    }
    order = order + 1

    for _, entry in ipairs(roster) do
        if entry.key ~= current and entry.released then
            navArgs[entry.key .. "Icon"] = {
                type = "description",
                order = order,
                width = "full",
                name = "|cffffffff" .. entry.displayName .. "|r",
                image = resolveIcon(entry, iconRoot, factionIcons),
                imageWidth = 24,
                imageHeight = 24,
                fontSize = "medium",
            }
            order = order + 1
            local desc = resolveDescription(entry.key)
            if desc and desc ~= "" then
                navArgs[entry.key .. "Desc"] = {
                    type = "description",
                    order = order,
                    width = "full",
                    name = desc,
                    fontSize = "small",
                }
                order = order + 1
            end
            navArgs[entry.key .. "Link"] = {
                type = "input",
                order = order,
                width = "full",
                name = "",
                get = function() return entry.url end,
                set = function() end,
            }
            order = order + 1
        end
    end

    if nav.extraLinks then
        for index, link in ipairs(nav.extraLinks) do
            navArgs["extra" .. index .. "Icon"] = {
                type = "description",
                order = order,
                width = "full",
                name = "|cffffffff" .. (link.label or "") .. "|r",
                image = link.icon and (iconRoot .. link.icon) or nil,
                imageWidth = link.icon and 24 or nil,
                imageHeight = link.icon and 24 or nil,
                fontSize = "medium",
            }
            order = order + 1
            navArgs["extra" .. index .. "Link"] = {
                type = "input",
                order = order,
                width = "full",
                name = "",
                get = function() return link.url or "" end,
                set = function() end,
            }
            order = order + 1
        end
    end

    return {
        type = "group",
        inline = true,
        order = nav.order or 99,
        name = str(locale, "moreAddons"),
        args = navArgs,
    }
end

local function buildSupportGroup(support, locale)
    local sArgs = {}
    local order = 1
    if support.discordURL then
        sArgs.discordLabel = {
            type = "description",
            order = order,
            width = "full",
            name = "|cffffffff" .. str(locale, "discord") .. "|r",
            image = support.discordIcon,
            imageWidth = support.discordIcon and 24 or nil,
            imageHeight = support.discordIcon and 24 or nil,
            fontSize = "medium",
        }
        order = order + 1
        sArgs.discordLink = {
            type = "input",
            order = order,
            width = "full",
            name = "",
            get = function() return support.discordURL end,
            set = function() end,
        }
        order = order + 1
    end
    if support.coffeeURL then
        sArgs.coffeeLabel = {
            type = "description",
            order = order,
            width = "full",
            name = "|cffffffff" .. str(locale, "coffee") .. "|r",
            image = support.coffeeIcon,
            imageWidth = support.coffeeIcon and 24 or nil,
            imageHeight = support.coffeeIcon and 24 or nil,
            fontSize = "medium",
        }
        order = order + 1
        sArgs.coffeeLink = {
            type = "input",
            order = order,
            width = "full",
            name = "",
            get = function() return support.coffeeURL end,
            set = function() end,
        }
        order = order + 1
    end
    return {
        type = "group",
        inline = true,
        order = 90,
        name = str(locale, "support"),
        args = sArgs,
    }
end

local function buildBounceNode(bounce, locale)
    local values = bounce.values or {
        mop = bounce.labelModern or str(locale, "bounceModern"),
        legacy = bounce.labelClassic or str(locale, "bounceClassic"),
    }
    return {
        name = bounce.name or str(locale, "bounce"),
        desc = bounce.desc or str(locale, "bounceDesc"),
        type = "select",
        width = "full",
        order = 1,
        values = values,
        get = function()
            if bounce.get then return bounce.get() end
        end,
        set = function(info, value)
            if bounce.set then bounce.set(info, value) end
        end,
    }
end

function lib:BuildGeneralPage(opts)
    opts = opts or {}
    if not lib:ResolveShow(opts.show) then return nil end
    assert(type(opts.currentAddon) == "string" and opts.currentAddon ~= "",
        MAJOR .. ":BuildGeneralPage requires opts.currentAddon")
    local locale = opts.locale
    local args = {}

    if opts.toggles then
        for index, t in ipairs(opts.toggles) do
            local get, set = makeGetSet(opts.bind, t)
            local node = {
                name = t.name,
                desc = t.desc,
                type = "toggle",
                order = t.order or index * 10,
                width = t.width,
                confirm = t.confirm,
                confirmText = t.confirmText,
                disabled = t.disabled,
                get = t.get or get,
                set = t.set or set,
            }
            applyShow(node, t.show)
            assert(args[t.key] == nil,
                MAJOR .. ":BuildGeneralPage toggle key collision: " .. tostring(t.key))
            args[t.key] = node
        end
    end

    if opts.extras then
        for k, v in pairs(opts.extras) do
            assert(args[k] == nil,
                MAJOR .. ":BuildGeneralPage extras key collision: " .. tostring(k))
            args[k] = v
        end
    end

    if opts.support then
        assert(args.supportGroup == nil,
            MAJOR .. ":BuildGeneralPage extras key collision: supportGroup")
        args.supportGroup = buildSupportGroup(opts.support, locale)
    end

    assert(args.byGroup == nil,
        MAJOR .. ":BuildGeneralPage extras key collision: byGroup")
    args.byGroup = buildNavBlock(opts, locale)

    return {
        name = opts.name or str(locale, "general"),
        type = "group",
        order = opts.order or 1,
        disabled = opts.disabled,
        args = args,
    }
end

function lib:BuildAlertPage(opts)
    opts = opts or {}
    if not lib:ResolveShow(opts.show) then return nil end
    assert(type(opts.notificationTypes) == "table" and #opts.notificationTypes > 0,
        MAJOR .. ":BuildAlertPage requires opts.notificationTypes")
    local locale = opts.locale
    local tabs = {}
    local tabOrder = 1

    local function buildPositionNode(position)
        local appName = position.appName or opts.appName
        local function panelClose()
            if position.close then return position.close() end
            if appName then
                local acd = fetchLib("AceConfigDialog-3.0")
                if acd then acd:Close(appName) end
            end
        end
        local function panelReopen()
            if position.reopen then return position.reopen() end
            if appName then
                local acd = fetchLib("AceConfigDialog-3.0")
                if acd then acd:Open(appName) end
            end
        end
        return {
            name = position.name or str(locale, "positionAlert"),
            type = "execute",
            width = position.width or "double",
            order = position.order or -1,
            func = function()
                local function run()
                    panelClose()
                    local positioner = fetchLib("LibMatic-AlertPositioner-1.0")
                    if positioner then
                        local get, set, default = position.get, position.set, position.default
                        local shared = opts.sharedPosition
                        local stack = shared and shared.get and fetchLib("LibMatic-AlertStack-1.0")
                        if stack and shared.get() then
                            get = function() return stack:GetPosition() end
                            set = function(x, y) stack:SetPosition(x, y) end
                            default = function() return stack:GetDefaultPosition() end
                        end
                        positioner:Open({
                            get = get,
                            set = set,
                            default = default,
                            reopen = panelReopen,
                            font = position.font,
                            color = position.color,
                        })
                    end
                end
                if C_Timer and C_Timer.After then C_Timer.After(0, run) else run() end
            end,
        }
    end
    local function buildSharedPositionNode(shared)
        local node = {
            name = shared.name or str(locale, "sharedPosition"),
            desc = shared.desc or str(locale, "sharedPositionDesc"),
            type = "toggle",
            width = shared.width or "double",
            order = shared.order or -0.5,
            get = function()
                return shared.get and shared.get() and true or false
            end,
            set = function(_, value)
                if shared.set then shared.set(value) end
            end,
        }
        applyShow(node, shared.show)
        return node
    end
    local function buildMaxSharedAlertsNode(shared)
        local node = {
            name = shared.maxName or str(locale, "maxSharedAlerts"),
            desc = shared.maxDesc or str(locale, "maxSharedAlertsDesc"),
            type = "range",
            min = 1,
            max = 10,
            step = 1,
            width = shared.maxWidth or "double",
            order = shared.maxOrder or -0.4,
            get = function()
                local stack = fetchLib("LibMatic-AlertStack-1.0")
                return stack and stack:GetMaxAlerts() or 5
            end,
            set = function(_, value)
                local stack = fetchLib("LibMatic-AlertStack-1.0")
                if stack then stack:SetMaxAlerts(value) end
            end,
            disabled = function()
                return not (shared.get and shared.get())
            end,
        }
        applyShow(node, shared.show)
        return node
    end
    local positionTab = (opts.position and opts.position.tab) or "general"

    if opts.globals or (opts.position and positionTab == "general") then
        local generalArgs = {}
        if opts.globals then
            for k, v in pairs(opts.globals) do
                generalArgs[k] = v
            end
        end
        if opts.position and positionTab == "general" then
            assert(generalArgs.positionAlert == nil,
                MAJOR .. ":BuildAlertPage globals key collision: positionAlert")
            generalArgs.positionAlert = buildPositionNode(opts.position)
            if opts.sharedPosition then
                assert(generalArgs.useSharedAlertPosition == nil,
                    MAJOR .. ":BuildAlertPage globals key collision: useSharedAlertPosition")
                generalArgs.useSharedAlertPosition = buildSharedPositionNode(opts.sharedPosition)
                if fetchLib("LibMatic-AlertStack-1.0") then
                    generalArgs.maxSharedAlerts = buildMaxSharedAlertsNode(opts.sharedPosition)
                end
            end
        end
        tabs.generalTab = {
            name = str(locale, "alertGeneralTab"),
            type = "group",
            order = tabOrder,
            args = generalArgs,
        }
        tabOrder = tabOrder + 1
    end

    local messageArgs = {}
    if opts.bounce then
        messageArgs.bounceStyle = buildBounceNode(opts.bounce, locale)
    end
    for index, nt in ipairs(opts.notificationTypes) do
        local get, set = makeGetSet(opts.bind, nt)
        if not get then
            get = function() return nt.defaultFormat end
        end
        local node = {
            name = nt.title,
            desc = nt.desc,
            type = "input",
            width = "full",
            order = 10 + index * 10,
            get = get,
            set = set,
        }
        applyShow(node, nt.show)
        assert(messageArgs[nt.key] == nil,
            MAJOR .. ":BuildAlertPage notificationType key collision: " .. tostring(nt.key))
        messageArgs[nt.key] = node
        if nt.preview then
            messageArgs[nt.key .. "Preview"] = {
                name = str(locale, "preview"),
                type = "execute",
                width = "normal",
                order = 11 + index * 10,
                func = nt.preview,
            }
        end
    end
    if opts.messageExtras then
        for k, v in pairs(opts.messageExtras) do
            assert(messageArgs[k] == nil,
                MAJOR .. ":BuildAlertPage messageExtras key collision: " .. tostring(k))
            messageArgs[k] = v
        end
    end
    tabs.messageTab = {
        name = str(locale, "alertMessageTab"),
        type = "group",
        order = tabOrder,
        args = messageArgs,
    }
    tabOrder = tabOrder + 1

    if opts.font or (opts.position and positionTab == "font") then
        local fontArgs = {}
        if opts.font then
            for k, v in pairs(opts.font) do
                fontArgs[k] = v
            end
        end
        if opts.position and positionTab == "font" then
            assert(fontArgs.positionAlert == nil,
                MAJOR .. ":BuildAlertPage font key collision: positionAlert")
            fontArgs.positionAlert = buildPositionNode(opts.position)
            if opts.sharedPosition then
                assert(fontArgs.useSharedAlertPosition == nil,
                    MAJOR .. ":BuildAlertPage font key collision: useSharedAlertPosition")
                fontArgs.useSharedAlertPosition = buildSharedPositionNode(opts.sharedPosition)
                if fetchLib("LibMatic-AlertStack-1.0") then
                    fontArgs.maxSharedAlerts = buildMaxSharedAlertsNode(opts.sharedPosition)
                end
            end
        end
        tabs.fontTab = {
            name = str(locale, "alertFontTab"),
            type = "group",
            order = tabOrder,
            args = fontArgs,
        }
    end

    return {
        name = opts.name or str(locale, "alert"),
        type = "group",
        childGroups = "tab",
        order = opts.order or 2,
        disabled = opts.disabled,
        args = tabs,
    }
end

function lib:BuildSoundPage(opts)
    opts = opts or {}
    if not lib:ResolveShow(opts.show) then return nil end
    assert(type(opts.slots) == "table",
        MAJOR .. ":BuildSoundPage requires opts.slots")
    local locale = opts.locale
    local LSM = fetchLib("LibSharedMedia-3.0")
    if not LSM then
        return {
            name = opts.name or str(locale, "sound"),
            type = "group",
            order = opts.order or 3,
            args = {
                missing = { type = "description", order = 1, name = str(locale, "missingLSM") },
            },
        }
    end
    local args = {}
    for index, slot in ipairs(opts.slots) do
        local get, set = makeGetSet(opts.bind, slot)
        local node = {
            name = slot.label,
            desc = slot.desc,
            type = "select",
            dialogControl = "LSM30_Sound",
            values = LSM:HashTable("sound"),
            width = "full",
            order = index * 10,
            get = get,
            set = function(info, value)
                if set then set(info, value) end
                local path = value and LSM:Fetch("sound", value)
                if path and PlaySoundFile then
                    pcall(PlaySoundFile, path, "Master")
                end
            end,
        }
        applyShow(node, slot.show)
        assert(args[slot.key] == nil,
            MAJOR .. ":BuildSoundPage slot key collision: " .. tostring(slot.key))
        args[slot.key] = node
    end
    if opts.muteToggle then
        local mt = opts.muteToggle
        local get, set = makeGetSet(opts.bind, mt)
        assert(args[mt.key] == nil,
            MAJOR .. ":BuildSoundPage slot key collision: " .. tostring(mt.key))
        args[mt.key] = {
            name = mt.name or str(locale, "muteAll"),
            desc = mt.desc,
            type = "toggle",
            width = "full",
            order = 500,
            get = mt.get or get,
            set = mt.set or set,
        }
    end
    if opts.scan then
        local scan = opts.scan
        assert(args.scanCustom == nil,
            MAJOR .. ":BuildSoundPage slot key collision: scanCustom")
        local node = {
            name = scan.name or str(locale, "scanCustomSounds"),
            desc = scan.desc,
            type = "execute",
            width = "full",
            order = scan.order or 550,
            func = function()
                local cm = fetchLib("LibMatic-CustomMedia-1.0")
                if not cm then return end
                local sounds, fonts = cm:Scan(scan.addon)
                if scan.onResult then
                    scan.onResult(sounds, fonts)
                end
            end,
        }
        applyShow(node, scan.show)
        args.scanCustom = node
    end
    if opts.reset ~= false and opts.bind
        and type(opts.bind.table) == "function"
        and type(opts.bind.defaults) == "function" then
        args.resetSounds = {
            name = str(locale, "reset"),
            type = "execute",
            width = "full",
            order = 600,
            confirm = true,
            confirmText = str(locale, "resetConfirm"),
            func = function()
                local target = opts.bind.table()
                local defaults = opts.bind.defaults()
                if target and defaults then
                    for k in pairs(target) do target[k] = nil end
                    for k, v in pairs(defaults) do target[k] = v end
                end
            end,
        }
    end
    return {
        name = opts.name or str(locale, "sound"),
        type = "group",
        order = opts.order or 3,
        disabled = opts.disabled,
        args = args,
    }
end

local DEFAULT_CHANNELS = { "party", "raid", "guild", "battleground" }

function lib:BuildSocialPage(opts)
    opts = opts or {}
    if not lib:ResolveShow(opts.show) then return nil end
    local locale = opts.locale
    local args = {}
    local channels = opts.channels
    if not channels then
        channels = {}
        for _, key in ipairs(DEFAULT_CHANNELS) do
            channels[#channels + 1] = { key = key }
        end
    end
    for index, ch in ipairs(channels) do
        local get, set = makeGetSet(opts.bind, ch)
        local node = {
            name = ch.name or str(locale, ch.key) or ch.key,
            desc = ch.desc,
            type = "toggle",
            width = "full",
            order = ch.order or index * 10,
            get = ch.get or get,
            set = ch.set or set,
        }
        applyShow(node, ch.show)
        assert(args[ch.key] == nil,
            MAJOR .. ":BuildSocialPage channel key collision: " .. tostring(ch.key))
        args[ch.key] = node
    end
    if opts.formats then
        for index, fmt in ipairs(opts.formats) do
            local get, set = makeGetSet(opts.bind, fmt)
            if not get then
                get = function() return fmt.defaultFormat end
            end
            local node = {
                name = fmt.title,
                desc = fmt.desc,
                type = "input",
                width = "full",
                order = 100 + index * 10,
                get = get,
                set = set,
            }
            applyShow(node, fmt.show)
            assert(args[fmt.key] == nil,
                MAJOR .. ":BuildSocialPage format key collision: " .. tostring(fmt.key))
            args[fmt.key] = node
        end
    end
    return {
        name = opts.name or str(locale, "social"),
        type = "group",
        order = opts.order or 4,
        disabled = opts.disabled,
        args = args,
    }
end

function lib:BuildChangeLogPage(opts)
    opts = opts or {}
    if not lib:ResolveShow(opts.show) then return nil end
    local locale = opts.locale
    local cl = fetchLib("LibMatic-ChangeLog-1.0")
    if not cl then
        return {
            name = opts.name or str(locale, "changelog"),
            type = "group",
            order = opts.order or 5,
            args = {
                missing = { type = "description", order = 1, name = str(locale, "missingChangeLog") },
            },
        }
    end
    local passthrough = {}
    for k, v in pairs(opts) do
        passthrough[k] = v
    end
    passthrough.order = opts.order or 5
    passthrough.name = opts.name or str(locale, "changelog")
    local tab = cl:BuildOptionsTable(passthrough)
    tab.args = tab.args or {}
    if opts.description then
        tab.args.maticDesc = {
            type = "description", order = 0.05, fontSize = "medium",
            name = opts.description,
        }
    end
    if opts.showPopupToggle then
        local t = opts.showPopupToggle
        tab.args.maticShowPopup = {
            type = "toggle", order = 0.1, width = "full",
            name = t.name or "Show changelog popup on update",
            desc = t.desc,
            get = t.get, set = t.set,
        }
    end
    if opts.openNow then
        tab.args.maticOpenNow = {
            type = "execute", order = 0.2, width = "full",
            name = opts.openNow.name or "Open changelog now",
            func = opts.openNow.func,
        }
    end
    return tab
end

function lib:BuildProfilesPage(opts)
    opts = opts or {}
    if not lib:ResolveShow(opts.show) then return nil end
    local locale = opts.locale
    local ado = fetchLib("AceDBOptions-3.0")
    if not ado or not opts.db then
        return {
            name = opts.name or str(locale, "profiles"),
            type = "group",
            order = opts.order or 90,
            args = {
                missing = { type = "description", order = 1, name = str(locale, "missingProfiles") },
            },
        }
    end
    local group = ado:GetOptionsTable(opts.db)
    group.order = opts.order or 90
    if group.args and group.args.reset then
        group.args.reset.confirm = true
        group.args.reset.confirmText = str(locale, "resetProfileConfirm")
    end
    if opts.name then
        group.name = opts.name
    end
    if opts.disabled ~= nil then
        group.disabled = opts.disabled
    end
    return group
end

function lib:BuildCountUpControls(spec)
    spec = spec or {}
    local enabled = spec.enabled or {}
    local duration = spec.duration or {}
    local hold = spec.hold or {}
    local startDelay = spec.startDelay or {}
    local names = spec.names or {}
    local baseOrder = spec.order or 50
    local function isOff()
        if enabled.get then return not enabled.get() end
        return true
    end
    local nodes = {
        toggle = {
            type = "toggle",
            name = names.toggle,
            desc = names.toggleDesc,
            order = spec.toggleOrder or baseOrder,
            width = spec.toggleWidth,
            get = enabled.get,
            set = enabled.set,
        },
        duration = {
            type = "range",
            name = names.duration,
            desc = names.durationDesc,
            order = spec.durationOrder or (baseOrder + 1),
            width = spec.width,
            min = spec.durationMin or 0.5,
            max = spec.durationMax or 10,
            step = spec.durationStep or 0.5,
            disabled = isOff,
            get = duration.get,
            set = duration.set,
        },
        hold = {
            type = "range",
            name = names.hold,
            desc = names.holdDesc,
            order = spec.holdOrder or (baseOrder + 2),
            width = spec.width,
            min = spec.holdMin or 0,
            max = spec.holdMax or 15,
            step = spec.holdStep or 0.5,
            disabled = isOff,
            get = hold.get,
            set = hold.set,
        },
    }
    if startDelay.get or startDelay.set then
        nodes.startDelay = {
            type = "range",
            name = names.startDelay,
            desc = names.startDelayDesc,
            order = spec.startDelayOrder or (baseOrder + 3),
            width = spec.startDelayWidth or spec.width,
            min = spec.startDelayMin or 1,
            max = spec.startDelayMax or 10,
            step = spec.startDelayStep or 0.5,
            get = startDelay.get,
            set = startDelay.set,
        }
    end
    return nodes
end

function lib:BuildShadowControls(spec)
    spec = spec or {}
    local strength = spec.strength or {}
    local names = spec.names or {}
    local baseOrder = spec.order or 60
    return {
        strength = {
            type = "range",
            name = names.strength,
            desc = names.strengthDesc,
            order = spec.strengthOrder or baseOrder,
            width = spec.width,
            min = spec.min or 0,
            max = spec.max or 1,
            step = spec.step or 0.05,
            get = strength.get,
            set = strength.set,
        },
    }
end

local ALERT_OUTLINE_VALUES = {
    [""]                       = "None",
    ["OUTLINE"]                = "Outline",
    ["THICKOUTLINE"]           = "Thick Outline",
    ["OUTLINEMONOCHROME"]      = "Outline Monochrome",
    ["THICKOUTLINEMONOCHROME"] = "Thick Outline Monochrome",
}

function lib:BuildAlertFontControls(spec)
    spec = spec or {}
    local names = spec.names or {}
    local LSM = fetchLib("LibSharedMedia-3.0")
    local args = {}
    if spec.font then
        args.font = {
            type = "select", dialogControl = "LSM30_Font",
            values = (LSM and LSM:HashTable("font")) or {},
            width = "full", order = 1,
            name = names.font or "Font face",
            get = spec.font.get, set = spec.font.set,
        }
    end
    if spec.fontSize then
        args.fontSize = {
            type = "range", min = spec.sizeMin or 8, max = spec.sizeMax or 64, step = 1,
            width = "full", order = 2,
            name = names.fontSize or "Font size",
            get = spec.fontSize.get, set = spec.fontSize.set,
        }
    end
    if spec.fontColor then
        args.fontColor = {
            type = "color", hasAlpha = false, order = 3,
            name = names.fontColor or "Font Color",
            get = spec.fontColor.get, set = spec.fontColor.set,
        }
    end
    if spec.fontColorAlt then
        args.fontColorAlt = {
            type = "color", hasAlpha = false, order = 4,
            name = names.fontColorAlt or "Secondary Color",
            get = spec.fontColorAlt.get, set = spec.fontColorAlt.set,
        }
    end
    if spec.fontOutline then
        args.fontOutline = {
            type = "select", width = "full", order = 5,
            values = ALERT_OUTLINE_VALUES,
            name = names.fontOutline or "Outline",
            get = spec.fontOutline.get, set = spec.fontOutline.set,
        }
    end
    if spec.shadowX then
        args.shadowX = {
            type = "range", min = -10, max = 10, step = 1, order = 6,
            name = names.shadowX or "Shadow X offset",
            get = spec.shadowX.get, set = spec.shadowX.set,
        }
    end
    if spec.shadowY then
        args.shadowY = {
            type = "range", min = -10, max = 10, step = 1, order = 7,
            name = names.shadowY or "Shadow Y offset",
            get = spec.shadowY.get, set = spec.shadowY.set,
        }
    end
    if spec.shadowColor then
        args.shadowColor = {
            type = "color", hasAlpha = false, order = 8,
            name = names.shadowColor or "Shadow color",
            get = spec.shadowColor.get, set = spec.shadowColor.set,
        }
    end
    if spec.shadowStrength then
        local sc = self:BuildShadowControls({
            order = 8.5,
            strength = spec.shadowStrength,
            names = { strength = names.shadowStrength or "Shadow strength" },
        })
        args.shadowStrength = sc.strength
    end
    if spec.reset then
        args.reset = {
            type = "execute", width = "full", order = 99,
            name = names.reset or "Reset Font Settings",
            func = spec.reset,
        }
    end
    return args
end

function lib:BuildAlertMessageControls(spec)
    spec = spec or {}
    local names = spec.names or {}
    local args = {}
    if spec.isUpper then
        args.isUpper = {
            type = "toggle", width = "full", order = 1,
            name = names.isUpper or "Uppercase the alert text",
            get = spec.isUpper.get, set = spec.isUpper.set,
        }
    end
    if spec.reset then
        args.reset = {
            type = "execute", width = "full", order = 99,
            name = names.reset or "Reset Alert Notification",
            func = spec.reset,
        }
    end
    return args
end

function lib:BuildAlertGlobalsControls(spec)
    spec = spec or {}
    local names = spec.names or {}
    local W = spec.controlWidth or "300"
    local args = {}

    local function addReset(key, resetFn, defaultName, order)
        if resetFn then
            args[key] = {
                type = "execute", width = W, order = order,
                name = names[key] or defaultName,
                func = resetFn,
            }
        end
    end

    if spec.delay then
        args.delay = {
            type = "range", width = "full", order = 0,
            name = names.delay or "Delay Before Showing (seconds)",
            desc = names.delayDesc or "How long to wait after the trigger before the alert appears.",
            min = spec.delayMin or 0, max = spec.delayMax or 30, step = spec.delayStep or 1,
            get = spec.delay.get, set = spec.delay.set,
        }
    end

    if spec.maxMessages then
        args.maxMessages = {
            type = "range", width = W, order = 1,
            name = names.maxMessages or "Max messages",
            desc = names.maxMessagesDesc or "Maximum number of floating alerts visible at once.",
            min = spec.maxMessagesMin or 2, max = spec.maxMessagesMax or 8, step = 1,
            get = spec.maxMessages.get, set = spec.maxMessages.set,
        }
        addReset("resetMaxMessages", spec.maxMessages.reset, "Reset Max Messages", 2)
    end

    if spec.displayDuration then
        args.displayDuration = {
            type = "range", width = W, order = 3,
            name = names.displayDuration or "Display duration (seconds)",
            desc = names.displayDurationDesc or "How long the alert stays fully visible before it starts fading.",
            min = spec.displayDurationMin or 1, max = spec.displayDurationMax or 30, step = spec.displayDurationStep or 0.5,
            get = spec.displayDuration.get, set = spec.displayDuration.set,
        }
        addReset("resetDisplayDuration", spec.displayDuration.reset, "Reset Start Delay", 4)
    end

    if spec.fadeTime then
        args.fadeTime = {
            type = "range", width = W, order = 5,
            name = names.fadeTime or "Fade time (seconds)",
            desc = names.fadeTimeDesc or "How long the alert takes to fade out once it starts.",
            min = 0, max = spec.fadeTimeMax or 10, step = spec.fadeTimeStep or 0.1,
            get = spec.fadeTime.get, set = spec.fadeTime.set,
        }
        addReset("resetFadeTime", spec.fadeTime.reset, "Reset Fade Time", 6)
    end

    if spec.animationLength then
        args.animationLength = {
            type = "range", width = spec.animWidth or "full", order = 7,
            name = names.animationLength or "Animation Length",
            desc = names.animationLengthDesc or "How long the number takes to count up, in seconds.",
            min = spec.animMin or 0.5, max = spec.animMax or 10, step = 0.5,
            disabled = spec.animDisabled,
            get = spec.animationLength.get, set = spec.animationLength.set,
        }
    end

    if spec.stayAfterAnimation then
        args.stayAfterAnimation = {
            type = "range", width = spec.animWidth or "full", order = 9,
            name = names.stayAfterAnimation or "Stay After Animation",
            desc = names.stayAfterAnimationDesc or "How long the alert stays on screen after the count-up finishes, in seconds, before it fades.",
            min = 0, max = spec.holdMax or 15, step = 0.5,
            disabled = spec.animDisabled,
            get = spec.stayAfterAnimation.get, set = spec.stayAfterAnimation.set,
        }
    end

    addReset("resetPosition", spec.resetPosition, "Reset Position", 11)

    return args
end

function lib:BuildAppearancePage(opts)
    opts = opts or {}
    local args = {}

    if opts.font then
        local LSM = fetchLib("LibSharedMedia-3.0")
        args.font = {
            type = "select",
            dialogControl = "LSM30_Font",
            values = (LSM and LSM:HashTable("font")) or {},
            width = "full",
            order = 1,
            name = (opts.names and opts.names.font) or "Font",
            get = opts.font.get,
            set = opts.font.set,
        }
        if opts.fontSize then
            args.fontSize = {
                type = "range",
                min = 6,
                max = 32,
                step = 1,
                width = "full",
                order = 2,
                name = (opts.names and opts.names.fontSize) or "Font Size",
                get = opts.fontSize.get,
                set = opts.fontSize.set,
            }
        end
        if opts.fontOutline then
            args.fontOutline = {
                type = "select",
                width = "full",
                order = 3,
                name = (opts.names and opts.names.fontOutline) or "Outline",
                values = {
                    NONE                  = opts.names and opts.names.outlineNone      or "None",
                    OUTLINE               = opts.names and opts.names.outline          or "Outline",
                    OUTLINEMONOCHROME     = opts.names and opts.names.outlineMono      or "Outline Monochrome",
                    THICKOUTLINE          = opts.names and opts.names.outlineThick     or "Thick Outline",
                    THICKOUTLINEMONOCHROME = opts.names and opts.names.outlineThickMono or "Thick Outline Monochrome",
                },
                get = opts.fontOutline.get,
                set = opts.fontOutline.set,
            }
        end
    end

    if opts.colorGroups then
        for i, entry in ipairs(opts.colorGroups) do
            args[entry.key] = {
                name = entry.name,
                desc = entry.desc,
                type = "color",
                width = "normal",
                order = 10 + i,
                get = entry.get,
                set = entry.set,
            }
        end
    end

    if opts.sort then
        args.sort = {
            type = "select",
            width = "full",
            order = 30,
            name = (opts.names and opts.names.sort) or "Sort",
            values = {
                recent  = opts.names and opts.names.sortRecent  or "Most Recent",
                highest = opts.names and opts.names.sortHighest or "Highest First",
            },
            get = opts.sort.get,
            set = opts.sort.set,
        }
    end

    if opts.group then
        args.group = {
            type = "toggle",
            width = "full",
            order = 31,
            name = (opts.names and opts.names.group) or "Group",
            desc = (opts.names and opts.names.groupDesc) or nil,
            get = opts.group.get,
            set = opts.group.set,
        }
    end

    if opts.groupOrder then
        args.groupOrder = {
            type = "select",
            width = "full",
            order = 32,
            name = (opts.names and opts.names.groupOrder) or "Group Order",
            desc = (opts.names and opts.names.groupOrderDesc) or nil,
            values = {
                damage  = opts.names and opts.names.groupOrderDamage  or "Damage First",
                healing = opts.names and opts.names.groupOrderHealing or "Healing First",
            },
            disabled = (opts.group and opts.group.get) and function() return not opts.group.get() end or nil,
            get = opts.groupOrder.get,
            set = opts.groupOrder.set,
        }
    end

    if opts.extra then
        for k, v in pairs(opts.extra) do
            args[k] = v
        end
    end

    return {
        type = "group",
        name = opts.name,
        order = opts.order,
        args = args,
    }
end

function lib:AddToBlizOptions(appName, displayName)
    local acd = fetchLib("AceConfigDialog-3.0")
    if not acd then return nil, nil end
    local panel, capturedID
    if Settings and Settings.RegisterCanvasLayoutCategory and Settings.RegisterAddOnCategory
        and C_SettingsUtil and C_SettingsUtil.OpenSettingsPanel then
        local origCanvas = Settings.RegisterCanvasLayoutCategory
        local origAddOn = Settings.RegisterAddOnCategory
        local numericByObject = {}
        Settings.RegisterCanvasLayoutCategory = function(...)
            local category = origCanvas(...)
            if category and category.GetID then
                local cid = category:GetID()
                if type(cid) == "number" then
                    numericByObject[category] = cid
                end
            end
            return category
        end
        Settings.RegisterAddOnCategory = function(category)
            local cid = category and numericByObject[category]
            if cid then
                category.ID = cid
                if capturedID == nil then capturedID = cid end
            end
            return origAddOn(category)
        end
        local ok, result = pcall(function()
            return acd:AddToBlizOptions(appName, displayName)
        end)
        Settings.RegisterCanvasLayoutCategory = origCanvas
        Settings.RegisterAddOnCategory = origAddOn
        if ok then panel = result end
    else
        local ok, result = pcall(function()
            return acd:AddToBlizOptions(appName, displayName)
        end)
        if ok then panel = result end
    end
    return panel, capturedID
end

function lib:OpenStandalone(appName)
    local acd = fetchLib("AceConfigDialog-3.0")
    if acd then
        acd:Open(appName)
    end
end

function lib:RegisterPanel(opts)
    local acfg = fetchLib("AceConfig-3.0")
    if not acfg then return nil, nil end
    acfg:RegisterOptionsTable(opts.appName, opts.options)
    local panel, catID = self:AddToBlizOptions(opts.appName, opts.displayName)
    self.panels = self.panels or {}
    self.panels[opts.appName] = { panel = panel, categoryID = catID }
    return panel, catID
end

function lib:OpenPanel(appName)
    local acd = fetchLib("AceConfigDialog-3.0")
    if not acd then return end
    if acd.OpenFrames and acd.OpenFrames[appName] then
        acd:Close(appName)
    else
        acd:Open(appName)
    end
end

function lib:ShowPanel(appName)
    local acd = fetchLib("AceConfigDialog-3.0")
    if not acd then return end
    acd:Open(appName)
end

function lib:GetPanelCategoryID(appName)
    return self.panels and self.panels[appName] and self.panels[appName].categoryID
end
