//! Client auto-materialisation (PLAN §7.2): every address that asks a question
//! ends up as a row in `clients`, so the operator can name it and assign it a
//! group without typing an address by hand.
//!
//! A DNS query must never wait on a database write, so `track` only records the
//! address in a fixed-capacity table in memory. A background loop drains that
//! table every `flush_interval_s` and writes one row per distinct client, and
//! prunes the rows of devices that went quiet on every
//! `prune_every_passes`-th pass.
//!
//! The table is bounded at `max_pending`. A full table drops the address and
//! counts it under `dropped_full`: a burst of spoofed source addresses must not
//! be able to grow this allocation, and a dropped address costs nothing, since
//! the next query from that client tracks it again.
//!
//! A materialised row does not reach the matcher until the next manager reload.
//! Nothing depends on it: `groupForClient` already falls back to the prefix
//! rules and then to the default group, so the row exists for the operator's
//! benefit, not for resolution.

const std = @import("std");

const address = @import("../platform/address.zig");
const client_names = @import("client_names.zig");
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const db = @import("../storage/db.zig");
const disk_monitor = @import("../storage/disk_monitor.zig");
const events = @import("../storage/events.zig");
const logger = @import("../storage/logger.zig");
const retention = @import("../storage/retention.zig");

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

/// One address waiting for its row, with the wall-clock second of its most
/// recent query.
const Pending = struct {
    addr: address.NetAddress,
    last_seen: i64,
};

pub const Tracker = struct {
    /// Distinct clients one flush interval can carry. A household LAN holds two
    /// orders of magnitude fewer; the headroom is for the spoofing case.
    pub const max_pending = 512;
    pub const flush_interval_s = 60;
    /// One day at `flush_interval_s` seconds per pass.
    pub const prune_every_passes = 1440;

    /// `tracked` counts the `track` calls that landed in the table, whether
    /// they created an entry or refreshed one, so `tracked + dropped_full` is
    /// the number of `track` calls.
    pub const Stats = struct {
        tracked: u64 = 0,
        flushed: u64 = 0,
        dropped_full: u64 = 0,
        pruned: u64 = 0,
        flush_failures: u64 = 0,
    };

    /// Guards `pending`, `count`, `passes` and `stats`. Every field below is
    /// written under it, so a reader takes it too; see `snapshotStats`.
    mutex: std.Io.Mutex,
    retention_days: *const retention.RetentionDays,
    pending: [max_pending]Pending,
    count: u32,
    passes: u64,
    stats: Stats,
    /// Wired by the composition root after `init`, following the
    /// `gate: ?*disk_monitor.Monitor` idiom. Null in every unit test here.
    diagnostics: ?*events.Store = null,

    /// `retention_days` is `logging.retention_days`, the same knob the query log
    /// prunes by (milestone-7 ruling 16). A client silent for that long is as
    /// uninteresting as a query that old — so both consumers share ONE cell
    /// and a settings apply moves them together.
    pub fn init(retention_days: *const retention.RetentionDays) Tracker {
        return .{
            .mutex = .init,
            .retention_days = retention_days,
            .pending = undefined,
            .count = 0,
            .passes = 0,
            .stats = .{},
        };
    }

    /// Records `addr` as seen now. Called from the query path, so it writes no
    /// database and returns no error: a full table drops the address.
    ///
    /// Returns `dropped_full` as it stands after this call. The query path
    /// mirrors that counter into its own atomic, and reporting it from here
    /// costs the caller nothing — the mutex is already held — where a second
    /// `snapshotStats` call would take it again on every query.
    ///
    /// `lockUncancelable` rather than `lock`: the caller is `Handler.handle`,
    /// which has no error union to carry `error.Canceled` out of. The critical
    /// section is a scan of at most `max_pending` addresses and holds no I/O.
    pub fn track(self: *Tracker, io: std.Io, addr: address.NetAddress) u64 {
        return self.trackAt(io, addr, std.Io.Clock.real.now(io).toSeconds());
    }

    /// `track` with the timestamp supplied, so a test does not depend on the
    /// wall clock.
    pub fn trackAt(self: *Tracker, io: std.Io, addr: address.NetAddress, now_s: i64) u64 {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);

        for (self.pending[0..self.count]) |*entry| {
            if (!entry.addr.eql(addr)) continue;
            entry.last_seen = now_s;
            self.stats.tracked += 1;
            return self.stats.dropped_full;
        }
        if (self.count == max_pending) {
            self.stats.dropped_full += 1;
            return self.stats.dropped_full;
        }
        self.pending[self.count] = .{ .addr = addr, .last_seen = now_s };
        self.count += 1;
        self.stats.tracked += 1;
        return self.stats.dropped_full;
    }

    /// Flush loop, first flush one interval in: an empty table at startup has
    /// nothing to write.
    ///
    /// `boot` rather than `awake`, so a box that suspends still sees its day
    /// elapse and prunes on schedule.
    ///
    /// `database` must be a connection dedicated to this loop: no other task may
    /// use the same handle while it runs. `FULLMUTEX` (`db.zig:218`) serializes
    /// one SQLite call against another, but a transaction is connection state,
    /// not call state, so a flush that lands between another writer's BEGIN and
    /// COMMIT would commit or roll back with that writer's batch. Milestone-7
    /// ruling 21 gives this loop its own `config.db` connection; the tracker
    /// opens nothing itself.
    ///
    /// `monitor` gates the pass: while free space is critical the tracker writes
    /// nothing, and the addresses it would have written stay dropped.
    pub fn run(
        self: *Tracker,
        io: std.Io,
        database: *db.Db,
        monitor: ?*disk_monitor.Monitor,
        names: ?*client_names.Resolver,
    ) std.Io.Cancelable!void {
        const interval: std.Io.Clock.Duration = .{
            .raw = .fromSeconds(flush_interval_s),
            .clock = .boot,
        };
        while (true) {
            try interval.sleep(io);
            const writes_allowed = if (monitor) |m| m.writesAllowed() else true;
            self.flushOnce(io, database, writes_allowed, names);
        }
    }

    /// One pass: drain the table, write a row per client, prune on every
    /// `prune_every_passes`-th pass, and then learn names for the rows that
    /// have none (milestone-25 ruling 1).
    ///
    /// The order of the three steps is fixed and one `now_s` serves all three.
    /// Resolving before pruning would spend PTR queries on rows the same pass
    /// deletes; resolving before the drain would make a slow resolver delay the
    /// writes the pass exists for.
    ///
    /// A gated pass does nothing at all, not even count: the work it skipped is
    /// still owed, and the pending addresses it leaves behind are re-tracked by
    /// the next query from each client.
    ///
    /// Every database failure logs one line at `warn` and counts. Nothing
    /// retries within a pass: the dropped addresses come back on their own, and
    /// a failed prune repeats the same work a day later against the same rows.
    ///
    /// Only `run` may call this concurrently with itself — the drain buffer is
    /// this call's stack, but the pass counter and the prune schedule assume a
    /// single caller.
    pub fn flushOnce(
        self: *Tracker,
        io: std.Io,
        database: *db.Db,
        writes_allowed: bool,
        names: ?*client_names.Resolver,
    ) void {
        // A gated pass skips naming too: naming writes.
        if (!writes_allowed) return;

        const now_s = std.Io.Clock.real.now(io).toSeconds();
        var drained: [max_pending]Pending = undefined;
        const batch = self.drain(io, &drained);

        var flushed: u64 = 0;
        var failures: u64 = 0;
        var last_failure: []const u8 = "";
        for (batch) |entry| {
            // `logger.max_client_len` is the RFC 5952 bound every address text
            // in this program is sized by, so `format` cannot fail here.
            var buf: [logger.max_client_len]u8 = undefined;
            var w: std.Io.Writer = .fixed(&buf);
            entry.addr.format(&w) catch unreachable;

            if (clients_repo.upsertSeen(database, w.buffered(), entry.last_seen)) {
                flushed += 1;
            } else |err| {
                if (failures == 0) {
                    log.warn("materialising client {s} failed: {s}", .{ w.buffered(), @errorName(err) });
                }
                failures += 1;
                last_failure = @errorName(err);
            }
        }

        // One aggregated outcome per pass, so the row's `occurrences` counts
        // failing passes rather than failing addresses. A pass that wrote
        // nothing resolves nothing: an empty batch is not evidence of success.
        if (failures != 0) {
            self.reportStorage(io, now_s, "materialise", "materialising a client failed", last_failure, failures);
        } else if (flushed != 0) {
            if (self.diagnostics) |store| store.resolve(io, now_s, .clients_storage, "materialise");
        }

        self.mutex.lockUncancelable(io);
        self.passes += 1;
        self.stats.flushed += flushed;
        self.stats.flush_failures += failures;
        const due = self.passes % prune_every_passes == 0;
        self.mutex.unlock(io);

        if (due) {
            const cutoff = now_s - self.retention_days.seconds();
            if (clients_repo.pruneStale(database, cutoff)) |deleted| {
                self.mutex.lockUncancelable(io);
                self.stats.pruned += deleted;
                self.mutex.unlock(io);
                if (self.diagnostics) |store| store.resolve(io, now_s, .clients_storage, "prune");
            } else |err| {
                log.warn("pruning clients before {d} failed: {s}", .{ cutoff, @errorName(err) });
                self.mutex.lockUncancelable(io);
                self.stats.flush_failures += 1;
                self.mutex.unlock(io);
                self.reportStorage(io, now_s, "prune", "pruning stale clients failed", @errorName(err), 1);
            }
        }

        if (names) |resolver| resolver.runPass(io, database, now_s);
    }

    /// One aggregated report per pass; `count` is how many failures it held.
    fn reportStorage(
        self: *Tracker,
        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, .clients_storage, operation, operation, .warning, detail);
    }

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

    /// Clients waiting for their row. Reaching `max_pending` is what turns
    /// further addresses into `dropped_full`.
    pub fn pendingClients(self: *Tracker, io: std.Io) u32 {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);
        return self.count;
    }

    /// Empties the table into `out` and returns what it copied. The lock is
    /// released before any database call, so the query path never waits on
    /// SQLite.
    fn drain(self: *Tracker, io: std.Io, out: *[max_pending]Pending) []const Pending {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);

        const n = self.count;
        @memcpy(out[0..n], self.pending[0..n]);
        self.count = 0;
        return out[0..n];
    }
};

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

const events_fixture = @import("../storage/events_fixture.zig");
const migrations = @import("../storage/migrations.zig");

const testing = std.testing;

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

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

fn parsed(text: []const u8) address.NetAddress {
    return address.NetAddress.parse(text) catch unreachable;
}

test "a client tracked twice before a flush yields one row at the later time" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

    var days: retention.RetentionDays = .init(30);
    var tracker: Tracker = .init(&days);
    // A table with room reports no drops.
    try testing.expectEqual(@as(u64, 0), tracker.trackAt(io, parsed("192.168.1.10"), 1700000000));
    try testing.expectEqual(@as(u64, 0), tracker.trackAt(io, parsed("192.168.1.10"), 1700000030));
    try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));

    tracker.flushOnce(io, &database, true, null);

    try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
    try testing.expectEqual(@as(i64, 1700000030), try lastSeen(&database, "192.168.1.10"));

    const stats = tracker.snapshotStats(io);
    try testing.expectEqual(@as(u64, 2), stats.tracked);
    try testing.expectEqual(@as(u64, 1), stats.flushed);
    try testing.expectEqual(@as(u64, 0), stats.flush_failures);
    try testing.expectEqual(@as(u32, 0), tracker.pendingClients(io));
}

test "distinct clients each get a row and ipv6 text is canonical" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

    var days: retention.RetentionDays = .init(30);
    var tracker: Tracker = .init(&days);
    _ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
    _ = tracker.trackAt(io, parsed("192.168.1.11"), 1700000001);
    _ = tracker.trackAt(io, parsed("fd00:0:0:0:0:0:0:1"), 1700000002);
    // An IPv4-mapped literal is the same client as its plain form.
    _ = tracker.trackAt(io, address.NetAddress.fromIp(try std.Io.net.IpAddress.parse("::ffff:192.168.1.10", 53)), 1700000003);
    try testing.expectEqual(@as(u32, 3), tracker.pendingClients(io));

    tracker.flushOnce(io, &database, true, null);

    try testing.expectEqual(@as(i64, 3), try clients_repo.countClients(&database));
    try testing.expectEqual(@as(i64, 1700000003), try lastSeen(&database, "192.168.1.10"));
    try testing.expectEqual(@as(i64, 1700000002), try lastSeen(&database, "fd00::1"));
    try testing.expectEqual(@as(u64, 3), tracker.snapshotStats(io).flushed);
}

test "a full table drops further clients and counts them" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

    var days: retention.RetentionDays = .init(30);
    var tracker: Tracker = .init(&days);
    for (0..Tracker.max_pending) |i| {
        var octets: [4]u8 = undefined;
        std.mem.writeInt(u32, &octets, @intCast(i), .big);
        _ = tracker.trackAt(io, .{ .ip4 = octets }, 1700000000);
    }
    try testing.expectEqual(@as(u32, Tracker.max_pending), tracker.pendingClients(io));
    try testing.expectEqual(@as(u64, 0), tracker.snapshotStats(io).dropped_full);

    // The return value is what the handler mirrors, so it must agree with
    // `snapshotStats` without a second lock acquisition.
    try testing.expectEqual(@as(u64, 1), tracker.trackAt(io, parsed("203.0.113.7"), 1700000000));
    try testing.expectEqual(@as(u64, 2), tracker.trackAt(io, parsed("203.0.113.8"), 1700000000));
    try testing.expectEqual(@as(u32, Tracker.max_pending), tracker.pendingClients(io));

    const stats = tracker.snapshotStats(io);
    try testing.expectEqual(@as(u64, 2), stats.dropped_full);
    try testing.expectEqual(@as(u64, Tracker.max_pending), stats.tracked);

    // A tracked client still refreshes while the table is full, and the flush
    // makes room for the next newcomer.
    _ = tracker.trackAt(io, .{ .ip4 = .{ 0, 0, 0, 0 } }, 1700000060);
    tracker.flushOnce(io, &database, true, null);
    try testing.expectEqual(@as(i64, Tracker.max_pending), try clients_repo.countClients(&database));
    try testing.expectEqual(@as(i64, 1700000060), try lastSeen(&database, "0.0.0.0"));

    _ = tracker.trackAt(io, parsed("203.0.113.7"), 1700000060);
    try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
}

test "a flush touches a hand-edited row without changing what the operator set" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openMigrated();
    defer database.close();
    try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
    try database.exec(
        \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
        \\VALUES ('192.168.1.10', 'laptop', 2, 1, 1690000000, 1690000000);
    );

    var days: retention.RetentionDays = .init(30);
    var tracker: Tracker = .init(&days);
    _ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
    tracker.flushOnce(io, &database, true, null);

    try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
    try testing.expectEqual(@as(i64, 1700000000), try lastSeen(&database, "192.168.1.10"));
    try testing.expectEqual(
        @as(i64, 1),
        try database.queryInt(
            \\SELECT count(*) FROM clients
            \\ WHERE ip = '192.168.1.10' AND name = 'laptop' AND group_id = 2
            \\   AND hand_edited = 1 AND first_seen = 1690000000
        ),
    );
}

test "a gated pass writes nothing and keeps the pending clients" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

    var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
    monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
    try testing.expect(!monitor.writesAllowed());

    var days: retention.RetentionDays = .init(30);
    var tracker: Tracker = .init(&days);
    _ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
    tracker.flushOnce(io, &database, monitor.writesAllowed(), null);

    try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
    try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
    try testing.expectEqual(@as(u64, 0), tracker.snapshotStats(io).flushed);
    try testing.expectEqual(@as(u64, 0), tracker.passes);

    // Free space recovers and the same pending client lands.
    monitor.state_raw.store(@intFromEnum(disk_monitor.State.warn), .monotonic);
    tracker.flushOnce(io, &database, monitor.writesAllowed(), null);
    try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
    try testing.expectEqual(@as(u64, 1), tracker.passes);
}

test "a failing upsert counts and leaves the client to be tracked again" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openMigrated();
    defer database.close();
    try database.exec(
        \\CREATE TRIGGER refuse_insert BEFORE INSERT ON clients
        \\BEGIN SELECT RAISE(ABORT, 'refused'); END;
    );

    var days: retention.RetentionDays = .init(30);
    var tracker: Tracker = .init(&days);
    _ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
    _ = tracker.trackAt(io, parsed("192.168.1.11"), 1700000000);
    tracker.flushOnce(io, &database, true, null);

    try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
    const stats = tracker.snapshotStats(io);
    try testing.expectEqual(@as(u64, 0), stats.flushed);
    try testing.expectEqual(@as(u64, 2), stats.flush_failures);
    try testing.expectEqual(@as(u32, 0), tracker.pendingClients(io));

    try database.exec("DROP TRIGGER refuse_insert;");
    _ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000060);
    tracker.flushOnce(io, &database, true, null);
    try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
}

test "the pass that comes due prunes the clients that went quiet" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

    const now = std.Io.Clock.real.now(io).toSeconds();
    const day = 86_400;
    try clients_repo.upsertSeen(&database, "10.0.0.1", now - 40 * day);
    try clients_repo.upsertSeen(&database, "10.0.0.2", now - 29 * day);

    var days: retention.RetentionDays = .init(30);
    var tracker: Tracker = .init(&days);
    // Every pass before the due one leaves both rows alone.
    for (0..Tracker.prune_every_passes - 1) |_| {
        tracker.flushOnce(io, &database, true, null);
    }
    try testing.expectEqual(@as(i64, 2), try clients_repo.countClients(&database));
    try testing.expectEqual(@as(u64, 0), tracker.snapshotStats(io).pruned);

    tracker.flushOnce(io, &database, true, null);

    try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
    try testing.expectEqual(@as(i64, now - 29 * day), try lastSeen(&database, "10.0.0.2"));
    try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).pruned);
    try testing.expectEqual(@as(u64, Tracker.prune_every_passes), tracker.passes);
}

test "a shorter retention prunes what the default keeps" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

    const now = std.Io.Clock.real.now(io).toSeconds();
    try clients_repo.upsertSeen(&database, "10.0.0.1", now - 3 * 86_400);

    var days: retention.RetentionDays = .init(1);
    var tracker: Tracker = .init(&days);
    tracker.passes = Tracker.prune_every_passes - 1;
    tracker.flushOnce(io, &database, true, null);

    try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
    try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).pruned);
}

test "setRetentionDays changes the cutoff the next prune pass uses" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

    const now = std.Io.Clock.real.now(io).toSeconds();
    try clients_repo.upsertSeen(&database, "10.0.0.1", now - 3 * 86_400);

    var days: retention.RetentionDays = .init(7);
    var tracker: Tracker = .init(&days);
    tracker.passes = Tracker.prune_every_passes - 1;
    tracker.flushOnce(io, &database, true, null);
    try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));

    // The shared cell, not a copy taken at construction.
    days.setRetentionDays(1);
    tracker.passes = Tracker.prune_every_passes - 1;
    tracker.flushOnce(io, &database, true, null);
    try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
    try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).pruned);
}

test "the run loop flushes on its interval and returns on cancel" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

    var days: retention.RetentionDays = .init(30);
    var tracker: Tracker = .init(&days);
    _ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);

    var future = try io.concurrent(Tracker.run, .{
        &tracker,
        io,
        &database,
        @as(?*disk_monitor.Monitor, null),
        @as(?*client_names.Resolver, null),
    });
    // The first flush is one interval away, so cancelling immediately proves the
    // loop starts by sleeping rather than by writing.
    try testing.expectError(error.Canceled, future.cancel(io));
    try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
    try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
}

// --- the naming step's place in the pass (milestone-25 ruling 1) ------------

const forward_zones = @import("../local/forward_zones.zig");
const local_tables = @import("local_tables.zig");
const validate = @import("../config/validate.zig");

/// Counts exchanges and times out on every one, and records how many client
/// rows existed when the first exchange was attempted.
const CountingExchange = struct {
    var calls: usize = 0;
    var database: ?*db.Db = null;
    var rows_at_first_call: i64 = -1;

    fn reset(target: *db.Db) void {
        calls = 0;
        database = target;
        rows_at_first_call = -1;
    }

    fn exchange(
        _: std.Io,
        _: validate.Resolver,
        _: []const u8,
        _: []u8,
    ) @import("../upstream/transport.zig").ExchangeError![]u8 {
        if (calls == 0) {
            rows_at_first_call = clients_repo.countClients(database.?) catch -1;
        }
        calls += 1;
        return error.Timeout;
    }
};

fn namingTables(io: std.Io, tables: *local_tables.LocalTables) !void {
    tables.swap(io, testing.allocator, .empty, try forward_zones.Zones.build(
        testing.allocator,
        &.{.{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:53" }},
    ));
}

test "the drain lands before any exchange, and attempts stop at the cap" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openMigrated();
    defer database.close();
    var tables: local_tables.LocalTables = .empty;
    defer tables.deinit(testing.allocator);
    try namingTables(io, &tables);

    var names: client_names.Resolver = .init(&tables);
    names.exchange_fn = CountingExchange.exchange;
    CountingExchange.reset(&database);

    var days: retention.RetentionDays = .init(30);
    var tracker: Tracker = .init(&days);
    const pending = clients_repo.max_per_pass + 4;
    for (0..pending) |i| {
        _ = tracker.trackAt(io, .{ .ip4 = .{ 192, 168, 2, @intCast(i) } }, 1700000000);
    }

    tracker.flushOnce(io, &database, true, &names);

    // Every pending row was written before the first exchange went out, which
    // is what keeps a slow resolver off the drain.
    try testing.expectEqual(@as(i64, @intCast(pending)), CountingExchange.rows_at_first_call);
    try testing.expectEqual(clients_repo.max_per_pass, CountingExchange.calls);
    try testing.expectEqual(@as(u64, @intCast(pending)), tracker.snapshotStats(io).flushed);
    try testing.expectEqual(@as(u64, clients_repo.max_per_pass), names.snapshotStats(io).failed);
}

test "a row the due pass prunes is never asked about" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openMigrated();
    defer database.close();
    var tables: local_tables.LocalTables = .empty;
    defer tables.deinit(testing.allocator);
    try namingTables(io, &tables);

    var names: client_names.Resolver = .init(&tables);
    names.exchange_fn = CountingExchange.exchange;
    CountingExchange.reset(&database);

    const now = std.Io.Clock.real.now(io).toSeconds();
    try clients_repo.upsertSeen(&database, "192.168.1.10", now - 40 * 86_400);

    var days: retention.RetentionDays = .init(30);
    var tracker: Tracker = .init(&days);
    tracker.passes = Tracker.prune_every_passes - 1;
    tracker.flushOnce(io, &database, true, &names);

    try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
    try testing.expectEqual(@as(usize, 0), CountingExchange.calls);
    try testing.expectEqual(@as(u64, 0), names.snapshotStats(io).attempted);
}

test "a gated pass attempts no naming either" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openMigrated();
    defer database.close();
    var tables: local_tables.LocalTables = .empty;
    defer tables.deinit(testing.allocator);
    try namingTables(io, &tables);

    var names: client_names.Resolver = .init(&tables);
    names.exchange_fn = CountingExchange.exchange;
    CountingExchange.reset(&database);

    try clients_repo.upsertSeen(&database, "192.168.1.10", 1700000000);
    var days: retention.RetentionDays = .init(30);
    var tracker: Tracker = .init(&days);
    tracker.flushOnce(io, &database, false, &names);

    try testing.expectEqual(@as(usize, 0), CountingExchange.calls);
    try testing.expectEqual(@as(u64, 0), names.snapshotStats(io).attempted);
}

test "a failing materialise opens one episode per pass and a clean pass closes it" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

    var fx: events_fixture.Fixture = .{};
    try fx.init(io, 1000);
    defer fx.deinit();

    var days: retention.RetentionDays = .init(30);
    var tracker: Tracker = .init(&days);
    tracker.diagnostics = &fx.store;

    try database.exec(
        \\CREATE TRIGGER refuse_insert BEFORE INSERT ON clients
        \\BEGIN SELECT RAISE(ABORT, 'refused'); END;
    );
    _ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
    _ = tracker.trackAt(io, parsed("192.168.1.11"), 1700000001);
    tracker.flushOnce(io, &database, true, null);

    try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
    try testing.expectEqualStrings("clients.storage", try fx.text("SELECT code FROM operational_events"));
    try testing.expectEqualStrings("materialise", try fx.text("SELECT subject_key FROM operational_events"));
    try testing.expectEqual(@as(i64, 1), try fx.count("SELECT occurrences FROM operational_events"));

    _ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000060);
    tracker.flushOnce(io, &database, true, null);
    try testing.expectEqual(@as(i64, 2), try fx.count("SELECT occurrences FROM operational_events"));

    try database.exec("DROP TRIGGER refuse_insert;");
    _ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000120);
    tracker.flushOnce(io, &database, true, null);

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

test "a failing prune opens its own episode the next due pass closes" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

    var fx: events_fixture.Fixture = .{};
    try fx.init(io, 1000);
    defer fx.deinit();

    var days: retention.RetentionDays = .init(30);
    var tracker: Tracker = .init(&days);
    tracker.diagnostics = &fx.store;
    // One pass short of due, so the pass below is the pruning one.
    tracker.passes = Tracker.prune_every_passes - 1;

    try database.exec(
        \\CREATE TRIGGER refuse_delete BEFORE DELETE ON clients
        \\BEGIN SELECT RAISE(ABORT, 'refused'); END;
    );
    try clients_repo.upsertSeen(&database, "192.168.1.10", 1);
    tracker.flushOnce(io, &database, true, null);

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

    try database.exec("DROP TRIGGER refuse_delete;");
    tracker.passes = Tracker.prune_every_passes - 1;
    tracker.flushOnce(io, &database, true, null);

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