local MAJOR, MINOR = "LibMatic-Settings-1.0", 17
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 = "XPMatic", displayName = "XPMatic", slug = "xpmatic",
        url = "https://www.curseforge.com/wow/addons/xpmatic",
        allianceIcon = "xpmatic-icon.tga", hordeIcon = "xpmatic-icon.tga",
        factionIcons = false, released = true,
    },
}

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 = "Per-spec upgrade scoring from the Icy Veins guides on tooltips, loot, and bags. Knows the Icy Veins best-in-slot list and marks your real BiS pieces with a skull in bags, on your character sheet, and on loot rolls. Gear Map, Auto Equip, Repair, Junk Sell.",
        deDE = "Intelligente Verbesserungserkennung pro Spezialisierung auf Basis der Icy-Veins-Guides, in Tooltips, bei Beute und in Taschen. Kennt die Icy-Veins-Best-in-Slot-Liste und markiert deine echten BiS-Gegenstände mit einem Totenkopf in den Taschen, im Charakterfenster und bei Beutewürfen. Ausrüstungskarte, automatisches Anlegen, automatisches Reparieren und automatischer Schrottverkauf.",
        esES = "Detección de mejoras por especialización basada en las guías de Icy Veins en las descripciones, el botín y las bolsas. Conoce la lista best-in-slot de Icy Veins y marca tus piezas BiS reales con una calavera en las bolsas, en tu hoja de personaje y en las tiradas de botín. Mapa de equipo, Equipamiento automático, Reparación automática y Venta de objetos basura.",
        esMX = "Puntuación de mejoras por especialización basada en las guías de Icy Veins en la información de objetos, el botín y las bolsas. Conoce la lista best-in-slot de Icy Veins y marca tus objetos BiS reales con una calavera en las bolsas, en tu hoja de personaje y en las tiradas de botín. Mapa de equipo, Equipar Automático, Reparar Automático y Vender Chatarra.",
        frFR = "Détection intelligente des améliorations par spécialisation issue des guides Icy Veins, sur les infobulles, le butin et les sacs. GearMatic connaît la liste best-in-slot d'Icy Veins et marque vos véritables pièces BiS d'une tête de mort dans vos sacs, sur votre feuille de personnage et sur les jets de butin. Carte d'équipement, équipement automatique, réparation automatique, vente de la camelote.",
        itIT = "Rilevamento dei miglioramenti per specializzazione basato sulle guide di Icy Veins, su suggerimenti, bottino e borse. Conosce la lista best-in-slot di Icy Veins e contrassegna i tuoi veri pezzi BiS con un teschio nelle borse, sulla scheda del personaggio e nei tiri per il bottino. Mappa dell'equipaggiamento, Equipaggiamento Automatico, Riparazione Automatica, Vendita della Cianfrusaglia.",
        koKR = "Icy Veins 가이드에 기반한 전문화별 업그레이드 점수를 도구 설명, 전리품, 가방에서 보여줍니다. Icy Veins의 부위별 최고 아이템 목록(BiS)을 인식하여, 가방과 캐릭터 창, 전리품 주사위에서 실제 BiS 아이템에 해골 표식을 달아줍니다. 장비 지도, 자동 착용, 자동 수리, 잡템 판매 기능을 포함합니다.",
        ptBR = "Pontuação de melhoria por especialização, vinda dos guias do Icy Veins, em dicas de ferramenta, saques e bolsas. Conhece a lista best-in-slot do Icy Veins e marca suas peças BiS reais com uma caveira nas bolsas, na ficha do personagem e nas rolagens de saque. Mapa de equipamento, Equipar Automático, Reparar Automático, Vender Bugigangas.",
        ruRU = "Умное определение улучшений на основе руководств Icy Veins для каждой специализации. В подсказках, добыче и сумках точно показывается, насколько каждый предмет улучшает экипировку. GearMatic знает список best-in-slot из Icy Veins и отмечает ваши настоящие BiS-предметы черепом в сумках, в окне персонажа и на бросках на лут. Карта снаряжения, автоматическая экипировка, ремонт, продажа хлама.",
        zhCN = "基于 Icy Veins 攻略的专精升级评分，覆盖鼠标提示、战利品和背包。内置 Icy Veins 极品装备（BiS）列表，在背包、角色窗口和拾取掷骰中用骷髅标记你真正的 BiS 部件。装备地图、自动装备、自动修理、自动出售垃圾。",
        zhTW = "以真實 Icy Veins 指南為依據的每專精升級評分，套用於工具提示、戰利品與背包。GearMatic 熟知 Icy Veins 的最佳部位裝備清單，並在你的背包、角色裝備面板與拾取擲骰上以骷髏標記出你真正的 BiS 部位裝備。內建裝備地圖、自動裝備、自動修理與自動販售垃圾物品功能。",
    },
    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 進行設定。",
    },
    XPMatic = {
        enUS = "XPMatic shows a live time to next level readout that learns your real leveling pace and pops an ETA alert on quest turn-ins, so you always know how long until your next ding.",
        deDE = "XPMatic zeigt eine Live-Anzeige der verbleibenden Zeit bis zur nächsten Stufe, die dein tatsächliches Aufstiegstempo erlernt, und blendet beim Abgeben von Quests eine Restzeit-Meldung ein, damit du immer weißt, wie lange es bis zur nächsten Stufe dauert.",
        esES = "XPMatic muestra en directo el tiempo que falta para el siguiente nivel, aprende tu ritmo real de subida y lanza una alerta con el tiempo estimado al entregar misiones, para que siempre sepas cuánto falta para subir de nivel.",
        esMX = "XPMatic muestra en vivo el tiempo que falta para el siguiente nivel, aprende tu ritmo real de subida y lanza una alerta con el tiempo estimado al entregar misiones, para que siempre sepas cuánto falta para subir de nivel.",
        frFR = "XPMatic affiche en temps réel le temps restant avant le prochain niveau, apprend votre rythme de progression réel et fait apparaître une alerte de temps estimé lors de la remise des quêtes, pour que vous sachiez toujours combien de temps il reste avant de monter de niveau.",
        itIT = "XPMatic mostra in tempo reale quanto manca al livello successivo, impara il tuo ritmo di livellamento reale e fa comparire un avviso con il tempo stimato quando consegni le missioni, così sai sempre quanto manca al prossimo livello.",
        koKR = "XPMatic은 실제 레벨업 속도를 학습해 다음 레벨까지 남은 시간을 실시간으로 보여주고, 퀘스트를 완료할 때 예상 시간 알림을 띄워 다음 레벨까지 얼마나 남았는지 항상 알 수 있게 해줍니다.",
        ptBR = "O XPMatic mostra em tempo real quanto falta para o próximo nível, aprende o seu ritmo real de evolução e exibe um alerta com o tempo estimado ao entregar missões, para que você sempre saiba quanto falta para o próximo nível.",
        ruRU = "XPMatic в реальном времени показывает, сколько осталось до следующего уровня, изучает ваш настоящий темп прокачки и выводит оповещение с оценкой времени при сдаче заданий, чтобы вы всегда знали, сколько осталось до следующего уровня.",
        zhCN = "XPMatic 实时显示距离升级还有多久，会学习你真实的升级速度，并在交任务时弹出预计时间提示，让你随时知道还要多久才能升到下一级。",
        zhTW = "XPMatic 即時顯示距離升級還有多久，會學習你真實的升級速度，並在交任務時彈出預估時間提示，讓你隨時掌握還要多久才能升到下一級。",
    },
}

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,
                disabled = entry.disabled,
                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
