//! mgf-hrb-stub-usb-test — stress-tests against the misbehaving-fake.
//!
//! Item #10 of the USB-worker backlog: a stub-firmware fake exercising the
//! survivability seams without needing a real misbehaving device. The
//! "stub firmware fake" is the existing `FakeBackend` (it already has the
//! knobs — `slow_acquire_ms`, `fail_acquire`, `slow_reset_ms`,
//! `can_reset`); what was missing is the integration-level stress suite
//! that drives Core + Synthesizer through the edge cases those knobs
//! enable. This file is that suite.
//!
//! Each test pins a survivability claim that single-module tests can't
//! reach because the property emerges from the *interaction* between
//! Core (Part 3), the Synthesizer (Part 5.3.4), and the FakeBackend's
//! programmable misbehaviour. If one of these breaks, the test names
//! tell you which guarantee shifted.

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 Core = @import("mgf_hrb_core").Core;
const Synthesizer = @import("mgf_hrb_usb_synth").Synthesizer;
const MAX_PENDING = @import("mgf_hrb_usb_synth").MAX_PENDING;

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

fn seedUsbInterfaces(fb: *FakeBackend, n: usize) !void {
    var buf: [128]u8 = undefined;
    var i: usize = 0;
    while (i < n) : (i += 1) {
        const urn = try std.fmt.bufPrint(&buf, "hw:/usb/bus3/port-{d}/dev/if0", .{i});
        try fb.add(urn, .claimable, .usb_interface);
    }
}

// ===========================================================================
// SCENARIO 1: a flaky USB acquire → Quarantined → operator-recoverable.
// ===========================================================================

test "stub-usb stress: timed-out acquire quarantines URN; clearQuarantine unblocks" {
    var fb = FakeBackend.init(testing.allocator);
    defer fb.deinit();
    try seedUsbInterfaces(&fb, 4);
    var core = Core.init(testing.allocator, fb.backend());
    defer core.deinit();

    const urn = Urn.from("hw:/usb/bus3/port-2/dev/if0");

    // Stub firmware reports a 5 s acquire; caller's budget is 50 ms.
    fb.slow_acquire_ms = 5_000;
    try testing.expectError(Err.Timeout, core.claim(&urn, .exclusive, 1, D.fromNow(50)));

    // Core quarantined the URN with the operational-timeout reason
    // — the next claim with a fresh budget is refused honestly.
    const q = core.quarantineStatus(&urn) orelse return error.UnexpectedNull;
    try testing.expectEqual(model.QuarantineReason.op_timeout, q.reason);

    fb.slow_acquire_ms = 0; // hardware recovered, but Core doesn't trust it
    try testing.expectError(Err.Unavailable, core.claim(&urn, .exclusive, 1, D.fromNow(1000)));

    // Operator performs a verified clean reset (modeled here as
    // `clearQuarantine`; the recovery path is policy choice — the
    // broker just reports state honestly). Claim works again.
    try testing.expect(core.clearQuarantine(&urn));
    const ok = try core.claim(&urn, .exclusive, 1, D.fromNow(1000));
    try core.release(ok, 1, D.none);
}

test "stub-usb stress: every URN that times out is quarantined independently" {
    var fb = FakeBackend.init(testing.allocator);
    defer fb.deinit();
    try seedUsbInterfaces(&fb, 8);
    var core = Core.init(testing.allocator, fb.backend());
    defer core.deinit();

    fb.slow_acquire_ms = 1_000;
    // Time out three different URNs.
    const targets: [3][]const u8 = .{
        "hw:/usb/bus3/port-0/dev/if0",
        "hw:/usb/bus3/port-3/dev/if0",
        "hw:/usb/bus3/port-6/dev/if0",
    };
    for (targets) |t| {
        const u = Urn.from(t);
        _ = core.claim(&u, .exclusive, 1, D.fromNow(10)) catch {};
    }

    for (targets) |t| {
        const u = Urn.from(t);
        try testing.expect(core.quarantineStatus(&u) != null);
    }
    // Untouched URN is still free — quarantine doesn't bleed across.
    const untouched = Urn.from("hw:/usb/bus3/port-4/dev/if0");
    try testing.expect(core.quarantineStatus(&untouched) == null);
}

// ===========================================================================
// SCENARIO 2: synthesizer under flood — collapse rate stays at-spec.
// ===========================================================================

const CountingSink = struct {
    state_changed_count: usize = 0,
    added_count: usize = 0,
    removed_count: usize = 0,
    other_count: usize = 0,

    fn push(ctx: *anyopaque, ev: model.Event) void {
        const self: *CountingSink = @ptrCast(@alignCast(ctx));
        switch (ev) {
            .state_changed => self.state_changed_count += 1,
            .added => self.added_count += 1,
            .removed => self.removed_count += 1,
            else => self.other_count += 1,
        }
    }

    fn sink(self: *CountingSink) kabi.EventSink {
        return .{ .ctx = self, .push = CountingSink.push };
    }
};

fn snapOnPort(port: u8, vid: u16, pid: u16, serial: ?[]const u8, at_ms: u64) identity.Snapshot {
    var urn_buf: [64]u8 = undefined;
    const urn_str = std.fmt.bufPrint(&urn_buf, "hw:/usb/bus3/port-{d}/dev/if0", .{port}) catch unreachable;
    const chain = [_]u8{port};
    return identity.Snapshot.init(.{
        .urn = Urn.from(urn_str),
        .vid = vid,
        .pid = pid,
        .serial_number = serial,
        .bus = 3,
        .port_chain = &chain,
        .captured_at_ms = at_ms,
    });
}

test "stub-usb stress: flood of N add/remove pairs collapses cleanly" {
    var col = CountingSink{};
    const out = col.sink();
    var s = Synthesizer.init(1_000);

    // 10 distinct devices, each unplugs then re-plugs within debounce
    // with the same VID/PID/serial → every pair must collapse to one
    // `state_changed`. NEVER any `added` or `removed` emitted.
    const N: u8 = 10;
    var port: u8 = 0;
    while (port < N) : (port += 1) {
        const snap_before = snapOnPort(port, 0x1234, 0x5678, "SN-001", 1_000_000);
        var urn_buf: [64]u8 = undefined;
        const urn_str = std.fmt.bufPrint(&urn_buf, "hw:/usb/bus3/port-{d}/dev/if0", .{port}) catch unreachable;
        const urn = Urn.from(urn_str);
        s.onRemoved(urn, snap_before, &out, 1_000_000);
    }
    port = 0;
    while (port < N) : (port += 1) {
        const snap_after = snapOnPort(port, 0x1234, 0x5678, "SN-001", 1_000_100);
        var urn_buf: [64]u8 = undefined;
        const urn_str = std.fmt.bufPrint(&urn_buf, "hw:/usb/bus3/port-{d}/dev/if0", .{port}) catch unreachable;
        const urn = Urn.from(urn_str);
        s.onAdded(urn, snap_after, &out);
    }

    try testing.expectEqual(@as(usize, N), col.state_changed_count);
    try testing.expectEqual(@as(usize, 0), col.added_count);
    try testing.expectEqual(@as(usize, 0), col.removed_count);
}

test "stub-usb stress: synth at MAX_PENDING forwards oldest as plain removed" {
    var col = CountingSink{};
    const out = col.sink();
    var s = Synthesizer.init(1_000);

    // Fill the synth to capacity with removes of distinct serials —
    // none match each other, so the cap forces an emission, not a
    // collapse. The first MAX_PENDING removes are silently held; the
    // (MAX_PENDING+1)th forces the oldest to be forwarded.
    var port: u8 = 0;
    while (port < MAX_PENDING) : (port += 1) {
        var serial_buf: [16]u8 = undefined;
        const serial = std.fmt.bufPrint(&serial_buf, "SN-{d:0>4}", .{port}) catch unreachable;
        const snap = snapOnPort(port, 0x1234, 0x5678, serial, 1_000_000);
        var urn_buf: [64]u8 = undefined;
        const urn_str = std.fmt.bufPrint(&urn_buf, "hw:/usb/bus3/port-{d}/dev/if0", .{port}) catch unreachable;
        s.onRemoved(Urn.from(urn_str), snap, &out, 1_000_000);
    }
    try testing.expectEqual(@as(usize, 0), col.removed_count);

    // One more push at capacity — oldest gets forwarded.
    const snap = snapOnPort(99, 0x1234, 0x5678, "SN-9999", 1_000_000);
    s.onRemoved(Urn.from("hw:/usb/bus3/port-99/dev/if0"), snap, &out, 1_000_000);
    try testing.expectEqual(@as(usize, 1), col.removed_count);

    // Drain on the way out — the remaining MAX_PENDING all come through.
    s.drainAll(&out);
    try testing.expectEqual(@as(usize, 1 + MAX_PENDING), col.removed_count);
}

// ===========================================================================
// SCENARIO 3: claim-contention under flood — Conflict is the only sane outcome.
// ===========================================================================

test "stub-usb stress: many concurrent claimants on one URN → one wins, rest Conflict" {
    var fb = FakeBackend.init(testing.allocator);
    defer fb.deinit();
    try seedUsbInterfaces(&fb, 1);
    var core = Core.init(testing.allocator, fb.backend());
    defer core.deinit();

    const urn = Urn.from("hw:/usb/bus3/port-0/dev/if0");

    // First claim wins.
    const winner = try core.claim(&urn, .exclusive, 1, D.none);

    // 32 other clients try; every one must get Conflict, never a
    // partial state, never a panic.
    var holder: model.ClientId = 2;
    while (holder < 34) : (holder += 1) {
        try testing.expectError(Err.Conflict, core.claim(&urn, .exclusive, holder, D.none));
    }

    // Live-lease count is still exactly one — the contention storm
    // left no residue (D-012 atomicity holds N times in a row).
    try testing.expectEqual(@as(usize, 1), core.liveLeaseCount());

    try core.release(winner, 1, D.none);
}

// ===========================================================================
// SCENARIO 4: synth correctly DECLINES to collapse a VID-flip (different vendor).
// ===========================================================================

test "stub-usb stress: a vendor flip on the same port surfaces as add+remove (no collapse)" {
    var col = CountingSink{};
    const out = col.sink();
    var s = Synthesizer.init(1_000);

    const urn = Urn.from("hw:/usb/bus3/port-3/dev/if0");

    s.onRemoved(urn, snapOnPort(3, 0x1234, 0x5678, "OLD-001", 1_000_000), &out, 1_000_000);
    s.onAdded(urn, snapOnPort(3, 0x9999, 0x0001, "NEW-002", 1_000_100), &out);
    s.flushExpired(&out, 1_001_500); // drain the old pending remove

    try testing.expectEqual(@as(usize, 0), col.state_changed_count); // comparator said different
    try testing.expectEqual(@as(usize, 1), col.added_count);
    try testing.expectEqual(@as(usize, 1), col.removed_count);
}
