//! The upstream generation and the owner that publishes it.
//!
//! A `Generation` is everything one upstream configuration needs to answer a
//! query: the parsed endpoints, the leaf clients behind them, the pool that
//! chooses between them, and — the reason it is a generation rather than a
//! plain struct — every configuration string it borrows, copied into an arena
//! it owns. `transport.Endpoint` borrows the URL text (transport.zig:51), so a
//! generation built from database rows that live in a request arena must not
//! outlive that arena unless it took its own copy. It takes its own copy.
//!
//! `Owner` publishes one generation at a time under the `CertStore` discipline
//! (cert_store.zig:199/:237): a mutex held briefly around a refcounted borrow,
//! a swap that marks the old generation retired, and a last release that tears
//! it down. A query acquires for the length of one exchange and copies whatever
//! it needs out of the generation before releasing, so a `replace` landing
//! mid-query never frees anything the query still reads.

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

const doh_client = @import("doh_client.zig");
const dot_client = @import("dot_client.zig");
const events = @import("../storage/events.zig");
const health = @import("health.zig");
const model = @import("../config/model.zig");
const pool_mod = @import("pool.zig");
const safe_url = @import("../safe_url.zig");
const transport = @import("transport.zig");

const log = std.log.scoped(.nxdns);

const doh_request_buf_len = doh_client.default_request_buf_len;
const doh_transfer_buf_len = doh_client.default_transfer_buf_len;

/// The `configuration.load` findings of one `build`, without the side effects.
///
/// `build` writes no event rows: at boot the caller replays this report through
/// its own `ConfigLoad.note` so the diagnostics are exactly what they were when
/// the composition lived in `app.zig`, and at runtime a candidate that is never
/// published must leave no trace at all. The strings are owned by the
/// generation's arena, so the report is readable for as long as the generation
/// is.
pub const BuildReport = struct {
    notes: []const Note = &.{},

    /// `url` is the subject key an upstream finding is filed under: the whole
    /// URL is the identity, and redaction is the caller's job because the
    /// caller is what renders it.
    pub const Note = struct {
        url: []const u8,
        message: []const u8,
    };
};

/// One finding rendered for the event store: the redacted label and the detail
/// line. Both borrow `Rendered`'s own buffers, so it must outlive the call that
/// writes the row.
pub const Rendered = struct {
    label_buf: [events.Store.max_subject_label_len]u8 = undefined,
    detail_buf: [events.Store.max_detail_len]u8 = undefined,
    label: []const u8 = "",
    detail: []const u8 = "",

    /// An upstream's identity is its url: the whole url is the key, and the
    /// redaction is the label, because a url can carry an account token.
    pub fn render(self: *Rendered, finding: BuildReport.Note) void {
        self.label = std.fmt.bufPrint(&self.label_buf, "{f}", .{
            safe_url.redact(finding.url),
        }) catch &self.label_buf;
        self.detail = std.fmt.bufPrint(&self.detail_buf, "upstream {f} {s}", .{
            safe_url.redactQuoted(finding.url),
            finding.message,
        }) catch &self.detail_buf;
    }
};

/// Reconciles the `configuration.load` episodes of an upstream replace, on the
/// task that published it and after the publish.
///
/// SCOPED, never `Store.resolveExcept`: that call resolves every active
/// `configuration.load` episode outside its kept set, which at runtime would
/// falsely close boot warnings about settings this replace never touched. Only
/// the keys the previous generation reported and the new one does not are
/// resolved, one at a time.
///
/// `previous_keys` is the copy taken at prepare, so nothing here depends on the
/// retired generation still being alive.
pub fn reconcileReport(
    store: *events.Store,
    io: std.Io,
    now_s: i64,
    new_notes: []const BuildReport.Note,
    previous_keys: []const []const u8,
) void {
    var rendered: Rendered = .{};
    for (new_notes) |finding| {
        rendered.render(finding);
        store.report(
            io,
            now_s,
            .configuration_load,
            finding.url,
            rendered.label,
            .warning,
            rendered.detail,
        );
    }

    var previous_buf: [events.Store.max_subject_key_len]u8 = undefined;
    var current_buf: [events.Store.max_subject_key_len]u8 = undefined;
    outer: for (previous_keys) |key| {
        const previous = events.canonicalKey(key, &previous_buf);
        for (new_notes) |finding| {
            if (std.mem.eql(u8, previous, events.canonicalKey(finding.url, &current_buf))) continue :outer;
        }
        store.resolve(io, now_s, .configuration_load, key);
    }
}

/// Everything a generation built from configuration rows owns. Absent when the
/// caller supplied the `transport.Client` directly — a test seam, and the shape
/// `nxdns check` would want for a single probe.
const Built = struct {
    gpa: Allocator,
    /// Every configuration string the generation borrows: the URL text each
    /// `Endpoint` slices its host and path out of, and each DoT `tls_name`.
    arena: std.heap.ArenaAllocator,
    upstreams: Upstreams,
    pool: pool_mod.Pool,
    report: BuildReport,
};

pub const Generation = struct {
    /// The exchange entry point. `built.pool.client()` for a configured
    /// generation, the caller's client otherwise.
    client: transport.Client,
    /// The pool behind `client`, for the health and metrics reads. Null when
    /// the client is not a pool.
    pool: ?*pool_mod.Pool = null,
    built: ?Built = null,
    /// Set when `retire` must free the generation's own storage. Null when the
    /// caller owns it — a generation on a test's stack.
    destroy_with: ?Allocator = null,
    /// Guarded by the owner's mutex, never touched outside it.
    refs: usize = 0,
    retired: bool = false,

    /// A generation over a client the caller owns and keeps alive. Owns
    /// nothing, so `retire` only invalidates it.
    pub fn borrowing(client: transport.Client) Generation {
        return .{ .client = client };
    }

    /// A generation over a pool the caller owns and keeps alive. The metrics
    /// and health paths read the pool through this.
    pub fn borrowingPool(pool: *pool_mod.Pool) Generation {
        return .{ .client = pool.client(), .pool = pool };
    }

    pub fn report(self: *const Generation) BuildReport {
        const built = self.built orelse return .{};
        return built.report;
    }

    /// The active entries, for a caller that wants the count rather than the
    /// health of each.
    pub fn activeCount(self: *Generation) usize {
        const built = &(self.built orelse return 0);
        return built.upstreams.used;
    }

    /// Connections first, memory second, storage last. Only ever called with
    /// `refs == 0`: by `Owner.release` when the last reader of a retired
    /// generation leaves, by the caller `Owner.replace` handed an idle
    /// generation back to, or by `Owner.deinit` at shutdown.
    pub fn retire(self: *Generation, io: std.Io) void {
        if (self.built) |*built| {
            built.upstreams.deinit(io, built.gpa);
            built.arena.deinit();
        }
        const destroy_with = self.destroy_with;
        self.* = undefined;
        if (destroy_with) |gpa| gpa.destroy(self);
    }
};

pub const BuildError = Allocator.Error || error{NoUsableUpstreams};

pub const BuildOptions = struct {
    gpa: Allocator,
    io: std.Io,
    /// Borrowed for the length of the call only: every string this generation
    /// keeps is copied into its arena before `build` returns.
    servers: []const model.UpstreamServer,
    http: *std.http.Client,
    bundle: *Certificate.Bundle,
    bundle_lock: *std.Io.RwLock,
    timeouts: pool_mod.Timeouts,
    health_config: health.Config = .{},
    seed: u64,
    diagnostics: ?*events.Store = null,
};

/// Builds one heap-stable generation from a row set. Side-effect free: it
/// writes no event rows and publishes nothing. Every failure frees everything
/// it allocated.
pub fn build(opts: BuildOptions) BuildError!*Generation {
    const gpa = opts.gpa;

    const generation = try gpa.create(Generation);
    errdefer gpa.destroy(generation);

    var arena_state: std.heap.ArenaAllocator = .init(gpa);
    errdefer arena_state.deinit();
    const arena = arena_state.allocator();

    // The rows this generation keeps, copied out of whatever memory the caller
    // read them into. `Endpoint.parse` slices host and path out of the URL, so
    // duplicating the URL covers all three.
    const owned = try arena.alloc(model.UpstreamServer, opts.servers.len);
    for (opts.servers, owned) |server, *copy| {
        copy.* = .{
            .url = try arena.dupe(u8, server.url),
            .priority = server.priority,
            .enabled = server.enabled,
            .tls_name = try arena.dupe(u8, server.tls_name),
        };
    }

    var notes: std.ArrayList(BuildReport.Note) = .empty;
    var upstreams = try Upstreams.build(
        opts.io,
        gpa,
        arena,
        owned,
        opts.http,
        opts.bundle,
        opts.bundle_lock,
        &notes,
    );
    errdefer upstreams.deinit(opts.io, gpa);

    generation.* = .{
        .client = undefined,
        .pool = null,
        .destroy_with = gpa,
        .built = .{
            .gpa = gpa,
            .arena = arena_state,
            .upstreams = upstreams,
            .pool = .init(
                upstreams.active(),
                opts.health_config,
                opts.timeouts,
                opts.seed,
            ),
            .report = .{ .notes = try notes.toOwnedSlice(arena) },
        },
    };
    const built = &generation.built.?;
    built.pool.diagnostics = opts.diagnostics;
    // Taken after the generation is in its final storage: the client is a
    // pointer to the pool inside it.
    generation.pool = &built.pool;
    generation.client = built.pool.client();
    return generation;
}

/// Publishes one generation at a time.
///
/// The initial generation is the caller's to provide and the owner's to tear
/// down: `deinit` retires whatever is live, and every generation a `replace`
/// displaces is retired by the owner or handed back to the caller idle.
pub const Owner = struct {
    mutex: std.Io.Mutex = .init,
    live: *Generation,
    /// How many generations `replace` has published. One candidate per owner
    /// means one increment per configuration write, however many keys of this
    /// owner that write named. Guarded by `mutex`, like everything else here.
    ///
    /// Not a `std.atomic.Value(u64)`: in Debug the x86_64 self-hosted backend
    /// of zig 0.16.0 miscompiles `replace` when a `lock xadd` sits between the
    /// `old.refs == 0` comparison and the branch on its result, and `replace`
    /// then returns null for every input. See AGENTS.md.
    published: u64 = 0,

    pub fn init(live: *Generation) Owner {
        return .{ .live = live };
    }

    /// Pins the live generation for one exchange or one scrape. The returned
    /// generation stays valid until the matching `release`, across any number
    /// of replaces.
    pub fn acquire(self: *Owner, io: std.Io) *Generation {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);
        self.live.refs += 1;
        return self.live;
    }

    /// The subject keys the live generation's report is filed under, copied
    /// into `arena`.
    ///
    /// Taken at prepare so that the retire-time reconciliation never depends on
    /// the displaced generation still being alive: by then its last reader may
    /// have freed it. The copy runs under the mutex because that is what pins
    /// the generation whose arena the strings live in; the allocation is from a
    /// bump arena and touches no `std.Io` primitive, so the hold stays as brief
    /// as every other one this type takes.
    pub fn copyLiveReportKeys(
        self: *Owner,
        io: std.Io,
        arena: Allocator,
    ) Allocator.Error![]const []const u8 {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);

        const notes = self.live.report().notes;
        const copies = try arena.alloc([]const u8, notes.len);
        for (notes, copies) |finding, *slot| slot.* = try arena.dupe(u8, finding.url);
        return copies;
    }

    pub fn release(self: *Owner, io: std.Io, generation: *Generation) void {
        self.mutex.lockUncancelable(io);
        std.debug.assert(generation.refs > 0);
        generation.refs -= 1;
        const retire_it = generation.retired and generation.refs == 0;
        self.mutex.unlock(io);
        if (retire_it) generation.retire(io);
    }

    /// Publishes `prepared` and retires the live generation. Infallible and
    /// I/O-free by construction: a pointer swap under the mutex.
    ///
    /// Returns the displaced generation when no reader held it, because there
    /// is then no release left to retire it and the caller must — the same
    /// `refs == 0` branch `CertStore.reload` takes. Returns null when a reader
    /// still holds it; that reader's release retires it.
    pub fn replace(self: *Owner, io: std.Io, prepared: *Generation) ?*Generation {
        std.debug.assert(prepared.refs == 0);
        std.debug.assert(!prepared.retired);
        self.mutex.lockUncancelable(io);
        const old = self.live;
        self.live = prepared;
        old.retired = true;
        const idle = old.refs == 0;
        self.published += 1;
        self.mutex.unlock(io);
        return if (idle) old else null;
    }

    /// How many replaces this owner has published.
    pub fn publishedCount(self: *Owner, io: std.Io) u64 {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);
        return self.published;
    }

    /// Shutdown teardown. Every listener and every metrics reader must have
    /// stopped: a live generation with readers left is a caller that tore down
    /// out of order, and a retired one with readers was freed by its own last
    /// release already.
    pub fn deinit(self: *Owner, io: std.Io) void {
        self.mutex.lockUncancelable(io);
        const live = self.live;
        std.debug.assert(live.refs == 0);
        self.mutex.unlock(io);
        live.retire(io);
        self.* = undefined;
    }
};

/// An owner over transport the caller already has: a fake client in a handler
/// test, or a pool a fixture built from fake entries. Owns nothing, so there is
/// nothing to tear down; both parts are inline so a caller keeps them on its
/// stack beside the handler.
pub const Borrowed = struct {
    generation: Generation = undefined,
    owner: Owner = undefined,

    pub fn client(self: *Borrowed, c: transport.Client) *Owner {
        self.generation = .borrowing(c);
        self.owner = .init(&self.generation);
        return &self.owner;
    }

    pub fn pool(self: *Borrowed, p: *pool_mod.Pool) *Owner {
        self.generation = .borrowingPool(p);
        self.owner = .init(&self.generation);
        return &self.owner;
    }
};

// ---------------------------------------------------------------------------
// the pool's entries and everything they point into
// ---------------------------------------------------------------------------

/// Every enabled upstream gets `pool_mod.slots_per_entry` leaf clients, one per
/// slot of its entry, so that many exchanges can be in flight against it at
/// once. `Slot.client` is a type-erased pointer into `doh` or `dot`, each of
/// those clients borrows a slice of `doh_buf`/`dot_buf`, and each entry borrows
/// a run of `slot_storage` and one counter of `recovery_counters` — so every
/// allocation here lives exactly as long as the generation does, and none of
/// them is ever resized. One slot is used by one task at a time, which is why
/// the buffers are per client and not shared the way `cli.probeUpstreams`
/// shares them.
const Upstreams = struct {
    entries: []pool_mod.Entry,
    used: usize,
    /// Sliced per entry into `Entry.slots`, never pointing into the client
    /// arrays: `Pool.init` sorts entries and the slices have to survive it.
    slot_storage: []pool_mod.Slot,
    /// One per enabled upstream, and the reason it is a separate allocation:
    /// `Pool.init` sorts entries by value, so a counter living inside an entry
    /// would be pointed at by the wrong upstream's clients after the sort.
    recovery_counters: []std.atomic.Value(u64),
    doh: []doh_client.DohClient,
    dot: []dot_client.DotClient,
    /// How much of `doh`/`dot` was actually initialized. A malformed or skipped
    /// upstream leaves the tail of an over-allocated array undefined, and both
    /// `deinit` and `build`'s failure paths iterate only the initialized
    /// prefix — reading a `DotClient` that was never built, or closing a
    /// session that was never opened, is what these two counts prevent.
    doh_used: usize,
    dot_used: usize,
    doh_buf: []u8,
    dot_buf: []u8,

    /// A disabled upstream is left out entirely; a malformed one is noted and
    /// skipped, because one bad row in a table of four must not take DNS down.
    /// No usable row at all is a configuration fault.
    ///
    /// `arena` is the generation's: `notes` borrows from it and so does every
    /// string in `servers`, which the caller has already copied there.
    fn build(
        io: std.Io,
        gpa: Allocator,
        arena: Allocator,
        servers: []const model.UpstreamServer,
        http: *std.http.Client,
        bundle: *Certificate.Bundle,
        bundle_lock: *std.Io.RwLock,
        notes: *std.ArrayList(BuildReport.Note),
    ) BuildError!Upstreams {
        var enabled: usize = 0;
        for (servers) |server| {
            if (server.enabled) enabled += 1;
        }
        if (enabled == 0) return error.NoUsableUpstreams;

        const chunk = tls.Client.min_buffer_len;
        const slots = pool_mod.slots_per_entry;
        const leaf_clients = enabled * slots;

        var self: Upstreams = .{
            .entries = try gpa.alloc(pool_mod.Entry, enabled),
            .used = 0,
            .slot_storage = &.{},
            .recovery_counters = &.{},
            .doh = &.{},
            .dot = &.{},
            .doh_used = 0,
            .dot_used = 0,
            .doh_buf = &.{},
            .dot_buf = &.{},
        };
        errdefer self.deinit(io, gpa);

        self.slot_storage = try gpa.alloc(pool_mod.Slot, leaf_clients);
        self.recovery_counters = try gpa.alloc(std.atomic.Value(u64), enabled);
        for (self.recovery_counters) |*counter| counter.* = .init(0);
        self.doh = try gpa.alloc(doh_client.DohClient, leaf_clients);
        self.dot = try gpa.alloc(dot_client.DotClient, leaf_clients);
        self.doh_buf = try gpa.alloc(u8, leaf_clients * (doh_request_buf_len + doh_transfer_buf_len));
        self.dot_buf = try gpa.alloc(u8, leaf_clients * 4 * chunk);

        for (servers) |server| {
            if (!server.enabled) continue;

            const endpoint = transport.Endpoint.parse(server.url) catch {
                try note(arena, notes, server.url, "not an https:// or tls:// endpoint; skipped");
                continue;
            };

            const entry_slots = self.slot_storage[self.used * slots ..][0..slots];
            switch (endpoint.scheme) {
                .doh => if (!self.wireDoh(http, endpoint, entry_slots)) {
                    try note(arena, notes, server.url, "not a usable DoH url; skipped");
                    continue;
                },
                .dot => self.wireDot(gpa, endpoint, server.tls_name, bundle, bundle_lock, entry_slots),
            }

            self.entries[self.used] = .{
                .endpoint = endpoint,
                .slots = entry_slots,
                .priority = server.priority,
                .enabled = true,
                .health = .init,
                .admission = .{ .permits = entry_slots.len },
                .reuse_recoveries = &self.recovery_counters[self.used],
            };
            self.used += 1;
        }

        if (self.used == 0) return error.NoUsableUpstreams;
        return self;
    }

    /// One `DohClient` per slot, all sharing the one `std.http.Client`: its
    /// connection pool already serves concurrent requests, and a `DohClient`'s
    /// only mutable state is the two buffers this gives each slot its own of.
    ///
    /// False means the url is not a usable DoH url, which `DohClient.init`
    /// decides from the url alone — so it fails on the first slot or on none.
    /// `doh_used` still advances per client rather than per entry: it means
    /// "initialized", and a skipped entry's clients are simply never reached.
    fn wireDoh(
        self: *Upstreams,
        http: *std.http.Client,
        endpoint: transport.Endpoint,
        slots: []pool_mod.Slot,
    ) bool {
        for (slots) |*slot| {
            const index = self.doh_used;
            const base = index * (doh_request_buf_len + doh_transfer_buf_len);
            self.doh[index] = doh_client.DohClient.init(
                http,
                endpoint,
                self.doh_buf[base..][0..doh_request_buf_len],
                self.doh_buf[base + doh_request_buf_len ..][0..doh_transfer_buf_len],
            ) catch return false;
            self.doh_used = index + 1;
            slot.* = .{ .client = self.doh[index].client() };
        }
        return true;
    }

    /// One `DotClient` per slot, each with its own four TLS buffers and all
    /// sharing the trust store. Every client of one entry reports its
    /// stale-reuse recoveries through that entry's counter.
    fn wireDot(
        self: *Upstreams,
        gpa: Allocator,
        endpoint: transport.Endpoint,
        tls_name: []const u8,
        bundle: *Certificate.Bundle,
        bundle_lock: *std.Io.RwLock,
        slots: []pool_mod.Slot,
    ) void {
        const chunk = tls.Client.min_buffer_len;
        const recoveries = &self.recovery_counters[self.used];
        for (slots) |*slot| {
            const index = self.dot_used;
            const base = index * 4 * chunk;
            self.dot[index] = dot_client.DotClient.init(
                endpoint,
                tls_name,
                gpa,
                bundle,
                bundle_lock,
                recoveries,
                .{
                    .tls_read = self.dot_buf[base..][0..chunk],
                    .tls_write = self.dot_buf[base + chunk ..][0..chunk],
                    .stream_read = self.dot_buf[base + 2 * chunk ..][0..chunk],
                    .stream_write = self.dot_buf[base + 3 * chunk ..][0..chunk],
                },
            );
            self.dot_used = index + 1;
            slot.* = .{ .client = self.dot[index].client() };
        }
    }

    /// The prefix `Pool.init` is given. The rest of `entries` is allocated but
    /// never filled, which is what keeps `deinit` able to free the whole block.
    fn active(self: *Upstreams) []pool_mod.Entry {
        return self.entries[0..self.used];
    }

    /// Connections first, memory second: a `DotClient` holds a socket its
    /// buffers belong to, so nothing it points at may be freed before it is
    /// closed.
    fn deinit(self: *Upstreams, io: std.Io, gpa: Allocator) void {
        for (self.dot[0..self.dot_used]) |*client| client.close(io);
        gpa.free(self.dot_buf);
        gpa.free(self.doh_buf);
        gpa.free(self.dot);
        gpa.free(self.doh);
        gpa.free(self.recovery_counters);
        gpa.free(self.slot_storage);
        gpa.free(self.entries);
        self.* = undefined;
    }
};

/// The warning goes out here as well as into the report: `std.log` is the
/// operator's boot transcript and a runtime candidate that is refused is still
/// worth a line, while the report is what writes the event row — at boot, or in
/// retire once a candidate is published.
fn note(
    arena: Allocator,
    notes: *std.ArrayList(BuildReport.Note),
    url: []const u8,
    message: []const u8,
) Allocator.Error!void {
    log.warn("upstream {f} {s}", .{ safe_url.redactQuoted(url), message });
    // `url` already lives in the generation's arena; the message is a literal.
    try notes.append(arena, .{ .url = url, .message = message });
}

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

const testing = std.testing;

const TestIo = struct {
    threaded: std.Io.Threaded,

    fn init(gpa: Allocator) TestIo {
        return .{ .threaded = .init(gpa, .{}) };
    }

    fn io(self: *TestIo) std.Io {
        return self.threaded.io();
    }

    fn deinit(self: *TestIo) void {
        self.threaded.deinit();
    }
};

const test_timeouts: pool_mod.Timeouts = .{
    .attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake },
    .total = .{ .raw = .fromMilliseconds(200), .clock = .awake },
};

/// A client that answers from a fixed reply and records the identity it named,
/// so a test can prove which generation served an exchange.
const FakeClient = struct {
    identity: []const u8,
    calls: std.atomic.Value(u64) = .init(0),
    /// Set once the exchange is inside the client, so a test knows the
    /// generation is really pinned before it swaps.
    entered: ?*std.Io.Event = null,
    /// Waited on before the exchange returns, so a test can hold an exchange
    /// open across a `replace`.
    gate: ?*std.Io.Event = null,

    fn client(self: *FakeClient) transport.Client {
        return .{ .ptr = self, .exchangeFn = exchangeFn };
    }

    fn exchangeFn(
        ptr: *anyopaque,
        io: std.Io,
        query: []const u8,
        response_buf: []u8,
        selected: *?[]const u8,
    ) transport.ExchangeError![]u8 {
        const self: *FakeClient = @ptrCast(@alignCast(ptr));
        selected.* = self.identity;
        _ = self.calls.fetchAdd(1, .monotonic);
        if (self.entered) |entered| entered.set(io);
        if (self.gate) |gate| gate.wait(io) catch return error.Canceled;
        @memcpy(response_buf[0..query.len], query);
        return response_buf[0..query.len];
    }
};

fn buildTestGeneration(
    io: std.Io,
    gpa: Allocator,
    http: *std.http.Client,
    bundle: *Certificate.Bundle,
    bundle_lock: *std.Io.RwLock,
    servers: []const model.UpstreamServer,
) BuildError!*Generation {
    return build(.{
        .gpa = gpa,
        .io = io,
        .servers = servers,
        .http = http,
        .bundle = bundle,
        .bundle_lock = bundle_lock,
        .timeouts = test_timeouts,
        .seed = 1,
    });
}

test "an owner hands out the live generation and retires the old one on the last release" {
    var t: TestIo = .init(testing.allocator);
    defer t.deinit();
    const io = t.io();

    var first: FakeClient = .{ .identity = "fake://g1" };
    var second: FakeClient = .{ .identity = "fake://g2" };
    var g1: Generation = .borrowing(first.client());
    var g2: Generation = .borrowing(second.client());

    var owner: Owner = .init(&g1);

    const held = owner.acquire(io);
    try testing.expectEqual(&g1, held);

    // A reader holds G1, so the swap cannot retire it here.
    try testing.expectEqual(@as(?*Generation, null), owner.replace(io, &g2));
    try testing.expect(g1.retired);

    // A new acquire lands on G2 while the old reader is still on G1.
    const fresh = owner.acquire(io);
    try testing.expectEqual(&g2, fresh);
    owner.release(io, fresh);

    owner.release(io, held);
    owner.deinit(io);
}

test "a replace with no reader holding the live generation retires it through the return path" {
    var t: TestIo = .init(testing.allocator);
    defer t.deinit();
    const io = t.io();

    var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
    defer http.deinit();
    var bundle: Certificate.Bundle = .empty;
    defer bundle.deinit(testing.allocator);
    var bundle_lock: std.Io.RwLock = .init;

    const g1 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
        .{ .url = "https://one.example/dns-query" },
    });
    const g2 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
        .{ .url = "https://two.example/dns-query" },
    });

    var owner: Owner = .init(g1);

    // Nobody holds G1: `replace` must hand it back, because no release will.
    const displaced = owner.replace(io, g2) orelse return error.ExpectedIdleGeneration;
    try testing.expectEqual(g1, displaced);
    displaced.retire(io);

    owner.deinit(io);
}

test "a generation owns its configuration strings after the rows they came from are freed" {
    var t: TestIo = .init(testing.allocator);
    defer t.deinit();
    const io = t.io();

    var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
    defer http.deinit();
    var bundle: Certificate.Bundle = .empty;
    defer bundle.deinit(testing.allocator);
    var bundle_lock: std.Io.RwLock = .init;

    // The rows a request arena would hand `build`. Freeing the arena poisons
    // every byte of them, so a generation that kept a borrow reads garbage.
    var rows_arena: std.heap.ArenaAllocator = .init(testing.allocator);
    const rows = rows_arena.allocator();
    const servers = try rows.dupe(model.UpstreamServer, &.{
        .{ .url = try rows.dupe(u8, "tls://dot.example.net:853"), .tls_name = try rows.dupe(u8, "dot.example.net") },
    });

    const generation = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, servers);
    rows_arena.deinit();

    var owner: Owner = .init(generation);
    defer owner.deinit(io);

    const held = owner.acquire(io);
    defer owner.release(io, held);

    var snapshots: [4]pool_mod.Snapshot = undefined;
    const count = try held.pool.?.snapshot(io, &snapshots);
    try testing.expectEqual(@as(usize, 1), count);
    try testing.expectEqualStrings("tls://dot.example.net:853", snapshots[0].url);
    try testing.expectEqual(@as(usize, 1), held.activeCount());
}

test "build reports a malformed row instead of writing it, and refuses a row set with nothing usable" {
    var t: TestIo = .init(testing.allocator);
    defer t.deinit();
    const io = t.io();

    var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
    defer http.deinit();
    var bundle: Certificate.Bundle = .empty;
    defer bundle.deinit(testing.allocator);
    var bundle_lock: std.Io.RwLock = .init;

    const generation = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
        .{ .url = "ftp://nope.example" },
        .{ .url = "https://good.example/dns-query" },
        .{ .url = "https://disabled.example/dns-query", .enabled = false },
    });
    var owner: Owner = .init(generation);
    defer owner.deinit(io);

    const report = generation.report();
    try testing.expectEqual(@as(usize, 1), report.notes.len);
    try testing.expectEqualStrings("ftp://nope.example", report.notes[0].url);
    try testing.expectEqualStrings("not an https:// or tls:// endpoint; skipped", report.notes[0].message);
    try testing.expectEqual(@as(usize, 1), generation.activeCount());

    try testing.expectError(error.NoUsableUpstreams, buildTestGeneration(
        io,
        testing.allocator,
        &http,
        &bundle,
        &bundle_lock,
        &.{.{ .url = "ftp://nope.example" }},
    ));
    try testing.expectError(error.NoUsableUpstreams, buildTestGeneration(
        io,
        testing.allocator,
        &http,
        &bundle,
        &bundle_lock,
        &.{.{ .url = "https://off.example", .enabled = false }},
    ));
}

// The concurrency criterion: an exchange in flight on G1 completes on G1, G1
// deinits only after that reader releases, and every exchange started after
// the swap runs on G2.
test "an exchange in flight survives a replace and the old generation retires after it" {
    var t: TestIo = .init(testing.allocator);
    defer t.deinit();
    const io = t.io();

    var entered: std.Io.Event = .unset;
    var gate: std.Io.Event = .unset;
    var first: FakeClient = .{ .identity = "fake://g1", .entered = &entered, .gate = &gate };
    var second: FakeClient = .{ .identity = "fake://g2" };
    var g1: Generation = .borrowing(first.client());
    var g2: Generation = .borrowing(second.client());
    var owner: Owner = .init(&g1);

    const Exchange = struct {
        fn run(o: *Owner, inner_io: std.Io, out: *?[]const u8) void {
            const generation = o.acquire(inner_io);
            defer o.release(inner_io, generation);
            var buf: [16]u8 = undefined;
            var selected: ?[]const u8 = null;
            _ = generation.client.exchange(inner_io, "abc", &buf, &selected) catch {};
            out.* = selected;
        }
    };

    var in_flight: ?[]const u8 = null;
    var future = try io.concurrent(Exchange.run, .{ &owner, io, &in_flight });

    // The swap must land with G1 really pinned, not merely likely to be.
    entered.waitUncancelable(io);

    try testing.expectEqual(@as(?*Generation, null), owner.replace(io, &g2));

    var after: ?[]const u8 = null;
    Exchange.run(&owner, io, &after);
    try testing.expectEqualStrings("fake://g2", after.?);

    gate.set(io);
    future.await(io);
    try testing.expectEqualStrings("fake://g1", in_flight.?);

    owner.deinit(io);
}

test "a metrics scrape running against the owner survives a replace under it" {
    var t: TestIo = .init(testing.allocator);
    defer t.deinit();
    const io = t.io();

    var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
    defer http.deinit();
    var bundle: Certificate.Bundle = .empty;
    defer bundle.deinit(testing.allocator);
    var bundle_lock: std.Io.RwLock = .init;

    const g1 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
        .{ .url = "https://one.example/dns-query" },
    });
    const g2 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
        .{ .url = "https://two.example/dns-query" },
        .{ .url = "tls://two.example:853", .tls_name = "two.example" },
    });
    var owner: Owner = .init(g1);
    defer owner.deinit(io);

    const Scrape = struct {
        /// What every scrape must be true of, whichever generation answered
        /// it: a URL the scrape read out of a generation it holds is a URL
        /// nothing has freed.
        fn run(o: *Owner, inner_io: std.Io, started: *std.Io.Event, seen: *usize) void {
            for (0..256) |i| {
                const generation = o.acquire(inner_io);
                defer o.release(inner_io, generation);

                var raw: [8]pool_mod.Snapshot = undefined;
                const count = generation.pool.?.snapshot(inner_io, &raw) catch 0;
                for (raw[0..count]) |entry| {
                    if (std.mem.startsWith(u8, entry.url, "https://") or
                        std.mem.startsWith(u8, entry.url, "tls://")) seen.* += 1;
                }
                if (i == 0) started.set(inner_io);
            }
        }
    };

    var started: std.Io.Event = .unset;
    var seen: usize = 0;
    var future = try io.concurrent(Scrape.run, .{ &owner, io, &started, &seen });

    started.waitUncancelable(io);
    if (owner.replace(io, g2)) |old| old.retire(io);

    future.await(io);
    // Every one of the 256 scrapes read at least one intact URL.
    try testing.expect(seen >= 256);
}
