//! `GET /metrics` — Prometheus text format 0.0.4 (ruling 21).
//!
//! Two halves, so that neither needs the other to be testable: `collect` walks
//! the live collaborators and copies every number into a `Sample`, and `render`
//! turns a `Sample` into text. Nothing is computed during rendering, with one
//! exception: an upstream url is redacted where its label is written rather
//! than where it is copied. `writeUrlLabel` carries the reasoning.
//!
//! Three rules the collection half obeys:
//!
//! - The cache and the DNS rate limiter are the query path's, so their numbers
//!   are read under the handler's own mutexes. Both sections are a struct copy
//!   long. `lockUncancelable` because a handler carries no `Canceled`.
//! - Every borrowed string is copied on the spot. `Pool.Snapshot.last_error`
//!   and `url` point into entries a concurrent failure may rewrite.
//! - A collaborator that is not wired omits its whole metric family rather than
//!   reporting zeros. An absent series is a gap a dashboard can see; a zero is
//!   a lie that looks like health.
//!
//! No timestamps: Prometheus stamps a scrape with its own clock, and the
//! optional per-sample timestamp is for federation, which nxdns does not do.

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

const cert_store = @import("../server/cert_store.zig");
const client_names = @import("../server/client_names.zig");
const clients = @import("../server/clients.zig");
const dns_cache = @import("../cache/dns_cache.zig");
const dns_handler = @import("../server/handler.zig");
const disk_monitor = @import("../storage/disk_monitor.zig");
const dot_server = @import("../server/dot_server.zig");
const events_mod = @import("../storage/events.zig");
const http_util = @import("http_util.zig");
const logging = @import("../platform/logging.zig");
const pool_mod = @import("../upstream/pool.zig");
const upstream_owner = @import("../upstream/owner.zig");
const rate_limiter = @import("../server/rate_limiter.zig");
const retention_mod = @import("../storage/retention.zig");
const safe_url = @import("../safe_url.zig");
const server = @import("server.zig");
const tcp_server = @import("../server/tcp_server.zig");
const udp_server = @import("../server/udp_server.zig");

/// The exposition format version, as the 0.0.4 specification writes it.
pub const content_type = "text/plain; version=0.0.4; charset=utf-8";

/// Upstreams copied per scrape. `validate.zig` bounds a configuration far below
/// this; a pool larger than the buffer is truncated rather than allocated for,
/// because a scrape must not depend on the heap.
pub const max_upstreams = 64;

const dns_stat_fields = @typeInfo(dns_handler.Handler.Stats).@"struct".fields;

/// The DNS pipeline counters, in `Handler.Stats` field order. Held as an array
/// so that a new counter in the handler appears here, and in the exposition,
/// without an edit.
pub const DnsCounters = [dns_stat_fields.len]u64;

pub const LoggerCounters = struct {
    queries_dropped: u64 = 0,
    rows_written: u64 = 0,
    batches_gated: u64 = 0,
};

pub const CacheSample = struct {
    stats: dns_cache.Stats,
    entries: u64,
    memory_bytes: u64,
};

pub const LimiterSample = struct {
    stats: rate_limiter.Stats,
    tracked_clients: u64,
};

pub const TrackerSample = struct {
    stats: clients.Tracker.Stats,
    pending_clients: u64,
};

pub const BlocklistSample = struct {
    refreshes_gated: u64,
    /// Null before the first snapshot is published.
    generation: ?u64,
};

pub const DiskSample = struct {
    gauges: disk_monitor.Gauges,
    sample_failures: u64,
};

/// The DoH listener counters this exposition exports (milestone-10 ruling 10):
/// the four every TLS listener keeps, plus DoH's `bad_requests`. A subset of
/// `doh_server.Snapshot` on purpose — the accept-side refusal counters stay
/// internal, exactly as they do for the DoT listener and TCP/53.
pub const DohListenerSample = struct {
    connections: u64,
    tls_handshake_failures: u64,
    idle_timeouts: u64,
    connection_errors: u64,
    bad_requests: u64,
};

/// One upstream, with every string owned by the caller's arena.
pub const UpstreamSample = struct {
    /// The configured url, whole. It reaches the exposition only through
    /// `writeUrlLabel`, which redacts it; a reader of this field is reading a
    /// credential.
    url: []const u8,
    enabled: bool,
    available: bool,
    consecutive_failures: u64,
    total_successes: u64,
    total_failures: u64,
    success_rate: f32,
    in_flight: u32,
    slots: u32,
    queued_total: u64,
    queued_seconds_total: f64,
    reuse_recoveries_total: u64,
};

/// The diagnostics store, as one scrape sees it. Two gauges and a counter,
/// written out rather than reflected over a stats struct because they are not
/// all the same kind of number.
pub const DiagnosticsSample = struct {
    active_warnings: u32,
    active_errors: u32,
    write_failures: u64,
};

/// Everything one scrape reports. A null section is a collaborator the state
/// does not have.
pub const Sample = struct {
    dns: DnsCounters = @splat(0),
    logger: LoggerCounters = .{},
    log_sink: logging.Stats = .{},
    cache: ?CacheSample = null,
    limiter: ?LimiterSample = null,
    tracker: ?TrackerSample = null,
    client_names: ?client_names.Resolver.Stats = null,
    retention: ?retention_mod.Stats = null,
    /// The diagnostics store's open episodes and its failed writes. Absent
    /// while no store is wired, like every other collaborator — an operator
    /// distinguishes "no series" from "zero episodes" through `/api/health`,
    /// which says which of the two it is.
    diagnostics: ?DiagnosticsSample = null,
    blocklist: ?BlocklistSample = null,
    disk: ?DiskSample = null,
    /// One entry per enabled TLS endpoint (milestone-10 ruling 10). Rendered
    /// under an `endpoint` label so both share the two `nxdns_cert_*`
    /// families. `last_reload_unix` is deliberately not exported: the reload
    /// endpoint reports cert state on demand.
    doh_certs: ?cert_store.CertStore.Stats = null,
    dot_certs: ?cert_store.CertStore.Stats = null,
    /// The listener families (ruling 10): absent while an endpoint is
    /// disabled or its bind failed, like every other unwired collaborator.
    doh_listener: ?DohListenerSample = null,
    dot_listener: ?dot_server.StatsSnapshot = null,
    /// The plain-DNS listener families (ruling 13). The app binds one listener
    /// per address family, and both answer the same port for the same reason,
    /// so their counters are summed into one family rather than labelled: an
    /// operator asks how much UDP/53 dropped, not how much of it arrived over
    /// IPv6. Absent when no listener is wired, like every other collaborator.
    udp_listener: ?udp_server.Snapshot = null,
    tcp_listener: ?tcp_server.Snapshot = null,
    upstreams: []const UpstreamSample = &.{},
    /// Exchanges the pool gave up on because the request's own budget ran out.
    /// Pool-wide rather than per-upstream on purpose: budget exhaustion is
    /// evidence about the pool, never about an endpoint, so it carries no url
    /// label and cannot live in `UpstreamSample`. Absent while no pool is
    /// wired, like every other collaborator.
    upstream_budget_exhausted_total: ?u64 = null,
};

pub fn handle(
    state: *server.WebState,
    io: std.Io,
    request: *http_util.Request,
) http_util.HandlerError!void {
    const sample = try collect(state, io, request.arena);

    var allocating: std.Io.Writer.Allocating = .init(request.arena);
    defer allocating.deinit();
    render(&allocating.writer, sample) catch return error.OutOfMemory;

    return http_util.respondBytes(request, .ok, allocating.written(), content_type, &.{});
}

pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.Error!Sample {
    var sample: Sample = .{ .log_sink = logging.stats() };

    if (state.handler) |handler| {
        sample.dns = dnsCounters(&handler.stats);

        // The pointer is loaded under the same mutex a query loads it under,
        // never before it: a live `cache.size` or `dns.rate_limit` change
        // frees the object it displaced as soon as the swap returns.
        {
            handler.cache_mutex.lockUncancelable(io);
            defer handler.cache_mutex.unlock(io);
            if (handler.cache) |cache| sample.cache = .{
                .stats = cache.stats,
                .entries = cache.len(),
                .memory_bytes = cache.memoryBytes(),
            };
        }

        {
            handler.limiter_mutex.lockUncancelable(io);
            defer handler.limiter_mutex.unlock(io);
            if (handler.limiter) |limiter| {
                sample.limiter = .{ .stats = limiter.stats, .tracked_clients = limiter.table.count() };
            }
        }
    }

    if (state.logger) |controller| {
        const reading = controller.sample(io);
        sample.logger = .{
            .queries_dropped = reading.queries_dropped,
            .rows_written = reading.rows_written,
            .batches_gated = reading.batches_gated,
        };
    }

    if (state.tracker) |tracker| sample.tracker = .{
        .stats = tracker.snapshotStats(io),
        .pending_clients = tracker.pendingClients(io),
    };

    if (state.client_names) |names| sample.client_names = names.snapshotStats(io);

    if (state.retention) |retention| sample.retention = retention.snapshotStats();

    if (state.events) |store| {
        const counts = store.activeCounts(io);
        sample.diagnostics = .{
            .active_warnings = counts.warnings,
            .active_errors = counts.errors,
            .write_failures = store.writeFailures(),
        };
    }

    if (state.manager) |manager| {
        const generation: ?u64 = if (manager.acquire(io)) |acquired| gen: {
            defer acquired.release(io);
            break :gen acquired.snapshot.generation;
        } else null;
        sample.blocklist = .{ .refreshes_gated = manager.refreshesGated(), .generation = generation };
    }

    if (state.monitor) |monitor| sample.disk = .{
        .gauges = monitor.gauges(),
        .sample_failures = monitor.sample_failures.load(.monotonic),
    };

    if (state.doh_certs) |store| sample.doh_certs = store.snapshotStats();
    if (state.dot_certs) |store| sample.dot_certs = store.snapshotStats();

    if (state.doh_listener) |listener| {
        const snapshot = listener.snapshotStats();
        sample.doh_listener = .{
            .connections = snapshot.connections,
            .tls_handshake_failures = snapshot.tls_handshake_failures,
            .idle_timeouts = snapshot.idle_timeouts,
            .connection_errors = snapshot.connection_errors,
            .bad_requests = snapshot.bad_requests,
        };
    }
    if (state.dot_listener) |listener| sample.dot_listener = listener.snapshotStats();

    sample.udp_listener = sumListeners(udp_server.Snapshot, udp_server.UdpServer, state.udp_listeners);
    sample.tcp_listener = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, state.tcp_listeners);

    if (state.upstreams) |owner| {
        const generation = owner.acquire(io);
        defer owner.release(io, generation);
        if (generation.pool) |pool| {
            sample.upstreams = try upstreams(pool, io, arena);
            sample.upstream_budget_exhausted_total = pool.budgetExhaustedTotal();
        }
    }

    return sample;
}

/// Adds one snapshot per listener field by field. Null for an empty slice, so
/// an unbound listener omits its family rather than reporting zeros.
///
/// A `u64` counter cannot realistically overflow the sum of four of them, and
/// wrapping addition would be a worse answer than a wrong-looking large one, so
/// the addition is the ordinary checked one.
fn sumListeners(comptime Snapshot: type, comptime Server: type, listeners: []const *Server) ?Snapshot {
    if (listeners.len == 0) return null;

    var total: Snapshot = undefined;
    inline for (@typeInfo(Snapshot).@"struct".fields) |field| {
        @field(total, field.name) = 0;
    }
    for (listeners) |listener| {
        const one = listener.snapshotStats();
        inline for (@typeInfo(Snapshot).@"struct".fields) |field| {
            @field(total, field.name) += @field(one, field.name);
        }
    }
    return total;
}

fn dnsCounters(stats: *const dns_handler.Handler.Stats) DnsCounters {
    var out: DnsCounters = undefined;
    inline for (dns_stat_fields, 0..) |field, i| {
        out[i] = @field(stats, field.name).load(.monotonic);
    }
    return out;
}

/// Copies pool health into `out` and returns the count.
///
/// `Pool.snapshot` takes the pool's mutex and copies structs, so it blocks only
/// on other snapshots. Cancellation is held off for the length of that copy:
/// the alternative is a report of no upstreams at all because the connection
/// happened to be closing, which reads as an outage. Shared with the health
/// rollup, which needs the same copy under the same reasoning.
pub fn poolSnapshot(pool: *pool_mod.Pool, io: std.Io, out: []pool_mod.Snapshot) usize {
    const prev = io.swapCancelProtection(.blocked);
    defer _ = io.swapCancelProtection(prev);
    return pool.snapshot(io, out) catch |err| switch (err) {
        error.Canceled => unreachable,
    };
}

fn upstreams(pool: *pool_mod.Pool, io: std.Io, arena: Allocator) Allocator.Error![]const UpstreamSample {
    var raw: [max_upstreams]pool_mod.Snapshot = undefined;
    const count = poolSnapshot(pool, io, &raw);

    const out = try arena.alloc(UpstreamSample, count);
    for (raw[0..count], out) |entry, *slot| {
        slot.* = .{
            .url = try arena.dupe(u8, entry.url),
            .enabled = entry.enabled,
            .available = entry.available,
            .consecutive_failures = entry.consecutive_failures,
            .total_successes = entry.total_successes,
            .total_failures = entry.total_failures,
            .success_rate = entry.success_rate,
            .in_flight = entry.in_flight,
            .slots = entry.slots,
            .queued_total = entry.queued_total,
            .queued_seconds_total = entry.queued_seconds_total,
            .reuse_recoveries_total = entry.reuse_recoveries_total,
        };
    }
    return out;
}

// ---------------------------------------------------------------------------
// rendering
// ---------------------------------------------------------------------------

pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
    try gauge(w, "nxdns_up", "1 while the nxdns process is answering scrapes.", 1);

    inline for (dns_stat_fields, sample.dns) |field, value| {
        try counter(
            w,
            "nxdns_dns_" ++ field.name ++ "_total",
            "DNS pipeline counter: " ++ field.name ++ ".",
            value,
        );
    }

    try counterGroup(w, "nxdns_querylog_", "Query log writer counter", sample.logger);
    try counterGroup(w, "nxdns_log_", "Diagnostic log sink counter", sample.log_sink);

    if (sample.cache) |cache| {
        try counterGroup(w, "nxdns_cache_", "DNS cache counter", cache.stats);
        try gauge(w, "nxdns_cache_entries", "Responses currently held in the DNS cache.", cache.entries);
        try gauge(w, "nxdns_cache_memory_bytes", "Bytes held by the DNS cache.", cache.memory_bytes);
    }

    if (sample.limiter) |limiter| {
        try counterGroup(w, "nxdns_dns_rate_limit_", "DNS rate limiter counter", limiter.stats);
        try gauge(
            w,
            "nxdns_dns_rate_limit_tracked_clients",
            "Client addresses the DNS rate limiter is tracking.",
            limiter.tracked_clients,
        );
    }

    if (sample.tracker) |tracker| {
        try counterGroup(w, "nxdns_clients_", "Client tracker counter", tracker.stats);
        try gauge(
            w,
            "nxdns_clients_pending",
            "Clients seen but not yet written to the database.",
            tracker.pending_clients,
        );
    }

    if (sample.client_names) |names| {
        try counterGroup(w, "nxdns_client_names_", "Learned client name counter", names);
    }

    if (sample.retention) |retention| {
        try counterGroup(w, "nxdns_retention_", "Query log retention counter", retention);
    }

    if (sample.diagnostics) |diagnostics| {
        try gauge(
            w,
            "nxdns_diagnostics_active_warnings",
            "Operational event episodes open right now at warning severity.",
            diagnostics.active_warnings,
        );
        try gauge(
            w,
            "nxdns_diagnostics_active_errors",
            "Operational event episodes open right now at error severity.",
            diagnostics.active_errors,
        );
        try counter(
            w,
            "nxdns_diagnostics_write_failures_total",
            "Operational events dropped because the diagnostics database refused the write.",
            diagnostics.write_failures,
        );
    }

    if (sample.blocklist) |blocklist| {
        try counter(
            w,
            "nxdns_blocklist_refreshes_gated_total",
            "Scheduled blocklist refreshes skipped because the disk was low.",
            blocklist.refreshes_gated,
        );
        if (blocklist.generation) |generation| {
            try gauge(
                w,
                "nxdns_blocklist_generation",
                "Generation of the filter snapshot currently answering queries.",
                generation,
            );
        }
    }

    if (sample.disk) |disk| {
        try gauge(w, "nxdns_disk_free_bytes", "Free bytes on the data filesystem.", disk.gauges.free_bytes);
        try gauge(w, "nxdns_disk_db_bytes", "Bytes held by the databases.", disk.gauges.db_bytes);
        try gauge(w, "nxdns_disk_log_bytes", "Bytes held by the log files.", disk.gauges.log_bytes);
        try counter(
            w,
            "nxdns_disk_sample_failures_total",
            "Disk measurements that failed.",
            disk.sample_failures,
        );
    }

    if (sample.udp_listener) |listener| {
        try counterGroup(w, "nxdns_udp_server_", "UDP/53 listener counter", listener);
    }
    if (sample.tcp_listener) |listener| {
        try counterGroup(w, "nxdns_tcp_server_", "TCP/53 listener counter", listener);
    }
    if (sample.doh_listener) |listener| {
        try counterGroup(w, "nxdns_doh_server_", "DoH listener counter", listener);
    }
    if (sample.dot_listener) |listener| {
        try counterGroup(w, "nxdns_dot_server_", "DoT listener counter", listener);
    }

    if (sample.doh_certs != null or sample.dot_certs != null) try renderCerts(w, sample);

    if (sample.upstreams.len != 0) try renderUpstreams(w, sample.upstreams);
    if (sample.upstream_budget_exhausted_total) |total| {
        try labeledHead(
            w,
            "nxdns_upstream_budget_exhausted_total",
            "Exchanges that ran out of their own total budget before any upstream answered.",
            "counter",
        );
        try w.print("nxdns_upstream_budget_exhausted_total {d}\n", .{total});
    }
}

fn renderCerts(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
    try labeledHead(w, "nxdns_cert_reloads_total", "Certificate reloads that published a new context.", "counter");
    if (sample.doh_certs) |stats| try endpointValue(w, "nxdns_cert_reloads_total", "doh", stats.reloads);
    if (sample.dot_certs) |stats| try endpointValue(w, "nxdns_cert_reloads_total", "dot", stats.reloads);

    try labeledHead(
        w,
        "nxdns_cert_reload_failures_total",
        "Certificate reloads that failed; the old certificate keeps serving.",
        "counter",
    );
    if (sample.doh_certs) |stats| {
        try endpointValue(w, "nxdns_cert_reload_failures_total", "doh", stats.reload_failures);
    }
    if (sample.dot_certs) |stats| {
        try endpointValue(w, "nxdns_cert_reload_failures_total", "dot", stats.reload_failures);
    }
}

/// The endpoint names are ours ("doh"/"dot"), so unlike a url label there is
/// nothing to escape.
fn endpointValue(
    w: *std.Io.Writer,
    name: []const u8,
    endpoint: []const u8,
    value: u64,
) std.Io.Writer.Error!void {
    try w.print("{s}{{endpoint=\"{s}\"}} {d}\n", .{ name, endpoint, value });
}

fn renderUpstreams(w: *std.Io.Writer, list: []const UpstreamSample) std.Io.Writer.Error!void {
    try labeledHead(w, "nxdns_upstream_up", "1 while an upstream is enabled and healthy.", "gauge");
    for (list, 0..) |entry, i| {
        try labeledValue(w, "nxdns_upstream_up", i, entry.url, @intFromBool(entry.available));
    }

    try labeledHead(w, "nxdns_upstream_enabled", "1 while an upstream is enabled by configuration.", "gauge");
    for (list, 0..) |entry, i| {
        try labeledValue(w, "nxdns_upstream_enabled", i, entry.url, @intFromBool(entry.enabled));
    }

    try labeledHead(w, "nxdns_upstream_success_rate", "Share of recent exchanges that succeeded.", "gauge");
    for (list, 0..) |entry, i| {
        try writeUpstreamLabels(w, "nxdns_upstream_success_rate", i, entry.url);
        try w.print(" {d:.4}\n", .{entry.success_rate});
    }

    try labeledHead(
        w,
        "nxdns_upstream_consecutive_failures",
        "Failures since an upstream last answered.",
        "gauge",
    );
    for (list, 0..) |entry, i| {
        try labeledValue(w, "nxdns_upstream_consecutive_failures", i, entry.url, entry.consecutive_failures);
    }

    try labeledHead(w, "nxdns_upstream_successes_total", "Exchanges an upstream answered.", "counter");
    for (list, 0..) |entry, i| {
        try labeledValue(w, "nxdns_upstream_successes_total", i, entry.url, entry.total_successes);
    }

    try labeledHead(w, "nxdns_upstream_failures_total", "Exchanges an upstream failed.", "counter");
    for (list, 0..) |entry, i| {
        try labeledValue(w, "nxdns_upstream_failures_total", i, entry.url, entry.total_failures);
    }

    try labeledHead(w, "nxdns_upstream_in_flight", "Exchanges in flight against an upstream.", "gauge");
    for (list, 0..) |entry, i| {
        try labeledValue(w, "nxdns_upstream_in_flight", i, entry.url, entry.in_flight);
    }

    try labeledHead(w, "nxdns_upstream_slots", "Concurrent exchanges an upstream allows.", "gauge");
    for (list, 0..) |entry, i| {
        try labeledValue(w, "nxdns_upstream_slots", i, entry.url, entry.slots);
    }

    try labeledHead(
        w,
        "nxdns_upstream_queued_total",
        "Exchanges that waited for a slot. Approximate; sampled at admission.",
        "counter",
    );
    for (list, 0..) |entry, i| {
        try labeledValue(w, "nxdns_upstream_queued_total", i, entry.url, entry.queued_total);
    }

    try labeledHead(
        w,
        "nxdns_upstream_queued_seconds_total",
        "Seconds exchanges spent waiting for a slot. Approximate; sampled at admission.",
        "counter",
    );
    for (list, 0..) |entry, i| {
        try writeUpstreamLabels(w, "nxdns_upstream_queued_seconds_total", i, entry.url);
        try w.print(" {d:.4}\n", .{entry.queued_seconds_total});
    }

    try labeledHead(
        w,
        "nxdns_upstream_reuse_recoveries_total",
        "Stale reused DoT connections recovered by a redial.",
        "counter",
    );
    for (list, 0..) |entry, i| {
        try labeledValue(w, "nxdns_upstream_reuse_recoveries_total", i, entry.url, entry.reuse_recoveries_total);
    }
}

/// Every field of a plain counter struct, under one prefix.
fn counterGroup(
    w: *std.Io.Writer,
    comptime prefix: []const u8,
    comptime help: []const u8,
    value: anytype,
) std.Io.Writer.Error!void {
    inline for (@typeInfo(@TypeOf(value)).@"struct".fields) |field| {
        try counter(w, prefix ++ field.name ++ "_total", help ++ ": " ++ field.name ++ ".", @field(value, field.name));
    }
}

fn counter(w: *std.Io.Writer, name: []const u8, help: []const u8, value: u64) std.Io.Writer.Error!void {
    try w.print("# HELP {s} {s}\n# TYPE {s} counter\n{s} {d}\n", .{ name, help, name, name, value });
}

fn gauge(w: *std.Io.Writer, name: []const u8, help: []const u8, value: u64) std.Io.Writer.Error!void {
    try w.print("# HELP {s} {s}\n# TYPE {s} gauge\n{s} {d}\n", .{ name, help, name, name, value });
}

fn labeledHead(
    w: *std.Io.Writer,
    name: []const u8,
    help: []const u8,
    kind: []const u8,
) std.Io.Writer.Error!void {
    try w.print("# HELP {s} {s}\n# TYPE {s} {s}\n", .{ name, help, name, kind });
}

fn labeledValue(
    w: *std.Io.Writer,
    name: []const u8,
    index: usize,
    url: []const u8,
    value: u64,
) std.Io.Writer.Error!void {
    try writeUpstreamLabels(w, name, index, url);
    try w.print(" {d}\n", .{value});
}

/// The label set every upstream family shares, up to and including the closing
/// brace. One definition, because eleven families have to agree on it exactly:
/// Prometheus identifies a series by its name and its whole label set, so a
/// family that labelled its samples differently would be a different series.
///
/// `index` is the upstream's position in the pool, in the priority order `Pool`
/// sorts on. It is here because the url alone stopped identifying a series once
/// it was redacted: two upstreams on one host — the shape a NextDNS account with
/// two profiles takes — both print `https://dns.nextdns.io`, and two samples of
/// one name with one label set is a duplicate series a scrape must not contain.
/// The position is read from the rendered slice rather than carried in
/// `UpstreamSample`, so no caller can build two samples that claim one index.
///
/// What the index is not: a durable key, and the difference is an operator's to
/// know. `Pool.Snapshot` carries no row id — threading one out of the repository
/// through the pool to reach here is a larger change than the defect warrants —
/// so the position is all there is. Removing `upstreams[0]` renumbers every
/// upstream after it, and one upstream's history then continues under the label
/// its neighbour used to carry.
///
/// What bounds that: the index is only load-bearing when two upstreams share an
/// origin, which is the case it was added for. Where origins differ, `url`
/// carries the identity on its own and a reorder moves nothing that a query
/// grouping on `url` can see. So group on `url`, and read `index` as the
/// disambiguator between upstreams that group would otherwise merge.
fn writeUpstreamLabels(
    w: *std.Io.Writer,
    name: []const u8,
    index: usize,
    url: []const u8,
) std.Io.Writer.Error!void {
    try w.print("{s}{{index=\"{d}\",url=\"", .{ name, index });
    try writeUrlLabel(w, url);
    try w.writeAll("\"}");
}

/// The one place an upstream url becomes exposition text.
///
/// `/metrics` is `.auth = .open` in `web/routes.zig` and `web.bind` defaults to
/// `0.0.0.0`, so a url in a label is readable by anything on the LAN without a
/// session, and a Prometheus that scrapes it keeps that string for as long as it
/// keeps the series. A NextDNS DoH upstream is `https://dns.nextdns.io/abcd12`,
/// where the path segment is the whole account identifier, so the label prints
/// what `safe_url.redact` leaves: the scheme, the host and the port.
///
/// The redaction sits here rather than in `collect` because this is where the
/// open endpoint writes the value. A `Sample` built anywhere else renders
/// through this function too, so the guarantee cannot be one caller away.
/// `UpstreamSample.url` stays whole for the same reason it is safe to: nothing
/// but this function reads it, and the session-authenticated
/// `GET /api/upstreams` reports the same urls whole.
///
/// **`redact` output is not safe to interpolate into a label value, and this
/// function is the reason it never has to be.** Do not delete the second layer
/// on the grounds that the first one escapes.
///
/// What `redact` gives: it escapes every control character and bounds its
/// output, so it emits neither a raw newline nor a lone backslash. What it does
/// not give: it leaves `"` alone. A `"` is legal in the text `redact` keeps, and
/// it is the one character that ends a label value — so a host holding one
/// closes the label and lets the rest of the string write label pairs of its
/// own. That is a defect of this call site, not of `redact`: a `"` needs no
/// escape in a log line, which is what `redact` was written for.
///
/// So `writeLabelValue` runs over the redacted text rather than instead of it,
/// and the order is the whole point. It also doubles a backslash `redact` wrote,
/// which is what keeps `\n` in a host from reading as a newline to a parser: the
/// four bytes `\x1b` arrive at a scrape as `\\x1b`.
///
/// A label value's escapes are not a shell's. `quoteText`, which the log lines
/// use, answers a different question — its delimiter is `'` and it writes its
/// own quotes — and it is not the tool here.
fn writeUrlLabel(w: *std.Io.Writer, url: []const u8) std.Io.Writer.Error!void {
    // `SafeUrl.format` prints at most `max_len` characters, plus the `...` that
    // marks a truncation. The buffer is that bound, so the write cannot fail.
    var buf: [safe_url.max_len + 3]u8 = undefined;
    var redacted: std.Io.Writer = .fixed(&buf);
    try redacted.print("{f}", .{safe_url.redact(url)});
    try writeLabelValue(w, redacted.buffered());
}

/// The three characters the exposition format reserves inside a label value.
fn writeLabelValue(w: *std.Io.Writer, value: []const u8) std.Io.Writer.Error!void {
    for (value) |byte| switch (byte) {
        '\\' => try w.writeAll("\\\\"),
        '"' => try w.writeAll("\\\""),
        '\n' => try w.writeAll("\\n"),
        else => try w.writeByte(byte),
    };
}

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

const db = @import("../storage/db.zig");
const local_tables = @import("../server/local_tables.zig");
const logger_controller = @import("../storage/logger_controller.zig");
const logger_mod = @import("../storage/logger.zig");
const migrations = @import("../storage/migrations.zig");
const transport = @import("../upstream/transport.zig");
const testing = std.testing;

/// Backs `testHandler`'s owner. File-scope because the handler is returned by
/// value and its `*Owner` has to outlive the return; nothing here exchanges, so
/// the generation behind it is never acquired.
var unreachable_upstream: upstream_owner.Borrowed = .{};

/// A handler with no upstream reachable: every test here reads counters and
/// never runs a query.
fn testHandler() dns_handler.Handler {
    return .{
        .upstream = unreachable_upstream.client(.{ .ptr = undefined, .exchangeFn = undefined }),
        .policy = .{ .blocking = .{ .mode = .zero, .ttl = 5 }, .forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake } },
    };
}

fn renderToString(gpa: Allocator, sample: Sample) ![]u8 {
    var allocating: std.Io.Writer.Allocating = .init(gpa);
    errdefer allocating.deinit();
    try render(&allocating.writer, sample);
    return allocating.toOwnedSlice();
}

test "a full sample renders the whole exposition, byte for byte" {
    var dns: DnsCounters = @splat(0);
    dns[0] = 12;
    dns[1] = 3;

    const upstream_list = [_]UpstreamSample{
        .{
            .url = "https://dns.example/dns-query",
            .enabled = true,
            .available = true,
            .consecutive_failures = 0,
            .total_successes = 9,
            .total_failures = 1,
            .success_rate = 0.9,
            .in_flight = 2,
            .slots = 8,
            .queued_total = 3,
            .queued_seconds_total = 0.25,
            .reuse_recoveries_total = 1,
        },
    };

    const sample: Sample = .{
        .dns = dns,
        .logger = .{ .queries_dropped = 1, .rows_written = 40, .batches_gated = 2 },
        .log_sink = .{ .lines_written = 5, .lines_deduped = 1, .rotations = 0, .sink_errors = 0 },
        .cache = .{
            .stats = .{ .hits = 7, .misses = 8, .inserts = 6, .evictions = 1, .expirations = 2, .invalid_hits = 0 },
            .entries = 5,
            .memory_bytes = 4096,
        },
        .limiter = .{ .stats = .{ .allowed = 20, .refused = 2, .untracked = 1 }, .tracked_clients = 3 },
        .tracker = .{
            .stats = .{ .tracked = 4, .flushed = 3, .dropped_full = 0, .pruned = 1, .flush_failures = 0 },
            .pending_clients = 2,
        },
        .client_names = .{ .attempted = 6, .answered = 3, .nxdomain = 1, .no_zone = 2, .invalid = 0, .failed = 0, .read_failures = 0, .write_failures = 1 },
        .retention = .{ .passes = 7, .rows_pruned = 100, .checkpoints = 7, .vacuums = 1 },
        .blocklist = .{ .refreshes_gated = 2, .generation = 4 },
        .disk = .{
            .gauges = .{ .free_bytes = 1000, .db_bytes = 200, .log_bytes = 30 },
            .sample_failures = 1,
        },
        .upstreams = &upstream_list,
    };

    const text = try renderToString(testing.allocator, sample);
    defer testing.allocator.free(text);

    // Every family, in the order `render` writes them. The golden text is the
    // contract a scrape reads; a counter that changes name changes this test.
    try testing.expectEqualStrings(
        \\# HELP nxdns_up 1 while the nxdns process is answering scrapes.
        \\# TYPE nxdns_up gauge
        \\nxdns_up 1
        \\# HELP nxdns_dns_queries_total DNS pipeline counter: queries.
        \\# TYPE nxdns_dns_queries_total counter
        \\nxdns_dns_queries_total 12
        \\# HELP nxdns_dns_dropped_malformed_total DNS pipeline counter: dropped_malformed.
        \\# TYPE nxdns_dns_dropped_malformed_total counter
        \\nxdns_dns_dropped_malformed_total 3
        \\
    , text[0..std.mem.indexOf(u8, text, "# HELP nxdns_dns_formerr_total").?]);

    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_querylog_queries_dropped_total 1\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_log_lines_written_total 5\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_hits_total 7\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_entries 5\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_memory_bytes 4096\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_rate_limit_refused_total 2\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_rate_limit_tracked_clients 3\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_clients_dropped_full_total 0\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_clients_pending 2\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_client_names_no_zone_total 2\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_client_names_answered_total 3\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_client_names_write_failures_total 1\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_retention_rows_pruned_total 100\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_blocklist_refreshes_gated_total 2\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_blocklist_generation 4\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_disk_free_bytes 1000\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_disk_sample_failures_total 1\n"));
    // The label set carries the redaction, so the path of the configured url is
    // already gone from the golden text.
    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_up{index=\"0\",url=\"https://dns.example\"} 1\n",
    ));
    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_success_rate{index=\"0\",url=\"https://dns.example\"} 0.9000\n",
    ));
    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_failures_total{index=\"0\",url=\"https://dns.example\"} 1\n",
    ));
    try testing.expect(std.mem.endsWith(
        u8,
        text,
        "nxdns_upstream_reuse_recoveries_total{index=\"0\",url=\"https://dns.example\"} 1\n",
    ));
}

test "the diagnostics family renders two gauges and one counter" {
    const text = try renderToString(testing.allocator, .{
        .diagnostics = .{ .active_warnings = 3, .active_errors = 1, .write_failures = 7 },
    });
    defer testing.allocator.free(text);

    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "# TYPE nxdns_diagnostics_active_warnings gauge\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_diagnostics_active_warnings 3\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "# TYPE nxdns_diagnostics_active_errors gauge\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_diagnostics_active_errors 1\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "# TYPE nxdns_diagnostics_write_failures_total counter\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_diagnostics_write_failures_total 7\n"));

    // No store is an absent family, not a family of zeros: "no episodes open"
    // and "nothing is recording them" must not render the same.
    const bare = try renderToString(testing.allocator, .{});
    defer testing.allocator.free(bare);
    try testing.expect(!std.mem.containsAtLeast(u8, bare, 1, "nxdns_diagnostics_"));
}

test "collect reads the diagnostics store's open episodes and failed writes" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();
    try db.applyPragmas(&database, .{});
    _ = try migrations.migrate(&database);
    var store = try events_mod.Store.init(io, &database, 1000);
    store.report(io, 1000, .disk_space, "data", "data", .warning, "low");

    var state: server.WebState = .{ .gpa = testing.allocator, .events = &store };
    var arena: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena.deinit();

    const sample = try collect(&state, io, arena.allocator());
    try testing.expectEqual(@as(u32, 1), sample.diagnostics.?.active_warnings);
    try testing.expectEqual(@as(u32, 0), sample.diagnostics.?.active_errors);
    try testing.expectEqual(@as(u64, 0), sample.diagnostics.?.write_failures);

    // A write that cannot land is counted here, through the production path
    // rather than by poking the field.
    try database.exec("DROP TABLE operational_events;");
    store.report(io, 1100, .disk_space, "data", "data", .warning, "low");
    const after = try collect(&state, io, arena.allocator());
    try testing.expect(after.diagnostics.?.write_failures >= 1);
}

test "every HELP line has a TYPE line and a sample, and every sample a name" {
    const text = try renderToString(testing.allocator, .{});
    defer testing.allocator.free(text);

    var helps: usize = 0;
    var types: usize = 0;
    var samples: usize = 0;
    var lines = std.mem.splitScalar(u8, text, '\n');
    while (lines.next()) |line| {
        if (line.len == 0) continue;
        if (std.mem.startsWith(u8, line, "# HELP ")) {
            helps += 1;
        } else if (std.mem.startsWith(u8, line, "# TYPE ")) {
            types += 1;
        } else {
            samples += 1;
            try testing.expect(std.mem.startsWith(u8, line, "nxdns_"));
        }
    }
    try testing.expectEqual(helps, types);
    try testing.expectEqual(helps, samples);
    // The families a bare state still has: `nxdns_up`, every DNS counter, the
    // query log writer's, and the diagnostic log sink's. Derived rather than
    // counted, so a new counter in any of those structs extends the exposition
    // and this assertion together.
    try testing.expectEqual(
        1 + dns_stat_fields.len +
            @typeInfo(LoggerCounters).@"struct".fields.len +
            @typeInfo(logging.Stats).@"struct".fields.len,
        samples,
    );
    // The reflective walk names the counters, so a renamed `Handler.Stats`
    // field silently renames a scraped series. Pin the ones an operator alerts
    // on by name.
    for ([_][]const u8{
        "nxdns_dns_queries_total",
        "nxdns_dns_formerr_total",
        "nxdns_dns_notimp_total",
        "nxdns_dns_badvers_total",
        "nxdns_dns_servfail_total",
        "nxdns_dns_refused_total",
        "nxdns_dns_blocked_total",
    }) |metric| {
        var line_buf: [128]u8 = undefined;
        const line = try std.fmt.bufPrint(&line_buf, "# TYPE {s} counter\n", .{metric});
        try testing.expect(std.mem.containsAtLeast(u8, text, 1, line));
    }
}

test "an unwired collaborator omits its family rather than reporting zeros" {
    const text = try renderToString(testing.allocator, .{});
    defer testing.allocator.free(text);

    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_up 1\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_queries_total 0\n"));
    try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_"));
    try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_disk_"));
    try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_"));
    try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_blocklist_"));
    try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_cert_"));
    try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_doh_server_"));
    try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_dot_server_"));
    try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_"));
    try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_"));
}

test "the plain-DNS listener families carry every counter of both listeners" {
    const text = try renderToString(testing.allocator, .{
        .udp_listener = .{
            .received = 90,
            .dropped_oversize = 1,
            .dropped_no_slot = 2,
            .dropped_handler = 3,
            .receive_errors = 4,
            .send_errors = 5,
        },
        .tcp_listener = .{
            .connections = 12,
            .rejected_at_capacity = 6,
            .rejected_at_shutdown = 7,
            .accept_errors = 8,
            .connection_errors = 9,
            .idle_timeouts = 10,
        },
    });
    defer testing.allocator.free(text);

    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_received_total 90\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_oversize_total 1\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_no_slot_total 2\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_handler_total 3\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_receive_errors_total 4\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_send_errors_total 5\n"));

    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_connections_total 12\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_capacity_total 6\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_shutdown_total 7\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_accept_errors_total 8\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_connection_errors_total 9\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_idle_timeouts_total 10\n"));

    // One family per listener kind, whatever the number of listeners behind it:
    // the counters are summed, not labelled.
    try testing.expectEqual(
        @as(usize, 1),
        std.mem.count(u8, text, "# TYPE nxdns_udp_server_received_total counter\n"),
    );
}

test "one family covers all four listeners, summed" {
    // Only `stats` is read, so the listeners need no socket: `snapshotStats`
    // loads counters and touches nothing else.
    var udp6: udp_server.UdpServer = undefined;
    udp6.stats = .{};
    udp6.stats.received.store(10, .monotonic);
    udp6.stats.dropped_no_slot.store(1, .monotonic);

    var udp4: udp_server.UdpServer = undefined;
    udp4.stats = .{};
    udp4.stats.received.store(7, .monotonic);
    udp4.stats.dropped_no_slot.store(2, .monotonic);

    var tcp6: tcp_server.TcpServer = undefined;
    tcp6.core.stats = .{};
    tcp6.core.stats.connections.store(4, .monotonic);

    var tcp4: tcp_server.TcpServer = undefined;
    tcp4.core.stats = .{};
    tcp4.core.stats.connections.store(5, .monotonic);
    tcp4.core.stats.idle_timeouts.store(3, .monotonic);

    const udp = sumListeners(udp_server.Snapshot, udp_server.UdpServer, &.{ &udp6, &udp4 }).?;
    try testing.expectEqual(@as(u64, 17), udp.received);
    try testing.expectEqual(@as(u64, 3), udp.dropped_no_slot);
    try testing.expectEqual(@as(u64, 0), udp.send_errors);

    const tcp = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, &.{ &tcp6, &tcp4 }).?;
    try testing.expectEqual(@as(u64, 9), tcp.connections);
    try testing.expectEqual(@as(u64, 3), tcp.idle_timeouts);

    // No listener at all is a missing family, not a family of zeros.
    try testing.expectEqual(
        @as(?udp_server.Snapshot, null),
        sumListeners(udp_server.Snapshot, udp_server.UdpServer, &.{}),
    );
}

test "the three forward-client counters reach the DNS families" {
    var dns: DnsCounters = @splat(0);
    dns[fieldIndex("forward_udp_truncated")] = 2;
    dns[fieldIndex("forward_foreign_datagrams")] = 3;
    dns[fieldIndex("forward_failures")] = 4;

    const text = try renderToString(testing.allocator, .{ .dns = dns });
    defer testing.allocator.free(text);

    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_forward_udp_truncated_total 2\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_forward_foreign_datagrams_total 3\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_forward_failures_total 4\n"));
}

test "listener counters render only for the wired servers" {
    const doh_only = try renderToString(testing.allocator, .{
        .doh_listener = .{
            .connections = 9,
            .tls_handshake_failures = 2,
            .idle_timeouts = 1,
            .connection_errors = 0,
            .bad_requests = 4,
        },
    });
    defer testing.allocator.free(doh_only);

    try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_connections_total 9\n"));
    try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_tls_handshake_failures_total 2\n"));
    try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_idle_timeouts_total 1\n"));
    try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_connection_errors_total 0\n"));
    try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_bad_requests_total 4\n"));
    try testing.expect(!std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_dot_server_"));

    const dot_only = try renderToString(testing.allocator, .{
        .dot_listener = .{
            .connections = 5,
            .tls_handshake_failures = 0,
            .idle_timeouts = 3,
            .connection_errors = 1,
        },
    });
    defer testing.allocator.free(dot_only);

    try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_connections_total 5\n"));
    try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_idle_timeouts_total 3\n"));
    try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_connection_errors_total 1\n"));
    // The DoT listener has no HTTP layer, so no bad_requests family.
    try testing.expect(!std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_bad_requests_total"));
    try testing.expect(!std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_doh_server_"));
}

test "cert reload counters render per endpoint, only for the wired stores" {
    const one = try renderToString(testing.allocator, .{
        .doh_certs = .{ .reloads = 2, .reload_failures = 1, .last_reload_unix = 1_700_000_000 },
    });
    defer testing.allocator.free(one);

    try testing.expect(std.mem.containsAtLeast(u8, one, 1, "nxdns_cert_reloads_total{endpoint=\"doh\"} 2\n"));
    try testing.expect(std.mem.containsAtLeast(u8, one, 1, "nxdns_cert_reload_failures_total{endpoint=\"doh\"} 1\n"));
    try testing.expect(!std.mem.containsAtLeast(u8, one, 1, "endpoint=\"dot\""));
    // The wall-clock second stays off the exposition.
    try testing.expect(!std.mem.containsAtLeast(u8, one, 1, "last_reload"));

    const both = try renderToString(testing.allocator, .{
        .doh_certs = .{ .reloads = 0, .reload_failures = 0, .last_reload_unix = 0 },
        .dot_certs = .{ .reloads = 3, .reload_failures = 0, .last_reload_unix = 0 },
    });
    defer testing.allocator.free(both);

    try testing.expect(std.mem.containsAtLeast(u8, both, 1, "nxdns_cert_reloads_total{endpoint=\"doh\"} 0\n"));
    try testing.expect(std.mem.containsAtLeast(u8, both, 1, "nxdns_cert_reloads_total{endpoint=\"dot\"} 3\n"));
    try testing.expect(std.mem.containsAtLeast(u8, both, 1, "nxdns_cert_reload_failures_total{endpoint=\"dot\"} 0\n"));
}

/// Reads a sample line's label set the way a scrape does and returns the number
/// of label pairs, or null if the line is not one a parser accepts.
///
/// A checker rather than a golden string, because the input that exercises it is
/// a url no parser accepts, and `safe_url.redact` is entitled to change what it
/// prints for one of those. What may not change is the property: an
/// operator-supplied byte must not close a label value early, end the line, or
/// leave an escape sequence behind that means something else to a parser. Only
/// the format's three escapes are accepted for that reason — a `\x1b` `redact`
/// wrote reaches here as `\\x1b`, whose backslash is escaped and whose `x1b` is
/// three ordinary characters.
fn labelPairs(line: []const u8) ?usize {
    var i = (std.mem.indexOfScalar(u8, line, '{') orelse return null) + 1;
    var pairs: usize = 0;
    while (true) {
        const eq = std.mem.indexOfScalarPos(u8, line, i, '=') orelse return null;
        if (eq == i) return null;
        for (line[i..eq]) |c| if (!std.ascii.isAlphanumeric(c) and c != '_') return null;
        if (eq + 1 >= line.len or line[eq + 1] != '"') return null;

        i = eq + 2;
        while (true) {
            if (i >= line.len) return null;
            if (line[i] == '"') break;
            if (line[i] != '\\') {
                i += 1;
                continue;
            }
            if (i + 1 >= line.len) return null;
            switch (line[i + 1]) {
                '\\', '"', 'n' => i += 2,
                else => return null,
            }
        }

        pairs += 1;
        i += 1;
        if (i >= line.len) return null;
        if (line[i] == '}') return pairs;
        if (line[i] != ',') return null;
        i += 1;
    }
}

/// `name{labels}` — what Prometheus identifies a series by. Null for a sample
/// that carries no label set.
fn seriesKey(line: []const u8) ?[]const u8 {
    const close = std.mem.lastIndexOfScalar(u8, line, '}') orelse return null;
    return line[0 .. close + 1];
}

test "a label value escapes the characters the format reserves" {
    // Every shape an operator-supplied url can take that reaches the label with
    // a character the format reserves. The assertion is the property, not the
    // text: these are urls no parser accepts, and `safe_url.redact` may change
    // what it prints for one of them without changing what this test protects.
    const hostile = [_][]const u8{
        "https://a\"b/dns-query",
        "https://a\nb/dns-query",
        "https://a\\b/dns-query",
        "https://a\x1bb/dns-query",
        "https://user:pa55@h\"ost/dns-query",
        "https://\"}{=,\"/dns-query",
        // The shape `safe_url.redact` is being hardened against in this same
        // wave: a `?` before the last `@`. What it prints is that fix's to
        // decide; that the label holds it safely is this one's.
        "https://lists.example?token=prefix@hunter2",
    };

    for (hostile) |url| {
        const upstream_list = [_]UpstreamSample{.{
            .url = url,
            .enabled = true,
            .available = false,
            .consecutive_failures = 2,
            .total_successes = 0,
            .total_failures = 2,
            .success_rate = 0,
            .in_flight = 1,
            .slots = 8,
            .queued_total = 4,
            .queued_seconds_total = 1.5,
            .reuse_recoveries_total = 2,
        }};
        const text = try renderToString(testing.allocator, .{ .upstreams = &upstream_list });
        defer testing.allocator.free(text);

        // The url wrote no line of its own, and lost none: every line is a
        // comment or a sample, and the eleven families contribute eleven
        // samples.
        var samples: usize = 0;
        var lines = std.mem.splitScalar(u8, text, '\n');
        while (lines.next()) |line| {
            if (line.len == 0 or std.mem.startsWith(u8, line, "# ")) continue;
            try testing.expect(std.mem.startsWith(u8, line, "nxdns_"));
            if (!std.mem.startsWith(u8, line, "nxdns_upstream_")) continue;
            samples += 1;
            // Both labels are there, and both values close where they opened.
            try testing.expectEqual(@as(?usize, 2), labelPairs(line));
        }
        try testing.expectEqual(@as(usize, 11), samples);
    }
}

test "two upstreams on one host stay two series" {
    // Redaction costs the url the job of telling two upstreams apart: a NextDNS
    // account with two profiles is two urls on one host, and both print
    // `https://dns.nextdns.io`. Two samples of one name with one label set is a
    // duplicate series, which is a broken scrape rather than a hidden one.
    const upstream_list = [_]UpstreamSample{
        .{
            .url = "https://dns.nextdns.io/abcd12",
            .enabled = true,
            .available = true,
            .consecutive_failures = 0,
            .total_successes = 5,
            .total_failures = 0,
            .success_rate = 1,
            .in_flight = 0,
            .slots = 8,
            .queued_total = 0,
            .queued_seconds_total = 0,
            .reuse_recoveries_total = 0,
        },
        .{
            .url = "https://dns.nextdns.io/efgh34",
            .enabled = true,
            .available = false,
            .consecutive_failures = 3,
            .total_successes = 9,
            .total_failures = 3,
            .success_rate = 0.75,
            .in_flight = 3,
            .slots = 8,
            .queued_total = 7,
            .queued_seconds_total = 2,
            .reuse_recoveries_total = 5,
        },
    };
    const text = try renderToString(testing.allocator, .{ .upstreams = &upstream_list });
    defer testing.allocator.free(text);

    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_up{index=\"0\",url=\"https://dns.nextdns.io\"} 1\n",
    ));
    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_up{index=\"1\",url=\"https://dns.nextdns.io\"} 0\n",
    ));
    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_successes_total{index=\"1\",url=\"https://dns.nextdns.io\"} 9\n",
    ));

    // Distinguishable, not merely present. `nxdns_upstream_up` is the family
    // this matters most in: one of these two upstreams is down and the other is
    // up, and a reader has to be able to see which. Under one shared label set
    // the two samples say 1 and 0 of the same series, so a scrape either reports
    // whichever it read last or rejects the pair — and the down upstream is
    // invisible either way, on the endpoint an operator watches to find out.
    var up_keys: [2][]const u8 = undefined;
    var up_values: [2][]const u8 = undefined;
    var found: usize = 0;
    var up_lines = std.mem.splitScalar(u8, text, '\n');
    while (up_lines.next()) |line| {
        if (!std.mem.startsWith(u8, line, "nxdns_upstream_up{")) continue;
        try testing.expect(found < up_keys.len);
        const key = seriesKey(line).?;
        up_keys[found] = key;
        up_values[found] = line[key.len + 1 ..];
        found += 1;
    }
    try testing.expectEqual(@as(usize, 2), found);
    try testing.expect(!std.mem.eql(u8, up_keys[0], up_keys[1]));
    try testing.expectEqualStrings("1", up_values[0]);
    try testing.expectEqualStrings("0", up_values[1]);

    // No two samples in the scrape share a series key, whatever the urls were.
    var keys: [64][]const u8 = undefined;
    var count: usize = 0;
    var lines = std.mem.splitScalar(u8, text, '\n');
    while (lines.next()) |line| {
        if (line.len == 0 or std.mem.startsWith(u8, line, "# ")) continue;
        const key = seriesKey(line) orelse continue;
        for (keys[0..count]) |seen| try testing.expect(!std.mem.eql(u8, seen, key));
        keys[count] = key;
        count += 1;
    }
    try testing.expectEqual(@as(usize, 22), count);
}

test "an upstream url is redacted before it reaches an open endpoint's label" {
    // `/metrics` is `.auth = .open`, so every label here is readable without a
    // session by anything that can reach the bind address. A NextDNS DoH
    // upstream carries the whole account identifier in its path, and a scraper
    // keeps a label for as long as it keeps the series.
    const upstream_list = [_]UpstreamSample{
        .{
            .url = "https://dns.nextdns.io/abcd12",
            .enabled = true,
            .available = true,
            .consecutive_failures = 0,
            .total_successes = 3,
            .total_failures = 0,
            .success_rate = 1,
            .in_flight = 0,
            .slots = 8,
            .queued_total = 0,
            .queued_seconds_total = 0,
            .reuse_recoveries_total = 0,
        },
        .{
            .url = "https://user:hunter2@dns.example:8443/dns-query?apikey=s3cr3t#frag",
            .enabled = false,
            .available = false,
            .consecutive_failures = 4,
            .total_successes = 0,
            .total_failures = 4,
            .success_rate = 0,
            .in_flight = 0,
            .slots = 8,
            .queued_total = 0,
            .queued_seconds_total = 0,
            .reuse_recoveries_total = 0,
        },
    };
    const text = try renderToString(testing.allocator, .{ .upstreams = &upstream_list });
    defer testing.allocator.free(text);

    // The four components a credential can live in, none of them exposed.
    try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "abcd12"));
    try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "hunter2"));
    try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "s3cr3t"));
    try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "frag"));
    try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "dns-query"));

    // Every family carries the label, so none of the eleven may keep the whole
    // url.
    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_up{index=\"0\",url=\"https://dns.nextdns.io\"} 1\n",
    ));
    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_enabled{index=\"0\",url=\"https://dns.nextdns.io\"} 1\n",
    ));
    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_success_rate{index=\"0\",url=\"https://dns.nextdns.io\"} 1.0000\n",
    ));
    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_successes_total{index=\"0\",url=\"https://dns.nextdns.io\"} 3\n",
    ));
    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_failures_total{index=\"0\",url=\"https://dns.nextdns.io\"} 0\n",
    ));

    // The scheme, the host and the port stay: an operator reading a scrape has
    // to know which upstream a series is about.
    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_enabled{index=\"1\",url=\"https://dns.example:8443\"} 0\n",
    ));
    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_consecutive_failures{index=\"1\",url=\"https://dns.example:8443\"} 4\n",
    ));
}

test "the queue families render with the label set every upstream family shares" {
    // The surface the Pi burst defect is read from: whether an upstream is
    // queueing (`queued_total`/`in_flight` against `slots`) and whether it is
    // churning connections (`reuse_recoveries_total`).
    const upstream_list = [_]UpstreamSample{.{
        .url = "tls://dns.example:853",
        .enabled = true,
        .available = true,
        .consecutive_failures = 0,
        .total_successes = 30,
        .total_failures = 0,
        .success_rate = 1,
        .in_flight = 6,
        .slots = 8,
        .queued_total = 22,
        .queued_seconds_total = 1.5,
        .reuse_recoveries_total = 3,
    }};
    const text = try renderToString(testing.allocator, .{ .upstreams = &upstream_list });
    defer testing.allocator.free(text);

    const label = "{index=\"0\",url=\"tls://dns.example:853\"}";
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_in_flight" ++ label ++ " 6\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_slots" ++ label ++ " 8\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_queued_total" ++ label ++ " 22\n"));
    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_queued_seconds_total" ++ label ++ " 1.5000\n",
    ));
    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_reuse_recoveries_total" ++ label ++ " 3\n",
    ));

    // The types a scrape reads them as, and the approximation the two queue
    // counters carry stated where an operator meets them.
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "# TYPE nxdns_upstream_in_flight gauge\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "# TYPE nxdns_upstream_slots gauge\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "# TYPE nxdns_upstream_queued_total counter\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "# TYPE nxdns_upstream_queued_seconds_total counter\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "# TYPE nxdns_upstream_reuse_recoveries_total counter\n"));
    try testing.expectEqual(@as(usize, 2), std.mem.count(u8, text, "Approximate; sampled at admission."));
}

test "a url longer than the redaction bound cannot run past it" {
    const long_host = "h" ** (4 * safe_url.max_len);
    const upstream_list = [_]UpstreamSample{.{
        .url = "https://" ++ long_host ++ "/dns-query",
        .enabled = true,
        .available = true,
        .consecutive_failures = 0,
        .total_successes = 0,
        .total_failures = 0,
        .success_rate = 1,
        .in_flight = 0,
        .slots = 8,
        .queued_total = 0,
        .queued_seconds_total = 0,
        .reuse_recoveries_total = 0,
    }};
    const text = try renderToString(testing.allocator, .{ .upstreams = &upstream_list });
    defer testing.allocator.free(text);

    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_up{index=\"0\",url=\"" ++
            ("https://" ++ long_host)[0..safe_url.max_len] ++ "...\"} 1\n",
    ));
}

/// A leaf client that answers, and counts that it was asked.
///
/// `pool.zig`'s own fake is private to that file and models failure modes this
/// test has no use for; all this one has to do is let a real `Pool.exchange`
/// complete, so the health half of the sample below is the pool's own
/// bookkeeping rather than a literal.
const AnsweringClient = struct {
    calls: std.atomic.Value(u32) = .init(0),

    const reply = "\x12\x34\x81\x80";

    fn exchangeFn(
        ptr: *anyopaque,
        io: std.Io,
        query: []const u8,
        response_buf: []u8,
        selected: *?[]const u8,
    ) transport.ExchangeError![]u8 {
        _ = io;
        _ = query;
        const self: *AnsweringClient = @ptrCast(@alignCast(ptr));
        _ = self.calls.fetchAdd(1, .acq_rel);
        selected.* = "fake://leaf";
        @memcpy(response_buf[0..reply.len], reply);
        return response_buf[0..reply.len];
    }

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

test "the queue families carry what a real pool recorded, through the real snapshot" {
    // The test above renders a hand-built `UpstreamSample`, so it proves the
    // exposition and nothing else. This one drives a real `Pool` and goes
    // through `Pool.snapshot` and `upstreams`, which is where a crossed field
    // would live. Every driven value is distinct for that reason: a swap
    // anywhere along the path renders the wrong number rather than a match.
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var leaf: AnsweringClient = .{};
    var slots = [_]pool_mod.Slot{
        .{ .client = leaf.client() },
        .{ .client = leaf.client() },
    };
    var recoveries: std.atomic.Value(u64) = .init(0);
    var entries = [_]pool_mod.Entry{.{
        .endpoint = try .parse("tls://dns.example:853"),
        .slots = &slots,
        .priority = 10,
        .enabled = true,
        .health = .init,
        .admission = .{ .permits = slots.len },
        .reuse_recoveries = &recoveries,
    }};
    var pool: pool_mod.Pool = .init(&entries, .{}, .{
        .attempt = .{ .raw = .fromSeconds(10), .clock = .awake },
        .total = .{ .raw = .fromSeconds(30), .clock = .awake },
    }, 1);

    var buf: [512]u8 = undefined;
    var selected: ?[]const u8 = null;
    _ = try pool.exchange(io, "\x12\x34\x01\x00", &buf, &selected);
    try testing.expectEqual(@as(u32, 1), leaf.calls.load(.acquire));

    // The counters a burst would move, written straight into the entry: this
    // test is about what the snapshot path carries, not about reproducing a
    // queue. The exchange above has already returned, so nothing else is
    // touching them.
    entries[0].in_flight.store(5, .release);
    entries[0].queued_total.store(7, .release);
    entries[0].queued_ns_total.store(1_500_000_000, .release);
    recoveries.store(9, .release);

    var arena: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena.deinit();
    const list = try upstreams(&pool, io, arena.allocator());
    const text = try renderToString(testing.allocator, .{ .upstreams = list });
    defer testing.allocator.free(text);

    const label = "{index=\"0\",url=\"tls://dns.example:853\"}";
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_in_flight" ++ label ++ " 5\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_slots" ++ label ++ " 2\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_queued_total" ++ label ++ " 7\n"));
    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_queued_seconds_total" ++ label ++ " 1.5000\n",
    ));
    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_reuse_recoveries_total" ++ label ++ " 9\n",
    ));

    // The one series the pool wrote by itself, so the five above are read
    // against an entry the pool really did use.
    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "nxdns_upstream_successes_total" ++ label ++ " 1\n",
    ));
}

test "collect reads the live counters of the components it is given" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var cache = try dns_cache.DnsCache.init(testing.allocator, .{ .size = 4 });
    defer cache.deinit();
    cache.stats.hits = 11;
    cache.stats.misses = 5;

    var limiter = try rate_limiter.RateLimiter.init(testing.allocator, .{ .limit = 10, .window_seconds = 60 });
    defer limiter.deinit();
    limiter.stats.refused = 3;

    var handler = testHandler();
    handler.cache = &cache;
    handler.limiter = &limiter;
    handler.stats.queries.store(42, .monotonic);
    handler.stats.blocked.store(7, .monotonic);

    var queue_buf: [4]logger_mod.Entry = undefined;
    var query_logger: logger_mod.Logger = .init(.{}, &queue_buf);
    query_logger.rows_written.store(90, .monotonic);
    var log_owner: logger_controller.Borrowed = .{};

    var tracker_days: retention_mod.RetentionDays = .init(30);
    var tracker: clients.Tracker = .init(&tracker_days);
    var retention_days: retention_mod.RetentionDays = .init(30);
    var retention: retention_mod.Retention = .init(&retention_days);
    var tables: local_tables.LocalTables = .empty;
    var names: client_names.Resolver = .init(&tables);
    names.stats.no_zone = 4;
    names.stats.attempted = 4;

    var state: server.WebState = .{
        .gpa = testing.allocator,
        .handler = &handler,
        .logger = log_owner.over(&query_logger),
        .tracker = &tracker,
        .client_names = &names,
        .retention = &retention,
    };

    var arena: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena.deinit();
    const sample = try collect(&state, io, arena.allocator());

    try testing.expectEqual(@as(u64, 42), sample.dns[fieldIndex("queries")]);
    try testing.expectEqual(@as(u64, 7), sample.dns[fieldIndex("blocked")]);
    try testing.expectEqual(@as(u64, 11), sample.cache.?.stats.hits);
    try testing.expectEqual(@as(u64, 5), sample.cache.?.stats.misses);
    try testing.expectEqual(@as(u64, 0), sample.cache.?.entries);
    try testing.expectEqual(@as(u64, 3), sample.limiter.?.stats.refused);
    try testing.expectEqual(@as(u64, 90), sample.logger.rows_written);
    try testing.expectEqual(@as(u64, 0), sample.tracker.?.pending_clients);
    try testing.expectEqual(@as(u64, 4), sample.client_names.?.no_zone);
    try testing.expectEqual(@as(u64, 0), sample.retention.?.passes);
    try testing.expectEqual(@as(?BlocklistSample, null), sample.blocklist);
    try testing.expectEqual(@as(usize, 0), sample.upstreams.len);
}

fn fieldIndex(comptime name: []const u8) usize {
    inline for (dns_stat_fields, 0..) |field, i| {
        if (comptime std.mem.eql(u8, field.name, name)) return i;
    }
    @compileError("no such counter: " ++ name);
}

test "the budget-exhausted counter renders pool-wide, without a url label" {
    const text = try renderToString(testing.allocator, .{ .upstream_budget_exhausted_total = 7 });
    defer testing.allocator.free(text);

    try testing.expect(std.mem.containsAtLeast(
        u8,
        text,
        1,
        "# TYPE nxdns_upstream_budget_exhausted_total counter\n",
    ));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_budget_exhausted_total 7\n"));

    // No pool, no series: an operator tells "no upstreams wired" from "zero
    // exhausted budgets" the same way every other collaborator is told.
    const absent = try renderToString(testing.allocator, .{});
    defer testing.allocator.free(absent);
    try testing.expect(!std.mem.containsAtLeast(u8, absent, 1, "nxdns_upstream_budget_exhausted_total"));
}
