//! `query_log` and its `domains` dimension table in `querylog.db`.
//!
//! Two shapes live here. The free functions follow the milestone-4 repository
//! idiom — prepare, use, finalize — because retention runs them a handful of
//! times per day, and the API read layer at the bottom of the file runs once
//! per HTTP request. The flush loop is the one hot path in the program, so it
//! gets `BatchWriter`, which owns its three statements for its whole life
//! (`db.zig:360` names this file as the reason `db.zig` carries no statement
//! cache).
//!
//! Every string in a `Row` is borrowed for the duration of the call only:
//! `Stmt.bindText` binds with `SQLITE_TRANSIENT`, so SQLite copies before
//! `writeBatch` returns.
//!
//! The rows are expendable log data. Nothing here retries, and the caller
//! decides what a failed batch means.

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

const db = @import("../db.zig");

/// One `query_log` row. The logger applies the privacy transforms of PLAN
/// §11.4 before it builds this, so `domain` and `client_ip` are already
/// whatever the operator agreed to store.
pub const Row = struct {
    timestamp: i64,
    domain: []const u8,
    client_ip: []const u8,
    qtype: ?u16,
    blocked: bool,
    block_reason: ?[]const u8,
    response_time_us: ?i64,
    cache_hit: ?bool,
    upstream: ?[]const u8,
};

const insert_domain_sql = "INSERT OR IGNORE INTO domains (domain) VALUES (?1)";

const select_domain_sql = "SELECT id FROM domains WHERE domain = ?1";

const insert_row_sql =
    \\INSERT INTO query_log
    \\  (timestamp, domain_id, client_ip, qtype, blocked, block_reason,
    \\   response_time_us, cache_hit, upstream)
    \\VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
;

/// Owns the prepared statements of the flush loop. Init once, reuse per batch.
///
/// `database` must outlive the writer and must not move: every `Stmt` holds a
/// `*Db`. Neither `Db` nor `Stmt` is thread-safe, so one writer belongs to one
/// task.
pub const BatchWriter = struct {
    database: *db.Db,
    insert_domain: db.Stmt,
    select_domain: db.Stmt,
    insert_row: db.Stmt,

    pub fn init(database: *db.Db) db.Error!BatchWriter {
        var insert_domain = try database.prepare(insert_domain_sql);
        errdefer insert_domain.deinit();
        var select_domain = try database.prepare(select_domain_sql);
        errdefer select_domain.deinit();
        const insert_row = try database.prepare(insert_row_sql);
        return .{
            .database = database,
            .insert_domain = insert_domain,
            .select_domain = select_domain,
            .insert_row = insert_row,
        };
    }

    pub fn deinit(self: *BatchWriter) void {
        self.insert_row.deinit();
        self.select_domain.deinit();
        self.insert_domain.deinit();
    }

    /// One transaction for the whole batch. Domains are interned through
    /// `INSERT OR IGNORE` followed by `SELECT id`.
    ///
    /// On any failure the transaction rolls back, so a batch is all or
    /// nothing, and the writer stays usable for the next batch.
    pub fn writeBatch(self: *BatchWriter, rows: []const Row) db.Error!void {
        if (rows.len == 0) return;

        var tx = try db.Tx.begin(self.database);
        // `errdefer`s run in reverse: the statements are released before the
        // ROLLBACK, so no read cursor is still open when it runs.
        errdefer tx.rollback();
        errdefer self.resetAll();

        for (rows) |row| {
            const domain_id = try self.internDomain(row.domain);
            try self.write(row, domain_id);
        }
        try tx.commit();
    }

    fn internDomain(self: *BatchWriter, domain: []const u8) db.Error!i64 {
        try self.insert_domain.reset();
        try self.insert_domain.bindText(1, domain);
        try self.insert_domain.exec();

        try self.select_domain.reset();
        try self.select_domain.bindText(1, domain);
        // The insert above either created the row or found it already there,
        // so a miss means the table changed under this connection.
        if (!try self.select_domain.step()) return error.NotFound;
        const id = self.select_domain.columnInt(0);
        // A statement stopped on a row keeps its cursor open until it is
        // reset; the transaction must not carry that to the next row.
        try self.select_domain.reset();
        return id;
    }

    fn write(self: *BatchWriter, row: Row, domain_id: i64) db.Error!void {
        var stmt = &self.insert_row;
        try stmt.reset();
        try stmt.bindInt(1, row.timestamp);
        try stmt.bindInt(2, domain_id);
        try stmt.bindText(3, row.client_ip);
        try bindIntOrNull(stmt, 4, if (row.qtype) |v| @as(i64, v) else null);
        try stmt.bindBool(5, row.blocked);
        try stmt.bindTextOrNull(6, row.block_reason);
        try bindIntOrNull(stmt, 7, row.response_time_us);
        try bindIntOrNull(stmt, 8, if (row.cache_hit) |v| @as(i64, @intFromBool(v)) else null);
        try stmt.bindTextOrNull(9, row.upstream);
        try stmt.exec();
    }

    /// Best effort: this runs on the failure path, where the error that
    /// matters is the one already on its way to the caller.
    fn resetAll(self: *BatchWriter) void {
        self.insert_row.reset() catch {};
        self.select_domain.reset() catch {};
        self.insert_domain.reset() catch {};
    }
};

fn bindIntOrNull(stmt: *db.Stmt, idx: c_int, value: ?i64) db.Error!void {
    if (value) |v| return stmt.bindInt(idx, v);
    return stmt.bindNull(idx);
}

/// Deletes every `query_log` row strictly older than `cutoff_ts` and returns
/// how many went.
///
/// Orphaned `domains` rows stay: it is a dimension table, re-interning a name
/// costs one indexed insert, and §11.3 asks for no collection.
pub fn pruneOlderThan(database: *db.Db, cutoff_ts: i64) db.Error!i64 {
    var stmt = try database.prepare("DELETE FROM query_log WHERE timestamp < ?1");
    defer stmt.deinit();
    try stmt.bindInt(1, cutoff_ts);
    try stmt.exec();
    return database.changes();
}

/// `PRAGMA wal_checkpoint(TRUNCATE)`: moves the WAL into the database and
/// truncates it to zero bytes, which is what keeps a day of log writes from
/// growing the WAL past the free space the disk monitor watches.
///
/// SQLite reports a checkpoint blocked by a concurrent reader in the row it
/// returns, not as an error code, so a blocked checkpoint is not an error
/// here. Retention checkpoints after every prune, so the next pass retries.
/// On a database that is not in WAL mode the pragma is a no-op.
pub fn checkpointTruncate(database: *db.Db) db.Error!void {
    return database.exec("PRAGMA wal_checkpoint(TRUNCATE);");
}

/// Rewrites the whole file. Retention runs this rarely by design — on an SD
/// card a full rewrite is the most expensive thing this program does.
pub fn vacuum(database: *db.Db) db.Error!void {
    return database.exec("VACUUM;");
}

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

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

// ---------------------------------------------------------------------------
// the API read layer (`GET /api/queries`, `/api/stats`, `/api/stats/timeseries`)
// ---------------------------------------------------------------------------

/// One row of `GET /api/queries`, joined back through the `domains` dimension.
///
/// `block_reason` and `upstream` are nullable columns, and a NULL reads as `""`
/// — the same convention `Stmt.columnText` already uses. Neither column is ever
/// written as an empty string (a reason is a word, an upstream is a URL), so the
/// mapping loses nothing and the API layer can treat `""` as "absent".
pub const QueryRow = struct {
    id: i64,
    ts: i64,
    domain: []const u8,
    client_ip: []const u8,
    qtype: ?u16,
    blocked: bool,
    block_reason: []const u8,
    response_time_us: ?i64,
    cache_hit: ?bool,
    upstream: []const u8,
};

/// Every field is an independent narrowing; `null` means "do not filter on it".
///
/// `since` is inclusive and `until` is exclusive, so adjacent windows tile
/// without double-counting a row on the boundary.
pub const QueryFilter = struct {
    limit: u32 = 100,
    /// Keyset cursor: only rows with a strictly smaller `id`. Rows come back
    /// newest-first, so this is the id of the last row of the previous page.
    before: ?i64 = null,
    /// Matched case-insensitively for ASCII, which is what SQLite's `LIKE`
    /// does and what a domain search wants.
    domain_substring: ?[]const u8 = null,
    client: ?[]const u8 = null,
    blocked: ?bool = null,
    since: ?i64 = null,
    until: ?i64 = null,
};

/// Ruling 11 caps the page at 1000; the repository enforces it too, so a caller
/// that forgets cannot ask this connection for the whole table.
pub const max_limit: u32 = 1000;

const select_head =
    \\SELECT q.id, q.timestamp, d.domain, q.client_ip, q.qtype, q.blocked,
    \\       q.block_reason, q.response_time_us, q.cache_hit, q.upstream
    \\  FROM query_log q JOIN domains d ON d.id = q.domain_id
;

/// The escape character of `where_domain`. SQLite does not give string literals
/// C escapes, so `'\'` in the SQL text is one backslash.
const like_escape = '\\';

const where_before = " q.id < ?";
const where_domain = " d.domain LIKE ? ESCAPE '\\'";
const where_client = " q.client_ip = ?";
const where_blocked = " q.blocked = ?";
const where_since = " q.timestamp >= ?";
const where_until = " q.timestamp < ?";
const select_tail = " ORDER BY q.id DESC LIMIT ?";

const where_keyword = " WHERE";
const and_keyword = " AND";

/// Assembles the statement from the fixed fragments above and nothing else.
///
/// **No value ever reaches this buffer.** Every filter contributes a `?` and is
/// bound afterwards, in the order the predicates were appended: an unnumbered
/// parameter takes the next free index, so append order and bind order are the
/// same single contract.
const Sql = struct {
    /// `where_keyword` is longer than `and_keyword` and is used at most once,
    /// so counting six of it bounds every reachable combination.
    const capacity = select_head.len + 6 * where_keyword.len + select_tail.len +
        where_before.len + where_domain.len + where_client.len +
        where_blocked.len + where_since.len + where_until.len;

    buf: [capacity]u8 = undefined,
    len: usize = 0,
    has_where: bool = false,

    fn put(self: *Sql, fragment: []const u8) void {
        @memcpy(self.buf[self.len..][0..fragment.len], fragment);
        self.len += fragment.len;
    }

    fn predicate(self: *Sql, fragment: []const u8) void {
        self.put(if (self.has_where) and_keyword else where_keyword);
        self.has_where = true;
        self.put(fragment);
    }

    fn text(self: *const Sql) []const u8 {
        return self.buf[0..self.len];
    }
};

/// Rows come back newest-first (`id DESC`). Every string is allocated from
/// `arena`, including the list's own storage, so the caller frees the whole
/// result by resetting the arena — there is nothing to unwind on failure.
pub fn selectQueries(database: *db.Db, arena: Allocator, filter: QueryFilter) db.Error!std.ArrayList(QueryRow) {
    var sql: Sql = .{};
    sql.put(select_head);
    if (filter.before != null) sql.predicate(where_before);
    if (filter.domain_substring != null) sql.predicate(where_domain);
    if (filter.client != null) sql.predicate(where_client);
    if (filter.blocked != null) sql.predicate(where_blocked);
    if (filter.since != null) sql.predicate(where_since);
    if (filter.until != null) sql.predicate(where_until);
    sql.put(select_tail);

    var stmt = try database.prepare(sql.text());
    defer stmt.deinit();

    var idx: c_int = 0;
    if (filter.before) |v| {
        idx += 1;
        try stmt.bindInt(idx, v);
    }
    if (filter.domain_substring) |v| {
        idx += 1;
        try stmt.bindText(idx, try likePattern(arena, v));
    }
    if (filter.client) |v| {
        idx += 1;
        try stmt.bindText(idx, v);
    }
    if (filter.blocked) |v| {
        idx += 1;
        try stmt.bindBool(idx, v);
    }
    if (filter.since) |v| {
        idx += 1;
        try stmt.bindInt(idx, v);
    }
    if (filter.until) |v| {
        idx += 1;
        try stmt.bindInt(idx, v);
    }
    idx += 1;
    try stmt.bindInt(idx, @min(filter.limit, max_limit));

    var out: std.ArrayList(QueryRow) = .empty;
    while (try stmt.step()) {
        try out.append(arena, .{
            .id = stmt.columnInt(0),
            .ts = stmt.columnInt(1),
            .domain = try stmt.columnTextAlloc(arena, 2),
            .client_ip = try stmt.columnTextAlloc(arena, 3),
            .qtype = if (stmt.isNull(4)) null else std.math.cast(u16, stmt.columnInt(4)) orelse
                return error.Mismatch,
            .blocked = stmt.columnBool(5),
            .block_reason = try stmt.columnTextAlloc(arena, 6),
            .response_time_us = if (stmt.isNull(7)) null else stmt.columnInt(7),
            .cache_hit = if (stmt.isNull(8)) null else stmt.columnBool(8),
            .upstream = try stmt.columnTextAlloc(arena, 9),
        });
    }
    return out;
}

/// Wraps `needle` in `%` and neutralises the two `LIKE` metacharacters, so a
/// user searching for `a_b` gets domains containing `a_b` and not domains
/// containing `axb`. The escape character escapes itself.
fn likePattern(arena: Allocator, needle: []const u8) Allocator.Error![]const u8 {
    var out: std.ArrayList(u8) = try .initCapacity(arena, needle.len * 2 + 2);
    out.appendAssumeCapacity('%');
    for (needle) |ch| {
        if (ch == '%' or ch == '_' or ch == like_escape) out.appendAssumeCapacity(like_escape);
        out.appendAssumeCapacity(ch);
    }
    out.appendAssumeCapacity('%');
    return out.items;
}

/// The `/api/stats` rollup for one period. `avg_response_time_us` is `null` when
/// no row in the window recorded a response time.
pub const StatsTotals = struct {
    queries: u64,
    blocked: u64,
    cached: u64,
    distinct_clients: u64,
    avg_response_time_us: ?i64,
};

/// The mean is derived from a sum and a count rather than SQL's `avg`, which
/// returns REAL: `Stmt` reads integers, and integer microseconds are exact.
const stats_totals_sql =
    \\SELECT count(*),
    \\       coalesce(sum(blocked <> 0), 0),
    \\       coalesce(sum(cache_hit = 1), 0),
    \\       count(DISTINCT client_ip),
    \\       coalesce(sum(response_time_us), 0),
    \\       count(response_time_us)
    \\  FROM query_log
    \\ WHERE timestamp >= ?1 AND timestamp < ?2
;

/// Aggregates `[since, until)`. An empty window is all zeros with a null mean,
/// not an error.
pub fn statsTotals(database: *db.Db, since: i64, until: i64) db.Error!StatsTotals {
    var stmt = try database.prepare(stats_totals_sql);
    defer stmt.deinit();
    try stmt.bindInt(1, since);
    try stmt.bindInt(2, until);

    // A bare aggregate always produces exactly one row; no row means the
    // statement is not the one this function prepared.
    if (!try stmt.step()) return error.Misuse;

    const timed = stmt.columnInt(5);
    return .{
        .queries = try countOf(stmt.columnInt(0)),
        .blocked = try countOf(stmt.columnInt(1)),
        .cached = try countOf(stmt.columnInt(2)),
        .distinct_clients = try countOf(stmt.columnInt(3)),
        .avg_response_time_us = if (timed == 0) null else @divTrunc(stmt.columnInt(4), timed),
    };
}

/// `count` and `sum` over non-negative columns cannot go negative; a negative
/// value means the row came from something other than this schema.
fn countOf(value: i64) db.Error!u64 {
    if (value < 0) return error.Mismatch;
    return @intCast(value);
}

/// One bucket of `/api/stats/timeseries`. `ts` is the bucket's inclusive start.
pub const Bucket = struct {
    ts: i64,
    queries: u64,
    blocked: u64,
    cached: u64,
};

const timeseries_sql =
    \\SELECT (timestamp - ?1) / ?2,
    \\       count(*),
    \\       coalesce(sum(blocked <> 0), 0),
    \\       coalesce(sum(cache_hit = 1), 0)
    \\  FROM query_log
    \\ WHERE timestamp >= ?1 AND timestamp < ?3
    \\ GROUP BY 1
;

/// Fills `out` with `out.len` buckets of `bucket_seconds` each, covering
/// `[since, since + bucket_seconds * out.len)`, and returns how many it wrote.
///
/// Every bucket is present: a window with no rows in it is written with zeros
/// rather than skipped, so the caller charts a contiguous axis without
/// reconstructing the gaps. Buckets are aligned to `since`, so the caller —
/// which knows the period grammar of ruling 13 — owns UTC alignment by choosing
/// `since`.
pub fn timeseries(database: *db.Db, since: i64, bucket_seconds: u32, out: []Bucket) db.Error!usize {
    if (out.len == 0) return 0;
    // Both are caller bugs, not runtime conditions: a zero width would make the
    // SQL divide by zero (SQLite yields NULL, silently emptying the chart), and
    // a window that does not fit i64 cannot be asked about.
    if (bucket_seconds == 0) return error.Misuse;
    const width: i64 = bucket_seconds;
    const span = std.math.mul(i64, width, std.math.cast(i64, out.len) orelse
        return error.Misuse) catch return error.Misuse;
    const until = std.math.add(i64, since, span) catch return error.Misuse;

    for (out, 0..) |*bucket, i| {
        bucket.* = .{ .ts = since + width * @as(i64, @intCast(i)), .queries = 0, .blocked = 0, .cached = 0 };
    }

    var stmt = try database.prepare(timeseries_sql);
    defer stmt.deinit();
    try stmt.bindInt(1, since);
    try stmt.bindInt(2, width);
    try stmt.bindInt(3, until);

    while (try stmt.step()) {
        // The WHERE clause already bounds the index to `out`; the check is
        // cheap and keeps a schema surprise from writing past the slice.
        const index = std.math.cast(usize, stmt.columnInt(0)) orelse return error.Mismatch;
        if (index >= out.len) return error.Mismatch;
        out[index].queries = try countOf(stmt.columnInt(1));
        out[index].blocked = try countOf(stmt.columnInt(2));
        out[index].cached = try countOf(stmt.columnInt(3));
    }
    return out.len;
}

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

const querylog_schema = @import("../querylog_schema.zig");

const testing = std.testing;

fn openLog() !db.Db {
    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    errdefer database.close();
    try db.applyPragmas(&database, .{});
    try database.exec(querylog_schema.ddl);
    return database;
}

fn plainRow(timestamp: i64, domain: []const u8) Row {
    return .{
        .timestamp = timestamp,
        .domain = domain,
        .client_ip = "192.0.2.10",
        .qtype = 1,
        .blocked = false,
        .block_reason = null,
        .response_time_us = 1200,
        .cache_hit = false,
        .upstream = "9.9.9.9",
    };
}

fn domainIdOf(database: *db.Db, domain: []const u8) !i64 {
    var stmt = try database.prepare(select_domain_sql);
    defer stmt.deinit();
    try stmt.bindText(1, domain);
    try testing.expect(try stmt.step());
    return stmt.columnInt(0);
}

test "writeBatch inserts every row and interns each domain once" {
    var database = try openLog();
    defer database.close();
    var writer = try BatchWriter.init(&database);
    defer writer.deinit();

    try writer.writeBatch(&.{
        plainRow(100, "example.com"),
        plainRow(101, "example.com"),
        plainRow(102, "ads.example.net"),
    });

    try testing.expectEqual(@as(i64, 3), try countRows(&database));
    try testing.expectEqual(@as(i64, 2), try countDomains(&database));

    const first = try domainIdOf(&database, "example.com");
    try testing.expectEqual(
        @as(i64, 2),
        try database.queryInt("SELECT count(*) FROM query_log WHERE domain_id = 1"),
    );
    try testing.expectEqual(@as(i64, 1), first);
}

test "a second batch reuses the interned domain id" {
    var database = try openLog();
    defer database.close();
    var writer = try BatchWriter.init(&database);
    defer writer.deinit();

    try writer.writeBatch(&.{plainRow(100, "example.com")});
    const before = try domainIdOf(&database, "example.com");

    try writer.writeBatch(&.{ plainRow(200, "example.com"), plainRow(201, "other.example") });
    const after = try domainIdOf(&database, "example.com");

    try testing.expectEqual(before, after);
    try testing.expectEqual(@as(i64, 3), try countRows(&database));
    try testing.expectEqual(@as(i64, 2), try countDomains(&database));
    try testing.expectEqual(
        @as(i64, 2),
        try database.queryInt("SELECT count(*) FROM query_log WHERE domain_id = 1"),
    );
}

test "nullable columns round-trip a value and a null" {
    var database = try openLog();
    defer database.close();
    var writer = try BatchWriter.init(&database);
    defer writer.deinit();

    try writer.writeBatch(&.{
        .{
            .timestamp = 10,
            .domain = "blocked.example",
            .client_ip = "2001:db8::1",
            .qtype = 28,
            .blocked = true,
            .block_reason = "blocklist",
            .response_time_us = 42,
            .cache_hit = true,
            .upstream = "dns.example",
        },
        .{
            .timestamp = 11,
            .domain = "quiet.example",
            .client_ip = "hidden",
            .qtype = null,
            .blocked = false,
            .block_reason = null,
            .response_time_us = null,
            .cache_hit = null,
            .upstream = null,
        },
    });

    var stmt = try database.prepare(
        \\SELECT d.domain, q.client_ip, q.qtype, q.blocked, q.block_reason,
        \\       q.response_time_us, q.cache_hit, q.upstream
        \\  FROM query_log q JOIN domains d ON d.id = q.domain_id
        \\ ORDER BY q.timestamp
    );
    defer stmt.deinit();

    try testing.expect(try stmt.step());
    try testing.expectEqualStrings("blocked.example", stmt.columnText(0));
    try testing.expectEqualStrings("2001:db8::1", stmt.columnText(1));
    try testing.expectEqual(@as(i64, 28), stmt.columnInt(2));
    try testing.expect(stmt.columnBool(3));
    try testing.expectEqualStrings("blocklist", stmt.columnText(4));
    try testing.expectEqual(@as(i64, 42), stmt.columnInt(5));
    try testing.expect(stmt.columnBool(6));
    try testing.expectEqualStrings("dns.example", stmt.columnText(7));

    try testing.expect(try stmt.step());
    try testing.expectEqualStrings("quiet.example", stmt.columnText(0));
    try testing.expectEqualStrings("hidden", stmt.columnText(1));
    try testing.expect(stmt.isNull(2));
    try testing.expect(!stmt.columnBool(3));
    try testing.expect(stmt.isNull(4));
    try testing.expect(stmt.isNull(5));
    try testing.expect(stmt.isNull(6));
    try testing.expect(stmt.isNull(7));

    try testing.expect(!try stmt.step());
}

test "an empty batch writes nothing and opens no transaction" {
    var database = try openLog();
    defer database.close();
    var writer = try BatchWriter.init(&database);
    defer writer.deinit();

    // A transaction is already open, so a `BEGIN IMMEDIATE` from `writeBatch`
    // would fail: this is what proves the empty batch returns before it.
    var tx = try db.Tx.begin(&database);
    try writer.writeBatch(&.{});
    tx.rollback();

    try testing.expectEqual(@as(i64, 0), try countRows(&database));
    try testing.expectEqual(@as(i64, 0), try countDomains(&database));
}

test "pruneOlderThan deletes strictly older rows and returns the count" {
    var database = try openLog();
    defer database.close();
    var writer = try BatchWriter.init(&database);
    defer writer.deinit();

    try writer.writeBatch(&.{
        plainRow(100, "old.example"),
        plainRow(199, "old.example"),
        plainRow(200, "edge.example"),
        plainRow(300, "fresh.example"),
    });

    try testing.expectEqual(@as(i64, 2), try pruneOlderThan(&database, 200));
    try testing.expectEqual(@as(i64, 2), try countRows(&database));
    // The row exactly at the cutoff stays.
    try testing.expectEqual(
        @as(i64, 1),
        try database.queryInt("SELECT count(*) FROM query_log WHERE timestamp = 200"),
    );
    // A second pass over the same cutoff finds nothing left to do.
    try testing.expectEqual(@as(i64, 0), try pruneOlderThan(&database, 200));
}

test "pruneOlderThan leaves the domains dimension table intact" {
    var database = try openLog();
    defer database.close();
    var writer = try BatchWriter.init(&database);
    defer writer.deinit();

    try writer.writeBatch(&.{ plainRow(10, "a.example"), plainRow(11, "b.example") });
    try testing.expectEqual(@as(i64, 2), try pruneOlderThan(&database, 1000));

    try testing.expectEqual(@as(i64, 0), try countRows(&database));
    try testing.expectEqual(@as(i64, 2), try countDomains(&database));
}

test "a failing row rolls the whole batch back and the writer survives it" {
    var database = try openLog();
    defer database.close();
    try database.exec(
        \\CREATE TRIGGER refuse_boom BEFORE INSERT ON query_log
        \\WHEN new.client_ip = 'boom'
        \\BEGIN SELECT RAISE(ABORT, 'refused'); END;
    );

    var writer = try BatchWriter.init(&database);
    defer writer.deinit();

    var doomed = plainRow(20, "second.example");
    doomed.client_ip = "boom";
    try testing.expectError(error.Constraint, writer.writeBatch(&.{
        plainRow(10, "first.example"),
        doomed,
    }));

    // The interned domain of the row that did insert is gone with it.
    try testing.expectEqual(@as(i64, 0), try countRows(&database));
    try testing.expectEqual(@as(i64, 0), try countDomains(&database));

    try writer.writeBatch(&.{plainRow(30, "third.example")});
    try testing.expectEqual(@as(i64, 1), try countRows(&database));
    try testing.expectEqual(@as(i64, 1), try countDomains(&database));
}

test "countRows and countDomains agree with what the batches wrote" {
    var database = try openLog();
    defer database.close();
    var writer = try BatchWriter.init(&database);
    defer writer.deinit();

    try testing.expectEqual(@as(i64, 0), try countRows(&database));
    try testing.expectEqual(@as(i64, 0), try countDomains(&database));

    var rows: [50]Row = undefined;
    var names: [50][16]u8 = undefined;
    for (&rows, &names, 0..) |*row, *name, i| {
        const written = std.fmt.bufPrint(name, "d{d}.example", .{i % 7}) catch unreachable;
        row.* = plainRow(@intCast(i), written);
    }
    try writer.writeBatch(&rows);

    try testing.expectEqual(@as(i64, 50), try countRows(&database));
    try testing.expectEqual(@as(i64, 7), try countDomains(&database));
}

// `PRAGMA wal_checkpoint` needs a real WAL, which an in-memory database cannot
// have. `std.testing.tmpDir` creates its directory under `.zig-cache/tmp/`
// relative to the process working directory, which is also how SQLite's VFS
// resolves the filename it is handed (`storage_integration_test.zig:44`).
const tmp_prefix = ".zig-cache/tmp/";
const sub_path_len = @typeInfo(@FieldType(testing.TmpDir, "sub_path")).array.len;

test "checkpointTruncate and vacuum run against a WAL file database" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();

    var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
    const path = try std.fmt.bufPrintZ(&path_buf, "{s}{s}/querylog.db", .{ tmp_prefix, &tmp.sub_path });

    var database = try db.Db.open(path, .{ .mode = .read_write_create });
    defer database.close();
    try db.applyPragmas(&database, .{});
    {
        var stmt = try database.prepare("PRAGMA journal_mode");
        defer stmt.deinit();
        try testing.expect(try stmt.step());
        // `columnText` is borrowed until the next call on the statement, so it
        // is compared here rather than carried out of this block.
        try testing.expectEqualStrings("wal", stmt.columnText(0));
    }
    try database.exec(querylog_schema.ddl);

    var writer = try BatchWriter.init(&database);
    defer writer.deinit();
    try writer.writeBatch(&.{ plainRow(10, "a.example"), plainRow(20, "b.example") });

    try checkpointTruncate(&database);
    try testing.expectEqual(@as(i64, 1), try pruneOlderThan(&database, 20));
    try checkpointTruncate(&database);
    try vacuum(&database);

    try testing.expectEqual(@as(i64, 1), try countRows(&database));
    try testing.expectEqual(@as(i64, 2), try countDomains(&database));
}

// --- the read layer -------------------------------------------------------

/// `BatchWriter` assigns `query_log.id` in the order it is handed the rows, so
/// every test below knows the id of each seeded row: the nth row of the nth
/// batch has id n.
fn seed(database: *db.Db, rows: []const Row) !void {
    var writer = try BatchWriter.init(database);
    defer writer.deinit();
    try writer.writeBatch(rows);
}

fn ids(rows: []const QueryRow, out: []i64) []const i64 {
    for (rows, 0..) |row, i| out[i] = row.id;
    return out[0..rows.len];
}

test "selectQueries returns the newest row first and reads every column" {
    var database = try openLog();
    defer database.close();
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();

    try seed(&database, &.{
        .{
            .timestamp = 10,
            .domain = "ads.example.net",
            .client_ip = "192.0.2.10",
            .qtype = 28,
            .blocked = true,
            .block_reason = "blocklist",
            .response_time_us = 4200,
            .cache_hit = true,
            .upstream = "https://dns.example/dns-query",
        },
        .{
            .timestamp = 20,
            .domain = "quiet.example",
            .client_ip = "hidden",
            .qtype = null,
            .blocked = false,
            .block_reason = null,
            .response_time_us = null,
            .cache_hit = null,
            .upstream = null,
        },
    });

    const rows = try selectQueries(&database, arena_state.allocator(), .{});
    try testing.expectEqual(@as(usize, 2), rows.items.len);

    const newest = rows.items[0];
    try testing.expectEqual(@as(i64, 2), newest.id);
    try testing.expectEqual(@as(i64, 20), newest.ts);
    try testing.expectEqualStrings("quiet.example", newest.domain);
    try testing.expectEqualStrings("hidden", newest.client_ip);
    try testing.expectEqual(@as(?u16, null), newest.qtype);
    try testing.expect(!newest.blocked);
    // A NULL text column reads as the empty string, by documented convention.
    try testing.expectEqualStrings("", newest.block_reason);
    try testing.expectEqual(@as(?i64, null), newest.response_time_us);
    try testing.expectEqual(@as(?bool, null), newest.cache_hit);
    try testing.expectEqualStrings("", newest.upstream);

    const oldest = rows.items[1];
    try testing.expectEqual(@as(i64, 1), oldest.id);
    try testing.expectEqual(@as(i64, 10), oldest.ts);
    try testing.expectEqualStrings("ads.example.net", oldest.domain);
    try testing.expectEqualStrings("192.0.2.10", oldest.client_ip);
    try testing.expectEqual(@as(?u16, 28), oldest.qtype);
    try testing.expect(oldest.blocked);
    try testing.expectEqualStrings("blocklist", oldest.block_reason);
    try testing.expectEqual(@as(?i64, 4200), oldest.response_time_us);
    try testing.expectEqual(@as(?bool, true), oldest.cache_hit);
    try testing.expectEqualStrings("https://dns.example/dns-query", oldest.upstream);
}

test "selectQueries honours the limit and caps it at max_limit" {
    var database = try openLog();
    defer database.close();
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    var rows: [1005]Row = undefined;
    for (&rows, 0..) |*row, i| row.* = plainRow(@intCast(i), "example.com");
    try seed(&database, &rows);

    const few = try selectQueries(&database, arena, .{ .limit = 3 });
    try testing.expectEqual(@as(usize, 3), few.items.len);

    // Asked for more than the cap, and for more rows than the cap, so the cap
    // is what bounds the answer rather than the table.
    const capped = try selectQueries(&database, arena, .{ .limit = 5000 });
    try testing.expectEqual(@as(usize, max_limit), capped.items.len);

    const none = try selectQueries(&database, arena, .{ .limit = 0 });
    try testing.expectEqual(@as(usize, 0), none.items.len);
}

test "keyset paging walks every row exactly once across the page boundaries" {
    var database = try openLog();
    defer database.close();
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    var seeded: [7]Row = undefined;
    for (&seeded, 0..) |*row, i| row.* = plainRow(@intCast(i), "example.com");
    try seed(&database, &seeded);

    var seen: std.ArrayList(i64) = .empty;
    defer seen.deinit(testing.allocator);

    var before: ?i64 = null;
    var pages: usize = 0;
    while (pages < 10) : (pages += 1) {
        const page = try selectQueries(&database, arena, .{ .limit = 3, .before = before });
        if (page.items.len == 0) break;
        for (page.items) |row| try seen.append(testing.allocator, row.id);
        before = page.items[page.items.len - 1].id;
    }

    // Two full pages and one short page; the fourth call returns nothing and
    // breaks before the counter, which is how the walk knows it is done.
    try testing.expectEqual(@as(usize, 3), pages);
    try testing.expectEqualSlices(i64, &.{ 7, 6, 5, 4, 3, 2, 1 }, seen.items);
}

test "each filter narrows the result on its own" {
    var database = try openLog();
    defer database.close();
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    var blocked_row = plainRow(200, "ads.example.net");
    blocked_row.client_ip = "192.0.2.20";
    blocked_row.blocked = true;
    blocked_row.block_reason = "blocklist";
    try seed(&database, &.{
        plainRow(100, "one.example.com"),
        blocked_row,
        plainRow(300, "two.example.com"),
    });

    var buf: [8]i64 = undefined;

    const by_domain = try selectQueries(&database, arena, .{ .domain_substring = "example.com" });
    try testing.expectEqualSlices(i64, &.{ 3, 1 }, ids(by_domain.items, &buf));

    const by_client = try selectQueries(&database, arena, .{ .client = "192.0.2.20" });
    try testing.expectEqualSlices(i64, &.{2}, ids(by_client.items, &buf));

    // An exact match, not a prefix: the seeded clients share the first octets.
    const no_client = try selectQueries(&database, arena, .{ .client = "192.0.2" });
    try testing.expectEqual(@as(usize, 0), no_client.items.len);

    const only_blocked = try selectQueries(&database, arena, .{ .blocked = true });
    try testing.expectEqualSlices(i64, &.{2}, ids(only_blocked.items, &buf));

    const only_allowed = try selectQueries(&database, arena, .{ .blocked = false });
    try testing.expectEqualSlices(i64, &.{ 3, 1 }, ids(only_allowed.items, &buf));

    // Every filter at once, all satisfied by the one blocked row.
    const combined = try selectQueries(&database, arena, .{
        .limit = 10,
        .before = 3,
        .domain_substring = "ads",
        .client = "192.0.2.20",
        .blocked = true,
        .since = 200,
        .until = 300,
    });
    try testing.expectEqualSlices(i64, &.{2}, ids(combined.items, &buf));
}

test "since is inclusive, until is exclusive, and an empty range selects nothing" {
    var database = try openLog();
    defer database.close();
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    try seed(&database, &.{
        plainRow(100, "a.example"),
        plainRow(200, "b.example"),
        plainRow(300, "c.example"),
    });

    var buf: [8]i64 = undefined;

    const window = try selectQueries(&database, arena, .{ .since = 100, .until = 300 });
    try testing.expectEqualSlices(i64, &.{ 2, 1 }, ids(window.items, &buf));

    const after = try selectQueries(&database, arena, .{ .since = 300 });
    try testing.expectEqualSlices(i64, &.{3}, ids(after.items, &buf));

    const empty = try selectQueries(&database, arena, .{ .since = 300, .until = 300 });
    try testing.expectEqual(@as(usize, 0), empty.items.len);

    const beyond = try selectQueries(&database, arena, .{ .since = 1000 });
    try testing.expectEqual(@as(usize, 0), beyond.items.len);
}

test "a domain substring matches % and _ as literal characters" {
    var database = try openLog();
    defer database.close();
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    try seed(&database, &.{
        plainRow(10, "a_b.example"),
        plainRow(20, "axb.example"),
        plainRow(30, "a%b.example"),
        plainRow(40, "azzb.example"),
        plainRow(50, "back\\slash.example"),
    });

    var buf: [8]i64 = undefined;

    // Unescaped, `_` is LIKE's single-character wildcard and would also match
    // "axb"; escaped, it matches only the underscore.
    const underscore = try selectQueries(&database, arena, .{ .domain_substring = "a_b" });
    try testing.expectEqualSlices(i64, &.{1}, ids(underscore.items, &buf));

    // Unescaped, `%` would match everything from "a" to "b", so "azzb" too.
    const percent = try selectQueries(&database, arena, .{ .domain_substring = "a%b" });
    try testing.expectEqualSlices(i64, &.{3}, ids(percent.items, &buf));

    // The escape character escapes itself, so it is searchable as well.
    const backslash = try selectQueries(&database, arena, .{ .domain_substring = "k\\s" });
    try testing.expectEqualSlices(i64, &.{5}, ids(backslash.items, &buf));

    // An empty needle is `%%`, which matches every row rather than none.
    const all = try selectQueries(&database, arena, .{ .domain_substring = "" });
    try testing.expectEqual(@as(usize, 5), all.items.len);
}

test "statsTotals aggregates the window and averages only the timed rows" {
    var database = try openLog();
    defer database.close();

    var timed = plainRow(100, "a.example");
    timed.response_time_us = 100;
    var blocked_row = plainRow(150, "ads.example");
    blocked_row.blocked = true;
    blocked_row.block_reason = "blocklist";
    blocked_row.response_time_us = 200;
    var cached = plainRow(199, "b.example");
    cached.client_ip = "192.0.2.99";
    cached.cache_hit = true;
    cached.response_time_us = null;
    try seed(&database, &.{ timed, blocked_row, cached, plainRow(200, "outside.example") });

    const totals = try statsTotals(&database, 100, 200);
    try testing.expectEqual(@as(u64, 3), totals.queries);
    try testing.expectEqual(@as(u64, 1), totals.blocked);
    try testing.expectEqual(@as(u64, 1), totals.cached);
    try testing.expectEqual(@as(u64, 2), totals.distinct_clients);
    // (100 + 200) / 2 — the untimed row is not in the divisor.
    try testing.expectEqual(@as(?i64, 150), totals.avg_response_time_us);
}

test "statsTotals over an empty window is zeros with a null average" {
    var database = try openLog();
    defer database.close();
    try seed(&database, &.{plainRow(100, "a.example")});

    for ([_][2]i64{ .{ 500, 600 }, .{ 100, 100 } }) |window| {
        const totals = try statsTotals(&database, window[0], window[1]);
        try testing.expectEqual(@as(u64, 0), totals.queries);
        try testing.expectEqual(@as(u64, 0), totals.blocked);
        try testing.expectEqual(@as(u64, 0), totals.cached);
        try testing.expectEqual(@as(u64, 0), totals.distinct_clients);
        try testing.expectEqual(@as(?i64, null), totals.avg_response_time_us);
    }
}

test "timeseries writes every bucket, including the ones with no rows" {
    var database = try openLog();
    defer database.close();

    var blocked_row = plainRow(1020, "ads.example");
    blocked_row.blocked = true;
    blocked_row.block_reason = "blocklist";
    var cached = plainRow(1035, "b.example");
    cached.cache_hit = true;
    try seed(&database, &.{
        plainRow(995, "before.example"),
        plainRow(1000, "a.example"),
        plainRow(1009, "a.example"),
        blocked_row,
        cached,
        plainRow(1040, "after.example"),
    });

    var buckets: [4]Bucket = undefined;
    try testing.expectEqual(@as(usize, 4), try timeseries(&database, 1000, 10, &buckets));

    // The row at 995 is before the window and the row at 1040 is past its end;
    // neither lands in a bucket.
    try testing.expectEqualSlices(Bucket, &.{
        .{ .ts = 1000, .queries = 2, .blocked = 0, .cached = 0 },
        .{ .ts = 1010, .queries = 0, .blocked = 0, .cached = 0 },
        .{ .ts = 1020, .queries = 1, .blocked = 1, .cached = 0 },
        .{ .ts = 1030, .queries = 1, .blocked = 0, .cached = 1 },
    }, &buckets);
}

test "timeseries over an empty table still writes the whole axis" {
    var database = try openLog();
    defer database.close();

    var buckets: [3]Bucket = undefined;
    try testing.expectEqual(@as(usize, 3), try timeseries(&database, 0, 60, &buckets));
    try testing.expectEqualSlices(Bucket, &.{
        .{ .ts = 0, .queries = 0, .blocked = 0, .cached = 0 },
        .{ .ts = 60, .queries = 0, .blocked = 0, .cached = 0 },
        .{ .ts = 120, .queries = 0, .blocked = 0, .cached = 0 },
    }, &buckets);
}

test "timeseries rejects a zero-width bucket and accepts an empty slice" {
    var database = try openLog();
    defer database.close();

    var buckets: [2]Bucket = undefined;
    try testing.expectError(error.Misuse, timeseries(&database, 0, 0, &buckets));

    var none: [0]Bucket = undefined;
    try testing.expectEqual(@as(usize, 0), try timeseries(&database, 0, 0, &none));
}

test "timeseries reports a window that does not fit an i64 rather than wrapping" {
    var database = try openLog();
    defer database.close();

    var buckets: [4]Bucket = undefined;
    try testing.expectError(
        error.Misuse,
        timeseries(&database, std.math.maxInt(i64) - 1, 3600, &buckets),
    );
}

test "the built SQL never carries a filter value and fits its buffer" {
    var sql: Sql = .{};
    sql.put(select_head);
    sql.predicate(where_before);
    sql.predicate(where_domain);
    sql.predicate(where_client);
    sql.predicate(where_blocked);
    sql.predicate(where_since);
    sql.predicate(where_until);
    sql.put(select_tail);

    // Every predicate present is the longest reachable statement.
    try testing.expect(sql.len <= Sql.capacity);
    try testing.expectEqual(@as(usize, 1), std.mem.count(u8, sql.text(), " WHERE"));
    try testing.expectEqual(@as(usize, 5), std.mem.count(u8, sql.text(), " AND"));
    // Six filters plus the LIMIT, each a bare parameter.
    try testing.expectEqual(@as(usize, 7), std.mem.count(u8, sql.text(), "?"));
}

test "likePattern wraps the needle and neutralises every metacharacter" {
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    try testing.expectEqualStrings("%plain%", try likePattern(arena, "plain"));
    try testing.expectEqualStrings("%a\\_b%", try likePattern(arena, "a_b"));
    try testing.expectEqualStrings("%a\\%b%", try likePattern(arena, "a%b"));
    try testing.expectEqualStrings("%a\\\\b%", try likePattern(arena, "a\\b"));
    try testing.expectEqualStrings("%%", try likePattern(arena, ""));
}
