//! Milestone-4 storage integration tests (spec S7): real directories, real
//! database files, real exports.
//!
//! This lives in its own file because it needs `@import("build_options")`, which
//! only exists when the compilation is driven by `build.zig`. The body compiles
//! on every `zig build test` run, so it cannot rot, and every test skips at run
//! time unless `-Dintegration` is passed. Case 22 additionally needs `-Dlive`.
//!
//! Hermetic: every case works inside one `std.testing.tmpDir`, and no case opens
//! a socket or resolves a name. Case 22 is the single exception and it is
//! guarded separately.
//!
//! Two mechanisms resolve the same paths here. `std.Io.Dir` calls go through the
//! temporary directory handle, while SQLite resolves its filenames through its
//! own VFS, which knows nothing about directory handles. Every path handed to
//! the database layer is therefore built relative to the process working
//! directory, which is what `Fixture.root` is for.

const std = @import("std");
const builtin = @import("builtin");
const build_options = @import("build_options");
const Writer = std.Io.Writer;

const cli = @import("../cli.zig");
const config_export = @import("../config/export.zig");
const import = @import("../config/import.zig");
const model = @import("../config/model.zig");
const validate = @import("../config/validate.zig");
const db = @import("db.zig");
const migrations = @import("migrations.zig");
const querylog_migrations = @import("querylog_migrations.zig");
const querylog_schema = @import("querylog_schema.zig");
const querylog_versions = @import("querylog_versions.zig");

const testing = std.testing;

/// `std.testing.tmpDir` creates its directory against `std.testing.io`, so every
/// call into the code under test uses the same `Io` instance.
const io = testing.io;

// ---------------------------------------------------------------------------
// fixture
// ---------------------------------------------------------------------------

/// Where `std.testing.tmpDir` puts its directories (`lib/std/testing.zig:634`).
const tmp_prefix = ".zig-cache/tmp/";

const sub_path_len = @typeInfo(@FieldType(testing.TmpDir, "sub_path")).array.len;

const path_buf_len = 256;
const file_limit: std.Io.Limit = .limited(8 * 1024 * 1024);

const Fixture = struct {
    tmp: testing.TmpDir,
    root_buf: [tmp_prefix.len + sub_path_len]u8,

    fn init() Fixture {
        var self: Fixture = .{
            .tmp = testing.tmpDir(.{ .iterate = true }),
            .root_buf = undefined,
        };
        @memcpy(self.root_buf[0..tmp_prefix.len], tmp_prefix);
        @memcpy(self.root_buf[tmp_prefix.len..], &self.tmp.sub_path);
        return self;
    }

    fn deinit(self: *Fixture) void {
        self.tmp.cleanup();
    }

    /// The temporary directory as a path relative to the process working
    /// directory.
    fn root(self: *const Fixture) []const u8 {
        return &self.root_buf;
    }

    fn path(self: *const Fixture, buf: []u8, name: []const u8) ![]const u8 {
        return std.fmt.bufPrint(buf, "{s}/{s}", .{ self.root(), name });
    }

    fn pathZ(self: *const Fixture, buf: []u8, name: []const u8) ![:0]const u8 {
        return std.fmt.bufPrintZ(buf, "{s}/{s}", .{ self.root(), name });
    }

    fn write(self: *const Fixture, name: []const u8, data: []const u8) !void {
        return self.tmp.dir.writeFile(io, .{ .sub_path = name, .data = data });
    }

    fn read(self: *const Fixture, name: []const u8) ![]u8 {
        return self.tmp.dir.readFileAlloc(io, name, testing.allocator, file_limit);
    }

    fn exists(self: *const Fixture, name: []const u8) !bool {
        self.tmp.dir.access(io, name, .{}) catch |e| switch (e) {
            error.FileNotFound => return false,
            else => |other| return other,
        };
        return true;
    }
};

/// An open, migrated `config.db` inside the fixture, reached exactly the way the
/// CLI reaches it.
const Data = struct {
    dir: cli.DataDir,
    database: db.Db,

    fn deinit(self: *Data) void {
        self.database.close();
        self.dir.close(io, testing.allocator);
    }
};

fn openDataDir(f: *const Fixture, name: []const u8) !cli.DataDir {
    var buf: [path_buf_len]u8 = undefined;
    const dir_path = try f.path(&buf, name);
    return cli.DataDir.open(io, testing.allocator, dir_path, true);
}

fn openMigrated(f: *const Fixture, name: []const u8) !Data {
    var dir = try openDataDir(f, name);
    errdefer dir.close(io, testing.allocator);

    var database = try dir.openConfigDb(io);
    errdefer database.close();
    _ = try migrations.migrate(&database);

    return .{ .dir = dir, .database = database };
}

fn importInto(f: *const Fixture, data: *Data, file: []const u8, allow_delete: bool) !void {
    var diags: validate.Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    return import.importFile(
        io,
        testing.allocator,
        &data.database,
        f.tmp.dir,
        file,
        .{ .allow_delete = allow_delete },
        &diags,
    );
}

fn expectMode(f: *const Fixture, name: []const u8, expected: std.posix.mode_t) !void {
    const stat = try f.tmp.dir.statFile(io, name, .{});
    const mode = stat.permissions.toMode() & 0o777;
    if (mode != expected) {
        std.debug.print("mode of '{s}' is {o}, expected {o}\n", .{ name, mode, expected });
        return error.TestUnexpectedResult;
    }
}

/// Running as root defeats a permission test: root bypasses the mode bits, the
/// open succeeds and the case proves nothing. Skipping is honest; asserting
/// would be a false pass.
fn runningAsRoot() bool {
    return switch (builtin.os.tag) {
        .linux => std.os.linux.geteuid() == 0,
        else => false,
    };
}

const Captured = struct {
    out: Writer.Allocating,
    err: Writer.Allocating,

    fn init() Captured {
        return .{ .out = .init(testing.allocator), .err = .init(testing.allocator) };
    }

    fn deinit(self: *Captured) void {
        self.out.deinit();
        self.err.deinit();
    }

    fn runner(self: *Captured) cli.Runner {
        return .{
            .io = io,
            .gpa = testing.allocator,
            .out = &self.out.writer,
            .err = &self.err.writer,
        };
    }
};

fn countLines(text: []const u8) usize {
    return std.mem.count(u8, text, "\n");
}

// ---------------------------------------------------------------------------
// querylog aside files
// ---------------------------------------------------------------------------

const aside_prefix = "querylog.db.";

const Names = struct {
    items: std.ArrayList([]u8),

    fn deinit(self: *Names) void {
        for (self.items.items) |name| testing.allocator.free(name);
        self.items.deinit(testing.allocator);
    }
};

fn collectAsides(f: *const Fixture) !Names {
    var names: Names = .{ .items = .empty };
    errdefer names.deinit();

    var it = f.tmp.dir.iterate();
    while (try it.next(io)) |entry| {
        if (!std.mem.startsWith(u8, entry.name, aside_prefix)) continue;
        try names.items.append(testing.allocator, try testing.allocator.dupe(u8, entry.name));
    }
    return names;
}

/// Writes `value` into `PRAGMA user_version` without touching anything else, so
/// the file stays a healthy database that merely carries the wrong fingerprint.
fn stampUserVersion(path: [:0]const u8, value: i32) !void {
    var database = try db.Db.open(path, .{ .mode = .read_write_existing });
    defer database.close();
    var buf: [64]u8 = undefined;
    const sql = try std.fmt.bufPrintZ(&buf, "PRAGMA user_version = {d};", .{value});
    try database.exec(sql);
}

fn createQuerylog(f: *const Fixture) !void {
    var buf: [path_buf_len]u8 = undefined;
    const path = try f.pathZ(&buf, "querylog.db");
    var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
    result.database.close();
    try testing.expectEqual(querylog_schema.RecreateReason.missing, result.recreated.?);
}

/// Holds `querylog.db` locked against every other connection, the way a second
/// nxdns process running on the same data directory would.
///
/// `BEGIN EXCLUSIVE` on its own does not do this. The file is in WAL mode, where
/// one writer and any number of readers coexist by design, so the probing `open`
/// would read straight past it. `PRAGMA locking_mode = EXCLUSIVE`, set before
/// this connection touches the file, makes SQLite take an exclusive lock on the
/// file itself and keep it until the connection closes. The holder writes
/// nothing, so the bytes on disk are unchanged for the case to compare against.
const LockHolder = struct {
    database: db.Db,
    held: bool,

    fn take(path: [:0]const u8) !LockHolder {
        var database = try db.Db.open(path, .{ .mode = .read_write_existing });
        errdefer database.close();
        try database.exec("PRAGMA locking_mode = EXCLUSIVE;");
        try database.exec("BEGIN EXCLUSIVE;");
        return .{ .database = database, .held = true };
    }

    /// Idempotent, so the case can `defer` it and still release early. A failed
    /// ROLLBACK is not worth reporting here: closing the connection drops the
    /// lock either way, which is the only thing this function owes the case.
    fn release(self: *LockHolder) void {
        if (!self.held) return;
        self.held = false;
        self.database.exec("ROLLBACK;") catch {};
        self.database.close();
    }
};

// ---------------------------------------------------------------------------
// configuration fixtures
// ---------------------------------------------------------------------------

/// The smallest configuration that validates.
const minimal_config =
    \\.{
    \\    .groups = .{ .{ .name = "default" } },
    \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
    \\}
;

/// A second valid configuration, distinguishable from `minimal_config` by one
/// query on `upstreams`.
const other_config =
    \\.{
    \\    .groups = .{ .{ .name = "default" } },
    \\    .upstreams = .{ .{ .url = "https://other.example/dns-query" } },
    \\}
;

/// Exercises every collection and several non-default scalars, so the byte-
/// stable round trip has something to be stable about.
const rich_config =
    \\.{
    \\    .dns = .{ .port = 5353 },
    \\    .logging = .{ .level = .err, .retention_days = 7 },
    \\    .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaGhhc2g" },
    \\    .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 },
    \\    },
    \\    .clients = .{ .{ .ip = "fd00::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 = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow },
    \\    },
    \\    .local_records = .{
    \\        .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 600 },
    \\        .{ .name = "nas.lan", .rtype = .aaaa, .value = "fd00::10" },
    \\    },
    \\    .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.1:53" } },
    \\}
;

/// Valid ZON, two validation problems: `dns.port` is 0 and `blocking.ttl`
/// exceeds a day.
const two_problem_config =
    \\.{
    \\    .dns = .{ .port = 0 },
    \\    .blocking = .{ .ttl = 90000 },
    \\    .groups = .{ .{ .name = "default" } },
    \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
    \\}
;

/// The same two problems as a `Config`, for the seeding path that skips the
/// validator.
const two_problem_model: model.Config = .{
    .dns = .{ .port = 0 },
    .blocking = .{ .ttl = 90000 },
    .groups = &.{.{ .name = "default" }},
    .upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
};

const broken_zon = ".{ .groups = ";

const export_header_line = "// nxdns configuration\n";

// ---------------------------------------------------------------------------
// case 1-7 and 23: the querylog recreate policy
// ---------------------------------------------------------------------------

test "S7 case 1: querylog open on a fresh directory creates the schema" {
    if (!build_options.integration) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();

    var buf: [path_buf_len]u8 = undefined;
    const path = try f.pathZ(&buf, "querylog.db");

    var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
    defer result.database.close();

    try testing.expectEqual(querylog_schema.RecreateReason.missing, result.recreated.?);
    try testing.expectEqual(
        @as(i64, querylog_versions.current_version),
        try result.database.queryInt("PRAGMA user_version"),
    );
    try testing.expectEqual(
        @as(i64, 1),
        try result.database.queryInt("SELECT count(*) FROM sqlite_schema WHERE name = 'domains'"),
    );
    try testing.expectEqual(
        @as(i64, 1),
        try result.database.queryInt("SELECT count(*) FROM sqlite_schema WHERE name = 'query_log'"),
    );
}

test "S7 case 2: reopening a healthy querylog recreates nothing" {
    if (!build_options.integration) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();
    try createQuerylog(&f);

    var buf: [path_buf_len]u8 = undefined;
    const path = try f.pathZ(&buf, "querylog.db");

    var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
    defer result.database.close();

    try testing.expectEqual(@as(?querylog_schema.RecreateReason, null), result.recreated);

    var asides = try collectAsides(&f);
    defer asides.deinit();
    try testing.expectEqual(@as(usize, 0), asides.items.items.len);
}

test "S7 case 3: a version this build cannot handle refuses and touches nothing" {
    if (!build_options.integration) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();
    try createQuerylog(&f);

    var buf: [path_buf_len]u8 = undefined;
    const path = try f.pathZ(&buf, "querylog.db");

    querylog_migrations.expected_failures.begin();
    defer querylog_migrations.expected_failures.end();

    // Both refusal lanes, against a checkpointed file with no sidecars beside
    // it: a stamp above this build's version (a downgrade) and a stamp that is
    // not a version at all (a pre-0.0.12 fingerprint, or a foreign file).
    const refusals = [_]struct { stamp: i32, expected: anyerror }{
        .{ .stamp = querylog_versions.current_version + 1, .expected = error.SchemaTooNew },
        .{ .stamp = 0, .expected = error.SchemaUnsupported },
        .{ .stamp = -7, .expected = error.SchemaUnsupported },
        .{ .stamp = 603440875, .expected = error.SchemaUnsupported },
    };

    for (refusals) |lane| {
        try stampUserVersion(path, lane.stamp);
        try testing.expect(!try f.exists("querylog.db-wal"));

        const original = try f.read("querylog.db");
        defer testing.allocator.free(original);

        try testing.expectError(
            lane.expected,
            querylog_schema.open(io, std.Io.Dir.cwd(), path),
        );

        // Byte-identical, not merely "still readable": nothing was rewritten,
        // no aside was made, and no fresh database was created beside it.
        const after = try f.read("querylog.db");
        defer testing.allocator.free(after);
        try testing.expectEqualSlices(u8, original, after);

        var asides = try collectAsides(&f);
        defer asides.deinit();
        try testing.expectEqual(@as(usize, 0), asides.items.items.len);
    }
}

test "S7 case 4: a garbage file recreates and the garbage is preserved" {
    if (!build_options.integration) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();
    try createQuerylog(&f);

    var garbage: [4096]u8 = undefined;
    @memset(&garbage, 0xab);
    try f.write("querylog.db", &garbage);

    var buf: [path_buf_len]u8 = undefined;
    const path = try f.pathZ(&buf, "querylog.db");

    var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
    defer result.database.close();

    const reason = result.recreated.?;
    try testing.expect(reason == .not_a_database or reason == .corrupt);

    var asides = try collectAsides(&f);
    defer asides.deinit();
    try testing.expectEqual(@as(usize, 1), asides.items.items.len);

    const expected: []const u8 = if (reason == .corrupt)
        "querylog.db.corrupt-"
    else
        "querylog.db.not-a-database-";
    try testing.expect(std.mem.startsWith(u8, asides.items.items[0], expected));

    const kept = try f.read(asides.items.items[0]);
    defer testing.allocator.free(kept);
    try testing.expectEqualSlices(u8, &garbage, kept);
}

test "S7 case 5: two recreates in the same second produce two distinct aside files" {
    if (!build_options.integration) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();
    try createQuerylog(&f);

    var buf: [path_buf_len]u8 = undefined;
    const path = try f.pathZ(&buf, "querylog.db");

    var round: usize = 0;
    while (round < 2) : (round += 1) {
        try f.write("querylog.db", "not a database at all");
        var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
        defer result.database.close();
        try testing.expectEqual(
            querylog_schema.RecreateReason.not_a_database,
            result.recreated.?,
        );
    }

    var asides = try collectAsides(&f);
    defer asides.deinit();
    try testing.expectEqual(@as(usize, 2), asides.items.items.len);
    try testing.expect(!std.mem.eql(u8, asides.items.items[0], asides.items.items[1]));
}

test "S7 case 6: a stale write-ahead log is removed before the fresh database is created" {
    if (!build_options.integration) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();
    try createQuerylog(&f);

    var buf: [path_buf_len]u8 = undefined;
    const path = try f.pathZ(&buf, "querylog.db");
    try f.write("querylog.db", "not a database at all");

    // Existence alone proves nothing: the fresh database turns WAL on again and
    // writes its own `-wal`. The marker is what distinguishes the stale file
    // from the new one.
    const marker = "NXDNS-STALE-WAL-MARKER";
    try f.write("querylog.db-wal", marker ** 16);

    var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
    defer result.database.close();
    try testing.expectEqual(
        querylog_schema.RecreateReason.not_a_database,
        result.recreated.?,
    );

    if (try f.exists("querylog.db-wal")) {
        const wal = try f.read("querylog.db-wal");
        defer testing.allocator.free(wal);
        try testing.expectEqual(@as(usize, 0), std.mem.count(u8, wal, marker));
    }
}

test "S7 case 7: an unreadable querylog propagates the error and is never destroyed" {
    if (!build_options.integration) return error.SkipZigTest;
    if (runningAsRoot()) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();
    try createQuerylog(&f);

    const original = try f.read("querylog.db");
    defer testing.allocator.free(original);

    var buf: [path_buf_len]u8 = undefined;
    const path = try f.pathZ(&buf, "querylog.db");

    try f.tmp.dir.setFilePermissions(io, "querylog.db", .fromMode(0o000), .{});
    const result = querylog_schema.open(io, std.Io.Dir.cwd(), path);
    try f.tmp.dir.setFilePermissions(io, "querylog.db", .fromMode(0o600), .{});

    try testing.expectError(error.CantOpen, result);

    const after = try f.read("querylog.db");
    defer testing.allocator.free(after);
    try testing.expectEqualSlices(u8, original, after);

    var asides = try collectAsides(&f);
    defer asides.deinit();
    try testing.expectEqual(@as(usize, 0), asides.items.items.len);
}

test "S7 case 23: a locked querylog propagates Busy and is never destroyed" {
    if (!build_options.integration) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();
    try createQuerylog(&f);

    const original = try f.read("querylog.db");
    defer testing.allocator.free(original);

    var buf: [path_buf_len]u8 = undefined;
    const path = try f.pathZ(&buf, "querylog.db");

    var holder = try LockHolder.take(path);
    defer holder.release();

    // The conflict surfaces inside `applyPragmas`, on `PRAGMA journal_mode =
    // WAL`. `querylog_schema.open` takes `db.OpenOptions`' default 5000 ms busy
    // timeout, so the call waits the timeout out before it reports the
    // conflict. Waiting is the behaviour under test, and shortening it would
    // mean adding a timeout knob to production code for the test's benefit, so
    // this case costs about five seconds.
    if (querylog_schema.open(io, std.Io.Dir.cwd(), path)) |result| {
        var opened = result;
        opened.database.close();
        return error.TestUnexpectedResult;
    } else |e| {
        try testing.expect(e == error.Busy or e == error.Locked);
    }

    try testing.expect(try f.exists("querylog.db"));

    const after = try f.read("querylog.db");
    defer testing.allocator.free(after);
    try testing.expectEqualSlices(u8, original, after);

    var asides = try collectAsides(&f);
    defer asides.deinit();
    try testing.expectEqual(@as(usize, 0), asides.items.items.len);

    // The same file, once the lock is gone, is opened without a recreate: the
    // failure above was transient and left nothing behind that would force one.
    holder.release();

    var reopened = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
    defer reopened.database.close();
    try testing.expectEqual(@as(?querylog_schema.RecreateReason, null), reopened.recreated);
    try testing.expectEqual(
        @as(i64, querylog_versions.current_version),
        try reopened.database.queryInt("PRAGMA user_version"),
    );

    var asides_after = try collectAsides(&f);
    defer asides_after.deinit();
    try testing.expectEqual(@as(usize, 0), asides_after.items.items.len);
}

// ---------------------------------------------------------------------------
// case 8-10: config.db, permissions and the schema stamp
// ---------------------------------------------------------------------------

test "S7 case 8: DataDir.open creates the data directory at mode 0700" {
    if (!build_options.integration) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();

    var data = try openDataDir(&f, "data");
    defer data.close(io, testing.allocator);

    try expectMode(&f, "data", 0o700);
}

test "S7 case 9: config.db and its write-ahead log are mode 0600" {
    if (!build_options.integration) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();

    var data = try openMigrated(&f, "data");
    defer data.deinit();

    try expectMode(&f, "data/config.db", 0o600);
    if (try f.exists("data/config.db-wal")) {
        try expectMode(&f, "data/config.db-wal", 0o600);
    }
}

test "S7 case 10: a config.db stamped one version ahead is refused and left alone" {
    if (!build_options.integration) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();

    {
        var data = try openMigrated(&f, "data");
        defer data.deinit();

        var stmt = try data.database.prepare("UPDATE schema_version SET version = ?1");
        defer stmt.deinit();
        try stmt.bindInt(1, @as(i64, migrations.target_version) + 1);
        try stmt.exec();
    }

    const before = try f.read("data/config.db");
    defer testing.allocator.free(before);

    {
        var dir = try openDataDir(&f, "data");
        defer dir.close(io, testing.allocator);
        var database = try dir.openConfigDb(io);
        defer database.close();
        try testing.expectError(error.SchemaTooNew, migrations.migrate(&database));
    }

    const after = try f.read("data/config.db");
    defer testing.allocator.free(after);
    try testing.expectEqualSlices(u8, before, after);
}

// ---------------------------------------------------------------------------
// case 11-19: export and import on real files
// ---------------------------------------------------------------------------

test "S7 case 11: an exported file is mode 0600 and starts with the header comment" {
    if (!build_options.integration) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();
    try f.write("config.zon", rich_config);

    var data = try openMigrated(&f, "data");
    defer data.deinit();
    try importInto(&f, &data, "config.zon", false);

    try config_export.writeToFile(io, testing.allocator, &data.database, f.tmp.dir, "out.zon");

    try expectMode(&f, "out.zon", 0o600);
    const text = try f.read("out.zon");
    defer testing.allocator.free(text);
    try testing.expect(std.mem.startsWith(u8, text, export_header_line));
}

test "S7 case 12: export, import and export again are byte-identical files" {
    if (!build_options.integration) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();
    try f.write("config.zon", rich_config);

    {
        var first = try openMigrated(&f, "one");
        defer first.deinit();
        try importInto(&f, &first, "config.zon", false);
        try config_export.writeToFile(io, testing.allocator, &first.database, f.tmp.dir, "a.zon");
    }
    {
        var second = try openMigrated(&f, "two");
        defer second.deinit();
        try importInto(&f, &second, "a.zon", false);
        try config_export.writeToFile(io, testing.allocator, &second.database, f.tmp.dir, "b.zon");
    }

    const a = try f.read("a.zon");
    defer testing.allocator.free(a);
    const b = try f.read("b.zon");
    defer testing.allocator.free(b);
    try testing.expectEqualStrings(a, b);
}

test "S7 case 13: import refuses a diff that deletes rows unless --allow-delete is given" {
    if (!build_options.integration) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();
    try f.write("first.zon", minimal_config);
    try f.write("second.zon", other_config);

    var data = try openMigrated(&f, "data");
    defer data.deinit();
    try importInto(&f, &data, "first.zon", false);

    // The two files name different upstream urls, and a url is the upstream's
    // identity: applying the second deletes the first's row, which is what the
    // gate exists to stop.
    try testing.expectError(error.DestructiveImport, importInto(&f, &data, "second.zon", false));
    try testing.expectEqual(
        @as(i64, 1),
        try data.database.queryInt(
            "SELECT count(*) FROM upstreams WHERE url = 'https://dns.example/dns-query'",
        ),
    );

    try importInto(&f, &data, "second.zon", true);
    try testing.expectEqual(
        @as(i64, 1),
        try data.database.queryInt(
            "SELECT count(*) FROM upstreams WHERE url = 'https://other.example/dns-query'",
        ),
    );
    try testing.expectEqual(@as(i64, 1), try data.database.queryInt("SELECT count(*) FROM upstreams"));
}

test "S7 case 14: an invalid import reports every problem and writes nothing" {
    if (!build_options.integration) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();
    try f.write("config.zon", two_problem_config);

    var data = try openMigrated(&f, "data");
    defer data.deinit();

    var diags: validate.Diagnostics = .init(testing.allocator);
    defer diags.deinit();

    if (import.importFile(
        io,
        testing.allocator,
        &data.database,
        f.tmp.dir,
        "config.zon",
        .{ .allow_delete = false },
        &diags,
    )) |_| {
        return error.TestUnexpectedResult;
    } else |_| {}

    try testing.expectEqual(@as(usize, 2), diags.problems.items.len);
    try testing.expectEqual(@as(i64, 0), try data.database.queryInt("SELECT count(*) FROM upstreams"));
    try testing.expectEqual(@as(i64, 0), try data.database.queryInt("SELECT count(*) FROM settings"));

    // Nothing beyond the database and its sidecars was created.
    var dir = try f.tmp.dir.openDir(io, "data", .{ .iterate = true });
    defer dir.close(io);
    var it = dir.iterate();
    while (try it.next(io)) |entry| {
        try testing.expect(std.mem.startsWith(u8, entry.name, cli.config_db_name));
    }
}

test "S7 case 19: writeToFile replaces an existing file and restores mode 0600" {
    if (!build_options.integration) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();
    try f.write("config.zon", rich_config);

    var data = try openMigrated(&f, "data");
    defer data.deinit();
    try importInto(&f, &data, "config.zon", false);

    const stale = "stale content that must not survive\n";
    try f.write("out.zon", stale);

    try config_export.writeToFile(io, testing.allocator, &data.database, f.tmp.dir, "out.zon");

    const text = try f.read("out.zon");
    defer testing.allocator.free(text);
    try testing.expectEqual(@as(usize, 0), std.mem.count(u8, text, stale));
    try testing.expect(std.mem.startsWith(u8, text, export_header_line));
    try expectMode(&f, "out.zon", 0o600);
}

// ---------------------------------------------------------------------------
// case 20-21: the CLI entry functions end to end
// ---------------------------------------------------------------------------

test "S7 case 20: runImport and runExport reproduce the byte-stable round trip" {
    if (!build_options.integration) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();
    try f.write("config.zon", rich_config);

    var captured: Captured = .init();
    defer captured.deinit();
    const r = captured.runner();

    var one_buf: [path_buf_len]u8 = undefined;
    const one = try f.path(&one_buf, "one");
    var two_buf: [path_buf_len]u8 = undefined;
    const two = try f.path(&two_buf, "two");
    var config_buf: [path_buf_len]u8 = undefined;
    const config_path = try f.path(&config_buf, "config.zon");
    var a_buf: [path_buf_len]u8 = undefined;
    const a_path = try f.path(&a_buf, "a.zon");
    var b_buf: [path_buf_len]u8 = undefined;
    const b_path = try f.path(&b_buf, "b.zon");

    try testing.expectEqual(
        cli.exit_ok,
        cli.runImport(r, .{ .paths = .{ .data_dir = one }, .file = config_path }),
    );
    try testing.expectEqual(
        cli.exit_ok,
        cli.runExport(r, .{ .paths = .{ .data_dir = one }, .out = a_path }),
    );
    try testing.expectEqual(
        cli.exit_ok,
        cli.runImport(r, .{ .paths = .{ .data_dir = two }, .file = a_path }),
    );
    try testing.expectEqual(
        cli.exit_ok,
        cli.runExport(r, .{ .paths = .{ .data_dir = two }, .out = b_path }),
    );
    try testing.expectEqualStrings("", captured.err.written());

    const a = try f.read("a.zon");
    defer testing.allocator.free(a);
    const b = try f.read("b.zon");
    defer testing.allocator.free(b);
    try testing.expectEqualStrings(a, b);
    try expectMode(&f, "a.zon", 0o600);
    try expectMode(&f, "one", 0o700);
}

test "S7 case 21: runCheck passes a seeded database and reports two stored problems" {
    if (!build_options.integration) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();
    try f.write("config.zon", rich_config);

    var good_buf: [path_buf_len]u8 = undefined;
    const good = try f.path(&good_buf, "good");
    var bad_buf: [path_buf_len]u8 = undefined;
    const bad = try f.path(&bad_buf, "bad");

    {
        var data = try openMigrated(&f, "good");
        defer data.deinit();
        try importInto(&f, &data, "config.zon", false);
    }
    {
        // `apply` rather than an import: the validator would refuse this
        // configuration, and the case needs the problems to reach the database.
        var data = try openMigrated(&f, "bad");
        defer data.deinit();
        var diags: validate.Diagnostics = .init(testing.allocator);
        defer diags.deinit();
        try import.apply(io, testing.allocator, &data.database, two_problem_model, 42, .{}, &diags);
    }

    {
        var captured: Captured = .init();
        defer captured.deinit();
        try testing.expectEqual(
            cli.exit_ok,
            cli.runCheck(captured.runner(), .{ .paths = .{ .data_dir = good } }, false),
        );
        try testing.expect(std.mem.count(u8, captured.out.written(), "OK: no problems found") == 1);
    }
    {
        var captured: Captured = .init();
        defer captured.deinit();
        try testing.expectEqual(
            cli.exit_check,
            cli.runCheck(captured.runner(), .{ .paths = .{ .data_dir = bad } }, false),
        );
        const text = captured.out.written();
        // One "checking database …" line plus one line per problem.
        try testing.expectEqual(@as(usize, 3), countLines(text));
        try testing.expect(std.mem.count(u8, text, "dns.port:") == 1);
        try testing.expect(std.mem.count(u8, text, "blocking.ttl:") == 1);
    }
}

// ---------------------------------------------------------------------------
// case 22: live
// ---------------------------------------------------------------------------

// Leaves the machine, so it is `-Dlive` only. A failure here is an environment
// finding, not a gate (the milestone-1 and milestone-3 convention).
test "S7 case 22: runCheck probes a real upstream and prints an OK line" {
    if (!build_options.live) return error.SkipZigTest;

    var f: Fixture = .init();
    defer f.deinit();
    try f.write("config.zon",
        \\.{
        \\    .groups = .{ .{ .name = "default" } },
        \\    .upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
        \\}
    );

    var config_buf: [path_buf_len]u8 = undefined;
    const config_path = try f.path(&config_buf, "config.zon");

    var captured: Captured = .init();
    defer captured.deinit();

    const code = cli.runCheck(
        captured.runner(),
        .{ .config = config_path },
        true,
    );
    try testing.expectEqual(cli.exit_ok, code);
    // Milestone 13 changed the probe line to the redacted `OK upstreams[i]`
    // form; this expectation went stale unnoticed because nothing ran -Dlive
    // between then and milestone 20.
    try testing.expect(std.mem.count(
        u8,
        captured.out.written(),
        "OK upstreams[0] https://cloudflare-dns.com\n",
    ) == 1);
}
