//! 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.
//!
//! One transaction per `logging.query_log_flush_interval_s`, not one per query.
//! At household rates a per-query commit costs orders of magnitude more disk
//! writes than the rows are worth — a minute of batching is what keeps an SD
//! card alive. The window is also what a crash costs: while the writer is
//! healthy and the disk gate is open, a process that dies loses about one
//! interval of query history. It is not a ceiling — a batch the gate is
//! holding, or one waiting on a write lock, is older than that — and nothing
//! here promises one, because query history is the cheapest data on the box.
//!
//! 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 until it recovers — or, if the process is
//! already stopping, drops what it holds. 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 events = @import("events.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);

/// Rows per transaction. Comptime: the window, not the batch size, is what an
/// operator has a reason to move, and a batch this size already amortizes the
/// commit at any household rate.
pub const flush_batch = 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);

/// A database failure is not in here: a batch the database refuses is dropped,
/// counted and reported where it happens, and the writer carries on.
/// `GatedAtShutdown` is the one condition `flush` cannot settle by itself —
/// the disk gate is shut and the process is stopping, so the batch in hand is
/// lost and so is everything still queued behind it. Only `runWriter` can see
/// both, so it does the counting and files the single episode.
pub const FlushError = std.Io.Cancelable || error{GatedAtShutdown};

/// 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),
    /// Set by `shutdown`, read by the disk gate. The gate holds a batch for as
    /// long as the disk stays critical, which is right while the process is
    /// running and a hang once it is stopping — nothing will ever release it.
    draining: std.atomic.Value(bool),
    /// Wired by the composition root after `init`, following the
    /// `gate: ?*disk_monitor.Monitor` idiom. Null in every unit test here.
    diagnostics: ?*events.Store = null,

    /// `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),
            .draining = .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.
    ///
    /// It must outlive the producers rather than share their lifetime: a
    /// cancellation that races the close decides at random whether the batch in
    /// hand is written or counted as dropped. `app.zig` spawns this outside the
    /// group it cancels for exactly that reason.
    ///
    /// `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);
            // No recovery path claims this episode: the writer is gone for the
            // life of the process, so the row stays active, which is the truth.
            self.reportWrite(io, "writer", "preparing the batch statements failed", @errorName(err), 0);
            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 = self.flushDeadline(io);
            // `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| switch (err) {
                error.Canceled => |e| {
                    self.countDropped(n);
                    return e;
                },
                // Nothing will open the gate now. Everything still queued is
                // lost with the batch in hand, so it is counted here and
                // announced once — a per-chunk report would write to the very
                // disk that is out of space, dozens of times, on the way out.
                error.GatedAtShutdown => {
                    self.countDropped(n);
                    const lost = n + self.dropRemaining(io);
                    scope.warn(
                        "query log: {d} rows dropped at shutdown, the disk gate was closed",
                        .{lost},
                    );
                    self.reportWrite(
                        io,
                        "batch",
                        "query log rows were dropped at shutdown",
                        "DiskCritical",
                        lost,
                    );
                    return;
                },
            };
        }
    }

    /// When the batch that starts now must be committed. `.boot` and not
    /// `.awake`: a suspended box would otherwise stretch the window by however
    /// long it slept, and the rows are already in memory waiting.
    ///
    /// An interval of 0 yields a deadline that has already passed, which is
    /// exactly the documented sentinel — `fill` then takes what is queued and
    /// returns without waiting for anything.
    fn flushDeadline(self: *const Logger, io: std.Io) std.Io.Clock.Timestamp {
        return .fromNow(io, .{
            .raw = .fromSeconds(self.cfg.query_log_flush_interval_s),
            .clock = .boot,
        });
    }

    /// Counts every entry left in a closed queue as dropped, and returns how
    /// many. 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) usize {
        var total: usize = 0;
        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);
            total += n;
        }
        return total;
    }

    /// Closes the queue and tells the writer to stop waiting on anything that
    /// may never arrive. `log` drops from here on, and `runWriter` returns once
    /// it has flushed what was left — a blocked `getOne` on a closed queue
    /// returns immediately, so the interval is never waited out at shutdown.
    ///
    /// The close comes first: a writer that sees `draining` set must be able to
    /// drain the queue to the end, and only a closed queue reports its end.
    ///
    /// Every producer must be stopped and joined before this is called
    /// (`app.zig`): an entry enqueued after the close is a dropped entry.
    pub fn shutdown(self: *Logger, io: std.Io) void {
        self.queue.close(io);
        self.draining.store(true, .release);
    }

    /// 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,
    ) FlushError!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()) {
                // Waiting for the disk to recover is right while the process
                // runs and wrong once it is stopping: nothing is going to free
                // space during shutdown, so the batch is lost either way and
                // the only choice left is between losing it counted and
                // hanging the exit. The counting and the one report belong to
                // `runWriter`, which knows the rest of the queue is lost too.
                if (self.draining.load(.acquire)) return error.GatedAtShutdown;
                _ = 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);
            self.reportWrite(io, "batch", "a query log batch was dropped", @errorName(err), entries.len);
            return;
        };
        _ = self.rows_written.fetchAdd(entries.len, .monotonic);
        if (self.diagnostics) |store| {
            store.resolve(io, std.Io.Clock.real.now(io).toSeconds(), .query_log_write, "batch");
        }
    }

    /// An error, not a warning: dropped query rows are gone, and a writer that
    /// never started means every later row is gone too.
    fn reportWrite(
        self: *Logger,
        io: std.Io,
        operation: []const u8,
        message: []const u8,
        error_name: []const u8,
        rows: usize,
    ) void {
        const store = self.diagnostics orelse return;
        var buf: [events.Store.max_detail_len]u8 = undefined;
        const detail = std.fmt.bufPrint(&buf, "{s}: {s} ({d} rows)", .{
            message,
            error_name,
            rows,
        }) catch buf[0..];
        store.report(
            io,
            std.Io.Clock.real.now(io).toSeconds(),
            .query_log_write,
            operation,
            operation,
            .@"error",
            detail,
        );
    }

    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 events_fixture = @import("events_fixture.zig");
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 "shutdown writes the batch the writer holds and the rest of the queue" {
    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;
    // An hour: nothing here can be explained by the window expiring. Every row
    // that lands does so because the close released it.
    var logger: Logger = .init(.{ .query_log_flush_interval_s = 3600 }, &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));
    }

    // The app.zig order: the only producer is this task and it is done, so the
    // close cannot lose an entry, and the writer is awaited rather than
    // canceled.
    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 "entries that arrive inside one window reach the database in one batch" {
    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: [16]Entry = undefined;
    var logger: Logger = .init(.{ .query_log_flush_interval_s = 3600 }, &buf);

    for (0..6) |i| logger.log(io, sampleEntry(@intCast(i), "batched.example"));
    // No further producer exists, so the close is what ends the fill — the same
    // thing that ends it at shutdown.
    logger.shutdown(io);

    var batch: [flush_batch]Entry = undefined;
    batch[0] = try logger.queue.getOne(io);
    var n: usize = 1;
    try logger.fill(io, &batch, logger.flushDeadline(io), &n);
    try testing.expectEqual(@as(usize, 6), n);

    // One `writeBatch` call, which is one transaction (`queries_repo.zig`).
    try logger.flush(io, &writer, batch[0..n], null);
    try testing.expectEqual(@as(u64, 6), logger.rows_written.load(.monotonic));
    try testing.expectEqual(@as(i64, 6), try queries_repo.countRows(&database));
}

test "a zero interval takes what is queued and waits for nothing" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

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

    var batch: [flush_batch]Entry = undefined;
    batch[0] = try logger.queue.getOne(io);
    var n: usize = 1;
    // The queue is open and the batch has room: any non-zero interval blocks
    // here until it expires. Zero returns with what was already queued.
    try logger.fill(io, &batch, logger.flushDeadline(io), &n);
    try testing.expectEqual(@as(usize, 2), n);
}

test "the writer holds an entry for the length of the flush interval" {
    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(.{ .query_log_flush_interval_s = 1 }, &buf);

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

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

    // Sampled, not asserted, while the writer runs: an assertion that fails
    // here would return before the writer is stopped, and `Threaded.deinit`
    // then waits on a task nothing will ever end.
    const quarter: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(250), .clock = .awake };
    try quarter.sleep(io);
    const written_at_a_quarter = logger.rows_written.load(.monotonic);

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

    logger.shutdown(io);
    try future.await(io);

    // A writer that commits per query has already written at a quarter of the
    // window; this one has not.
    try testing.expectEqual(@as(u64, 0), written_at_a_quarter);
    try testing.expect(waited < 600);
    try testing.expectEqual(@as(u64, 1), logger.rows_written.load(.monotonic));
    try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
}

test "a full batch flushes without waiting for the interval" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

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

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

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

    // The window is an hour away, so only a full batch can release a flush.
    const poll: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };
    var waited: usize = 0;
    while (logger.rows_written.load(.monotonic) < flush_batch and waited < 400) : (waited += 1) {
        try poll.sleep(io);
    }
    // Sampled with the writer still running, asserted once it has stopped.
    const written_before_shutdown = logger.rows_written.load(.monotonic);

    logger.shutdown(io);
    try future.await(io);

    // Exactly one batch went out early, and the 50 behind it waited for the
    // close rather than for the hour.
    try testing.expect(waited < 400);
    try testing.expectEqual(@as(u64, flush_batch), written_before_shutdown);
    try testing.expectEqual(@as(i64, 150), 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;
    // Zero interval: the writer reaches the gate with what is queued instead of
    // waiting a minute for a producer that no longer exists.
    var logger: Logger = .init(.{ .query_log_flush_interval_s = 0 }, &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 "a disk-gated writer drops what it holds at shutdown instead of hanging" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

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

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

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

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

    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 and waited < 400) : (waited += 1) {
        try poll.sleep(io);
    }

    // Two and a half chunks wait behind the batch the gate is holding, so a
    // drain that reported per chunk would file three episodes' worth of writes
    // to the disk that is out of space.
    const queued = 250;
    for (0..queued) |i| logger.log(io, sampleEntry(@intCast(i + 3), "queued.example"));

    // The disk never recovers. The wait still ends, and every entry is counted.
    logger.shutdown(io);
    try future.await(io);

    try testing.expect(waited < 400);
    try testing.expectEqual(@as(u64, queued + 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));

    // One episode, reported once, and its detail carries the whole loss rather
    // than the size of whichever chunk was last.
    try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
    try testing.expectEqual(@as(i64, 1), try fx.count("SELECT occurrences FROM operational_events"));
    try testing.expectEqualStrings("batch", try fx.text("SELECT subject_key FROM operational_events"));
    try testing.expectEqualStrings(
        "query log rows were dropped at shutdown: DiskCritical (252 rows)",
        try fx.text("SELECT detail FROM operational_events"),
    );
}

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

test "a dropped batch opens an error episode the next good batch closes" {
    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 fx: events_fixture.Fixture = .{};
    try fx.init(io, 1000);
    defer fx.deinit();

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

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

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

    try testing.expectEqualStrings("query_log.write", try fx.text("SELECT code FROM operational_events"));
    try testing.expectEqualStrings("batch", try fx.text("SELECT subject_key FROM operational_events"));
    try testing.expectEqualStrings("error", try fx.text("SELECT severity FROM operational_events"));

    const good = [_]Entry{sampleEntry(11, "next.example")};
    try logger.flush(io, &writer, &good, null);

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

test "a writer that cannot prepare leaves an episode no recovery path claims" {
    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 fx: events_fixture.Fixture = .{};
    try fx.init(io, 1000);
    defer fx.deinit();

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

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

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

    // The writer returned, so nothing can ever close this. A second run finds
    // the queue closed and adds no second episode.
    try logger.runWriter(io, &database, null);
    try testing.expectEqual(
        @as(i64, 1),
        try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
    );
}
