//! mgf-hrb-subscription — Part 13 §5.4.2 condition-filtered subscriptions.
//!
//! The wire ops `subscribe` / `unsubscribe` (server.zig) register / tear
//! down a per-client `Subscription` here. Each Subscription carries:
//!   * A `scope` (URN glob; same shape as `list`'s scope).
//!   * A parsed `Predicate` (the JSON expression tree from §5.4.2).
//!   * A bounded ring buffer of `Event` records — the per-subscription
//!     queue the spec mandates.
//!
//! Fan-out is `pushFanout`, called from the two event SOURCES — the
//! lease engine's sink (M-B B1) and the broker's backend event pump
//! (M-B B2). A subscriber pulls from THEIR queue by passing
//! `subscription_id` to `drain_events`. Since B2 no client drains a
//! backend: the pump is the single drainer, so two concurrent operators
//! can no longer steal each other's hardware events.
//!
//! ## Long-poll (M-B B2)
//!
//! `drainSubscriptionWait` blocks when the ring is empty and the caller
//! supplied a positive `wait_ms`, waking on an event, on the
//! subscription's death, or on the budget lapsing. The wait is a futex on
//! `wake_seq`, sampled inside the same critical section as the ring check
//! — that co-location is what makes the missed-wakeup race impossible.
//! See `wake_seq` for why a futex rather than a condition variable, and
//! `drainSubscriptionWait` for the proof.
//!
//! ## Why a module singleton (not a passed-around state struct)
//!
//! The subscription registry is broker-wide state: every wire op needs
//! to reach it, and every client connection's lifecycle needs to reap
//! its subscriptions on disconnect. Threading a `*SubscriptionRegistry`
//! through `server.handle()` + `Facade` + every formatter would be a
//! big diff for a small need. A module-level static (with its own
//! SpinMutex) is the same scope but no surface change.
//!
//! ## Memory discipline
//!
//! Each Subscription owns an `std.heap.ArenaAllocator` — the parsed
//! predicate's nested strings + slices live inside it. Subscription
//! teardown frees the arena in one shot. No per-node free is needed
//! anywhere in the evaluator.
//!
//! ## v1 grammar coverage (Part 13 §5.4.2)
//!
//! Operators:  `all_of`, `any_of`, `not`.
//! Terms:      `kind_in`, `urn_glob`, `attr_eq`, `attr_in`.
//! Empty / missing predicate → matches every event (subscriber wanted
//! "everything in scope").
//!
//! Capped sizes (defensive bounds; bigger sizes are a Phase 2B knob):
//!   * `MAX_QUEUE_LEN` = 64  events per subscription (~16 KB worst case)
//!   * `MAX_SUBSCRIPTIONS_PER_PEER` = 8
//!   * `MAX_TOTAL_SUBSCRIPTIONS` = 256
//!   * `MAX_PREDICATE_NODES` = 64 (per parse, summed across operators+terms)
//!   * `MAX_SUBSCRIPTION_ID_LEN` = 64 bytes
//!   * `MAX_STRING_LEN` = 256 bytes (predicate literals: urn_glob, attr values)

const std = @import("std");
const model = @import("mgf_hrb_model");
const kabi = @import("mgf_hrb_kabi");
const admission = @import("mgf_hrb_admission");

const Event = model.Event;
const Urn = model.Urn;
const Err = model.Err;

pub const MAX_QUEUE_LEN: usize = 64;
pub const MAX_SUBSCRIPTIONS_PER_PEER: usize = 8;
pub const MAX_TOTAL_SUBSCRIPTIONS: usize = 256;
/// F-B2-02 (court) — ceiling on HIDDEN (legacy-drain) subscriptions, which
/// live in their own table (see `Registry`). One per connection by
/// construction, so the honest bound is the broker's concurrent-connection
/// cap and not a number invented here. Imported rather than restated:
/// `admission.MAX_ACTIVE_CLIENTS` is the authority, and a ceiling that
/// silently drifted below the number of connections that can exist would
/// re-open exactly the defect this split closes.
pub const MAX_HIDDEN_SUBSCRIPTIONS: usize = admission.MAX_ACTIVE_CLIENTS;
/// M-B B2 — ceiling on a single long-poll `drain_events` wait, in ms.
/// A caller asking for more is refused at the trust gate (`request.zig`),
/// not silently clamped: a client that believes it armed a 10-minute wait
/// and got 30 s would mis-time its own retry loop.
pub const MAX_WAIT_MS: u64 = 30_000;
pub const MAX_PREDICATE_NODES: usize = 64;
pub const MAX_SUBSCRIPTION_ID_LEN: usize = 64;
pub const MAX_STRING_LEN: usize = 256;

// ---------------------------------------------------------------------------
// Predicate types — a small AST.
// ---------------------------------------------------------------------------

/// One event-kind tag (matches `Event`'s union tags). Closed grammar:
/// `kind_in` only accepts values from this set. Unknown tags BadRequest
/// at parse time. New event kinds added to `Event` MUST also be added
/// here for `kind_in` to filter them.
pub const EventKind = enum {
    added,
    removed,
    state_changed,
    ownership_changed,
    faulted,
    gap,
    driver_bound,
    driver_unbound,
    // NOTE: `device_ready` is spec'd in Part 13 §5.4.1 + §5.4.3 but
    // not yet implemented on the Zig side; when it lands (Phase 6E),
    // add it here AND to `fromEvent` / `fromStr` in one diff.

    pub fn fromStr(s: []const u8) ?EventKind {
        const list = [_]struct { name: []const u8, kind: EventKind }{
            .{ .name = "added", .kind = .added },
            .{ .name = "removed", .kind = .removed },
            .{ .name = "state_changed", .kind = .state_changed },
            .{ .name = "ownership_changed", .kind = .ownership_changed },
            .{ .name = "faulted", .kind = .faulted },
            .{ .name = "gap", .kind = .gap },
            .{ .name = "driver_bound", .kind = .driver_bound },
            .{ .name = "driver_unbound", .kind = .driver_unbound },
        };
        for (list) |e| if (std.mem.eql(u8, s, e.name)) return e.kind;
        return null;
    }

    /// Map a `model.Event` tag to its EventKind. The two enums are
    /// kept in lock-step manually because Zig's union doesn't expose
    /// its discriminant as a separately-introspectable enum we can
    /// alias here without a circular dependency.
    pub fn fromEvent(ev: *const Event) EventKind {
        return switch (ev.*) {
            .added => .added,
            .removed => .removed,
            .state_changed => .state_changed,
            .ownership_changed => .ownership_changed,
            .faulted => .faulted,
            .gap => .gap,
            .driver_bound => .driver_bound,
            .driver_unbound => .driver_unbound,
        };
    }
};

pub const AttrEq = struct {
    key: []const u8,
    value: []const u8,
};

pub const AttrIn = struct {
    key: []const u8,
    values: [][]const u8,
};

/// The predicate AST. Recursive — `all_of` / `any_of` carry child
/// predicate slices; `not` carries one. Leaf terms are atomic.
///
/// `matches_all` is the explicit "no predicate" sentinel — a subscribe
/// request with no `predicate` field (or `{}`) parses to this and
/// matches every event in scope.
pub const Predicate = union(enum) {
    all_of: []Predicate,
    any_of: []Predicate,
    not: *Predicate,
    kind_in: []EventKind,
    urn_glob: []const u8,
    attr_eq: AttrEq,
    attr_in: AttrIn,
    matches_all: void,
};

// ---------------------------------------------------------------------------
// Predicate parsing — std.json.Value → Predicate.
// ---------------------------------------------------------------------------

/// Parse a JSON-shaped predicate into a Predicate AST allocated in
/// `arena`. The caller owns the arena (frees it when the subscription
/// goes away). Returns Err.BadRequest on any malformed shape — every
/// branch must check both the JSON shape AND the per-field charset/
/// size caps. The closed grammar protects the broker from unbounded
/// recursion (`MAX_PREDICATE_NODES`) or hostile inputs.
///
/// `node_budget` is a single shared counter the parser decrements
/// per node; when it hits 0, parse fails BadRequest. Prevents a
/// pathological 1000-deep predicate from exhausting the stack.
///
/// N1 (review wave 3) — Err.BadRequest vs Err.Overloaded taxonomy.
///
/// Every `arena.alloc/create/dupe(...) catch return Err.X` in this
/// function returns `Err.Overloaded`, NOT `Err.BadRequest`. The
/// arena's only failure mode is `error.OutOfMemory`; misclassifying
/// it as BadRequest told the consumer "your input is bad" when the
/// actual cause is "the broker is at capacity." The SDK's
/// OverloadedError docstring tells consumers to back off + retry,
/// which is the right response to OOM (BadRequestError says "fix
/// your input," which is wrong here). Wave-2's J-cluster J28
/// surfaced this; wave-3 ships the fix.
///
/// BadRequest is reserved for genuine shape/charset/grammar errors:
/// wrong JSON node type, op-name typo, exceeded node_budget, etc.
pub fn parsePredicate(
    v: std.json.Value,
    arena: std.mem.Allocator,
    node_budget: *u32,
) Err!Predicate {
    if (node_budget.* == 0) return Err.BadRequest;
    node_budget.* -= 1;

    const obj = switch (v) {
        .object => |o| o,
        else => return Err.BadRequest,
    };

    // Exactly one operator/term per node — predicates are tagged dicts.
    if (obj.count() != 1) return Err.BadRequest;

    var it = obj.iterator();
    const entry = it.next().?;
    const op_name = entry.key_ptr.*;
    const val = entry.value_ptr.*;

    if (std.mem.eql(u8, op_name, "all_of")) {
        const arr = switch (val) {
            .array => |a| a,
            else => return Err.BadRequest,
        };
        // N2 (review wave 3) — empty-array refusal.
        //
        // Pre-N2: `{"all_of": []}` was accepted as vacuously true (an
        // empty all_of evaluates true by definition — no failing
        // term). `{"any_of": []}` was accepted as vacuously false.
        // Both shapes are almost certainly client bugs: the user
        // who wrote `all_of: []` meant `all_of: [SOMETHING]` and
        // forgot to add the term. Silently accepting the vacuous
        // truth/falsehood gives them a subscription that ALWAYS
        // fires (all_of) or NEVER fires (any_of) — an
        // operator-debugging trap of the highest order.
        //
        // Honest discipline: reject empty arrays at parse time
        // with BadRequest + a detail message. If a consumer
        // genuinely wants "match everything," they should omit
        // the predicate entirely (the broker treats absent
        // predicate as matches-all per the §5.4.2 contract).
        if (arr.items.len == 0) return Err.BadRequest;
        const out = arena.alloc(Predicate, arr.items.len) catch return Err.Overloaded;
        for (arr.items, 0..) |child, i| {
            out[i] = try parsePredicate(child, arena, node_budget);
        }
        return .{ .all_of = out };
    }
    if (std.mem.eql(u8, op_name, "any_of")) {
        const arr = switch (val) {
            .array => |a| a,
            else => return Err.BadRequest,
        };
        // N2 — same empty-array refusal as all_of above.
        if (arr.items.len == 0) return Err.BadRequest;
        const out = arena.alloc(Predicate, arr.items.len) catch return Err.Overloaded;
        for (arr.items, 0..) |child, i| {
            out[i] = try parsePredicate(child, arena, node_budget);
        }
        return .{ .any_of = out };
    }
    if (std.mem.eql(u8, op_name, "not")) {
        const child = arena.create(Predicate) catch return Err.Overloaded;
        child.* = try parsePredicate(val, arena, node_budget);
        return .{ .not = child };
    }
    if (std.mem.eql(u8, op_name, "kind_in")) {
        const arr = switch (val) {
            .array => |a| a,
            else => return Err.BadRequest,
        };
        // N2 — empty kind_in is the same client-error class as empty
        // all_of/any_of: `kind_in: []` matches nothing forever. Refuse.
        if (arr.items.len == 0) return Err.BadRequest;
        const out = arena.alloc(EventKind, arr.items.len) catch return Err.Overloaded;
        for (arr.items, 0..) |child, i| {
            const s = switch (child) {
                .string => |sx| sx,
                else => return Err.BadRequest,
            };
            out[i] = EventKind.fromStr(s) orelse return Err.BadRequest;
        }
        return .{ .kind_in = out };
    }
    if (std.mem.eql(u8, op_name, "urn_glob")) {
        const s = switch (val) {
            .string => |sx| sx,
            else => return Err.BadRequest,
        };
        if (s.len > MAX_STRING_LEN) return Err.BadRequest;
        const copy = arena.dupe(u8, s) catch return Err.Overloaded;
        return .{ .urn_glob = copy };
    }
    if (std.mem.eql(u8, op_name, "attr_eq")) {
        const inner = switch (val) {
            .object => |o| o,
            else => return Err.BadRequest,
        };
        const key_v = inner.get("key") orelse return Err.BadRequest;
        const value_v = inner.get("value") orelse return Err.BadRequest;
        const key_s = switch (key_v) {
            .string => |sx| sx,
            else => return Err.BadRequest,
        };
        const value_s = switch (value_v) {
            .string => |sx| sx,
            else => return Err.BadRequest,
        };
        if (key_s.len > MAX_STRING_LEN or value_s.len > MAX_STRING_LEN) return Err.BadRequest;
        return .{ .attr_eq = .{
            .key = arena.dupe(u8, key_s) catch return Err.Overloaded,
            .value = arena.dupe(u8, value_s) catch return Err.Overloaded,
        } };
    }
    if (std.mem.eql(u8, op_name, "attr_in")) {
        const inner = switch (val) {
            .object => |o| o,
            else => return Err.BadRequest,
        };
        const key_v = inner.get("key") orelse return Err.BadRequest;
        const values_v = inner.get("values") orelse return Err.BadRequest;
        const key_s = switch (key_v) {
            .string => |sx| sx,
            else => return Err.BadRequest,
        };
        if (key_s.len > MAX_STRING_LEN) return Err.BadRequest;
        const arr = switch (values_v) {
            .array => |a| a,
            else => return Err.BadRequest,
        };
        // N2 — empty attr_in values list matches nothing forever; same
        // bug class as empty kind_in / all_of / any_of. Refuse at parse.
        if (arr.items.len == 0) return Err.BadRequest;
        const out_vals = arena.alloc([]const u8, arr.items.len) catch return Err.Overloaded;
        for (arr.items, 0..) |child, i| {
            const s = switch (child) {
                .string => |sx| sx,
                else => return Err.BadRequest,
            };
            if (s.len > MAX_STRING_LEN) return Err.BadRequest;
            out_vals[i] = arena.dupe(u8, s) catch return Err.Overloaded;
        }
        return .{ .attr_in = .{
            .key = arena.dupe(u8, key_s) catch return Err.Overloaded,
            .values = out_vals,
        } };
    }
    return Err.BadRequest;
}

// ---------------------------------------------------------------------------
// Predicate evaluation.
// ---------------------------------------------------------------------------

/// Caller-supplied attribute lookup for `attr_eq` / `attr_in` evaluation.
/// Given an event URN + a key, return the value (slice owned by caller's
/// buffer; valid for the duration of the lookupAttr call). Return null
/// when the URN/key has no value.
///
/// Two kinds of attrs are looked up:
///   * Event-intrinsic: `kind`, `urn`, `driver` (for driver_bound /
///     driver_unbound). These are answered without touching any
///     backend — see `lookupIntrinsicAttr`.
///   * Device attrs (`vid`, `pid`, `bInterfaceClass`, …): these
///     require an `inspect` call. The server provides a closure that
///     wraps `facade.inspect()` and pulls the attr out. v1 cost: one
///     inspect per event per attr_eq term; events are infrequent so
///     this is acceptable. Caching is a future tightening.
pub const AttrLookup = struct {
    ctx: ?*anyopaque,
    /// Returns a slice valid until the next call. Null on no-such-attr.
    /// May be null when no lookup is wired (then attr_eq/attr_in only
    /// match against event-intrinsic attrs).
    get: ?*const fn (ctx: ?*anyopaque, urn: []const u8, key: []const u8) ?[]const u8,
};

/// True iff `ev` matches `pred`. `lookup` is consulted for `attr_eq`
/// / `attr_in` terms when the key isn't event-intrinsic.
pub fn evaluate(
    pred: *const Predicate,
    ev: *const Event,
    lookup: AttrLookup,
) bool {
    return switch (pred.*) {
        .matches_all => true,
        .all_of => |children| {
            for (children) |*c| {
                if (!evaluate(c, ev, lookup)) return false;
            }
            return true;
        },
        .any_of => |children| {
            for (children) |*c| {
                if (evaluate(c, ev, lookup)) return true;
            }
            return false;
        },
        .not => |child| !evaluate(child, ev, lookup),
        .kind_in => |kinds| {
            const ev_kind = EventKind.fromEvent(ev);
            for (kinds) |k| if (k == ev_kind) return true;
            return false;
        },
        .urn_glob => |glob| {
            const ev_urn = eventUrn(ev) orelse return false;
            return Urn.from(ev_urn).matches(glob);
        },
        .attr_eq => |ae| {
            const ev_urn_opt = eventUrn(ev);
            // Intrinsic-first lookup (no syscalls).
            if (lookupIntrinsicAttr(ev, ae.key)) |v| {
                return std.mem.eql(u8, v, ae.value);
            }
            // Fall back to device-attr lookup via the closure.
            if (lookup.get) |fn_| if (ev_urn_opt) |ev_urn| {
                if (fn_(lookup.ctx, ev_urn, ae.key)) |v| {
                    return std.mem.eql(u8, v, ae.value);
                }
            };
            return false;
        },
        .attr_in => |ai| {
            const ev_urn_opt = eventUrn(ev);
            if (lookupIntrinsicAttr(ev, ai.key)) |v| {
                for (ai.values) |candidate| {
                    if (std.mem.eql(u8, v, candidate)) return true;
                }
                return false;
            }
            if (lookup.get) |fn_| if (ev_urn_opt) |ev_urn| {
                if (fn_(lookup.ctx, ev_urn, ai.key)) |v| {
                    for (ai.values) |candidate| {
                        if (std.mem.eql(u8, v, candidate)) return true;
                    }
                }
            };
            return false;
        },
    };
}

/// The event's URN, when the variant carries one. Gap carries none.
///
/// Lifetime note: we read the URN's bytes through `ev` (the caller's
/// pointer), not through a switch-capture value. A switch on `ev.*`
/// copies the union into a temporary; payload slices taken from that
/// temporary become dangling the moment this function returns — a
/// bug that manifests intermittently depending on call-stack reuse.
/// Going through `ev.*.<variant>` keeps the slice anchored to the
/// caller's Event lifetime.
pub fn eventUrn(ev: *const Event) ?[]const u8 {
    return switch (ev.*) {
        .added => ev.added.urn.slice(),
        .removed => ev.removed.urn.slice(),
        .state_changed => ev.state_changed.urn.slice(),
        .ownership_changed => ev.ownership_changed.urn.slice(),
        .faulted => ev.faulted.urn.slice(),
        .driver_bound => ev.driver_bound.urn.slice(),
        .driver_unbound => ev.driver_unbound.urn.slice(),
        .gap => null,
    };
}

/// Event-intrinsic attr lookup: keys that are answerable WITHOUT a
/// backend round-trip. Today's set:
///   * `kind` → the event's tag name (`"added"`, `"driver_bound"`, …)
///   * `urn`  → the event's URN (when it carries one)
///   * `driver` → for driver_bound / driver_unbound, the driver name
///
/// Returns null when the key isn't intrinsic; the evaluator then
/// falls back to the device-attr lookup closure (if any).
pub fn lookupIntrinsicAttr(ev: *const Event, key: []const u8) ?[]const u8 {
    if (std.mem.eql(u8, key, "kind")) return @tagName(EventKind.fromEvent(ev));
    if (std.mem.eql(u8, key, "urn")) return eventUrn(ev);
    if (std.mem.eql(u8, key, "driver")) return switch (ev.*) {
        // Same lifetime trick as `eventUrn` — read through `ev`, not
        // through a switch-capture, so the returned slice stays
        // anchored to the caller's Event.
        .driver_bound => ev.driver_bound.driver.slice(),
        .driver_unbound => ev.driver_unbound.driver.slice(),
        else => null,
    };
    return null;
}

// ---------------------------------------------------------------------------
// Subscription + ring buffer.
// ---------------------------------------------------------------------------

/// One subscription's bounded queue. Fixed-size ring; on overflow the
/// OLDEST event is dropped to make room, and a one-shot `overflow_seen`
/// flag is raised. The flag surfaces as a synthetic
/// `gap{source=subscription_overflow}` event at the next `drainHead`
/// — guaranteeing the consumer sees ONE honest gap per overflow burst
/// without growing the queue past its cap. Spec model: Part 13 §5.4.2.
/// Gap-honest at the subscription boundary.
///
/// Why drain-time synthesis (not push-time inline): writing the gap
/// in-place during overflow would either consume a queue slot — which
/// would then overflow itself on the next push — or overwrite the
/// displacing event we're trying to make room for. Drain-time is the
/// clean point; the gap surfaces once per burst, then the flag resets
/// and the burst can re-arm.
const RingBuffer = struct {
    items: [MAX_QUEUE_LEN]Event = undefined,
    head: usize = 0, // index of oldest
    len: usize = 0,
    overflow_seen: bool = false,
    overflow_since_ms: u64 = 0,

    fn push(self: *RingBuffer, ev: Event) void {
        if (self.len < MAX_QUEUE_LEN) {
            const idx = (self.head + self.len) % MAX_QUEUE_LEN;
            self.items[idx] = ev;
            self.len += 1;
            return;
        }
        // Overflow: drop oldest by advancing head; push new at the
        // newly-vacated tail position. Mark the flag.
        if (!self.overflow_seen) {
            self.overflow_seen = true;
            self.overflow_since_ms = kabi.nowMs();
        }
        self.head = (self.head + 1) % MAX_QUEUE_LEN;
        // len stays at MAX_QUEUE_LEN; new tail index is one before the
        // dropped slot (post-advance).
        const idx = (self.head + self.len - 1) % MAX_QUEUE_LEN;
        self.items[idx] = ev;
    }

    /// Pop the oldest event, or null when empty. Surfaces a synthetic
    /// gap on FIRST pop after an overflow burst — at which point the
    /// flag clears, so the same burst doesn't keep emitting gaps.
    fn pop(self: *RingBuffer) ?Event {
        if (self.overflow_seen) {
            self.overflow_seen = false;
            return Event{ .gap = .{
                .source = .subscription_overflow,
                .since_ms = self.overflow_since_ms,
            } };
        }
        if (self.len == 0) return null;
        const ev = self.items[self.head];
        self.head = (self.head + 1) % MAX_QUEUE_LEN;
        self.len -= 1;
        return ev;
    }
};

/// One client subscription. Owned by the SubscriptionRegistry.
pub const Subscription = struct {
    /// Caller-chosen id (unique per peer). Echoed in the response.
    id_buf: [MAX_SUBSCRIPTION_ID_LEN]u8 = undefined,
    id_len: u8 = 0,
    /// Per-connection owner key (the broker uses the client cfd —
    /// connection-unique while live). Subscriptions are reaped via
    /// `reapPeer(peer_key)` from the server's handleClient defer
    /// when the connection closes.
    peer_key: u64,
    /// URN glob (e.g. "hw:/usb/**"). Pre-filter: events whose URN
    /// doesn't match the scope are dropped before predicate eval.
    scope_buf: [128]u8 = undefined,
    scope_len: u8 = 0,
    /// Per-subscription arena — predicate AST + its inner strings
    /// live here. Freed in one shot on teardown.
    arena: std.heap.ArenaAllocator,
    predicate: Predicate,
    /// Bounded queue (per §5.4.2).
    queue: RingBuffer = .{},
    /// R2-A3 — eval-in-progress refcount. `pushFanout` increments this
    /// under the registry lock for every subscription it's about to
    /// evaluate OUTSIDE the lock, and decrements it (under the lock)
    /// when done. `reapPeer`/`unregister` must NOT free a subscription
    /// whose `refs > 0` — a fan-out thread is reading its predicate AST;
    /// they mark it `dead` + unlink it and defer the free to whichever
    /// side drops `refs` to zero. Always accessed under `registry.lock`.
    refs: u32 = 0,
    /// R2-A3 — set when `reapPeer`/`unregister` unlinked this sub while
    /// `refs > 0` (deferred free). A dead sub is already removed from the
    /// registry slots; `pushFanout`'s commit phase skips delivering to it
    /// and frees it when the last in-flight eval releases its ref.
    dead: bool = false,
    /// M-B B2 — this subscription was auto-created by a LEGACY (scope-mode)
    /// `drain_events` call, not by a client `subscribe`.
    ///
    /// Since F-B2-02 the flag is descriptive, not load-bearing: hidden subs
    /// live in `registry.hidden_slots` and visible ones in `registry.slots`,
    /// so "is this hidden?" is answered by WHICH TABLE it is in, and no
    /// lookup has to remember to check a boolean. That is the point of the
    /// split — the previous shape relied on four separate `if (sub.hidden)`
    /// guards being kept in step, and a fifth lookup added later would have
    /// silently exposed hidden subs to clients.
    ///
    /// What holds either way: it carries an empty id, no client can name
    /// it, it does not count against `MAX_SUBSCRIPTIONS_PER_PEER` or
    /// against `MAX_TOTAL_SUBSCRIPTIONS`, it is bounded instead by
    /// `MAX_HIDDEN_SUBSCRIPTIONS` (its own true ceiling — one per
    /// connection), it is NOT counted by `count()` and therefore not by
    /// `state_snapshot.active_subscriptions`, it receives fan-out exactly
    /// like a visible subscription, and it is reaped with its connection.
    hidden: bool = false,

    pub fn id(self: *const Subscription) []const u8 {
        return self.id_buf[0..self.id_len];
    }
    pub fn scope(self: *const Subscription) []const u8 {
        return self.scope_buf[0..self.scope_len];
    }
};

// ---------------------------------------------------------------------------
// SubscriptionRegistry — module-level singleton.
// ---------------------------------------------------------------------------

/// Module-level registry. Access only via the `register` / `unregister`
/// / `pushFanout` / `drainSubscription` / `reapPeer` helpers; those
/// take the mutex internally. Direct access would race with other
/// threads (the broker is thread-per-client; Part 7.1).
const Registry = struct {
    lock: kabi.SpinMutex = .{},
    /// F-B2-01 (court) — owner of `lock` as a thread id, or 0 when unheld.
    /// `SpinMutex` is ownerless by design, so this is the only way to
    /// ASSERT that the wake token is sampled inside the ring check's
    /// critical section rather than merely document it. Same pattern, and
    /// the same reasoning, as `Core.lock_owner` (M-B B1): exact, not
    /// heuristic — only the owning thread writes its own id, and it clears
    /// the field before unlocking. Safety builds only.
    lock_owner: std.atomic.Value(u64) = .init(0),
    // Subscriptions are heap-allocated so the slice can hand out
    // stable pointers as it grows; backing storage is a pre-sized
    // optional slot table to avoid an allocator dependency at the
    // module scope.
    //
    // F-B2-02 (court) — TWO tables, deliberately.
    //
    // Until the split, a hidden legacy-drain subscription occupied a slot
    // in `slots`, so 32 connections × 8 subscriptions saturated all 256
    // and a NEW connection's first `drain_events` — the plainest call in
    // the protocol, which before B2 could not fail at all — answered
    // `Overloaded`. The court reproduced it live. Two resources were
    // sharing one ceiling for no reason: they have different bounds and
    // different failure meanings.
    //
    // A hidden subscription is one-per-connection by construction, so its
    // true bound is the concurrent-connection cap, and it now lives in a
    // table of exactly that size. The consequences are the point:
    // `subscribe()` can no longer be starved by legacy drainers, a legacy
    // drain can no longer be refused because subscribers filled a table it
    // has no business sharing, and neither ceiling can be reached by
    // traffic aimed at the other.
    slots: [MAX_TOTAL_SUBSCRIPTIONS]?*Subscription = [_]?*Subscription{null} ** MAX_TOTAL_SUBSCRIPTIONS,
    hidden_slots: [MAX_HIDDEN_SUBSCRIPTIONS]?*Subscription = [_]?*Subscription{null} ** MAX_HIDDEN_SUBSCRIPTIONS,
    /// The allocator used to back Subscription structs + their arenas.
    /// Installed by `init`; nullable so importing the module without
    /// using it is harmless.
    alloc: ?std.mem.Allocator = null,
};

var registry: Registry = .{};

/// F-B2-01 — lock-ownership tracking is compiled out of release builds,
/// exactly like `core.zig`'s. The assertions it powers are a development
/// proof, never control flow.
const LOCK_TRACKING = std.debug.runtime_safety;

fn currentThreadId() u64 {
    return @intCast(std.Thread.getCurrentId());
}

/// Take `registry.lock` and record ownership. EVERY acquisition goes
/// through here — that is what makes `sampleWakeTokenLocked`'s assertion a
/// proof rather than a hope.
fn lockRegistry() void {
    registry.lock.lock();
    if (LOCK_TRACKING) registry.lock_owner.store(currentThreadId(), .monotonic);
}

/// Release `registry.lock`. Clears ownership BEFORE unlocking so no window
/// exists in which another thread holds the lock while this field still
/// names us.
fn unlockRegistry() void {
    if (LOCK_TRACKING) registry.lock_owner.store(0, .monotonic);
    registry.lock.unlock();
}

/// True iff THIS thread currently holds `registry.lock`. Always false when
/// tracking is compiled out — for assertions only, never control flow.
fn lockHeldBySelf() bool {
    if (!LOCK_TRACKING) return false;
    return registry.lock_owner.load(.monotonic) == currentThreadId();
}

/// Assert THIS thread holds the registry lock.
///
/// The `if (LOCK_TRACKING)` guard is NOT decoration. `lockHeldBySelf()`
/// returns false when tracking is compiled out, so a bare
/// `std.debug.assert(lockHeldBySelf())` becomes `assert(false)` in
/// ReleaseFast — and there `unreachable` is undefined behaviour, not a
/// panic. The first ReleaseFast run of this fold segfaulted eleven tests
/// on exactly that, which is why the gate runs both modes.
///
/// B1's `Core.publish` is safe without the guard only because its
/// assertion is NEGATED (`assert(!lockHeldBySelf())`) — a positive
/// lock-held assertion always needs this shape. Hence the named helper:
/// the next person to assert lock ownership should not have to rediscover
/// the asymmetry.
fn assertLockHeld() void {
    if (LOCK_TRACKING) std.debug.assert(lockHeldBySelf());
}

/// B2-AM1 — how many subscriptions are linked into `registry.slots` right
/// now, readable WITHOUT taking the registry lock.
///
/// Why it exists: since B1 every committed lease mutation calls
/// `pushFanout`, and since B2 so does every hardware event the pump drains
/// — so the zero-subscriber path is now on the broker's hot mutation path,
/// not on a polling client's. Pre-AM1 that path cost a lock acquire plus a
/// 256-slot scan for a delivery to nobody (B1 measured a 159 ns floor). The
/// counter turns it into one relaxed atomic load.
///
/// ORDERING — load-bearing. The increment happens BEFORE the slot is
/// installed and the decrement AFTER it is unlinked, both under the lock.
/// So a `pushFanout` that observes 0 provably ran at a moment when the
/// slot table held no subscription it could have delivered to: the counter
/// is never optimistic about emptiness, only ever pessimistic (it may read
/// non-zero for a sub that is already unlinked, which merely costs the scan
/// the early return would have skipped).
var live_subs: std.atomic.Value(usize) = std.atomic.Value(usize).init(0);

// ---------------------------------------------------------------------------
// M-B B2 — the long-poll wake token.
// ---------------------------------------------------------------------------

/// Monotonic counter, bumped inside the SAME critical section that makes a
/// subscription's ring gain an event or lose its subscription. It is the
/// futex word long-polling drains block on.
///
/// ## Why a futex word and not a condition variable
///
/// The packet specifies a condvar. Zig 0.16's standard library has no
/// `std.Thread.Mutex` / `std.Thread.Condition` at all — the synchronisation
/// primitives moved to `std.Io.{Mutex, Condition}`, which require an `Io`
/// instance the broker (raw-syscall, allocator-free on its boot path) does
/// not have and which ship NO timed wait. So a literal condvar is not
/// reachable in this toolchain. A futex word paired with the registry's
/// existing `SpinMutex` gives the identical guarantee, and arguably a
/// crisper proof: FUTEX_WAIT's compare-and-block is performed by the KERNEL
/// atomically, so "I decided to wait" and "the value I decided on is still
/// current" cannot be separated by any interleaving. See
/// `drainSubscriptionWait` for the full missed-wakeup argument.
///
/// Wraparound at 2^32 is a non-event: the waiter only ever asks "is it
/// still exactly the value I sampled a moment ago", and 4 billion fan-outs
/// cannot happen inside one wait without the waiter being woken by the
/// first of them.
var wake_seq: std.atomic.Value(u32) = std.atomic.Value(u32).init(0);

/// Bump the wake token. MUST be called with `registry.lock` HELD, in the
/// same critical section as the state change being announced — that
/// co-location is one half of the missed-wakeup proof. F-B2-01: asserted,
/// not merely documented.
fn bumpWakeLocked() void {
    assertLockHeld();
    _ = wake_seq.fetchAdd(1, .release);
}

/// Sample the wake token. MUST be called with `registry.lock` HELD, in the
/// SAME critical section as the ring check whose emptiness the caller is
/// about to sleep on — the other half of the proof.
///
/// F-B2-01 (court, MEDIUM). The invariant used to live inside
/// `waitLocked`, where it was true by construction of that one function
/// and therefore invisible: a reader could not tell, from the call site,
/// that hoisting the sample was fatal. And it IS fatal only rarely — the
/// court measured the natural-timing stress pin catching the hoisted-sample
/// race 0 times in 10, because the window is a few instructions wide. A
/// defect that a probabilistic pin misses nine times in ten needs a
/// structural guard, not a better dice roll.
///
/// So the token is now sampled BY THE CALLER, inside its own critical
/// section, and passed to `waitLocked` as a value. The assertion below
/// fires deterministically, on the first long poll, in every safety build,
/// if anyone ever moves that sample out from under the lock.
fn sampleWakeTokenLocked() u32 {
    assertLockHeld();
    return wake_seq.load(.acquire);
}

/// Wake every blocked drain. Call AFTER releasing `registry.lock` (a
/// syscall under a spin mutex would make every other thread spin through
/// it). Safe to separate from `bumpWakeLocked`: a waiter that sampled the
/// token before the bump gets EAGAIN from the kernel's compare and never
/// blocks; one that sampled it after did so under the lock and therefore
/// already saw the state change.
fn wakeAll() void {
    _ = std.os.linux.futex_3arg(
        &wake_seq.raw,
        .{ .cmd = .WAKE, .private = true },
        std.math.maxInt(i32),
    );
}

/// Install the backing allocator. Call once during broker startup
/// (main.zig). Safe to call multiple times (idempotent — the latest
/// allocator wins, but existing subscriptions are unaffected).
pub fn init(alloc: std.mem.Allocator) void {
    lockRegistry();
    defer unlockRegistry();
    registry.alloc = alloc;
}

/// Reset the registry — frees every subscription + clears all slots.
/// Used by tests; production teardown happens at process exit so this
/// is not strictly required there.
pub fn deinit() void {
    {
        lockRegistry();
        defer unlockRegistry();
        deinitLocked();
    }
    // Anyone blocked in a long-poll drain must not sleep out its full
    // budget against a registry that no longer exists.
    wakeAll();
}

fn deinitLocked() void {
    for (&registry.slots) |*slot| freeSlotLocked(slot);
    for (&registry.hidden_slots) |*slot| freeSlotLocked(slot);
    live_subs.store(0, .release);
    bumpWakeLocked();
}

fn freeSlotLocked(slot: *?*Subscription) void {
    if (slot.*) |sub| {
        sub.arena.deinit();
        if (registry.alloc) |a| a.destroy(sub);
    }
    slot.* = null;
}

/// Register a new subscription. Returns the assigned subscription
/// (the same id the caller supplied; we just echo it back). Fails
/// BadRequest if the id is empty / too long / a duplicate-for-this-peer,
/// Overloaded if registry / per-peer caps are hit.
pub fn register(
    peer_key: u64,
    id_str: []const u8,
    scope: []const u8,
    predicate: Predicate,
    arena: std.heap.ArenaAllocator,
) Err!*Subscription {
    if (id_str.len == 0 or id_str.len > MAX_SUBSCRIPTION_ID_LEN) {
        var arena_mut = arena;
        arena_mut.deinit();
        return Err.BadRequest;
    }
    if (scope.len > 128) {
        var arena_mut = arena;
        arena_mut.deinit();
        return Err.BadRequest;
    }

    lockRegistry();
    defer unlockRegistry();

    const alloc = registry.alloc orelse {
        var arena_mut = arena;
        arena_mut.deinit();
        return Err.Unavailable;
    };

    // Per-peer dedup + cap check.
    var per_peer: usize = 0;
    for (registry.slots) |slot| {
        const sub = slot orelse continue;
        if (sub.peer_key != peer_key) continue;
        per_peer += 1;
        if (std.mem.eql(u8, sub.id(), id_str)) {
            var arena_mut = arena;
            arena_mut.deinit();
            return Err.BadRequest; // duplicate id for this peer
        }
    }
    if (per_peer >= MAX_SUBSCRIPTIONS_PER_PEER) {
        var arena_mut = arena;
        arena_mut.deinit();
        return Err.Overloaded;
    }

    // Find a free slot.
    var free_slot: ?usize = null;
    for (registry.slots, 0..) |slot, i| {
        if (slot == null) {
            free_slot = i;
            break;
        }
    }
    const slot_idx = free_slot orelse {
        var arena_mut = arena;
        arena_mut.deinit();
        return Err.Overloaded;
    };

    const sub = alloc.create(Subscription) catch {
        var arena_mut = arena;
        arena_mut.deinit();
        return Err.Overloaded;
    };
    sub.* = .{
        .peer_key = peer_key,
        .arena = arena,
        .predicate = predicate,
    };
    @memcpy(sub.id_buf[0..id_str.len], id_str);
    sub.id_len = @intCast(id_str.len);
    @memcpy(sub.scope_buf[0..scope.len], scope);
    sub.scope_len = @intCast(scope.len);
    // B2-AM1 — count BEFORE linking (see `live_subs`' ordering note).
    _ = live_subs.fetchAdd(1, .release);
    registry.slots[slot_idx] = sub;
    return sub;
}

/// M-B B2 — install the hidden per-connection subscription that serves a
/// LEGACY (scope-mode) `drain_events`. Caller holds `registry.lock`.
///
/// The filter is carried entirely by `scope` (the registry's scope
/// pre-filter is a URN glob, so this IS `urn_glob(scope)`) and the
/// predicate is left `matches_all`. DELIBERATE, and not the same as
/// literally storing `.{ .urn_glob = scope }`: a `gap` event carries no
/// URN, so a `urn_glob` PREDICATE evaluates false for it and would drop
/// every gap — whereas the scope pre-filter explicitly lets URN-less
/// events through to every subscription (see `pushFanout` phase 1).
/// Legacy drainers received netlink gaps before B2 and must keep
/// receiving them (D-022: we never silently lose).
fn openHiddenLocked(peer_key: u64, scope: []const u8) Err!*Subscription {
    // RC-B2-03 (delta court) — the invariant this refusal sits under.
    //
    // `format.fmtDrainEvents` renders EVERY `BadRequest` from the legacy
    // path as "your scope differs from this connection's first scope-mode
    // drain" (F-B2-05). That detail is only truthful while this line is
    // unreachable from the wire — which it is, because the trust gate caps
    // `scope` at `request.MAX_URN_BYTES` (128) before dispatch, so a
    // longer scope is refused there and never arrives. The check stays as
    // the in-process guard for `scope_buf`'s capacity.
    //
    // If `MAX_URN_BYTES` ever exceeds 128, this becomes reachable and the
    // detail string becomes a lie: fix BOTH, or give this its own code.
    if (scope.len > 128) return Err.BadRequest;
    const alloc = registry.alloc orelse return Err.Unavailable;

    // F-B2-02 — the HIDDEN table. Sized to the concurrent-connection cap,
    // which is this resource's real bound (one per connection), so a full
    // table is unreachable while the admission layer holds: every hidden
    // sub is owned by a live connection, and there cannot be more of those
    // than `MAX_ACTIVE_CLIENTS`. `Overloaded` stays as the honest answer
    // if that invariant is ever broken elsewhere, rather than an
    // unreachable-panic that would take the broker down over an accounting
    // bug.
    var free_slot: ?usize = null;
    for (registry.hidden_slots, 0..) |slot, i| {
        if (slot == null) {
            free_slot = i;
            break;
        }
    }
    const slot_idx = free_slot orelse return Err.Overloaded;

    const sub = alloc.create(Subscription) catch return Err.Overloaded;
    sub.* = .{
        .peer_key = peer_key,
        // An empty arena: `matches_all` allocates nothing. It still owns
        // one so teardown stays uniform (`freeSubLocked` deinits it).
        .arena = std.heap.ArenaAllocator.init(alloc),
        .predicate = .{ .matches_all = {} },
        .hidden = true,
    };
    @memcpy(sub.scope_buf[0..scope.len], scope);
    sub.scope_len = @intCast(scope.len);
    _ = live_subs.fetchAdd(1, .release);
    registry.hidden_slots[slot_idx] = sub;
    return sub;
}

/// Free a subscription's backing memory (arena + struct). MUST be called
/// with `registry.lock` held AND only when `sub.refs == 0` (no fan-out
/// thread is reading its predicate). R2-A3 — the single free site;
/// `unregister`/`reapPeer`/`pushFanout` route here once it's safe.
fn freeSubLocked(sub: *Subscription) void {
    sub.arena.deinit();
    if (registry.alloc) |a| a.destroy(sub);
}

/// Unlink `sub` from its slot and free it — or, if a fan-out eval is
/// in flight (`refs > 0`), mark it `dead` and defer the free to the
/// eval's commit phase. Caller holds `registry.lock`. `slot` is the
/// table cell pointing at `sub` (cleared here so no new fan-out picks
/// it up). R2-A3.
fn retireSubLocked(slot: *?*Subscription, sub: *Subscription) void {
    slot.* = null; // unlink first — no new pushFanout will snapshot it
    // B2-AM1 — uncount AFTER unlinking (see `live_subs`' ordering note).
    _ = live_subs.fetchSub(1, .release);
    if (sub.refs > 0) {
        sub.dead = true; // deferred free: the last in-flight eval frees it
    } else {
        freeSubLocked(sub);
    }
}

/// Tear down `(peer_key, id_str)`. Returns `Err.NotFound` if no
/// such subscription exists (idempotency = caller's responsibility).
pub fn unregister(peer_key: u64, id_str: []const u8) Err!void {
    {
        lockRegistry();
        defer unlockRegistry();
        var found = false;
        for (&registry.slots) |*slot| {
            const sub = slot.* orelse continue;
            if (sub.peer_key != peer_key) continue;
            if (!std.mem.eql(u8, sub.id(), id_str)) continue;
            retireSubLocked(slot, sub);
            // B2 — a waiter blocked on THIS subscription must learn it
            // died rather than sleep out its budget against a freed ring.
            bumpWakeLocked();
            found = true;
            break;
        }
        if (!found) return Err.NotFound;
    }
    wakeAll();
}

/// Reap every subscription owned by `peer_key`. Called from the
/// server's per-client teardown path when a peer disconnects.
pub fn reapPeer(peer_key: u64) void {
    {
        lockRegistry();
        defer unlockRegistry();
        for (&registry.slots) |*slot| {
            const sub = slot.* orelse continue;
            if (sub.peer_key != peer_key) continue;
            retireSubLocked(slot, sub);
        }
        // F-B2-02 — the hidden table is reaped on the SAME event. Splitting
        // the accounting must not split the lifetime: a hidden sub still
        // dies with its connection, like every other subscription.
        for (&registry.hidden_slots) |*slot| {
            const sub = slot.* orelse continue;
            if (sub.peer_key != peer_key) continue;
            retireSubLocked(slot, sub);
        }
        // B2 — subscription death is one of the three things a long-poll
        // waiter waits for. Unconditional bump: cheap, and it keeps the
        // wake contract independent of whether this peer had waiters.
        bumpWakeLocked();
    }
    wakeAll();
}

/// Fan one event out to EVERY active subscription whose scope matches
/// the event's URN AND whose predicate evaluates true.
///
/// Called from the broker's two — and only two — event SOURCES: the lease
/// engine's injected sink (M-B B1, once per committed mutation) and the
/// backend event pump (M-B B2, once per event drained off a watch fd). It
/// used to be reachable only from the `drain_events` formatter, i.e. as a
/// side effect of some client polling, which is why nobody polling meant
/// nobody notified.
///
/// `lookup` is the device-attr resolver — provided by the caller so the
/// predicate evaluator can fetch USB/PCI attrs without subscription.zig
/// importing the facade.
///
/// R2-A3 — three phases so the (potentially slow, sysfs-backed) predicate
/// evaluation runs OUTSIDE `registry.lock`, without a use-after-free
/// against a concurrent `reapPeer`/`unregister`:
///
///   1. **Snapshot (locked):** walk the slots, apply the cheap scope
///      pre-filter, and for each candidate increment its `refs` and
///      record the pointer. `refs > 0` pins the subscription: a
///      concurrent reap unlinks + marks it `dead` but will NOT free it.
///   2. **Evaluate (UNLOCKED):** run `evaluate(predicate, ev, lookup)`
///      for each snapshotted sub. Safe to deref the predicate AST — the
///      ref pin keeps the arena alive even if the peer disconnects mid-
///      eval. The slow `lookup` no longer holds the registry lock.
///   3. **Commit (locked):** for each sub, deliver to its queue iff it
///      matched AND isn't `dead` (a reaped sub's client is gone); drop
///      the ref; free the sub if this was the last ref AND it's `dead`.
///
/// CONTRACT (relaxed by R2-A3): because eval now runs unlocked, the
/// `lookup` callback MAY re-enter this module if it ever needs to — the
/// old "must not re-enter or it deadlocks" hazard is gone. (In practice
/// `lookup` is the facade's sysfs fetch and still has no reason to.)
pub fn pushFanout(ev: Event, lookup: AttrLookup) void {
    // Phase 0 (B2-AM1) — nobody is listening: return before touching the
    // lock. See `live_subs` for why this cannot drop a deliverable event.
    if (live_subs.load(.acquire) == 0) return;

    // Phase 1 — snapshot scope-matching candidates under the lock,
    // pinning each with a ref so it can't be freed during eval. F-B2-02:
    // BOTH tables. Splitting the accounting must not split delivery — a
    // hidden subscription is a real subscriber with a real ring, and a
    // legacy drainer that stopped receiving events would be the same
    // silent loss B2 exists to remove.
    var snap: [MAX_TOTAL_SUBSCRIPTIONS + MAX_HIDDEN_SUBSCRIPTIONS]*Subscription = undefined;
    var n: usize = 0;
    {
        lockRegistry();
        defer unlockRegistry();
        for ([2][]?*Subscription{ &registry.slots, &registry.hidden_slots }) |table| {
            for (table) |*slot| {
                const sub = slot.* orelse continue;
                // Scope pre-filter: a gap event has no URN; let it through
                // to every subscription (gap-honest — consumers must learn
                // about gaps regardless of their scope).
                if (eventUrn(&ev)) |ev_urn| {
                    const ev_urn_obj = Urn.from(ev_urn);
                    if (!ev_urn_obj.matches(sub.scope())) continue;
                }
                sub.refs += 1;
                snap[n] = sub;
                n += 1;
            }
        }
    }

    // Phase 2 — evaluate predicates OUTSIDE the lock. The ref pin makes
    // dereferencing each sub's predicate AST safe even if its peer is
    // being reaped concurrently. `matched[i]` records the verdict.
    var matched: [MAX_TOTAL_SUBSCRIPTIONS + MAX_HIDDEN_SUBSCRIPTIONS]bool = undefined;
    for (snap[0..n], 0..) |sub, i| {
        matched[i] = evaluate(&sub.predicate, &ev, lookup);
    }

    // Phase 3 — commit under the lock: deliver to live matches, release
    // the ref, and free any sub whose last in-flight eval just ended
    // after it was reaped (deferred free).
    var delivered = false;
    {
        lockRegistry();
        defer unlockRegistry();
        for (snap[0..n], 0..) |sub, i| {
            if (matched[i] and !sub.dead) {
                sub.queue.push(ev);
                delivered = true;
            }
            sub.refs -= 1;
            if (sub.refs == 0 and sub.dead) freeSubLocked(sub);
        }
        // B2 — the wake token is bumped INSIDE the critical section that
        // made the event visible in the ring. That co-location is the
        // whole missed-wakeup proof; `drainSubscriptionWait` states it in
        // full from the waiter's side.
        if (delivered) bumpWakeLocked();
    }
    if (delivered) wakeAll();
}

/// Drain up to `max` events from `(peer_key, id_str)`'s queue
/// into `out`. Returns the number of events written. `Err.NotFound`
/// when the subscription doesn't exist (or belongs to a different
/// peer — caller's identity is enforced here, not by the dispatcher).
pub fn drainSubscription(
    peer_key: u64,
    id_str: []const u8,
    out: []Event,
) Err!usize {
    return drainSubscriptionWait(peer_key, id_str, out, 0);
}

/// Find a live, client-addressable subscription. Caller holds the lock.
fn findLocked(peer_key: u64, id_str: []const u8) ?*Subscription {
    // F-B2-02 — only the VISIBLE table is searched, and since the split
    // it is the only table a hidden sub is absent from by construction
    // rather than by a flag check that a future edit could drop.
    for (&registry.slots) |*slot| {
        const sub = slot.* orelse continue;
        if (sub.peer_key != peer_key) continue;
        if (!std.mem.eql(u8, sub.id(), id_str)) continue;
        return sub;
    }
    return null;
}

/// Find this peer's hidden legacy subscription. Caller holds the lock.
fn findHiddenLocked(peer_key: u64) ?*Subscription {
    for (&registry.hidden_slots) |*slot| {
        const sub = slot.* orelse continue;
        if (sub.peer_key == peer_key) return sub;
    }
    return null;
}

/// Drain up to `out.len` events, blocking up to `wait_ms` for the first
/// one (M-B B2 long-poll). `wait_ms == 0` is the pre-B2 behaviour exactly:
/// take whatever is queued and answer, possibly with nothing.
///
/// Returns when ANY of the three things a caller waits for happens:
/// events arrive, the subscription dies (`Err.NotFound`), or the budget
/// lapses (0 events — an honest empty answer, not an error).
///
/// ## The missed-wakeup proof (the classic; the court demands it)
///
/// The race is: the ring is empty, we commit to waiting, an event lands
/// in the window between that decision and the actual sleep, and its wake
/// is therefore delivered to nobody — we sleep out the full budget on a
/// ring that already holds the event.
///
/// It cannot happen here, and the reason is structural rather than a
/// matter of ordering luck. Two facts:
///
///   1. **We sample the wake token `seq` under `registry.lock`, in the
///      SAME critical section as the ring check** — see the `while` loop
///      below: `drainRingLocked` and `sampleWakeTokenLocked` sit between
///      one lock/unlock pair with nothing between them that can block.
///      F-B2-01 made this structural rather than a matter of reading
///      carefully: the sample is the CALLER's statement and asserts the
///      lock is held, so hoisting it below the unlock trips a Debug
///      assert on the first long poll rather than losing a wakeup once in
///      a thousand (the court measured the stress pin catching that race
///      0/10 at natural timing).
///   2. **`pushFanout` pushes into the ring and bumps `wake_seq` inside
///      ONE critical section on that same lock** (`bumpWakeLocked` is
///      called before its `defer unlock` runs).
///
/// Therefore any given fan-out's critical section is ordered against ours
/// one way or the other — there is no overlap:
///
///   * It ran BEFORE ours → its event is already in the ring when we pop
///     → `n > 0` and we never wait at all.
///   * It runs AFTER ours → `wake_seq` no longer equals the `seq` we
///     sampled → `FUTEX_WAIT` fails the kernel's compare and returns
///     EAGAIN *without ever blocking* → we loop and see the event.
///
/// There is no third interleaving. The kernel-side compare-and-block is
/// what a condvar's "atomically release the mutex and enqueue" buys, and
/// it is why the lock may safely be dropped between the sample and the
/// wait: the value we sampled is the wait's own precondition, checked
/// atomically by the kernel at the moment of sleeping.
///
/// The re-lookup at the top of every lap is the other half of the
/// discipline: the lock IS released while we sleep, so a concurrent
/// `reapPeer` may have freed the subscription. We never hold a
/// `*Subscription` across a wait.
pub fn drainSubscriptionWait(
    peer_key: u64,
    id_str: []const u8,
    out: []Event,
    wait_ms: u64,
) Err!usize {
    const deadline_ms = kabi.nowMs() +| wait_ms;
    lockRegistry();
    defer unlockRegistry();
    while (true) {
        const sub = findLocked(peer_key, id_str) orelse return Err.NotFound;
        const n = drainRingLocked(sub, out);
        if (n > 0 or wait_ms == 0) return n;
        // Sampled HERE, under the same lock as the ring check above.
        const seq = sampleWakeTokenLocked();
        if (!waitLocked(seq, deadline_ms)) return 0; // lapsed — honest []
    }
}

/// M-B B2 — serve a LEGACY (no `subscription_id`) `drain_events`.
///
/// Before B2 this pulled straight from whichever backends intersected
/// `scope`, which meant two concurrent operators RACED for the same
/// netlink messages: whoever drained first consumed them and the other
/// saw nothing. Now the pump is the only backend drainer and every
/// legacy caller gets its own hidden per-connection subscription, lazily
/// opened on first drain. The observable contract is unchanged — "the
/// events since your last drain" — but it is now per-client true instead
/// of first-come-first-served, and overflow is the ring's existing
/// drop-oldest + synthetic-gap discipline instead of silent loss.
///
/// ## One scope per connection (F-B2-05, court)
///
/// The hidden subscription's scope is fixed by the FIRST legacy drain on
/// the connection. A later drain asking for a DIFFERENT scope is refused
/// with `BadRequest`, and that refusal is the honest answer rather than a
/// limitation.
///
/// The alternative shipped briefly and was wrong: the ring was drained,
/// then the popped events were filtered against the newly-asked scope and
/// the non-matching ones DISCARDED. Popping is destructive, so those
/// events were gone — not deferred, not re-queued, gone — while the
/// response said `ok`. That is a silent loss, which D-022 forbids
/// outright, and it was dressed up as "a narrowing re-scope is honest."
/// A caller cannot narrow a subscription it never widened.
///
/// Refusing costs nothing real: subscriptions have always been
/// per-connection, the SDK's `monitor_fd` pump and every
/// `drain_events(scope=…)` consumer use one scope for a connection's
/// lifetime, and a client that genuinely wants a different scope has two
/// correct moves — a new connection, or `subscribe()` with the scope it
/// wants. What it may no longer do is ask the broker to throw events away
/// on its behalf and call the result a drain.
pub fn drainLegacyScope(
    peer_key: u64,
    scope: []const u8,
    out: []Event,
    wait_ms: u64,
) Err!usize {
    const deadline_ms = kabi.nowMs() +| wait_ms;
    lockRegistry();
    defer unlockRegistry();
    while (true) {
        const sub = if (findHiddenLocked(peer_key)) |existing| blk: {
            // F-B2-05 — refuse rather than silently destroy. Checked on
            // EVERY lap, not just the first: the lock is dropped while we
            // wait, but the hidden sub's scope is immutable for its
            // lifetime, so this can only ever fail on lap one.
            if (!std.mem.eql(u8, existing.scope(), scope)) return Err.BadRequest;
            break :blk existing;
        } else try openHiddenLocked(peer_key, scope);

        const n = drainRingLocked(sub, out);
        if (n > 0) return n;
        if (wait_ms == 0) return 0;
        const seq = sampleWakeTokenLocked();
        if (!waitLocked(seq, deadline_ms)) return 0;
    }
}

/// Pop as much as fits. Caller holds the lock.
fn drainRingLocked(sub: *Subscription, out: []Event) usize {
    var n: usize = 0;
    while (n < out.len) {
        const ev = sub.queue.pop() orelse break;
        out[n] = ev;
        n += 1;
    }
    return n;
}

/// Sleep until the wake token leaves `seq`, or the budget lapses. Caller
/// holds `registry.lock` on entry AND on return; the lock is dropped only
/// for the duration of the sleep.
///
/// F-B2-01 — `seq` is a PARAMETER, sampled by the caller via
/// `sampleWakeTokenLocked()` inside the same critical section as its ring
/// check. It is not sampled here, and that is the whole point: as a local
/// it was an invariant of this function's body, invisible from the call
/// site and enforced by nothing. As a parameter it is the caller's
/// obligation, asserted at the sample point, and a hoist out from under
/// the lock trips a Debug assert on the first long poll instead of
/// surfacing as a lost wakeup once in a thousand.
fn waitLocked(seq: u32, deadline_ms: u64) bool {
    assertLockHeld();
    const now = kabi.nowMs();
    if (now >= deadline_ms) return false;
    const remaining_ms = deadline_ms - now;

    unlockRegistry();
    defer lockRegistry();
    const ts = std.os.linux.timespec{
        .sec = @intCast(remaining_ms / std.time.ms_per_s),
        .nsec = @intCast((remaining_ms % std.time.ms_per_s) * std.time.ns_per_ms),
    };
    // Return value deliberately ignored: EAGAIN (the token already moved),
    // ETIMEDOUT (budget spent), EINTR (a signal) and success are all the
    // same instruction to the caller — re-check the ring under the lock
    // and decide there. Nothing may assume WHY we woke.
    _ = std.os.linux.futex_4arg(
        &wake_seq.raw,
        .{ .cmd = .WAIT, .private = true },
        seq,
        &ts,
    );
    return true;
}

/// How many CLIENT-VISIBLE subscriptions are registered right now — the
/// ones a client created with `subscribe` and can name, drain and
/// unsubscribe.
///
/// F-B2-03 (court) — this is what `state_snapshot.active_subscriptions`
/// reports, and it deliberately EXCLUDES the hidden legacy-drain
/// subscriptions. The court proved the alternative was a lie an operator
/// would act on: before the split, a single `drain_events` — a read, from
/// a client that had subscribed to nothing — moved `active_subscriptions`
/// from 0 to 1, and `mgf-hrb doctor` duly reported a subscription nobody
/// had made. A broker-internal bookkeeping row is not a subscription; the
/// number an operator reads must count the things they can point at.
///
/// Also the test helper for "is the registry clean", which is why the
/// hidden count has its own accessor rather than being folded in here.
pub fn count() usize {
    lockRegistry();
    defer unlockRegistry();
    var n: usize = 0;
    for (registry.slots) |slot| if (slot != null) {
        n += 1;
    };
    return n;
}

/// How many hidden legacy-drain subscriptions exist — one per connection
/// that has issued a scope-mode `drain_events`. Test-visible so the split
/// accounting can be pinned from both sides; deliberately NOT part of any
/// operator-facing number (see `count`).
pub fn hiddenCount() usize {
    lockRegistry();
    defer unlockRegistry();
    var n: usize = 0;
    for (registry.hidden_slots) |slot| if (slot != null) {
        n += 1;
    };
    return n;
}
