//! Converges `config.db` onto a parsed, validated configuration without wiping
//! it (milestone 20, rulings 3, 4 and 5).
//!
//! The defect this module exists to fix: `import.applyToDb` deletes and
//! reinserts every row, `blocklist_sources` included, and the compiled
//! blocklists are named after the source row id (`<id>.list` / `<id>.wild` /
//! `<id>.allow`). A configuration re-applied on every boot would therefore hand
//! every source a new id, orphan every compiled file, and re-download every
//! blocklist on every restart.
//!
//! So nothing is wiped. Every table has an identity; a row the file and the
//! database agree on is **updated in place**, keeping its row id and every
//! runtime column beside it — checksum, `last_updated`, counters,
//! `first_seen` / `last_seen`, `created_at`. A row the file no longer declares
//! is deleted. A row the file declares and the database lacks is inserted.
//!
//! Two properties are load-bearing, and the tests at the bottom exist for them:
//!
//!   * **Idempotence.** Applying the same configuration twice leaves the
//!     database byte-identical, non-canonical addresses and plaintext passwords
//!     included.
//!   * **Writes only on difference.** A matched row whose declarative columns
//!     already hold the file's values is not written at all, so an unchanged
//!     boot moves `sqlite3_total_changes` by zero and needs no WAL headroom. A
//!     full SD card cannot brick a restart that a wipe-free no-op would have
//!     survived.
//!
//! Pure of `std.Io` except where argon2 needs it. No filesystem access: the
//! caller has already read and validated the file.
//!
//! **The caller decides the commit.** `begin` opens one `BEGIN IMMEDIATE`, runs
//! every pass, and returns a `Pass` with that transaction still open and the
//! `Summary` already filled in. Finishing it is the caller's move:
//! `Pass.commit` or `Pass.rollback`, and one of the two must run.
//!
//! This shape is not a convenience — it is what ruling 6 needs. `nxdns import`
//! refuses a run whose diff would delete rows unless `--allow-delete` says
//! otherwise, and it has to decide that *after* seeing the counts and *inside*
//! the write lock that produced them. An engine that committed on its own would
//! leave the gate reading a database it had already changed, which is the
//! TOCTOU window `BEGIN IMMEDIATE` exists to close:
//!
//! ```zig
//! var pass = try reconcile.begin(io, gpa, database, cfg, now, .{});
//! errdefer pass.rollback();
//! if (!allow_delete and pass.summary.anyDeletes()) {
//!     pass.rollback();
//!     return error.DestructiveImport;
//! }
//! try pass.commit();
//! ```
//!
//! A failure *inside* `begin` needs no cleanup: it rolls its own transaction
//! back and returns the error, so there is no half-open `Pass` to handle.

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

const db = @import("../storage/db.zig");
const config_schema = @import("../storage/config_schema.zig");
const context = @import("../storage/repositories/context.zig");
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const groups_repo = @import("../storage/repositories/groups_repo.zig");
const local_repo = @import("../storage/repositories/local_repo.zig");
const rules_repo = @import("../storage/repositories/rules_repo.zig");
const settings_repo = @import("../storage/repositories/settings_repo.zig");
const sources_repo = @import("../storage/repositories/sources_repo.zig");
const upstreams_repo = @import("../storage/repositories/upstreams_repo.zig");
const address = @import("../platform/address.zig");
const model = @import("model.zig");

const log = std.log.scoped(.config_reconcile);

/// The row id migration step 1 seeds `groups` with. `§7.2`'s fallback
/// assignment and `upsertSeen` both depend on it, so the engine refuses to
/// commit a pass that would move it.
pub const default_group_id: i64 = 1;

/// The name of that row. The engine matches it and never creates it.
pub const default_group_name = "default";

/// The settings key the engine writes directly rather than through
/// `model.toSettings`, and the one key its sweep never removes (ruling 4).
pub const password_hash_key = "web.password_hash";

/// Holds any PHC-encoded argon2id string comfortably (import.zig's number).
const hash_buf_len = 256;

/// The canonical text of an IPv6 prefix, the longest value canonicalised here.
const canonical_buf_len = 64;

pub const Error = db.Error || error{
    BadClientIp,
    BadClientPrefix,
    MissingDefaultGroup,
    PasswordAndHashBothSet,
    Canceled,
};

pub const TableCounts = struct {
    inserted: u32 = 0,
    updated: u32 = 0,
    deleted: u32 = 0,

    pub fn total(self: TableCounts) u32 {
        return self.inserted + self.updated + self.deleted;
    }
};

/// Which way authentication moved, so a change to it is never a silent line
/// item in a count (ruling 4).
pub const AuthTransition = enum { none, enabled, disabled, rotated };

pub const Summary = struct {
    groups: TableCounts = .{},
    sources: TableCounts = .{},
    clients: TableCounts = .{},
    client_prefixes: TableCounts = .{},
    rules: TableCounts = .{},
    group_sources: TableCounts = .{},
    upstreams: TableCounts = .{},
    local_records: TableCounts = .{},
    forward_zones: TableCounts = .{},
    settings: TableCounts = .{},
    auth_transition: AuthTransition = .none,

    /// True when the pass changed nothing at all — the answer an unchanged
    /// configuration must produce.
    pub fn isNoOp(self: Summary) bool {
        return self.totals().total() == 0 and self.auth_transition == .none;
    }

    /// True when any table lost a row. `import`'s diff gate (ruling 6) is this
    /// predicate: reconcile never deletes runtime state, but deleting a
    /// declarative row the file stopped declaring is still destructive.
    pub fn anyDeletes(self: Summary) bool {
        return self.totals().deleted != 0;
    }

    /// The per-table counts added together.
    pub fn totals(self: Summary) TableCounts {
        var sum: TableCounts = .{};
        inline for (@typeInfo(Summary).@"struct".fields) |field| {
            if (field.type == TableCounts) {
                const counts = @field(self, field.name);
                sum.inserted += counts.inserted;
                sum.updated += counts.updated;
                sum.deleted += counts.deleted;
            }
        }
        return sum;
    }
};

pub const Options = struct {
    /// Collects the settings keys this pass wrote or removed, so the startup
    /// summary can name them (ruling 8 — the keys, never the values). Each key
    /// is duplicated into the `gpa` passed to `reconcile`; the caller owns and
    /// frees them. Null collects nothing.
    changed_settings: ?*std.ArrayList([]const u8) = null,
};

/// A finished set of passes whose transaction is still open, and the summary of
/// what they did. Exactly one of `commit` and `rollback` must run.
///
/// Nothing this holds outlives the call that produced it: the `Summary` is
/// plain counts and the `Tx` addresses the caller's own `Db`, so a `Pass` is
/// free to be moved or returned.
pub const Pass = struct {
    tx: db.Tx,
    summary: Summary,

    /// Publishes every write the passes made.
    pub fn commit(self: *Pass) Error!void {
        return self.tx.commit();
    }

    /// Discards every write the passes made, leaving the database
    /// byte-for-byte as it was. Safe to call twice and safe after `commit`, so
    /// it works as an `errdefer` beside an explicit call.
    pub fn rollback(self: *Pass) void {
        self.tx.rollback();
    }
};

/// Converges `database` onto `cfg` inside one `BEGIN IMMEDIATE`, and returns
/// with that transaction **open** — see this file's header for why, and for the
/// `import` diff-gate shape it exists to serve.
///
/// `now` is the caller's wall clock in epoch seconds, used only for the runtime
/// columns a newly inserted row needs (`first_seen`, `last_seen`,
/// `created_at`). No existing row's timestamp is ever restamped.
///
/// On failure the transaction is already rolled back and the database is
/// byte-for-byte as it was; the caller has nothing to finish.
pub fn begin(
    io: std.Io,
    gpa: Allocator,
    database: *db.Db,
    cfg: model.Config,
    now: i64,
    options: Options,
) Error!Pass {
    var tx = try db.Tx.begin(database);
    errdefer tx.rollback();

    const summary = try runPasses(io, gpa, database, cfg, now, options);

    return .{ .tx = tx, .summary = summary };
}

/// Every pass, in order, on a transaction the caller has already opened.
fn runPasses(
    io: std.Io,
    gpa: Allocator,
    database: *db.Db,
    cfg: model.Config,
    now: i64,
    options: Options,
) Error!Summary {
    var arena_state: std.heap.ArenaAllocator = .init(gpa);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    var summary: Summary = .{};
    var doomed: Doomed = .{};

    // --- Phase A: upserts, parents first -----------------------------------
    //
    // The referrers below resolve group names and source urls to the ids these
    // two passes have just settled, so nothing here may be reordered.

    var group_ids: context.IdMap = .empty;
    var source_ids: context.IdMap = .empty;

    try reconcileGroups(database, arena, cfg, &group_ids, &summary.groups, &doomed);
    // `reconcileGroups` has already refused a database whose group 1 is not the
    // seeded `default`, so what is left to catch here is a file that never
    // declares the group at all.
    _ = group_ids.get(default_group_name) orelse return error.MissingDefaultGroup;

    try reconcileSources(database, arena, cfg, &source_ids, &summary.sources, &doomed);

    const ctx: context.InsertContext = .{
        .now = now,
        .group_ids = &group_ids,
        .source_ids = &source_ids,
    };

    try reconcileClients(database, arena, cfg, ctx, &summary.clients, &doomed);
    try reconcileClientPrefixes(database, arena, cfg, ctx, &summary.client_prefixes, &doomed);
    try reconcileRules(database, arena, cfg, ctx, &summary.rules, &doomed);
    try reconcileGroupSources(database, arena, cfg, ctx, &summary.group_sources, &doomed);
    try reconcileUpstreams(database, arena, cfg, ctx, &summary.upstreams, &doomed);
    try reconcileLocalRecords(database, arena, cfg, ctx, &summary.local_records, &doomed);
    try reconcileForwardZones(database, arena, cfg, ctx, &summary.forward_zones, &doomed);
    try reconcileSettings(io, gpa, arena, database, cfg, &summary, &doomed, options);

    // --- Phase B: delete what the file no longer declares, child first ------
    //
    // In `config_schema.delete_order`. Every declarative child of a dying group
    // is itself absent from the file — the validator guarantees a file's rules
    // and prefixes name a file's groups — so the child passes have already
    // deleted and counted those rows by the time the parent goes. The FK
    // cascades on `rules`, `client_prefixes` and `group_sources` are a safety
    // net here, never the accountant: a cascade that fired would remove rows
    // this summary did not count.
    comptime std.debug.assert(config_schema.delete_order.len == 10);

    for (doomed.group_sources.items) |pair| {
        try groups_repo.deleteGroupSourcePair(database, pair);
        summary.group_sources.deleted += 1;
    }
    for (doomed.rules.items) |id| {
        try rules_repo.deleteRule(database, id);
        summary.rules.deleted += 1;
    }
    for (doomed.client_prefixes.items) |id| {
        try clients_repo.deleteClientPrefix(database, id);
        summary.client_prefixes.deleted += 1;
    }
    for (doomed.clients.items) |id| {
        try clients_repo.deleteClient(database, id);
        summary.clients.deleted += 1;
    }
    for (doomed.upstreams.items) |id| {
        try upstreams_repo.deleteUpstream(database, id);
        summary.upstreams.deleted += 1;
    }
    for (doomed.local_records.items) |id| {
        try local_repo.deleteLocalRecord(database, id);
        summary.local_records.deleted += 1;
    }
    for (doomed.forward_zones.items) |id| {
        try local_repo.deleteForwardZone(database, id);
        summary.forward_zones.deleted += 1;
    }
    for (doomed.settings.items) |key| {
        try settings_repo.deleteSetting(database, key);
        summary.settings.deleted += 1;
        try recordSettingsKey(gpa, options, key);
    }
    for (doomed.sources.items) |id| {
        try sources_repo.deleteSource(database, id);
        summary.sources.deleted += 1;
    }

    // Immediately before the groups pass, and only here: `clients.group_id`
    // carries no `ON DELETE` action, so an observed device sitting in a group
    // the file stopped declaring would trip the foreign key mid-transaction —
    // exit 1 and a restart loop, after a `check` that said the file was fine.
    for (doomed.groups.items) |id| {
        const moved = try clients_repo.reassignObservedClients(database, id, default_group_id);
        summary.clients.updated += moved;
    }
    for (doomed.groups.items) |id| {
        try groups_repo.deleteGroup(database, id);
        summary.groups.deleted += 1;
    }

    return summary;
}

/// What Phase A found in the database and the file no longer declares. Every
/// list is arena-owned and holds identities, not rows: Phase B does the
/// deleting, in the order the foreign keys require.
const Doomed = struct {
    groups: std.ArrayList(i64) = .empty,
    sources: std.ArrayList(i64) = .empty,
    clients: std.ArrayList(i64) = .empty,
    client_prefixes: std.ArrayList(i64) = .empty,
    rules: std.ArrayList(i64) = .empty,
    group_sources: std.ArrayList(groups_repo.GroupSourcePair) = .empty,
    upstreams: std.ArrayList(i64) = .empty,
    local_records: std.ArrayList(i64) = .empty,
    forward_zones: std.ArrayList(i64) = .empty,
    settings: std.ArrayList([]const u8) = .empty,
};

// ---------------------------------------------------------------------------
// Phase A, table by table
// ---------------------------------------------------------------------------

fn reconcileGroups(
    database: *db.Db,
    arena: Allocator,
    cfg: model.Config,
    ids: *context.IdMap,
    counts: *TableCounts,
    doomed: *Doomed,
) Error!void {
    const rows = try groups_repo.listGroupRows(database, arena);
    const matched = try matchFlags(arena, rows.items.len);

    // The pinned row is validated before any desired group can claim it.
    // Migration step 1 seeds `(1, 'default')` and the API refuses to rename or
    // delete it, so an absent or renamed group 1 means a hand-edited database.
    // The engine refuses that database rather than reseeding it: recreating the
    // group here would hand the fallback a fresh id on any table whose rowids
    // have moved on, and every unassigned client would follow it silently.
    const pinned = pinnedDefault(rows.items) orelse {
        log.warn(
            "group {d} is absent or is not named '{s}'",
            .{ default_group_id, default_group_name },
        );
        return error.Unexpected;
    };

    for (cfg.groups) |group| {
        // `default` matches the pinned row and nothing else. It is never
        // inserted, whatever the file declares and whatever the table holds.
        const found = if (std.mem.eql(u8, group.name, default_group_name))
            claim(matched, pinned)
        else
            findUnmatched(rows.items, matched, group, matchGroup);

        if (found) |i| {
            const row = rows.items[i];
            if (row.safe_search != group.safe_search) {
                try groups_repo.updateGroup(database, row.id, group);
                counts.updated += 1;
            }
            // The key borrows from `cfg`, which outlives the transaction.
            try ids.put(arena, group.name, row.id);
        } else {
            const id = try groups_repo.insertGroupRow(database, group);
            counts.inserted += 1;
            try ids.put(arena, group.name, id);
        }
    }

    for (rows.items, matched) |row, hit| {
        if (!hit) try doomed.groups.append(arena, row.id);
    }
}

fn matchGroup(row: groups_repo.GroupRow, group: model.Group) bool {
    return std.mem.eql(u8, row.name, group.name);
}

/// The index of the row the migration pinned, or null when group 1 is absent or
/// carries another name.
fn pinnedDefault(rows: []const groups_repo.GroupRow) ?usize {
    for (rows, 0..) |row, i| {
        if (row.id != default_group_id) continue;
        return if (std.mem.eql(u8, row.name, default_group_name)) i else null;
    }
    return null;
}

/// Takes one named row, the way `findUnmatched` takes a searched one. Null when
/// something has claimed it already, which for `default` means the file
/// declared the group twice.
fn claim(matched: []bool, i: usize) ?usize {
    if (matched[i]) return null;
    matched[i] = true;
    return i;
}

fn reconcileSources(
    database: *db.Db,
    arena: Allocator,
    cfg: model.Config,
    ids: *context.IdMap,
    counts: *TableCounts,
    doomed: *Doomed,
) Error!void {
    const rows = try sources_repo.listSourceRows(database, arena);
    const matched = try matchFlags(arena, rows.items.len);

    for (cfg.blocklist_sources) |item| {
        if (findUnmatched(rows.items, matched, item, matchSource)) |i| {
            const row = rows.items[i];
            // `updateSource` writes the four configuration columns only; the
            // checksum, the counters and `last_updated` stay where the refresh
            // path left them, which is the whole point of matching by url.
            if (!std.mem.eql(u8, row.name, item.name) or
                row.enabled != item.enabled or
                row.is_suggested != item.is_suggested)
            {
                try sources_repo.updateSource(database, row.id, item);
                counts.updated += 1;
            }
            try ids.put(arena, item.url, row.id);
        } else {
            const id = try sources_repo.insertSourceRow(database, item);
            counts.inserted += 1;
            try ids.put(arena, item.url, id);
        }
    }

    for (rows.items, matched) |row, hit| {
        if (!hit) try doomed.sources.append(arena, row.id);
    }
}

fn matchSource(row: sources_repo.SourceRow, item: model.BlocklistSource) bool {
    return std.mem.eql(u8, row.url, item.url);
}

/// Clients are the one table where a row can predate the configuration: the DNS
/// path materialises `hand_edited = 0` rows straight from live traffic.
///
///   * An observed row the file says nothing about is kept wholesale and counts
///     nothing.
///   * An observed row whose address the file now declares is **promoted in
///     place**: name, group and the flag are written, `first_seen` and
///     `last_seen` are not, and the row id survives. It counts as an update,
///     because a row was written.
///   * Only a formerly declared row (`hand_edited = 1`) the file dropped is
///     deleted.
fn reconcileClients(
    database: *db.Db,
    arena: Allocator,
    cfg: model.Config,
    ctx: context.InsertContext,
    counts: *TableCounts,
    doomed: *Doomed,
) Error!void {
    const rows = try clients_repo.listClientRows(database, arena);
    const matched = try matchFlags(arena, rows.items.len);

    for (cfg.clients) |client| {
        var buf: [canonical_buf_len]u8 = undefined;
        var canonical = client;
        canonical.ip = try canonicalIp(client.ip, &buf);
        const group_id = try ctx.groupId(client.group);

        if (findUnmatched(rows.items, matched, canonical, matchClient)) |i| {
            const row = rows.items[i];
            if (!std.mem.eql(u8, row.name, client.name) or
                row.group_id != group_id or
                !row.hand_edited)
            {
                try clients_repo.updateClient(database, row.id, .{
                    .name = client.name,
                    .group_id = group_id,
                });
                counts.updated += 1;
            }
        } else {
            try clients_repo.insertClient(database, canonical, ctx);
            counts.inserted += 1;
        }
    }

    for (rows.items, matched) |row, hit| {
        // An observed row is runtime state. The file cannot remove it — it can
        // only declare an address, never un-see one — so absence from the file
        // is not a reason to delete it.
        if (!hit and row.hand_edited) try doomed.clients.append(arena, row.id);
    }
}

fn matchClient(row: clients_repo.ClientRow, client: model.Client) bool {
    return std.mem.eql(u8, row.ip, client.ip);
}

fn reconcileClientPrefixes(
    database: *db.Db,
    arena: Allocator,
    cfg: model.Config,
    ctx: context.InsertContext,
    counts: *TableCounts,
    doomed: *Doomed,
) Error!void {
    const rows = try clients_repo.listClientPrefixRows(database, arena);
    const matched = try matchFlags(arena, rows.items.len);

    for (cfg.client_prefixes) |entry| {
        var buf: [canonical_buf_len]u8 = undefined;
        var canonical = entry;
        canonical.prefix = try canonicalPrefix(entry.prefix, &buf);
        const group_id = try ctx.groupId(entry.group);

        if (findUnmatched(rows.items, matched, canonical, matchPrefix)) |i| {
            const row = rows.items[i];
            if (row.group_id != group_id or row.priority != entry.priority) {
                try clients_repo.updateClientPrefix(database, row.id, .{
                    .prefix = canonical.prefix,
                    .group_id = group_id,
                    .priority = entry.priority,
                });
                counts.updated += 1;
            }
        } else {
            try clients_repo.insertClientPrefix(database, canonical, ctx);
            counts.inserted += 1;
        }
    }

    for (rows.items, matched) |row, hit| {
        if (!hit) try doomed.client_prefixes.append(arena, row.id);
    }
}

fn matchPrefix(row: clients_repo.ClientPrefixRow, entry: model.ClientPrefix) bool {
    return std.mem.eql(u8, row.prefix, entry.prefix);
}

/// `rules` has no natural key — nothing distinguishes two identical rules — so
/// the identity is the whole tuple and the match is a **multiset**: exact
/// duplicates pair off one for one, and a file carrying a rule twice keeps two
/// rows with two `created_at` stamps. `findUnmatched` consuming its match is
/// what makes that work.
fn reconcileRules(
    database: *db.Db,
    arena: Allocator,
    cfg: model.Config,
    ctx: context.InsertContext,
    counts: *TableCounts,
    doomed: *Doomed,
) Error!void {
    const rows = try rules_repo.listRuleRows(database, arena);
    const matched = try matchFlags(arena, rows.items.len);

    for (cfg.rules) |rule| {
        const wanted: rules_repo.RuleInput = .{
            .group_id = try ctx.groupId(rule.group),
            .pattern = rule.pattern,
            .kind = rule.kind,
            .action = rule.action,
        };
        if (findUnmatched(rows.items, matched, wanted, matchRule)) |_| {
            // The tuple *is* the declarative content, so a match is never a
            // write and `created_at` never moves.
        } else {
            _ = try rules_repo.insertRuleRow(database, wanted, ctx.now);
            counts.inserted += 1;
        }
    }

    for (rows.items, matched) |row, hit| {
        if (!hit) try doomed.rules.append(arena, row.id);
    }
}

fn matchRule(row: rules_repo.RuleRow, wanted: rules_repo.RuleInput) bool {
    return row.group_id == wanted.group_id and
        row.kind == wanted.kind and
        row.action == wanted.action and
        std.mem.eql(u8, row.pattern, wanted.pattern);
}

/// The pair is the whole row, so this table has no update case at all.
fn reconcileGroupSources(
    database: *db.Db,
    arena: Allocator,
    cfg: model.Config,
    ctx: context.InsertContext,
    counts: *TableCounts,
    doomed: *Doomed,
) Error!void {
    const rows = try groups_repo.listGroupSourcePairs(database, arena);
    const matched = try matchFlags(arena, rows.items.len);

    for (cfg.group_sources) |item| {
        const wanted: groups_repo.GroupSourcePair = .{
            .group_id = try ctx.groupId(item.group),
            .source_id = try ctx.sourceId(item.source_url),
        };
        if (findUnmatched(rows.items, matched, wanted, matchGroupSource) == null) {
            try groups_repo.insertGroupSource(database, item, ctx);
            counts.inserted += 1;
        }
    }

    for (rows.items, matched) |row, hit| {
        if (!hit) try doomed.group_sources.append(arena, row);
    }
}

fn matchGroupSource(row: groups_repo.GroupSourcePair, wanted: groups_repo.GroupSourcePair) bool {
    return row.group_id == wanted.group_id and row.source_id == wanted.source_id;
}

fn reconcileUpstreams(
    database: *db.Db,
    arena: Allocator,
    cfg: model.Config,
    ctx: context.InsertContext,
    counts: *TableCounts,
    doomed: *Doomed,
) Error!void {
    const rows = try upstreams_repo.listUpstreamRows(database, arena);
    const matched = try matchFlags(arena, rows.items.len);

    for (cfg.upstreams) |item| {
        if (findUnmatched(rows.items, matched, item, matchUpstream)) |i| {
            const row = rows.items[i];
            if (row.priority != item.priority or
                row.enabled != item.enabled or
                !std.mem.eql(u8, row.tls_name, item.tls_name))
            {
                try upstreams_repo.updateUpstream(database, row.id, item);
                counts.updated += 1;
            }
        } else {
            try upstreams_repo.insertUpstream(database, item, ctx);
            counts.inserted += 1;
        }
    }

    for (rows.items, matched) |row, hit| {
        if (!hit) try doomed.upstreams.append(arena, row.id);
    }
}

fn matchUpstream(row: upstreams_repo.UpstreamRow, item: model.UpstreamServer) bool {
    return std.mem.eql(u8, row.url, item.url);
}

fn reconcileLocalRecords(
    database: *db.Db,
    arena: Allocator,
    cfg: model.Config,
    ctx: context.InsertContext,
    counts: *TableCounts,
    doomed: *Doomed,
) Error!void {
    const rows = try local_repo.listLocalRecordRows(database, arena);
    const matched = try matchFlags(arena, rows.items.len);

    for (cfg.local_records) |item| {
        if (findUnmatched(rows.items, matched, item, matchLocalRecord)) |i| {
            const row = rows.items[i];
            if (row.ttl != item.ttl) {
                try local_repo.updateLocalRecord(database, row.id, item);
                counts.updated += 1;
            }
        } else {
            try local_repo.insertLocalRecord(database, item, ctx);
            counts.inserted += 1;
        }
    }

    for (rows.items, matched) |row, hit| {
        if (!hit) try doomed.local_records.append(arena, row.id);
    }
}

/// `UNIQUE(name, rtype, value)` — the DDL's own key, so the ttl is the only
/// declarative column an edit can touch.
fn matchLocalRecord(row: local_repo.LocalRecordRow, item: model.LocalRecord) bool {
    return row.rtype == item.rtype and
        std.mem.eql(u8, row.name, item.name) and
        std.mem.eql(u8, row.value, item.value);
}

fn reconcileForwardZones(
    database: *db.Db,
    arena: Allocator,
    cfg: model.Config,
    ctx: context.InsertContext,
    counts: *TableCounts,
    doomed: *Doomed,
) Error!void {
    const rows = try local_repo.listForwardZoneRows(database, arena);
    const matched = try matchFlags(arena, rows.items.len);

    for (cfg.forward_zones) |item| {
        if (findUnmatched(rows.items, matched, item, matchForwardZone)) |i| {
            const row = rows.items[i];
            if (!std.mem.eql(u8, row.resolver, item.resolver)) {
                try local_repo.updateForwardZone(database, row.id, item);
                counts.updated += 1;
            }
        } else {
            try local_repo.insertForwardZone(database, item, ctx);
            counts.inserted += 1;
        }
    }

    for (rows.items, matched) |row, hit| {
        if (!hit) try doomed.forward_zones.append(arena, row.id);
    }
}

fn matchForwardZone(row: local_repo.ForwardZoneRow, item: model.ForwardZone) bool {
    return std.mem.eql(u8, row.zone, item.zone);
}

// ---------------------------------------------------------------------------
// settings, and the password rule
// ---------------------------------------------------------------------------

fn reconcileSettings(
    io: std.Io,
    gpa: Allocator,
    arena: Allocator,
    database: *db.Db,
    cfg: model.Config,
    summary: *Summary,
    doomed: *Doomed,
    options: Options,
) Error!void {
    const stored = try settings_repo.listSettings(database, arena);

    // `toSettings` no longer emits either password field (ruling 4), so what it
    // produces is exactly the set of keys a configuration fully determines.
    var pairs: std.ArrayList(model.SettingPair) = .empty;
    try model.toSettings(cfg, arena, &pairs);

    for (pairs.items) |pair| {
        if (storedValue(stored.items, pair.key)) |current| {
            if (std.mem.eql(u8, current, pair.value)) continue;
            try settings_repo.putSetting(database, pair.key, pair.value);
            summary.settings.updated += 1;
        } else {
            try settings_repo.putSetting(database, pair.key, pair.value);
            summary.settings.inserted += 1;
        }
        try recordSettingsKey(gpa, options, pair.key);
    }

    try reconcilePassword(io, gpa, database, cfg, stored.items, summary, options);

    // The sweep. `web.password_hash` is the single exemption, because the
    // engine owns that row and "the file said nothing" means keep it — a
    // general sweep would read the file's silence as a deletion and open the
    // admin UI to the LAN.
    for (stored.items) |pair| {
        if (std.mem.eql(u8, pair.key, password_hash_key)) continue;
        if (hasKey(pairs.items, pair.key)) continue;
        try doomed.settings.append(arena, pair.key);
    }
}

/// Ruling 4, the whole rule in one place. Presence is the question at every
/// branch, never emptiness — a file that states nothing keeps the stored hash,
/// and disabling authentication takes the explicit `password_hash = ""`.
fn reconcilePassword(
    io: std.Io,
    gpa: Allocator,
    database: *db.Db,
    cfg: model.Config,
    stored: []const model.SettingPair,
    summary: *Summary,
    options: Options,
) Error!void {
    if (cfg.web.password != null and cfg.web.password_hash != null) {
        // `validate` rejects this first on every path an operator can reach.
        // The engine repeats the check because it must never guess which of two
        // contradictory security settings was meant.
        return error.PasswordAndHashBothSet;
    }

    const current = storedValue(stored, password_hash_key);
    var hash_buf: [hash_buf_len]u8 = undefined;

    const desired: ?[]const u8 = desired: {
        // A hash is written verbatim, `""` included: that is the documented way
        // to turn authentication off.
        if (cfg.web.password_hash) |hash| break :desired hash;

        if (cfg.web.password) |plain| {
            // Verify against the stored hash and keep it on a match. Not a cost
            // saving — argon2 verification recomputes the whole function with
            // the stored salt and costs exactly what hashing costs — but the
            // only way the file stays idempotent, since hashing generates a
            // fresh salt every time. Never replace this with a cached-plaintext
            // comparison; that would be a security bug.
            if (current) |value| {
                if (value.len != 0 and try verifyKeeps(io, gpa, value, plain)) break :desired value;
            }
            break :desired try hashPassword(io, gpa, plain, &hash_buf);
        }

        // Silence means keep.
        break :desired null;
    };

    const was_on = if (current) |value| value.len != 0 else false;
    const now_on = if (desired) |value| value.len != 0 else was_on;

    if (desired) |value| {
        if (current) |value_before| {
            if (!std.mem.eql(u8, value_before, value)) {
                try settings_repo.putSetting(database, password_hash_key, value);
                summary.settings.updated += 1;
                try recordSettingsKey(gpa, options, password_hash_key);
                if (was_on and now_on) summary.auth_transition = .rotated;
            }
        } else {
            try settings_repo.putSetting(database, password_hash_key, value);
            summary.settings.inserted += 1;
            try recordSettingsKey(gpa, options, password_hash_key);
        }
    }

    if (was_on != now_on) summary.auth_transition = if (now_on) .enabled else .disabled;
}

fn storedValue(pairs: []const model.SettingPair, key: []const u8) ?[]const u8 {
    for (pairs) |pair| {
        if (std.mem.eql(u8, pair.key, key)) return pair.value;
    }
    return null;
}

fn hasKey(pairs: []const model.SettingPair, key: []const u8) bool {
    return storedValue(pairs, key) != null;
}

fn recordSettingsKey(gpa: Allocator, options: Options, key: []const u8) Error!void {
    const list = options.changed_settings orelse return;
    const copy = try gpa.dupe(u8, key);
    errdefer gpa.free(copy);
    try list.append(gpa, copy);
}

/// True when `plain` is the password behind `stored`, so the stored hash may be
/// kept as it is.
///
/// A stored PHC string this build cannot read is not a match: the file's
/// password is the authority and a fresh hash replaces the unreadable one. The
/// reason is logged at `warn` — never the password, never the hash.
fn verifyKeeps(io: std.Io, gpa: Allocator, stored: []const u8, plain: []const u8) Error!bool {
    std.crypto.pwhash.argon2.strVerify(stored, plain, .{ .allocator = gpa }, io) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        error.Canceled => return error.Canceled,
        error.PasswordVerificationFailed => return false,
        else => {
            log.warn("the stored web.password_hash could not be verified ({s}); " ++
                "hashing the configured password instead", .{@errorName(e)});
            return false;
        },
    };
    return true;
}

/// argon2id with the OWASP parameters (t=2, m=19 MiB, p=1) — the same shape
/// `import` and `PUT /api/settings` produce, so a hash cannot say where it came
/// from.
fn hashPassword(io: std.Io, gpa: Allocator, plain: []const u8, buf: []u8) Error![]const u8 {
    return std.crypto.pwhash.argon2.strHash(plain, .{
        .allocator = gpa,
        .params = .owasp_2id,
        .mode = .argon2id,
        .encoding = .phc,
    }, buf, io) catch |e| switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.Canceled => error.Canceled,
        else => {
            log.warn("hashing web.password failed: {s}", .{@errorName(e)});
            return error.Unexpected;
        },
    };
}

// ---------------------------------------------------------------------------
// matching
// ---------------------------------------------------------------------------

fn matchFlags(arena: Allocator, len: usize) Allocator.Error![]bool {
    const flags = try arena.alloc(bool, len);
    @memset(flags, false);
    return flags;
}

/// The first row `equals` accepts that no earlier file entry has already
/// claimed, marked as claimed on the way out.
///
/// Consuming the match is what makes `rules` a multiset rather than a set: two
/// identical file entries take two rows, and a third would insert. For every
/// other table the identity is unique, so the consumption is invisible.
fn findUnmatched(
    rows: anytype,
    matched: []bool,
    wanted: anytype,
    comptime equals: fn (@typeInfo(@TypeOf(rows)).pointer.child, @TypeOf(wanted)) bool,
) ?usize {
    for (rows, 0..) |row, i| {
        if (matched[i]) continue;
        if (equals(row, wanted)) {
            matched[i] = true;
            return i;
        }
    }
    return null;
}

/// The validator compares client addresses after canonicalisation and the
/// column stores the canonical text, so the file's value is canonicalised
/// *before* it is matched. Matching the raw file string against the canonical
/// column would churn row ids under an unchanged non-canonical file, which is
/// exactly what ruling 5 forbids — and an exported-config test could never
/// catch it, because an export emits canonical forms.
fn canonicalIp(text: []const u8, buf: []u8) error{BadClientIp}![]const u8 {
    const addr = address.NetAddress.parse(text) catch return error.BadClientIp;
    var w: std.Io.Writer = .fixed(buf);
    addr.format(&w) catch return error.BadClientIp;
    return w.buffered();
}

fn canonicalPrefix(text: []const u8, buf: []u8) error{BadClientPrefix}![]const u8 {
    const prefix = address.Prefix.parse(text) catch return error.BadClientPrefix;
    var w: std.Io.Writer = .fixed(buf);
    prefix.format(&w) catch return error.BadClientPrefix;
    return w.buffered();
}

// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------

const testing = std.testing;
const migrations = @import("../storage/migrations.zig");
const validate = @import("validate.zig");

fn openMigrated() !db.Db {
    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    errdefer database.close();
    try db.applyPragmas(&database, .{});
    _ = try migrations.migrate(&database);
    return database;
}

/// Every row of every config table, rendered in a stable order. Two dumps are
/// equal exactly when the database content is.
fn dump(database: *db.Db, gpa: Allocator) ![]u8 {
    var out: std.Io.Writer.Allocating = .init(gpa);
    errdefer out.deinit();
    const w = &out.writer;

    inline for (config_schema.table_names) |table| {
        try w.print("{s}\n", .{table});
        var rows = try database.prepare("SELECT * FROM " ++ table ++ " ORDER BY 1, 2");
        defer rows.deinit();
        const columns = db.c.sqlite3_column_count(rows.handle);
        while (try rows.step()) {
            var col: c_int = 0;
            while (col < columns) : (col += 1) {
                // The type marker is what makes "equal dumps" mean "equal
                // content": `columnText` renders SQL NULL as the empty string,
                // so without it a nulled `clients.name` and an emptied one
                // would compare equal and ruling 5 would be proved by a dump
                // that cannot see the difference.
                switch (db.c.sqlite3_column_type(rows.handle, col)) {
                    db.column_type.null_value => try w.writeAll(" null"),
                    db.column_type.integer => try w.print(" i:{d}", .{rows.columnInt(col)}),
                    db.column_type.blob => try w.print(" b:{s}", .{rows.columnText(col)}),
                    // Text, and float, which no config column declares.
                    else => try w.print(" t:{s}", .{rows.columnText(col)}),
                }
            }
            try w.writeAll("\n");
        }
    }
    return out.toOwnedSlice();
}

/// Parses `source` and converges the database onto it, the way `run --config`
/// will. Validation runs first, exactly as it does in production, so a test
/// config that could never reach the engine fails here rather than silently
/// exercising an unreachable path.
fn applyText(
    io: std.Io,
    database: *db.Db,
    arena: Allocator,
    source: [:0]const u8,
    now: i64,
) !Summary {
    const cfg = try std.zon.parse.fromSliceAlloc(model.Config, arena, source, null, .{});
    var diags: validate.Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    try validate.validate(cfg, &diags);
    var pass = try begin(io, testing.allocator, database, cfg, now, .{});
    errdefer pass.rollback();
    try pass.commit();
    return pass.summary;
}

const minimal_source: [:0]const u8 =
    \\.{
    \\    .groups = .{ .{ .name = "default" } },
    \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
    \\}
;

/// Exercises every collection, with a non-canonical client address and a
/// plaintext password: the two inputs a naive engine churns on.
const full_source: [:0]const u8 =
    \\.{
    \\    .dns = .{ .port = 5353 },
    \\    .web = .{ .password = "correct horse battery staple" },
    \\    .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } },
    \\    .upstreams = .{
    \\        .{ .url = "https://dns.example/dns-query", .priority = 10 },
    \\        .{ .url = "tls://192.0.2.53:853", .priority = 20, .enabled = false, .tls_name = "dot.example" },
    \\    },
    \\    .clients = .{ .{ .ip = "FD00:0:0:0:0:0:0:1", .name = "tablet", .group = "kids" } },
    \\    .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } },
    \\    .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "ads" } },
    \\    .group_sources = .{ .{ .group = "kids", .source_url = "https://lists.example/ads.txt" } },
    \\    .rules = .{
    \\        .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block },
    \\        .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block },
    \\        .{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow },
    \\    },
    \\    .local_records = .{ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 600 } },
    \\    .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.1:53" } },
    \\}
;

const Bench = struct {
    threaded: std.Io.Threaded,
    arena_state: std.heap.ArenaAllocator,
    database: db.Db,

    fn init(self: *Bench) !void {
        self.threaded = .init(testing.allocator, .{});
        self.arena_state = .init(testing.allocator);
        self.database = try openMigrated();
    }

    fn deinit(self: *Bench) void {
        self.database.close();
        self.arena_state.deinit();
        self.threaded.deinit();
    }

    fn io(self: *Bench) std.Io {
        return self.threaded.io();
    }

    fn arena(self: *Bench) Allocator {
        return self.arena_state.allocator();
    }

    fn apply(self: *Bench, source: [:0]const u8, now: i64) !Summary {
        return applyText(self.io(), &self.database, self.arena(), source, now);
    }
};

/// Seeds the runtime columns of every source, the way a completed refresh
/// would, so a test can prove the engine preserved them.
fn seedSourceStats(database: *db.Db, id: i64) !void {
    return sources_repo.updateSourceStats(database, id, .{
        .last_updated = 1_700_000_000,
        .domain_count = 4321,
        .wildcard_count = 21,
        .exception_count = 9,
        .skipped_regex_count = 7,
        .skipped_unsupported_count = 33,
        .checksum = "a" ** 64,
    });
}

test "reconciling the same configuration twice is byte-identical and writes nothing" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();
    const gpa = testing.allocator;

    // A plaintext password and a non-canonical v6 address are both in
    // `full_source`: hashing generates a fresh salt per call and matching a raw
    // file string against a canonical column churns row ids, so a naive engine
    // fails this test on either input alone.
    _ = try bench.apply(full_source, 1_700_000_000);

    const before = try dump(&bench.database, gpa);
    defer gpa.free(before);
    const changes_before = bench.database.totalChanges();

    // A later clock, so anything that restamped a timestamp would show.
    const summary = try bench.apply(full_source, 1_800_000_000);

    const after = try dump(&bench.database, gpa);
    defer gpa.free(after);

    try testing.expectEqualStrings(before, after);
    try testing.expect(summary.isNoOp());
    try testing.expectEqual(AuthTransition.none, summary.auth_transition);
    // Stronger than byte-equality: an UPDATE that rewrote identical values
    // would leave the dump equal and still move this counter.
    try testing.expectEqual(changes_before, bench.database.totalChanges());
}

test "a source keeps its id, its checksum and its counters across a reconcile" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();
    const gpa = testing.allocator;

    _ = try bench.apply(full_source, 1_700_000_000);

    var seeded = try sources_repo.listSourceRows(&bench.database, gpa);
    defer seeded.deinit(gpa);
    defer sources_repo.freeSourceRows(gpa, seeded.items);
    try testing.expectEqual(@as(usize, 1), seeded.items.len);
    const id = seeded.items[0].id;
    try seedSourceStats(&bench.database, id);

    // The name changes, which is a declarative edit; the url does not, so the
    // identity holds and the compiled `<id>.list` stays valid.
    const renamed: [:0]const u8 =
        \\.{
        \\    .groups = .{ .{ .name = "default" } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\    .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "advertising" } },
        \\}
    ;
    const summary = try bench.apply(renamed, 1_800_000_000);
    try testing.expectEqual(@as(u32, 1), summary.sources.updated);
    try testing.expectEqual(@as(u32, 0), summary.sources.deleted);
    try testing.expectEqual(@as(u32, 0), summary.sources.inserted);

    const row = (try sources_repo.getSource(&bench.database, gpa, id)).?;
    defer sources_repo.freeSourceRow(gpa, row);
    try testing.expectEqualStrings("advertising", row.name);
    try testing.expectEqual(@as(?i64, 1_700_000_000), row.last_updated);
    try testing.expectEqual(@as(i64, 4321), row.domain_count);
    try testing.expectEqual(@as(i64, 9), row.exception_count);
    try testing.expectEqual(@as(i64, 7), row.skipped_regex_count);
    try testing.expectEqual(@as(i64, 33), row.skipped_unsupported_count);
    try testing.expectEqualStrings("a" ** 64, row.checksum.?);
}

test "changing a source url is a new identity: new id, no runtime state" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();
    const gpa = testing.allocator;

    _ = try bench.apply(full_source, 1_700_000_000);
    const old_id = try bench.database.queryInt(
        "SELECT id FROM blocklist_sources WHERE url = 'https://lists.example/ads.txt'",
    );
    try seedSourceStats(&bench.database, old_id);

    const moved: [:0]const u8 =
        \\.{
        \\    .groups = .{ .{ .name = "default" } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\    .blocklist_sources = .{ .{ .url = "https://lists.example/ads-v2.txt", .name = "ads" } },
        \\}
    ;
    const summary = try bench.apply(moved, 1_800_000_000);
    try testing.expectEqual(@as(u32, 1), summary.sources.inserted);
    try testing.expectEqual(@as(u32, 1), summary.sources.deleted);

    var rows = try sources_repo.listSourceRows(&bench.database, gpa);
    defer rows.deinit(gpa);
    defer sources_repo.freeSourceRows(gpa, rows.items);
    try testing.expectEqual(@as(usize, 1), rows.items.len);
    try testing.expect(rows.items[0].id != old_id);
    // A fresh row means a fresh download, which is the accepted trade: the
    // compiled artefacts are named after the id, so they cannot follow a url.
    try testing.expectEqual(@as(?i64, null), rows.items[0].last_updated);
    try testing.expectEqual(@as(?[]const u8, null), rows.items[0].checksum);
}

test "a source the file stops declaring is removed" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    _ = try bench.apply(full_source, 1_700_000_000);
    const summary = try bench.apply(minimal_source, 1_800_000_000);

    try testing.expectEqual(@as(u32, 1), summary.sources.deleted);
    try testing.expectEqual(@as(i64, 0), try sources_repo.countBlocklistSources(&bench.database));
    // The assignment went with it, counted by its own pass rather than left to
    // the ON DELETE CASCADE.
    try testing.expectEqual(@as(u32, 1), summary.group_sources.deleted);
}

test "an observed client survives a reconcile that never mentions it" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    try clients_repo.upsertSeen(&bench.database, "192.168.1.5", 1_700_000_000);
    try clients_repo.upsertSeen(&bench.database, "192.168.1.5", 1_700_000_100);

    const summary = try bench.apply(minimal_source, 1_800_000_000);
    try testing.expectEqual(@as(u32, 0), summary.clients.deleted);
    try testing.expectEqual(@as(u32, 0), summary.clients.updated);

    var stmt = try bench.database.prepare(
        "SELECT hand_edited, first_seen, last_seen FROM clients WHERE ip = '192.168.1.5'",
    );
    defer stmt.deinit();
    try testing.expect(try stmt.step());
    try testing.expectEqual(@as(i64, 0), stmt.columnInt(0));
    try testing.expectEqual(@as(i64, 1_700_000_000), stmt.columnInt(1));
    try testing.expectEqual(@as(i64, 1_700_000_100), stmt.columnInt(2));
}

test "declaring an observed address promotes the row in place" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    // Seen twice, so the two timestamps differ and no assertion below can pass
    // by carrying one column into the other. `full_source` names this address,
    // in a non-canonical spelling.
    try clients_repo.upsertSeen(&bench.database, "fd00::1", 1_700_000_200);
    try clients_repo.upsertSeen(&bench.database, "fd00::1", 1_700_000_900);
    const id_before = try bench.database.queryInt("SELECT id FROM clients WHERE ip = 'fd00::1'");

    const summary = try bench.apply(full_source, 1_800_000_000);
    try testing.expectEqual(@as(u32, 1), summary.clients.updated);
    try testing.expectEqual(@as(u32, 0), summary.clients.inserted);
    try testing.expectEqual(@as(u32, 0), summary.clients.deleted);

    var stmt = try bench.database.prepare(
        \\SELECT c.id, c.hand_edited, c.name, g.name, c.first_seen, c.last_seen
        \\  FROM clients c JOIN groups g ON g.id = c.group_id WHERE c.ip = 'fd00::1'
    );
    defer stmt.deinit();
    try testing.expect(try stmt.step());
    // The row id survives, so nothing keyed by it is orphaned.
    try testing.expectEqual(id_before, stmt.columnInt(0));
    try testing.expectEqual(@as(i64, 1), stmt.columnInt(1));
    try testing.expectEqualStrings("tablet", stmt.columnText(2));
    try testing.expectEqualStrings("kids", stmt.columnText(3));
    // The observation history is the tracker's. The reconcile clock is far
    // above both values, so a restamp would be unmissable.
    try testing.expectEqual(@as(i64, 1_700_000_200), stmt.columnInt(4));
    try testing.expectEqual(@as(i64, 1_700_000_900), stmt.columnInt(5));
}

test "a declared client the file drops is removed, an observed one is not" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    _ = try bench.apply(full_source, 1_700_000_000);
    try clients_repo.upsertSeen(&bench.database, "10.0.0.9", 1_700_000_200);

    const summary = try bench.apply(minimal_source, 1_800_000_000);
    try testing.expectEqual(@as(u32, 1), summary.clients.deleted);

    try testing.expectEqual(@as(i64, 0), try bench.database.queryInt(
        "SELECT count(*) FROM clients WHERE ip = 'fd00::1'",
    ));
    try testing.expectEqual(@as(i64, 1), try bench.database.queryInt(
        "SELECT count(*) FROM clients WHERE ip = '10.0.0.9' AND hand_edited = 0",
    ));
}

test "removing a group reassigns its observed clients to the default group" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    _ = try bench.apply(full_source, 1_700_000_000);

    // A device the DNS path materialised, moved into `kids` the way a prefix
    // rule or an operator would. `clients.group_id` has no ON DELETE action, so
    // without the reassignment the group delete below trips the foreign key,
    // the transaction aborts, and the box restart-loops on a file that `check`
    // called valid.
    try clients_repo.upsertSeen(&bench.database, "10.0.0.9", 1_700_000_200);
    const kids = (try groups_repo.groupId(&bench.database, "kids")).?;
    const observed = try bench.database.queryInt("SELECT id FROM clients WHERE ip = '10.0.0.9'");
    try bench.database.exec("UPDATE clients SET group_id = 2 WHERE ip = '10.0.0.9';");
    try testing.expectEqual(kids, try bench.database.queryInt(
        "SELECT group_id FROM clients WHERE ip = '10.0.0.9'",
    ));

    const summary = try bench.apply(minimal_source, 1_800_000_000);
    try testing.expectEqual(@as(u32, 1), summary.groups.deleted);

    var stmt = try bench.database.prepare(
        "SELECT id, group_id, hand_edited FROM clients WHERE ip = '10.0.0.9'",
    );
    defer stmt.deinit();
    try testing.expect(try stmt.step());
    try testing.expectEqual(observed, stmt.columnInt(0));
    try testing.expectEqual(default_group_id, stmt.columnInt(1));
    // Reassignment is not a promotion: the device is still runtime state.
    try testing.expectEqual(@as(i64, 0), stmt.columnInt(2));
}

test "renaming a group reassigns its observed clients rather than failing" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    _ = try bench.apply(full_source, 1_700_000_000);
    try clients_repo.upsertSeen(&bench.database, "10.0.0.9", 1_700_000_200);
    try bench.database.exec("UPDATE clients SET group_id = 2 WHERE ip = '10.0.0.9';");

    // A rename is a delete plus an insert to an engine that matches groups by
    // name, so it walks into the same foreign key as a removal does.
    const renamed: [:0]const u8 =
        \\.{
        \\    .groups = .{ .{ .name = "default" }, .{ .name = "children", .safe_search = true } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\}
    ;
    const summary = try bench.apply(renamed, 1_800_000_000);
    try testing.expectEqual(@as(u32, 1), summary.groups.inserted);
    try testing.expectEqual(@as(u32, 1), summary.groups.deleted);

    try testing.expectEqual(default_group_id, try bench.database.queryInt(
        "SELECT group_id FROM clients WHERE ip = '10.0.0.9'",
    ));
}

test "editing safe_search on an existing group converges without moving its id" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    _ = try bench.apply(full_source, 1_700_000_000);
    const kids = (try groups_repo.groupId(&bench.database, "kids")).?;

    const relaxed: [:0]const u8 =
        \\.{
        \\    .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = false } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\}
    ;
    const summary = try bench.apply(relaxed, 1_800_000_000);
    try testing.expectEqual(@as(u32, 1), summary.groups.updated);
    try testing.expectEqual(@as(u32, 0), summary.groups.inserted);
    try testing.expectEqual(@as(u32, 0), summary.groups.deleted);

    try testing.expectEqual(kids, (try groups_repo.groupId(&bench.database, "kids")).?);
    try testing.expectEqual(@as(i64, 0), try bench.database.queryInt(
        "SELECT safe_search FROM groups WHERE name = 'kids'",
    ));
}

test "the default group keeps id 1 across every reconcile" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    _ = try bench.apply(full_source, 1_700_000_000);
    try testing.expectEqual(default_group_id, (try groups_repo.groupId(&bench.database, "default")).?);
    _ = try bench.apply(minimal_source, 1_800_000_000);
    try testing.expectEqual(default_group_id, (try groups_repo.groupId(&bench.database, "default")).?);
}

test "the default group keeps id 1 however late the file declares it" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    // File order is the operator's, not the engine's. A pass that inserted
    // groups in file order onto a fresh database — rather than matching
    // `default` to the row migration step 1 already seeded — would hand id 2 to
    // `default` here and trip the pin, so this is the ordering the engine has
    // to be indifferent to.
    const default_last: [:0]const u8 =
        \\.{
        \\    .groups = .{
        \\        .{ .name = "kids", .safe_search = true },
        \\        .{ .name = "guests" },
        \\        .{ .name = "default" },
        \\    },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\}
    ;
    const summary = try bench.apply(default_last, 1_700_000_000);
    try testing.expectEqual(@as(u32, 2), summary.groups.inserted);
    try testing.expectEqual(@as(u32, 0), summary.groups.updated);

    try testing.expectEqual(default_group_id, (try groups_repo.groupId(&bench.database, "default")).?);
    // And the two new groups took ids of their own rather than colliding.
    try testing.expect((try groups_repo.groupId(&bench.database, "kids")).? != default_group_id);
    try testing.expect((try groups_repo.groupId(&bench.database, "guests")).? != default_group_id);

    // Re-applying the same file is still a no-op, which it would not be if the
    // first pass had settled the ids by luck.
    const again = try bench.apply(default_last, 1_800_000_000);
    try testing.expect(again.isNoOp());
}

test "a database whose group 1 is not 'default' is refused, not renumbered" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();
    const gpa = testing.allocator;

    // The API refuses to rename or delete the default group and migration
    // step 1 seeds it, so only a hand-edited database reaches this state. It
    // must fail loudly: `upsertSeen` and §7.2's fallback both resolve an
    // unassigned client to group 1, so converging onto a file that puts
    // `default` somewhere else would silently move every such device.
    try bench.database.exec("UPDATE groups SET name = 'renamed' WHERE id = 1;");
    const before = try dump(&bench.database, gpa);
    defer gpa.free(before);

    const cfg = try std.zon.parse.fromSliceAlloc(model.Config, bench.arena(), minimal_source, null, .{});
    try testing.expectError(
        error.Unexpected,
        begin(bench.io(), gpa, &bench.database, cfg, 1_700_000_000, .{}),
    );

    const after = try dump(&bench.database, gpa);
    defer gpa.free(after);
    try testing.expectEqualStrings(before, after);
}

test "a database with no group 1 is refused rather than reseeded" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();
    const gpa = testing.allocator;

    // The empty table is the dangerous shape, not the merely renamed one: the
    // file declares `default`, and an engine that treats it as an ordinary
    // desired group inserts it, collects id 1 from a table whose rowids start
    // over, and reports success while having quietly reseeded the row the
    // migration owns.
    try bench.database.exec("DELETE FROM groups;");

    const cfg = try std.zon.parse.fromSliceAlloc(model.Config, bench.arena(), minimal_source, null, .{});
    try testing.expectError(
        error.Unexpected,
        begin(bench.io(), gpa, &bench.database, cfg, 1_700_000_000, .{}),
    );

    // Refused means refused: no group was created on the way out.
    try testing.expectEqual(@as(i64, 0), try bench.database.queryInt("SELECT count(*) FROM groups"));
}

test "recording a settings key frees the copy the list could not take" {
    // `testing.allocator` fails this test if the duped key outlives the failed
    // append, which is the whole assertion.
    var failing: std.testing.FailingAllocator = .init(testing.allocator, .{ .fail_index = 1 });
    const gpa = failing.allocator();

    var changed: std.ArrayList([]const u8) = .empty;
    defer changed.deinit(gpa);

    // The dupe is allocation 0 and succeeds; growing the list is allocation 1
    // and does not.
    try testing.expectError(
        error.OutOfMemory,
        recordSettingsKey(gpa, .{ .changed_settings = &changed }, "dns.port"),
    );
    try testing.expectEqual(@as(usize, 0), changed.items.len);
}

test "the dump tells SQL NULL apart from the empty string" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();
    const gpa = testing.allocator;

    // `clients.name` is nullable, so this pair of states is reachable. Ruling 5
    // is proved by comparing dumps, and a dump that renders both as nothing
    // would call these two databases identical.
    _ = try bench.apply(minimal_source, 1_700_000_000);
    try bench.database.exec(
        \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
        \\VALUES ('10.0.0.5', NULL, 1, 0, 1700000000, 1700000000);
        ,
    );
    const with_null = try dump(&bench.database, gpa);
    defer gpa.free(with_null);

    try bench.database.exec("UPDATE clients SET name = '' WHERE ip = '10.0.0.5';");
    const with_empty = try dump(&bench.database, gpa);
    defer gpa.free(with_empty);

    try testing.expect(!std.mem.eql(u8, with_null, with_empty));
}

test "rules keep created_at across a reconcile, duplicates included" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();
    const gpa = testing.allocator;

    // `full_source` carries the same wildcard rule twice: the identity is a
    // multiset, so both rows must survive with both stamps.
    _ = try bench.apply(full_source, 1_700_000_000);

    var before = try rules_repo.listRuleRows(&bench.database, gpa);
    defer before.deinit(gpa);
    defer rules_repo.freeRuleRows(gpa, before.items);
    try testing.expectEqual(@as(usize, 3), before.items.len);
    for (before.items) |row| try testing.expectEqual(@as(i64, 1_700_000_000), row.created_at);

    const summary = try bench.apply(full_source, 1_800_000_000);
    try testing.expectEqual(@as(u32, 0), summary.rules.total());

    var after = try rules_repo.listRuleRows(&bench.database, gpa);
    defer after.deinit(gpa);
    defer rules_repo.freeRuleRows(gpa, after.items);
    try testing.expectEqual(before.items.len, after.items.len);
    for (before.items, after.items) |old, new| {
        try testing.expectEqual(old.id, new.id);
        // The second pass ran with a clock 100 million seconds later. A rule
        // restamped by an edit-in-place would read 1_800_000_000 here.
        try testing.expectEqual(@as(i64, 1_700_000_000), new.created_at);
    }
}

test "a regex rule declared in the file converges into the table and back out" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    // Under `.managed_file` authority the API refuses rule writes, so this is
    // the only way a regex rule reaches the table in that mode. `reconcileRules`
    // compares the whole tuple and needs no code of its own for the new kind.
    const with_regex: [:0]const u8 =
        \\.{
        \\    .groups = .{ .{ .name = "default" } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\    .rules = .{
        \\        .{ .group = "default", .pattern = "^ad[0-9]+-", .kind = .regex, .action = .block },
        \\    },
        \\}
    ;
    const first = try bench.apply(with_regex, 1_700_000_000);
    try testing.expectEqual(@as(u32, 1), first.rules.inserted);

    const gpa = testing.allocator;
    var rows = try rules_repo.listRuleRows(&bench.database, gpa);
    defer rows.deinit(gpa);
    defer rules_repo.freeRuleRows(gpa, rows.items);
    try testing.expectEqual(@as(usize, 1), rows.items.len);
    try testing.expectEqual(model.RuleKind.regex, rows.items[0].kind);
    try testing.expectEqualStrings("^ad[0-9]+-", rows.items[0].pattern);

    // Idempotent: the tuple matches itself, so a second pass writes nothing.
    const second = try bench.apply(with_regex, 1_800_000_000);
    try testing.expectEqual(@as(u32, 0), second.rules.total());

    // And a file that stops declaring it takes the row with it.
    const without: [:0]const u8 =
        \\.{
        \\    .groups = .{ .{ .name = "default" } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\}
    ;
    const third = try bench.apply(without, 1_900_000_000);
    try testing.expectEqual(@as(u32, 1), third.rules.deleted);
    try testing.expectEqual(@as(i64, 0), try rules_repo.countRules(&bench.database));
}

test "dropping one of two identical rules removes exactly one row" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    _ = try bench.apply(full_source, 1_700_000_000);

    const single: [:0]const u8 =
        \\.{
        \\    .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\    .rules = .{
        \\        .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block },
        \\    },
        \\}
    ;
    const summary = try bench.apply(single, 1_800_000_000);
    try testing.expectEqual(@as(u32, 2), summary.rules.deleted);
    try testing.expectEqual(@as(u32, 0), summary.rules.inserted);
    try testing.expectEqual(@as(i64, 1), try rules_repo.countRules(&bench.database));
}

test "a plaintext password is hashed once and then verified and kept" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    const first = try bench.apply(full_source, 1_700_000_000);
    try testing.expectEqual(AuthTransition.enabled, first.auth_transition);

    var stmt = try bench.database.prepare("SELECT value FROM settings WHERE key = 'web.password_hash'");
    defer stmt.deinit();
    try testing.expect(try stmt.step());
    const hash = try testing.allocator.dupe(u8, stmt.columnText(0));
    defer testing.allocator.free(hash);
    try testing.expect(std.mem.startsWith(u8, hash, "$argon2id$"));

    // The plaintext is never a row.
    try testing.expectEqual(@as(i64, 0), try bench.database.queryInt(
        "SELECT count(*) FROM settings WHERE key = 'web.password'",
    ));

    // Second pass: hashing again would mint a fresh salt and a different PHC
    // string, so keeping the stored hash is what makes the file idempotent.
    const second = try bench.apply(full_source, 1_800_000_000);
    try testing.expectEqual(AuthTransition.none, second.auth_transition);
    try testing.expectEqual(@as(u32, 0), second.settings.total());
    try testing.expectEqualStrings(hash, try storedHash(&bench.database, bench.arena()));
}

test "a changed plaintext password rehashes and reports a rotation" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    _ = try bench.apply(full_source, 1_700_000_000);
    const before = try testing.allocator.dupe(u8, try storedHash(&bench.database, bench.arena()));
    defer testing.allocator.free(before);

    const changed: [:0]const u8 =
        \\.{
        \\    .web = .{ .password = "a different passphrase entirely" },
        \\    .groups = .{ .{ .name = "default" } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\}
    ;
    const summary = try bench.apply(changed, 1_800_000_000);
    try testing.expectEqual(AuthTransition.rotated, summary.auth_transition);

    const after = try storedHash(&bench.database, bench.arena());
    try testing.expect(!std.mem.eql(u8, before, after));
    try testing.expect(std.mem.startsWith(u8, after, "$argon2id$"));
}

test "a file that states no password leaves the stored hash and auth alone" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    _ = try bench.apply(full_source, 1_700_000_000);
    const before = try testing.allocator.dupe(u8, try storedHash(&bench.database, bench.arena()));
    defer testing.allocator.free(before);

    // The trap this rule exists for: an operator trims the ugly PHC string out
    // of an exported file, meaning "keep the current password". Reading that
    // silence as `""` would open the admin UI to the LAN.
    const summary = try bench.apply(minimal_source, 1_800_000_000);
    try testing.expectEqual(AuthTransition.none, summary.auth_transition);
    try testing.expectEqualStrings(before, try storedHash(&bench.database, bench.arena()));

    var cfg: model.Config = .{};
    var unknown: usize = 0;
    const stored = try settings_repo.listSettings(&bench.database, bench.arena());
    try model.fromSettings(stored.items, &cfg, &unknown);
    try testing.expect(cfg.web.password_hash != null);
}

test "an explicit empty password_hash disables authentication and says so" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    _ = try bench.apply(full_source, 1_700_000_000);

    const disabled: [:0]const u8 =
        \\.{
        \\    .web = .{ .password_hash = "" },
        \\    .groups = .{ .{ .name = "default" } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\}
    ;
    const summary = try bench.apply(disabled, 1_800_000_000);
    try testing.expectEqual(AuthTransition.disabled, summary.auth_transition);
    try testing.expectEqualStrings("", try storedHash(&bench.database, bench.arena()));

    // And it stays disabled without churning the row.
    const again = try bench.apply(disabled, 1_900_000_000);
    try testing.expectEqual(AuthTransition.none, again.auth_transition);
    try testing.expectEqual(@as(u32, 0), again.settings.total());
}

test "a password_hash the file states verbatim is written verbatim" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    const hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaGhhc2g";
    const literal: [:0]const u8 =
        \\.{
        \\    .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaGhhc2g" },
        \\    .groups = .{ .{ .name = "default" } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\}
    ;
    const summary = try bench.apply(literal, 1_700_000_000);
    try testing.expectEqual(AuthTransition.enabled, summary.auth_transition);
    try testing.expectEqualStrings(hash, try storedHash(&bench.database, bench.arena()));

    const again = try bench.apply(literal, 1_800_000_000);
    try testing.expectEqual(@as(u32, 0), again.settings.total());
}

test "a password and a password_hash together are refused" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();
    const gpa = testing.allocator;

    _ = try bench.apply(minimal_source, 1_700_000_000);
    const before = try dump(&bench.database, gpa);
    defer gpa.free(before);

    const both: model.Config = .{
        .groups = &.{.{ .name = "default" }},
        .upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
        .web = .{ .password = "plaintext", .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" },
    };
    try testing.expectError(
        error.PasswordAndHashBothSet,
        begin(bench.io(), gpa, &bench.database, both, 1_800_000_000, .{}),
    );

    const after = try dump(&bench.database, gpa);
    defer gpa.free(after);
    try testing.expectEqualStrings(before, after);
}

test "the caller sees the delete counts before it decides, and can still refuse" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();
    const gpa = testing.allocator;

    _ = try bench.apply(full_source, 1_700_000_000);
    const before = try dump(&bench.database, gpa);
    defer gpa.free(before);

    // `import`'s diff gate (ruling 6), written the way R2 will write it: read
    // the counts, then decide. The read happens inside the same
    // `BEGIN IMMEDIATE` that produced them, so no other writer can change the
    // database between the count and the verdict.
    const cfg = try std.zon.parse.fromSliceAlloc(model.Config, bench.arena(), minimal_source, null, .{});
    {
        var pass = try begin(bench.io(), gpa, &bench.database, cfg, 1_800_000_000, .{});
        errdefer pass.rollback();
        try testing.expect(pass.summary.anyDeletes());
        try testing.expectEqual(@as(u32, 1), pass.summary.groups.deleted);
        pass.rollback();
    }

    // Refusing costs the database nothing: every write the passes made on the
    // way to the count went with the transaction.
    //
    // The dump is the proof here, not `totalChanges`: that counter is the
    // connection's and counts rows a statement touched whether or not the
    // transaction survived, so it can only ever show that nothing was
    // *attempted* — which is the no-op test above, not this one.
    const after = try dump(&bench.database, gpa);
    defer gpa.free(after);
    try testing.expectEqualStrings(before, after);

    // And the same pass, allowed, applies.
    {
        var pass = try begin(bench.io(), gpa, &bench.database, cfg, 1_800_000_000, .{});
        errdefer pass.rollback();
        try pass.commit();
    }
    const applied = try dump(&bench.database, gpa);
    defer gpa.free(applied);
    try testing.expect(!std.mem.eql(u8, before, applied));
    try testing.expectEqual(@as(i64, 0), try sources_repo.countBlocklistSources(&bench.database));
}

test "a failure mid-transaction rolls the whole pass back" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();
    const gpa = testing.allocator;

    _ = try bench.apply(full_source, 1_700_000_000);
    try clients_repo.upsertSeen(&bench.database, "10.0.0.9", 1_700_000_200);
    const before = try dump(&bench.database, gpa);
    defer gpa.free(before);

    // Two identical local records violate `UNIQUE(name, rtype, value)`. The
    // validator would catch this, which is exactly why the test bypasses it:
    // the all-or-nothing guarantee has to hold on its own, including for the
    // rows the passes before this one already wrote.
    const broken: model.Config = .{
        .groups = &.{.{ .name = "default" }},
        .upstreams = &.{.{ .url = "https://other.example/dns-query" }},
        .local_records = &.{
            .{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" },
            .{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" },
        },
    };
    try testing.expectError(
        error.Constraint,
        begin(bench.io(), gpa, &bench.database, broken, 1_800_000_000, .{}),
    );

    const after = try dump(&bench.database, gpa);
    defer gpa.free(after);
    try testing.expectEqualStrings(before, after);
}

test "the summary names the settings keys that changed and never their values" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();
    const gpa = testing.allocator;

    _ = try bench.apply(full_source, 1_700_000_000);

    var changed: std.ArrayList([]const u8) = .empty;
    defer {
        for (changed.items) |key| gpa.free(key);
        changed.deinit(gpa);
    }

    const cfg = try std.zon.parse.fromSliceAlloc(model.Config, bench.arena(),
        \\.{
        \\    .dns = .{ .port = 5300 },
        \\    .groups = .{ .{ .name = "default" } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\}
    , null, .{});
    var pass = try begin(bench.io(), gpa, &bench.database, cfg, 1_800_000_000, .{
        .changed_settings = &changed,
    });
    errdefer pass.rollback();
    try pass.commit();
    const summary = pass.summary;

    try testing.expectEqual(@as(u32, 1), summary.settings.updated);
    var saw_port = false;
    for (changed.items) |key| {
        if (std.mem.eql(u8, key, "dns.port")) saw_port = true;
        try testing.expect(!std.mem.eql(u8, key, "web.password"));
    }
    try testing.expect(saw_port);
}

test "a settings key the model no longer produces is swept, and the hash is not" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    _ = try bench.apply(full_source, 1_700_000_000);
    // A key from a newer binary, left behind by a downgrade.
    try settings_repo.putSetting(&bench.database, "future.setting", "whatever");

    const summary = try bench.apply(full_source, 1_800_000_000);
    try testing.expectEqual(@as(u32, 1), summary.settings.deleted);
    try testing.expectEqual(@as(i64, 0), try bench.database.queryInt(
        "SELECT count(*) FROM settings WHERE key = 'future.setting'",
    ));
    // The one exemption survived the sweep, and authentication with it.
    try testing.expectEqual(@as(i64, 1), try bench.database.queryInt(
        "SELECT count(*) FROM settings WHERE key = 'web.password_hash'",
    ));
    try testing.expectEqual(AuthTransition.none, summary.auth_transition);
}

test "reconciling an empty database from a full file inserts every table" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    const summary = try bench.apply(full_source, 1_700_000_000);

    // `default` is already there from migration step 1, so only `kids` is new.
    try testing.expectEqual(@as(u32, 1), summary.groups.inserted);
    try testing.expectEqual(@as(u32, 1), summary.sources.inserted);
    try testing.expectEqual(@as(u32, 1), summary.clients.inserted);
    try testing.expectEqual(@as(u32, 1), summary.client_prefixes.inserted);
    try testing.expectEqual(@as(u32, 3), summary.rules.inserted);
    try testing.expectEqual(@as(u32, 1), summary.group_sources.inserted);
    try testing.expectEqual(@as(u32, 2), summary.upstreams.inserted);
    try testing.expectEqual(@as(u32, 1), summary.local_records.inserted);
    try testing.expectEqual(@as(u32, 1), summary.forward_zones.inserted);
    try testing.expect(summary.settings.inserted > 0);
    try testing.expect(summary.anyDeletes() == false);

    // The v6 client was written canonical, not as the file spelled it.
    try testing.expectEqual(@as(i64, 1), try bench.database.queryInt(
        "SELECT count(*) FROM clients WHERE ip = 'fd00::1'",
    ));
}

test "every table loses the rows the file stopped declaring, and reports each" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    _ = try bench.apply(full_source, 1_700_000_000);
    const summary = try bench.apply(minimal_source, 1_800_000_000);

    // One assertion per table, because each delete pass is its own loop and a
    // missing one would otherwise leave rows behind that nothing counted.
    try testing.expectEqual(@as(u32, 1), summary.groups.deleted);
    try testing.expectEqual(@as(u32, 1), summary.sources.deleted);
    try testing.expectEqual(@as(u32, 1), summary.clients.deleted);
    try testing.expectEqual(@as(u32, 1), summary.client_prefixes.deleted);
    try testing.expectEqual(@as(u32, 3), summary.rules.deleted);
    try testing.expectEqual(@as(u32, 1), summary.group_sources.deleted);
    try testing.expectEqual(@as(u32, 1), summary.upstreams.deleted);
    try testing.expectEqual(@as(u32, 1), summary.local_records.deleted);
    try testing.expectEqual(@as(u32, 1), summary.forward_zones.deleted);

    // And the rows are actually gone, not merely counted.
    try testing.expectEqual(@as(i64, 1), try groups_repo.countGroups(&bench.database));
    try testing.expectEqual(@as(i64, 0), try sources_repo.countBlocklistSources(&bench.database));
    try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&bench.database));
    try testing.expectEqual(@as(i64, 0), try clients_repo.countClientPrefixes(&bench.database));
    try testing.expectEqual(@as(i64, 0), try rules_repo.countRules(&bench.database));
    try testing.expectEqual(@as(i64, 0), try groups_repo.countGroupSources(&bench.database));
    try testing.expectEqual(@as(i64, 1), try upstreams_repo.countUpstreams(&bench.database));
    try testing.expectEqual(@as(i64, 0), try local_repo.countLocalRecords(&bench.database));
    try testing.expectEqual(@as(i64, 0), try local_repo.countForwardZones(&bench.database));
}

test "editing an upstream, a local record and a forward zone writes only those rows" {
    var bench: Bench = undefined;
    try bench.init();
    defer bench.deinit();

    _ = try bench.apply(full_source, 1_700_000_000);

    const edited: [:0]const u8 =
        \\.{
        \\    .dns = .{ .port = 5353 },
        \\    .web = .{ .password = "correct horse battery staple" },
        \\    .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } },
        \\    .upstreams = .{
        \\        .{ .url = "https://dns.example/dns-query", .priority = 5 },
        \\        .{ .url = "tls://192.0.2.53:853", .priority = 20, .enabled = false, .tls_name = "dot.example" },
        \\    },
        \\    .clients = .{ .{ .ip = "FD00:0:0:0:0:0:0:1", .name = "tablet", .group = "kids" } },
        \\    .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 10 } },
        \\    .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "ads" } },
        \\    .group_sources = .{ .{ .group = "kids", .source_url = "https://lists.example/ads.txt" } },
        \\    .rules = .{
        \\        .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block },
        \\        .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block },
        \\        .{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow },
        \\    },
        \\    .local_records = .{ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 } },
        \\    .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.2:53" } },
        \\}
    ;
    const summary = try bench.apply(edited, 1_800_000_000);

    try testing.expectEqual(@as(u32, 1), summary.upstreams.updated);
    try testing.expectEqual(@as(u32, 1), summary.client_prefixes.updated);
    try testing.expectEqual(@as(u32, 1), summary.local_records.updated);
    try testing.expectEqual(@as(u32, 1), summary.forward_zones.updated);
    // Everything else stayed as it was: an edit is not a rewrite of the file.
    try testing.expectEqual(@as(u32, 0), summary.groups.total());
    try testing.expectEqual(@as(u32, 0), summary.sources.total());
    try testing.expectEqual(@as(u32, 0), summary.clients.total());
    try testing.expectEqual(@as(u32, 0), summary.rules.total());
    try testing.expectEqual(@as(u32, 0), summary.settings.total());
    try testing.expect(!summary.anyDeletes());
}

fn storedHash(database: *db.Db, arena: Allocator) ![]const u8 {
    const pairs = try settings_repo.listSettings(database, arena);
    return storedValue(pairs.items, password_hash_key) orelse error.TestUnexpectedResult;
}
