local MAJOR, MINOR = "LibMatic-Settings-1.0", 13
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 = "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 = "跨角色聲望追蹤工具，提供浮動的聲望增減提示、可排序的瀏覽介面，以及顯示每個另一個角色聲望等級的工具提示疊層。",
    },
}

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",
    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?",
    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)
        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()
                    if position.close then position.close() end
                    local positioner = fetchLib("LibMatic-AlertPositioner-1.0")
                    if positioner then
                        positioner:Open({
                            get = position.get,
                            set = position.set,
                            default = position.default,
                            reopen = position.reopen,
                            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 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)
        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)
        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 = set,
        }
        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")
    return cl:BuildOptionsTable(passthrough)
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 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

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:GetPanelCategoryID(appName)
    return self.panels and self.panels[appName] and self.panels[appName].categoryID
end
