//! The stable owner of the query logger (milestone 34 §S4).
//!
//! `logging.query_log_buffer_max` is the queue's capacity, and the queue is
//! allocated once at boot. Changing it means a new buffer, a new `Logger`, a
//! new writer task and a new database connection — a whole new generation. The
//! controller is what everything else holds instead of the `Logger`, so a
//! resize swaps the generation underneath `QuerySink`, `/metrics` and
//! `/api/health` without any of them learning that generations exist.
//!
//! Three invariants make that safe, and each has a mechanism:
//!
//! 1. **No producer ever enqueues into a closed queue.** A producer borrows the
//!    live generation through `acquire`/`release`; retirement waits for every
//!    borrow of the old generation to be released before it closes that queue.
//!    There is no resize drop window, and no resize-specific drop counter — if
//!    one were needed, this file would be wrong.
//! 2. **Publish cannot fail.** Everything fallible — the buffer, the `Logger`,
//!    the connection, the writer task and its prepared statements — happens in
//!    `prepare`, before the settings transaction commits. The new writer is
//!    spawned already parked on an activation gate, so `publish` is a pointer
//!    swap and one `Event.set`.
//! 3. **One task joins each writer.** `Future.await` is not thread-safe
//!    (`Io.zig:1198`), so the reaper — spawned once at construction — is the
//!    sole joiner of retired writers, and the shutdown path is the sole joiner
//!    of the live one. At most one retired generation exists at a time, which
//!    is what bounds writers, connections and buffers; a second resize while
//!    one is still draining is refused rather than queued.
//!
//! What must stay continuous across a swap lives here rather than in a
//! generation: the counters, the disk-gate episode and the drop stamp are
//! summed and merged across the outstanding generations, and a retired
//! generation's finals fold into `base` the moment its writer is joined, so no
//! reader ever sees a counter go backwards. `writer_failed` is the exception
//! and reports the LIVE writer only — a successful resize means a writer that
//! prepared cleanly is now serving, which is the truth the health endpoint
//! needs; the rows a failed retired writer lost are already in the drop count.

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

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

const scope = std.log.scoped(.query_logger);

/// Where a generation's writer connection comes from. The controller opens one
/// per generation and closes it after that generation's writer is joined: two
/// writers must never share a `db.Db`.
pub const Source = struct {
    dir: std.Io.Dir,
    path: [:0]const u8,
};

/// The cumulative totals of every generation whose writer has been joined.
/// Live generations are added to these on every read.
const Totals = struct {
    queries_dropped: u64 = 0,
    rows_written: u64 = 0,
    batches_gated: u64 = 0,
    last_drop_s: i64 = 0,

    fn fold(self: *Totals, lg: *const logger.Logger) void {
        self.queries_dropped += lg.queries_dropped.load(.monotonic);
        self.rows_written += lg.rows_written.load(.monotonic);
        self.batches_gated += lg.batches_gated.load(.monotonic);
        self.last_drop_s = @max(self.last_drop_s, lg.last_drop_s.load(.monotonic));
    }
};

/// What a facade reader sees: one coherent reading of every outstanding
/// generation, taken under the controller's mutex.
pub const Sample = struct {
    queries_dropped: u64,
    rows_written: u64,
    batches_gated: u64,
    /// Null while nothing has been dropped, exactly as `Logger.lastDropSeconds`.
    last_drop_s: ?i64,
    /// The LIVE writer's state. A retired writer's failures are in the counters.
    writer_failed: bool,
    /// The worst episode any outstanding generation is in. A retired writer
    /// still holding a batch against a full disk is a current fault whether or
    /// not the live one has noticed the disk yet.
    gate_episode: logger.GateEpisode,
};

/// One buffer, one `Logger`, one writer task and one connection, with the
/// producer refcount that decides when all four can go away.
///
/// Heap-allocated and never moved: the queue holds waiting tasks in intrusive
/// lists, and `logger` points into `owned`.
pub const Generation = struct {
    /// The producers' view. Points at `owned.state` for a generation this
    /// controller built, and at the caller's `Logger` for a borrowed one.
    logger: *logger.Logger,
    /// How many producers are inside `transformed`+`logTransformed` right now.
    /// Guarded by the controller's mutex.
    borrows: usize = 0,
    /// Set by `publish` under the controller's mutex.
    retired: bool = false,
    /// Null for a borrowed generation, which owns nothing and is never retired.
    owned: ?Owned = null,

    const Owned = struct {
        /// Pointed at by `Generation.logger`. Never moved.
        state: logger.Logger,
        queue_buf: []logger.Entry,
        database: db.Db,
        writer: std.Io.Future(std.Io.Cancelable!void),
        /// Set by the writer once its statements are prepared, or once
        /// preparing them has failed. `prepare` waits on it.
        ready: std.Io.Event = .unset,
        /// Opened by `publish`, or by `abandon` to release a writer that will
        /// never serve. The writer consumes nothing until one of the two.
        activation: std.Io.Event = .unset,
        /// What `BatchWriter.init` refused with, read after `ready`.
        prepare_error: ?db.Error = null,
        /// Set before `activation` when the candidate is thrown away.
        aborted: bool = false,
        capacity: u32,
    };

    fn deinit(self: *Generation, gpa: Allocator) void {
        if (self.owned) |*owned| {
            owned.database.close();
            gpa.free(owned.queue_buf);
        }
        gpa.destroy(self);
    }
};

pub const InitError = Allocator.Error || std.Io.ConcurrentError;

pub const Options = struct {
    gpa: Allocator,
    /// The connection `cli.DataDir.openQuerylogDb` established, moved in. The
    /// controller closes it when the boot generation retires or the process
    /// stops.
    database: db.Db,
    /// Where later generations open their own connections. The controller is
    /// resizable only when this is set.
    source: ?Source,
    logging: model.Logging,
    monitor: ?*disk_monitor.Monitor = null,
    diagnostics: ?*events.Store = null,
};

/// What `prepare` refuses with. Every variant leaves the running logger and the
/// database untouched.
pub const PrepareError = Allocator.Error || db.Error || std.Io.ConcurrentError || error{
    /// `config/validate.zig`'s floor.
    BufferTooSmall,
    /// `config/validate.zig`'s ceiling, `logger.query_log_buffer_max`.
    BufferTooLarge,
    /// A previous resize's generation is still draining. At most one retired
    /// generation may exist, so this bounds writers, connections and buffers
    /// rather than queueing an unbounded chain of them.
    PreviousResizeDraining,
    /// The candidate writer could not prepare its statements.
    WriterStatementsFailed,
    /// A borrowed controller owns no generation to replace.
    NotResizable,
};

/// `config/validate.zig`'s own wording for a refused buffer size, so a settings
/// change and a startup rejection say the same thing about the same value.
/// Returns null for the failures that are not about the size.
pub fn sizeMessage(err: PrepareError, requested: u32, buf: []u8) ?[]const u8 {
    return switch (err) {
        error.BufferTooSmall => "must be at least 1",
        error.BufferTooLarge => std.fmt.bufPrint(
            buf,
            "must be at most {d}, got {d}",
            .{ logger.query_log_buffer_max, requested },
        ) catch buf[0..],
        else => null,
    };
}

/// A generation built, connected and parked, waiting for a commit that has not
/// happened yet. Exactly one of `publish` or `abandon` must consume it.
pub const Prepared = struct {
    generation: *Generation,
};

pub const Controller = struct {
    gpa: Allocator,
    source: ?Source,
    monitor: ?*disk_monitor.Monitor,
    diagnostics: ?*events.Store,

    /// Guards `live`, `retired`, `phase`, `stopping`, `base` and every
    /// generation's `borrows`. Held briefly and never across an await — the
    /// CertStore discipline (`cert_store.zig:199`).
    mutex: std.Io.Mutex = .init,
    /// Signalled whenever the reaper's work might have changed: a retirement
    /// was published, a borrow was released, or shutdown began.
    progress: std.Io.Condition = .init,

    live: *Generation,
    /// The one generation a resize may leave behind.
    retired: ?*Generation = null,
    phase: Phase = .idle,
    stopping: bool = false,
    stopped: bool = false,
    base: Totals = .{},

    /// Null for a borrowed controller. The sole joiner of retired writers.
    reaper: ?std.Io.Future(void) = null,

    const Phase = enum {
        idle,
        /// A retired generation is waiting for its last producer borrow.
        waiting_borrows,
        /// The reaper owns the retired generation and is joining its writer.
        draining,
    };

    /// Builds the boot generation around `opts.database` and starts its writer
    /// and the reaper.
    ///
    /// The boot writer takes the plain `Logger.runWriter` path rather than the
    /// parked one: a process that cannot prepare its statements at boot still
    /// serves DNS with `writer_failed` set, which is today's behaviour and the
    /// right one — refusing to start would cost more than the log rows do.
    /// Only a resize can afford to refuse.
    pub fn init(self: *Controller, io: std.Io, opts: Options) InitError!void {
        var database = opts.database;
        const generation = build(opts.gpa, database, opts.logging, opts.diagnostics) catch |err| {
            database.close();
            return err;
        };
        errdefer generation.deinit(opts.gpa);
        const owned = &generation.owned.?;

        self.* = .{
            .gpa = opts.gpa,
            .source = opts.source,
            .monitor = opts.monitor,
            .diagnostics = opts.diagnostics,
            .live = generation,
        };

        owned.writer = try io.concurrent(logger.Logger.runWriter, .{
            generation.logger,
            io,
            opts.gpa,
            &owned.database,
            opts.monitor,
        });
        errdefer {
            generation.logger.shutdown(io);
            owned.writer.await(io) catch {};
        }

        self.reaper = try io.concurrent(runReaper, .{ self, io });
    }

    /// Stops every generation in the order the writer deadlock demands, and
    /// joins every task this controller started.
    ///
    /// Producers must already be quiesced (`app.zig` cancels their group
    /// first): an entry enqueued after the close is a dropped entry, and a
    /// borrow still held here would stall the reaper.
    ///
    /// The order is today's safe order, once per generation. The live queue
    /// closes before its `draining` flag is set, because a writer that sees
    /// `draining` must still be able to reach the end of its queue and only a
    /// closed queue reports its end. The retired generation gets `draining`
    /// too — without it a retired writer parked on a shut disk gate waits for a
    /// recovery that is never coming, and the reaper would never join it.
    /// Idempotent: the teardown path and `deinit` both call it.
    pub fn shutdown(self: *Controller, io: std.Io) void {
        if (self.stopped) return;
        self.stopped = true;

        self.mutex.lockUncancelable(io);
        const live = self.live;
        if (live.owned == null) {
            self.mutex.unlock(io);
            return;
        }
        live.logger.shutdown(io);
        if (self.retired) |old| old.logger.draining.store(true, .release);
        self.stopping = true;
        self.progress.broadcast(io);
        self.mutex.unlock(io);

        live.owned.?.writer.await(io) catch {};
        if (self.reaper) |*reaper| {
            reaper.await(io);
            self.reaper = null;
        }
    }

    /// Frees the live generation and its connection. Stops everything first if
    /// the caller has not.
    pub fn deinit(self: *Controller, io: std.Io) void {
        self.shutdown(io);
        std.debug.assert(self.retired == null);
        if (self.live.owned != null) self.live.deinit(self.gpa);
    }

    // -----------------------------------------------------------------------
    // the producer side
    // -----------------------------------------------------------------------

    /// Pins the live generation for the length of one entry's transform and
    /// enqueue. Every `acquire` must be paired with a `release`.
    pub fn acquire(self: *Controller, io: std.Io) *Generation {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);
        self.live.borrows += 1;
        return self.live;
    }

    /// Releases a borrow. The last borrow of a retired generation is what lets
    /// the reaper close that queue.
    pub fn release(self: *Controller, io: std.Io, generation: *Generation) void {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);
        std.debug.assert(generation.borrows > 0);
        generation.borrows -= 1;
        if (generation.retired and generation.borrows == 0) self.progress.broadcast(io);
    }

    // -----------------------------------------------------------------------
    // the live-configuration setters (milestone 34 §S1, addressed here so they
    // survive a resize)
    // -----------------------------------------------------------------------

    /// Installs the privacy policy on every outstanding generation.
    ///
    /// Each generation keeps its own packed word and a producer still decides
    /// one entry from one load of the generation it borrowed, so a mixed policy
    /// remains impossible: the two stores below are to two different queues'
    /// producers, and no producer reads both.
    pub fn setPrivacy(self: *Controller, io: std.Io, p: logger.Privacy) void {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);
        self.live.logger.setPrivacy(p);
        if (self.retired) |old| old.logger.setPrivacy(p);
    }

    pub fn setFlushInterval(self: *Controller, io: std.Io, seconds: u16) void {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);
        self.live.logger.setFlushInterval(seconds);
        if (self.retired) |old| old.logger.setFlushInterval(seconds);
    }

    // -----------------------------------------------------------------------
    // the facade's read side
    // -----------------------------------------------------------------------

    /// Every counter and state the metrics and health surfaces read, in one
    /// coherent reading across the outstanding generations.
    pub fn sample(self: *Controller, io: std.Io) Sample {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);

        var totals = self.base;
        totals.fold(self.live.logger);
        var episode = self.live.logger.gateEpisode();
        if (self.retired) |old| {
            totals.fold(old.logger);
            episode = @enumFromInt(@max(@intFromEnum(episode), @intFromEnum(old.logger.gateEpisode())));
        }

        return .{
            .queries_dropped = totals.queries_dropped,
            .rows_written = totals.rows_written,
            .batches_gated = totals.batches_gated,
            .last_drop_s = if (totals.last_drop_s == 0) null else totals.last_drop_s,
            .writer_failed = self.live.logger.writer_failed.load(.acquire),
            .gate_episode = episode,
        };
    }

    /// Whether a previous resize's generation is still outstanding. This is
    /// what `prepare` refuses on, and what a caller polls to know when the one
    /// retirement slot is free again.
    pub fn retirementPending(self: *Controller, io: std.Io) bool {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);
        return self.retired != null;
    }

    /// The live queue's capacity, which is what a resize changes.
    pub fn capacity(self: *Controller, io: std.Io) u32 {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);
        const owned = self.live.owned orelse return @intCast(self.live.logger.queue.capacity());
        return owned.capacity;
    }

    // -----------------------------------------------------------------------
    // resize: prepare → (the caller commits) → publish, or abandon
    // -----------------------------------------------------------------------

    /// Builds a complete replacement generation and parks its writer. Fallible
    /// from end to end and side-effect free: on any failure nothing has been
    /// published, no connection is left open and the running logger has not
    /// been touched.
    ///
    /// The caller commits its database row only after this returns, and then
    /// calls exactly one of `publish` or `abandon`.
    ///
    /// `cfg` is the FINAL MERGED logging configuration, not the live one: the
    /// same write that resizes the queue may also change the privacy flags or
    /// the flush interval, and those are published against the generation this
    /// candidate is about to replace. Seeding from the live values would leave
    /// the replacement carrying the policy the write just retired.
    pub fn prepare(self: *Controller, io: std.Io, cfg: model.Logging) PrepareError!Prepared {
        const entries = cfg.query_log_buffer_max;
        if (entries < 1) return error.BufferTooSmall;
        if (entries > logger.query_log_buffer_max) return error.BufferTooLarge;

        const source = self.source orelse return error.NotResizable;

        {
            self.mutex.lockUncancelable(io);
            defer self.mutex.unlock(io);
            if (self.retired != null) return error.PreviousResizeDraining;
        }
        var database = try querylog_schema.reopen(io, source.dir, source.path);
        const generation = build(self.gpa, database, cfg, self.diagnostics) catch |err| {
            database.close();
            return err;
        };
        errdefer generation.deinit(self.gpa);
        const owned = &generation.owned.?;

        owned.writer = try io.concurrent(runParkedWriter, .{ generation, io, self.gpa, self.monitor });

        // The statements are prepared before anything is published, so a
        // failure here is a refused settings change rather than a writer that
        // dies under a queue producers are already filling.
        owned.ready.waitUncancelable(io);
        if (owned.prepare_error) |err| {
            owned.writer.await(io) catch {};
            scope.warn(
                "query logger resize: preparing the batch statements failed: {s}",
                .{@errorName(err)},
            );
            // `database` is closed by the errdefers above, which still cover
            // everything this function allocated.
            return error.WriterStatementsFailed;
        }

        return .{ .generation = generation };
    }

    /// Throws a prepared generation away: the transaction it was built for did
    /// not commit. Releases the parked writer, joins it and closes everything.
    pub fn abandon(self: *Controller, io: std.Io, prepared: Prepared) void {
        const owned = &prepared.generation.owned.?;
        owned.aborted = true;
        owned.activation.set(io);
        owned.writer.await(io) catch {};
        prepared.generation.deinit(self.gpa);
    }

    /// Infallible and I/O-free: swap the live pointer, mark the old generation
    /// retired, open the new writer's activation gate. Nothing is closed,
    /// joined or freed here — that is the reaper's work, and it happens after
    /// the last producer of the old generation releases it.
    pub fn publish(self: *Controller, io: std.Io, prepared: Prepared) void {
        const fresh = prepared.generation;

        self.mutex.lockUncancelable(io);
        const old = self.live;
        self.live = fresh;
        old.retired = true;
        self.retired = old;
        self.phase = .waiting_borrows;
        self.progress.broadcast(io);
        self.mutex.unlock(io);

        fresh.owned.?.activation.set(io);
    }

    // -----------------------------------------------------------------------
    // the reaper
    // -----------------------------------------------------------------------

    /// The sole joiner of retired writers, for the controller's whole life.
    ///
    /// `publish` never spawns: a spawn can fail, and publish may not. This task
    /// exists from construction and waits for work instead.
    fn runReaper(self: *Controller, io: std.Io) void {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);

        while (true) {
            if (self.phase == .waiting_borrows and self.retired.?.borrows == 0) {
                const old = self.retired.?;
                self.phase = .draining;
                self.mutex.unlock(io);
                drain(old, io);
                self.mutex.lockUncancelable(io);

                // Folded before the generation is freed, so the totals never
                // dip: a reader between the fold and the free sees the same
                // numbers either way.
                self.base.fold(old.logger);
                self.retired = null;
                self.phase = .idle;
                old.deinit(self.gpa);
                self.progress.broadcast(io);
                continue;
            }
            if (self.stopping and self.retired == null) return;
            self.progress.waitUncancelable(io, &self.mutex);
        }
    }
};

/// One heap-stable generation with its writer future still unset. Takes
/// ownership of `database`.
fn build(
    gpa: Allocator,
    database: db.Db,
    cfg: model.Logging,
    diagnostics: ?*events.Store,
) Allocator.Error!*Generation {
    const generation = try gpa.create(Generation);
    errdefer gpa.destroy(generation);

    const queue_buf = try gpa.alloc(logger.Entry, cfg.query_log_buffer_max);
    errdefer gpa.free(queue_buf);

    generation.* = .{
        .logger = undefined,
        .owned = .{
            .state = .init(cfg, queue_buf),
            .queue_buf = queue_buf,
            .database = database,
            .writer = undefined,
            .capacity = cfg.query_log_buffer_max,
        },
    };
    const owned = &generation.owned.?;
    generation.logger = &owned.state;
    owned.state.diagnostics = diagnostics;
    return generation;
}

/// Closes a retired generation's queue, waits for its writer to empty it, and
/// closes its connection. Runs with the controller's mutex released: it awaits.
fn drain(generation: *Generation, io: std.Io) void {
    const owned = &generation.owned.?;
    // `retire`, not `shutdown`: with `draining` set a gate-held writer would
    // take the `GatedAtShutdown` path and DROP the batch it is holding
    // (`logger.zig`). A replaced generation is not a stopping process — the
    // disk can still recover, and those rows are still going to be written.
    generation.logger.retire(io);
    owned.writer.await(io) catch {};
}

/// A resize generation's writer: prepare, report, park, then serve.
fn runParkedWriter(
    generation: *Generation,
    io: std.Io,
    gpa: Allocator,
    monitor: ?*disk_monitor.Monitor,
) std.Io.Cancelable!void {
    const owned = &generation.owned.?;

    var writer = queries_repo.BatchWriter.init(gpa, &owned.database) catch |err| {
        owned.prepare_error = err;
        owned.ready.set(io);
        return;
    };
    defer writer.deinit();
    owned.ready.set(io);

    // Nothing is consumed until `publish` or `abandon` decides. The queue this
    // writer will serve has no producers yet either — it is not live.
    owned.activation.waitUncancelable(io);
    if (owned.aborted) return;

    return generation.logger.runPrepared(io, &writer, monitor);
}

/// A controller over a `Logger` the caller already owns: no connection, no
/// writer, no reaper and no resize. Owns nothing, so there is nothing to tear
/// down; both parts are inline so a caller keeps them on its stack beside the
/// sink — `upstream/owner.zig`'s `Borrowed`.
pub const Borrowed = struct {
    generation: Generation = undefined,
    controller: Controller = undefined,

    pub fn over(self: *Borrowed, lg: *logger.Logger) *Controller {
        self.generation = .{ .logger = lg };
        self.controller = .{
            .gpa = undefined,
            .source = null,
            .monitor = null,
            .diagnostics = null,
            .live = &self.generation,
        };
        return &self.controller;
    }
};

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

const testing = std.testing;

/// `std.testing.tmpDir` creates its directory against `std.testing.io`, so
/// every call into the code under test uses the same `Io` instance — an
/// `Io.Threaded`, which is what makes `io.concurrent` available to the writers.
const test_io = testing.io;

/// Where `std.testing.tmpDir` puts its directories.
const tmp_prefix = ".zig-cache/tmp/";
const sub_path_len = @typeInfo(@FieldType(testing.TmpDir, "sub_path")).array.len;

/// A real `querylog.db` in a temporary directory with a controller over it.
///
/// Must not be moved once `start` has run: the reaper holds `&self.controller`,
/// and the queue keeps waiting tasks in intrusive lists.
const Harness = struct {
    tmp: testing.TmpDir = undefined,
    path_buf: [tmp_prefix.len + sub_path_len + "/querylog.db".len + 1]u8 = undefined,
    path: [:0]const u8 = undefined,
    monitor: ?*disk_monitor.Monitor = null,
    controller: Controller = undefined,
    cfg: model.Logging = .{},

    fn start(self: *Harness, cfg: model.Logging) !void {
        self.cfg = cfg;
        self.tmp = testing.tmpDir(.{});
        errdefer self.tmp.cleanup();

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

        var opened = try querylog_schema.open(test_io, std.Io.Dir.cwd(), self.path);
        errdefer opened.database.close();

        try self.controller.init(test_io, .{
            .gpa = testing.allocator,
            .database = opened.database,
            .source = .{ .dir = std.Io.Dir.cwd(), .path = self.path },
            .logging = cfg,
            .monitor = self.monitor,
        });
    }

    fn stop(self: *Harness) void {
        self.controller.deinit(test_io);
        self.tmp.cleanup();
    }

    /// A resize of the started configuration, which is what a settings write
    /// hands `prepare`: the whole merged section with one field moved.
    fn prepareResize(self: *Harness, entries: u32) PrepareError!Prepared {
        var cfg = self.cfg;
        cfg.query_log_buffer_max = entries;
        return self.controller.prepare(test_io, cfg);
    }

    /// The rows on disk, read through a connection of its own after the
    /// controller has closed every one of its own.
    fn storedRows(self: *Harness) !i64 {
        var database = try querylog_schema.reopen(test_io, std.Io.Dir.cwd(), self.path);
        defer database.close();
        return queries_repo.countRows(&database);
    }

    /// A connection for a test that needs to change the schema under a running
    /// controller.
    fn sideConnection(self: *Harness) !db.Db {
        return querylog_schema.reopen(test_io, std.Io.Dir.cwd(), self.path);
    }
};

fn testEntry(timestamp: i64, domain: []const u8) logger.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",
    });
}

/// One producer, following the borrow protocol `QuerySink` follows.
fn produce(controller: *Controller, count: usize, base: i64) void {
    for (0..count) |i| {
        const generation = controller.acquire(test_io);
        defer controller.release(test_io, generation);
        generation.logger.log(test_io, testEntry(base + @as(i64, @intCast(i)), "producer.example"));
    }
}

const poll: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };

fn awaitRows(controller: *Controller, target: u64, limit: usize) !void {
    var polls: usize = 0;
    while (controller.sample(test_io).rows_written < target) : (polls += 1) {
        try testing.expect(polls < limit);
        try poll.sleep(test_io);
    }
}

fn awaitGated(controller: *Controller, limit: usize) !void {
    var polls: usize = 0;
    while (controller.sample(test_io).batches_gated == 0) : (polls += 1) {
        try testing.expect(polls < limit);
        try poll.sleep(test_io);
    }
}

fn awaitRetired(controller: *Controller, limit: usize) !void {
    var polls: usize = 0;
    while (controller.retirementPending(test_io)) : (polls += 1) {
        try testing.expect(polls < limit);
        try poll.sleep(test_io);
    }
}

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

test "a resize under concurrent producers loses nothing and accounts for every entry" {
    var h: Harness = .{};
    // Room for every entry all four producers can write: an overflow drop here
    // would be ordinary backpressure, and this case is about the swap.
    try h.start(.{ .query_log_buffer_max = 4096, .query_log_flush_interval_s = 0 });
    defer h.stop();

    const producers = 4;
    const each = 200;
    var futures: [producers]std.Io.Future(void) = undefined;
    var started: usize = 0;
    // Declared before the spawn loop so a partial spawn still joins what ran.
    defer for (futures[0..started]) |*future| future.await(test_io);
    while (started < producers) : (started += 1) {
        futures[started] = try test_io.concurrent(produce, .{
            &h.controller,
            each,
            @as(i64, @intCast(started)) * each,
        });
    }

    // The swap happens with all four producers mid-flight. Every entry either
    // reaches the old queue before its close or the new one after the publish;
    // no third outcome exists, which is what "no resize drop window" means.
    const prepared = try h.prepareResize(8192);
    h.controller.publish(test_io, prepared);
    try testing.expectEqual(@as(u32, 8192), h.controller.capacity(test_io));

    for (futures[0..started]) |*future| future.await(test_io);
    started = 0;

    try awaitRetired(&h.controller, 400);
    h.controller.shutdown(test_io);

    const total = producers * each;
    const reading = h.controller.sample(test_io);
    // Every entry produced is accounted for exactly once, and none of them was
    // lost to the swap.
    try testing.expectEqual(@as(u64, total), reading.rows_written + reading.queries_dropped);
    try testing.expectEqual(@as(u64, 0), reading.queries_dropped);
    try testing.expectEqual(@as(i64, total), try h.storedRows());
}

test "a resize with the disk gate closed publishes at once and retires when the gate reopens" {
    var monitor = critical();
    var h: Harness = .{ .monitor = &monitor };
    try h.start(.{ .query_log_buffer_max = 64, .query_log_flush_interval_s = 0 });
    defer h.stop();

    produce(&h.controller, 8, 1);
    try awaitGated(&h.controller, 400);

    // The old writer is holding a batch against a shut gate. Publish must not
    // wait for it: nothing here is allowed to block on the disk.
    const prepared = try h.prepareResize(128);
    h.controller.publish(test_io, prepared);
    try testing.expect(h.controller.retirementPending(test_io));

    produce(&h.controller, 8, 100);

    monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
    try awaitRetired(&h.controller, 1200);
    h.controller.shutdown(test_io);

    // The retirement mode does not set `draining`, so the held batch waited for
    // the disk instead of being counted as lost.
    const reading = h.controller.sample(test_io);
    try testing.expectEqual(@as(u64, 0), reading.queries_dropped);
    try testing.expectEqual(@as(u64, 16), reading.rows_written);
    try testing.expectEqual(@as(i64, 16), try h.storedRows());
}

test "a candidate whose writer cannot prepare its statements leaves the running logger alone" {
    var h: Harness = .{};
    try h.start(.{ .query_log_buffer_max = 16, .query_log_flush_interval_s = 0 });
    defer h.stop();

    produce(&h.controller, 4, 1);
    try awaitRows(&h.controller, 4, 400);

    // The live writer already holds its prepared statements; a new connection
    // would find no table to prepare against.
    var side = try h.sideConnection();
    try side.exec("ALTER TABLE query_log RENAME TO query_log_hidden");

    try testing.expectError(error.WriterStatementsFailed, h.prepareResize(64));

    // Untouched: same capacity, same generation, nothing retiring, and the
    // counters carry on from where they were.
    try testing.expectEqual(@as(u32, 16), h.controller.capacity(test_io));
    try testing.expect(!h.controller.retirementPending(test_io));
    try testing.expect(!h.controller.sample(test_io).writer_failed);

    try side.exec("ALTER TABLE query_log_hidden RENAME TO query_log");
    side.close();

    // And the refusal left nothing behind: the next resize works.
    const prepared = try h.prepareResize(64);
    h.controller.publish(test_io, prepared);
    try awaitRetired(&h.controller, 400);
    try testing.expectEqual(@as(u32, 64), h.controller.capacity(test_io));

    produce(&h.controller, 4, 100);
    h.controller.shutdown(test_io);
    try testing.expectEqual(@as(u64, 8), h.controller.sample(test_io).rows_written);
}

test "a prepared candidate the caller abandons closes everything it opened" {
    var h: Harness = .{};
    try h.start(.{ .query_log_buffer_max = 16, .query_log_flush_interval_s = 0 });
    defer h.stop();

    const prepared = try h.prepareResize(64);
    h.controller.abandon(test_io, prepared);

    try testing.expectEqual(@as(u32, 16), h.controller.capacity(test_io));
    try testing.expect(!h.controller.retirementPending(test_io));

    produce(&h.controller, 3, 1);
    h.controller.shutdown(test_io);
    try testing.expectEqual(@as(u64, 3), h.controller.sample(test_io).rows_written);
}

test "an invalid buffer size is refused with the message startup uses" {
    var h: Harness = .{};
    try h.start(.{ .query_log_buffer_max = 16 });
    defer h.stop();

    var buf: [64]u8 = undefined;

    try testing.expectError(error.BufferTooSmall, h.prepareResize(0));
    try testing.expectEqualStrings(
        "must be at least 1",
        sizeMessage(error.BufferTooSmall, 0, &buf).?,
    );

    const over = logger.query_log_buffer_max + 1;
    try testing.expectError(error.BufferTooLarge, h.prepareResize(over));
    var expected_buf: [64]u8 = undefined;
    // `config/validate.zig`'s wording, formatted from the same constant.
    const expected = try std.fmt.bufPrint(
        &expected_buf,
        "must be at most {d}, got {d}",
        .{ logger.query_log_buffer_max, over },
    );
    try testing.expectEqualStrings(expected, sizeMessage(error.BufferTooLarge, over, &buf).?);

    // Neither refusal touched anything.
    try testing.expectEqual(@as(u32, 16), h.controller.capacity(test_io));
}

test "a second resize is refused while the previous one is still draining" {
    var monitor = critical();
    var h: Harness = .{ .monitor = &monitor };
    try h.start(.{ .query_log_buffer_max = 32, .query_log_flush_interval_s = 0 });
    defer h.stop();

    produce(&h.controller, 4, 1);
    try awaitGated(&h.controller, 400);

    const first = try h.prepareResize(64);
    h.controller.publish(test_io, first);

    // The gate is shut, so the retired writer cannot finish. A second resize
    // would put a third buffer, connection and writer in flight.
    try testing.expectError(error.PreviousResizeDraining, h.prepareResize(128));

    monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
    try awaitRetired(&h.controller, 1200);

    // The slot is free again the moment the retirement completes.
    const second = try h.prepareResize(128);
    h.controller.publish(test_io, second);
    try testing.expectEqual(@as(u32, 128), h.controller.capacity(test_io));
    try awaitRetired(&h.controller, 400);
    h.controller.shutdown(test_io);
}

test "the counters, the drop stamp and the gate episode survive a swap" {
    var monitor = critical();
    var h: Harness = .{ .monitor = &monitor };
    // One entry of room behind a writer the gate is holding: the producers
    // below have to displace each other, so drops and a stamp exist to carry.
    try h.start(.{ .query_log_buffer_max = 1, .query_log_flush_interval_s = 0 });
    defer h.stop();

    produce(&h.controller, 1, 1);
    try awaitGated(&h.controller, 400);
    produce(&h.controller, 40, 10);

    const before = h.controller.sample(test_io);
    try testing.expect(before.queries_dropped > 0);
    try testing.expect(before.last_drop_s != null);
    try testing.expectEqual(logger.GateEpisode.losing, before.gate_episode);

    const prepared = try h.prepareResize(64);
    h.controller.publish(test_io, prepared);

    // Read across the swap: the new generation's counters start at zero, so
    // anything that reported only the live generation would go backwards here.
    const after = h.controller.sample(test_io);
    try testing.expectEqual(before.queries_dropped, after.queries_dropped);
    try testing.expectEqual(before.last_drop_s, after.last_drop_s);
    try testing.expectEqual(before.rows_written, after.rows_written);
    // The retired writer is still holding its batch against the shut gate, and
    // that is still a current fault.
    try testing.expectEqual(logger.GateEpisode.losing, after.gate_episode);

    monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
    try awaitRetired(&h.controller, 1200);

    // Folding the retired generation's finals into the base is the other half:
    // its counters must not vanish with it.
    const settled = h.controller.sample(test_io);
    try testing.expectEqual(before.queries_dropped, settled.queries_dropped);
    try testing.expectEqual(before.last_drop_s, settled.last_drop_s);
    try testing.expect(settled.rows_written >= before.rows_written);
    try testing.expectEqual(logger.GateEpisode.open, settled.gate_episode);

    h.controller.shutdown(test_io);
}

/// Flips both privacy fields together, over and over, through the controller.
fn flipPrivacy(controller: *Controller, rounds: usize) void {
    for (0..rounds) |i| {
        controller.setPrivacy(test_io, if (i % 2 == 0)
            .{}
        else
            .{ .hide_domains = true, .hide_client_ips = true });
    }
}

fn resizeRepeatedly(h: *Harness, rounds: usize) void {
    for (0..rounds) |i| {
        const prepared = h.prepareResize(if (i % 2 == 0) 48 else 64) catch continue;
        h.controller.publish(test_io, prepared);
        awaitRetired(&h.controller, 2000) catch return;
    }
}

test "no producer sees a privacy policy that redacts one field and not the other, across resizes" {
    var h: Harness = .{};
    try h.start(.{ .query_log_buffer_max = 64, .query_log_flush_interval_s = 0 });
    defer h.stop();

    const source = testEntry(1, "tracker.example");
    var flipper = try test_io.concurrent(flipPrivacy, .{ &h.controller, 20_000 });
    var resizer = try test_io.concurrent(resizeRepeatedly, .{ &h, 4 });

    // Recorded, not asserted, while the two other tasks run: an assertion that
    // returned here would leave them unjoined.
    var mixed = false;
    for (0..20_000) |_| {
        const generation = h.controller.acquire(test_io);
        const out = generation.logger.transformed(source);
        h.controller.release(test_io, generation);
        const domain_hidden = std.mem.eql(u8, out.domain(), logger.hidden_marker);
        const client_hidden = std.mem.eql(u8, out.clientIp(), logger.hidden_marker);
        if (domain_hidden != client_hidden) mixed = true;
    }
    flipper.await(test_io);
    resizer.await(test_io);
    try testing.expect(!mixed);

    try awaitRetired(&h.controller, 2000);
    h.controller.shutdown(test_io);
}

test "shutdown joins a retirement the disk gate is still blocking" {
    var monitor = critical();
    var h: Harness = .{ .monitor = &monitor };
    try h.start(.{ .query_log_buffer_max = 64, .query_log_flush_interval_s = 0 });
    defer h.stop();

    const held = 6;
    produce(&h.controller, held, 1);
    try awaitGated(&h.controller, 400);

    const prepared = try h.prepareResize(128);
    h.controller.publish(test_io, prepared);
    try testing.expect(h.controller.retirementPending(test_io));

    const after_swap = 4;
    produce(&h.controller, after_swap, 100);

    // The disk never recovers. Both writers are gate-held, one of them retired
    // and owned by the reaper — the exit still ends, each future is awaited by
    // exactly one task, and every entry is counted rather than silently lost.
    h.controller.shutdown(test_io);

    const reading = h.controller.sample(test_io);
    try testing.expectEqual(@as(u64, held + after_swap), reading.queries_dropped);
    try testing.expectEqual(@as(u64, 0), reading.rows_written);
    try testing.expectEqual(@as(i64, 0), try h.storedRows());
}

test "a borrowed controller reports its logger and refuses to resize" {
    var queue_buf: [4]logger.Entry = undefined;
    var lg: logger.Logger = .init(.{}, &queue_buf);
    lg.rows_written.store(7, .monotonic);
    lg.queries_dropped.store(2, .monotonic);

    var owner: Borrowed = .{};
    const controller = owner.over(&lg);

    const reading = controller.sample(test_io);
    try testing.expectEqual(@as(u64, 7), reading.rows_written);
    try testing.expectEqual(@as(u64, 2), reading.queries_dropped);
    try testing.expectEqual(@as(u32, 4), controller.capacity(test_io));

    try testing.expectError(error.NotResizable, controller.prepare(test_io, .{ .query_log_buffer_max = 8 }));

    // Owns nothing, so both teardown calls are no-ops over the caller's logger.
    controller.shutdown(test_io);
    controller.deinit(test_io);
}
