local MAJOR, MINOR = "LibMatic-Settings-1.0", 23
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 Icy Veins guides on tooltips with alt info too, loot, quest rewards and bags. Procs, trinkets, and set bonuses simmed by SimulationCraft and WoWSims. BiS skulls, Gear Map, durability alerts, auto-equip, junk sell.",
        deDE = "Verbesserungsbewertung pro Spezialisierung auf Basis der Icy-Veins-Guides in Tooltips (auch mit Twink-Infos), bei Beute, Questbelohnungen und in Taschen. Procs, Schmuckstücke und Set-Boni von SimulationCraft und WoWSims simuliert. Totenkopf-Markierung für BiS, Ausrüstungskarte, Haltbarkeitswarnungen, automatisches Anlegen, Schrottverkauf.",
        esES = "Puntuación de mejoras por especialización basada en las guías de Icy Veins en las descripciones (con información de tus alters), el botín, las recompensas de misión y las bolsas. Procs, abalorios y bonus de conjunto simulados por SimulationCraft y WoWSims. Calaveras BiS, Mapa de equipo, alertas de durabilidad, equipamiento automático, venta de basura.",
        esMX = "Puntuación de mejoras por especialización basada en las guías de Icy Veins en la información de objetos (con datos de tus alts), el botín, las recompensas de misión y las bolsas. Procs, abalorios y bonificaciones de conjunto simulados por SimulationCraft y WoWSims. Calaveras BiS, Mapa de equipo, alertas de durabilidad, Equipar Automático, Vender Chatarra.",
        frFR = "Score d'amélioration par spécialisation issu des guides Icy Veins, sur les infobulles (avec les infos de vos rerolls), le butin, les récompenses de quête et les sacs. Procs, bijoux et bonus d'ensemble simulés par SimulationCraft et WoWSims. Têtes de mort BiS, Carte d'équipement, alertes de durabilité, équipement automatique, vente de la camelote.",
        itIT = "Punteggio dei miglioramenti per specializzazione basato sulle guide di Icy Veins su suggerimenti (anche con le informazioni degli alt), bottino, ricompense delle missioni e borse. Proc, monili e bonus del set simulati da SimulationCraft e WoWSims. Teschi BiS, Mappa dell'equipaggiamento, avvisi di durabilità, equipaggiamento automatico, vendita della cianfrusaglia.",
        koKR = "Icy Veins 가이드에 기반한 전문화별 업그레이드 점수를 도구 설명(부캐 정보 포함), 전리품, 퀘스트 보상, 가방에서 보여줍니다. 장착 효과, 장신구, 세트 효과는 SimulationCraft와 WoWSims로 시뮬레이션됩니다. BiS 해골 표식, 장비 지도, 내구도 알림, 자동 착용, 잡템 판매.",
        ptBR = "Pontuação de melhoria por especialização com base nos guias do Icy Veins em dicas de ferramenta (com informações dos alts), saques, recompensas de missão e bolsas. Procs, berloques e bônus de conjunto simulados pelo SimulationCraft e WoWSims. Caveiras BiS, Mapa de equipamento, alertas de durabilidade, Equipar Automático, Vender Bugigangas.",
        ruRU = "Оценка улучшений для каждой специализации на основе руководств Icy Veins в подсказках (в том числе со сведениями об альтах), добыче, наградах за задания и сумках. Прок-эффекты, аксессуары и бонусы комплектов просимулированы в SimulationCraft и WoWSims. Черепа BiS, карта снаряжения, оповещения о прочности, автоэкипировка, продажа хлама.",
        zhCN = "基于 Icy Veins 攻略的专精升级评分，覆盖鼠标提示（含小号信息）、战利品、任务奖励和背包。触发特效、饰品与套装效果由 SimulationCraft 和 WoWSims 模拟。BiS 骷髅标记、装备地图、耐久提醒、自动装备、自动出售垃圾。",
        zhTW = "以 Icy Veins 指南為依據的每專精升級評分，套用於工具提示（含分身資訊）、戰利品、任務獎勵與背包。觸發效果、飾品與套裝效果由 SimulationCraft 與 WoWSims 模擬。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
    -- lib.localeOverride lets a host addon's language override (a test mode,
    -- or any future user-facing language picker) reach these descriptions;
    -- unset, the client locale decides as always.
    local locale = lib.localeOverride or (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
    -- AceDBOptions picks its language once, from the client locale at file
    -- load, so a host addon language override cannot reach it. When the
    -- caller supplies p_* strings (harvested from AceDBOptions' own
    -- translations) overlay them onto the generated controls.
    if locale and group.args then
        local a = group.args
        local function ov(node, field, key, prefix, suffix)
            if not node then return end
            local v = locale[key]
            if type(v) == "string" then node[field] = (prefix or "") .. v .. (suffix or "") end
        end
        if type(locale.p_profiles) == "string" then group.name = locale.p_profiles end
        ov(a.desc, "name", "p_intro", nil, "\n")
        ov(a.descreset, "name", "p_reset_desc")
        ov(a.reset, "name", "p_reset")
        ov(a.reset, "desc", "p_reset_sub")
        ov(a.choosedesc, "name", "p_choose_desc", "\n")
        ov(a.new, "name", "p_new")
        ov(a.new, "desc", "p_new_sub")
        ov(a.choose, "name", "p_choose")
        ov(a.choose, "desc", "p_choose_sub")
        ov(a.copydesc, "name", "p_copy_desc", "\n")
        ov(a.copyfrom, "name", "p_copy")
        ov(a.copyfrom, "desc", "p_copy_desc")
        ov(a.deldesc, "name", "p_delete_desc", "\n")
        ov(a.delete, "name", "p_delete")
        ov(a.delete, "desc", "p_delete_sub")
        if a.delete and type(locale.p_delete_confirm) == "string" then
            a.delete.confirmText = locale.p_delete_confirm
        end
        if a.current and type(locale.p_current) == "string" then
            local cur = locale.p_current
            a.current.name = function(info)
                return cur .. " " .. NORMAL_FONT_COLOR_CODE .. info.handler:GetCurrentProfile() .. FONT_COLOR_CODE_CLOSE
            end
        end
    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 Display Duration", 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

    if spec.cooldown then
        args.cooldown = {
            type = "range", width = W, order = 10,
            name = names.cooldown or "Alert cooldown (seconds)",
            desc = names.cooldownDesc or "Minimum time between two alerts of the same kind. Triggers inside the cooldown are skipped.",
            min = spec.cooldownMin or 0, max = spec.cooldownMax or 120, step = spec.cooldownStep or 1,
            get = spec.cooldown.get, set = spec.cooldown.set,
        }
    end

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

    return args
end

local function toggleNode(entry, order, width, name, desc)
    if not entry then return nil end
    return {
        type = "toggle", width = width, order = order,
        name = name, desc = desc,
        get = entry.get, set = entry.set,
        disabled = entry.disabled,
    }
end

local function rangeNode(entry, order, width, name, desc, dmin, dmax, dstep)
    if not entry then return nil end
    return {
        type = "range", width = width, order = order,
        name = name, desc = desc,
        min = entry.min or dmin, max = entry.max or dmax, step = entry.step or dstep,
        isPercent = entry.isPercent,
        get = entry.get, set = entry.set,
        disabled = entry.disabled,
    }
end

function lib:BuildMinimapControls(spec)
    spec = spec or {}
    local names = spec.names or {}
    local W = spec.controlWidth or "full"
    local base = spec.order or 20
    local args = {}
    args.showMinimap = toggleNode(spec.enabled, base,
        W, names.showMinimap or "Show Minimap Button",
        names.showMinimapDesc or "Show this addon's button on the minimap. Left-click opens the main window, right-click opens options.")
    args.lockMinimap = toggleNode(spec.locked, base + 1,
        W, names.lockMinimap or "Lock Minimap Button",
        names.lockMinimapDesc or "Keep the minimap button where it is instead of letting it be dragged around the minimap.")
    return args
end

function lib:BuildFrameControls(spec)
    spec = spec or {}
    local names = spec.names or {}
    local W = spec.controlWidth or "full"
    local base = spec.order or 30
    local frameName = spec.frameName or "Window"
    local args = {}
    args.showFrame = toggleNode(spec.show, base,
        W, names.showFrame or ("Show " .. frameName),
        names.showFrameDesc or ("Show the " .. frameName .. " on screen."))
    args.lockFrame = toggleNode(spec.locked, base + 1,
        W, names.lockFrame or "Lock Position",
        names.lockFrameDesc or ("Keep the " .. frameName .. " where it is instead of letting it be dragged."))
    args.frameScale = rangeNode(spec.scale, base + 2, W,
        names.frameScale or "Scale",
        names.frameScaleDesc or ("Size of the " .. frameName .. " relative to the default."),
        0.5, 2, 0.05)
    if args.frameScale and args.frameScale.isPercent == nil then args.frameScale.isPercent = true end
    args.frameAlpha = rangeNode(spec.alpha, base + 3, W,
        names.frameAlpha or "Opacity",
        names.frameAlphaDesc or ("How see-through the " .. frameName .. " is."),
        0.2, 1, 0.05)
    if args.frameAlpha and args.frameAlpha.isPercent == nil then args.frameAlpha.isPercent = true end
    if spec.resetPosition then
        args.resetFramePosition = {
            type = "execute", width = spec.buttonWidth or "300", order = base + 4,
            name = names.resetFramePosition or "Reset Position",
            desc = names.resetFramePositionDesc or ("Move the " .. frameName .. " back to its default spot."),
            func = spec.resetPosition,
        }
    end
    return args
end

function lib:BuildAlertIconControls(spec)
    spec = spec or {}
    local names = spec.names or {}
    local W = spec.controlWidth or "full"
    local base = spec.order or 40
    local args = {}
    args.showIcon = toggleNode(spec.enabled, base,
        W, names.showIcon or "Show Alert Icon",
        names.showIconDesc or "Show an icon next to the alert text.")
    if spec.icon and type(spec.icon.options) == "table" then
        local values, sorting = {}, {}
        for _, option in ipairs(spec.icon.options) do
            local label = option.label or tostring(option.value)
            if option.texture then
                label = "|T" .. option.texture .. ":16:16|t " .. label
            end
            values[option.value] = label
            sorting[#sorting + 1] = option.value
        end
        args.alertIcon = {
            type = "select", width = W, order = base + 1,
            name = names.alertIcon or "Alert Icon",
            desc = names.alertIconDesc or "Which icon the alert shows.",
            values = values, sorting = sorting,
            get = spec.icon.get, set = spec.icon.set,
            disabled = spec.icon.disabled,
        }
    end
    args.iconSize = rangeNode(spec.size, base + 2, W,
        names.iconSize or "Icon Size",
        names.iconSizeDesc or "Size of the alert icon in pixels.",
        12, 64, 1)
    return args
end

function lib:BuildAnimationControls(spec)
    spec = spec or {}
    local names = spec.names or {}
    local W = spec.controlWidth or "full"
    local base = spec.order or 50
    local args = {}
    args.animateIcon = toggleNode(spec.iconPop, base,
        W, names.animateIcon or "Animate Alert Icon",
        names.animateIconDesc or "Pop the alert icon in with a short bounce when the alert appears.")
    args.iconPopScale = rangeNode(spec.iconPopScale, base + 1, W,
        names.iconPopScale or "Icon Pop Scale",
        names.iconPopScaleDesc or "How large the icon grows at the peak of its pop, relative to its normal size.",
        1, 2, 0.1)
    args.spawnStagger = rangeNode(spec.stagger, base + 2, W,
        names.spawnStagger or "Stagger (seconds)",
        names.spawnStaggerDesc or "Delay between alerts that fire at the same moment, so they cascade instead of overlapping.",
        0, 1, 0.05)
    return args
end

function lib:BuildTooltipControls(spec)
    spec = spec or {}
    local names = spec.names or {}
    local W = spec.controlWidth or "full"
    local base = spec.order or 60
    local args = {}
    args.tooltipEnabled = toggleNode(spec.enabled, base,
        W, names.tooltipEnabled or "Show Summary in Tooltip",
        names.tooltipEnabledDesc or "Add this addon's information to item, spell, or unit tooltips.")
    args.tooltipOtherChars = toggleNode(spec.otherChars, base + 1,
        W, names.tooltipOtherChars or "Include Other Characters",
        names.tooltipOtherCharsDesc or "Also list what your other characters know in the tooltip.")
    return args
end

local SOUND_RATE_LIMITS = {
    { value = 0, label = "No limit" },
    { value = 2, label = "2 seconds" },
    { value = 10, label = "10 seconds" },
    { value = 30, label = "30 seconds" },
    { value = 60, label = "1 minute" },
    { value = 300, label = "5 minutes" },
    { value = 600, label = "10 minutes" },
    { value = 900, label = "15 minutes" },
    { value = 1800, label = "30 minutes" },
    { value = 3600, label = "1 hour" },
    { value = 7200, label = "2 hours" },
}
lib.SOUND_RATE_LIMITS = SOUND_RATE_LIMITS

local SOUND_CHANNELS = {
    { value = "Master", label = "Master" },
    { value = "SFX", label = "Sound Effects" },
    { value = "Music", label = "Music" },
    { value = "Ambience", label = "Ambience" },
    { value = "Dialog", label = "Dialog" },
}
lib.SOUND_CHANNELS = SOUND_CHANNELS

local function selectFrom(list)
    local values, sorting = {}, {}
    for _, entry in ipairs(list) do
        values[entry.value] = entry.label
        sorting[#sorting + 1] = entry.value
    end
    return values, sorting
end

function lib:BuildSoundExtrasControls(spec)
    spec = spec or {}
    local names = spec.names or {}
    local W = spec.controlWidth or "full"
    local base = spec.order or 70
    local args = {}
    if spec.channel then
        local values, sorting = selectFrom(spec.channel.options or SOUND_CHANNELS)
        args.soundChannel = {
            type = "select", width = W, order = base,
            name = names.soundChannel or "Sound Channel",
            desc = names.soundChannelDesc or "Which audio channel alert sounds play on. Master ignores the in-game effects volume slider.",
            values = values, sorting = sorting,
            get = spec.channel.get, set = spec.channel.set,
            disabled = spec.channel.disabled,
        }
    end
    if spec.rateLimit then
        local values, sorting = selectFrom(spec.rateLimit.options or SOUND_RATE_LIMITS)
        args.soundRateLimit = {
            type = "select", width = W, order = base + 1,
            name = names.soundRateLimit or "Sound Rate Limit",
            desc = names.soundRateLimitDesc or "Shortest gap between two alert sounds. Alerts still show, only the sound is skipped.",
            values = values, sorting = sorting,
            get = spec.rateLimit.get, set = spec.rateLimit.set,
            disabled = spec.rateLimit.disabled,
        }
    end
    args.soundOncePerLogin = toggleNode(spec.oncePerLogin, base + 2,
        W, names.soundOncePerLogin or "Play Sound Once Per Login",
        names.soundOncePerLoginDesc or "After the first alert sound of a session, later alerts stay silent until you log in again.")
    args.soundInCombat = toggleNode(spec.inCombat, base + 3,
        W, names.soundInCombat or "Play Sounds In Combat",
        names.soundInCombatDesc or "Turn off to keep alert sounds quiet while you are in combat.")
    return args
end

function lib:BuildGeneralExtrasControls(spec)
    spec = spec or {}
    local names = spec.names or {}
    local W = spec.controlWidth or "full"
    local base = spec.order or 80
    local args = {}
    args.loginBanner = toggleNode(spec.loginBanner, base,
        W, names.loginBanner or "Show Login Message",
        names.loginBannerDesc or "Print the addon's loaded message in chat after you log in.")
    args.updateNotify = toggleNode(spec.updateNotify, base + 1,
        W, names.updateNotify or "Notify Me About Updates",
        names.updateNotifyDesc or "Tell you in chat when a newer version is seen on another player.")
    args.chatNotifications = toggleNode(spec.chatNotifications, base + 2,
        W, names.chatNotifications or "Chat Notifications",
        names.chatNotificationsDesc or "Also print each alert to the chat window.")
    args.debug = toggleNode(spec.debug, base + 3,
        W, names.debug or "Debug Mode",
        names.debugDesc or "Print extra diagnostic lines to chat. Leave off unless asked for a bug report.")
    return args
end

function lib:ImageOptionValues(options, size)
    local values, sorting = {}, {}
    for _, option in ipairs(options or {}) do
        local label = option.label or tostring(option.value)
        local px = option.size or size or 18
        if option.texture then
            label = ("|T%s:%d:%d|t "):format(option.texture, px, px) .. label
        end
        if type(option.textures) == "table" then
            local prefix = ""
            for _, tex in ipairs(option.textures) do
                prefix = prefix .. ("|T%s:%d:%d|t "):format(tex, px, px)
            end
            label = prefix .. label
        end
        values[option.value] = label
        sorting[#sorting + 1] = option.value
    end
    return values, sorting
end

function lib:BuildImageSelect(spec)
    spec = spec or {}
    if not (spec.get and spec.set and type(spec.options) == "table") then
        return nil
    end
    local values, sorting = self:ImageOptionValues(spec.options, spec.imageSize)
    return {
        type = "select", width = spec.width or "normal", order = spec.order or 1,
        name = spec.name or "Style",
        desc = spec.desc,
        values = values, sorting = sorting,
        get = spec.get, set = spec.set,
        disabled = spec.disabled,
    }
end

function lib:BuildMarkerControls(spec)
    spec = spec or {}
    local names = spec.names or {}
    local W = spec.controlWidth or "normal"
    local base = spec.order or 90
    local markers = fetchLib("LibMatic-MarkerPack-1.0")
    local addon = spec.addon
    local args = {}
    if not (markers and addon) then
        return args
    end
    if spec.indicatorStyle then
        local values, sorting = markers:IndicatorOptionValues(addon, spec.embed, names.indicatorStyles)
        args.indicatorStyle = {
            type = "select", width = W, order = base,
            name = names.indicatorStyle or "Indicator style",
            desc = names.indicatorStyleDesc or "How upgrade and downgrade markers look everywhere: double chevrons or arrows.",
            values = values, sorting = sorting,
            get = spec.indicatorStyle.get, set = spec.indicatorStyle.set,
            disabled = spec.indicatorStyle.disabled,
        }
    end
    if spec.skullStyle then
        local values, sorting = markers:SkullOptionValues(addon, spec.embed, names.skullStyles)
        args.skullStyle = {
            type = "select", width = W, order = base + 1,
            name = names.skullStyle or "Skull style",
            desc = names.skullStyleDesc or "Which skull marks the best item everywhere: the fanged red-eyed skull, the grim cracked skull, or the classic skull.",
            values = values, sorting = sorting,
            get = spec.skullStyle.get, set = spec.skullStyle.set,
            disabled = spec.skullStyle.disabled,
        }
    end
    args.markerSize = rangeNode(spec.size, base + 2, W,
        names.markerSize or "Marker size",
        names.markerSizeDesc or "Size of the upgrade, downgrade and skull markers in pixels.",
        10, 40, 1)
    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
