//! format_test — unit tests for the wire formatters (`format.zig`),
//! extracted from server.zig in 1A-ii. Before the decomposition these
//! were exercised ONLY indirectly via broker-spawning Python contract
//! tests; now `format.zig` is a library module, the formatters are pure
//! `fn(facade, …, out) []const u8`, and they're directly unit-testable
//! against a FakeBackend-backed facade (WS-B, the #1 coverage gap closure).

const std = @import("std");
const testing = std.testing;
const model = @import("mgf_hrb_model");
const kabi = @import("mgf_hrb_kabi");
const Core = @import("mgf_hrb_core").Core;
const facademod = @import("mgf_hrb_facade");
const FakeBackend = @import("mgf_hrb_fake_backend").FakeBackend;
const format = @import("mgf_hrb_format");

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

// ---------------------------------------------------------------------------
// errToWireCode — the WIRE-CODE CONTRACT. These strings ARE the protocol the
// SDK's `from_wire` matches on; a rename here is a wire break. The switch in
// `errToWireCode` is exhaustive over `Err` (compile-fails on a new variant),
// so this pins the string each variant maps to.
// ---------------------------------------------------------------------------

test "errToWireCode maps each Err variant to its stable wire code" {
    try testing.expectEqualStrings("NotFound", format.errToWireCode(Err.NotFound));
    try testing.expectEqualStrings("NotPermitted", format.errToWireCode(Err.NotPermitted));
    try testing.expectEqualStrings("Conflict", format.errToWireCode(Err.Conflict));
    try testing.expectEqualStrings("Unsupported", format.errToWireCode(Err.Unsupported));
    try testing.expectEqualStrings("HardwareFault", format.errToWireCode(Err.HardwareFault));
    try testing.expectEqualStrings("InvalidToken", format.errToWireCode(Err.InvalidToken));
    try testing.expectEqualStrings("BadRequest", format.errToWireCode(Err.BadRequest));
    try testing.expectEqualStrings("Timeout", format.errToWireCode(Err.Timeout));
    try testing.expectEqualStrings("Overloaded", format.errToWireCode(Err.Overloaded));
    try testing.expectEqualStrings("Revoked", format.errToWireCode(Err.Revoked));
    try testing.expectEqualStrings("Unavailable", format.errToWireCode(Err.Unavailable));
}

test "fmtErr emits the typed error envelope" {
    var out: [128]u8 = undefined;
    try testing.expectEqualStrings(
        "{\"ok\":false,\"err\":\"NotFound\"}\n",
        format.fmtErr(&out, "NotFound"),
    );
}

// ---------------------------------------------------------------------------
// Facade-backed serialisers. A 2-worker FakeBackend constellation (mirrors
// facade_test.zig's helper) lets us assert the enumeration formatters produce
// the right wire shape — empty, populated, and the honest `truncated` flag.
// ---------------------------------------------------------------------------

const Fix = struct {
    pci_fb: FakeBackend,
    usb_fb: FakeBackend,
    pci_core: Core,
    usb_core: Core,
    workers: [2]facademod.Worker,
    facade: facademod.Facade,

    fn deinit(self: *Fix) void {
        self.pci_core.deinit();
        self.usb_core.deinit();
        self.pci_fb.deinit();
        self.usb_fb.deinit();
    }
};

fn build(c: *Fix, alloc: std.mem.Allocator) !void {
    c.pci_fb = FakeBackend.init(alloc);
    c.usb_fb = FakeBackend.init(alloc);
    try c.pci_fb.add("hw:/pci/0000:01:00.0/fn0", .claimable, .pcie_function);
    try c.usb_fb.add("hw:/usb/bus1/dev4/if0", .claimable, .usb_interface);
    c.pci_core = Core.init(alloc, c.pci_fb.backend());
    c.usb_core = Core.init(alloc, c.usb_fb.backend());
    c.workers = .{
        .{ .scope_prefix = "hw:/pci/", .core = &c.pci_core },
        .{ .scope_prefix = "hw:/usb/", .core = &c.usb_core },
    };
    c.facade = facademod.Facade.init(&c.workers);
}

test "fmtList: a scope with no matches → empty resources, truncated:false" {
    var c: Fix = undefined;
    try build(&c, testing.allocator);
    defer c.deinit();
    var out: [4096]u8 = undefined;
    const r = format.fmtList(&c.facade, "hw:/nope/**", 0, &out);
    try testing.expect(std.mem.startsWith(u8, r, "{\"ok\":true,\"resources\":["));
    try testing.expect(std.mem.indexOf(u8, r, "\"resources\":[]") != null);
    try testing.expect(std.mem.indexOf(u8, r, "\"truncated\":false") != null);
}

test "fmtList: a populated scope lists the seeded URN" {
    var c: Fix = undefined;
    try build(&c, testing.allocator);
    defer c.deinit();
    var out: [4096]u8 = undefined;
    const r = format.fmtList(&c.facade, "hw:/pci/**", 0, &out);
    try testing.expect(std.mem.indexOf(u8, r, "hw:/pci/0000:01:00.0/fn0") != null);
    // The USB URN is out of this scope — must NOT appear.
    try testing.expect(std.mem.indexOf(u8, r, "hw:/usb/") == null);
    try testing.expect(std.mem.indexOf(u8, r, "\"truncated\":false") != null);
}

test "fmtListLeases: no live leases → empty leases, truncated:false" {
    var c: Fix = undefined;
    try build(&c, testing.allocator);
    defer c.deinit();
    var out: [4096]u8 = undefined;
    const r = format.fmtListLeases(&c.facade, "hw:/**", 0, &out);
    try testing.expect(std.mem.startsWith(u8, r, "{\"ok\":true,\"leases\":["));
    try testing.expect(std.mem.indexOf(u8, r, "\"leases\":[]") != null);
    try testing.expect(std.mem.indexOf(u8, r, "\"truncated\":false") != null);
}

test "fmtListLeases: a held lease is serialised with its fields" {
    var c: Fix = undefined;
    try build(&c, testing.allocator);
    defer c.deinit();
    const urn = Urn.from("hw:/pci/0000:01:00.0/fn0");
    _ = try c.pci_core.claim(&urn, .exclusive, 42, kabi.Deadline.none);
    var out: [4096]u8 = undefined;
    const r = format.fmtListLeases(&c.facade, "hw:/**", 0, &out);
    try testing.expect(std.mem.indexOf(u8, r, "hw:/pci/0000:01:00.0/fn0") != null);
    try testing.expect(std.mem.indexOf(u8, r, "\"holder\":42") != null);
    try testing.expect(std.mem.indexOf(u8, r, "\"mode\":\"exclusive\"") != null);
}

// ---------------------------------------------------------------------------
// M-B B1 (designer fold, 2026-08-13) — the lease engine's events reach the
// WIRE with their real names.
//
// Before the fold, `ownership_changed` and `faulted` had no arm in
// EventJsonWriter and fell into a catch-all that rendered
// `{"kind":"other"}`. Once core.zig started emitting them that catch-all
// became a lie by omission: the event is nameable, and a consumer told
// "other" cannot act on it. These pins assert the exact bytes, end to end —
// a real facade-routed mutation, through the Core's sink, through
// `subscription.pushFanout`, into the subscriber's ring, out of
// `fmtDrainEvents`.
// ---------------------------------------------------------------------------

const sub = @import("mgf_hrb_subscription");
const request = @import("mgf_hrb_request");

fn drainReq(sub_id: []const u8) request.DrainEventsReq {
    var r = request.DrainEventsReq{ .scope = Urn.from("hw:/**") };
    @memcpy(r.sub_id_buf[0..sub_id.len], sub_id);
    r.sub_id_len = @intCast(sub_id.len);
    return r;
}

fn subscribeAll(peer: u64, id: []const u8, scope: []const u8) !void {
    const arena = std.heap.ArenaAllocator.init(testing.allocator);
    _ = try sub.register(peer, id, scope, .{ .matches_all = {} }, arena);
}

test "B1 wire: a claim renders as a NAMED ownership_changed, never {kind:other}" {
    sub.init(testing.allocator);
    defer sub.deinit();
    var c: Fix = undefined;
    try build(&c, testing.allocator);
    defer c.deinit();
    try subscribeAll(1, "s1", "hw:/pci/**");

    const urn = Urn.from("hw:/pci/0000:01:00.0/fn0");
    const lease = try c.facade.claim(0, &urn, .exclusive, 77, kabi.Deadline.none);

    var out: [4096]u8 = undefined;
    const r0 = drainReq("s1");
    const claimed = format.fmtDrainEvents(&c.facade, 1, &r0, &out);
    try testing.expect(std.mem.indexOf(u8, claimed, "\"kind\":\"other\"") == null);
    try testing.expect(std.mem.indexOf(
        u8,
        claimed,
        "{\"kind\":\"ownership_changed\",\"urn\":\"hw:/pci/0000:01:00.0/fn0\",\"old\":null,\"new\":77}",
    ) != null);

    // ...and the release renders the mirror image: a holder, then nobody.
    try c.facade.release(&urn, lease, 77, kabi.Deadline.none);
    var out2: [4096]u8 = undefined;
    const r1 = drainReq("s1");
    const released = format.fmtDrainEvents(&c.facade, 1, &r1, &out2);
    try testing.expect(std.mem.indexOf(u8, released, "\"kind\":\"other\"") == null);
    try testing.expect(std.mem.indexOf(
        u8,
        released,
        "{\"kind\":\"ownership_changed\",\"urn\":\"hw:/pci/0000:01:00.0/fn0\",\"old\":77,\"new\":null}",
    ) != null);
}

test "B1 wire: a quarantine renders as a NAMED faulted carrying its reason code" {
    sub.init(testing.allocator);
    defer sub.deinit();
    var c: Fix = undefined;
    try build(&c, testing.allocator);
    defer c.deinit();
    try subscribeAll(1, "s1", "hw:/pci/**");

    // Quarantine via the acquire-timeout trigger (Part 2.7.1 #1).
    const urn = Urn.from("hw:/pci/0000:01:00.0/fn0");
    c.pci_fb.slow_acquire_ms = 5000;
    try testing.expectError(
        Err.Timeout,
        c.facade.claim(0, &urn, .exclusive, 1, kabi.Deadline.fromNow(10)),
    );

    var out: [4096]u8 = undefined;
    const r0 = drainReq("s1");
    const drained = format.fmtDrainEvents(&c.facade, 1, &r0, &out);
    try testing.expect(std.mem.indexOf(u8, drained, "\"kind\":\"other\"") == null);
    // The failed grant announces nothing; the quarantine it committed does.
    try testing.expect(std.mem.indexOf(u8, drained, "\"kind\":\"ownership_changed\"") == null);
    var expect_buf: [256]u8 = undefined;
    const expected = try std.fmt.bufPrint(
        &expect_buf,
        "{{\"kind\":\"faulted\",\"urn\":\"hw:/pci/0000:01:00.0/fn0\",\"code\":{d}}}",
        .{@intFromEnum(model.QuarantineReason.op_timeout)},
    );
    try testing.expect(std.mem.indexOf(u8, drained, expected) != null);
}

// ---------------------------------------------------------------------------
// M-B B2 — the flagship: TWO OPERATORS, ONE EVENT, BOTH TOLD.
//
// Before B2 the netlink socket was drained by whichever client's
// `drain_events` reached it first. `fmtDrainEvents` legacy mode teed the
// backend's stream straight into ONE caller's response, emptying it. Two
// operators watching the same bus therefore raced, and the loser silently
// saw nothing — the "two things agree with each other" class the estate
// keeps refuting, in the one place where disagreement is invisible.
//
// The fixture below gives a FakeBackend a real `drain_events` slot (the
// stock one is null) by copying its vtable and overriding that entry — so
// these pins exercise the exact code path a USB netlink source takes,
// with no hardware.
//
// RED ON PRE-B2 CODE. Recorded in the B2 report: on 25f8f07 the
// two-client pin fails with client A receiving the event and client B
// receiving `[]`, and the single-drainer pin fails with the backend
// drained twice — once per client.
// ---------------------------------------------------------------------------

const kabi_b2 = @import("mgf_hrb_kabi");

/// The backend's own event queue — the netlink socket's stand-in. It is
/// module-level because a vtable entry is a plain function pointer with no
/// closure; the FakeBackend `ctx` it is installed against carries no room
/// for it.
var stub_queue: [8]model.Event = undefined;
var stub_queue_n: usize = 0;
/// How many times a backend drain has been executed. The single-drainer
/// property is exactly "clients never move this number".
var stub_drain_calls: usize = 0;
var stub_vtable: kabi_b2.KernelBackend.VTable = undefined;

fn stubDrainEvents(
    ctx: *anyopaque,
    scope: []const u8,
    sink: *const kabi_b2.EventSink,
    deadline: kabi_b2.Deadline,
) Err!void {
    _ = ctx;
    _ = scope;
    _ = deadline;
    stub_drain_calls += 1;
    // The stealing shape, stated plainly: a drain CONSUMES the queue.
    for (stub_queue[0..stub_queue_n]) |ev| sink.push(sink.ctx, ev);
    stub_queue_n = 0;
}

fn seedBackendEvent(urn: []const u8) void {
    stub_queue[stub_queue_n] = .{ .added = .{ .urn = Urn.from(urn) } };
    stub_queue_n += 1;
}

/// What `event_pump.run` does, inlined: the broker — and only the broker —
/// drains the backend and hands each event to the fan-out.
fn pumpOnce(be: kabi.KernelBackend) void {
    const Push = struct {
        fn push(_: *anyopaque, ev: model.Event) void {
            sub.pushFanout(ev, .{ .ctx = null, .get = null });
        }
    };
    var ctx: u8 = 0;
    const sink = kabi.EventSink{ .ctx = &ctx, .push = Push.push };
    be.drainEvents("hw:/**", &sink, kabi.Deadline.none) catch {};
}

const EventFix = struct {
    fb: FakeBackend,
    core: Core,
    workers: [1]facademod.Worker,
    facade: facademod.Facade,
    backend: kabi.KernelBackend,

    fn init(self: *EventFix, alloc: std.mem.Allocator) !void {
        stub_queue_n = 0;
        stub_drain_calls = 0;
        self.fb = FakeBackend.init(alloc);
        try self.fb.add("hw:/usb/bus1/dev4/if0", .claimable, .usb_interface);
        // Copy the stock vtable through the public `vtable` field and give
        // it the event slot the fake deliberately lacks.
        stub_vtable = self.fb.backend().vtable.*;
        stub_vtable.drain_events = stubDrainEvents;
        self.backend = .{ .ctx = self.fb.backend().ctx, .vtable = &stub_vtable };
        self.core = Core.init(alloc, self.backend);
        self.workers = .{.{ .scope_prefix = "hw:/usb/", .core = &self.core }};
        self.facade = facademod.Facade.init(&self.workers);
    }

    fn deinit(self: *EventFix) void {
        self.core.deinit();
        self.fb.deinit();
    }
};

fn legacyDrainReq(scope: []const u8) request.DrainEventsReq {
    return .{ .scope = Urn.from(scope) };
}

test "B2 FLAGSHIP: two operators, one event — BOTH are told (the stealing class dies)" {
    sub.init(testing.allocator);
    defer sub.deinit();
    var c: EventFix = undefined;
    try c.init(testing.allocator);
    defer c.deinit();

    // Two connections, both in LEGACY scope mode with overlapping scopes —
    // the exact shape `monitor_fd` and `drain_events(scope=…)` produce, and
    // the shape that used to race. The first drain is what auto-registers
    // each connection's hidden subscription.
    const peer_a: u64 = 11;
    const peer_b: u64 = 22;
    var out: [4096]u8 = undefined;
    const r_a = legacyDrainReq("hw:/usb/**");
    const r_b = legacyDrainReq("hw:/**");
    _ = format.fmtDrainEvents(&c.facade, peer_a, &r_a, &out);
    _ = format.fmtDrainEvents(&c.facade, peer_b, &r_b, &out);

    // ONE event arrives at the backend.
    seedBackendEvent("hw:/usb/bus1/dev4/if0");

    const expected_ev = "{\"kind\":\"added\",\"urn\":\"hw:/usb/bus1/dev4/if0\"}";
    // A drains first. THIS is where A used to rob B: the legacy tee pulled
    // the backend's queue straight into A's response and emptied it. Now
    // A's drain cannot reach a backend at all, so there is nothing for A
    // to take and nothing for B to lose.
    var out_theft: [4096]u8 = undefined;
    const theft = format.fmtDrainEvents(&c.facade, peer_a, &r_a, &out_theft);
    try testing.expect(std.mem.indexOf(u8, theft, expected_ev) == null);
    try testing.expectEqual(@as(usize, 1), stub_queue_n); // still there, unstolen

    // The broker drains it — once, for everyone.
    pumpOnce(c.backend);

    var out_a: [4096]u8 = undefined;
    var out_b: [4096]u8 = undefined;
    const got_a = format.fmtDrainEvents(&c.facade, peer_a, &r_a, &out_a);
    const got_b = format.fmtDrainEvents(&c.facade, peer_b, &r_b, &out_b);

    try testing.expect(std.mem.indexOf(u8, got_a, expected_ev) != null);
    // The whole milestone, in one assertion: B was not robbed by A.
    try testing.expect(std.mem.indexOf(u8, got_b, expected_ev) != null);
}

test "B2: no client drain reaches a backend — the pump is the only drainer" {
    sub.init(testing.allocator);
    defer sub.deinit();
    var c: EventFix = undefined;
    try c.init(testing.allocator);
    defer c.deinit();

    // A legacy drain, a subscriber-mode drain, and a second legacy drain
    // from another connection: every shape a client can ask for.
    try subscribeAll(7, "s1", "hw:/**");
    var out: [4096]u8 = undefined;
    const legacy = legacyDrainReq("hw:/**");
    const subscriber = drainReq("s1");
    _ = format.fmtDrainEvents(&c.facade, 7, &legacy, &out);
    _ = format.fmtDrainEvents(&c.facade, 7, &subscriber, &out);
    _ = format.fmtDrainEvents(&c.facade, 8, &legacy, &out);

    try testing.expectEqual(@as(usize, 0), stub_drain_calls);
}

test "B2: the hidden legacy subscription does not spend the peer's subscribe budget" {
    sub.init(testing.allocator);
    defer sub.deinit();
    var c: EventFix = undefined;
    try c.init(testing.allocator);
    defer c.deinit();

    const peer: u64 = 99;
    var out: [4096]u8 = undefined;
    const legacy = legacyDrainReq("hw:/**");
    _ = format.fmtDrainEvents(&c.facade, peer, &legacy, &out); // opens the hidden sub
    // All MAX_SUBSCRIPTIONS_PER_PEER explicit slots must still be available:
    // doing the one thing a pre-B2 client did cannot cost it a subscription.
    var i: usize = 0;
    while (i < sub.MAX_SUBSCRIPTIONS_PER_PEER) : (i += 1) {
        var id_buf: [8]u8 = undefined;
        const id = try std.fmt.bufPrint(&id_buf, "s{d}", .{i});
        const arena = std.heap.ArenaAllocator.init(testing.allocator);
        _ = try sub.register(peer, id, "hw:/**", .{ .matches_all = {} }, arena);
    }
    // ...and the cap still bites at the right number.
    const arena = std.heap.ArenaAllocator.init(testing.allocator);
    try testing.expectError(
        Err.Overloaded,
        sub.register(peer, "one-too-many", "hw:/**", .{ .matches_all = {} }, arena),
    );
}

test "B2: reaping a connection reaps its hidden legacy subscription too" {
    sub.init(testing.allocator);
    defer sub.deinit();
    var c: EventFix = undefined;
    try c.init(testing.allocator);
    defer c.deinit();

    var out: [4096]u8 = undefined;
    const legacy = legacyDrainReq("hw:/**");
    _ = format.fmtDrainEvents(&c.facade, 5, &legacy, &out);
    // F-B2-02/03 — it lives in the HIDDEN table and is invisible to the
    // operator-facing count, but its LIFETIME is unchanged: it dies with
    // the connection like every other subscription.
    try testing.expectEqual(@as(usize, 1), sub.hiddenCount());
    try testing.expectEqual(@as(usize, 0), sub.count());
    sub.reapPeer(5);
    try testing.expectEqual(@as(usize, 0), sub.hiddenCount());
    try testing.expectEqual(@as(usize, 0), sub.count());
}

test "B2: a legacy drain still sees gap events (they carry no URN)" {
    sub.init(testing.allocator);
    defer sub.deinit();
    var c: EventFix = undefined;
    try c.init(testing.allocator);
    defer c.deinit();

    var out: [4096]u8 = undefined;
    const legacy = legacyDrainReq("hw:/usb/**");
    _ = format.fmtDrainEvents(&c.facade, 3, &legacy, &out);

    // A netlink overrun. It has no URN, so a literal `urn_glob(scope)`
    // predicate would evaluate FALSE and drop it — which is why the hidden
    // subscription carries the glob as its SCOPE (where URN-less events are
    // explicitly let through) and leaves the predicate matches-all.
    sub.pushFanout(
        .{ .gap = .{ .source = .netlink_uevent, .since_ms = 7 } },
        .{ .ctx = null, .get = null },
    );
    const got = format.fmtDrainEvents(&c.facade, 3, &legacy, &out);
    try testing.expect(std.mem.indexOf(u8, got, "\"kind\":\"gap\"") != null);
}

test "B1 wire: EVERY model.Event variant is named on the wire — none falls through" {
    // The real pin is in `format.zig`: EventJsonWriter's switch has no
    // `else` arm, so a new `model.Event` variant fails the BUILD until
    // someone decides how it renders. This test guards that intent against
    // a future refactor that reintroduces a catch-all: it enumerates the
    // variant set the wire currently names, so growing the union without
    // touching the writer breaks here too, with a message that says why.
    //
    // If you are here because this failed: a variant was added to
    // mgf-hrb-model's `Event`. Give it an arm in `EventJsonWriter` (and a
    // tag in `subscription.EventKind`), then add its name below. Do NOT
    // add an `else` arm — `{"kind":"other"}` is the exact dishonesty B1
    // removed.
    const named = [_][]const u8{
        "added",
        "removed",
        "state_changed",
        "ownership_changed",
        "faulted",
        "gap",
        "driver_bound",
        "driver_unbound",
    };
    const fields = @typeInfo(model.Event).@"union".fields;
    try testing.expectEqual(named.len, fields.len);
    inline for (fields) |f| {
        var found = false;
        for (named) |n| {
            if (std.mem.eql(u8, n, f.name)) found = true;
        }
        try testing.expect(found);
    }
}

// ---------------------------------------------------------------------------
// B1-F05 (court finding) — the QuarantineReason discriminants ARE the wire.
//
// `faulted.code` carries `@intFromEnum(QuarantineReason)`, so the integer a
// consumer reads is the member's POSITION in the carved model's enum. Every
// other pin for this spells both sides with `@intFromEnum`, which is
// self-referential: reorder two members and the pins agree with the new
// numbering and stay green while every deployed consumer starts reading the
// wrong reason.
//
// This sweep hardcodes the integers, exactly as a consumer must. Its failure
// mode is precisely the one that matters: reordering ANY two members of
// `mgf-hrb-model`'s `QuarantineReason` turns it red. (It cannot be
// demonstrated live from here — the model is a hash-pinned carved dependency
// and this chunk may not edit it — so what is asserted is the CURRENT
// numbering, which is the thing a reorder would break.)
//
// If you are here because this failed: someone reordered or removed a
// QuarantineReason. That is a WIRE BREAK for `faulted.code`, not a
// refactor. Append new reasons at the END; never renumber.
// ---------------------------------------------------------------------------

test "B1-F05 wire contract: every QuarantineReason discriminant, hardcoded" {
    const QR = model.QuarantineReason;
    try testing.expectEqual(@as(u32, 0), @as(u32, @intFromEnum(QR.reset_timeout)));
    try testing.expectEqual(@as(u32, 1), @as(u32, @intFromEnum(QR.reset_unverified)));
    try testing.expectEqual(@as(u32, 2), @as(u32, @intFromEnum(QR.op_timeout)));
    try testing.expectEqual(@as(u32, 3), @as(u32, @intFromEnum(QR.fault_maybe_dirty)));
    try testing.expectEqual(@as(u32, 4), @as(u32, @intFromEnum(QR.unclaimed_after_restart)));
    // A member APPENDED at the end is compatible and only needs a line
    // above; this catches the append so the decision is conscious.
    try testing.expectEqual(@as(usize, 5), @typeInfo(QR).@"enum".fields.len);
}
