//! Milestone-6 integration tests (spec S8): the phase-6 components against real
//! files, a real `querylog.db`, a real filesystem sample and a real log sink.
//!
//! 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 case skips at run
//! time unless `-Dintegration` is passed.
//!
//! Hermetic: every case works inside one `std.testing.tmpDir` and none of them
//! opens a socket or resolves a name.
//!
//! Two mechanisms resolve the same paths here. `std.Io.Dir` calls go through the
//! temporary directory handle, while SQLite and the log sink resolve their
//! filenames through the process working directory. Every path handed to those
//! two is therefore built from `Fixture.root`.

const std = @import("std");
const build_options = @import("build_options");

const dns_cache = @import("../cache/dns_cache.zig");
const address = @import("../platform/address.zig");
const logging = @import("../platform/logging.zig");
const packet = @import("../dns/packet.zig");
const rate_limiter = @import("../server/rate_limiter.zig");
const db = @import("db.zig");
const disk_monitor = @import("disk_monitor.zig");
const logger = @import("logger.zig");
const queries_repo = @import("repositories/queries_repo.zig");
const querylog_schema = @import("querylog_schema.zig");
const querylog_versions = @import("querylog_versions.zig");
const retention = @import("retention.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. That instance is
/// an `Io.Threaded` (`lib/std/testing.zig:34`), which is what makes
/// `io.concurrent` available to the writer-task cases.
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 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 rootZ(self: *const Fixture, buf: []u8) ![:0]const u8 {
        return std.fmt.bufPrintZ(buf, "{s}", .{self.root()});
    }

    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;
    }

    fn sizeOf(self: *const Fixture, name: []const u8) !u64 {
        const stat = try self.tmp.dir.statFile(io, name, .{});
        return stat.size;
    }
};

/// A fresh `querylog.db` inside the fixture, opened the way the daemon opens it.
const QueryLog = struct {
    opened: querylog_schema.OpenResult,

    fn create(f: *const Fixture) !QueryLog {
        var buf: [path_buf_len]u8 = undefined;
        const path = try f.pathZ(&buf, "querylog.db");
        return .{ .opened = try querylog_schema.open(io, std.Io.Dir.cwd(), path) };
    }

    fn deinit(self: *QueryLog) void {
        self.opened.database.close();
    }

    fn database(self: *QueryLog) *db.Db {
        return &self.opened.database;
    }
};

fn entryAt(timestamp: i64, domain: []const u8) logger.Entry {
    return .init(.{
        .timestamp = timestamp,
        .domain = domain,
        .client_ip = "192.0.2.10",
        .qtype = 1,
        .blocked = false,
        .response_time_us = 1200,
        .cache_hit = false,
        .upstream = "9.9.9.9",
    });
}

const poll_interval: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };

/// Waits for `counter` to reach `target`, up to `limit` polls of 5 ms. A
/// deadline that passes is a failure rather than slowness: every case here is
/// bounded well below the 2 s the spec allows.
fn awaitCount(counter: *const std.atomic.Value(u64), target: u64, limit: usize) !void {
    var polls: usize = 0;
    while (counter.load(.monotonic) < target) : (polls += 1) {
        try testing.expect(polls < limit);
        try poll_interval.sleep(io);
    }
}

fn writeRows(database: *db.Db, timestamps: []const i64, domain: []const u8) !void {
    var writer = try queries_repo.BatchWriter.init(testing.allocator, database);
    defer writer.deinit();

    var rows: [16]queries_repo.Row = undefined;
    for (timestamps, rows[0..timestamps.len]) |timestamp, *row| {
        row.* = .{
            .timestamp = timestamp,
            .domain = domain,
            .client_ip = "192.0.2.10",
            .qtype = 1,
            .qclass = 1,
            .rcode = 0,
            .blocked = false,
            .response_time_us = null,
            .cache_hit = null,
            .upstream = null,
            .group_id = 1,
            .group_name = "default",
            .policy_action = .allow,
            .policy_reason = .no_match,
            .matched = null,
            .source_id = null,
            .source_name = null,
            .cname_target = null,
            .safe_search_target = null,
            .route_kind = .upstream,
            .forward_zone = null,
        };
    }
    try writer.writeBatch(rows[0..timestamps.len]);
}

/// The timestamps in the log, oldest first.
fn readTimestamps(database: *db.Db, out: []i64) ![]i64 {
    var stmt = try database.prepare("SELECT timestamp FROM query_log ORDER BY timestamp");
    defer stmt.deinit();
    var n: usize = 0;
    while (try stmt.step()) : (n += 1) {
        if (n == out.len) return error.TestUnexpectedResult;
        out[n] = stmt.columnInt(0);
    }
    return out[0..n];
}

// ---------------------------------------------------------------------------
// case 1-6: the query logger, retention and the disk gate on a real database
// ---------------------------------------------------------------------------

test "S8 case 1: the logger writes a real querylog.db end to end" {
    if (!build_options.integration) return error.SkipZigTest;

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

    var log_db = try QueryLog.create(&f);
    defer log_db.deinit();
    try testing.expectEqual(querylog_schema.RecreateReason.missing, log_db.opened.recreated.?);

    var queue_buf: [512]logger.Entry = undefined;
    var query_log: logger.Logger = .init(.{}, &queue_buf);

    var future = try io.concurrent(logger.Logger.runWriter, .{
        &query_log,
        io,
        testing.allocator,
        log_db.database(),
        @as(?*disk_monitor.Monitor, null),
    });
    // Declared after the `log_db.deinit` defer so LIFO stops the writer first:
    // an early return below would otherwise close the database under a live
    // writer and leave the never-closed queue parking it forever.
    defer {
        query_log.shutdown(io);
        future.await(io) catch {};
    }

    var name_buf: [32]u8 = undefined;
    for (0..250) |i| {
        const domain = try std.fmt.bufPrint(&name_buf, "d{d}.example", .{i % 10});
        query_log.log(io, entryAt(@intCast(i), domain));
    }
    query_log.shutdown(io);
    try future.await(io);

    try testing.expectEqual(@as(u64, 0), query_log.queries_dropped.load(.monotonic));
    try testing.expectEqual(@as(u64, 250), query_log.rows_written.load(.monotonic));
    try testing.expectEqual(@as(i64, 250), try queries_repo.countRows(log_db.database()));
    try testing.expectEqual(@as(i64, 10), try queries_repo.countDomains(log_db.database()));
    try testing.expectEqual(
        @as(i64, querylog_versions.current_version),
        try log_db.database().queryInt("PRAGMA user_version"),
    );
}

test "S8 case 2: a single entry reaches the file once the flush interval passes" {
    if (!build_options.integration) return error.SkipZigTest;

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

    var log_db = try QueryLog.create(&f);
    defer log_db.deinit();

    var queue_buf: [8]logger.Entry = undefined;
    // A one-second window, not the 60-second default: this case is about the
    // entry reaching the file on the interval alone, with nothing to close the
    // queue for it.
    const flush_interval_s = 1;
    var query_log: logger.Logger = .init(.{ .query_log_flush_interval_s = flush_interval_s }, &queue_buf);

    var future = try io.concurrent(logger.Logger.runWriter, .{
        &query_log,
        io,
        testing.allocator,
        log_db.database(),
        @as(?*disk_monitor.Monitor, null),
    });
    // Declared after the `log_db.deinit` defer so LIFO stops the writer first:
    // an early return below would otherwise close the database under a live
    // writer and leave the never-closed queue parking it forever.
    defer {
        query_log.shutdown(io);
        future.await(io) catch {};
    }

    query_log.log(io, entryAt(1, "only.example"));

    // Three flush intervals of headroom: a row that has not landed by then is a
    // failure of the interval race, not a slow machine.
    const limit = 3 * flush_interval_s * 1000 / 5;
    try awaitCount(&query_log.rows_written, 1, limit);
    try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(log_db.database()));

    query_log.shutdown(io);
    try future.await(io);
}

test "S8 case 3: a full queue drops the oldest entries and the newest survive" {
    if (!build_options.integration) return error.SkipZigTest;

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

    var log_db = try QueryLog.create(&f);
    defer log_db.deinit();

    var root_buf: [path_buf_len]u8 = undefined;
    var monitor: disk_monitor.Monitor = .init(
        .{},
        f.tmp.dir,
        try f.rootZ(&root_buf),
        null,
    );
    monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);

    var queue_buf: [8]logger.Entry = undefined;
    // Zero interval: this case is about the queue cap and the gate, so the
    // writer takes what is queued and goes straight to the gate.
    var query_log: logger.Logger = .init(.{ .query_log_flush_interval_s = 0 }, &queue_buf);

    // The whole burst is enqueued before the writer starts. A writer already
    // draining the queue would take entries out of it mid-burst and make the
    // number of drops depend on the scheduler.
    for (0..20) |i| query_log.log(io, entryAt(@intCast(i), "burst.example"));
    try testing.expectEqual(@as(u64, 12), query_log.queries_dropped.load(.monotonic));

    var future = try io.concurrent(logger.Logger.runWriter, .{
        &query_log,
        io,
        testing.allocator,
        log_db.database(),
        @as(?*disk_monitor.Monitor, &monitor),
    });
    // Declared after the `log_db.deinit` defer so LIFO stops the writer first:
    // an early return below would otherwise close the database under a live
    // writer and leave the never-closed queue parking it forever.
    defer {
        query_log.shutdown(io);
        future.await(io) catch {};
    }

    try awaitCount(&query_log.batches_gated, 1, 200);
    try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(log_db.database()));

    // Un-gate before the shutdown: a writer still held by the gate counts its
    // batch as dropped rather than writing it, which is a different case (the
    // logger's own suite covers it).
    monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
    try awaitCount(&query_log.rows_written, 8, 300);
    query_log.shutdown(io);
    try future.await(io);

    var stamps: [16]i64 = undefined;
    const kept = try readTimestamps(log_db.database(), &stamps);
    try testing.expectEqual(@as(usize, 8), kept.len);
    for (kept, 0..) |stamp, i| {
        try testing.expectEqual(@as(i64, @intCast(i + 12)), stamp);
    }
}

test "S8 case 4: the privacy transforms reach the stored rows" {
    if (!build_options.integration) return error.SkipZigTest;

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

    var log_db = try QueryLog.create(&f);
    defer log_db.deinit();

    var queue_buf: [64]logger.Entry = undefined;
    var query_log: logger.Logger = .init(
        .{ .hide_domains = true, .hide_client_ips = true },
        &queue_buf,
    );

    var future = try io.concurrent(logger.Logger.runWriter, .{
        &query_log,
        io,
        testing.allocator,
        log_db.database(),
        @as(?*disk_monitor.Monitor, null),
    });
    // Declared after the `log_db.deinit` defer so LIFO stops the writer first:
    // an early return below would otherwise close the database under a live
    // writer and leave the never-closed queue parking it forever.
    defer {
        query_log.shutdown(io);
        future.await(io) catch {};
    }

    var name_buf: [32]u8 = undefined;
    for (0..20) |i| {
        const domain = try std.fmt.bufPrint(&name_buf, "private{d}.example", .{i});
        query_log.log(io, entryAt(@intCast(i), domain));
    }
    query_log.shutdown(io);
    try future.await(io);

    try testing.expectEqual(@as(i64, 20), try queries_repo.countRows(log_db.database()));
    // Every name collapsed onto the marker, so the dimension table holds one row.
    try testing.expectEqual(@as(i64, 1), try queries_repo.countDomains(log_db.database()));
    try testing.expectEqual(
        @as(i64, 20),
        try log_db.database().queryInt(
            \\SELECT count(*) FROM query_log q JOIN domains d ON d.id = q.domain_id
            \\ WHERE d.domain = 'hidden' AND q.client_ip = 'hidden'
        ),
    );
}

test "S8 case 5: a retention pass prunes the old rows and truncates the write-ahead log" {
    if (!build_options.integration) return error.SkipZigTest;

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

    var log_db = try QueryLog.create(&f);
    defer log_db.deinit();

    const now = std.Io.Clock.real.now(io).toSeconds();
    const day = 86_400;
    try writeRows(log_db.database(), &.{ now - 40 * day, now - 31 * day }, "old.example");
    try writeRows(log_db.database(), &.{ now - 3 * day, now - 60 }, "fresh.example");
    try testing.expectEqual(@as(i64, 4), try queries_repo.countRows(log_db.database()));
    try testing.expect(try f.sizeOf("querylog.db-wal") > 0);

    var pass_days: retention.RetentionDays = .init(30);
    var pass: retention.Retention = .init(&pass_days);
    pass.runOnce(io, log_db.database(), null, null);

    try testing.expectEqual(@as(u64, 1), pass.snapshotStats().passes);
    try testing.expectEqual(@as(u64, 2), pass.snapshotStats().rows_pruned);
    try testing.expectEqual(@as(u64, 1), pass.snapshotStats().checkpoints);
    try testing.expectEqual(@as(u64, 0), pass.snapshotStats().vacuums);
    try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(log_db.database()));
    // Both names stay: the dimension table is not collected.
    try testing.expectEqual(@as(i64, 2), try queries_repo.countDomains(log_db.database()));

    if (try f.exists("querylog.db-wal")) {
        try testing.expectEqual(@as(u64, 0), try f.sizeOf("querylog.db-wal"));
    }
}

test "S8 case 6: a critical disk gates the flushes and recovery releases them" {
    if (!build_options.integration) return error.SkipZigTest;

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

    var log_db = try QueryLog.create(&f);
    defer log_db.deinit();

    var root_buf: [path_buf_len]u8 = undefined;
    const data_path = try f.rootZ(&root_buf);

    // No filesystem holds this much free space, so the sample classifies
    // critical against the real `statvfs` reading rather than a stub.
    const unreachable_mb = std.math.maxInt(u32);
    var monitor: disk_monitor.Monitor = .init(
        .{ .min_free_mb = unreachable_mb, .warn_free_mb = unreachable_mb },
        f.tmp.dir,
        data_path,
        null,
    );
    monitor.sample(io, null, 0);
    try testing.expectEqual(disk_monitor.State.critical, monitor.state());
    try testing.expect(!monitor.writesAllowed());
    try testing.expect(monitor.gauges().free_bytes > 0);
    try testing.expect(monitor.gauges().db_bytes > 0);

    var queue_buf: [64]logger.Entry = undefined;
    // Zero interval: the case is the gate's hold and release, so the writer
    // takes the five queued entries straight to it.
    var query_log: logger.Logger = .init(.{ .query_log_flush_interval_s = 0 }, &queue_buf);
    for (0..5) |i| query_log.log(io, entryAt(@intCast(i), "gated.example"));

    var future = try io.concurrent(logger.Logger.runWriter, .{
        &query_log,
        io,
        testing.allocator,
        log_db.database(),
        @as(?*disk_monitor.Monitor, &monitor),
    });
    // Declared after the `log_db.deinit` defer so LIFO stops the writer first:
    // an early return below would otherwise close the database under a live
    // writer and leave the never-closed queue parking it forever.
    defer {
        query_log.shutdown(io);
        future.await(io) catch {};
    }

    try awaitCount(&query_log.batches_gated, 1, 200);
    try testing.expectEqual(@as(u64, 0), query_log.rows_written.load(.monotonic));
    try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(log_db.database()));

    monitor.setThresholds(.{ .min_free_mb = 0, .warn_free_mb = 0 });
    monitor.sample(io, null, 0);
    try testing.expectEqual(disk_monitor.State.ok, monitor.state());
    try testing.expect(monitor.writesAllowed());

    // The gate re-reads the monitor once per `gate_retry_s`, so the release
    // costs at most that one second.
    const limit = (logger.gate_retry_s * 1000 + 500) / 5;
    try awaitCount(&query_log.rows_written, 5, limit);

    query_log.shutdown(io);
    try future.await(io);

    try testing.expect(query_log.batches_gated.load(.monotonic) > 0);
    try testing.expectEqual(@as(i64, 5), try queries_repo.countRows(log_db.database()));
    try testing.expectEqual(@as(u64, 0), monitor.sample_failures.load(.monotonic));
}

// ---------------------------------------------------------------------------
// case 7: the log sink against a real file
// ---------------------------------------------------------------------------

test "S8 case 7: the log sink appends, rotates and honours max_files" {
    if (!build_options.integration) return error.SkipZigTest;

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

    var path_buf: [path_buf_len]u8 = undefined;
    const log_path = try f.path(&path_buf, "nxdns.log");

    const before = logging.stats();

    // `install` would take the 1 MiB floor of `max_size_mb`, which is a
    // megabyte of writes per generation; `installForTest` overrides that one
    // threshold and nothing else.
    const max_bytes = 256;
    logging.installForTest(io, .{
        .level = .info,
        .output = .file,
        .file_path = log_path,
        .max_files = 3,
    }, max_bytes);
    defer logging.deinstall();

    // `logFn` is called directly: the test runner installs its own
    // `std_options`, so a `std.log` call here would never reach this sink.
    for (0..40) |i| logging.logFn(.warn, .s8_sink, "rotation line {d}", .{i});
    for (0..5) |i| logging.logFn(.info, .s8_sink, "tail line {d}", .{i});
    // Below the configured threshold, so it is filtered rather than written.
    logging.logFn(.debug, .s8_sink, "never written", .{});

    logging.deinstall();

    const after = logging.stats();
    try testing.expectEqual(@as(u64, 45), after.lines_written - before.lines_written);
    try testing.expect(after.rotations > before.rotations);
    try testing.expectEqual(@as(u64, 0), after.sink_errors - before.sink_errors);
    try testing.expectEqual(@as(u64, 0), after.lines_deduped - before.lines_deduped);

    try testing.expect(try f.exists("nxdns.log"));
    try testing.expect(try f.sizeOf("nxdns.log") <= max_bytes);
    try testing.expect(try f.exists("nxdns.log.1"));
    try testing.expect(try f.exists("nxdns.log.2"));
    // `max_files` counts the live file, so generation 3 is never created.
    try testing.expect(!try f.exists("nxdns.log.3"));

    // The live file holds the newest lines, and it is reopened rather than
    // truncated: a second install appends behind what is already there.
    const kept = try f.tmp.dir.readFileAlloc(io, "nxdns.log", testing.allocator, .limited(4096));
    defer testing.allocator.free(kept);
    try testing.expect(std.mem.count(u8, kept, "tail line 4") == 1);
    try testing.expect(std.mem.count(u8, kept, "(s8_sink)") >= 1);

    const live_bytes = kept.len;
    logging.installForTest(io, .{
        .level = .info,
        .output = .file,
        .file_path = log_path,
        .max_files = 3,
    }, max_bytes * 16);
    logging.logFn(.info, .s8_sink, "after reopen", .{});
    logging.deinstall();

    const reopened = try f.tmp.dir.readFileAlloc(io, "nxdns.log", testing.allocator, .limited(8192));
    defer testing.allocator.free(reopened);
    try testing.expect(reopened.len > live_bytes);
    try testing.expectEqualStrings(kept, reopened[0..live_bytes]);
    try testing.expect(std.mem.count(u8, reopened, "after reopen") == 1);
}

// ---------------------------------------------------------------------------
// case 8-9: the pure components against real packets and real addresses
// ---------------------------------------------------------------------------

/// A NOERROR response for example.com A carrying one answer per TTL.
fn buildAnswer(buf: []u8, ttls: []const u32) ![]u8 {
    const query = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
        "\x07example\x03com\x00\x00\x01\x00\x01";
    const request = try packet.parse(query);
    const q = packet.firstQuestion(request).?;

    var builder = try packet.ResponseBuilder.init(buf, request.header, q);
    for (ttls) |ttl| {
        try builder.addAnswer(q.name, .a, .in, ttl, "\x0a\x00\x00\x01");
    }
    return builder.finish();
}

fn firstAnswerTtl(bytes: []const u8) !u32 {
    const p = try packet.parse(bytes);
    var it = packet.answers(p);
    return (try it.next()).?.ttl;
}

test "S8 case 8: the cache ages a real response and expires it at the boundary" {
    if (!build_options.integration) return error.SkipZigTest;

    var response_buf: [512]u8 = undefined;
    const response = try buildAnswer(&response_buf, &.{ 300, 600 });
    const class = dns_cache.classify(response, 3600).?;
    try testing.expectEqual(@as(u32, 300), class.ttl_seconds);
    try testing.expectEqual(false, class.negative);

    var cache = try dns_cache.DnsCache.init(testing.allocator, .{ .size = 16, .negative_ttl_max = 3600 });
    defer cache.deinit();

    var key_buf: [dns_cache.max_key_len]u8 = undefined;
    const key = dns_cache.buildKey(&key_buf, "example.com", 1, 1, false, null);

    try cache.put(1000, key, response, class);
    try testing.expectEqual(@as(u32, 1), cache.len());

    var out: [512]u8 = undefined;
    const hit = cache.get(1120, key, &out).?;
    try testing.expectEqual(response.len, hit.len);
    try testing.expectEqual(@as(u32, 180), try firstAnswerTtl(hit));

    // The stored copy keeps its own age, so a later hit ages from the same base.
    const later = cache.get(1290, key, &out).?;
    try testing.expectEqual(@as(u32, 10), try firstAnswerTtl(later));

    // The transaction ID is the caller's to set, and the aged bytes still parse.
    packet.setId(later, 0xbeef);
    try testing.expectEqual(@as(u16, 0xbeef), (try packet.parse(later)).header.id);

    // The entry expires at stored_at + ttl, and that second is already too late.
    try testing.expectEqual(@as(?[]u8, null), cache.get(1300, key, &out));
    try testing.expectEqual(@as(u32, 0), cache.len());
    try testing.expectEqual(@as(u64, 2), cache.stats.hits);
    try testing.expectEqual(@as(u64, 1), cache.stats.expirations);
}

test "S8 case 9: the limiter refuses the query past the limit and only that client" {
    if (!build_options.integration) return error.SkipZigTest;

    var limiter = try rate_limiter.RateLimiter.init(
        testing.allocator,
        .{ .limit = 1000, .window_seconds = 60 },
    );
    defer limiter.deinit();

    const mapped = address.NetAddress.fromIp(
        try std.Io.net.IpAddress.parse("::ffff:192.168.1.40", 53),
    ).key();
    const plain = (try address.NetAddress.parse("192.168.1.40")).key();
    // One client, whichever family the socket reported it under.
    try testing.expectEqualSlices(u8, &plain, &mapped);

    const start: std.Io.Timestamp = .{ .nanoseconds = 1 << 80 };
    for (0..1000) |i| {
        const now: std.Io.Timestamp = .{ .nanoseconds = start.nanoseconds + @as(i96, @intCast(i)) };
        try testing.expect(limiter.check(now, mapped));
    }
    try testing.expect(!limiter.check(start, plain));

    try testing.expectEqual(@as(u64, 1000), limiter.stats.allowed);
    try testing.expectEqual(@as(u64, 1), limiter.stats.refused);
    try testing.expectEqual(@as(u64, 0), limiter.stats.untracked);
    try testing.expectEqual(@as(u32, 1), limiter.trackedClients());

    const other = (try address.NetAddress.parse("fd00::40")).key();
    try testing.expect(limiter.check(start, other));
    try testing.expectEqual(@as(u32, 2), limiter.trackedClients());

    // The next window admits the refused client again.
    const next: std.Io.Timestamp = .{ .nanoseconds = start.nanoseconds + 60 * std.time.ns_per_s };
    try testing.expect(limiter.check(next, mapped));
    try testing.expectEqual(@as(u64, 1), limiter.stats.refused);
}
