//! `nxdns import`: a ZON file becomes the configuration of `config.db`.
//!
//! A thin wrapper over `config/reconcile.zig` since milestone 20 (ruling 6).
//! Everything about *what* an apply does to the database — identity matching,
//! runtime-state preservation, the settings sweep, the password rule — belongs
//! to the engine and is documented there. What lives here is the file end of the
//! command: read, parse, validate, and the one policy the engine does not carry.
//!
//! **The policy is the diff gate.** Reconcile never deletes runtime state, but a
//! row the file stops declaring is deleted, so `nxdns import ./wrong.zon`
//! against a configured database would still remove every group, rule, upstream
//! and source that file omits. So an import whose diff deletes anything is
//! refused unless `--allow-delete` says otherwise. It replaces the emptiness
//! guard this file used to carry and is strictly better: an additive or
//! edit-only import needs no flag at all, and the flag now names what it
//! permits.
//!
//! The decision happens *inside* the `BEGIN IMMEDIATE` that produced the counts,
//! which is why `reconcile.begin` hands back an open transaction rather than
//! committing on its own. Checking after a commit would be a verdict on a
//! database the check had already changed.
//!
//! The order is the specification. Nothing reaches the database until the file
//! has been read, parsed and validated, and every write happens inside that one
//! transaction, so a refused import leaves the database byte-for-byte as it was.
//!
//! No filesystem write happens anywhere in this file: the input is opened
//! read-only and the database is SQLite's business.

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

const db = @import("../storage/db.zig");
const loader = @import("loader.zig");
const model = @import("model.zig");
const reconcile = @import("reconcile.zig");
const validate = @import("validate.zig");

pub const Options = struct {
    /// `--allow-delete`. Permits an apply whose diff removes declarative rows.
    allow_delete: bool = false,
};

pub const Error = db.Error || validate.ValidateError || reconcile.Error || loader.ReadError ||
    error{ DestructiveImport, ConfigTooLarge, ParseZon };

/// Reads, parses, validates, then converges the database onto the file.
pub fn importFile(
    io: std.Io,
    gpa: Allocator,
    database: *db.Db,
    dir: std.Io.Dir,
    path: []const u8,
    options: Options,
    diags: *validate.Diagnostics,
) Error!void {
    // `loader.readSource`, not `loader.readManaged`: the managed-file
    // classification (ruling 2) belongs to the path `--config` names. `import`
    // takes a path an operator typed at a shell that has already told them the
    // file is missing, and its open failures keep their own names and exit 1.
    const source = loader.readSource(io, gpa, dir, path) catch |e| switch (e) {
        error.StreamTooLong => return error.ConfigTooLarge,
        else => |other| return other,
    };
    defer gpa.free(source);

    return importSource(io, gpa, database, source, options, diags);
}

/// `importFile` minus the file. It exists because every step from the parse
/// onwards is testable without touching a filesystem.
pub fn importSource(
    io: std.Io,
    gpa: Allocator,
    database: *db.Db,
    source: [:0]const u8,
    options: Options,
    diags: *validate.Diagnostics,
) Error!void {
    // Arena-owned and never handed to `std.zon.parse.free`; `loader.parse`
    // carries the rule and its citation.
    var arena_state: std.heap.ArenaAllocator = .init(gpa);
    defer arena_state.deinit();

    const cfg = try loader.parse(arena_state.allocator(), source, diags);
    try validate.validate(cfg, diags);

    const now = std.Io.Clock.real.now(io).toSeconds();
    return apply(io, gpa, database, cfg, now, options, diags);
}

/// An already-parsed, already-validated configuration into the database, all or
/// nothing, with the diff gate in front of the commit. `now` is the caller's
/// timestamp for the runtime columns the model omits; only rows the engine
/// inserts take it.
pub fn apply(
    io: std.Io,
    gpa: Allocator,
    database: *db.Db,
    cfg: model.Config,
    now: i64,
    options: Options,
    diags: *validate.Diagnostics,
) Error!void {
    var pass = try reconcile.begin(io, gpa, database, cfg, now, .{});
    errdefer pass.rollback();

    if (!options.allow_delete and pass.summary.anyDeletes()) {
        // Rolled back before the diagnostic is recorded: the report can fail on
        // an allocation and the database must be back as it was either way.
        pass.rollback();
        try reportDeletes(diags, pass.summary);
        return error.DestructiveImport;
    }

    return pass.commit();
}

/// The counts an operator needs before deciding whether `--allow-delete` is what
/// they meant. A Zig error carries no text, so this is the whole message.
///
/// Truncation is silent and bounded by construction: ten table names and ten
/// counts cannot fill the buffer, and a truncated line would still name the
/// tables that mattered most.
fn reportDeletes(diags: *validate.Diagnostics, summary: reconcile.Summary) error{OutOfMemory}!void {
    var buf: [512]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    var first = true;
    inline for (@typeInfo(reconcile.Summary).@"struct".fields) |field| {
        if (field.type == reconcile.TableCounts) {
            const counts = @field(summary, field.name);
            if (counts.deleted != 0) {
                w.print("{s}{s} {d}", .{ if (first) "" else ", ", field.name, counts.deleted }) catch {};
                first = false;
            }
        }
    }

    return diags.add(
        error.DestructiveImport,
        "import",
        .{},
        "this file would delete rows the database holds ({s}); re-run with --allow-delete to apply it",
        .{w.buffered()},
    );
}

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

const testing = std.testing;
const config_schema = @import("../storage/config_schema.zig");
const export_mod = @import("export.zig");
const migrations = @import("../storage/migrations.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. Driven by `table_names` — the
/// list that includes `groups`, so a renumbered group is visible here.
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) {
                try w.print(" {s}", .{rows.columnText(col)});
            }
            try w.writeAll("\n");
        }
    }
    return out.toOwnedSlice();
}

/// The smallest config that validates: one enabled upstream and the `default`
/// group.
const minimal_source: [:0]const u8 =
    \\.{
    \\    .groups = .{ .{ .name = "default" } },
    \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
    \\}
;

/// Exercises every collection and a few non-default scalars.
const full_source: [:0]const u8 =
    \\.{
    \\    .dns = .{ .port = 5353 },
    \\    .logging = .{ .level = .err, .retention_days = 7 },
    \\    .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 } },
    \\    .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" } },
    \\}
;

/// `full_source` with one upstream added and nothing removed: the additive
/// re-import the gate must let through untouched.
const additive_source: [:0]const u8 =
    \\.{
    \\    .dns = .{ .port = 5353 },
    \\    .logging = .{ .level = .err, .retention_days = 7 },
    \\    .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" },
    \\        .{ .url = "https://extra.example/dns-query", .priority = 30 },
    \\    },
    \\    .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 } },
    \\    .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" } },
    \\}
;

/// `full_source` with one declarative column edited on a matched identity, and
/// nothing added or removed: the edit-only re-import, which also needs no flag.
const edited_source: [:0]const u8 =
    \\.{
    \\    .dns = .{ .port = 5354 },
    \\    .logging = .{ .level = .err, .retention_days = 7 },
    \\    .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = false } },
    \\    .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 = "slate", .group = "kids" } },
    \\    .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } },
    \\    .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "advertising" } },
    \\    .group_sources = .{ .{ .group = "kids", .source_url = "https://lists.example/ads.txt" } },
    \\    .rules = .{ .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block } },
    \\    .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" } },
    \\}
;

fn importText(io: std.Io, database: *db.Db, source: [:0]const u8, options: Options) !void {
    var diags: validate.Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    return importSource(io, testing.allocator, database, source, options, &diags);
}

test "importSource converges a migrated database and group 'default' keeps id 1" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openMigrated();
    defer database.close();

    try importText(io, &database, full_source, .{});
    try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT id FROM groups WHERE name = 'default'"));
    try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT count(*) FROM upstreams"));
    // The v6 client address was written in canonical form, not as typed.
    try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM clients WHERE ip = 'fd00::1'"));
    try testing.expectEqual(
        @as(i64, 1),
        try database.queryInt("SELECT count(*) FROM upstreams WHERE tls_name = 'dot.example'"),
    );
}

test "an export and re-import leaves the operational_events log untouched" {
    // `operational_events` is runtime state, not configuration: it is out of
    // `table_names` and out of `delete_order`, so an export must not emit it and
    // an import's wipe must not reach it. A diagnostics log destroyed by a
    // routine `nxdns import` would take the record of what the box has been
    // doing with it.
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = testing.allocator;

    var database = try openMigrated();
    defer database.close();
    try importText(io, &database, full_source, .{});
    try database.exec(
        \\INSERT INTO operational_events
        \\  (code, subject_key, subject_label, severity, first_seen, last_seen, occurrences, resolved_at)
        \\VALUES ('disk.space', 'data', 'data', 'warning', 100, 200, 3, NULL),
        \\       ('blocklist.refresh', 'https://a.example', 'A', 'warning', 100, 150, 1, 300);
    );

    var rendered: std.Io.Writer.Allocating = .init(gpa);
    defer rendered.deinit();
    try export_mod.writeToWriter(gpa, &database, &rendered.writer);
    const source = try gpa.dupeZ(u8, rendered.written());
    defer gpa.free(source);

    // The export is the whole declared configuration and says nothing about
    // the log.
    try testing.expect(!std.mem.containsAtLeast(u8, source, 1, "operational_events"));
    try testing.expect(!std.mem.containsAtLeast(u8, source, 1, "disk.space"));

    // A round trip is by definition delete-free for the config tables, and the
    // events survive it untouched, resolved and active alike.
    try importText(io, &database, source, .{ .allow_delete = true });
    var stmt = try database.prepare(
        "SELECT code, occurrences, resolved_at FROM operational_events ORDER BY id",
    );
    defer stmt.deinit();
    try testing.expect(try stmt.step());
    try testing.expectEqualStrings("disk.space", stmt.columnText(0));
    try testing.expectEqual(@as(i64, 3), stmt.columnInt(1));
    try testing.expect(stmt.isNull(2));
    try testing.expect(try stmt.step());
    try testing.expectEqualStrings("blocklist.refresh", stmt.columnText(0));
    try testing.expectEqual(@as(i64, 300), stmt.columnInt(2));
    try testing.expect(!try stmt.step());
}

test "an import whose diff deletes rows is refused, names the tables, and changes nothing" {
    // Ruling 6: the emptiness guard is gone, so this is what stops
    // `nxdns import ./wrong.zon` from emptying a configured database.
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = testing.allocator;

    var database = try openMigrated();
    defer database.close();
    try importText(io, &database, full_source, .{});

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

    var diags: validate.Diagnostics = .init(gpa);
    defer diags.deinit();
    try testing.expectError(
        error.DestructiveImport,
        importSource(io, gpa, &database, minimal_source, .{}, &diags),
    );

    // Byte-identical, which is the rollback proof: `total_changes` is
    // connection-scoped and counts the writes a rollback undid.
    const after = try dump(&database, gpa);
    defer gpa.free(after);
    try testing.expectEqualStrings(before, after);

    // The per-table counts are the message: an operator has to see what would
    // have gone before deciding that `--allow-delete` is what they meant.
    var rendered: std.Io.Writer.Allocating = .init(gpa);
    defer rendered.deinit();
    try diags.writeAll(&rendered.writer);
    const text = rendered.written();
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "groups 1"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "upstreams 1"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "rules 1"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "--allow-delete"));
}

test "the same import applies once --allow-delete is given" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openMigrated();
    defer database.close();
    try importText(io, &database, full_source, .{});

    try importText(io, &database, minimal_source, .{ .allow_delete = true });

    try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
    try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM groups"));
    try testing.expectEqual(@as(i64, 0), try database.queryInt("SELECT count(*) FROM rules"));
}

test "an additive import needs no flag" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openMigrated();
    defer database.close();
    try importText(io, &database, full_source, .{});

    try importText(io, &database, additive_source, .{});
    try testing.expectEqual(@as(i64, 3), try database.queryInt("SELECT count(*) FROM upstreams"));
}

test "an edit-only import needs no flag" {
    // "Edit-only" is edits to declarative columns on a matched identity. An edit
    // that changes an identity column is a delete plus an insert to the engine,
    // and does need the flag — which is why this file edits names, priorities
    // and flags rather than urls.
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openMigrated();
    defer database.close();
    try importText(io, &database, full_source, .{});

    try importText(io, &database, edited_source, .{});
    try testing.expectEqual(@as(i64, 1), try database.queryInt(
        "SELECT count(*) FROM clients WHERE ip = 'fd00::1' AND name = 'slate'",
    ));
    try testing.expectEqual(@as(i64, 1), try database.queryInt(
        "SELECT count(*) FROM blocklist_sources WHERE name = 'advertising'",
    ));
    try testing.expectEqual(@as(i64, 0), try database.queryInt(
        "SELECT count(*) FROM groups WHERE name = 'kids' AND safe_search = 1",
    ));
}

test "an identity-column edit is a delete plus an insert, and needs the flag" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = testing.allocator;

    var database = try openMigrated();
    defer database.close();
    try importText(io, &database, minimal_source, .{});

    const moved_url: [:0]const u8 =
        \\.{
        \\    .groups = .{ .{ .name = "default" } },
        \\    .upstreams = .{ .{ .url = "https://other.example/dns-query" } },
        \\}
    ;
    var diags: validate.Diagnostics = .init(gpa);
    defer diags.deinit();
    try testing.expectError(
        error.DestructiveImport,
        importSource(io, gpa, &database, moved_url, .{}, &diags),
    );
    try testing.expectEqual(@as(i64, 1), try database.queryInt(
        "SELECT count(*) FROM upstreams WHERE url = 'https://dns.example/dns-query'",
    ));
}

test "an import that fails mid-transaction leaves the database as it was" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = testing.allocator;

    var database = try openMigrated();
    defer database.close();
    try importText(io, &database, full_source, .{});

    const before = try dump(&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 calls `apply`
    // directly: the all-or-nothing guarantee has to hold on its own.
    const broken: model.Config = .{
        .groups = &.{.{ .name = "default" }},
        .upstreams = &.{.{ .url = "https://dns.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" },
        },
    };
    var diags: validate.Diagnostics = .init(gpa);
    defer diags.deinit();
    try testing.expectError(
        error.Constraint,
        apply(io, gpa, &database, broken, 42, .{ .allow_delete = true }, &diags),
    );

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

test "importSource writes nothing when validation fails" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = testing.allocator;

    var database = try openMigrated();
    defer database.close();

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

    // No `default` group and no enabled upstream.
    const bad: [:0]const u8 =
        \\.{ .groups = .{ .{ .name = "kids" } } }
    ;
    var diags: validate.Diagnostics = .init(gpa);
    defer diags.deinit();
    try testing.expectError(
        error.MissingDefaultGroup,
        importSource(io, gpa, &database, bad, .{}, &diags),
    );
    try testing.expect(diags.problems.items.len >= 2);

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

test "importSource reports a ZON syntax error and writes nothing" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = testing.allocator;

    var database = try openMigrated();
    defer database.close();

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

    var diags: validate.Diagnostics = .init(gpa);
    defer diags.deinit();
    try testing.expectError(
        error.ParseZon,
        importSource(io, gpa, &database, ".{ .groups = ", .{}, &diags),
    );

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

    // The point of the diagnostic: what the CLI prints must name the line and the
    // column, not just `ParseZon`.
    try testing.expect(diags.problems.items.len >= 1);
    var rendered: std.Io.Writer.Allocating = .init(gpa);
    defer rendered.deinit();
    try diags.writeAll(&rendered.writer);
    try testing.expect(std.mem.indexOf(u8, rendered.written(), "1:14: error: ") != null);
}

test "importSource splits a multi-line ZON failure into one problem per message" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openMigrated();
    defer database.close();

    var diags: validate.Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    // An unexpected field renders as an "error:" line plus a "note:" line, so
    // the rendering the CLI prints has to be split rather than embedded whole.
    try testing.expectError(
        error.ParseZon,
        importSource(io, testing.allocator, &database, ".{ .grops = .{} }", .{}, &diags),
    );

    try testing.expectEqual(@as(usize, 2), diags.problems.items.len);
    for (diags.problems.items) |problem| {
        try testing.expectEqual(@as(?usize, null), std.mem.findScalar(u8, problem.message, '\n'));
    }
    try testing.expect(std.mem.indexOf(u8, diags.problems.items[0].message, "error: ") != null);
    try testing.expect(std.mem.indexOf(u8, diags.problems.items[1].message, "note: ") != null);
}

test "a config omitting every optional field parses into an arena and leaks nothing" {
    // The S5.1 rule as a test: `std.zon.parse.free` is never called, the arena
    // is the only release, and `std.testing.allocator` fails the test if a
    // defaulted rodata string were ever handed to the allocator.
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();

    var diags: validate.Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    const cfg = try loader.parse(arena_state.allocator(), minimal_source, &diags);
    try testing.expectEqualStrings("0.0.0.0", cfg.dns.bind_ipv4);
    try testing.expectEqual(@as(u16, 53), cfg.dns.port);
    try testing.expectEqual(@as(usize, 1), cfg.groups.len);
}

test "a password is hashed into web.password_hash and never stored verbatim" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = testing.allocator;

    var database = try openMigrated();
    defer database.close();

    const cfg: model.Config = .{
        .groups = &.{.{ .name = "default" }},
        .upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
        .web = .{ .password = "correct horse battery staple" },
    };
    var diags: validate.Diagnostics = .init(gpa);
    defer diags.deinit();
    try apply(io, gpa, &database, cfg, 42, .{}, &diags);

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

    try testing.expectEqual(
        @as(i64, 0),
        try database.queryInt("SELECT count(*) FROM settings WHERE key = 'web.password'"),
    );
}

test "a password and a password_hash together are refused" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = testing.allocator;

    var database = try openMigrated();
    defer database.close();

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

    const cfg: 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" },
    };
    var diags: validate.Diagnostics = .init(gpa);
    defer diags.deinit();
    try testing.expectError(
        error.PasswordAndHashBothSet,
        apply(io, gpa, &database, cfg, 42, .{}, &diags),
    );

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