//! usb_test — Phase U1 conformance for the USB worker.
//!
//! Pins the USB-specific properties that aren't covered by the generic kabi
//! conformance suite: URN grammar (§2.2), composite-device modelling
//! (§2.4 — interfaces-first, blast-radius-one), identity-class assignment
//! (§2.3), and Snapshot construction from worker-supplied USB fields.
//!
//! The generic seam contract (acquire all-or-nothing, release idempotent,
//! deadlines, atomicity) is exercised by `conformance.zig` — this file does
//! NOT re-test those; it adds the *USB-flavoured* properties on top.

const std = @import("std");
const testing = std.testing;
const model = @import("mgf_hrb_model");
const kabi = @import("mgf_hrb_kabi");
const identity = @import("mgf_hrb_identity");
const FakeBackend = @import("mgf_hrb_fake_backend").FakeBackend;
const usb = @import("mgf_hrb_usb");

const Urn = model.Urn;
const D = kabi.Deadline;

// The canonical fixture: a 3-interface composite (a USB headset's audio +
// audio + HID, the example called out in §2.4). Lives at bus 1, port 2.3.
fn headsetFixture() usb.Composite {
    return .{
        .bus = 1,
        .port_chain = &.{ 2, 3 },
        .vid = 0x046d, // Logitech, per the design's worked example
        .pid = 0x0a44,
        .serial_number = "HS-SN-0001",
        .interfaces = &.{
            .{ .if_num = 0, .class = 0x01 }, // Audio Control
            .{ .if_num = 1, .class = 0x01 }, // Audio Streaming
            .{ .if_num = 3, .class = 0x03 }, // HID (volume keys)
        },
    };
}

// Collecting FactSink so we can inspect what `enumerate` emitted.
const Collector = struct {
    items: std.ArrayListUnmanaged(model.Resource) = .empty,
    alloc: std.mem.Allocator,
    fn push(ctx: *anyopaque, r: model.Resource) void {
        const self: *Collector = @ptrCast(@alignCast(ctx));
        self.items.append(self.alloc, r) catch {};
    }
    fn sink(self: *Collector) kabi.FactSink {
        return .{ .ctx = self, .push = push };
    }
};

// ----- URN grammar (§2.2) ----------------------------------------------------

test "USB URN: grammar matches §2.2 (hw:/usb/bus<N>/port-<chain>/dev/if<K>)" {
    var buf: [usb.MAX_USB_URN_BYTES]u8 = undefined;
    const u = usb.urnForInterface(&buf, 1, &.{ 2, 3 }, 0);
    try testing.expectEqualStrings("hw:/usb/bus1/port-2.3/dev/if0", u.slice());
}

test "USB URN: single-rung port chain (root-hub direct attach)" {
    var buf: [usb.MAX_USB_URN_BYTES]u8 = undefined;
    const u = usb.urnForInterface(&buf, 2, &.{4}, 1);
    try testing.expectEqualStrings("hw:/usb/bus2/port-4/dev/if1", u.slice());
}

test "USB URN: deep port chain (nested hubs)" {
    var buf: [usb.MAX_USB_URN_BYTES]u8 = undefined;
    const u = usb.urnForInterface(&buf, 1, &.{ 2, 3, 1, 4 }, 0);
    try testing.expectEqualStrings("hw:/usb/bus1/port-2.3.1.4/dev/if0", u.slice());
}

test "USB URN: parent device URN lacks the if<K> suffix" {
    var buf: [usb.MAX_USB_URN_BYTES]u8 = undefined;
    const u = usb.urnForDevice(&buf, 1, &.{ 2, 3 });
    try testing.expectEqualStrings("hw:/usb/bus1/port-2.3/dev", u.slice());
}

// ----- Composite modelling (§2.4 — interfaces first) ------------------------

test "composite: enumerate emits ONE Resource per interface, not per device" {
    var fb = FakeBackend.init(testing.allocator);
    defer fb.deinit();
    const n = try usb.seedComposite(&fb, headsetFixture());
    try testing.expectEqual(@as(usize, 3), n);

    var col = Collector{ .alloc = testing.allocator };
    defer col.items.deinit(testing.allocator);
    const s = col.sink();
    const be = fb.backend();
    try be.enumerate("hw:/usb/**", &s, D.none);

    // The composite has 3 interfaces — 3 Resources, no separate "device" row
    // (§2.4 / D-013: the device is a topology parent, not a Resource).
    try testing.expectEqual(@as(usize, 3), col.items.items.len);
    for (col.items.items) |r| {
        try testing.expectEqual(model.Family.claimable, r.family);
        try testing.expectEqual(model.Kind.usb_interface, r.kind);
    }
}

test "composite: each interface gets its own distinct URN" {
    var fb = FakeBackend.init(testing.allocator);
    defer fb.deinit();
    _ = try usb.seedComposite(&fb, headsetFixture());

    var col = Collector{ .alloc = testing.allocator };
    defer col.items.deinit(testing.allocator);
    const s = col.sink();
    const be = fb.backend();
    try be.enumerate("hw:/usb/**", &s, D.none);

    // No duplicates — uniqueness check.
    try testing.expectEqual(@as(usize, 3), col.items.items.len);
    var seen: usize = 0;
    for (col.items.items) |r| {
        if (std.mem.eql(u8, r.urn.slice(), "hw:/usb/bus1/port-2.3/dev/if0")) seen += 1;
        if (std.mem.eql(u8, r.urn.slice(), "hw:/usb/bus1/port-2.3/dev/if1")) seen += 1;
        if (std.mem.eql(u8, r.urn.slice(), "hw:/usb/bus1/port-2.3/dev/if3")) seen += 1;
    }
    try testing.expectEqual(@as(usize, 3), seen);
}

test "composite: claim on if0 does NOT affect siblings (blast-radius-one)" {
    // §2.4: "a claim's blast radius is one interface, not one device." Two
    // distinct kernel drivers can bind to two distinct interfaces of the
    // same composite (Audio on if0/if1, HID on if3) — revoking one cannot
    // disturb the co-existing drivers on its siblings. This is the USB
    // analog of the IOMMU-group story; on USB it's native because the
    // granularity matches reality.
    var fb = FakeBackend.init(testing.allocator);
    defer fb.deinit();
    _ = try usb.seedComposite(&fb, headsetFixture());
    const be = fb.backend();

    const if0 = Urn.from("hw:/usb/bus1/port-2.3/dev/if0");
    const if1 = Urn.from("hw:/usb/bus1/port-2.3/dev/if1");
    const if3 = Urn.from("hw:/usb/bus1/port-2.3/dev/if3");

    // Claim if0 exclusively.
    const tok0 = try be.acquire(&if0, .exclusive, D.none);

    // Siblings remain claimable — exclusive acquire on them must succeed.
    const tok1 = try be.acquire(&if1, .exclusive, D.none);
    const tok3 = try be.acquire(&if3, .exclusive, D.none);

    // Cleanup. Order doesn't matter — release is total + idempotent.
    be.release(tok0, D.none);
    be.release(tok1, D.none);
    be.release(tok3, D.none);
}

// ----- Identity (§2.3, §4.2) -------------------------------------------------

test "Snapshot: identity_class is intrinsic when the device exposes a serial" {
    const composite = headsetFixture(); // serial_number = "HS-SN-0001"
    const snap = usb.snapshotForInterface(composite, 0, 1_000);
    try testing.expectEqual(identity.IdentityClass.intrinsic, snap.identity_class);
    try testing.expect(snap.hasSerial());
    try testing.expectEqualStrings("HS-SN-0001", snap.serial());
}

test "Snapshot: identity_class is topological when no serial is present" {
    var composite = headsetFixture();
    composite.serial_number = null;
    const snap = usb.snapshotForInterface(composite, 0, 1_000);
    try testing.expectEqual(identity.IdentityClass.topological, snap.identity_class);
    try testing.expect(!snap.hasSerial());
}

test "Snapshot: empty-string serial treated like null (G2 over silent truncation)" {
    var composite = headsetFixture();
    composite.serial_number = "";
    const snap = usb.snapshotForInterface(composite, 0, 1_000);
    try testing.expectEqual(identity.IdentityClass.topological, snap.identity_class);
    try testing.expect(!snap.hasSerial());
}

test "Snapshot: URN matches the seeded URN for the same (bus, port_chain, if_num)" {
    const composite = headsetFixture();
    const snap = usb.snapshotForInterface(composite, 3, 1_000);
    try testing.expectEqualStrings(
        "hw:/usb/bus1/port-2.3/dev/if3",
        snap.urn.slice(),
    );
}

test "Snapshot: vid/pid/bus/port_chain copied through" {
    const composite = headsetFixture();
    const snap = usb.snapshotForInterface(composite, 0, 12_345);
    try testing.expectEqual(@as(u16, 0x046d), snap.vid);
    try testing.expectEqual(@as(u16, 0x0a44), snap.pid);
    try testing.expectEqual(@as(u8, 1), snap.bus);
    try testing.expectEqual(@as(u4, 2), snap.port_depth);
    try testing.expectEqual(@as(u8, 2), snap.port_chain[0]);
    try testing.expectEqual(@as(u8, 3), snap.port_chain[1]);
    try testing.expectEqual(@as(u64, 12_345), snap.captured_at_ms);
}

// ----- Cross-property: comparator works on USB-built Snapshots --------------

test "comparator: two interfaces on the same composite are NOT the same physical unit" {
    // The comparator answers "same physical *device*?" — two interfaces on the
    // same composite are at the same (bus, port_chain) but their URNs differ.
    // The Snapshot's URN field is informational (the comparator scores by
    // vid/serial/port_chain/pid, not URN), so two interfaces of the same
    // composite score as SAME — they ARE the same physical unit, just two
    // sockets into it. This pins the comparator's behaviour for the consumer.
    const composite = headsetFixture();
    const a = usb.snapshotForInterface(composite, 0, 1_000);
    const b = usb.snapshotForInterface(composite, 1, 1_000);
    const verdict = identity.sameLikely(&a, &b, .{});
    try testing.expectEqual(identity.Confidence.same, verdict);
}

test "comparator: Recovery → DFU on a USB-built Snapshot matches the iFig score" {
    // The canonical case from MODEL-01 / §4.1, built end-to-end through the
    // USB helpers. Recovery PID 0x1281 → DFU PID 0x1227, same bus/port, serial
    // drops in DFU. Should score LIKELY_SAME per the spec.
    const recovery_composite = usb.Composite{
        .bus = 1,
        .port_chain = &.{ 2, 3 },
        .vid = 0x05AC,
        .pid = 0x1281,
        .serial_number = "F4GS9PWWJCM7",
        .interfaces = &.{.{ .if_num = 0, .class = 0xFF }},
    };
    const dfu_composite = usb.Composite{
        .bus = 1,
        .port_chain = &.{ 2, 3 },
        .vid = 0x05AC,
        .pid = 0x1227,
        .serial_number = null, // DFU drops iSerial
        .interfaces = &.{.{ .if_num = 0, .class = 0xFE }},
    };
    const before = usb.snapshotForInterface(recovery_composite, 0, 1_000);
    const after = usb.snapshotForInterface(dfu_composite, 0, 1_500);
    const verdict = identity.sameLikely(&before, &after, .{});
    try testing.expectEqual(identity.Confidence.likely_same, verdict);
}
