//! Client names learned over reverse DNS (milestone 25).
//!
//! The tracker materialises every querying address into a `clients` row, but
//! `name` stays NULL until the operator types one. This resolver asks the box
//! the operator already declared for the LAN's reverse space: it builds the
//! address's reverse name, matches it against the live forward zones, sends one
//! PTR query to that zone's resolver, and stores the answer in `learned_name` —
//! runtime state beside `last_seen`, never configuration.
//!
//! A reverse name no declared forward zone covers is sent to nobody: not the
//! upstream pool, not any resolver. What the milestone guarantees is that the
//! name reaches only the resolver of a zone the operator declared; where that
//! resolver points is the operator's declaration, exactly as it is for forward
//! queries.
//!
//! `runPass` is the last step of the tracker's flush pass, on the tracker's task
//! against the tracker's dedicated connection. A DNS query never waits on
//! naming, and naming never runs concurrently with itself. The pass is bounded:
//! at most `clients_repo.max_per_pass` candidates, one exchange in flight, and
//! `ptr_read_timeout` per exchange.
//!
//! PTR answers are untrusted bytes from whatever box the operator pointed a zone
//! at. Nothing reaches the database, a log line or the UI without passing
//! `reverse_name.acceptHostname`.

const std = @import("std");

const address = @import("../platform/address.zig");
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const db = @import("../storage/db.zig");
const dns_header = @import("../dns/header.zig");
const edns = @import("../dns/edns.zig");
const events = @import("../storage/events.zig");
const forward_client = @import("../local/forward_client.zig");
const local_tables = @import("local_tables.zig");
const name_mod = @import("../dns/name.zig");
const packet = @import("../dns/packet.zig");
const question = @import("../dns/question.zig");
const record = @import("../dns/record.zig");
const reverse_name = @import("../local/reverse_name.zig");
const transport = @import("../upstream/transport.zig");
const types = @import("../dns/types.zig");
const validate = @import("../config/validate.zig");

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

/// A query is a header, one name and the two fixed fields.
const query_buf_len = types.header_len + types.max_name_len + 4;
/// One PTR answer never approaches this; the ceiling exists so a resolver that
/// answers with something enormous fails the exchange rather than this buffer.
const response_buf_len = 2048;

/// What one attempt decided. `attempted` is the sum of these five, which a test
/// asserts.
pub const Outcome = enum { answered, nxdomain, no_zone, invalid, failed };

/// The exchange seam. Production code cannot inject into `ForwardClient` — it is
/// a concrete struct this module constructs — so the seam sits above it. Tests
/// replace the function pointer; `defaultExchange` is what the server runs.
pub const ExchangeFn = *const fn (
    io: std.Io,
    resolver: validate.Resolver,
    query: []const u8,
    response_buf: []u8,
) transport.ExchangeError![]u8;

pub const Resolver = struct {
    /// Naming's own read budget on the `.awake` clock. It deliberately does not
    /// inherit the handler's `forward_read_timeout`, which the operator may have
    /// set as high as the whole upstream budget.
    pub const ptr_read_timeout: std.Io.Clock.Duration = .{
        .raw = .fromSeconds(2),
        .clock = .awake,
    };

    /// Next attempt after a definitive outcome (`answered`, `nxdomain`): the
    /// daily refresh that tracks DHCP renames.
    pub const refresh_after_s: i64 = 86_400;
    /// Next attempt after a non-definitive one (`no_zone`, `failed`, `invalid`):
    /// an operator who declares the missing zone or fixes the resolver sees
    /// names within the hour, and a dead resolver costs at most
    /// `clients_repo.max_per_pass` timeouts per hour.
    pub const retry_after_s: i64 = 3_600;

    /// The six outcome counters, plus two that sit outside the outcome sum:
    /// `read_failures` (the candidate SELECT failed, so the pass skipped naming)
    /// and `write_failures` (the row update failed, but the outcome was still
    /// counted).
    pub const Stats = struct {
        attempted: u64 = 0,
        answered: u64 = 0,
        nxdomain: u64 = 0,
        no_zone: u64 = 0,
        invalid: u64 = 0,
        failed: u64 = 0,
        read_failures: u64 = 0,
        write_failures: u64 = 0,
    };

    /// Guards `stats` only. Every other field is written once at construction.
    mutex: std.Io.Mutex = .init,
    /// The live forward zones, read under `acquire`/`release` so naming always
    /// sees the generation the operator last published.
    tables: *local_tables.LocalTables,
    stats: Stats = .{},
    exchange_fn: ExchangeFn = defaultExchange,
    /// Wired by the composition root after `init`, following the
    /// `gate: ?*disk_monitor.Monitor` idiom. Null in every unit test here.
    diagnostics: ?*events.Store = null,

    pub fn init(tables: *local_tables.LocalTables) Resolver {
        return .{ .tables = tables };
    }

    pub fn snapshotStats(self: *Resolver, io: std.Io) Stats {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);
        return self.stats;
    }

    /// One naming pass: at most `clients_repo.max_per_pass` candidate rows,
    /// resolved serially, each written back through one repo call.
    ///
    /// Nothing here returns an error. A failed read skips the pass, a failed
    /// write leaves the row to be attempted again, and both count. Each logs at
    /// most one `warn` per pass.
    pub fn runPass(self: *Resolver, io: std.Io, database: *db.Db, now_s: i64) void {
        var candidates: clients_repo.CandidateBuf = undefined;
        const count = clients_repo.resolveCandidates(database, &candidates, now_s) catch |err| {
            log.warn("selecting clients to name failed: {s}", .{@errorName(err)});
            self.mutex.lockUncancelable(io);
            self.stats.read_failures += 1;
            self.mutex.unlock(io);
            self.reportStorage(io, now_s, "read", "selecting clients to name failed", @errorName(err), 1);
            return;
        };
        if (self.diagnostics) |store| store.resolve(io, now_s, .client_names_storage, "read");

        var write_failures: u64 = 0;
        var last_write_error: []const u8 = "";
        for (candidates[0..count]) |*candidate| {
            var learned_buf: [types.max_name_len]u8 = undefined;
            const result = self.attempt(io, candidate.ip(), &learned_buf);

            const attempt_after = nextAttempt(now_s, result.outcome);
            if (clients_repo.noteNameOutcome(database, candidate.ip(), .{
                .attempt_after = attempt_after,
                .learned = result.learned,
            })) |_| {} else |err| {
                if (write_failures == 0) {
                    log.warn("recording the name of {s} failed: {s}", .{ candidate.ip(), @errorName(err) });
                }
                write_failures += 1;
                last_write_error = @errorName(err);
            }

            self.mutex.lockUncancelable(io);
            self.stats.attempted += 1;
            switch (result.outcome) {
                .answered => self.stats.answered += 1,
                .nxdomain => self.stats.nxdomain += 1,
                .no_zone => self.stats.no_zone += 1,
                .invalid => self.stats.invalid += 1,
                .failed => self.stats.failed += 1,
            }
            self.mutex.unlock(io);
        }

        // The clean-pass determination is what closes a `write` episode: one
        // aggregated outcome per pass, so a row's `occurrences` counts failing
        // passes rather than failing rows.
        if (write_failures == 0) {
            if (self.diagnostics) |store| store.resolve(io, now_s, .client_names_storage, "write");
            return;
        }
        self.mutex.lockUncancelable(io);
        self.stats.write_failures += write_failures;
        self.mutex.unlock(io);
        self.reportStorage(io, now_s, "write", "recording a client name failed", last_write_error, write_failures);
    }

    /// One aggregated report per pass. `count` is how many failures that pass
    /// held; the detail carries it because the row itself counts passes.
    fn reportStorage(
        self: *Resolver,
        io: std.Io,
        now_s: i64,
        operation: []const u8,
        message: []const u8,
        error_name: []const u8,
        count: u64,
    ) void {
        const store = self.diagnostics orelse return;
        var buf: [events.Store.max_detail_len]u8 = undefined;
        const detail = std.fmt.bufPrint(&buf, "{s}: {s} ({d} this pass)", .{
            message,
            error_name,
            count,
        }) catch buf[0..];
        store.report(io, now_s, .client_names_storage, operation, operation, .warning, detail);
    }

    const Attempt = struct {
        outcome: Outcome,
        learned: clients_repo.LearnedName,
    };

    /// One address: reverse name, zone match, exchange, classification. Any
    /// learned text lives in `learned_buf`, which the caller owns until the row
    /// is written.
    fn attempt(self: *Resolver, io: std.Io, ip: []const u8, learned_buf: *[types.max_name_len]u8) Attempt {
        // A stored address nxdns did not write. Nothing can be asked about it.
        const addr = address.NetAddress.parse(ip) catch return .{ .outcome = .failed, .learned = .keep };

        var reverse_buf: [reverse_name.max_reverse_len]u8 = undefined;
        const reverse = reverse_name.reverseName(addr, &reverse_buf);

        // The zone's resolver is copied by value under the shared lock and the
        // handle is released before any socket work: `match` returns a pointer
        // into the current generation, and `swap` frees that generation as soon
        // as it holds the exclusive lock.
        const resolver: validate.Resolver = resolver: {
            const handle = self.tables.acquire(io);
            defer handle.release(io);
            const zone = handle.zones.match(reverse) orelse
                return .{ .outcome = .no_zone, .learned = .keep };
            break :resolver zone.resolver;
        };

        const query_name = name_mod.fromText(reverse) catch
            return .{ .outcome = .failed, .learned = .keep };

        var query_buf: [query_buf_len]u8 = undefined;
        const query = encodeQuery(io, query_name, &query_buf) catch
            return .{ .outcome = .failed, .learned = .keep };

        var response_buf: [response_buf_len]u8 = undefined;
        const reply = self.exchange_fn(io, resolver, query, &response_buf) catch
            return .{ .outcome = .failed, .learned = .keep };

        return classify(query_name, reply, learned_buf);
    }
};

/// A definitive outcome refreshes daily; everything else retries within the
/// hour. Saturating, so a clock far in the future cannot overflow the column.
fn nextAttempt(now_s: i64, outcome: Outcome) i64 {
    const delta: i64 = switch (outcome) {
        .answered, .nxdomain => Resolver.refresh_after_s,
        .no_zone, .invalid, .failed => Resolver.retry_after_s,
    };
    return now_s +| delta;
}

/// One question, RD set, a random id from `io.random` — `std.crypto.random` does
/// not exist in 0.16.0. The id is visible to a test through the exchange seam,
/// so nothing else is injected for determinism.
fn encodeQuery(io: std.Io, query_name: name_mod.Name, buf: *[query_buf_len]u8) std.Io.Writer.Error![]const u8 {
    var id_bytes: [2]u8 = undefined;
    io.random(&id_bytes);

    dns_header.encode(.{
        .id = std.mem.readInt(u16, &id_bytes, .big),
        .flags = .{
            .rcode = .no_error,
            .z = 0,
            .ra = false,
            .rd = true,
            .tc = false,
            .aa = false,
            .opcode = .query,
            .qr = false,
        },
        .qdcount = 1,
        .ancount = 0,
        .nscount = 0,
        .arcount = 0,
    }, buf[0..types.header_len]);

    var w: std.Io.Writer = .fixed(buf[types.header_len..]);
    try question.encode(.{ .name = query_name, .qtype = .ptr, .qclass = .in }, &w);
    return buf[0 .. types.header_len + w.buffered().len];
}

/// The three validation layers of ruling 4. Layer 1 (ID and question echo) ran
/// inside the exchange; layers 2 and 3 are here.
fn classify(
    query_name: name_mod.Name,
    reply: []const u8,
    learned_buf: *[types.max_name_len]u8,
) Resolver.Attempt {
    const parsed = packet.parse(reply) catch return .{ .outcome = .failed, .learned = .keep };

    // The RCODE is the full 12-bit value: the header's four bits extended by the
    // OPT record's upper eight (RFC 6891 §6.1.3). Classifying on the header
    // nibble alone would store or clear a name on a reply the resolver called an
    // error — a NOERROR header under, say, extended RCODE 1 (BADVERS's neighbour
    // in the extended space) carries no usable answer. An OPT that does not
    // parse makes the whole reply malformed.
    const opt: ?edns.OptRecord = if (packet.findOptRecord(parsed)) |rec|
        edns.parseOpt(reply, rec) catch return .{ .outcome = .failed, .learned = .keep }
    else
        null;

    switch (edns.extendedRcode(parsed.header.flags.rcode, opt)) {
        // The router affirmatively says the address has no name. Keeping a stale
        // one would show a device under its previous owner's hostname.
        @intFromEnum(types.Rcode.nx_domain) => return .{ .outcome = .nxdomain, .learned = .clear },
        @intFromEnum(types.Rcode.no_error) => {},
        // REFUSED, FORMERR, SERVFAIL, every extended value, anything unknown: an
        // outage must not strip names from the whole dashboard.
        else => return .{ .outcome = .failed, .learned = .keep },
    }

    var answers = packet.answers(parsed);
    while (answers.next() catch return .{ .outcome = .failed, .learned = .keep }) |rec| {
        // Layer 2: the first PTR record of class IN whose owner name is the one
        // that was asked about. A record failing any of these is skipped, not an
        // error; records after the first accepted one are ignored.
        if (rec.rtype != .ptr) continue;
        if (rec.class != @intFromEnum(types.Class.in)) continue;
        if (!name_mod.eqlIgnoreCase(rec.name, query_name)) continue;

        // Layer 3: the target decodes and looks like a hostname.
        const target = record.rdataCname(reply, rec) catch
            return .{ .outcome = .invalid, .learned = .keep };

        var w: std.Io.Writer = .fixed(learned_buf);
        name_mod.formatText(target, &w) catch
            return .{ .outcome = .invalid, .learned = .keep };
        const text = w.buffered();
        for (learned_buf[0..text.len]) |*byte| byte.* = std.ascii.toLower(byte.*);
        if (!reverse_name.acceptHostname(text)) return .{ .outcome = .invalid, .learned = .keep };

        return .{ .outcome = .answered, .learned = .{ .store = text } };
    }

    // NOERROR with no accepted record is NODATA: the router says as
    // affirmatively as NXDOMAIN does that the address carries no name, so the
    // outcome is definitive and clears.
    return .{ .outcome = .nxdomain, .learned = .clear };
}

/// The production exchange: a stack `ForwardClient` at `ptr_read_timeout`. The
/// client's own `debug` diagnostics predate this milestone's no-log rule and
/// stand as they are.
///
/// `ForwardClient` hands its duration to the UDP receive and then again to the
/// TCP fallback, so a resolver that answers UDP late with TC=1 and then stalls
/// TCP would spend up to twice the budget on one attempt. `raceWithin` — the
/// mechanism `exchangeTcp` itself uses (forward_client.zig:195) — puts UDP,
/// the fallback and all under one deadline, which is what makes 16 × 2 s a
/// bound on the pass rather than an estimate.
fn defaultExchange(
    io: std.Io,
    resolver: validate.Resolver,
    query: []const u8,
    response_buf: []u8,
) transport.ExchangeError![]u8 {
    return exchangeWithin(io, Resolver.ptr_read_timeout, resolver, query, response_buf);
}

/// The budget is a parameter rather than the constant so that a test can put a
/// short one in front of the same code: the deadline is the thing under test,
/// and a case that waits out two real seconds twice over is a case nobody runs.
fn exchangeWithin(
    io: std.Io,
    budget: std.Io.Clock.Duration,
    resolver: validate.Resolver,
    query: []const u8,
    response_buf: []u8,
) transport.ExchangeError![]u8 {
    return transport.raceWithin(io, budget, exchangeOnce, .{ io, budget, resolver, query, response_buf });
}

fn exchangeOnce(
    io: std.Io,
    budget: std.Io.Clock.Duration,
    resolver: validate.Resolver,
    query: []const u8,
    response_buf: []u8,
) transport.ExchangeError![]u8 {
    var frame_buf: [forward_client.min_frame_buf]u8 = undefined;
    var client: forward_client.ForwardClient = .init(resolver, &frame_buf, budget);
    return client.exchange(io, query, response_buf);
}

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

const forward_zones = @import("../local/forward_zones.zig");
const migrations = @import("../storage/migrations.zig");
const events_fixture = @import("../storage/events_fixture.zig");
const testing = std.testing;

/// A stub exchange whose reply and error are set by the test. `calls` is the
/// assertion for "no query was sent to anyone".
const Stub = struct {
    var calls: usize = 0;
    var last_query: [query_buf_len]u8 = undefined;
    var last_query_len: usize = 0;
    var replies: []const []const u8 = &.{};
    var fail: ?transport.ExchangeError = null;

    fn reset() void {
        calls = 0;
        last_query_len = 0;
        replies = &.{};
        fail = null;
    }

    fn exchange(
        _: std.Io,
        _: validate.Resolver,
        query: []const u8,
        response_buf: []u8,
    ) transport.ExchangeError![]u8 {
        @memcpy(last_query[0..query.len], query);
        last_query_len = query.len;
        const index = calls;
        calls += 1;
        if (fail) |err| return err;
        const reply = replies[@min(index, replies.len - 1)];
        // The id is the query's, so `validateResponse`-shaped checks in the
        // production path would pass on these bytes too.
        @memcpy(response_buf[0..reply.len], reply);
        std.mem.writeInt(u16, response_buf[0..2], std.mem.readInt(u16, query[0..2], .big), .big);
        return response_buf[0..reply.len];
    }
};

/// Builds a PTR reply for `owner`: `rcode`, and one record per entry in
/// `records`. Written into `buf`, which the caller keeps alive.
const ReplyRecord = struct {
    owner: []const u8,
    rtype: types.Type = .ptr,
    class: u16 = 1,
    target: []const u8,
};

fn buildReply(
    buf: []u8,
    question_name: []const u8,
    rcode: types.Rcode,
    records: []const ReplyRecord,
) ![]const u8 {
    var w: std.Io.Writer = .fixed(buf);
    var head: [types.header_len]u8 = undefined;
    dns_header.encode(.{
        .id = 0,
        .flags = .{
            .rcode = rcode,
            .z = 0,
            .ra = true,
            .rd = true,
            .tc = false,
            .aa = false,
            .opcode = .query,
            .qr = true,
        },
        .qdcount = 1,
        .ancount = @intCast(records.len),
        .nscount = 0,
        .arcount = 0,
    }, &head);
    try w.writeAll(&head);

    try question.encode(.{
        .name = try name_mod.fromText(question_name),
        .qtype = .ptr,
        .qclass = .in,
    }, &w);

    for (records) |r| {
        var rdata_buf: [types.max_name_len]u8 = undefined;
        var rdata_w: std.Io.Writer = .fixed(&rdata_buf);
        try name_mod.encode(try name_mod.fromText(r.target), &rdata_w);
        try record.encode(.{
            .name = try name_mod.fromText(r.owner),
            .rtype = r.rtype,
            .class = r.class,
            .ttl = 300,
            .rdata = .{ .offset = 0, .len = 0 },
        }, rdata_w.buffered(), &w);
    }
    return w.buffered();
}

/// `buildReply` plus an OPT record carrying `extended_rcode`, so a reply can say
/// NOERROR in its header and something else in the twelve bits that count.
fn buildReplyWithOpt(
    buf: []u8,
    question_name: []const u8,
    rcode: types.Rcode,
    records: []const ReplyRecord,
    extended_rcode: u8,
) ![]const u8 {
    const without_opt = try buildReply(buf, question_name, rcode, records);
    var w: std.Io.Writer = .fixed(buf[without_opt.len..]);
    try edns.encodeOpt(.{
        .udp_payload_size = 1232,
        .extended_rcode = extended_rcode,
        .version = 0,
        .do_bit = false,
        .options = .{ .offset = 0, .len = 0 },
    }, &.{}, &w);

    const total = without_opt.len + w.buffered().len;
    var h = try dns_header.parse(buf[0..total]);
    h.arcount = 1;
    dns_header.encode(h, buf[0..types.header_len]);
    return buf[0..total];
}

const Fixture = struct {
    threaded: std.Io.Threaded,
    database: db.Db,
    tables: local_tables.LocalTables,
    resolver: Resolver,

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

    fn deinit(self: *Fixture) void {
        self.tables.deinit(testing.allocator);
        self.database.close();
        self.threaded.deinit();
    }

    fn learned(self: *Fixture, ip: []const u8, buf: []u8) !?[]const u8 {
        var stmt = try self.database.prepare("SELECT learned_name FROM clients WHERE ip = ?1");
        defer stmt.deinit();
        try stmt.bindText(1, ip);
        try testing.expect(try stmt.step());
        if (stmt.isNull(0)) return null;
        const text = stmt.columnText(0);
        @memcpy(buf[0..text.len], text);
        return buf[0..text.len];
    }
};

/// A fixture whose forward zones cover `168.192.in-addr.arpa`, with the stub
/// wired in.
fn fixture(f: *Fixture, zone: ?[]const u8) !void {
    f.threaded = .init(testing.allocator, .{});
    f.database = try db.Db.open(":memory:", .{ .mode = .memory });
    try db.applyPragmas(&f.database, .{});
    _ = try migrations.migrate(&f.database);
    f.tables = .empty;
    if (zone) |z| {
        f.tables.swap(f.threaded.io(), testing.allocator, .empty, try forward_zones.Zones.build(
            testing.allocator,
            &.{.{ .zone = z, .resolver = "udp://192.168.1.1:53" }},
        ));
    }
    f.resolver = .init(&f.tables);
    f.resolver.exchange_fn = Stub.exchange;
    Stub.reset();
}

fn attemptAfterOf(database: *db.Db, ip: []const u8) !i64 {
    var stmt = try database.prepare("SELECT name_attempt_after FROM clients WHERE ip = ?1");
    defer stmt.deinit();
    try stmt.bindText(1, ip);
    try testing.expect(try stmt.step());
    return stmt.columnInt(0);
}

test "an answered PTR stores the target lowercased and refreshes daily" {
    var f: Fixture = undefined;
    try fixture(&f, "168.192.in-addr.arpa");
    defer f.deinit();

    try clients_repo.upsertSeen(&f.database, "192.168.1.10", 1700000000);

    var reply_buf: [512]u8 = undefined;
    const reply = try buildReply(&reply_buf, "10.1.168.192.in-addr.arpa", .no_error, &.{
        .{ .owner = "10.1.168.192.in-addr.arpa", .target = "NAS-1.Lan" },
    });
    const replies = [_][]const u8{reply};
    Stub.replies = &replies;

    f.resolver.runPass(f.io(), &f.database, 1700000000);

    var buf: [253]u8 = undefined;
    try testing.expectEqualStrings("nas-1.lan", (try f.learned("192.168.1.10", &buf)).?);
    try testing.expectEqual(
        @as(i64, 1700000000 + Resolver.refresh_after_s),
        try attemptAfterOf(&f.database, "192.168.1.10"),
    );

    const stats = f.resolver.snapshotStats(f.io());
    try testing.expectEqual(@as(u64, 1), stats.attempted);
    try testing.expectEqual(@as(u64, 1), stats.answered);
    try testing.expectEqual(@as(usize, 1), Stub.calls);

    // The query is a PTR question for the reverse name, with RD set.
    const sent = Stub.last_query[0..Stub.last_query_len];
    const parsed = try packet.parse(sent);
    try testing.expect(parsed.header.flags.rd);
    try testing.expectEqual(@as(u16, 1), parsed.header.qdcount);
    const q = packet.firstQuestion(parsed).?;
    try testing.expectEqual(types.Type.ptr, q.qtype);
    try testing.expectEqual(types.Class.in, q.qclass);
    var text: std.Io.Writer.Allocating = .init(testing.allocator);
    defer text.deinit();
    try name_mod.formatText(q.name, &text.writer);
    try testing.expectEqualStrings("10.1.168.192.in-addr.arpa", text.written());
}

test "a second answer overwrites the first" {
    var f: Fixture = undefined;
    try fixture(&f, "168.192.in-addr.arpa");
    defer f.deinit();

    try clients_repo.upsertSeen(&f.database, "192.168.1.10", 1700000000);

    var first_buf: [512]u8 = undefined;
    var second_buf: [512]u8 = undefined;
    const replies = [_][]const u8{
        try buildReply(&first_buf, "10.1.168.192.in-addr.arpa", .no_error, &.{
            .{ .owner = "10.1.168.192.in-addr.arpa", .target = "old.lan" },
        }),
        try buildReply(&second_buf, "10.1.168.192.in-addr.arpa", .no_error, &.{
            .{ .owner = "10.1.168.192.in-addr.arpa", .target = "new.lan" },
        }),
    };
    Stub.replies = &replies;

    f.resolver.runPass(f.io(), &f.database, 1700000000);
    var buf: [253]u8 = undefined;
    try testing.expectEqualStrings("old.lan", (try f.learned("192.168.1.10", &buf)).?);

    f.resolver.runPass(f.io(), &f.database, 1700000000 + Resolver.refresh_after_s);
    try testing.expectEqualStrings("new.lan", (try f.learned("192.168.1.10", &buf)).?);
    try testing.expectEqual(@as(usize, 2), Stub.calls);
}

test "NXDOMAIN and NODATA both clear the learned name" {
    for ([_]types.Rcode{ .nx_domain, .no_error }) |rcode| {
        var f: Fixture = undefined;
        try fixture(&f, "168.192.in-addr.arpa");
        defer f.deinit();

        try clients_repo.upsertSeen(&f.database, "192.168.1.10", 1700000000);
        try clients_repo.noteNameOutcome(&f.database, "192.168.1.10", .{
            .attempt_after = 0,
            .learned = .{ .store = "stale.lan" },
        });

        var reply_buf: [512]u8 = undefined;
        const replies = [_][]const u8{
            try buildReply(&reply_buf, "10.1.168.192.in-addr.arpa", rcode, &.{}),
        };
        Stub.replies = &replies;

        f.resolver.runPass(f.io(), &f.database, 1700000000);

        var buf: [253]u8 = undefined;
        try testing.expectEqual(@as(?[]const u8, null), try f.learned("192.168.1.10", &buf));
        try testing.expectEqual(@as(u64, 1), f.resolver.snapshotStats(f.io()).nxdomain);
        // Definitive: the daily refresh, not the hourly retry.
        try testing.expectEqual(
            @as(i64, 1700000000 + Resolver.refresh_after_s),
            try attemptAfterOf(&f.database, "192.168.1.10"),
        );
    }
}

test "REFUSED, an unknown rcode and a transport failure all keep the stored name" {
    const Case = struct { rcode: ?types.Rcode, fail: ?transport.ExchangeError };
    const cases = [_]Case{
        .{ .rcode = .refused, .fail = null },
        .{ .rcode = @enumFromInt(11), .fail = null },
        .{ .rcode = null, .fail = error.Timeout },
        .{ .rcode = null, .fail = error.ConnectFailed },
    };

    for (cases) |case| {
        var f: Fixture = undefined;
        try fixture(&f, "168.192.in-addr.arpa");
        defer f.deinit();

        try clients_repo.upsertSeen(&f.database, "192.168.1.10", 1700000000);
        try clients_repo.noteNameOutcome(&f.database, "192.168.1.10", .{
            .attempt_after = 0,
            .learned = .{ .store = "keep.lan" },
        });

        var reply_buf: [512]u8 = undefined;
        var replies: [1][]const u8 = undefined;
        if (case.rcode) |rcode| {
            replies[0] = try buildReply(&reply_buf, "10.1.168.192.in-addr.arpa", rcode, &.{});
            Stub.replies = &replies;
        }
        Stub.fail = case.fail;

        f.resolver.runPass(f.io(), &f.database, 1700000000);

        var buf: [253]u8 = undefined;
        try testing.expectEqualStrings("keep.lan", (try f.learned("192.168.1.10", &buf)).?);
        const stats = f.resolver.snapshotStats(f.io());
        try testing.expectEqual(@as(u64, 1), stats.failed);
        try testing.expectEqual(
            @as(i64, 1700000000 + Resolver.retry_after_s),
            try attemptAfterOf(&f.database, "192.168.1.10"),
        );
    }
}

test "a target that fails acceptHostname counts invalid and keeps the stored name" {
    var f: Fixture = undefined;
    try fixture(&f, "168.192.in-addr.arpa");
    defer f.deinit();

    try clients_repo.upsertSeen(&f.database, "192.168.1.10", 1700000000);
    try clients_repo.noteNameOutcome(&f.database, "192.168.1.10", .{
        .attempt_after = 0,
        .learned = .{ .store = "keep.lan" },
    });

    var reply_buf: [512]u8 = undefined;
    const replies = [_][]const u8{
        try buildReply(&reply_buf, "10.1.168.192.in-addr.arpa", .no_error, &.{
            .{ .owner = "10.1.168.192.in-addr.arpa", .target = "-bad.lan" },
        }),
    };
    Stub.replies = &replies;

    f.resolver.runPass(f.io(), &f.database, 1700000000);

    var buf: [253]u8 = undefined;
    try testing.expectEqualStrings("keep.lan", (try f.learned("192.168.1.10", &buf)).?);
    const stats = f.resolver.snapshotStats(f.io());
    try testing.expectEqual(@as(u64, 1), stats.invalid);
    try testing.expectEqual(@as(u64, 0), stats.answered);
}

test "a record with the wrong type, class or owner is skipped" {
    var f: Fixture = undefined;
    try fixture(&f, "168.192.in-addr.arpa");
    defer f.deinit();

    try clients_repo.upsertSeen(&f.database, "192.168.1.10", 1700000000);

    var reply_buf: [1024]u8 = undefined;
    const replies = [_][]const u8{
        try buildReply(&reply_buf, "10.1.168.192.in-addr.arpa", .no_error, &.{
            .{ .owner = "10.1.168.192.in-addr.arpa", .rtype = .cname, .target = "wrongtype.lan" },
            .{ .owner = "10.1.168.192.in-addr.arpa", .class = 3, .target = "wrongclass.lan" },
            .{ .owner = "99.1.168.192.in-addr.arpa", .target = "wrongowner.lan" },
            .{ .owner = "10.1.168.192.IN-ADDR.ARPA", .target = "right.lan" },
            .{ .owner = "10.1.168.192.in-addr.arpa", .target = "second.lan" },
        }),
    };
    Stub.replies = &replies;

    f.resolver.runPass(f.io(), &f.database, 1700000000);

    // The owner name matches case-insensitively, and records after the first
    // accepted one are ignored.
    var buf: [253]u8 = undefined;
    try testing.expectEqualStrings("right.lan", (try f.learned("192.168.1.10", &buf)).?);
}

test "an uncovered reverse name sends nothing to anyone and counts no_zone" {
    var f: Fixture = undefined;
    try fixture(&f, "168.192.in-addr.arpa");
    defer f.deinit();

    try clients_repo.upsertSeen(&f.database, "10.0.0.5", 1700000000);

    f.resolver.runPass(f.io(), &f.database, 1700000000);

    try testing.expectEqual(@as(usize, 0), Stub.calls);
    const stats = f.resolver.snapshotStats(f.io());
    try testing.expectEqual(@as(u64, 1), stats.attempted);
    try testing.expectEqual(@as(u64, 1), stats.no_zone);
    try testing.expectEqual(
        @as(i64, 1700000000 + Resolver.retry_after_s),
        try attemptAfterOf(&f.database, "10.0.0.5"),
    );
}

test "with no zones declared at all nothing is sent" {
    var f: Fixture = undefined;
    try fixture(&f, null);
    defer f.deinit();

    try clients_repo.upsertSeen(&f.database, "192.168.1.10", 1700000000);
    f.resolver.runPass(f.io(), &f.database, 1700000000);

    try testing.expectEqual(@as(usize, 0), Stub.calls);
    try testing.expectEqual(@as(u64, 1), f.resolver.snapshotStats(f.io()).no_zone);
}

test "a named row is never attempted and the candidate cap holds" {
    var f: Fixture = undefined;
    try fixture(&f, "168.192.in-addr.arpa");
    defer f.deinit();

    var reply_buf: [512]u8 = undefined;
    const replies = [_][]const u8{
        // The owner never matches, so every attempt is NODATA — the point here
        // is how many attempts happen, not what they decide.
        try buildReply(&reply_buf, "10.1.168.192.in-addr.arpa", .no_error, &.{}),
    };
    Stub.replies = &replies;

    for (0..clients_repo.max_per_pass + 4) |i| {
        var ip_buf: [64]u8 = undefined;
        const ip = try std.fmt.bufPrint(&ip_buf, "192.168.2.{d}", .{i});
        try clients_repo.upsertSeen(&f.database, ip, 1700000000);
    }
    _ = try clients_repo.insertClientRow(&f.database, .{
        .ip = "192.168.3.1",
        .name = "named",
        .group_id = 1,
    }, 1700000000);

    f.resolver.runPass(f.io(), &f.database, 1700000000);

    try testing.expectEqual(clients_repo.max_per_pass, Stub.calls);
    try testing.expectEqual(@as(u64, clients_repo.max_per_pass), f.resolver.snapshotStats(f.io()).attempted);
    try testing.expectEqual(@as(i64, 0), try attemptAfterOf(&f.database, "192.168.3.1"));
}

test "attempted equals the sum of the five outcome counters" {
    var f: Fixture = undefined;
    try fixture(&f, "168.192.in-addr.arpa");
    defer f.deinit();

    var ok_buf: [512]u8 = undefined;
    var nodata_buf: [512]u8 = undefined;
    var bad_buf: [512]u8 = undefined;
    var refused_buf: [512]u8 = undefined;
    const replies = [_][]const u8{
        try buildReply(&ok_buf, "10.1.168.192.in-addr.arpa", .no_error, &.{
            .{ .owner = "10.1.168.192.in-addr.arpa", .target = "nas.lan" },
        }),
        try buildReply(&nodata_buf, "11.1.168.192.in-addr.arpa", .no_error, &.{}),
        try buildReply(&bad_buf, "12.1.168.192.in-addr.arpa", .no_error, &.{
            .{ .owner = "12.1.168.192.in-addr.arpa", .target = "-bad.lan" },
        }),
        try buildReply(&refused_buf, "13.1.168.192.in-addr.arpa", .refused, &.{}),
    };
    Stub.replies = &replies;

    // Covered by the zone, in ip order, plus one that no zone covers.
    try clients_repo.upsertSeen(&f.database, "192.168.1.10", 1700000000);
    try clients_repo.upsertSeen(&f.database, "192.168.1.11", 1700000000);
    try clients_repo.upsertSeen(&f.database, "192.168.1.12", 1700000000);
    try clients_repo.upsertSeen(&f.database, "192.168.1.13", 1700000000);
    try clients_repo.upsertSeen(&f.database, "10.0.0.1", 1700000000);

    f.resolver.runPass(f.io(), &f.database, 1700000000);

    const s = f.resolver.snapshotStats(f.io());
    try testing.expectEqual(@as(u64, 5), s.attempted);
    try testing.expectEqual(s.attempted, s.answered + s.nxdomain + s.no_zone + s.invalid + s.failed);
    try testing.expectEqual(@as(u64, 1), s.answered);
    try testing.expectEqual(@as(u64, 1), s.nxdomain);
    try testing.expectEqual(@as(u64, 1), s.invalid);
    try testing.expectEqual(@as(u64, 1), s.failed);
    try testing.expectEqual(@as(u64, 1), s.no_zone);
    // The two failure counters sit outside the outcome sum.
    try testing.expectEqual(@as(u64, 0), s.read_failures);
    try testing.expectEqual(@as(u64, 0), s.write_failures);
}

test "a failing candidate select skips the pass and counts read_failures" {
    var f: Fixture = undefined;
    try fixture(&f, "168.192.in-addr.arpa");
    defer f.deinit();

    try clients_repo.upsertSeen(&f.database, "192.168.1.10", 1700000000);
    try f.database.exec("DROP TABLE clients;");

    f.resolver.runPass(f.io(), &f.database, 1700000000);

    try testing.expectEqual(@as(usize, 0), Stub.calls);
    const s = f.resolver.snapshotStats(f.io());
    try testing.expectEqual(@as(u64, 1), s.read_failures);
    try testing.expectEqual(@as(u64, 0), s.attempted);
}

test "a failing write counts write_failures and still counts the outcome" {
    var f: Fixture = undefined;
    try fixture(&f, "168.192.in-addr.arpa");
    defer f.deinit();

    try clients_repo.upsertSeen(&f.database, "10.0.0.1", 1700000000);
    try f.database.exec(
        \\CREATE TRIGGER refuse_update BEFORE UPDATE ON clients
        \\BEGIN SELECT RAISE(ABORT, 'refused'); END;
    );

    f.resolver.runPass(f.io(), &f.database, 1700000000);

    const s = f.resolver.snapshotStats(f.io());
    try testing.expectEqual(@as(u64, 1), s.write_failures);
    try testing.expectEqual(@as(u64, 1), s.attempted);
    try testing.expectEqual(@as(u64, 1), s.no_zone);
}

/// Swaps a whole new zone generation in during the first attempt of a pass, and
/// records the resolver each attempt was handed. Both assertions read the port,
/// because the two generations differ only there.
const Swapping = struct {
    const old_port: u16 = 5301;
    const new_port: u16 = 5302;

    var tables: *local_tables.LocalTables = undefined;
    var io_ref: std.Io = undefined;
    var ports: [2]u16 = .{ 0, 0 };
    var calls: usize = 0;

    fn exchange(
        io: std.Io,
        resolver: validate.Resolver,
        query: []const u8,
        response_buf: []u8,
    ) transport.ExchangeError![]u8 {
        const index = calls;
        calls += 1;

        // Inside the hazard window: the handle has been released, so `swap`
        // takes the exclusive lock immediately and frees the generation the
        // first attempt matched against. A `*const Zone` retained across the
        // release would now point into freed memory.
        if (index == 0) {
            tables.swap(io_ref, testing.allocator, .empty, forward_zones.Zones.build(
                testing.allocator,
                &.{.{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:5302" }},
            ) catch return error.SystemResources);
        }

        // Read *after* the swap, so a stale pointer shows up as a wrong port.
        if (index < ports.len) ports[index] = resolver.port;
        return Stub.exchange(io, resolver, query, response_buf);
    }
};

test "each attempt re-acquires, and a swap mid-pass cannot reach the copy in flight" {
    var f: Fixture = undefined;
    try fixture(&f, null);
    defer f.deinit();

    // The old generation: the same zone, on the old port.
    f.tables.swap(f.io(), testing.allocator, .empty, try forward_zones.Zones.build(
        testing.allocator,
        &.{.{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:5301" }},
    ));

    // Two candidates in one pass: the first runs against the old generation, the
    // second must see the new one.
    try clients_repo.upsertSeen(&f.database, "192.168.1.10", 1700000000);
    try clients_repo.upsertSeen(&f.database, "192.168.1.11", 1700000000);

    var reply_buf: [512]u8 = undefined;
    const replies = [_][]const u8{
        try buildReply(&reply_buf, "10.1.168.192.in-addr.arpa", .no_error, &.{}),
    };
    Stub.replies = &replies;

    Swapping.tables = &f.tables;
    Swapping.io_ref = f.io();
    Swapping.ports = .{ 0, 0 };
    Swapping.calls = 0;
    f.resolver.exchange_fn = Swapping.exchange;

    f.resolver.runPass(f.io(), &f.database, 1700000000);

    try testing.expectEqual(@as(usize, 2), Swapping.calls);
    // The copy, not the freed generation: the first attempt still names the port
    // it matched, after the table it came from is gone.
    try testing.expectEqual(Swapping.old_port, Swapping.ports[0]);
    // The re-acquire: the second attempt sees what the operator just published,
    // so no handle and no table pointer survived the first attempt.
    try testing.expectEqual(Swapping.new_port, Swapping.ports[1]);
}

test "the retry cadences are the constants the milestone fixes" {
    try testing.expectEqual(@as(i64, 86_400), Resolver.refresh_after_s);
    try testing.expectEqual(@as(i64, 3_600), Resolver.retry_after_s);
    try testing.expectEqual(@as(i96, 2 * std.time.ns_per_s), Resolver.ptr_read_timeout.raw.nanoseconds);
    try testing.expectEqual(std.Io.Clock.awake, Resolver.ptr_read_timeout.clock);

    // A clock far in the future cannot overflow the column.
    try testing.expectEqual(std.math.maxInt(i64), nextAttempt(std.math.maxInt(i64), .answered));
}

// --- the production exchange, over a loopback socket ------------------------

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

/// A PTR responder on 127.0.0.1: one answer naming whatever was asked about.
fn loopbackPtrResolver(io: std.Io, socket: *const net.Socket, calls: *std.atomic.Value(u64)) void {
    var buf: [2048]u8 = undefined;
    while (true) {
        const msg = socket.receive(io, &buf) catch return;
        _ = calls.fetchAdd(1, .monotonic);

        const request = packet.parse(msg.data) catch continue;
        const q = packet.firstQuestion(request) orelse continue;

        var rdata_buf: [types.max_name_len]u8 = undefined;
        var rdata_w: std.Io.Writer = .fixed(&rdata_buf);
        name_mod.encode(name_mod.fromText("loop-host.lan") catch continue, &rdata_w) catch continue;

        var reply_buf: [512]u8 = undefined;
        var b = packet.ResponseBuilder.init(&reply_buf, request.header, q) catch continue;
        b.addAnswer(q.name, .ptr, .in, 300, rdata_w.buffered()) catch continue;
        socket.send(io, &msg.from, b.finish()) catch return;
    }
}

test "the production exchange reaches a real resolver over UDP" {
    if (!build_options.integration) return error.SkipZigTest;

    var f: Fixture = undefined;
    // The zone is rebuilt below against the socket's real port.
    try fixture(&f, null);
    defer f.deinit();
    const io = f.io();

    const bind_address: net.IpAddress = try .parse("127.0.0.1", 0);
    const socket = try bind_address.bind(io, .{ .mode = .dgram });
    defer socket.close(io);

    var calls: std.atomic.Value(u64) = .init(0);
    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, loopbackPtrResolver, .{ io, &socket, &calls });

    var url_buf: [64]u8 = undefined;
    const url = try std.fmt.bufPrint(&url_buf, "udp://127.0.0.1:{d}", .{socket.address.ip4.port});
    f.tables.swap(io, testing.allocator, .empty, try forward_zones.Zones.build(
        testing.allocator,
        &.{.{ .zone = "168.192.in-addr.arpa", .resolver = url }},
    ));

    // The seam's production default, not the stub.
    f.resolver.exchange_fn = defaultExchange;
    try clients_repo.upsertSeen(&f.database, "192.168.1.10", 1700000000);

    f.resolver.runPass(io, &f.database, 1700000000);

    try testing.expectEqual(@as(u64, 1), calls.load(.monotonic));
    var buf: [253]u8 = undefined;
    try testing.expectEqualStrings("loop-host.lan", (try f.learned("192.168.1.10", &buf)).?);
    try testing.expectEqual(@as(u64, 1), f.resolver.snapshotStats(io).answered);
}

/// Answers the first datagram late in the budget with TC=1, which sends the
/// client to TCP — where a listener that never accepts leaves it stalled.
fn truncatingThenStallingResolver(io: std.Io, socket: *const net.Socket) void {
    var buf: [2048]u8 = undefined;
    const msg = socket.receive(io, &buf) catch return;
    const request = packet.parse(msg.data) catch return;

    (std.Io.Clock.Duration{
        .raw = .fromMilliseconds(one_deadline_late_ms),
        .clock = .awake,
    }).sleep(io) catch return;

    // The question has to be echoed: `transport.validateResponse` runs before
    // the client reads the TC bit, so a bare header would come back as
    // `BadResponse` and never reach the TCP fallback this case is about.
    const q = packet.firstQuestion(request) orelse return;
    var reply_buf: [512]u8 = undefined;
    var b = packet.ResponseBuilder.init(&reply_buf, request.header, q) catch return;
    const reply = b.finish();

    var flags = dns_header.parse(reply) catch return;
    flags.flags.tc = true;
    dns_header.encode(flags, reply[0..types.header_len]);
    socket.send(io, &msg.from, reply) catch return;
}

const one_deadline_budget_ms = 400;
/// Late enough in the budget that a second, fresh budget for the TCP leg is
/// unmistakable in the elapsed time.
const one_deadline_late_ms = 300;

test "one deadline covers the udp leg, the TC=1 fallback and the tcp leg" {
    if (!build_options.integration) return error.SkipZigTest;

    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    const bind_address: net.IpAddress = try .parse("127.0.0.1", 0);
    const socket = try bind_address.bind(io, .{ .mode = .dgram });
    defer socket.close(io);
    const port = socket.address.ip4.port;

    // Bound but never accepted: the connect completes out of the kernel's
    // backlog and the read then waits forever, which is the stall the second
    // budget would be spent on.
    const tcp_address: net.IpAddress = try .parse("127.0.0.1", port);
    var tcp_listener = try tcp_address.listen(io, .{ .reuse_address = true });
    defer tcp_listener.deinit(io);

    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, truncatingThenStallingResolver, .{ io, &socket });

    var url_buf: [64]u8 = undefined;
    const url = try std.fmt.bufPrint(&url_buf, "udp://127.0.0.1:{d}", .{port});
    const resolver = try validate.parseResolver(url);

    var query_buf: [query_buf_len]u8 = undefined;
    const query = try encodeQuery(io, try name_mod.fromText("10.1.168.192.in-addr.arpa"), &query_buf);

    var response_buf: [response_buf_len]u8 = undefined;
    const one_budget: std.Io.Clock.Duration = .{
        .raw = .fromMilliseconds(one_deadline_budget_ms),
        .clock = .awake,
    };

    const started = std.Io.Clock.awake.now(io);
    try testing.expectError(
        error.Timeout,
        exchangeWithin(io, one_budget, resolver, query, &response_buf),
    );
    const elapsed = started.durationTo(std.Io.Clock.awake.now(io)).nanoseconds;

    // The unwrapped path spends 300 ms on the UDP leg and then a fresh 400 ms
    // budget on the stalled TCP leg, ~700 ms in all. The bound must therefore
    // sit between one budget and that sum — 2× the budget would pass the very
    // bug this test exists to catch.
    try testing.expect(elapsed < @as(i96, one_deadline_budget_ms + one_deadline_late_ms / 2) * std.time.ns_per_ms);
}

test "a nonzero extended rcode under a NOERROR header is a failure, not an answer" {
    var f: Fixture = undefined;
    try fixture(&f, "168.192.in-addr.arpa");
    defer f.deinit();

    try clients_repo.upsertSeen(&f.database, "192.168.1.10", 1700000000);
    try clients_repo.noteNameOutcome(&f.database, "192.168.1.10", .{
        .attempt_after = 0,
        .learned = .{ .store = "keep.lan" },
    });

    // The header says NOERROR and the answer section carries a perfectly good
    // PTR record, so header-only classification would store `nas.lan`. The full
    // twelve bits say otherwise (RFC 6891 §6.1.3).
    var reply_buf: [512]u8 = undefined;
    const replies = [_][]const u8{
        try buildReplyWithOpt(&reply_buf, "10.1.168.192.in-addr.arpa", .no_error, &.{
            .{ .owner = "10.1.168.192.in-addr.arpa", .target = "nas.lan" },
        }, 1),
    };
    Stub.replies = &replies;

    f.resolver.runPass(f.io(), &f.database, 1700000000);

    var buf: [253]u8 = undefined;
    try testing.expectEqualStrings("keep.lan", (try f.learned("192.168.1.10", &buf)).?);
    const s = f.resolver.snapshotStats(f.io());
    try testing.expectEqual(@as(u64, 1), s.failed);
    try testing.expectEqual(@as(u64, 0), s.answered);
    try testing.expectEqual(@as(u64, 0), s.nxdomain);
    // Non-definitive, so the hourly retry.
    try testing.expectEqual(
        @as(i64, 1700000000 + Resolver.retry_after_s),
        try attemptAfterOf(&f.database, "192.168.1.10"),
    );
}

test "an extended rcode of zero still reads as the header's rcode" {
    var f: Fixture = undefined;
    try fixture(&f, "168.192.in-addr.arpa");
    defer f.deinit();

    try clients_repo.upsertSeen(&f.database, "192.168.1.10", 1700000000);

    var reply_buf: [512]u8 = undefined;
    const replies = [_][]const u8{
        try buildReplyWithOpt(&reply_buf, "10.1.168.192.in-addr.arpa", .no_error, &.{
            .{ .owner = "10.1.168.192.in-addr.arpa", .target = "nas.lan" },
        }, 0),
    };
    Stub.replies = &replies;

    f.resolver.runPass(f.io(), &f.database, 1700000000);

    var buf: [253]u8 = undefined;
    try testing.expectEqualStrings("nas.lan", (try f.learned("192.168.1.10", &buf)).?);
    try testing.expectEqual(@as(u64, 1), f.resolver.snapshotStats(f.io()).answered);
}

test "a failing write opens one episode per pass and a clean pass closes it" {
    var f: Fixture = undefined;
    try fixture(&f, "168.192.in-addr.arpa");
    defer f.deinit();

    var fx: events_fixture.Fixture = .{};
    try fx.init(f.io(), 1000);
    defer fx.deinit();
    f.resolver.diagnostics = &fx.store;

    // Two addresses outside the declared zone: both reach the write step
    // without any exchange, which is not what this test is about.
    try clients_repo.upsertSeen(&f.database, "10.0.0.1", 1700000000);
    try clients_repo.upsertSeen(&f.database, "10.0.0.2", 1700000000);
    try f.database.exec(
        \\CREATE TRIGGER refuse_update BEFORE UPDATE ON clients
        \\BEGIN SELECT RAISE(ABORT, 'refused'); END;
    );

    f.resolver.runPass(f.io(), &f.database, 1700000000);

    // Two failing rows, one aggregated report: the row counts failing passes.
    try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
    try testing.expectEqualStrings("client_names.storage", try fx.text("SELECT code FROM operational_events"));
    try testing.expectEqualStrings("write", try fx.text("SELECT subject_key FROM operational_events"));
    try testing.expectEqualStrings("warning", try fx.text("SELECT severity FROM operational_events"));
    try testing.expectEqual(@as(i64, 1), try fx.count("SELECT occurrences FROM operational_events"));

    f.resolver.runPass(f.io(), &f.database, 1700000060);
    try testing.expectEqual(@as(i64, 2), try fx.count("SELECT occurrences FROM operational_events"));

    try f.database.exec("DROP TRIGGER refuse_update;");
    f.resolver.runPass(f.io(), &f.database, 1700000120);

    try testing.expectEqual(
        @as(i64, 0),
        try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
    );
}

test "a failing candidate select opens a read episode the next clean pass closes" {
    var f: Fixture = undefined;
    try fixture(&f, "168.192.in-addr.arpa");
    defer f.deinit();

    var fx: events_fixture.Fixture = .{};
    try fx.init(f.io(), 1000);
    defer fx.deinit();
    f.resolver.diagnostics = &fx.store;

    try clients_repo.upsertSeen(&f.database, "10.0.0.1", 1700000000);
    try f.database.exec("ALTER TABLE clients RENAME TO clients_aside;");
    f.resolver.runPass(f.io(), &f.database, 1700000000);

    try testing.expectEqualStrings("read", try fx.text(
        "SELECT subject_key FROM operational_events WHERE resolved_at IS NULL",
    ));

    try f.database.exec("ALTER TABLE clients_aside RENAME TO clients;");
    f.resolver.runPass(f.io(), &f.database, 1700000060);

    try testing.expectEqual(
        @as(i64, 0),
        try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
    );
}
