//! The one *declarative* configuration model: loading, reconciliation, import,
//! export and the running server all speak this struct, and it is the whole
//! shape of a config file. It is not the only shape the repositories accept —
//! the API edits rows one at a time through narrower inputs such as
//! `RuleInput`, `ClientInput` and `ClientEdit`, so a field added here does not
//! reach those paths by itself.
//!
//! Pure: no `std.Io` value is a parameter anywhere, no SQLite, no clock. The
//! only `std.Io` types that appear are `std.Io.Duration` as a conversion result.
//!
//! Runtime columns are deliberately absent. `clients.first_seen`,
//! `clients.last_seen`, `rules.created_at` and
//! `blocklist_sources.{last_updated, domain_count, wildcard_count,
//! exception_count, skipped_regex_count, skipped_unsupported_count, checksum}`
//! are facts a running server produces, not configuration. Including them would make two exports taken
//! minutes apart differ, which would make the byte-stable round trip untestable
//! against a live server.
//!
//! Declarative configuration reaches the database through exactly one path:
//! `config/reconcile.zig`. `nxdns import` is a thin wrapper over it, and so is
//! `run --config`. Reconciliation asks what changed rather than replacing
//! wholesale, so a row the input still names keeps the runtime state attached
//! to it: a source matched by url keeps its id, checksum and counters, a rule
//! keeps its `created_at`, and a client keeps its first-seen and last-seen
//! stamps. The source id and checksum are the two that decide whether a
//! file-mode restart reuses the compiled bodies or downloads them again:
//! `loadSource` names the files after the id and accepts them only against the
//! stored checksum. The counters ride along as reported state.

const std = @import("std");
const Allocator = std.mem.Allocator;

pub const Config = struct {
    upstream: Upstream = .{},
    dns: Dns = .{},
    blocking: Blocking = .{},
    cache: Cache = .{},
    web: Web = .{},
    doh_server: TlsEndpoint = .{},
    dot_server: TlsEndpoint = .{ .port = 853 },
    edns: Edns = .{},
    logging: Logging = .{},
    disk: Disk = .{},
    blocklist_update: BlocklistUpdate = .{},

    groups: []const Group = &.{},
    upstreams: []const UpstreamServer = &.{},
    clients: []const Client = &.{},
    client_prefixes: []const ClientPrefix = &.{},
    blocklist_sources: []const BlocklistSource = &.{},
    group_sources: []const GroupSource = &.{},
    rules: []const Rule = &.{},
    local_records: []const LocalRecord = &.{},
    forward_zones: []const ForwardZone = &.{},
};

pub const Upstream = struct {
    /// Bounds one attempt against one upstream inside the pool's failover loop.
    attempt_timeout_ms: u32 = 2500,
    /// The forward-zone client's whole-exchange budget, and nothing else: one
    /// bound covers the UDP attempt, a TC=1 fallback and the TCP retry
    /// together, not each of them. It bounds a different subsystem from the two
    /// above (`src/local/forward_client.zig`), so no cross-check relates it to
    /// them.
    read_timeout_ms: u32 = 3000,
    /// The whole-exchange budget: every failover attempt together, not one of
    /// them. The pool races the entire loop against it.
    total_timeout_ms: u32 = 5000,
};

pub const Dns = struct {
    bind_ipv4: []const u8 = "0.0.0.0",
    bind_ipv6: []const u8 = "::",
    port: u16 = 53,
    rate_limit: u32 = 1000,
    rate_window_seconds: u32 = 60,
};

pub const BlockResponse = enum {
    zero,
    nxdomain,

    pub fn toDb(self: BlockResponse) []const u8 {
        return switch (self) {
            .zero => "zero",
            .nxdomain => "nxdomain",
        };
    }

    pub fn fromDb(text: []const u8) ?BlockResponse {
        if (std.mem.eql(u8, text, "zero")) return .zero;
        if (std.mem.eql(u8, text, "nxdomain")) return .nxdomain;
        return null;
    }
};

pub const Blocking = struct { response: BlockResponse = .zero, ttl: u32 = 5 };

pub const Cache = struct { size: u32 = 10000, negative_ttl_max: u32 = 3600 };

pub const Web = struct {
    enabled: bool = true,
    bind: []const u8 = "0.0.0.0",
    port: u16 = 8080,
    /// Operator input only. Never a settings row, never exported.
    ///
    /// Optional because absence and emptiness are different declarations: null
    /// means "the file says nothing about the password, keep the stored hash",
    /// while a present value is an instruction to set one.
    password: ?[]const u8 = null,
    /// argon2id PHC string. Null means "the file says nothing, keep what is
    /// stored"; an explicit `""` is the documented way to disable
    /// authentication.
    password_hash: ?[]const u8 = null,
    session_ttl_hours: u16 = 24,
    api_rate_limit_per_min: u32 = 300,
    /// Requests from the box itself skip the API rate limit. On by default: a
    /// local script or health probe is the operator's own traffic, not the
    /// abuse the limiter defends against (PLAN §10).
    api_localhost_exempt: bool = true,
    sse_max_connections_per_ip: u16 = 3,
    /// Comma-separated IP literals. A request arriving from one of these peers
    /// is identified by the last entry of its `X-Forwarded-For` header instead
    /// of by the socket peer, so the API limiter and the per-address SSE cap
    /// bind the real client rather than the proxy. Empty means no proxy is
    /// trusted and the socket peer is always the client. One string rather than
    /// a list because the settings codec stores scalars only.
    trusted_proxies: []const u8 = "",
};

/// Walks `web.trusted_proxies`. The validator and the web server both read the
/// setting, so what counts as one element — comma-separated, surrounding spaces
/// and tabs trimmed — is spelled out once here. An empty setting yields nothing;
/// an empty element (a stray comma) is yielded, so the validator can name it
/// rather than silently drop it.
pub fn trustedProxies(text: []const u8) TrustedProxyIterator {
    return .{ .rest = text, .done = text.len == 0 };
}

pub const TrustedProxyIterator = struct {
    rest: []const u8,
    done: bool,

    pub fn next(self: *TrustedProxyIterator) ?[]const u8 {
        if (self.done) return null;
        const end = std.mem.findScalar(u8, self.rest, ',') orelse {
            const last = self.rest;
            self.done = true;
            return std.mem.trim(u8, last, " \t");
        };
        const element = self.rest[0..end];
        self.rest = self.rest[end + 1 ..];
        return std.mem.trim(u8, element, " \t");
    }
};

pub const TlsEndpoint = struct {
    enabled: bool = false,
    bind: []const u8 = "0.0.0.0",
    port: u16 = 443,
    cert_path: []const u8 = "/etc/nxdns/cert.pem",
    key_path: []const u8 = "/etc/nxdns/key.pem",
};

pub const EcsMode = enum {
    strip,
    forward,

    pub fn toDb(self: EcsMode) []const u8 {
        return switch (self) {
            .strip => "strip",
            .forward => "forward",
        };
    }

    pub fn fromDb(text: []const u8) ?EcsMode {
        if (std.mem.eql(u8, text, "strip")) return .strip;
        if (std.mem.eql(u8, text, "forward")) return .forward;
        return null;
    }
};

pub const Edns = struct { ecs_mode: EcsMode = .strip };

pub const LogLevel = enum {
    err,
    warn,
    info,
    debug,

    /// `.err` stores as "error": that is the operator-facing word, and the Zig
    /// tag cannot be `error` because it is a keyword.
    pub fn toDb(self: LogLevel) []const u8 {
        return switch (self) {
            .err => "error",
            .warn => "warn",
            .info => "info",
            .debug => "debug",
        };
    }

    pub fn fromDb(text: []const u8) ?LogLevel {
        if (std.mem.eql(u8, text, "error")) return .err;
        if (std.mem.eql(u8, text, "warn")) return .warn;
        if (std.mem.eql(u8, text, "info")) return .info;
        if (std.mem.eql(u8, text, "debug")) return .debug;
        return null;
    }
};

pub const LogOutput = enum {
    stderr,
    syslog,
    file,

    pub fn toDb(self: LogOutput) []const u8 {
        return switch (self) {
            .stderr => "stderr",
            .syslog => "syslog",
            .file => "file",
        };
    }

    pub fn fromDb(text: []const u8) ?LogOutput {
        if (std.mem.eql(u8, text, "stderr")) return .stderr;
        if (std.mem.eql(u8, text, "syslog")) return .syslog;
        if (std.mem.eql(u8, text, "file")) return .file;
        return null;
    }
};

pub const Logging = struct {
    level: LogLevel = .info,
    retention_days: u16 = 30,
    query_log_buffer_max: u32 = 10000,
    /// How long the query-log writer gathers entries before it commits them.
    /// `0` does not wait at all: it flushes the entry that woke the writer plus
    /// whatever is already queued.
    query_log_flush_interval_s: u16 = 60,
    hide_domains: bool = false,
    hide_client_ips: bool = false,
    output: LogOutput = .stderr,
    file_path: []const u8 = "/var/log/nxdns/nxdns.log",
    max_size_mb: u32 = 50,
    max_files: u8 = 5,
};

pub const Disk = struct { min_free_mb: u32 = 200, warn_free_mb: u32 = 500 };

pub const BlocklistUpdate = struct { enabled: bool = true, interval_hours: u16 = 24 };

pub const Group = struct { name: []const u8, safe_search: bool = false };

pub const UpstreamServer = struct {
    url: []const u8,
    priority: i32 = 100,
    enabled: bool = true,
    /// DoT only. The DNS name used for SNI and certificate verification while
    /// the connection still dials the URL's host. `std.crypto.Certificate`
    /// matches dNSName SANs only, so a `tls://` upstream written as an IP
    /// literal cannot verify without one. Empty means "verify by the URL host".
    tls_name: []const u8 = "",
};

pub const Client = struct { ip: []const u8, name: []const u8 = "", group: []const u8 = "default" };

pub const ClientPrefix = struct { prefix: []const u8, group: []const u8 = "default", priority: i32 = 100 };

pub const BlocklistSource = struct {
    url: []const u8,
    name: []const u8,
    enabled: bool = true,
    is_suggested: bool = false,
};

pub const GroupSource = struct { group: []const u8, source_url: []const u8 };

/// The three spellings `CHECK(kind IN ('exact','wildcard','regex'))` admits
/// after migration step 4.
pub const RuleKind = enum {
    exact,
    wildcard,
    regex,

    pub fn toDb(self: RuleKind) []const u8 {
        return switch (self) {
            .exact => "exact",
            .wildcard => "wildcard",
            .regex => "regex",
        };
    }

    pub fn fromDb(text: []const u8) ?RuleKind {
        if (std.mem.eql(u8, text, "exact")) return .exact;
        if (std.mem.eql(u8, text, "wildcard")) return .wildcard;
        if (std.mem.eql(u8, text, "regex")) return .regex;
        return null;
    }
};

pub const RuleAction = enum {
    allow,
    block,

    pub fn toDb(self: RuleAction) []const u8 {
        return switch (self) {
            .allow => "allow",
            .block => "block",
        };
    }

    pub fn fromDb(text: []const u8) ?RuleAction {
        if (std.mem.eql(u8, text, "allow")) return .allow;
        if (std.mem.eql(u8, text, "block")) return .block;
        return null;
    }
};

pub const Rule = struct { group: []const u8, pattern: []const u8, kind: RuleKind, action: RuleAction };

/// Tag names are lowercase because ZON enum literals are; the DB text is
/// uppercase because `CHECK(rtype IN ('A','AAAA','CNAME'))` says so.
pub const RecordType = enum {
    a,
    aaaa,
    cname,

    pub fn toDb(self: RecordType) []const u8 {
        return switch (self) {
            .a => "A",
            .aaaa => "AAAA",
            .cname => "CNAME",
        };
    }

    pub fn fromDb(text: []const u8) ?RecordType {
        if (std.mem.eql(u8, text, "A")) return .a;
        if (std.mem.eql(u8, text, "AAAA")) return .aaaa;
        if (std.mem.eql(u8, text, "CNAME")) return .cname;
        return null;
    }
};

pub const LocalRecord = struct { name: []const u8, rtype: RecordType, value: []const u8, ttl: u32 = 300 };

pub const ForwardZone = struct { zone: []const u8, resolver: []const u8 };

// ---------------------------------------------------------------------------
// Unit conversions (S2.4)
// ---------------------------------------------------------------------------

/// Fails the build unless `FieldType`'s maximum times `factor` fits `Dest`.
/// Overflow is made impossible by the types rather than checked at runtime,
/// which is why none of the conversions below can return an error.
pub fn assertFits(comptime FieldType: type, comptime factor: comptime_int, comptime Dest: type) void {
    if (@as(u128, std.math.maxInt(FieldType)) * factor > @as(u128, std.math.maxInt(Dest))) {
        @compileError("unit conversion overflows " ++ @typeName(Dest) ++ ": " ++
            @typeName(FieldType) ++ " times the conversion factor does not fit");
    }
}

comptime {
    assertFits(u32, std.time.ns_per_ms, i96); // timeouts
    assertFits(u16, 3600, i64); // session ttl, update interval
    assertFits(u16, 86400, i64); // retention
    assertFits(u32, 1024 * 1024, u64); // MiB conversions
}

pub fn readTimeout(u: Upstream) std.Io.Duration {
    return .{ .nanoseconds = @as(i96, u.read_timeout_ms) * std.time.ns_per_ms };
}

pub fn attemptTimeout(u: Upstream) std.Io.Duration {
    return .{ .nanoseconds = @as(i96, u.attempt_timeout_ms) * std.time.ns_per_ms };
}

pub fn totalTimeout(u: Upstream) std.Io.Duration {
    return .{ .nanoseconds = @as(i96, u.total_timeout_ms) * std.time.ns_per_ms };
}

pub fn sessionTtlSeconds(w: Web) i64 {
    return @as(i64, w.session_ttl_hours) * 3600;
}

pub fn maxLogBytes(l: Logging) u64 {
    return @as(u64, l.max_size_mb) * 1024 * 1024;
}

pub fn minFreeBytes(d: Disk) u64 {
    return @as(u64, d.min_free_mb) * 1024 * 1024;
}

pub fn warnFreeBytes(d: Disk) u64 {
    return @as(u64, d.warn_free_mb) * 1024 * 1024;
}

pub fn updateIntervalSeconds(b: BlocklistUpdate) i64 {
    return @as(i64, b.interval_hours) * 3600;
}

// ---------------------------------------------------------------------------
// settings(key, value) bridge (S2.3)
// ---------------------------------------------------------------------------

pub const SettingPair = struct { key: []const u8, value: []const u8 };

pub const SettingsError = error{ BadSettingValue, OutOfMemory };

/// The scalar sections are exactly the `Config` fields whose type is a struct;
/// the collections are slices. Deriving the list this way means a new section
/// joins the settings mapping automatically and cannot drift out of it.
fn isScalarSection(comptime T: type) bool {
    return @typeInfo(T) == .@"struct";
}

/// The skip policy splits by direction, because encode and decode need
/// different sets.
///
/// `web.password` is operator input and is skipped both ways: it is hashed into
/// `web.password_hash` and discarded (S2.5).
///
/// `web.password_hash` is skipped on **encode only**. The reconcile engine owns
/// that settings row directly — ruling 4 of milestone 20 makes absence mean
/// "keep the stored hash", which a general encode pass cannot express. Skipping
/// it on decode as well would leave `cfg.web.password_hash` null on every read
/// path, turn `auth.authEnabled` false, and silently open the admin UI.
fn isEncodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
    if (!std.mem.eql(u8, section, "web")) return false;
    return std.mem.eql(u8, field, "password") or std.mem.eql(u8, field, "password_hash");
}

fn isDecodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
    return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
}

fn encodeValue(comptime T: type, value: T, gpa: Allocator) error{OutOfMemory}![]u8 {
    return switch (@typeInfo(T)) {
        .bool => try gpa.dupe(u8, if (value) "true" else "false"),
        .int => try std.fmt.allocPrint(gpa, "{d}", .{value}),
        .@"enum" => try gpa.dupe(u8, value.toDb()),
        .pointer => try gpa.dupe(u8, value),
        else => @compileError("unsupported setting field type " ++ @typeName(T)),
    };
}

/// Decoding an integer uses the field's declared type, so a stored value out of
/// that range is `error.BadSettingValue` and never a truncating cast.
fn decodeValue(comptime T: type, text: []const u8) error{BadSettingValue}!T {
    return switch (@typeInfo(T)) {
        .bool => if (std.mem.eql(u8, text, "true"))
            true
        else if (std.mem.eql(u8, text, "false"))
            false
        else
            error.BadSettingValue,
        .int => std.fmt.parseInt(T, text, 10) catch error.BadSettingValue,
        .@"enum" => T.fromDb(text) orelse error.BadSettingValue,
        .pointer => text,
        // A stored key is a present value, so an optional field decodes to a
        // non-null one; the null stays reserved for the absent key, which never
        // reaches this function at all.
        .optional => |info| try decodeValue(info.child, text),
        else => @compileError("unsupported setting field type " ++ @typeName(T)),
    };
}

/// Frees the `value` of every pair. Keys are comptime strings and are never
/// freed.
pub fn freeSettings(gpa: Allocator, pairs: []const SettingPair) void {
    for (pairs) |pair| gpa.free(pair.value);
}

/// Writes every scalar field of `cfg` as a key/value pair into `out`. Keys are
/// comptime strings (never freed); values are allocated from `gpa` and belong
/// to the caller, which frees them with `freeSettings`. On failure nothing this
/// call appended survives.
pub fn toSettings(cfg: Config, gpa: Allocator, out: *std.ArrayList(SettingPair)) error{OutOfMemory}!void {
    const start = out.items.len;
    errdefer {
        freeSettings(gpa, out.items[start..]);
        out.shrinkRetainingCapacity(start);
    }

    inline for (@typeInfo(Config).@"struct".fields) |section_field| {
        if (comptime isScalarSection(section_field.type)) {
            const section = @field(cfg, section_field.name);
            inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
                if (comptime !isEncodeSkipped(section_field.name, field.name)) {
                    const value = try encodeValue(field.type, @field(section, field.name), gpa);
                    errdefer gpa.free(value);
                    try out.append(gpa, .{ .key = section_field.name ++ "." ++ field.name, .value = value });
                }
            }
        }
    }
}

/// Applies `pairs` onto `cfg`, which the caller has initialized to `.{}`.
/// An absent key keeps the default — that is how a migration adds a setting
/// with no data step. An unknown key is logged at `warn` and counted in
/// `unknown_keys`; it is never an error, because downgrading a binary must not
/// brick a config database.
///
/// String values are borrowed from `pairs`, so `cfg` lives no longer than the
/// storage the pairs point into.
pub fn fromSettings(pairs: []const SettingPair, cfg: *Config, unknown_keys: *usize) SettingsError!void {
    for (pairs) |pair| {
        var matched = false;
        inline for (@typeInfo(Config).@"struct".fields) |section_field| {
            if (comptime isScalarSection(section_field.type)) {
                inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
                    if (comptime !isDecodeSkipped(section_field.name, field.name)) {
                        if (std.mem.eql(u8, pair.key, section_field.name ++ "." ++ field.name)) {
                            @field(@field(cfg, section_field.name), field.name) =
                                try decodeValue(field.type, pair.value);
                            matched = true;
                        }
                    }
                }
            }
        }
        if (!matched) {
            unknown_keys.* += 1;
            std.log.warn("unknown settings key '{s}' ignored", .{pair.key});
        }
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

const testing = std.testing;

/// Every key `toSettings` produces on a default `Config`, sorted. A field added
/// without updating this list breaks the test below, which is the point.
const expected_keys = [_][]const u8{
    "blocking.response",
    "blocking.ttl",
    "blocklist_update.enabled",
    "blocklist_update.interval_hours",
    "cache.negative_ttl_max",
    "cache.size",
    "disk.min_free_mb",
    "disk.warn_free_mb",
    "dns.bind_ipv4",
    "dns.bind_ipv6",
    "dns.port",
    "dns.rate_limit",
    "dns.rate_window_seconds",
    "doh_server.bind",
    "doh_server.cert_path",
    "doh_server.enabled",
    "doh_server.key_path",
    "doh_server.port",
    "dot_server.bind",
    "dot_server.cert_path",
    "dot_server.enabled",
    "dot_server.key_path",
    "dot_server.port",
    "edns.ecs_mode",
    "logging.file_path",
    "logging.hide_client_ips",
    "logging.hide_domains",
    "logging.level",
    "logging.max_files",
    "logging.max_size_mb",
    "logging.output",
    "logging.query_log_buffer_max",
    "logging.query_log_flush_interval_s",
    "logging.retention_days",
    "upstream.attempt_timeout_ms",
    "upstream.read_timeout_ms",
    "upstream.total_timeout_ms",
    "web.api_localhost_exempt",
    "web.api_rate_limit_per_min",
    "web.bind",
    "web.enabled",
    "web.port",
    "web.session_ttl_hours",
    "web.sse_max_connections_per_ip",
    "web.trusted_proxies",
};

fn lessThanKey(_: void, a: SettingPair, b: SettingPair) bool {
    return std.mem.lessThan(u8, a.key, b.key);
}

test "toSettings on a default config produces exactly the expected key list" {
    const gpa = testing.allocator;
    var pairs: std.ArrayList(SettingPair) = .empty;
    defer {
        freeSettings(gpa, pairs.items);
        pairs.deinit(gpa);
    }

    try toSettings(.{}, gpa, &pairs);
    std.mem.sort(SettingPair, pairs.items, {}, lessThanKey);

    try testing.expectEqual(expected_keys.len, pairs.items.len);
    for (expected_keys, pairs.items) |expected, pair| {
        try testing.expectEqualStrings(expected, pair.key);
    }
}

test "toSettings never emits web.password" {
    const gpa = testing.allocator;
    var pairs: std.ArrayList(SettingPair) = .empty;
    defer {
        freeSettings(gpa, pairs.items);
        pairs.deinit(gpa);
    }

    try toSettings(.{ .web = .{ .password = "hunter2" } }, gpa, &pairs);
    for (pairs.items) |pair| {
        try testing.expect(!std.mem.eql(u8, pair.key, "web.password"));
    }
}

test "toSettings and fromSettings round-trip a non-default config" {
    const gpa = testing.allocator;
    const original: Config = .{
        .upstream = .{ .attempt_timeout_ms = 111, .read_timeout_ms = 222, .total_timeout_ms = 333 },
        .dns = .{
            .bind_ipv4 = "127.0.0.1",
            .bind_ipv6 = "::1",
            .port = 5353,
            .rate_limit = 7,
            .rate_window_seconds = 11,
        },
        .blocking = .{ .response = .nxdomain, .ttl = 13 },
        .cache = .{ .size = 17, .negative_ttl_max = 19 },
        .web = .{
            .enabled = false,
            .bind = "10.0.0.1",
            .port = 9090,
            .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def",
            .session_ttl_hours = 23,
            .api_rate_limit_per_min = 29,
            .api_localhost_exempt = false,
            .sse_max_connections_per_ip = 31,
            .trusted_proxies = "10.0.0.9,fd00::9",
        },
        .doh_server = .{
            .enabled = true,
            .bind = "10.0.0.2",
            .port = 4443,
            .cert_path = "/a/cert.pem",
            .key_path = "/a/key.pem",
        },
        .dot_server = .{
            .enabled = true,
            .bind = "10.0.0.3",
            .port = 8853,
            .cert_path = "/b/cert.pem",
            .key_path = "/b/key.pem",
        },
        .edns = .{ .ecs_mode = .forward },
        .logging = .{
            .level = .err,
            .retention_days = 41,
            .query_log_buffer_max = 43,
            .query_log_flush_interval_s = 44,
            .hide_domains = true,
            .hide_client_ips = true,
            .output = .file,
            .file_path = "/var/log/x.log",
            .max_size_mb = 47,
            .max_files = 53,
        },
        .disk = .{ .min_free_mb = 59, .warn_free_mb = 61 },
        .blocklist_update = .{ .enabled = false, .interval_hours = 67 },
    };

    var pairs: std.ArrayList(SettingPair) = .empty;
    defer {
        freeSettings(gpa, pairs.items);
        pairs.deinit(gpa);
    }
    try toSettings(original, gpa, &pairs);

    var restored: Config = .{};
    var unknown: usize = 0;
    try fromSettings(pairs.items, &restored, &unknown);
    try testing.expectEqual(@as(usize, 0), unknown);

    inline for (@typeInfo(Config).@"struct".fields) |section_field| {
        if (comptime isScalarSection(section_field.type)) {
            inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
                if (comptime !isEncodeSkipped(section_field.name, field.name)) {
                    const a = @field(@field(original, section_field.name), field.name);
                    const b = @field(@field(restored, section_field.name), field.name);
                    if (comptime @typeInfo(field.type) == .pointer) {
                        try testing.expectEqualStrings(a, b);
                    } else {
                        try testing.expectEqual(a, b);
                    }
                }
            }
        }
    }
}

test "an unknown settings key is counted and not an error" {
    var cfg: Config = .{};
    var unknown: usize = 0;
    const pairs = [_]SettingPair{
        .{ .key = "dns.port", .value = "5300" },
        .{ .key = "future.setting", .value = "whatever" },
        .{ .key = "web.password", .value = "never a row" },
    };

    try fromSettings(&pairs, &cfg, &unknown);
    try testing.expectEqual(@as(u16, 5300), cfg.dns.port);
    // `web.password` is skipped in both directions, so it counts as unknown.
    try testing.expectEqual(@as(usize, 2), unknown);
}

test "web.password_hash decodes from the settings table but is never encoded" {
    const gpa = testing.allocator;
    var pairs: std.ArrayList(SettingPair) = .empty;
    defer {
        freeSettings(gpa, pairs.items);
        pairs.deinit(gpa);
    }

    // Encode: the reconciler owns that row, so no pass over the model emits it.
    const hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def";
    try toSettings(.{ .web = .{ .password_hash = hash } }, gpa, &pairs);
    for (pairs.items) |pair| {
        try testing.expect(!std.mem.eql(u8, pair.key, "web.password_hash"));
        try testing.expect(!std.mem.eql(u8, pair.key, "web.password"));
    }

    // Decode: every read path still sees the stored hash, or `authEnabled`
    // would read false on a box that has a password set.
    var cfg: Config = .{};
    var unknown: usize = 0;
    const stored = [_]SettingPair{.{ .key = "web.password_hash", .value = hash }};
    try fromSettings(&stored, &cfg, &unknown);
    try testing.expectEqual(@as(usize, 0), unknown);
    try testing.expectEqualStrings(hash, cfg.web.password_hash.?);
}

test "an optional settings field is null when absent and non-null when present" {
    var absent: Config = .{};
    var unknown: usize = 0;
    const other = [_]SettingPair{.{ .key = "dns.port", .value = "5300" }};
    try fromSettings(&other, &absent, &unknown);
    try testing.expectEqual(@as(?[]const u8, null), absent.web.password_hash);

    // An explicit empty string is a present value, not an absent key: it is how
    // a config file disables authentication.
    var empty: Config = .{};
    const disabled = [_]SettingPair{.{ .key = "web.password_hash", .value = "" }};
    try fromSettings(&disabled, &empty, &unknown);
    try testing.expect(empty.web.password_hash != null);
    try testing.expectEqualStrings("", empty.web.password_hash.?);
}

test "a malformed settings value is BadSettingValue" {
    var cfg: Config = .{};
    var unknown: usize = 0;

    const bad_int = [_]SettingPair{.{ .key = "dns.port", .value = "not a number" }};
    try testing.expectError(error.BadSettingValue, fromSettings(&bad_int, &cfg, &unknown));

    // 70000 does not fit u16: an out-of-range value is refused, not truncated.
    const out_of_range = [_]SettingPair{.{ .key = "dns.port", .value = "70000" }};
    try testing.expectError(error.BadSettingValue, fromSettings(&out_of_range, &cfg, &unknown));

    const bad_bool = [_]SettingPair{.{ .key = "web.enabled", .value = "yes" }};
    try testing.expectError(error.BadSettingValue, fromSettings(&bad_bool, &cfg, &unknown));

    const bad_enum = [_]SettingPair{.{ .key = "logging.level", .value = "verbose" }};
    try testing.expectError(error.BadSettingValue, fromSettings(&bad_enum, &cfg, &unknown));
}

test "an absent key keeps the default" {
    var cfg: Config = .{};
    var unknown: usize = 0;
    const pairs = [_]SettingPair{.{ .key = "dns.port", .value = "5300" }};

    try fromSettings(&pairs, &cfg, &unknown);
    try testing.expectEqual(@as(u32, 1000), cfg.dns.rate_limit);
    try testing.expectEqual(LogLevel.info, cfg.logging.level);
}

test "LogLevel.err encodes as error and decodes back" {
    try testing.expectEqualStrings("error", LogLevel.err.toDb());
    try testing.expectEqual(LogLevel.err, LogLevel.fromDb("error").?);
    try testing.expect(LogLevel.fromDb("err") == null);
}

fn expectEnumRoundTrip(comptime E: type) !void {
    inline for (@typeInfo(E).@"enum".fields) |field| {
        const value: E = @enumFromInt(field.value);
        try testing.expectEqual(value, E.fromDb(value.toDb()).?);
    }
    try testing.expect(E.fromDb("nonsense") == null);
    try testing.expect(E.fromDb("") == null);
}

test "every toDb and fromDb enum pair round-trips over all tags" {
    try expectEnumRoundTrip(BlockResponse);
    try expectEnumRoundTrip(EcsMode);
    try expectEnumRoundTrip(LogLevel);
    try expectEnumRoundTrip(LogOutput);
    try expectEnumRoundTrip(RuleKind);
    try expectEnumRoundTrip(RuleAction);
    try expectEnumRoundTrip(RecordType);
}

test "RuleKind carries the third kind through export and import" {
    try testing.expectEqualStrings("regex", RuleKind.regex.toDb());
    try testing.expectEqual(RuleKind.regex, RuleKind.fromDb("regex").?);
    try testing.expect(RuleKind.fromDb("Regex") == null);
}

test "RecordType stores the uppercase DDL spelling" {
    try testing.expectEqualStrings("A", RecordType.a.toDb());
    try testing.expectEqualStrings("AAAA", RecordType.aaaa.toDb());
    try testing.expectEqualStrings("CNAME", RecordType.cname.toDb());
    try testing.expect(RecordType.fromDb("a") == null);
}

test "unit conversions" {
    try testing.expectEqual(
        @as(i96, 2500) * std.time.ns_per_ms,
        attemptTimeout(.{}).nanoseconds,
    );
    try testing.expectEqual(
        @as(i96, 3000) * std.time.ns_per_ms,
        readTimeout(.{}).nanoseconds,
    );
    try testing.expectEqual(
        @as(i96, 5000) * std.time.ns_per_ms,
        totalTimeout(.{}).nanoseconds,
    );
    try testing.expectEqual(@as(i64, 24 * 3600), sessionTtlSeconds(.{}));
    try testing.expectEqual(@as(u64, 50 * 1024 * 1024), maxLogBytes(.{}));
    try testing.expectEqual(@as(u64, 200 * 1024 * 1024), minFreeBytes(.{}));
    try testing.expectEqual(@as(u64, 500 * 1024 * 1024), warnFreeBytes(.{}));
    try testing.expectEqual(@as(i64, 24 * 3600), updateIntervalSeconds(.{}));
}

test "unit conversions at the field maximum do not overflow" {
    const max_upstream: Upstream = .{
        .attempt_timeout_ms = std.math.maxInt(u32),
        .read_timeout_ms = std.math.maxInt(u32),
        .total_timeout_ms = std.math.maxInt(u32),
    };
    try testing.expectEqual(
        @as(i96, std.math.maxInt(u32)) * std.time.ns_per_ms,
        readTimeout(max_upstream).nanoseconds,
    );
    try testing.expectEqual(
        @as(i96, std.math.maxInt(u32)) * std.time.ns_per_ms,
        attemptTimeout(max_upstream).nanoseconds,
    );
    try testing.expectEqual(
        @as(i64, std.math.maxInt(u16)) * 3600,
        sessionTtlSeconds(.{ .session_ttl_hours = std.math.maxInt(u16) }),
    );
    try testing.expectEqual(
        @as(u64, std.math.maxInt(u32)) * 1024 * 1024,
        maxLogBytes(.{ .max_size_mb = std.math.maxInt(u32) }),
    );
}
