//! Async query logger (PLAN §11.4). The query path hands an `Entry` to `log`
//! and never touches the database: one writer task owns the `db.Db` handle, and
//! everything between the two is an `std.Io.Queue`.
//!
//! `Io.Queue` copies elements as raw bytes (`Io.zig:2189`), so an `Entry` owns
//! every byte it carries — a slice into the caller's packet buffer would dangle
//! the moment the query finishes. That is the whole reason this file has fixed
//! buffers instead of slices.
//!
//! The privacy transforms of §11.4 run in `transformed`, before the entry is
//! enqueued, so nothing downstream — the database or the event stream — can
//! observe a value the operator asked to hide. `log` is the two halves in
//! order; `QuerySink` calls them separately so both of its consumers see the
//! one transformed entry.
//!
//! Log rows are expendable. A full queue drops the oldest unflushed entry, a
//! failed batch is dropped whole, and a disk that crossed the critical
//! threshold holds batches back indefinitely. Each of the three has a counter.
//! A writer that cannot prepare its statements closes the queue and marks
//! `writer_failed`, so the loss is visible rather than silent.

const std = @import("std");

const db = @import("db.zig");
const disk_monitor = @import("disk_monitor.zig");
const model = @import("../config/model.zig");
const queries_repo = @import("repositories/queries_repo.zig");

/// Named `scope` rather than `log`: `Logger.log` is the enqueue entry point,
/// and the two names collide inside the struct.
const scope = std.log.scoped(.query_logger);

/// Flush tuning is comptime: §12.1 defines no configuration keys for it and a
/// household deployment has no reason to tune it.
pub const flush_batch = 100;
pub const flush_interval_ms = 100;

/// What `hide_domains` and `hide_client_ips` store instead of the real value.
pub const hidden_marker = "hidden";

/// How long a batch waits before it re-reads the disk monitor.
pub const gate_retry_s = 1;

/// The widths of the query log's text columns, and the single source of them:
/// every producer that formats into one of these fields sizes its own buffer
/// from the constant here, so nothing can format wider than the row stores.
pub const max_domain_len = 253;
/// RFC 5952 text of any IPv6 address, zone identifier included.
pub const max_client_len = 45;
pub const max_reason_len = 32;
pub const max_upstream_len = 64;

/// One row on its way to `query_log`, carrying its own bytes.
pub const Entry = struct {
    timestamp: i64,
    domain_buf: [max_domain_len]u8,
    domain_len: u8,
    client_buf: [max_client_len]u8,
    client_len: u8,
    qtype: ?u16,
    blocked: bool,
    reason_buf: [max_reason_len]u8,
    reason_len: u8,
    response_time_us: ?i64,
    cache_hit: ?bool,
    upstream_buf: [max_upstream_len]u8,
    upstream_len: u8,

    /// The borrowed shape of an entry. `init` copies out of it, so a caller can
    /// build one from slices that die with the query.
    pub const Fields = struct {
        timestamp: i64,
        domain: []const u8,
        client_ip: []const u8,
        qtype: ?u16 = null,
        blocked: bool = false,
        /// Empty means "no reason", which reaches the database as NULL.
        block_reason: []const u8 = "",
        response_time_us: ?i64 = null,
        cache_hit: ?bool = null,
        /// Empty means "no upstream", which reaches the database as NULL.
        upstream: []const u8 = "",
    };

    /// Copies each string in, truncated to what its buffer holds. A name longer
    /// than 253 bytes is not a valid domain name, so truncation here means the
    /// caller skipped the parser, not that a real name was lost.
    pub fn init(f: Fields) Entry {
        var entry: Entry = .{
            .timestamp = f.timestamp,
            .domain_buf = undefined,
            .domain_len = 0,
            .client_buf = undefined,
            .client_len = 0,
            .qtype = f.qtype,
            .blocked = f.blocked,
            .reason_buf = undefined,
            .reason_len = 0,
            .response_time_us = f.response_time_us,
            .cache_hit = f.cache_hit,
            .upstream_buf = undefined,
            .upstream_len = 0,
        };
        entry.setDomain(f.domain);
        entry.setClientIp(f.client_ip);
        entry.reason_len = copyInto(&entry.reason_buf, f.block_reason);
        entry.upstream_len = copyInto(&entry.upstream_buf, f.upstream);
        return entry;
    }

    pub fn setDomain(self: *Entry, value: []const u8) void {
        self.domain_len = copyInto(&self.domain_buf, value);
    }

    pub fn setClientIp(self: *Entry, value: []const u8) void {
        self.client_len = copyInto(&self.client_buf, value);
    }

    pub fn domain(self: *const Entry) []const u8 {
        return self.domain_buf[0..self.domain_len];
    }

    pub fn clientIp(self: *const Entry) []const u8 {
        return self.client_buf[0..self.client_len];
    }

    pub fn blockReason(self: *const Entry) []const u8 {
        return self.reason_buf[0..self.reason_len];
    }

    pub fn upstream(self: *const Entry) []const u8 {
        return self.upstream_buf[0..self.upstream_len];
    }
};

fn copyInto(buf: []u8, value: []const u8) u8 {
    const n = @min(buf.len, value.len);
    @memcpy(buf[0..n], value[0..n]);
    return @intCast(n);
}

/// The row borrows from `entry`, which must outlive the `writeBatch` call.
fn toRow(entry: *const Entry) queries_repo.Row {
    return .{
        .timestamp = entry.timestamp,
        .domain = entry.domain(),
        .client_ip = entry.clientIp(),
        .qtype = entry.qtype,
        .blocked = entry.blocked,
        .block_reason = emptyAsNull(entry.blockReason()),
        .response_time_us = entry.response_time_us,
        .cache_hit = entry.cache_hit,
        .upstream = emptyAsNull(entry.upstream()),
    };
}

fn emptyAsNull(value: []const u8) ?[]const u8 {
    return if (value.len == 0) null else value;
}

const EntryQueue = std.Io.Queue(Entry);

/// What the flush interval race can produce. `Select` demands that each field
/// type match its task's return type exactly.
const Outcome = union(enum) {
    entry: std.Io.Cancelable!?Entry,
    expiry: std.Io.Cancelable!void,
};

pub const Logger = struct {
    cfg: model.Logging,
    queue: EntryQueue,
    queries_dropped: std.atomic.Value(u64),
    rows_written: std.atomic.Value(u64),
    batches_gated: std.atomic.Value(u64),
    /// Set when `runWriter` gives up before it consumed anything. The queue is
    /// closed and every entry counts as dropped from that point, so a caller
    /// that sees this must not expect rows.
    writer_failed: std.atomic.Value(bool),

    /// `queue_buf.len` is the backpressure cap — the composition root
    /// (`app.zig:311`) allocates `cfg.logging.query_log_buffer_max` entries,
    /// which `config/validate.zig` bounds. The queue holds waiting tasks in
    /// intrusive lists, so a `Logger` must not be moved once anything has
    /// touched it.
    pub fn init(cfg: model.Logging, queue_buf: []Entry) Logger {
        return .{
            .cfg = cfg,
            .queue = .init(queue_buf),
            .queries_dropped = .init(0),
            .rows_written = .init(0),
            .batches_gated = .init(0),
            .writer_failed = .init(false),
        };
    }

    /// Applies the privacy transforms and enqueues without ever blocking the
    /// query path. A full queue loses its oldest unflushed entry (§11.4).
    pub fn log(self: *Logger, io: std.Io, entry: Entry) void {
        self.logTransformed(io, self.transformed(entry));
    }

    /// The §11.4 privacy transforms, on their own. `QuerySink` runs them once
    /// and hands the result to every consumer, so nothing downstream — the
    /// database or the event stream — can observe a value the operator asked
    /// to hide.
    pub fn transformed(self: *const Logger, entry: Entry) Entry {
        var out = entry;
        if (self.cfg.hide_domains) out.setDomain(hidden_marker);
        if (self.cfg.hide_client_ips) out.setClientIp(hidden_marker);
        return out;
    }

    /// `log` without the transforms, for a caller that already applied them.
    pub fn logTransformed(self: *Logger, io: std.Io, entry: Entry) void {
        self.enqueue(io, entry);
    }

    /// Retries until the put succeeds, and each failed attempt drops exactly
    /// one oldest entry. A fixed attempt cap would break the policy under
    /// contention: a producer that steals the slot this call freed would make
    /// this call pay for two entries, the dropped one and its own.
    fn enqueue(self: *Logger, io: std.Io, entry: Entry) void {
        while (true) {
            // A closed queue or a canceled task means shutdown is underway;
            // both leave this entry unwritten, which is what the counter says.
            const put = self.queue.put(io, &.{entry}, 0) catch break;
            if (put == 1) return;

            // A zero-capacity queue holds nothing to drop: the put above was
            // this entry's one chance at a waiting getter.
            if (self.queue.capacity() == 0) break;

            var oldest: [1]Entry = undefined;
            const got = self.queue.get(io, &oldest, 0) catch break;
            if (got == 1) self.countDropped(1);
        }
        self.countDropped(1);
    }

    /// The writer task: owns `database` and its prepared statements for its
    /// whole life. Returns when `shutdown` closes the queue and the last batch
    /// is flushed, or when the task is canceled.
    ///
    /// `monitor` is the §11.6 gate. Null disables gating.
    pub fn runWriter(
        self: *Logger,
        io: std.Io,
        database: *db.Db,
        monitor: ?*disk_monitor.Monitor,
    ) std.Io.Cancelable!void {
        var writer = queries_repo.BatchWriter.init(database) catch |err| {
            scope.warn("query logger: preparing the batch statements failed: {s}", .{@errorName(err)});
            // Without a writer there is no consumer, so leaving the queue open
            // would silently swallow every later entry.
            self.writer_failed.store(true, .release);
            self.queue.close(io);
            self.dropRemaining(io);
            return;
        };
        defer writer.deinit();

        var batch: [flush_batch]Entry = undefined;
        while (true) {
            // A closed queue hands over its buffered elements before it reports
            // `Closed` (`Io.zig:2118`), so this drains before it returns.
            batch[0] = self.queue.getOne(io) catch |err| switch (err) {
                error.Closed => return,
                error.Canceled => |e| return e,
            };
            const deadline: std.Io.Clock.Timestamp = .fromNow(io, .{
                .raw = .fromMilliseconds(flush_interval_ms),
                .clock = .awake,
            });
            // `n` is live across both calls: entries already taken off the
            // queue are lost if either one is canceled, so they must count.
            var n: usize = 1;
            self.fill(io, &batch, deadline, &n) catch |err| {
                self.countDropped(n);
                return err;
            };
            self.flush(io, &writer, batch[0..n], monitor) catch |err| {
                self.countDropped(n);
                return err;
            };
        }
    }

    /// Counts every entry left in a closed queue as dropped. The drain is
    /// uncancelable: a cancellation racing the writer's own failure would
    /// otherwise abandon the buffered entries without counting them.
    fn dropRemaining(self: *Logger, io: std.Io) void {
        var leftover: [flush_batch]Entry = undefined;
        while (true) {
            const n = self.queue.getUncancelable(io, &leftover, 0) catch |err| switch (err) {
                error.Closed => break,
            };
            if (n == 0) break;
            self.countDropped(n);
        }
    }

    /// Closes the queue. `log` drops from here on and `runWriter` returns once
    /// it has flushed what was left.
    ///
    /// A writer held by the disk gate keeps holding: it flushes when the disk
    /// recovers, and the `group.cancel` that follows this call in `app.zig`
    /// stops a writer that will not wait. A canceled
    /// writer counts the batch it holds under `queries_dropped`.
    pub fn shutdown(self: *Logger, io: std.Io) void {
        self.queue.close(io);
    }

    /// Fills `batch` behind the entry already in slot 0, until it is full or
    /// `deadline` passes. `n` counts the slots that hold an entry, and stays
    /// accurate on the cancellation path so the caller can count what is lost.
    fn fill(
        self: *Logger,
        io: std.Io,
        batch: *[flush_batch]Entry,
        deadline: std.Io.Clock.Timestamp,
        n: *usize,
    ) std.Io.Cancelable!void {
        n.* += self.drainAvailable(io, batch[n.*..]);

        while (n.* < batch.len) {
            const remaining = deadline.durationFromNow(io);
            if (remaining.raw.nanoseconds <= 0) break;
            const entry = try self.getWithin(io, remaining) orelse break;
            batch[n.*] = entry;
            n.* += 1;
            n.* += self.drainAvailable(io, batch[n.*..]);
        }
    }

    /// Whatever is already queued, without blocking.
    fn drainAvailable(self: *Logger, io: std.Io, room: []Entry) usize {
        if (room.len == 0) return 0;
        return self.queue.get(io, room, 0) catch 0;
    }

    /// Races one blocking `getOne` against the rest of the flush interval —
    /// `std.Io.Condition` has no timed wait, so the timer is a task.
    ///
    /// The loser is drained rather than discarded: a `getOne` that finishes
    /// just after the timer has already taken an entry off the queue, and
    /// `Select.cancelDiscard` would throw that entry away.
    fn getWithin(
        self: *Logger,
        io: std.Io,
        budget: std.Io.Clock.Duration,
    ) std.Io.Cancelable!?Entry {
        var outcomes: [2]Outcome = undefined;
        var race: std.Io.Select(Outcome) = .init(io, &outcomes);

        race.concurrent(.entry, takeOne, .{ &self.queue, io }) catch |err| switch (err) {
            // No second unit of concurrency: the caller flushes what it holds
            // rather than block past the interval.
            error.ConcurrencyUnavailable => return null,
        };
        race.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) {
            error.ConcurrencyUnavailable => return drainRace(&race),
        };

        const first = race.await() catch |err| {
            // Teardown: the entry the getter already took has nowhere to go.
            if (drainRace(&race)) |_| self.countDropped(1);
            return err;
        };
        const late = drainRace(&race);
        return outcomeEntry(first) orelse late;
    }

    /// One batch, one transaction. A batch is dropped whole on a database
    /// failure: these are log rows, and blocking on them would fill the queue
    /// and cost live queries instead.
    fn flush(
        self: *Logger,
        io: std.Io,
        writer: *queries_repo.BatchWriter,
        entries: []const Entry,
        monitor: ?*disk_monitor.Monitor,
    ) std.Io.Cancelable!void {
        if (entries.len == 0) return;

        if (monitor) |m| {
            const pause: std.Io.Clock.Duration = .{
                .raw = .fromSeconds(gate_retry_s),
                .clock = .awake,
            };
            while (!m.writesAllowed()) {
                _ = self.batches_gated.fetchAdd(1, .monotonic);
                try pause.sleep(io);
            }
        }

        var rows: [flush_batch]queries_repo.Row = undefined;
        for (entries, rows[0..entries.len]) |*entry, *row| row.* = toRow(entry);

        writer.writeBatch(rows[0..entries.len]) catch |err| {
            scope.warn("query log batch of {d} rows dropped: {s}", .{ entries.len, @errorName(err) });
            self.countDropped(entries.len);
            return;
        };
        _ = self.rows_written.fetchAdd(entries.len, .monotonic);
    }

    fn countDropped(self: *Logger, n: usize) void {
        _ = self.queries_dropped.fetchAdd(n, .monotonic);
    }
};

fn takeOne(queue: *EntryQueue, io: std.Io) std.Io.Cancelable!?Entry {
    const entry = queue.getOne(io) catch |err| switch (err) {
        error.Closed => return null,
        error.Canceled => |e| return e,
    };
    return entry;
}

fn drainRace(race: *std.Io.Select(Outcome)) ?Entry {
    var found: ?Entry = null;
    while (race.cancel()) |outcome| {
        if (outcomeEntry(outcome)) |entry| found = entry;
    }
    return found;
}

fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void {
    return budget.sleep(io);
}

fn outcomeEntry(outcome: Outcome) ?Entry {
    return switch (outcome) {
        .entry => |result| result catch null,
        .expiry => null,
    };
}

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

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

const testing = std.testing;

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

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

test "an entry carries its own bytes and reads them back" {
    const entry: Entry = .init(.{
        .timestamp = 1700000000,
        .domain = "ads.example.com",
        .client_ip = "2001:db8::1",
        .qtype = 28,
        .blocked = true,
        .block_reason = "blocklist",
        .response_time_us = 42,
        .cache_hit = true,
        .upstream = "dns.example",
    });

    try testing.expectEqualStrings("ads.example.com", entry.domain());
    try testing.expectEqualStrings("2001:db8::1", entry.clientIp());
    try testing.expectEqualStrings("blocklist", entry.blockReason());
    try testing.expectEqualStrings("dns.example", entry.upstream());
    try testing.expectEqual(@as(?u16, 28), entry.qtype);
    try testing.expect(entry.blocked);
    try testing.expectEqual(@as(?i64, 42), entry.response_time_us);
    try testing.expectEqual(@as(?bool, true), entry.cache_hit);
}

test "an oversize string is truncated to what its buffer holds" {
    const long_domain = "a" ** 400;
    const entry: Entry = .init(.{
        .timestamp = 1,
        .domain = long_domain,
        .client_ip = "192.0.2.1",
        .block_reason = "r" ** 64,
        .upstream = "u" ** 128,
    });

    try testing.expectEqual(@as(usize, max_domain_len), entry.domain().len);
    try testing.expectEqual(@as(usize, max_reason_len), entry.blockReason().len);
    try testing.expectEqual(@as(usize, max_upstream_len), entry.upstream().len);
    try testing.expectEqualStrings("a" ** max_domain_len, entry.domain());
}

test "toRow maps the empty strings to null and passes the rest through" {
    const bare: Entry = .init(.{
        .timestamp = 7,
        .domain = "example.com",
        .client_ip = "192.0.2.5",
    });
    const bare_row = toRow(&bare);
    try testing.expectEqual(@as(i64, 7), bare_row.timestamp);
    try testing.expectEqualStrings("example.com", bare_row.domain);
    try testing.expectEqualStrings("192.0.2.5", bare_row.client_ip);
    try testing.expectEqual(@as(?[]const u8, null), bare_row.block_reason);
    try testing.expectEqual(@as(?[]const u8, null), bare_row.upstream);
    try testing.expectEqual(@as(?u16, null), bare_row.qtype);
    try testing.expectEqual(@as(?bool, null), bare_row.cache_hit);

    const full: Entry = .init(.{
        .timestamp = 8,
        .domain = "blocked.example",
        .client_ip = "192.0.2.6",
        .blocked = true,
        .block_reason = "blocklist",
        .upstream = "9.9.9.9",
    });
    const full_row = toRow(&full);
    try testing.expect(full_row.blocked);
    try testing.expectEqualStrings("blocklist", full_row.block_reason.?);
    try testing.expectEqualStrings("9.9.9.9", full_row.upstream.?);
}

test "log applies both privacy transforms before the entry reaches the queue" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

    logger.log(io, sampleEntry(100, "tracker.example"));

    const queued = try logger.queue.getOne(io);
    try testing.expectEqualStrings(hidden_marker, queued.domain());
    try testing.expectEqualStrings(hidden_marker, queued.clientIp());
    try testing.expectEqual(@as(i64, 100), queued.timestamp);
    try testing.expectEqual(@as(u64, 0), logger.queries_dropped.load(.monotonic));
}

test "log hides only the field its switch names" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var buf: [4]Entry = undefined;
    var domains_only: Logger = .init(.{ .hide_domains = true }, &buf);
    domains_only.log(io, sampleEntry(1, "tracker.example"));
    const hidden_domain = try domains_only.queue.getOne(io);
    try testing.expectEqualStrings(hidden_marker, hidden_domain.domain());
    try testing.expectEqualStrings("192.0.2.10", hidden_domain.clientIp());

    var clients_only: Logger = .init(.{ .hide_client_ips = true }, &buf);
    clients_only.log(io, sampleEntry(2, "tracker.example"));
    const hidden_client = try clients_only.queue.getOne(io);
    try testing.expectEqualStrings("tracker.example", hidden_client.domain());
    try testing.expectEqualStrings(hidden_marker, hidden_client.clientIp());

    var neither: Logger = .init(.{}, &buf);
    neither.log(io, sampleEntry(3, "tracker.example"));
    const untouched = try neither.queue.getOne(io);
    try testing.expectEqualStrings("tracker.example", untouched.domain());
    try testing.expectEqualStrings("192.0.2.10", untouched.clientIp());
}

test "the split halves reproduce log byte for byte" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    const configs = [_]model.Logging{
        .{},
        .{ .hide_domains = true },
        .{ .hide_client_ips = true },
        .{ .hide_domains = true, .hide_client_ips = true },
    };

    for (configs) |cfg| {
        var buf: [4]Entry = undefined;
        var logger: Logger = .init(cfg, &buf);
        const source = sampleEntry(100, "tracker.example");

        logger.log(io, source);
        logger.logTransformed(io, logger.transformed(source));

        const from_log = try logger.queue.getOne(io);
        const from_halves = try logger.queue.getOne(io);
        try testing.expectEqualSlices(
            u8,
            std.mem.asBytes(&from_log),
            std.mem.asBytes(&from_halves),
        );
    }
}

test "a full queue drops the oldest entry and counts it" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var buf: [2]Entry = undefined;
    var logger: Logger = .init(.{}, &buf);

    logger.log(io, sampleEntry(1, "first.example"));
    logger.log(io, sampleEntry(2, "second.example"));
    logger.log(io, sampleEntry(3, "third.example"));

    try testing.expectEqual(@as(u64, 1), logger.queries_dropped.load(.monotonic));

    const older = try logger.queue.getOne(io);
    const newer = try logger.queue.getOne(io);
    try testing.expectEqualStrings("second.example", older.domain());
    try testing.expectEqualStrings("third.example", newer.domain());
}

test "a zero-capacity queue drops every entry exactly once" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var buf: [0]Entry = undefined;
    var logger: Logger = .init(.{}, &buf);

    for (0..5) |i| logger.log(io, sampleEntry(@intCast(i), "example.com"));
    try testing.expectEqual(@as(u64, 5), logger.queries_dropped.load(.monotonic));
}

test "log after shutdown drops instead of blocking" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var buf: [4]Entry = undefined;
    var logger: Logger = .init(.{}, &buf);
    logger.shutdown(io);

    logger.log(io, sampleEntry(1, "example.com"));
    try testing.expectEqual(@as(u64, 1), logger.queries_dropped.load(.monotonic));
}

test "the writer drains every entry and shutdown ends it" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

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

    var future = try io.concurrent(Logger.runWriter, .{
        &logger,
        io,
        &database,
        @as(?*disk_monitor.Monitor, null),
    });

    var names: [250][32]u8 = undefined;
    for (&names, 0..) |*name, i| {
        const written = try std.fmt.bufPrint(name, "d{d}.example", .{i % 10});
        logger.log(io, sampleEntry(@intCast(i), written));
    }
    logger.shutdown(io);
    try future.await(io);

    try testing.expectEqual(@as(u64, 0), logger.queries_dropped.load(.monotonic));
    try testing.expectEqual(@as(u64, 250), logger.rows_written.load(.monotonic));
    try testing.expectEqual(@as(i64, 250), try queries_repo.countRows(&database));
    try testing.expectEqual(@as(i64, 10), try queries_repo.countDomains(&database));
}

test "the writer flushes an entry once the interval passes" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

    var buf: [8]Entry = undefined;
    var logger: Logger = .init(.{}, &buf);

    var future = try io.concurrent(Logger.runWriter, .{
        &logger,
        io,
        &database,
        @as(?*disk_monitor.Monitor, null),
    });

    logger.log(io, sampleEntry(1, "only.example"));

    const poll: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };
    var waited: usize = 0;
    while (logger.rows_written.load(.monotonic) == 0) : (waited += 1) {
        // Ten times the interval; a flush that has not happened by then is a
        // failure, not slowness.
        try testing.expect(waited < 200);
        try poll.sleep(io);
    }

    logger.shutdown(io);
    try future.await(io);
    try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
}

test "a gated flush holds the batch until the disk recovers" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

    var buf: [4]Entry = undefined;
    var logger: Logger = .init(.{}, &buf);

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

    const entries = [_]Entry{ sampleEntry(1, "held.example"), sampleEntry(2, "held.example") };
    var future = try io.concurrent(Logger.flush, .{
        &logger,
        io,
        &writer,
        @as([]const Entry, &entries),
        @as(?*disk_monitor.Monitor, &monitor),
    });

    const poll: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };
    var waited: usize = 0;
    while (logger.batches_gated.load(.monotonic) == 0) : (waited += 1) {
        try testing.expect(waited < 200);
        try poll.sleep(io);
    }
    try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));

    monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
    try future.await(io);

    try testing.expect(logger.batches_gated.load(.monotonic) >= 1);
    try testing.expectEqual(@as(u64, 2), logger.rows_written.load(.monotonic));
    try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database));
}

test "a failing batch is dropped whole and the writer stays usable" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    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 queries_repo.BatchWriter.init(&database);
    defer writer.deinit();

    var buf: [4]Entry = undefined;
    var logger: Logger = .init(.{}, &buf);

    var doomed = sampleEntry(10, "poison.example");
    doomed.setClientIp("boom");
    const bad = [_]Entry{ sampleEntry(9, "good.example"), doomed };
    try logger.flush(io, &writer, &bad, null);

    try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
    try testing.expectEqual(@as(u64, 0), logger.rows_written.load(.monotonic));
    try testing.expectEqual(@as(u64, 2), logger.queries_dropped.load(.monotonic));

    const good = [_]Entry{sampleEntry(11, "next.example")};
    try logger.flush(io, &writer, &good, null);
    try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
    try testing.expectEqual(@as(u64, 1), logger.rows_written.load(.monotonic));
}

test "a writer that cannot prepare closes the queue and counts every entry" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    // No schema: `BatchWriter.init` cannot prepare against a missing table.
    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();

    var buf: [8]Entry = undefined;
    var logger: Logger = .init(.{}, &buf);

    for (0..3) |i| logger.log(io, sampleEntry(@intCast(i), "early.example"));

    try logger.runWriter(io, &database, null);

    try testing.expect(logger.writer_failed.load(.acquire));
    try testing.expectEqual(@as(u64, 3), logger.queries_dropped.load(.monotonic));
    try testing.expectEqual(@as(u64, 0), logger.rows_written.load(.monotonic));

    // The queue is closed, so later entries drop and count instead of piling up.
    logger.log(io, sampleEntry(99, "late.example"));
    try testing.expectEqual(@as(u64, 4), logger.queries_dropped.load(.monotonic));
}

test "a canceled writer counts the batch it was holding" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

    var buf: [8]Entry = undefined;
    var logger: Logger = .init(.{}, &buf);

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

    // Both entries are queued before the writer starts, so the batch it takes
    // into the gate holds exactly two.
    logger.log(io, sampleEntry(1, "held.example"));
    logger.log(io, sampleEntry(2, "held.example"));

    var future = try io.concurrent(Logger.runWriter, .{
        &logger,
        io,
        &database,
        @as(?*disk_monitor.Monitor, &monitor),
    });

    const poll: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };
    var waited: usize = 0;
    while (logger.batches_gated.load(.monotonic) == 0) : (waited += 1) {
        try testing.expect(waited < 400);
        try poll.sleep(io);
    }

    try testing.expectError(error.Canceled, future.cancel(io));

    try testing.expectEqual(@as(u64, 2), logger.queries_dropped.load(.monotonic));
    try testing.expectEqual(@as(u64, 0), logger.rows_written.load(.monotonic));
    try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
}

test "an empty batch touches neither the database nor the counters" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

    var buf: [4]Entry = undefined;
    var logger: Logger = .init(.{}, &buf);

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

    // Gated or not, an empty batch returns before it reads the monitor.
    try logger.flush(io, &writer, &.{}, &monitor);

    try testing.expectEqual(@as(u64, 0), logger.batches_gated.load(.monotonic));
    try testing.expectEqual(@as(u64, 0), logger.rows_written.load(.monotonic));
    try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
}
