//! `clients` and `client_prefixes`.
//!
//! `listClients` returns only `hand_edited = 1` rows. A client the server
//! materialised from live traffic is runtime state, not configuration, and must
//! not appear in an export. `hand_edited` is the only marker of operator intent
//! in this table, so it also decides what the reconcile engine may delete: a
//! declared row the file drops is removed, an observed row is kept whatever the
//! file says, and declaring an observed address promotes that row in place.
//! `countClients` counts **all** rows and is a test helper.
//!
//! The configuration path is list / insert / update / delete / count, plus the
//! two runtime calls `upsertSeen` and `pruneStale` that `server/clients.zig`'s
//! tracker owns.
//! The REST surface is the third section: it speaks row ids and shows
//! every client, materialised ones included.

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

const db = @import("../db.zig");
const logger = @import("../logger.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const crud = @import("crud.zig");

const IdMap = context.IdMap;
const InsertContext = context.InsertContext;

// ---------------------------------------------------------------------------
// clients
// ---------------------------------------------------------------------------

const list_clients_sql =
    \\SELECT c.ip, c.name, g.name FROM clients c
    \\  JOIN groups g ON g.id = c.group_id
    \\ WHERE c.hand_edited = 1
    \\ ORDER BY c.ip
;

/// Every string in the result is a heap copy owned by `gpa`.
pub fn listClients(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Client) {
    return crud.listRows(model.Client, database, gpa, list_clients_sql, readClient);
}

fn readClient(stmt: *db.Stmt, gpa: Allocator) db.Error!model.Client {
    const ip = try stmt.columnTextAlloc(gpa, 0);
    errdefer gpa.free(ip);
    // `clients.name` is nullable; `columnTextAlloc` reads NULL as "", which is
    // exactly the model's default.
    const name = try stmt.columnTextAlloc(gpa, 1);
    errdefer gpa.free(name);
    const group = try stmt.columnTextAlloc(gpa, 2);
    errdefer gpa.free(group);
    return .{ .ip = ip, .name = name, .group = group };
}

pub fn freeClients(gpa: Allocator, items: []const model.Client) void {
    crud.freeRows(model.Client, gpa, items);
}

const insert_client_sql =
    \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
    \\VALUES (?1, ?2, ?3, 1, ?4, ?4)
;

/// `hand_edited` is 1: a client that reached a repository through the config
/// model came from an operator's file, by definition.
pub fn insertClient(database: *db.Db, item: model.Client, ctx: InsertContext) db.Error!void {
    const group_id = try ctx.groupId(item.group);

    var stmt = try database.prepare(insert_client_sql);
    defer stmt.deinit();
    try stmt.bindText(1, item.ip);
    try stmt.bindText(2, item.name);
    try stmt.bindInt(3, group_id);
    try stmt.bindInt(4, ctx.now);
    try stmt.exec();
}

const upsert_seen_sql =
    \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
    \\VALUES (?1, NULL, (SELECT id FROM groups WHERE name = 'default'), 0, ?2, ?2)
    \\ON CONFLICT(ip) DO UPDATE SET last_seen = excluded.last_seen
;

/// Materialises a client seen in live traffic (PLAN §7.2), or touches
/// `last_seen` on the row that already holds `ip`.
///
/// The conflict target is `clients.ip`, which the schema declares UNIQUE. Only
/// `last_seen` is updated: `name`, `group_id` and `hand_edited` are the
/// operator's, and a device that keeps querying must not overwrite them. A
/// hand-edited row is touched too, so the operator sees liveness for the
/// clients they named.
///
/// A new row lands in the `default` group. `groupForClient` resolves the real
/// group from the prefix rules at query time, so the column here only decides
/// what the operator sees before they assign the device themselves.
///
/// A database with no group named `default` fails the insert with
/// `error.Constraint` rather than writing a dangling row. `validate.zig`
/// rejects such a configuration long before a server serves from it.
pub fn upsertSeen(database: *db.Db, ip: []const u8, now_s: i64) db.Error!void {
    var stmt = try database.prepare(upsert_seen_sql);
    defer stmt.deinit();
    try stmt.bindText(1, ip);
    try stmt.bindInt(2, now_s);
    try stmt.exec();
}

/// Deletes the auto-materialised clients whose last query predates `cutoff_s`,
/// and returns how many rows went. `hand_edited = 1` rows are configuration and
/// survive any silence.
///
/// `last_seen` is the only signal, because §3.6 forbids joining `config.db`
/// against the query log.
pub fn pruneStale(database: *db.Db, cutoff_s: i64) db.Error!u32 {
    var stmt = try database.prepare("DELETE FROM clients WHERE hand_edited = 0 AND last_seen < ?1");
    defer stmt.deinit();
    try stmt.bindInt(1, cutoff_s);
    try stmt.exec();
    const deleted = database.changes();
    return @intCast(@min(deleted, std.math.maxInt(u32)));
}

// --- learned names (milestone 25) ------------------------------------------
//
// `learned_name` and `name_attempt_after` are runtime state beside `last_seen`,
// never configuration: `listClients` does not select them, so an export cannot
// carry them, and the reconcile engine never writes them. The operator's `name`
// is never touched here, and the operator never writes `learned_name` — so
// precedence is a display rule, not a write conflict.

/// How many rows one naming pass may take. The candidate buffer is sized by
/// this, and the SQL's LIMIT repeats it as a literal.
pub const max_per_pass: usize = 16;

/// One selected address. Fixed-size storage because the naming path allocates
/// nothing: `logger.max_client_len` is the bound every address text in this
/// program is sized by.
pub const Candidate = struct {
    buf: [logger.max_client_len]u8 = undefined,
    len: usize = 0,

    pub fn ip(self: *const Candidate) []const u8 {
        return self.buf[0..self.len];
    }
};

pub const CandidateBuf = [max_per_pass]Candidate;

const resolve_candidates_sql =
    \\SELECT ip FROM clients
    \\ WHERE (name IS NULL OR name = '') AND name_attempt_after <= ?1
    \\ ORDER BY name_attempt_after, ip LIMIT 16
;

/// Fills `out` with the rows whose displayed name would come from learning and
/// whose next attempt is due, and returns how many slots it filled.
///
/// `hand_edited` is deliberately absent from the predicate: display precedence
/// never consults it, so candidacy must not either. A row with a name is
/// skipped whatever its flag, because its learned name would never be shown.
///
/// `name_attempt_after` holds the epoch second before which the row is not
/// attempted again, so the predicate is a plain comparison: 0 (the column
/// default) means eligible now, and no arithmetic can overflow on a corrupt
/// value.
///
/// `error.Mismatch`: a stored `ip` longer than `logger.max_client_len`, which
/// means something other than nxdns wrote the row.
pub fn resolveCandidates(database: *db.Db, out: *CandidateBuf, now_s: i64) db.Error!usize {
    var stmt = try database.prepare(resolve_candidates_sql);
    defer stmt.deinit();
    try stmt.bindInt(1, now_s);

    var count: usize = 0;
    while (try stmt.step()) : (count += 1) {
        const ip = stmt.columnText(0);
        if (ip.len > logger.max_client_len) return error.Mismatch;
        @memcpy(out[count].buf[0..ip.len], ip);
        out[count].len = ip.len;
    }
    return count;
}

/// What one naming attempt decided about a row's learned name.
pub const LearnedName = union(enum) {
    /// The resolver answered a target that passed `acceptHostname`.
    store: []const u8,
    /// The resolver said the address has no name (NXDOMAIN or NODATA).
    clear,
    /// Anything else — an outage must not strip names from the dashboard.
    keep,
};

pub const NameOutcome = struct {
    /// The epoch second before which this row is not attempted again.
    attempt_after: i64,
    learned: LearnedName,
};

const store_learned_sql =
    "UPDATE clients SET learned_name = ?2, name_attempt_after = ?3 WHERE ip = ?1";
const clear_learned_sql =
    "UPDATE clients SET learned_name = NULL, name_attempt_after = ?2 WHERE ip = ?1";
const keep_learned_sql =
    "UPDATE clients SET name_attempt_after = ?2 WHERE ip = ?1";

/// Records one attempt: always the next attempt time, and the learned name only
/// when the outcome decided one.
///
/// A row deleted between selection and this call makes the UPDATE touch zero
/// rows. That is a no-op by design — the device left, and nothing was learned
/// about nothing — so it is neither an error nor a counted failure.
pub fn noteNameOutcome(database: *db.Db, ip: []const u8, outcome: NameOutcome) db.Error!void {
    switch (outcome.learned) {
        .store => |text| {
            var stmt = try database.prepare(store_learned_sql);
            defer stmt.deinit();
            try stmt.bindText(1, ip);
            try stmt.bindText(2, text);
            try stmt.bindInt(3, outcome.attempt_after);
            try stmt.exec();
        },
        .clear => {
            var stmt = try database.prepare(clear_learned_sql);
            defer stmt.deinit();
            try stmt.bindText(1, ip);
            try stmt.bindInt(2, outcome.attempt_after);
            try stmt.exec();
        },
        .keep => {
            var stmt = try database.prepare(keep_learned_sql);
            defer stmt.deinit();
            try stmt.bindText(1, ip);
            try stmt.bindInt(2, outcome.attempt_after);
            try stmt.exec();
        },
    }
}

pub fn deleteAllClients(database: *db.Db) db.Error!void {
    return database.exec("DELETE FROM clients;");
}

/// Counts every row, including the materialised ones `listClients` filters out.
/// Used by tests.
pub fn countClients(database: *db.Db) db.Error!i64 {
    return database.queryInt("SELECT count(*) FROM clients");
}

// ---------------------------------------------------------------------------
// client_prefixes
// ---------------------------------------------------------------------------

const list_client_prefixes_sql =
    \\SELECT p.prefix, g.name, p.priority FROM client_prefixes p
    \\  JOIN groups g ON g.id = p.group_id
    \\ ORDER BY p.prefix
;

pub fn listClientPrefixes(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ClientPrefix) {
    return crud.listRows(model.ClientPrefix, database, gpa, list_client_prefixes_sql, readClientPrefix);
}

fn readClientPrefix(stmt: *db.Stmt, gpa: Allocator) db.Error!model.ClientPrefix {
    const prefix = try stmt.columnTextAlloc(gpa, 0);
    errdefer gpa.free(prefix);
    const group = try stmt.columnTextAlloc(gpa, 1);
    errdefer gpa.free(group);
    // The column is a 64-bit integer; the model field is `i32`. A value outside
    // that range means something other than nxdns wrote the row.
    const priority = std.math.cast(i32, stmt.columnInt(2)) orelse return error.Mismatch;
    return .{ .prefix = prefix, .group = group, .priority = priority };
}

pub fn freeClientPrefixes(gpa: Allocator, items: []const model.ClientPrefix) void {
    crud.freeRows(model.ClientPrefix, gpa, items);
}

pub fn insertClientPrefix(database: *db.Db, item: model.ClientPrefix, ctx: InsertContext) db.Error!void {
    const group_id = try ctx.groupId(item.group);

    var stmt = try database.prepare("INSERT INTO client_prefixes (prefix, group_id, priority) VALUES (?1, ?2, ?3)");
    defer stmt.deinit();
    try stmt.bindText(1, item.prefix);
    try stmt.bindInt(2, group_id);
    try stmt.bindInt(3, item.priority);
    try stmt.exec();
}

pub fn deleteAllClientPrefixes(database: *db.Db) db.Error!void {
    return database.exec("DELETE FROM client_prefixes;");
}

pub fn countClientPrefixes(database: *db.Db) db.Error!i64 {
    return database.queryInt("SELECT count(*) FROM client_prefixes");
}

// ---------------------------------------------------------------------------
// REST surface (milestone 8)
// ---------------------------------------------------------------------------
//
// `/api/clients` shows every row — a device the server materialised from live
// traffic is exactly what the operator wants to name — so these reads carry no
// `hand_edited` filter and report the flag instead. The write shapes take
// `group_id`, not a group name: the REST layer identifies every resource by row
// id, and a `group_id` no group holds must surface as the foreign-key violation
// it is.

pub const ClientRow = struct {
    id: i64,
    ip: []const u8,
    /// `clients.name` is nullable; a NULL reads as `""`, as it does on the
    /// import path.
    name: []const u8,
    /// The name learned over reverse DNS, or `""` when nothing was learned.
    /// Display-only runtime state: `name` wins whenever it is non-empty, and
    /// this never reaches an export. `name_attempt_after` is deliberately not
    /// here — it is scheduling state with no operator meaning.
    learned_name: []const u8,
    group_id: i64,
    group: []const u8,
    hand_edited: bool,
    first_seen: i64,
    last_seen: i64,
};

/// What creating a client by hand needs. `first_seen` and `last_seen` are the
/// caller's clock, so this shape does not carry them.
pub const ClientInput = struct {
    ip: []const u8,
    name: []const u8 = "",
    group_id: i64,
};

/// What editing a client may change (ruling 9). `ip` is absent on purpose: it is
/// the identity live traffic matches a row by, and rewriting it would collide
/// with the row the tracker materialises for the device that still holds it.
pub const ClientEdit = struct {
    name: []const u8 = "",
    group_id: i64,
};

const list_client_rows_sql =
    \\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen,
    \\       c.learned_name
    \\  FROM clients c
    \\  JOIN groups g ON g.id = c.group_id
    \\ ORDER BY c.ip
;

const get_client_sql =
    \\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen,
    \\       c.learned_name
    \\  FROM clients c
    \\  JOIN groups g ON g.id = c.group_id
    \\ WHERE c.id = ?1
;

/// Every client, materialised ones included. Every string is a heap copy owned
/// by `gpa`.
pub fn listClientRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientRow) {
    return crud.listRows(ClientRow, database, gpa, list_client_rows_sql, readClientRow);
}

pub fn freeClientRow(gpa: Allocator, row: ClientRow) void {
    crud.freeRow(ClientRow, gpa, row);
}

pub fn freeClientRows(gpa: Allocator, items: []const ClientRow) void {
    crud.freeRows(ClientRow, gpa, items);
}

pub fn getClient(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ClientRow {
    var stmt = try database.prepare(get_client_sql);
    defer stmt.deinit();
    try stmt.bindInt(1, id);
    if (!try stmt.step()) return null;
    return try readClientRow(&stmt, gpa);
}

fn readClientRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ClientRow {
    const ip = try stmt.columnTextAlloc(gpa, 1);
    errdefer gpa.free(ip);
    const name = try stmt.columnTextAlloc(gpa, 2);
    errdefer gpa.free(name);
    const group = try stmt.columnTextAlloc(gpa, 4);
    errdefer gpa.free(group);
    // `clients.learned_name` is nullable; NULL reads as "", like `name`.
    const learned_name = try stmt.columnTextAlloc(gpa, 8);
    errdefer gpa.free(learned_name);
    return .{
        .id = stmt.columnInt(0),
        .ip = ip,
        .name = name,
        .learned_name = learned_name,
        .group_id = stmt.columnInt(3),
        .group = group,
        .hand_edited = stmt.columnBool(5),
        .first_seen = stmt.columnInt(6),
        .last_seen = stmt.columnInt(7),
    };
}

const insert_client_row_sql =
    \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
    \\VALUES (?1, ?2, ?3, 1, ?4, ?4)
;

/// Creates a client the operator typed, so `hand_edited` is 1 — the difference
/// from `upsertSeen`, which materialises what the DNS path saw and never claims
/// a row is configuration.
///
/// `now_s` is unix epoch seconds, from `std.Io.Clock.real`; it seeds both
/// timestamps, exactly as `insertClient` does on the import path.
///
/// `error.Constraint`: `clients.ip` is UNIQUE, or `group_id` names no group.
pub fn insertClientRow(database: *db.Db, item: ClientInput, now_s: i64) db.Error!i64 {
    var stmt = try database.prepare(insert_client_row_sql);
    defer stmt.deinit();
    try stmt.bindText(1, item.ip);
    try stmt.bindText(2, item.name);
    try stmt.bindInt(3, item.group_id);
    try stmt.bindInt(4, now_s);
    try stmt.exec();
    return database.lastInsertRowid();
}

/// An edit is what makes a client configuration, so this sets `hand_edited` to
/// 1 on every call (ruling 9) and `pruneStale` stops considering the row.
/// `first_seen` and `last_seen` stay the tracker's.
///
/// `error.NotFound`: no client holds `id`. `error.Constraint`: `group_id` names
/// no group.
pub fn updateClient(database: *db.Db, id: i64, item: ClientEdit) db.Error!void {
    var stmt = try database.prepare(
        "UPDATE clients SET name = ?2, group_id = ?3, hand_edited = 1 WHERE id = ?1",
    );
    defer stmt.deinit();
    try stmt.bindInt(1, id);
    try stmt.bindText(2, item.name);
    try stmt.bindInt(3, item.group_id);
    return crud.execStrict(database, &stmt);
}

/// `error.NotFound`: no client holds `id`. Nothing references `clients`, so a
/// delete cannot violate a constraint — and a device that keeps querying
/// re-materialises through `upsertSeen`.
pub fn deleteClient(database: *db.Db, id: i64) db.Error!void {
    var stmt = try database.prepare("DELETE FROM clients WHERE id = ?1");
    defer stmt.deinit();
    try stmt.bindInt(1, id);
    return crud.execStrict(database, &stmt);
}

pub const ClientPrefixRow = struct {
    id: i64,
    prefix: []const u8,
    group_id: i64,
    group: []const u8,
    priority: i32,
};

pub const ClientPrefixInput = struct {
    prefix: []const u8,
    group_id: i64,
    priority: i32 = 100,
};

const list_client_prefix_rows_sql =
    \\SELECT p.id, p.prefix, p.group_id, g.name, p.priority FROM client_prefixes p
    \\  JOIN groups g ON g.id = p.group_id
    \\ ORDER BY p.prefix
;

/// Same order as `listClientPrefixes`; every string is a heap copy owned by
/// `gpa`.
pub fn listClientPrefixRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientPrefixRow) {
    return crud.listRows(ClientPrefixRow, database, gpa, list_client_prefix_rows_sql, readClientPrefixRow);
}

fn readClientPrefixRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ClientPrefixRow {
    const prefix = try stmt.columnTextAlloc(gpa, 1);
    errdefer gpa.free(prefix);
    const group = try stmt.columnTextAlloc(gpa, 3);
    errdefer gpa.free(group);
    // The column is a 64-bit integer; the row field is `i32`. A value outside
    // that range means something other than nxdns wrote the row.
    const priority = std.math.cast(i32, stmt.columnInt(4)) orelse return error.Mismatch;
    return .{
        .id = stmt.columnInt(0),
        .prefix = prefix,
        .group_id = stmt.columnInt(2),
        .group = group,
        .priority = priority,
    };
}

pub fn freeClientPrefixRow(gpa: Allocator, row: ClientPrefixRow) void {
    crud.freeRow(ClientPrefixRow, gpa, row);
}

pub fn freeClientPrefixRows(gpa: Allocator, items: []const ClientPrefixRow) void {
    crud.freeRows(ClientPrefixRow, gpa, items);
}

/// Replaces the whole prefix table inside a transaction (ruling 9 makes
/// `/api/client-prefixes` one atomic list resource). Row ids do not survive the
/// call: every row is written fresh.
///
/// `error.Constraint`: `client_prefixes.prefix` is UNIQUE, so a prefix repeated
/// in `items` is rejected rather than collapsed — two rows for one prefix with
/// different groups or priorities is a contradiction, not a set. Also fires when
/// a `group_id` names no group. Either way the old table survives untouched.
pub fn replaceClientPrefixes(database: *db.Db, items: []const ClientPrefixInput) db.Error!void {
    var tx = try db.Tx.begin(database);
    errdefer tx.rollback();

    try deleteAllClientPrefixes(database);

    var stmt = try database.prepare(
        "INSERT INTO client_prefixes (prefix, group_id, priority) VALUES (?1, ?2, ?3)",
    );
    defer stmt.deinit();
    for (items) |item| {
        // `reset` clears the bindings too, so all three are bound again on
        // every pass.
        try stmt.reset();
        try stmt.bindText(1, item.prefix);
        try stmt.bindInt(2, item.group_id);
        try stmt.bindInt(3, item.priority);
        try stmt.exec();
    }

    try tx.commit();
}

// ---------------------------------------------------------------------------
// reconcile surface (milestone 20)
// ---------------------------------------------------------------------------
//
// `replaceClientPrefixes` above is the REST list resource: one atomic swap of
// the whole table, in a transaction of its own. The reconcile engine cannot use
// it — it runs inside a transaction already, and rewriting every row would
// forfeit the row ids and the zero-writes property the engine exists for — so
// it edits and removes prefixes one at a time instead.

/// Writes the two columns a prefix row carries besides its identity.
///
/// `error.NotFound`: no prefix holds `id`. `error.Constraint`:
/// `client_prefixes.prefix` is UNIQUE, or `group_id` names no group.
pub fn updateClientPrefix(database: *db.Db, id: i64, item: ClientPrefixInput) db.Error!void {
    var stmt = try database.prepare(
        "UPDATE client_prefixes SET prefix = ?2, group_id = ?3, priority = ?4 WHERE id = ?1",
    );
    defer stmt.deinit();
    try stmt.bindInt(1, id);
    try stmt.bindText(2, item.prefix);
    try stmt.bindInt(3, item.group_id);
    try stmt.bindInt(4, item.priority);
    return crud.execStrict(database, &stmt);
}

/// `error.NotFound`: no prefix holds `id`. Nothing references
/// `client_prefixes`, so a delete cannot violate a constraint.
pub fn deleteClientPrefix(database: *db.Db, id: i64) db.Error!void {
    var stmt = try database.prepare("DELETE FROM client_prefixes WHERE id = ?1");
    defer stmt.deinit();
    try stmt.bindInt(1, id);
    return crud.execStrict(database, &stmt);
}

/// Moves the observed clients of one group to another, and reports how many
/// rows moved.
///
/// `clients.group_id` references `groups(id)` with no `ON DELETE` action
/// (config_schema.zig:26), so a group that any client still sits in cannot be
/// deleted. When a configuration stops declaring a group, its *declared*
/// clients go with it, but the devices the DNS path materialised into it did
/// not come from the configuration and must not be deleted for a decision that
/// was never about them. They move to the default group, which is also the
/// semantics the operator asked for: they un-declared the group, not the
/// devices.
///
/// `hand_edited = 1` rows are untouched — those are configuration, and the
/// reconcile engine has already accounted for them.
pub fn reassignObservedClients(database: *db.Db, from_group_id: i64, to_group_id: i64) db.Error!u32 {
    var stmt = try database.prepare(
        "UPDATE clients SET group_id = ?2 WHERE hand_edited = 0 AND group_id = ?1",
    );
    defer stmt.deinit();
    try stmt.bindInt(1, from_group_id);
    try stmt.bindInt(2, to_group_id);
    try stmt.exec();
    const moved = database.changes();
    return @intCast(@min(moved, std.math.maxInt(u32)));
}

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

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

/// Migration step 1 seeds `(1, 'default')`; `kids` is added here so the join
/// has two distinct groups to resolve.
fn seedGroups(database: *db.Db) !IdMap {
    try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
    var ids: IdMap = .empty;
    errdefer ids.deinit(testing.allocator);
    try ids.put(testing.allocator, "default", 1);
    try ids.put(testing.allocator, "kids", 2);
    return ids;
}

fn seedClients(database: *db.Db, ids: *const IdMap) !void {
    const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids };
    try insertClient(database, .{ .ip = "192.168.1.20", .name = "laptop", .group = "kids" }, ctx);
    try insertClient(database, .{ .ip = "192.168.1.10", .name = "desk" }, ctx);
    try insertClient(database, .{ .ip = "fd00::1", .group = "kids" }, ctx);
}

fn seedClientPrefixes(database: *db.Db, ids: *const IdMap) !void {
    const ctx: InsertContext = .{ .group_ids = ids };
    try insertClientPrefix(database, .{ .prefix = "192.168.2.0/24", .group = "kids", .priority = 10 }, ctx);
    try insertClientPrefix(database, .{ .prefix = "192.168.1.0/24", .priority = 50 }, ctx);
    try insertClientPrefix(database, .{ .prefix = "fd00::/48", .group = "kids" }, ctx);
}

test "clients round-trip in ip order with group names resolved" {
    var database = try openMigrated();
    defer database.close();
    var ids = try seedGroups(&database);
    defer ids.deinit(testing.allocator);
    try seedClients(&database, &ids);

    var items = try listClients(&database, testing.allocator);
    defer items.deinit(testing.allocator);
    defer freeClients(testing.allocator, items.items);

    try testing.expectEqual(@as(usize, 3), items.items.len);
    try testing.expectEqualStrings("192.168.1.10", items.items[0].ip);
    try testing.expectEqualStrings("desk", items.items[0].name);
    try testing.expectEqualStrings("default", items.items[0].group);
    try testing.expectEqualStrings("192.168.1.20", items.items[1].ip);
    try testing.expectEqualStrings("laptop", items.items[1].name);
    try testing.expectEqualStrings("kids", items.items[1].group);
    try testing.expectEqualStrings("fd00::1", items.items[2].ip);
    try testing.expectEqualStrings("", items.items[2].name);
    try testing.expectEqualStrings("kids", items.items[2].group);

    try testing.expectEqual(
        @as(i64, 1700000000),
        try database.queryInt("SELECT first_seen FROM clients WHERE ip = '192.168.1.10'"),
    );
    try testing.expectEqual(
        @as(i64, 1700000000),
        try database.queryInt("SELECT last_seen FROM clients WHERE ip = '192.168.1.10'"),
    );
}

test "a hand_edited = 0 client is absent from listClients but counted by countClients" {
    var database = try openMigrated();
    defer database.close();
    var ids = try seedGroups(&database);
    defer ids.deinit(testing.allocator);
    try seedClients(&database, &ids);
    try database.exec(
        \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
        \\VALUES ('10.0.0.5', 'auto', 1, 0, 1, 1);
    );

    try testing.expectEqual(@as(i64, 4), try countClients(&database));

    var items = try listClients(&database, testing.allocator);
    defer items.deinit(testing.allocator);
    defer freeClients(testing.allocator, items.items);
    try testing.expectEqual(@as(usize, 3), items.items.len);
    for (items.items) |item| {
        try testing.expect(!std.mem.eql(u8, item.ip, "10.0.0.5"));
    }
}

test "deleteAllClients empties the table and countClients reflects it" {
    var database = try openMigrated();
    defer database.close();
    var ids = try seedGroups(&database);
    defer ids.deinit(testing.allocator);
    try seedClients(&database, &ids);

    try testing.expectEqual(@as(i64, 3), try countClients(&database));
    try deleteAllClients(&database);
    try testing.expectEqual(@as(i64, 0), try countClients(&database));
}

test "insertClient reports a group the caller's map does not hold" {
    var database = try openMigrated();
    defer database.close();
    const ctx: InsertContext = .{};
    try testing.expectError(
        error.NotFound,
        insertClient(&database, .{ .ip = "192.168.1.1" }, ctx),
    );
}

fn listClientsUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
    var database = try openMigrated();
    defer database.close();
    try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
    try seedClients(&database, ids);

    var items = try listClients(&database, gpa);
    defer items.deinit(gpa);
    defer freeClients(gpa, items.items);
}

test "listClients is leak-safe under allocation failure" {
    var ids: IdMap = .empty;
    defer ids.deinit(testing.allocator);
    try ids.put(testing.allocator, "default", 1);
    try ids.put(testing.allocator, "kids", 2);
    try testing.checkAllAllocationFailures(testing.allocator, listClientsUnderFailure, .{&ids});
}

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

test "upsertSeen materialises an unseen client in the default group" {
    var database = try openMigrated();
    defer database.close();

    try upsertSeen(&database, "192.168.1.50", 1700000000);

    try testing.expectEqual(@as(i64, 1), try countClients(&database));
    const row = try seenRow(&database, "192.168.1.50");
    try testing.expectEqual(@as(i64, 0), row.hand_edited);
    try testing.expectEqual(@as(i64, 1700000000), row.first_seen);
    try testing.expectEqual(@as(i64, 1700000000), row.last_seen);
    try testing.expectEqual(@as(i64, 1), row.group_id);

    // Materialised clients are runtime state, so an export must not see them.
    var items = try listClients(&database, testing.allocator);
    defer items.deinit(testing.allocator);
    defer freeClients(testing.allocator, items.items);
    try testing.expectEqual(@as(usize, 0), items.items.len);
}

test "upsertSeen touches last_seen and leaves first_seen alone" {
    var database = try openMigrated();
    defer database.close();

    try upsertSeen(&database, "192.168.1.50", 1700000000);
    try upsertSeen(&database, "192.168.1.50", 1700000600);

    try testing.expectEqual(@as(i64, 1), try countClients(&database));
    const row = try seenRow(&database, "192.168.1.50");
    try testing.expectEqual(@as(i64, 1700000000), row.first_seen);
    try testing.expectEqual(@as(i64, 1700000600), row.last_seen);
}

test "upsertSeen keeps a hand-edited row's name, group and flag" {
    var database = try openMigrated();
    defer database.close();
    var ids = try seedGroups(&database);
    defer ids.deinit(testing.allocator);
    try seedClients(&database, &ids);

    try upsertSeen(&database, "192.168.1.20", 1700009999);

    try testing.expectEqual(@as(i64, 3), try countClients(&database));
    const row = try seenRow(&database, "192.168.1.20");
    try testing.expectEqual(@as(i64, 1), row.hand_edited);
    try testing.expectEqual(@as(i64, 2), row.group_id);
    try testing.expectEqual(@as(i64, 1700000000), row.first_seen);
    try testing.expectEqual(@as(i64, 1700009999), row.last_seen);

    var items = try listClients(&database, testing.allocator);
    defer items.deinit(testing.allocator);
    defer freeClients(testing.allocator, items.items);
    try testing.expectEqual(@as(usize, 3), items.items.len);
    try testing.expectEqualStrings("laptop", items.items[1].name);
    try testing.expectEqualStrings("kids", items.items[1].group);
}

test "upsertSeen reports a database with no default group" {
    var database = try openMigrated();
    defer database.close();
    try database.exec("UPDATE groups SET name = 'renamed' WHERE id = 1;");

    try testing.expectError(error.Constraint, upsertSeen(&database, "192.168.1.50", 1700000000));
    try testing.expectEqual(@as(i64, 0), try countClients(&database));
}

test "pruneStale removes only stale auto-materialised rows" {
    var database = try openMigrated();
    defer database.close();
    var ids = try seedGroups(&database);
    defer ids.deinit(testing.allocator);
    // A hand-edited row far older than the cutoff.
    try seedClients(&database, &ids);

    try upsertSeen(&database, "10.0.0.1", 1700000000);
    try upsertSeen(&database, "10.0.0.2", 1700000199);
    // Exactly at the cutoff: the comparison is strict, so it stays.
    try upsertSeen(&database, "10.0.0.3", 1700000200);
    try upsertSeen(&database, "10.0.0.4", 1700000300);

    try testing.expectEqual(@as(u32, 2), try pruneStale(&database, 1700000200));
    try testing.expectEqual(@as(i64, 5), try countClients(&database));
    try testing.expectEqual(
        @as(i64, 0),
        try database.queryInt("SELECT count(*) FROM clients WHERE ip IN ('10.0.0.1', '10.0.0.2')"),
    );
    try testing.expectEqual(@as(i64, 3), try database.queryInt("SELECT count(*) FROM clients WHERE hand_edited = 1"));

    // A second pass over the same cutoff finds nothing left to do.
    try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1700000200));
}

test "pruneStale spares hand-edited rows however stale" {
    var database = try openMigrated();
    defer database.close();
    var ids = try seedGroups(&database);
    defer ids.deinit(testing.allocator);
    try seedClients(&database, &ids);

    try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1800000000));
    try testing.expectEqual(@as(i64, 3), try countClients(&database));
}

// --- learned names ---------------------------------------------------------

/// `columnText` is borrowed until the statement dies, so the value is copied
/// into the caller's buffer.
fn learnedName(database: *db.Db, ip: []const u8, buf: []u8) !?[]const u8 {
    var stmt = try 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];
}

fn attemptAfter(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 "resolveCandidates selects unnamed rows in attempt-then-ip order" {
    var database = try openMigrated();
    defer database.close();

    try upsertSeen(&database, "192.168.1.30", 1700000000);
    try upsertSeen(&database, "192.168.1.10", 1700000000);
    try upsertSeen(&database, "192.168.1.20", 1700000000);
    // Attempted already, and due later than the other two.
    try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 500, .learned = .keep });

    var buf: CandidateBuf = undefined;
    const count = try resolveCandidates(&database, &buf, 1000);
    try testing.expectEqual(@as(usize, 3), count);
    try testing.expectEqualStrings("192.168.1.20", buf[0].ip());
    try testing.expectEqualStrings("192.168.1.30", buf[1].ip());
    try testing.expectEqualStrings("192.168.1.10", buf[2].ip());
}

test "resolveCandidates skips named rows whatever their hand_edited flag" {
    var database = try openMigrated();
    defer database.close();

    _ = try insertClientRow(&database, .{ .ip = "192.168.1.7", .name = "printer", .group_id = 1 }, 1);
    // Hand-edited but unnamed: grouped, not named, so it still benefits.
    _ = try insertClientRow(&database, .{ .ip = "192.168.1.8", .group_id = 1 }, 1);
    try upsertSeen(&database, "192.168.1.9", 1);
    // An empty name is as unnamed as NULL.
    try database.exec("UPDATE clients SET name = '' WHERE ip = '192.168.1.9';");

    var buf: CandidateBuf = undefined;
    const count = try resolveCandidates(&database, &buf, 1000);
    try testing.expectEqual(@as(usize, 2), count);
    try testing.expectEqualStrings("192.168.1.8", buf[0].ip());
    try testing.expectEqualStrings("192.168.1.9", buf[1].ip());
}

test "a never-attempted row is due at any now_s, and the cutoff is inclusive" {
    var database = try openMigrated();
    defer database.close();
    var buf: CandidateBuf = undefined;

    try upsertSeen(&database, "192.168.1.10", 1700000000);
    // `now_s` smaller than any cadence constant still selects the DEFAULT 0 row.
    try testing.expectEqual(@as(usize, 1), try resolveCandidates(&database, &buf, 0));

    try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 1700086400, .learned = .keep });
    try testing.expectEqual(@as(usize, 1), try resolveCandidates(&database, &buf, 1700086400));
    try testing.expectEqual(@as(usize, 0), try resolveCandidates(&database, &buf, 1700086399));
}

test "an extreme name_attempt_after never selects and never traps" {
    var database = try openMigrated();
    defer database.close();
    var buf: CandidateBuf = undefined;

    try upsertSeen(&database, "192.168.1.10", 1700000000);
    try noteNameOutcome(&database, "192.168.1.10", .{
        .attempt_after = std.math.maxInt(i64),
        .learned = .keep,
    });

    try testing.expectEqual(@as(usize, 0), try resolveCandidates(&database, &buf, std.math.maxInt(i64) - 1));
    try testing.expectEqual(@as(usize, 1), try resolveCandidates(&database, &buf, std.math.maxInt(i64)));
}

test "resolveCandidates stops at max_per_pass" {
    var database = try openMigrated();
    defer database.close();

    for (0..max_per_pass + 4) |i| {
        var ip_buf: [logger.max_client_len]u8 = undefined;
        const ip = try std.fmt.bufPrint(&ip_buf, "192.168.2.{d}", .{i});
        try upsertSeen(&database, ip, 1700000000);
    }

    var buf: CandidateBuf = undefined;
    try testing.expectEqual(max_per_pass, try resolveCandidates(&database, &buf, 1700000000));
}

test "noteNameOutcome stores, overwrites, clears and keeps" {
    var database = try openMigrated();
    defer database.close();
    var name_buf: [logger.max_client_len]u8 = undefined;
    try upsertSeen(&database, "192.168.1.10", 1700000000);

    try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 10, .learned = .{ .store = "nas.lan" } });
    try testing.expectEqualStrings("nas.lan", (try learnedName(&database, "192.168.1.10", &name_buf)).?);
    try testing.expectEqual(@as(i64, 10), try attemptAfter(&database, "192.168.1.10"));

    // The router is the authority on its own zone: a changed answer wins.
    try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 20, .learned = .{ .store = "tv.lan" } });
    try testing.expectEqualStrings("tv.lan", (try learnedName(&database, "192.168.1.10", &name_buf)).?);

    // Keep leaves the stored name and only moves the schedule.
    try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 30, .learned = .keep });
    try testing.expectEqualStrings("tv.lan", (try learnedName(&database, "192.168.1.10", &name_buf)).?);
    try testing.expectEqual(@as(i64, 30), try attemptAfter(&database, "192.168.1.10"));

    try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 40, .learned = .clear });
    try testing.expectEqual(@as(?[]const u8, null), try learnedName(&database, "192.168.1.10", &name_buf));
    try testing.expectEqual(@as(i64, 40), try attemptAfter(&database, "192.168.1.10"));
}

test "noteNameOutcome on a row that no longer exists is a no-op" {
    var database = try openMigrated();
    defer database.close();

    try noteNameOutcome(&database, "192.168.1.99", .{ .attempt_after = 10, .learned = .{ .store = "gone.lan" } });
    try noteNameOutcome(&database, "192.168.1.99", .{ .attempt_after = 10, .learned = .clear });
    try noteNameOutcome(&database, "192.168.1.99", .{ .attempt_after = 10, .learned = .keep });
    try testing.expectEqual(@as(i64, 0), try countClients(&database));
}

test "a learned name reads back on ClientRow and a NULL reads as the empty string" {
    var database = try openMigrated();
    defer database.close();

    try upsertSeen(&database, "192.168.1.10", 1700000000);
    try upsertSeen(&database, "192.168.1.11", 1700000000);
    try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 10, .learned = .{ .store = "nas.lan" } });

    var rows = try listClientRows(&database, testing.allocator);
    defer rows.deinit(testing.allocator);
    defer freeClientRows(testing.allocator, rows.items);
    try testing.expectEqualStrings("nas.lan", rows.items[0].learned_name);
    try testing.expectEqualStrings("", rows.items[1].learned_name);

    const fetched = (try getClient(&database, testing.allocator, rows.items[0].id)).?;
    defer freeClientRow(testing.allocator, fetched);
    try testing.expectEqualStrings("nas.lan", fetched.learned_name);
}

test "updateClient leaves learned_name alone and removes the row from candidacy" {
    var database = try openMigrated();
    defer database.close();

    var name_buf: [logger.max_client_len]u8 = undefined;
    try upsertSeen(&database, "192.168.1.10", 1700000000);
    try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 0, .learned = .{ .store = "nas.lan" } });
    const id = try database.queryInt("SELECT id FROM clients WHERE ip = '192.168.1.10'");
    try updateClient(&database, id, .{ .name = "the nas", .group_id = 1 });

    try testing.expectEqualStrings("nas.lan", (try learnedName(&database, "192.168.1.10", &name_buf)).?);
    var buf: CandidateBuf = undefined;
    try testing.expectEqual(@as(usize, 0), try resolveCandidates(&database, &buf, 1700000000));
}

test "an export is byte-identical across a learned name landing" {
    const export_mod = @import("../../config/export.zig");

    var database = try openMigrated();
    defer database.close();
    var ids = try seedGroups(&database);
    defer ids.deinit(testing.allocator);
    try seedClients(&database, &ids);
    try upsertSeen(&database, "192.168.1.99", 1700000000);

    var before: std.Io.Writer.Allocating = .init(testing.allocator);
    defer before.deinit();
    try export_mod.writeToWriter(testing.allocator, &database, &before.writer);

    try noteNameOutcome(&database, "192.168.1.99", .{
        .attempt_after = 1700086400,
        .learned = .{ .store = "phone.lan" },
    });

    var after: std.Io.Writer.Allocating = .init(testing.allocator);
    defer after.deinit();
    try export_mod.writeToWriter(testing.allocator, &database, &after.writer);

    try testing.expectEqualStrings(before.written(), after.written());
}

test "client_prefixes round-trip in prefix order with group names resolved" {
    var database = try openMigrated();
    defer database.close();
    var ids = try seedGroups(&database);
    defer ids.deinit(testing.allocator);
    try seedClientPrefixes(&database, &ids);

    var items = try listClientPrefixes(&database, testing.allocator);
    defer items.deinit(testing.allocator);
    defer freeClientPrefixes(testing.allocator, items.items);

    try testing.expectEqual(@as(usize, 3), items.items.len);
    try testing.expectEqualStrings("192.168.1.0/24", items.items[0].prefix);
    try testing.expectEqualStrings("default", items.items[0].group);
    try testing.expectEqual(@as(i32, 50), items.items[0].priority);
    try testing.expectEqualStrings("192.168.2.0/24", items.items[1].prefix);
    try testing.expectEqualStrings("kids", items.items[1].group);
    try testing.expectEqual(@as(i32, 10), items.items[1].priority);
    try testing.expectEqualStrings("fd00::/48", items.items[2].prefix);
    try testing.expectEqualStrings("kids", items.items[2].group);
    try testing.expectEqual(@as(i32, 100), items.items[2].priority);
}

test "deleteAllClientPrefixes empties the table and countClientPrefixes reflects it" {
    var database = try openMigrated();
    defer database.close();
    var ids = try seedGroups(&database);
    defer ids.deinit(testing.allocator);
    try seedClientPrefixes(&database, &ids);

    try testing.expectEqual(@as(i64, 3), try countClientPrefixes(&database));
    try deleteAllClientPrefixes(&database);
    try testing.expectEqual(@as(i64, 0), try countClientPrefixes(&database));
}

fn listClientPrefixesUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
    var database = try openMigrated();
    defer database.close();
    try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
    try seedClientPrefixes(&database, ids);

    var items = try listClientPrefixes(&database, gpa);
    defer items.deinit(gpa);
    defer freeClientPrefixes(gpa, items.items);
}

test "listClientPrefixes is leak-safe under allocation failure" {
    var ids: IdMap = .empty;
    defer ids.deinit(testing.allocator);
    try ids.put(testing.allocator, "default", 1);
    try ids.put(testing.allocator, "kids", 2);
    try testing.checkAllAllocationFailures(testing.allocator, listClientPrefixesUnderFailure, .{&ids});
}

// --- REST surface ----------------------------------------------------------

test "a client round-trips through insert, get, list, update and delete" {
    var database = try openMigrated();
    defer database.close();
    var ids = try seedGroups(&database);
    defer ids.deinit(testing.allocator);

    const id = try insertClientRow(&database, .{
        .ip = "192.168.1.7",
        .name = "printer",
        .group_id = 2,
    }, 1700000000);

    const fetched = (try getClient(&database, testing.allocator, id)).?;
    defer freeClientRow(testing.allocator, fetched);
    try testing.expectEqual(id, fetched.id);
    try testing.expectEqualStrings("192.168.1.7", fetched.ip);
    try testing.expectEqualStrings("printer", fetched.name);
    try testing.expectEqual(@as(i64, 2), fetched.group_id);
    try testing.expectEqualStrings("kids", fetched.group);
    try testing.expect(fetched.hand_edited);
    try testing.expectEqual(@as(i64, 1700000000), fetched.first_seen);
    try testing.expectEqual(@as(i64, 1700000000), fetched.last_seen);

    try updateClient(&database, id, .{ .name = "label printer", .group_id = 1 });
    const updated = (try getClient(&database, testing.allocator, id)).?;
    defer freeClientRow(testing.allocator, updated);
    try testing.expectEqualStrings("label printer", updated.name);
    try testing.expectEqualStrings("default", updated.group);
    try testing.expectEqualStrings("192.168.1.7", updated.ip);
    // The tracker's timestamps are not the editor's to move.
    try testing.expectEqual(@as(i64, 1700000000), updated.first_seen);

    try deleteClient(&database, id);
    try testing.expectEqual(@as(?ClientRow, null), try getClient(&database, testing.allocator, id));
    try testing.expectEqual(@as(i64, 0), try countClients(&database));
}

test "listClientRows shows materialised clients with hand_edited false" {
    var database = try openMigrated();
    defer database.close();
    var ids = try seedGroups(&database);
    defer ids.deinit(testing.allocator);

    _ = try insertClientRow(&database, .{ .ip = "192.168.1.10", .name = "desk", .group_id = 1 }, 1700000000);
    try upsertSeen(&database, "192.168.1.99", 1700000500);

    var rows = try listClientRows(&database, testing.allocator);
    defer rows.deinit(testing.allocator);
    defer freeClientRows(testing.allocator, rows.items);

    try testing.expectEqual(@as(usize, 2), rows.items.len);
    try testing.expectEqualStrings("192.168.1.10", rows.items[0].ip);
    try testing.expect(rows.items[0].hand_edited);
    try testing.expectEqualStrings("192.168.1.99", rows.items[1].ip);
    try testing.expect(!rows.items[1].hand_edited);
    // A materialised row carries no name; NULL reads as the empty string.
    try testing.expectEqualStrings("", rows.items[1].name);
    try testing.expectEqualStrings("default", rows.items[1].group);
}

test "an edited client stops being a candidate for pruneStale" {
    var database = try openMigrated();
    defer database.close();

    try upsertSeen(&database, "192.168.1.99", 1700000000);
    const id = (try database.queryInt("SELECT id FROM clients WHERE ip = '192.168.1.99'"));
    try updateClient(&database, id, .{ .name = "tv", .group_id = 1 });

    try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1800000000));
    const row = (try getClient(&database, testing.allocator, id)).?;
    defer freeClientRow(testing.allocator, row);
    try testing.expect(row.hand_edited);
}

test "client update and delete report NotFound for an id no row holds" {
    var database = try openMigrated();
    defer database.close();

    try testing.expectError(error.NotFound, updateClient(&database, 404, .{ .group_id = 1 }));
    try testing.expectError(error.NotFound, deleteClient(&database, 404));
    try testing.expectEqual(@as(?ClientRow, null), try getClient(&database, testing.allocator, 404));
}

test "a duplicate ip and an unknown group both surface as error.Constraint" {
    var database = try openMigrated();
    defer database.close();

    const id = try insertClientRow(&database, .{ .ip = "192.168.1.7", .group_id = 1 }, 1);
    try testing.expectError(
        error.Constraint,
        insertClientRow(&database, .{ .ip = "192.168.1.7", .group_id = 1 }, 1),
    );
    try testing.expectError(
        error.Constraint,
        insertClientRow(&database, .{ .ip = "192.168.1.8", .group_id = 404 }, 1),
    );
    try testing.expectError(error.Constraint, updateClient(&database, id, .{ .group_id = 404 }));
    try testing.expectEqual(@as(i64, 1), try countClients(&database));
}

test "client prefixes replace as one atomic list" {
    var database = try openMigrated();
    defer database.close();
    var ids = try seedGroups(&database);
    defer ids.deinit(testing.allocator);

    try replaceClientPrefixes(&database, &.{
        .{ .prefix = "192.168.2.0/24", .group_id = 2, .priority = 10 },
        .{ .prefix = "192.168.1.0/24", .group_id = 1, .priority = 50 },
    });

    var rows = try listClientPrefixRows(&database, testing.allocator);
    defer rows.deinit(testing.allocator);
    defer freeClientPrefixRows(testing.allocator, rows.items);
    try testing.expectEqual(@as(usize, 2), rows.items.len);
    try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix);
    try testing.expectEqual(@as(i64, 1), rows.items[0].group_id);
    try testing.expectEqualStrings("default", rows.items[0].group);
    try testing.expectEqual(@as(i32, 50), rows.items[0].priority);
    try testing.expectEqualStrings("192.168.2.0/24", rows.items[1].prefix);
    try testing.expectEqualStrings("kids", rows.items[1].group);
    try testing.expectEqual(@as(i32, 10), rows.items[1].priority);
    try testing.expect(rows.items[0].id != rows.items[1].id);

    // The replacement is total, and the empty list clears the table.
    try replaceClientPrefixes(&database, &.{.{ .prefix = "fd00::/48", .group_id = 2 }});
    try testing.expectEqual(@as(i64, 1), try countClientPrefixes(&database));
    try replaceClientPrefixes(&database, &.{});
    try testing.expectEqual(@as(i64, 0), try countClientPrefixes(&database));
}

test "replaceClientPrefixes rolls back on a duplicate prefix or an unknown group" {
    var database = try openMigrated();
    defer database.close();
    var ids = try seedGroups(&database);
    defer ids.deinit(testing.allocator);

    try replaceClientPrefixes(&database, &.{.{ .prefix = "192.168.1.0/24", .group_id = 1 }});

    try testing.expectError(error.Constraint, replaceClientPrefixes(&database, &.{
        .{ .prefix = "10.0.0.0/8", .group_id = 2 },
        .{ .prefix = "10.0.0.0/8", .group_id = 1 },
    }));
    try testing.expectError(error.Constraint, replaceClientPrefixes(&database, &.{
        .{ .prefix = "10.0.0.0/8", .group_id = 404 },
    }));

    // Both failures left the previous list in place.
    var rows = try listClientPrefixRows(&database, testing.allocator);
    defer rows.deinit(testing.allocator);
    defer freeClientPrefixRows(testing.allocator, rows.items);
    try testing.expectEqual(@as(usize, 1), rows.items.len);
    try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix);
}

fn clientRowsUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
    var database = try openMigrated();
    defer database.close();
    try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
    try seedClients(&database, ids);
    try seedClientPrefixes(&database, ids);

    var rows = try listClientRows(&database, gpa);
    defer rows.deinit(gpa);
    defer freeClientRows(gpa, rows.items);

    const one = (try getClient(&database, gpa, rows.items[0].id)).?;
    defer freeClientRow(gpa, one);

    var prefixes = try listClientPrefixRows(&database, gpa);
    defer prefixes.deinit(gpa);
    defer freeClientPrefixRows(gpa, prefixes.items);
}

test "the client read surface is leak-safe under allocation failure" {
    var ids: IdMap = .empty;
    defer ids.deinit(testing.allocator);
    try ids.put(testing.allocator, "default", 1);
    try ids.put(testing.allocator, "kids", 2);
    try testing.checkAllAllocationFailures(testing.allocator, clientRowsUnderFailure, .{&ids});
}
