// Round-trip + golden tests for the declarative config schema.
//
// The schema is now the single source for both serialize (config_export::serialize →
// writeTable) and parse (parseConfigTable → readInto), so there is no legacy code
// to compare against. What still earns its keep:
//   - read inverse — readInto(writeTable(x)) == x for every section: the schema's
//                    read and write are mutual inverses (catches a field whose read
//                    key != write key, or a lossy codec).
//   - bar golden   — config_export::serialize(probe)["bar"] stays byte-identical to a captured
//                    reference (locks the resolve-and-flatten monitor-override emit).
//   - clamp goldens — pin parse-time range behavior.

#include "config/config_export.h"
#include "config/config_types.h"
#include "config/schema/config_schema.h"
#include "config/schema/config_sections.h"
#include "config/schema/engine.h"
#include "core/input/key_chord.h"
#include "core/toml.h"
#include "scripting/plugin_id.h"

#include <algorithm>
#include <optional>
#include <print>
#include <set>
#include <sstream>
#include <string>

using namespace noctalia::config::schema;

namespace {

  int g_failures = 0;

  void fail(const std::string& message) {
    std::println(stderr, "config_schema_roundtrip: FAIL: {}", message);
    ++g_failures;
  }

  // Mirror of ConfigService::formatToml so serialized output matches exactly.
  std::string formatToml(const toml::table& table) {
    std::ostringstream out;
    out << toml::toml_formatter{
        table, toml::toml_formatter::default_flags & ~toml::format_flags::allow_literal_strings
    };
    return out.str();
  }

  void checkPluginSourceNameValidation() {
    const std::string valid[] = {"official", "my-repo", "team.plugins", "repo_2", "A1"};
    for (const auto& name : valid) {
      if (!isValidPluginSourceName(name)) {
        fail("plugins: rejected valid source name " + name);
      }
    }

    const std::string invalid[] = {"", ".", "..", "../repo", "repo/name", "repo name", "-repo", "_repo"};
    for (const auto& name : invalid) {
      if (isValidPluginSourceName(name)) {
        fail("plugins: accepted invalid source name " + name);
      }
    }

    const toml::table root = toml::parse(R"(
enabled = ["me/hello", "../bad", "missing-slash", "me/foo/bar"]

[[source]]
name = "good-repo"
kind = "git"
location = "https://example.invalid/good"

[[source]]
name = "../bad"
kind = "git"
location = "https://example.invalid/bad"
)");

    PluginsConfig plugins;
    Diagnostics diag;
    readInto(root, plugins, pluginsSchema(), "plugins", diag);
    if (plugins.sources.size() != 1 || plugins.sources[0].name != "good-repo") {
      fail("plugins: schema did not keep only valid source names");
    }
    if (plugins.enabled.size() != 1 || plugins.enabled[0] != "me/hello") {
      fail("plugins: schema did not keep only valid enabled plugin ids");
    }
    bool sawWarning = false;
    bool sawEnabledWarning = false;
    for (const auto& entry : diag.entries) {
      if (entry.severity == Diagnostics::Severity::Warning && entry.path == "plugins.source.name") {
        sawWarning = true;
      }
      if (entry.severity == Diagnostics::Severity::Warning && entry.path == "plugins.enabled") {
        sawEnabledWarning = true;
      }
    }
    if (!sawWarning) {
      fail("plugins: schema did not warn for invalid source name");
    }
    if (!sawEnabledWarning) {
      fail("plugins: schema did not warn for invalid enabled plugin id");
    }
  }

  void checkIdleActionResolution() {
    const IdleBehaviorConfig screenOff{
        .name = "screen-off",
        .enabled = true,
        .timeoutSeconds = 60,
        .action = "screen_off",
        .command = {},
        .resumeCommand = "notify-send resumed",
    };
    const ResolvedIdleBehavior resolvedScreenOff = resolveIdleBehaviorActions(screenOff);
    if (resolvedScreenOff.idleAction.kind != IdleActionKind::ScreenOff) {
      fail("idle: screen_off did not resolve to native screen-off");
    }
    if (resolvedScreenOff.resumeAction.kind != IdleActionKind::ScreenOn) {
      fail("idle: screen_off did not retain native screen-on with a custom resume command");
    }
    if (resolvedScreenOff.resumeCommand != screenOff.resumeCommand) {
      fail("idle: screen_off did not retain its additional resume command");
    }

    const IdleBehaviorConfig custom{
        .name = "custom",
        .enabled = true,
        .timeoutSeconds = 60,
        .action = "command",
        .command = "notify-send idle",
        .resumeCommand = "notify-send resumed",
    };
    const ResolvedIdleBehavior resolvedCustom = resolveIdleBehaviorActions(custom);
    if (resolvedCustom.idleAction.kind != IdleActionKind::Command
        || resolvedCustom.idleAction.command != custom.command) {
      fail("idle: custom command did not resolve to its configured idle command");
    }
    if (resolvedCustom.resumeAction.kind != IdleActionKind::None) {
      fail("idle: custom command gained an implicit native resume action");
    }
    if (resolvedCustom.resumeCommand != custom.resumeCommand) {
      fail("idle: custom command did not retain its configured resume command");
    }
  }

  void checkPluginIdValidation() {
    const std::string valid[] = {"noctalia/screen_recorder", "me/hello", "Team/repo_2", "a/b.c-d"};
    for (const auto& id : valid) {
      if (!scripting::isValidPluginId(id)) {
        fail("plugins: rejected valid plugin id " + id);
      }
      if (!scripting::pluginSubdirFromId(id).has_value()) {
        fail("plugins: did not derive subdir for valid plugin id " + id);
      }
    }

    const std::string invalid[] = {"",           "hello",  "me/",       "/hello", "me/foo/bar", "me/../hello",
                                   "me/foo bar", "../foo", "me/.hidden"};
    for (const auto& id : invalid) {
      if (scripting::isValidPluginId(id)) {
        fail("plugins: accepted invalid plugin id " + id);
      }
      if (scripting::pluginSubdirFromId(id).has_value()) {
        fail("plugins: derived subdir for invalid plugin id " + id);
      }
    }

    // Canonical entry ids gate host construction and state-store scoping, so the
    // shapes that must not slip through are the near-misses: no colon, extra colons,
    // and an empty or malformed entry segment.
    const std::string validEntries[] = {
        "noctalia/screen_recorder:widget", "me/hello:a", "Team/repo_2:entry-1", "a/b.c-d:e.f"
    };
    for (const auto& id : validEntries) {
      if (!scripting::isValidPluginEntryId(id)) {
        fail("plugins: rejected valid entry id " + id);
      }
    }

    const std::string invalidEntries[] = {
        "",          "me/hello",   "me/hello:",   ":widget",          "me/hello:a:b",
        "mehello:a", "me/hello:.", "me/hello:..", "me/hello:wid get", "me/foo/bar:a",
    };
    for (const auto& id : invalidEntries) {
      if (scripting::isValidPluginEntryId(id)) {
        fail("plugins: accepted invalid entry id " + id);
      }
    }
  }

  // A fully-specified bar with a fully-specified monitor override. Every override
  // optional is set so the resolve-and-flatten write round-trips back into the
  // same override on read (a partial override would come back fully resolved).
  BarConfig makeProbeBar() {
    BarConfig bar;
    bar.name = "default";
    bar.position = "bottom";
    bar.enabled = false;
    bar.autoHide = true;
    bar.smartAutoHide = false;
    bar.showOnWorkspaceSwitch = true;
    bar.reserveSpace = false;
    bar.layer = "overlay";
    bar.thickness = 44;
    bar.backgroundOpacity = 0.85f;
    bar.border = colorSpecFromConfigString("#123456");
    bar.borderWidth = 2.0f;
    bar.radius = 18;
    bar.radiusTopLeft = 4;
    bar.radiusTopRight = 6;
    bar.radiusBottomLeft = 8;
    bar.radiusBottomRight = 10;
    bar.concaveEdgeCorners = true;
    bar.marginEnds = 100;
    bar.marginEdge = 5;
    bar.marginOppositeEdge = 12;
    bar.actions = {{"middle", "none"}, {"right", "media toggle"}};
    bar.deadZone.actions = {
        {"left", "exec notify-send bar-left"},
        {"right", "exec notify-send bar-right"},
        {"middle", "exec notify-send bar-middle"},
        {"scroll_up", "exec notify-send bar-scroll-up"},
        {"scroll_down", "exec notify-send bar-scroll-down"},
        {"back", "media previous"},
        {"forward", "media next"},
    };
    bar.padding = 12;
    bar.widgetSpacing = 8;
    bar.shadow = false;
    bar.contactShadow = true;
    bar.panelOverlap = 2;
    bar.capsuleThickness = 0.5f;
    bar.scale = 2.0f;
    bar.fontScale = 1.5f;
    bar.fontWeight = 600;
    bar.fontFamily = "Inter";
    bar.startWidgets = {"launcher"};
    bar.centerWidgets = {"clock", "weather"};
    bar.endWidgets = {"battery"};
    bar.widgetCapsuleDefault = true;
    bar.widgetCapsuleFill = colorSpecFromConfigString("#abcdef");
    bar.widgetCapsuleForeground = colorSpecFromConfigString("#fedcba");
    bar.widgetColor = colorSpecFromConfigString("#0a0b0c");
    bar.widgetIconColor = colorSpecFromConfigString("#0c0b0a");
    bar.widgetCapsulePadding = 16.0f;
    bar.widgetCapsuleRadius = 12.0;
    bar.widgetCapsuleOpacity = 0.9f;
    bar.widgetCapsuleBorderSpecified = true;
    bar.widgetCapsuleBorder = colorSpecFromConfigString("#111213");
    bar.hoverHighlight = false;
    BarCapsuleGroupStyle group;
    group.id = "grp1";
    group.members = {"clock", "weather"};
    group.fill = colorSpecFromConfigString("#222324");
    group.borderSpecified = true;
    group.border = colorSpecFromConfigString("#333435");
    group.foreground = colorSpecFromConfigString("#444546");
    group.padding = 20.0f;
    group.radius = 14.0f;
    group.opacity = 0.8f;
    group.accordion = true;
    group.accordionDirection = BarAccordionDirection::Start;
    group.widgetSpacing = 10;
    bar.widgetCapsuleGroups = {group};

    BarMonitorOverride ovr;
    ovr.match = "DP-1";
    ovr.position = "top";
    ovr.enabled = true;
    ovr.autoHide = false;
    ovr.smartAutoHide = false;
    ovr.showOnWorkspaceSwitch = true;
    ovr.reserveSpace = true;
    ovr.layer = "top";
    ovr.thickness = 50;
    ovr.backgroundOpacity = 0.7f;
    ovr.border = colorSpecFromConfigString("#a1a2a3");
    ovr.borderWidth = 3.0f;
    ovr.radius = 22;
    ovr.radiusTopLeft = 1;
    ovr.radiusTopRight = 2;
    ovr.radiusBottomLeft = 3;
    ovr.radiusBottomRight = 4;
    ovr.concaveEdgeCorners = false;
    ovr.marginEnds = 70;
    ovr.marginEdge = 9;
    ovr.marginOppositeEdge = 4;
    ovr.deadZone.actions = std::unordered_map<std::string, std::string>{
        {"left", "exec notify-send bar-left"},
        {"right", "exec notify-send bar-right"},
    };
    ovr.padding = 11;
    ovr.widgetSpacing = 7;
    ovr.shadow = true;
    ovr.contactShadow = false;
    ovr.panelOverlap = -1;
    ovr.capsuleThickness = 0.25f;
    ovr.scale = 1.5f;
    ovr.fontScale = 1.5f;
    ovr.fontFamily = "Fira Sans";
    ovr.startWidgets = std::vector<std::string>{"tray"};
    ovr.centerWidgets = std::vector<std::string>{"media"};
    ovr.endWidgets = std::vector<std::string>{"volume"};
    ovr.widgetCapsuleDefault = false;
    ovr.widgetCapsuleFill = colorSpecFromConfigString("#b1b2b3");
    ovr.widgetCapsuleBorderSpecified = true;
    ovr.widgetCapsuleBorder = colorSpecFromConfigString("#c1c2c3");
    ovr.widgetCapsuleForeground = colorSpecFromConfigString("#d1d2d3");
    ovr.widgetColor = colorSpecFromConfigString("#e1e2e3");
    ovr.widgetIconColor = colorSpecFromConfigString("#e3e2e1");
    ovr.hoverHighlight = true;
    BarCapsuleGroupStyle ogroup;
    ogroup.id = "ogrp";
    ogroup.members = {"volume"};
    ogroup.fill = colorSpecFromConfigString("#f1f2f3");
    ogroup.borderSpecified = true;
    ogroup.border = colorSpecFromConfigString("#0f0e0d");
    ogroup.foreground = colorSpecFromConfigString("#0c0b0a");
    ogroup.padding = 18.0f;
    ogroup.radius = 9.0f;
    ogroup.opacity = 0.6f;
    ovr.widgetCapsuleGroups = std::vector<BarCapsuleGroupStyle>{ogroup};
    ovr.widgetCapsulePadding = 24.0;
    ovr.widgetCapsuleRadius = 30.0;
    ovr.widgetCapsuleOpacity = 0.5;
    bar.monitorOverrides = {ovr};
    return bar;
  }

  // Build a config whose migrated sections hold non-default values, so parity
  // checks exercise real serialization rather than all-defaults.
  Config makeProbe() {
    Config c;
    c.audio = AudioConfig{true, true, 0.73f, "change.ogg", "notify.ogg"};
    c.weather = WeatherConfig{false, false, 17, "imperial"};
    c.osd.position = "bottom_left";
    c.osd.positionVertical = "top_right";
    c.osd.orientation = "vertical";
    c.osd.scale = 1.4f;
    c.osd.backgroundOpacity = 0.42f;
    c.osd.border = false;
    c.osd.offsetX = 33;
    c.osd.offsetY = 11;
    c.osd.monitors = {"DP-1", "HDMI-A-1"};
    c.osd.kinds.lockKeys = false;
    c.osd.kinds.keyboardLayout = false;
    c.backdrop = BackdropConfig{true, 0.8f, 0.2f};
    c.lockscreen = LockscreenConfig{
        .lockBeforeSuspend = false,
        .blurredDesktop = true,
        .blurIntensity = 0.6f,
        .tintIntensity = 0.25f,
        .monitors = {"DP-1"}
    };
    c.system.monitor.enabled = false;
    c.system.monitor.cpuTempSensorPath = "/sys/class/hwmon/hwmon3/temp1_input";
    c.system.monitor.cpuPollSeconds = 5.0f;
    c.system.monitor.gpuPollSeconds = 4.0f;
    c.system.monitor.memoryPollSeconds = 6.0f;
    c.system.monitor.networkPollSeconds = 7.0f;
    c.system.monitor.diskPollSeconds = 12.0f;
    c.nightlight = NightLightConfig{true, true, 6000, 3500}; // gap satisfied
    c.location.autoLocate = true;
    c.location.address = "Berlin";
    c.location.customSchedule = true;
    c.location.sunset = "20:30";
    c.location.sunrise = "06:15";
    c.location.latitude = 52.52;
    c.location.longitude = 13.405;
    c.notification = NotificationConfig{
        .enableDaemon = false,
        .showAppName = false,
        .showActions = false,
        .position = "bottom_left",
        .layer = "overlay",
        .scale = 1.3f,
        .backgroundOpacity = 0.5f,
        .border = false,
        .offsetX = 12,
        .offsetY = 6,
        .monitors = {"DP-2"},
        .collapseOnDismiss = false,
        .historyRetentionHours = 48,
        .filters = {NotificationFilterConfig{
            .name = "discord",
            .enabled = true,
            .match = "discord",
            .showToast = false,
            .saveHistory = false,
            .playSound = false,
            .allowPermanent = false,
            .allowedUrgencies = {"normal", "critical"},
        }},
    };
    c.dock.enabled = true;
    c.dock.position = DockEdge::Left;
    c.dock.iconSize = 40;
    c.dock.border = colorSpecFromRole(ColorRole::Primary);
    c.dock.borderWidth = 1.5f;
    c.dock.radius = 20;
    c.dock.radiusTopLeft = 10;
    c.dock.radiusTopRight = 12;
    c.dock.radiusBottomLeft = 14;
    c.dock.radiusBottomRight = 16;
    c.dock.launcherPosition = DockLauncherPosition::Start;
    c.dock.pinned = {"firefox.desktop"};
    c.dock.monitors = {"DP-1"};
    c.brightness.enableDdcutil = true;
    c.brightness.ddcutilIgnoreMmids = {"ABC123"};
    c.brightness.monitorOverrides = {
        {"DP-1", BrightnessBackendPreference::Ddcutil, std::nullopt, 7},
        {"eDP-1", std::nullopt, "intel_backlight", std::nullopt},
    };
    c.battery.warningThreshold = 15;
    c.battery.deviceThresholds = {{"BAT0", 10}, {"hidpp:1", 25}};
    c.controlCenter.sidebarMode = ControlCenterSidebarMode::Full;
    c.controlCenter.sidebarSectionMode = ControlCenterSidebarMode::None;
    c.controlCenter.calendarTab.showEventsCard = false;
    c.controlCenter.calendarTab.showWeekNumbers = true;
    c.controlCenter.calendarTab.eventDateFormat = "%Y-%m-%d";
    c.controlCenter.calendarTab.eventTimeFormat = "%I:%M %p";
    c.controlCenter.shortcuts = {{"wifi"}, {"bluetooth"}};
    c.calendar.enabled = true;
    c.calendar.refreshMinutes = 30;
    c.calendar.accounts = {
        {"acc1", "google", "Work", "#ff0000", "", "", "", {}},
        {"acc2",
         "caldav",
         "Home",
         "",
         "custom",
         "https://dav.example.com/remote.php/dav/",
         "user",
         {"personal"},
         CalendarCredentialSource::File,
         "/run/agenix/noctalia-caldav"},
    };
    // Explicit chords so write→read round-trips (empty would emit defaults instead).
    c.keybinds.validate = {*parseKeyChordSpec("Return")};
    c.keybinds.cancel = {*parseKeyChordSpec("Escape")};
    c.keybinds.left = {*parseKeyChordSpec("Left")};
    c.keybinds.right = {*parseKeyChordSpec("Right")};
    c.keybinds.up = {*parseKeyChordSpec("Up")};
    c.keybinds.down = {*parseKeyChordSpec("Down")};
    c.keybinds.tabNext = defaultKeybindSet(KeybindAction::TabNext);
    c.keybinds.tabPrevious = defaultKeybindSet(KeybindAction::TabPrevious);
    c.keybinds.deleteEntry = defaultKeybindSet(KeybindAction::Delete);
    c.keybinds.copy = defaultKeybindSet(KeybindAction::Copy);
    c.keybinds.save = defaultKeybindSet(KeybindAction::Save);
    c.hooks.commands[0] = {"notify-send hi"};
    c.hooks.commands[2] = {"cmd-a", "cmd-b"};
    c.idle.preActionFadeSeconds = 3.0f;
    // Explicit normalized actions so normalizeIdleBehaviorAction is a no-op on read.
    c.idle.behaviors = {
        {"dim", true, 60, "lock", "", "", true},
        {"off", false, 300, "screen_off", "", "", true, 30},
    };
    c.wallpaper.enabled = false;
    c.wallpaper.fillColor = colorSpecFromConfigString("#ff8800");
    c.wallpaper.transitions = {WallpaperTransition::Wipe, WallpaperTransition::Zoom};
    c.wallpaper.transitionDurationMs = 2000.0f;
    c.wallpaper.edgeSmoothness = 0.5f;
    c.wallpaper.directory = "/srv/wallpapers"; // absolute: expandUserPath leaves it unchanged
    c.wallpaper.automation.enabled = true;
    c.wallpaper.automation.intervalSeconds = 30;
    c.wallpaper.automation.order = WallpaperAutomationConfig::Order::Alphabetical;
    c.wallpaper.monitorOverrides = {
        {"DP-1", true, colorSpecFromConfigString("#00ff00"), std::string("/srv/wp1"), std::nullopt, std::nullopt},
    };
    c.accessibility.uiScale = 1.25f;
    c.shell.buttonBorders = false;
    c.shell.fontFamily = "Inter";
    c.shell.lang = "en_US";
    c.shell.timeFormat = "{:%H:%M:%S}";
    c.shell.passwordMaskStyle = PasswordMaskStyle::RandomIcons;
    c.shell.clipboardHistoryMaxEntries = 80;
    c.shell.clipboardAutoPaste = ClipboardAutoPasteMode::CtrlV;
    c.storage.keySource = StorageKeySource::File;
    c.storage.keyFile = "/run/agenix/noctalia-storage-key";
    c.shell.avatarPath = "/home/u/face.png";
    c.shell.settingsWindowTranslucent = true;
    c.shell.animation.speed = 1.5f;
    c.shell.shadow.direction = ShadowDirection::UpLeft;
    c.shell.panel.transparencyMode = PanelTransparencyMode::Glass;
    c.shell.panel.floatingLayer = "top";
    c.shell.panel.launcherPlacement = PanelPlacement::Floating;
    c.shell.launcher.compact = true;
    c.shell.launcher.sortByUsage = false;
    DmenuEntryConfig notifyDmenu;
    notifyDmenu.id = "notify";
    notifyDmenu.exec = std::string("notify-send \"{query}\"");
    notifyDmenu.prefix = std::string("/notify");
    notifyDmenu.label = std::string("Notify");
    notifyDmenu.glyph = std::string("bell");
    notifyDmenu.freeform = true;
    c.shell.launcher.dmenu.entries = {notifyDmenu};
    c.shell.launcher.providerPrefix = ".";
    c.shell.launcher.providers = {
        LauncherProviderConfig{"session", "s", true}, LauncherProviderConfig{"wallpaper", "w"}
    };
    c.shell.keyboardLayout.customLabels = {{"English (US)", "US"}, {"German", "DE"}};
    c.shell.screenCorners.enabled = true;
    c.shell.screenCorners.size = 24;
    c.shell.mpris.blacklist = {"firefox"};
    c.shell.screenshot.directory = "/shots";
    c.shell.screenshot.pipeToCommand = true;
    c.shell.session.actions = {
        SessionPanelActionConfig{
            "lock",
            true,
            std::nullopt,
            std::string("Lock"),
            std::string("lock"),
            SessionActionButtonVariant::Primary,
            parseKeyChordSpec("Ctrl+l"),
        },
        SessionPanelActionConfig{
            "shutdown", false, std::nullopt, std::nullopt, std::nullopt, SessionActionButtonVariant::Destructive,
            std::nullopt
        },
    };
    c.shell.session.power.suspend = "zzz";
    c.shell.session.power.reboot = "sudo -n reboot";
    c.shell.session.power.shutdown = "sudo -n poweroff";
    c.theme.source = PaletteSource::Wallpaper;
    c.theme.builtinPalette = "Tokyo";
    c.theme.mode = ThemeMode::Light;
    c.theme.templates.enableBuiltinTemplates = false;
    c.theme.templates.builtinIds = {"a", "b"};
    c.theme.templates.customColors = {
        {"accent", "#112233", "#112233", "#332211", true}, {"bg", "#000000", "#000000", "#000000", false}
    };
    c.theme.templates.userTemplates = {
        ThemeConfig::UserTemplateConfig{
            "tmpl1",
            true,
            "/in.png",
            ThemeConfig::TemplateInputPathModesConfig{"/d.png", "/l.png"},
            {"/out1", "/out2"},
            "/dyn",
            "compareX",
            {{"c1", "#aabbcc"}},
            "pre",
            "post",
            "kde-color-scheme",
            3,
        },
    };
    c.accessibility.uiScale = 1.25f;
    c.accessibility.highContrast = true;

    c.hotCorners.enabled = true;
    c.hotCorners.topLeft = {.action = "launcher", .command = ""};
    c.hotCorners.bottomRight = {.action = "command", .command = "notify-send corner"};

    // pluginSettings is not part of pluginsSchema ([plugin_settings] is its own root
    // key), so the section round-trip covers sources + enabled + auto_update only.
    c.plugins.sources = {
        {.kind = PluginSourceKind::Git,
         .name = "official",
         .location = "https://github.com/noctalia-dev/official-plugins"},
    };
    c.plugins.enabled = {"noctalia/notes"};
    c.plugins.autoUpdate = PluginAutoUpdateMode::None; // non-default (default is All) so the round-trip exercises it

    c.bars = {makeProbeBar()};
    return c;
  }

  void checkClamps() {
    // sound_volume above the max clamps to 1.0.
    {
      auto t = toml::parse("sound_volume = 2.5");
      AudioConfig a{};
      Diagnostics d;
      readInto(t, a, audioSchema(), "audio", d);
      if (a.soundVolume != 1.0f) {
        fail("audio.sound_volume clamp: expected 1.0");
      }
    }
    // osd.offset_x has a min-only floor at 0.
    {
      auto t = toml::parse("offset_x = -5");
      OsdConfig o{};
      Diagnostics d;
      readInto(t, o, osdSchema(), "osd", d);
      if (o.offsetX != 0) {
        fail("osd.offset_x floor: expected 0");
      }
    }
    // Unknown enum-like string is left untouched on a plain string field (no enum here),
    // so just verify osd.scale below the min clamps up.
    {
      auto t = toml::parse("scale = 0.1");
      OsdConfig o{};
      Diagnostics d;
      readInto(t, o, osdSchema(), "osd", d);
      if (o.scale != 0.5f) {
        fail("osd.scale clamp: expected 0.5");
      }
    }
    // Bar font_scale uses the same lower bound exposed by the Settings slider.
    {
      auto t = toml::parse("font_scale = 0.1");
      BarConfig b{};
      Diagnostics d;
      readInto(t, b, barFieldsSchema(), "bar", d);
      if (b.fontScale != 0.2f) {
        fail("bar.font_scale clamp: expected 0.2");
      }
    }
    // Clipboard history count accepts large text-heavy histories but still has
    // an explicit config ceiling.
    {
      auto t = toml::parse("clipboard_history_max_entries = 25000");
      ShellConfig s{};
      Diagnostics d;
      readInto(t, s, shellSchema(), "shell", d);
      if (s.clipboardHistoryMaxEntries != 10000) {
        fail("shell.clipboard_history_max_entries clamp: expected 10000");
      }
    }
  }

  void checkMonitorFontScaleChangeSet() {
    Config before;
    BarConfig bar;
    bar.name = "default";
    BarMonitorOverride monitor;
    monitor.match = "DP-1";
    bar.monitorOverrides.push_back(monitor);
    before.bars.push_back(bar);

    Config after = before;
    after.bars.front().monitorOverrides.front().fontScale = 1.5F;
    if (!computeConfigChangeSet(before, after).bars) {
      fail("monitor font_scale override did not mark bars changed");
    }
  }

  std::pair<PluginsConfig, Diagnostics> parsePlugins(std::string_view config) {
    PluginsConfig plugins;
    Diagnostics diagnostics;
    const toml::table root = toml::parse(config);
    readInto(root, plugins, pluginsSchema(), "plugins", diagnostics);
    return {std::move(plugins), std::move(diagnostics)};
  }

  void checkPluginAutoUpdateMode() {
    const auto erroredOnAutoUpdate = [](const Diagnostics& diag) {
      return std::ranges::any_of(diag.entries, [](const auto& entry) {
        return entry.severity == Diagnostics::Severity::Error && entry.path == "plugins.auto_update";
      });
    };

    // Cases: config snippet, expected mode
    const auto cases = {
        std::pair{"auto_update = \"all\"", PluginAutoUpdateMode::All},
        std::pair{"auto_update = \"official\"", PluginAutoUpdateMode::Official},
        std::pair{"auto_update = \"none\"", PluginAutoUpdateMode::None},
    };
    for (const auto& [text, expected] : cases) {
      const auto [plugins, diag] = parsePlugins(text);
      if (plugins.autoUpdate != expected || diag.hasErrors()) {
        fail(
            std::string("plugins.auto_update: '")
            + text
            + "' should parse to "
            + std::string(enumToKey(kPluginAutoUpdateModes, expected))
        );
      }
    }
    // Unknown strings, unsupported types, and the legacy boolean form error
    // and leave the default in place.
    for (const auto text :
         {"auto_update = \"sometimes\"", "auto_update = 1.5", "auto_update = true", "auto_update = false"}) {
      const auto [plugins, diag] = parsePlugins(text);
      if (plugins.autoUpdate != PluginAutoUpdateMode::All || !erroredOnAutoUpdate(diag)) {
        fail(std::string("plugins.auto_update: '") + text + "' should error and keep the default");
      }
    }
  }

  void checkAutoUpdateScopeSelection() {
    // The official scope matches by name AND location: a user-added source that
    // reuses the "official" name is not the official source.
    const std::vector<PluginSourceConfig> sources = {
        defaultPluginSources()[0], // the official source
        {.kind = PluginSourceKind::Git,
         .name = "community",
         .location = "https://github.com/noctalia-dev/community-plugins"},
        {.kind = PluginSourceKind::Git,
         .name = "disabled",
         .location = "https://example.invalid/disabled",
         .enabled = false},
        {.kind = PluginSourceKind::Path, .name = "local", .location = "/tmp/plugins"},
    };
    const auto expectLocations = [](std::string_view fixtureName, const std::vector<PluginSourceConfig>& fixture,
                                    PluginAutoUpdateMode mode, std::vector<std::string_view> locations) {
      std::vector<std::string_view> selected;
      for (const auto& source : fixture) {
        if (sourceInAutoUpdateScope(source, mode)) {
          selected.push_back(source.location);
        }
      }
      if (!std::ranges::equal(selected, locations)) {
        fail(
            "auto-update scope ("
            + std::string(fixtureName)
            + "): unexpected sources selected for "
            + std::string(enumToKey(kPluginAutoUpdateModes, mode))
        );
      }
    };
    expectLocations("defaults", sources, PluginAutoUpdateMode::None, {});
    expectLocations(
        "defaults", sources, PluginAutoUpdateMode::Official, {"https://github.com/noctalia-dev/official-plugins"}
    );
    // Every enabled git source; disabled and path sources stay out.
    expectLocations(
        "defaults", sources, PluginAutoUpdateMode::All,
        {"https://github.com/noctalia-dev/official-plugins", "https://github.com/noctalia-dev/community-plugins"}
    );
    // A single source reusing the "official" name with an untrusted location is
    // legal config (names must be unique, locations need not), but the location
    // check must keep it out of the official scope.
    const std::vector<PluginSourceConfig> untrustedOfficial = {
        {.kind = PluginSourceKind::Git, .name = "official", .location = "https://example.invalid/untrusted"},
        {.kind = PluginSourceKind::Git,
         .name = "community",
         .location = "https://github.com/noctalia-dev/community-plugins"},
    };
    expectLocations("untrustedOfficial", untrustedOfficial, PluginAutoUpdateMode::Official, {});
    expectLocations(
        "untrustedOfficial", untrustedOfficial, PluginAutoUpdateMode::All,
        {"https://example.invalid/untrusted", "https://github.com/noctalia-dev/community-plugins"}
    );
  }

  void checkDuplicatePluginSourceRejection() {
    const auto erroredOnSource = [](const Diagnostics& diag) {
      return std::ranges::any_of(diag.entries, [](const auto& entry) {
        return entry.severity == Diagnostics::Severity::Error && entry.path == "plugins.source";
      });
    };
    // Legit official source first: the duplicate is dropped, the legit entry kept.
    const auto [legitPlugins, legitDiag] = parsePlugins(R"(
[[source]]
name = "official"
kind = "git"
location = "https://github.com/noctalia-dev/official-plugins"

[[source]]
name = "official"
kind = "git"
location = "https://example.invalid/untrusted"
)");
    if (!erroredOnSource(legitDiag)
        || legitPlugins.sources.size() != 1
        || legitPlugins.sources[0].location != "https://github.com/noctalia-dev/official-plugins") {
      fail("plugins.source: duplicate names must error and keep the first entry");
    }
  }

  void checkCalendarCredentialSourceValidation() {
    const auto parse = [](std::string_view accountConfig) {
      const toml::table table = toml::parse(accountConfig);
      CalendarConfig calendar;
      Diagnostics diagnostics;
      readInto(table, calendar, calendarSchema(), "calendar", diagnostics);
      return diagnostics;
    };

    const Diagnostics valid = parse(R"(
[account.agenix]
type = "caldav"
provider = "custom"
server_url = "https://dav.example.com/"
username = "user"
credential_source = "file"
password_file = "/run/agenix/noctalia-caldav"
)");
    if (valid.hasErrors()) {
      fail("calendar: valid file credential source was rejected");
    }

    const Diagnostics missingFile = parse(R"(
[account.agenix]
type = "caldav"
provider = "icloud"
username = "user"
credential_source = "file"
)");
    if (!missingFile.hasErrors()) {
      fail("calendar: file credential source accepted a missing password_file");
    }

    const Diagnostics conflictingFile = parse(R"(
[account.keyring]
type = "caldav"
provider = "icloud"
username = "user"
credential_source = "secret-service"
password_file = "/run/agenix/noctalia-caldav"
)");
    if (!conflictingFile.hasErrors()) {
      fail("calendar: secret-service credential source accepted password_file");
    }

    const Diagnostics unknownSource = parse(R"(
[account.invalid]
type = "caldav"
provider = "icloud"
username = "user"
credential_source = "automatic"
)");
    if (!unknownSource.hasErrors()) {
      fail("calendar: unknown credential source was not an error");
    }
  }

  void checkPanelFloatingLayerValidation() {
    const auto parse = [](std::string_view panelConfig, ShellConfig& shell) {
      const toml::table table = toml::parse(panelConfig);
      Diagnostics diagnostics;
      readInto(table, shell, shellSchema(), "shell", diagnostics);
      return diagnostics;
    };

    ShellConfig validShell;
    const Diagnostics valid = parse("[panel]\nfloating_layer = \"top\"", validShell);
    if (valid.hasErrors() || validShell.panel.floatingLayer != "top") {
      fail("shell.panel.floating_layer: valid top layer was rejected");
    }

    ShellConfig invalidShell;
    const Diagnostics invalid = parse("[panel]\nfloating_layer = \"bottom\"", invalidShell);
    if (invalidShell.panel.floatingLayer != "overlay") {
      fail("shell.panel.floating_layer: invalid value replaced the overlay default");
    }
    bool sawWarning = false;
    for (const auto& entry : invalid.entries) {
      if (entry.severity == Diagnostics::Severity::Warning && entry.path == "shell.panel.floating_layer") {
        sawWarning = true;
      }
    }
    if (!sawWarning) {
      fail("shell.panel.floating_layer: invalid value did not produce a warning");
    }
  }

  void checkStorageKeySourceValidation() {
    const auto parse = [](std::string_view storageConfig) {
      const toml::table table = toml::parse(storageConfig);
      StorageConfig storage;
      Diagnostics diagnostics;
      readInto(table, storage, storageSchema(), "storage", diagnostics);
      return diagnostics;
    };

    const Diagnostics valid = parse(R"(
key_source = "file"
key_file = "/run/agenix/noctalia-storage-key"
)");
    if (valid.hasErrors()) {
      fail("storage: valid file key source was rejected");
    }

    const Diagnostics missingFile = parse(R"(
key_source = "file"
)");
    if (!missingFile.hasErrors()) {
      fail("storage: file key source accepted a missing key_file");
    }

    const Diagnostics conflictingFile = parse(R"(
key_source = "secret-service"
key_file = "/run/agenix/noctalia-storage-key"
)");
    if (!conflictingFile.hasErrors()) {
      fail("storage: secret-service key source accepted key_file");
    }

    const Diagnostics relativeFile = parse(R"(
key_source = "file"
key_file = "noctalia-storage-key"
)");
    if (!relativeFile.hasErrors()) {
      fail("storage: file key source accepted a relative key_file");
    }

    const Diagnostics unknownSource = parse(R"(
key_source = "automatic"
)");
    if (!unknownSource.hasErrors()) {
      fail("storage: unknown key source was not an error");
    }
    if (!isKnownConfigPath({"storage", "key_source"})
        || !isKnownConfigPath({"storage", "key_file"})
        || isKnownConfigPath({"shell", "clipboard_storage", "key_source"})) {
      fail("storage: canonical config paths were not enforced");
    }
  }

  // color falls back to color_dark then color_light so a single-mode entry survives
  // the name+color keep-predicate and carries a usable comparison color.
  void checkCustomColorFallback() {
    auto root = toml::parse(R"(
[templates.custom_colors]
onlydark = { color_dark = "#111111" }
onlylight = { color_light = "#222222" }
bare = { color = "#333333" }
both = { color_dark = "#444444", color_light = "#555555" }
)");
    ThemeConfig theme{};
    Diagnostics d;
    readInto(root, theme, themeSchema(), "theme", d);

    auto find = [&](std::string_view name) -> const ThemeConfig::TemplateColorConfig* {
      for (const auto& c : theme.templates.customColors)
        if (c.name == name)
          return &c;
      return nullptr;
    };

    const auto* onlydark = find("onlydark");
    if (onlydark == nullptr || onlydark->color != "#111111" || onlydark->color_dark != "#111111") {
      fail("custom_colors onlydark: color should fall back to color_dark");
    }
    const auto* onlylight = find("onlylight");
    if (onlylight == nullptr || onlylight->color != "#222222" || onlylight->color_light != "#222222") {
      fail("custom_colors onlylight: color should fall back to color_light");
    }
    const auto* bare = find("bare");
    if (bare == nullptr || bare->color != "#333333" || !bare->color_dark.empty() || !bare->color_light.empty()) {
      fail("custom_colors bare: color set, no dark/light overrides");
    }
    const auto* both = find("both");
    if (both == nullptr
        || both->color != "#444444"
        || both->color_dark != "#444444"
        || both->color_light != "#555555") {
      fail("custom_colors both: color prefers color_dark, both overrides retained");
    }
  }

  // Template palette files use [config.custom_colors]; export must emit the canonical
  // [theme.templates.custom_colors] table after parse.
  void checkTemplateConfigCustomColorsExport() {
    const auto root = toml::parse(R"(
[config.custom_colors.red]
color = "#FF0000"
blend = true

[config.custom_colors.blue]
color = "#0000FF"
blend = false
)");
    Config config;
    liftTemplateConfigCustomColors(root, config);
    if (config.theme.templates.customColors.size() != 2) {
      fail("config.custom_colors lift: expected two custom colors in config");
    }

    const toml::table exported = config_export::serialize(config);
    const auto* theme = exported["theme"].as_table();
    const auto* templates = theme != nullptr ? (*theme)["templates"].as_table() : nullptr;
    const auto* customColors = templates != nullptr ? (*templates)["custom_colors"].as_table() : nullptr;
    if (customColors == nullptr || !customColors->contains("red") || !customColors->contains("blue")) {
      fail("config.custom_colors lift: export missing theme.templates.custom_colors entries");
    }
  }

} // namespace

int main() {
  checkIdleActionResolution();
  // Captured from the pre-refactor config_export::serialize for the fully-specified probe
  // bar. Pins byte-identical bar serialization across the schema migration: the
  // resolve-and-flatten monitor write and the conditional/optional fields must
  // emit exactly these bytes.
  const char* const kBarGolden =
      R"(order = [ "default" ]

[default]
auto_hide = true
background_opacity = 0.85000002384185791
border = "#123456"
border_width = 2.0
capsule = true
capsule_border = "#111213"
capsule_fill = "#ABCDEF"
capsule_foreground = "#FEDCBA"
capsule_opacity = 0.89999997615814209
capsule_padding = 16.0
capsule_radius = 12.0
capsule_thickness = 0.5
center = [ "clock", "weather" ]
color = "#0A0B0C"
concave_edge_corners = true
contact_shadow = true
enabled = false
end = [ "battery" ]
font_family = "Inter"
font_scale = 1.5
font_weight = 600
hover_highlight = false
icon_color = "#0C0B0A"
layer = "overlay"
margin_edge = 5
margin_ends = 100
margin_opposite_edge = 12
padding = 12
panel_overlap = 2
position = "bottom"
radius = 18
radius_bottom_left = 8
radius_bottom_right = 10
radius_top_left = 4
radius_top_right = 6
reserve_space = false
scale = 2.0
shadow = false
show_on_workspace_switch = true
smart_auto_hide = false
start = [ "launcher" ]
thickness = 44
widget_spacing = 8

    [default.actions]
    middle = "none"
    right = "media toggle"

    [default.dead_zone.actions]
    back = "media previous"
    forward = "media next"
    left = "exec notify-send bar-left"
    middle = "exec notify-send bar-middle"
    right = "exec notify-send bar-right"
    scroll_down = "exec notify-send bar-scroll-down"
    scroll_up = "exec notify-send bar-scroll-up"

    [default.monitor.DP-1]
    auto_hide = false
    background_opacity = 0.69999998807907104
    border = "#A1A2A3"
    border_width = 3.0
    capsule = false
    capsule_border = "#C1C2C3"
    capsule_fill = "#B1B2B3"
    capsule_foreground = "#D1D2D3"
    capsule_opacity = 0.5
    capsule_padding = 24.0
    capsule_radius = 30.0
    capsule_thickness = 0.25
    center = [ "media" ]
    color = "#E1E2E3"
    concave_edge_corners = false
    contact_shadow = false
    enabled = true
    end = [ "volume" ]
    font_family = "Fira Sans"
    font_scale = 1.5
    font_weight = 600
    hover_highlight = true
    icon_color = "#E3E2E1"
    layer = "top"
    margin_edge = 9
    margin_ends = 70
    margin_opposite_edge = 4
    match = "DP-1"
    padding = 11
    panel_overlap = -1
    position = "top"
    radius = 22
    radius_bottom_left = 3
    radius_bottom_right = 4
    radius_top_left = 1
    radius_top_right = 2
    reserve_space = true
    scale = 1.5
    shadow = true
    show_on_workspace_switch = true
    smart_auto_hide = false
    start = [ "tray" ]
    thickness = 50
    widget_spacing = 7

        [default.monitor.DP-1.actions]
        middle = "none"
        right = "media toggle"

        [default.monitor.DP-1.dead_zone.actions]
        left = "exec notify-send bar-left"
        right = "exec notify-send bar-right"

        [[default.monitor.DP-1.capsule_group]]
        accordion = false
        accordion_direction = "end"
        border = "#0F0E0D"
        enabled = true
        fill = "#F1F2F3"
        foreground = "#0C0B0A"
        id = "ogrp"
        members = [ "volume" ]
        opacity = 0.60000002384185791
        padding = 18.0
        radius = 9.0

    [[default.capsule_group]]
    accordion = true
    accordion_direction = "start"
    border = "#333435"
    enabled = true
    fill = "#222324"
    foreground = "#444546"
    id = "grp1"
    members = [ "clock", "weather" ]
    opacity = 0.80000001192092896
    padding = 20.0
    radius = 14.0
    widget_spacing = 10)";

  const Config probe = makeProbe();
  const toml::table serialized = config_export::serialize(probe);

  {
    Config pluginMapProbe;
    pluginMapProbe.plugins.pluginSettings["me/display-output"]["output_glyphs"] =
        WidgetSettingStringMap{{"eDP-1", "laptop"}, {"DP-1", "monitor"}};
    const toml::table pluginMapSerialized = config_export::serialize(pluginMapProbe);
    const auto* outputGlyphs = pluginMapSerialized["plugin_settings"]["me/display-output"]["output_glyphs"].as_table();
    if (outputGlyphs == nullptr
        || (*outputGlyphs)["eDP-1"].value<std::string>() != std::optional<std::string>{"laptop"}
        || (*outputGlyphs)["DP-1"].value<std::string>() != std::optional<std::string>{"monitor"}) {
      fail("plugin string-map setting did not serialize as a TOML table");
    }
  }

  // Bar: write parity against the captured golden, plus read-inverse via the
  // schemas (reconstructing the bar exactly as config_service does).
  {
    const std::string fresh = formatToml(*serialized["bar"].as_table());
    if (fresh != kBarGolden) {
      fail(
          "bar: serialization drifted from golden\n--- golden ---\n"
          + std::string(kBarGolden)
          + "\n--- fresh ---\n"
          + fresh
      );
    }
  }
  {
    const auto* barTbl = serialized["bar"]["default"].as_table();
    BarConfig rt;
    rt.name = "default";
    Diagnostics diag;
    if (auto v = (*barTbl)["position"].value<std::string>()) {
      rt.position = *v;
    }
    readInto(*barTbl, rt, barFieldsSchema(), "bar.default", diag);
    if (const auto* monMap = (*barTbl)["monitor"].as_table()) {
      for (const auto& [monName, monNode] : *monMap) {
        if (const auto* monTbl = monNode.as_table()) {
          BarMonitorOverride ovr;
          ovr.match = std::string(monName.str());
          readInto(*monTbl, ovr, barMonitorOverrideSchema(), "bar.default.monitor", diag);
          rt.monitorOverrides.push_back(std::move(ovr));
        }
      }
    }
    if (!(rt == probe.bars[0])) {
      fail("bar: read inverse did not reconstruct the original bar (incl. monitor override)");
    }
  }

  // Every schema-backed section must round-trip, AND the probe must actually populate
  // it. Iterating the section registry rather than a hand-written list means a new
  // section is covered the moment it is declared — and fails here until its probe
  // values are filled in.
  {
    const Config defaults;
    for (const SectionSpec& spec : sections()) {
      const std::string name(spec.name);
      if (spec.sectionEqual(probe, defaults)) {
        fail(name + ": makeProbe leaves this section at its defaults — populate it, or the round-trip is vacuous");
        continue;
      }
      const auto* sectionTbl = serialized[spec.name].as_table();
      if (sectionTbl == nullptr) {
        fail(name + ": config_export::serialize emitted no [" + name + "] table");
        continue;
      }
      Config roundtrip;
      Diagnostics diag;
      spec.read(*sectionTbl, roundtrip, diag);
      if (!spec.sectionEqual(roundtrip, probe)) {
        fail(name + ": read inverse did not reconstruct the original section");
      }
    }
  }

  // Section names must be unique across the registry and the custom root keys, or a
  // lookup would silently resolve to the wrong handler.
  {
    std::set<std::string_view> seen;
    for (const SectionSpec& spec : sections()) {
      if (!seen.insert(spec.name).second) {
        fail(std::string(spec.name) + ": duplicate section name in the registry");
      }
    }
    for (const std::string_view key : customRootKeys()) {
      if (!seen.insert(key).second) {
        fail(std::string(key) + ": custom root key collides with a registry section");
      }
    }
  }

  checkPluginIdValidation();
  checkPluginSourceNameValidation();
  checkCalendarCredentialSourceValidation();
  checkStorageKeySourceValidation();
  checkPanelFloatingLayerValidation();
  checkClamps();
  checkMonitorFontScaleChangeSet();
  checkPluginAutoUpdateMode();
  checkAutoUpdateScopeSelection();
  checkDuplicatePluginSourceRejection();
  checkCustomColorFallback();
  checkTemplateConfigCustomColorsExport();

  if (g_failures == 0) {
    std::println("config_schema_roundtrip: all checks passed");
    return 0;
  }
  std::println(stderr, "config_schema_roundtrip: {} failure(s)", g_failures);
  return 1;
}
